diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 262a757..f9fbfe1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,3 +5,11 @@ updates: directory: "/" schedule: interval: "monthly" + # Group all github/codeql-action/* sub-paths (init, analyze, ...) into a + # single PR so they are always bumped together. Bumping them independently + # leaves the workflow with mismatched versions across steps, which + # github/codeql-action rejects at runtime. + groups: + codeql-action: + patterns: + - "github/codeql-action/*" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8d8dfb3..c4aa91c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -36,13 +36,13 @@ jobs: build-mode: autobuild steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index d4329ed..6f54df4 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -13,44 +13,62 @@ concurrency: permissions: contents: read +env: + CONFORMANCE_VERSION: "0.2.0-alpha.11" + jobs: server-conformance: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "^1.26" - - name: Start everything-server - run: | - go run ./conformance/everything-server/main.go -http=":3001" & - # Wait for the server to be ready. - timeout 30 bash -c 'until curl -s http://localhost:3001/mcp; do sleep 0.5; done' - - name: "Run conformance tests" - uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15 + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - mode: server - url: http://localhost:3001/mcp - suite: active - expected-failures: ./conformance/baseline.yml node-version: 22 + - name: Build everything-server + run: go build -o everything-server ./conformance/everything-server + - name: Run conformance tests (2025-11-25, stateful) + run: | + ./everything-server -http=":3001" -stateless=false & + SERVER_PID=$! + trap "kill -9 $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true" EXIT + timeout 30 bash -c 'until curl -s http://localhost:3001/mcp > /dev/null; do sleep 0.5; done' + npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ + --url http://localhost:3001/mcp \ + --requirements 2025-11-25 \ + --expected-failures ./conformance/baseline.yml + - name: Run conformance tests (2026-07-28, stateless) + run: | + ./everything-server -http=":3001" -stateless & + SERVER_PID=$! + trap "kill -9 $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true" EXIT + timeout 30 bash -c 'until curl -s http://localhost:3001/mcp > /dev/null; do sleep 0.5; done' + npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ + --url http://localhost:3001/mcp \ + --requirements 2026-07-28 \ + --expected-failures ./conformance/baseline.yml client-conformance: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "^1.26" - - name: "Run conformance tests" - uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15 + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - mode: client - command: go run ./conformance/everything-client - suite: core - expected-failures: ./conformance/baseline.yml node-version: 22 + - name: Run conformance tests (full suite) + run: | + npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \ + --command "go run ./conformance/everything-client" \ + --suite all \ + --expected-failures ./conformance/baseline.yml diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index ee6f596..d255e63 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -16,11 +16,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "^1.26" - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check docs are up-to-date run: | go generate ./... diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 098a9a9..2daa939 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -11,25 +11,29 @@ permissions: jobs: vulncheck: + # Don't run scheduled builds on forks. + if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk' runs-on: ubuntu-latest steps: - name: Run govulncheck - uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 + uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1.1.0 with: go-version-file: go.mod go-package: ./... conformance: + # Don't run scheduled builds on forks. + if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk' runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "^1.26" - name: Set up Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" - name: Run server conformance tests diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index c45111f..4d6d31e 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -1,4 +1,4 @@ -name: Publish Docs to GitHub Pages +name: Publish Docs on: release: @@ -13,12 +13,20 @@ jobs: contents: write runs-on: ubuntu-latest steps: - - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Configure Git Credentials + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./docs - publish_branch: gh-pages - enable_jekyll: true + python-version: 3.x + - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: mkdocs-material-${{ env.cache_id }} + path: .cache + restore-keys: | + mkdocs-material- + - run: pip install mkdocs-material + - run: mkdocs gh-deploy --force diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 67694c9..98db4c4 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -34,12 +34,12 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 with: results_file: results.sarif results_format: sarif @@ -64,7 +64,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: SARIF file path: results.sarif @@ -73,6 +73,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: results.sarif diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4c07f0f..5c351b3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,9 +14,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "^1.26" - name: Check formatting @@ -31,7 +31,7 @@ jobs: - name: Run Go vet run: go vet ./... - name: Run staticcheck - uses: dominikh/staticcheck-action@024238d2898c874f26d723e7d0ff4308c35589a2 # v1 + uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # v1.4.1 with: install-go: false version: "v0.6.1" @@ -43,9 +43,9 @@ jobs: go: ["1.25", "1.26"] steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ matrix.go }} - name: Test @@ -55,9 +55,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "1.26" - name: Test with -race diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 303f6c2..3d8b090 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,4 @@ + # Contributing to the Go MCP SDK Thank you for your interest in contributing! The Go SDK needs active @@ -6,11 +7,31 @@ new and emerging use-cases. We welcome all forms of contribution, from filing and reviewing issues, to contributing fixes, to proposing and implementing new features. -As described in the [design document](./design/design.md), it is important for +As described in the [design document](https://github.com/modelcontextprotocol/go-sdk/blob/main/design/design.md), it is important for the MCP SDK to remain idiomatic, future-proof, and extensible. The process described here is intended to ensure that the SDK evolves safely and transparently, while adhering to these goals. +1. [Development setup](#development-setup) + 1. [Conformance tests](#conformance-tests) +1. [Filing issues](#filing-issues) + 1. [Bugs](#bugs) + 1. [Proposals](#proposals) + 1. [Design discussion](#design-discussion) +1. [Contributing code](#contributing-code) + 1. [Adding and updating dependencies](#adding-and-updating-dependencies) + 1. [Updating the README](#updating-the-readme) +1. [Versioning](#versioning) + 1. [What counts as a breaking change](#what-counts-as-a-breaking-change) + 1. [Deprecation](#deprecation) +1. [Timeouts](#timeouts) +1. [Code of conduct](#code-of-conduct) +1. [Governance](#governance) + 1. [Working Group meetings](#working-group-meetings) + 1. [Discord](#discord) + 1. [Antitrust considerations](#antitrust-considerations) + + ## Development setup This module can be built and tested using the standard Go toolchain. Run `go @@ -170,6 +191,57 @@ The CI system will automatically check that the README is up-to-date by running `go generate ./internal/readme` and verifying no changes result. If you see a CI failure about the README being out of sync, follow the steps above to regenerate it. +## Versioning + +The SDK follows [semantic versioning](https://semver.org), as the Go module +system requires: the major version is part of the module path, so a breaking +release would import as `github.com/modelcontextprotocol/go-sdk/v2`. A breaking +change therefore cannot reach existing users by accident; they have to change +their import path to receive one. + +This policy covers the exported API of the SDK's importable packages — `mcp`, +`jsonrpc`, `auth`, `auth/extauth` and `oauthex`. Everything under `internal/` +is not importable outside the module and may change in any release. + +Which MCP spec revisions each SDK version speaks is documented in the +[README](README.md#version-compatibility). Support for a new revision arrives +in a minor release; dropping a revision the SDK previously negotiated is a +breaking change. + +### What counts as a breaking change + +- Removing or renaming an exported identifier. +- Changing the signature of an exported function or method. +- Removing or renaming a field of an exported struct, or changing its type. +- Adding a method to an exported interface that users are meant to implement. +- Changing documented behavior that callers could reasonably rely on. +- Dropping support for an MCP spec revision. + +The following are not breaking, and may land in a minor or patch release: + +- Adding an exported identifier, or a field to an exported struct. +- Fixing behavior that contradicted its documentation. +- Refactoring that leaves the exported API unchanged. +- Adding support for a new MCP spec revision. +- Raising the minimum Go version to one still supported upstream, per the + [README](README.md#version-compatibility). + +### Deprecation + +An API on its way out is marked with a `// Deprecated:` comment naming its +replacement, which `gopls` and `staticcheck` surface at call sites. It keeps +working until the next major version, since removing it would itself be a +breaking change. + +Protocol features that the *specification* deprecates are a separate matter and +follow the spec's timeline rather than this policy. The features deprecated by +[SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging) +— roots, sampling and logging — remain supported for at least twelve months, as +noted in the [README](README.md#version-compatibility). + +Any change to the exported API, breaking or not, goes through the +[proposal process](#proposals). + ## Timeouts If a contributor hasn't responded to issue questions or PR comments in two weeks, diff --git a/README.md b/README.md index ffaf33e..baeb3e7 100644 --- a/README.md +++ b/README.md @@ -34,16 +34,24 @@ contains feature documentation, mapping the MCP spec to the packages above. The following table shows which versions of the Go SDK support which versions of the MCP specification: -| SDK Version | Latest MCP Spec | All Supported MCP Specs | -|-----------------|-------------------|----------------------------------------------------| -| v1.4.0+ | 2025-11-25\* | 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 | -| v1.2.0 - v1.3.1 | 2025-11-25\*\* | 2025-11-25\*\*, 2025-06-18, 2025-03-26, 2024-11-05 | -| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 | +| SDK Version | Latest MCP Spec | All Supported MCP Specs | +|-----------------|-------------------|------------------------------------------------------------------| +| v1.7.0+ | 2026-07-28 | 2026-07-28, 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.4.0 - v1.6.1 | 2025-11-25\* | 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.2.0 - v1.3.1 | 2025-11-25\*\* | 2025-11-25\*\*, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 | \* Client side OAuth has experimental support. \*\* Partial support for 2025-11-25 (client side OAuth and Sampling with tools not available). +The roots, sampling, and logging features are deprecated as of protocol version +2026-07-28 by +[SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging). +The SDK continues to support them for compatibility during the deprecation +window (at least twelve months). See the individual feature documentation for +migration guidance. + New releases of the SDK target only supported versions of Go. See https://go.dev/doc/devel/release#policy for more information. diff --git a/auth/auth.go b/auth/auth.go index 40fa259..dc02d7d 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -47,6 +47,34 @@ type RequireBearerTokenOptions struct { ResourceMetadataURL string // The required scopes. Scopes []string + // AllowMissingExpiration opts the middleware out of the + // `tokenInfo.Expiration.IsZero()` reject. Default false preserves the + // existing strict behaviour (every TokenInfo must carry an Expiration). + // + // Some IdPs emit session-bound bearer tokens that do not carry a standalone + // `exp` claim — the token's lifetime is bounded by an external session and + // is not advertised in-band. Resource servers integrating with such IdPs + // need to opt in to validating the rest of the token (scopes, signature + // via the verifier callback, etc.) without requiring the expiration field + // to be present. + // + // When enabled, the verifier is still responsible for any session-level + // validity check it can perform; this option only relaxes the middleware's + // own expiration enforcement. + AllowMissingExpiration bool + // ClockSkew bounds the tolerance applied to a token's Expiration when + // deciding whether it has elapsed. A token is rejected only if + // Expiration + ClockSkew is before the current time. Zero (the default) + // preserves strict comparison: any expired token is rejected immediately. + // + // Resource servers running behind a CDN, in distributed deployments, or + // communicating with an authorization server whose clock drifts a few + // seconds (common with cloud-managed IdPs) need a small positive value + // here to avoid rejecting tokens that are valid by the issuer's clock + // but momentarily appear expired by the verifier's. The same tolerance + // guards against an issuer's clock running slightly fast at /token + // issuance time. + ClockSkew time.Duration } type tokenInfoKey struct{} @@ -129,11 +157,17 @@ func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenO } } - // Check expiration. - if tokenInfo.Expiration.IsZero() { - return nil, "token missing expiration", http.StatusUnauthorized + if opts == nil { + opts = &RequireBearerTokenOptions{} } - if tokenInfo.Expiration.Before(time.Now()) { + // Check expiration, with optional clock-skew tolerance. Skew only applies + // when an expiration is present; a missing expiration is governed solely by + // AllowMissingExpiration. + if tokenInfo.Expiration.IsZero() { + if !opts.AllowMissingExpiration { + return nil, "token missing expiration", http.StatusUnauthorized + } + } else if tokenInfo.Expiration.Add(opts.ClockSkew).Before(time.Now()) { return nil, "token expired", http.StatusUnauthorized } return tokenInfo, "", 0 diff --git a/auth/auth_example_test.go b/auth/auth_example_test.go new file mode 100644 index 0000000..bf97eb8 --- /dev/null +++ b/auth/auth_example_test.go @@ -0,0 +1,222 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package auth_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sync" + + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/oauthex" + "golang.org/x/oauth2" +) + +// savingTokenSource is an oauth2.TokenSource that passes the oauth2 config and +// token to the given saver function each time the access token value changes. +type savingTokenSource struct { + mu sync.Mutex + src oauth2.TokenSource + saver func(*oauth2.Config, *oauth2.Token) error + config *oauth2.Config + accessToken string +} + +func (s *savingTokenSource) Token() (*oauth2.Token, error) { + s.mu.Lock() + defer s.mu.Unlock() + tok, err := s.src.Token() + if err != nil { + return nil, err + } + if s.accessToken != tok.AccessToken { + s.accessToken = tok.AccessToken + // This saver implementation always returns nil. + _ = s.saver(s.config, tok) + } + return tok, nil +} + +// NewSavingTokenSource persists OAuth 2.0 sessions by intercepting token +// changes from the wrapped oauth2.TokenSource. When this wrapper detects an +// access token change, it calls the provided session saver with the +// oauth2.Config and the new oauth2.Token. +// +// initial is an optional access token the caller may already hold (such as a +// token loaded from storage). It initialises the wrapper's state so that if +// the first wrapped.Token() call returns the same token, does not trigger a +// redundant call to saver(). Pass nil when there is no existing token, in +// which case the first token produced by wrapped.Token() is saved. +func NewSavingTokenSource(wrapped oauth2.TokenSource, config *oauth2.Config, initial *oauth2.Token, saver func(*oauth2.Config, *oauth2.Token) error) oauth2.TokenSource { + if wrapped == nil { + return nil + } + if saver == nil { + return wrapped + } + var accessToken string + if initial != nil { + accessToken = initial.AccessToken + } + return &savingTokenSource{ + src: wrapped, + saver: saver, + config: config, + accessToken: accessToken, + } +} + +// sessionStore is an in-memory OAuth 2.0 session store used to demonstrate +// persistence. A production implementation would persist the session to disk or +// a secret store. +type sessionStore struct { + config *oauth2.Config + token *oauth2.Token +} + +// save persists the given OAuth 2.0 config and token. +func (s *sessionStore) save(config *oauth2.Config, token *oauth2.Token) error { + fmt.Printf("Saving token: %s\n", token.AccessToken) + s.config = config + s.token = token + return nil +} + +// restore loads a previously persisted OAuth 2.0 session, if one exists. +func (s *sessionStore) restore() (*oauth2.Config, *oauth2.Token, error) { + if s.config != nil && s.token != nil { + fmt.Println("Restoring session.") + } else { + fmt.Println("No session found to restore.") + } + return s.config, s.token, nil +} + +// newMockAuthServer returns an httptest.Server that simulates both an MCP +// resource server requiring authorization and its OAuth authorization server. +func newMockAuthServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/oauth-authorization-server": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"issuer": "http://%s", "authorization_endpoint": "http://%s/auth", "token_endpoint": "http://%s/token", "code_challenge_methods_supported": ["S256"]}`, r.Host, r.Host, r.Host) + case "/token": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token": "mock-token", "token_type": "bearer"}`)) + default: + // The mock MCP endpoint returns 401 until the client presents a valid + // bearer token. + if r.Header.Get("Authorization") == "Bearer mock-token" { + // A real server would return a valid MCP message here. The empty + // response causes Connect to return an error after authorization, + // which is ignored for this example which demonstrates token + // persistence only. + w.WriteHeader(http.StatusOK) + return + } + w.Header().Set("WWW-Authenticate", "Bearer") + w.WriteHeader(http.StatusUnauthorized) + } + })) +} + +// This example shows how OAuth2 session persistence might be implemented. It +// connects twice using a shared in-memory session store: the first connection +// authorizes and saves the session, and the second restores and reuses it. +func Example_persistence() { + // Simulate an MCP server that requires authorization and an OAuth server. + mockServer := newMockAuthServer() + defer mockServer.Close() + + // store persists the OAuth2 session across both connections. + store := &sessionStore{} + + // connect performs a single client connection, restoring any saved session + // beforehand and saving any new session acquired during authorization. + connect := func() { + // Load the OAuth2 session if available, and use it. + var initialTS oauth2.TokenSource + cfg, tok, err := store.restore() + if err == nil && cfg != nil && tok != nil { + initialTS = NewSavingTokenSource( + cfg.TokenSource(context.Background(), tok), + cfg, tok, store.save, + ) + } + + // Configure and initialize the AuthorizationCodeHandler with a + // NewTokenSource that saves the session when the access token changes, + // and the InitialTokenSource set to the session loaded via restore(). + config := &auth.AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "example", + }, + Client: mockServer.Client(), + AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + fmt.Println("No token source found. Transport is calling Authorize()...") + // Extract the generated state from the authorization URL + u, _ := url.Parse(args.URL) + state := u.Query().Get("state") + return &auth.AuthorizationResult{Code: "mock-code", State: state}, nil + }, + NewTokenSource: func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) { + // This save implementation always returns nil. + _ = store.save(cfg, token) + return NewSavingTokenSource( + cfg.TokenSource(ctx, token), cfg, token, store.save, + ), nil + }, + InitialTokenSource: initialTS, + } + handler, err := auth.NewAuthorizationCodeHandler(config) + if err != nil { + fmt.Printf("Error creating handler: %v\n", err) + return + } + + // Set the constructed handler on a transport. + transport := &mcp.StreamableClientTransport{ + Endpoint: mockServer.URL + "/sse", + OAuthHandler: handler, + } + + // Create a client and attempt to connect using the configured + // transport. The transport will automatically: + // 1. Call TokenSource() to check for an existing session. This will + // return InitialTokenSource, if set. + // 2. Try the MCP endpoint, and encounter a 401 response from the mock + // server. + // 3. Call Authorize() to perform the OAuth flow, which calls + // NewTokenSource. In this example NewTokenSource saves the newly + // acquired session. + // 4. Retry the MCP endpoint with a valid bearer token and get a 200. + client := mcp.NewClient( + &mcp.Implementation{Name: "example", Version: "1.0.0"}, nil, + ) + // Response ignored: this example asserts authorization only. + _, _ = client.Connect(context.Background(), transport, nil) + } + + // The first connection has no saved session, so it authorizes and saves. + fmt.Println("--- First connect ---") + connect() + // The second connection restores the saved session and reuses it, so no + // further authorization or save occurs. + fmt.Println("--- Second connect ---") + connect() + + // Output: + // --- First connect --- + // No session found to restore. + // No token source found. Transport is calling Authorize()... + // Saving token: mock-token + // --- Second connect --- + // Restoring session. +} diff --git a/auth/auth_test.go b/auth/auth_test.go index 4028c90..654ca26 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -62,6 +62,11 @@ func TestVerify(t *testing.T) { "no expiration", nil, "Bearer noexp", "token missing expiration", 401, }, + { + "no expiration with AllowMissingExpiration accepts", + &RequireBearerTokenOptions{AllowMissingExpiration: true}, "Bearer noexp", + "", 0, + }, { "expired", nil, "Bearer expired", "token expired", 401, @@ -284,3 +289,63 @@ func TestRequireBearerToken(t *testing.T) { }) } } + +// TestRequireBearerToken_ClockSkew verifies that the ClockSkew option +// extends the expiration check tolerance: a token whose Expiration is in the +// recent past is accepted iff the elapsed interval is within ClockSkew. +func TestRequireBearerToken_ClockSkew(t *testing.T) { + tests := []struct { + name string + clockSkew time.Duration + expiredAgo time.Duration + wantStatus int + }{ + { + name: "no skew, fresh token accepted", + clockSkew: 0, + expiredAgo: -time.Minute, // expires in 1 minute + wantStatus: http.StatusOK, + }, + { + name: "no skew, expired token rejected", + clockSkew: 0, + expiredAgo: 5 * time.Second, // expired 5s ago + wantStatus: http.StatusUnauthorized, + }, + { + name: "with skew, recently-expired token accepted", + clockSkew: 30 * time.Second, + expiredAgo: 5 * time.Second, + wantStatus: http.StatusOK, + }, + { + name: "with skew, token expired beyond tolerance rejected", + clockSkew: 10 * time.Second, + expiredAgo: 30 * time.Second, + wantStatus: http.StatusUnauthorized, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + verifier := func(_ context.Context, _ string, _ *http.Request) (*TokenInfo, error) { + return &TokenInfo{Expiration: time.Now().Add(-tt.expiredAgo)}, nil + } + handler := RequireBearerToken(verifier, &RequireBearerTokenOptions{ + ClockSkew: tt.clockSkew, + })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer anything") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + }) + } +} diff --git a/auth/authorization_code.go b/auth/authorization_code.go index 846feb7..f349842 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -14,7 +14,10 @@ import ( "net/url" "slices" "strings" + "sync" + "github.com/modelcontextprotocol/go-sdk/internal/authutil" + "github.com/modelcontextprotocol/go-sdk/internal/util" "github.com/modelcontextprotocol/go-sdk/oauthex" "golang.org/x/oauth2" ) @@ -34,6 +37,10 @@ type ClientIDMetadataDocumentConfig struct { type DynamicClientRegistrationConfig struct { // Metadata to be used in dynamic client registration request as per // https://datatracker.ietf.org/doc/html/rfc7591#section-2. + // + // If Metadata.ApplicationType is empty, it will be inferred from + // Metadata.RedirectURIs. When set, it will be validated against the inferred type + // and an error will be returned if they conflict. Metadata *oauthex.ClientRegistrationMetadata } @@ -44,6 +51,12 @@ type AuthorizationResult struct { Code string // State string returned by the authorization server. State string + // Iss is the issuer identifier returned by the authorization server in the + // authorization response per [RFC 9207]. The AuthorizationCodeFetcher should + // populate this from the "iss" query parameter in the redirect URI if present. + // + // [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207 + Iss string } // AuthorizationArgs is the input to [AuthorizationCodeFetcher]. @@ -85,6 +98,37 @@ type AuthorizationCodeHandlerConfig struct { // See [AuthorizationCodeFetcher] for details. AuthorizationCodeFetcher AuthorizationCodeFetcher + // ScopeFilter, if non-nil, is called with the scopes discovered from the + // protected resource metadata (or WWW-Authenticate challenge) and returns + // the scopes to request during authorization. It gives the client full + // control to narrow, extend, or reorder that set — e.g. dropping Gmail's + // gmail.metadata, which the Gmail API refuses to combine with the search "q" + // parameter even alongside gmail.readonly. It runs before offline_access + // (see RequestRefreshToken) and the step-up union, so neither is affected. + ScopeFilter func(discovered []string) []string + + // RequestRefreshToken indicates that the client intends to use refresh + // tokens and is capable of storing them securely. + // + // When true and the Authorization Server metadata contains "offline_access" + // in its scopes_supported, the client adds "offline_access" to the + // requested scopes. + // + // When using Dynamic Client Registration, callers should include + // "refresh_token" in [DynamicClientRegistrationConfig].Metadata.GrantTypes + // directly to advertise refresh token support to the Authorization Server. + // + // When using Client ID Metadata Document, the document hosted at the + // Client ID URL should include "refresh_token" in its grant_types. + // + // See https://modelcontextprotocol.io/seps/2207-oidc-refresh-token-guidance. + RequestRefreshToken bool + + // AcceptUnadvertisedIss accepts a matching RFC 9207 iss even when the + // authorization server metadata omits authorization_response_iss_parameter_supported. + // The zero value (false) keeps the historical reject-unadvertised-iss behavior. + AcceptUnadvertisedIss bool + // Client is an optional HTTP client to use for HTTP requests. // It is used for the following requests: // - Fetching Protected Resource Metadata @@ -97,6 +141,24 @@ type AuthorizationCodeHandlerConfig struct { // https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#server-side-request-forgery-ssrf // If not provided, http.DefaultClient will be used. Client *http.Client + + // NewTokenSource is an optional function that can be set to construct the + // token source that will be used by the [AuthorizationCodeHandler]. If + // non-nil, it is called after the authorization code is successfully + // exchanged for a token in [AuthorizationCodeHandler.Authorize] + // to obtain the [oauth2.TokenSource] returned by + // [AuthorizationCodeHandler.TokenSource]. Implementations must use the + // provided context, which is properly configured for constructing a + // TokenSource. The default is to call [oauth2.Config.TokenSource]. + NewTokenSource func(context.Context, *oauth2.Config, *oauth2.Token) (oauth2.TokenSource, error) + + // InitialTokenSource is an optional field that can be set to inject the + // token source that will be used by the [AuthorizationCodeHandler]. If + // non-nil, it is set as the token source that will be returned by + // [AuthorizationCodeHandler.TokenSource] during handler initialization. + // The default is nil, which means no token source has been set initially, + // and will trigger a call to [AuthorizationCodeHandler.Authorize]. + InitialTokenSource oauth2.TokenSource } // AuthorizationCodeHandler is an implementation of [OAuthHandler] that uses @@ -104,13 +166,21 @@ type AuthorizationCodeHandlerConfig struct { type AuthorizationCodeHandler struct { config *AuthorizationCodeHandlerConfig + // mu protects concurrent access to tokenSource and grantedScopes. + mu sync.RWMutex + // tokenSource is the token source to use for authorization. tokenSource oauth2.TokenSource + + // grantedScopes maps authorization server issuer to the list of scopes granted by that issuer. + grantedScopes map[string][]string } var _ OAuthHandler = (*AuthorizationCodeHandler)(nil) func (h *AuthorizationCodeHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { + h.mu.RLock() + defer h.mu.RUnlock() return h.tokenSource, nil } @@ -150,6 +220,12 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho } else if !slices.Contains(dCfg.Metadata.RedirectURIs, config.RedirectURL) { return nil, fmt.Errorf("RedirectURL %q is not in the list of allowed redirect URIs for dynamic client registration", config.RedirectURL) } + applicationType := inferApplicationType(dCfg.Metadata.RedirectURIs) + if dCfg.Metadata.ApplicationType == "" { + dCfg.Metadata.ApplicationType = applicationType + } else if dCfg.Metadata.ApplicationType != applicationType { + return nil, fmt.Errorf("application type %q conflicts with the application type inferred from redirect URIs", dCfg.Metadata.ApplicationType) + } } if config.RedirectURL == "" { // If the RedirectURL was supposed to be set by the dynamic client registration, @@ -159,7 +235,11 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho if config.Client == nil { config.Client = http.DefaultClient } - return &AuthorizationCodeHandler{config: config}, nil + return &AuthorizationCodeHandler{ + config: config, + tokenSource: config.InitialTokenSource, + grantedScopes: make(map[string][]string), + }, nil } func isNonRootHTTPSURL(u string) bool { @@ -170,6 +250,36 @@ func isNonRootHTTPSURL(u string) bool { return pu.Scheme == "https" && pu.Path != "" } +// inferApplicationType returns an application type based on the redirect URIs. +func inferApplicationType(redirectURIs []string) string { + hasNative := false + hasWeb := false + for _, uri := range redirectURIs { + u, err := url.Parse(uri) + if err != nil { + return "" + } + switch u.Scheme { + case "http", "https": + if util.IsLoopback(u.Hostname()) { + hasNative = true + } else { + hasWeb = true + } + default: + hasNative = true + } + } + + if hasNative && hasWeb { + return "" + } + if hasNative { + return "native" + } + return "web" +} + // Authorize performs the authorization flow. // It is designed to perform the whole Authorization Code Grant flow. // On success, [AuthorizationCodeHandler.TokenSource] will return a token source with the fetched token. @@ -217,11 +327,33 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ return err } - scps := scopesFromChallenges(wwwChallenges) - if len(scps) == 0 && len(prm.ScopesSupported) > 0 { - scps = prm.ScopesSupported + requestedScopes := scopesFromChallenges(wwwChallenges) + if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 { + requestedScopes = prm.ScopesSupported } + // Let the client adjust the discovered scopes before offline_access and the + // step-up union below so neither is affected. + if h.config.ScopeFilter != nil { + requestedScopes = h.config.ScopeFilter(requestedScopes) + } + + // SEP-2207: when the client desires refresh tokens and the Authorization + // Server advertises offline_access support, add it to the requested scopes. + if h.config.RequestRefreshToken && + slices.Contains(asm.ScopesSupported, "offline_access") && + !slices.Contains(requestedScopes, "offline_access") { + requestedScopes = append(requestedScopes, "offline_access") + } + + // Accumulate scopes: union previously granted scopes with the newly + // challenged scopes so that step-up authorization does not lose + // permissions granted in earlier rounds (SEP-2350). + h.mu.RLock() + granted := h.grantedScopes[asm.Issuer] + h.mu.RUnlock() + requestedScopes = authutil.UnionScopes(granted, requestedScopes) + cfg := &oauth2.Config{ ClientID: resolvedClientConfig.clientID, ClientSecret: resolvedClientConfig.clientSecret, @@ -232,7 +364,7 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ AuthStyle: resolvedClientConfig.authStyle, }, RedirectURL: h.config.RedirectURL, - Scopes: scps, + Scopes: requestedScopes, } authRes, err := h.getAuthorizationCode(ctx, cfg, prm.Resource) @@ -240,8 +372,16 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ // Purposefully leaving the error unwrappable so it can be handled by the caller. return err } + if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported, h.config.AcceptUnadvertisedIss); err != nil { + return err + } - return h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource) + err = h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource) + if err != nil { + return err + } + + return h.updateGrantedScopes(asm.Issuer, requestedScopes) } // resourceMetadataURLFromChallenges returns a resource metadata URL from the given "WWW-Authenticate" header challenges, @@ -415,6 +555,9 @@ func (h *AuthorizationCodeHandler) handleRegistration(ctx context.Context, asm * // 2. Attempt to use pre-registered client configuration. preCfg := h.config.PreregisteredClient if preCfg != nil { + if preCfg.Issuer != "" && !authutil.IssuersEqual(preCfg.Issuer, asm.Issuer) { + return nil, fmt.Errorf("authorization server issuer %q does not match pre-registered credentials issuer %q", asm.Issuer, preCfg.Issuer) + } authStyle := selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported) clientSecret := "" if preCfg.ClientSecretAuth != nil { @@ -477,6 +620,36 @@ func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg }, nil } +// validateIssuerResponse validates the "iss" parameter in an authorization response +// per [RFC 9207]. When the server advertises authorization_response_iss_parameter_supported, +// iss is required and must match expectedIssuer. When it does not advertise support, +// an empty iss is accepted; a present iss is compared to expectedIssuer (RFC 9207 §2.4) +// and a mismatch is always rejected. A matching unadvertised iss is accepted only when +// acceptUnadvertisedIss is true (local policy); the default is the historical reject. +// +// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207 +func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported, acceptUnadvertisedIss bool) error { + if issParameterSupported { + if iss == "" { + return fmt.Errorf("authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response") + } + if iss != expectedIssuer { + return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer) + } + return nil + } + if iss == "" { + return nil + } + if iss != expectedIssuer { + return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer) + } + if !acceptUnadvertisedIss { + return fmt.Errorf("authorization server does not advertise RFC 9207 iss parameter support but iss was received in the authorization response") + } + return nil +} + // exchangeAuthorizationCode exchanges the authorization code for a token // and stores it in a token source. func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context, cfg *oauth2.Config, authResult *authResult, resourceURL string) error { @@ -489,6 +662,52 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context if err != nil { return fmt.Errorf("token exchange failed: %w", err) } - h.tokenSource = cfg.TokenSource(clientCtx, token) + // The token source outlives this authorization request: it is stored on the + // handler and used by the transport for the lifetime of the connection. The + // oauth2 library captures the context passed to TokenSource and reuses it for + // every subsequent token refresh (see golang.org/x/oauth2: tokenRefresher + // retains the context and passes it to each refresh round-trip). Binding it to + // the per-request ctx makes all later refreshes fail with "context canceled" + // once that request (or the connect operation that triggered authorization) + // completes. Use a background context that still carries the configured HTTP + // client so refreshes keep working for the life of the token source. + refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, h.config.Client) + var ts oauth2.TokenSource + if h.config.NewTokenSource == nil { + ts = cfg.TokenSource(refreshCtx, token) + } else { + var err error + ts, err = h.config.NewTokenSource(refreshCtx, cfg, token) + if err != nil { + return fmt.Errorf("constructing token source failed: %w", err) + } + } + h.mu.Lock() + h.tokenSource = ts + h.mu.Unlock() + return nil +} + +// updateGrantedScopes updates the granted scopes based on the token source and requested scopes. +func (h *AuthorizationCodeHandler) updateGrantedScopes(issuer string, requestedScopes []string) error { + h.mu.RLock() + ts := h.tokenSource + h.mu.RUnlock() + + if ts == nil { + return nil + } + tok, err := ts.Token() + if err != nil { + return err + } + + h.mu.Lock() + if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil { + h.grantedScopes[issuer] = requestedScopes + } else { + h.grantedScopes[issuer] = tokenScopes + } + h.mu.Unlock() return nil } diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index cd4741a..69a7ca6 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -10,10 +10,13 @@ import ( "net/http" "net/http/httptest" "net/http/httputil" + "net/url" + "slices" "strings" "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/modelcontextprotocol/go-sdk/internal/oauthtest" "github.com/modelcontextprotocol/go-sdk/oauthex" "golang.org/x/oauth2" @@ -75,6 +78,7 @@ func TestAuthorize(t *testing.T) { return &AuthorizationResult{ Code: location.Query().Get("code"), State: location.Query().Get("state"), + Iss: location.Query().Get("iss"), }, nil }, }) @@ -111,6 +115,227 @@ func TestAuthorize(t *testing.T) { } } +// TestAuthorize_RefreshAfterContextCancel verifies that the token source built +// by Authorize keeps refreshing after the context passed to Authorize is +// cancelled. The token source is stored on the handler and used by the +// transport for the whole connection lifetime, whereas Authorize is typically +// called from a request- or connect-scoped context. golang.org/x/oauth2 +// captures the context handed to Config.TokenSource and reuses it for every +// refresh, so binding it to the request context made every refresh after the +// access token expired fail with "context canceled". Regression test for that. +func TestAuthorize_RefreshAfterContextCancel(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + // expires_in below oauth2's 10s expiry delta, so the reuse token source + // treats the access token as expired immediately and must refresh. + AccessTokenTTL: 1, + IssueRefreshToken: true, + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test_client_id": { + Secret: "test_client_secret", + RedirectURIs: []string{"http://localhost:12345/callback"}, + }, + }, + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test_client_secret"}, + }, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Get(args.URL) + if err != nil { + return nil, fmt.Errorf("failed to visit auth URL: %v", err) + } + defer resp.Body.Close() + location, err := resp.Location() + if err != nil { + return nil, fmt.Errorf("failed to get location header: %v", err) + } + return &AuthorizationResult{ + Code: location.Query().Get("code"), + State: location.Query().Get("state"), + Iss: location.Query().Get("iss"), + }, nil + }, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, resourceURL, nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + resp.Header.Set("WWW-Authenticate", "Bearer resource_metadata="+resourceServer.URL+"/.well-known/oauth-protected-resource/resource") + + // Authorize under a context that we cancel immediately afterwards, mimicking + // the request/connect context the transport passes and that is already done + // by the time a later token refresh runs. + ctx, cancel := context.WithCancel(context.Background()) + if err := handler.Authorize(ctx, req, resp); err != nil { + t.Fatalf("Authorize failed: %v", err) + } + cancel() + + tokenSource, err := handler.TokenSource(context.Background()) + if err != nil { + t.Fatalf("Failed to get token source: %v", err) + } + // The access token is already expired, so this forces a refresh round-trip. + // It must not fail with "context canceled" from the cancelled Authorize ctx. + token, err := tokenSource.Token() + if err != nil { + t.Fatalf("token refresh after Authorize context cancellation failed: %v", err) + } + if token.AccessToken != "test_access_token_refreshed" { + t.Errorf("expected refreshed access token %q, got %q", "test_access_token_refreshed", token.AccessToken) + } +} + +func TestAuthorize_ScopeAccumulation(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test_client_id": { + Secret: "test_client_secret", + RedirectURIs: []string{"http://localhost:12345/callback"}, + }, + }, + }, + TokenScopeFunc: func(requestedScope string) string { + // Simulate a server that never grants "write". + var granted []string + for _, s := range strings.Fields(requestedScope) { + if s != "write" { + granted = append(granted, s) + } + } + return strings.Join(granted, " ") + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + + var capturedAuthURLs []string + noRedirectClient := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "test_client_secret", + }, + }, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + capturedAuthURLs = append(capturedAuthURLs, args.URL) + resp, err := noRedirectClient.Get(args.URL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + loc, err := resp.Location() + if err != nil { + return nil, err + } + return &AuthorizationResult{ + Code: loc.Query().Get("code"), + State: loc.Query().Get("state"), + Iss: loc.Query().Get("iss"), + }, nil + }, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + // First authorization: 401 with scope="read write". + // The token response will only grant "read" (TokenScopeFunc strips "write"). + req := httptest.NewRequest(http.MethodGet, resourceURL, nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + } + resp.Header.Set("WWW-Authenticate", + fmt.Sprintf(`Bearer scope="read write", resource_metadata="%s/.well-known/oauth-protected-resource/resource"`, resourceServer.URL)) + if err := handler.Authorize(context.Background(), req, resp); err != nil { + t.Fatalf("First Authorize failed: %v", err) + } + + // Verify first auth URL requested "read" and "write". + firstURL, err := url.Parse(capturedAuthURLs[0]) + if err != nil { + t.Fatalf("Failed to parse first auth URL: %v", err) + } + firstScopes := strings.Fields(firstURL.Query().Get("scope")) + if diff := cmp.Diff([]string{"read", "write"}, firstScopes, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("First auth scopes mismatch (-want +got):\n%s", diff) + } + + // Verify only "read" was granted (the token omitted "write"). + issuer := authServer.URL() + if diff := cmp.Diff([]string{"read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("After first Authorize, grantedScopes mismatch (-want +got):\n%s", diff) + } + + // Second authorization: 403 insufficient_scope with scope="admin". + // Accumulated scopes should be "read" (previously granted) + "admin" (new). + req2 := httptest.NewRequest(http.MethodGet, resourceURL, nil) + resp2 := &http.Response{ + StatusCode: http.StatusForbidden, + Header: make(http.Header), + Body: http.NoBody, + } + resp2.Header.Set("WWW-Authenticate", + fmt.Sprintf(`Bearer error="insufficient_scope", scope="admin", resource_metadata="%s/.well-known/oauth-protected-resource/resource"`, resourceServer.URL)) + if err := handler.Authorize(context.Background(), req2, resp2); err != nil { + t.Fatalf("Second Authorize failed: %v", err) + } + + // Verify second auth URL accumulated "read" (granted) + "admin" (challenged), + // but NOT "write" (requested but never granted). + secondURL, err := url.Parse(capturedAuthURLs[1]) + if err != nil { + t.Fatalf("Failed to parse second auth URL: %v", err) + } + secondScopes := strings.Fields(secondURL.Query().Get("scope")) + if diff := cmp.Diff([]string{"admin", "read"}, secondScopes, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("Second auth scopes mismatch (-want +got):\n%s", diff) + } +} + func TestAuthorize_ForbiddenUnhandledError(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/resource", nil) resp := &http.Response{ @@ -479,6 +704,8 @@ func TestHandleRegistration(t *testing.T) { asm *oauthex.AuthServerMeta want *resolvedClientConfig wantError bool + issuerMatch bool + issuerSuffix string }{ { name: "ClientIDMetadataDocument", @@ -517,6 +744,79 @@ func TestHandleRegistration(t *testing.T) { authStyle: oauth2.AuthStyleInParams, }, }, + { + name: "Preregistered_IssuerMatch", + serverConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "pre_client_id": { + Secret: "pre_client_secret", + }, + }, + }, + handlerConfig: &AuthorizationCodeHandlerConfig{ + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "pre_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "pre_client_secret", + }, + Issuer: "", // set dynamically in the test + }, + }, + want: &resolvedClientConfig{ + registrationType: registrationTypePreregistered, + clientID: "pre_client_id", + clientSecret: "pre_client_secret", + authStyle: oauth2.AuthStyleInParams, + }, + issuerMatch: true, + }, + { + name: "Preregistered_IssuerMismatch", + serverConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "pre_client_id": { + Secret: "pre_client_secret", + }, + }, + }, + handlerConfig: &AuthorizationCodeHandlerConfig{ + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "pre_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "pre_client_secret", + }, + Issuer: "https://other-issuer.example.com", + }, + }, + wantError: true, + }, + { + name: "Preregistered_IssuerMatchTrailingSlash", + serverConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "pre_client_id": { + Secret: "pre_client_secret", + }, + }, + }, + handlerConfig: &AuthorizationCodeHandlerConfig{ + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "pre_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "pre_client_secret", + }, + Issuer: "", // set dynamically in the test (with trailing slash) + }, + }, + want: &resolvedClientConfig{ + registrationType: registrationTypePreregistered, + clientID: "pre_client_id", + clientSecret: "pre_client_secret", + authStyle: oauth2.AuthStyleInParams, + }, + issuerMatch: true, + issuerSuffix: "/", + }, { name: "NoneSupported", handlerConfig: &AuthorizationCodeHandlerConfig{ @@ -530,6 +830,10 @@ func TestHandleRegistration(t *testing.T) { t.Run(tt.name, func(t *testing.T) { s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{RegistrationConfig: tt.serverConfig}) s.Start(t) + // Set the Issuer dynamically if requested by the test case. + if tt.issuerMatch { + tt.handlerConfig.PreregisteredClient.Issuer = s.URL() + tt.issuerSuffix + } tt.handlerConfig.AuthorizationCodeFetcher = func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { return nil, nil } @@ -549,6 +853,9 @@ func TestHandleRegistration(t *testing.T) { } return } + if tt.wantError { + t.Fatal("handleRegistration() expected error, got nil") + } if got.registrationType != tt.want.registrationType { t.Errorf("handleRegistration() registrationType = %v, want %v", got.registrationType, tt.want.registrationType) } @@ -608,6 +915,551 @@ func TestDynamicRegistration(t *testing.T) { } } +func TestValidateIssuerResponse(t *testing.T) { + const expectedIssuer = "https://auth.example.com" + + tests := []struct { + name string + iss string + issSupported bool + acceptUnadvertisedIss bool + wantErr bool + wantErrContains string + }{ + { + name: "ValidIss", + iss: expectedIssuer, + issSupported: true, + }, + { + name: "WrongIss", + iss: "https://attacker.example.com", + issSupported: true, + wantErr: true, + wantErrContains: "does not match expected issuer", + }, + { + name: "MissingIssWhenRequired", + iss: "", + issSupported: true, + wantErr: true, + wantErrContains: "RFC 9207", + }, + { + name: "MissingIssWhenNotRequired", + iss: "", + issSupported: false, + }, + { + // Default / zero-value policy: reject a matching unadvertised iss + // (released v1.7.0 behavior). + name: "UnadvertisedIssCorrectRejectedByDefault", + iss: expectedIssuer, + issSupported: false, + wantErr: true, + wantErrContains: "does not advertise", + }, + { + name: "UnadvertisedIssCorrectAcceptedWhenOptedIn", + iss: expectedIssuer, + issSupported: false, + acceptUnadvertisedIss: true, + }, + { + name: "UnadvertisedIssWrong", + iss: "https://attacker.example.com", + issSupported: false, + wantErr: true, + wantErrContains: "does not match expected issuer", + }, + { + name: "UnadvertisedIssWrongStillRejectedWhenOptedIn", + iss: "https://attacker.example.com", + issSupported: false, + acceptUnadvertisedIss: true, + wantErr: true, + wantErrContains: "does not match expected issuer", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported, tt.acceptUnadvertisedIss) + if tt.wantErr { + if err == nil { + t.Fatalf("validateIssuerResponse() = nil, want error containing %q", tt.wantErrContains) + } + if !strings.Contains(err.Error(), tt.wantErrContains) { + t.Errorf("validateIssuerResponse() error = %q, want it to contain %q", err.Error(), tt.wantErrContains) + } + } else if err != nil { + t.Fatalf("validateIssuerResponse() unexpected error = %v", err) + } + }) + } +} + +// TestAuthorize_AcceptUnadvertisedIssPlumbing checks that Authorize passes +// AuthorizationCodeHandlerConfig.AcceptUnadvertisedIss through to +// validateIssuerResponse. The fake server returns iss without advertising +// authorization_response_iss_parameter_supported. +func TestAuthorize_AcceptUnadvertisedIssPlumbing(t *testing.T) { + tests := []struct { + name string + acceptUnadvertisedIss bool + wantErrContains string + }{ + { + name: "ZeroValueRejects", + wantErrContains: "does not advertise", + }, + { + name: "OptInAccepts", + acceptUnadvertisedIss: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + UnadvertiseIssParameter: true, + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test_client_id": { + Secret: "test_client_secret", + RedirectURIs: []string{"http://localhost:12345/callback"}, + }, + }, + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + AcceptUnadvertisedIss: tt.acceptUnadvertisedIss, + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "test_client_secret", + }, + }, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Get(args.URL) + if err != nil { + return nil, fmt.Errorf("failed to visit auth URL: %v", err) + } + defer resp.Body.Close() + location, err := resp.Location() + if err != nil { + return nil, fmt.Errorf("failed to get location header: %v", err) + } + return &AuthorizationResult{ + Code: location.Query().Get("code"), + State: location.Query().Get("state"), + Iss: location.Query().Get("iss"), + }, nil + }, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, resourceURL, nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + resp.Header.Set( + "WWW-Authenticate", + "Bearer resource_metadata="+resourceServer.URL+"/.well-known/oauth-protected-resource/resource", + ) + err = handler.Authorize(context.Background(), req, resp) + if tt.wantErrContains != "" { + if err == nil { + t.Fatalf("Authorize() = nil, want error containing %q", tt.wantErrContains) + } + if !strings.Contains(err.Error(), tt.wantErrContains) { + t.Fatalf("Authorize() error = %q, want it to contain %q", err.Error(), tt.wantErrContains) + } + return + } + if err != nil { + t.Fatalf("Authorize() unexpected error = %v", err) + } + }) + } +} + +func TestInferApplicationType(t *testing.T) { + tests := []struct { + name string + redirectURIs []string + want string + }{ + { + name: "localhost", + redirectURIs: []string{"http://localhost:8085/callback"}, + want: "native", + }, + { + name: "127.0.0.1", + redirectURIs: []string{"http://127.0.0.1:8085/callback"}, + want: "native", + }, + { + name: "IPv6 loopback", + redirectURIs: []string{"http://[::1]:8085/callback"}, + want: "native", + }, + { + name: "custom scheme", + redirectURIs: []string{"myapp://callback"}, + want: "native", + }, + { + name: "HTTPS remote", + redirectURIs: []string{"https://myapp.example.com/callback"}, + want: "web", + }, + { + name: "mixed native and web", + redirectURIs: []string{"https://myapp.example.com/callback", "http://localhost:8085/callback"}, + want: "", + }, + { + name: "multiple remote", + redirectURIs: []string{"https://app1.example.com/cb", "https://app2.example.com/cb"}, + want: "web", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := inferApplicationType(tt.redirectURIs) + if got != tt.want { + t.Errorf("inferApplicationType() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestApplicationTypeInference(t *testing.T) { + fetcher := func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + return nil, nil + } + + tests := []struct { + name string + redirectURIs []string + initialAppType string + wantAppType string + wantErr bool + }{ + { + name: "inferred as native for localhost", + redirectURIs: []string{"http://localhost:8085/callback"}, + wantAppType: "native", + }, + { + name: "inferred as web for remote", + redirectURIs: []string{"https://example.com/callback"}, + wantAppType: "web", + }, + { + name: "mixed native and web URIs sets empty application type", + redirectURIs: []string{"https://example.com/callback", "http://localhost:8085/callback"}, + wantAppType: "", + }, + { + name: "explicit value matching inference is preserved", + redirectURIs: []string{"http://localhost:8085/callback"}, + initialAppType: "native", + wantAppType: "native", + }, + { + name: "explicit value conflicts with inference returns error", + redirectURIs: []string{"http://localhost:8085/callback"}, + initialAppType: "web", + wantErr: true, + }, + { + name: "explicit value when inference is ambiguous returns error", + redirectURIs: []string{"https://example.com/callback", "http://localhost:8085/callback"}, + initialAppType: "web", + wantErr: true, + }, + { + name: "invalid URI returns empty application type", + redirectURIs: []string{"http://%/"}, + wantAppType: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &AuthorizationCodeHandlerConfig{ + DynamicClientRegistrationConfig: &DynamicClientRegistrationConfig{ + Metadata: &oauthex.ClientRegistrationMetadata{ + RedirectURIs: tt.redirectURIs, + ApplicationType: tt.initialAppType, + }, + }, + AuthorizationCodeFetcher: fetcher, + } + _, err := NewAuthorizationCodeHandler(cfg) + if (err != nil) != tt.wantErr { + t.Fatalf("NewAuthorizationCodeHandler() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + got := cfg.DynamicClientRegistrationConfig.Metadata.ApplicationType + if got != tt.wantAppType { + t.Errorf("ApplicationType = %q, want %q", got, tt.wantAppType) + } + }) + } +} + +func TestAuthorize_OfflineAccessScope(t *testing.T) { + tests := []struct { + name string + requestRefreshToken bool + asScopesSupported []string + challengeScopes string + wantOfflineAccess bool + }{ + { + name: "AddedWhenASSupportsAndClientRequests", + requestRefreshToken: true, + asScopesSupported: []string{"openid", "offline_access"}, + wantOfflineAccess: true, + }, + { + name: "NotAddedWhenClientDoesNotRequest", + requestRefreshToken: false, + asScopesSupported: []string{"openid", "offline_access"}, + wantOfflineAccess: false, + }, + { + name: "NotAddedWhenASDoesNotSupport", + requestRefreshToken: true, + asScopesSupported: []string{"openid"}, + wantOfflineAccess: false, + }, + { + name: "NotDuplicatedWhenAlreadyInScopes", + requestRefreshToken: true, + asScopesSupported: []string{"openid", "offline_access"}, + challengeScopes: "read offline_access", + wantOfflineAccess: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + ScopesSupported: tt.asScopesSupported, + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test_client_id": { + Secret: "test_client_secret", + RedirectURIs: []string{"http://localhost:12345/callback"}, + }, + }, + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + + var capturedAuthURL string + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "test_client_secret", + }, + }, + RequestRefreshToken: tt.requestRefreshToken, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + capturedAuthURL = args.URL + return nil, fmt.Errorf("stop after capturing URL") + }, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, resourceURL, nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + wwwAuth := "Bearer resource_metadata=" + resourceServer.URL + "/.well-known/oauth-protected-resource/resource" + if tt.challengeScopes != "" { + wwwAuth += fmt.Sprintf(", scope=%q", tt.challengeScopes) + } + resp.Header.Set("WWW-Authenticate", wwwAuth) + + // Authorize will fail at the fetcher, but we only care about the URL. + handler.Authorize(context.Background(), req, resp) + + if capturedAuthURL == "" { + t.Fatal("AuthorizationCodeFetcher was not called") + } + u, err := url.Parse(capturedAuthURL) + if err != nil { + t.Fatalf("failed to parse captured auth URL: %v", err) + } + scopes := strings.Fields(u.Query().Get("scope")) + hasOfflineAccess := slices.Contains(scopes, "offline_access") + if hasOfflineAccess != tt.wantOfflineAccess { + t.Errorf("offline_access in scopes = %v, want %v (scopes: %v)", hasOfflineAccess, tt.wantOfflineAccess, scopes) + } + + // When offline_access was already present in challenge scopes, + // verify it appears exactly once. + if tt.wantOfflineAccess { + count := 0 + for _, s := range scopes { + if s == "offline_access" { + count++ + } + } + if count != 1 { + t.Errorf("offline_access appears %d times in scopes, want 1", count) + } + } + }) + } +} + +func TestAuthorize_ScopeFilter(t *testing.T) { + // advertised is delivered via the WWW-Authenticate "scope" challenge, which + // the handler treats as the discovered scopes passed to ScopeFilter. + const advertised = "gmail.metadata gmail.readonly gmail.compose" + tests := []struct { + name string + filter func(discovered []string) []string + want []string // requested scopes, order-independent + }{ + { + name: "nil filter leaves scopes unchanged", + filter: nil, + want: []string{"gmail.metadata", "gmail.readonly", "gmail.compose"}, + }, + { + name: "filter drops a scope", + filter: func(d []string) []string { + return slices.DeleteFunc(slices.Clone(d), func(s string) bool { return s == "gmail.metadata" }) + }, + want: []string{"gmail.readonly", "gmail.compose"}, + }, + { + name: "filter replaces the set entirely", + filter: func([]string) []string { return []string{"gmail.readonly"} }, + want: []string{"gmail.readonly"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test_client_id": { + Secret: "test_client_secret", + RedirectURIs: []string{"http://localhost:12345/callback"}, + }, + }, + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + + var capturedAuthURL string + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test_client_secret"}, + }, + ScopeFilter: tt.filter, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + capturedAuthURL = args.URL + return nil, fmt.Errorf("stop after capturing URL") + }, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, resourceURL, nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + resp.Header.Set("WWW-Authenticate", fmt.Sprintf( + "Bearer resource_metadata=%s/.well-known/oauth-protected-resource/resource, scope=%q", + resourceServer.URL, advertised)) + + handler.Authorize(context.Background(), req, resp) + + if capturedAuthURL == "" { + t.Fatal("AuthorizationCodeFetcher was not called") + } + u, err := url.Parse(capturedAuthURL) + if err != nil { + t.Fatalf("failed to parse captured auth URL: %v", err) + } + // Compare as a set: UnionScopes (applied downstream) returns map keys, + // so the order of the requested scope parameter is not deterministic. + got := strings.Fields(u.Query().Get("scope")) + want := slices.Clone(tt.want) + slices.Sort(got) + slices.Sort(want) + if !slices.Equal(got, want) { + t.Errorf("requested scopes = %v, want %v (any order)", got, want) + } + }) + } +} + // validConfig for test to create an AuthorizationCodeHandler using its constructor. // Values that are relevant to the test should be set explicitly. func validConfig() *AuthorizationCodeHandlerConfig { @@ -619,3 +1471,92 @@ func validConfig() *AuthorizationCodeHandlerConfig { }, } } + +func TestNewTokenSource(t *testing.T) { + // mock the /token endpoint to successfully return an access token on code exchange + mockTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/token" { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"access_token": "test_token", "token_type": "bearer"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer mockTS.Close() + + // configure the handler and set NewTokenSource + var called bool + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client", + }, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + u, _ := url.Parse(args.URL) + return &AuthorizationResult{ + Code: "test_code", + State: u.Query().Get("state"), + }, nil + }, + NewTokenSource: func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) { + called = true + return oauth2.StaticTokenSource(token), nil + }, + Client: mockTS.Client(), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Simulate a 401 response from a resource server. + // The WWW-Authenticate: Bearer header triggers the authorization logic. + req := httptest.NewRequest(http.MethodGet, mockTS.URL, nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + resp.Header.Set("WWW-Authenticate", "Bearer") + + // Authorize and confirm NewTokenSource was called. + err = handler.Authorize(t.Context(), req, resp) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !called { + t.Error("expected NewTokenSource to be called") + } +} + +func TestInitialTokenSource(t *testing.T) { + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + }, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + return nil, nil + }, + InitialTokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "set_token"}), + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + ts, err := handler.TokenSource(t.Context()) + if err != nil { + t.Fatalf("failed to get token source: %v", err) + } + if ts == nil { + t.Fatal("expected token source to be non-nil") + } + + tok, err := ts.Token() + if err != nil { + t.Fatalf("failed to get Token: %v", err) + } + if tok.AccessToken != "set_token" { + t.Errorf("expected access token 'set_token', got '%s'", tok.AccessToken) + } +} diff --git a/auth/extauth/client_credentials.go b/auth/extauth/client_credentials.go new file mode 100644 index 0000000..c65d8b0 --- /dev/null +++ b/auth/extauth/client_credentials.go @@ -0,0 +1,279 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package extauth + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/internal/authutil" + "github.com/modelcontextprotocol/go-sdk/oauthex" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +// ClientCredentialsHandlerConfig is the configuration for [ClientCredentialsHandler]. +type ClientCredentialsHandlerConfig struct { + // Credentials contains the pre-registered client ID and secret. + // REQUIRED. Both ClientID and ClientSecretAuth must be set, since the + // client credentials grant requires a confidential client. + Credentials *oauthex.ClientCredentials + + // HTTPClient is an optional HTTP client for customization. + // If nil, http.DefaultClient is used. + // OPTIONAL. + HTTPClient *http.Client +} + +// ClientCredentialsHandler is an implementation of [auth.OAuthHandler] that +// uses the OAuth 2.0 Client Credentials grant (RFC 6749 Section 4.4) to +// obtain access tokens. +// +// This handler is intended for service-to-service authentication where the +// client has pre-registered credentials (client ID and secret) and does not +// require user interaction. It bypasses both dynamic client registration and +// the authorization code flow. +// +// The token endpoint and scopes are discovered automatically via Protected +// Resource Metadata (RFC 9728) and Authorization Server Metadata (RFC 8414), +// following the ext-auth specification SEP-1046. +type ClientCredentialsHandler struct { + config *ClientCredentialsHandlerConfig + tokenSource oauth2.TokenSource + + // grantedScopes maps authorization server issuer to the list of scopes granted by that issuer. + grantedScopes map[string][]string +} + +// Compile-time check that ClientCredentialsHandler implements auth.OAuthHandler. +var _ auth.OAuthHandler = (*ClientCredentialsHandler)(nil) + +// NewClientCredentialsHandler creates a new ClientCredentialsHandler. +// It validates the configuration and returns an error if invalid. +func NewClientCredentialsHandler(config *ClientCredentialsHandlerConfig) (*ClientCredentialsHandler, error) { + if config == nil { + return nil, fmt.Errorf("config must be provided") + } + if config.Credentials == nil { + return nil, fmt.Errorf("credentials are required") + } + if err := config.Credentials.Validate(); err != nil { + return nil, fmt.Errorf("invalid credentials: %w", err) + } + if config.Credentials.ClientSecretAuth == nil { + return nil, fmt.Errorf("clientSecretAuth is required for client credentials grant") + } + return &ClientCredentialsHandler{ + config: config, + grantedScopes: make(map[string][]string), + }, nil +} + +// TokenSource returns the token source for outgoing requests. +// Returns nil if authorization has not been performed yet. +func (h *ClientCredentialsHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { + return h.tokenSource, nil +} + +// Authorize performs the Client Credentials grant to obtain an access token. +// It is called when a request fails with 401 or 403. +// +// The flow follows the ext-auth specification SEP-1046: +// 1. Discover Protected Resource Metadata from the request URL +// 2. Discover Authorization Server Metadata from PRM +// 3. Exchange client credentials for an access token at the token endpoint +func (h *ClientCredentialsHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error { + defer resp.Body.Close() + defer io.Copy(io.Discard, resp.Body) + + httpClient := h.config.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + + // Step 1: Discover Protected Resource Metadata. + wwwChallenges, err := oauthex.ParseWWWAuthenticate(resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")]) + if err != nil { + return fmt.Errorf("failed to parse WWW-Authenticate header: %v", err) + } + + prm, err := getProtectedResourceMetadata(ctx, wwwChallenges, req.URL.String(), httpClient) + if err != nil { + return err + } + + if len(prm.AuthorizationServers) == 0 { + return fmt.Errorf("protected resource metadata has no authorization servers specified") + } + + // Step 2: Discover Authorization Server Metadata. + asm, err := auth.GetAuthServerMetadata(ctx, prm.AuthorizationServers[0], httpClient) + if err != nil { + return fmt.Errorf("failed to get authorization server metadata: %w", err) + } + if asm == nil { + // Fallback to 2025-03-26 spec: predefined endpoints. + authServerURL := prm.AuthorizationServers[0] + asm = &oauthex.AuthServerMeta{ + Issuer: authServerURL, + TokenEndpoint: authServerURL + "/token", + } + } + + creds := h.config.Credentials + if creds.Issuer != "" && !authutil.IssuersEqual(creds.Issuer, asm.Issuer) { + return fmt.Errorf("authorization server issuer %q does not match pre-registered credentials issuer %q", asm.Issuer, creds.Issuer) + } + + // Determine requestedScopes: use PRM's scopes_supported if available. + requestedScopes := scopesFromChallenges(wwwChallenges) + if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 { + requestedScopes = prm.ScopesSupported + } + + // Accumulate scopes: union previously granted scopes with the newly + // challenged scopes so that step-up authorization does not lose + // permissions granted in earlier rounds (SEP-2350). + requestedScopes = authutil.UnionScopes(h.grantedScopes[asm.Issuer], requestedScopes) + + // Step 3: Exchange client credentials for an access token. + cfg := &clientcredentials.Config{ + ClientID: creds.ClientID, + ClientSecret: creds.ClientSecretAuth.ClientSecret, + TokenURL: asm.TokenEndpoint, + Scopes: requestedScopes, + AuthStyle: selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported), + } + + ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient) + h.tokenSource = cfg.TokenSource(ctxWithClient) + + return h.updateGrantedScopes(asm.Issuer, requestedScopes) +} + +func (h *ClientCredentialsHandler) updateGrantedScopes(issuer string, requestedScopes []string) error { + if h.tokenSource == nil { + return nil + } + tok, err := h.tokenSource.Token() + if err != nil { + h.tokenSource = nil + return fmt.Errorf("client credentials token request failed: %w", err) + } + if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil { + h.grantedScopes[issuer] = requestedScopes + } else { + h.grantedScopes[issuer] = tokenScopes + } + return nil +} + +// getProtectedResourceMetadata discovers Protected Resource Metadata (RFC 9728) +// from the request URL. This mirrors the logic in AuthorizationCodeHandler. +func getProtectedResourceMetadata(ctx context.Context, wwwChallenges []oauthex.Challenge, mcpServerURL string, httpClient *http.Client) (*oauthex.ProtectedResourceMetadata, error) { + // Use MCP server URL as the resource URI per + // https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#canonical-server-uri. + for _, u := range protectedResourceMetadataURLs(resourceMetadataURLFromChallenges(wwwChallenges), mcpServerURL) { + prm, err := oauthex.GetProtectedResourceMetadata(ctx, u.url, u.resource, httpClient) + if err != nil { + continue + } + if prm == nil { + continue + } + if len(prm.AuthorizationServers) == 0 { + // If we found PRM, we enforce the 2025-11-25 spec and not search further. + return nil, fmt.Errorf("protected resource metadata has no authorization servers specified") + } + return prm, nil + } + // Fallback to 2025-03-26 spec: MCP server root is the Authorization Server. + // https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery + u, err := url.Parse(mcpServerURL) + if err != nil { + return nil, fmt.Errorf("failed to parse MCP server URL: %v", err) + } + u.Path = "" + return &oauthex.ProtectedResourceMetadata{ + AuthorizationServers: []string{u.String()}, + Resource: mcpServerURL, + }, nil +} + +type prmURL struct { + url string + resource string +} + +// protectedResourceMetadataURLs returns URLs to try for PRM discovery. +// This mirrors the logic in AuthorizationCodeHandler. +func protectedResourceMetadataURLs(metadataURL, resourceURL string) []prmURL { + var urls []prmURL + if metadataURL != "" { + urls = append(urls, prmURL{url: metadataURL, resource: resourceURL}) + } + ru, err := url.Parse(resourceURL) + if err != nil { + return urls + } + mu := *ru + // At the path of the server's MCP endpoint. + mu.Path = "/.well-known/oauth-protected-resource/" + strings.TrimLeft(ru.Path, "/") + urls = append(urls, prmURL{url: mu.String(), resource: resourceURL}) + // At the root. + mu.Path = "/.well-known/oauth-protected-resource" + ru.Path = "" + urls = append(urls, prmURL{url: mu.String(), resource: ru.String()}) + return urls +} + +// resourceMetadataURLFromChallenges returns a resource metadata URL from +// WWW-Authenticate challenges, or the empty string if there is none. +func resourceMetadataURLFromChallenges(cs []oauthex.Challenge) string { + for _, c := range cs { + if u := c.Params["resource_metadata"]; u != "" { + return u + } + } + return "" +} + +// scopesFromChallenges returns scopes from WWW-Authenticate challenges. +// It only looks at challenges with the "Bearer" scheme. +func scopesFromChallenges(cs []oauthex.Challenge) []string { + for _, c := range cs { + if c.Scheme == "bearer" && c.Params["scope"] != "" { + return strings.Fields(c.Params["scope"]) + } + } + return nil +} + +// selectTokenAuthMethod selects the preferred token endpoint auth method based on +// the authorization server's supported methods. Prefers client_secret_post over +// client_secret_basic per the OAuth 2.1 draft. +func selectTokenAuthMethod(supported []string) oauth2.AuthStyle { + prefOrder := []string{ + "client_secret_post", + "client_secret_basic", + } + for _, method := range prefOrder { + if slices.Contains(supported, method) { + switch method { + case "client_secret_post": + return oauth2.AuthStyleInParams + case "client_secret_basic": + return oauth2.AuthStyleInHeader + } + } + } + return oauth2.AuthStyleAutoDetect +} diff --git a/auth/extauth/client_credentials_test.go b/auth/extauth/client_credentials_test.go new file mode 100644 index 0000000..b2973de --- /dev/null +++ b/auth/extauth/client_credentials_test.go @@ -0,0 +1,420 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package extauth + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/internal/oauthtest" + "github.com/modelcontextprotocol/go-sdk/oauthex" + "golang.org/x/oauth2" +) + +func validClientCredentialsConfig() *ClientCredentialsHandlerConfig { + return &ClientCredentialsHandlerConfig{ + Credentials: &oauthex.ClientCredentials{ + ClientID: "test-client", + ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test-secret"}, + }, + } +} + +func TestNewClientCredentialsHandler_Validation(t *testing.T) { + tests := []struct { + name string + config *ClientCredentialsHandlerConfig + wantError string + }{ + { + name: "nil config", + config: nil, + wantError: "config must be provided", + }, + { + name: "nil credentials", + config: func() *ClientCredentialsHandlerConfig { + c := validClientCredentialsConfig() + c.Credentials = nil + return c + }(), + wantError: "credentials are required", + }, + { + name: "missing client ID", + config: func() *ClientCredentialsHandlerConfig { + c := validClientCredentialsConfig() + c.Credentials.ClientID = "" + return c + }(), + wantError: "ClientID is required", + }, + { + name: "missing client secret auth", + config: func() *ClientCredentialsHandlerConfig { + c := validClientCredentialsConfig() + c.Credentials.ClientSecretAuth = nil + return c + }(), + wantError: "clientSecretAuth is required", + }, + { + name: "empty client secret", + config: func() *ClientCredentialsHandlerConfig { + c := validClientCredentialsConfig() + c.Credentials.ClientSecretAuth.ClientSecret = "" + return c + }(), + wantError: "ClientSecret is required", + }, + { + name: "valid config", + config: validClientCredentialsConfig(), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := NewClientCredentialsHandler(tc.config) + if tc.wantError != "" { + if err == nil { + t.Errorf("expected error containing %q, got nil", tc.wantError) + } else if !strings.Contains(err.Error(), tc.wantError) { + t.Errorf("error %q does not contain %q", err.Error(), tc.wantError) + } + } else if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestClientCredentialsHandler_Authorize(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{ + ServeOAuthInsertedEndpoint: true, + }, + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test-client": {Secret: "test-secret"}, + }, + }, + ClientCredentialsConfig: &oauthtest.ClientCredentialsConfig{ + Enabled: true, + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + ScopesSupported: []string{"mcp:read", "mcp:write"}, + })) + + t.Run("successful authorization", func(t *testing.T) { + handler, err := NewClientCredentialsHandler(validClientCredentialsConfig()) + if err != nil { + t.Fatal(err) + } + + // TokenSource should be nil before Authorize. + ts, err := handler.TokenSource(t.Context()) + if err != nil { + t.Fatal(err) + } + if ts != nil { + t.Fatal("expected nil TokenSource before Authorize") + } + + // Simulate a 401 response from the MCP server. + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{}, + Body: http.NoBody, + } + req := httptest.NewRequest("GET", resourceURL, nil) + if err := handler.Authorize(t.Context(), req, resp); err != nil { + t.Fatalf("Authorize failed: %v", err) + } + + // TokenSource should now return a valid token. + ts, err = handler.TokenSource(t.Context()) + if err != nil { + t.Fatal(err) + } + if ts == nil { + t.Fatal("expected non-nil TokenSource after Authorize") + } + tok, err := ts.Token() + if err != nil { + t.Fatalf("Token() failed: %v", err) + } + if tok.AccessToken != "test_access_token" { + t.Errorf("got access token %q, want %q", tok.AccessToken, "test_access_token") + } + }) + + t.Run("bad credentials", func(t *testing.T) { + config := validClientCredentialsConfig() + config.Credentials.ClientSecretAuth.ClientSecret = "wrong-secret" + handler, err := NewClientCredentialsHandler(config) + if err != nil { + t.Fatal(err) + } + + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{}, + Body: http.NoBody, + } + req := httptest.NewRequest("GET", resourceURL, nil) + if err := handler.Authorize(t.Context(), req, resp); err == nil { + t.Error("expected Authorize to fail with bad credentials") + } + + // TokenSource should still be nil after failed Authorize. + ts, err := handler.TokenSource(t.Context()) + if err != nil { + t.Fatal(err) + } + if ts != nil { + t.Error("expected nil TokenSource after failed Authorize") + } + }) + + t.Run("scopes from WWW-Authenticate challenge", func(t *testing.T) { + handler, err := NewClientCredentialsHandler(validClientCredentialsConfig()) + if err != nil { + t.Fatal(err) + } + + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{ + "WWW-Authenticate": []string{`Bearer scope="read write"`}, + }, + Body: http.NoBody, + } + req := httptest.NewRequest("GET", resourceURL, nil) + if err := handler.Authorize(t.Context(), req, resp); err != nil { + t.Fatalf("Authorize failed: %v", err) + } + + ts, err := handler.TokenSource(t.Context()) + if err != nil { + t.Fatal(err) + } + if ts == nil { + t.Fatal("expected non-nil TokenSource") + } + }) + + t.Run("issuer mismatch", func(t *testing.T) { + config := validClientCredentialsConfig() + config.Credentials.Issuer = "https://other-issuer.example.com" + handler, err := NewClientCredentialsHandler(config) + if err != nil { + t.Fatal(err) + } + + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{}, + Body: http.NoBody, + } + req := httptest.NewRequest("GET", resourceURL, nil) + err = handler.Authorize(t.Context(), req, resp) + if err == nil { + t.Fatal("expected Authorize to fail with issuer mismatch") + } + if !strings.Contains(err.Error(), "does not match") { + t.Errorf("error %q does not mention issuer mismatch", err.Error()) + } + }) + + t.Run("issuer match ignoring trailing slash", func(t *testing.T) { + config := validClientCredentialsConfig() + // authServer.URL() has no trailing slash; configure with one to + // verify the comparison tolerates the difference (per RFC 8414 §3.3 + // normalization applied in oauthex.IssuersEqual). + config.Credentials.Issuer = authServer.URL() + "/" + handler, err := NewClientCredentialsHandler(config) + if err != nil { + t.Fatal(err) + } + + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{}, + Body: http.NoBody, + } + req := httptest.NewRequest("GET", resourceURL, nil) + if err := handler.Authorize(t.Context(), req, resp); err != nil { + t.Fatalf("Authorize() unexpected error = %v", err) + } + }) + + t.Run("PRM via resource_metadata in challenge", func(t *testing.T) { + prmMux := http.NewServeMux() + prmMux.Handle("/custom-prm", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + prmServer := httptest.NewServer(prmMux) + t.Cleanup(prmServer.Close) + + handler, err := NewClientCredentialsHandler(validClientCredentialsConfig()) + if err != nil { + t.Fatal(err) + } + + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{ + "WWW-Authenticate": []string{`Bearer resource_metadata="` + prmServer.URL + `/custom-prm"`}, + }, + Body: http.NoBody, + } + req := httptest.NewRequest("GET", resourceURL, nil) + if err := handler.Authorize(t.Context(), req, resp); err != nil { + t.Fatalf("Authorize with resource_metadata challenge failed: %v", err) + } + + ts, err := handler.TokenSource(t.Context()) + if err != nil { + t.Fatal(err) + } + if ts == nil { + t.Fatal("expected non-nil TokenSource") + } + }) +} + +func TestClientCredentialsHandler_ScopeAccumulation(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{ + ServeOAuthInsertedEndpoint: true, + }, + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test-client": {Secret: "test-secret"}, + }, + }, + ClientCredentialsConfig: &oauthtest.ClientCredentialsConfig{ + Enabled: true, + }, + TokenScopeFunc: func(requestedScope string) string { + // Simulate a server that never grants "write". + var granted []string + for _, s := range strings.Fields(requestedScope) { + if s != "write" { + granted = append(granted, s) + } + } + return strings.Join(granted, " ") + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + + handler, err := NewClientCredentialsHandler(validClientCredentialsConfig()) + if err != nil { + t.Fatal(err) + } + + // First authorization: 401 with scope="read write". + // The token response will only grant "read" (TokenScopeFunc strips "write"). + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + } + resp.Header.Set("WWW-Authenticate", `Bearer scope="read write"`) + req := httptest.NewRequest("GET", resourceURL, nil) + if err := handler.Authorize(t.Context(), req, resp); err != nil { + t.Fatalf("First Authorize failed: %v", err) + } + + // Verify only "read" was granted (the token omitted "write"). + issuer := authServer.URL() + if diff := cmp.Diff([]string{"read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("After first Authorize, grantedScopes mismatch (-want +got):\n%s", diff) + } + + // Second authorization: 401 with scope="admin" (simulating step-up). + // Accumulated scopes should be "read" (previously granted) + "admin" (new), + // but NOT "write" (requested but never granted). + resp2 := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + } + resp2.Header.Set("WWW-Authenticate", `Bearer scope="admin"`) + req2 := httptest.NewRequest("GET", resourceURL, nil) + if err := handler.Authorize(t.Context(), req2, resp2); err != nil { + t.Fatalf("Second Authorize failed: %v", err) + } + + // Verify accumulated scopes: "read" (granted) + "admin" (new), no "write". + if diff := cmp.Diff([]string{"admin", "read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("After second Authorize, grantedScopes mismatch (-want +got):\n%s", diff) + } +} + +func TestSelectTokenAuthMethod(t *testing.T) { + tests := []struct { + name string + supported []string + want oauth2.AuthStyle + }{ + { + name: "prefers client_secret_post", + supported: []string{"client_secret_basic", "client_secret_post"}, + want: oauth2.AuthStyleInParams, + }, + { + name: "falls back to client_secret_basic", + supported: []string{"client_secret_basic"}, + want: oauth2.AuthStyleInHeader, + }, + { + name: "auto detect when none supported", + supported: []string{"private_key_jwt"}, + want: oauth2.AuthStyleAutoDetect, + }, + { + name: "auto detect when empty", + supported: nil, + want: oauth2.AuthStyleAutoDetect, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := selectTokenAuthMethod(tc.supported) + if got != tc.want { + t.Errorf("selectTokenAuthMethod(%v) = %v, want %v", tc.supported, got, tc.want) + } + }) + } +} diff --git a/auth/extauth/enterprise_handler_test.go b/auth/extauth/enterprise_handler_test.go index c19b5a2..86f0414 100644 --- a/auth/extauth/enterprise_handler_test.go +++ b/auth/extauth/enterprise_handler_test.go @@ -1,43 +1,37 @@ // Copyright 2026 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. package extauth import ( "context" - "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" + "github.com/modelcontextprotocol/go-sdk/internal/oauthtest" "github.com/modelcontextprotocol/go-sdk/oauthex" "golang.org/x/oauth2" ) -// TestNewEnterpriseHandler_Validation tests validation in NewEnterpriseHandler. -func TestNewEnterpriseHandler_Validation(t *testing.T) { - validConfig := &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ - ClientID: "idp_client_id", - }, +func validEnterpriseHandlerConfig() *EnterpriseHandlerConfig { + return &EnterpriseHandlerConfig{ + IdPIssuerURL: "https://idp.example.com", + IdPCredentials: &oauthex.ClientCredentials{ClientID: "idp_client_id"}, MCPAuthServerURL: "https://mcp-auth.example.com", MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ - ClientID: "mcp_client_id", - }, + MCPCredentials: &oauthex.ClientCredentials{ClientID: "mcp_client_id"}, IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { - token := &oauth2.Token{ - AccessToken: "mock_access_token", - TokenType: "Bearer", - } + token := &oauth2.Token{AccessToken: "mock", TokenType: "Bearer"} return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil }, } +} +func TestNewEnterpriseHandler_Validation(t *testing.T) { tests := []struct { name string config *EnterpriseHandlerConfig @@ -50,151 +44,101 @@ func TestNewEnterpriseHandler_Validation(t *testing.T) { }, { name: "missing IdPIssuerURL", - config: &EnterpriseHandlerConfig{ - IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.IdPIssuerURL = "" + return c + }(), wantError: "IdPIssuerURL is required", }, { name: "nil IdPCredentials", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: nil, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.IdPCredentials = nil + return c + }(), wantError: "IdPCredentials is required", }, { name: "invalid IdPCredentials - empty ClientID", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ - ClientID: "", // Invalid - empty - }, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.IdPCredentials = &oauthex.ClientCredentials{ClientID: ""} + return c + }(), wantError: "invalid IdPCredentials", }, { name: "invalid IdPCredentials - empty ClientSecret in ClientSecretAuth", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ - ClientID: "idp_client_id", - ClientSecretAuth: &oauthex.ClientSecretAuth{ - ClientSecret: "", // Invalid - empty secret when ClientSecretAuth is set - }, - }, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.IdPCredentials = &oauthex.ClientCredentials{ + ClientID: "idp_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: ""}, + } + return c + }(), wantError: "invalid IdPCredentials", }, { name: "missing MCPAuthServerURL", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - MCPAuthServerURL: "", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.MCPAuthServerURL = "" + return c + }(), wantError: "MCPAuthServerURL is required", }, { name: "missing MCPResourceURI", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "", - MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.MCPResourceURI = "" + return c + }(), wantError: "MCPResourceURI is required", }, { name: "nil MCPCredentials", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: nil, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.MCPCredentials = nil + return c + }(), wantError: "MCPCredentials is required", }, { name: "invalid MCPCredentials - empty ClientID", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ - ClientID: "", // Invalid - empty - }, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.MCPCredentials = &oauthex.ClientCredentials{ClientID: ""} + return c + }(), wantError: "invalid MCPCredentials", }, { name: "missing IDTokenFetcher", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - IDTokenFetcher: nil, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.IDTokenFetcher = nil + return c + }(), wantError: "IDTokenFetcher is required", }, { - name: "valid config - public clients (no ClientSecretAuth)", - config: validConfig, + name: "valid public clients", + config: validEnterpriseHandlerConfig(), wantError: "", }, { - name: "valid config - confidential clients (with ClientSecretAuth)", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ - ClientID: "idp_client_id", - ClientSecretAuth: &oauthex.ClientSecretAuth{ - ClientSecret: "idp_secret", - }, - }, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ - ClientID: "mcp_client_id", - ClientSecretAuth: &oauthex.ClientSecretAuth{ - ClientSecret: "mcp_secret", - }, - }, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { - token := &oauth2.Token{ - AccessToken: "mock_access_token", - TokenType: "Bearer", - } - return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil - }, - }, + name: "valid confidential clients", + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.IdPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "idp_secret"} + c.MCPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "mcp_secret"} + return c + }(), wantError: "", }, } @@ -221,45 +165,47 @@ func TestNewEnterpriseHandler_Validation(t *testing.T) { } } -// TestEnterpriseHandler_Authorize_E2E tests the complete enterprise authorization flow. func TestEnterpriseHandler_Authorize_E2E(t *testing.T) { - // Set up IdP (Identity Provider) fake server with token exchange support - idpServer := setupIdPServer(t) + const idJAGToken = "id-jag-token-from-idp" - // Set up MCP authorization server with JWT bearer grant support - mcpAuthServer := setupMCPAuthServer(t) - - // Create enterprise handler - handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{ - IdPIssuerURL: idpServer.URL, - IdPCredentials: &oauthex.ClientCredentials{ - ClientID: "idp_client_id", - ClientSecretAuth: &oauthex.ClientSecretAuth{ - ClientSecret: "idp_secret", - }, + idpServer := oauthtest.NewFakeIdPServer(oauthtest.IdPConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "idp_client_id": {Secret: "idp_secret"}, }, - MCPAuthServerURL: mcpAuthServer.URL, - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ - ClientID: "mcp_client_id", - ClientSecretAuth: &oauthex.ClientSecretAuth{ - ClientSecret: "mcp_secret", - }, - }, - MCPScopes: []string{"read", "write"}, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { - token := &oauth2.Token{ - AccessToken: "mock_access_token", - TokenType: "Bearer", - } - return token.WithExtra(map[string]any{"id_token": "mock_id_token_from_user_login"}), nil + TokenExchangeConfig: &oauthtest.TokenExchangeConfig{ + IDJAGToken: idJAGToken, }, }) + idpServer.Start(t) + + mcpAuthServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "mcp_client_id": {Secret: "mcp_secret"}, + }, + }, + JWTBearerConfig: &oauthtest.JWTBearerConfig{ + ValidAssertions: []string{idJAGToken}, + }, + }) + mcpAuthServer.Start(t) + + config := validEnterpriseHandlerConfig() + config.IdPIssuerURL = idpServer.URL() + config.IdPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "idp_secret"} + config.MCPAuthServerURL = mcpAuthServer.URL() + config.MCPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "mcp_secret"} + config.MCPScopes = []string{"read", "write"} + config.IDTokenFetcher = func(ctx context.Context) (*oauth2.Token, error) { + token := &oauth2.Token{AccessToken: "mock_access_token", TokenType: "Bearer"} + return token.WithExtra(map[string]any{"id_token": "mock_id_token_from_user_login"}), nil + } + + handler, err := NewEnterpriseHandler(config) if err != nil { t.Fatalf("NewEnterpriseHandler failed: %v", err) } - // Simulate a 401 response from MCP server req := httptest.NewRequest(http.MethodGet, "https://mcp.example.com/api", nil) resp := &http.Response{ StatusCode: http.StatusUnauthorized, @@ -268,12 +214,10 @@ func TestEnterpriseHandler_Authorize_E2E(t *testing.T) { Request: req, } - // Perform authorization if err := handler.Authorize(context.Background(), req, resp); err != nil { t.Fatalf("Authorize failed: %v", err) } - // Verify token source is set tokenSource, err := handler.TokenSource(context.Background()) if err != nil { t.Fatalf("TokenSource failed: %v", err) @@ -282,164 +226,22 @@ func TestEnterpriseHandler_Authorize_E2E(t *testing.T) { t.Fatal("expected token source to be set after authorization") } - // Verify we can get a token token, err := tokenSource.Token() if err != nil { t.Fatalf("Token() failed: %v", err) } - if token.AccessToken != "mcp_access_token_from_jwt_bearer" { - t.Errorf("unexpected access token: got %q, want %q", - token.AccessToken, "mcp_access_token_from_jwt_bearer") + if token.AccessToken != "test_access_token" { + t.Errorf("AccessToken = %q, want %q", token.AccessToken, "test_access_token") } } -// setupIdPServer creates a fake IdP server that supports token exchange. -func setupIdPServer(t *testing.T) *httptest.Server { - t.Helper() - mux := http.NewServeMux() - - var server *httptest.Server - - // OAuth/OIDC metadata endpoint - uses closure to get server URL - mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "issuer": server.URL, - "token_endpoint": server.URL + "/token", - "authorization_endpoint": server.URL + "/authorize", - "code_challenge_methods_supported": []string{"S256"}, - }) - }) - - // Token endpoint - supports token exchange (RFC 8693) - mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, "failed to parse form", http.StatusBadRequest) - return - } - - grantType := r.Form.Get("grant_type") - if grantType != oauthex.GrantTypeTokenExchange { - http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest) - return - } - - // Verify client authentication - clientID := r.Form.Get("client_id") - clientSecret := r.Form.Get("client_secret") - if clientID != "idp_client_id" || clientSecret != "idp_secret" { - http.Error(w, "invalid client credentials", http.StatusUnauthorized) - return - } - - // Verify token exchange parameters - if r.Form.Get("requested_token_type") != oauthex.TokenTypeIDJAG { - http.Error(w, "invalid requested_token_type", http.StatusBadRequest) - return - } - if r.Form.Get("subject_token_type") != oauthex.TokenTypeIDToken { - http.Error(w, "invalid subject_token_type", http.StatusBadRequest) - return - } - if r.Form.Get("subject_token") == "" { - http.Error(w, "missing subject_token", http.StatusBadRequest) - return - } - - // Return ID-JAG (Identity Assertion JWT Authorization Grant) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "access_token": "id-jag-token-from-idp", - "issued_token_type": oauthex.TokenTypeIDJAG, - "token_type": "N_A", - "expires_in": 300, - "scope": "read write", - }) - }) - - server = httptest.NewServer(mux) - t.Cleanup(server.Close) - - return server -} - -// setupMCPAuthServer creates a fake MCP authorization server that supports JWT bearer grant. -func setupMCPAuthServer(t *testing.T) *httptest.Server { - t.Helper() - mux := http.NewServeMux() - - var server *httptest.Server - - // OAuth metadata endpoint - uses closure to get server URL - mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "issuer": server.URL, - "token_endpoint": server.URL + "/token", - "code_challenge_methods_supported": []string{"S256"}, - }) - }) - - // Token endpoint - supports JWT bearer grant (RFC 7523) - mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, "failed to parse form", http.StatusBadRequest) - return - } - - grantType := r.Form.Get("grant_type") - if grantType != "urn:ietf:params:oauth:grant-type:jwt-bearer" { - http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest) - return - } - - // Verify client authentication - clientID := r.Form.Get("client_id") - clientSecret := r.Form.Get("client_secret") - if clientID != "mcp_client_id" || clientSecret != "mcp_secret" { - http.Error(w, "invalid client credentials", http.StatusUnauthorized) - return - } - - // Verify assertion (ID-JAG) - assertion := r.Form.Get("assertion") - if assertion != "id-jag-token-from-idp" { - http.Error(w, "invalid assertion", http.StatusBadRequest) - return - } - - // Return access token - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "access_token": "mcp_access_token_from_jwt_bearer", - "token_type": "Bearer", - "expires_in": 3600, - "scope": "read write", - }) - }) - - server = httptest.NewServer(mux) - t.Cleanup(server.Close) - - return server -} - -// TestEnterpriseHandler_Authorize_IDTokenFetcherError tests error handling when IDTokenFetcher fails. func TestEnterpriseHandler_Authorize_IDTokenFetcherError(t *testing.T) { - handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ - ClientID: "idp_client_id", - }, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ - ClientID: "mcp_client_id", - }, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { - return nil, fmt.Errorf("user cancelled login") - }, - }) + config := validEnterpriseHandlerConfig() + config.IDTokenFetcher = func(ctx context.Context) (*oauth2.Token, error) { + return nil, fmt.Errorf("user cancelled login") + } + + handler, err := NewEnterpriseHandler(config) if err != nil { t.Fatalf("NewEnterpriseHandler failed: %v", err) } @@ -457,30 +259,12 @@ func TestEnterpriseHandler_Authorize_IDTokenFetcherError(t *testing.T) { t.Fatal("expected error from Authorize, got nil") } if !strings.Contains(err.Error(), "failed to obtain ID token") { - t.Errorf("expected error about ID token, got: %v", err) + t.Errorf("error = %v, want error containing %q", err, "failed to obtain ID token") } } -// TestEnterpriseHandler_TokenSource_BeforeAuthorization tests TokenSource before authorization. func TestEnterpriseHandler_TokenSource_BeforeAuthorization(t *testing.T) { - handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ - ClientID: "idp_client_id", - }, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ - ClientID: "mcp_client_id", - }, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { - token := &oauth2.Token{ - AccessToken: "mock_access_token", - TokenType: "Bearer", - } - return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil - }, - }) + handler, err := NewEnterpriseHandler(validEnterpriseHandlerConfig()) if err != nil { t.Fatalf("NewEnterpriseHandler failed: %v", err) } diff --git a/auth/extauth/oidc_login_test.go b/auth/extauth/oidc_login_test.go index eb5a3eb..9cd9b93 100644 --- a/auth/extauth/oidc_login_test.go +++ b/auth/extauth/oidc_login_test.go @@ -1,468 +1,152 @@ // Copyright 2026 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. package extauth import ( "context" - "encoding/base64" - "encoding/json" "fmt" "net/http" - "net/http/httptest" "net/url" "strings" "testing" - "time" "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/internal/oauthtest" "github.com/modelcontextprotocol/go-sdk/oauthex" + "golang.org/x/oauth2" ) -// TestInitiateOIDCLogin tests the OIDC authorization request generation. -func TestInitiateOIDCLogin(t *testing.T) { - // Create mock IdP server - idpServer := createMockOIDCServer(t) - defer idpServer.Close() - config := &OIDCLoginConfig{ - IssuerURL: idpServer.URL, - Credentials: &oauthex.ClientCredentials{ - ClientID: "test-client", - }, - RedirectURL: "http://localhost:8080/callback", - Scopes: []string{"openid", "profile", "email"}, - HTTPClient: idpServer.Client(), - } - t.Run("successful initiation", func(t *testing.T) { - authReq, _, err := initiateOIDCLogin(context.Background(), config) - if err != nil { - t.Fatalf("initiateOIDCLogin failed: %v", err) - } - // Validate authURL - if authReq.authURL == "" { - t.Error("authURL is empty") - } - // Parse and validate URL parameters - u, err := url.Parse(authReq.authURL) - if err != nil { - t.Fatalf("Failed to parse authURL: %v", err) - } - q := u.Query() - if q.Get("response_type") != "code" { - t.Errorf("expected response_type 'code', got '%s'", q.Get("response_type")) - } - if q.Get("client_id") != "test-client" { - t.Errorf("expected client_id 'test-client', got '%s'", q.Get("client_id")) - } - if q.Get("redirect_uri") != "http://localhost:8080/callback" { - t.Errorf("expected redirect_uri 'http://localhost:8080/callback', got '%s'", q.Get("redirect_uri")) - } - if q.Get("scope") != "openid profile email" { - t.Errorf("expected scope 'openid profile email', got '%s'", q.Get("scope")) - } - if q.Get("code_challenge_method") != "S256" { - t.Errorf("expected code_challenge_method 'S256', got '%s'", q.Get("code_challenge_method")) - } - // Validate state is generated - if authReq.state == "" { - t.Error("state is empty") - } - if q.Get("state") != authReq.state { - t.Errorf("state in URL doesn't match returned state") - } - // Validate PKCE parameters - if authReq.codeVerifier == "" { - t.Error("codeVerifier is empty") - } - if q.Get("code_challenge") == "" { - t.Error("code_challenge is empty") - } - }) - t.Run("with login_hint", func(t *testing.T) { - configWithHint := *config - configWithHint.LoginHint = "user@example.com" - authReq, _, err := initiateOIDCLogin(context.Background(), &configWithHint) - if err != nil { - t.Fatalf("initiateOIDCLogin failed: %v", err) - } - u, err := url.Parse(authReq.authURL) - if err != nil { - t.Fatalf("Failed to parse authURL: %v", err) - } - q := u.Query() - if q.Get("login_hint") != "user@example.com" { - t.Errorf("expected login_hint 'user@example.com', got '%s'", q.Get("login_hint")) - } - }) - t.Run("without login_hint", func(t *testing.T) { - authReq, _, err := initiateOIDCLogin(context.Background(), config) - if err != nil { - t.Fatalf("initiateOIDCLogin failed: %v", err) - } - u, err := url.Parse(authReq.authURL) - if err != nil { - t.Fatalf("Failed to parse authURL: %v", err) - } - q := u.Query() - if q.Has("login_hint") { - t.Errorf("expected no login_hint parameter, but got '%s'", q.Get("login_hint")) - } - }) - t.Run("nil config", func(t *testing.T) { - _, _, err := initiateOIDCLogin(context.Background(), nil) - if err == nil { - t.Error("expected error for nil config, got nil") - } - }) - t.Run("missing openid scope", func(t *testing.T) { - badConfig := *config - badConfig.Scopes = []string{"profile", "email"} // Missing "openid" - _, _, err := initiateOIDCLogin(context.Background(), &badConfig) - if err == nil { - t.Error("expected error for missing openid scope, got nil") - } - if !strings.Contains(err.Error(), "openid") { - t.Errorf("expected error about missing 'openid', got: %v", err) - } - }) - t.Run("missing required fields", func(t *testing.T) { - tests := []struct { - name string - mutate func(*OIDCLoginConfig) - expectErr string - }{ - { - name: "missing IssuerURL", - mutate: func(c *OIDCLoginConfig) { c.IssuerURL = "" }, - expectErr: "IssuerURL is required", - }, - { - name: "missing ClientID", - mutate: func(c *OIDCLoginConfig) { c.Credentials.ClientID = "" }, - expectErr: "ClientID is required", - }, - { - name: "missing RedirectURL", - mutate: func(c *OIDCLoginConfig) { - c.RedirectURL = "" - // Ensure ClientID is present to test RedirectURL validation - c.Credentials = &oauthex.ClientCredentials{ClientID: "test"} - }, - expectErr: "RedirectURL is required", - }, - { - name: "missing Scopes", - mutate: func(c *OIDCLoginConfig) { - c.Scopes = nil - // Ensure required fields are present to test Scopes validation - c.Credentials = &oauthex.ClientCredentials{ClientID: "test"} - c.RedirectURL = "http://localhost:8080/callback" - }, - expectErr: "at least one scope is required", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - badConfig := *config - tt.mutate(&badConfig) - _, _, err := initiateOIDCLogin(context.Background(), &badConfig) - if err == nil { - t.Error("expected error, got nil") - } - if !strings.Contains(err.Error(), tt.expectErr) { - t.Errorf("expected error containing '%s', got: %v", tt.expectErr, err) - } - }) - } - }) -} +const testRedirectURL = "http://localhost:18927/callback" -// TestCompleteOIDCLogin tests the authorization code exchange. -func TestCompleteOIDCLogin(t *testing.T) { - // Create mock IdP server - idpServer := createMockOIDCServerWithToken(t) - defer idpServer.Close() - config := &OIDCLoginConfig{ - IssuerURL: idpServer.URL, +func validOIDCLoginConfig(issuerURL string) *OIDCLoginConfig { + return &OIDCLoginConfig{ + IssuerURL: issuerURL, Credentials: &oauthex.ClientCredentials{ ClientID: "test-client", ClientSecretAuth: &oauthex.ClientSecretAuth{ ClientSecret: "test-secret", }, }, - RedirectURL: "http://localhost:8080/callback", + RedirectURL: testRedirectURL, Scopes: []string{"openid", "profile", "email"}, - HTTPClient: idpServer.Client(), } - t.Run("successful code exchange", func(t *testing.T) { - // First initiate to get oauth2Config - _, oauth2Config, err := initiateOIDCLogin(context.Background(), config) - if err != nil { - t.Fatalf("initiateOIDCLogin failed: %v", err) - } +} - token, err := completeOIDCLogin( - context.Background(), - config, - oauth2Config, - "test-auth-code", - "test-code-verifier", - ) - if err != nil { - t.Fatalf("completeOIDCLogin failed: %v", err) +func TestPerformOIDCLogin(t *testing.T) { + idpServer := oauthtest.NewFakeIdPServer(oauthtest.IdPConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test-client": { + Secret: "test-secret", + RedirectURIs: []string{testRedirectURL}, + }, + }, + }) + idpServer.Start(t) + + config := validOIDCLoginConfig(idpServer.URL()) + + // fetchAuthCode visits the authorization URL on the fake IdP server, + // follows the redirect, and extracts the authorization code and state. + fetchAuthCode := func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, } - // Validate tokens + resp, err := client.Get(args.URL) + if err != nil { + return nil, fmt.Errorf("failed to visit auth URL: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusFound { + return nil, fmt.Errorf("expected redirect, got status %d", resp.StatusCode) + } + loc, err := resp.Location() + if err != nil { + return nil, fmt.Errorf("missing Location header: %w", err) + } + return &auth.AuthorizationResult{ + Code: loc.Query().Get("code"), + State: loc.Query().Get("state"), + }, nil + } + + verifyOIDCToken := func(token *oauth2.Token) error { idToken, ok := token.Extra("id_token").(string) if !ok || idToken == "" { - t.Error("id_token is missing or empty") + return fmt.Errorf("id_token is missing or empty") + } + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + return fmt.Errorf("id_token is not a JWT (expected 3 parts, got %d)", len(parts)) } if token.AccessToken == "" { - t.Error("AccessToken is empty") + return fmt.Errorf("access token is empty") } if token.TokenType != "Bearer" { - t.Errorf("expected TokenType 'Bearer', got '%s'", token.TokenType) + return fmt.Errorf("token type = %q, want %q", token.TokenType, "Bearer") } - if token.Expiry.IsZero() { - t.Error("Expiry is zero") - } - }) - t.Run("missing parameters", func(t *testing.T) { - _, oauth2Config, _ := initiateOIDCLogin(context.Background(), config) - - tests := []struct { - name string - authCode string - codeVerifier string - expectErr string - }{ - { - name: "missing authCode", - authCode: "", - codeVerifier: "test-verifier", - expectErr: "authCode is required", - }, - { - name: "missing codeVerifier", - authCode: "test-code", - codeVerifier: "", - expectErr: "codeVerifier is required", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := completeOIDCLogin( - context.Background(), - config, - oauth2Config, - tt.authCode, - tt.codeVerifier, - ) - if err == nil { - t.Error("expected error, got nil") - } - if !strings.Contains(err.Error(), tt.expectErr) { - t.Errorf("expected error containing '%s', got: %v", tt.expectErr, err) - } - }) - } - }) -} - -// TestOIDCLoginE2E tests the complete OIDC login flow end-to-end. -func TestOIDCLoginE2E(t *testing.T) { - // Create mock IdP server - idpServer := createMockOIDCServerWithToken(t) - defer idpServer.Close() - config := &OIDCLoginConfig{ - IssuerURL: idpServer.URL, - Credentials: &oauthex.ClientCredentials{ - ClientID: "test-client", - ClientSecretAuth: &oauthex.ClientSecretAuth{ - ClientSecret: "test-secret", - }, - }, - RedirectURL: "http://localhost:8080/callback", - Scopes: []string{"openid", "profile", "email"}, - HTTPClient: idpServer.Client(), - } - // Step 1: Initiate login - authReq, oauth2Config, err := initiateOIDCLogin(context.Background(), config) - if err != nil { - t.Fatalf("initiateOIDCLogin failed: %v", err) - } - // Step 2: Simulate user authentication and redirect - // (In real flow, user would visit authReq.authURL and IdP would redirect back) - // Here we just use a mock authorization code - mockAuthCode := "mock-authorization-code" - // Step 3: Complete login with authorization code - token, err := completeOIDCLogin( - context.Background(), - config, - oauth2Config, - mockAuthCode, - authReq.codeVerifier, - ) - if err != nil { - t.Fatalf("completeOIDCLogin failed: %v", err) - } - // Validate we got an ID token - idToken, ok := token.Extra("id_token").(string) - if !ok || idToken == "" { - t.Error("Expected ID token, got empty or missing") - } - // Validate ID token is a JWT (has 3 parts) - parts := strings.Split(idToken, ".") - if len(parts) != 3 { - t.Errorf("Expected JWT with 3 parts, got %d parts", len(parts)) - } -} - -// createMockOIDCServer creates a mock OIDC server for testing initiateOIDCLogin. -func createMockOIDCServer(t *testing.T) *httptest.Server { - var serverURL string - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Handle OIDC discovery - if r.URL.Path == "/.well-known/openid-configuration" { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "issuer": serverURL, - "authorization_endpoint": serverURL + "/authorize", - "token_endpoint": serverURL + "/token", - "jwks_uri": serverURL + "/.well-known/jwks.json", - "response_types_supported": []string{"code"}, - "code_challenge_methods_supported": []string{"S256"}, - "grant_types_supported": []string{"authorization_code"}, - }) - return - } - http.NotFound(w, r) - })) - serverURL = server.URL - return server -} - -// createMockOIDCServerWithToken creates a mock OIDC server that also handles token exchange. -func createMockOIDCServerWithToken(t *testing.T) *httptest.Server { - var serverURL string - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Handle OIDC discovery - if r.URL.Path == "/.well-known/openid-configuration" { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "issuer": serverURL, - "authorization_endpoint": serverURL + "/authorize", - "token_endpoint": serverURL + "/token", - "jwks_uri": serverURL + "/.well-known/jwks.json", - "response_types_supported": []string{"code"}, - "code_challenge_methods_supported": []string{"S256"}, - "grant_types_supported": []string{"authorization_code"}, - }) - return - } - // Handle token endpoint - if r.URL.Path == "/token" { - if err := r.ParseForm(); err != nil { - http.Error(w, "failed to parse form", http.StatusBadRequest) - return - } - // Validate grant type - if r.FormValue("grant_type") != "authorization_code" { - http.Error(w, "invalid grant_type", http.StatusBadRequest) - return - } - // Create mock ID token (JWT) - now := time.Now().Unix() - idToken := fmt.Sprintf("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.%s.mock-signature", - base64EncodeClaims(map[string]any{ - "iss": serverURL, - "sub": "test-user", - "aud": "test-client", - "exp": now + 3600, - "iat": now, - "email": "test@example.com", - })) - // Return token response - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "access_token": "mock-access-token", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "mock-refresh-token", - "id_token": idToken, - }) - return - } - http.NotFound(w, r) - })) - serverURL = server.URL - return server -} - -// base64EncodeClaims encodes JWT claims for testing. -func base64EncodeClaims(claims map[string]any) string { - claimsJSON, _ := json.Marshal(claims) - return base64.RawURLEncoding.EncodeToString(claimsJSON) -} - -// TestPerformOIDCLogin tests the combined OIDC login flow with callback. -func TestPerformOIDCLogin(t *testing.T) { - // Create mock IdP server - idpServer := createMockOIDCServerWithToken(t) - defer idpServer.Close() - config := &OIDCLoginConfig{ - IssuerURL: idpServer.URL, - Credentials: &oauthex.ClientCredentials{ - ClientID: "test-client", - ClientSecretAuth: &oauthex.ClientSecretAuth{ - ClientSecret: "test-secret", - }, - }, - RedirectURL: "http://localhost:8080/callback", - Scopes: []string{"openid", "profile", "email"}, - HTTPClient: idpServer.Client(), + return nil } t.Run("successful flow", func(t *testing.T) { - token, err := PerformOIDCLogin(context.Background(), config, + token, err := PerformOIDCLogin(context.Background(), config, fetchAuthCode) + if err != nil { + t.Fatalf("PerformOIDCLogin() error = %v", err) + } + if err := verifyOIDCToken(token); err != nil { + t.Errorf("invalid token returned: %v", err) + } + }) + + t.Run("with login_hint", func(t *testing.T) { + configWithHint := *config + configWithHint.LoginHint = "user@example.com" + + token, err := PerformOIDCLogin(context.Background(), &configWithHint, func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { - // Validate authURL has required parameters u, err := url.Parse(args.URL) if err != nil { return nil, fmt.Errorf("invalid authURL: %w", err) } - q := u.Query() - if q.Get("response_type") != "code" { - return nil, fmt.Errorf("missing response_type") + if got := u.Query().Get("login_hint"); got != "user@example.com" { + t.Errorf("login_hint = %q, want %q", got, "user@example.com") } - if q.Get("state") == "" { - return nil, fmt.Errorf("missing state") - } - - // Simulate successful user authentication - return &auth.AuthorizationResult{ - Code: "mock-auth-code", - State: q.Get("state"), // Return the expected state from URL - }, nil + return fetchAuthCode(ctx, args) }) - if err != nil { - t.Fatalf("PerformOIDCLogin failed: %v", err) + t.Fatalf("PerformOIDCLogin() error = %v", err) } + if err := verifyOIDCToken(token); err != nil { + t.Errorf("invalid token returned: %v", err) + } + }) - idToken, ok := token.Extra("id_token").(string) - if !ok || idToken == "" { - t.Error("id_token is missing or empty") + t.Run("without login_hint", func(t *testing.T) { + token, err := PerformOIDCLogin(context.Background(), config, + func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + u, err := url.Parse(args.URL) + if err != nil { + return nil, fmt.Errorf("invalid authURL: %w", err) + } + if u.Query().Has("login_hint") { + t.Errorf("login_hint should be absent, got %q", u.Query().Get("login_hint")) + } + return fetchAuthCode(ctx, args) + }) + if err != nil { + t.Fatalf("PerformOIDCLogin() error = %v", err) } - if token.AccessToken == "" { - t.Error("AccessToken is empty") + if err := verifyOIDCToken(token); err != nil { + t.Errorf("invalid token returned: %v", err) } }) t.Run("state mismatch", func(t *testing.T) { _, err := PerformOIDCLogin(context.Background(), config, func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { - // Return wrong state to simulate CSRF attack return &auth.AuthorizationResult{ Code: "mock-auth-code", State: "wrong-state", @@ -470,10 +154,10 @@ func TestPerformOIDCLogin(t *testing.T) { }) if err == nil { - t.Error("expected error for state mismatch, got nil") + t.Fatal("expected error for state mismatch, got nil") } if !strings.Contains(err.Error(), "state mismatch") { - t.Errorf("expected state mismatch error, got: %v", err) + t.Errorf("error = %v, want error containing %q", err, "state mismatch") } }) @@ -484,20 +168,96 @@ func TestPerformOIDCLogin(t *testing.T) { }) if err == nil { - t.Error("expected error, got nil") + t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "user cancelled") { - t.Errorf("expected 'user cancelled' error, got: %v", err) - } - }) - - t.Run("nil fetcher", func(t *testing.T) { - _, err := PerformOIDCLogin(context.Background(), config, nil) - if err == nil { - t.Error("expected error for nil fetcher, got nil") - } - if !strings.Contains(err.Error(), "authCodeFetcher is required") { - t.Errorf("expected 'authCodeFetcher is required' error, got: %v", err) + t.Errorf("error = %v, want error containing %q", err, "user cancelled") } }) } + +func TestPerformOIDCLogin_ValidationErrors(t *testing.T) { + const nonexistentIssuer = "https://idp.example.com" + + noopFetcher := func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + return nil, fmt.Errorf("should not be called") + } + + tests := []struct { + name string + config *OIDCLoginConfig + fetcher auth.AuthorizationCodeFetcher + wantErr string + }{ + { + name: "nil config", + config: nil, + fetcher: noopFetcher, + wantErr: "config is required", + }, + { + name: "nil fetcher", + config: validOIDCLoginConfig(nonexistentIssuer), + fetcher: nil, + wantErr: "authCodeFetcher is required", + }, + { + name: "missing IssuerURL", + config: validOIDCLoginConfig(""), + fetcher: noopFetcher, + wantErr: "IssuerURL is required", + }, + { + name: "missing ClientID", + config: func() *OIDCLoginConfig { + c := validOIDCLoginConfig(nonexistentIssuer) + c.Credentials = &oauthex.ClientCredentials{} + return c + }(), + fetcher: noopFetcher, + wantErr: "ClientID is required", + }, + { + name: "missing RedirectURL", + config: func() *OIDCLoginConfig { + c := validOIDCLoginConfig(nonexistentIssuer) + c.RedirectURL = "" + return c + }(), + fetcher: noopFetcher, + wantErr: "RedirectURL is required", + }, + { + name: "missing Scopes", + config: func() *OIDCLoginConfig { + c := validOIDCLoginConfig(nonexistentIssuer) + c.Scopes = nil + return c + }(), + fetcher: noopFetcher, + wantErr: "at least one scope is required", + }, + { + name: "missing openid scope", + config: func() *OIDCLoginConfig { + c := validOIDCLoginConfig(nonexistentIssuer) + c.Scopes = []string{"profile", "email"} + return c + }(), + fetcher: noopFetcher, + wantErr: "openid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := PerformOIDCLogin(context.Background(), tt.config, tt.fetcher) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error = %v, want error containing %q", err, tt.wantErr) + } + }) + } +} diff --git a/auth/shared.go b/auth/shared.go index 8c5e10c..e58d438 100644 --- a/auth/shared.go +++ b/auth/shared.go @@ -44,7 +44,12 @@ func authorizationServerMetadataURLs(issuerURL string) []string { return nil } - if baseURL.Path == "" { + // RFC 8414, section 3.1 requires any terminating "/" to be removed from the + // issuer identifier before the well-known suffix is inserted, so an issuer + // such as "https://auth.example.com/" has no path component at all. + issuerPath := strings.Trim(baseURL.Path, "/") + + if issuerPath == "" { // "OAuth 2.0 Authorization Server Metadata". baseURL.Path = "/.well-known/oauth-authorization-server" urls = append(urls, baseURL.String()) @@ -54,15 +59,14 @@ func authorizationServerMetadataURLs(issuerURL string) []string { return urls } - originalPath := baseURL.Path // "OAuth 2.0 Authorization Server Metadata with path insertion". - baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/") + baseURL.Path = "/.well-known/oauth-authorization-server/" + issuerPath urls = append(urls, baseURL.String()) // "OpenID Connect Discovery 1.0 with path insertion". - baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/") + baseURL.Path = "/.well-known/openid-configuration/" + issuerPath urls = append(urls, baseURL.String()) // "OpenID Connect Discovery 1.0 with path appending". - baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration" + baseURL.Path = "/" + issuerPath + "/.well-known/openid-configuration" urls = append(urls, baseURL.String()) return urls diff --git a/auth/shared_test.go b/auth/shared_test.go index 16b5054..34e0a35 100644 --- a/auth/shared_test.go +++ b/auth/shared_test.go @@ -6,11 +6,81 @@ package auth import ( "net/http" + "slices" "testing" "github.com/modelcontextprotocol/go-sdk/internal/oauthtest" ) +func TestAuthorizationServerMetadataURLs(t *testing.T) { + tests := []struct { + issuerURL string + want []string + }{ + { + issuerURL: "https://auth.example.com", + want: []string{ + "https://auth.example.com/.well-known/oauth-authorization-server", + "https://auth.example.com/.well-known/openid-configuration", + }, + }, + { + issuerURL: "https://auth.example.com/", + want: []string{ + "https://auth.example.com/.well-known/oauth-authorization-server", + "https://auth.example.com/.well-known/openid-configuration", + }, + }, + { + issuerURL: "https://auth.example.com/tenant1", + want: []string{ + "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", + "https://auth.example.com/.well-known/openid-configuration/tenant1", + "https://auth.example.com/tenant1/.well-known/openid-configuration", + }, + }, + { + issuerURL: "https://auth.example.com/tenant1/", + want: []string{ + "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", + "https://auth.example.com/.well-known/openid-configuration/tenant1", + "https://auth.example.com/tenant1/.well-known/openid-configuration", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.issuerURL, func(t *testing.T) { + if got := authorizationServerMetadataURLs(tt.issuerURL); !slices.Equal(got, tt.want) { + t.Errorf("authorizationServerMetadataURLs(%q) =\n%v\nwant\n%v", tt.issuerURL, got, tt.want) + } + }) + } +} + +// TestGetAuthServerMetadataTrailingSlashIssuer checks that an issuer identifier +// written with a terminating slash still finds metadata served at the root +// well-known path. +func TestGetAuthServerMetadataTrailingSlashIssuer(t *testing.T) { + s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{ + ServeOAuthInsertedEndpoint: true, + }, + }) + s.Start(t) + + got, err := GetAuthServerMetadata(t.Context(), s.URL()+"/", http.DefaultClient) + if err != nil { + t.Fatalf("GetAuthServerMetadata() error = %v, want nil", err) + } + if got == nil { + t.Fatal("GetAuthServerMetadata() got nil, want metadata") + } + if got.Issuer != s.URL() { + t.Errorf("GetAuthServerMetadata() issuer = %q, want %q", got.Issuer, s.URL()) + } +} + func TestGetAuthServerMetadata(t *testing.T) { tests := []struct { name string diff --git a/conformance/baseline.yml b/conformance/baseline.yml index 9d7bef8..4867fe9 100644 --- a/conformance/baseline.yml +++ b/conformance/baseline.yml @@ -1,2 +1,14 @@ -server: [] # All tests pass! -client: [] # All tests pass! +client: + # OAuth mechanisms not implemented by the Go MCP SDK. + - auth/client-credentials-basic + - auth/client-credentials-jwt + - auth/enterprise-managed-authorization + + # SEP-1932 (DPoP Sender-Constrained Tokens) client-side support not yet + # implemented by the Go MCP SDK. + - auth/dpop + - auth/dpop-nonce + + # SEP-1933 (Workload Identity Federation JWT-bearer) client-side support + # not yet implemented by the Go MCP SDK. + - auth/wif-jwt-bearer diff --git a/conformance/everything-client/main.go b/conformance/everything-client/main.go index 947b095..b7d8ba0 100644 --- a/conformance/everything-client/main.go +++ b/conformance/everything-client/main.go @@ -45,27 +45,44 @@ func registerScenario(name string, handler scenarioHandler) { func init() { registerScenario("initialize", runBasicClient) registerScenario("tools_call", runToolsCallClient) + registerScenario("request-metadata", runRequestMetadataClient) registerScenario("elicitation-sep1034-client-defaults", runElicitationDefaultsClient) registerScenario("sse-retry", runSSERetryClient) + registerScenario("json-schema-ref-no-deref", runJSONSchemaRefNoDerefClient) + registerScenario("json-schema-2020-12-preservation", runJSONSchemaPreservationClient) + registerScenario("sep-2322-client-request-state", runMrtrClient) + registerScenario("http-standard-headers", runHTTPStandardHeadersClient) + registerScenario("http-custom-headers", runHTTPCustomHeadersClient) + registerScenario("http-invalid-tool-headers", runHTTPInvalidToolHeadersClient) authScenarios := []string{ "auth/2025-03-26-oauth-metadata-backcompat", "auth/2025-03-26-oauth-endpoint-fallback", + "auth/authorization-server-migration", "auth/basic-cimd", + "auth/iss-normalized", + "auth/iss-not-advertised", + "auth/iss-supported", + "auth/iss-supported-missing", + "auth/iss-unexpected", + "auth/iss-wrong-issuer", "auth/metadata-default", + "auth/metadata-issuer-mismatch", "auth/metadata-var1", "auth/metadata-var2", "auth/metadata-var3", + "auth/offline-access-not-supported", + "auth/offline-access-scope", "auth/pre-registration", "auth/resource-mismatch", - "auth/scope-from-www-authenticate", "auth/scope-from-scopes-supported", + "auth/scope-from-www-authenticate", "auth/scope-omitted-when-undefined", - "auth/scope-step-up", "auth/scope-retry-limit", + "auth/scope-step-up", "auth/token-endpoint-auth-basic", - "auth/token-endpoint-auth-post", "auth/token-endpoint-auth-none", + "auth/token-endpoint-auth-post", } for _, scenario := range authScenarios { registerScenario(scenario, runAuthClient) @@ -232,6 +249,7 @@ func fetchAuthorizationCodeAndState(ctx context.Context, args *auth.Authorizatio return &auth.AuthorizationResult{ Code: locURL.Query().Get("code"), State: locURL.Query().Get("state"), + Iss: locURL.Query().Get("iss"), }, nil } @@ -287,6 +305,238 @@ func runAuthClient(ctx context.Context, serverURL string, configCtx map[string]a return nil } +// ============================================================================ +// request-metadata scenario (SEP-2575) +// ============================================================================ + +// runRequestMetadataClient exercises the SEP-2575 wire-level negotiation: +// every request must carry the MCP-Protocol-Version header and the per-request +// _meta envelope, and the client must retry with a supported version when its +// first choice is rejected with -32022. The Go SDK's Connect() drives +// server/discover unconditionally for 2026-07-28, which is exactly that +// mechanism. +func runRequestMetadataClient(ctx context.Context, serverURL string, _ map[string]any) error { + session, err := connectToServer(ctx, serverURL) + if err != nil { + return err + } + defer session.Close() + return nil +} + +// ============================================================================ +// json-schema-ref-no-deref scenario (SEP-2106) +// ============================================================================ + +// runJSONSchemaRefNoDerefClient asserts that listTools does not dereference +// network $ref URLs in tool schemas. The Go SDK never fetches external refs; +// a plain connect → listTools is sufficient — if the SDK ever regressed and +// tried to GET the canary URL the conformance referee would record the +// failure. +func runJSONSchemaRefNoDerefClient(ctx context.Context, serverURL string, _ map[string]any) error { + session, err := connectToServer(ctx, serverURL) + if err != nil { + return err + } + defer session.Close() + + _, _ = session.ListTools(ctx, nil) + return nil +} + +// ============================================================================ +// json-schema-2020-12-preservation scenario (SEP-1613, SEP-2106) +// ============================================================================ + +// runJSONSchemaPreservationClient echoes the focal tool's inputSchema back to +// the referee verbatim, through the permissive json_schema_echo tool, so that +// it can observe which 2020-12 keywords survived the client's parse of +// tools/list. The Go SDK surfaces a server's schema as the raw JSON object it +// received, so nothing is stripped. +func runJSONSchemaPreservationClient(ctx context.Context, serverURL string, _ map[string]any) error { + session, err := connectToServer(ctx, serverURL) + if err != nil { + return err + } + defer session.Close() + + tools, err := session.ListTools(ctx, nil) + if err != nil { + return fmt.Errorf("session.ListTools(): %v", err) + } + + const focalTool = "json_schema_2020_12_tool" + idx := slices.IndexFunc(tools.Tools, func(t *mcp.Tool) bool { + return t.Name == focalTool + }) + if idx == -1 { + return fmt.Errorf("tool %q not found", focalTool) + } + + const echoTool = "json_schema_echo" + if _, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: echoTool, + Arguments: map[string]any{"schema": tools.Tools[idx].InputSchema}, + }); err != nil { + return fmt.Errorf("session.CallTool(%q): %v", echoTool, err) + } + + return nil +} + +// ============================================================================ +// SEP-2322 multi-round-trip client scenario +// ============================================================================ + +// runMrtrClient drives the SEP-2322 client-request-state scenario. The +// SDK's built-in client-side MRTR middleware transparently fulfills embedded +// elicitation requests via ElicitationHandler and echoes requestState. +func runMrtrClient(ctx context.Context, serverURL string, _ map[string]any) error { + elicitationHandler := func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{ + Action: "accept", + Content: map[string]any{"confirmed": true}, + }, nil + } + session, err := connectToServer(ctx, serverURL, + withClientOptions(&mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{}}, + ElicitationHandler: elicitationHandler, + }), + ) + if err != nil { + return err + } + defer session.Close() + + toolNames := []string{ + "test_mrtr_echo_state", + "test_mrtr_no_state", + "test_mrtr_unrelated", + "test_mrtr_no_result_type", + } + for _, name := range toolNames { + if _, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: name, + Arguments: map[string]any{}, + }); err != nil { + log.Printf("CallTool(%q) rejected locally (expected for some cases): %v", name, err) + } + } + return nil +} + +// ============================================================================ +// SEP-2243 HTTP header scenarios +// ============================================================================ + +// runHTTPStandardHeadersClient (SEP-2243 standard headers) drives tools, +// resources and prompts once each so the referee sees Mcp-Method / Mcp-Name +// headers on every verb the SDK supports. +func runHTTPStandardHeadersClient(ctx context.Context, serverURL string, _ map[string]any) error { + session, err := connectToServer(ctx, serverURL) + if err != nil { + return err + } + defer session.Close() + + if tools, err := session.ListTools(ctx, nil); err == nil && len(tools.Tools) > 0 { + _, _ = session.CallTool(ctx, &mcp.CallToolParams{ + Name: tools.Tools[0].Name, + Arguments: map[string]any{}, + }) + } + if res, err := session.ListResources(ctx, nil); err == nil && len(res.Resources) > 0 { + _, _ = session.ReadResource(ctx, &mcp.ReadResourceParams{URI: res.Resources[0].URI}) + } + if prompts, err := session.ListPrompts(ctx, nil); err == nil && len(prompts.Prompts) > 0 { + _, _ = session.GetPrompt(ctx, &mcp.GetPromptParams{ + Name: prompts.Prompts[0].Name, + Arguments: map[string]string{}, + }) + } + return nil +} + +// runHTTPCustomHeadersClient (SEP-2243 custom headers) lists tools (so the +// SDK caches inputSchema and its x-mcp-header annotations), then makes the +// runner-supplied tool calls so the referee validates the Mcp-Param-* +// headers. +func runHTTPCustomHeadersClient(ctx context.Context, serverURL string, configCtx map[string]any) error { + session, err := connectToServer(ctx, serverURL) + if err != nil { + return err + } + defer session.Close() + + if _, err := session.ListTools(ctx, nil); err != nil { + return fmt.Errorf("session.ListTools(): %v", err) + } + for _, call := range readToolCallsContext(configCtx) { + if _, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: call.Name, + Arguments: call.Arguments, + }); err != nil { + log.Printf("CallTool(%q): %v", call.Name, err) + } + } + return nil +} + +// runHTTPInvalidToolHeadersClient (SEP-2243 invalid-tool filtering) lists +// tools — a correct SDK leaves only the valid ones — then calls each survivor +// so the referee records SUCCESS for every excluded tool never called. +func runHTTPInvalidToolHeadersClient(ctx context.Context, serverURL string, _ map[string]any) error { + session, err := connectToServer(ctx, serverURL) + if err != nil { + return err + } + defer session.Close() + + tools, err := session.ListTools(ctx, nil) + if err != nil { + return fmt.Errorf("session.ListTools(): %v", err) + } + for _, tool := range tools.Tools { + _, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: tool.Name, + Arguments: map[string]any{"region": "us-west1"}, + }) + if err != nil { + log.Printf("call %q rejected: %v", tool.Name, err) + } + } + return nil +} + +type toolCall struct { + Name string + Arguments map[string]any +} + +// readToolCallsContext parses the `toolCalls` array from MCP_CONFORMANCE_CONTEXT. +// Format: {"toolCalls": [{"name":"...", "arguments":{...}}, ...]}. +func readToolCallsContext(configCtx map[string]any) []toolCall { + raw, ok := configCtx["toolCalls"].([]any) + if !ok { + return nil + } + out := make([]toolCall, 0, len(raw)) + for _, item := range raw { + obj, ok := item.(map[string]any) + if !ok { + continue + } + name, _ := obj["name"].(string) + args, _ := obj["arguments"].(map[string]any) + if args == nil { + args = map[string]any{} + } + out = append(out, toolCall{Name: name, Arguments: args}) + } + return out +} + // ============================================================================ // Main entry point // ============================================================================ diff --git a/conformance/everything-server/main.go b/conformance/everything-server/main.go index 70bd4bc..c532d43 100644 --- a/conformance/everything-server/main.go +++ b/conformance/everything-server/main.go @@ -5,6 +5,8 @@ // The conformance server implements features required for MCP conformance testing. // It mirrors the functionality of the TypeScript conformance server at // https://github.com/modelcontextprotocol/conformance/blob/main/examples/servers/typescript/everything-server.ts +// +//lint:file-ignore SA1019 conformance server exercises deprecated SEP-2577 APIs (roots, sampling, logging). package main import ( @@ -17,15 +19,18 @@ import ( "log" "net/http" "os" + "strings" "time" "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/yosida95/uritemplate/v3" ) var ( - httpAddr = flag.String("http", "", "if set, use streamable HTTP at this address, instead of stdin/stdout") + httpAddr = flag.String("http", "", "if set, use streamable HTTP at this address, instead of stdin/stdout") + stateless = flag.Bool("stateless", true, "use stateless streamable HTTP mode") ) const watchedResourceURI = "test://watched-resource" @@ -58,7 +63,7 @@ func main() { if *httpAddr != "" { handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server - }, nil) + }, &mcp.StreamableHTTPOptions{Stateless: *stateless}) log.Printf("Conformance server listening at %s", *httpAddr) log.Fatal(http.ListenAndServe(*httpAddr, handler)) } else { @@ -151,14 +156,19 @@ func registerTools(server *mcp.Server) { Description: "Tests elicitation with enum schema improvements per SEP-1330", }, testElicitationEnumsHandler) + // SEP-1613 / SEP-2106: JSON Schema 2020-12 conformance test tool. + // The scenario verifies that $schema/$defs/additionalProperties (SEP-1613) + // and the broader 2020-12 vocabulary — $anchor, allOf/anyOf, if/then/else — + // (SEP-2106) survive tools/list verbatim. mcp.AddTool(server, &mcp.Tool{ Name: "json_schema_2020_12_tool", - Description: "Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)", + Description: "Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613, SEP-2106)", InputSchema: json.RawMessage(`{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "$defs": { "address": { + "$anchor": "addressDef", "type": "object", "properties": { "street": { "type": "string" }, @@ -168,8 +178,20 @@ func registerTools(server *mcp.Server) { }, "properties": { "name": { "type": "string" }, - "address": { "$ref": "#/$defs/address" } + "address": { "$ref": "#/$defs/address" }, + "contactMethod": { "type": "string", "enum": ["phone", "email"] }, + "phone": { "type": "string" }, + "email": { "type": "string" } }, + "allOf": [ + { "anyOf": [{ "required": ["phone"] }, { "required": ["email"] }] } + ], + "if": { + "properties": { "contactMethod": { "const": "phone" } }, + "required": ["contactMethod"] + }, + "then": { "required": ["phone"] }, + "else": { "required": ["email"] }, "additionalProperties": false }`), }, jsonSchema202012Handler) @@ -178,6 +200,87 @@ func registerTools(server *mcp.Server) { Name: "test_reconnection", Description: "Tests SSE stream disconnection and client reconnection (SEP-1699). Server will close the stream mid-call and send the result after client reconnects.", }, testReconnectionHandler) + + // SEP-2243 x-mcp-header tool — arms the http-custom-header-server-validation + // conformance scenario (which skips when no tool with an x-mcp-header + // annotation is found). + mcp.AddTool(server, &mcp.Tool{ + Name: "test_x_mcp_header", + Description: "Tests SEP-2243 Mcp-Param-* server-side validation", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "region": { "type": "string", "description": "mirrored into Mcp-Param-Region", "x-mcp-header": "Region" }, + "level": { "type": "integer", "description": "non-mirrored argument" } + } + }`), + }, testXMcpHeaderHandler) + + // SEP-2575 diagnostic tools used by the stateless conformance scenario. + mcp.AddTool(server, &mcp.Tool{ + Name: "test_missing_capability", + Description: "Requires the sampling client capability; used to verify MissingRequiredClientCapabilityError (-32021) (SEP-2575)", + }, testMissingCapabilityHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_streaming_elicitation", + Description: "Streams progress notifications while a call is in flight; used to verify response streams carry only notifications, never independent JSON-RPC requests (SEP-2575)", + }, testStreamingElicitationHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_logging_tool", + Description: "Attempts to emit a log message; the framework must drop it when the client did not set _meta.../logLevel (SEP-2575)", + }, testLoggingToolHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_trigger_tool_change", + Description: "Mutates the tool list to trigger a notifications/tools/list_changed on active subscription streams (SEP-2575)", + }, makeTriggerToolChangeHandler(server)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_trigger_prompt_change", + Description: "Mutates the prompt list to trigger a notifications/prompts/list_changed on active subscription streams (SEP-2575)", + }, makeTriggerPromptChangeHandler(server)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_input_required_result_elicitation", + Description: "MRTR (SEP-2322): asks for the caller name via an in-band elicitation request", + }, testInputRequiredElicitationHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_input_required_result_sampling", + Description: "MRTR (SEP-2322): asks for an LLM completion via an in-band sampling request", + }, testInputRequiredSamplingHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_input_required_result_list_roots", + Description: "MRTR (SEP-2322): asks for the client roots via an in-band roots/list request", + }, testInputRequiredListRootsHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_input_required_result_request_state", + Description: "MRTR (SEP-2322): round-trips integrity-protected requestState alongside an elicitation request", + }, testInputRequiredRequestStateHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_input_required_result_multiple_inputs", + Description: "MRTR (SEP-2322): asks for elicitation, sampling and roots input in a single round", + }, testInputRequiredMultipleInputsHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_input_required_result_multi_round", + Description: "MRTR (SEP-2322): two elicitation rounds with evolving requestState before completing", + }, testInputRequiredMultiRoundHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_input_required_result_tampered_state", + Description: "MRTR (SEP-2322): rejects retries whose requestState fails integrity verification", + }, testInputRequiredTamperedStateHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_input_required_result_capabilities", + Description: "MRTR (SEP-2322): only requests input kinds the declared client capabilities cover", + }, testInputRequiredCapabilitiesHandler) } // Tool handlers @@ -474,33 +577,466 @@ func testElicitationEnumsHandler(ctx context.Context, req *mcp.CallToolRequest, }, nil, nil } -type jsonSchemaAddress struct { - Street string `json:"street"` - City string `json:"city"` -} - -type jsonSchemaInput struct { - Name string `json:"name"` - Address *jsonSchemaAddress `json:"address"` -} - -func jsonSchema202012Handler(ctx context.Context, req *mcp.CallToolRequest, input jsonSchemaInput) (*mcp.CallToolResult, any, error) { - // Echo back the arguments received - var addressStr string - if input.Address != nil { - addressStr = fmt.Sprintf("{street: %q, city: %q}", input.Address.Street, input.Address.City) - } else { - addressStr = "nil" - } +func jsonSchema202012Handler(ctx context.Context, req *mcp.CallToolRequest, input json.RawMessage) (*mcp.CallToolResult, any, error) { return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{ - Text: fmt.Sprintf("Received: name=%q, address=%s", input.Name, addressStr), + Text: fmt.Sprintf("JSON Schema 2020-12 tool called with: %s", input), }, }, }, nil, nil } +type testXMcpHeaderInput struct { + Region string `json:"region,omitempty"` + Level int `json:"level,omitempty"` +} + +func testXMcpHeaderHandler(ctx context.Context, req *mcp.CallToolRequest, input testXMcpHeaderInput) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("region=%s", input.Region)}, + }, + }, nil, nil +} + +// testMissingCapabilityHandler requires the client to have declared the +// sampling capability. When absent, it returns a MissingRequiredClientCapabilityError +// with data.requiredCapabilities = {"sampling": {}} per SEP-2575. +func testMissingCapabilityHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + caps := req.ClientCapabilities() + if caps != nil && caps.Sampling != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Client declared the sampling capability; tool executed."}, + }, + }, nil, nil + } + missingCapabilityData := mcp.MissingRequiredClientCapabilityData{ + RequiredCapabilities: &mcp.ClientCapabilities{ + Sampling: &mcp.SamplingCapabilities{}, + }, + } + dataBytes, err := json.Marshal(missingCapabilityData) + if err != nil { + return nil, nil, err + } + return nil, nil, &jsonrpc.Error{ + Code: mcp.CodeMissingRequiredClientCapabilities, + Message: "sampling capability required but not declared by client", + Data: dataBytes, + } +} + +// testStreamingElicitationHandler returns a plain result. Per SEP-2575, the +// response stream must carry no independent top-level JSON-RPC requests; a +// plain response trivially satisfies this. The scenario declares no +// `elicitation` capability, so this handler must not call req.Session.Elicit. +func testStreamingElicitationHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "stream observed: result frames only, no top-level requests"}, + }, + }, nil, nil +} + +// testLoggingToolHandler attempts to emit a log message. The SDK's +// ServerSession.Log gates on the per-request _meta.../logLevel, so this is a +// no-op notification-wise when the client did not opt in. +func testLoggingToolHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + req.Session.Log(ctx, &mcp.LoggingMessageParams{ + Level: "info", + Data: "test_logging_tool executed", + }) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Log attempted; framework gates on _meta.../logLevel."}, + }, + }, nil, nil +} + +// makeTriggerToolChangeHandler returns a handler that (re-)registers a +// no-op transient tool. Every call to Server.AddTool dispatches a +// notifications/tools/list_changed to active subscription streams (even when +// it replaces a tool with the same name), which is what the SEP-2575 +// subscription checks assert. +func makeTriggerToolChangeHandler(server *mcp.Server) mcp.ToolHandlerFor[any, any] { + return func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + mcp.AddTool(server, &mcp.Tool{ + Name: "__transient_tool_for_list_changed", + Description: "Transient tool used to trigger tools/list_changed", + }, func(context.Context, *mcp.CallToolRequest, any) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{}, nil, nil + }) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "tools_list_changed published"}, + }, + }, nil, nil + } +} + +// makeTriggerPromptChangeHandler is the prompt analogue of +// makeTriggerToolChangeHandler. +func makeTriggerPromptChangeHandler(server *mcp.Server) mcp.ToolHandlerFor[any, any] { + return func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + server.AddPrompt(&mcp.Prompt{ + Name: "__transient_prompt_for_list_changed", + Description: "Transient prompt used to trigger prompts/list_changed", + }, func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{}, nil + }) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "prompts_list_changed published"}, + }, + }, nil, nil + } +} + +// ============================================================================= +// SEP-2322 Multi-Round-Trip helpers +// ============================================================================= + +// requestStateTamperedSuffix is what the tampered-state conformance scenario +// appends to a valid requestState. Detecting this suffix is sufficient to +// pass the check; a production server would use a real integrity mechanism +// (HMAC / AEAD, per SEP-2322 § "Server Requirements"). +const requestStateTamperedSuffix = "-TAMPERED" + +// requestStateValid reports whether the state looks like one this server +// minted (i.e. the harness did not append the tampered marker). +func requestStateValid(state string) bool { + return state != "" && !strings.HasSuffix(state, requestStateTamperedSuffix) +} + +// acceptedContent returns the "content" field of an ElicitResult only when the +// user accepted the elicitation. Returns nil if the response is missing, of the +// wrong type, or the action is not "accept". +func acceptedContent(responses mcp.InputResponseMap, key string) map[string]any { + if responses == nil { + return nil + } + resp, ok := responses[key] + if !ok { + return nil + } + elicit, ok := resp.(*mcp.ElicitResult) + if !ok || elicit == nil || elicit.Action != "accept" { + return nil + } + return elicit.Content +} + +// ============================================================================= +// SEP-2322 Multi-Round-Trip tool handlers +// ============================================================================= + +// nameElicitSchema is the requestedSchema used by tools that elicit a name. +func nameElicitSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": {Type: "string"}, + }, + Required: []string{"name"}, + } +} + +// testInputRequiredElicitationHandler asks for the caller name via elicitation. +// Reused by the result-type, missing-input-response, ignore-extra-params, and +// validate-input scenarios: anything that does not carry an accepted +// "user_name" response is answered with a fresh InputRequiredResult. +func testInputRequiredElicitationHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + if content := acceptedContent(req.Params.InputResponses, "user_name"); content != nil { + name, _ := content["name"].(string) + if name != "" { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Hello, %s!", name)}}, + }, nil, nil + } + } + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "user_name": &mcp.ElicitParams{ + Message: "What is your name?", + RequestedSchema: nameElicitSchema(), + }, + }, + }, nil, nil +} + +// samplingResponseText extracts the first text content from a sampling +// response. The Go SDK unmarshals responses with a "role" field into +// *CreateMessageWithToolsResult (a superset of the legacy single-content +// CreateMessageResult). +func samplingResponseText(responses mcp.InputResponseMap, key string) (string, bool) { + resp, ok := responses[key].(*mcp.CreateMessageWithToolsResult) + if !ok || resp == nil { + return "", false + } + for _, c := range resp.Content { + if tc, ok := c.(*mcp.TextContent); ok { + return tc.Text, true + } + } + return "(non-text response)", true +} + +// testInputRequiredSamplingHandler asks for an LLM completion via sampling. +func testInputRequiredSamplingHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + if text, ok := samplingResponseText(req.Params.InputResponses, "capital_question"); ok { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Sampling response: %s", text)}}, + }, nil, nil + } + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "capital_question": &mcp.CreateMessageParams{ + Messages: []*mcp.SamplingMessage{ + {Role: "user", Content: &mcp.TextContent{Text: "What is the capital of France?"}}, + }, + MaxTokens: 100, + }, + }, + }, nil, nil +} + +// testInputRequiredListRootsHandler asks for the client roots. +func testInputRequiredListRootsHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + if resp, ok := req.Params.InputResponses["client_roots"].(*mcp.ListRootsResult); ok && resp != nil { + var uris []string + for _, r := range resp.Roots { + uris = append(uris, r.URI) + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Client exposed %d root(s): %s", len(resp.Roots), strings.Join(uris, ", "))}, + }, + }, nil, nil + } + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "client_roots": &mcp.ListRootsParams{}, + }, + }, nil, nil +} + +// testInputRequiredRequestStateHandler round-trips an opaque requestState +// alongside an elicitation request. +func testInputRequiredRequestStateHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + if content := acceptedContent(req.Params.InputResponses, "confirm"); content != nil { + if requestStateValid(req.Params.RequestState) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "state-ok: requestState received and confirmation accepted"}}, + }, nil, nil + } + return nil, nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: "invalid requestState", + } + } + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "confirm": &mcp.ElicitParams{ + Message: "Please confirm", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"ok": {Type: "boolean"}}, + Required: []string{"ok"}, + }, + }, + }, + RequestState: "request_state", + }, nil, nil +} + +// testInputRequiredMultipleInputsHandler asks for elicitation, sampling, and +// roots input in a single round. +func testInputRequiredMultipleInputsHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + nameContent := acceptedContent(req.Params.InputResponses, "user_name") + name, _ := nameContent["name"].(string) + + greetingText, hasGreeting := samplingResponseText(req.Params.InputResponses, "greeting") + + var rootsCount int + rootsResp, rootsOK := req.Params.InputResponses["client_roots"].(*mcp.ListRootsResult) + if rootsOK && rootsResp != nil { + rootsCount = len(rootsResp.Roots) + } + + if name != "" && hasGreeting && rootsOK { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("%s %s — %d root(s) visible", greetingText, name, rootsCount)}, + }, + }, nil, nil + } + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "user_name": &mcp.ElicitParams{ + Message: "What is your name?", + RequestedSchema: nameElicitSchema(), + }, + "greeting": &mcp.CreateMessageParams{ + Messages: []*mcp.SamplingMessage{ + {Role: "user", Content: &mcp.TextContent{Text: "Generate a greeting"}}, + }, + MaxTokens: 50, + }, + "client_roots": &mcp.ListRootsParams{}, + }, + RequestState: "multiple_inputs", + }, nil, nil +} + +// testInputRequiredMultiRoundHandler runs two elicitation rounds with +// evolving requestState before completing. The round number (and the name +// elicited in round 1) lives in the state, since the stateless server keeps +// no per-session memory. +func testInputRequiredMultiRoundHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + state := req.Params.RequestState + // State format: "round=1" or "round=2;name=". + switch { + case state == "": + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "step1": &mcp.ElicitParams{ + Message: "Step 1: What is your name?", + RequestedSchema: nameElicitSchema(), + }, + }, + RequestState: "round=1", + }, nil, nil + case state == "round=1": + name, _ := acceptedContent(req.Params.InputResponses, "step1")["name"].(string) + if name == "" { + name = "unknown" + } + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "step2": &mcp.ElicitParams{ + Message: "Step 2: What is your favorite color?", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"color": {Type: "string"}}, + Required: []string{"color"}, + }, + }, + }, + RequestState: "round=2;name=" + name, + }, nil, nil + default: + color, _ := acceptedContent(req.Params.InputResponses, "step2")["color"].(string) + if color == "" { + color = "unknown" + } + name := strings.TrimPrefix(state, "round=2;name=") + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Multi-round complete: %s likes %s", name, color)}, + }, + }, nil, nil + } +} + +// testInputRequiredTamperedStateHandler rejects retries whose requestState +// looks tampered. The conformance harness tampers by appending "-TAMPERED"; +// production servers should use a real integrity mechanism (HMAC / AEAD). +func testInputRequiredTamperedStateHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + if req.Params.RequestState != "" { + if !requestStateValid(req.Params.RequestState) { + return nil, nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: "invalid requestState: tampered", + } + } + if acceptedContent(req.Params.InputResponses, "confirm") != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "state-ok: requestState received and confirmation accepted"}}, + }, nil, nil + } + } + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "confirm": &mcp.ElicitParams{ + Message: "Please confirm", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"ok": {Type: "boolean"}}, + Required: []string{"ok"}, + }, + }, + }, + RequestState: "tampered_state", + }, nil, nil +} + +// testInputRequiredCapabilitiesHandler only requests input kinds the client +// declared capabilities cover. Reads capabilities from the per-request +// _meta["io.modelcontextprotocol/clientCapabilities"] envelope. +func testInputRequiredCapabilitiesHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) > 0 { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "Capability-aware input requests fulfilled"}}, + }, nil, nil + } + caps := req.ClientCapabilities() + reqs := mcp.InputRequestMap{} + if caps != nil && caps.Elicitation != nil { + reqs["user_name"] = &mcp.ElicitParams{ + Message: "What is your name?", + RequestedSchema: nameElicitSchema(), + } + } + if caps != nil && caps.Sampling != nil { + reqs["greeting"] = &mcp.CreateMessageParams{ + Messages: []*mcp.SamplingMessage{ + {Role: "user", Content: &mcp.TextContent{Text: "Generate a short greeting"}}, + }, + MaxTokens: 50, + } + } + if caps != nil && (caps.RootsV2 != nil || caps.Roots.ListChanged) { + reqs["client_roots"] = &mcp.ListRootsParams{} + } + if len(reqs) == 0 { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "No declared client capability supports an in-band input request"}}, + }, nil, nil + } + return &mcp.CallToolResult{InputRequests: reqs}, nil, nil +} + +// testInputRequiredPromptHandler is the prompts/get analogue: elicits context +// on the first call and returns a GetPromptResult with messages on retry. +func testInputRequiredPromptHandler(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + if content := acceptedContent(req.Params.InputResponses, "user_context"); content != nil { + contextStr, _ := content["context"].(string) + return &mcp.GetPromptResult{ + Description: "A prompt with elicited context", + Messages: []*mcp.PromptMessage{ + { + Role: "user", + Content: &mcp.TextContent{Text: fmt.Sprintf("Context: %s", contextStr)}, + }, + }, + }, nil + } + return &mcp.GetPromptResult{ + InputRequests: mcp.InputRequestMap{ + "user_context": &mcp.ElicitParams{ + Message: "Please provide context for the prompt", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"context": {Type: "string"}}, + Required: []string{"context"}, + }, + }, + }, + }, nil +} + func testReconnectionHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { // Close the SSE stream to trigger client reconnection (SEP-1699) if req.Extra != nil && req.Extra.CloseSSEStream != nil { @@ -648,6 +1184,11 @@ func registerPrompts(server *mcp.Server) { Title: "Prompt With Image", Description: "A prompt that includes image content", }, promptWithImageHandler) + + server.AddPrompt(&mcp.Prompt{ + Name: "test_input_required_result_prompt", + Description: "MRTR (SEP-2322): asks for context via an in-band elicitation request", + }, testInputRequiredPromptHandler) } func simplePromptHandler(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { diff --git a/design/mrtr.md b/design/mrtr.md new file mode 100644 index 0000000..16321a4 --- /dev/null +++ b/design/mrtr.md @@ -0,0 +1,264 @@ +## Context + +A proposal for implementing Multi Round-Trip Requests +(MRTR) as defined in [SEP-2322](https://github.com/CaitieM20/modelcontextprotocol/blob/de6d76fba3078eda957dadb3cec51ca8ab851b5c/seps/2322-MRTR.md). + +In the new protocol version servers can't initiate requests to clients, but when a server requires additional input for completing `tools/call`, `prompts/get`, or `resources/read` it can return an incomplete result along with a set of `inputRequests`. The client fulfills them locally and retries the same call with `inputResponses` attached. + +## Goals + +**Must have:** + +* Backward compatibility. +* Correct representation on the wire. + +**Nice to have:** + +* Minimal changes to the exported API surface. +* Hard for server implementers to construct an invalid payload. +* Simple input request handling for clients. +* Protocol-version-independent code. +* Consistency with the rest of the SDK. + +## Proposal + +`ServerSession` methods return an error for new-version protocol connections. + +`InputRequest`/`InputResponse` is introduced as a sealed-interface: +```go +// Implemented by *ElicitParams, *CreateMessageParams, *ListRootsParams +type InputRequest interface{ isInputRequest() } + +type InputRequestMap map[string]InputRequest +// MarshalJSON encodes as map[string]struct{ Method string; Params InputRequest } +func (m InputRequestMap) MarshalJSON() ([]byte, error) { ... } +// UnmarshalJSON decodes from map[string]struct{ Method string; Params InputRequest } +func (m *InputRequestMap) UnmarshalJSON(data []byte) error { ... } + +// Implemented by *ElicitResult, *CreateMessageResult, *ListRootsResult. +type InputResponse interface{ isInputResult() } + +type InputResponseMap map[string]InputResponse +// MarshalJSON encodes as map[string]struct{ Method string; Result InputResponse } +func (m InputResponseMap) MarshalJSON() ([]byte, error) { ... } +// UnmarshalJSON decodes from map[string]struct{ Method string; Result InputResponse } +func (m *InputResponseMap) UnmarshalJSON(data []byte) error { ... } +``` + +All affected methods' `*Params` are extended with `InputResponseMap` and `RequestState` fields: +```go +type CallToolParams struct { + ... + InputResponses InputResponseMap `json:"inputResponses,omitempty"` + RequestState string `json:"requestState,omitempty"` +} +// Same for GetPromptParams, ReadResourceParams +``` + +`InputRequests` and `RequestState` fields are added directly to `CallToolResult`, `GetPromptResult`, and `ReadResourceResult` as exported. +Result type discriminator (completed, input_required) is unexported so that SDK users don't need to set it to the correct constant in addition to setting either `Content` or `InputRequests`. Handler execution result is validated and augmented before marshaling: +```go +type CallToolResult struct { + ... + InputRequests InputRequestMap `json:"inputRequests,omitempty"` + RequestState string `json:"requestState,omitempty"` + resultType string // set by the SDK and used in MarshalJSON() +} +// Same for GetPromptResult, ReadResourceResult. +``` +Alternatively, the field could only exist on `wire struct`, but this would make us return `complete` to older clients or empty string to newer clients, because there's no access to negotiated protocol version in `MarshalJSON`. + +Servers request additional input by constructing a correct struct literal: +```go +mcp.AddTool(s, tool, func(ctx context.Context, req *mcp.CallToolRequest, in MyIn) (*mcp.CallToolResult, MyOut, error) { + if !hasConfirmation(in) { + return &mcp.CallToolResult{ + InputRequests: InputRequestMap{"confirm": &mcp.ElicitParams{Message: "Sure?"}}, + RequestState: "state-token", + }, zero, nil + } + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "done"}}}, myOut, nil +}) +``` +The SDK validates at runtime that a handler does not return both content and `InputRequests` — doing so logs a warning and returns a `CodeInternalError` JSON-RPC error. + +An unexported receiving middleware is installed on the server for backward compatibility with older clients. When a handler returns `InputRequests` and the connected client uses a protocol version that does not support MRTR, the middleware fulfills the requests by calling `ServerSession.Elicit`/`CreateMessage`/`ListRoots` on the client directly and reinvokes the handler once with the collected `InputResponses`. If any of these calls fail, the entire request fails. Input requests are fulfilled concurrently. This lets server developers write protocol-version-independent code. + +An unexported sending middleware is installed on the client, which similarly to `urlElicitationMiddleware` will automatically invoke handlers for the corresponding methods on incomplete results and retry the original request. Clients have an option to disable it and write a retry loop manually using `NeedsInput()`: +```go +type MultiRoundTripOptions struct { + Disabled bool +} + +client := mcp.NewClient(impl, &mcp.ClientOptions{MultiRoundTrip: &mcp.MultiRoundTripOptions{Disabled: true}}) +result, err := client.CallTool(ctx, &mcp.CallToolParams{Name: "my-tool"}) +if result.NeedsInput() { ... } +``` + +`NeedsInput()` checks the unexported `resultType` field rather than `InputRequests`, correctly handling the load-shedding case where the server returns `input_required` with an empty map. + +**Pros** + +This is arguably the simplest and the most transparent approach which is also closest to the spec. +What gets explicitly set on the server can be observed on the wire and on the client. +The opt-out client middleware follows the principle of the least surprise for app developers. If client method handlers were provided they will continue to be invoked regardless of the protocol version in use. The `Disabled` option lets "power-users" build any custom handling logic. +The server middleware makes handler code protocol-version-independent — the same handler works for both old and new clients. + +**Cons** + +The biggest downside of the proposal is that server developers can construct incorrect responses (both content and input requests) and this will only be validated at runtime. + +## Alternatives considered + +### Unexported fields + +MRTR fields can be unexported, accessible only through getters, constructible only through constructor functions, and handled explicitly in custom `(Unm|M)arshalJSON`. This will make it impossible for developers to construct incorrect responses and for clients to perform an erroneous `len(result.InputRequests) > 0` check in the load-shedding case. +```go +type CallToolResult struct { + ... + inputRequests InputRequestMap + requestState string + resultType string +} + +func (r *CallToolResult) InputRequests() (InputRequestMap, bool) { ... } + +// InputRequiredResult struct exists for backward-compatibility in case of new fields being needed for input request results. +type InputRequiredResult struct { + InputRequests InputRequestMap + RequestState string +} + +// RequireInput constructs a tool call, prompt or resource result with input requests set. +// mrtrResult provides methods for setting private fields on these types. +func RequireInput[T any, TP interface { *T; mrtrResult }](r InputRequiredResult) TP { ... } +``` + +On the server: +```go +mcp.AddTool(s, tool, func(ctx context.Context, req *mcp.CallToolRequest, in MyIn) (*mcp.CallToolResult, MyOut, error) { + if !hasConfirmation(in) { + return mcp.RequireInput[mcp.CallToolResult](mcp.InputRequiredResult{ + InputRequests: mcp.InputRequestMap{"confirm": &mcp.ElicitParams{Message: "Deploy to production?"}}, + RequestState: "deployment-123", + }), nil, nil + } + return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: "done"}}}, myOut, nil +}) +``` + +On the client: +```go +result, err := client.CallTool(ctx, &mcp.CallToolParams{Name: "my-tool"}) +if requests, ok := result.InputRequests(); ok { ... } +``` + +The biggest downside of this approach is the obscure data model with hidden fields. An incomplete `mcp.CallToolResult` looks like an uninitialized struct until `InputRequests` method result is examined. +In addition to this, the verbose `RequireInput` syntax (no auto type inference from assignment target) does not look idiomatic and fits poorly into the existing SDK APIs. + +--- + +### `InputRequiredError` type + +We could explore a different data channel - `error` return value. This would give us the natural "happy path is when all inputs are provided" flow on the server side, and good result interpretability on the client side (impossible to confuse with a successful response). +The new error could be converted to the correct wire representation at the marshaling stage. +```go +type InputRequiredError struct { + InputRequests InputRequestMap + RequestState string +} + +func (e *InputRequiredError) Error() string { + return fmt.Sprintf("input required: %d request(s)", len(e.InputRequests)) +} +``` + +On the server: +```go +mcp.AddTool(s, tool, func(ctx context.Context, req *mcp.CallToolRequest, in MyIn) (*mcp.CallToolResult, MyOut, error) { + if !hasConfirmation(in) { + return nil, zero, &mcp.InputRequiredError{ + InputRequests: mcp.InputRequestMap{"confirm": &mcp.ElicitParams{Message: "Sure?"}}, + RequestState: "state-token", + } + } + return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: "done"}}}, myOut, nil +}) +``` + +On the client: +```go +result, err := client.CallTool(ctx, &mcp.CallToolParams{Name: "my-tool"}) +var inputReqErr *mcp.InputRequiredError +if errors.As(err, &inputReqErr) { ... } +``` + +The downsides of this approach are: +* The drift from the protocol, where MRTR is not an error flow. +* Obscure "customError -> non-error protocol type on wirte -> customError" data lifecycle. +* Things get confusing for error-processing middleware. + +--- + +### New functions + +We could introduce new functions with a different handler signature where the return type is a sealed interface. This would give us compiler-enforced correctness for values constructed by tool handlers and clients would be forced to unpack `mcp.RoundTripCallToolResult` and make a concious decision for how to handle it. +```go +type RoundTripToolHandler func(context.Context, *CallToolRequest) (RoundTripCallToolResult, error) +type RoundTripToolHandlerFor[In, Out any] func(context.Context, *CallToolRequest, In) (RoundTripCallToolResult, Out, error) + +// RoundTripCallToolResult is implemented by CallToolResult and IncompleteResult +type RoundTripCallToolResult interface { isMRTRResult() } + +type IncompleteResult struct { + ... + InputRequests InputRequestMap `json:"inputRequests,omitempty"` + RequestState string `json:"requestState,omitempty"` +} + +func (s *Server) AddRoundTripTool(t *Tool, h RoundTripToolHandler) +func AddRoundTripTool[In, Out any](s *Server, t *Tool, h RoundTripToolHandlerFor[In, Out]) +``` + +`Server.AddTool` wraps the old `ToolHandler` into a `RoundTripToolHandler` to update its function signature: +```go +mcp.AddRoundTripTool(s, tool, func(ctx context.Context, req *mcp.CallToolRequest, in MyIn) (mcp.RoundTripCallToolResult, MyOut, error) { + if needsInput(in) { + return &mcp.IncompleteResult{ + ResultType: mcp.ResultTypeInputRequired, + InputRequests: InputRequestMap{"confirm": &mcp.ElicitParams{Message: "Sure?"}}, + }, zero, nil + } + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "done"}}}, myOut, nil +}) +``` + +The downsides of this approach are: +* SEP suggests `ResultType` will potentially be extended with new values, `RoundTrip` in new function names will not allow us to cleanly extend the sealed interface with new types. But an overly generic name for new functions will make the API use-case less clear. +* Different code +* SDK takes the same action (puts it on the wire) regardless of the returned type, it exists only for enforcing correctness of the user code. +* Exported API surface bloat: +7 exported functions. + +--- + +### Exported Middleware + +We could flip "unexported MRTR middleware with opt-out option" to "exported middleware with opt-in requirement". +```go +func AutoMRTR(opts *MultiRoundTripOptions) Middleware { ... } +type MultiRoundTripOptions struct { + MaxRetries int +} +client := mcp.NewClient(impl, nil) +client.AddSendingMiddleware(mcp.AutoMRTR(&mcp.MultiRoundTripOptions{ + MaxRetries: 5, +})) +``` +This would change semantics of `*Handler` fields - depending on the protocol version in use, an extra initialization step will be required for them to "take effect". + +--- + +### Server API protocol version bridging + +Converting `ServerSession.Elicit`/`CreateMessage`/`ListRoots` calls into MRTR wire format transparently (suspend the handler, return `input_required`, resume on retry). Rejected because of a significant implementation effort and the fact that it contradicts the design goal of MRTR where servers shouldn't hold resources between round trips, and it should be possible for a retry to arrive on any server instance in a multi-server deployment. + diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..bd97e60 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +go.sdk.modelcontextprotocol.io \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index e155e49..d07cfa0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,22 @@ -# Features +# Overview + +The SDK consists of several importable packages: + +- The + [`github.com/modelcontextprotocol/go-sdk/mcp`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp) + package defines the primary APIs for constructing and using MCP clients and + servers. +- The + [`github.com/modelcontextprotocol/go-sdk/jsonrpc`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/jsonrpc) package is for users implementing + their own transports. +- The + [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) + package provides some primitives for supporting OAuth. +- The + [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) + package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. + These docs mirror the [official MCP spec](https://modelcontextprotocol.io/specification/2025-06-18). Use the index below to learn how the SDK implements a particular aspect of the diff --git a/docs/client.md b/docs/client.md index 565fae9..23d57be 100644 --- a/docs/client.md +++ b/docs/client.md @@ -2,14 +2,26 @@ # Support for MCP client features 1. [Roots](#roots) + 1. [Roots list changed](#roots-list-changed) 1. [Sampling](#sampling) 1. [Elicitation](#elicitation) + 1. [Schema defaults and enums](#schema-defaults-and-enums) + 1. [Completing a URL elicitation](#completing-a-url-elicitation) +1. [Multi Round-Trip Requests](#multi-round-trip-requests) 1. [Capabilities](#capabilities) 1. [Capability inference](#capability-inference) 1. [Explicit capabilities](#explicit-capabilities) + 1. [Extensions](#extensions) ## Roots +> **Note:** The roots feature is deprecated as of protocol version 2026-07-28 +> ([SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging)). +> It remains fully functional during the deprecation window (at least twelve +> months). The SDK continues to support roots for compatibility. New code +> should pass paths via tool parameters, resource URIs, or configuration +> instead. + MCP allows clients to specify a set of filesystem ["roots"](https://modelcontextprotocol.io/specification/2025-06-18/client/roots). The SDK supports this as follows: @@ -27,31 +39,36 @@ client, a call to `AddRoot` or `RemoveRoots` will result in a [`ServerSession.ListRoots`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.ListRoots) method. To receive notifications about root changes, set [`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler). +For protocol versions `2026-07-28` and later, `ListRoots` requests are +delivered via the +[Multi Round-Trip Requests](#multi-round-trip-requests) +pattern. ```go func Example_roots() { ctx := context.Background() - // Create a client with a single root. + // Create a client with two roots. c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) - c.AddRoots(&mcp.Root{URI: "file://a"}) + c.AddRoots(&mcp.Root{URI: "file://a"}, &mcp.Root{URI: "file://b"}) - // Now create a server with a handler to receive notifications about roots. - rootsChanged := make(chan struct{}) - handleRootsChanged := func(ctx context.Context, req *mcp.RootsListChangedRequest) { - rootList, err := req.Session.ListRoots(ctx, nil) - if err != nil { - log.Fatal(err) + // Create a server with a tool that requests roots via the multi round-trip + // pattern (SEP-2322): server-to-client requests are no longer sent as + // standalone JSON-RPC calls on protocol version >= 2026-07-28. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "roots"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"roots": &mcp.ListRootsParams{}}, + }, nil, nil } + rootList := req.Params.InputResponses["roots"].(*mcp.ListRootsResult) var roots []string for _, root := range rootList.Roots { roots = append(roots, root.URI) } fmt.Println(roots) - close(rootsChanged) - } - s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ - RootsListChangedHandler: handleRootsChanged, + return &mcp.CallToolResult{}, nil, nil }) // Connect the server and client... @@ -68,15 +85,85 @@ func Example_roots() { } defer clientSession.Close() - // ...and add a root. The server is notified about the change. - c.AddRoots(&mcp.Root{URI: "file://b"}) - <-rootsChanged + // ...and call the tool. The client's multi round-trip driver fulfils the + // embedded roots/list request and retries the call. + if _, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "roots"}); err != nil { + log.Fatal(err) + } // Output: [file://a file://b] } ``` +### Roots list changed + +`Client.AddRoots` and `Client.RemoveRoots` notify every connected server that +the list changed. Servers observe this through +[`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler); +as with the server-side list-changed notifications, it reports only that +something changed, so read the list back with `ServerSession.ListRoots`. + +```go +func Example_rootsListChanged() { + ctx := context.Background() + + changed := make(chan struct{}, 2) + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + RootsListChangedHandler: func(context.Context, *mcp.RootsListChangedRequest) { + changed <- struct{}{} + }, + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + c.AddRoots(&mcp.Root{URI: "file:///project"}) + + t1, t2 := mcp.NewInMemoryTransports() + ss, err := s.Connect(ctx, t1, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + + // ListRoots is a server-initiated request, so this session negotiates a + // protocol version that still allows one. + cs, err := c.Connect(ctx, t2, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"}) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + // Roots added after the client connects notify every connected server. + c.AddRoots(&mcp.Root{URI: "file:///scratch"}) + <-changed + + // The notification says only that the list changed, so read it back. + res, err := ss.ListRoots(ctx, nil) + if err != nil { + log.Fatal(err) + } + for _, root := range res.Roots { + fmt.Println(root.URI) + } + + c.RemoveRoots("file:///scratch") + <-changed + fmt.Println("roots changed again") + + // Output: + // file:///project + // file:///scratch + // roots changed again +} +``` + ## Sampling +> **Note:** The sampling feature is deprecated as of protocol version +> 2026-07-28 +> ([SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging)). +> It remains fully functional during the deprecation window (at least twelve +> months). The SDK continues to support sampling for compatibility. Servers +> that need LLM completions should call LLM provider APIs directly. + [Sampling](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling) is a way for servers to leverage the client's AI capabilities. It is implemented in the SDK as follows: @@ -88,6 +175,11 @@ This function is invoked whenever the server requests sampling. **Server-side**: To use sampling from the server, call [`ServerSession.CreateMessage`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.CreateMessage). +For protocol versions `2026-07-28` and later, sampling requests are +delivered via the +[Multi Round-Trip Requests](#multi-round-trip-requests) +pattern. + ```go func Example_sampling() { ctx := context.Background() @@ -105,22 +197,35 @@ func Example_sampling() { // Connect the server and client... ct, st := mcp.NewInMemoryTransports() + // Create a server with a tool that requests sampling via the multi + // round-trip pattern (SEP-2322): server-to-client requests are no longer + // sent as standalone JSON-RPC calls on protocol version >= 2026-07-28. s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "sample"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"msg": &mcp.CreateMessageParams{}}, + }, nil, nil + } + msg := req.Params.InputResponses["msg"].(*mcp.CreateMessageWithToolsResult) + return &mcp.CallToolResult{Content: msg.Content}, nil, nil + }) session, err := s.Connect(ctx, st, nil) if err != nil { log.Fatal(err) } defer session.Close() - if _, err := c.Connect(ctx, ct, nil); err != nil { - log.Fatal(err) - } - - msg, err := session.CreateMessage(ctx, &mcp.CreateMessageParams{}) + clientSession, err := c.Connect(ctx, ct, nil) if err != nil { log.Fatal(err) } - fmt.Println(msg.Content.(*mcp.TextContent).Text) + + res, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "sample"}) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) // Output: would have created a message } ``` @@ -140,12 +245,38 @@ you must declare that capability explicitly (see [Capabilities](#capabilities)) **Server-side**: To use elicitation from the server, call [`ServerSession.Elicit`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Elicit). +For protocol versions `2026-07-28` and later, elicitation requests are +delivered via the +[Multi Round-Trip Requests](#multi-round-trip-requests) +pattern. + ```go func Example_elicitation() { ctx := context.Background() ct, st := mcp.NewInMemoryTransports() + // Create a server with a tool that requests elicitation via the multi + // round-trip pattern (SEP-2322): server-to-client requests are no longer + // sent as standalone JSON-RPC calls on protocol version >= 2026-07-28. s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "ask"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"input": &mcp.ElicitParams{ + Message: "This should fail", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "test": {Type: "string"}, + }, + }, + }}, + }, nil, nil + } + res := req.Params.InputResponses["input"].(*mcp.ElicitResult) + fmt.Println(res.Content["test"]) + return &mcp.CallToolResult{}, nil, nil + }) ss, err := s.Connect(ctx, st, nil) if err != nil { log.Fatal(err) @@ -157,26 +288,200 @@ func Example_elicitation() { return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil }, }) - if _, err := c.Connect(ctx, ct, nil); err != nil { - log.Fatal(err) - } - res, err := ss.Elicit(ctx, &mcp.ElicitParams{ - Message: "This should fail", - RequestedSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "test": {Type: "string"}, - }, - }, - }) + clientSession, err := c.Connect(ctx, ct, nil) if err != nil { log.Fatal(err) } - fmt.Println(res.Content["test"]) + if _, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "ask"}); err != nil { + log.Fatal(err) + } // Output: value } ``` +### Schema defaults and enums + +`ElicitParams.RequestedSchema` is a flat schema of primitive fields, which the +client renders as a form. Two field keywords shape that form. + +A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034--support-default-values-for-all-primitive-types-in)) prefills a +field. When the user accepts without supplying it, the SDK fills the field in +from the schema before the result reaches either side's caller — the client +does so after its elicitation handler returns, and `ServerSession.Elicit` does +so again on receipt. This is unconditional; there is no opt-in flag. Marking a +defaulted field `Required` defeats it: accepted content is validated against +the schema before defaults are applied, so an answer that omits the field is +rejected rather than defaulted. + +An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330-elicitation-enum-schema-improvements-and-standards)) restricts a +field to a fixed set of values, which the client renders as a choice. Enums +are supported only on `"string"` fields; declaring one on another type is +rejected. To label the choices, set the legacy `enumNames` keyword through +`Schema.Extra`, with exactly one name per enum value — a mismatched length is +rejected. + +```go +func Example_elicitationSchema() { + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "export_report"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"format": &mcp.ElicitParams{ + Message: "Export quarterly-sales as which format?", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "format": { + Type: "string", + Title: "Format", + Enum: []any{"pdf", "csv"}, + Default: json.RawMessage(`"pdf"`), + Extra: map[string]any{"enumNames": []any{"PDF document", "CSV spreadsheet"}}, + }, + }, + }, + }}, + }, nil, nil + } + res := req.Params.InputResponses["format"].(*mcp.ElicitResult) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "Exported as " + res.Content["format"].(string)}}, + }, nil, nil + }) + if _, err := s.Connect(ctx, st, nil); err != nil { + log.Fatal(err) + } + + // The user accepts without filling anything in. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{}}, nil + }, + }) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "export_report"}) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) + // Output: Exported as pdf +} +``` + +### Completing a URL elicitation + +In URL mode the user finishes out of band, in a browser, so nothing in the +elicitation result tells the client when they are done. The server signals that +with +[`ServerSession.NotifyElicitationComplete`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.NotifyElicitationComplete), +passing the same `ElicitationID` the request carried; the client observes it +through +[`ClientOptions.ElicitationCompleteHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ElicitationCompleteHandler). +Send it from whatever endpoint the hosted flow redirects back to. + +The notification matters most when a handler rejects a request with +[`URLElicitationRequiredError`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#URLElicitationRequiredError): +the client parks the original request until a notification names that +`ElicitationID`, and then retries it automatically. Until one arrives, the +client waits. + +```go +func Example_elicitationComplete() { + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + + done := make(chan struct{}) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}, + }, + ElicitationHandler: func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + fmt.Println("opening", req.Params.URL) + return &mcp.ElicitResult{Action: "accept"}, nil + }, + ElicitationCompleteHandler: func(_ context.Context, req *mcp.ElicitationCompleteNotificationRequest) { + fmt.Println("flow finished:", req.Params.ElicitationID) + close(done) + }, + }) + cs, err := c.Connect(ctx, ct, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"}) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + const elicitationID = "connect-calendar-1" + if _, err := ss.Elicit(ctx, &mcp.ElicitParams{ + Message: "Grant calendar access", + URL: "https://calendar.example.com/consent?state=" + elicitationID, + ElicitationID: elicitationID, + }); err != nil { + log.Fatal(err) + } + + // The hosted page redirects back to the server, whose callback endpoint + // signals that the user is done. + if err := ss.NotifyElicitationComplete(ctx, &mcp.ElicitationCompleteParams{ElicitationID: elicitationID}); err != nil { + log.Fatal(err) + } + <-done + + // Output: + // opening https://calendar.example.com/consent?state=connect-calendar-1 + // flow finished: connect-calendar-1 +} +``` + +## Multi Round-Trip Requests + +[SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) +introduces the MRTR pattern: server-to-client requests for sampling, +elicitation, and roots are no longer issued as fresh JSON-RPC requests but +are carried inside the in-flight reply of a `tools/call`, `prompts/get`, or +`resources/read`. The client must respond by retrying the original request +with the produced responses. + +The SDK installs `clientMultiRoundTripMiddleware` for every client by +default. The middleware: + +1. Inspects each `tools/call`/`prompts/get`/`resources/read` reply. +2. If the result's `NeedsInput()` is true, fans out the `InputRequests` map + concurrently, calling the configured handler for each (`elicit`, + `createMessage`/`createMessageWithTools`, or `listRoots`). +3. Threads the server-supplied opaque `RequestState` back unchanged. +4. Retries the original request with the responses set, repeating until the + result no longer needs input. + +The middleware is enabled by default. To opt out, set +[`ClientOptions.MultiRoundTrip.Disabled = true`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#MultiRoundTripOptions); +the client will then surface input-required results directly to the caller +(the returned `CallToolResult`, `GetPromptResult`, or `ReadResourceResult` +will report `NeedsInput() == true` and expose the server's `InputRequests` +and opaque `RequestState`). Your code must fulfil each request and re-issue +the original call with `InputResponses` set and `RequestState` echoed +back. + +For legacy (`<= 2025-11-25`) servers, the SDK transparently sends server +requests on the legacy server-initiated channel; the MRTR machinery is a +no-op in that direction. For legacy clients talking to MRTR-style servers, +the server SDK applies the inverse compatibility shim — see the +[server-side documentation](server.md#multi-round-trip-requests). + ## Capabilities Client capabilities are advertised to servers during the initialization @@ -233,3 +538,11 @@ client := mcp.NewClient(impl, &mcp.ClientOptions{ }) ``` +### Extensions + +[SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133) +adds an `extensions` map to `ClientCapabilities` and `ServerCapabilities` so +that optional capabilities outside the core protocol can be declared on the +wire. Keys are namespaced as `"{vendor-prefix}/{extension-name}"`; values +are per-extension settings objects. + diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..ea38c9b --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1 @@ +--8<-- "CONTRIBUTING.md" diff --git a/docs/images/logo-dark.svg b/docs/images/logo-dark.svg new file mode 100644 index 0000000..03d9f85 --- /dev/null +++ b/docs/images/logo-dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/docs/images/logo-light.svg b/docs/images/logo-light.svg new file mode 100644 index 0000000..fe5edb7 --- /dev/null +++ b/docs/images/logo-light.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index 8ec5ea6..0000000 --- a/docs/index.html +++ /dev/null @@ -1,15 +0,0 @@ ---- ---- - - - - - MCP Go SDK Documentation - - - - -

Redirecting to MCP Go SDK documentation ({{ site.github.latest_release.tag_name }})...

-

For API reference, see pkg.go.dev.

- - diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index f53ef6d..43a62ce 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -1,5 +1,5 @@ - # Backwards compatibility and MCPGODEBUG +# Backwards compatibility and MCPGODEBUG According to our compatibility promise, we can't break backward compatibility of the SDK API. However, sometimes we need to change the behavior of the SDK @@ -18,6 +18,142 @@ ## `MCPGODEBUG` history +### 1.9.0 + +Options listed below were added and will be removed in the 1.11.0 version of the SDK. + +- `structuredcontentfloat64` added. If set to `1`, numbers in + `CallToolResult.StructuredContent` are decoded as `float64`, restoring the + previous behavior. The default behavior decodes numbers as `json.Number` so + that large integers and other exact JSON values do not silently lose + precision. + +### 1.8.0 + +Options listed below were added and will be removed in the 1.9.0 version of the SDK. + +- `plaintextstatefulrejection` added. If set to `1`, a stateful + `StreamableHTTPHandler` will respond with a plain-text `http.Error` 400 body + when it receives a request carrying per-request metadata (i.e. an + `io.modelcontextprotocol/protocolVersion` `_meta` field, or an + `MCP-Protocol-Version` header >= `2026-07-28`), restoring the previous + behavior. The default behavior was changed so that the server responds with a + JSON-RPC error of code `CodeUnsupportedProtocolVersion` (`-32022`) carrying + an `UnsupportedProtocolVersionData` payload that advertises the legacy + versions the server supports. This lets the client's + existing renegotiation logic recover and prevents the failure from tearing + down the underlying connection. + +- `blockingcancelnotify` added. If set to `1`, a cancelled call waits + synchronously for the best-effort `notifications/cancelled` message to be + delivered (up to `notifyCancellationTimeout`, currently 5 s) before + returning to the caller, restoring the previous behavior. The delivery + error is joined into the caller's returned error. The default behavior was + changed so that the call is retired immediately and the notification is + sent asynchronously off the caller's return path: the caller returns as + soon as its context is cancelled and cannot be delayed by a slow or + unresponsive peer. See issue #1150. + +Options below were removed, according to plan: + +- `seterroroverwrite`. `SetError` now always preserves existing `Content`. + +- `enableoriginverification`. A nil + `StreamableHTTPOptions.CrossOriginProtection` now always means no + cross-origin protection. Wrap the handler with cross-origin protection + middleware instead. + +- `disablecontenttypecheck`. Content-Type validation on HTTP POST requests can + no longer be disabled. + +- `disablelocalhostprotection`, whose removal had been postponed to this + release. Set the `DisableLocalhostProtection` field in + `StreamableHTTPOptions` or `SSEOptions` to opt out of DNS rebinding + protection. + +### 1.7.0 + +Options listed below were added and will be removed in the 1.9.0 version of the SDK. + +- `customresnotfounderrcode` added. If set to `1`, `ResourceNotFoundError` will + use the custom error code `-32002` instead of the standard `-32602` (Invalid + Params), restoring the previous behavior. The default behavior was changed to + align with SEP-2164 and the JSON-RPC specification. + +- `hintomitempty` added. If set to `1`, `ToolAnnotations` JSON marshaling + will omit `ReadOnlyHint` and `IdempotentHint` when their value is `false`, + restoring the previous behavior. The default behavior was changed to always + serialize these fields, since their Go types are bare `bool` (not `*bool`) + and omitting `false` made it indistinguishable from unset. + +- `allowsessionsinstateless` added. If set to `1`, stateless streamable HTTP + servers will read the `Mcp-Session-Id` request header (or generate one via + `GetSessionID`), set it on response headers, and accept `DELETE` requests, + restoring the previous behavior. The default behavior was changed so that + stateless servers ignore session IDs entirely and reject `DELETE` with 405. + +- `nomethodnotfoundcodeinerror` added. If set to `1`, the jsonrpc2 layer will not + include the MethodNotFound Error (`-32601`) in the error response when the + requested method in STDIO transport is not found. The default behavior was + changed to include the MethodNotFound Error in the error response when the + requested method in STDIO transport is not found. + +- `noprotocolerrorbody` added. If set to `1`, the streamable HTTP client will + not attempt to decode the JSON-RPC error body of a non-2xx HTTP response, + and any non-transient error will permanently fail the connection, restoring + the previous behavior. The default behavior was changed so that the client + attempts to decode the JSON-RPC error body of a non-2xx response, surfaces + the underlying JSON-RPC error, and wraps it with `jsonrpc2.ErrRejected` so + that per-call rejections do not tear down the session. + +- `nowrapinvalidparams` added. If set to `1`, the server will not wrap + params-decoding failures with `jsonrpc2.ErrInvalidParams`, so wire responses + carry the zero-value error code `0` instead of `-32602` ("invalid params"), + restoring the previous behavior. The default behavior was changed so that + the server always reports params-decoding failures with the standard + `-32602` code. + +- `disablecompleteparamsvalidation` added. If set to `1`, `Server.complete` + will not validate that the required `ref` and `argument.name` fields on + `CompleteParams` are present, restoring the previous behavior of dispatching + the request to the completion handler unconditionally. The default behavior + was changed to reject malformed requests with `-32602` (Invalid Params). + +### 1.6.1 + +Options listed below were added and will be removed in the 1.8.0 version of the SDK. + +- `disablecontenttypecheck` added. If set to `1`, Content-Type validation on + HTTP POST requests will be disabled, allowing requests with non-JSON or missing + Content-Type headers. The default behavior is to validate that HTTP POST + requests have Content-Type: application/json. + +### 1.6.0 + +Options listed below were added and will be removed in the 1.8.0 version of the SDK. + +- `seterroroverwrite` added. If set to `1`, `SetError` will always overwrite + `Content` with the error text, restoring the previous behavior. The default + behavior was changed to preserve existing `Content` if it has already been + populated. + +- `enableoriginverification` added. If set to `1`, default (zero-value) + cross-origin protection will be applied when + `StreamableHTTPOptions.CrossOriginProtection` is nil, restoring the + behavior from v1.4.1-v1.5.0. The default behavior was changed to not + enable cross-origin protection. + +- `disablelocalhostprotection` removal was postponed until 1.8.0, as it is now + also used in the SSE transport. + +Options below were removed: + +- `jsonescaping`, according to plan, + +- `disablecrossoriginprotection`, it was replaced by + `enableoriginverification` after the default was changed to not enable + cross-origin protection. + ### 1.4.1 Options listed below will be removed in the 1.6.0 version of the SDK. @@ -37,5 +173,6 @@ Options listed below will be removed in the 1.6.0 version of the SDK. - `disablelocalhostprotection` added. If set to `1`, newly added DNS rebinding protection will be disabled. The default behavior was changed to enable DNS rebinding protection. The protection can also be disabled by setting the - `DisableLocalhostProtection` field in the `StreamableHTTPOptions` struct to - `true`, which is the recommended way to disable the protection long term. + `DisableLocalhostProtection` field in the `StreamableHTTPOptions` or + `SSEOptions` struct to `true`, which is the recommended way to disable + the protection long term. **Removal of this option was postponed until 1.8.0.** diff --git a/docs/protocol.md b/docs/protocol.md index f50bbfb..06c1b77 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -2,9 +2,14 @@ # Support for the MCP base protocol 1. [Lifecycle](#lifecycle) + 1. [Discovery](#discovery) + 1. [Per-request metadata keys](#per-request-metadata-keys) + 1. [Per-response metadata keys](#per-response-metadata-keys) + 1. [Subscriptions](#subscriptions) 1. [Transports](#transports) 1. [Stdio Transport](#stdio-transport) 1. [Streamable Transport](#streamable-transport) + 1. [Legacy SSE Transport](#legacy-sse-transport) 1. [Custom transports](#custom-transports) 1. [Concurrency](#concurrency) 1. [Authorization](#authorization) @@ -13,26 +18,36 @@ 1. [Security](#security) 1. [Confused Deputy](#confused-deputy) 1. [Token Passthrough](#token-passthrough) - 1. [Server-Side Request Forgery (SSRF)](#server-side-request-forgery-(ssrf)) + 1. [Server-Side Request Forgery](#server-side-request-forgery) 1. [Session Hijacking](#session-hijacking) + 1. [Issuer Mix-Up](#issuer-mix-up) 1. [Utilities](#utilities) 1. [Cancellation](#cancellation) 1. [Ping](#ping) 1. [Progress](#progress) + 1. [Error codes](#error-codes) ## Lifecycle The SDK provides an API for defining both MCP clients and servers, and connecting them over various transports. When a client and server are -connected, it creates a logical session, which follows the MCP spec's -[lifecycle](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle). +connected, it creates a logical session. -In this SDK, both a -[`Client`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Client) -and -[`Server`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server) -can handle multiple peers. Every time a new peer is connected, it creates a new -session. +The MCP specification defines two lifecycle models, and the SDK supports both +transparently based on the negotiated protocol version: + +- The **legacy `initialize` handshake** + ([lifecycle](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle)) + used by protocol versions through `2025-11-25`. +- A **stateless** model introduced in `2026-07-28` by + [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), + in which there is no `initialize`/`notifications/initialized` handshake, and + each request carries its protocol version and client capabilities in + `_meta`. Clients SHOULD also include their identity (`clientInfo`) on every + request, and servers SHOULD include their identity (`serverInfo`) on every + response. + +In both models, the SDK exposes the same API: - A `Client` is a logical MCP client, configured with various [`ClientOptions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions). @@ -48,11 +63,16 @@ session. [`Server.Connect`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.Connect), it creates a [`ServerSession`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession). - This session is not initialized until the client sends the - `notifications/initialized` message. Use `ServerOptions.InitializedHandler` - to listen for this event, or just use the session through various feature - handlers (such as a `ToolHandler`). Requests to the server are rejected until - the client has initialized the session. + +In the legacy model, the server session is not considered initialized until +the client sends the `notifications/initialized` message. Use +`ServerOptions.InitializedHandler` to listen for this event, or just use the +session through various feature handlers (such as a `ToolHandler`). Requests +to the server are rejected until the client has initialized the session. + +In the stateless model (`2026-07-28`+), there is no handshake. The server +processes the first request the moment it arrives, validates the per-request +`_meta` fields. Both `ClientSession` and `ServerSession` have a `Close` method to terminate the session, and a `Wait` method to await session termination by the peer. Typically, @@ -62,19 +82,13 @@ it is the client's responsibility to end the session. func Example_lifecycle() { ctx := context.Background() - // Create a client and server. - // Wait for the client to initialize the session. + // Create a client and server. Under protocol version 2026-07-28, there + // is no dedicated initialize/initialized handshake: the session is + // implicitly live the moment the client issues its first request. client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) - server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ - InitializedHandler: func(context.Context, *mcp.InitializedRequest) { - fmt.Println("initialized!") - }, - }) + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) // Connect the server and client using in-memory transports. - // - // Connect the server first so that it's ready to receive initialization - // messages from the client. t1, t2 := mcp.NewInMemoryTransports() serverSession, err := server.Connect(ctx, t1, nil) if err != nil { @@ -93,10 +107,86 @@ func Example_lifecycle() { if err := serverSession.Wait(); err != nil { log.Fatal(err) } - // Output: initialized! + // Output: } ``` +### Discovery + +Introduced in `2026-07-28` by +[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), +the `server/discover` RPC lets a client discover a server's supported +protocol versions, capabilities, and identity before issuing any other +request. Servers implementing `2026-07-28` MUST implement it. + +- **Server**: `Server.Connect` registers the + `server/discover` handler automatically; the response is computed from the + server's static configuration (capabilities, server info, instructions) and + the transport-filtered list of supported protocol versions. +- **Client**: `Client.Connect` calls `server/discover` first and + uses the result to negotiate a mutually supported version. If discovery + fails or the server does not support the latest version, the client falls back to the + legacy `initialize` handshake. + +A server advertises and negotiates every protocol version the SDK supports; +set [`ServerOptions.SupportedProtocolVersions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions) +to narrow that set, for example to stop serving a revision your deployment has +retired. + +```go +server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v1.0.0"}, &mcp.ServerOptions{ + SupportedProtocolVersions: []string{"2026-07-28", "2025-11-25"}, +}) +``` + +The set can only narrow support, never widen it; the field's documentation +covers how each protocol era answers a request at an excluded version. + +### Per-request metadata keys + +When the negotiated protocol version is `2026-07-28` or later, every request +carries these keys inside its `_meta` map (constants live in +[`mcp/protocol.go`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#pkg-constants)): + +| Constant | Wire key | Type | Required | +|---|---|---|---| +| `MetaKeyProtocolVersion` | `io.modelcontextprotocol/protocolVersion` | `string` | Yes | +| `MetaKeyClientCapabilities` | `io.modelcontextprotocol/clientCapabilities` | `*ClientCapabilities` | Yes | +| `MetaKeyClientInfo` | `io.modelcontextprotocol/clientInfo` | `*Implementation` | No | +| `MetaKeyLogLevel` | `io.modelcontextprotocol/logLevel` | `LoggingLevel` (deprecated by SEP-2577) | No | + +The client populates the required keys automatically on every outgoing +request, and populates `clientInfo` when configured with an `*Implementation` +(the default). Server-side handlers can read them through +`ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, and +`ServerRequest[P].ClientCapabilities()`. + +### Per-response metadata keys + +Under the same protocol version, servers SHOULD identify themselves on every +response. The SDK populates this +key automatically on every outgoing response: + +| Constant | Wire key | Type | Required | +|---|---|---|---| +| `MetaKeyServerInfo` | `io.modelcontextprotocol/serverInfo` | `*Implementation` | No | + +### Subscriptions + +Introduced in `2026-07-28` by +[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), +`subscriptions/listen` replaces the legacy `resources/subscribe` RPC and +the GET-based SSE endpoint with a single long-lived request that +multiplexes every kind of server-to-client change notification a client +opts in to. On the wire the client sends one `subscriptions/listen` +request whose `notifications` field enumerates what it wants +(`toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, and/or +a list of resource URIs in `resourceSubscriptions`); the server replies +first with a `notifications/subscriptions/acknowledged` notification +reporting the honored subset, then streams every change notification on +the same request, and finally closes with a `SubscriptionsListenResult` +when it tears the subscription down. + ## Transports A @@ -163,7 +253,7 @@ func ExampleStreamableHTTPHandler() { defer httpServer.Close() // The SDK is currently permissive of some missing keys in "params". - resp := mustPostMessage(`{"jsonrpc": "2.0", "id": 1, "method":"initialize", "params": {}}`, httpServer.URL) + resp := mustPostMessage(`{"jsonrpc": "2.0", "id": 1, "method":"initialize", "params": {"protocolVersion":"2025-11-25"}}`, httpServer.URL) fmt.Println(resp) // Output: // {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-11-25","serverInfo":{"name":"server","version":"v0.1.0"}}} @@ -181,12 +271,46 @@ connect to the server: transport := &mcp.StreamableClientTransport{ Endpoint: "http://localhost:8080/mcp", } -client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...}) +client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1.0.0"}, nil) +session, err := client.Connect(ctx, transport, nil) ``` The `StreamableClientTransport` handles the HTTP requests and communicates with the server using the streamable transport protocol. +#### HTTP Headers + +[SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243) +standardises a small set of MCP-specific HTTP headers, sent and validated by +the SDK only for sessions negotiated on protocol version `2026-07-28` or +later. + +| Header | Direction | Purpose | +|---|---|---| +| `Mcp-Protocol-Version` | request | Mirrors `_meta.io.modelcontextprotocol/protocolVersion` from the body | +| `Mcp-Session-Id` | request/response | Logical session identifier (removed in stateless mode) | +| `Mcp-Method` | request | Mirrors the JSON-RPC `method` field; mismatch ⇒ `-32020` | +| `Mcp-Name` | request | Mirrors the request's principal name (`tools/call.params.name`, `prompts/get.params.name`, `resources/read.params.uri`); mismatch ⇒ `-32020` | +| `Mcp-Param-{Header}` | request | Per-tool parameter passthrough; see below | + +The header-name set is exhaustive: for protocol versions earlier than +`2026-07-28`, only `Mcp-Protocol-Version` and `Mcp-Session-Id` are recognised. + +**Body↔header mirroring.** When `Mcp-Method` or `Mcp-Name` is present but +does not match the JSON-RPC body, the server returns +[`CodeHeaderMismatch`](#error-codes) (`-32020`). The same applies to a +mismatch between `Mcp-Protocol-Version` and the body's +`_meta.io.modelcontextprotocol/protocolVersion`. + +**`Mcp-Param-*` passthrough.** A tool may annotate properties of its +`InputSchema` with `x-mcp-header: ""`. When a client invokes that +tool over HTTP, the SDK serializes the matching argument(s) as +`Mcp-Param-` request headers (using `=?base64?...?=` encoding for +non-ASCII values). On the server, the headers are validated against the body +and either accepted or rejected with `-32020`. Properties so annotated must +be typed `string`, `integer`, or `boolean`; tools that fail validation at +registration time are silently dropped from `tools/list`. + #### Resumability and Redelivery By default, the streamable server does not support [resumability or @@ -200,6 +324,13 @@ value. The SDK provides a `MemoryEventStore` for testing or simple use cases; for production use it is generally advisable to use a more sophisticated implementation. +> **Note**: [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575) +> removes SSE stream resumability (`Last-Event-ID`, SSE event IDs) for protocol +> version `2026-07-28`. The SDK preserves the `EventStore` and `Last-Event-ID` +> code paths for backward compatibility with `2025-11-25` and earlier; on +> `2026-07-28` sessions, a broken response stream loses the in-flight request +> and the client must re-issue it as a new request with a new ID. + #### Stateless Mode The streamable server supports a _stateless mode_ by setting @@ -218,10 +349,71 @@ to see the logical session > modelcontextprotocol/modelcontextprotocol#1372, or > modelcontextprotocol/modelcontextprotocol#1442 for potential refinements. -_See [examples/server/distributed](../examples/server/distributed/main.go) for +> **Required for `2026-07-28`**: the streamable HTTP transport accepts +> requests at protocol version `2026-07-28` **only** when `Stateless = true`. +> Requests at that version against a non-stateless handler are rejected. + +_See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/distributed/main.go) for an example using stateless mode to implement a server distributed across multiple processes._ +### Legacy SSE Transport + +Before the streamable transport, the +[2024-11-05](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports) +spec defined an HTTP transport built from two endpoints: a hanging GET that +streams server-to-client messages as server-sent events, and a per-session +endpoint the client POSTs its messages to. The SDK still implements it, for +talking to peers that predate the streamable transport. New deployments should +use the [streamable transport](#streamable-transport) instead. + +It is implemented across three types, mirroring the streamable ones: + +- [`SSEHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEHandler): + an `http.Handler` that creates a session per incoming GET and routes POSTs to + the right session. Construct it with + [`NewSSEHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#NewSSEHandler), + which takes a function returning the `Server` to serve a given request. +- [`SSEServerTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEServerTransport): + one such session. Reach for it directly only when serving the two endpoints + yourself; `SSEHandler` creates these for you. +- [`SSEClientTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEClientTransport): + the client side, which needs only the `Endpoint` of the GET. + +```go +func ExampleSSEHandler() { + server := mcp.NewServer(&mcp.Implementation{Name: "adder", Version: "v0.0.1"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: "add", Description: "add two numbers"}, Add) + + handler := mcp.NewSSEHandler(func(*http.Request) *mcp.Server { return server }, nil) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + ctx := context.Background() + transport := &mcp.SSEClientTransport{Endpoint: httpServer.URL} + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v1.0.0"}, nil) + cs, err := client.Connect(ctx, transport, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.CallTool(ctx, &mcp.CallToolParams{ + Name: "add", + Arguments: map[string]any{"x": 1, "y": 2}, + }) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) + + // Output: 3 +} +``` + +_See [examples/server/sse](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/sse/main.go) +for a standalone server._ + ### Custom transports The SDK supports [custom @@ -230,7 +422,7 @@ by implementing the [`Transport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Transport) interface: a logical bidirectional stream of JSON-RPC messages. -_Full example: [examples/server/custom-transport](../examples/server/custom-transport/main.go)._ +_Full example: [examples/server/custom-transport](https://github.com/modelcontextprotocol/go-sdk/blob/main//examples/server/custom-transport/main.go)._ ### Concurrency @@ -322,6 +514,7 @@ This handler supports: - [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents) - [Pre-registered clients](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration) - [Dynamic Client Registration](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration) +- [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) Authorization Server Issuer Identification To use it, configure the handler and assign it to the transport: @@ -333,11 +526,12 @@ authHandler, _ := auth.NewAuthorizationCodeHandler(&auth.AuthorizationCodeHandle // PreregisteredClientConfig: ... // DynamicClientRegistrationConfig: ... AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { - // Open the args.URL in a browser and return the resulting code and state. + // Open the args.URL in a browser and return the resulting code, state, and iss. // See full example in examples/auth/client/main.go. code := ... state := ... - return &auth.AuthorizationResult{Code: code, State: state}, nil + iss := ... // "iss" query parameter from the redirect URI (RFC 9207) + return &auth.AuthorizationResult{Code: code, State: state, Iss: iss}, nil }, }) @@ -443,26 +637,26 @@ of tokens and is the responsibility of the provided to [`RequireBearerToken`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerToken). -### Server-Side Request Forgery (SSRF) +### Server-Side Request Forgery -The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) are as follows: +The OAuth discovery helpers apply these [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) by default: -- _Enforce HTTPS_. The OAuth helpers provided by the SDK reject the `http://` URLs -except loopback addresses (`localhost`, `127.0.0.1`, `::1`). +- _Enforce HTTPS_. Reject `http://` URLs (except loopback: `localhost`, `127.0.0.1`, `::1`) +and redirects that downgrade `https` to a non-`https` scheme. -- _Block Private IP Ranges_. The OAuth helpers provided by the SDK allow passing -a custom `http.Client`. Developers are advised to customize the client it with -appropriate network protections, including IP range blocking. The SDK does not provide -this capability out of the box. +- _Block Private IP Ranges_. Reject targets that resolve to a non-public address +(private, link-local including the `169.254.169.254` metadata endpoint, CGNAT, multicast, +unspecified). The check runs on the initial URL and at dial time, guarding against DNS rebinding. -- _Validate Redirect Targets_. Similarly to previous point, customized `http.Client` -can be used to validate network hops. The SDK does not provide this capability out -of the box. +- _Validate Redirect Targets_. Reject redirects to non-public addresses and cap the redirect count. -- _Use Egress Proxies_. This is out of scope for the SDK and can be configured separately. +- _Use Egress Proxies_. Out of scope for the SDK; configure separately. -- _DNS Resolution Considerations_. The SDK has DNS rebinding protection on the server side which is enabled by default. For the client side, consider providing -a custom `http.Client` that would implement DNS pinning. +**Opting out.** Passing a custom `http.Client` can bypass these checks, making SSRF +protection your responsibility. Specifically: a custom `Transport.DialContext`/`DialTLSContext`, +a non-`*http.Transport`, or a configured `Proxy` disables the dial-time IP check; setting +`CheckRedirect` replaces all redirect validation. In these cases, implement your own IP +blocking, redirect validation, and DNS pinning. ### Session Hijacking @@ -490,6 +684,22 @@ sets `UserID` on the returned `TokenInfo`, the streamable transport will: `TokenInfo.UserID` to enable this protection. This prevents an attacker with a valid token from hijacking another user's session by guessing or obtaining their session ID. +### Issuer Mix-Up + +The [mitigation](https://www.rfc-editor.org/rfc/rfc9207) against issuer mix-up attacks is +implemented per [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207). The SDK client validates +the `iss` parameter in authorization responses to ensure they originated from the expected +authorization server: + +- If `iss` is present in the redirect URI, the SDK verifies it matches the issuer from the + authorization server's metadata. A mismatch results in an error. +- If `iss` is absent but the authorization server advertises + `authorization_response_iss_parameter_supported: true` in its [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414) + metadata, the SDK rejects the response with an error. + +The `AuthorizationCodeFetcher` is responsible for extracting the `iss` query parameter from +the redirect URI and returning it in [`AuthorizationResult.Iss`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#AuthorizationResult). + ## Utilities ### Cancellation @@ -578,6 +788,13 @@ session if the ping fails, set or [`ServerOptions.KeepAlive`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.KeepAlive). +> **Note**: `ping` is removed from the protocol as of `2026-07-28` by +> [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575). +> The SDK preserves the API for backward compatibility with legacy clients; +> when both peers negotiate `2026-07-28`, the server returns +> `MethodNotFound` (`-32601`) for `ping` and `KeepAlive` should not be +> enabled. + ### Progress [Progress](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress) @@ -639,3 +856,22 @@ func Example_progress() { } ``` +### Error codes + +The SDK uses the standard JSON-RPC base codes (parse error `-32700`, +invalid request `-32600`, method not found `-32601`, invalid params +`-32602`, internal error `-32603`) plus the following MCP-specific codes: + +| Constant | Code | Meaning | +|---|---|---| +| [`CodeHeaderMismatch`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CodeHeaderMismatch) | `-32020` | An MCP HTTP header (`Mcp-Method`, `Mcp-Name`, `Mcp-Protocol-Version`, or `Mcp-Param-*`) does not match the JSON-RPC body | +| [`CodeMissingRequiredClientCapabilities`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CodeMissingRequiredClientCapabilities) | `-32021` | Server requires client capabilities the client did not declare | +| [`CodeUnsupportedProtocolVersion`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CodeUnsupportedProtocolVersion) | `-32022` | Requested protocol version not supported. Data: `UnsupportedProtocolVersionData{Supported []string, Requested string}` | +| [`CodeResourceNotFound`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CodeResourceNotFound) | `-32602` | Resource URI not found (variable; was `-32002` before SEP-2164) | + +The error code allocation policy defined in `2026-07-28` +([SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)) +partitions the JSON-RPC server-error range: + +- `-32000` to `-32019`: implementation-defined; existing SDK usage is grandfathered. +- `-32020` to `-32099`: reserved for the MCP specification. diff --git a/docs/quick_start.md b/docs/quick_start.md new file mode 100644 index 0000000..995b378 --- /dev/null +++ b/docs/quick_start.md @@ -0,0 +1,105 @@ + +# Quick Start + +1. [Installation](#installation) +1. [Getting started](#getting-started) + +## Installation + +``` +go get github.com/modelcontextprotocol/go-sdk/mcp +``` + +## Getting started + +To get started creating an MCP server, create an `mcp.Server` instance, add +features to it, and then run it over an `mcp.Transport`. For example, this +server adds a single simple tool, and then connects it to clients over +stdin/stdout: + +```go +package main + +import ( + "context" + "log" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type Input struct { + Name string `json:"name" jsonschema:"the name of the person to greet"` +} + +type Output struct { + Greeting string `json:"greeting" jsonschema:"the greeting to tell to the user"` +} + +func SayHi(ctx context.Context, req *mcp.CallToolRequest, input Input) ( + *mcp.CallToolResult, + Output, + error, +) { + return nil, Output{Greeting: "Hi " + input.Name}, nil +} + +func main() { + // Create a server with a single tool. + server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v1.0.0"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi) + // Run the server over stdin/stdout, until the client disconnects. + if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { + log.Fatal(err) + } +} +``` + +To communicate with that server, create an `mcp.Client` and connect it to the +corresponding server, by running the server command and communicating over its +stdin/stdout: + +```go +package main + +import ( + "context" + "log" + "os/exec" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func main() { + ctx := context.Background() + + // Create a new client, with no features. + client := mcp.NewClient(&mcp.Implementation{Name: "mcp-client", Version: "v1.0.0"}, nil) + + // Connect to a server over stdin/stdout. + transport := &mcp.CommandTransport{Command: exec.Command("myserver")} + session, err := client.Connect(ctx, transport, nil) + if err != nil { + log.Fatal(err) + } + defer session.Close() + + // Call a tool on the server. + params := &mcp.CallToolParams{ + Name: "greet", + Arguments: map[string]any{"name": "you"}, + } + res, err := session.CallTool(ctx, params) + if err != nil { + log.Fatalf("CallTool failed: %v", err) + } + if res.IsError { + log.Fatal("tool failed") + } + for _, c := range res.Content { + log.Print(c.(*mcp.TextContent).Text) + } +} +``` + +The [`examples/`](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples) directory contains more example clients and +servers. diff --git a/docs/rough_edges.md b/docs/rough_edges.md index e0e6d73..c1a618d 100644 --- a/docs/rough_edges.md +++ b/docs/rough_edges.md @@ -59,3 +59,12 @@ v2. wrapper) we need to first unmarshal into a `map[string]any` in order to do server-side validation of required fields. CallToolParams could have just had a map[string]any. + +- `StreamableHTTPOptions.CrossOriginProtection` should not have been part of + the SDK API. Cross-origin protection is a general HTTP concern, not specific + to MCP, and can be applied as standard HTTP middleware. + +- `ToolAnnotations` (`mcp/protocol.go`) should have all fields typed as `*bool` + for full control to define what is being sent over the wire. Different + MCP clients have different requirements, and some of them require all fields + to be explicitly set to either `true` or `false`. diff --git a/docs/server.md b/docs/server.md index e8c19f5..8973e3a 100644 --- a/docs/server.md +++ b/docs/server.md @@ -2,14 +2,24 @@ # Support for MCP server features 1. [Prompts](#prompts) + 1. [Prompt message content](#prompt-message-content) 1. [Resources](#resources) + 1. [Binary resources](#binary-resources) + 1. [Resource subscriptions](#resource-subscriptions) 1. [Tools](#tools) + 1. [Tool result content](#tool-result-content) +1. [List changed notifications](#list-changed-notifications) +1. [Multi Round-Trip Requests](#multi-round-trip-requests) + 1. [Talking to legacy clients](#talking-to-legacy-clients) + 1. [Example](#example) +1. [Cacheable list results](#cacheable-list-results) 1. [Utilities](#utilities) 1. [Completion](#completion) 1. [Logging](#logging) 1. [Capabilities](#capabilities) 1. [Capability inference](#capability-inference) 1. [Explicit capabilities](#explicit-capabilities) + 1. [Extensions](#extensions) 1. [Pagination](#pagination) ## Prompts @@ -110,6 +120,91 @@ func Example_prompts() { } ``` +### Prompt message content + +A +[`PromptMessage`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#PromptMessage) +has two fields: a `Role`, either `"user"` or `"assistant"`, and a single +`Content`. That is the same interface a tool result uses (see [Tool result +content](#tool-result-content)), so a prompt message can hold an image, audio, +or a resource's contents as readily as text. + +Media on its own says nothing about what the model should do with it, so send a +text message alongside it. Embedding a resource puts its contents directly in +the message, sparing the client a separate `resources/read`. Note that +`ResourceContents.URI` is not checked against the resources the server has +registered: it records where the contents came from, so use the URI of a real +resource when there is one, as in the example below. + +```go +func Example_promptContent() { + ctx := context.Background() + + const styleGuide = "- Prefer clarity over cleverness.\n" + screenshotPNG := []byte("\x89PNG\r\n\x1a\n") + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + + s.AddResource(&mcp.Resource{URI: "doc://style-guide", MIMEType: "text/markdown"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: "doc://style-guide", + MIMEType: "text/markdown", + Text: styleGuide, + }}, + }, nil + }) + + s.AddPrompt(&mcp.Prompt{Name: "review_screenshot"}, + func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{ + Messages: []*mcp.PromptMessage{ + {Role: "user", Content: &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ + URI: "doc://style-guide", + MIMEType: "text/markdown", + Text: styleGuide, + }, + }}, + {Role: "user", Content: &mcp.ImageContent{Data: screenshotPNG, MIMEType: "image/png"}}, + {Role: "user", Content: &mcp.TextContent{Text: "Review the screenshot against the style guide."}}, + }, + }, nil + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{Name: "review_screenshot"}) + if err != nil { + log.Fatal(err) + } + for _, msg := range res.Messages { + switch content := msg.Content.(type) { + case *mcp.TextContent: + fmt.Println(msg.Role, "text:", content.Text) + case *mcp.ImageContent: + fmt.Printf("%s image: %s, %d bytes\n", msg.Role, content.MIMEType, len(content.Data)) + case *mcp.EmbeddedResource: + fmt.Printf("%s embedded resource: %s (%s)\n", msg.Role, content.Resource.URI, content.Resource.MIMEType) + } + } + // Output: + // user embedded resource: doc://style-guide (text/markdown) + // user image: image/png, 8 bytes + // user text: Review the screenshot against the style guide. +} +``` + ## Resources In MCP terms, a _resource_ is some data referenced by a URI. @@ -142,7 +237,12 @@ and to unsubscribe. Set [`ClientOptions.ResourceUpdatedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ResourceUpdatedHandler) -to be notified of changes to subscribed resources. +to be notified of changes to subscribed resources. On `2026-07-28` and +later sessions the SDK delivers these notifications over a +`subscriptions/listen` stream instead of the legacy `resources/subscribe` +RPC; see [Subscriptions +(`subscriptions/listen`)](protocol.md#subscriptions) +for the wire-level details. **Server-side**: Use @@ -236,6 +336,145 @@ func Example_resources() { } ``` +### Binary resources + +A +[`ResourceContents`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceContents) +carries its data in either `Text` or `Blob`. Binary data goes in `Blob` as +plain bytes — the SDK base64-encodes it on the wire — and leaves `Text` empty, +so a client tells the two apart by which field is populated. + +```go +func Example_binaryResource() { + ctx := context.Background() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + s.AddResource(&mcp.Resource{URI: "file:///logo.png", MIMEType: "image/png"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: "file:///logo.png", + MIMEType: "image/png", + Blob: []byte("\x89PNG\r\n\x1a\n"), + }}, + }, nil + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: "file:///logo.png"}) + if err != nil { + log.Fatal(err) + } + contents := res.Contents[0] + fmt.Printf("%s: %d bytes of %s, text is %q\n", + contents.URI, len(contents.Blob), contents.MIMEType, contents.Text) + + // Output: + // file:///logo.png: 8 bytes of image/png, text is "" +} +``` + +### Resource subscriptions + +A client interested in changes to one resource subscribes to its URI with +[`ClientSession.Subscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Subscribe) +and stops with +[`ClientSession.Unsubscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Unsubscribe). +Set +[`ServerOptions.SubscribeHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.SubscribeHandler) +and +[`UnsubscribeHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.UnsubscribeHandler) +to track who is listening; setting either one gives the server the +`resources.subscribe` capability. + +Publish a change with +[`Server.ResourceUpdated`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.ResourceUpdated), +which notifies every session subscribed to that URI. The notification carries +only the URI, not the new contents, so a client that wants them reads the +resource again. + +```go +func Example_resourceSubscription() { + ctx := context.Background() + + config := "theme=light\n" + + updated := make(chan string, 1) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + // The notification carries only the URI, so read the resource again to + // see what it now holds. + ResourceUpdatedHandler: func(context.Context, *mcp.ResourceUpdatedNotificationRequest) { + updated <- "config://app" + }, + }) + + subscribed := make(chan string, 2) + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + SubscribeHandler: func(_ context.Context, req *mcp.SubscribeRequest) error { + subscribed <- "subscribed to " + req.Params.URI + return nil + }, + UnsubscribeHandler: func(_ context.Context, req *mcp.UnsubscribeRequest) error { + subscribed <- "unsubscribed from " + req.Params.URI + return nil + }, + }) + s.AddResource(&mcp.Resource{URI: "config://app"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{URI: "config://app", Text: config}}, + }, nil + }) + + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + if err := cs.Subscribe(ctx, &mcp.SubscribeParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + fmt.Println(<-subscribed) + + config = "theme=dark\n" + if err := s.ResourceUpdated(ctx, &mcp.ResourceUpdatedNotificationParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + uri := <-updated + + res, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: uri}) + if err != nil { + log.Fatal(err) + } + fmt.Print("re-read: ", res.Contents[0].Text) + + if err := cs.Unsubscribe(ctx, &mcp.UnsubscribeParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + fmt.Println(<-subscribed) + + // Output: + // subscribed to config://app + // re-read: theme=dark + // unsubscribed from config://app +} +``` + ## Tools MCP servers can provide @@ -287,7 +526,7 @@ In order to implement a tool, the user must do all of the following: - Unmarshal the input schema into a Go value - Execute the tool logic. - Marshal the tool's structured output (if any) to JSON, and store it in the - result's `StructuredOutput` field as well as the unstructured `Content` field. + result's `StructuredContent` field as well as the unstructured `Content` field. - Validate that output JSON against the tool's output schema. - If any tool errors occurred, pack them into the unstructured content and set `IsError` to `true.` @@ -312,13 +551,17 @@ mcp.AddTool(server, &mcp.Tool{Name: "my_tool"}, handler) This does the following automatically: -- If `Tool.InputSchema` or `Tool.OutputSchema` are unset, the input and output - schemas are inferred from the `In` type, which must be a struct or map. - Optional `jsonschema` struct tags provide argument descriptions. +- If `Tool.InputSchema` is unset, the input schema is inferred from the `In` + type, which must be a struct or map. +- If `Tool.OutputSchema` is unset and the `Out` type is not `any`, the output + schema is inferred from the `Out` type. Per SEP-2106, `Out` may be any Go + type whose inferred schema is a valid JSON Schema (struct, map, slice, + primitive, etc.). +- Optional `jsonschema` struct tags provide argument and output descriptions. - Tool arguments are validated against the input schema. - Tool arguments are marshaled into the `In` value. - Tool output (the `Out` value) is marshaled into the result's - `StructuredOutput`, as well as the unstructured `Content`. + `StructuredContent`, as well as the unstructured `Content`. - Output is validated against the tool's output schema. - If an ordinary error is returned, it is stored int the `CallToolResult` and `IsError` is set to `true`. @@ -399,10 +642,107 @@ mcp.AddTool(server, &mcp.Tool{ }, WeatherTool) ``` -_See [mcp/tool_example_test.go](../mcp/tool_example_test.go) for the full -example, or [examples/server/toolschemas](examples/server/toolschemas/main.go) +_See [mcp/tool_example_test.go](https://github.com/modelcontextprotocol/go-sdk/blob/main/mcp/tool_example_test.go) for the full +example, or [examples/server/toolschemas](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/toolschemas/main.go) for more examples of customizing tool schemas._ +### Tool result content + +Alongside its structured output, a tool result carries a list of +[`Content`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Content) +blocks in `CallToolResult.Content`, and may mix as many kinds as it needs: + +| Type | Carries | +| --- | --- | +| [`TextContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#TextContent) | plain text | +| [`ImageContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ImageContent) | image data, with a MIME type | +| [`AudioContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#AudioContent) | audio data, with a MIME type | +| [`EmbeddedResource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#EmbeddedResource) | the contents of a resource, inline | +| [`ResourceLink`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceLink) | a reference to a resource the client can read separately | + +`ImageContent.Data` and `AudioContent.Data` are plain `[]byte`; the SDK +base64-encodes them on the wire, so handlers never encode by hand, and base64 +carried over from another MCP SDK must be decoded before it is assigned. An +`EmbeddedResource` wraps a +[`ResourceContents`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceContents), +which holds either `Text` or, for binary data, `Blob`. Prefer a `ResourceLink` +when the client may not need the contents, since an embedded resource is +transferred whether it is used or not. + +When a handler bound with `AddTool` leaves `Content` unset, the SDK fills it +with the JSON encoding of the output value; set `Content` explicitly, as below, +to return anything else. `StructuredContent` is still populated from the output +value either way. + +```go +func ExampleAddTool_contentTypes() { + chartPNG := []byte("\x89PNG\r\n\x1a\n") + summaryWAV := []byte("RIFF....WAVE") + + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: "report"}, func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Q3 revenue by region."}, + &mcp.ImageContent{Data: chartPNG, MIMEType: "image/png"}, + &mcp.AudioContent{Data: summaryWAV, MIMEType: "audio/wav"}, + // An embedded resource inlines the contents, so the client + // needs no follow-up resources/read. Binary contents go in + // ResourceContents.Blob instead of Text. + &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ + URI: "file:///reports/q3.csv", + MIMEType: "text/csv", + Text: "region,revenue\nEMEA,42\n", + }, + }, + // A resource link only points at a resource. Prefer it when the + // client may not need the contents, since an embedded resource + // is transferred either way. + &mcp.ResourceLink{URI: "file:///reports/q3.pdf", MIMEType: "application/pdf"}, + }, + }, nil, nil + }) + + ctx := context.Background() + session, err := connect(ctx, server) + if err != nil { + log.Fatal(err) + } + defer session.Close() + + res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "report"}) + if err != nil { + log.Fatal(err) + } + + // Clients type-switch over the content they receive. + for _, content := range res.Content { + switch c := content.(type) { + case *mcp.TextContent: + fmt.Println("text:", c.Text) + case *mcp.ImageContent: + fmt.Printf("image: %s, %d bytes\n", c.MIMEType, len(c.Data)) + case *mcp.AudioContent: + fmt.Printf("audio: %s, %d bytes\n", c.MIMEType, len(c.Data)) + case *mcp.EmbeddedResource: + fmt.Printf("embedded resource: %s (%s)\n", c.Resource.URI, c.Resource.MIMEType) + case *mcp.ResourceLink: + fmt.Printf("resource link: %s (%s)\n", c.URI, c.MIMEType) + } + } + // Output: + // text: Q3 revenue by region. + // image: image/png, 8 bytes + // audio: audio/wav, 12 bytes + // embedded resource: file:///reports/q3.csv (text/csv) + // resource link: file:///reports/q3.pdf (application/pdf) +} +``` + +`ToolUseContent` and `ToolResultContent` also implement `Content`, but are only +valid in sampling messages, not in tool results. + **Stateless server deployments:** Some deployments create a new [`Server`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server) for each incoming request, re-registering tools every time. To avoid repeated @@ -420,6 +760,248 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { } ``` +## List changed notifications + +Adding or removing a feature on a connected server sends the matching +`notifications/*/list_changed` to every client, which dispatches it to +[`ClientOptions.ToolListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ToolListChangedHandler), +[`PromptListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.PromptListChangedHandler), +or +[`ResourceListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ResourceListChangedHandler). +A notification reports only that the list changed, so a client that needs the +new contents lists them again. + +These notifications ride on capabilities, and capabilities are inferred from +what is registered before [`Server.Connect`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.Connect) +(see [Capabilities](#capabilities)). A server that registers its features +afterwards, as the example below does, has to declare +`ServerOptions.HasTools`, `HasPrompts`, or `HasResources` itself, or the +client is never told. + +```go +func Example_listChanged() { + ctx := context.Background() + + changed := make(chan string, 3) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) { + changed <- "tools" + }, + PromptListChangedHandler: func(context.Context, *mcp.PromptListChangedRequest) { + changed <- "prompts" + }, + ResourceListChangedHandler: func(context.Context, *mcp.ResourceListChangedRequest) { + changed <- "resources" + }, + }) + + // Nothing is registered before Connect, so the capabilities that carry these + // notifications have to be declared explicitly. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + HasTools: true, + HasPrompts: true, + HasResources: true, + }) + + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + mcp.AddTool(s, &mcp.Tool{Name: "greet"}, func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{}, nil, nil + }) + fmt.Println("changed:", <-changed) + + s.AddPrompt(&mcp.Prompt{Name: "review"}, func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{}, nil + }) + fmt.Println("changed:", <-changed) + + s.AddResource(&mcp.Resource{URI: "file:///a"}, func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{}, nil + }) + fmt.Println("changed:", <-changed) + + // Removing a feature notifies the client just as adding one does. + s.RemoveTools("greet") + fmt.Println("changed:", <-changed) + + // Output: + // changed: tools + // changed: prompts + // changed: resources + // changed: tools +} +``` + +## Multi Round-Trip Requests + +[SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) +defines a new pattern for server-to-client requests (sampling, elicitation, +roots). Instead of issuing a fresh JSON-RPC request mid-flight, the server +returns an `InputRequiredResult` from its in-flight handler — the +`InputRequests` field of `CallToolResult`, `GetPromptResult`, or +`ReadResourceResult` carries the requests for additional information. The +client responds by retrying the original call with `InputResponses` set. + +The SDK supports this pattern from both sides without requiring callers to +choose: + +- **Returning input requests from a handler.** Set `InputRequests` on the + result you return. + Leave `Content` / `StructuredContent` empty — returning + both at once is a server bug and the SDK returns `-32603 InternalError`. +- **Calling the legacy APIs.** `ServerSession.Elicit`, + `ServerSession.CreateMessage(WithTools)`, and `ServerSession.ListRoots` + remain available and work for both new and legacy clients. + +### Talking to legacy clients + +The server installs `serverMultiRoundTripMiddleware` automatically. For +clients on a protocol version earlier than `2026-07-28`, the middleware +intercepts any `InputRequiredResult` your handler returns, fulfils each +input request itself by calling the legacy server-initiated APIs +(`Elicit`, `CreateMessage`, `ListRoots`), and re-invokes your handler +exactly once with the responses already populated. This means a handler +written in the MRTR style works against both old and new clients without +code changes. + +### Example + +The following example shows a "greet" tool whose handler asks the user +for their name via elicitation before producing the final greeting. The +handler runs twice — once to issue the elicitation, once to consume the +response — but the client call site sees a single `CallTool` returning the +final result, because the SDK's MRTR middleware handles the round trip on +either side of the wire (depending on the negotiated protocol version). + +```go +// Example_mrtr demonstrates the [Multi Round-Trip Requests] pattern +// (SEP-2322). A tool handler signals "I need more information from the user" +// by returning a [mcp.CallToolResult] whose `InputRequests` field carries the +// requests for the additional information. Each request can be an +// [mcp.ElicitParams] (ask the user a question), +// [mcp.CreateMessageParams] (sample from the client's LLM), or +// [mcp.ListRootsParams] (list the client's roots). +// +// On protocol version `2026-07-28` and later, the client's +// `clientMultiRoundTripMiddleware` fulfils each input request from the +// configured handler and retries the original call transparently. On earlier +// protocol versions, the server's `serverMultiRoundTripMiddleware` performs +// the equivalent dance from the server side by calling the legacy +// server-to-client API (`ServerSession.Elicit`, `CreateMessage`, or +// `ListRoots`) and re-invoking the handler with the response. Either way, +// the user-facing client call site sees only the final result. +// +// [Multi Round-Trip Requests]: https://modelcontextprotocol.io/specification/draft/basic/patterns#multi-round-trip-requests +func Example_mrtr() { + ctx := context.Background() + + // Server: a "greet" tool that asks the user for their name before + // returning a greeting. The handler runs twice per logical call: + // once to issue the elicitation, once to consume the response. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "greet"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + // First call: ask the user for their name. The map key + // ("user_name") is a label the handler chooses; the client must + // echo it back in InputResponses. The wire-level method name + // ("elicitation/create") is derived from the value's Go type. + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "user_name": &mcp.ElicitParams{ + Message: "What's your name?", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": {Type: "string"}, + }, + }, + }, + }, + // RequestState is an opaque token the client echoes back on + // the retry, letting the handler resume its work without + // per-session storage. + RequestState: "step=1", + }, nil, nil + } + // Retry: read the elicitation response and produce the final greeting. + name := req.Params.InputResponses["user_name"].(*mcp.ElicitResult).Content["name"].(string) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "Hello " + name}}, + }, nil, nil + }) + + // Client: declares an elicitation handler. The SDK's MRTR middleware + // uses it to fulfil any input request the tool handler returns. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"name": "MCP Go"}}, nil + }, + }) + + ct, st := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, st, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + // Single call site. The MRTR round trip is invisible to the caller. + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "greet"}) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) +} +``` + +## Cacheable list results + +[SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549) +adds `ttlMs` and `cacheScope` fields to the results of `tools/list`, +`prompts/list`, `resources/list`, `resources/templates/list`, and +`resources/read`. Both fields complement existing list +results: clients use `ttlMs` as a freshness hint to reduce polling, +and `cacheScope` (`"public"` or `"private"`) controls whether shared +intermediaries may cache the response. + +**Server-side defaults.** The SDK fills in `CacheScope = "public"` whenever a +result leaves it empty, since the field is required on the wire. Everything +else is left as produced: a `ResourceHandler` that sets `TTLMs` or `CacheScope` +on its `ReadResourceResult` keeps those values. The list results and +`server/discover` are built by the SDK itself, so they start out with +`TTLMs = 0`, which a client cache treats as "immediately stale". + +**Setting a policy.** `ServerOptions.SetCacheable` decides the fields for every +result that carries them. It runs after the corresponding handler, receives +what that handler produced, and may keep, adjust, or replace it. The request is +passed in so that policy can vary by method, session, or authorization context. + +```go +server := mcp.NewServer(impl, &mcp.ServerOptions{ + SetCacheable: func(_ context.Context, req mcp.Request, c *mcp.Cacheable) { + // A 30s freshness hint, except where the handler chose its own. + if c.TTLMs == 0 { + c.TTLMs = 30_000 + } + }, +}) +``` + +`SetCacheable` may run with the server's lock held, so it must not call back +into the `Server`: adding or removing a feature, or ranging over +`Server.Sessions`, deadlocks. + ## Utilities ### Completion @@ -470,6 +1052,13 @@ _ = mcp.NewServer(&mcp.Implementation{Name: "server"}, &mcp.ServerOptions{ ### Logging +> **Note:** The logging feature is deprecated as of protocol version +> 2026-07-28 +> ([SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging)). +> It remains fully functional during the deprecation window (at least twelve +> months). The SDK continues to support logging for compatibility. Servers +> should migrate to stderr logging (for STDIO transports) or OpenTelemetry. + MCP servers can send logging messages to MCP clients. (This form of logging is distinct from server-side logging, where the server produces logs that remain server-side, for use by server maintainers.) @@ -477,8 +1066,12 @@ server produces logs that remain server-side, for use by server maintainers.) **Server-side**: The minimum log level is part of the server state. For stateful sessions, there is no default log level: no log messages will be sent -until the client calls `SetLevel` (see below). -For stateful sessions, the level defaults to "info". +until the client calls `SetLoggingLevel` (see below). +For legacy stateless sessions the level defaults to "info". +For sessionless 2026-07-28 requests there is no server-side default: each request +carries its level in `_meta` (`io.modelcontextprotocol/logLevel`), and nothing is +logged until the client sends one. +(Note: logging is deprecated as of 2026-07-28 / SEP-2577.) [`ServerSession.Log`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Log) is the low-level way for servers to log to clients. It sends a logging notification to the client if the level of the message @@ -494,7 +1087,7 @@ Servers always report the logging capability. **Client-side**: Set [`ClientOptions.LoggingMessageHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.LoggingMessageHandler) to receive log messages. -Call [`ClientSession.SetLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLevel) to change the log level for a session. +Call [`ClientSession.SetLoggingLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLoggingLevel) to change the log level for a session. ```go func Example_logging() { @@ -602,6 +1195,14 @@ server := mcp.NewServer(impl, &mcp.ServerOptions{ **Deprecated**: The `HasPrompts`, `HasResources`, and `HasTools` fields on `ServerOptions` are deprecated. Use `Capabilities` instead. +### Extensions + +[SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133) +adds an `extensions` map to `ServerCapabilities` so that optional +capabilities outside the core protocol can be declared on the wire. Keys +are namespaced as `"{vendor-prefix}/{extension-name}"`; values are +per-extension settings objects. + ### Pagination Server-side feature lists may be diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1a0c367..950eef7 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -50,9 +50,8 @@ func ExampleLoggingTransport() { } // Output: - // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-11-25","serverInfo":{"name":"server","version":"v0.0.1"}}} - // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-11-25","capabilities":{"roots":{"listChanged":true}}}} - // write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} + // read: {"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"server","version":"v0.0.1"}},"ttlMs":0,"cacheScope":"public","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"capabilities":{"logging":{}}}} + // write: {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/clientCapabilities":{"roots":{"listChanged":true}},"io.modelcontextprotocol/clientInfo":{"name":"client","version":"v0.0.1"},"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}} } ``` diff --git a/examples/auth/client/main.go b/examples/auth/client/main.go index f514eba..7e5d464 100644 --- a/examples/auth/client/main.go +++ b/examples/auth/client/main.go @@ -36,6 +36,7 @@ func (r *codeReceiver) serveRedirectHandler(listener net.Listener) { r.authChan <- &auth.AuthorizationResult{ Code: req.URL.Query().Get("code"), State: req.URL.Query().Get("state"), + Iss: req.URL.Query().Get("iss"), } fmt.Fprint(w, "Authentication successful. You can close this window.") }) diff --git a/examples/http/README.md b/examples/http/README.md index 3bd8c93..ae6bccb 100644 --- a/examples/http/README.md +++ b/examples/http/README.md @@ -15,7 +15,7 @@ The example implements: ```bash go run . server ``` -This starts an MCP server on `http://localhost:8080` (default) that provides a `cityTime` tool. +This starts an MCP server on `http://localhost:8000` (default) that provides a `cityTime` tool. To run a client in another terminal: diff --git a/examples/server/auth-middleware/go.mod b/examples/server/auth-middleware/go.mod index 8690256..f1bfc37 100644 --- a/examples/server/auth-middleware/go.mod +++ b/examples/server/auth-middleware/go.mod @@ -1,16 +1,16 @@ module auth-middleware-example -go 1.23.0 +go 1.25.0 require ( - github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/modelcontextprotocol/go-sdk v0.3.0 ) require ( github.com/google/jsonschema-go v0.4.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect ) replace github.com/modelcontextprotocol/go-sdk => ../../../ diff --git a/examples/server/auth-middleware/go.sum b/examples/server/auth-middleware/go.sum index d257e10..a14b134 100644 --- a/examples/server/auth-middleware/go.sum +++ b/examples/server/auth-middleware/go.sum @@ -1,5 +1,6 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= @@ -9,5 +10,6 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= diff --git a/examples/server/custom-method/main.go b/examples/server/custom-method/main.go new file mode 100644 index 0000000..6b2861f --- /dev/null +++ b/examples/server/custom-method/main.go @@ -0,0 +1,110 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +// The custom-method example demonstrates registering and calling a custom +// JSON-RPC method that is not part of the standard MCP spec. +// +// The server registers a "latin/translate" method that translates simple +// English phrases into Latin. A client connects over an in-memory transport, +// calls the custom method, and prints the result. +package main + +import ( + "context" + "fmt" + "log" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type TranslateParams struct { + mcp.ParamsBase + Text string `json:"text"` +} + +type TranslateResult struct { + mcp.ResultBase + Latin string `json:"latin"` +} + +var translations = map[string]string{ + "hello": "salve", + "goodbye": "vale", + "thank you": "gratias tibi ago", + "how are you": "quid agis", + "good morning": "bonum mane", + "good night": "bonam noctem", + "friend": "amicus", + "water": "aqua", + "love": "amor", + "war": "bellum", + "peace": "pax", + "truth": "veritas", + "light": "lux", + "time": "tempus", + "life": "vita", + "death": "mors", + "star": "stella", + "earth": "terra", + "sea": "mare", + "the die is cast": "alea iacta est", + "i came i saw i conquered": "veni vidi vici", + "seize the day": "carpe diem", +} + +func main() { + ctx := context.Background() + + server := mcp.NewServer(&mcp.Implementation{Name: "latin-server", Version: "v1.0.0"}, nil) + + if err := mcp.AddReceivingCustomMethod(server, "latin/translate", + func(ctx context.Context, ss *mcp.ServerSession, params *TranslateParams) (*TranslateResult, error) { + key := strings.ToLower(strings.TrimSpace(params.Text)) + latin, ok := translations[key] + if !ok { + latin = fmt.Sprintf("[unknown: %q — try: %s]", params.Text, knownPhrases()) + } + return &TranslateResult{Latin: latin}, nil + }); err != nil { + log.Fatal(err) + } + + ct, st := mcp.NewInMemoryTransports() + + ss, err := server.Connect(ctx, st, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + + client := mcp.NewClient(&mcp.Implementation{Name: "latin-client", Version: "v1.0.0"}, nil) + if err := mcp.AddSendingCustomMethod[*TranslateParams, *TranslateResult](client, "latin/translate"); err != nil { + log.Fatal(err) + } + + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + phrases := []string{"Hello", "Seize the day", "Peace", "Truth", "I came I saw I conquered"} + for _, phrase := range phrases { + result, err := mcp.CallCustomMethod[*TranslateParams, *TranslateResult]( + ctx, cs, "latin/translate", &TranslateParams{Text: phrase}) + if err != nil { + log.Fatalf("translate %q: %v", phrase, err) + } + fmt.Printf("%-35s → %s\n", phrase, result.Latin) + } +} + +func knownPhrases() string { + phrases := make([]string, 0, len(translations)) + for k := range translations { + phrases = append(phrases, fmt.Sprintf("%q", k)) + } + return strings.Join(phrases, ", ") +} diff --git a/examples/server/distributed/main.go b/examples/server/distributed/main.go index 0e5dfe5..888cba5 100644 --- a/examples/server/distributed/main.go +++ b/examples/server/distributed/main.go @@ -16,6 +16,8 @@ // Example: // // ./distributed -http=localhost:8080 -child_ports=8081,8082 +// +//lint:file-ignore SA1019 example server exercises deprecated SEP-2577 logging API for demonstration. package main import ( diff --git a/examples/server/everything/main.go b/examples/server/everything/main.go index e7634ce..a08cac8 100644 --- a/examples/server/everything/main.go +++ b/examples/server/everything/main.go @@ -3,6 +3,8 @@ // license that can be found in the LICENSE file. // The everything server implements all supported features of an MCP server. +// +//lint:file-ignore SA1019 example server exercises deprecated SEP-2577 APIs (roots, sampling, logging) for demonstration. package main import ( diff --git a/examples/server/middleware/main.go b/examples/server/middleware/main.go index 4e37471..262d726 100644 --- a/examples/server/middleware/main.go +++ b/examples/server/middleware/main.go @@ -38,7 +38,7 @@ func main() { logger.Info("MCP method started", "method", method, "session_id", req.GetSession().ID(), - "has_params", req.GetParams() != nil, + "has_params", mcp.HasParams(req), ) // Log more for tool calls. if ctr, ok := req.(*mcp.CallToolRequest); ok { diff --git a/examples/server/rate-limiting/go.mod b/examples/server/rate-limiting/go.mod index 61c8788..dc37554 100644 --- a/examples/server/rate-limiting/go.mod +++ b/examples/server/rate-limiting/go.mod @@ -1,6 +1,6 @@ module github.com/modelcontextprotocol/go-sdk/examples/rate-limiting -go 1.23.0 +go 1.25.0 require ( github.com/modelcontextprotocol/go-sdk v0.3.0 diff --git a/go.mod b/go.mod index a6aae2b..3287a95 100644 --- a/go.mod +++ b/go.mod @@ -5,14 +5,16 @@ go 1.25.0 require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-cmp v0.7.0 - github.com/google/jsonschema-go v0.4.2 + github.com/google/jsonschema-go v0.4.3 github.com/segmentio/encoding v0.5.4 github.com/yosida95/uritemplate/v3 v3.0.2 golang.org/x/oauth2 v0.35.0 + golang.org/x/time v0.15.0 golang.org/x/tools v0.42.0 ) require ( github.com/segmentio/asm v1.1.3 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index a60fd62..c13454a 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= -github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/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= @@ -12,7 +12,11 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= diff --git a/internal/authutil/scopes.go b/internal/authutil/scopes.go new file mode 100644 index 0000000..4b5de73 --- /dev/null +++ b/internal/authutil/scopes.go @@ -0,0 +1,36 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. +package authutil + +import ( + "maps" + "slices" + "strings" + + "golang.org/x/oauth2" +) + +// UnionScopes returns the union of the existing and challenged scope sets. +// It is used during step-up authorization to accumulate scopes across +// authorization rounds (SEP-2350). +func UnionScopes(existing, challenged []string) []string { + combined := make(map[string]struct{}) + for _, s := range existing { + combined[s] = struct{}{} + } + for _, s := range challenged { + combined[s] = struct{}{} + } + return slices.Collect(maps.Keys(combined)) +} + +// ScopesFromToken extracts the granted scopes from an OAuth2 token response. +// Per RFC 6749 §5.1, the scope parameter is optional; returns nil if absent. +func ScopesFromToken(token *oauth2.Token) []string { + scope, ok := token.Extra("scope").(string) + if !ok { + return nil + } + return strings.Fields(scope) +} diff --git a/internal/authutil/scopes_test.go b/internal/authutil/scopes_test.go new file mode 100644 index 0000000..1fdfd7d --- /dev/null +++ b/internal/authutil/scopes_test.go @@ -0,0 +1,72 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. +package authutil + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +func TestUnionScopes(t *testing.T) { + tests := []struct { + name string + existing []string + challenged []string + want []string + }{ + { + name: "both empty", + existing: nil, + challenged: nil, + want: nil, + }, + { + name: "existing only", + existing: []string{"read"}, + challenged: nil, + want: []string{"read"}, + }, + { + name: "challenged only", + existing: nil, + challenged: []string{"write"}, + want: []string{"write"}, + }, + { + name: "disjoint scopes", + existing: []string{"read"}, + challenged: []string{"write"}, + want: []string{"read", "write"}, + }, + { + name: "overlapping scopes", + existing: []string{"read", "write"}, + challenged: []string{"write", "admin"}, + want: []string{"read", "write", "admin"}, + }, + { + name: "identical scopes", + existing: []string{"read", "write"}, + challenged: []string{"read", "write"}, + want: []string{"read", "write"}, + }, + { + name: "mixed scopes", + existing: []string{"b", "a"}, + challenged: []string{"c", "a"}, + want: []string{"a", "b", "c"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := UnionScopes(tt.existing, tt.challenged) + if diff := cmp.Diff(tt.want, got, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("UnionScopes() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/authutil/util.go b/internal/authutil/util.go new file mode 100644 index 0000000..880d1bf --- /dev/null +++ b/internal/authutil/util.go @@ -0,0 +1,13 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package authutil + +import "strings" + +// IssuersEqual reports whether two OAuth 2.0 authorization server issuer +// identifiers refer to the same server comparing them without the final trailing slash. +func IssuersEqual(a, b string) bool { + return strings.TrimSuffix(a, "/") == strings.TrimSuffix(b, "/") +} diff --git a/internal/authutil/util_test.go b/internal/authutil/util_test.go new file mode 100644 index 0000000..9d3ff7a --- /dev/null +++ b/internal/authutil/util_test.go @@ -0,0 +1,29 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package authutil + +import "testing" + +func TestIssuersEqual(t *testing.T) { + tests := []struct { + a, b string + want bool + }{ + {"https://issuer.example.com", "https://issuer.example.com", true}, + {"https://issuer.example.com/", "https://issuer.example.com", true}, + {"https://issuer.example.com", "https://issuer.example.com/", true}, + {"https://issuer.example.com/", "https://issuer.example.com/", true}, + {"https://issuer.example.com/tenant", "https://issuer.example.com/tenant", true}, + {"https://issuer.example.com/tenant/", "https://issuer.example.com/tenant", true}, + {"https://issuer.example.com", "https://other.example.com", false}, + {"https://issuer.example.com/a", "https://issuer.example.com/b", false}, + {"", "", true}, + } + for _, tt := range tests { + if got := IssuersEqual(tt.a, tt.b); got != tt.want { + t.Errorf("IssuersEqual(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want) + } + } +} diff --git a/internal/docs/README.src.md b/internal/docs/README.src.md index 16d93db..3283efa 100644 --- a/internal/docs/README.src.md +++ b/internal/docs/README.src.md @@ -1,4 +1,21 @@ -# Features +# Overview + +The SDK consists of several importable packages: + +- The + [`github.com/modelcontextprotocol/go-sdk/mcp`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp) + package defines the primary APIs for constructing and using MCP clients and + servers. +- The + [`github.com/modelcontextprotocol/go-sdk/jsonrpc`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/jsonrpc) package is for users implementing + their own transports. +- The + [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) + package provides some primitives for supporting OAuth. +- The + [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) + package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. + These docs mirror the [official MCP spec](https://modelcontextprotocol.io/specification/2025-06-18). Use the index below to learn how the SDK implements a particular aspect of the diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index f6ae786..c0f64b4 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -4,6 +4,13 @@ ## Roots +> **Note:** The roots feature is deprecated as of protocol version 2026-07-28 +> ([SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging)). +> It remains fully functional during the deprecation window (at least twelve +> months). The SDK continues to support roots for compatibility. New code +> should pass paths via tool parameters, resource URIs, or configuration +> instead. + MCP allows clients to specify a set of filesystem ["roots"](https://modelcontextprotocol.io/specification/2025-06-18/client/roots). The SDK supports this as follows: @@ -21,11 +28,32 @@ client, a call to `AddRoot` or `RemoveRoots` will result in a [`ServerSession.ListRoots`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.ListRoots) method. To receive notifications about root changes, set [`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler). +For protocol versions `2026-07-28` and later, `ListRoots` requests are +delivered via the +[Multi Round-Trip Requests](#multi-round-trip-requests) +pattern. %include ../../mcp/client_example_test.go roots - +### Roots list changed + +`Client.AddRoots` and `Client.RemoveRoots` notify every connected server that +the list changed. Servers observe this through +[`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler); +as with the server-side list-changed notifications, it reports only that +something changed, so read the list back with `ServerSession.ListRoots`. + +%include ../../mcp/client_example_test.go rootslistchanged - + ## Sampling +> **Note:** The sampling feature is deprecated as of protocol version +> 2026-07-28 +> ([SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging)). +> It remains fully functional during the deprecation window (at least twelve +> months). The SDK continues to support sampling for compatibility. Servers +> that need LLM completions should call LLM provider APIs directly. + [Sampling](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling) is a way for servers to leverage the client's AI capabilities. It is implemented in the SDK as follows: @@ -37,6 +65,11 @@ This function is invoked whenever the server requests sampling. **Server-side**: To use sampling from the server, call [`ServerSession.CreateMessage`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.CreateMessage). +For protocol versions `2026-07-28` and later, sampling requests are +delivered via the +[Multi Round-Trip Requests](#multi-round-trip-requests) +pattern. + %include ../../mcp/client_example_test.go sampling - ## Elicitation @@ -54,8 +87,90 @@ you must declare that capability explicitly (see [Capabilities](#capabilities)) **Server-side**: To use elicitation from the server, call [`ServerSession.Elicit`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Elicit). +For protocol versions `2026-07-28` and later, elicitation requests are +delivered via the +[Multi Round-Trip Requests](#multi-round-trip-requests) +pattern. + %include ../../mcp/client_example_test.go elicitation - +### Schema defaults and enums + +`ElicitParams.RequestedSchema` is a flat schema of primitive fields, which the +client renders as a form. Two field keywords shape that form. + +A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034--support-default-values-for-all-primitive-types-in)) prefills a +field. When the user accepts without supplying it, the SDK fills the field in +from the schema before the result reaches either side's caller — the client +does so after its elicitation handler returns, and `ServerSession.Elicit` does +so again on receipt. This is unconditional; there is no opt-in flag. Marking a +defaulted field `Required` defeats it: accepted content is validated against +the schema before defaults are applied, so an answer that omits the field is +rejected rather than defaulted. + +An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330-elicitation-enum-schema-improvements-and-standards)) restricts a +field to a fixed set of values, which the client renders as a choice. Enums +are supported only on `"string"` fields; declaring one on another type is +rejected. To label the choices, set the legacy `enumNames` keyword through +`Schema.Extra`, with exactly one name per enum value — a mismatched length is +rejected. + +%include ../../mcp/client_example_test.go elicitationschema - + +### Completing a URL elicitation + +In URL mode the user finishes out of band, in a browser, so nothing in the +elicitation result tells the client when they are done. The server signals that +with +[`ServerSession.NotifyElicitationComplete`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.NotifyElicitationComplete), +passing the same `ElicitationID` the request carried; the client observes it +through +[`ClientOptions.ElicitationCompleteHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ElicitationCompleteHandler). +Send it from whatever endpoint the hosted flow redirects back to. + +The notification matters most when a handler rejects a request with +[`URLElicitationRequiredError`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#URLElicitationRequiredError): +the client parks the original request until a notification names that +`ElicitationID`, and then retries it automatically. Until one arrives, the +client waits. + +%include ../../mcp/client_example_test.go elicitationcomplete - + +## Multi Round-Trip Requests + +[SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) +introduces the MRTR pattern: server-to-client requests for sampling, +elicitation, and roots are no longer issued as fresh JSON-RPC requests but +are carried inside the in-flight reply of a `tools/call`, `prompts/get`, or +`resources/read`. The client must respond by retrying the original request +with the produced responses. + +The SDK installs `clientMultiRoundTripMiddleware` for every client by +default. The middleware: + +1. Inspects each `tools/call`/`prompts/get`/`resources/read` reply. +2. If the result's `NeedsInput()` is true, fans out the `InputRequests` map + concurrently, calling the configured handler for each (`elicit`, + `createMessage`/`createMessageWithTools`, or `listRoots`). +3. Threads the server-supplied opaque `RequestState` back unchanged. +4. Retries the original request with the responses set, repeating until the + result no longer needs input. + +The middleware is enabled by default. To opt out, set +[`ClientOptions.MultiRoundTrip.Disabled = true`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#MultiRoundTripOptions); +the client will then surface input-required results directly to the caller +(the returned `CallToolResult`, `GetPromptResult`, or `ReadResourceResult` +will report `NeedsInput() == true` and expose the server's `InputRequests` +and opaque `RequestState`). Your code must fulfil each request and re-issue +the original call with `InputResponses` set and `RequestState` echoed +back. + +For legacy (`<= 2025-11-25`) servers, the SDK transparently sends server +requests on the legacy server-initiated channel; the MRTR machinery is a +no-op in that direction. For legacy clients talking to MRTR-style servers, +the server SDK applies the inverse compatibility shim — see the +[server-side documentation](server.md#multi-round-trip-requests). + ## Capabilities Client capabilities are advertised to servers during the initialization @@ -112,3 +227,11 @@ client := mcp.NewClient(impl, &mcp.ClientOptions{ }) ``` +### Extensions + +[SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133) +adds an `extensions` map to `ClientCapabilities` and `ServerCapabilities` so +that optional capabilities outside the core protocol can be declared on the +wire. Keys are namespaced as `"{vendor-prefix}/{extension-name}"`; values +are per-extension settings objects. + diff --git a/internal/docs/doc.go b/internal/docs/doc.go index ede0d42..4164060 100644 --- a/internal/docs/doc.go +++ b/internal/docs/doc.go @@ -10,6 +10,7 @@ //go:generate weave -o ../../docs/troubleshooting.md ./troubleshooting.src.md //go:generate weave -o ../../docs/rough_edges.md ./rough_edges.src.md //go:generate weave -o ../../docs/mcpgodebug.md ./mcpgodebug.src.md +//go:generate weave -o ../../docs/quick_start.md ./quick_start.src.md // The doc package generates the documentation at /doc, via go:generate. // diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index 60b321b..8d1385f 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -1,4 +1,4 @@ - # Backwards compatibility and MCPGODEBUG +# Backwards compatibility and MCPGODEBUG According to our compatibility promise, we can't break backward compatibility of the SDK API. However, sometimes we need to change the behavior of the SDK @@ -17,6 +17,142 @@ ## `MCPGODEBUG` history +### 1.9.0 + +Options listed below were added and will be removed in the 1.11.0 version of the SDK. + +- `structuredcontentfloat64` added. If set to `1`, numbers in + `CallToolResult.StructuredContent` are decoded as `float64`, restoring the + previous behavior. The default behavior decodes numbers as `json.Number` so + that large integers and other exact JSON values do not silently lose + precision. + +### 1.8.0 + +Options listed below were added and will be removed in the 1.9.0 version of the SDK. + +- `plaintextstatefulrejection` added. If set to `1`, a stateful + `StreamableHTTPHandler` will respond with a plain-text `http.Error` 400 body + when it receives a request carrying per-request metadata (i.e. an + `io.modelcontextprotocol/protocolVersion` `_meta` field, or an + `MCP-Protocol-Version` header >= `2026-07-28`), restoring the previous + behavior. The default behavior was changed so that the server responds with a + JSON-RPC error of code `CodeUnsupportedProtocolVersion` (`-32022`) carrying + an `UnsupportedProtocolVersionData` payload that advertises the legacy + versions the server supports. This lets the client's + existing renegotiation logic recover and prevents the failure from tearing + down the underlying connection. + +- `blockingcancelnotify` added. If set to `1`, a cancelled call waits + synchronously for the best-effort `notifications/cancelled` message to be + delivered (up to `notifyCancellationTimeout`, currently 5 s) before + returning to the caller, restoring the previous behavior. The delivery + error is joined into the caller's returned error. The default behavior was + changed so that the call is retired immediately and the notification is + sent asynchronously off the caller's return path: the caller returns as + soon as its context is cancelled and cannot be delayed by a slow or + unresponsive peer. See issue #1150. + +Options below were removed, according to plan: + +- `seterroroverwrite`. `SetError` now always preserves existing `Content`. + +- `enableoriginverification`. A nil + `StreamableHTTPOptions.CrossOriginProtection` now always means no + cross-origin protection. Wrap the handler with cross-origin protection + middleware instead. + +- `disablecontenttypecheck`. Content-Type validation on HTTP POST requests can + no longer be disabled. + +- `disablelocalhostprotection`, whose removal had been postponed to this + release. Set the `DisableLocalhostProtection` field in + `StreamableHTTPOptions` or `SSEOptions` to opt out of DNS rebinding + protection. + +### 1.7.0 + +Options listed below were added and will be removed in the 1.9.0 version of the SDK. + +- `customresnotfounderrcode` added. If set to `1`, `ResourceNotFoundError` will + use the custom error code `-32002` instead of the standard `-32602` (Invalid + Params), restoring the previous behavior. The default behavior was changed to + align with SEP-2164 and the JSON-RPC specification. + +- `hintomitempty` added. If set to `1`, `ToolAnnotations` JSON marshaling + will omit `ReadOnlyHint` and `IdempotentHint` when their value is `false`, + restoring the previous behavior. The default behavior was changed to always + serialize these fields, since their Go types are bare `bool` (not `*bool`) + and omitting `false` made it indistinguishable from unset. + +- `allowsessionsinstateless` added. If set to `1`, stateless streamable HTTP + servers will read the `Mcp-Session-Id` request header (or generate one via + `GetSessionID`), set it on response headers, and accept `DELETE` requests, + restoring the previous behavior. The default behavior was changed so that + stateless servers ignore session IDs entirely and reject `DELETE` with 405. + +- `nomethodnotfoundcodeinerror` added. If set to `1`, the jsonrpc2 layer will not + include the MethodNotFound Error (`-32601`) in the error response when the + requested method in STDIO transport is not found. The default behavior was + changed to include the MethodNotFound Error in the error response when the + requested method in STDIO transport is not found. + +- `noprotocolerrorbody` added. If set to `1`, the streamable HTTP client will + not attempt to decode the JSON-RPC error body of a non-2xx HTTP response, + and any non-transient error will permanently fail the connection, restoring + the previous behavior. The default behavior was changed so that the client + attempts to decode the JSON-RPC error body of a non-2xx response, surfaces + the underlying JSON-RPC error, and wraps it with `jsonrpc2.ErrRejected` so + that per-call rejections do not tear down the session. + +- `nowrapinvalidparams` added. If set to `1`, the server will not wrap + params-decoding failures with `jsonrpc2.ErrInvalidParams`, so wire responses + carry the zero-value error code `0` instead of `-32602` ("invalid params"), + restoring the previous behavior. The default behavior was changed so that + the server always reports params-decoding failures with the standard + `-32602` code. + +- `disablecompleteparamsvalidation` added. If set to `1`, `Server.complete` + will not validate that the required `ref` and `argument.name` fields on + `CompleteParams` are present, restoring the previous behavior of dispatching + the request to the completion handler unconditionally. The default behavior + was changed to reject malformed requests with `-32602` (Invalid Params). + +### 1.6.1 + +Options listed below were added and will be removed in the 1.8.0 version of the SDK. + +- `disablecontenttypecheck` added. If set to `1`, Content-Type validation on + HTTP POST requests will be disabled, allowing requests with non-JSON or missing + Content-Type headers. The default behavior is to validate that HTTP POST + requests have Content-Type: application/json. + +### 1.6.0 + +Options listed below were added and will be removed in the 1.8.0 version of the SDK. + +- `seterroroverwrite` added. If set to `1`, `SetError` will always overwrite + `Content` with the error text, restoring the previous behavior. The default + behavior was changed to preserve existing `Content` if it has already been + populated. + +- `enableoriginverification` added. If set to `1`, default (zero-value) + cross-origin protection will be applied when + `StreamableHTTPOptions.CrossOriginProtection` is nil, restoring the + behavior from v1.4.1-v1.5.0. The default behavior was changed to not + enable cross-origin protection. + +- `disablelocalhostprotection` removal was postponed until 1.8.0, as it is now + also used in the SSE transport. + +Options below were removed: + +- `jsonescaping`, according to plan, + +- `disablecrossoriginprotection`, it was replaced by + `enableoriginverification` after the default was changed to not enable + cross-origin protection. + ### 1.4.1 Options listed below will be removed in the 1.6.0 version of the SDK. @@ -36,5 +172,6 @@ Options listed below will be removed in the 1.6.0 version of the SDK. - `disablelocalhostprotection` added. If set to `1`, newly added DNS rebinding protection will be disabled. The default behavior was changed to enable DNS rebinding protection. The protection can also be disabled by setting the - `DisableLocalhostProtection` field in the `StreamableHTTPOptions` struct to - `true`, which is the recommended way to disable the protection long term. \ No newline at end of file + `DisableLocalhostProtection` field in the `StreamableHTTPOptions` or + `SSEOptions` struct to `true`, which is the recommended way to disable + the protection long term. **Removal of this option was postponed until 1.8.0.** diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index bfaf654..7d6b01b 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -6,15 +6,23 @@ The SDK provides an API for defining both MCP clients and servers, and connecting them over various transports. When a client and server are -connected, it creates a logical session, which follows the MCP spec's -[lifecycle](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle). +connected, it creates a logical session. -In this SDK, both a -[`Client`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Client) -and -[`Server`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server) -can handle multiple peers. Every time a new peer is connected, it creates a new -session. +The MCP specification defines two lifecycle models, and the SDK supports both +transparently based on the negotiated protocol version: + +- The **legacy `initialize` handshake** + ([lifecycle](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle)) + used by protocol versions through `2025-11-25`. +- A **stateless** model introduced in `2026-07-28` by + [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), + in which there is no `initialize`/`notifications/initialized` handshake, and + each request carries its protocol version and client capabilities in + `_meta`. Clients SHOULD also include their identity (`clientInfo`) on every + request, and servers SHOULD include their identity (`serverInfo`) on every + response. + +In both models, the SDK exposes the same API: - A `Client` is a logical MCP client, configured with various [`ClientOptions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions). @@ -30,11 +38,16 @@ session. [`Server.Connect`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.Connect), it creates a [`ServerSession`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession). - This session is not initialized until the client sends the - `notifications/initialized` message. Use `ServerOptions.InitializedHandler` - to listen for this event, or just use the session through various feature - handlers (such as a `ToolHandler`). Requests to the server are rejected until - the client has initialized the session. + +In the legacy model, the server session is not considered initialized until +the client sends the `notifications/initialized` message. Use +`ServerOptions.InitializedHandler` to listen for this event, or just use the +session through various feature handlers (such as a `ToolHandler`). Requests +to the server are rejected until the client has initialized the session. + +In the stateless model (`2026-07-28`+), there is no handshake. The server +processes the first request the moment it arrives, validates the per-request +`_meta` fields. Both `ClientSession` and `ServerSession` have a `Close` method to terminate the session, and a `Wait` method to await session termination by the peer. Typically, @@ -42,6 +55,82 @@ it is the client's responsibility to end the session. %include ../../mcp/mcp_example_test.go lifecycle - +### Discovery + +Introduced in `2026-07-28` by +[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), +the `server/discover` RPC lets a client discover a server's supported +protocol versions, capabilities, and identity before issuing any other +request. Servers implementing `2026-07-28` MUST implement it. + +- **Server**: `Server.Connect` registers the + `server/discover` handler automatically; the response is computed from the + server's static configuration (capabilities, server info, instructions) and + the transport-filtered list of supported protocol versions. +- **Client**: `Client.Connect` calls `server/discover` first and + uses the result to negotiate a mutually supported version. If discovery + fails or the server does not support the latest version, the client falls back to the + legacy `initialize` handshake. + +A server advertises and negotiates every protocol version the SDK supports; +set [`ServerOptions.SupportedProtocolVersions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions) +to narrow that set, for example to stop serving a revision your deployment has +retired. + +```go +server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v1.0.0"}, &mcp.ServerOptions{ + SupportedProtocolVersions: []string{"2026-07-28", "2025-11-25"}, +}) +``` + +The set can only narrow support, never widen it; the field's documentation +covers how each protocol era answers a request at an excluded version. + +### Per-request metadata keys + +When the negotiated protocol version is `2026-07-28` or later, every request +carries these keys inside its `_meta` map (constants live in +[`mcp/protocol.go`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#pkg-constants)): + +| Constant | Wire key | Type | Required | +|---|---|---|---| +| `MetaKeyProtocolVersion` | `io.modelcontextprotocol/protocolVersion` | `string` | Yes | +| `MetaKeyClientCapabilities` | `io.modelcontextprotocol/clientCapabilities` | `*ClientCapabilities` | Yes | +| `MetaKeyClientInfo` | `io.modelcontextprotocol/clientInfo` | `*Implementation` | No | +| `MetaKeyLogLevel` | `io.modelcontextprotocol/logLevel` | `LoggingLevel` (deprecated by SEP-2577) | No | + +The client populates the required keys automatically on every outgoing +request, and populates `clientInfo` when configured with an `*Implementation` +(the default). Server-side handlers can read them through +`ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, and +`ServerRequest[P].ClientCapabilities()`. + +### Per-response metadata keys + +Under the same protocol version, servers SHOULD identify themselves on every +response. The SDK populates this +key automatically on every outgoing response: + +| Constant | Wire key | Type | Required | +|---|---|---|---| +| `MetaKeyServerInfo` | `io.modelcontextprotocol/serverInfo` | `*Implementation` | No | + +### Subscriptions + +Introduced in `2026-07-28` by +[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), +`subscriptions/listen` replaces the legacy `resources/subscribe` RPC and +the GET-based SSE endpoint with a single long-lived request that +multiplexes every kind of server-to-client change notification a client +opts in to. On the wire the client sends one `subscriptions/listen` +request whose `notifications` field enumerates what it wants +(`toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, and/or +a list of resource URIs in `resourceSubscriptions`); the server replies +first with a `notifications/subscriptions/acknowledged` notification +reporting the honored subset, then streams every change notification on +the same request, and finally closes with a `SubscriptionsListenResult` +when it tears the subscription down. + ## Transports A @@ -106,12 +195,46 @@ connect to the server: transport := &mcp.StreamableClientTransport{ Endpoint: "http://localhost:8080/mcp", } -client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...}) +client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1.0.0"}, nil) +session, err := client.Connect(ctx, transport, nil) ``` The `StreamableClientTransport` handles the HTTP requests and communicates with the server using the streamable transport protocol. +#### HTTP Headers + +[SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243) +standardises a small set of MCP-specific HTTP headers, sent and validated by +the SDK only for sessions negotiated on protocol version `2026-07-28` or +later. + +| Header | Direction | Purpose | +|---|---|---| +| `Mcp-Protocol-Version` | request | Mirrors `_meta.io.modelcontextprotocol/protocolVersion` from the body | +| `Mcp-Session-Id` | request/response | Logical session identifier (removed in stateless mode) | +| `Mcp-Method` | request | Mirrors the JSON-RPC `method` field; mismatch ⇒ `-32020` | +| `Mcp-Name` | request | Mirrors the request's principal name (`tools/call.params.name`, `prompts/get.params.name`, `resources/read.params.uri`); mismatch ⇒ `-32020` | +| `Mcp-Param-{Header}` | request | Per-tool parameter passthrough; see below | + +The header-name set is exhaustive: for protocol versions earlier than +`2026-07-28`, only `Mcp-Protocol-Version` and `Mcp-Session-Id` are recognised. + +**Body↔header mirroring.** When `Mcp-Method` or `Mcp-Name` is present but +does not match the JSON-RPC body, the server returns +[`CodeHeaderMismatch`](#error-codes) (`-32020`). The same applies to a +mismatch between `Mcp-Protocol-Version` and the body's +`_meta.io.modelcontextprotocol/protocolVersion`. + +**`Mcp-Param-*` passthrough.** A tool may annotate properties of its +`InputSchema` with `x-mcp-header: ""`. When a client invokes that +tool over HTTP, the SDK serializes the matching argument(s) as +`Mcp-Param-` request headers (using `=?base64?...?=` encoding for +non-ASCII values). On the server, the headers are validated against the body +and either accepted or rejected with `-32020`. Properties so annotated must +be typed `string`, `integer`, or `boolean`; tools that fail validation at +registration time are silently dropped from `tools/list`. + #### Resumability and Redelivery By default, the streamable server does not support [resumability or @@ -125,6 +248,13 @@ value. The SDK provides a `MemoryEventStore` for testing or simple use cases; for production use it is generally advisable to use a more sophisticated implementation. +> **Note**: [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575) +> removes SSE stream resumability (`Last-Event-ID`, SSE event IDs) for protocol +> version `2026-07-28`. The SDK preserves the `EventStore` and `Last-Event-ID` +> code paths for backward compatibility with `2025-11-25` and earlier; on +> `2026-07-28` sessions, a broken response stream loses the in-flight request +> and the client must re-issue it as a new request with a new ID. + #### Stateless Mode The streamable server supports a _stateless mode_ by setting @@ -143,10 +273,42 @@ to see the logical session > modelcontextprotocol/modelcontextprotocol#1372, or > modelcontextprotocol/modelcontextprotocol#1442 for potential refinements. -_See [examples/server/distributed](../examples/server/distributed/main.go) for +> **Required for `2026-07-28`**: the streamable HTTP transport accepts +> requests at protocol version `2026-07-28` **only** when `Stateless = true`. +> Requests at that version against a non-stateless handler are rejected. + +_See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/distributed/main.go) for an example using stateless mode to implement a server distributed across multiple processes._ +### Legacy SSE Transport + +Before the streamable transport, the +[2024-11-05](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports) +spec defined an HTTP transport built from two endpoints: a hanging GET that +streams server-to-client messages as server-sent events, and a per-session +endpoint the client POSTs its messages to. The SDK still implements it, for +talking to peers that predate the streamable transport. New deployments should +use the [streamable transport](#streamable-transport) instead. + +It is implemented across three types, mirroring the streamable ones: + +- [`SSEHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEHandler): + an `http.Handler` that creates a session per incoming GET and routes POSTs to + the right session. Construct it with + [`NewSSEHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#NewSSEHandler), + which takes a function returning the `Server` to serve a given request. +- [`SSEServerTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEServerTransport): + one such session. Reach for it directly only when serving the two endpoints + yourself; `SSEHandler` creates these for you. +- [`SSEClientTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEClientTransport): + the client side, which needs only the `Endpoint` of the GET. + +%include ../../mcp/sse_example_test.go ssehandler - + +_See [examples/server/sse](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/sse/main.go) +for a standalone server._ + ### Custom transports The SDK supports [custom @@ -155,7 +317,7 @@ by implementing the [`Transport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Transport) interface: a logical bidirectional stream of JSON-RPC messages. -_Full example: [examples/server/custom-transport](../examples/server/custom-transport/main.go)._ +_Full example: [examples/server/custom-transport](https://github.com/modelcontextprotocol/go-sdk/blob/main//examples/server/custom-transport/main.go)._ ### Concurrency @@ -247,6 +409,7 @@ This handler supports: - [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents) - [Pre-registered clients](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration) - [Dynamic Client Registration](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration) +- [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) Authorization Server Issuer Identification To use it, configure the handler and assign it to the transport: @@ -258,11 +421,12 @@ authHandler, _ := auth.NewAuthorizationCodeHandler(&auth.AuthorizationCodeHandle // PreregisteredClientConfig: ... // DynamicClientRegistrationConfig: ... AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { - // Open the args.URL in a browser and return the resulting code and state. + // Open the args.URL in a browser and return the resulting code, state, and iss. // See full example in examples/auth/client/main.go. code := ... state := ... - return &auth.AuthorizationResult{Code: code, State: state}, nil + iss := ... // "iss" query parameter from the redirect URI (RFC 9207) + return &auth.AuthorizationResult{Code: code, State: state, Iss: iss}, nil }, }) @@ -368,26 +532,26 @@ of tokens and is the responsibility of the provided to [`RequireBearerToken`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerToken). -### Server-Side Request Forgery (SSRF) +### Server-Side Request Forgery -The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) are as follows: +The OAuth discovery helpers apply these [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) by default: -- _Enforce HTTPS_. The OAuth helpers provided by the SDK reject the `http://` URLs -except loopback addresses (`localhost`, `127.0.0.1`, `::1`). +- _Enforce HTTPS_. Reject `http://` URLs (except loopback: `localhost`, `127.0.0.1`, `::1`) +and redirects that downgrade `https` to a non-`https` scheme. -- _Block Private IP Ranges_. The OAuth helpers provided by the SDK allow passing -a custom `http.Client`. Developers are advised to customize the client it with -appropriate network protections, including IP range blocking. The SDK does not provide -this capability out of the box. +- _Block Private IP Ranges_. Reject targets that resolve to a non-public address +(private, link-local including the `169.254.169.254` metadata endpoint, CGNAT, multicast, +unspecified). The check runs on the initial URL and at dial time, guarding against DNS rebinding. -- _Validate Redirect Targets_. Similarly to previous point, customized `http.Client` -can be used to validate network hops. The SDK does not provide this capability out -of the box. +- _Validate Redirect Targets_. Reject redirects to non-public addresses and cap the redirect count. -- _Use Egress Proxies_. This is out of scope for the SDK and can be configured separately. +- _Use Egress Proxies_. Out of scope for the SDK; configure separately. -- _DNS Resolution Considerations_. The SDK has DNS rebinding protection on the server side which is enabled by default. For the client side, consider providing -a custom `http.Client` that would implement DNS pinning. +**Opting out.** Passing a custom `http.Client` can bypass these checks, making SSRF +protection your responsibility. Specifically: a custom `Transport.DialContext`/`DialTLSContext`, +a non-`*http.Transport`, or a configured `Proxy` disables the dial-time IP check; setting +`CheckRedirect` replaces all redirect validation. In these cases, implement your own IP +blocking, redirect validation, and DNS pinning. ### Session Hijacking @@ -415,6 +579,22 @@ sets `UserID` on the returned `TokenInfo`, the streamable transport will: `TokenInfo.UserID` to enable this protection. This prevents an attacker with a valid token from hijacking another user's session by guessing or obtaining their session ID. +### Issuer Mix-Up + +The [mitigation](https://www.rfc-editor.org/rfc/rfc9207) against issuer mix-up attacks is +implemented per [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207). The SDK client validates +the `iss` parameter in authorization responses to ensure they originated from the expected +authorization server: + +- If `iss` is present in the redirect URI, the SDK verifies it matches the issuer from the + authorization server's metadata. A mismatch results in an error. +- If `iss` is absent but the authorization server advertises + `authorization_response_iss_parameter_supported: true` in its [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414) + metadata, the SDK rejects the response with an error. + +The `AuthorizationCodeFetcher` is responsible for extracting the `iss` query parameter from +the redirect URI and returning it in [`AuthorizationResult.Iss`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#AuthorizationResult). + ## Utilities ### Cancellation @@ -445,6 +625,13 @@ session if the ping fails, set or [`ServerOptions.KeepAlive`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.KeepAlive). +> **Note**: `ping` is removed from the protocol as of `2026-07-28` by +> [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575). +> The SDK preserves the API for backward compatibility with legacy clients; +> when both peers negotiate `2026-07-28`, the server returns +> `MethodNotFound` (`-32601`) for `ping` and `KeepAlive` should not be +> enabled. + ### Progress [Progress](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress) @@ -462,3 +649,22 @@ Issue #460 discusses some potential ergonomic improvements to this API. %include ../../mcp/mcp_example_test.go progress - +### Error codes + +The SDK uses the standard JSON-RPC base codes (parse error `-32700`, +invalid request `-32600`, method not found `-32601`, invalid params +`-32602`, internal error `-32603`) plus the following MCP-specific codes: + +| Constant | Code | Meaning | +|---|---|---| +| [`CodeHeaderMismatch`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CodeHeaderMismatch) | `-32020` | An MCP HTTP header (`Mcp-Method`, `Mcp-Name`, `Mcp-Protocol-Version`, or `Mcp-Param-*`) does not match the JSON-RPC body | +| [`CodeMissingRequiredClientCapabilities`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CodeMissingRequiredClientCapabilities) | `-32021` | Server requires client capabilities the client did not declare | +| [`CodeUnsupportedProtocolVersion`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CodeUnsupportedProtocolVersion) | `-32022` | Requested protocol version not supported. Data: `UnsupportedProtocolVersionData{Supported []string, Requested string}` | +| [`CodeResourceNotFound`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CodeResourceNotFound) | `-32602` | Resource URI not found (variable; was `-32002` before SEP-2164) | + +The error code allocation policy defined in `2026-07-28` +([SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)) +partitions the JSON-RPC server-error range: + +- `-32000` to `-32019`: implementation-defined; existing SDK usage is grandfathered. +- `-32020` to `-32099`: reserved for the MCP specification. diff --git a/internal/docs/quick_start.src.md b/internal/docs/quick_start.src.md new file mode 100644 index 0000000..c0f1546 --- /dev/null +++ b/internal/docs/quick_start.src.md @@ -0,0 +1,27 @@ +# Quick Start + +%toc + +## Installation + +``` +go get github.com/modelcontextprotocol/go-sdk/mcp +``` + +## Getting started + +To get started creating an MCP server, create an `mcp.Server` instance, add +features to it, and then run it over an `mcp.Transport`. For example, this +server adds a single simple tool, and then connects it to clients over +stdin/stdout: + +%include ../readme/server/server.go - + +To communicate with that server, create an `mcp.Client` and connect it to the +corresponding server, by running the server command and communicating over its +stdin/stdout: + +%include ../readme/client/client.go - + +The [`examples/`](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples) directory contains more example clients and +servers. diff --git a/internal/docs/rough_edges.src.md b/internal/docs/rough_edges.src.md index 42e79f7..90751f7 100644 --- a/internal/docs/rough_edges.src.md +++ b/internal/docs/rough_edges.src.md @@ -58,3 +58,12 @@ v2. wrapper) we need to first unmarshal into a `map[string]any` in order to do server-side validation of required fields. CallToolParams could have just had a map[string]any. + +- `StreamableHTTPOptions.CrossOriginProtection` should not have been part of + the SDK API. Cross-origin protection is a general HTTP concern, not specific + to MCP, and can be applied as standard HTTP middleware. + +- `ToolAnnotations` (`mcp/protocol.go`) should have all fields typed as `*bool` + for full control to define what is being sent over the wire. Different + MCP clients have different requirements, and some of them require all fields + to be explicitly set to either `true` or `false`. \ No newline at end of file diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 694fc0f..ff66383 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -33,6 +33,24 @@ notification. %include ../../mcp/server_example_test.go prompts - +### Prompt message content + +A +[`PromptMessage`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#PromptMessage) +has two fields: a `Role`, either `"user"` or `"assistant"`, and a single +`Content`. That is the same interface a tool result uses (see [Tool result +content](#tool-result-content)), so a prompt message can hold an image, audio, +or a resource's contents as readily as text. + +Media on its own says nothing about what the model should do with it, so send a +text message alongside it. Embedding a resource puts its contents directly in +the message, sparing the client a separate `resources/read`. Note that +`ResourceContents.URI` is not checked against the resources the server has +registered: it records where the contents came from, so use the URI of a real +resource when there is one, as in the example below. + +%include ../../mcp/server_example_test.go promptcontent - + ## Resources In MCP terms, a _resource_ is some data referenced by a URI. @@ -65,7 +83,12 @@ and to unsubscribe. Set [`ClientOptions.ResourceUpdatedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ResourceUpdatedHandler) -to be notified of changes to subscribed resources. +to be notified of changes to subscribed resources. On `2026-07-28` and +later sessions the SDK delivers these notifications over a +`subscriptions/listen` stream instead of the legacy `resources/subscribe` +RPC; see [Subscriptions +(`subscriptions/listen`)](protocol.md#subscriptions) +for the wire-level details. **Server-side**: Use @@ -89,6 +112,37 @@ notification. %include ../../mcp/server_example_test.go resources - +### Binary resources + +A +[`ResourceContents`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceContents) +carries its data in either `Text` or `Blob`. Binary data goes in `Blob` as +plain bytes — the SDK base64-encodes it on the wire — and leaves `Text` empty, +so a client tells the two apart by which field is populated. + +%include ../../mcp/server_example_test.go binaryresource - + +### Resource subscriptions + +A client interested in changes to one resource subscribes to its URI with +[`ClientSession.Subscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Subscribe) +and stops with +[`ClientSession.Unsubscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Unsubscribe). +Set +[`ServerOptions.SubscribeHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.SubscribeHandler) +and +[`UnsubscribeHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.UnsubscribeHandler) +to track who is listening; setting either one gives the server the +`resources.subscribe` capability. + +Publish a change with +[`Server.ResourceUpdated`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.ResourceUpdated), +which notifies every session subscribed to that URI. The notification carries +only the URI, not the new contents, so a client that wants them reads the +resource again. + +%include ../../mcp/server_example_test.go subscribe - + ## Tools MCP servers can provide @@ -140,7 +194,7 @@ In order to implement a tool, the user must do all of the following: - Unmarshal the input schema into a Go value - Execute the tool logic. - Marshal the tool's structured output (if any) to JSON, and store it in the - result's `StructuredOutput` field as well as the unstructured `Content` field. + result's `StructuredContent` field as well as the unstructured `Content` field. - Validate that output JSON against the tool's output schema. - If any tool errors occurred, pack them into the unstructured content and set `IsError` to `true.` @@ -165,13 +219,17 @@ mcp.AddTool(server, &mcp.Tool{Name: "my_tool"}, handler) This does the following automatically: -- If `Tool.InputSchema` or `Tool.OutputSchema` are unset, the input and output - schemas are inferred from the `In` type, which must be a struct or map. - Optional `jsonschema` struct tags provide argument descriptions. +- If `Tool.InputSchema` is unset, the input schema is inferred from the `In` + type, which must be a struct or map. +- If `Tool.OutputSchema` is unset and the `Out` type is not `any`, the output + schema is inferred from the `Out` type. Per SEP-2106, `Out` may be any Go + type whose inferred schema is a valid JSON Schema (struct, map, slice, + primitive, etc.). +- Optional `jsonschema` struct tags provide argument and output descriptions. - Tool arguments are validated against the input schema. - Tool arguments are marshaled into the `In` value. - Tool output (the `Out` value) is marshaled into the result's - `StructuredOutput`, as well as the unstructured `Content`. + `StructuredContent`, as well as the unstructured `Content`. - Output is validated against the tool's output schema. - If an ordinary error is returned, it is stored int the `CallToolResult` and `IsError` is set to `true`. @@ -189,10 +247,43 @@ the `Tool.InputSchema` explicitly: %include ../../mcp/tool_example_test.go customschemas - -_See [mcp/tool_example_test.go](../mcp/tool_example_test.go) for the full -example, or [examples/server/toolschemas](examples/server/toolschemas/main.go) +_See [mcp/tool_example_test.go](https://github.com/modelcontextprotocol/go-sdk/blob/main/mcp/tool_example_test.go) for the full +example, or [examples/server/toolschemas](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/toolschemas/main.go) for more examples of customizing tool schemas._ +### Tool result content + +Alongside its structured output, a tool result carries a list of +[`Content`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Content) +blocks in `CallToolResult.Content`, and may mix as many kinds as it needs: + +| Type | Carries | +| --- | --- | +| [`TextContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#TextContent) | plain text | +| [`ImageContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ImageContent) | image data, with a MIME type | +| [`AudioContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#AudioContent) | audio data, with a MIME type | +| [`EmbeddedResource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#EmbeddedResource) | the contents of a resource, inline | +| [`ResourceLink`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceLink) | a reference to a resource the client can read separately | + +`ImageContent.Data` and `AudioContent.Data` are plain `[]byte`; the SDK +base64-encodes them on the wire, so handlers never encode by hand, and base64 +carried over from another MCP SDK must be decoded before it is assigned. An +`EmbeddedResource` wraps a +[`ResourceContents`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceContents), +which holds either `Text` or, for binary data, `Blob`. Prefer a `ResourceLink` +when the client may not need the contents, since an embedded resource is +transferred whether it is used or not. + +When a handler bound with `AddTool` leaves `Content` unset, the SDK fills it +with the JSON encoding of the output value; set `Content` explicitly, as below, +to return anything else. `StructuredContent` is still populated from the output +value either way. + +%include ../../mcp/tool_example_test.go contenttypes - + +`ToolUseContent` and `ToolResultContent` also implement `Content`, but are only +valid in sampling messages, not in tool results. + **Stateless server deployments:** Some deployments create a new [`Server`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server) for each incoming request, re-registering tools every time. To avoid repeated @@ -210,6 +301,106 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { } ``` +## List changed notifications + +Adding or removing a feature on a connected server sends the matching +`notifications/*/list_changed` to every client, which dispatches it to +[`ClientOptions.ToolListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ToolListChangedHandler), +[`PromptListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.PromptListChangedHandler), +or +[`ResourceListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ResourceListChangedHandler). +A notification reports only that the list changed, so a client that needs the +new contents lists them again. + +These notifications ride on capabilities, and capabilities are inferred from +what is registered before [`Server.Connect`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.Connect) +(see [Capabilities](#capabilities)). A server that registers its features +afterwards, as the example below does, has to declare +`ServerOptions.HasTools`, `HasPrompts`, or `HasResources` itself, or the +client is never told. + +%include ../../mcp/server_example_test.go listchanged - + +## Multi Round-Trip Requests + +[SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) +defines a new pattern for server-to-client requests (sampling, elicitation, +roots). Instead of issuing a fresh JSON-RPC request mid-flight, the server +returns an `InputRequiredResult` from its in-flight handler — the +`InputRequests` field of `CallToolResult`, `GetPromptResult`, or +`ReadResourceResult` carries the requests for additional information. The +client responds by retrying the original call with `InputResponses` set. + +The SDK supports this pattern from both sides without requiring callers to +choose: + +- **Returning input requests from a handler.** Set `InputRequests` on the + result you return. + Leave `Content` / `StructuredContent` empty — returning + both at once is a server bug and the SDK returns `-32603 InternalError`. +- **Calling the legacy APIs.** `ServerSession.Elicit`, + `ServerSession.CreateMessage(WithTools)`, and `ServerSession.ListRoots` + remain available and work for both new and legacy clients. + +### Talking to legacy clients + +The server installs `serverMultiRoundTripMiddleware` automatically. For +clients on a protocol version earlier than `2026-07-28`, the middleware +intercepts any `InputRequiredResult` your handler returns, fulfils each +input request itself by calling the legacy server-initiated APIs +(`Elicit`, `CreateMessage`, `ListRoots`), and re-invokes your handler +exactly once with the responses already populated. This means a handler +written in the MRTR style works against both old and new clients without +code changes. + +### Example + +The following example shows a "greet" tool whose handler asks the user +for their name via elicitation before producing the final greeting. The +handler runs twice — once to issue the elicitation, once to consume the +response — but the client call site sees a single `CallTool` returning the +final result, because the SDK's MRTR middleware handles the round trip on +either side of the wire (depending on the negotiated protocol version). + +%include ../../mcp/server_example_test.go mrtr - + +## Cacheable list results + +[SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549) +adds `ttlMs` and `cacheScope` fields to the results of `tools/list`, +`prompts/list`, `resources/list`, `resources/templates/list`, and +`resources/read`. Both fields complement existing list +results: clients use `ttlMs` as a freshness hint to reduce polling, +and `cacheScope` (`"public"` or `"private"`) controls whether shared +intermediaries may cache the response. + +**Server-side defaults.** The SDK fills in `CacheScope = "public"` whenever a +result leaves it empty, since the field is required on the wire. Everything +else is left as produced: a `ResourceHandler` that sets `TTLMs` or `CacheScope` +on its `ReadResourceResult` keeps those values. The list results and +`server/discover` are built by the SDK itself, so they start out with +`TTLMs = 0`, which a client cache treats as "immediately stale". + +**Setting a policy.** `ServerOptions.SetCacheable` decides the fields for every +result that carries them. It runs after the corresponding handler, receives +what that handler produced, and may keep, adjust, or replace it. The request is +passed in so that policy can vary by method, session, or authorization context. + +```go +server := mcp.NewServer(impl, &mcp.ServerOptions{ + SetCacheable: func(_ context.Context, req mcp.Request, c *mcp.Cacheable) { + // A 30s freshness hint, except where the handler chose its own. + if c.TTLMs == 0 { + c.TTLMs = 30_000 + } + }, +}) +``` + +`SetCacheable` may run with the server's lock held, so it must not call back +into the `Server`: adding or removing a feature, or ranging over +`Server.Sessions`, deadlocks. + ## Utilities ### Completion @@ -232,6 +423,13 @@ requests. ### Logging +> **Note:** The logging feature is deprecated as of protocol version +> 2026-07-28 +> ([SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging)). +> It remains fully functional during the deprecation window (at least twelve +> months). The SDK continues to support logging for compatibility. Servers +> should migrate to stderr logging (for STDIO transports) or OpenTelemetry. + MCP servers can send logging messages to MCP clients. (This form of logging is distinct from server-side logging, where the server produces logs that remain server-side, for use by server maintainers.) @@ -239,8 +437,12 @@ server produces logs that remain server-side, for use by server maintainers.) **Server-side**: The minimum log level is part of the server state. For stateful sessions, there is no default log level: no log messages will be sent -until the client calls `SetLevel` (see below). -For stateful sessions, the level defaults to "info". +until the client calls `SetLoggingLevel` (see below). +For legacy stateless sessions the level defaults to "info". +For sessionless 2026-07-28 requests there is no server-side default: each request +carries its level in `_meta` (`io.modelcontextprotocol/logLevel`), and nothing is +logged until the client sends one. +(Note: logging is deprecated as of 2026-07-28 / SEP-2577.) [`ServerSession.Log`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Log) is the low-level way for servers to log to clients. It sends a logging notification to the client if the level of the message @@ -256,7 +458,7 @@ Servers always report the logging capability. **Client-side**: Set [`ClientOptions.LoggingMessageHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.LoggingMessageHandler) to receive log messages. -Call [`ClientSession.SetLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLevel) to change the log level for a session. +Call [`ClientSession.SetLoggingLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLoggingLevel) to change the log level for a session. %include ../../mcp/server_example_test.go logging - @@ -307,6 +509,14 @@ server := mcp.NewServer(impl, &mcp.ServerOptions{ **Deprecated**: The `HasPrompts`, `HasResources`, and `HasTools` fields on `ServerOptions` are deprecated. Use `Capabilities` instead. +### Extensions + +[SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133) +adds an `extensions` map to `ServerCapabilities` so that optional +capabilities outside the core protocol can be declared on the wire. Keys +are namespaced as `"{vendor-prefix}/{extension-name}"`; values are +per-extension settings objects. + ### Pagination Server-side feature lists may be diff --git a/internal/docs/toc_test.go b/internal/docs/toc_test.go new file mode 100644 index 0000000..23b02e7 --- /dev/null +++ b/internal/docs/toc_test.go @@ -0,0 +1,70 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package docs + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// Headings are slugged twice by two different tools: weave writes the %toc +// links, and the renderer (GitHub, and mkdocs on go.sdk.modelcontextprotocol.io) +// assigns the heading ids. The two agree on plain words, and disagree on +// punctuation: weave keeps "(", ")" and "/" and drops "_", the renderers drop +// the first three and keep the last. A heading carrying any of them therefore +// gets a table-of-contents entry that lands nowhere. +// +// This test reads the generated docs and checks every %toc entry against the +// ids the renderers will actually produce. + +var ( + headingRE = regexp.MustCompile(`(?m)^(#{1,6})\s+(.*?)\s*$`) + tocLineRE = regexp.MustCompile(`(?m)^\s*1\. \[(.*?)\]\((#.*?)\)\s*$`) + dropRE = regexp.MustCompile(`[^\w\s-]`) + spaceRE = regexp.MustCompile(`[-\s]+`) +) + +// renderedID reproduces the anchor GitHub and python-markdown's toc extension +// derive from heading text: take the text, drop everything that is not a word +// character, whitespace or a hyphen, lowercase it, and join on hyphens. +func renderedID(heading string) string { + s := strings.ReplaceAll(heading, "`", "") + s = dropRE.ReplaceAllString(s, "") + s = strings.ToLower(strings.TrimSpace(s)) + return spaceRE.ReplaceAllString(s, "-") +} + +func TestTOCAnchorsResolve(t *testing.T) { + files, err := filepath.Glob(filepath.Join("..", "..", "docs", "*.md")) + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatal("no generated docs found") + } + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + text := string(data) + + ids := make(map[string]bool) + for _, m := range headingRE.FindAllStringSubmatch(text, -1) { + ids[renderedID(m[2])] = true + } + for _, m := range tocLineRE.FindAllStringSubmatch(text, -1) { + label, target := m[1], strings.TrimPrefix(m[2], "#") + if !ids[target] { + t.Errorf("%s: table-of-contents entry %q points at #%s, which no heading produces "+ + "(want #%s); drop (, ), / and _ from the heading", + filepath.Base(file), label, target, renderedID(label)) + } + } + } +} diff --git a/internal/json/json.go b/internal/json/json.go index b4c897f..3a0e52b 100644 --- a/internal/json/json.go +++ b/internal/json/json.go @@ -8,11 +8,21 @@ package json import ( "bytes" + "fmt" "io" "github.com/segmentio/encoding/json" ) +// defaultMaxDepth is the maximum JSON nesting depth accepted by [Unmarshal]. +// Realistic MCP payloads nest only a handful of levels, so 1000 is far more +// than any legitimate message requires while keeping worst-case parse cost +// bounded. +const defaultMaxDepth = 1000 + +// errMaxDepthExceeded is returned by [Unmarshal] when the input nests deeper than [defaultMaxDepth]. +var errMaxDepthExceeded = fmt.Errorf("json: exceeded maximum nesting depth of %d", defaultMaxDepth) + type Decoder struct { dec *json.Decoder } @@ -28,5 +38,56 @@ func (d *Decoder) Decode(v any) error { } func Unmarshal(data []byte, v any) error { + if err := checkMaxDepth(data, defaultMaxDepth); err != nil { + return err + } return NewDecoder(bytes.NewReader(data)).Decode(v) } + +func UnmarshalUseNumber(data []byte, v any) error { + if err := checkMaxDepth(data, defaultMaxDepth); err != nil { + return err + } + dec := NewDecoder(bytes.NewReader(data)) + dec.dec.UseNumber() + return dec.Decode(v) +} + +// checkMaxDepth scans data once and reports [errMaxDepthExceeded] if the +// nesting of JSON objects and arrays exceeds maxDepth. It is a lightweight pass which +// tracks '{' and '[' against '}' and ']' while skipping over the contents of strings. +func checkMaxDepth(data []byte, maxDepth int) error { + var ( + depth int + inString bool + escaped bool + ) + for _, b := range data { + if inString { + switch { + case escaped: + escaped = false + case b == '\\': + escaped = true + case b == '"': + inString = false + } + continue + } + + switch b { + case '"': + inString = true + case '{', '[': + depth++ + if depth > maxDepth { + return errMaxDepthExceeded + } + case '}', ']': + if depth > 0 { + depth-- + } + } + } + return nil +} diff --git a/internal/json/json_test.go b/internal/json/json_test.go index d037e88..b1d110c 100644 --- a/internal/json/json_test.go +++ b/internal/json/json_test.go @@ -7,9 +7,11 @@ package json import ( "bytes" "encoding/json" + "errors" "fmt" "strings" "testing" + "time" "github.com/google/go-cmp/cmp" ) @@ -140,6 +142,51 @@ func TestUnmarshalNullCharacter(t *testing.T) { } } +func TestUnmarshalChecksNestingDepth(t *testing.T) { + createNesting := func(depth int) []byte { + return []byte(strings.Repeat("[", depth) + strings.Repeat("]", depth)) + } + + tests := []struct { + name string + data []byte + wantErr bool + }{ + {"flat object", []byte(`{"a":1}`), false}, + {"at limit", createNesting(defaultMaxDepth), false}, + {"one over limit", createNesting(defaultMaxDepth + 1), true}, + {"brackets in strings do not count", []byte(`{"a":"[[[[[[[[[["}`), false}, + {"escaped quote in string", []byte(`{"a":"\"[[[[[["}`), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var v any + err := Unmarshal(tt.data, &v) + gotDepthErr := errors.Is(err, errMaxDepthExceeded) + if gotDepthErr != tt.wantErr { + t.Fatalf("Unmarshal() error = %v (err=%v), want depth error = %v", gotDepthErr, err, tt.wantErr) + } + }) + } +} + +func TestUnmarshalMaxDepthExitsFast(t *testing.T) { + data := []byte(strings.Repeat("[", 500_000) + strings.Repeat("]", 500_000)) + done := make(chan error, 1) + go func() { + var v any + done <- Unmarshal(data, &v) + }() + select { + case err := <-done: + if !errors.Is(err, errMaxDepthExceeded) { + t.Fatalf("Unmarshal error = %v, want %v", err, errMaxDepthExceeded) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("Unmarshal did not reject deeply nested payload promptly") + } +} + func FuzzUnmarshalKeyComparison(f *testing.F) { // Add seed corpus for expected valid key and the known problematic cases. f.Add("field") diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 571df63..94da6ae 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -14,46 +14,24 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/internal/json" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" ) -// Binder builds a connection configuration. -// This may be used in servers to generate a new configuration per connection. -// ConnectionOptions itself implements Binder returning itself unmodified, to -// allow for the simple cases where no per connection information is needed. -type Binder interface { - // Bind returns the ConnectionOptions to use when establishing the passed-in - // Connection. - // - // The connection is not ready to use when Bind is called, - // but Bind may close it without reading or writing to it. - Bind(context.Context, *Connection) ConnectionOptions -} - -// A BinderFunc implements the Binder interface for a standalone Bind function. -type BinderFunc func(context.Context, *Connection) ConnectionOptions - -func (f BinderFunc) Bind(ctx context.Context, c *Connection) ConnectionOptions { - return f(ctx, c) -} - -var _ Binder = BinderFunc(nil) - -// ConnectionOptions holds the options for new connections. -type ConnectionOptions struct { - // Framer allows control over the message framing and encoding. - // If nil, HeaderFramer will be used. - Framer Framer - // Preempter allows registration of a pre-queue message handler. - // If nil, no messages will be preempted. - Preempter Preempter - // Handler is used as the queued message handler for inbound messages. - // If nil, all responses will be ErrNotHandled. - Handler Handler - // OnInternalError, if non-nil, is called with any internal errors that occur - // while serving the connection, such as protocol errors or invariant - // violations. (If nil, internal errors result in panics.) - OnInternalError func(error) -} +// nomethodnotfoundcodeinerror is a compatibility parameter that restores the +// pre-fix behavior of [processResult], where wrapped [ErrNotHandled] or +// [ErrMethodNotFound] errors returned by request handlers were not +// recognized as "method not found" signals. The original switch statement +// compared sentinel errors with ==, which never matched errors returned via +// fmt.Errorf("%w: ...", ErrNotHandled, ...) — including the ones produced +// by checkRequest. As a result the wire error response carried code 0 +// instead of code -32601. The fix uses errors.Is to recognize wrapped +// sentinels and append the method name to the message. +// +// To restore the previous behavior, set MCPGODEBUG=nomethodnotfoundcodeinerror=1. +// This option will be removed in a future SDK version. +// See the documentation for the mcpgodebug package for instructions on how +// to use it. +var nomethodnotfoundcodeinerror = mcpgodebug.Value("nomethodnotfoundcodeinerror") // Connection manages the jsonrpc2 protocol, connecting responses back to their // calls. Connection is bidirectional; it does not have a designated server or @@ -180,12 +158,12 @@ func (s *inFlightState) shuttingDown(errClosing error) error { if s.readErr != nil { // If the read side of the connection is broken, we cannot read new call // requests, and cannot read responses to our outgoing calls. - return fmt.Errorf("%w: %v", errClosing, s.readErr) + return fmt.Errorf("%w: %w", errClosing, s.readErr) } if s.writeErr != nil { // If the write side of the connection is broken, we cannot write responses // for incoming calls, and cannot write requests for outgoing calls. - return fmt.Errorf("%w: %v", errClosing, s.writeErr) + return fmt.Errorf("%w: %w", errClosing, s.writeErr) } return nil } @@ -194,12 +172,31 @@ func (s *inFlightState) shuttingDown(errClosing error) error { type incomingRequest struct { *Request // the request being processed ctx context.Context - cancel context.CancelFunc + cancel context.CancelCauseFunc } -// Bind returns the options unmodified. -func (o ConnectionOptions) Bind(context.Context, *Connection) ConnectionOptions { - return o +// Reader abstracts the transport mechanics from the JSON RPC protocol. +// A Connection reads messages from the reader it was provided on construction, +// and assumes that each call to Read fully transfers a single message, +// or returns an error. +// +// A reader is not safe for concurrent use, it is expected it will be used by +// a single Connection in a safe manner. +type Reader interface { + // Read gets the next message from the stream. + Read(context.Context) (Message, error) +} + +// Writer abstracts the transport mechanics from the JSON RPC protocol. +// A Connection writes messages using the writer it was provided on construction, +// and assumes that each call to Write fully transfers a single message, +// or returns an error. +// +// A writer must be safe for concurrent use, as writes may occur concurrently +// in practice: libraries may make calls or respond to requests asynchronously. +type Writer interface { + // Write sends a message to the stream. + Write(context.Context, Message) error } // A ConnectionConfig configures a bidirectional jsonrpc2 connection. @@ -211,12 +208,30 @@ type ConnectionConfig struct { Bind func(*Connection) Handler // required OnDone func() // optional OnInternalError func(error) // optional + + // PropagateCancellation controls whether cancellation of the context + // passed to [NewConnection] is observable by request handlers. + // + // By default (false), the connection wraps that context (see [notDone]) + // so handlers' Done channels do not fire when the connection's root + // context is cancelled. Cancellation of an in-flight handler is then + // expected to flow only through the jsonrpc2 layer's explicit channels + // (the [Preempter] reacting to the peer's cancel notification, or a + // transport read/write failure cancelling every in-flight request). + // + // Set this true when cancellation of the connection's context is itself + // a meaningful signal that handlers should react to (for example, when + // the connection is tied to a carrier that owns it and whose end means + // the request is cancelled). + PropagateCancellation bool // optional } // NewConnection creates a new [Connection] object and starts processing // incoming messages. func NewConnection(ctx context.Context, cfg ConnectionConfig) *Connection { - ctx = notDone{ctx} + if !cfg.PropagateCancellation { + ctx = notDone{ctx} + } c := &Connection{ state: inFlightState{closer: cfg.Closer}, @@ -230,59 +245,16 @@ func NewConnection(ctx context.Context, cfg ConnectionConfig) *Connection { return c } -// bindConnection creates a new connection and runs it. -// -// This is used by the Dial and Serve functions to build the actual connection. -// -// The connection is closed automatically (and its resources cleaned up) when -// the last request has completed after the underlying ReadWriteCloser breaks, -// but it may be stopped earlier by calling Close (for a clean shutdown). -func bindConnection(bindCtx context.Context, rwc io.ReadWriteCloser, binder Binder, onDone func()) *Connection { - // TODO: Should we create a new event span here? - // This will propagate cancellation from ctx; should it? - ctx := notDone{bindCtx} - - c := &Connection{ - state: inFlightState{closer: rwc}, - done: make(chan struct{}), - onDone: onDone, - } - // It's tempting to set a finalizer on c to verify that the state has gone - // idle when the connection becomes unreachable. Unfortunately, the Binder - // interface makes that unsafe: it allows the Handler to close over the - // Connection, which could create a reference cycle that would cause the - // Connection to become uncollectable. - - options := binder.Bind(bindCtx, c) - framer := options.Framer - if framer == nil { - framer = HeaderFramer() - } - c.handler = options.Handler - if c.handler == nil { - c.handler = defaultHandler{} - } - c.onInternalError = options.OnInternalError - - c.writer = framer.Writer(rwc) - reader := framer.Reader(rwc) - c.start(ctx, reader, options.Preempter) - return c -} - func (c *Connection) start(ctx context.Context, reader Reader, preempter Preempter) { c.updateInFlight(func(s *inFlightState) { select { case <-c.done: - // Bind already closed the connection; don't start a goroutine to read it. + // The connection was already closed; don't start a goroutine to read it. return default: } // The goroutine started here will continue until the underlying stream is closed. - // - // (If the Binder closed the Connection already, this should error out and - // return almost immediately.) s.reading = true go c.readIncoming(ctx, reader, preempter) }) @@ -439,18 +411,6 @@ type AsyncCall struct { // This can be used to cancel the call if needed. func (ac *AsyncCall) ID() ID { return ac.id } -// IsReady can be used to check if the result is already prepared. -// This is guaranteed to return true on a result for which Await has already -// returned, or a call that failed to send in the first place. -func (ac *AsyncCall) IsReady() bool { - select { - case <-ac.ready: - return true - default: - return false - } -} - // retire processes the response to the call. // // It is an error to call retire more than once: retire is guarded by the @@ -493,12 +453,19 @@ func (ac *AsyncCall) Await(ctx context.Context, result any) error { // will not cause any messages that have not arrived yet with that ID to be // cancelled. func (c *Connection) Cancel(id ID) { + c.CancelCause(id, nil) +} + +// CancelCause is like [Connection.Cancel], but records cause as the reason +// the Context was cancelled, so that the Handle call can read it back through +// [context.Cause]. A nil cause reads as [context.Canceled]. +func (c *Connection) CancelCause(id ID, cause error) { var req *incomingRequest c.updateInFlight(func(s *inFlightState) { req = s.incomingByID[id] }) if req != nil { - req.cancel() + req.cancel(cause) } } @@ -588,6 +555,16 @@ func (c *Connection) readIncoming(ctx context.Context, reader Reader, preempter ac.retire(&Response{ID: id, Error: err}) } s.outgoingCalls = nil + + // Cancel any incoming requests still in flight: with the reader gone we + // cannot receive cancellation notifications, and likely cannot write a + // response either, so parked handlers have nothing useful left to do. + // The read error (typically io.EOF from the peer disconnecting) is the + // cause, rather than a directional ErrServerClosing/ErrClientClosing + // sentinel: at this layer the connection has no designated end. + for _, r := range s.incomingByID { + r.cancel(err) + } }) } @@ -596,7 +573,7 @@ func (c *Connection) readIncoming(ctx context.Context, reader Reader, preempter func (c *Connection) acceptRequest(ctx context.Context, msg *Request, preempter Preempter) { // In theory notifications cannot be cancelled, but we build them a cancel // context anyway. - reqCtx, cancel := context.WithCancel(ctx) + reqCtx, cancel := context.WithCancelCause(ctx) req := &incomingRequest{ Request: msg, ctx: reqCtx, @@ -697,15 +674,14 @@ func (c *Connection) handleAsync() { return } - // Only deliver to the Handler if not already canceled. + // Only deliver to the Handler if not already canceled. If the request + // was canceled with a cause (e.g. a write failure cancels every + // in-flight request), report that cause rather than the bare + // context.Canceled. if err := req.ctx.Err(); err != nil { - c.updateInFlight(func(s *inFlightState) { - if s.writeErr != nil { - // Assume that req.ctx was canceled due to s.writeErr. - // TODO(#51365): use a Context API to plumb this through req.ctx. - err = fmt.Errorf("%w: %v", ErrServerClosing, s.writeErr) - } - }) + if cause := context.Cause(req.ctx); cause != nil { + err = cause + } c.processResult("handleAsync", req, nil, err) continue } @@ -723,9 +699,7 @@ func (c *Connection) handleAsync() { // processResult processes the result of a request and, if appropriate, sends a response. func (c *Connection) processResult(from any, req *incomingRequest, result any, err error) error { - switch err { - case ErrNotHandled, ErrMethodNotFound: - // Add detail describing the unhandled method. + if nomethodnotfoundcodeinerror != "1" && (errors.Is(err, ErrNotHandled) || errors.Is(err, ErrMethodNotFound)) { err = fmt.Errorf("%w: %q", ErrMethodNotFound, req.Method) } @@ -768,7 +742,7 @@ func (c *Connection) processResult(from any, req *incomingRequest, result any, e } // Cancel the request to free any associated resources. - req.cancel() + req.cancel(nil) c.updateInFlight(func(s *inFlightState) { if s.incoming == 0 { panic("jsonrpc2: processResult called when incoming count is already zero") @@ -784,9 +758,12 @@ func (c *Connection) write(ctx context.Context, msg Message) error { var err error // Fail writes immediately if the connection is shutting down. // - // TODO(rfindley): should we allow cancellation notifications through? It - // could be the case that writes can still succeed. + // Allow outgoing "notifications" forwarded by the Notify method. + // This will allow to send the cancelled notification when the client is shutting down. c.updateInFlight(func(s *inFlightState) { + if req, ok := msg.(*Request); ok && !req.IsCall() && s.outgoingNotifications > 0 { + return + } err = s.shuttingDown(ErrServerClosing) }) if err == nil { @@ -808,7 +785,7 @@ func (c *Connection) write(ctx context.Context, msg Message) error { if s.writeErr == nil { s.writeErr = err for _, r := range s.incomingByID { - r.cancel() + r.cancel(fmt.Errorf("%w: %v", ErrServerClosing, err)) } } }) @@ -831,6 +808,15 @@ func (c *Connection) internalErrorf(format string, args ...any) error { } // notDone is a context.Context wrapper that returns a nil Done channel. +// +// Request handlers' contexts are derived from the connection's root context, +// which by default is wrapped in notDone so a transport-level cancellation +// does not implicitly cancel every in-flight handler. Cancellation of an +// in-flight handler is instead expected to flow only through the jsonrpc2 +// layer's explicit channels: the [Preempter] calling [Connection.Cancel] in +// response to the peer's cancel notification, or the transport itself +// failing (the read loop exits on EOF or a write fails) — both of which +// cancel every in-flight incoming request in turn. type notDone struct{ ctx context.Context } func (ic notDone) Value(key any) any { diff --git a/internal/jsonrpc2/conn_test.go b/internal/jsonrpc2/conn_test.go new file mode 100644 index 0000000..afeb4c0 --- /dev/null +++ b/internal/jsonrpc2/conn_test.go @@ -0,0 +1,38 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package jsonrpc2 + +import ( + "errors" + "io" + "testing" +) + +// TestShuttingDownWrapsWriteError verifies that when a connection shuts down +// because its write side failed, the returned error preserves the underlying +// write error in its chain so callers can classify it with errors.Is (for +// example, distinguishing io.EOF from a clean host disconnect versus a real +// failure). +func TestShuttingDownWrapsWriteError(t *testing.T) { + s := &inFlightState{writeErr: io.EOF} + err := s.shuttingDown(ErrServerClosing) + if !errors.Is(err, ErrServerClosing) { + t.Errorf("shuttingDown() error = %v, want it to wrap ErrServerClosing", err) + } + if !errors.Is(err, io.EOF) { + t.Errorf("shuttingDown() error = %v, want it to wrap io.EOF", err) + } +} + +func TestShuttingDownWrapsReadError(t *testing.T) { + s := &inFlightState{readErr: io.EOF} + err := s.shuttingDown(ErrServerClosing) + if !errors.Is(err, ErrServerClosing) { + t.Errorf("shuttingDown() error = %v, want it to wrap ErrServerClosing", err) + } + if !errors.Is(err, io.EOF) { + t.Errorf("shuttingDown() error = %v, want it to wrap io.EOF", err) + } +} diff --git a/internal/jsonrpc2/frame.go b/internal/jsonrpc2/frame.go deleted file mode 100644 index 72527cb..0000000 --- a/internal/jsonrpc2/frame.go +++ /dev/null @@ -1,208 +0,0 @@ -// Copyright 2018 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -package jsonrpc2 - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - "strconv" - "strings" - "sync" -) - -// Reader abstracts the transport mechanics from the JSON RPC protocol. -// A Conn reads messages from the reader it was provided on construction, -// and assumes that each call to Read fully transfers a single message, -// or returns an error. -// -// A reader is not safe for concurrent use, it is expected it will be used by -// a single Conn in a safe manner. -type Reader interface { - // Read gets the next message from the stream. - Read(context.Context) (Message, error) -} - -// Writer abstracts the transport mechanics from the JSON RPC protocol. -// A Conn writes messages using the writer it was provided on construction, -// and assumes that each call to Write fully transfers a single message, -// or returns an error. -// -// A writer must be safe for concurrent use, as writes may occur concurrently -// in practice: libraries may make calls or respond to requests asynchronously. -type Writer interface { - // Write sends a message to the stream. - Write(context.Context, Message) error -} - -// Framer wraps low level byte readers and writers into jsonrpc2 message -// readers and writers. -// It is responsible for the framing and encoding of messages into wire form. -// -// TODO(rfindley): rethink the framer interface, as with JSONRPC2 batching -// there is a need for Reader and Writer to be correlated, and while the -// implementation of framing here allows that, it is not made explicit by the -// interface. -// -// Perhaps a better interface would be -// -// Frame(io.ReadWriteCloser) (Reader, Writer). -type Framer interface { - // Reader wraps a byte reader into a message reader. - Reader(io.Reader) Reader - // Writer wraps a byte writer into a message writer. - Writer(io.Writer) Writer -} - -// RawFramer returns a new Framer. -// The messages are sent with no wrapping, and rely on json decode consistency -// to determine message boundaries. -func RawFramer() Framer { return rawFramer{} } - -type rawFramer struct{} -type rawReader struct{ in *json.Decoder } -type rawWriter struct { - mu sync.Mutex - out io.Writer -} - -func (rawFramer) Reader(rw io.Reader) Reader { - return &rawReader{in: json.NewDecoder(rw)} -} - -func (rawFramer) Writer(rw io.Writer) Writer { - return &rawWriter{out: rw} -} - -func (r *rawReader) Read(ctx context.Context) (Message, error) { - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - } - var raw json.RawMessage - if err := r.in.Decode(&raw); err != nil { - return nil, err - } - msg, err := DecodeMessage(raw) - return msg, err -} - -func (w *rawWriter) Write(ctx context.Context, msg Message) error { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - data, err := EncodeMessage(msg) - if err != nil { - return fmt.Errorf("marshaling message: %v", err) - } - - w.mu.Lock() - defer w.mu.Unlock() - _, err = w.out.Write(data) - return err -} - -// HeaderFramer returns a new Framer. -// The messages are sent with HTTP content length and MIME type headers. -// This is the format used by LSP and others. -func HeaderFramer() Framer { return headerFramer{} } - -type headerFramer struct{} -type headerReader struct{ in *bufio.Reader } -type headerWriter struct { - mu sync.Mutex - out io.Writer -} - -func (headerFramer) Reader(rw io.Reader) Reader { - return &headerReader{in: bufio.NewReader(rw)} -} - -func (headerFramer) Writer(rw io.Writer) Writer { - return &headerWriter{out: rw} -} - -func (r *headerReader) Read(ctx context.Context) (Message, error) { - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - } - - firstRead := true // to detect a clean EOF below - var contentLength int64 - // read the header, stop on the first empty line - for { - line, err := r.in.ReadString('\n') - if err != nil { - if err == io.EOF { - if firstRead && line == "" { - return nil, io.EOF // clean EOF - } - err = io.ErrUnexpectedEOF - } - return nil, fmt.Errorf("failed reading header line: %w", err) - } - firstRead = false - - line = strings.TrimSpace(line) - // check we have a header line - if line == "" { - break - } - colon := strings.IndexRune(line, ':') - if colon < 0 { - return nil, fmt.Errorf("invalid header line %q", line) - } - name, value := line[:colon], strings.TrimSpace(line[colon+1:]) - switch { - case strings.EqualFold(name, "Content-Length"): - if contentLength, err = strconv.ParseInt(value, 10, 32); err != nil { - return nil, fmt.Errorf("failed parsing Content-Length: %v", value) - } - if contentLength <= 0 { - return nil, fmt.Errorf("invalid Content-Length: %v", contentLength) - } - default: - // ignoring unknown headers - } - } - if contentLength == 0 { - return nil, fmt.Errorf("missing Content-Length header") - } - data := make([]byte, contentLength) - _, err := io.ReadFull(r.in, data) - if err != nil { - return nil, err - } - msg, err := DecodeMessage(data) - return msg, err -} - -func (w *headerWriter) Write(ctx context.Context, msg Message) error { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - w.mu.Lock() - defer w.mu.Unlock() - - data, err := EncodeMessage(msg) - if err != nil { - return fmt.Errorf("marshaling message: %v", err) - } - _, err = fmt.Fprintf(w.out, "Content-Length: %v\r\n\r\n", len(data)) - if err == nil { - _, err = w.out.Write(data) - } - return err -} diff --git a/internal/jsonrpc2/jsonrpc2.go b/internal/jsonrpc2/jsonrpc2.go index 234e6ee..76a5116 100644 --- a/internal/jsonrpc2/jsonrpc2.go +++ b/internal/jsonrpc2/jsonrpc2.go @@ -12,17 +12,12 @@ import ( "errors" ) -var ( - // ErrIdleTimeout is returned when serving timed out waiting for new connections. - ErrIdleTimeout = errors.New("timed out waiting for new connections") - - // ErrNotHandled is returned from a Handler or Preempter to indicate it did - // not handle the request. - // - // If a Handler returns ErrNotHandled, the server replies with - // ErrMethodNotFound. - ErrNotHandled = errors.New("JSON RPC not handled") -) +// ErrNotHandled is returned from a Handler or Preempter to indicate it did +// not handle the request. +// +// If a Handler returns ErrNotHandled, the server replies with +// ErrMethodNotFound. +var ErrNotHandled = errors.New("JSON RPC not handled") // Preempter handles messages on a connection before they are queued to the main // handler. @@ -40,15 +35,6 @@ type Preempter interface { Preempt(ctx context.Context, req *Request) (result any, err error) } -// A PreempterFunc implements the Preempter interface for a standalone Preempt function. -type PreempterFunc func(ctx context.Context, req *Request) (any, error) - -func (f PreempterFunc) Preempt(ctx context.Context, req *Request) (any, error) { - return f(ctx, req) -} - -var _ Preempter = PreempterFunc(nil) - // Handler handles messages on a connection. type Handler interface { // Handle is invoked sequentially for each incoming request that has not @@ -67,16 +53,6 @@ type Handler interface { Handle(ctx context.Context, req *Request) (result any, err error) } -type defaultHandler struct{} - -func (defaultHandler) Preempt(context.Context, *Request) (any, error) { - return nil, ErrNotHandled -} - -func (defaultHandler) Handle(context.Context, *Request) (any, error) { - return nil, ErrNotHandled -} - // A HandlerFunc implements the Handler interface for a standalone Handle function. type HandlerFunc func(ctx context.Context, req *Request) (any, error) @@ -85,37 +61,3 @@ func (f HandlerFunc) Handle(ctx context.Context, req *Request) (any, error) { } var _ Handler = HandlerFunc(nil) - -// async is a small helper for operations with an asynchronous result that you -// can wait for. -type async struct { - ready chan struct{} // closed when done - firstErr chan error // 1-buffered; contains either nil or the first non-nil error -} - -func newAsync() *async { - var a async - a.ready = make(chan struct{}) - a.firstErr = make(chan error, 1) - a.firstErr <- nil - return &a -} - -func (a *async) done() { - close(a.ready) -} - -func (a *async) wait() error { - <-a.ready - err := <-a.firstErr - a.firstErr <- err - return err -} - -func (a *async) setError(err error) { - storedErr := <-a.firstErr - if storedErr == nil { - storedErr = err - } - a.firstErr <- storedErr -} diff --git a/internal/jsonrpc2/jsonrpc2_test.go b/internal/jsonrpc2/jsonrpc2_test.go deleted file mode 100644 index 8c79300..0000000 --- a/internal/jsonrpc2/jsonrpc2_test.go +++ /dev/null @@ -1,385 +0,0 @@ -// Copyright 2018 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -package jsonrpc2_test - -import ( - "context" - "encoding/json" - "fmt" - "path" - "reflect" - "testing" - "time" - - "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" -) - -var callTests = []invoker{ - call{"no_args", nil, true}, - call{"one_string", "fish", "got:fish"}, - call{"one_number", 10, "got:10"}, - call{"join", []string{"a", "b", "c"}, "a/b/c"}, - sequence{"notify", []invoker{ - notify{"set", 3}, - notify{"add", 5}, - call{"get", nil, 8}, - }}, - sequence{"preempt", []invoker{ - async{"a", "wait", "a"}, - notify{"unblock", "a"}, - collect{"a", true, false}, - }}, - sequence{"basic cancel", []invoker{ - async{"b", "wait", "b"}, - cancel{"b"}, - collect{"b", nil, true}, - }}, - sequence{"queue", []invoker{ - async{"a", "wait", "a"}, - notify{"set", 1}, - notify{"add", 2}, - notify{"add", 3}, - notify{"add", 4}, - call{"peek", nil, 0}, // accumulator will not have any adds yet - notify{"unblock", "a"}, - collect{"a", true, false}, - call{"get", nil, 10}, // accumulator now has all the adds - }}, - sequence{"fork", []invoker{ - async{"a", "fork", "a"}, - notify{"set", 1}, - notify{"add", 2}, - notify{"add", 3}, - notify{"add", 4}, - call{"get", nil, 10}, // fork will not have blocked the adds - notify{"unblock", "a"}, - collect{"a", true, false}, - }}, - sequence{"concurrent", []invoker{ - async{"a", "fork", "a"}, - notify{"unblock", "a"}, - async{"b", "fork", "b"}, - notify{"unblock", "b"}, - collect{"a", true, false}, - collect{"b", true, false}, - }}, -} - -type binder struct { - framer jsonrpc2.Framer - runTest func(*handler) -} - -type handler struct { - conn *jsonrpc2.Connection - accumulator int - waiters chan map[string]chan struct{} - calls map[string]*jsonrpc2.AsyncCall -} - -type invoker interface { - Name() string - Invoke(t *testing.T, ctx context.Context, h *handler) -} - -type notify struct { - method string - params any -} - -type call struct { - method string - params any - expect any -} - -type async struct { - name string - method string - params any -} - -type collect struct { - name string - expect any - fails bool -} - -type cancel struct { - name string -} - -type sequence struct { - name string - tests []invoker -} - -type echo call - -type cancelParams struct{ ID int64 } - -func TestConnectionRaw(t *testing.T) { - testConnection(t, jsonrpc2.RawFramer()) -} - -func TestConnectionHeader(t *testing.T) { - testConnection(t, jsonrpc2.HeaderFramer()) -} - -func testConnection(t *testing.T, framer jsonrpc2.Framer) { - ctx := context.Background() - listener, err := jsonrpc2.NetPipeListener(ctx) - if err != nil { - t.Fatal(err) - } - server := jsonrpc2.NewServer(ctx, listener, binder{framer, nil}) - defer func() { - listener.Close() - server.Wait() - }() - - for _, test := range callTests { - t.Run(test.Name(), func(t *testing.T) { - client, err := jsonrpc2.Dial(ctx, - listener.Dialer(), binder{framer, func(h *handler) { - // Sleep a little to a void a race with setting conn.writer in jsonrpc2.bindConnection. - time.Sleep(50 * time.Millisecond) - defer h.conn.Close() - test.Invoke(t, ctx, h) - if call, ok := test.(*call); ok { - // also run all simple call tests in echo mode - (*echo)(call).Invoke(t, ctx, h) - } - }}, nil) - if err != nil { - t.Fatal(err) - } - client.Wait() - }) - } -} - -func (test notify) Name() string { return test.method } -func (test notify) Invoke(t *testing.T, ctx context.Context, h *handler) { - if err := h.conn.Notify(ctx, test.method, test.params); err != nil { - t.Fatalf("%v:Notify failed: %v", test.method, err) - } -} - -func (test call) Name() string { return test.method } -func (test call) Invoke(t *testing.T, ctx context.Context, h *handler) { - results := newResults(test.expect) - if err := h.conn.Call(ctx, test.method, test.params).Await(ctx, results); err != nil { - t.Fatalf("%v:Call failed: %v", test.method, err) - } - verifyResults(t, test.method, results, test.expect) -} - -func (test echo) Invoke(t *testing.T, ctx context.Context, h *handler) { - results := newResults(test.expect) - if err := h.conn.Call(ctx, "echo", []any{test.method, test.params}).Await(ctx, results); err != nil { - t.Fatalf("%v:Echo failed: %v", test.method, err) - } - verifyResults(t, test.method, results, test.expect) -} - -func (test async) Name() string { return test.name } -func (test async) Invoke(t *testing.T, ctx context.Context, h *handler) { - h.calls[test.name] = h.conn.Call(ctx, test.method, test.params) -} - -func (test collect) Name() string { return test.name } -func (test collect) Invoke(t *testing.T, ctx context.Context, h *handler) { - o := h.calls[test.name] - results := newResults(test.expect) - err := o.Await(ctx, results) - switch { - case test.fails && err == nil: - t.Fatalf("%v:Collect was supposed to fail", test.name) - case !test.fails && err != nil: - t.Fatalf("%v:Collect failed: %v", test.name, err) - } - verifyResults(t, test.name, results, test.expect) -} - -func (test cancel) Name() string { return test.name } -func (test cancel) Invoke(t *testing.T, ctx context.Context, h *handler) { - o := h.calls[test.name] - if err := h.conn.Notify(ctx, "cancel", &cancelParams{o.ID().Raw().(int64)}); err != nil { - t.Fatalf("%v:Collect failed: %v", test.name, err) - } -} - -func (test sequence) Name() string { return test.name } -func (test sequence) Invoke(t *testing.T, ctx context.Context, h *handler) { - for _, child := range test.tests { - child.Invoke(t, ctx, h) - } -} - -// newResults makes a new empty copy of the expected type to put the results into -func newResults(expect any) any { - switch e := expect.(type) { - case []any: - var r []any - for _, v := range e { - r = append(r, reflect.New(reflect.TypeOf(v)).Interface()) - } - return r - case nil: - return nil - default: - return reflect.New(reflect.TypeOf(expect)).Interface() - } -} - -// verifyResults compares the results to the expected values -func verifyResults(t *testing.T, method string, results any, expect any) { - if expect == nil { - if results != nil { - t.Errorf("%v:Got results %+v where none expected", method, expect) - } - return - } - val := reflect.Indirect(reflect.ValueOf(results)).Interface() - if !reflect.DeepEqual(val, expect) { - t.Errorf("%v:Results are incorrect, got %+v expect %+v", method, val, expect) - } -} - -func (b binder) Bind(ctx context.Context, conn *jsonrpc2.Connection) jsonrpc2.ConnectionOptions { - h := &handler{ - conn: conn, - waiters: make(chan map[string]chan struct{}, 1), - calls: make(map[string]*jsonrpc2.AsyncCall), - } - h.waiters <- make(map[string]chan struct{}) - if b.runTest != nil { - go b.runTest(h) - } - return jsonrpc2.ConnectionOptions{ - Framer: b.framer, - Preempter: h, - Handler: h, - } -} - -func (h *handler) waiter(name string) chan struct{} { - waiters := <-h.waiters - defer func() { h.waiters <- waiters }() - waiter, found := waiters[name] - if !found { - waiter = make(chan struct{}) - waiters[name] = waiter - } - return waiter -} - -func (h *handler) Preempt(ctx context.Context, req *jsonrpc2.Request) (any, error) { - switch req.Method { - case "unblock": - var name string - if err := json.Unmarshal(req.Params, &name); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - close(h.waiter(name)) - return nil, nil - case "peek": - if len(req.Params) > 0 { - return nil, fmt.Errorf("%w: expected no params", jsonrpc2.ErrInvalidParams) - } - return h.accumulator, nil - case "cancel": - var params cancelParams - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - h.conn.Cancel(jsonrpc2.Int64ID(params.ID)) - return nil, nil - default: - return nil, jsonrpc2.ErrNotHandled - } -} - -func (h *handler) Handle(ctx context.Context, req *jsonrpc2.Request) (any, error) { - switch req.Method { - case "no_args": - if len(req.Params) > 0 { - return nil, fmt.Errorf("%w: expected no params", jsonrpc2.ErrInvalidParams) - } - return true, nil - case "one_string": - var v string - if err := json.Unmarshal(req.Params, &v); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - return "got:" + v, nil - case "one_number": - var v int - if err := json.Unmarshal(req.Params, &v); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - return fmt.Sprintf("got:%d", v), nil - case "set": - var v int - if err := json.Unmarshal(req.Params, &v); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - h.accumulator = v - return nil, nil - case "add": - var v int - if err := json.Unmarshal(req.Params, &v); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - h.accumulator += v - return nil, nil - case "get": - if len(req.Params) > 0 { - return nil, fmt.Errorf("%w: expected no params", jsonrpc2.ErrInvalidParams) - } - return h.accumulator, nil - case "join": - var v []string - if err := json.Unmarshal(req.Params, &v); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - return path.Join(v...), nil - case "echo": - var v []any - if err := json.Unmarshal(req.Params, &v); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - var result any - err := h.conn.Call(ctx, v[0].(string), v[1]).Await(ctx, &result) - return result, err - case "wait": - var name string - if err := json.Unmarshal(req.Params, &name); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - select { - case <-h.waiter(name): - return true, nil - case <-ctx.Done(): - return nil, ctx.Err() - } - case "fork": - var name string - if err := json.Unmarshal(req.Params, &name); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - jsonrpc2.Async(ctx) - waitFor := h.waiter(name) - select { - case <-waitFor: - return true, nil - case <-ctx.Done(): - return nil, ctx.Err() - } - default: - return nil, jsonrpc2.ErrNotHandled - } -} diff --git a/internal/jsonrpc2/messages.go b/internal/jsonrpc2/messages.go index b424780..8b96770 100644 --- a/internal/jsonrpc2/messages.go +++ b/internal/jsonrpc2/messages.go @@ -11,8 +11,6 @@ import ( "fmt" internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" - - "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" ) // ID is a Request identifier, which is defined by the spec to be a string, integer, or null. @@ -173,8 +171,20 @@ func EncodeIndent(msg Message, prefix, indent string) ([]byte, error) { return bytes.TrimRight(buf.Bytes(), "\n"), nil } +// wireDecode is the decode form of [wireCombined]. Method is a [json.RawMessage] +// so we can tell whether the "method" key was present on the wire, including +// when its value is the empty string (see go-sdk#976). +type wireDecode struct { + VersionTag string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Method json.RawMessage `json:"method"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *WireError `json:"error,omitempty"` +} + func DecodeMessage(data []byte) (Message, error) { - msg := wireCombined{} + msg := wireDecode{} if err := internaljson.Unmarshal(data, &msg); err != nil { return nil, fmt.Errorf("unmarshaling jsonrpc message: %w", err) } @@ -185,15 +195,19 @@ func DecodeMessage(data []byte) (Message, error) { if err != nil { return nil, err } - if msg.Method != "" { - // has a method, must be a call + if len(msg.Method) > 0 { + // The "method" key was present. Decode its value (including ""). + var method string + if err := internaljson.Unmarshal(msg.Method, &method); err != nil { + return nil, fmt.Errorf("unmarshaling jsonrpc message: %w", err) + } return &Request{ - Method: msg.Method, + Method: method, ID: id, Params: msg.Params, }, nil } - // no method, should be a response + // no method key, should be a response if !id.IsValid() { return nil, ErrInvalidRequest } @@ -219,18 +233,8 @@ func marshalToRaw(obj any) (json.RawMessage, error) { return json.RawMessage(data), nil } -// jsonescaping is a compatibility parameter that allows to restore -// JSON escaping in the JSON marshaling, which stopped being the default -// in the 1.4.0 version of the SDK. See the documentation for the -// mcpgodebug package for instructions how to enable it. -// The option will be removed in the 1.6.0 version of the SDK. -var jsonescaping = mcpgodebug.Value("jsonescaping") - // jsonMarshal marshals obj to JSON like json.Marshal but without HTML escaping. func jsonMarshal(obj any) ([]byte, error) { - if jsonescaping == "1" { - return json.Marshal(obj) - } var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) diff --git a/internal/jsonrpc2/net.go b/internal/jsonrpc2/net.go deleted file mode 100644 index 05db062..0000000 --- a/internal/jsonrpc2/net.go +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright 2018 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -package jsonrpc2 - -import ( - "context" - "io" - "net" - "os" -) - -// This file contains implementations of the transport primitives that use the standard network -// package. - -// NetListenOptions is the optional arguments to the NetListen function. -type NetListenOptions struct { - NetListenConfig net.ListenConfig - NetDialer net.Dialer -} - -// NetListener returns a new Listener that listens on a socket using the net package. -func NetListener(ctx context.Context, network, address string, options NetListenOptions) (Listener, error) { - ln, err := options.NetListenConfig.Listen(ctx, network, address) - if err != nil { - return nil, err - } - return &netListener{net: ln}, nil -} - -// netListener is the implementation of Listener for connections made using the net package. -type netListener struct { - net net.Listener -} - -// Accept blocks waiting for an incoming connection to the listener. -func (l *netListener) Accept(context.Context) (io.ReadWriteCloser, error) { - return l.net.Accept() -} - -// Close will cause the listener to stop listening. It will not close any connections that have -// already been accepted. -func (l *netListener) Close() error { - addr := l.net.Addr() - err := l.net.Close() - if addr.Network() == "unix" { - rerr := os.Remove(addr.String()) - if rerr != nil && err == nil { - err = rerr - } - } - return err -} - -// Dialer returns a dialer that can be used to connect to the listener. -func (l *netListener) Dialer() Dialer { - return NetDialer(l.net.Addr().Network(), l.net.Addr().String(), net.Dialer{}) -} - -// NetDialer returns a Dialer using the supplied standard network dialer. -func NetDialer(network, address string, nd net.Dialer) Dialer { - return &netDialer{ - network: network, - address: address, - dialer: nd, - } -} - -type netDialer struct { - network string - address string - dialer net.Dialer -} - -func (n *netDialer) Dial(ctx context.Context) (io.ReadWriteCloser, error) { - return n.dialer.DialContext(ctx, n.network, n.address) -} - -// NetPipeListener returns a new Listener that listens using net.Pipe. -// It is only possibly to connect to it using the Dialer returned by the -// Dialer method, each call to that method will generate a new pipe the other -// side of which will be returned from the Accept call. -func NetPipeListener(ctx context.Context) (Listener, error) { - return &netPiper{ - done: make(chan struct{}), - dialed: make(chan io.ReadWriteCloser), - }, nil -} - -// netPiper is the implementation of Listener build on top of net.Pipes. -type netPiper struct { - done chan struct{} - dialed chan io.ReadWriteCloser -} - -// Accept blocks waiting for an incoming connection to the listener. -func (l *netPiper) Accept(context.Context) (io.ReadWriteCloser, error) { - // Block until the pipe is dialed or the listener is closed, - // preferring the latter if already closed at the start of Accept. - select { - case <-l.done: - return nil, net.ErrClosed - default: - } - select { - case rwc := <-l.dialed: - return rwc, nil - case <-l.done: - return nil, net.ErrClosed - } -} - -// Close will cause the listener to stop listening. It will not close any connections that have -// already been accepted. -func (l *netPiper) Close() error { - // unblock any accept calls that are pending - close(l.done) - return nil -} - -func (l *netPiper) Dialer() Dialer { - return l -} - -func (l *netPiper) Dial(ctx context.Context) (io.ReadWriteCloser, error) { - client, server := net.Pipe() - - select { - case l.dialed <- server: - return client, nil - - case <-l.done: - client.Close() - server.Close() - return nil, net.ErrClosed - } -} diff --git a/internal/jsonrpc2/serve.go b/internal/jsonrpc2/serve.go deleted file mode 100644 index 424163a..0000000 --- a/internal/jsonrpc2/serve.go +++ /dev/null @@ -1,330 +0,0 @@ -// Copyright 2020 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -package jsonrpc2 - -import ( - "context" - "fmt" - "io" - "runtime" - "sync" - "sync/atomic" - "time" -) - -// Listener is implemented by protocols to accept new inbound connections. -type Listener interface { - // Accept accepts an inbound connection to a server. - // It blocks until either an inbound connection is made, or the listener is closed. - Accept(context.Context) (io.ReadWriteCloser, error) - - // Close closes the listener. - // Any blocked Accept or Dial operations will unblock and return errors. - Close() error - - // Dialer returns a dialer that can be used to connect to this listener - // locally. - // If a listener does not implement this it will return nil. - Dialer() Dialer -} - -// Dialer is used by clients to dial a server. -type Dialer interface { - // Dial returns a new communication byte stream to a listening server. - Dial(ctx context.Context) (io.ReadWriteCloser, error) -} - -// Server is a running server that is accepting incoming connections. -type Server struct { - listener Listener - binder Binder - async *async - - shutdownOnce sync.Once - closing int32 // atomic: set to nonzero when Shutdown is called -} - -// Dial uses the dialer to make a new connection, wraps the returned -// reader and writer using the framer to make a stream, and then builds -// a connection on top of that stream using the binder. -// -// The returned Connection will operate independently using the Preempter and/or -// Handler provided by the Binder, and will release its own resources when the -// connection is broken, but the caller may Close it earlier to stop accepting -// (or sending) new requests. -// -// If non-nil, the onDone function is called when the connection is closed. -func Dial(ctx context.Context, dialer Dialer, binder Binder, onDone func()) (*Connection, error) { - // dial a server - rwc, err := dialer.Dial(ctx) - if err != nil { - return nil, err - } - return bindConnection(ctx, rwc, binder, onDone), nil -} - -// NewServer starts a new server listening for incoming connections and returns -// it. -// This returns a fully running and connected server, it does not block on -// the listener. -// You can call Wait to block on the server, or Shutdown to get the sever to -// terminate gracefully. -// To notice incoming connections, use an intercepting Binder. -func NewServer(ctx context.Context, listener Listener, binder Binder) *Server { - server := &Server{ - listener: listener, - binder: binder, - async: newAsync(), - } - go server.run(ctx) - return server -} - -// Wait returns only when the server has shut down. -func (s *Server) Wait() error { - return s.async.wait() -} - -// Shutdown informs the server to stop accepting new connections. -func (s *Server) Shutdown() { - s.shutdownOnce.Do(func() { - atomic.StoreInt32(&s.closing, 1) - s.listener.Close() - }) -} - -// run accepts incoming connections from the listener, -// If IdleTimeout is non-zero, run exits after there are no clients for this -// duration, otherwise it exits only on error. -func (s *Server) run(ctx context.Context) { - defer s.async.done() - - var activeConns sync.WaitGroup - for { - rwc, err := s.listener.Accept(ctx) - if err != nil { - // Only Shutdown closes the listener. If we get an error after Shutdown is - // called, assume that was the cause and don't report the error; - // otherwise, report the error in case it is unexpected. - if atomic.LoadInt32(&s.closing) == 0 { - s.async.setError(err) - } - // We are done generating new connections for good. - break - } - - // A new inbound connection. - activeConns.Add(1) - _ = bindConnection(ctx, rwc, s.binder, activeConns.Done) // unregisters itself when done - } - activeConns.Wait() -} - -// NewIdleListener wraps a listener with an idle timeout. -// -// When there are no active connections for at least the timeout duration, -// calls to Accept will fail with ErrIdleTimeout. -// -// A connection is considered inactive as soon as its Close method is called. -func NewIdleListener(timeout time.Duration, wrap Listener) Listener { - l := &idleListener{ - wrapped: wrap, - timeout: timeout, - active: make(chan int, 1), - timedOut: make(chan struct{}), - idleTimer: make(chan *time.Timer, 1), - } - l.idleTimer <- time.AfterFunc(l.timeout, l.timerExpired) - return l -} - -type idleListener struct { - wrapped Listener - timeout time.Duration - - // Only one of these channels is receivable at any given time. - active chan int // count of active connections; closed when Close is called if not timed out - timedOut chan struct{} // closed when the idle timer expires - idleTimer chan *time.Timer // holds the timer only when idle -} - -// Accept accepts an incoming connection. -// -// If an incoming connection is accepted concurrent to the listener being closed -// due to idleness, the new connection is immediately closed. -func (l *idleListener) Accept(ctx context.Context) (io.ReadWriteCloser, error) { - rwc, err := l.wrapped.Accept(ctx) - - select { - case n, ok := <-l.active: - if err != nil { - if ok { - l.active <- n - } - return nil, err - } - if ok { - l.active <- n + 1 - } else { - // l.wrapped.Close Close has been called, but Accept returned a - // connection. This race can occur with concurrent Accept and Close calls - // with any net.Listener, and it is benign: since the listener was closed - // explicitly, it can't have also timed out. - } - return l.newConn(rwc), nil - - case <-l.timedOut: - if err == nil { - // Keeping the connection open would leave the listener simultaneously - // active and closed due to idleness, which would be contradictory and - // confusing. Close the connection and pretend that it never happened. - rwc.Close() - } else { - // In theory the timeout could have raced with an unrelated error return - // from Accept. However, ErrIdleTimeout is arguably still valid (since we - // would have closed due to the timeout independent of the error), and the - // harm from returning a spurious ErrIdleTimeout is negligible anyway. - } - return nil, ErrIdleTimeout - - case timer := <-l.idleTimer: - if err != nil { - // The idle timer doesn't run until it receives itself from the idleTimer - // channel, so it can't have called l.wrapped.Close yet and thus err can't - // be ErrIdleTimeout. Leave the idle timer as it was and return whatever - // error we got. - l.idleTimer <- timer - return nil, err - } - - if !timer.Stop() { - // Failed to stop the timer — the timer goroutine is in the process of - // firing. Send the timer back to the timer goroutine so that it can - // safely close the timedOut channel, and then wait for the listener to - // actually be closed before we return ErrIdleTimeout. - l.idleTimer <- timer - rwc.Close() - <-l.timedOut - return nil, ErrIdleTimeout - } - - l.active <- 1 - return l.newConn(rwc), nil - } -} - -func (l *idleListener) Close() error { - select { - case _, ok := <-l.active: - if ok { - close(l.active) - } - - case <-l.timedOut: - // Already closed by the timer; take care not to double-close if the caller - // only explicitly invokes this Close method once, since the io.Closer - // interface explicitly leaves doubled Close calls undefined. - return ErrIdleTimeout - - case timer := <-l.idleTimer: - if !timer.Stop() { - // Couldn't stop the timer. It shouldn't take long to run, so just wait - // (so that the Listener is guaranteed to be closed before we return) - // and pretend that this call happened afterward. - // That way we won't leak any timers or goroutines when Close returns. - l.idleTimer <- timer - <-l.timedOut - return ErrIdleTimeout - } - close(l.active) - } - - return l.wrapped.Close() -} - -func (l *idleListener) Dialer() Dialer { - return l.wrapped.Dialer() -} - -func (l *idleListener) timerExpired() { - select { - case n, ok := <-l.active: - if ok { - panic(fmt.Sprintf("jsonrpc2: idleListener idle timer fired with %d connections still active", n)) - } else { - panic("jsonrpc2: Close finished with idle timer still running") - } - - case <-l.timedOut: - panic("jsonrpc2: idleListener idle timer fired more than once") - - case <-l.idleTimer: - // The timer for this very call! - } - - // Close the Listener with all channels still blocked to ensure that this call - // to l.wrapped.Close doesn't race with the one in l.Close. - defer close(l.timedOut) - l.wrapped.Close() -} - -func (l *idleListener) connClosed() { - select { - case n, ok := <-l.active: - if !ok { - // l is already closed, so it can't close due to idleness, - // and we don't need to track the number of active connections any more. - return - } - n-- - if n == 0 { - l.idleTimer <- time.AfterFunc(l.timeout, l.timerExpired) - } else { - l.active <- n - } - - case <-l.timedOut: - panic("jsonrpc2: idleListener idle timer fired before last active connection was closed") - - case <-l.idleTimer: - panic("jsonrpc2: idleListener idle timer active before last active connection was closed") - } -} - -type idleListenerConn struct { - wrapped io.ReadWriteCloser - l *idleListener - closeOnce sync.Once -} - -func (l *idleListener) newConn(rwc io.ReadWriteCloser) *idleListenerConn { - c := &idleListenerConn{ - wrapped: rwc, - l: l, - } - - // A caller that forgets to call Close may disrupt the idleListener's - // accounting, even though the file descriptor for the underlying connection - // may eventually be garbage-collected anyway. - // - // Set a (best-effort) finalizer to verify that a Close call always occurs. - // (We will clear the finalizer explicitly in Close.) - runtime.SetFinalizer(c, func(c *idleListenerConn) { - panic("jsonrpc2: IdleListener connection became unreachable without a call to Close") - }) - - return c -} - -func (c *idleListenerConn) Read(p []byte) (int, error) { return c.wrapped.Read(p) } -func (c *idleListenerConn) Write(p []byte) (int, error) { return c.wrapped.Write(p) } - -func (c *idleListenerConn) Close() error { - defer c.closeOnce.Do(func() { - c.l.connClosed() - runtime.SetFinalizer(c, nil) - }) - return c.wrapped.Close() -} diff --git a/internal/jsonrpc2/serve_test.go b/internal/jsonrpc2/serve_test.go deleted file mode 100644 index 282a684..0000000 --- a/internal/jsonrpc2/serve_test.go +++ /dev/null @@ -1,354 +0,0 @@ -// Copyright 2020 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -package jsonrpc2_test - -import ( - "context" - "errors" - "fmt" - "runtime" - "runtime/debug" - "testing" - "time" - - "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" -) - -// needsLocalhostNet skips t if networking does not work for ports opened -// with "localhost". -// forked from golang.org/x/tools/internal/testenv. -func needsLocalhostNet(t testing.TB) { - switch runtime.GOOS { - case "js", "wasip1": - t.Skipf(`Listening on "localhost" fails on %s; see https://go.dev/issue/59718`, runtime.GOOS) - } -} - -func TestIdleTimeout(t *testing.T) { - needsLocalhostNet(t) - - // Use a panicking time.AfterFunc instead of context.WithTimeout so that we - // get a goroutine dump on failure. We expect the test to take on the order of - // a few tens of milliseconds at most, so 10s should be several orders of - // magnitude of headroom. - timer := time.AfterFunc(10*time.Second, func() { - debug.SetTraceback("all") - panic("TestIdleTimeout deadlocked") - }) - defer timer.Stop() - - ctx := context.Background() - - try := func(d time.Duration) (longEnough bool) { - listener, err := jsonrpc2.NetListener(ctx, "tcp", "localhost:0", jsonrpc2.NetListenOptions{}) - if err != nil { - t.Fatal(err) - } - - idleStart := time.Now() - listener = jsonrpc2.NewIdleListener(d, listener) - defer listener.Close() - - server := jsonrpc2.NewServer(ctx, listener, jsonrpc2.ConnectionOptions{}) - - // Exercise some connection/disconnection patterns, and then assert that when - // our timer fires, the server exits. - conn1, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil) - if err != nil { - if since := time.Since(idleStart); since < d { - t.Fatalf("conn1 failed to connect after %v: %v", since, err) - } - t.Log("jsonrpc2.Dial:", err) - return false // Took to long to dial, so the failure could have been due to the idle timeout. - } - // On the server side, Accept can race with the connection timing out. - // Send a call and wait for the response to ensure that the connection was - // actually fully accepted. - ac := conn1.Call(ctx, "ping", nil) - if err := ac.Await(ctx, nil); !errors.Is(err, jsonrpc2.ErrMethodNotFound) { - if since := time.Since(idleStart); since < d { - t.Fatalf("conn1 broken after %v: %v", since, err) - } - t.Log(`conn1.Call(ctx, "ping", nil):`, err) - conn1.Close() - return false - } - - // Since conn1 was successfully accepted and remains open, the server is - // definitely non-idle. Dialing another simultaneous connection should - // succeed. - conn2, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil) - if err != nil { - conn1.Close() - t.Fatalf("conn2 failed to connect while non-idle after %v: %v", time.Since(idleStart), err) - return false - } - // Ensure that conn2 is also accepted on the server side before we close - // conn1. Otherwise, the connection can appear idle if the server processes - // the closure of conn1 and the idle timeout before it finally notices conn2 - // in the accept queue. - // (That failure mode may explain the failure noted in - // https://go.dev/issue/49387#issuecomment-1303979877.) - ac = conn2.Call(ctx, "ping", nil) - if err := ac.Await(ctx, nil); !errors.Is(err, jsonrpc2.ErrMethodNotFound) { - t.Fatalf("conn2 broken while non-idle after %v: %v", time.Since(idleStart), err) - } - - if err := conn1.Close(); err != nil { - t.Fatalf("conn1.Close failed with error: %v", err) - } - idleStart = time.Now() - if err := conn2.Close(); err != nil { - t.Fatalf("conn2.Close failed with error: %v", err) - } - - conn3, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil) - if err != nil { - if since := time.Since(idleStart); since < d { - t.Fatalf("conn3 failed to connect after %v: %v", since, err) - } - t.Log("jsonrpc2.Dial:", err) - return false // Took to long to dial, so the failure could have been due to the idle timeout. - } - - ac = conn3.Call(ctx, "ping", nil) - if err := ac.Await(ctx, nil); !errors.Is(err, jsonrpc2.ErrMethodNotFound) { - if since := time.Since(idleStart); since < d { - t.Fatalf("conn3 broken after %v: %v", since, err) - } - t.Log(`conn3.Call(ctx, "ping", nil):`, err) - conn3.Close() - return false - } - - idleStart = time.Now() - if err := conn3.Close(); err != nil { - t.Fatalf("conn3.Close failed with error: %v", err) - } - - serverError := server.Wait() - - if !errors.Is(serverError, jsonrpc2.ErrIdleTimeout) { - t.Errorf("run() returned error %v, want %v", serverError, jsonrpc2.ErrIdleTimeout) - } - if since := time.Since(idleStart); since < d { - t.Errorf("server shut down after %v idle; want at least %v", since, d) - } - return true - } - - d := 1 * time.Millisecond - for { - t.Logf("testing with idle timeout %v", d) - if !try(d) { - d *= 2 - continue - } - break - } -} - -type msg struct { - Msg string -} - -type fakeHandler struct{} - -func (fakeHandler) Handle(ctx context.Context, req *jsonrpc2.Request) (any, error) { - switch req.Method { - case "ping": - return &msg{"pong"}, nil - default: - return nil, jsonrpc2.ErrNotHandled - } -} - -func TestServe(t *testing.T) { - ctx := context.Background() - - tests := []struct { - name string - factory func(context.Context, testing.TB) (jsonrpc2.Listener, error) - }{ - {"tcp", func(ctx context.Context, t testing.TB) (jsonrpc2.Listener, error) { - needsLocalhostNet(t) - return jsonrpc2.NetListener(ctx, "tcp", "localhost:0", jsonrpc2.NetListenOptions{}) - }}, - {"pipe", func(ctx context.Context, t testing.TB) (jsonrpc2.Listener, error) { - return jsonrpc2.NetPipeListener(ctx) - }}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - fake, err := test.factory(ctx, t) - if err != nil { - t.Fatal(err) - } - conn, shutdown, err := newFake(t, ctx, fake) - if err != nil { - t.Fatal(err) - } - defer shutdown() - var got msg - if err := conn.Call(ctx, "ping", &msg{"ting"}).Await(ctx, &got); err != nil { - t.Fatal(err) - } - if want := "pong"; got.Msg != want { - t.Errorf("conn.Call(...): returned %q, want %q", got, want) - } - }) - } -} - -func newFake(t *testing.T, ctx context.Context, l jsonrpc2.Listener) (*jsonrpc2.Connection, func(), error) { - server := jsonrpc2.NewServer(ctx, l, jsonrpc2.ConnectionOptions{ - Handler: fakeHandler{}, - }) - - client, err := jsonrpc2.Dial(ctx, - l.Dialer(), - jsonrpc2.ConnectionOptions{ - Handler: fakeHandler{}, - }, nil) - if err != nil { - return nil, nil, err - } - return client, func() { - if err := l.Close(); err != nil { - t.Fatal(err) - } - if err := client.Close(); err != nil { - t.Fatal(err) - } - server.Wait() - }, nil -} - -// TestIdleListenerAcceptCloseRace checks for the Accept/Close race fixed in CL 388597. -// -// (A bug in the idleListener implementation caused a successful Accept to block -// on sending to a background goroutine that could have already exited.) -func TestIdleListenerAcceptCloseRace(t *testing.T) { - ctx := context.Background() - - n := 10 - - // Each iteration of the loop appears to take around a millisecond, so to - // avoid spurious failures we'll set the watchdog for three orders of - // magnitude longer. When the bug was present, this reproduced the deadlock - // reliably on a Linux workstation when run with -count=100, which should be - // frequent enough to show up on the Go build dashboard if it regresses. - watchdog := time.Duration(n) * 1000 * time.Millisecond - timer := time.AfterFunc(watchdog, func() { - debug.SetTraceback("all") - panic(fmt.Sprintf("%s deadlocked after %v", t.Name(), watchdog)) - }) - defer timer.Stop() - - for ; n > 0; n-- { - listener, err := jsonrpc2.NetPipeListener(ctx) - if err != nil { - t.Fatal(err) - } - listener = jsonrpc2.NewIdleListener(24*time.Hour, listener) - - done := make(chan struct{}) - go func() { - conn, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil) - listener.Close() - if err == nil { - conn.Close() - } - close(done) - }() - - // Accept may return a non-nil error if Close closes the underlying network - // connection before the wrapped Accept call unblocks. However, it must not - // deadlock! - c, err := listener.Accept(ctx) - if err == nil { - c.Close() - } - <-done - } -} - -// TestCloseCallRace checks for a race resulting in a deadlock when a Call on -// one side of the connection races with a Close (or otherwise broken -// connection) initiated from the other side. -// -// (The Call method was waiting for a result from the Read goroutine to -// determine which error value to return, but the Read goroutine was waiting for -// in-flight calls to complete before reporting that result.) -func TestCloseCallRace(t *testing.T) { - ctx := context.Background() - n := 10 - - watchdog := time.Duration(n) * 1000 * time.Millisecond - timer := time.AfterFunc(watchdog, func() { - debug.SetTraceback("all") - panic(fmt.Sprintf("%s deadlocked after %v", t.Name(), watchdog)) - }) - defer timer.Stop() - - for ; n > 0; n-- { - listener, err := jsonrpc2.NetPipeListener(ctx) - if err != nil { - t.Fatal(err) - } - - pokec := make(chan *jsonrpc2.AsyncCall, 1) - - s := jsonrpc2.NewServer(ctx, listener, jsonrpc2.BinderFunc(func(_ context.Context, srvConn *jsonrpc2.Connection) jsonrpc2.ConnectionOptions { - h := jsonrpc2.HandlerFunc(func(ctx context.Context, _ *jsonrpc2.Request) (any, error) { - // Start a concurrent call from the server to the client. - // The point of this test is to ensure this doesn't deadlock - // if the client shuts down the connection concurrently. - // - // The racing Call may or may not receive a response: it should get a - // response if it is sent before the client closes the connection, and - // it should fail with some kind of "connection closed" error otherwise. - go func() { - pokec <- srvConn.Call(ctx, "poke", nil) - }() - - return &msg{"pong"}, nil - }) - return jsonrpc2.ConnectionOptions{Handler: h} - })) - - dialConn, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil) - if err != nil { - listener.Close() - s.Wait() - t.Fatal(err) - } - - // Calling any method on the server should provoke it to asynchronously call - // us back. While it is starting that call, we will close the connection. - if err := dialConn.Call(ctx, "ping", nil).Await(ctx, nil); err != nil { - t.Error(err) - } - if err := dialConn.Close(); err != nil { - t.Error(err) - } - - // Ensure that the Call on the server side did not block forever when the - // connection closed. - pokeCall := <-pokec - if err := pokeCall.Await(ctx, nil); err == nil { - t.Errorf("unexpected nil error from server-initited call") - } else if errors.Is(err, jsonrpc2.ErrMethodNotFound) { - // The call completed before the Close reached the handler. - } else { - // The error was something else. - t.Logf("server-initiated call completed with expected error: %v", err) - } - - listener.Close() - s.Wait() - } -} diff --git a/internal/jsonrpc2/wire.go b/internal/jsonrpc2/wire.go index c0a41bf..499b026 100644 --- a/internal/jsonrpc2/wire.go +++ b/internal/jsonrpc2/wire.go @@ -28,9 +28,6 @@ var ( // The following errors are not part of the json specification, but // compliant extensions specific to this implementation. - // ErrServerOverloaded is returned when a message was refused due to a - // server being temporarily unable to accept any new messages. - ErrServerOverloaded = NewError(-32000, "overloaded") // ErrUnknown should be used for all non coded errors. ErrUnknown = NewError(-32001, "unknown error") // ErrServerClosing is returned for calls that arrive while the server is closing. @@ -90,7 +87,7 @@ func (err *WireError) Error() string { func (err *WireError) Is(other error) bool { w, ok := other.(*WireError) - if !ok { + if !ok || w == nil { return false } return err.Code == w.Code diff --git a/internal/jsonrpc2/wire_test.go b/internal/jsonrpc2/wire_test.go index 6f576f0..ef47144 100644 --- a/internal/jsonrpc2/wire_test.go +++ b/internal/jsonrpc2/wire_test.go @@ -7,6 +7,7 @@ package jsonrpc2_test import ( "bytes" "encoding/json" + "errors" "reflect" "testing" @@ -102,6 +103,74 @@ func newResponse(id any, result any, rerr error) jsonrpc2.Message { return msg } +// go-sdk#976: empty method must decode as a request (encode omits empty method). +func TestDecodeEmptyMethodRequest(t *testing.T) { + encoded := []byte(`{"jsonrpc":"2.0","id":5,"method":"","params":{}}`) + msg, err := jsonrpc2.DecodeMessage(encoded) + if err != nil { + t.Fatal(err) + } + req, ok := msg.(*jsonrpc2.Request) + if !ok { + t.Fatalf("message type = %T, want *jsonrpc2.Request", msg) + } + if req.Method != "" { + t.Errorf("Method = %q, want empty string", req.Method) + } + if req.ID != jsonrpc2.Int64ID(5) { + t.Errorf("ID = %v, want 5", req.ID.Raw()) + } + if !req.IsCall() { + t.Error("empty method with id=5 should be a call") + } +} + +func TestDecodeResponseUnchanged(t *testing.T) { + encoded := []byte(`{"jsonrpc":"2.0","id":2,"result":{}}`) + msg, err := jsonrpc2.DecodeMessage(encoded) + if err != nil { + t.Fatal(err) + } + if _, ok := msg.(*jsonrpc2.Response); !ok { + t.Fatalf("message type = %T, want *jsonrpc2.Response", msg) + } +} + +// Messages with an id but no "method" key are responses, not malformed requests. +func TestDecodeIDOnlyMessageIsResponse(t *testing.T) { + encoded := []byte(`{"jsonrpc":"2.0","id":5}`) + msg, err := jsonrpc2.DecodeMessage(encoded) + if err != nil { + t.Fatal(err) + } + if _, ok := msg.(*jsonrpc2.Response); !ok { + t.Fatalf("message type = %T, want *jsonrpc2.Response", msg) + } +} + +// TestWireErrorIs checks the errors.Is behavior of WireError, including that +// comparing against a typed-nil *WireError does not panic. +func TestWireErrorIs(t *testing.T) { + err := jsonrpc2.NewError(-32600, "invalid request") + for _, test := range []struct { + name string + target error + want bool + }{ + {name: "same code", target: jsonrpc2.NewError(-32600, "other message"), want: true}, + {name: "different code", target: jsonrpc2.NewError(-32601, "invalid request"), want: false}, + {name: "typed nil WireError", target: (*jsonrpc2.WireError)(nil), want: false}, + {name: "untyped nil", target: nil, want: false}, + {name: "other error type", target: errors.New("invalid request"), want: false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := errors.Is(err, test.target); got != test.want { + t.Errorf("errors.Is(%v, %v) = %v, want %v", err, test.target, got, test.want) + } + }) + } +} + func checkJSON(t *testing.T, got, want []byte) { // compare the compact form, to allow for formatting differences g := &bytes.Buffer{} diff --git a/internal/oauthtest/fake_authorization_server.go b/internal/oauthtest/fake_authorization_server.go index 6fed39b..4cd6826 100644 --- a/internal/oauthtest/fake_authorization_server.go +++ b/internal/oauthtest/fake_authorization_server.go @@ -15,6 +15,7 @@ import ( "maps" "net/http" "net/http/httptest" + "net/url" "slices" "testing" @@ -49,6 +50,23 @@ type RegistrationConfig struct { DynamicClientRegistrationEnabled bool } +// JWTBearerConfig configures support for the JWT Bearer grant type (RFC 7523) +// on a [FakeAuthorizationServer]. +type JWTBearerConfig struct { + // ValidAssertions is the set of assertion values that are accepted. + // If empty, any non-empty assertion is accepted. + ValidAssertions []string +} + +// ClientCredentialsConfig configures support for the client_credentials +// grant type (RFC 6749 Section 4.4) on a [FakeAuthorizationServer]. +type ClientCredentialsConfig struct { + // Enabled controls whether the /token endpoint accepts + // grant_type=client_credentials and returns an access token + // if client authentication succeeds. + Enabled bool +} + // Config holds configuration for FakeAuthorizationServer. type Config struct { // The optional path component of the issuer URL. @@ -59,6 +77,43 @@ type Config struct { MetadataEndpointConfig *MetadataEndpointConfig // Configuration for client registration. RegistrationConfig *RegistrationConfig + // JWTBearerConfig enables RFC 7523 JWT Bearer grant at the /token endpoint. + // If non-nil, the server accepts grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer. + JWTBearerConfig *JWTBearerConfig + // ClientCredentialsConfig enables RFC 6749 Section 4.4 client credentials + // grant at the /token endpoint. + ClientCredentialsConfig *ClientCredentialsConfig + // ScopesSupported is an optional list of scopes to advertise in the + // authorization server metadata. + ScopesSupported []string + // TokenScopeFunc, if set, is called with the scope from the authorization + // request and returns the scope string to include in the token response. + TokenScopeFunc func(requestedScope string) string + // AccessTokenTTL, if non-zero, is the expires_in (in seconds) returned by the + // /token endpoint for both the authorization_code and refresh_token grants. + // When zero a default of 3600 is used. Set it small to force a client's reuse + // token source to treat the access token as expired and refresh it. + AccessTokenTTL int + // IssueRefreshToken, if true, includes a refresh_token in token responses and + // enables grant_type=refresh_token at the /token endpoint. + IssueRefreshToken bool + // UnadvertiseIssParameter, if true, omits + // authorization_response_iss_parameter_supported from metadata while the + // authorize endpoint still includes iss. Used to test local policy for + // unadvertised RFC 9207 iss. + UnadvertiseIssParameter bool +} + +// testRefreshToken is the refresh token issued and accepted by the fake server +// when Config.IssueRefreshToken is set. +const testRefreshToken = "test_refresh_token" + +// accessTokenExpiresIn returns the expires_in value to use in token responses. +func (s *FakeAuthorizationServer) accessTokenExpiresIn() int { + if s.config.AccessTokenTTL != 0 { + return s.config.AccessTokenTTL + } + return 3600 } // FakeAuthorizationServer is a fake OAuth 2.0 Authorization Server for testing. @@ -72,6 +127,7 @@ type FakeAuthorizationServer struct { type codeInfo struct { CodeChallenge string + Scope string } // NewFakeAuthorizationServer creates a new FakeAuthorizationServer. @@ -143,10 +199,14 @@ func (s *FakeAuthorizationServer) handleMetadata(w http.ResponseWriter, r *http. AuthorizationEndpoint: s.URL() + s.config.IssuerPath + "/authorize", TokenEndpoint: s.URL() + s.config.IssuerPath + "/token", RegistrationEndpoint: registrationEndpoint, + ScopesSupported: s.config.ScopesSupported, ResponseTypesSupported: []string{"code"}, CodeChallengeMethodsSupported: []string{"S256"}, ClientIDMetadataDocumentSupported: cimdSupported, TokenEndpointAuthMethodsSupported: []string{"client_secret_post", "client_secret_basic"}, + // Advertise RFC 9207 support unless UnadvertiseIssParameter is set: + // the authorize endpoint still includes "iss" in responses either way. + AuthorizationResponseIssParameterSupported: !s.config.UnadvertiseIssParameter, } // Set CORS headers for cross-origin client discovery. w.Header().Set("Access-Control-Allow-Origin", "*") @@ -232,11 +292,13 @@ func (s *FakeAuthorizationServer) handleAuthorize(w http.ResponseWriter, r *http code := rand.Text() s.codes[code] = codeInfo{ CodeChallenge: codeChallenge, + Scope: r.URL.Query().Get("scope"), } state := r.URL.Query().Get("state") + issuer := s.URL() + s.config.IssuerPath - redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state) + redirectURL := fmt.Sprintf("%s?code=%s&state=%s&iss=%s", redirectURI, code, state, url.QueryEscape(issuer)) http.Redirect(w, r, redirectURL, http.StatusFound) } @@ -253,10 +315,23 @@ func (s *FakeAuthorizationServer) handleToken(w http.ResponseWriter, r *http.Req http.Error(w, err.Error(), http.StatusUnauthorized) return } - if r.Form.Get("grant_type") != "authorization_code" { - http.Error(w, "invalid grant_type", http.StatusBadRequest) - return + + grantType := r.Form.Get("grant_type") + switch grantType { + case "authorization_code": + s.handleAuthorizationCodeGrant(w, r) + case "urn:ietf:params:oauth:grant-type:jwt-bearer": + s.handleJWTBearerGrant(w, r) + case "client_credentials": + s.handleClientCredentialsGrant(w, r) + case "refresh_token": + s.handleRefreshTokenGrant(w, r) + default: + http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest) } +} + +func (s *FakeAuthorizationServer) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) { code := r.Form.Get("code") if code == "" { http.Error(w, "missing code", http.StatusBadRequest) @@ -279,6 +354,61 @@ func (s *FakeAuthorizationServer) handleToken(w http.ResponseWriter, r *http.Req return } + resp := map[string]any{ + "access_token": "test_access_token", + "token_type": "Bearer", + "expires_in": s.accessTokenExpiresIn(), + } + if s.config.IssueRefreshToken { + resp["refresh_token"] = testRefreshToken + } + if s.config.TokenScopeFunc != nil { + if scope := s.config.TokenScopeFunc(codeInfo.Scope); scope != "" { + resp["scope"] = scope + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// handleRefreshTokenGrant implements grant_type=refresh_token (RFC 6749 Section +// 6) when Config.IssueRefreshToken is set, returning a distinct access token so +// callers can observe that a refresh occurred. +func (s *FakeAuthorizationServer) handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request) { + if !s.config.IssueRefreshToken { + http.Error(w, "refresh_token grant not supported", http.StatusBadRequest) + return + } + if r.Form.Get("refresh_token") != testRefreshToken { + http.Error(w, "invalid refresh_token", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "test_access_token_refreshed", + "token_type": "Bearer", + "expires_in": s.accessTokenExpiresIn(), + "refresh_token": testRefreshToken, + }) +} + +func (s *FakeAuthorizationServer) handleJWTBearerGrant(w http.ResponseWriter, r *http.Request) { + if s.config.JWTBearerConfig == nil { + http.Error(w, "JWT bearer grant not supported", http.StatusBadRequest) + return + } + assertion := r.Form.Get("assertion") + if assertion == "" { + http.Error(w, "missing assertion", http.StatusBadRequest) + return + } + if len(s.config.JWTBearerConfig.ValidAssertions) > 0 { + if !slices.Contains(s.config.JWTBearerConfig.ValidAssertions, assertion) { + http.Error(w, "invalid assertion", http.StatusBadRequest) + return + } + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "access_token": "test_access_token", @@ -287,6 +417,25 @@ func (s *FakeAuthorizationServer) handleToken(w http.ResponseWriter, r *http.Req }) } +func (s *FakeAuthorizationServer) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Request) { + if s.config.ClientCredentialsConfig == nil || !s.config.ClientCredentialsConfig.Enabled { + http.Error(w, "client_credentials grant not supported", http.StatusBadRequest) + return + } + resp := map[string]any{ + "access_token": "test_access_token", + "token_type": "Bearer", + "expires_in": 3600, + } + if s.config.TokenScopeFunc != nil { + if scope := s.config.TokenScopeFunc(r.Form.Get("scope")); scope != "" { + resp["scope"] = scope + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + func (s *FakeAuthorizationServer) authenticateClient(r *http.Request) error { clientID, clientSecret, ok := r.BasicAuth() if !ok { diff --git a/internal/oauthtest/fake_idp_server.go b/internal/oauthtest/fake_idp_server.go new file mode 100644 index 0000000..b7057ff --- /dev/null +++ b/internal/oauthtest/fake_idp_server.go @@ -0,0 +1,258 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package oauthtest + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "maps" + "net/http" + "net/http/httptest" + "slices" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/oauthex" +) + +// TokenExchangeConfig configures RFC 8693 token exchange support on a [FakeIdPServer]. +type TokenExchangeConfig struct { + // IDJAGToken is the ID-JAG value returned from token exchange. + // Defaults to "test-id-jag-token" if empty. + IDJAGToken string +} + +// IdPConfig holds configuration for [FakeIdPServer]. +type IdPConfig struct { + // PreregisteredClients maps client IDs to their info. + PreregisteredClients map[string]ClientInfo + + // TokenExchangeConfig enables RFC 8693 token exchange at the /token endpoint. + // If non-nil, the server accepts grant_type=urn:ietf:params:oauth:grant-type:token-exchange. + TokenExchangeConfig *TokenExchangeConfig +} + +// FakeIdPServer is a fake OIDC Identity Provider for testing. +// It supports: +// - OIDC discovery (/.well-known/openid-configuration) +// - Authorization Code Grant with PKCE +// - ID Token issuance (fake JWTs) +// - RFC 8693 Token Exchange (ID Token → ID-JAG), if configured +type FakeIdPServer struct { + server *httptest.Server + mux *http.ServeMux + config IdPConfig + clients map[string]ClientInfo + codes map[string]codeInfo +} + +// NewFakeIdPServer creates a new FakeIdPServer. +func NewFakeIdPServer(config IdPConfig) *FakeIdPServer { + s := &FakeIdPServer{ + mux: http.NewServeMux(), + config: config, + clients: make(map[string]ClientInfo), + codes: make(map[string]codeInfo), + } + maps.Copy(s.clients, config.PreregisteredClients) + + s.mux.HandleFunc("/.well-known/openid-configuration", s.handleMetadata) + s.mux.HandleFunc("/authorize", s.handleAuthorize) + s.mux.HandleFunc("/token", s.handleToken) + s.server = httptest.NewUnstartedServer(s.mux) + + return s +} + +// Start starts the HTTP server and registers a cleanup function on t. +func (s *FakeIdPServer) Start(t testing.TB) { + s.server.Start() + t.Cleanup(s.server.Close) +} + +// URL returns the base URL of the server (issuer). +func (s *FakeIdPServer) URL() string { + return s.server.URL +} + +func (s *FakeIdPServer) handleMetadata(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + grantTypes := []string{"authorization_code"} + if s.config.TokenExchangeConfig != nil { + grantTypes = append(grantTypes, oauthex.GrantTypeTokenExchange) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "issuer": s.URL(), + "authorization_endpoint": s.URL() + "/authorize", + "token_endpoint": s.URL() + "/token", + "jwks_uri": s.URL() + "/.well-known/jwks.json", + "response_types_supported": []string{"code"}, + "code_challenge_methods_supported": []string{"S256"}, + "grant_types_supported": grantTypes, + }) +} + +func (s *FakeIdPServer) handleAuthorize(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + clientID := r.URL.Query().Get("client_id") + clientInfo, ok := s.clients[clientID] + if !ok { + http.Error(w, "unknown client_id", http.StatusBadRequest) + return + } + redirectURI := r.URL.Query().Get("redirect_uri") + if redirectURI == "" { + http.Error(w, "missing redirect_uri", http.StatusBadRequest) + return + } + if !slices.Contains(clientInfo.RedirectURIs, redirectURI) { + http.Error(w, "invalid redirect_uri", http.StatusBadRequest) + return + } + codeChallenge := r.URL.Query().Get("code_challenge") + if codeChallenge == "" { + http.Error(w, "missing code_challenge", http.StatusBadRequest) + return + } + code := rand.Text() + s.codes[code] = codeInfo{CodeChallenge: codeChallenge} + state := r.URL.Query().Get("state") + redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state) + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +func (s *FakeIdPServer) handleToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "failed to parse form", http.StatusBadRequest) + return + } + + if err := s.authenticateClient(r); err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + grantType := r.Form.Get("grant_type") + switch grantType { + case "authorization_code": + s.handleAuthorizationCodeGrant(w, r) + case oauthex.GrantTypeTokenExchange: + s.handleTokenExchangeGrant(w, r) + default: + http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest) + } +} + +func (s *FakeIdPServer) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) { + code := r.Form.Get("code") + if code == "" { + http.Error(w, "missing code", http.StatusBadRequest) + return + } + ci, ok := s.codes[code] + if !ok { + http.Error(w, "unknown authorization code", http.StatusBadRequest) + return + } + verifier := r.Form.Get("code_verifier") + if verifier == "" { + http.Error(w, "missing code_verifier", http.StatusBadRequest) + return + } + sha := sha256.Sum256([]byte(verifier)) + expectedChallenge := base64.RawURLEncoding.EncodeToString(sha[:]) + if expectedChallenge != ci.CodeChallenge { + http.Error(w, "PKCE verification failed", http.StatusBadRequest) + return + } + + clientID := r.Form.Get("client_id") + now := time.Now().Unix() + idToken := fakeIDToken(s.URL(), clientID, now) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "test_access_token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "test_refresh_token", + "id_token": idToken, + }) +} + +func (s *FakeIdPServer) handleTokenExchangeGrant(w http.ResponseWriter, r *http.Request) { + if s.config.TokenExchangeConfig == nil { + http.Error(w, "token exchange not supported", http.StatusBadRequest) + return + } + if r.Form.Get("requested_token_type") != oauthex.TokenTypeIDJAG { + http.Error(w, "invalid requested_token_type", http.StatusBadRequest) + return + } + if r.Form.Get("subject_token_type") != oauthex.TokenTypeIDToken { + http.Error(w, "invalid subject_token_type", http.StatusBadRequest) + return + } + if r.Form.Get("subject_token") == "" { + http.Error(w, "missing subject_token", http.StatusBadRequest) + return + } + + idJAG := s.config.TokenExchangeConfig.IDJAGToken + if idJAG == "" { + idJAG = "test-id-jag-token" + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": idJAG, + "issued_token_type": oauthex.TokenTypeIDJAG, + "token_type": "N_A", + "expires_in": 300, + }) +} + +func (s *FakeIdPServer) authenticateClient(r *http.Request) error { + clientID := r.Form.Get("client_id") + clientSecret := r.Form.Get("client_secret") + clientInfo, ok := s.clients[clientID] + if !ok { + return fmt.Errorf("unknown client") + } + if clientInfo.Secret != clientSecret { + return fmt.Errorf("invalid client credentials") + } + return nil +} + +// fakeIDToken creates a fake JWT ID token for testing. +// The token has a valid structure but is not cryptographically signed. +func fakeIDToken(issuer, audience string, now int64) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + claimsJSON, _ := json.Marshal(map[string]any{ + "iss": issuer, + "sub": "test-user", + "aud": audience, + "exp": now + 3600, + "iat": now, + "email": "test@example.com", + }) + claims := base64.RawURLEncoding.EncodeToString(claimsJSON) + return header + "." + claims + ".mock-signature" +} diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index d419b02..ce1f6fc 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -33,16 +33,24 @@ contains feature documentation, mapping the MCP spec to the packages above. The following table shows which versions of the Go SDK support which versions of the MCP specification: -| SDK Version | Latest MCP Spec | All Supported MCP Specs | -|-----------------|-------------------|----------------------------------------------------| -| v1.4.0+ | 2025-11-25\* | 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 | -| v1.2.0 - v1.3.1 | 2025-11-25\*\* | 2025-11-25\*\*, 2025-06-18, 2025-03-26, 2024-11-05 | -| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 | +| SDK Version | Latest MCP Spec | All Supported MCP Specs | +|-----------------|-------------------|------------------------------------------------------------------| +| v1.7.0+ | 2026-07-28 | 2026-07-28, 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.4.0 - v1.6.1 | 2025-11-25\* | 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.2.0 - v1.3.1 | 2025-11-25\*\* | 2025-11-25\*\*, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 | \* Client side OAuth has experimental support. \*\* Partial support for 2025-11-25 (client side OAuth and Sampling with tools not available). +The roots, sampling, and logging features are deprecated as of protocol version +2026-07-28 by +[SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging). +The SDK continues to support them for compatibility during the deprecation +window (at least twelve months). See the individual feature documentation for +migration guidance. + New releases of the SDK target only supported versions of Go. See https://go.dev/doc/devel/release#policy for more information. diff --git a/internal/readme/contributing.src.md b/internal/readme/contributing.src.md new file mode 100644 index 0000000..f2e931b --- /dev/null +++ b/internal/readme/contributing.src.md @@ -0,0 +1,273 @@ +# Contributing to the Go MCP SDK + +Thank you for your interest in contributing! The Go SDK needs active +contributions to keep up with changes in the MCP spec, fix bugs, and accommodate +new and emerging use-cases. We welcome all forms of contribution, from filing +and reviewing issues, to contributing fixes, to proposing and implementing new +features. + +As described in the [design document](https://github.com/modelcontextprotocol/go-sdk/blob/main/design/design.md), it is important for +the MCP SDK to remain idiomatic, future-proof, and extensible. The process +described here is intended to ensure that the SDK evolves safely and +transparently, while adhering to these goals. + +%toc + + +## Development setup + +This module can be built and tested using the standard Go toolchain. Run `go +test ./...` to run all its tests. + +To test changes to this module against another module that uses the SDK, we +recommend using a [`go.work` file](https://go.dev/doc/tutorial/workspaces) to +define a multi-module workspace. For example, if your directory contains a +`project` directory containing your project, and a `go-sdk` directory +containing the SDK, run: + +```sh +go work init ./project ./go-sdk +``` + +### Conformance tests + +The SDK includes a script to run the official MCP conformance tests against the +SDK's conformance server: + +```sh +./scripts/conformance.sh +``` + +By default, results are cleaned up after the script runs. To save results to a +specific directory: + +```sh +./scripts/conformance.sh --result_dir ./conformance-results +``` + +To run against a local checkout of the +[conformance repo](https://github.com/modelcontextprotocol/conformance) instead +of the latest npm release: + +```sh +./scripts/conformance.sh --conformance_repo ~/src/conformance +``` + +Note: you must run `npm install` in the conformance repo first. + +Run `./scripts/conformance.sh --help` for more options. + +## Filing issues + +This project uses the [GitHub issue +tracker](https://github.com/modelcontextprotocol/go-sdk/issues) for issues. The +process for filing bugs and proposals is described below. + +TODO(rfindley): describe a process for asking general questions in the public +MCP discord server. + +### Bugs + +Please [report +bugs](https://github.com/modelcontextprotocol/go-sdk/issues/new). If the SDK is +not working as you expected, it is likely due to a bug or inadequate +documentation, and reporting an issue will help us address this shortcoming. + +When reporting a bug, make sure to answer these five questions: + +1. What did you do? +2. What did you see? +3. What did you expect to see? +4. What version of the Go MCP SDK are you using? +5. What version of Go are you using (`go version`)? + +### Proposals + +A proposal is an issue that proposes a new API for the SDK, or a change to the +signature or behavior of an existing API. Proposals should be labeled with the +'proposal' label, and require an explicit approval from a maintainer before +being accepted (indicated by the 'proposal-accepted' label). Proposals must +remain open for at least a week to allow discussion before being accepted or +declined by a maintainer. + +Proposals that are straightforward and uncontroversial may be approved based on +discussion on the issue tracker or in a GitHub Discussion. However, proposals +that are deemed to be sufficiently unclear or complicated may be deferred to a +regular Working Group meeting (see 'Governance' below). + +This process is similar to the [Go proposal +process](https://github.com/golang/proposal), but is necessarily lighter weight +to accommodate the greater rate of change expected for the SDK. + +### Design discussion + +For open ended design discussion (anything that doesn't fall into the issue +categories above), use [GitHub +Discussions](https://github.com/modelcontextprotocol/go-sdk/discussions). +Ideally, each discussion should be focused on one aspect of the design. For +example: Tool Binding and Session APIs would be two separate discussions. +When discussions reach a consensus, they should be promoted into proposals. + +## Contributing code + +The project uses GitHub pull requests (PRs) to review changes. + +Any significant change should be associated with a GitHub issue. Issues that +are deemed to be good opportunities for contribution are be labeled ['Help +Wanted'](https://github.com/modelcontextprotocol/go-sdk/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22help%20wanted%22). +If you want to work on such an issue, please first comment on the issue to say +that you're interested in contributing. For issues _not_ labeled 'Help Wanted', +it is recommended that you ask (and wait for confirmation) on the issue before +contributing, to avoid duplication of effort or wasted work. For nontrivial +changes that _don't_ relate to an existing issue, please file an issue first. + +Changes should be high quality and well tested, and should generally follow the +[Google Go style guide](https://google.github.io/styleguide/go/). Commit +messages should follow the [format used by the Go +project](https://go.dev/wiki/CommitMessage). + +Unless otherwise noted, the Go source files are distributed under the license +found in the LICENSE file. New contributions are licensed under Apache 2.0. All +Go files in the SDK should have a copyright header following the format below: + +```go +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. +``` + +### Adding and updating dependencies + +In general, the SDK tries to use as few dependencies as possible. Each new +dependency is a potential source for bugs, churn, and conflicts for our users. +Therefore, we require a [proposal](#proposals) for any new module dependency, +including upgrading an existing module to a new major version. New dependencies +should be evaluated for their stability and security, and should be +well-established in the Go ecosystem. + +In general, dependencies should be for internal use by the SDK implementation, +or for testing. Do not include types from dependencies in the SDK API. + +On the other hand, updating existing dependencies can be done at any time +without a proposal, as long as their major version does not change. Prefer to +update dependencies immediately following a release of the SDK, to allow as +much time as possible to find issues with the new version. + +After any change to dependencies, run govulncheck to check them for +vulnerabilities. + +``` +go run golang.org/x/vuln/cmd/govulncheck@latest +``` + +### Updating the README + +The top-level `README.md` file is generated from `internal/readme/README.src.md` +and should not be edited directly. To update the README: + +1. Make your changes to `internal/readme/README.src.md` +2. Run `go generate ./internal/readme` from the repository root to regenerate `README.md` +3. Commit both files together + +The CI system will automatically check that the README is up-to-date by running +`go generate ./internal/readme` and verifying no changes result. If you see a CI failure about the +README being out of sync, follow the steps above to regenerate it. + +## Versioning + +The SDK follows [semantic versioning](https://semver.org), as the Go module +system requires: the major version is part of the module path, so a breaking +release would import as `github.com/modelcontextprotocol/go-sdk/v2`. A breaking +change therefore cannot reach existing users by accident; they have to change +their import path to receive one. + +This policy covers the exported API of the SDK's importable packages — `mcp`, +`jsonrpc`, `auth`, `auth/extauth` and `oauthex`. Everything under `internal/` +is not importable outside the module and may change in any release. + +Which MCP spec revisions each SDK version speaks is documented in the +[README](README.md#version-compatibility). Support for a new revision arrives +in a minor release; dropping a revision the SDK previously negotiated is a +breaking change. + +### What counts as a breaking change + +- Removing or renaming an exported identifier. +- Changing the signature of an exported function or method. +- Removing or renaming a field of an exported struct, or changing its type. +- Adding a method to an exported interface that users are meant to implement. +- Changing documented behavior that callers could reasonably rely on. +- Dropping support for an MCP spec revision. + +The following are not breaking, and may land in a minor or patch release: + +- Adding an exported identifier, or a field to an exported struct. +- Fixing behavior that contradicted its documentation. +- Refactoring that leaves the exported API unchanged. +- Adding support for a new MCP spec revision. +- Raising the minimum Go version to one still supported upstream, per the + [README](README.md#version-compatibility). + +### Deprecation + +An API on its way out is marked with a `// Deprecated:` comment naming its +replacement, which `gopls` and `staticcheck` surface at call sites. It keeps +working until the next major version, since removing it would itself be a +breaking change. + +Protocol features that the *specification* deprecates are a separate matter and +follow the spec's timeline rather than this policy. The features deprecated by +[SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging) +— roots, sampling and logging — remain supported for at least twelve months, as +noted in the [README](README.md#version-compatibility). + +Any change to the exported API, breaking or not, goes through the +[proposal process](#proposals). + +## Timeouts + +If a contributor hasn't responded to issue questions or PR comments in two weeks, +the issue or PR may be closed. It can be reopened when the contributor can resume +work. + +## Code of conduct + +This project follows the [Go Community Code of Conduct](https://go.dev/conduct). +If you encounter a conduct-related issue, please mail conduct@golang.org. + +## Governance + +Initially, the Go SDK repository will be administered by the Go team and +Anthropic, and they will be the approvers (the set of people able to merge PRs +to the SDK), also referred to as the 'Working Group'. The policies here are +also intended to satisfy necessary constraints of the Go team's participation +in the project. This may change in the future: see 'Ongoing Evaluation' below. + +### Working Group meetings + +On a regular basis, the Working Group will host a virtual meeting to discuss +outstanding proposals and other changes to the SDK. These meetings and their +agendas will be announced in advance, and open to all. The meetings will be +recorded, and recordings and meeting notes will be made available afterward. +(TODO: decide on a mechanism for tracking these meetings--likely a GitHub +issue.) + +This process is similar to the [Go Tools +call](https://go.dev/wiki/golang-tools), though it is expected that meetings +will at least initially occur on a more frequent basis. + +### Discord + +Discord (either the public or private Anthropic discord servers) should only be +used for logistical coordination or answering questions. For transparency and +durability, design discussion and decisions should occur in GitHub issues, +GitHub discussions, or public steering meetings. + +### Antitrust considerations + +The goal of this repository is to provide a robust and complete Go +implementation of the model context protocol, in an open and transparent +manner, without bias toward specific integration paths or providers. To that +end, the model context protocol organization's [antitrust +policy](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/ANTITRUST.md) +applies to all participation in this project. diff --git a/internal/readme/doc.go b/internal/readme/doc.go index 34bc60c..4461b94 100644 --- a/internal/readme/doc.go +++ b/internal/readme/doc.go @@ -2,7 +2,9 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -//go:generate go run golang.org/x/example/internal/cmd/weave@latest -o ../../README.md ./README.src.md +//go:generate -command weave go run golang.org/x/example/internal/cmd/weave@latest +//go:generate weave -o ../../README.md ./README.src.md +//go:generate weave -o ../../CONTRIBUTING.md ./contributing.src.md // The readme package is used to generate README.md at the top-level of this // repo. Regenerate the README with go generate. diff --git a/internal/util/errors.go b/internal/util/errors.go new file mode 100644 index 0000000..ebeb614 --- /dev/null +++ b/internal/util/errors.go @@ -0,0 +1,16 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package util + +import ( + "fmt" +) + +// Wrapf wraps *errp with the given formatted message if *errp is not nil. +func Wrapf(errp *error, format string, args ...any) { + if *errp != nil { + *errp = fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), *errp) + } +} diff --git a/internal/util/net.go b/internal/util/net.go index 6858614..593012d 100644 --- a/internal/util/net.go +++ b/internal/util/net.go @@ -24,3 +24,26 @@ func IsLoopback(addr string) bool { } return ip.IsLoopback() } + +// cgnat is the RFC 6598 carrier-grade NAT range (100.64.0.0/10). netip's +// IsPrivate does not report it, but it is shared ISP space that is not publicly +// routable and could point at another tenant behind the same NAT. +var cgnat = netip.MustParsePrefix("100.64.0.0/10") + +// IsPrivateOrReserved reports whether ip belongs to a range that must not be +// reachable through a server-controlled URL, as a defense against SSRF: private +// (RFC 1918 / RFC 4193 ULA), link-local (including the 169.254.169.254 cloud +// metadata endpoint), carrier-grade NAT, multicast, and the unspecified address. +func IsPrivateOrReserved(ip netip.Addr) bool { + ip = ip.Unmap() + if !ip.IsValid() { + return true + } + if ip.IsPrivate() || + ip.IsLinkLocalUnicast() || + ip.IsMulticast() || + ip.IsUnspecified() { + return true + } + return cgnat.Contains(ip) +} diff --git a/internal/util/net_test.go b/internal/util/net_test.go index fd3187b..21b7179 100644 --- a/internal/util/net_test.go +++ b/internal/util/net_test.go @@ -3,7 +3,10 @@ // that can be found in the LICENSE file. package util -import "testing" +import ( + "net/netip" + "testing" +) // TestIsLoopback tests the IsLoopback helper function. func TestIsLoopback(t *testing.T) { @@ -33,3 +36,52 @@ func TestIsLoopback(t *testing.T) { }) } } + +func TestIsPrivateOrReserved(t *testing.T) { + tests := []struct { + ip string + want bool + }{ + // Private (RFC 1918) and IPv6 ULA (RFC 4193). + {"10.0.0.1", true}, + {"172.16.5.4", true}, + {"192.168.1.1", true}, + {"fd00::1", true}, + // Link-local, including the cloud metadata endpoint. + {"169.254.169.254", true}, + {"169.254.0.1", true}, + {"fe80::1", true}, + // Carrier-grade NAT (RFC 6598). + {"100.64.0.1", true}, + {"100.127.255.255", true}, + // Multicast and unspecified. + {"224.0.0.1", true}, + {"ff02::1", true}, + {"0.0.0.0", true}, + {"::", true}, + // IPv4-mapped IPv6 of a private address must also be caught. + {"::ffff:10.0.0.1", true}, + {"::ffff:192.168.0.1", true}, + // Loopback is intentionally NOT reserved here (policy handled elsewhere). + {"127.0.0.1", false}, + {"::1", false}, + // Public addresses are allowed. + {"8.8.8.8", false}, + {"1.1.1.1", false}, + {"2606:4700:4700::1111", false}, + // Just outside CGNAT. + {"100.63.255.255", false}, + {"100.128.0.0", false}, + } + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + ip, err := netip.ParseAddr(tt.ip) + if err != nil { + t.Fatalf("ParseAddr(%q): %v", tt.ip, err) + } + if got := IsPrivateOrReserved(ip); got != tt.want { + t.Errorf("IsPrivateOrReserved(%q) = %v, want %v", tt.ip, got, tt.want) + } + }) + } +} diff --git a/internal/util/util.go b/internal/util/util.go deleted file mode 100644 index 4b5c325..0000000 --- a/internal/util/util.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2025 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -package util - -import ( - "cmp" - "fmt" - "iter" - "slices" -) - -// Helpers below are copied from gopls' moremaps package. - -// Sorted returns an iterator over the entries of m in key order. -func Sorted[M ~map[K]V, K cmp.Ordered, V any](m M) iter.Seq2[K, V] { - // TODO(adonovan): use maps.Sorted if proposal #68598 is accepted. - return func(yield func(K, V) bool) { - keys := KeySlice(m) - slices.Sort(keys) - for _, k := range keys { - if !yield(k, m[k]) { - break - } - } - } -} - -// KeySlice returns the keys of the map M, like slices.Collect(maps.Keys(m)). -func KeySlice[M ~map[K]V, K comparable, V any](m M) []K { - r := make([]K, 0, len(m)) - for k := range m { - r = append(r, k) - } - return r -} - -// Wrapf wraps *errp with the given formatted message if *errp is not nil. -func Wrapf(errp *error, format string, args ...any) { - if *errp != nil { - *errp = fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), *errp) - } -} diff --git a/mcp/cache.go b/mcp/cache.go new file mode 100644 index 0000000..9837337 --- /dev/null +++ b/mcp/cache.go @@ -0,0 +1,87 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package mcp + +import ( + "sync" + "time" +) + +// methodCache is a per-method TTL cache for list and read results, as +// described in SEP-2549. Each entry is keyed by cursor (for paginated list +// methods) or URI (for resources/read). +type methodCache[R CacheableResult] struct { + mu sync.Mutex + cachedValues map[string]*cacheEntry[R] +} + +type cacheEntry[R CacheableResult] struct { + result R + receivedAt time.Time +} + +func (e *cacheEntry[R]) isValid() bool { + return time.Since(e.receivedAt) < time.Duration(e.result.GetTTLMs())*time.Millisecond +} + +func (mc *methodCache[R]) get(key string) (R, bool) { + mc.mu.Lock() + defer mc.mu.Unlock() + entry, ok := mc.cachedValues[key] + if !ok { + var zero R + return zero, false + } + if entry.result.GetTTLMs() <= 0 || !entry.isValid() { + delete(mc.cachedValues, key) + var zero R + return zero, false + } + return entry.result, true +} + +func (mc *methodCache[R]) put(key string, result R) { + mc.mu.Lock() + defer mc.mu.Unlock() + if mc.cachedValues == nil { + mc.cachedValues = make(map[string]*cacheEntry[R]) + } + mc.cachedValues[key] = &cacheEntry[R]{ + result: result, + receivedAt: time.Now(), + } +} + +func (mc *methodCache[R]) invalidate() { + mc.mu.Lock() + defer mc.mu.Unlock() + clear(mc.cachedValues) +} + +func (mc *methodCache[R]) invalidateKey(key string) { + mc.mu.Lock() + defer mc.mu.Unlock() + delete(mc.cachedValues, key) +} + +// cursorParams is the constraint for list-method params that carry a pagination +// cursor and can be checked for nil. Both methods are already implemented by +// every concrete list-params type. +type cursorParams interface { + Params + cursorPtr() *string +} + +// cachedListResult returns a cached list result keyed by the request cursor +// (SEP-2549). It returns the zero value and false on miss or when params is nil. +func cachedListResult[P cursorParams, R CacheableResult](cache *methodCache[R], params P) (R, bool) { + key := "" + if !params.isNil() { + if cp := params.cursorPtr(); cp != nil { + key = *cp + } + } + return cache.get(key) +} diff --git a/mcp/capabilities_test.go b/mcp/capabilities_test.go index 01ff45f..86c9970 100644 --- a/mcp/capabilities_test.go +++ b/mcp/capabilities_test.go @@ -2,6 +2,8 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +//lint:file-ignore SA1019 tests exercise deprecated SEP-2577 APIs (roots, sampling, logging). + package mcp import ( @@ -25,8 +27,12 @@ func TestServerListChangedNotifications(t *testing.T) { wantNotifyCount int64 }{ { - name: "Default: notification sent", - serverOpts: nil, + name: "Default: notification sent", + serverOpts: &ServerOptions{ + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: true}, + }, + }, wantNotifyCount: 1, }, { diff --git a/mcp/client.go b/mcp/client.go index dc7bef1..1ee13e8 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -10,6 +10,8 @@ import ( "fmt" "iter" "log/slog" + "maps" + "reflect" "slices" "strings" "sync" @@ -32,6 +34,11 @@ type Client struct { sessions []*ClientSession sendingMethodHandler_ MethodHandler receivingMethodHandler_ MethodHandler + // sendMethods is the list of methods this client may send to a + // server: it always contains the standard server methods (from + // serverMethodInfos) plus any custom methods registered via + // [AddSendingCustomMethod]. + sendMethods map[string]methodInfo } // NewClient creates a new [Client]. @@ -58,13 +65,21 @@ func NewClient(impl *Implementation, options *ClientOptions) *Client { opts.Logger = ensureLogger(nil) } - return &Client{ + sendMethods := make(map[string]methodInfo, len(serverMethodInfos)) + maps.Copy(sendMethods, serverMethodInfos) + + c := &Client{ impl: impl, opts: opts, roots: newFeatureSet(func(r *Root) string { return r.URI }), sendingMethodHandler_: defaultSendingMethodHandler, receivingMethodHandler_: defaultReceivingMethodHandler[*ClientSession], + sendMethods: sendMethods, } + if opts.MultiRoundTrip == nil || !opts.MultiRoundTrip.Disabled { + c.AddSendingMiddleware(clientMultiRoundTripMiddleware()) + } + return c } // ClientOptions configures the behavior of the client. @@ -78,6 +93,12 @@ type ClientOptions struct { // &SamplingCapabilities{}. If [ClientOptions.Capabilities] is set and has a // non nil value for [ClientCapabilities.Sampling], that value overrides the // inferred capability. + // + // Deprecated: the sampling feature is deprecated as of protocol version + // 2026-07-28 (SEP-2577). It remains functional during the deprecation + // window (at least twelve months). Migrate to calling LLM provider APIs + // directly from your server. See + // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. CreateMessageHandler func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) // CreateMessageWithToolsHandler handles incoming sampling/createMessage // requests that may involve tool use. It returns @@ -91,6 +112,12 @@ type ClientOptions struct { // // It is a panic to set both CreateMessageHandler and // CreateMessageWithToolsHandler. + // + // Deprecated: the sampling feature is deprecated as of protocol version + // 2026-07-28 (SEP-2577). It remains functional during the deprecation + // window (at least twelve months). Migrate to calling LLM provider APIs + // directly from your server. See + // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. CreateMessageWithToolsHandler func(context.Context, *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) // ElicitationHandler handles incoming requests for elicitation/create. // @@ -148,18 +175,43 @@ type ClientOptions struct { // ElicitationCompleteHandler handles incoming notifications for notifications/elicitation/complete. ElicitationCompleteHandler func(context.Context, *ElicitationCompleteNotificationRequest) // Handlers for notifications from the server. - ToolListChangedHandler func(context.Context, *ToolListChangedRequest) - PromptListChangedHandler func(context.Context, *PromptListChangedRequest) - ResourceListChangedHandler func(context.Context, *ResourceListChangedRequest) - ResourceUpdatedHandler func(context.Context, *ResourceUpdatedNotificationRequest) + ToolListChangedHandler func(context.Context, *ToolListChangedRequest) + PromptListChangedHandler func(context.Context, *PromptListChangedRequest) + ResourceListChangedHandler func(context.Context, *ResourceListChangedRequest) + ResourceUpdatedHandler func(context.Context, *ResourceUpdatedNotificationRequest) + // LoggingMessageHandler handles incoming notifications/message + // notifications from the server. + // + // Deprecated: the logging feature is deprecated as of protocol version + // 2026-07-28 (SEP-2577). It remains functional during the deprecation + // window (at least twelve months). See + // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. LoggingMessageHandler func(context.Context, *LoggingMessageRequest) ProgressNotificationHandler func(context.Context, *ProgressNotificationClientRequest) + // MultiRoundTrip configures the automatic MultiRoundTrip (Multi Round-Trip Requests) middleware. + // By default (nil), the middleware is enabled with default settings. + // Set Disabled to true to opt out of automatic MultiRoundTrip handling. + MultiRoundTrip *MultiRoundTripOptions // If non-zero, defines an interval for regular "ping" requests. // If the peer fails to respond to pings originating from the keepalive check, // the session is automatically closed. + // NOTE: The keepalive feature is only available for protocol versions < 2026-07-28 KeepAlive time.Duration + // KeepAliveFailureThreshold is the number of consecutive keepalive ping + // failures tolerated before the session is closed. A value of 0 or 1 + // closes the session on the first failure (the default). Higher values + // align with the spec's "multiple failed pings MAY trigger a connection + // reset" guidance, letting a transient miss pass without tearing down an + // otherwise live session. Has no effect unless KeepAlive is non-zero. + KeepAliveFailureThreshold int } +// toolContextKeyType is the context key type for passing tool definitions +// from CallTool to the transport layer. +type toolContextKeyType struct{} + +var toolContextKey = toolContextKeyType{} + // bind implements the binder[*ClientSession] interface, so that Clients can // be connected using [connect]. func (c *Client) bind(mcpConn Connection, conn *jsonrpc2.Connection, state *clientSessionState, onClose func()) *ClientSession { @@ -193,11 +245,12 @@ func (e unsupportedProtocolVersionError) Error() string { return fmt.Sprintf("unsupported protocol version: %q", e.version) } -// ClientSessionOptions is reserved for future use. +// ClientSessionOptions configures a client session created by [Client.Connect]. type ClientSessionOptions struct { - // protocolVersion overrides the protocol version sent in the initialize - // request, for testing. If empty, latestProtocolVersion is used. - protocolVersion string + // ProtocolVersion is the protocol version sent in the initialize (or + // discover) request. If empty, the latest supported version is used. + // The server may negotiate a different mutually supported version. + ProtocolVersion string } func (c *Client) capabilities(protocolVersion string) *ClientCapabilities { @@ -253,15 +306,81 @@ func (c *Client) capabilities(protocolVersion string) *ClientCapabilities { // server, calls or notifications will return an error wrapping // [ErrConnectionClosed]. func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOptions) (cs *ClientSession, err error) { - cs, err = connect(ctx, t, c, (*clientSessionState)(nil), nil) + cs, err = connect(ctx, t, c, (*clientSessionState)(nil), nil, c.opts.Logger) if err != nil { return nil, err } protocolVersion := latestProtocolVersion - if opts != nil && opts.protocolVersion != "" { - protocolVersion = opts.protocolVersion + if opts != nil && opts.ProtocolVersion != "" { + protocolVersion = opts.ProtocolVersion } + + if protocolVersion >= protocolVersion20260728 { + // Per SEP-2575, try the stateless server/discover RPC first. If the server + // signals it doesn't support it, fall back to the legacy initialize + // handshake. + discoverCtx := context.WithValue(ctx, protocolVersionContextKey{}, protocolVersion) + // We try to discover the server's capabilities. If the server rejects the + // requested version but specifies which versions it supports, we negotiate + // a mutually supported version and try again. + for range 2 { + discRes, err := c.discover(discoverCtx, cs) + if err == nil { + cs.state.InitializeResult = discRes + if hc, ok := cs.mcpConn.(clientConnection); ok { + hc.sessionUpdated(cs.state) + } + subscribeParams := &SubscriptionsListenParams{ + Notifications: &NotificationSubscriptions{}, + } + caps := discRes.Capabilities + if caps == nil { + caps = &ServerCapabilities{} + } + if c.opts.ToolListChangedHandler != nil && caps.Tools != nil && caps.Tools.ListChanged { + subscribeParams.Notifications.ToolsListChanged = true + } + if c.opts.PromptListChangedHandler != nil && caps.Prompts != nil && caps.Prompts.ListChanged { + subscribeParams.Notifications.PromptsListChanged = true + } + if c.opts.ResourceListChangedHandler != nil && caps.Resources != nil && caps.Resources.ListChanged { + subscribeParams.Notifications.ResourcesListChanged = true + } + if subscribeParams.Notifications.ToolsListChanged || + subscribeParams.Notifications.PromptsListChanged || + subscribeParams.Notifications.ResourcesListChanged { + // ClientSession.Close cancels the listenCtx context to send notifications/cancelled. + listenCtx, cancelListen := context.WithCancel(context.Background()) + cs.listenCancel = cancelListen + if err := cs.subscriptionsListen(listenCtx, subscribeParams); err != nil { + cancelListen() + return nil, fmt.Errorf("opening subscriptions/listen: %w", err) + } + } + return cs, nil + } + + // Try to negotiate a mutually supported version if the server + // reports an UnsupportedProtocolVersionError with a supported version. + var werr *jsonrpc.Error + if errors.As(err, &werr) && werr.Code == CodeUnsupportedProtocolVersion && len(werr.Data) > 0 { + var data UnsupportedProtocolVersionData + if err := json.Unmarshal(werr.Data, &data); err == nil { + if negotiatedVersion := negotiateMutuallySupportedVersion(data.Supported); negotiatedVersion != "" && negotiatedVersion >= protocolVersion20260728 { + discoverCtx = context.WithValue(ctx, protocolVersionContextKey{}, negotiatedVersion) + continue + } + } + } + // Per the spec, fall back to the legacy initialize handshake on any + // non-modern error from server/discover. + break + } + // Use the latest legacy protocol version for the fallback initialize. + protocolVersion = protocolVersion20251125 + } + params := &InitializeParams{ ProtocolVersion: protocolVersion, ClientInfo: c.impl, @@ -274,6 +393,7 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp return nil, err } if !slices.Contains(supportedProtocolVersions, res.ProtocolVersion) { + _ = cs.Close() return nil, unsupportedProtocolVersionError{res.ProtocolVersion} } cs.state.InitializeResult = res @@ -293,6 +413,54 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp return cs, nil } +// discover sends a SEP-2575 server/discover request to probe the server for +// stateless protocol support. +func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeResult, error) { + protocolVersion := protocolVersionFromContext(ctx) + caps := c.capabilities(protocolVersion) + params := &DiscoverParams{ + Meta: Meta{ + MetaKeyProtocolVersion: protocolVersion, + MetaKeyClientInfo: c.impl, + MetaKeyClientCapabilities: caps.toV2(), + }, + } + req := &DiscoverRequest{Session: cs, Params: params} + res, err := handleSend[*DiscoverResult](ctx, methodDiscover, req) + if err != nil { + return nil, err + } + + // Pick the highest protocol version that both the server and this SDK support. + // Since supportedProtocolVersions is defined in descending order (newest to oldest), + // the first match we find is the highest supported version. + var negotiated string + if slices.Contains(res.SupportedVersions, protocolVersion) { + negotiated = protocolVersion + } else { + negotiated = negotiateMutuallySupportedVersion(res.SupportedVersions) + } + if negotiated == "" || negotiated < protocolVersion20260728 { + // If there is no overlap, fall back to initialize so version + // negotiation can happen via the legacy path. + return nil, &jsonrpc2.WireError{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + } + } + + var serverInfo *Implementation + if v, ok := decodeMetaValue[*Implementation](res.GetMeta(), MetaKeyServerInfo); ok { + serverInfo = v + } + return &InitializeResult{ + Capabilities: res.Capabilities, + Instructions: res.Instructions, + ProtocolVersion: negotiated, + ServerInfo: serverInfo, + }, nil +} + // A ClientSession is a logical connection with an MCP server. Its // methods can be used to send requests or notifications to the server. Create // a session by calling [Client.Connect]. @@ -309,15 +477,32 @@ type ClientSession struct { conn *jsonrpc2.Connection client *Client keepaliveCancel context.CancelFunc + listenCancel context.CancelFunc mcpConn Connection // No mutex is (currently) required to guard the session state, because it is // only set synchronously during Client.Connect. state clientSessionState + // Per-method TTL caches for list results (SEP-2549). + toolsCache methodCache[*ListToolsResult] + promptsCache methodCache[*ListPromptsResult] + resourcesCache methodCache[*ListResourcesResult] + resourceTemplatesCache methodCache[*ListResourceTemplatesResult] + readResourceCache methodCache[*ReadResourceResult] + // Pending URL elicitations waiting for completion notifications. pendingElicitationsMu sync.Mutex pendingElicitations map[string]chan struct{} + + // resourceSubsMu guards resourceSubs. + resourceSubsMu sync.Mutex + // resourceSubs maps a subscribed resource URI to the cancel func of the + // goroutine running its dedicated subscriptions/listen stream. Populated + // only under SEP-2575; the legacy protocol routes Subscribe and + // Unsubscribe straight to the resources/subscribe and resources/unsubscribe + // RPCs and leaves this map untouched. + resourceSubs map[string]context.CancelFunc } type clientSessionState struct { @@ -326,6 +511,45 @@ type clientSessionState struct { func (cs *ClientSession) InitializeResult() *InitializeResult { return cs.state.InitializeResult } +// usesNewProtocol reports whether this session has negotiated a protocol +// version >= 2026-07-28, which requires the SEP-2575 per-request `_meta` +// triple on every outgoing request. +func (cs *ClientSession) usesNewProtocol() bool { + res := cs.state.InitializeResult + return res != nil && res.ProtocolVersion >= protocolVersion20260728 +} + +// injectRequestMeta populates the SEP-2575 per-request `_meta` fields +// (protocolVersion, optional clientInfo, clientCapabilities) on the given +// outgoing request params. Keys already present in params.Meta are not +// overwritten. Per PR modelcontextprotocol/modelcontextprotocol#3002 +// clientInfo is SHOULD (not MUST), and is omitted when the client has no +// [Implementation] configured. +func injectRequestMeta[T any, P interface { + *T + Params +}](cs *ClientSession, params P) P { + res := cs.state.InitializeResult + if params == nil { + params = new(T) + } + m := params.GetMeta() + if m == nil { + m = map[string]any{} + } + if _, ok := m[MetaKeyProtocolVersion]; !ok { + m[MetaKeyProtocolVersion] = res.ProtocolVersion + } + if _, ok := m[MetaKeyClientInfo]; !ok && cs.client.impl != nil { + m[MetaKeyClientInfo] = cs.client.impl + } + if _, ok := m[MetaKeyClientCapabilities]; !ok { + m[MetaKeyClientCapabilities] = cs.client.capabilities(res.ProtocolVersion).toV2() + } + params.SetMeta(m) + return params +} + func (cs *ClientSession) ID() string { if c, ok := cs.mcpConn.(hasSessionID); ok { return c.SessionID() @@ -340,13 +564,18 @@ func (cs *ClientSession) ID() string { // Close is idempotent and concurrency safe. func (cs *ClientSession) Close() error { // Note: keepaliveCancel access is safe without a mutex because: - // 1. keepaliveCancel is only written once during startKeepalive (happens-before all Close calls) + // 1. keepaliveCancel is only written once during Client.Connect (through startKeepalive), + // which happens before any code that may call Close from another goroutine // 2. context.CancelFunc is safe to call multiple times and from multiple goroutines // 3. The keepalive goroutine calls Close on ping failure, but this is safe since // Close is idempotent and conn.Close() handles concurrent calls correctly if cs.keepaliveCancel != nil { cs.keepaliveCancel() } + if cs.listenCancel != nil { + cs.listenCancel() + } + cs.cancelAllResourceSubscriptions() err := cs.conn.Close() if cs.onClose != nil && cs.calledOnClose.CompareAndSwap(false, true) { @@ -362,6 +591,24 @@ func (cs *ClientSession) Wait() error { return cs.conn.Wait() } +// lookupTool returns the most recently seen definition of the tool with the +// given name across all cached ListTools results, or nil if no such tool has +// been seen. It is used by CallTool to inject the tool definition into the +// outgoing request context for transport-layer features (e.g. x-mcp-header +// param annotations). +func (cs *ClientSession) lookupTool(name string) *Tool { + cs.toolsCache.mu.Lock() + defer cs.toolsCache.mu.Unlock() + for _, entry := range cs.toolsCache.cachedValues { + for _, t := range entry.result.Tools { + if t.Name == name { + return t + } + } + } + return nil +} + // registerElicitationWaiter registers a waiter for an elicitation complete // notification with the given elicitation ID. It returns two functions: an await // function that waits for the notification or context cancellation, and a cleanup @@ -404,12 +651,18 @@ func (cs *ClientSession) registerElicitationWaiter(elicitationID string) (await // startKeepalive starts the keepalive mechanism for this client session. func (cs *ClientSession) startKeepalive(interval time.Duration) { - startKeepalive(cs, interval, &cs.keepaliveCancel) + startKeepalive(cs, interval, cs.client.opts.KeepAliveFailureThreshold, &cs.keepaliveCancel, cs.client.opts.Logger) } // AddRoots adds the given roots to the client, // replacing any with the same URIs, // and notifies any connected servers. +// +// Deprecated: the roots feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). Migrate to passing paths via tool parameters, +// resource URIs, or configuration. See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func (c *Client) AddRoots(roots ...*Root) { // Only notify if something could change. if len(roots) == 0 { @@ -422,6 +675,12 @@ func (c *Client) AddRoots(roots ...*Root) { // RemoveRoots removes the roots with the given URIs, // and notifies any connected servers if the list has changed. // It is not an error to remove a nonexistent root. +// +// Deprecated: the roots feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). Migrate to passing paths via tool parameters, +// resource URIs, or configuration. See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func (c *Client) RemoveRoots(uris ...string) { changeAndNotify(c, notificationRootsListChanged, &RootsListChangedParams{}, func() bool { return c.roots.remove(uris...) }) @@ -888,6 +1147,9 @@ func (c *Client) AddSendingMiddleware(middleware ...Middleware) { // // Receiving middleware is called when a request is received. It is useful for tasks // such as authentication, request logging and metrics. +// +// A received message need not carry params: use [HasParams] before inspecting +// [Request.GetParams]. func (c *Client) AddReceivingMiddleware(middleware ...Middleware) { c.mu.Lock() defer c.mu.Unlock() @@ -913,10 +1175,13 @@ var clientMethodInfos = map[string]methodInfo{ notificationLoggingMessage: newClientMethodInfo(clientMethod((*Client).callLoggingHandler), notification), notificationProgress: newClientMethodInfo(clientSessionMethod((*ClientSession).callProgressNotificationHandler), notification), notificationElicitationComplete: newClientMethodInfo(clientMethod((*Client).callElicitationCompleteHandler), notification|missingParamsOK), + notificationSubscriptionsAck: newClientMethodInfo(clientMethod((*Client).callSubscriptionsAckHandler), notification|missingParamsOK), } func (cs *ClientSession) sendingMethodInfos() map[string]methodInfo { - return serverMethodInfos + cs.client.mu.Lock() + defer cs.client.mu.Unlock() + return cs.client.sendMethods } func (cs *ClientSession) receivingMethodInfos() map[string]methodInfo { @@ -969,18 +1234,51 @@ func (cs *ClientSession) Ping(ctx context.Context, params *PingParams) error { } // ListPrompts lists prompts that are currently available on the server. +// +// Results may be served from a client-side TTL cache populated by previous +// calls; see SEP-2549. func (cs *ClientSession) ListPrompts(ctx context.Context, params *ListPromptsParams) (*ListPromptsResult, error) { - return handleSend[*ListPromptsResult](ctx, methodListPrompts, newClientRequest(cs, orZero[Params](params))) + if cs.usesNewProtocol() { + if result, ok := cachedListResult(&cs.promptsCache, params); ok { + return result, nil + } + params = injectRequestMeta(cs, params) + } + result, err := handleSend[*ListPromptsResult](ctx, methodListPrompts, newClientRequest(cs, orZero[Params](params))) + if err != nil { + return nil, err + } + if cs.usesNewProtocol() { + cs.promptsCache.put(params.Cursor, result) + } + return result, nil } // GetPrompt gets a prompt from the server. func (cs *ClientSession) GetPrompt(ctx context.Context, params *GetPromptParams) (*GetPromptResult, error) { + if cs.usesNewProtocol() { + params = injectRequestMeta(cs, params) + } return handleSend[*GetPromptResult](ctx, methodGetPrompt, newClientRequest(cs, orZero[Params](params))) } // ListTools lists tools that are currently available on the server. func (cs *ClientSession) ListTools(ctx context.Context, params *ListToolsParams) (*ListToolsResult, error) { - return handleSend[*ListToolsResult](ctx, methodListTools, newClientRequest(cs, orZero[Params](params))) + if cs.usesNewProtocol() { + if result, ok := cachedListResult(&cs.toolsCache, params); ok { + return result, nil + } + params = injectRequestMeta(cs, params) + } + result, err := handleSend[*ListToolsResult](ctx, methodListTools, newClientRequest(cs, orZero[Params](params))) + if err != nil { + return nil, err + } + result.Tools = filterValidTools(cs.client.opts.Logger, result.Tools) + if cs.usesNewProtocol() { + cs.toolsCache.put(params.Cursor, result) + } + return result, nil } // CallTool calls the tool with the given parameters. @@ -994,9 +1292,23 @@ func (cs *ClientSession) CallTool(ctx context.Context, params *CallToolParams) ( // Avoid sending nil over the wire. params.Arguments = map[string]any{} } + if tool := cs.lookupTool(params.Name); tool != nil { + ctx = context.WithValue(ctx, toolContextKey, tool) + } + if cs.usesNewProtocol() { + params = injectRequestMeta(cs, params) + } return handleSend[*CallToolResult](ctx, methodCallTool, newClientRequest(cs, orZero[Params](params))) } +// SetLoggingLevel sets the minimum severity level for log messages sent by +// the server. +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). Migrate to consuming stderr output (for STDIO +// servers) or OpenTelemetry. See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func (cs *ClientSession) SetLoggingLevel(ctx context.Context, params *SetLoggingLevelParams) error { _, err := handleSend[*emptyResult](ctx, methodSetLevel, newClientRequest(cs, orZero[Params](params))) return err @@ -1004,38 +1316,161 @@ func (cs *ClientSession) SetLoggingLevel(ctx context.Context, params *SetLogging // ListResources lists the resources that are currently available on the server. func (cs *ClientSession) ListResources(ctx context.Context, params *ListResourcesParams) (*ListResourcesResult, error) { - return handleSend[*ListResourcesResult](ctx, methodListResources, newClientRequest(cs, orZero[Params](params))) + if cs.usesNewProtocol() { + if result, ok := cachedListResult(&cs.resourcesCache, params); ok { + return result, nil + } + params = injectRequestMeta(cs, params) + } + result, err := handleSend[*ListResourcesResult](ctx, methodListResources, newClientRequest(cs, orZero[Params](params))) + if err != nil { + return nil, err + } + if cs.usesNewProtocol() { + cs.resourcesCache.put(params.Cursor, result) + } + return result, nil } // ListResourceTemplates lists the resource templates that are currently available on the server. func (cs *ClientSession) ListResourceTemplates(ctx context.Context, params *ListResourceTemplatesParams) (*ListResourceTemplatesResult, error) { - return handleSend[*ListResourceTemplatesResult](ctx, methodListResourceTemplates, newClientRequest(cs, orZero[Params](params))) + if cs.usesNewProtocol() { + if result, ok := cachedListResult(&cs.resourceTemplatesCache, params); ok { + return result, nil + } + params = injectRequestMeta(cs, params) + } + result, err := handleSend[*ListResourceTemplatesResult](ctx, methodListResourceTemplates, newClientRequest(cs, orZero[Params](params))) + if err != nil { + return nil, err + } + if cs.usesNewProtocol() { + cs.resourceTemplatesCache.put(params.Cursor, result) + } + return result, nil } // ReadResource asks the server to read a resource and return its contents. func (cs *ClientSession) ReadResource(ctx context.Context, params *ReadResourceParams) (*ReadResourceResult, error) { - return handleSend[*ReadResourceResult](ctx, methodReadResource, newClientRequest(cs, orZero[Params](params))) + if cs.usesNewProtocol() { + var uri string + if params != nil { + uri = params.URI + } + if result, ok := cs.readResourceCache.get(uri); ok { + return result, nil + } + params = injectRequestMeta(cs, params) + } + result, err := handleSend[*ReadResourceResult](ctx, methodReadResource, newClientRequest(cs, orZero[Params](params))) + if err != nil { + return nil, err + } + if cs.usesNewProtocol() { + cs.readResourceCache.put(params.URI, result) + } + return result, nil } func (cs *ClientSession) Complete(ctx context.Context, params *CompleteParams) (*CompleteResult, error) { + if cs.usesNewProtocol() { + params = injectRequestMeta(cs, params) + } return handleSend[*CompleteResult](ctx, methodComplete, newClientRequest(cs, orZero[Params](params))) } // Subscribe sends a "resources/subscribe" request to the server, asking for // notifications when the specified resource changes. func (cs *ClientSession) Subscribe(ctx context.Context, params *SubscribeParams) error { - _, err := handleSend[*emptyResult](ctx, methodSubscribe, newClientRequest(cs, orZero[Params](params))) + if !cs.usesNewProtocol() { + _, err := handleSend[*emptyResult](ctx, methodSubscribe, newClientRequest(cs, orZero[Params](params))) + return err + } + if params == nil || params.URI == "" { + return fmt.Errorf("Subscribe: missing URI") + } + uri := params.URI + + var listenCtx context.Context + cs.resourceSubsMu.Lock() + if _, exists := cs.resourceSubs[uri]; !exists { + var cancel context.CancelFunc + listenCtx, cancel = context.WithCancel(context.Background()) + if cs.resourceSubs == nil { + cs.resourceSubs = make(map[string]context.CancelFunc) + } + cs.resourceSubs[uri] = cancel + } + cs.resourceSubsMu.Unlock() + if listenCtx == nil { + // Already subscribed to this URI + return nil + } + + return cs.subscriptionsListen(listenCtx, &SubscriptionsListenParams{ + Notifications: &NotificationSubscriptions{ + ResourceSubscriptions: []string{uri}, + }, + }) +} + +// Unsubscribe cancels a previous [ClientSession.Subscribe] for params.URI. +// +// Under the legacy protocol it sends a "resources/unsubscribe" request. +// +// Under SEP-2575 it cancels the background "subscriptions/listen" stream +// opened by Subscribe for the URI. Unsubscribe is idempotent: calling it for +// a URI that is not currently subscribed is a no-op. +func (cs *ClientSession) Unsubscribe(ctx context.Context, params *UnsubscribeParams) error { + if !cs.usesNewProtocol() { + _, err := handleSend[*emptyResult](ctx, methodUnsubscribe, newClientRequest(cs, orZero[Params](params))) + return err + } + if params == nil || params.URI == "" { + return fmt.Errorf("Unsubscribe: missing URI") + } + cs.resourceSubsMu.Lock() + cancel, ok := cs.resourceSubs[params.URI] + delete(cs.resourceSubs, params.URI) + cs.resourceSubsMu.Unlock() + if ok { + cancel() + } + return nil +} + +// cancelAllResourceSubscriptions cancels every active SEP-2575 resource +// subscription opened via Subscribe. The listen goroutines exit +// asynchronously as their contexts unwind. Called from Close. +func (cs *ClientSession) cancelAllResourceSubscriptions() { + cs.resourceSubsMu.Lock() + subs := cs.resourceSubs + cs.resourceSubs = nil + cs.resourceSubsMu.Unlock() + for _, cancel := range subs { + cancel() + } +} + +// SubscriptionsListen opens a SEP-2575 "subscriptions/listen" stream. +// +// The server's first message on the stream is "notifications/subscriptions/acknowledged"; +// subsequent opted-in notifications (e.g. tools/list_changed) are delivered through the +// usual handlers registered in [ClientOptions]. +func (cs *ClientSession) subscriptionsListen(ctx context.Context, params *SubscriptionsListenParams) error { + params = injectRequestMeta(cs, params) + _, err := handleSend[*SubscriptionsListenResult](ctx, methodSubscriptionsListen, newClientRequest(cs, orZero[Params](params))) return err } -// Unsubscribe sends a "resources/unsubscribe" request to the server, cancelling -// a previous subscription. -func (cs *ClientSession) Unsubscribe(ctx context.Context, params *UnsubscribeParams) error { - _, err := handleSend[*emptyResult](ctx, methodUnsubscribe, newClientRequest(cs, orZero[Params](params))) - return err +func (c *Client) callSubscriptionsAckHandler(context.Context, *ClientRequest[*SubscriptionsAcknowledgedParams]) (Result, error) { + return nil, nil } func (c *Client) callToolChangedHandler(ctx context.Context, req *ToolListChangedRequest) (Result, error) { + if cs, ok := req.GetSession().(*ClientSession); ok { + cs.toolsCache.invalidate() + } if h := c.opts.ToolListChangedHandler; h != nil { h(ctx, req) } @@ -1043,6 +1478,9 @@ func (c *Client) callToolChangedHandler(ctx context.Context, req *ToolListChange } func (c *Client) callPromptChangedHandler(ctx context.Context, req *PromptListChangedRequest) (Result, error) { + if cs, ok := req.GetSession().(*ClientSession); ok { + cs.promptsCache.invalidate() + } if h := c.opts.PromptListChangedHandler; h != nil { h(ctx, req) } @@ -1050,6 +1488,10 @@ func (c *Client) callPromptChangedHandler(ctx context.Context, req *PromptListCh } func (c *Client) callResourceChangedHandler(ctx context.Context, req *ResourceListChangedRequest) (Result, error) { + if cs, ok := req.GetSession().(*ClientSession); ok { + cs.resourcesCache.invalidate() + cs.resourceTemplatesCache.invalidate() + } if h := c.opts.ResourceListChangedHandler; h != nil { h(ctx, req) } @@ -1057,6 +1499,9 @@ func (c *Client) callResourceChangedHandler(ctx context.Context, req *ResourceLi } func (c *Client) callResourceUpdatedHandler(ctx context.Context, req *ResourceUpdatedNotificationRequest) (Result, error) { + if cs, ok := req.GetSession().(*ClientSession); ok && req.Params != nil { + cs.readResourceCache.invalidateKey(req.Params.URI) + } if h := c.opts.ResourceUpdatedHandler; h != nil { h(ctx, req) } @@ -1180,3 +1625,67 @@ func paginate[P listParams, R listResult[T], T any](ctx context.Context, params } } } + +// AddSendingCustomMethod registers a custom JSON-RPC method +// that the client may send to the server. +// +// Registration is decoupled from invocation: extensions typically call this +// during setup, while the actual call site uses [CallCustomMethod]. +// +// if err := mcp.AddSendingCustomMethod[*SearchParams, *SearchResult](c, "acme/search"); err != nil { +// return err +// } +// // ... later, anywhere a *ClientSession is available: +// result, err := mcp.CallCustomMethod[*SearchParams, *SearchResult]( +// ctx, cs, "acme/search", &SearchParams{Query: "hello"}) +// +// AddSendingCustomMethod returns an error if method is the name of a standard +// MCP method. Registering the same method twice replaces the previous +// registration. +func AddSendingCustomMethod[P paramsPtr[PT], R Result, PT any]( + c *Client, + method string, +) error { + if _, ok := serverMethodInfos[method]; ok { + return fmt.Errorf("mcp: AddSendingCustomMethod: %q shadows a standard MCP method", method) + } + + mi := methodInfo{ + newResult: func() Result { + return reflect.New(reflect.TypeFor[R]().Elem()).Interface().(R) + }, + } + + c.mu.Lock() + defer c.mu.Unlock() + c.sendMethods[method] = mi + return nil +} + +// CallCustomMethod sends a custom (non-standard) JSON-RPC method to the +// server and decodes the response into R. +// +// The method must have been registered on the session's client via +// [AddSendingCustomMethod]. +func CallCustomMethod[P paramsPtr[PT], R Result, PT any]( + ctx context.Context, + cs *ClientSession, + method string, + params P, +) (R, error) { + c := cs.client + c.mu.Lock() + _, ok := c.sendMethods[method] + c.mu.Unlock() + if !ok { + var zero R + return zero, fmt.Errorf("mcp: CallCustomMethod: %q is not registered; call AddSendingCustomMethod first", method) + } + if cs.usesNewProtocol() { + params = injectRequestMeta(cs, params) + } + return handleSend[R](ctx, method, &ClientRequest[P]{ + Session: cs, + Params: params, + }) +} diff --git a/mcp/client_example_test.go b/mcp/client_example_test.go index cc7146c..98ac3b1 100644 --- a/mcp/client_example_test.go +++ b/mcp/client_example_test.go @@ -6,6 +6,7 @@ package mcp_test import ( "context" + "encoding/json" "fmt" "log" @@ -18,26 +19,27 @@ import ( func Example_roots() { ctx := context.Background() - // Create a client with a single root. + // Create a client with two roots. c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) - c.AddRoots(&mcp.Root{URI: "file://a"}) + c.AddRoots(&mcp.Root{URI: "file://a"}, &mcp.Root{URI: "file://b"}) - // Now create a server with a handler to receive notifications about roots. - rootsChanged := make(chan struct{}) - handleRootsChanged := func(ctx context.Context, req *mcp.RootsListChangedRequest) { - rootList, err := req.Session.ListRoots(ctx, nil) - if err != nil { - log.Fatal(err) + // Create a server with a tool that requests roots via the multi round-trip + // pattern (SEP-2322): server-to-client requests are no longer sent as + // standalone JSON-RPC calls on protocol version >= 2026-07-28. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "roots"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"roots": &mcp.ListRootsParams{}}, + }, nil, nil } + rootList := req.Params.InputResponses["roots"].(*mcp.ListRootsResult) var roots []string for _, root := range rootList.Roots { roots = append(roots, root.URI) } fmt.Println(roots) - close(rootsChanged) - } - s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ - RootsListChangedHandler: handleRootsChanged, + return &mcp.CallToolResult{}, nil, nil }) // Connect the server and client... @@ -54,14 +56,71 @@ func Example_roots() { } defer clientSession.Close() - // ...and add a root. The server is notified about the change. - c.AddRoots(&mcp.Root{URI: "file://b"}) - <-rootsChanged + // ...and call the tool. The client's multi round-trip driver fulfils the + // embedded roots/list request and retries the call. + if _, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "roots"}); err != nil { + log.Fatal(err) + } // Output: [file://a file://b] } // !-roots +// !+rootslistchanged + +func Example_rootsListChanged() { + ctx := context.Background() + + changed := make(chan struct{}, 2) + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + RootsListChangedHandler: func(context.Context, *mcp.RootsListChangedRequest) { + changed <- struct{}{} + }, + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + c.AddRoots(&mcp.Root{URI: "file:///project"}) + + t1, t2 := mcp.NewInMemoryTransports() + ss, err := s.Connect(ctx, t1, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + + // ListRoots is a server-initiated request, so this session negotiates a + // protocol version that still allows one. + cs, err := c.Connect(ctx, t2, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"}) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + // Roots added after the client connects notify every connected server. + c.AddRoots(&mcp.Root{URI: "file:///scratch"}) + <-changed + + // The notification says only that the list changed, so read it back. + res, err := ss.ListRoots(ctx, nil) + if err != nil { + log.Fatal(err) + } + for _, root := range res.Roots { + fmt.Println(root.URI) + } + + c.RemoveRoots("file:///scratch") + <-changed + fmt.Println("roots changed again") + + // Output: + // file:///project + // file:///scratch + // roots changed again +} + +// !-rootslistchanged + // !+sampling func Example_sampling() { @@ -80,22 +139,35 @@ func Example_sampling() { // Connect the server and client... ct, st := mcp.NewInMemoryTransports() + // Create a server with a tool that requests sampling via the multi + // round-trip pattern (SEP-2322): server-to-client requests are no longer + // sent as standalone JSON-RPC calls on protocol version >= 2026-07-28. s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "sample"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"msg": &mcp.CreateMessageParams{}}, + }, nil, nil + } + msg := req.Params.InputResponses["msg"].(*mcp.CreateMessageWithToolsResult) + return &mcp.CallToolResult{Content: msg.Content}, nil, nil + }) session, err := s.Connect(ctx, st, nil) if err != nil { log.Fatal(err) } defer session.Close() - if _, err := c.Connect(ctx, ct, nil); err != nil { - log.Fatal(err) - } - - msg, err := session.CreateMessage(ctx, &mcp.CreateMessageParams{}) + clientSession, err := c.Connect(ctx, ct, nil) if err != nil { log.Fatal(err) } - fmt.Println(msg.Content.(*mcp.TextContent).Text) + + res, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "sample"}) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) // Output: would have created a message } @@ -107,7 +179,28 @@ func Example_elicitation() { ctx := context.Background() ct, st := mcp.NewInMemoryTransports() + // Create a server with a tool that requests elicitation via the multi + // round-trip pattern (SEP-2322): server-to-client requests are no longer + // sent as standalone JSON-RPC calls on protocol version >= 2026-07-28. s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "ask"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"input": &mcp.ElicitParams{ + Message: "This should fail", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "test": {Type: "string"}, + }, + }, + }}, + }, nil, nil + } + res := req.Params.InputResponses["input"].(*mcp.ElicitResult) + fmt.Println(res.Content["test"]) + return &mcp.CallToolResult{}, nil, nil + }) ss, err := s.Connect(ctx, st, nil) if err != nil { log.Fatal(err) @@ -119,23 +212,128 @@ func Example_elicitation() { return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil }, }) - if _, err := c.Connect(ctx, ct, nil); err != nil { - log.Fatal(err) - } - res, err := ss.Elicit(ctx, &mcp.ElicitParams{ - Message: "This should fail", - RequestedSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "test": {Type: "string"}, - }, - }, - }) + clientSession, err := c.Connect(ctx, ct, nil) if err != nil { log.Fatal(err) } - fmt.Println(res.Content["test"]) + if _, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "ask"}); err != nil { + log.Fatal(err) + } // Output: value } // !-elicitation + +// !+elicitationschema + +func Example_elicitationSchema() { + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "export_report"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"format": &mcp.ElicitParams{ + Message: "Export quarterly-sales as which format?", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "format": { + Type: "string", + Title: "Format", + Enum: []any{"pdf", "csv"}, + Default: json.RawMessage(`"pdf"`), + Extra: map[string]any{"enumNames": []any{"PDF document", "CSV spreadsheet"}}, + }, + }, + }, + }}, + }, nil, nil + } + res := req.Params.InputResponses["format"].(*mcp.ElicitResult) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "Exported as " + res.Content["format"].(string)}}, + }, nil, nil + }) + if _, err := s.Connect(ctx, st, nil); err != nil { + log.Fatal(err) + } + + // The user accepts without filling anything in. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{}}, nil + }, + }) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "export_report"}) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) + // Output: Exported as pdf +} + +// !-elicitationschema + +// !+elicitationcomplete + +func Example_elicitationComplete() { + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + + done := make(chan struct{}) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}, + }, + ElicitationHandler: func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + fmt.Println("opening", req.Params.URL) + return &mcp.ElicitResult{Action: "accept"}, nil + }, + ElicitationCompleteHandler: func(_ context.Context, req *mcp.ElicitationCompleteNotificationRequest) { + fmt.Println("flow finished:", req.Params.ElicitationID) + close(done) + }, + }) + cs, err := c.Connect(ctx, ct, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"}) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + const elicitationID = "connect-calendar-1" + if _, err := ss.Elicit(ctx, &mcp.ElicitParams{ + Message: "Grant calendar access", + URL: "https://calendar.example.com/consent?state=" + elicitationID, + ElicitationID: elicitationID, + }); err != nil { + log.Fatal(err) + } + + // The hosted page redirects back to the server, whose callback endpoint + // signals that the user is done. + if err := ss.NotifyElicitationComplete(ctx, &mcp.ElicitationCompleteParams{ElicitationID: elicitationID}); err != nil { + log.Fatal(err) + } + <-done + + // Output: + // opening https://calendar.example.com/consent?state=connect-calendar-1 + // flow finished: connect-calendar-1 +} + +// !-elicitationcomplete diff --git a/mcp/client_test.go b/mcp/client_test.go index fc37c3e..f02b905 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -2,17 +2,22 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +//lint:file-ignore SA1019 tests exercise deprecated SEP-2577 APIs (roots, sampling, logging). + package mcp import ( "context" "fmt" "log/slog" + "sync/atomic" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) type Item struct { @@ -440,6 +445,89 @@ func TestClientCapabilities(t *testing.T) { } } +func TestLookupTool(t *testing.T) { + tool1 := &Tool{Name: "tool1", Description: "first"} + tool2 := &Tool{Name: "tool2", Description: "second"} + tool1Updated := &Tool{Name: "tool1", Description: "updated"} + + // page represents a single cached ListToolsResult, keyed by cursor. + type page struct { + cursor string + tools []*Tool + } + + testCases := []struct { + name string + pages []page + lookup string + want *Tool + }{ + { + name: "empty cache", + lookup: "tool1", + want: nil, + }, + { + name: "single tool found", + pages: []page{{cursor: "", tools: []*Tool{tool1}}}, + lookup: "tool1", + want: tool1, + }, + { + name: "unknown tool", + pages: []page{{cursor: "", tools: []*Tool{tool1}}}, + lookup: "nonexistent", + want: nil, + }, + { + name: "multiple tools single page", + pages: []page{{cursor: "", tools: []*Tool{tool1, tool2}}}, + lookup: "tool2", + want: tool2, + }, + { + name: "tool found across paginated pages", + pages: []page{ + {cursor: "", tools: []*Tool{tool1}}, + {cursor: "page2", tools: []*Tool{tool2}}, + }, + lookup: "tool2", + want: tool2, + }, + { + name: "re-list same cursor overwrites entry", + pages: []page{ + {cursor: "", tools: []*Tool{tool1}}, + {cursor: "", tools: []*Tool{tool1Updated}}, + }, + lookup: "tool1", + want: tool1Updated, + }, + { + name: "empty page no-op", + pages: []page{{cursor: "", tools: []*Tool{}}}, + lookup: "tool1", + want: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cs := &ClientSession{} + for _, p := range tc.pages { + cs.toolsCache.put(p.cursor, &ListToolsResult{ + Cacheable: Cacheable{TTLMs: 60_000}, + Tools: p.tools, + }) + } + got := cs.lookupTool(tc.lookup) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("lookupTool(%q) mismatch (-want +got):\n%s", tc.lookup, diff) + } + }) + } +} + func TestClientCapabilitiesOverWire(t *testing.T) { testCases := []struct { name string @@ -543,3 +631,464 @@ func TestClientCapabilitiesOverWire(t *testing.T) { }) } } + +// TestClientConnectDiscover exercises the SEP-2575 server/discover probe that +// Client.Connect now sends before falling back to the legacy initialize +// handshake. +// +// Each subtest installs a server-side receiving middleware that intercepts the +// "server/discover" method and returns a canned response: a successful +// DiscoverResult, a "Method not found" error, an UnsupportedProtocolVersion +// error, an unrelated failure, or a DiscoverResult whose supportedVersions +// don't overlap with the SDK. The test then asserts the resulting session +// state and whether the legacy initialize handshake ran. +func TestClientConnectDiscover(t *testing.T) { + const otherVersionsOnly = "1999-01-01" + + tests := []struct { + name string + // discoverHandler decides how the server replies to server/discover. + // Returning (nil, nil) means "let the default stub handle it" (which + // returns ErrMethodNotFound). + discoverHandler func() (Result, error) + // wantInitialize is true if the legacy initialize handshake should + // have run (i.e. discover signaled "not supported"). + wantInitialize bool + // wantVersion is the protocol version expected to end up on + // ClientSession.state.InitializeResult after Connect returns. + wantVersion string + }{ + { + name: "discover success skips initialize", + discoverHandler: func() (Result, error) { + return &DiscoverResult{ + Meta: Meta{ + MetaKeyServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, + }, + SupportedVersions: []string{protocolVersion20260728}, + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: true}, + }, + }, nil + }, + wantInitialize: false, + wantVersion: protocolVersion20260728, + }, + { + name: "method not found falls back to initialize", + discoverHandler: func() (Result, error) { + return nil, jsonrpc2.ErrMethodNotFound + }, + wantInitialize: true, + wantVersion: protocolVersion20251125, + }, + { + name: "unsupported protocol version falls back to initialize", + discoverHandler: func() (Result, error) { + return nil, &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + } + }, + wantInitialize: true, + wantVersion: protocolVersion20251125, + }, + { + name: "no overlapping supported version falls back to initialize", + discoverHandler: func() (Result, error) { + return &DiscoverResult{ + Meta: Meta{ + MetaKeyServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, + }, + SupportedVersions: []string{otherVersionsOnly}, + Capabilities: &ServerCapabilities{}, + }, nil + }, + wantInitialize: true, + wantVersion: protocolVersion20251125, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + + var ( + gotDiscover atomic.Bool + gotInitialize atomic.Bool + ) + + s := NewServer(testImpl, nil) + s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + switch method { + case methodDiscover: + gotDiscover.Store(true) + return tc.discoverHandler() + case methodInitialize: + gotInitialize.Store(true) + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server Connect: %v", err) + } + defer ss.Close() + + c := NewClient(testImpl, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer cs.Close() + + if !gotDiscover.Load() { + t.Error("server did not receive server/discover") + } + if got, want := gotInitialize.Load(), tc.wantInitialize; got != want { + t.Errorf("initialize invoked = %v, want %v", got, want) + } + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after Connect") + } + if got, want := ir.ProtocolVersion, tc.wantVersion; got != want { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want) + } + }) + } +} + +// TestClientConnectDiscover_RequestContents verifies that the server/discover +// request sent by Client.Connect carries the SEP-2575 per-request _meta triple: +// protocolVersion, clientInfo, and clientCapabilities. +func TestClientConnectDiscover_RequestContents(t *testing.T) { + ctx := context.Background() + + type captured struct { + params *DiscoverParams + } + var got captured + + s := NewServer(testImpl, nil) + s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodDiscover { + sr, ok := req.(*ServerRequest[*DiscoverParams]) + if !ok { + t.Errorf("discover req has unexpected type %T", req) + return nil, jsonrpc2.ErrMethodNotFound + } + got.params = sr.Params + // Make discover "not supported" so Connect proceeds (we only + // care about the discover request payload here). + return nil, jsonrpc2.ErrMethodNotFound + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server Connect: %v", err) + } + defer ss.Close() + + clientImpl := &Implementation{Name: "discover-probe-client", Version: "v9.9.9"} + c := NewClient(clientImpl, &ClientOptions{ + CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { + return nil, nil + }, + }) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer cs.Close() + + if got.params == nil { + t.Fatal("server did not receive server/discover") + } + + meta := got.params.GetMeta() + if v, _ := meta[MetaKeyProtocolVersion].(string); v != protocolVersion20260728 { + t.Errorf("_meta[%s] = %q, want %q", MetaKeyProtocolVersion, v, protocolVersion20260728) + } + // _meta values round-trip through JSON, so on the server side they + // arrive as map[string]any rather than the typed Go pointers we sent. + info, ok := meta[MetaKeyClientInfo].(map[string]any) + if !ok { + t.Fatalf("_meta[%s] = %T, want map[string]any", MetaKeyClientInfo, meta[MetaKeyClientInfo]) + } + if got, want := info["name"], any(clientImpl.Name); got != want { + t.Errorf("clientInfo.name = %v, want %v", got, want) + } + caps, ok := meta[MetaKeyClientCapabilities].(map[string]any) + if !ok { + t.Fatalf("_meta[%s] = %T, want map[string]any", MetaKeyClientCapabilities, meta[MetaKeyClientCapabilities]) + } + if _, ok := caps["sampling"]; !ok { + t.Errorf("clientCapabilities.sampling missing (CreateMessageHandler was set); got %v", caps) + } +} + +// TestInMemory_E2E_DiscoverSuccess is a full end-to-end smoke test for +// SEP-2575 over a non-HTTP transport. +func TestInMemory_E2E_DiscoverSuccess(t *testing.T) { + ctx := context.Background() + + server := NewServer(&Implementation{Name: "stdio-like-server", Version: "v1"}, nil) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "stdio-like-client", Version: "v1"}, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil; discover should have populated it") + } + if ir.ProtocolVersion != protocolVersion20260728 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (negotiated via discover, no initialize)", + ir.ProtocolVersion, protocolVersion20260728) + } + if ir.ServerInfo == nil || ir.ServerInfo.Name != "stdio-like-server" { + t.Errorf("InitializeResult.ServerInfo = %+v, want name=stdio-like-server", ir.ServerInfo) + } + + // Prove the session is usable. + if _, err := cs.ListTools(ctx, nil); err != nil { + t.Errorf("ListTools after discover: %v", err) + } +} + +// TestInMemory_E2E_DiscoverFallback_NoOverlap verifies the fallback path +// over an InMemory (STDIO-equivalent) transport: the client probes with +// _meta.protocolVersion = 2026-07-28, but the server overrides discover via +// middleware to advertise only legacy versions, so the client must fall +// back to the legacy initialize handshake. +func TestInMemory_E2E_DiscoverFallback_NoOverlap(t *testing.T) { + ctx := context.Background() + server := NewServer(&Implementation{Name: "vpre-like-server", Version: "v1"}, nil) + // Intercept discover and reply as if we were a server that only + // supports legacy versions. + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodDiscover { + return &DiscoverResult{ + Meta: Meta{ + MetaKeyServerInfo: &Implementation{Name: "vpre-like-server", Version: "v1"}, + }, + SupportedVersions: []string{protocolVersion20251125}, + Capabilities: &ServerCapabilities{}, + }, nil + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after fallback initialize") + } + // The fallback initialize explicitly requests protocolVersion20251125 + // (see client.go), so the server negotiates that version. + if ir.ProtocolVersion != protocolVersion20251125 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after no-overlap discover)", + ir.ProtocolVersion, protocolVersion20251125) + } + + // Prove the session is usable after fallback. + if _, err := cs.ListTools(ctx, nil); err != nil { + t.Errorf("ListTools after fallback initialize: %v", err) + } +} + +// TestInMemory_E2E_DiscoverFallback_MethodNotFound verifies the fallback +// path over InMemory when the server doesn't know about server/discover at +// all (simulating a true pre-SEP-2575 server). +func TestInMemory_E2E_DiscoverFallback_MethodNotFound(t *testing.T) { + ctx := context.Background() + + server := NewServer(&Implementation{Name: "vpre-server", Version: "v1"}, nil) + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodDiscover { + return nil, jsonrpc2.ErrMethodNotFound + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after fallback initialize") + } + // The fallback initialize explicitly requests protocolVersion20251125 + // (see client.go), so the server negotiates that version. + if ir.ProtocolVersion != protocolVersion20251125 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after MethodNotFound)", + ir.ProtocolVersion, protocolVersion20251125) + } + + if _, err := cs.ListTools(ctx, nil); err != nil { + t.Errorf("ListTools after fallback initialize: %v", err) + } +} + +// TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion verifies the +// fallback path when the server explicitly rejects the discover probe with +// CodeUnsupportedProtocolVersion (the structured SEP-2575 signal). This +// exercises Path A of the fallback logic in client.go. +func TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion(t *testing.T) { + ctx := context.Background() + + server := NewServer(&Implementation{Name: "strict-server", Version: "v1"}, nil) + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodDiscover { + return nil, &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + } + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after fallback initialize") + } + // The fallback initialize explicitly requests protocolVersion20251125 + // (see client.go), so the server negotiates that version. + if ir.ProtocolVersion != protocolVersion20251125 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after UnsupportedProtocolVersion)", + ir.ProtocolVersion, protocolVersion20251125) + } +} + +// TestClientConnectDiscover_UnsupportedVersionNegotiation verifies the +// per SEP-2575 Version Negotiation Flow: when the client probes server/discover +// with a protocol version the server doesn't implement, the server's +// UnsupportedProtocolVersionError carries Data.Supported, and the client +// selects a mutually supported version from that list and retries. +func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) { + ctx := context.Background() + + const unsupportedClientVersion = "2099-12-31" + + var ( + discoverCalls atomic.Int32 + gotInitialize atomic.Bool + observedDiscoverVersion atomic.Value // string + ) + + s := NewServer(testImpl, nil) + s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + switch method { + case methodDiscover: + sr, ok := req.(*ServerRequest[*DiscoverParams]) + if !ok { + t.Errorf("discover req has unexpected type %T", req) + return nil, jsonrpc2.ErrMethodNotFound + } + if v, _ := sr.Params.GetMeta()[MetaKeyProtocolVersion].(string); v != "" { + observedDiscoverVersion.Store(v) + } + discoverCalls.Add(1) + case methodInitialize: + gotInitialize.Store(true) + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server Connect: %v", err) + } + defer ss.Close() + + c := NewClient(testImpl, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: unsupportedClientVersion}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer cs.Close() + + if got, want := discoverCalls.Load(), int32(1); got != want { + t.Errorf("server/discover handler call count = %d, want %d (first probe is rejected by the dispatcher; only the retry reaches the handler)", got, want) + } + if got, _ := observedDiscoverVersion.Load().(string); got != protocolVersion20260728 { + t.Errorf("retry discover requested version = %q, want %q (highest mutually supported version)", got, protocolVersion20260728) + } + if gotInitialize.Load() { + t.Error("legacy initialize handshake ran, but negotiated discover should have succeeded") + } + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after Connect") + } + if got, want := ir.ProtocolVersion, protocolVersion20260728; got != want { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want) + } +} diff --git a/mcp/conformance_test.go b/mcp/conformance_test.go index 7f1a744..d7f4f74 100644 --- a/mcp/conformance_test.go +++ b/mcp/conformance_test.go @@ -143,6 +143,31 @@ func incTool(_ context.Context, _ *CallToolRequest, args incInput) (*CallToolRes return nil, incOutput{args.X + 1}, nil } +// confirmThenGreet exercises the multi-round-trip pattern in +// conformance fixtures. +func confirmThenGreet(_ context.Context, req *CallToolRequest, _ struct{}) (*CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &CallToolResult{ + InputRequests: InputRequestMap{ + "who": &ElicitParams{ + Message: "What is your name?", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": {Type: "string"}, + }, + }, + }, + }, + RequestState: "step=1", + }, nil, nil + } + name := req.Params.InputResponses["who"].(*ElicitResult).Content["name"].(string) + return &CallToolResult{ + Content: []Content{&TextContent{Text: "hello " + name}}, + }, nil, nil +} + var iconObj = Icon{ Source: "foobar", MIMEType: "image/png", @@ -193,6 +218,11 @@ func runServerTest(t *testing.T, test *conformanceTest) { Title: "contentTool", Description: "return resourceLink content with Icon", }, contentTool) + case "confirmThenGreet": + AddTool(s, &Tool{ + Name: "confirmThenGreet", + Description: "ask the user for their name via elicitation, then greet them", + }, confirmThenGreet) default: t.Fatalf("unknown tool %q", tn) } diff --git a/mcp/content.go b/mcp/content.go index 95ea40d..7af90fe 100644 --- a/mcp/content.go +++ b/mcp/content.go @@ -189,6 +189,11 @@ func (c *EmbeddedResource) fromWire(wire *wireContent) { // ToolUseContent represents a request from the assistant to invoke a tool. // This content type is only valid in sampling messages. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type ToolUseContent struct { // ID is a unique identifier for this tool use, used to match with ToolResultContent. ID string @@ -229,12 +234,19 @@ func (c *ToolUseContent) fromWire(wire *wireContent) { // ToolResultContent represents the result of a tool invocation. // This content type is only valid in sampling messages with role "user". +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type ToolResultContent struct { // ToolUseID references the ID from the corresponding ToolUseContent. ToolUseID string // Content holds the unstructured result of the tool call. Content []Content - // StructuredContent holds an optional structured result as a JSON object. + // StructuredContent holds an optional structured result. Per SEP-2106, it + // may be any valid JSON value (object, array, or primitive) conforming to + // the tool's output schema. StructuredContent any // IsError indicates whether the tool call ended in an error. IsError bool diff --git a/mcp/content_nil_test.go b/mcp/content_nil_test.go index ba263e6..f759986 100644 --- a/mcp/content_nil_test.go +++ b/mcp/content_nil_test.go @@ -15,6 +15,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -223,4 +224,12 @@ func TestContentUnmarshalNilWithInvalidContent(t *testing.T) { } } -var ctrCmpOpts = []cmp.Option{cmp.AllowUnexported(mcp.CallToolResult{})} +var ctrCmpOpts = []cmp.Option{ + cmpopts.IgnoreUnexported(mcp.CallToolResult{}, mcp.GetPromptResult{}, mcp.ReadResourceResult{}), + // Server responses under the >= 2026-07-28 protocol carry an auto-populated + // [mcp.MetaKeyServerInfo] entry; tests that compare result bodies against + // hand-crafted expected values should ignore it. + cmpopts.IgnoreFields(mcp.CallToolResult{}, "Meta"), + cmpopts.IgnoreFields(mcp.GetPromptResult{}, "Meta"), + cmpopts.IgnoreFields(mcp.ReadResourceResult{}, "Meta"), +} diff --git a/mcp/elicitation_test.go b/mcp/elicitation_test.go index cc08820..098fce3 100644 --- a/mcp/elicitation_test.go +++ b/mcp/elicitation_test.go @@ -12,6 +12,7 @@ import ( "testing/synctest" "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -114,7 +115,7 @@ func TestElicitationURLMode(t *testing.T) { }, ElicitationHandler: tc.handler, }) - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -165,8 +166,18 @@ func TestElicitationCompleteNotification(t *testing.T) { }, }) - _, ss, cleanup := basicClientServerConnection(t, c, nil, nil) - defer cleanup() + ct, st := NewInMemoryTransports() + s := NewServer(testImpl, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatal(err) + } + defer cs.Close() // 1. Server initiates a URL elicitation elicitID := "testElicitationID-123" @@ -184,9 +195,9 @@ func TestElicitationCompleteNotification(t *testing.T) { } // 2. Server sends elicitation complete notification (simulating out-of-band completion) - err = handleNotify(ctx, notificationElicitationComplete, newServerRequest(ss, &ElicitationCompleteParams{ + err = ss.NotifyElicitationComplete(ctx, &ElicitationCompleteParams{ ElicitationID: elicitID, - })) + }) if err != nil { t.Fatalf("failed to send elicitation complete notification: %v", err) } @@ -199,6 +210,41 @@ func TestElicitationCompleteNotification(t *testing.T) { }) } +func TestNotifyElicitationCompleteInvalidParams(t *testing.T) { + ctx := context.Background() + + ct, st := NewInMemoryTransports() + s := NewServer(testImpl, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + c := NewClient(testImpl, nil) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // An empty elicitation ID cannot be matched to a pending elicitation by the + // client, so it is rejected rather than sent. + for _, tc := range []struct { + name string + params *ElicitationCompleteParams + }{ + {"nil params", nil}, + {"empty ID", &ElicitationCompleteParams{}}, + } { + t.Run(tc.name, func(t *testing.T) { + err := ss.NotifyElicitationComplete(ctx, tc.params) + if !errors.Is(err, jsonrpc2.ErrInvalidParams) { + t.Errorf("NotifyElicitationComplete(%v) = %v, want %v", tc.params, err, jsonrpc2.ErrInvalidParams) + } + }) + } +} + func TestElicitationNoValidationWithoutAccept(t *testing.T) { ctx := context.Background() @@ -245,7 +291,7 @@ func TestElicitationNoValidationWithoutAccept(t *testing.T) { return &ElicitResult{Action: tc.action, Content: tc.content}, nil }, }) - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } diff --git a/mcp/error_test.go b/mcp/error_test.go index 47a99f8..c69fc4b 100644 --- a/mcp/error_test.go +++ b/mcp/error_test.go @@ -111,6 +111,48 @@ func TestServerErrors(t *testing.T) { } } +// TestResourceNotFoundErrorCode verifies ResourceNotFoundError code and +// CodeResourceNotFound are -32602 (InvalidParams) per SEP-2164. +func TestResourceNotFoundErrorCode(t *testing.T) { + err := ResourceNotFoundError("file:///test.txt") + var rpcErr *jsonrpc.Error + if !errors.As(err, &rpcErr) { + t.Fatalf("got error type %T, want *jsonrpc.Error", err) + } + if rpcErr.Code != jsonrpc.CodeInvalidParams { + t.Errorf("got error code %d, want %d (InvalidParams)", rpcErr.Code, jsonrpc.CodeInvalidParams) + } + if CodeResourceNotFound != jsonrpc.CodeInvalidParams { + t.Errorf("CodeResourceNotFound = %d, want %d", CodeResourceNotFound, jsonrpc.CodeInvalidParams) + } +} + +// TestReadResourceNilResult verifies that a resource handler returning +// (nil, nil) yields a clean error to the client instead of a nil pointer +// panic that crashes the server. The sibling handlers callTool and getPrompt +// already tolerate a (nil, nil) return; readResource must do the same. +func TestReadResourceNilResult(t *testing.T) { + ctx := context.Background() + + cs, _, cleanup := basicConnection(t, func(s *Server) { + s.AddResource( + &Resource{URI: "file:///nil.txt", Name: "nil", MIMEType: "text/plain"}, + func(ctx context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + return nil, nil + }, + ) + }) + defer cleanup() + + _, err := cs.ReadResource(ctx, &ReadResourceParams{URI: "file:///nil.txt"}) + if err == nil { + t.Fatal("got nil error, want non-nil error for nil resource result") + } + if !strings.Contains(err.Error(), "nil information") { + t.Errorf("got error %q, want it to mention 'nil information'", err.Error()) + } +} + // TestInputValidationToolError validates that input validation errors (missing // required params, wrong types) are returned as tool results with IsError=true, // not as JSON-RPC errors. This allows LLMs to see the error and self-correct. @@ -258,10 +300,9 @@ func TestURLElicitationRequired(t *testing.T) { // In a real scenario, this would happen out-of-band after the user // completes the form submission. go func() { - err := handleNotify(ctx, notificationElicitationComplete, - newServerRequest(ss, &ElicitationCompleteParams{ - ElicitationID: elicitID, - })) + err := ss.NotifyElicitationComplete(ctx, &ElicitationCompleteParams{ + ElicitationID: elicitID, + }) if err != nil { t.Errorf("failed to send elicitation complete notification: %v", err) } diff --git a/mcp/event.go b/mcp/event.go index 41ff4ae..f0cbb2f 100644 --- a/mcp/event.go +++ b/mcp/event.go @@ -52,7 +52,12 @@ func writeEvent(w http.ResponseWriter, evt Event) (int, error) { if evt.Retry != "" { fmt.Fprintf(&b, "retry: %s\n", evt.Retry) } - fmt.Fprintf(&b, "data: %s\n\n", string(evt.Data)) + // Write the payload directly into a pre-grown buffer to avoid the extra + // copy from string(evt.Data) and repeated buffer regrowths. + b.Grow(len("data: \n\n") + len(evt.Data)) + b.WriteString("data: ") + b.Write(evt.Data) + b.WriteString("\n\n") n, err := w.Write(b.Bytes()) rc := http.NewResponseController(w) // Ignore returned error as flushing is best-effort. @@ -60,13 +65,21 @@ func writeEvent(w http.ResponseWriter, evt Event) (int, error) { return n, err } -// scanEvents iterates SSE events in the given scanner. The iterated error is -// terminal: if encountered, the stream is corrupt or broken and should no -// longer be used. +// DefaultMaxEventSize is the default maximum number of bytes buffered while +// reading a single server-sent event before the input is rejected. +const DefaultMaxEventSize = 16 << 20 // 16 MiB + +// scanEventsLimited iterates SSE events in the given reader, buffering at most +// maxEventSize bytes per event. When maxEventSize > 0, an event is reported as +// [errMalformedEvent] once its size exceeds it; a non-positive maxEventSize +// disables the cap. +// +// The iterated error is terminal: if encountered, the stream is corrupt or +// broken and should no longer be used. // // TODO(rfindley): consider a different API here that makes failure modes more // apparent. -func scanEvents(r io.Reader) iter.Seq2[Event, error] { +func scanEventsLimited(r io.Reader, maxEventSize int) iter.Seq2[Event, error] { reader := bufio.NewReader(r) // TODO: investigate proper behavior when events are out of order, or have @@ -89,8 +102,9 @@ func scanEvents(r io.Reader) iter.Seq2[Event, error] { // - Lines starting with ":" are ignored. // - Records are terminated with two consecutive newlines. var ( - evt Event - dataBuf *bytes.Buffer // if non-nil, preceding field was also data + evt Event + dataBuf *bytes.Buffer // if non-nil, preceding field was also data + eventBytes int // bytes read for the current, in-progress event ) yieldEvent := func() bool { if dataBuf != nil { @@ -107,11 +121,20 @@ func scanEvents(r io.Reader) iter.Seq2[Event, error] { return true } for { - line, err := reader.ReadBytes('\n') + budget := -1 + if maxEventSize > 0 { + budget = maxEventSize - eventBytes + } + line, err := readEventLine(reader, budget) + if errors.Is(err, errEventTooLarge) { + yield(Event{}, fmt.Errorf("%w: SSE event exceeded %d bytes without terminating", errMalformedEvent, maxEventSize)) + return + } if err != nil && !errors.Is(err, io.EOF) { yield(Event{}, fmt.Errorf("error reading event: %v", err)) return } + eventBytes += len(line) line = bytes.TrimRight(line, "\r\n") isEOF := errors.Is(err, io.EOF) @@ -119,6 +142,7 @@ func scanEvents(r io.Reader) iter.Seq2[Event, error] { if !yieldEvent() { return } + eventBytes = 0 // reset the budget between events if isEOF { return } @@ -317,6 +341,31 @@ var ErrEventsPurged = errors.New("data purged") // transient I/O errors which may be retryable. var errMalformedEvent = errors.New("malformed event") +// errEventTooLarge is returned by [readEventLine] when a single line would +// exceed the remaining per-event byte budget. +var errEventTooLarge = errors.New("SSE event exceeded maximum size") + +// readEventLine reads a single '\n'-terminated line from r. When budget >= 0 it +// reads at most budget bytes, returning [errEventTooLarge] once that budget is +// exceeded before a newline arrives. +func readEventLine(r *bufio.Reader, budget int) ([]byte, error) { + if budget < 0 { + return r.ReadBytes('\n') + } + var line []byte + for { + frag, err := r.ReadSlice('\n') + if len(line)+len(frag) > budget { + return nil, errEventTooLarge + } + line = append(line, frag...) + if errors.Is(err, bufio.ErrBufferFull) { + continue // looking for delim + } + return line, err + } +} + // After implements [EventStore.After]. func (s *MemoryEventStore) After(_ context.Context, sessionID, streamID string, index int) iter.Seq2[[]byte, error] { // Return the data items to yield. @@ -332,12 +381,15 @@ func (s *MemoryEventStore) After(_ context.Context, sessionID, streamID string, if !ok { return nil, fmt.Errorf("MemoryEventStore.After: unknown stream ID %v in session %q", streamID, sessionID) } - start := index + 1 - if dl.first > start { + start := (index + 1) - dl.first + if start < 0 { return nil, fmt.Errorf("MemoryEventStore.After: index %d, stream ID %v, session %q: %w", index, streamID, sessionID, ErrEventsPurged) } - return slices.Clone(dl.data[start-dl.first:]), nil + if start >= len(dl.data) { + return nil, nil + } + return slices.Clone(dl.data[start:]), nil } return func(yield func([]byte, error) bool) { diff --git a/mcp/event_test.go b/mcp/event_test.go index 91ddb9d..d326415 100644 --- a/mcp/event_test.go +++ b/mcp/event_test.go @@ -5,9 +5,12 @@ package mcp import ( + "bytes" "context" "crypto/rand" + "errors" "fmt" + "io" "slices" "strings" "testing" @@ -134,7 +137,7 @@ func TestScanEvents(t *testing.T) { r := strings.NewReader(tt.input) var got []Event var err error - for e, err2 := range scanEvents(r) { + for e, err2 := range scanEventsLimited(r, DefaultMaxEventSize) { if err2 != nil { err = err2 break @@ -175,6 +178,136 @@ func TestScanEvents(t *testing.T) { } } +// endlessReader streams a fixed prefix once, then repeats fill forever. +type endlessReader struct { + prefix string + sent bool + repeat string + repIndex int +} + +func (r *endlessReader) Read(p []byte) (int, error) { + if !r.sent { + n := copy(p, r.prefix) + r.sent = true + return n, nil + } + for i := range p { + p[i] = r.repeat[r.repIndex%len(r.repeat)] + r.repIndex++ + } + return len(p), nil +} + +func (r *endlessReader) Close() error { return nil } + +// TestScanEventsMaxEventSize verifies that scanEvents bounds the bytes +// buffered for a single event. +func TestScanEventsMaxEventSize(t *testing.T) { + wantByte := byte('A') + eventOfSize := func(size int) []byte { + var buf bytes.Buffer + buf.WriteString("data: ") + buf.Write(bytes.Repeat([]byte{wantByte}, size)) + buf.WriteString("\n\n") + return buf.Bytes() + } + + tests := []struct { + name string + reader io.Reader + maxEventSize int + wantErr bool + wantDataLengths []int + }{ + { + name: "negative budget disables the cap", + reader: bytes.NewReader(eventOfSize(512)), + maxEventSize: -1, + wantDataLengths: []int{512}, + }, + { + name: "long line under cap", // bufio.Reader default buf size is 1 << 12 + reader: bytes.NewReader(eventOfSize(1 << 14)), + maxEventSize: 1 << 15, + wantDataLengths: []int{1 << 14}, + }, + { + name: "unbounded rejected", + reader: &endlessReader{prefix: "data: ", repeat: string(wantByte)}, + maxEventSize: 1024, + wantErr: true, + }, + + { + name: "consecutive data fields longer than limit", + reader: strings.NewReader(func() string { + var b strings.Builder + for range 2048 { + b.WriteString("data: \n") + } + return b.String() + }()), + maxEventSize: 1024, + wantErr: true, + }, + { + name: "budget resets between events", + reader: bytes.NewReader(append(eventOfSize(3*1024), eventOfSize(3*1024)...)), + maxEventSize: 4 * 1024, + wantDataLengths: []int{3 * 1024, 3 * 1024}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + type result struct { + events []Event + err error + } + + done := make(chan result, 1) + go func() { + var events []Event + for e, err := range scanEventsLimited(tt.reader, tt.maxEventSize) { + if err != nil { + done <- result{events, err} + return + } + events = append(events, e) + } + done <- result{events, nil} + }() + + var res result + select { + case res = <-done: + case <-time.After(5 * time.Second): + t.Fatal("scanEvents did not return") + } + + if tt.wantErr { + if !errors.Is(res.err, errMalformedEvent) { + t.Fatalf("got error %v, want errMalformedEvent", res.err) + } + return + } + if res.err != nil { + t.Fatalf("unexpected error: %v", res.err) + } + if len(res.events) != len(tt.wantDataLengths) { + t.Fatalf("got %d events, want %d", len(res.events), len(tt.wantDataLengths)) + } + for i, wantLen := range tt.wantDataLengths { + wantRepeated := string(wantByte) + if got := res.events[i].Data; string(got) != strings.Repeat(wantRepeated, wantLen) { + t.Errorf("event %d: got %d data bytes, want %d bytes of %s", i, len(got), wantLen, wantRepeated) + } + } + }) + } +} + func TestMemoryEventStoreState(t *testing.T) { ctx := context.Background() @@ -299,7 +432,11 @@ func TestMemoryEventStoreAfter(t *testing.T) { {"S1", "1", 0, []string{"d2", "d3"}, ""}, {"S1", "1", 1, []string{"d3"}, ""}, {"S1", "1", 2, nil, ""}, + {"S1", "1", 3, nil, ""}, + {"S1", "1", 100, nil, ""}, // past the end of stream "1" (highest is 2) {"S1", "2", 0, nil, ""}, + {"S1", "2", 1, nil, ""}, + {"S1", "2", 100, nil, ""}, {"S1", "3", 0, nil, "unknown stream ID"}, {"S2", "0", 0, nil, "unknown session ID"}, } { diff --git a/mcp/logging.go b/mcp/logging.go index b1bd82b..e8d0cd7 100644 --- a/mcp/logging.go +++ b/mcp/logging.go @@ -13,6 +13,8 @@ import ( "slices" "sync" "time" + + "golang.org/x/time/rate" ) // Logging levels. @@ -66,7 +68,19 @@ func compareLevels(l1, l2 LoggingLevel) int { return cmp.Compare(mcpLevelToSlog(l1), mcpLevelToSlog(l2)) } +type logLevelContextKey struct{} + +func logLevelFromContext(ctx context.Context) (LoggingLevel, bool) { + v, ok := ctx.Value(logLevelContextKey{}).(LoggingLevel) + return v, ok +} + // LoggingHandlerOptions are options for a LoggingHandler. +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type LoggingHandlerOptions struct { // The value for the "logger" field of logging notifications. LoggerName string @@ -77,16 +91,21 @@ type LoggingHandlerOptions struct { } // A LoggingHandler is a [slog.Handler] for MCP. +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type LoggingHandler struct { opts LoggingHandlerOptions ss *ServerSession // Ensures that the buffer reset is atomic with the write (see Handle). // A pointer so that clones share the mutex. See // https://github.com/golang/example/blob/master/slog-handler-guide/README.md#getting-the-mutex-right. - mu *sync.Mutex - lastMessageSent time.Time // for rate-limiting - buf *bytes.Buffer - handler slog.Handler + mu *sync.Mutex + limiter *rate.Limiter // for rate-limiting + buf *bytes.Buffer + handler slog.Handler } // ensureLogger returns l if non-nil, otherwise a discard logger. @@ -99,6 +118,11 @@ func ensureLogger(l *slog.Logger) *slog.Logger { // NewLoggingHandler creates a [LoggingHandler] that logs to the given [ServerSession] using a // [slog.JSONHandler]. +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func NewLoggingHandler(ss *ServerSession, opts *LoggingHandlerOptions) *LoggingHandler { var buf bytes.Buffer jsonHandler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{ @@ -118,6 +142,9 @@ func NewLoggingHandler(ss *ServerSession, opts *LoggingHandlerOptions) *LoggingH } if opts != nil { lh.opts = *opts + if opts.MinInterval > 0 { + lh.limiter = rate.NewLimiter(rate.Every(opts.MinInterval), 1) + } } return lh } @@ -126,6 +153,9 @@ func NewLoggingHandler(ss *ServerSession, opts *LoggingHandlerOptions) *LoggingH func (h *LoggingHandler) Enabled(ctx context.Context, level slog.Level) bool { // This is also checked in ServerSession.LoggingMessage, so checking it here // is just an optimization that skips building the JSON. + if mcpLevel, ok := logLevelFromContext(ctx); ok { + return mcpLevel != "" && level >= mcpLevelToSlog(mcpLevel) + } h.ss.mu.Lock() mcpLevel := h.ss.state.LogLevel h.ss.mu.Unlock() @@ -157,11 +187,7 @@ func (h *LoggingHandler) Handle(ctx context.Context, r slog.Record) error { func (h *LoggingHandler) handle(ctx context.Context, r slog.Record) error { // Observe the rate limit. - // TODO(jba): use golang.org/x/time/rate. - h.mu.Lock() - skip := time.Since(h.lastMessageSent) < h.opts.MinInterval - h.mu.Unlock() - if skip { + if h.limiter != nil && !h.limiter.Allow() { return nil } @@ -184,10 +210,6 @@ func (h *LoggingHandler) handle(ctx context.Context, r slog.Record) error { return err } - h.mu.Lock() - h.lastMessageSent = time.Now() - h.mu.Unlock() - params := &LoggingMessageParams{ Logger: h.opts.LoggerName, Level: slogLevelToMCP(r.Level), diff --git a/mcp/mcp_example_test.go b/mcp/mcp_example_test.go index 25f39fb..120789c 100644 --- a/mcp/mcp_example_test.go +++ b/mcp/mcp_example_test.go @@ -19,19 +19,13 @@ import ( func Example_lifecycle() { ctx := context.Background() - // Create a client and server. - // Wait for the client to initialize the session. + // Create a client and server. Under protocol version 2026-07-28, there + // is no dedicated initialize/initialized handshake: the session is + // implicitly live the moment the client issues its first request. client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) - server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ - InitializedHandler: func(context.Context, *mcp.InitializedRequest) { - fmt.Println("initialized!") - }, - }) + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) // Connect the server and client using in-memory transports. - // - // Connect the server first so that it's ready to receive initialization - // messages from the client. t1, t2 := mcp.NewInMemoryTransports() serverSession, err := server.Connect(ctx, t1, nil) if err != nil { @@ -50,7 +44,7 @@ func Example_lifecycle() { if err := serverSession.Wait(); err != nil { log.Fatal(err) } - // Output: initialized! + // Output: } // !-lifecycle diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 5a20e4c..7b10b30 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -2,6 +2,8 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +//lint:file-ignore SA1019 tests exercise deprecated SEP-2577 APIs (roots, sampling, logging). + package mcp import ( @@ -12,6 +14,8 @@ import ( "fmt" "io" "log/slog" + "net/http" + "net/http/httptest" "net/url" "path/filepath" "runtime" @@ -24,7 +28,9 @@ import ( "time" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -165,7 +171,10 @@ func TestEndToEnd(t *testing.T) { c.AddRoots(&Root{URI: "file://" + rootAbs}) // Connect the client. - cs, err := c.Connect(ctx, ct, nil) + // + // Pin the session to 2025-11-25 so the legacy + // semantics apply. + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -205,7 +214,7 @@ func TestEndToEnd(t *testing.T) { Role: "user", }}, } - if diff := cmp.Diff(wantReview, gotReview); diff != "" { + if diff := cmp.Diff(wantReview, gotReview, ctrCmpOpts...); diff != "" { t.Errorf("prompts/get 'code_review' mismatch (-want +got):\n%s", diff) } @@ -701,13 +710,13 @@ func TestCancellation(t *testing.T) { synctest.Test(t, func(t *testing.T) { var ( start = make(chan struct{}) - cancelled = make(chan struct{}, 1) // don't block the request + cancelled = make(chan error, 1) // don't block the request ) slowTool := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { start <- struct{}{} select { case <-ctx.Done(): - cancelled <- struct{}{} + cancelled <- context.Cause(ctx) case <-time.After(5 * time.Second): return nil, nil, nil } @@ -723,7 +732,72 @@ func TestCancellation(t *testing.T) { <-start cancel() - <-cancelled + // The client sends its context's error as the reason of its cancelled + // notification, and the handler reads it back as the cause. + cause := <-cancelled + if !errors.Is(cause, context.Canceled) { + t.Errorf("context.Cause = %v, want it to wrap context.Canceled", cause) + } + if want := "request cancelled by the peer: " + context.Canceled.Error(); cause == nil || cause.Error() != want { + t.Errorf("context.Cause = %v, want %q", cause, want) + } + }) +} + +// TestCancellationReason verifies that the reason a peer gives in its +// cancelled notification reaches the handler as the cause of its context, +// which is what lets a server log it as the specification asks. +// +// The call and the notification are written to the connection directly, so +// the reason is one the SDK's own client would never send; the session is +// pinned to 2025-11-25 so that a request written that way needs no _meta. +func TestCancellationReason(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + var ( + start = make(chan struct{}) + cancelled = make(chan error, 1) // don't block the request + ) + slowTool := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + start <- struct{}{} + select { + case <-ctx.Done(): + cancelled <- context.Cause(ctx) + case <-time.After(5 * time.Second): + cancelled <- nil + } + return nil, nil, nil + } + ctx := context.Background() + ct, st := NewInMemoryTransports() + s := NewServer(testImpl, nil) + AddTool(s, &Tool{Name: "slow", InputSchema: &jsonschema.Schema{Type: "object"}}, slowTool) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + cs, err := NewClient(testImpl, nil).Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cs.Close() }) + + call := cs.conn.Call(ctx, methodCallTool, &CallToolParams{Name: "slow"}) + <-start + if err := cs.conn.Notify(ctx, notificationCancelled, &CancelledParams{RequestID: call.ID().Raw(), Reason: "user asked"}); err != nil { + t.Fatal(err) + } + + cause := <-cancelled + if cause == nil { + t.Fatal("the tool ran to completion, want it cancelled") + } + if !errors.Is(cause, context.Canceled) { + t.Errorf("context.Cause = %v, want it to wrap context.Canceled", cause) + } + if got, want := cause.Error(), "request cancelled by the peer: user asked"; got != want { + t.Errorf("context.Cause = %q, want %q", got, want) + } }) } @@ -752,7 +826,10 @@ func TestMiddleware(t *testing.T) { c.AddSendingMiddleware(traceCalls[*ClientSession](&cbuf, "S1"), traceCalls[*ClientSession](&cbuf, "S2")) c.AddReceivingMiddleware(traceCalls[*ClientSession](&cbuf, "R1"), traceCalls[*ClientSession](&cbuf, "R2")) - cs, err := c.Connect(ctx, ct, nil) + // Pin to 2025-11-25 because the test's expected wire sequence asserts + // the legacy initialize / notifications/initialized handshake, which + // 2026-07-28 replaces with server/discover. + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -842,7 +919,7 @@ func TestNoJSONNull(t *testing.T) { } c := NewClient(testImpl, nil) - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -938,6 +1015,61 @@ func TestKeepAlive(t *testing.T) { }) } +func TestKeepAliveMethodNotFound(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.Background() + + ct, st := NewInMemoryTransports() + + // Server that rejects ping with method-not-found, simulating a + // server that does not implement the optional ping method. + s := NewServer(testImpl, nil) + AddTool(s, greetTool(), sayHi) + s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == "ping" { + return nil, jsonrpc2.ErrMethodNotFound + } + return next(ctx, method, req) + } + }) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + clientOpts := &ClientOptions{ + KeepAlive: 50 * time.Millisecond, + } + c := NewClient(testImpl, clientOpts) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Advance past several keepalive cycles. + time.Sleep(200 * time.Millisecond) + + // The session should still be alive despite the server not + // supporting ping. + result, err := cs.CallTool(ctx, &CallToolParams{ + Name: "greet", + Arguments: map[string]any{"Name": "user"}, + }) + if err != nil { + t.Fatalf("call failed after keepalive with method-not-found: %v", err) + } + if len(result.Content) == 0 { + t.Fatal("expected content in result") + } + if textContent, ok := result.Content[0].(*TextContent); !ok || textContent.Text != "hi user" { + t.Fatalf("unexpected result: %v", result.Content[0]) + } + }) +} + func TestElicitationUnsupportedMethod(t *testing.T) { ctx := context.Background() ct, st := NewInMemoryTransports() @@ -956,7 +1088,7 @@ func TestElicitationUnsupportedMethod(t *testing.T) { return &CreateMessageResult{Model: "aModel", Content: &TextContent{}}, nil }, }) - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1005,7 +1137,7 @@ func TestElicitationSchemaValidation(t *testing.T) { return &ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil }, }) - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1491,7 +1623,7 @@ func TestElicitContentValidation(t *testing.T) { return &ElicitResult{Action: "accept", Content: map[string]any{"test": "potato"}}, nil }, }) - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1574,7 +1706,7 @@ func TestElicitationProgressToken(t *testing.T) { return &ElicitResult{Action: "accept"}, nil }, }) - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1616,7 +1748,7 @@ func TestElicitationCapabilityDeclaration(t *testing.T) { } defer ss.Close() - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1653,7 +1785,7 @@ func TestElicitationCapabilityDeclaration(t *testing.T) { } defer ss.Close() - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1690,7 +1822,7 @@ func TestElicitationDefaultValues(t *testing.T) { return &ElicitResult{Action: "accept", Content: map[string]any{"default": "response"}}, nil }, }) - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1817,6 +1949,131 @@ func TestKeepAliveFailure(t *testing.T) { }) } +// TestKeepAliveFailure_Logged verifies that a keepalive ping failure is +// reported via the configured slog.Logger instead of being silently dropped. +// Regression test for #218. +func TestKeepAliveFailure_Logged(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.Background() + + ct, st := NewInMemoryTransports() + + // Server without keepalive. + s := NewServer(testImpl, nil) + AddTool(s, greetTool(), sayHi) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + + // Client with a short keepalive and a logger that writes to a + // buffer so we can assert on its output. + var buf bytes.Buffer + clientOpts := &ClientOptions{ + KeepAlive: 50 * time.Millisecond, + Logger: slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelError})), + } + c := NewClient(testImpl, clientOpts) + // Pin to 2025-11-25: KeepAlive uses the ping RPC, which is removed + // in 2026-07-28, so keepalive is only meaningful on legacy protocol + // versions. + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + synctest.Wait() + + // Trigger a ping failure by closing the server side. + ss.Close() + + time.Sleep(100 * time.Millisecond) + synctest.Wait() + + got := buf.String() // slog serializes Write calls internally + if !strings.Contains(got, "keepalive ping failed") { + t.Errorf("expected keepalive failure to be logged, got log output:\n%s", got) + } + }) +} + +// scriptedKeepaliveSession is a keepaliveSession test double whose Ping +// returns errors from a script (one entry consumed per call; the last entry +// repeats once exhausted), and records how many times Close was called. Ping +// returns immediately so the keepalive loop's pace is driven purely by the +// ticker, making the test deterministic under synctest. +type scriptedKeepaliveSession struct { + pingErrs []error + pingCalls atomic.Int64 + closeCalls atomic.Int64 +} + +func (s *scriptedKeepaliveSession) Ping(context.Context, *PingParams) error { + n := int(s.pingCalls.Add(1)) - 1 + if n >= len(s.pingErrs) { + n = len(s.pingErrs) - 1 + } + return s.pingErrs[n] +} + +func (s *scriptedKeepaliveSession) Close() error { + s.closeCalls.Add(1) + return nil +} + +// TestStartKeepalive_FailureThreshold verifies that the session is kept alive +// across consecutive ping failures below the threshold and only closed once the +// threshold is reached. +func TestStartKeepalive_FailureThreshold(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + const interval = 100 * time.Millisecond + sess := &scriptedKeepaliveSession{pingErrs: []error{errors.New("boom")}} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + var cancel context.CancelFunc + startKeepalive(sess, interval, 3, &cancel, logger) + defer cancel() + + // After two ticks → two failures, still below threshold 3: not closed. + time.Sleep(2*interval + interval/2) + synctest.Wait() + if got := sess.closeCalls.Load(); got != 0 { + t.Fatalf("session closed below threshold: closeCalls=%d (pingCalls=%d)", got, sess.pingCalls.Load()) + } + + // Third tick → third failure reaches threshold: session closed. + time.Sleep(interval) + synctest.Wait() + if got := sess.closeCalls.Load(); got != 1 { + t.Fatalf("expected one Close at threshold, got closeCalls=%d (pingCalls=%d)", got, sess.pingCalls.Load()) + } + }) +} + +// TestStartKeepalive_SuccessResetsFailures verifies that a successful ping +// resets the consecutive-failure counter, so an isolated failure between +// successes never accumulates toward the threshold. +func TestStartKeepalive_SuccessResetsFailures(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + const interval = 100 * time.Millisecond + // fail, success, fail, fail, then success (the tail repeats): the run + // never has 3 consecutive failures, so the session is never closed. + sess := &scriptedKeepaliveSession{pingErrs: []error{ + errors.New("boom"), nil, errors.New("boom"), errors.New("boom"), nil, + }} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + var cancel context.CancelFunc + startKeepalive(sess, interval, 3, &cancel, logger) + defer cancel() + + time.Sleep(6 * interval) + synctest.Wait() + if got := sess.closeCalls.Load(); got != 0 { + t.Fatalf("session closed despite a success resetting the counter: closeCalls=%d (pingCalls=%d)", got, sess.pingCalls.Load()) + } + }) +} + func TestAddTool_DuplicateNoPanicAndNoDuplicate(t *testing.T) { // Adding the same tool pointer twice should not panic and should not // produce duplicates in the server's tool list. @@ -1887,8 +2144,22 @@ func TestSynchronousNotifications(t *testing.T) { return new(CallToolResult), nil, nil }) } - cs, ss, cleanup := basicClientServerConnection(t, client, server, addTool) - defer cleanup() + ctx := context.Background() + ct, st := NewInMemoryTransports() + addTool(server) + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = cs.Close() + ss.Wait() + }) t.Log("from client") { @@ -2228,4 +2499,1454 @@ func TestToolErrorMiddleware(t *testing.T) { } } -var ctrCmpOpts = []cmp.Option{cmp.AllowUnexported(CallToolResult{})} +func TestSetErrorPreservesContent(t *testing.T) { + for _, tt := range []struct { + name string + content []Content + err error + wantContent string + }{ + { + name: "nil content", + err: errors.New("internal failure"), + wantContent: "internal failure", + }, + { + name: "empty slice content", + content: []Content{}, + err: errors.New("internal failure"), + wantContent: "internal failure", + }, + { + name: "existing content preserved", + content: []Content{&TextContent{Text: "user-friendly msg"}}, + err: errors.New("db timeout"), + wantContent: "user-friendly msg", + }, + } { + t.Run(tt.name, func(t *testing.T) { + res := CallToolResult{Content: tt.content} + res.SetError(tt.err) + if !res.IsError { + t.Fatal("want IsError=true") + } + if got := res.Content[0].(*TextContent).Text; got != tt.wantContent { + t.Errorf("Content text = %q, want %q", got, tt.wantContent) + } + if got := res.GetError(); got != tt.err { + t.Errorf("GetError() = %v, want %v", got, tt.err) + } + }) + } +} + +var ctrCmpOpts = []cmp.Option{ + cmpopts.IgnoreUnexported(CallToolResult{}, GetPromptResult{}, ReadResourceResult{}), + // Server responses under the >= 2026-07-28 protocol carry an auto-populated + // [MetaKeyServerInfo] entry; tests that compare result bodies against + // hand-crafted expected values should ignore it. + cmpopts.IgnoreFields(CallToolResult{}, "Meta"), + cmpopts.IgnoreFields(GetPromptResult{}, "Meta"), + cmpopts.IgnoreFields(ReadResourceResult{}, "Meta"), +} + +// runSubscriptionsListenTest exercises the SEP-2575 auto-listen flow end-to-end +// against the supplied transport pair. It captures every notification and the +// acknowledgment the client sees, then asserts: +// +// - the auto-listen issued by Client.Connect is acknowledged with a tagged +// subscription ID; +// - tool and prompt list-changed notifications are delivered to the matching +// handlers, each carrying the same subscription ID as the ack; +// - the subscription persists across multiple unrelated changes; +// - cs.Close() ends the subscription and further changes don't deliver. +func runSubscriptionsListenTest(t *testing.T, client *Client, server *Server, ct Transport, events chan subListenEvent) { + t.Helper() + + ctx, topCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer topCancel() + + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + + waitFor := func(kind string) subListenEvent { + t.Helper() + select { + case e := <-events: + if e.kind != kind { + t.Fatalf("got event %q (id=%s), want kind %q", e.kind, e.id, kind) + } + return e + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for %q event", kind) + return subListenEvent{} + } + } + expectNoEvent := func(d time.Duration) { + t.Helper() + select { + case e := <-events: + t.Fatalf("unexpected event %q (id=%s)", e.kind, e.id) + case <-time.After(d): + } + } + + ack := waitFor("ack") + if ack.id == "" { + t.Fatalf("acknowledgment missing subscription ID") + } + + server.AddTool(&Tool{Name: "t2", InputSchema: &jsonschema.Schema{Type: "object"}}, nil) + if e := waitFor("tool"); e.id != ack.id { + t.Errorf("first tool notif id = %s, want %s", e.id, ack.id) + } + + server.AddPrompt(&Prompt{Name: "p2"}, nil) + if e := waitFor("prompt"); e.id != ack.id { + t.Errorf("first prompt notif id = %s, want %s", e.id, ack.id) + } + + server.AddTool(&Tool{Name: "t3", InputSchema: &jsonschema.Schema{Type: "object"}}, nil) + if e := waitFor("tool"); e.id != ack.id { + t.Errorf("second tool notif id = %s, want %s", e.id, ack.id) + } + server.AddPrompt(&Prompt{Name: "p3"}, nil) + if e := waitFor("prompt"); e.id != ack.id { + t.Errorf("second prompt notif id = %s, want %s", e.id, ack.id) + } + expectNoEvent(notificationDelay * 5) + + cs.Close() + time.Sleep(50 * time.Millisecond) + + server.AddTool(&Tool{Name: "t4", InputSchema: &jsonschema.Schema{Type: "object"}}, nil) + server.AddPrompt(&Prompt{Name: "p4"}, nil) + expectNoEvent(notificationDelay * 20) +} + +type subListenEvent struct { + kind string // "ack", "tool", "prompt" + id string // subscription ID from _meta, stringified for cross-encoding equality +} + +// newSubListenClient returns a client wired to push every ack and every +// list-changed notification it receives into events, tagged with the kind +// and the subscription ID extracted from _meta. +func newSubListenClient(events chan subListenEvent) *Client { + asEvent := func(kind string, raw any) subListenEvent { + return subListenEvent{kind, fmt.Sprint(raw)} + } + c := NewClient(testImpl, &ClientOptions{ + ToolListChangedHandler: func(_ context.Context, req *ToolListChangedRequest) { + events <- asEvent("tool", req.Params.Meta[MetaKeySubscriptionID]) + }, + PromptListChangedHandler: func(_ context.Context, req *PromptListChangedRequest) { + events <- asEvent("prompt", req.Params.Meta[MetaKeySubscriptionID]) + }, + }) + c.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == notificationSubscriptionsAck { + if cr, ok := req.(*ClientRequest[*SubscriptionsAcknowledgedParams]); ok && cr.Params != nil { + events <- asEvent("ack", cr.Params.Meta[MetaKeySubscriptionID]) + } + } + return next(ctx, method, req) + } + }) + return c +} + +func newSubListenServer() *Server { + s := NewServer(testImpl, nil) + AddTool(s, &Tool{Name: "t1"}, sayHi) + s.AddPrompt(&Prompt{Name: "p1"}, nil) + return s +} + +// TestSubscriptionsListen_InMemory exercises the listen flow over the +// session-shared in-memory transport (semantically equivalent to STDIO). +// Cancellation here propagates via notifications/cancelled. +func TestSubscriptionsListen_InMemory(t *testing.T) { + events := make(chan subListenEvent, 64) + server := newSubListenServer() + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + runSubscriptionsListenTest(t, newSubListenClient(events), server, ct, events) +} + +// TestSubscriptionsListen_Streamable exercises the listen flow over a +// stateless HTTP server (SEP-2575). Each listen rides its own SSE response +// stream; cs.Close() tears it down. +func TestSubscriptionsListen_Streamable(t *testing.T) { + events := make(chan subListenEvent, 64) + server := newSubListenServer() + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + runSubscriptionsListenTest(t, newSubListenClient(events), server, + &StreamableClientTransport{Endpoint: httpServer.URL}, events) +} + +// TestSubscriptionsListen_NoHandlersNoListen verifies that a new-protocol +// client without any list-changed handlers registered does not open an +// auto-listen on connect, and therefore does not receive any acknowledgment +// or downstream notifications. +func TestSubscriptionsListen_NoHandlersNoListen(t *testing.T) { + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + events := make(chan subListenEvent, 8) + server := newSubListenServer() + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + c := NewClient(testImpl, nil) + c.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == notificationSubscriptionsAck { + events <- subListenEvent{"ack", ""} + } + return next(ctx, method, req) + } + }) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + server.AddTool(&Tool{Name: "t2", InputSchema: &jsonschema.Schema{Type: "object"}}, nil) + + select { + case e := <-events: + t.Fatalf("unexpected event %q on no-handler client", e.kind) + case <-time.After(notificationDelay * 10): + } +} + +// TestSubscriptionsListen_MissingNotifications verifies that a +// subscriptions/listen request without the required "notifications" field +// is rejected with an invalid params error. +func TestSubscriptionsListen_MissingNotifications(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + server := newSubListenServer() + _, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + // Invoke the server handler directly with a nil Notifications field, + // simulating a request whose params omit the required field on the wire. + // The client-side subscriptionsListen does not await the RPC response + // (the call's lifetime is the notification stream), so we assert the + // server-side behavior at the handler level. + id, err := jsonrpc.MakeID("test-1") + if err != nil { + t.Fatalf("MakeID: %v", err) + } + reqCtx := context.WithValue(ctx, idContextKey{}, id) + req := &SubscriptionsListenRequest{ + Session: ss, + Params: &SubscriptionsListenParams{}, // Notifications is nil + } + _, err = server.subscriptionsListen(reqCtx, req) + if err == nil { + t.Fatal("expected error for missing notifications field, got nil") + } + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("expected *jsonrpc.Error, got %T: %v", err, err) + } + if jerr.Code != jsonrpc.CodeInvalidParams { + t.Errorf("error code = %d, want %d", jerr.Code, jsonrpc.CodeInvalidParams) + } +} + +// resourceSubServer builds a server that advertises resource subscriptions +// and records every Subscribe/Unsubscribe handler invocation through chans. +func resourceSubServer(t *testing.T, subCh, unsubCh chan string) *Server { + t.Helper() + s := NewServer(testImpl, &ServerOptions{ + SubscribeHandler: func(_ context.Context, r *SubscribeRequest) error { + subCh <- r.Params.URI + return nil + }, + UnsubscribeHandler: func(_ context.Context, r *UnsubscribeRequest) error { + unsubCh <- r.Params.URI + return nil + }, + }) + s.AddResource(&Resource{Name: "r1", URI: "file:///r1"}, nil) + return s +} + +// resourceSubEvent is one delivered notifications/resources/updated. +type resourceSubEvent struct { + uri string + id string // _meta subscription ID, stringified +} + +// TestResourceSubscriptionsSEP2575_Streamable verifies the Subscribe -> +// ResourceUpdated path on a stateless Streamable HTTP server. +// +// Caveat: per-subscription Unsubscribe is intentionally NOT verified here. +// In stateless Streamable HTTP mode the subscriptions/listen handler blocks +// on its request context, and neither the HTTP POST disconnect nor the +// separate notifications/cancelled POST currently propagates to that +// handler's context. The handler only unwinds when the server next attempts +// a write to the (now-dead) SSE stream and the writeErr branch in the +// jsonrpc2 layer cancels the in-flight request. To keep the test +// hermetic we therefore trigger a write at the end by adding a resource, +// which fires notifications/resources/list_changed on the auto-listen path +// (if any) and on the per-URI listen, causing the listen handler to unwind. +// The spec-correct fix is to plumb the POST's request context down to the +// subscriptionsListen handler so HTTP disconnect is observed directly; this +// is tracked separately. +func TestResourceSubscriptions_Streamable(t *testing.T) { + + subCh := make(chan string, 8) + unsubCh := make(chan string, 8) + events := make(chan resourceSubEvent, 16) + + server := resourceSubServer(t, subCh, unsubCh) + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + c := NewClient(testImpl, &ClientOptions{ + ResourceUpdatedHandler: func(_ context.Context, req *ResourceUpdatedNotificationRequest) { + id := "" + if req.Params != nil && req.Params.Meta != nil { + id = fmt.Sprint(req.Params.Meta[MetaKeySubscriptionID]) + } + events <- resourceSubEvent{uri: req.Params.URI, id: id} + }, + }) + cs, err := c.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, + &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + + if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("subscribe r1: %v", err) + } + select { + case got := <-subCh: + if got != "file:///r1" { + t.Fatalf("got URI %q, want %q", got, "file:///r1") + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for SubscribeHandler") + } + + server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"}) + select { + case e := <-events: + if e.uri != "file:///r1" { + t.Fatalf("got URI %q, want %q", e.uri, "file:///r1") + } + if e.id == "" || e.id == "" { + t.Fatalf("missing subscription ID on update (got %q)", e.id) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for resource update") + } + + // See test header comment for the explanation of this teardown ritual: + // close the client, then drop server-side TCP, then drive a write that + // will fail (any extra ResourceUpdated for our URI), to unblock the + // in-flight listen handler so httpServer.Close can return. + _ = cs.Close() + httpServer.CloseClientConnections() + server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"}) + httpServer.Close() +} + +// TestResourceSubscriptions_InMemory mirrors TestResourceSubscriptions_Streamable +// over an in-memory (stdio-equivalent) transport: the per-URI Subscribe path +// uses notifications/cancelled rather than HTTP disconnect for teardown. +func TestResourceSubscriptions_InMemory(t *testing.T) { + + subCh := make(chan string, 8) + unsubCh := make(chan string, 8) + events := make(chan resourceSubEvent, 16) + + server := resourceSubServer(t, subCh, unsubCh) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + c := NewClient(testImpl, &ClientOptions{ + ResourceUpdatedHandler: func(_ context.Context, req *ResourceUpdatedNotificationRequest) { + id := "" + if req.Params != nil && req.Params.Meta != nil { + id = fmt.Sprint(req.Params.Meta[MetaKeySubscriptionID]) + } + events <- resourceSubEvent{uri: req.Params.URI, id: id} + }, + }) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("subscribe r1: %v", err) + } + waitURI := func(ch chan string, want string) { + t.Helper() + select { + case got := <-ch: + if got != want { + t.Fatalf("got URI %q, want %q", got, want) + } + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for URI %q", want) + } + } + waitURI(subCh, "file:///r1") + + server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"}) + select { + case e := <-events: + if e.uri != "file:///r1" { + t.Fatalf("got URI %q, want %q", e.uri, "file:///r1") + } + if e.id == "" || e.id == "" { + t.Fatalf("missing subscription ID on update (got %q)", e.id) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for resource update") + } + + // On stdio/in-memory, Unsubscribe sends notifications/cancelled which + // reaches the server preempter, cancels the listen handler ctx, and + // triggers the UnsubscribeHandler. + if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("unsubscribe r1: %v", err) + } + waitURI(unsubCh, "file:///r1") + + // Updates for an unsubscribed URI MUST NOT be delivered. Give the server + // a moment to process the cancellation first. + time.Sleep(50 * time.Millisecond) + server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"}) + select { + case e := <-events: + t.Fatalf("unexpected resource update after unsubscribe: %q (id=%s)", e.uri, e.id) + case <-time.After(notificationDelay * 10): + } +} + +// TestResourceSubscriptions_Subscribe_Idempotent verifies that calling +// Subscribe twice for the same URI in the same session is a no-op for the +// second call: it returns nil without invoking SubscribeHandler again and +// without opening a second listen stream. +func TestResourceSubscriptions_Subscribe_Idempotent(t *testing.T) { + + subCh := make(chan string, 8) + unsubCh := make(chan string, 8) + + server := resourceSubServer(t, subCh, unsubCh) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + c := NewClient(testImpl, &ClientOptions{ + ResourceUpdatedHandler: func(context.Context, *ResourceUpdatedNotificationRequest) {}, + }) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("first subscribe: %v", err) + } + select { + case got := <-subCh: + if got != "file:///r1" { + t.Fatalf("got URI %q, want %q", got, "file:///r1") + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for first SubscribeHandler") + } + + // Second Subscribe for the same URI returns nil and does NOT fire + // SubscribeHandler again. + if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("second subscribe should be no-op, got error: %v", err) + } + select { + case got := <-subCh: + t.Fatalf("duplicate Subscribe should not re-invoke SubscribeHandler (got %q)", got) + case <-time.After(notificationDelay * 10): + } + + // Subsequent Unsubscribe still works (verifies the URI is tracked + // correctly even though the second Subscribe was a no-op). + if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("unsubscribe: %v", err) + } + select { + case <-unsubCh: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for UnsubscribeHandler") + } +} + +// TestResourceSubscriptions_MultipleURIs verifies that two concurrent +// Subscribe calls on the same session each open their own independent listen +// stream with a distinct subscription ID. Unsubscribing one does not affect +// the other. +func TestResourceSubscriptions_MultipleURIs(t *testing.T) { + + subCh := make(chan string, 8) + unsubCh := make(chan string, 8) + events := make(chan resourceSubEvent, 16) + + server := resourceSubServer(t, subCh, unsubCh) + server.AddResource(&Resource{Name: "r2", URI: "file:///r2"}, nil) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + c := NewClient(testImpl, &ClientOptions{ + ResourceUpdatedHandler: func(_ context.Context, req *ResourceUpdatedNotificationRequest) { + id := "" + if req.Params != nil && req.Params.Meta != nil { + id = fmt.Sprint(req.Params.Meta[MetaKeySubscriptionID]) + } + events <- resourceSubEvent{uri: req.Params.URI, id: id} + }, + }) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("subscribe r1: %v", err) + } + if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r2"}); err != nil { + t.Fatalf("subscribe r2: %v", err) + } + + // Each Subscribe MUST fire its own SubscribeHandler invocation. + gotURIs := map[string]bool{} + for range 2 { + select { + case got := <-subCh: + gotURIs[got] = true + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for SubscribeHandler") + } + } + if !gotURIs["file:///r1"] || !gotURIs["file:///r2"] { + t.Fatalf("missing SubscribeHandler invocation; got %v", gotURIs) + } + + // Each update delivers exactly one event tagged with that URI's distinct + // subscription ID. + server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"}) + ev1 := <-events + server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r2"}) + ev2 := <-events + if ev1.uri != "file:///r1" || ev2.uri != "file:///r2" { + t.Fatalf("got URIs %q and %q, want r1 and r2", ev1.uri, ev2.uri) + } + if ev1.id == "" || ev2.id == "" { + t.Fatalf("missing subscription IDs: r1=%q r2=%q", ev1.id, ev2.id) + } + if ev1.id == ev2.id { + t.Fatalf("r1 and r2 should have distinct subscription IDs, both = %q", ev1.id) + } + + // Unsubscribe r1 only. r2 keeps working. + if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("unsubscribe r1: %v", err) + } + select { + case got := <-unsubCh: + if got != "file:///r1" { + t.Fatalf("got unsubscribe URI %q, want %q", got, "file:///r1") + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for UnsubscribeHandler") + } + time.Sleep(50 * time.Millisecond) // let server-side cancellation settle + + server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"}) + server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r2"}) + + // Only the r2 update should arrive. + select { + case e := <-events: + if e.uri != "file:///r2" || e.id != ev2.id { + t.Fatalf("post-unsubscribe got %q (id=%s), want r2 (id=%s)", e.uri, e.id, ev2.id) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for r2 update after r1 unsubscribe") + } + select { + case e := <-events: + t.Fatalf("unexpected event after r1 unsubscribe: %q (id=%s)", e.uri, e.id) + case <-time.After(notificationDelay * 10): + } + + // Explicit Unsubscribe r2 to verify it still works independently. + if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: "file:///r2"}); err != nil { + t.Fatalf("unsubscribe r2: %v", err) + } + select { + case <-unsubCh: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for r2 UnsubscribeHandler") + } +} + +// TestSubscriptionsListen_MultipleSessions verifies that two concurrent +// client sessions on the same server are isolated: a list-changed +// notification is fanned out to BOTH sessions, each with its own subscription +// ID; closing one session does not affect deliveries to the other. +func TestSubscriptionsListen_MultipleSessions(t *testing.T) { + + server := newSubListenServer() + + // Open two clients on the same server. + open := func(t *testing.T) (*ClientSession, chan subListenEvent, *ServerSession) { + t.Helper() + events := make(chan subListenEvent, 16) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + c := newSubListenClient(events) + cs, err := c.Connect(context.Background(), ct, + &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + return cs, events, ss + } + csA, evA, ssA := open(t) + defer ssA.Close() + csB, evB, ssB := open(t) + defer ssB.Close() + + waitFor := func(ch chan subListenEvent, kind string) subListenEvent { + t.Helper() + select { + case e := <-ch: + if e.kind != kind { + t.Fatalf("got event %q, want %q", e.kind, kind) + } + return e + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for %q event", kind) + return subListenEvent{} + } + } + ackA := waitFor(evA, "ack") + ackB := waitFor(evB, "ack") + // Request IDs are per-connection, so both sessions may legitimately use + // the same ID number; we only require that each session's own + // notifications carry its own ack ID. + + // A single change fans out to BOTH sessions, each tagged with that + // session's own ack ID. + server.AddTool(&Tool{Name: "t2", InputSchema: &jsonschema.Schema{Type: "object"}}, nil) + gotA := waitFor(evA, "tool") + gotB := waitFor(evB, "tool") + if gotA.id != ackA.id { + t.Errorf("session A: tool notif id=%s, want %s", gotA.id, ackA.id) + } + if gotB.id != ackB.id { + t.Errorf("session B: tool notif id=%s, want %s", gotB.id, ackB.id) + } + + // Close A; B's subscription must keep delivering. + csA.Close() + time.Sleep(50 * time.Millisecond) + server.AddTool(&Tool{Name: "t3", InputSchema: &jsonschema.Schema{Type: "object"}}, nil) + gotB2 := waitFor(evB, "tool") + if gotB2.id != ackB.id { + t.Errorf("session B after A closed: tool notif id=%s, want %s", gotB2.id, ackB.id) + } + select { + case e := <-evA: + t.Fatalf("session A unexpected event after Close: %q", e.kind) + case <-time.After(notificationDelay * 5): + } + + csB.Close() +} + +// TestSubscriptionsListen_ResourceListChanged covers the resources/list_changed +// auto-listen branch (the other two list-changed types are covered by +// runSubscriptionsListenTest, but resources is not). +func TestSubscriptionsListen_ResourceListChanged(t *testing.T) { + events := make(chan subListenEvent, 16) + + server := NewServer(testImpl, nil) + server.AddResource(&Resource{Name: "r1", URI: "file:///r1"}, nil) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + asEvent := func(kind string, raw any) subListenEvent { + return subListenEvent{kind, fmt.Sprint(raw)} + } + c := NewClient(testImpl, &ClientOptions{ + ResourceListChangedHandler: func(_ context.Context, req *ResourceListChangedRequest) { + id := any(nil) + if req.Params != nil && req.Params.Meta != nil { + id = req.Params.Meta[MetaKeySubscriptionID] + } + events <- asEvent("resource", id) + }, + }) + c.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == notificationSubscriptionsAck { + if cr, ok := req.(*ClientRequest[*SubscriptionsAcknowledgedParams]); ok && cr.Params != nil { + events <- asEvent("ack", cr.Params.Meta[MetaKeySubscriptionID]) + } + } + return next(ctx, method, req) + } + }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + + waitFor := func(kind string) subListenEvent { + t.Helper() + select { + case e := <-events: + if e.kind != kind { + t.Fatalf("got event %q, want %q", e.kind, kind) + } + return e + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for %q", kind) + return subListenEvent{} + } + } + ack := waitFor("ack") + if ack.id == "" { + t.Fatal("acknowledgment missing subscription ID") + } + server.AddResource(&Resource{Name: "r2", URI: "file:///r2"}, nil) + got := waitFor("resource") + if got.id != ack.id { + t.Errorf("resource notif id=%s, want %s", got.id, ack.id) + } + cs.Close() +} + +// TestSubscriptionsListen_DisconnectScrubsMaps verifies that closing a +// session removes its entries from the server's three per-type subscription +// maps via Server.disconnect. +func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) { + events := make(chan subListenEvent, 16) + server := newSubListenServer() + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + c := newSubListenClient(events) + cs, err := c.Connect(context.Background(), ct, + &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + + // Wait for the auto-listen to actually register on the server side. + select { + case e := <-events: + if e.kind != "ack" { + t.Fatalf("got %q, want ack", e.kind) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for ack") + } + + // Server should now have this session in tools+prompts maps (the fixture + // client opts in to both via newSubListenClient). + server.mu.Lock() + if _, ok := server.toolChangeSubscriptions[ss]; !ok { + server.mu.Unlock() + t.Fatal("session missing from toolChangeSubscriptions before close") + } + if _, ok := server.promptChangeSubscriptions[ss]; !ok { + server.mu.Unlock() + t.Fatal("session missing from promptChangeSubscriptions before close") + } + server.mu.Unlock() + + cs.Close() + ss.Close() + // Closures complete asynchronously on the server side. + deadline := time.Now().Add(5 * time.Second) + for { + server.mu.Lock() + _, inTool := server.toolChangeSubscriptions[ss] + _, inPrompt := server.promptChangeSubscriptions[ss] + _, inResource := server.resourceChangeSubscriptions[ss] + server.mu.Unlock() + if !inTool && !inPrompt && !inResource { + return + } + if time.Now().After(deadline) { + t.Fatalf("subscription maps not scrubbed after Close: tool=%v prompt=%v resource=%v", + inTool, inPrompt, inResource) + } + time.Sleep(10 * time.Millisecond) + } +} + +// newSubListenSubscribeServer is newSubListenServer with a resource and the +// handlers that make the server advertise resources.subscribe, so that a +// listen can carry a resource subscription. +func newSubListenSubscribeServer() *Server { + s := NewServer(testImpl, &ServerOptions{ + SubscribeHandler: func(context.Context, *SubscribeRequest) error { return nil }, + UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error { return nil }, + }) + AddTool(s, &Tool{Name: "t1"}, sayHi) + s.AddPrompt(&Prompt{Name: "p1"}, nil) + s.AddResource(&Resource{Name: "r1", URI: "file:///r1"}, nil) + return s +} + +func waitSubListenEvent(t *testing.T, events chan subListenEvent, kind string) subListenEvent { + t.Helper() + select { + case e := <-events: + if e.kind != kind { + t.Fatalf("got event %q, want %q", e.kind, kind) + } + return e + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for %q", kind) + return subListenEvent{} + } +} + +// TestSubscriptionsListen_TeardownKeepsOtherListens verifies that unwinding +// one listen leaves the other listens on the same session registered. +// Unsubscribe tears down the listen that Subscribe opened for the resource; +// the auto-listen opened by Connect must keep delivering list-changed +// notifications. +func TestSubscriptionsListen_TeardownKeepsOtherListens(t *testing.T) { + events := make(chan subListenEvent, 16) + server := newSubListenSubscribeServer() + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cs, err := newSubListenClient(events).Connect(ctx, ct, + &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + autoListen := waitSubListenEvent(t, events, "ack") + + if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("Subscribe: %v", err) + } + resourceListen := waitSubListenEvent(t, events, "ack") + if resourceListen.id == autoListen.id { + t.Fatalf("Subscribe reused subscription ID %s", autoListen.id) + } + + if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("Unsubscribe: %v", err) + } + // Wait for the server to unwind that listen, observed through a registry + // the auto-listen does not appear in. + waitUntil(t, 5*time.Second, "resource listen to unwind", func() bool { + server.mu.Lock() + defer server.mu.Unlock() + _, ok := server.resourceSubscriptions["file:///r1"] + return !ok + }) + + AddTool(server, &Tool{Name: "t2"}, sayHi) + if got := waitSubListenEvent(t, events, "tool"); got.id != autoListen.id { + t.Errorf("tool notification id = %s, want auto-listen %s", got.id, autoListen.id) + } +} + +// TestSubscriptionsListen_TeardownRetiresOwnRegistration verifies that a +// listen still retires what it registered, so that per-listen tracking does +// not turn into a leak. +func TestSubscriptionsListen_TeardownRetiresOwnRegistration(t *testing.T) { + events := make(chan subListenEvent, 8) + server := newSubListenServer() + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + // A client with no list-changed handlers does not auto-listen, so the + // listen opened below is the session's only one. + c := NewClient(testImpl, nil) + c.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == notificationSubscriptionsAck { + events <- subListenEvent{"ack", ""} + } + return next(ctx, method, req) + } + }) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + listenCtx, cancelListen := context.WithCancel(context.Background()) + defer cancelListen() + if err := cs.subscriptionsListen(listenCtx, &SubscriptionsListenParams{ + Notifications: &NotificationSubscriptions{ToolsListChanged: true, PromptsListChanged: true}, + }); err != nil { + t.Fatalf("listen: %v", err) + } + waitSubListenEvent(t, events, "ack") + + server.mu.Lock() + _, inTool := server.toolChangeSubscriptions[ss] + _, inPrompt := server.promptChangeSubscriptions[ss] + server.mu.Unlock() + if !inTool || !inPrompt { + t.Fatal("listen not registered") + } + + cancelListen() + waitUntil(t, 5*time.Second, "registrations to be retired", func() bool { + server.mu.Lock() + defer server.mu.Unlock() + _, inTool := server.toolChangeSubscriptions[ss] + _, inPrompt := server.promptChangeSubscriptions[ss] + return !inTool && !inPrompt + }) +} + +// waitUntil polls cond until it reports true, failing the test after timeout. +func waitUntil(t *testing.T, timeout time.Duration, what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for !cond() { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %s", what) + } + time.Sleep(10 * time.Millisecond) + } +} + +// TestSubscriptionsListen_RespectsServerCapabilities verifies that during +// Connect the client only opens a SEP-2575 subscriptions/listen stream for the +// change notifications the server advertised during capability negotiation. +func TestSubscriptionsListen_RespectsServerCapabilities(t *testing.T) { + for _, tc := range []struct { + name string + caps *ServerCapabilities + wantListen bool + }{ + { + name: "advertised", + caps: &ServerCapabilities{Tools: &ToolCapabilities{ListChanged: true}}, + wantListen: true, + }, + { + name: "not advertised", + caps: &ServerCapabilities{Tools: &ToolCapabilities{}}, + wantListen: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{Capabilities: tc.caps}) + AddTool(server, &Tool{Name: "t1"}, sayHi) + + listenSeen := make(chan struct{}, 1) + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodSubscriptionsListen { + select { + case listenSeen <- struct{}{}: + default: + } + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + c := NewClient(testImpl, &ClientOptions{ + ToolListChangedHandler: func(context.Context, *ToolListChangedRequest) {}, + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + if _, err := cs.ListTools(ctx, nil); err != nil { + t.Fatalf("ListTools: %v", err) + } + + if tc.wantListen { + select { + case <-listenSeen: + case <-time.After(5 * time.Second): + t.Fatal("expected subscriptions/listen, but none was sent") + } + return + } + + select { + case <-listenSeen: + t.Fatal("client sent subscriptions/listen for an unadvertised capability") + case <-time.After(notificationDelay * 20): + } + }) + } +} + +// TestServerSessionCloseWithActiveListen is a regression test for +// modelcontextprotocol/go-sdk#1160: ServerSession.Close must not deadlock +// when the client has an active subscriptions/listen stream. Previously, +// the server-side handler parked on ctx.Done and Close waited forever for +// the in-flight request to drain. +// +// The client's auto-listen (triggered by registering a list-changed handler) +// opens the stream on Connect — no explicit Subscribe is needed. The server +// must expose the corresponding list-changed capability, which happens +// automatically when at least one tool/prompt/resource is registered. +func TestServerSessionCloseWithActiveListen(t *testing.T) { + ctx := context.Background() + s := NewServer(&Implementation{Name: "s", Version: "0"}, nil) + AddTool(s, &Tool{Name: "t"}, sayHi) + + ct, st := NewInMemoryTransports() + if _, err := s.Connect(ctx, st, nil); err != nil { + t.Fatalf("server connect: %v", err) + } + c := NewClient(&Implementation{Name: "c", Version: "0"}, &ClientOptions{ + ToolListChangedHandler: func(context.Context, *ToolListChangedRequest) {}, + }) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + // Give the auto-listen request time to reach the server-side handler. + time.Sleep(20 * time.Millisecond) + + var ss *ServerSession + for x := range s.Sessions() { + ss = x + break + } + if ss == nil { + t.Fatal("no server session found") + } + + // Sanity check: the auto-listen must actually have registered an entry in + // listenIDs, otherwise the test below would trivially pass without + // exercising the fix. + if n := listenIDsCount(ss); n == 0 { + t.Fatal("expected auto-listen to register a request ID on the server session") + } + + done := make(chan error, 1) + go func() { done <- ss.Close() }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("ServerSession.Close deadlocked with an active subscriptions/listen") + } +} + +// listenIDsCount reports how many request IDs are currently recorded in the +// session's listenIDs set. +func listenIDsCount(ss *ServerSession) int { + ss.mu.Lock() + defer ss.mu.Unlock() + return len(ss.listenIDs) +} + +// A completed listen stream must not leave a stale entry in the session. +func TestListenPrunedAfterSingleCompletion(t *testing.T) { + cs, ss, cleanup := basicClientServerConnection(t, nil, nil, func(s *Server) { + AddTool(s, &Tool{Name: "t"}, sayHi) + }) + _ = cleanup + + ctx := context.Background() + lctx, cancel := context.WithCancel(ctx) + go cs.subscriptionsListen(lctx, &SubscriptionsListenParams{ + Notifications: &NotificationSubscriptions{ToolsListChanged: true}, + }) + time.Sleep(30 * time.Millisecond) + cancel() // peer cancels: server handler returns + time.Sleep(30 * time.Millisecond) + + if n := listenIDsCount(ss); n != 0 { + t.Fatalf("completed listen left %d stale entry/ies", n) + } +} + +// Completed listens must not accumulate: the slice grows without bound today. +func TestListenIDsDoNotAccumulate(t *testing.T) { + cs, ss, cleanup := basicClientServerConnection(t, nil, nil, func(s *Server) { + AddTool(s, &Tool{Name: "t"}, sayHi) + }) + _ = cleanup + + ctx := context.Background() + + const cycles = 15 + for range cycles { + lctx, cancel := context.WithCancel(ctx) + go cs.subscriptionsListen(lctx, &SubscriptionsListenParams{ + Notifications: &NotificationSubscriptions{ToolsListChanged: true}, + }) + time.Sleep(15 * time.Millisecond) + cancel() + time.Sleep(10 * time.Millisecond) + } + time.Sleep(30 * time.Millisecond) + + if n := listenIDsCount(ss); n != 0 { + t.Fatalf("listenIDs grew unbounded: %d stale entries after %d completed listens", n, cycles) + } +} + +// The leak is reachable through the public Subscribe/Unsubscribe API: every +// subscription opens a listen stream and unsubscribing completes it, so a real +// client cycling subscriptions on a long-lived session leaks one entry per cycle. +func TestListenIDsLeakViaPublicSubscribeUnsubscribe(t *testing.T) { + cs, ss, cleanup := basicClientServerConnection(t, nil, nil, func(s *Server) { + AddTool(s, &Tool{Name: "t"}, sayHi) + }) + _ = cleanup + + ctx := context.Background() + + const cycles = 3 + for i := range cycles { + uri := fmt.Sprintf("resource://cycle-%d", i) + if err := cs.Subscribe(ctx, &SubscribeParams{URI: uri}); err != nil { + t.Fatalf("Subscribe %d: %v", i, err) + } + time.Sleep(15 * time.Millisecond) + if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: uri}); err != nil { + t.Fatalf("Unsubscribe %d: %v", i, err) + } + } + time.Sleep(30 * time.Millisecond) + + if n := listenIDsCount(ss); n != 0 { + t.Fatalf("public Subscribe/Unsubscribe leaked %d stale listenIDs after %d cycles", n, cycles) + } +} + +func TestCustomMethods(t *testing.T) { + type searchParams struct { + ParamsBase + Query string `json:"query"` + Limit int `json:"limit,omitempty"` + } + + type searchResult struct { + ResultBase + Hits []string `json:"hits"` + Total int `json:"total"` + } + + callCustom := func(ctx context.Context, conn *jsonrpc2.Connection, method string, params, result any) error { + return conn.Call(ctx, method, params).Await(ctx, result) + } + + ctx := context.Background() + s := NewServer(testImpl, nil) + + if err := AddReceivingCustomMethod(s, "acme/search", func(ctx context.Context, ss *ServerSession, params *searchParams) (*searchResult, error) { + hits := []string{"result for " + params.Query} + return &searchResult{ + Hits: hits, + Total: len(hits), + }, nil + }); err != nil { + t.Fatal(err) + } + + ct, st := NewInMemoryTransports() + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + + c := NewClient(testImpl, nil) + if err := AddSendingCustomMethod[*searchParams, *searchResult](c, "acme/search"); err != nil { + t.Fatal(err) + } + + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cs.Close() }) + + // Test raw JSON-RPC call. + var result1 searchResult + if err := callCustom(ctx, cs.getConn(), "acme/search", &searchParams{Query: "hello", Limit: 10}, &result1); err != nil { + t.Fatal(err) + } + if len(result1.Hits) != 1 || result1.Hits[0] != "result for hello" { + t.Errorf("raw call: unexpected hits: %v", result1.Hits) + } + if result1.Total != 1 { + t.Errorf("raw call: unexpected total: %d", result1.Total) + } + + // Test the typed CallCustomMethod helper. + result2, err := CallCustomMethod[*searchParams, *searchResult]( + ctx, cs, "acme/search", &searchParams{Query: "world"}) + if err != nil { + t.Fatal(err) + } + if len(result2.Hits) != 1 || result2.Hits[0] != "result for world" { + t.Errorf("CallCustomMethod: unexpected hits: %v", result2.Hits) + } + if result2.Total != 1 { + t.Errorf("CallCustomMethod: unexpected total: %d", result2.Total) + } + + // CallCustomMethod must reject methods that were never registered. + if _, err := CallCustomMethod[*searchParams, *searchResult]( + ctx, cs, "acme/unregistered", &searchParams{Query: "x"}); err == nil { + t.Error("CallCustomMethod: expected error for unregistered method, got nil") + } +} + +func TestAddCustomMethodRejectsStandardMethods(t *testing.T) { + type params struct{ ParamsBase } + type result struct{ ResultBase } + + t.Run("server", func(t *testing.T) { + s := NewServer(testImpl, nil) + err := AddReceivingCustomMethod(s, "tools/call", + func(ctx context.Context, ss *ServerSession, p *params) (*result, error) { + return &result{}, nil + }) + if err == nil { + t.Fatal("AddReceivingCustomMethod: expected error when shadowing a standard method, got nil") + } + }) + + t.Run("client", func(t *testing.T) { + c := NewClient(testImpl, nil) + if err := AddSendingCustomMethod[*params, *result](c, "tools/call"); err == nil { + t.Fatal("AddSendingCustomMethod: expected error when shadowing a standard method, got nil") + } + }) +} + +// TestCallCustomMethodTypedNilParams exercises the typed-nil params path. +// User param types embed ParamsBase, so the inherited isNil forwarder would +// dereference a typed-nil outer if injectRequestMeta were called with it. +// CallCustomMethod must allocate a fresh value before the meta-injection step. +func TestCallCustomMethodTypedNilParams(t *testing.T) { + type pingParams struct{ ParamsBase } + type pingResult struct{ ResultBase } + + ctx := context.Background() + s := NewServer(testImpl, nil) + if err := AddReceivingCustomMethod(s, "acme/ping", + func(ctx context.Context, ss *ServerSession, p *pingParams) (*pingResult, error) { + return &pingResult{}, nil + }); err != nil { + t.Fatal(err) + } + ct, st := NewInMemoryTransports() + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + + c := NewClient(testImpl, nil) + if err := AddSendingCustomMethod[*pingParams, *pingResult](c, "acme/ping"); err != nil { + t.Fatal(err) + } + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cs.Close() }) + + var typedNil *pingParams + if _, err := CallCustomMethod[*pingParams, *pingResult](ctx, cs, "acme/ping", typedNil); err != nil { + t.Fatalf("CallCustomMethod with typed-nil params: %v", err) + } +} + +func TestServerLogLevelDoesNotLeakBetweenNewProtocolRequests(t *testing.T) { + ctx := context.Background() + s := NewServer(testImpl, nil) + _, st := NewInMemoryTransports() + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + + logged := make(chan LoggingLevel, 1) + s.AddSendingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == notificationLoggingMessage { + logged <- req.GetParams().(*LoggingMessageParams).Level + return nil, nil + } + return next(ctx, method, req) + } + }) + + started := make(chan struct{}) + release := make(chan struct{}) + AddTool(s, &Tool{Name: "blocked-log"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + close(started) + <-release + if err := req.Session.Log(ctx, &LoggingMessageParams{Level: "warning", Data: "request log"}); err != nil { + return nil, nil, err + } + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + AddTool(s, &Tool{Name: "noop"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + + withLogLevel := &CallToolParams{Name: "blocked-log"} + withLogLevel.SetMeta(newProtocolMeta("warning")) + errc := make(chan error, 1) + go func() { + _, err := ss.handle(ctx, req(1, methodCallTool, withLogLevel)) + errc <- err + }() + + <-started + withoutLogLevel := &CallToolParams{Name: "noop"} + withoutLogLevel.SetMeta(newProtocolMeta("")) + if _, err := ss.handle(ctx, req(2, methodCallTool, withoutLogLevel)); err != nil { + t.Fatal(err) + } + close(release) + if err := <-errc; err != nil { + t.Fatal(err) + } + + select { + case got := <-logged: + if got != "warning" { + t.Fatalf("logged level = %q, want warning", got) + } + default: + t.Fatal("request-scoped warning log was suppressed after another request cleared session log level") + } +} + +func newProtocolMeta(logLevel LoggingLevel) Meta { + m := Meta{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: testImpl, + MetaKeyClientCapabilities: (&ClientCapabilities{}).toV2(), + } + if logLevel != "" { + m[MetaKeyLogLevel] = logLevel + } + return m +} diff --git a/mcp/mrtr.go b/mcp/mrtr.go new file mode 100644 index 0000000..fdf98ec --- /dev/null +++ b/mcp/mrtr.go @@ -0,0 +1,299 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "fmt" + "log/slog" + "sync" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "golang.org/x/sync/errgroup" +) + +const maxMultiRoundTripRetries = 10 +const maxLoadSheddingMultiRoundTripRetries = 3 + +// MultiRoundTripOptions configures the client-side multi round-trip request (SEP-2322) +// middleware. The middleware is enabled by default and automatically fulfills input +// requests from the server by invoking the appropriate client handlers and +// retrying the original call. +type MultiRoundTripOptions struct { + // Disabled prevents the automatic multi-round-tirp middleware from being installed. + // When true, the client returns input-required results directly and callers must + // handle the retry loop themselves using [CallToolResult.NeedsInput], + // [GetPromptResult.NeedsInput], or [ReadResourceResult.NeedsInput]. + Disabled bool +} + +type multiRoundTripResponse interface { + setResultType(resultType) + inputRequests() map[string]InputRequest + requestState() string + hasContent() bool +} + +// validateMultiRoundTripResult rejects a result that carries both content and +// input requests. [annotateResultType] labels the result later, in +// [ServerSession.handle], so this function does not set resultType. +func validateMultiRoundTripResult(logger *slog.Logger, res multiRoundTripResponse) error { + if res == nil { + return nil + } + if res.inputRequests() != nil && res.hasContent() { + logger.Warn("handler returned both content and inputRequests") + return &jsonrpc.Error{ + Code: jsonrpc.CodeInternalError, + Message: "server bug: result has both content and inputRequests", + } + } + return nil +} + +func clientSupportsMultiRoundTrip(ss *ServerSession) bool { + protocolVersion := latestProtocolVersion + if iparams := ss.InitializeParams(); iparams != nil { + protocolVersion = iparams.ProtocolVersion + } + return protocolVersion >= protocolVersion20260728 +} + +func clientMultiRoundTripMiddleware() Middleware { + return func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method != methodCallTool && method != methodGetPrompt && method != methodReadResource { + return next(ctx, method, req) + } + + loadSheddingFailures := 0 + for retries := 1; ; retries++ { + res, err := next(ctx, method, req) + if err != nil { + return res, err + } + mrtrResult, ok := res.(multiRoundTripResponse) + if !ok { + return res, nil + } + reqMap := mrtrResult.inputRequests() + if reqMap == nil { + return res, nil + } + if len(reqMap) == 0 { + loadSheddingFailures++ + } + if loadSheddingFailures >= maxLoadSheddingMultiRoundTripRetries { + return nil, fmt.Errorf("multi-round-trip: exceeded maximum load-shedding retries (%d)", maxLoadSheddingMultiRoundTripRetries) + } + if retries >= maxMultiRoundTripRetries { + return nil, fmt.Errorf("multi-round-trip: exceeded maximum retries (%d)", maxMultiRoundTripRetries) + } + cs, ok := req.GetSession().(*ClientSession) + if !ok { + return res, nil + } + responses, err := fulfillInputRequests(ctx, cs, reqMap) + if err != nil { + return nil, err + } + req, err = setMultiRoundTripRetryParams(method, req, responses, mrtrResult.requestState()) + if err != nil { + return nil, err + } + } + } + } +} + +// serverMultiRoundTripMiddleware is a receiving middleware for servers that transparently +// handles multi-round-trip for clients on older protocol versions. When a handler returns +// InputRequests and the client does not support multi-round-trip, the middleware fulfills +// the requests by calling the client directly and reinvokes the handler once with the responses. +func serverMultiRoundTripMiddleware() Middleware { + return func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method != methodCallTool && method != methodGetPrompt && method != methodReadResource { + return next(ctx, method, req) + } + + ss, ok := req.GetSession().(*ServerSession) + if !ok { + return next(ctx, method, req) + } + if clientSupportsMultiRoundTrip(ss) { + return next(ctx, method, req) + } + + res, err := next(ctx, method, req) + if err != nil { + return res, err + } + mrtrResult, ok := res.(multiRoundTripResponse) + if !ok { + return res, nil + } + reqMap := mrtrResult.inputRequests() + if reqMap == nil { + return res, nil + } + if len(reqMap) == 0 { + return nil, fmt.Errorf("the server is busy, retry later") + } + responses, err := fulfillServerInputRequests(ctx, ss, reqMap) + if err != nil { + return nil, err + } + req, err = setMultiRoundTripRetryParams(method, req, responses, mrtrResult.requestState()) + if err != nil { + return nil, err + } + return next(ctx, method, req) + } + } +} + +func fulfillServerInputRequests(ctx context.Context, ss *ServerSession, requests InputRequestMap) (InputResponseMap, error) { + g, ctx := errgroup.WithContext(ctx) + var mu sync.Mutex + responses := make(InputResponseMap, len(requests)) + for id, ir := range requests { + g.Go(func() error { + resp, err := fulfillServerInputRequest(ctx, ss, ir) + if err != nil { + return fmt.Errorf("fulfilling input request %q: %w", id, err) + } + mu.Lock() + responses[id] = resp + mu.Unlock() + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, fmt.Errorf("multi-round-trip: %w", err) + } + return responses, nil +} + +func fulfillServerInputRequest(ctx context.Context, ss *ServerSession, ir InputRequest) (InputResponse, error) { + switch p := ir.(type) { + case *ElicitParams: + return ss.Elicit(ctx, p) + case *CreateMessageParams: + return ss.CreateMessageWithTools(ctx, createMessageParamsToWithTools(p)) + case *CreateMessageWithToolsParams: + return ss.CreateMessageWithTools(ctx, p) + case *ListRootsParams: + return ss.ListRoots(ctx, p) + default: + return nil, fmt.Errorf("unknown input request type: %T", ir) + } +} + +func createMessageParamsToWithTools(p *CreateMessageParams) *CreateMessageWithToolsParams { + var msgs []*SamplingMessageV2 + for _, m := range p.Messages { + msgs = append(msgs, &SamplingMessageV2{Content: []Content{m.Content}, Role: m.Role}) + } + return &CreateMessageWithToolsParams{ + Meta: p.Meta, + IncludeContext: p.IncludeContext, + MaxTokens: p.MaxTokens, + Messages: msgs, + Metadata: p.Metadata, + ModelPreferences: p.ModelPreferences, + StopSequences: p.StopSequences, + SystemPrompt: p.SystemPrompt, + Temperature: p.Temperature, + } +} + +// setMultiRoundTripRetryParams returns the request for the next round of a +// multi-round-trip retry: a request whose params are a shallow copy of the +// original, carrying the fulfilled responses and the request state to echo. +// The original params are never mutated: on the client side they are +// caller-owned, and a params struct reused across calls must not carry one +// call's inputResponses and requestState into the next, where a server +// gating on them (for example an elicitation confirmation) would treat the +// new call as already answered. +// +// An unrecognized params type is an error. It cannot occur on the server +// side, but client sending middleware runs before the retry loop and may +// substitute its own params. +func setMultiRoundTripRetryParams(method string, req Request, responses InputResponseMap, state string) (Request, error) { + var retryParams Params + switch p := req.GetParams().(type) { + case *CallToolParams: + cp := *p + cp.InputResponses = responses + cp.RequestState = state + retryParams = &cp + case *CallToolParamsRaw: + cp := *p + cp.InputResponses = responses + cp.RequestState = state + retryParams = &cp + case *GetPromptParams: + cp := *p + cp.InputResponses = responses + cp.RequestState = state + retryParams = &cp + case *ReadResourceParams: + cp := *p + cp.InputResponses = responses + cp.RequestState = state + retryParams = &cp + default: + return nil, fmt.Errorf("multi-round-trip: unsupported params type %T", p) + } + switch s := req.GetSession().(type) { + case *ClientSession: + return newClientRequest(s, retryParams), nil + case *ServerSession: + // Rebuild with the same constructor handleReceive uses: the + // receiving dispatch type-asserts on the request's concrete + // *ServerRequest[P] type, and Extra must carry over. + return s.receivingMethodInfos()[method].newRequest(s, retryParams, req.GetExtra()), nil + default: + return nil, fmt.Errorf("multi-round-trip: unsupported session type %T", s) + } +} + +func fulfillInputRequests(ctx context.Context, cs *ClientSession, requests InputRequestMap) (InputResponseMap, error) { + g, ctx := errgroup.WithContext(ctx) + var mu sync.Mutex + responses := make(InputResponseMap, len(requests)) + for id, ir := range requests { + g.Go(func() error { + resp, err := fulfillInputRequest(ctx, cs, ir) + if err != nil { + return fmt.Errorf("fulfilling input request %q: %w", id, err) + } + mu.Lock() + responses[id] = resp + mu.Unlock() + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, fmt.Errorf("multi round-trip: %w", err) + } + return responses, nil +} + +func fulfillInputRequest(ctx context.Context, cs *ClientSession, ir InputRequest) (InputResponse, error) { + switch p := ir.(type) { + case *ElicitParams: + return cs.client.elicit(ctx, newClientRequest(cs, p)) + case *CreateMessageParams: + return cs.client.createMessage(ctx, &CreateMessageWithToolsRequest{Session: cs, Params: createMessageParamsToWithTools(p)}) + case *CreateMessageWithToolsParams: + return cs.client.createMessage(ctx, &CreateMessageWithToolsRequest{Session: cs, Params: p}) + case *ListRootsParams: + return cs.client.listRoots(ctx, newClientRequest(cs, p)) + default: + return nil, fmt.Errorf("unknown input request type: %T", ir) + } +} diff --git a/mcp/mrtr_test.go b/mcp/mrtr_test.go new file mode 100644 index 0000000..d966219 --- /dev/null +++ b/mcp/mrtr_test.go @@ -0,0 +1,913 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +//lint:file-ignore SA1019 tests exercise deprecated SEP-2577 APIs (roots, sampling, logging). + +package mcp + +import ( + "context" + "fmt" + "slices" + "strings" + "sync/atomic" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/jsonschema-go/jsonschema" +) + +func TestMultiRoundTrip_ManualRetry(t *testing.T) { + type deployResult struct { + Deployed bool `json:"deployed"` + Reason string `json:"reason,omitempty"` + } + + ctx := context.Background() + + srv := NewServer(testImpl, nil) + AddTool(srv, &Tool{Name: "deploy"}, func(ctx context.Context, req *CallToolRequest, input struct{}) (*CallToolResult, *deployResult, error) { + if len(req.Params.InputResponses) == 0 { + return &CallToolResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Deploy to production?"}}, + RequestState: "deployment-123", + }, nil, nil + } + + resp, ok := req.Params.InputResponses["confirm"] + if !ok { + return &CallToolResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Please confirm (retry)"}}, + }, nil, nil + } + + if req.Params.RequestState == "" { + return &CallToolResult{}, &deployResult{Deployed: false, Reason: "no_state"}, nil + } + if elicitResult := resp.(*ElicitResult); elicitResult != nil && elicitResult.Action != "accept" { + return &CallToolResult{}, &deployResult{Deployed: false, Reason: "cancelled"}, nil + } + + return &CallToolResult{}, &deployResult{Deployed: true}, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + MultiRoundTrip: &MultiRoundTripOptions{Disabled: true}, + }) + + // Round 1: initiate deployment + res, err := conn.CallTool(ctx, &CallToolParams{Name: "deploy"}) + if err != nil { + t.Fatalf("CallTool() error = %v", err) + } + if !res.NeedsInput() { + t.Fatal("NeedsInput() = false, want true") + } + if got := len(res.InputRequests); got != 1 { + t.Fatalf("len(res.InputRequests) = %d, want 1", got) + } + if _, ok := res.InputRequests["confirm"].(*ElicitParams); !ok { + t.Fatalf("res.InputRequests[confirm] type = %T, want *ElicitParams", res.InputRequests["confirm"]) + } + + // Round 2: retry with confirmation + res, err = conn.CallTool(ctx, &CallToolParams{ + Name: "deploy", + InputResponses: InputResponseMap{ + "confirm": &ElicitResult{Action: "accept", Content: map[string]any{"ok": true}}, + }, + RequestState: res.RequestState, + }) + if err != nil { + t.Fatalf("CallTool() follow-up error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after follow-up, want false") + } + + if diff := cmp.Diff(map[string]any{"deployed": true}, res.StructuredContent, ctrCmpOpts...); diff != "" { + t.Errorf("result mismatch (-want +got):\n%s", diff) + } +} + +func TestMultiRoundTrip_AutoRetry(t *testing.T) { + + tests := []struct { + name string + inputRequests InputRequestMap + wantResult map[string]any + }{ + { + name: "elicit", + inputRequests: InputRequestMap{ + "confirm": &ElicitParams{Message: "Deploy?"}, + }, + wantResult: map[string]any{"ids": []any{"confirm"}}, + }, + { + name: "createMessage", + inputRequests: InputRequestMap{ + "summarize": &CreateMessageParams{ + Messages: []*SamplingMessage{{Role: "user", Content: &TextContent{Text: "summarize"}}}, + MaxTokens: 100, + }, + }, + wantResult: map[string]any{"ids": []any{"summarize"}}, + }, + { + name: "listRoots", + inputRequests: InputRequestMap{ + "roots": &ListRootsParams{}, + }, + wantResult: map[string]any{"ids": []any{"roots"}}, + }, + { + name: "all three", + inputRequests: InputRequestMap{ + "confirm": &ElicitParams{Message: "OK?"}, + "draft": &CreateMessageParams{ + Messages: []*SamplingMessage{{Role: "user", Content: &TextContent{Text: "write"}}}, + MaxTokens: 50, + }, + "roots": &ListRootsParams{}, + }, + wantResult: map[string]any{"ids": []any{"confirm", "draft", "roots"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + inputRequests := tt.inputRequests + AddTool(srv, &Tool{Name: "act"}, func(ctx context.Context, req *CallToolRequest, input struct{}) (*CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &CallToolResult{ + InputRequests: inputRequests, + RequestState: "state-1", + }, nil, nil + } + // Collect the IDs of fulfilled responses. + var ids []string + for id := range req.Params.InputResponses { + ids = append(ids, id) + } + slices.Sort(ids) + return &CallToolResult{}, map[string]any{"ids": ids}, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + ElicitationHandler: func(_ context.Context, req *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + CreateMessageHandler: func(_ context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { + return &CreateMessageResult{ + Model: "test-model", + Role: "assistant", + Content: &TextContent{Text: "response"}, + }, nil + }, + }) + conn.client.AddRoots(&Root{URI: "file:///workspace", Name: "workspace"}) + + res, err := conn.CallTool(ctx, &CallToolParams{Name: "act"}) + if err != nil { + t.Fatalf("CallTool() error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after auto-retry, want false") + } + + // Sort the expected IDs for stable comparison. + if wantIDs, ok := tt.wantResult["ids"].([]any); ok { + slices.SortFunc(wantIDs, func(a, b any) int { + if a.(string) < b.(string) { + return -1 + } + return 1 + }) + } + + if diff := cmp.Diff(tt.wantResult, res.StructuredContent, ctrCmpOpts...); diff != "" { + t.Errorf("result mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestMultiRoundTrip_AutoRetryDoesNotMutateCallerParams verifies that +// the client middleware carries inputResponses and requestState on a +// copy of the caller's params: after CallTool returns, the caller's +// struct is unchanged, and reusing it for a second call fulfills the +// input requests again instead of silently replaying the first call's +// answers against a server that gates on them. +func TestMultiRoundTrip_AutoRetryDoesNotMutateCallerParams(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + AddTool(srv, &Tool{Name: "act"}, func(ctx context.Context, req *CallToolRequest, input struct{}) (*CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &CallToolResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Sure?"}}, + RequestState: "state-1", + }, nil, nil + } + return &CallToolResult{}, map[string]any{"ok": true}, nil + }) + + var elicitations atomic.Int32 + conn := mustConnect(t, srv, &ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + elicitations.Add(1) + return &ElicitResult{Action: "accept"}, nil + }, + }) + + params := &CallToolParams{Name: "act"} + res, err := conn.CallTool(ctx, params) + if err != nil { + t.Fatalf("CallTool() error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after auto-retry, want false") + } + if params.InputResponses != nil { + t.Errorf("params.InputResponses = %v after CallTool, want nil (caller params must not be mutated)", params.InputResponses) + } + if params.RequestState != "" { + t.Errorf("params.RequestState = %q after CallTool, want empty (caller params must not be mutated)", params.RequestState) + } + if got := elicitations.Load(); got != 1 { + t.Fatalf("elicitations = %d, want 1", got) + } + + // Reusing the same params struct must fulfill the input requests + // again, not replay the first call's responses. + if _, err := conn.CallTool(ctx, params); err != nil { + t.Fatalf("CallTool() reuse error = %v", err) + } + if got := elicitations.Load(); got != 2 { + t.Errorf("elicitations after reuse = %d, want 2 (stale responses must not be replayed)", got) + } +} + +// TestMultiRoundTrip_GetPrompt_AutoRetryDoesNotMutateCallerParams is the +// GetPrompt analogue of the CallTool caller-params test. +func TestMultiRoundTrip_GetPrompt_AutoRetryDoesNotMutateCallerParams(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddPrompt(&Prompt{Name: "review"}, func(_ context.Context, req *GetPromptRequest) (*GetPromptResult, error) { + if len(req.Params.InputResponses) == 0 { + return &GetPromptResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Include sensitive data?"}}, + RequestState: "prompt-state", + }, nil + } + return &GetPromptResult{ + Messages: []*PromptMessage{{Role: "user", Content: &TextContent{Text: "review this code"}}}, + }, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + + params := &GetPromptParams{Name: "review"} + res, err := conn.GetPrompt(ctx, params) + if err != nil { + t.Fatalf("GetPrompt() error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after auto-retry, want false") + } + if params.InputResponses != nil { + t.Errorf("params.InputResponses = %v after GetPrompt, want nil (caller params must not be mutated)", params.InputResponses) + } + if params.RequestState != "" { + t.Errorf("params.RequestState = %q after GetPrompt, want empty (caller params must not be mutated)", params.RequestState) + } +} + +// TestMultiRoundTrip_ReadResource_AutoRetryDoesNotMutateCallerParams is +// the ReadResource analogue of the CallTool caller-params test. +func TestMultiRoundTrip_ReadResource_AutoRetryDoesNotMutateCallerParams(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddResource(&Resource{URI: "test://data", Name: "data"}, func(_ context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + if len(req.Params.InputResponses) == 0 { + return &ReadResourceResult{ + InputRequests: InputRequestMap{"auth": &ElicitParams{Message: "Authenticate?"}}, + RequestState: "resource-state", + }, nil + } + return &ReadResourceResult{ + Contents: []*ResourceContents{{URI: "test://data", Text: "resource data"}}, + }, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + + params := &ReadResourceParams{URI: "test://data"} + res, err := conn.ReadResource(ctx, params) + if err != nil { + t.Fatalf("ReadResource() error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after auto-retry, want false") + } + if params.InputResponses != nil { + t.Errorf("params.InputResponses = %v after ReadResource, want nil (caller params must not be mutated)", params.InputResponses) + } + if params.RequestState != "" { + t.Errorf("params.RequestState = %q after ReadResource, want empty (caller params must not be mutated)", params.RequestState) + } +} + +// wrappedCallToolParams embeds *CallToolParams so it satisfies Params and +// marshals identically, while being a concrete type the multi-round-trip +// retry does not recognize. +type wrappedCallToolParams struct{ *CallToolParams } + +// TestMultiRoundTrip_UnsupportedRetryParamsType verifies that the client +// retry loop reports an explicit error when the params carry a type it +// cannot build retry params for, instead of silently resending the request +// unchanged until the retry cap. +func TestMultiRoundTrip_UnsupportedRetryParamsType(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + AddTool(srv, &Tool{Name: "act"}, func(ctx context.Context, req *CallToolRequest, input struct{}) (*CallToolResult, any, error) { + return &CallToolResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Sure?"}}, + RequestState: "state-1", + }, nil, nil + }) + + st, ct := NewInMemoryTransports() + ss, err := srv.Connect(t.Context(), st, nil) + if err != nil { + t.Fatalf("server.Connect() error = %v", err) + } + t.Cleanup(func() { _ = ss.Close() }) + + c := NewClient(testImpl, &ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + // Sending middleware added here wraps outside the multi-round-trip + // middleware installed at client construction, so the retry loop sees + // the substituted params type. + c.AddSendingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodCallTool { + cs := req.GetSession().(*ClientSession) + req = newClientRequest[Params](cs, wrappedCallToolParams{req.GetParams().(*CallToolParams)}) + } + return next(ctx, method, req) + } + }) + cs, err := c.Connect(t.Context(), ct, nil) + if err != nil { + t.Fatalf("client.Connect() error = %v", err) + } + t.Cleanup(func() { _ = cs.Close() }) + + _, err = cs.CallTool(ctx, &CallToolParams{Name: "act"}) + if err == nil || !strings.Contains(err.Error(), "unsupported params type") { + t.Fatalf("CallTool() error = %v, want unsupported params type error", err) + } +} + +func TestMultiRoundTrip_MaxRetries(t *testing.T) { + testCases := []struct { + name string + requests InputRequestMap + wantRetries int + }{ + { + name: "load shedding", + requests: InputRequestMap{}, + wantRetries: maxLoadSheddingMultiRoundTripRetries, + }, + { + name: "input request", + requests: InputRequestMap{"confirm": &ElicitParams{Message: "Again?"}}, + wantRetries: maxMultiRoundTripRetries, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + + var serverCalls atomic.Int32 + srv := NewServer(testImpl, nil) + AddTool(srv, &Tool{Name: "loop"}, func(ctx context.Context, req *CallToolRequest, input struct{}) (*CallToolResult, any, error) { + serverCalls.Add(1) + return &CallToolResult{InputRequests: tc.requests, RequestState: "loop-state"}, nil, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + ElicitationHandler: func(_ context.Context, req *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + + _, err := conn.CallTool(ctx, &CallToolParams{Name: "loop"}) + if err == nil { + t.Fatal("CallTool() err = nil, want error for exceeded max retries") + } + if serverCalls.Load() != int32(tc.wantRetries) { + t.Errorf("serverCalls = %d, want %d", serverCalls.Load(), tc.wantRetries) + } + }) + } +} + +func TestMultiRoundTrip_ServerMiddleware(t *testing.T) { + // multiRoundTripToolHandler returns a ToolHandler (plain, non-generic) that requests + // the given inputRequests on the first call and returns the fulfilled + // response IDs on the second. + multiRoundTripToolHandler := func(inputRequests InputRequestMap) ToolHandler { + return func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + if len(req.Params.InputResponses) == 0 { + return &CallToolResult{ + InputRequests: inputRequests, + RequestState: "state-1", + }, nil + } + var ids []string + for id := range req.Params.InputResponses { + ids = append(ids, id) + } + slices.Sort(ids) + content := &TextContent{Text: fmt.Sprintf("%v", ids)} + return &CallToolResult{Content: []Content{content}}, nil + } + } + + tests := []struct { + name string + inputRequests InputRequestMap + wantText string + }{ + { + name: "elicit via ToolHandler", + inputRequests: InputRequestMap{ + "confirm": &ElicitParams{Message: "Sure?"}, + }, + wantText: "[confirm]", + }, + { + name: "elicit and listRoots via ToolHandler", + inputRequests: InputRequestMap{ + "confirm": &ElicitParams{Message: "OK?"}, + "roots": &ListRootsParams{}, + }, + wantText: "[confirm roots]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddTool( + &Tool{Name: "act", InputSchema: &jsonschema.Schema{Type: "object"}}, + multiRoundTripToolHandler(tt.inputRequests), + ) + + // Connect with an OLD protocol version where multi-round-trip is not supported. + // The server middleware should handle it transparently. + st, ct := NewInMemoryTransports() + ss, err := srv.Connect(t.Context(), st, nil) + if err != nil { + t.Fatalf("server.Connect() error = %v", err) + } + t.Cleanup(func() { _ = ss.Close() }) + + c := NewClient(testImpl, &ClientOptions{ + MultiRoundTrip: &MultiRoundTripOptions{Disabled: true}, + ElicitationHandler: func(_ context.Context, req *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + c.AddRoots(&Root{URI: "file:///workspace", Name: "workspace"}) + cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatalf("client.Connect() error = %v", err) + } + t.Cleanup(func() { _ = cs.Close() }) + + res, err := cs.CallTool(ctx, &CallToolParams{Name: "act"}) + if err != nil { + t.Fatalf("CallTool() error = %v", err) + } + if got := res.Content[0].(*TextContent).Text; got != tt.wantText { + t.Errorf("result text = %q, want %q", got, tt.wantText) + } + }) + } +} + +// TestMultiRoundTrip_ServerMiddleware_GetPrompt verifies the server-side +// shim retry for prompts: the rebuilt retry request must keep its concrete +// *ServerRequest[*GetPromptParams] type for the receiving dispatch. +func TestMultiRoundTrip_ServerMiddleware_GetPrompt(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddPrompt(&Prompt{Name: "review"}, func(_ context.Context, req *GetPromptRequest) (*GetPromptResult, error) { + if len(req.Params.InputResponses) == 0 { + return &GetPromptResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Sure?"}}, + RequestState: "prompt-state", + }, nil + } + return &GetPromptResult{ + Messages: []*PromptMessage{{Role: "user", Content: &TextContent{Text: "approved"}}}, + }, nil + }) + + conn := mustConnectOldProtocol(t, srv, &ClientOptions{ + MultiRoundTrip: &MultiRoundTripOptions{Disabled: true}, + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + + res, err := conn.GetPrompt(ctx, &GetPromptParams{Name: "review"}) + if err != nil { + t.Fatalf("GetPrompt() error = %v", err) + } + if got := res.Messages[0].Content.(*TextContent).Text; got != "approved" { + t.Errorf("message text = %q, want %q", got, "approved") + } +} + +func TestMultiRoundTrip_GetPrompt_AutoRetry(t *testing.T) { + + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddPrompt(&Prompt{Name: "review"}, func(_ context.Context, req *GetPromptRequest) (*GetPromptResult, error) { + if len(req.Params.InputResponses) == 0 { + return &GetPromptResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Include sensitive data?"}}, + RequestState: "prompt-state", + }, nil + } + return &GetPromptResult{ + Description: "Code review prompt", + Messages: []*PromptMessage{{Role: "user", Content: &TextContent{Text: "review this code"}}}, + }, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + + res, err := conn.GetPrompt(ctx, &GetPromptParams{Name: "review"}) + if err != nil { + t.Fatalf("GetPrompt() error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after auto-retry, want false") + } + if len(res.Messages) != 1 { + t.Fatalf("len(res.Messages) = %d, want 1", len(res.Messages)) + } + if got := res.Messages[0].Content.(*TextContent).Text; got != "review this code" { + t.Errorf("message text = %q, want %q", got, "review this code") + } +} + +func TestMultiRoundTrip_GetPrompt_ManualRetry(t *testing.T) { + + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddPrompt(&Prompt{Name: "review"}, func(_ context.Context, req *GetPromptRequest) (*GetPromptResult, error) { + if len(req.Params.InputResponses) == 0 { + return &GetPromptResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Include sensitive data?"}}, + RequestState: "prompt-state", + }, nil + } + return &GetPromptResult{ + Description: "Code review prompt", + Messages: []*PromptMessage{{Role: "user", Content: &TextContent{Text: "review this code"}}}, + }, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + MultiRoundTrip: &MultiRoundTripOptions{Disabled: true}, + }) + + res, err := conn.GetPrompt(ctx, &GetPromptParams{Name: "review"}) + if err != nil { + t.Fatalf("GetPrompt() error = %v", err) + } + if !res.NeedsInput() { + t.Fatal("NeedsInput() = false, want true") + } + if _, ok := res.InputRequests["confirm"].(*ElicitParams); !ok { + t.Fatalf("InputRequests[confirm] type = %T, want *ElicitParams", res.InputRequests["confirm"]) + } + + res, err = conn.GetPrompt(ctx, &GetPromptParams{ + Name: "review", + InputResponses: InputResponseMap{"confirm": &ElicitResult{Action: "accept"}}, + RequestState: res.RequestState, + }) + if err != nil { + t.Fatalf("GetPrompt() follow-up error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after follow-up, want false") + } + if len(res.Messages) != 1 { + t.Fatalf("len(res.Messages) = %d, want 1", len(res.Messages)) + } +} + +func TestMultiRoundTrip_ReadResource_AutoRetry(t *testing.T) { + + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddResource(&Resource{URI: "test://data", Name: "data"}, func(_ context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + if len(req.Params.InputResponses) == 0 { + return &ReadResourceResult{ + InputRequests: InputRequestMap{"auth": &ElicitParams{Message: "Authenticate?"}}, + RequestState: "resource-state", + }, nil + } + return &ReadResourceResult{ + Contents: []*ResourceContents{{URI: "test://data", Text: "resource data"}}, + }, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + + res, err := conn.ReadResource(ctx, &ReadResourceParams{URI: "test://data"}) + if err != nil { + t.Fatalf("ReadResource() error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after auto-retry, want false") + } + if len(res.Contents) != 1 { + t.Fatalf("len(res.Contents) = %d, want 1", len(res.Contents)) + } + if got := res.Contents[0].Text; got != "resource data" { + t.Errorf("resource text = %q, want %q", got, "resource data") + } +} + +func TestMultiRoundTrip_ReadResource_ManualRetry(t *testing.T) { + + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddResource(&Resource{URI: "test://data", Name: "data"}, func(_ context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + if len(req.Params.InputResponses) == 0 { + return &ReadResourceResult{ + InputRequests: InputRequestMap{"auth": &ElicitParams{Message: "Authenticate?"}}, + RequestState: "resource-state", + }, nil + } + return &ReadResourceResult{ + Contents: []*ResourceContents{{URI: "test://data", Text: "resource data"}}, + }, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + MultiRoundTrip: &MultiRoundTripOptions{Disabled: true}, + }) + + res, err := conn.ReadResource(ctx, &ReadResourceParams{URI: "test://data"}) + if err != nil { + t.Fatalf("ReadResource() error = %v", err) + } + if !res.NeedsInput() { + t.Fatal("NeedsInput() = false, want true") + } + if _, ok := res.InputRequests["auth"].(*ElicitParams); !ok { + t.Fatalf("InputRequests[auth] type = %T, want *ElicitParams", res.InputRequests["auth"]) + } + + res, err = conn.ReadResource(ctx, &ReadResourceParams{ + URI: "test://data", + InputResponses: InputResponseMap{"auth": &ElicitResult{Action: "accept"}}, + RequestState: res.RequestState, + }) + if err != nil { + t.Fatalf("ReadResource() follow-up error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after follow-up, want false") + } + if len(res.Contents) != 1 { + t.Fatalf("len(res.Contents) = %d, want 1", len(res.Contents)) + } +} + +// TestSetMultiRoundTripRetryParams pins the properties the retry-request +// builder must uphold: the original params are never mutated, the copy +// carries the responses and state, the server-side rebuild preserves the +// request's concrete *ServerRequest[P] type (the receiving dispatch +// type-asserts on it) and its Extra, and an unrecognized params type is an +// explicit error. The server readResource case matters here because the +// end-to-end shim path cannot reach it (the read handler's nil-contents +// validation rejects input-required results for old-protocol clients). +func TestSetMultiRoundTripRetryParams(t *testing.T) { + ss := &ServerSession{server: NewServer(testImpl, nil)} + extra := &RequestExtra{} + responses := InputResponseMap{"confirm": &ElicitResult{Action: "accept"}} + + // checkRetry asserts the returned params carry the retry fields while + // the original params were left untouched. + checkRetry := func(t *testing.T, orig, retry Params) { + t.Helper() + switch p := retry.(type) { + case *CallToolParams: + if len(p.InputResponses) != 1 || p.RequestState != "s1" { + t.Errorf("retry params = %+v, want responses and state carried", p) + } + case *CallToolParamsRaw: + if len(p.InputResponses) != 1 || p.RequestState != "s1" { + t.Errorf("retry params = %+v, want responses and state carried", p) + } + case *GetPromptParams: + if len(p.InputResponses) != 1 || p.RequestState != "s1" { + t.Errorf("retry params = %+v, want responses and state carried", p) + } + case *ReadResourceParams: + if len(p.InputResponses) != 1 || p.RequestState != "s1" { + t.Errorf("retry params = %+v, want responses and state carried", p) + } + default: + t.Errorf("retry params type = %T, want one of the multi-round-trip params types", p) + } + switch p := orig.(type) { + case *CallToolParams: + if p.InputResponses != nil || p.RequestState != "" { + t.Errorf("original params mutated: %+v", p) + } + case *CallToolParamsRaw: + if p.InputResponses != nil || p.RequestState != "" { + t.Errorf("original params mutated: %+v", p) + } + case *GetPromptParams: + if p.InputResponses != nil || p.RequestState != "" { + t.Errorf("original params mutated: %+v", p) + } + case *ReadResourceParams: + if p.InputResponses != nil || p.RequestState != "" { + t.Errorf("original params mutated: %+v", p) + } + } + } + + t.Run("server callTool", func(t *testing.T) { + orig := &CallToolParamsRaw{Name: "act"} + req, err := setMultiRoundTripRetryParams(methodCallTool, &ServerRequest[*CallToolParamsRaw]{Session: ss, Params: orig, Extra: extra}, responses, "s1") + if err != nil { + t.Fatalf("setMultiRoundTripRetryParams() error = %v", err) + } + got, ok := req.(*ServerRequest[*CallToolParamsRaw]) + if !ok { + t.Fatalf("retry request type = %T, want *ServerRequest[*CallToolParamsRaw]", req) + } + if got.Extra != extra { + t.Error("retry request lost Extra") + } + checkRetry(t, orig, got.Params) + }) + + t.Run("server getPrompt", func(t *testing.T) { + orig := &GetPromptParams{Name: "review"} + req, err := setMultiRoundTripRetryParams(methodGetPrompt, &ServerRequest[*GetPromptParams]{Session: ss, Params: orig, Extra: extra}, responses, "s1") + if err != nil { + t.Fatalf("setMultiRoundTripRetryParams() error = %v", err) + } + got, ok := req.(*ServerRequest[*GetPromptParams]) + if !ok { + t.Fatalf("retry request type = %T, want *ServerRequest[*GetPromptParams]", req) + } + if got.Extra != extra { + t.Error("retry request lost Extra") + } + checkRetry(t, orig, got.Params) + }) + + t.Run("server readResource", func(t *testing.T) { + orig := &ReadResourceParams{URI: "test://data"} + req, err := setMultiRoundTripRetryParams(methodReadResource, &ServerRequest[*ReadResourceParams]{Session: ss, Params: orig, Extra: extra}, responses, "s1") + if err != nil { + t.Fatalf("setMultiRoundTripRetryParams() error = %v", err) + } + got, ok := req.(*ServerRequest[*ReadResourceParams]) + if !ok { + t.Fatalf("retry request type = %T, want *ServerRequest[*ReadResourceParams]", req) + } + if got.Extra != extra { + t.Error("retry request lost Extra") + } + checkRetry(t, orig, got.Params) + }) + + t.Run("client params", func(t *testing.T) { + for _, orig := range []Params{ + &CallToolParams{Name: "act"}, + &GetPromptParams{Name: "review"}, + &ReadResourceParams{URI: "test://data"}, + } { + req, err := setMultiRoundTripRetryParams(methodCallTool, newClientRequest[Params](nil, orig), responses, "s1") + if err != nil { + t.Fatalf("setMultiRoundTripRetryParams(%T) error = %v", orig, err) + } + got, ok := req.(*ClientRequest[Params]) + if !ok { + t.Fatalf("retry request type = %T, want *ClientRequest[Params]", req) + } + checkRetry(t, orig, got.Params) + } + }) + + t.Run("unsupported params type", func(t *testing.T) { + wrapped := wrappedCallToolParams{&CallToolParams{Name: "act"}} + _, err := setMultiRoundTripRetryParams(methodCallTool, newClientRequest[Params](nil, wrapped), responses, "s1") + if err == nil || !strings.Contains(err.Error(), "unsupported params type") { + t.Fatalf("setMultiRoundTripRetryParams() error = %v, want unsupported params type error", err) + } + }) +} + +func mustConnect(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSession { + t.Helper() + + st, ct := NewInMemoryTransports() + ss, err := s.Connect(t.Context(), st, nil) + if err != nil { + t.Fatalf("server.Connect() error = %v", err) + } + t.Cleanup(func() { + _ = ss.Close() + }) + + c := NewClient(testImpl, clientOpts) + cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client.Connect() error = %v", err) + } + t.Cleanup(func() { + _ = cs.Close() + }) + return cs +} + +// mustConnectOldProtocol is mustConnect for a client on a protocol version +// that predates multi-round-trip: the server-side shim, not the client +// middleware, fulfills input requests. +func mustConnectOldProtocol(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSession { + t.Helper() + + st, ct := NewInMemoryTransports() + ss, err := s.Connect(t.Context(), st, nil) + if err != nil { + t.Fatalf("server.Connect() error = %v", err) + } + t.Cleanup(func() { + _ = ss.Close() + }) + + c := NewClient(testImpl, clientOpts) + cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatalf("client.Connect() error = %v", err) + } + t.Cleanup(func() { + _ = cs.Close() + }) + return cs +} diff --git a/mcp/protocol.go b/mcp/protocol.go index 0b6bf47..c340e60 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -10,8 +10,199 @@ import ( "maps" internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" ) +// resultType indicates whether a result is complete or requires further input +// from the client via the multi round-trip request protocol. +type resultType string + +const ( + // resultTypeComplete indicates the result is final. + // This is the default when resultType is empty. + resultTypeComplete resultType = "complete" + + // resultTypeInputRequired indicates the server needs additional client + // input before it can complete the request. The client should fulfill the + // InputRequests and retry the call with the responses. + resultTypeInputRequired resultType = "input_required" +) + +type completeResultWithType struct { + ResultType resultType `json:"resultType,omitempty"` +} + +func (r *completeResultWithType) setResultType(rt resultType) { r.ResultType = rt } +func (*completeResultWithType) isCompleteResult() {} + +type completeResultResponse interface { + setResultType(resultType) + isCompleteResult() +} + +// annotateResultType sets the resultType that protocol 2026-07-28 requires on +// every result. +func annotateResultType(res Result) { + switch r := res.(type) { + case completeResultResponse: + r.setResultType(resultTypeComplete) + case multiRoundTripResponse: + // These results are complete or input_required, so label them by + // whether the handler asked for more client input. + if r.inputRequests() != nil { + r.setResultType(resultTypeInputRequired) + } else { + r.setResultType(resultTypeComplete) + } + } +} + +// InputRequest is a type for parameters that a server can include in the response +// to request input from client (SEP-2322). Implementations are [*ElicitParams], +// [*CreateMessageParams], and [*ListRootsParams]. +type InputRequest interface{ isInputRequest() } + +// InputRequestMap maps server-assigned request IDs to [InputRequest] values. +// It is used in result types to tell the client what input the server needs. +type InputRequestMap map[string]InputRequest + +func (m InputRequestMap) MarshalJSON() ([]byte, error) { + if m == nil { + return json.Marshal(map[string]any(nil)) + } + type wire struct { + Method string `json:"method"` + Params InputRequest `json:"params,omitempty"` + } + typeToMethod := func(v InputRequest) (string, error) { + switch v.(type) { + case *ElicitParams: + return methodElicit, nil + case *CreateMessageParams, *CreateMessageWithToolsParams: + return methodCreateMessage, nil + case *ListRootsParams: + return methodListRoots, nil + default: + return "", fmt.Errorf("unsupported type: %T", v) + } + } + converted := map[string]*wire{} + for k, v := range m { + method, err := typeToMethod(v) + if err != nil { + return nil, err + } + if ep, ok := v.(*ElicitParams); ok { + v = ep.inferElicitMode() + } + converted[k] = &wire{Method: method, Params: v} + } + return json.Marshal(converted) +} + +func (m *InputRequestMap) UnmarshalJSON(data []byte) error { + type raw struct { + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + var rawMap map[string]*raw + if err := json.Unmarshal(data, &rawMap); err != nil { + return err + } + if rawMap == nil { + return nil + } + result := make(InputRequestMap, len(rawMap)) + for k, raw := range rawMap { + switch raw.Method { + case methodElicit: + var p ElicitParams + if err := json.Unmarshal(raw.Params, &p); err != nil { + return err + } + result[k] = &p + case methodCreateMessage: + var p CreateMessageWithToolsParams + if err := json.Unmarshal(raw.Params, &p); err != nil { + return err + } + result[k] = &p + case methodListRoots: + var p ListRootsParams + if err := json.Unmarshal(raw.Params, &p); err != nil { + return err + } + result[k] = &p + default: + return fmt.Errorf("unsupported InputRequest method: %q", raw.Method) + } + } + *m = result + return nil +} + +// InputResponse is a type for results that a client sends back when fulfilling +// a server input request (SEP-2322). Implementations are [*ElicitResult], +// [*CreateMessageResult], and [*ListRootsResult]. +type InputResponse interface{ isInputResponse() } + +// InputResponseMap maps request IDs (from [InputRequestMap]) to [InputResponse] +// values. It is used in params types when retrying a call after an +// input-required result. +type InputResponseMap map[string]InputResponse + +func (m *InputResponseMap) UnmarshalJSON(data []byte) error { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMap); err != nil { + return err + } + result := make(InputResponseMap, len(rawMap)) + for k, raw := range rawMap { + v, err := unmarshalInputResponse(raw) + if err != nil { + return fmt.Errorf("inputResponses[%q]: %w", k, err) + } + result[k] = v + } + *m = result + return nil +} + +// unmarshalInputResponse determines the concrete InputResponse type from the +// JSON structure by searching for a discriminating key in a raw message. +func unmarshalInputResponse(data json.RawMessage) (InputResponse, error) { + var probe struct { + Action json.RawMessage `json:"action"` + Role json.RawMessage `json:"role"` + Roots json.RawMessage `json:"roots"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return nil, err + } + switch { + case probe.Roots != nil: + var p ListRootsResult + if err := json.Unmarshal(data, &p); err != nil { + return nil, err + } + return &p, nil + case probe.Action != nil: + var p ElicitResult + if err := json.Unmarshal(data, &p); err != nil { + return nil, err + } + return &p, nil + case probe.Role != nil: + var p CreateMessageWithToolsResult + if err := json.Unmarshal(data, &p); err != nil { + return nil, err + } + return &p, nil + default: + return nil, fmt.Errorf(`cannot determine InputResponse type: expected "action", "role", or "roots" key`) + } +} + // Optional annotations for the client. The client can use annotations to inform // how objects are used or displayed. type Annotations struct { @@ -45,6 +236,13 @@ type CallToolParams struct { // Arguments holds the tool arguments. It can hold any value that can be // marshaled to JSON. Arguments any `json:"arguments,omitempty"` + + // InputResponses maps input request IDs to responses. Set it when the + // client retries a call that asked for input. See [CallToolResult.NeedsInput]. + InputResponses InputResponseMap `json:"inputResponses,omitempty"` + // RequestState is the opaque state from the previous input-required result. + // The client must echo this back when retrying. + RequestState string `json:"requestState,omitempty"` } // CallToolParamsRaw is passed to tool handlers on the server. Its arguments @@ -60,6 +258,13 @@ type CallToolParamsRaw struct { // is the responsibility of the tool handler to unmarshal and validate the // Arguments (see [AddTool]). Arguments json.RawMessage `json:"arguments,omitempty"` + + // InputResponses maps input request IDs to responses. Set it when the + // client retries a call that asked for input. See [CallToolResult.NeedsInput]. + InputResponses InputResponseMap `json:"inputResponses,omitempty"` + // RequestState is the opaque state from the previous input-required result. + // The client must echo this back when retrying. + RequestState string `json:"requestState,omitempty"` } // A CallToolResult is the server's response to a tool call. @@ -81,7 +286,11 @@ type CallToolResult struct { Content []Content `json:"content"` // StructuredContent is an optional value that represents the structured - // result of the tool call. It must marshal to a JSON object. + // result of the tool call. Per SEP-2106, it may marshal to any valid JSON + // value (object, array, or primitive) conforming to the tool's + // [Tool.OutputSchema]. + // Numbers received from the wire are represented as [json.Number] to preserve + // their exact values. // // When using a [ToolHandlerFor] with structured output, you should not // populate this field. It will be automatically populated with the typed Out @@ -106,6 +315,24 @@ type CallToolResult struct { // the Content field. IsError bool `json:"isError,omitempty"` + // InputRequests is a map of server-assigned IDs to input requests. + // It is set only when the result needs more client input. + // The client must fulfill these and echo the IDs back in InputResponses + // when it retries the call. + InputRequests InputRequestMap `json:"inputRequests,omitempty"` + + // RequestState is an opaque string the client must echo back when + // retrying after an input-required result. Servers use this to carry + // context between independent requests. + // + // Unauthenticated servers must encrypt, sign and verify this value. + RequestState string `json:"requestState,omitempty"` + + // resultType records whether the call finished or needs more client input. + // Empty or resultTypeComplete means it finished. resultTypeInputRequired + // means the client must fulfill InputRequests and retry. + // See [CallToolResult.NeedsInput]. + resultType resultType // The error passed to setError, if any. // It is not marshaled, and therefore it is only visible on the server. // Its only use is in server sending middleware, where it can be accessed @@ -113,10 +340,15 @@ type CallToolResult struct { err error } -// SetError sets the error for the tool result and populates the Content field -// with the error text. It also sets IsError to true. +// SetError sets the error for the tool result and sets IsError to true. +// If Content has not already been populated, it is set to the error text. +// If Content has already been populated, it is left unchanged, allowing callers +// to provide a user-friendly message while still recording the underlying error +// for inspection via [GetError] in server middleware. func (r *CallToolResult) SetError(err error) { - r.Content = []Content{&TextContent{Text: err.Error()}} + if len(r.Content) == 0 { + r.Content = []Content{&TextContent{Text: err.Error()}} + } r.IsError = true r.err = err } @@ -127,32 +359,88 @@ func (r *CallToolResult) GetError() error { return r.err } -func (*CallToolResult) isResult() {} +func (*CallToolResult) isResult() {} +func (x *CallToolResult) isNil() bool { return x == nil } + +func (r *CallToolResult) setResultType(rt resultType) { r.resultType = rt } +func (r *CallToolResult) requestState() string { return r.RequestState } +func (r *CallToolResult) inputRequests() map[string]InputRequest { + if r == nil { + return nil + } + return r.InputRequests +} +func (r *CallToolResult) hasContent() bool { + return len(r.Content) > 0 || r.StructuredContent != nil +} + +// NeedsInput reports whether this result requires further client input. +// This is true when the server returned a resultType of "input_required". +// When NeedsInput returns true, check InputRequests for the set of +// requests the server needs fulfilled before retrying the call. +// An empty InputRequests with NeedsInput true indicates load-shedding. +func (r *CallToolResult) NeedsInput() bool { return r.resultType == resultTypeInputRequired } + +// structuredcontentfloat64 is a compatibility parameter that restores the +// previous behavior of decoding numbers in [CallToolResult.StructuredContent] +// as float64 values. By default, numbers are decoded as [json.Number] to avoid +// losing precision. The option will be removed in the 1.11.0 version of the SDK. +var structuredcontentfloat64 = mcpgodebug.Value("structuredcontentfloat64") + +func (x *CallToolResult) MarshalJSON() ([]byte, error) { + type res CallToolResult // avoid recursion + type wire struct { + res + ResultType resultType `json:"resultType,omitempty"` + InputRequests json.RawMessage `json:"inputRequests,omitempty"` // shadows res.InputRequests + } + w := wire{res: res(*x), ResultType: x.resultType} + if x.InputRequests != nil { + ir, err := json.Marshal(x.InputRequests) + if err != nil { + return nil, err + } + w.InputRequests = ir + } + return json.Marshal(w) +} -// UnmarshalJSON handles the unmarshalling of content into the Content -// interface. func (x *CallToolResult) UnmarshalJSON(data []byte) error { type res CallToolResult // avoid recursion var wire struct { res - Content []*wireContent `json:"content"` + Content []*wireContent `json:"content"` + StructuredContent json.RawMessage `json:"structuredContent"` + ResultType resultType `json:"resultType"` } if err := internaljson.Unmarshal(data, &wire); err != nil { return err } + if len(wire.StructuredContent) > 0 { + unmarshal := internaljson.UnmarshalUseNumber + if structuredcontentfloat64 == "1" { + unmarshal = internaljson.Unmarshal + } + if err := unmarshal(wire.StructuredContent, &wire.res.StructuredContent); err != nil { + return err + } + } var err error if wire.res.Content, err = contentsFromWire(wire.Content, nil); err != nil { return err } + wire.res.resultType = wire.ResultType *x = CallToolResult(wire.res) return nil } func (x *CallToolParams) isParams() {} +func (x *CallToolParams) isNil() bool { return x == nil } func (x *CallToolParams) GetProgressToken() any { return getProgressToken(x) } func (x *CallToolParams) SetProgressToken(t any) { setProgressToken(x, t) } func (x *CallToolParamsRaw) isParams() {} +func (x *CallToolParamsRaw) isNil() bool { return x == nil } func (x *CallToolParamsRaw) GetProgressToken() any { return getProgressToken(x) } func (x *CallToolParamsRaw) SetProgressToken(t any) { setProgressToken(x, t) } @@ -171,10 +459,16 @@ type CancelledParams struct { } func (x *CancelledParams) isParams() {} +func (x *CancelledParams) isNil() bool { return x == nil } func (x *CancelledParams) GetProgressToken() any { return getProgressToken(x) } func (x *CancelledParams) SetProgressToken(t any) { setProgressToken(x, t) } // RootCapabilities describes a client's support for roots. +// +// Deprecated: the roots feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type RootCapabilities struct { // ListChanged reports whether the client supports notifications for // changes to the roots list. @@ -202,14 +496,27 @@ type ClientCapabilities struct { // Deprecated: use RootsV2. As described in #607, Roots should have been a // pointer to a RootCapabilities value. Roots will be continue to be // populated, but any new fields will only be added in the RootsV2 field. + // + // The roots feature itself is also deprecated by SEP-2577; see RootsV2. Roots struct { // ListChanged reports whether the client supports notifications for // changes to the roots list. ListChanged bool `json:"listChanged,omitempty"` } `json:"roots,omitempty"` - // RootsV2 is present if the client supports roots. When capabilities are explicitly configured via [ClientOptions.Capabilities] + // RootsV2 is present if the client supports roots. When capabilities are + // explicitly configured via [ClientOptions.Capabilities]. + // + // Deprecated: the roots feature is deprecated as of protocol version + // 2026-07-28 (SEP-2577). It remains functional during the deprecation + // window (at least twelve months). See + // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. RootsV2 *RootCapabilities `json:"-"` // Sampling is present if the client supports sampling from an LLM. + // + // Deprecated: the sampling feature is deprecated as of protocol version + // 2026-07-28 (SEP-2577). It remains functional during the deprecation + // window (at least twelve months). See + // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. Sampling *SamplingCapabilities `json:"sampling,omitempty"` // Elicitation is present if the client supports elicitation from the server. Elicitation *ElicitationCapabilities `json:"elicitation,omitempty"` @@ -358,7 +665,8 @@ type CompleteParams struct { Ref *CompleteReference `json:"ref"` } -func (*CompleteParams) isParams() {} +func (x *CompleteParams) isParams() {} +func (x *CompleteParams) isNil() bool { return x == nil } type CompletionResultDetails struct { HasMore bool `json:"hasMore,omitempty"` @@ -368,14 +676,22 @@ type CompletionResultDetails struct { // The server's response to a completion/complete request type CompleteResult struct { + completeResultWithType // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` Completion CompletionResultDetails `json:"completion"` } -func (*CompleteResult) isResult() {} +func (*CompleteResult) isResult() {} +func (x *CompleteResult) isNil() bool { return x == nil } +// CreateMessageParams holds parameters for a sampling/createMessage request. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type CreateMessageParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -383,10 +699,11 @@ type CreateMessageParams struct { // A request to include context from one or more MCP servers (including the // caller), to be attached to the prompt. The client may ignore this request. // - // The default is "none". Values "thisServer" and - // "allServers" are soft-deprecated. Servers SHOULD only use these values if - // the client declares ClientCapabilities.sampling.context. These values may - // be removed in future spec releases. + // The default is "none". The values "thisServer" and "allServers" are + // deprecated as of protocol version 2025-11-25 (SEP-2596) and will be + // removed no later than the sampling feature itself (SEP-2577). Servers + // SHOULD omit this field or use "none". See + // https://modelcontextprotocol.io/seps/2596-feature-lifecycle-and-deprecation-policy. IncludeContext string `json:"includeContext,omitempty"` // The maximum number of tokens to sample, as requested by the server. The // client may choose to sample fewer tokens than requested. @@ -406,6 +723,8 @@ type CreateMessageParams struct { } func (x *CreateMessageParams) isParams() {} +func (x *CreateMessageParams) isInputRequest() {} +func (x *CreateMessageParams) isNil() bool { return x == nil } func (x *CreateMessageParams) GetProgressToken() any { return getProgressToken(x) } func (x *CreateMessageParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -414,6 +733,11 @@ func (x *CreateMessageParams) SetProgressToken(t any) { setProgressToken(x, t) } // and messages that support array content (for parallel tool calls). // // Use with [ServerSession.CreateMessageWithTools]. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type CreateMessageWithToolsParams struct { Meta `json:"_meta,omitempty"` IncludeContext string `json:"includeContext,omitempty"` @@ -432,6 +756,8 @@ type CreateMessageWithToolsParams struct { } func (x *CreateMessageWithToolsParams) isParams() {} +func (x *CreateMessageWithToolsParams) isInputRequest() {} +func (x *CreateMessageWithToolsParams) isNil() bool { return x == nil } func (x *CreateMessageWithToolsParams) GetProgressToken() any { return getProgressToken(x) } func (x *CreateMessageWithToolsParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -472,6 +798,11 @@ func (p *CreateMessageWithToolsParams) toBase() (*CreateMessageParams, error) { // object for compatibility with pre-2025-11-25 implementations. When // unmarshaling, a single JSON content object is accepted and wrapped in a // one-element slice. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type SamplingMessageV2 struct { Content []Content `json:"content"` Role Role `json:"role"` @@ -513,6 +844,11 @@ func (m *SamplingMessageV2) UnmarshalJSON(data []byte) error { // The client should inform the user before returning the sampled message, to // allow them to inspect the response (human in the loop) and decide whether to // allow the server to see it. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type CreateMessageResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -531,7 +867,9 @@ type CreateMessageResult struct { StopReason string `json:"stopReason,omitempty"` } -func (*CreateMessageResult) isResult() {} +func (*CreateMessageResult) isResult() {} +func (x *CreateMessageResult) isNil() bool { return x == nil } +func (*CreateMessageResult) isInputResponse() {} func (r *CreateMessageResult) UnmarshalJSON(data []byte) error { type result CreateMessageResult // avoid recursion var wire struct { @@ -558,6 +896,11 @@ func (r *CreateMessageResult) UnmarshalJSON(data []byte) error { // // When unmarshaling, a single JSON content object is accepted and wrapped in a // one-element slice, for compatibility with clients that return a single block. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type CreateMessageWithToolsResult struct { Meta `json:"_meta,omitempty"` Content []Content `json:"content"` @@ -576,7 +919,9 @@ var createMessageWithToolsResultAllow = map[string]bool{ "tool_use": true, } -func (*CreateMessageWithToolsResult) isResult() {} +func (*CreateMessageWithToolsResult) isResult() {} +func (x *CreateMessageWithToolsResult) isNil() bool { return x == nil } +func (*CreateMessageWithToolsResult) isInputResponse() {} // MarshalJSON marshals the result. When Content has a single element, it is // marshaled as a single object for compatibility with pre-2025-11-25 @@ -635,9 +980,16 @@ type GetPromptParams struct { Arguments map[string]string `json:"arguments,omitempty"` // The name of the prompt or prompt template. Name string `json:"name"` + + // InputResponses maps input request IDs to responses. Set it when the + // client retries a call that asked for input. See [CallToolResult.NeedsInput]. + InputResponses InputResponseMap `json:"inputResponses,omitempty"` + // RequestState is the opaque state from the previous input-required result. + RequestState string `json:"requestState,omitempty"` } func (x *GetPromptParams) isParams() {} +func (x *GetPromptParams) isNil() bool { return x == nil } func (x *GetPromptParams) GetProgressToken() any { return getProgressToken(x) } func (x *GetPromptParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -649,9 +1001,67 @@ type GetPromptResult struct { // An optional description for the prompt. Description string `json:"description,omitempty"` Messages []*PromptMessage `json:"messages"` + + // InputRequests is set when the result needs more client input. + // See [CallToolResult.InputRequests]. + InputRequests InputRequestMap `json:"inputRequests,omitempty"` + // RequestState is the opaque state for multi-round-trip retries. + // See [CallToolResult.RequestState]. + RequestState string `json:"requestState,omitempty"` + + // resultType records whether the call finished or needs more client input. + // See [CallToolResult.NeedsInput]. + resultType resultType } -func (*GetPromptResult) isResult() {} +func (*GetPromptResult) isResult() {} +func (x *GetPromptResult) isNil() bool { return x == nil } + +func (r *GetPromptResult) setResultType(rt resultType) { r.resultType = rt } +func (r *GetPromptResult) requestState() string { return r.RequestState } +func (r *GetPromptResult) inputRequests() map[string]InputRequest { + if r == nil { + return nil + } + return r.InputRequests +} +func (r *GetPromptResult) hasContent() bool { return len(r.Messages) > 0 } + +// NeedsInput reports whether this result requires further client input. +// See [CallToolResult.NeedsInput] for details. +func (r *GetPromptResult) NeedsInput() bool { return r.resultType == resultTypeInputRequired } + +func (x *GetPromptResult) MarshalJSON() ([]byte, error) { + type res GetPromptResult + type wire struct { + res + ResultType resultType `json:"resultType,omitempty"` + InputRequests json.RawMessage `json:"inputRequests,omitempty"` // shadows res.InputRequests + } + w := wire{res: res(*x), ResultType: x.resultType} + if x.InputRequests != nil { + ir, err := json.Marshal(x.InputRequests) + if err != nil { + return nil, err + } + w.InputRequests = ir + } + return json.Marshal(w) +} + +func (x *GetPromptResult) UnmarshalJSON(data []byte) error { + type res GetPromptResult + var wire struct { + res + ResultType resultType `json:"resultType"` + } + if err := internaljson.Unmarshal(data, &wire); err != nil { + return err + } + wire.res.resultType = wire.ResultType + *x = GetPromptResult(wire.res) + return nil +} // InitializeParams is sent by the client to initialize the session. type InitializeParams struct { @@ -690,6 +1100,7 @@ func (p *initializeParamsV2) toV1() *InitializeParams { } func (x *InitializeParams) isParams() {} +func (x *InitializeParams) isNil() bool { return x == nil } func (x *InitializeParams) GetProgressToken() any { return getProgressToken(x) } func (x *InitializeParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -714,7 +1125,8 @@ type InitializeResult struct { ServerInfo *Implementation `json:"serverInfo"` } -func (*InitializeResult) isResult() {} +func (*InitializeResult) isResult() {} +func (x *InitializeResult) isNil() bool { return x == nil } type InitializedParams struct { // Meta is reserved by the protocol to allow clients and servers to attach @@ -723,6 +1135,7 @@ type InitializedParams struct { } func (x *InitializedParams) isParams() {} +func (x *InitializedParams) isNil() bool { return x == nil } func (x *InitializedParams) GetProgressToken() any { return getProgressToken(x) } func (x *InitializedParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -735,16 +1148,91 @@ type ListPromptsParams struct { Cursor string `json:"cursor,omitempty"` } +type DiscoverParams struct { + Meta `json:"_meta,omitempty"` +} + +func (x *DiscoverParams) isParams() {} +func (x *DiscoverParams) isNil() bool { return x == nil } +func (x *DiscoverParams) GetProgressToken() any { return getProgressToken(x) } +func (x *DiscoverParams) SetProgressToken(t any) { setProgressToken(x, t) } + +type DiscoverResult struct { + completeResultWithType + Meta `json:"_meta,omitempty"` + Cacheable + // The versions of the Model Context Protocol that the server supports. + SupportedVersions []string `json:"supportedVersions"` + // The server's capabilities. + Capabilities *ServerCapabilities `json:"capabilities"` + // Instructions describing how to use the server and its features. + Instructions string `json:"instructions,omitempty"` +} + +func (*DiscoverResult) isResult() {} +func (x *DiscoverResult) isNil() bool { return x == nil } + func (x *ListPromptsParams) isParams() {} +func (x *ListPromptsParams) isNil() bool { return x == nil } func (x *ListPromptsParams) GetProgressToken() any { return getProgressToken(x) } func (x *ListPromptsParams) SetProgressToken(t any) { setProgressToken(x, t) } func (x *ListPromptsParams) cursorPtr() *string { return &x.Cursor } +// CacheableResult is a result that supports a time-to-live (TTL) hint for +// client-side caching. +type CacheableResult interface { + Result + GetTTLMs() int + GetCacheScope() string +} + +// Cacheable describes a result that supports a time-to-live (TTL) hint for +// client-side caching. +type Cacheable struct { + // A hint from the server indicating how long (in milliseconds) the + // client MAY cache this response before re-fetching. Semantics are + // analogous to HTTP Cache-Control max-age. + // + // If 0, the response SHOULD be considered immediately stale. + // If positive, the client SHOULD consider the result fresh for this + // many milliseconds after receiving the response. + TTLMs int `json:"ttlMs"` + + // Indicates the intended scope of the cached response, analogous to + // HTTP Cache-Control: public vs Cache-Control: private. + // + // "public": Any client or intermediary MAY cache and serve the response. + // "private": Only the requesting user's client MAY cache the response. + // + // Defaults to "public" if absent. + CacheScope string `json:"cacheScope"` +} + +// GetTTLMs returns the TTL hint in milliseconds. +func (c Cacheable) GetTTLMs() int { return c.TTLMs } + +// GetCacheScope returns the cache scope. +func (c Cacheable) GetCacheScope() string { return c.CacheScope } + +// normalize fills in the protocol default for any cache field left +// unset. An absent cacheScope means "public", but the field is required on the +// wire, so the default is materialized here rather than sent empty. +// +// Values already present are preserved: this must not undo a decision made by +// a resource handler or by [ServerOptions.SetCacheable]. +func (c *Cacheable) normalize() { + if c.CacheScope == "" { + c.CacheScope = "public" + } +} + // The server's response to a prompts/list request from the client. type ListPromptsResult struct { + completeResultWithType // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` + Cacheable // An opaque token representing the pagination position after the last returned // result. If present, there may be more results available. NextCursor string `json:"nextCursor,omitempty"` @@ -752,6 +1240,7 @@ type ListPromptsResult struct { } func (x *ListPromptsResult) isResult() {} +func (x *ListPromptsResult) isNil() bool { return x == nil } func (x *ListPromptsResult) nextCursorPtr() *string { return &x.NextCursor } type ListResourceTemplatesParams struct { @@ -764,15 +1253,18 @@ type ListResourceTemplatesParams struct { } func (x *ListResourceTemplatesParams) isParams() {} +func (x *ListResourceTemplatesParams) isNil() bool { return x == nil } func (x *ListResourceTemplatesParams) GetProgressToken() any { return getProgressToken(x) } func (x *ListResourceTemplatesParams) SetProgressToken(t any) { setProgressToken(x, t) } func (x *ListResourceTemplatesParams) cursorPtr() *string { return &x.Cursor } // The server's response to a resources/templates/list request from the client. type ListResourceTemplatesResult struct { + completeResultWithType // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` + Cacheable // An opaque token representing the pagination position after the last returned // result. If present, there may be more results available. NextCursor string `json:"nextCursor,omitempty"` @@ -780,6 +1272,7 @@ type ListResourceTemplatesResult struct { } func (x *ListResourceTemplatesResult) isResult() {} +func (x *ListResourceTemplatesResult) isNil() bool { return x == nil } func (x *ListResourceTemplatesResult) nextCursorPtr() *string { return &x.NextCursor } type ListResourcesParams struct { @@ -792,15 +1285,18 @@ type ListResourcesParams struct { } func (x *ListResourcesParams) isParams() {} +func (x *ListResourcesParams) isNil() bool { return x == nil } func (x *ListResourcesParams) GetProgressToken() any { return getProgressToken(x) } func (x *ListResourcesParams) SetProgressToken(t any) { setProgressToken(x, t) } func (x *ListResourcesParams) cursorPtr() *string { return &x.Cursor } // The server's response to a resources/list request from the client. type ListResourcesResult struct { + completeResultWithType // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` + Cacheable // An opaque token representing the pagination position after the last returned // result. If present, there may be more results available. NextCursor string `json:"nextCursor,omitempty"` @@ -808,8 +1304,15 @@ type ListResourcesResult struct { } func (x *ListResourcesResult) isResult() {} +func (x *ListResourcesResult) isNil() bool { return x == nil } func (x *ListResourcesResult) nextCursorPtr() *string { return &x.NextCursor } +// ListRootsParams holds parameters for a roots/list request. +// +// Deprecated: the roots feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type ListRootsParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -817,12 +1320,19 @@ type ListRootsParams struct { } func (x *ListRootsParams) isParams() {} +func (x *ListRootsParams) isInputRequest() {} +func (x *ListRootsParams) isNil() bool { return x == nil } func (x *ListRootsParams) GetProgressToken() any { return getProgressToken(x) } func (x *ListRootsParams) SetProgressToken(t any) { setProgressToken(x, t) } // The client's response to a roots/list request from the server. This result // contains an array of Root objects, each representing a root directory or file // that the server can operate on. +// +// Deprecated: the roots feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type ListRootsResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -830,7 +1340,9 @@ type ListRootsResult struct { Roots []*Root `json:"roots"` } -func (*ListRootsResult) isResult() {} +func (*ListRootsResult) isResult() {} +func (x *ListRootsResult) isNil() bool { return x == nil } +func (*ListRootsResult) isInputResponse() {} type ListToolsParams struct { // This property is reserved by the protocol to allow clients and servers to @@ -842,15 +1354,18 @@ type ListToolsParams struct { } func (x *ListToolsParams) isParams() {} +func (x *ListToolsParams) isNil() bool { return x == nil } func (x *ListToolsParams) GetProgressToken() any { return getProgressToken(x) } func (x *ListToolsParams) SetProgressToken(t any) { setProgressToken(x, t) } func (x *ListToolsParams) cursorPtr() *string { return &x.Cursor } // The server's response to a tools/list request from the client. type ListToolsResult struct { + completeResultWithType // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` + Cacheable // An opaque token representing the pagination position after the last returned // result. If present, there may be more results available. NextCursor string `json:"nextCursor,omitempty"` @@ -858,14 +1373,27 @@ type ListToolsResult struct { } func (x *ListToolsResult) isResult() {} +func (x *ListToolsResult) isNil() bool { return x == nil } func (x *ListToolsResult) nextCursorPtr() *string { return &x.NextCursor } // The severity of a log message. // // These map to syslog message severities, as specified in RFC-5424: // https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type LoggingLevel string +// LoggingMessageParams holds the parameters for a notifications/message +// notification. +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type LoggingMessageParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -880,6 +1408,7 @@ type LoggingMessageParams struct { } func (x *LoggingMessageParams) isParams() {} +func (x *LoggingMessageParams) isNil() bool { return x == nil } func (x *LoggingMessageParams) GetProgressToken() any { return getProgressToken(x) } func (x *LoggingMessageParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -887,6 +1416,11 @@ func (x *LoggingMessageParams) SetProgressToken(t any) { setProgressToken(x, t) // // Keys not declared here are currently left unspecified by the spec and are up // to the client to interpret. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type ModelHint struct { // A hint for a model name. // @@ -913,6 +1447,11 @@ type ModelHint struct { // These preferences are always advisory. The client may ignore them. It is also // up to the client to decide how to interpret these preferences and how to // balance them against other considerations. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type ModelPreferences struct { // How much to prioritize cost when selecting a model. A value of 0 means cost // is not important, while a value of 1 means cost is the most important factor. @@ -942,6 +1481,7 @@ type PingParams struct { } func (x *PingParams) isParams() {} +func (x *PingParams) isNil() bool { return x == nil } func (x *PingParams) GetProgressToken() any { return getProgressToken(x) } func (x *PingParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -962,7 +1502,8 @@ type ProgressNotificationParams struct { Total float64 `json:"total,omitempty"` } -func (*ProgressNotificationParams) isParams() {} +func (x *ProgressNotificationParams) isParams() {} +func (x *ProgressNotificationParams) isNil() bool { return x == nil } // IconTheme specifies the theme an icon is designed for. type IconTheme string @@ -1032,6 +1573,7 @@ type PromptListChangedParams struct { } func (x *PromptListChangedParams) isParams() {} +func (x *PromptListChangedParams) isNil() bool { return x == nil } func (x *PromptListChangedParams) GetProgressToken() any { return getProgressToken(x) } func (x *PromptListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1070,9 +1612,16 @@ type ReadResourceParams struct { // The URI of the resource to read. The URI can use any protocol; it is up to // the server how to interpret it. URI string `json:"uri"` + + // InputResponses maps input request IDs to responses. Set it when the + // client retries a call that asked for input. See [CallToolResult.NeedsInput]. + InputResponses InputResponseMap `json:"inputResponses,omitempty"` + // RequestState is the opaque state from the previous input-required result. + RequestState string `json:"requestState,omitempty"` } func (x *ReadResourceParams) isParams() {} +func (x *ReadResourceParams) isNil() bool { return x == nil } func (x *ReadResourceParams) GetProgressToken() any { return getProgressToken(x) } func (x *ReadResourceParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1080,11 +1629,70 @@ func (x *ReadResourceParams) SetProgressToken(t any) { setProgressToken(x, t) } type ReadResourceResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. - Meta `json:"_meta,omitempty"` + Meta `json:"_meta,omitempty"` + Cacheable Contents []*ResourceContents `json:"contents"` + + // InputRequests is set when the result needs more client input. + // See [CallToolResult.InputRequests]. + InputRequests InputRequestMap `json:"inputRequests,omitempty"` + // RequestState is the opaque state for multi-round-trip retries. + // See [CallToolResult.RequestState]. + RequestState string `json:"requestState,omitempty"` + + // resultType records whether the call finished or needs more client input. + // See [CallToolResult.NeedsInput]. + resultType resultType } -func (*ReadResourceResult) isResult() {} +func (*ReadResourceResult) isResult() {} +func (x *ReadResourceResult) isNil() bool { return x == nil } + +func (r *ReadResourceResult) setResultType(rt resultType) { r.resultType = rt } +func (r *ReadResourceResult) requestState() string { return r.RequestState } +func (r *ReadResourceResult) inputRequests() map[string]InputRequest { + if r == nil { + return nil + } + return r.InputRequests +} +func (r *ReadResourceResult) hasContent() bool { return len(r.Contents) > 0 } + +// NeedsInput reports whether this result requires further client input. +// See [CallToolResult.NeedsInput] for details. +func (r *ReadResourceResult) NeedsInput() bool { return r.resultType == resultTypeInputRequired } + +func (x *ReadResourceResult) MarshalJSON() ([]byte, error) { + type res ReadResourceResult + type wire struct { + res + ResultType resultType `json:"resultType,omitempty"` + InputRequests json.RawMessage `json:"inputRequests,omitempty"` // shadows res.InputRequests + } + w := wire{res: res(*x), ResultType: x.resultType} + if x.InputRequests != nil { + ir, err := json.Marshal(x.InputRequests) + if err != nil { + return nil, err + } + w.InputRequests = ir + } + return json.Marshal(w) +} + +func (x *ReadResourceResult) UnmarshalJSON(data []byte) error { + type res ReadResourceResult + var wire struct { + res + ResultType resultType `json:"resultType"` + } + if err := internaljson.Unmarshal(data, &wire); err != nil { + return err + } + wire.res.resultType = wire.ResultType + *x = ReadResourceResult(wire.res) + return nil +} // A known resource that the server is capable of reading. type Resource struct { @@ -1129,6 +1737,7 @@ type ResourceListChangedParams struct { } func (x *ResourceListChangedParams) isParams() {} +func (x *ResourceListChangedParams) isNil() bool { return x == nil } func (x *ResourceListChangedParams) GetProgressToken() any { return getProgressToken(x) } func (x *ResourceListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1168,6 +1777,11 @@ type ResourceTemplate struct { type Role string // Represents a root directory or file that the server can operate on. +// +// Deprecated: the roots feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type Root struct { // See [specification/2025-06-18/basic/index#general-fields] for notes on _meta // usage. @@ -1182,6 +1796,13 @@ type Root struct { URI string `json:"uri"` } +// RootsListChangedParams holds parameters for a notifications/roots/list_changed +// notification. +// +// Deprecated: the roots feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type RootsListChangedParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -1189,6 +1810,7 @@ type RootsListChangedParams struct { } func (x *RootsListChangedParams) isParams() {} +func (x *RootsListChangedParams) isNil() bool { return x == nil } func (x *RootsListChangedParams) GetProgressToken() any { return getProgressToken(x) } func (x *RootsListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1196,6 +1818,11 @@ func (x *RootsListChangedParams) SetProgressToken(t any) { setProgressToken(x, t // below directly above ClientCapabilities. // SamplingCapabilities describes the client's support for sampling. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type SamplingCapabilities struct { // Context indicates the client supports includeContext values other than "none". Context *SamplingContextCapabilities `json:"context,omitempty"` @@ -1204,12 +1831,27 @@ type SamplingCapabilities struct { } // SamplingContextCapabilities indicates the client supports context inclusion. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type SamplingContextCapabilities struct{} // SamplingToolsCapabilities indicates the client supports tool use in sampling. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type SamplingToolsCapabilities struct{} // ToolChoice controls how the model uses tools during sampling. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type ToolChoice struct { // Mode controls tool invocation behavior: // - "auto": Model decides whether to use tools (default) @@ -1236,6 +1878,11 @@ type URLElicitationCapabilities struct{} // // For assistant messages, Content may be text, image, audio, or tool_use. // For user messages, Content may be text, image, audio, or tool_result. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type SamplingMessage struct { Content Content `json:"content"` Role Role `json:"role"` @@ -1261,6 +1908,12 @@ func (m *SamplingMessage) UnmarshalJSON(data []byte) error { return nil } +// SetLoggingLevelParams holds parameters for a logging/setLevel request. +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type SetLoggingLevelParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -1272,6 +1925,7 @@ type SetLoggingLevelParams struct { } func (x *SetLoggingLevelParams) isParams() {} +func (x *SetLoggingLevelParams) isNil() bool { return x == nil } func (x *SetLoggingLevelParams) GetProgressToken() any { return getProgressToken(x) } func (x *SetLoggingLevelParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1330,6 +1984,13 @@ type Tool struct { Icons []Icon `json:"icons,omitempty"` } +// hintomitempty is a compatibility parameter that restores the pre-1.7.0 +// behavior of [ToolAnnotations] JSON marshaling, where false-valued bare bool +// fields (ReadOnlyHint, IdempotentHint) were omitted from the output. +// See the documentation for the mcpgodebug package for instructions on how to +// enable it. +var hintomitempty = mcpgodebug.Value("hintomitempty") + // Additional properties describing a Tool to clients. // // NOTE: all properties in ToolAnnotations are hints. They are not @@ -1352,7 +2013,7 @@ type ToolAnnotations struct { // (This property is meaningful only when ReadOnlyHint == false.) // // Default: false - IdempotentHint bool `json:"idempotentHint,omitempty"` + IdempotentHint bool `json:"idempotentHint"` // If true, this tool may interact with an "open world" of external entities. If // false, the tool's domain of interaction is closed. For example, the world of // a web search tool is open, whereas that of a memory tool is not. @@ -1362,11 +2023,30 @@ type ToolAnnotations struct { // If true, the tool does not modify its environment. // // Default: false - ReadOnlyHint bool `json:"readOnlyHint,omitempty"` + ReadOnlyHint bool `json:"readOnlyHint"` // A human-readable title for the tool. Title string `json:"title,omitempty"` } +// MarshalJSON implements [json.Marshaler] for ToolAnnotations. +// +// To restore the previous behavior where false-valued ReadOnlyHint and +// IdempotentHint were omitted, set MCPGODEBUG=hintomitempty=1. +func (t ToolAnnotations) MarshalJSON() ([]byte, error) { + if hintomitempty == "1" { + type compat struct { + DestructiveHint *bool `json:"destructiveHint,omitempty"` + IdempotentHint bool `json:"idempotentHint,omitempty"` + OpenWorldHint *bool `json:"openWorldHint,omitempty"` + ReadOnlyHint bool `json:"readOnlyHint,omitempty"` + Title string `json:"title,omitempty"` + } + return json.Marshal(compat(t)) + } + type nomethod ToolAnnotations + return json.Marshal(nomethod(t)) +} + type ToolListChangedParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -1374,6 +2054,7 @@ type ToolListChangedParams struct { } func (x *ToolListChangedParams) isParams() {} +func (x *ToolListChangedParams) isNil() bool { return x == nil } func (x *ToolListChangedParams) GetProgressToken() any { return getProgressToken(x) } func (x *ToolListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1387,7 +2068,8 @@ type SubscribeParams struct { URI string `json:"uri"` } -func (*SubscribeParams) isParams() {} +func (x *SubscribeParams) isParams() {} +func (x *SubscribeParams) isNil() bool { return x == nil } // Sent from the client to request cancellation of resources/updated // notifications from the server. This should follow a previous @@ -1400,7 +2082,8 @@ type UnsubscribeParams struct { URI string `json:"uri"` } -func (*UnsubscribeParams) isParams() {} +func (x *UnsubscribeParams) isParams() {} +func (x *UnsubscribeParams) isNil() bool { return x == nil } // A notification from the server to the client, informing it that a resource // has changed and may need to be read again. This should only be sent if the @@ -1413,7 +2096,64 @@ type ResourceUpdatedNotificationParams struct { URI string `json:"uri"` } -func (*ResourceUpdatedNotificationParams) isParams() {} +func (x *ResourceUpdatedNotificationParams) isParams() {} +func (x *ResourceUpdatedNotificationParams) isNil() bool { return x == nil } + +// NotificationSubscriptions describes the set of server-to-client +// notifications a client wishes to receive on a [SubscriptionsListenParams] +// stream. Each field is an explicit opt-in: a server MUST NOT push +// notifications of a type the client did not request. +type NotificationSubscriptions struct { + // ToolsListChanged opts in to "notifications/tools/list_changed". + ToolsListChanged bool `json:"toolsListChanged,omitempty"` + // PromptsListChanged opts in to "notifications/prompts/list_changed". + PromptsListChanged bool `json:"promptsListChanged,omitempty"` + // ResourcesListChanged opts in to "notifications/resources/list_changed". + ResourcesListChanged bool `json:"resourcesListChanged,omitempty"` + // ResourceSubscriptions enumerates the resource URIs for which the client + // wants "notifications/resources/updated". Replaces the legacy + // resources/subscribe RPC. + ResourceSubscriptions []string `json:"resourceSubscriptions,omitempty"` +} + +// SubscriptionsListenParams are the parameters for the +// "subscriptions/listen" RPC. +type SubscriptionsListenParams struct { + // Meta carries the per-request `_meta` triple. + Meta `json:"_meta,omitempty"` + // Notifications declares which notification types the client wants to + // receive on this stream. + Notifications *NotificationSubscriptions `json:"notifications"` +} + +func (x *SubscriptionsListenParams) isParams() {} +func (x *SubscriptionsListenParams) isNil() bool { return x == nil } + +// SubscriptionsAcknowledgedParams are the parameters for the +// "notifications/subscriptions/acknowledged" notification, which the server +// MUST send as the first message on a subscriptions/listen stream. It carries +// the subset of the requested [NotificationSubscriptions] that the server has +// agreed to honor. +type SubscriptionsAcknowledgedParams struct { + Meta `json:"_meta,omitempty"` + Notifications NotificationSubscriptions `json:"notifications"` +} + +func (x *SubscriptionsAcknowledgedParams) isParams() {} +func (x *SubscriptionsAcknowledgedParams) isNil() bool { return x == nil } + +// SubscriptionsListenResult is the response to a "subscriptions/listen" +// request, signalling that the subscription has ended gracefully (for example, +// during server shutdown). Because the listen stream is long-lived, this +// result is sent only when the server tears the subscription down; an abrupt +// transport close carries no response. +type SubscriptionsListenResult struct { + completeResultWithType + Meta `json:"_meta"` +} + +func (*SubscriptionsListenResult) isResult() {} +func (x *SubscriptionsListenResult) isNil() bool { return x == nil } // TODO(jba): add CompleteRequest and related types. @@ -1452,11 +2192,28 @@ type ElicitParams struct { ElicitationID string `json:"elicitationId,omitempty"` } -func (x *ElicitParams) isParams() {} +func (x *ElicitParams) isParams() {} +func (x *ElicitParams) isInputRequest() {} +func (x *ElicitParams) isNil() bool { return x == nil } func (x *ElicitParams) GetProgressToken() any { return getProgressToken(x) } func (x *ElicitParams) SetProgressToken(t any) { setProgressToken(x, t) } +// inferElicitMode returns x with Mode populated by inference if it was empty. +// Mode is inferred as "url" when URL or ElicitationID is set, otherwise "form". +func (x *ElicitParams) inferElicitMode() *ElicitParams { + if x == nil || x.Mode != "" { + return x + } + x2 := *x + if x.URL != "" || x.ElicitationID != "" { + x2.Mode = "url" + } else { + x2.Mode = "form" + } + return &x2 +} + // The client's response to an elicitation/create request from the server. type ElicitResult struct { // This property is reserved by the protocol to allow clients and servers to @@ -1472,7 +2229,9 @@ type ElicitResult struct { Content map[string]any `json:"content,omitempty"` } -func (*ElicitResult) isResult() {} +func (*ElicitResult) isResult() {} +func (x *ElicitResult) isNil() bool { return x == nil } +func (*ElicitResult) isInputResponse() {} // ElicitationCompleteParams is sent from the server to the client, informing it that an out-of-band elicitation interaction has completed. type ElicitationCompleteParams struct { @@ -1484,18 +2243,21 @@ type ElicitationCompleteParams struct { ElicitationID string `json:"elicitationId"` } -func (*ElicitationCompleteParams) isParams() {} +func (x *ElicitationCompleteParams) isParams() {} +func (x *ElicitationCompleteParams) isNil() bool { return x == nil } -// An Implementation describes the name and version of an MCP implementation, with an optional -// title for UI representation. +// An Implementation describes the name and version of an MCP implementation, with +// optional display metadata. type Implementation struct { // Intended for programmatic or logical use, but used as a display name in past // specs or fallback (if title isn't present). Name string `json:"name"` // Intended for UI and end-user contexts — optimized to be human-readable and // easily understood, even by those unfamiliar with domain-specific terminology. - Title string `json:"title,omitempty"` - Version string `json:"version"` + Title string `json:"title,omitempty"` + // A human-readable description of the implementation. + Description string `json:"description,omitempty"` + Version string `json:"version"` // WebsiteURL for the server, if any. WebsiteURL string `json:"websiteUrl,omitempty"` // Icons for the Server, if any. @@ -1506,6 +2268,11 @@ type Implementation struct { type CompletionCapabilities struct{} // LoggingCapabilities describes the server's support for sending log messages to the client. +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type LoggingCapabilities struct{} // PromptCapabilities describes the server's support for prompts. @@ -1516,7 +2283,7 @@ type PromptCapabilities struct { // ResourceCapabilities describes the server's support for resources. type ResourceCapabilities struct { - // ListChanged reports whether the client supports notifications for + // ListChanged reports whether this server supports notifications for // changes to the resource list. ListChanged bool `json:"listChanged,omitempty"` // Subscribe reports whether this server supports subscribing to resource @@ -1526,7 +2293,7 @@ type ResourceCapabilities struct { // ToolCapabilities describes the server's support for tools. type ToolCapabilities struct { - // ListChanged reports whether the client supports notifications for + // ListChanged reports whether this server supports notifications for // changes to the tool list. ListChanged bool `json:"listChanged,omitempty"` } @@ -1549,6 +2316,11 @@ type ServerCapabilities struct { // suggestions. Completions *CompletionCapabilities `json:"completions,omitempty"` // Logging is present if the server supports log messages. + // + // Deprecated: the logging feature is deprecated as of protocol version + // 2026-07-28 (SEP-2577). It remains functional during the deprecation + // window (at least twelve months). See + // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. Logging *LoggingCapabilities `json:"logging,omitempty"` // Prompts is present if the server supports prompts. Prompts *PromptCapabilities `json:"prompts,omitempty"` @@ -1590,6 +2362,7 @@ const ( methodCallTool = "tools/call" notificationCancelled = "notifications/cancelled" methodComplete = "completion/complete" + methodDiscover = "server/discover" methodCreateMessage = "sampling/createMessage" methodElicit = "elicitation/create" notificationElicitationComplete = "notifications/elicitation/complete" @@ -1611,6 +2384,60 @@ const ( notificationRootsListChanged = "notifications/roots/list_changed" methodSetLevel = "logging/setLevel" methodSubscribe = "resources/subscribe" + methodSubscriptionsListen = "subscriptions/listen" notificationToolListChanged = "notifications/tools/list_changed" methodUnsubscribe = "resources/unsubscribe" + notificationSubscriptionsAck = "notifications/subscriptions/acknowledged" ) + +// Per-request _meta field names for the >= 2026-07-28 protocol version. +// +// These keys appear inside a Params._meta map and carry information that +// previously came from the initialization handshake (SEP-2575). +const ( + // MetaKeyProtocolVersion identifies the MCP protocol version that the + // request follows. + MetaKeyProtocolVersion = "io.modelcontextprotocol/protocolVersion" + // MetaKeyClientInfo carries the client's [Implementation]. + MetaKeyClientInfo = "io.modelcontextprotocol/clientInfo" + // MetaKeyServerInfo carries the server's [Implementation] on responses. + MetaKeyServerInfo = "io.modelcontextprotocol/serverInfo" + // MetaKeyClientCapabilities carries the client's [ClientCapabilities]. + MetaKeyClientCapabilities = "io.modelcontextprotocol/clientCapabilities" + // MetaKeyLogLevel identifies the desired log level for the request. + // + // Deprecated: the logging feature is deprecated as of protocol version + // 2026-07-28 (SEP-2577). It remains functional during the deprecation + // window (at least twelve months). See + // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. + MetaKeyLogLevel = "io.modelcontextprotocol/logLevel" + // MetaKeySubscriptionID identifies the subscriptions/listen request that an + // out-of-band notification belongs to. + MetaKeySubscriptionID = "io.modelcontextprotocol/subscriptionId" +) + +// UnsupportedProtocolVersionData is the SEP-2575 payload carried in the +// `data` field of a JSON-RPC error response with code +// [CodeUnsupportedProtocolVersion]. The server uses it to advertise which +// versions it supports so the client can pick a mutually supported one. +type UnsupportedProtocolVersionData struct { + // Supported is the list of protocol versions the server supports. + Supported []string `json:"supported"` + // Requested is the protocol version the client asked for. + Requested string `json:"requested"` +} + +// MissingRequiredClientCapabilityData is the SEP-2575 payload carried in the +// `data` field of a JSON-RPC error response with code +// [CodeMissingRequiredClientCapabilities]. The server uses it to indicate +// which client capabilities are required to process the request but were not +// declared by the client in its per-request `_meta` field. +// +// Handlers that require a specific client capability should inspect the +// per-request [ServerRequest.ClientCapabilities] and return a JSON-RPC error +// populated with this structure when the required capability is missing. +type MissingRequiredClientCapabilityData struct { + // RequiredCapabilities is the set of capabilities the server requires + // from the client to process the request. + RequiredCapabilities *ClientCapabilities `json:"requiredCapabilities"` +} diff --git a/mcp/protocol_test.go b/mcp/protocol_test.go index 751d081..4f0c345 100644 --- a/mcp/protocol_test.go +++ b/mcp/protocol_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" ) func TestParamsMeta(t *testing.T) { @@ -491,7 +492,7 @@ func TestCompleteResult(t *testing.T) { if err := json.Unmarshal([]byte(test.in), &got); err != nil { t.Fatalf("json.Unmarshal(CompleteResult) failed: %v", err) } - if diff := cmp.Diff(test.want, got); diff != "" { + if diff := cmp.Diff(test.want, got, cmpopts.IgnoreUnexported(CompleteResult{})); diff != "" { t.Errorf("CompleteResult unmarshal mismatch (-want +got):\n%s", diff) } }) @@ -1151,6 +1152,108 @@ func TestToWithTools_Conversion(t *testing.T) { } } +func TestInputRequestMapJSON(t *testing.T) { + t.Run("nil is omitted from JSON", func(t *testing.T) { + result := CallToolResult{Content: []Content{&TextContent{Text: "ok"}}} + data, err := json.Marshal(&result) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if _, ok := raw["inputRequests"]; ok { + t.Errorf("nil InputRequests should be omitted, got %s", raw["inputRequests"]) + } + }) + + t.Run("non-nil empty round-trips", func(t *testing.T) { + result := CallToolResult{ + Content: []Content{&TextContent{Text: "ok"}}, + InputRequests: InputRequestMap{}, + } + data, err := json.Marshal(&result) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if string(raw["inputRequests"]) != "{}" { + t.Errorf("empty InputRequests should marshal to {}, got %s", raw["inputRequests"]) + } + var got CallToolResult + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.InputRequests == nil { + t.Error("empty InputRequests should round-trip as non-nil") + } + }) + + t.Run("populated round-trips", func(t *testing.T) { + result := CallToolResult{ + Content: []Content{&TextContent{Text: "ok"}}, + InputRequests: InputRequestMap{ + "r1": &ElicitParams{Message: "confirm?"}, + }, + } + data, err := json.Marshal(&result) + if err != nil { + t.Fatal(err) + } + var got CallToolResult + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.InputRequests == nil { + t.Fatal("InputRequests should not be nil after round-trip") + } + if _, ok := got.InputRequests["r1"]; !ok { + t.Error("expected key r1 in InputRequests") + } + }) +} + +func TestInputResponseMapJSON(t *testing.T) { + tests := []struct { + name string + value InputResponse + check func(t *testing.T, got InputResponse) + }{ + { + name: "elicit", + value: &ElicitResult{Action: "accept", Content: map[string]any{"ok": true}}, + }, + { + name: "sampling", + value: &CreateMessageWithToolsResult{Role: "assistant", Model: "test-model", Content: []Content{&TextContent{Text: "hello"}}}, + }, + { + name: "list-roots", + value: &ListRootsResult{Roots: []*Root{{URI: "file:///tmp", Name: "tmp"}}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key := tt.name + data, err := json.Marshal(InputResponseMap{key: tt.value}) + if err != nil { + t.Fatal(err) + } + var got InputResponseMap + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(tt.value, got[key], ctrCmpOpts...); diff != "" { + t.Errorf("mismatch (-want, +got):\n%s", diff) + } + }) + } +} + func TestContentUnmarshal(t *testing.T) { // Verify that types with a Content field round-trip properly. roundtrip := func(in, out any) { @@ -1182,7 +1285,7 @@ func TestContentUnmarshal(t *testing.T) { Meta: Meta{"m": true}, Content: content, IsError: true, - StructuredContent: 3.0, + StructuredContent: json.Number("3"), } var gotf CallToolResult roundtrip(ctrf, &gotf) @@ -1194,3 +1297,78 @@ func TestContentUnmarshal(t *testing.T) { var gotpm PromptMessage roundtrip(pm, &gotpm) } + +func TestCallToolResultStructuredContentFloat64Compatibility(t *testing.T) { + prev := structuredcontentfloat64 + structuredcontentfloat64 = "1" + t.Cleanup(func() { structuredcontentfloat64 = prev }) + + var got CallToolResult + if err := json.Unmarshal([]byte(`{"content":[],"structuredContent":{"id":9007199254740993}}`), &got); err != nil { + t.Fatal(err) + } + structured, ok := got.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("StructuredContent type = %T, want map[string]any", got.StructuredContent) + } + if got := structured["id"]; got != float64(9007199254740992) { + t.Errorf("id = %v (%T), want rounded float64 value", got, got) + } +} + +func TestToolAnnotations_MarshalJSON(t *testing.T) { + boolPtr := func(b bool) *bool { return &b } + + tests := []struct { + name string + in ToolAnnotations + want string + }{ + { + name: "ZeroValue", + in: ToolAnnotations{}, + want: `{"idempotentHint":false,"readOnlyHint":false}`, + }, + { + name: "AllFalse", + in: ToolAnnotations{ + DestructiveHint: boolPtr(false), + IdempotentHint: false, + OpenWorldHint: boolPtr(false), + ReadOnlyHint: false, + }, + want: `{"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false}`, + }, + { + name: "AllTrue", + in: ToolAnnotations{ + DestructiveHint: boolPtr(true), + IdempotentHint: true, + OpenWorldHint: boolPtr(true), + ReadOnlyHint: true, + Title: "my tool", + }, + want: `{"destructiveHint":true,"idempotentHint":true,"openWorldHint":true,"readOnlyHint":true,"title":"my tool"}`, + }, + { + name: "MixedValues", + in: ToolAnnotations{ + ReadOnlyHint: true, + Title: "read tool", + }, + want: `{"idempotentHint":false,"readOnlyHint":true,"title":"read tool"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(tt.in) + if err != nil { + t.Fatalf("json.Marshal(%v) failed: %v", tt.in, err) + } + if diff := cmp.Diff(tt.want, string(got)); diff != "" { + t.Errorf("json.Marshal() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/mcp/requests.go b/mcp/requests.go index 4280941..64414ca 100644 --- a/mcp/requests.go +++ b/mcp/requests.go @@ -19,12 +19,14 @@ type ( ReadResourceRequest = ServerRequest[*ReadResourceParams] RootsListChangedRequest = ServerRequest[*RootsListChangedParams] SubscribeRequest = ServerRequest[*SubscribeParams] + SubscriptionsListenRequest = ServerRequest[*SubscriptionsListenParams] UnsubscribeRequest = ServerRequest[*UnsubscribeParams] ) type ( CreateMessageRequest = ClientRequest[*CreateMessageParams] CreateMessageWithToolsRequest = ClientRequest[*CreateMessageWithToolsParams] + DiscoverRequest = ClientRequest[*DiscoverParams] ElicitRequest = ClientRequest[*ElicitParams] initializedClientRequest = ClientRequest[*InitializedParams] InitializeRequest = ClientRequest[*InitializeParams] diff --git a/mcp/resource.go b/mcp/resource.go index bc4b3cb..fff38a2 100644 --- a/mcp/resource.go +++ b/mcp/resource.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strings" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" "github.com/modelcontextprotocol/go-sdk/internal/util" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/yosida95/uritemplate/v3" @@ -37,8 +38,25 @@ type serverResourceTemplate struct { // If it cannot find the resource, it should return the result of calling [ResourceNotFoundError]. type ResourceHandler func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error) +// customresnotfounderrcode is a compatibility parameter that restores the +// pre-1.7.0 behavior of [ResourceNotFoundError] and [CodeResourceNotFound], +// where the error code was a custom -32002. See the documentation for the mcpgodebug +// package for instructions on how to enable it. +// The option will be removed in the future version of the SDK. +var customresnotfounderrcode = mcpgodebug.Value("customresnotfounderrcode") + +func init() { + if customresnotfounderrcode == "1" { + CodeResourceNotFound = -32002 + } +} + // ResourceNotFoundError returns an error indicating that a resource being read could // not be found. +// +// By default, the error code is -32602 (Invalid Params), as specified in the +// MCP specification (SEP-2164). To restore the pre-1.7.0 release behavior where the +// error code was -32002, set MCPGODEBUG=customresnotfounderrcode=1. func ResourceNotFoundError(uri string) error { return &jsonrpc.Error{ Code: CodeResourceNotFound, diff --git a/mcp/sampling_test.go b/mcp/sampling_test.go index bdffd45..7a3ecab 100644 --- a/mcp/sampling_test.go +++ b/mcp/sampling_test.go @@ -2,6 +2,8 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +//lint:file-ignore SA1019 tests exercise deprecated SEP-2577 sampling APIs. + package mcp // TODO: move other sampling-related tests to this file. @@ -51,7 +53,7 @@ func TestSamplingWithTools_ToolUse(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -122,7 +124,7 @@ func TestSamplingWithTools_ToolResult(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -286,7 +288,7 @@ func TestSamplingWithTools_ToolResultWithError(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -348,7 +350,7 @@ func TestSamplingWithTools_ParallelToolCalls(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -418,7 +420,7 @@ func TestCreateMessage_MultipleContentError(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } diff --git a/mcp/server.go b/mcp/server.go index 97b5d44..569abab 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -27,6 +27,7 @@ import ( "github.com/google/jsonschema-go/jsonschema" internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" "github.com/modelcontextprotocol/go-sdk/internal/util" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/yosida95/uritemplate/v3" @@ -43,17 +44,29 @@ type Server struct { // fixed at creation impl *Implementation opts ServerOptions + // protocolVersions is the descending-ordered list of protocol versions + // this server advertises and negotiates: [supportedProtocolVersions], + // narrowed by [ServerOptions.SupportedProtocolVersions]. + protocolVersions []string - mu sync.Mutex - prompts *featureSet[*serverPrompt] - tools *featureSet[*serverTool] - resources *featureSet[*serverResource] - resourceTemplates *featureSet[*serverResourceTemplate] - sessions []*ServerSession - sendingMethodHandler_ MethodHandler - receivingMethodHandler_ MethodHandler - resourceSubscriptions map[string]map[*ServerSession]bool // uri -> session -> bool - pendingNotifications map[string]*time.Timer // notification name -> timer for pending notification send + mu sync.Mutex + prompts *featureSet[*serverPrompt] + tools *featureSet[*serverTool] + resources *featureSet[*serverResource] + resourceTemplates *featureSet[*serverResourceTemplate] + sessions []*ServerSession + sendingMethodHandler_ MethodHandler + receivingMethodHandler_ MethodHandler + toolChangeSubscriptions map[*ServerSession]jsonrpc.ID // session -> requestID for "tools/changed" + promptChangeSubscriptions map[*ServerSession]jsonrpc.ID // session -> requestID for "prompts/changed" + resourceChangeSubscriptions map[*ServerSession]jsonrpc.ID // session -> requestID for "resources/changed" + resourceSubscriptions map[string]map[*ServerSession]jsonrpc.ID // uri -> session -> requestID + pendingNotifications map[string]*time.Timer // notification name -> timer for pending notification send + // receiveMethods is the merged map of methods this server may receive + // from a client: it always contains the standard server methods (from + // serverMethodInfos) plus any custom methods registered via + // [AddReceivingCustomMethod]. + receiveMethods map[string]methodInfo } // ServerOptions is used to configure behavior of the server. @@ -70,6 +83,12 @@ type ServerOptions struct { // If zero, defaults to [DefaultPageSize]. PageSize int // If non-nil, called when "notifications/roots/list_changed" is received. + // + // Deprecated: the roots feature is deprecated as of protocol version + // 2026-07-28 (SEP-2577). It remains functional during the deprecation + // window (at least twelve months). Migrate to passing paths via tool + // parameters, resource URIs, or configuration. See + // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. RootsListChangedHandler func(context.Context, *RootsListChangedRequest) // If non-nil, called when "notifications/progress" is received. ProgressNotificationHandler func(context.Context, *ProgressNotificationServerRequest) @@ -79,6 +98,13 @@ type ServerOptions struct { // If the peer fails to respond to pings originating from the keepalive check, // the session is automatically closed. KeepAlive time.Duration + // KeepAliveFailureThreshold is the number of consecutive keepalive ping + // failures tolerated before the session is closed. A value of 0 or 1 + // closes the session on the first failure (the default). Higher values + // align with the spec's "multiple failed pings MAY trigger a connection + // reset" guidance, letting a transient miss pass without tearing down an + // otherwise live session. Has no effect unless KeepAlive is non-zero. + KeepAliveFailureThreshold int // Function called when a client session subscribes to a resource. SubscribeHandler func(context.Context, *SubscribeRequest) error // Function called when a client session unsubscribes from a resource. @@ -143,7 +169,35 @@ type ServerOptions struct { // // As a special case, if GetSessionID returns the empty string, the // Mcp-Session-Id header will not be set. + // + // GetSessionID is not consulted when [StreamableHTTPOptions.Stateless] is + // true, since stateless servers do not maintain sessions. GetSessionID func() string + + // SetCacheable, if non-nil, decides the cache-control fields (ttlMs and + // cacheScope) of every result that carries them: server/discover, the + // four list methods, and resources/read. + // + // It is called once per such result, after the corresponding handler has + // returned, with c holding the values that handler produced. + SetCacheable func(ctx context.Context, req Request, c *Cacheable) + + // SupportedProtocolVersions, if non-empty, restricts the protocol versions + // this server advertises. If empty, every version returned by + // [SupportedProtocolVersions] is used. + // The list can only narrow support, never widen it. + // A request using the >= 2026-07-28 protocol at an excluded version is + // rejected with error code [CodeUnsupportedProtocolVersion]. + // + // The legacy initialize handshake never rejects. The lifecycle spec + // requires the server to answer a request it does not support with a + // version it does, so an excluded version is answered with the newest + // listed version that handshake can negotiate, and the client is expected + // to disconnect when it cannot speak it. A list holding no such version + // (one restricted to 2026-07-28 and later) is answered with 2025-11-25, + // the newest handshake-era version, so that the client disconnects rather + // than reading the answer as a negotiation into the new protocol. + SupportedProtocolVersions []string } // NewServer creates a new MCP server. The resulting server has no features: @@ -180,22 +234,44 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { opts.GetSessionID = rand.Text } + protocolVersions := SupportedProtocolVersions() + if len(opts.SupportedProtocolVersions) > 0 { + for _, v := range opts.SupportedProtocolVersions { + if !slices.Contains(supportedProtocolVersions, v) { + panic(fmt.Errorf("unsupported protocol version %q", v)) + } + } + protocolVersions = slices.DeleteFunc(protocolVersions, func(v string) bool { + return !slices.Contains(opts.SupportedProtocolVersions, v) + }) + } + if opts.Logger == nil { // ensure we have a logger opts.Logger = ensureLogger(nil) } - return &Server{ - impl: impl, - opts: opts, - prompts: newFeatureSet(func(p *serverPrompt) string { return p.prompt.Name }), - tools: newFeatureSet(func(t *serverTool) string { return t.tool.Name }), - resources: newFeatureSet(func(r *serverResource) string { return r.resource.URI }), - resourceTemplates: newFeatureSet(func(t *serverResourceTemplate) string { return t.resourceTemplate.URITemplate }), - sendingMethodHandler_: defaultSendingMethodHandler, - receivingMethodHandler_: defaultReceivingMethodHandler[*ServerSession], - resourceSubscriptions: make(map[string]map[*ServerSession]bool), - pendingNotifications: make(map[string]*time.Timer), + receiveMethods := make(map[string]methodInfo, len(serverMethodInfos)) + maps.Copy(receiveMethods, serverMethodInfos) + + s := &Server{ + impl: impl, + opts: opts, + protocolVersions: protocolVersions, + prompts: newFeatureSet(func(p *serverPrompt) string { return p.prompt.Name }), + tools: newFeatureSet(func(t *serverTool) string { return t.tool.Name }), + resources: newFeatureSet(func(r *serverResource) string { return r.resource.URI }), + resourceTemplates: newFeatureSet(func(t *serverResourceTemplate) string { return t.resourceTemplate.URITemplate }), + sendingMethodHandler_: defaultSendingMethodHandler, + receivingMethodHandler_: defaultReceivingMethodHandler[*ServerSession], + toolChangeSubscriptions: make(map[*ServerSession]jsonrpc.ID), + promptChangeSubscriptions: make(map[*ServerSession]jsonrpc.ID), + resourceChangeSubscriptions: make(map[*ServerSession]jsonrpc.ID), + resourceSubscriptions: make(map[string]map[*ServerSession]jsonrpc.ID), + pendingNotifications: make(map[string]*time.Timer), + receiveMethods: receiveMethods, } + s.AddReceivingMiddleware(serverMultiRoundTripMiddleware()) + return s } // AddPrompt adds a [Prompt] to the server, or replaces one with the same name. @@ -220,7 +296,8 @@ func (s *Server) RemovePrompts(names ...string) { // that takes no input, or one where any input is valid, set [Tool.InputSchema] to // `{"type": "object"}`, using your preferred library or `json.RawMessage`. // -// If present, [Tool.OutputSchema] must also have type "object". +// If present, [Tool.OutputSchema] may be any valid JSON Schema (object, array, +// primitive, or composition). // // When the handler is invoked as part of a CallTool request, req.Params.Arguments // will be a json.RawMessage. @@ -247,6 +324,9 @@ func (s *Server) AddTool(t *Tool, h ToolHandler) { panic(fmt.Errorf("AddTool %q: missing input schema", t.Name)) } if s, ok := t.InputSchema.(*jsonschema.Schema); ok { + if s == nil { + panic(fmt.Errorf("AddTool %q: input schema is nil", t.Name)) + } if s.Type != "object" { panic(fmt.Errorf(`AddTool %q: input schema must have type "object"`, t.Name)) } @@ -261,19 +341,19 @@ func (s *Server) AddTool(t *Tool, h ToolHandler) { } if t.OutputSchema != nil { if s, ok := t.OutputSchema.(*jsonschema.Schema); ok { - if s.Type != "object" { - panic(fmt.Errorf(`AddTool %q: output schema must have type "object"`, t.Name)) + if s == nil { + panic(fmt.Errorf("AddTool %q: output schema is nil", t.Name)) } } else { - var m map[string]any + var m any if err := remarshal(t.OutputSchema, &m); err != nil { - panic(fmt.Errorf("AddTool %q: can't marshal output schema to a JSON object: %v", t.Name, err)) - } - if typ := m["type"]; typ != "object" { - panic(fmt.Errorf(`AddTool %q: output schema must have type "object" (got %v)`, t.Name, typ)) + panic(fmt.Errorf("AddTool %q: can't marshal output schema to JSON: %v", t.Name, err)) } } } + if err := validateParamHeaderAnnotations(t); err != nil { + panic(fmt.Errorf("AddTool %q: invalid parameter header annotations: %v", t.Name, err)) + } st := &serverTool{tool: t, handler: h} // Assume there was a change, since add replaces existing tools. // (It's possible a tool was replaced with an identical one, but not worth checking.) @@ -319,7 +399,7 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCa } // Validate input and apply defaults. var err error - input, err = applySchema(input, inputResolved) + input, err = applySchema(input, inputResolved, false) if err != nil { var errRes CallToolResult errRes.SetError(fmt.Errorf("validating \"arguments\": %v", err)) @@ -358,8 +438,12 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCa } // Marshal the output and put the RawMessage in the StructuredContent field. + // Skip when the handler returned input requests (multi round-trip): content and + // inputRequests are mutually exclusive on the wire. var outval any = out - if elemZero != nil { + if res.InputRequests != nil { + outval = nil + } else if elemZero != nil { // Avoid typed nil, which will serialize as JSON null. // Instead, use the zero value of the unpointered type. var z Out @@ -377,7 +461,7 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCa // // We validate against the JSON, rather than the output value, as // some types may have custom JSON marshalling (issue #447). - outJSON, err = applySchema(outJSON, outputResolved) + outJSON, err = applySchema(outJSON, outputResolved, true) if err != nil { return nil, fmt.Errorf("validating tool output: %w", err) } @@ -386,10 +470,18 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCa // If the Content field isn't being used, return the serialized JSON in a // TextContent block, as the spec suggests: // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content. + // + // Ensure a serialized-JSON TextContent fallback is present in case of servers using array + // or primitive structuredContent, so that pre-SEP-2106 clients can recover the structured + // payload from unstructured content. if res.Content == nil { res.Content = []Content{&TextContent{ Text: string(outJSON), }} + } else if !isObjectJSON(outJSON) { + res.Content = append(res.Content, &TextContent{ + Text: string(outJSON), + }) } } return res, nil @@ -437,6 +529,9 @@ func setSchema[T any](sfield *any, rfield **jsonschema.Resolved, cache *SchemaCa if err != nil { return zero, err } + if internalSchema == nil { + return zero, fmt.Errorf("schema is nil for type %v", rt) + } *sfield = internalSchema resolved, err := internalSchema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) @@ -466,6 +561,10 @@ func setSchema[T any](sfield *any, rfield **jsonschema.Resolved, cache *SchemaCa } } + if internalSchema == nil { + return zero, fmt.Errorf("schema is nil for type %v", rt) + } + resolved, err := internalSchema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) if err != nil { return zero, err @@ -493,9 +592,10 @@ func setSchema[T any](sfield *any, rfield **jsonschema.Resolved, cache *SchemaCa // empty object schema value. // // If the tool's output schema is nil, and the Out type is not 'any', the -// output schema is set to the schema inferred from the Out type argument, -// which must also be a map or struct. If the Out type is 'any', the output -// schema is omitted. +// output schema is set to the schema inferred from the Out type argument. +// Per SEP-2106, the Out type may be any Go type whose inferred schema is a +// valid JSON Schema (struct, map, slice, primitive, etc.). If the Out type is +// 'any', the output schema is omitted. // // Unlike [Server.AddTool], AddTool does a lot automatically, and forces // tools to conform to the MCP spec. See [ToolHandlerFor] for a detailed @@ -604,19 +704,36 @@ func (s *Server) capabilities() *ServerCapabilities { return caps } +// disablecompleteparamsvalidation is a compatibility parameter that restores +// the previous behavior of [Server.complete], where required fields on +// [CompleteParams] were not validated before dispatching to the completion +// handler. See the documentation for the mcpgodebug package for instructions +// how to enable it. +// The option will be removed in a future version of the SDK. +var disablecompleteparamsvalidation = mcpgodebug.Value("disablecompleteparamsvalidation") + func (s *Server) complete(ctx context.Context, req *CompleteRequest) (*CompleteResult, error) { + if disablecompleteparamsvalidation != "1" { + if req.Params.Ref == nil { + return nil, fmt.Errorf("%w: missing required 'ref' field", jsonrpc2.ErrInvalidParams) + } + if req.Params.Argument.Name == "" { + return nil, fmt.Errorf("%w: missing required 'argument.name' field", jsonrpc2.ErrInvalidParams) + } + } if s.opts.CompletionHandler == nil { return nil, jsonrpc2.ErrMethodNotFound } return s.opts.CompletionHandler(ctx, req) } -// Map from notification name to its corresponding params. The params have no fields, -// so a single struct can be reused. -var changeNotificationParams = map[string]Params{ - notificationToolListChanged: &ToolListChangedParams{}, - notificationPromptListChanged: &PromptListChangedParams{}, - notificationResourceListChanged: &ResourceListChangedParams{}, +// Map from notification name to a function creating its corresponding Params. +// We need to create a fresh one each time to add the jsonrpc ID. See +// [injectMetaSubscriptionID]. +var changeNotificationParams = map[string]func() Params{ + notificationToolListChanged: func() Params { return &ToolListChangedParams{} }, + notificationPromptListChanged: func() Params { return &PromptListChangedParams{} }, + notificationResourceListChanged: func() Params { return &ResourceListChangedParams{} }, } // How long to wait before sending a change notification. @@ -650,12 +767,75 @@ func (s *Server) changeAndNotify(notification string, change func() bool) { // notifySessions sends the notification n to all existing sessions. // It is called asynchronously by changeAndNotify. +// +// Legacy (pre-SEP-2575) sessions receive the notification on the shared +// session channel. Sessions speaking the new protocol receive it only if they +// have an active subscriptions/listen stream that opted in to this +// notification type. func (s *Server) notifySessions(n string) { s.mu.Lock() - sessions := slices.Clone(s.sessions) s.pendingNotifications[n] = nil + // Legacy (pre-SEP-2575) sessions receive list-changed notifications on the + // shared session channel without opt-in; collect them while we hold the lock. + var legacySessions []*ServerSession + for _, sess := range s.sessions { + if sess.InitializeParams().isNil() || sess.InitializeParams().ProtocolVersion < protocolVersion20260728 { + legacySessions = append(legacySessions, sess) + } + } + var subscribers map[*ServerSession]jsonrpc.ID + switch n { + case notificationToolListChanged: + subscribers = maps.Clone(s.toolChangeSubscriptions) + case notificationPromptListChanged: + subscribers = maps.Clone(s.promptChangeSubscriptions) + case notificationResourceListChanged: + subscribers = maps.Clone(s.resourceChangeSubscriptions) + } s.mu.Unlock() // Don't hold the lock during notification: it causes deadlock. - notifySessions(sessions, n, changeNotificationParams[n], s.opts.Logger) + + // Notify legacy sessions on the shared session channel regardless of + // their subscription status. + notifySessions(legacySessions, n, changeNotificationParams[n](), s.opts.Logger) + + // Notify modern sessions only if they have an active subscription for + // this notification type. + s.notifySubscribedSessions(subscribers, n, changeNotificationParams[n]) +} + +// notifySubscribedSessions delivers a list-changed or resource-updated +// notification to each session in subscribers, stamping the session's +// listen-request ID into the params' _meta so the receiving client can demultiplex +// notifications belonging to different concurrent listens. +func (s *Server) notifySubscribedSessions(subscribers map[*ServerSession]jsonrpc.ID, method string, makeParams func() Params) { + if len(subscribers) == 0 { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + for sess, reqID := range subscribers { + params := makeParams() + injectMetaSubscriptionID(params, reqID) + req := newRequest(sess, params) + if err := handleNotify(ctx, method, req); err != nil { + s.opts.Logger.Warn(fmt.Sprintf("calling %s: %v", method, err)) + } + } +} + +// injectMetaSubscriptionID stamps the listen request's JSON-RPC ID into the +// params' _meta under [MetaKeySubscriptionID], so the receiving client can +// correlate the notification with the [subscriptions/listen] stream it +// originated. +// +// [subscriptions/listen]: https://modelcontextprotocol.io/seps/2575-stateless-mcp#multiple-concurrent-subscriptions +func injectMetaSubscriptionID(params Params, reqID jsonrpc.ID) { + m := params.GetMeta() + if m == nil { + m = map[string]any{} + } + m[MetaKeySubscriptionID] = reqID.Raw() + params.SetMeta(m) } // shouldSendListChangedNotification checks if the server's capabilities allow @@ -698,18 +878,23 @@ func (s *Server) Sessions() iter.Seq[*ServerSession] { return slices.Values(clients) } -func (s *Server) listPrompts(_ context.Context, req *ListPromptsRequest) (*ListPromptsResult, error) { +func (s *Server) listPrompts(ctx context.Context, req *ListPromptsRequest) (*ListPromptsResult, error) { s.mu.Lock() defer s.mu.Unlock() if req.Params == nil { req.Params = &ListPromptsParams{} } - return paginateList(s.prompts, s.opts.PageSize, req.Params, &ListPromptsResult{}, func(res *ListPromptsResult, prompts []*serverPrompt) { + res, err := paginateList(s.prompts, s.opts.PageSize, req.Params, &ListPromptsResult{}, func(res *ListPromptsResult, prompts []*serverPrompt) { res.Prompts = []*Prompt{} // avoid JSON null for _, p := range prompts { res.Prompts = append(res.Prompts, p.prompt) } }) + if err != nil { + return nil, err + } + s.resolveCacheable(ctx, req, &res.Cacheable) + return res, nil } func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetPromptResult, error) { @@ -723,27 +908,102 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm Message: fmt.Sprintf("unknown prompt %q", req.Params.Name), } } - return prompt.handler(ctx, req) + res, err := prompt.handler(ctx, req) + if err == nil && res != nil { + if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil { + return nil, err + } + } + return res, err } -func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListToolsResult, error) { +// discover is the server-side handler for the SEP-2575 "server/discover" RPC. +// +// It returns the protocol versions supported by the underlying transport, +// the server's capabilities, the server's identity, and the server's +// instructions, allowing clients to negotiate without performing the legacy +// initialize handshake. +func (s *Server) discover(ctx context.Context, req *ServerRequest[*DiscoverParams]) (*DiscoverResult, error) { + req.Session.mu.Lock() + versions := req.Session.supportedVersions + req.Session.mu.Unlock() + if versions == nil { + versions = slices.Clone(s.protocolVersions) + } + // Read the request-scoped identity/capabilities before acquiring the + // session lock: these accessors may fall back to Session.InitializeParams + // (which also locks Session.mu), so calling them from inside updateState + // would self-deadlock. + init := &InitializeParams{ + ProtocolVersion: req.ProtocolVersion(), + Capabilities: req.ClientCapabilities(), + ClientInfo: req.ClientInfo(), + } + // Only persist InitializeParams when the transport can actually serve + // the new protocol. On transports that cannot (notably stateful + // StreamableHTTPHandler), a discover request creates a session that + // is never surfaced to the client via Mcp-Session-Id; leaving + // InitializeParams nil lets serveStatefulPOST's safety-net cleanup + // close it instead of leaking. + if slices.ContainsFunc(versions, func(v string) bool { return v >= protocolVersion20260728 }) { + req.Session.updateState(func(state *ServerSessionState) { + state.InitializeParams = init + }) + } + res := &DiscoverResult{ + SupportedVersions: versions, + Capabilities: s.capabilities(), + Instructions: s.opts.Instructions, + } + s.resolveCacheable(ctx, req, &res.Cacheable) + return res, nil +} + +// filterSupportedVersions returns the subset of versions that the Transport +// can serve. If t does not implement [ProtocolVersionSupporter], every version +// is included. +func filterSupportedVersions(t Transport, versions []string) []string { + pvs, ok := t.(ProtocolVersionSupporter) + if !ok { + return slices.Clone(versions) + } + out := make([]string, 0, len(versions)) + for _, v := range versions { + if pvs.SupportsProtocolVersion(v) { + out = append(out, v) + } + } + return out +} + +func (s *Server) listTools(ctx context.Context, req *ListToolsRequest) (*ListToolsResult, error) { s.mu.Lock() defer s.mu.Unlock() if req.Params == nil { req.Params = &ListToolsParams{} } - return paginateList(s.tools, s.opts.PageSize, req.Params, &ListToolsResult{}, func(res *ListToolsResult, tools []*serverTool) { + res, err := paginateList(s.tools, s.opts.PageSize, req.Params, &ListToolsResult{}, func(res *ListToolsResult, tools []*serverTool) { res.Tools = []*Tool{} // avoid JSON null for _, t := range tools { res.Tools = append(res.Tools, t.tool) } }) + if err != nil { + return nil, err + } + s.resolveCacheable(ctx, req, &res.Cacheable) + return res, nil +} + +// getServerTool looks up a server tool by name. +func (s *Server) getServerTool(name string) (*serverTool, bool) { + s.mu.Lock() + defer s.mu.Unlock() + return s.tools.get(name) } func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { - s.mu.Lock() - st, ok := s.tools.get(req.Params.Name) - s.mu.Unlock() + st, ok := s.getServerTool(req.Params.Name) if !ok { return nil, &jsonrpc.Error{ Code: jsonrpc.CodeInvalidParams, @@ -751,41 +1011,56 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR } } res, err := st.handler(ctx, req) - if err == nil && res != nil && res.Content == nil { - res2 := *res - res2.Content = []Content{} // avoid "null" - res = &res2 + if err == nil && res != nil { + if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil { + return nil, err + } + if res.Content == nil && res.InputRequests == nil { + res2 := *res + res2.Content = []Content{} // avoid "null" + res = &res2 + } } return res, err } -func (s *Server) listResources(_ context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) { +func (s *Server) listResources(ctx context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) { s.mu.Lock() defer s.mu.Unlock() if req.Params == nil { req.Params = &ListResourcesParams{} } - return paginateList(s.resources, s.opts.PageSize, req.Params, &ListResourcesResult{}, func(res *ListResourcesResult, resources []*serverResource) { + res, err := paginateList(s.resources, s.opts.PageSize, req.Params, &ListResourcesResult{}, func(res *ListResourcesResult, resources []*serverResource) { res.Resources = []*Resource{} // avoid JSON null for _, r := range resources { res.Resources = append(res.Resources, r.resource) } }) + if err != nil { + return nil, err + } + s.resolveCacheable(ctx, req, &res.Cacheable) + return res, nil } -func (s *Server) listResourceTemplates(_ context.Context, req *ListResourceTemplatesRequest) (*ListResourceTemplatesResult, error) { +func (s *Server) listResourceTemplates(ctx context.Context, req *ListResourceTemplatesRequest) (*ListResourceTemplatesResult, error) { s.mu.Lock() defer s.mu.Unlock() if req.Params == nil { req.Params = &ListResourceTemplatesParams{} } - return paginateList(s.resourceTemplates, s.opts.PageSize, req.Params, &ListResourceTemplatesResult{}, + res, err := paginateList(s.resourceTemplates, s.opts.PageSize, req.Params, &ListResourceTemplatesResult{}, func(res *ListResourceTemplatesResult, rts []*serverResourceTemplate) { res.ResourceTemplates = []*ResourceTemplate{} // avoid JSON null for _, rt := range rts { res.ResourceTemplates = append(res.ResourceTemplates, rt.resourceTemplate) } }) + if err != nil { + return nil, err + } + s.resolveCacheable(ctx, req, &res.Cacheable) + return res, nil } func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { @@ -802,7 +1077,17 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R if err != nil { return nil, err } - if res == nil || res.Contents == nil { + if res == nil { + return nil, fmt.Errorf("reading resource %s: read handler returned nil information", uri) + } + if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil { + return nil, err + } + s.resolveCacheable(ctx, req, &res.Cacheable) + if res.InputRequests != nil { + return res, nil + } + if res.Contents == nil { return nil, fmt.Errorf("reading resource %s: read handler returned nil information", uri) } // As a convenience, populate some fields. @@ -817,6 +1102,22 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R return res, nil } +// resolveCacheable settles the cache-control fields of an outgoing result. +// +// On entry c holds whatever the feature handler produced, which is the zero +// value for results the SDK builds itself. [ServerOptions.SetCacheable] may +// then rewrite it, and any cacheScope still unset is filled with the protocol +// default. +// +// The list methods call this with s.mu held, so SetCacheable must not call +// back into the server; see its documentation. +func (s *Server) resolveCacheable(ctx context.Context, req Request, c *Cacheable) { + if s.opts.SetCacheable != nil { + s.opts.SetCacheable(ctx, req, c) + } + c.normalize() +} + // lookupResourceHandler returns the resource handler and MIME type for the resource or // resource template matching uri. If none, the last return value is false. func (s *Server) lookupResourceHandler(uri string) (ResourceHandler, string, bool) { @@ -882,14 +1183,36 @@ func fileResourceHandler(dir string) ResourceHandler { func (s *Server) ResourceUpdated(ctx context.Context, params *ResourceUpdatedNotificationParams) error { s.mu.Lock() subscribedSessions := s.resourceSubscriptions[params.URI] - sessions := slices.Collect(maps.Keys(subscribedSessions)) + // Only add legacy sessions for the notification, new ones use the new notification mechanism. + var legacySessions []*ServerSession + newSessions := make(map[*ServerSession]jsonrpc.ID) + for sess, reqID := range subscribedSessions { + if sess.InitializeParams().isNil() || sess.InitializeParams().ProtocolVersion < protocolVersion20260728 { + legacySessions = append(legacySessions, sess) + } else { + newSessions[sess] = reqID + } + } s.mu.Unlock() - notifySessions(sessions, notificationResourceUpdated, params, s.opts.Logger) - s.opts.Logger.Info("resource updated notification sent", "uri", params.URI, "subscriber_count", len(sessions)) + + notifySessions(legacySessions, notificationResourceUpdated, params, s.opts.Logger) + + // Notify modern sessions, injecting the per-session subscription ID into the notification's metadata. + s.notifySubscribedSessions(newSessions, notificationResourceUpdated, func() Params { + p := *params + return &p + }) + s.opts.Logger.Info("resource updated notification sent", + "uri", params.URI, + "subscriber_count", len(legacySessions)+len(newSessions)) return nil } func (s *Server) subscribe(ctx context.Context, req *SubscribeRequest) (*emptyResult, error) { + requestID, ok := ctx.Value(idContextKey{}).(jsonrpc.ID) + if !ok || !requestID.IsValid() { + return nil, fmt.Errorf("%w: subscribe requires a request ID", jsonrpc2.ErrInvalidRequest) + } if s.opts.SubscribeHandler == nil { return nil, fmt.Errorf("%w: server does not support resource subscriptions", jsonrpc2.ErrMethodNotFound) } @@ -900,10 +1223,10 @@ func (s *Server) subscribe(ctx context.Context, req *SubscribeRequest) (*emptyRe s.mu.Lock() defer s.mu.Unlock() if s.resourceSubscriptions[req.Params.URI] == nil { - s.resourceSubscriptions[req.Params.URI] = make(map[*ServerSession]bool) + s.resourceSubscriptions[req.Params.URI] = make(map[*ServerSession]jsonrpc.ID) } - s.resourceSubscriptions[req.Params.URI][req.Session] = true - s.opts.Logger.Info("resource subscribed", "uri", req.Params.URI, "session_id", req.Session.ID()) + s.resourceSubscriptions[req.Params.URI][req.Session] = requestID + s.opts.Logger.Info("resource subscribed", "uri", req.Params.URI, "session_id", req.Session.ID(), "request_id", requestID) return &emptyResult{}, nil } @@ -930,6 +1253,100 @@ func (s *Server) unsubscribe(ctx context.Context, req *UnsubscribeRequest) (*emp return &emptyResult{}, nil } +func (s *Server) subscriptionsListen(ctx context.Context, req *SubscriptionsListenRequest) (*SubscriptionsListenResult, error) { + requestID, ok := ctx.Value(idContextKey{}).(jsonrpc.ID) + if !ok || !requestID.IsValid() { + return nil, fmt.Errorf("%w: subscriptions/listen requires a request ID", jsonrpc2.ErrInvalidRequest) + } + + if req.Params.Notifications == nil { + return nil, fmt.Errorf("%w: missing required 'notifications' field", jsonrpc2.ErrInvalidParams) + } + + allowed := s.allowedSubscriptions(req.Params.Notifications) + s.mu.Lock() + if allowed.ToolsListChanged { + s.toolChangeSubscriptions[req.Session] = requestID + } + if allowed.PromptsListChanged { + s.promptChangeSubscriptions[req.Session] = requestID + } + if allowed.ResourcesListChanged { + s.resourceChangeSubscriptions[req.Session] = requestID + } + s.mu.Unlock() + defer func() { + // Retire only what this listen registered above: a session can hold + // several listens at once, and deleting by session alone retires a + // registration belonging to a listen that is still open. + s.mu.Lock() + if allowed.ToolsListChanged { + delete(s.toolChangeSubscriptions, req.Session) + } + if allowed.PromptsListChanged { + delete(s.promptChangeSubscriptions, req.Session) + } + if allowed.ResourcesListChanged { + delete(s.resourceChangeSubscriptions, req.Session) + } + s.mu.Unlock() + }() + + for _, uri := range allowed.ResourceSubscriptions { + _, err := s.subscribe(ctx, &SubscribeRequest{ + Session: req.Session, + Params: &SubscribeParams{ + URI: uri, + Meta: req.Params.GetMeta(), + }, + }) + if err != nil { + return nil, err + } + defer s.unsubscribe(ctx, &UnsubscribeRequest{ + Session: req.Session, + Params: &UnsubscribeParams{ + URI: uri, + Meta: req.Params.GetMeta(), + }, + }) + } + + ackParams := &SubscriptionsAcknowledgedParams{ + Notifications: allowed, + Meta: Meta{MetaKeySubscriptionID: requestID.Raw()}, + } + if err := req.Session.notifySubscriptionAcked(ctx, ackParams); err != nil { + return nil, fmt.Errorf("sending subscriptions/acknowledged: %w", err) + } + + // If there are any active subscriptions, we block until the context is cancelled. Otherwise, we return immediately. + if len(allowed.ResourceSubscriptions) > 0 || allowed.ToolsListChanged || allowed.PromptsListChanged || allowed.ResourcesListChanged { + <-ctx.Done() + } + return &SubscriptionsListenResult{ + Meta: Meta{MetaKeySubscriptionID: requestID.Raw()}, + }, nil +} + +func (s *Server) allowedSubscriptions(want *NotificationSubscriptions) NotificationSubscriptions { + caps := s.capabilities() + agreed := NotificationSubscriptions{} + if want.ToolsListChanged && caps.Tools != nil && caps.Tools.ListChanged { + agreed.ToolsListChanged = true + } + if want.PromptsListChanged && caps.Prompts != nil && caps.Prompts.ListChanged { + agreed.PromptsListChanged = true + } + if want.ResourcesListChanged && caps.Resources != nil && caps.Resources.ListChanged { + agreed.ResourcesListChanged = true + } + if len(want.ResourceSubscriptions) > 0 && caps.Resources != nil && caps.Resources.Subscribe { + agreed.ResourceSubscriptions = slices.Clone(want.ResourceSubscriptions) + } + return agreed +} + // Run runs the server over the given transport, which must be persistent. // // Run blocks until the client terminates the connection or the provided @@ -999,6 +1416,10 @@ func (s *Server) disconnect(cc *ServerSession) { for _, subscribedSessions := range s.resourceSubscriptions { delete(subscribedSessions, cc) } + delete(s.toolChangeSubscriptions, cc) + delete(s.promptChangeSubscriptions, cc) + delete(s.resourceChangeSubscriptions, cc) + s.opts.Logger.Info("server session disconnected", "session_id", cc.ID()) } @@ -1026,11 +1447,33 @@ func (s *Server) Connect(ctx context.Context, t Transport, opts *ServerSessionOp } s.opts.Logger.Info("server connecting") - ss, err := connect(ctx, t, s, state, onClose) + ss, err := connect(ctx, t, s, state, onClose, s.opts.Logger) if err != nil { s.opts.Logger.Error("server connect error", "error", err) return nil, err } + + // Compute the protocol versions this session can serve, filtered by the + // transport's capabilities (if it implements [ProtocolVersionSupporter]). + // The list is consumed by the SEP-2575 server/discover handler. + // + // The write is guarded by ss.mu to establish a happens-before edge with + // the matching read in Server.discover, which runs on the jsonrpc2 read + // goroutine spawned inside connect(). The two are not concurrent in + // wall-clock terms (no incoming message is dispatched until the caller + // has fed the transport, which happens after Server.Connect returns), + // but without the lock the Go memory model gives the read goroutine no + // guarantee of seeing this write, and -race flags it. + ss.mu.Lock() + ss.supportedVersions = filterSupportedVersions(t, s.protocolVersions) + ss.mu.Unlock() + + // Start keepalive before returning the session to avoid race conditions with Close. + // This is safe because the spec allows sending pings before initialization (see ServerSession.handle for details). + if s.opts.KeepAlive > 0 { + ss.startKeepalive(ss.server.opts.KeepAlive) + } + return ss, nil } @@ -1058,9 +1501,6 @@ func (ss *ServerSession) initialized(ctx context.Context, params *InitializedPar ss.server.opts.Logger.Error("duplicate initialized notification") return nil, fmt.Errorf("duplicate %q received", notificationInitialized) } - if ss.server.opts.KeepAlive > 0 { - ss.startKeepalive(ss.server.opts.KeepAlive) - } if h := ss.server.opts.InitializedHandler; h != nil { h(ctx, serverRequestFor(ss, params)) } @@ -1090,6 +1530,13 @@ func (ss *ServerSession) NotifyProgress(ctx context.Context, params *ProgressNot return handleNotify(ctx, notificationProgress, newServerRequest(ss, orZero[Params](params))) } +// notifySubscriptionAcked sends a "notifications/subscriptions/acknowledged" +// notification on the listen stream represented by this session, indicating +// the subscription filter the server accepted (SEP-2575). +func (ss *ServerSession) notifySubscriptionAcked(ctx context.Context, params *SubscriptionsAcknowledgedParams) error { + return handleNotify(ctx, notificationSubscriptionsAck, newServerRequest(ss, orZero[Params](params))) +} + func newServerRequest[P Params](ss *ServerSession, params P) *ServerRequest[P] { return &ServerRequest[P]{Session: ss, Params: params} } @@ -1110,10 +1557,22 @@ type ServerSession struct { server *Server conn *jsonrpc2.Connection mcpConn Connection - keepaliveCancel context.CancelFunc // TODO: theory around why keepaliveCancel need not be guarded + keepaliveCancel context.CancelFunc + + // supportedVersions is the subset of [Server.protocolVersions] that the + // transport can actually serve, computed once at connection time from + // [ProtocolVersionSupporter] (if implemented by the transport) and used by + // the SEP-2575 server/discover handler. + supportedVersions []string mu sync.Mutex state ServerSessionState + // listenIDs holds the request IDs of in-flight subscriptions/listen + // handlers on this session. subscriptions/listen parks on ctx.Done until + // cancelled, so ServerSession.Close must cancel these contexts explicitly + // via jsonrpc2.Connection.Cancel to avoid deadlocking on the jsonrpc2 + // drain. See modelcontextprotocol/go-sdk#1160. + listenIDs []jsonrpc.ID } func (ss *ServerSession) updateState(mut func(*ServerSessionState)) { @@ -1160,6 +1619,23 @@ func (ss *ServerSession) ID() string { return "" } +// assertServerInitiatedRequestAllowed returns an error when the session is +// negotiated at protocol version >= 2026-07-28, where the spec (SEP-2322 / +// SEP-2575) forbids server-initiated JSON-RPC requests for elicitation, +// sampling, and roots: those interactions MUST be embedded as [InputRequests] +// in an [InputRequiredResult] returned from a handler for one of the multi +// round-trip methods (`tools/call`, `prompts/get`, `resources/read`). +func (ss *ServerSession) assertServerInitiatedRequestAllowed(method string) error { + if iparams := ss.InitializeParams(); iparams != nil && + iparams.ProtocolVersion >= protocolVersion20260728 { + return fmt.Errorf( + "%q cannot be sent while serving a request on protocol version %s: "+ + "return an InputRequests map instead (multi round-trip requests, SEP-2322)", + method, iparams.ProtocolVersion) + } + return nil +} + // Ping pings the client. func (ss *ServerSession) Ping(ctx context.Context, params *PingParams) error { _, err := handleSend[*emptyResult](ctx, methodPing, newServerRequest(ss, orZero[Params](params))) @@ -1167,10 +1643,19 @@ func (ss *ServerSession) Ping(ctx context.Context, params *PingParams) error { } // ListRoots lists the client roots. +// +// Deprecated: the roots feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). Migrate to passing paths via tool parameters, +// resource URIs, or configuration. See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func (ss *ServerSession) ListRoots(ctx context.Context, params *ListRootsParams) (*ListRootsResult, error) { if err := ss.checkInitialized(methodListRoots); err != nil { return nil, err } + if err := ss.assertServerInitiatedRequestAllowed(methodListRoots); err != nil { + return nil, err + } return handleSend[*ListRootsResult](ctx, methodListRoots, newServerRequest(ss, orZero[Params](params))) } @@ -1179,10 +1664,19 @@ func (ss *ServerSession) ListRoots(ctx context.Context, params *ListRootsParams) // If the client returns multiple content blocks (e.g. parallel tool calls), // CreateMessage returns an error. Use [ServerSession.CreateMessageWithTools] // for tool-enabled sampling. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). Migrate to calling LLM provider APIs directly +// from your server. See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func (ss *ServerSession) CreateMessage(ctx context.Context, params *CreateMessageParams) (*CreateMessageResult, error) { if err := ss.checkInitialized(methodCreateMessage); err != nil { return nil, err } + if err := ss.assertServerInitiatedRequestAllowed(methodCreateMessage); err != nil { + return nil, err + } if params == nil { params = &CreateMessageParams{Messages: []*SamplingMessage{}} } @@ -1216,10 +1710,19 @@ func (ss *ServerSession) CreateMessage(ctx context.Context, params *CreateMessag // returning a [CreateMessageWithToolsResult] that supports array content // (for parallel tool calls). Use this instead of [ServerSession.CreateMessage] // when the request includes tools. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). Migrate to calling LLM provider APIs directly +// from your server. See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func (ss *ServerSession) CreateMessageWithTools(ctx context.Context, params *CreateMessageWithToolsParams) (*CreateMessageWithToolsResult, error) { if err := ss.checkInitialized(methodCreateMessage); err != nil { return nil, err } + if err := ss.assertServerInitiatedRequestAllowed(methodCreateMessage); err != nil { + return nil, err + } if params == nil { params = &CreateMessageWithToolsParams{Messages: []*SamplingMessageV2{}} } @@ -1236,19 +1739,14 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli if err := ss.checkInitialized(methodElicit); err != nil { return nil, err } + if err := ss.assertServerInitiatedRequestAllowed(methodElicit); err != nil { + return nil, err + } if params == nil { return nil, fmt.Errorf("%w: params cannot be nil", jsonrpc2.ErrInvalidParams) } - if params.Mode == "" { - params2 := *params - if params.URL != "" || params.ElicitationID != "" { - params2.Mode = "url" - } else { - params2.Mode = "form" - } - params = ¶ms2 - } + params = params.inferElicitMode() if iparams := ss.InitializeParams(); iparams == nil || iparams.Capabilities == nil || iparams.Capabilities.Elicitation == nil { return nil, fmt.Errorf("client does not support elicitation") @@ -1272,7 +1770,7 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli return nil, err } - if res.Action != "accept" { + if res.Action != "accept" || res.Content == nil { return res, nil } @@ -1302,13 +1800,36 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli return res, nil } +// NotifyElicitationComplete tells the client that the out-of-band ("url" mode) +// elicitation identified by params.ElicitationID has finished. +func (ss *ServerSession) NotifyElicitationComplete(ctx context.Context, params *ElicitationCompleteParams) error { + if params == nil { + return fmt.Errorf("%w: params cannot be nil", jsonrpc2.ErrInvalidParams) + } + if params.ElicitationID == "" { + return fmt.Errorf("%w: ElicitationID cannot be empty", jsonrpc2.ErrInvalidParams) + } + return handleNotify(ctx, notificationElicitationComplete, newServerRequest(ss, orZero[Params](params))) +} + // Log sends a log message to the client. -// The message is not sent if the client has not called SetLevel, or if its level -// is below that of the last SetLevel. +// +// For new-protocol (>= 2026-07-28) requests, the level is taken from the +// originating request's `_meta` field (SEP-2575); an absent or empty value +// suppresses the message per spec. For old-protocol requests, the level is +// taken from the session state set via `logging/setLevel`. +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func (ss *ServerSession) Log(ctx context.Context, params *LoggingMessageParams) error { - ss.mu.Lock() - logLevel := ss.state.LogLevel - ss.mu.Unlock() + logLevel, ok := logLevelFromContext(ctx) + if !ok { + ss.mu.Lock() + logLevel = ss.state.LogLevel + ss.mu.Unlock() + } if logLevel == "" { // The spec is unclear, but seems to imply that no log messages are sent until the client // sets the level. @@ -1345,6 +1866,9 @@ func (s *Server) AddSendingMiddleware(middleware ...Middleware) { // // Receiving middleware is called when a request is received. It is useful for tasks // such as authentication, request logging and metrics. +// +// A received message need not carry params: use [HasParams] before inspecting +// [Request.GetParams]. func (s *Server) AddReceivingMiddleware(middleware ...Middleware) { s.mu.Lock() defer s.mu.Unlock() @@ -1358,6 +1882,7 @@ func (s *Server) AddReceivingMiddleware(middleware ...Middleware) { // curating these method flags. var serverMethodInfos = map[string]methodInfo{ methodComplete: newServerMethodInfo(serverMethod((*Server).complete), 0), + methodDiscover: newServerMethodInfo(serverMethod((*Server).discover), missingParamsOK), methodInitialize: initializeMethodInfo(), methodPing: newServerMethodInfo(serverSessionMethod((*ServerSession).ping), missingParamsOK), methodListPrompts: newServerMethodInfo(serverMethod((*Server).listPrompts), missingParamsOK), @@ -1369,6 +1894,7 @@ var serverMethodInfos = map[string]methodInfo{ methodReadResource: newServerMethodInfo(serverMethod((*Server).readResource), 0), methodSetLevel: newServerMethodInfo(serverSessionMethod((*ServerSession).setLevel), 0), methodSubscribe: newServerMethodInfo(serverMethod((*Server).subscribe), 0), + methodSubscriptionsListen: newServerMethodInfo(serverMethod((*Server).subscriptionsListen), 0), methodUnsubscribe: newServerMethodInfo(serverMethod((*Server).unsubscribe), 0), notificationCancelled: newServerMethodInfo(serverSessionMethod((*ServerSession).cancel), notification|missingParamsOK), notificationInitialized: newServerMethodInfo(serverSessionMethod((*ServerSession).initialized), notification|missingParamsOK), @@ -1397,7 +1923,15 @@ func initializeMethodInfo() methodInfo { func (ss *ServerSession) sendingMethodInfos() map[string]methodInfo { return clientMethodInfos } -func (ss *ServerSession) receivingMethodInfos() map[string]methodInfo { return serverMethodInfos } +func (s *Server) receivingMethodInfos() map[string]methodInfo { + s.mu.Lock() + defer s.mu.Unlock() + return s.receiveMethods +} + +func (ss *ServerSession) receivingMethodInfos() map[string]methodInfo { + return ss.server.receivingMethodInfos() +} func (ss *ServerSession) sendingMethodHandler() MethodHandler { s := ss.server @@ -1422,18 +1956,65 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, initialized := ss.state.InitializeParams != nil ss.mu.Unlock() - // From the spec: - // "The client SHOULD NOT send requests other than pings before the server - // has responded to the initialize request." - switch req.Method { - case methodInitialize, methodPing, notificationInitialized: - default: - if !initialized { - ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method) - return nil, fmt.Errorf("method %q is invalid during session initialization", req.Method) + // Per-request protocol detection (SEP-2575): if the request carries + // `io.modelcontextprotocol/protocolVersion` in its `_meta` field, it + // follows the new sessionless protocol. The initialization gate is + // skipped for such requests. + validatedMeta, perRequestErr := validateRequestMeta(req) + if perRequestErr != nil { + return nil, perRequestErr + } + + if validatedMeta.usesNewProtocol && + !slices.Contains(ss.server.protocolVersions, validatedMeta.initializeParams.ProtocolVersion) { + data, _ := json.Marshal(UnsupportedProtocolVersionData{ + Supported: ss.server.protocolVersions, + Requested: validatedMeta.initializeParams.ProtocolVersion, + }) + return nil, &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + Data: data, } } + switch req.Method { + case methodInitialize, methodPing, notificationInitialized, notificationRootsListChanged, methodSetLevel, methodSubscribe, methodUnsubscribe: + if validatedMeta.usesNewProtocol { + ss.server.opts.Logger.Error("method removed in the new protocol", "method", req.Method) + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeMethodNotFound, + Message: fmt.Sprintf("%q is not supported in the new protocol", req.Method), + } + } + case methodDiscover: + // In case of methodDiscover call the state.initializeParams is populated + // within the discover handle function to make sure the method is supported + // when the user is probing a pre-2026-07-28 server. + if !validatedMeta.usesNewProtocol { + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeMethodNotFound, + Message: fmt.Sprintf("%q is only supported in protocol version >= %s", req.Method, protocolVersion20260728), + } + } + default: + if !initialized && validatedMeta.usesNewProtocol && validatedMeta.initializeParams != nil { + ss.updateState(func(state *ServerSessionState) { + state.InitializeParams = validatedMeta.initializeParams + }) + } + } + + // In legacy protocol versions, a client cannot send requests other than + // pings before the server has responded to the initialize request. A + // new-protocol request is exempt: a SEP-2575 session has no 'initialize' + // to wait for, and the request itself says which protocol it speaks. + if !initialized && !validatedMeta.usesNewProtocol && req.IsCall() && + req.Method != methodInitialize && req.Method != methodPing { + ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method) + return nil, fmt.Errorf("method %q is invalid during session initialization", req.Method) + } + // modelcontextprotocol/go-sdk#26: handle calls asynchronously, and // notifications synchronously, except for 'initialize' which shouldn't be // asynchronous to other @@ -1445,7 +2026,65 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, // server->client calls and notifications to the incoming request from which // they originated. See [idContextKey] for details. ctx = context.WithValue(ctx, idContextKey{}, req.ID) - return handleReceive(ctx, ss, req) + if validatedMeta.usesNewProtocol { + ctx = context.WithValue(ctx, logLevelContextKey{}, validatedMeta.logLevel) + } + + // subscriptions/listen parks on ctx.Done until the peer cancels it (or the + // underlying reader breaks). Track the request ID so ServerSession.Close + // can cancel the in-flight handler via jsonrpc2.Connection.Cancel and + // avoid deadlocking on the jsonrpc2 drain. + if req.Method == methodSubscriptionsListen { + ss.mu.Lock() + ss.listenIDs = append(ss.listenIDs, req.ID) + ss.mu.Unlock() + // The listen completes when the handler returns (peer cancellation, + // stream break, or error); drop the ID so completed listens don't + // accumulate in the slice indefinitely. + defer func() { + ss.mu.Lock() + for i, id := range ss.listenIDs { + if id == req.ID { + ss.listenIDs = append(ss.listenIDs[:i], ss.listenIDs[i+1:]...) + break + } + } + ss.mu.Unlock() + }() + } + + res, err := handleReceive(ctx, ss, req) + if err != nil { + return nil, err + } + // A middleware can return a typed nil value that satisfies the Result + // interface. Annotating that value panics. + if validatedMeta.usesNewProtocol && res != nil && !res.isNil() { + annotateResultType(res) + annotateServerInfo(res, ss.server.impl) + } + return res, nil +} + +// annotateServerInfo sets [MetaKeyServerInfo] on the result's `_meta` unless +// it is already present. Per SEP-2575, servers should identify themselves in +// each result's `_meta`. +func annotateServerInfo(res Result, impl *Implementation) { + if res == nil || impl == nil { + return + } + if _, isEmpty := res.(*emptyResult); isEmpty { + return + } + m := res.GetMeta() + if m == nil { + m = map[string]any{} + } + if _, ok := m[MetaKeyServerInfo]; ok { + return + } + m[MetaKeyServerInfo] = impl + res.SetMeta(m) } // InitializeParams returns the InitializeParams provided during the client's @@ -1460,15 +2099,28 @@ func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParam if params == nil { return nil, fmt.Errorf("%w: \"params\" must be be provided", jsonrpc2.ErrInvalidParams) } + var ( + wasInit bool + negotiated string + ) ss.updateState(func(state *ServerSessionState) { - state.InitializeParams = params + wasInit = state.InitializeParams != nil + if !wasInit { + state.InitializeParams = params + state.NegotiatedProtocolVersion = negotiatedVersion(params.ProtocolVersion, ss.server.protocolVersions) + } + negotiated = state.NegotiatedProtocolVersion }) + if wasInit { + ss.server.opts.Logger.Error("duplicate initialize request") + return nil, fmt.Errorf("duplicate %q received", methodInitialize) + } s := ss.server return &InitializeResult{ // TODO(rfindley): alter behavior when falling back to an older version: // reject unsupported features. - ProtocolVersion: negotiatedVersion(params.ProtocolVersion), + ProtocolVersion: negotiated, Capabilities: s.capabilities(), Instructions: s.opts.Instructions, ServerInfo: s.impl, @@ -1504,12 +2156,25 @@ func (ss *ServerSession) setLevel(_ context.Context, params *SetLoggingLevelPara func (ss *ServerSession) Close() error { if ss.keepaliveCancel != nil { // Note: keepaliveCancel access is safe without a mutex because: - // 1. keepaliveCancel is only written once during startKeepalive (happens-before all Close calls) + // 1. keepaliveCancel is only written once during Server.Connect (through startKeepalive), + // which happens before any code that may call Close from another goroutine // 2. context.CancelFunc is safe to call multiple times and from multiple goroutines // 3. The keepalive goroutine calls Close on ping failure, but this is safe since // Close is idempotent and conn.Close() handles concurrent calls correctly ss.keepaliveCancel() } + + // Unblock any in-flight subscriptions/listen handlers, which otherwise park + // on ctx.Done and would deadlock conn.Close (which waits for in-flight + // requests to drain). + ss.mu.Lock() + ids := ss.listenIDs + ss.listenIDs = nil + ss.mu.Unlock() + for _, id := range ids { + ss.conn.Cancel(id) + } + err := ss.conn.Close() if ss.onClose != nil && ss.calledOnClose.CompareAndSwap(false, true) { @@ -1526,7 +2191,7 @@ func (ss *ServerSession) Wait() error { // startKeepalive starts the keepalive mechanism for this server session. func (ss *ServerSession) startKeepalive(interval time.Duration) { - startKeepalive(ss, interval, &ss.keepaliveCancel) + startKeepalive(ss, interval, ss.server.opts.KeepAliveFailureThreshold, &ss.keepaliveCancel, ss.server.opts.Logger) } // pageToken is the internal structure for the opaque pagination cursor. @@ -1604,3 +2269,55 @@ func paginateList[P listParams, R listResult[T], T any](fs *featureSet[T], pageS *res.nextCursorPtr() = nextCursor return res, nil } + +// AddReceivingCustomMethod registers a handler for a custom (non-standard) +// JSON-RPC method on the server. +// +// When a client sends a request with the given method name, the params will be +// unmarshaled into P, the handler will be called, and the returned R will be +// marshaled as the JSON-RPC result. +// +// Custom methods go through the server's middleware chain just like standard +// MCP methods (tools/call, prompts/list, etc.). +// +// P and R must implement [Params] and [Result] respectively, which is most +// easily done by embedding [ParamsBase] and [ResultBase]: +// +// type SearchParams struct { +// mcp.ParamsBase +// Query string `json:"query"` +// } +// +// type SearchResult struct { +// mcp.ResultBase +// Hits []string `json:"hits"` +// } +// +// if err := mcp.AddReceivingCustomMethod(server, "acme/search", +// func(ctx context.Context, ss *mcp.ServerSession, params *SearchParams) (*SearchResult, error) { +// return &SearchResult{Hits: []string{"result"}}, nil +// }); err != nil { +// return err +// } +// +// AddReceivingCustomMethod returns an error if method is the name of a +// standard MCP method. Registering the same custom method twice replaces the +// previous handler. +func AddReceivingCustomMethod[P paramsPtr[T], R Result, T any]( + s *Server, + method string, + handler func(ctx context.Context, ss *ServerSession, params P) (R, error), +) error { + if _, ok := serverMethodInfos[method]; ok { + return fmt.Errorf("mcp: AddReceivingCustomMethod: %q shadows a standard MCP method", method) + } + + typed := typedServerMethodHandler[P, R](func(ctx context.Context, req *ServerRequest[P]) (R, error) { + return handler(ctx, req.Session, req.Params) + }) + + s.mu.Lock() + defer s.mu.Unlock() + s.receiveMethods[method] = newServerMethodInfo(typed, missingParamsOK) + return nil +} diff --git a/mcp/server_example_test.go b/mcp/server_example_test.go index db04920..ae4318f 100644 --- a/mcp/server_example_test.go +++ b/mcp/server_example_test.go @@ -2,6 +2,8 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +//lint:file-ignore SA1019 examples exercise deprecated SEP-2577 logging APIs for demonstration. + package mcp_test import ( @@ -11,6 +13,7 @@ import ( "log/slog" "sync/atomic" + "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -85,6 +88,77 @@ func Example_prompts() { // !-prompts +// !+promptcontent + +func Example_promptContent() { + ctx := context.Background() + + const styleGuide = "- Prefer clarity over cleverness.\n" + screenshotPNG := []byte("\x89PNG\r\n\x1a\n") + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + + s.AddResource(&mcp.Resource{URI: "doc://style-guide", MIMEType: "text/markdown"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: "doc://style-guide", + MIMEType: "text/markdown", + Text: styleGuide, + }}, + }, nil + }) + + s.AddPrompt(&mcp.Prompt{Name: "review_screenshot"}, + func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{ + Messages: []*mcp.PromptMessage{ + {Role: "user", Content: &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ + URI: "doc://style-guide", + MIMEType: "text/markdown", + Text: styleGuide, + }, + }}, + {Role: "user", Content: &mcp.ImageContent{Data: screenshotPNG, MIMEType: "image/png"}}, + {Role: "user", Content: &mcp.TextContent{Text: "Review the screenshot against the style guide."}}, + }, + }, nil + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{Name: "review_screenshot"}) + if err != nil { + log.Fatal(err) + } + for _, msg := range res.Messages { + switch content := msg.Content.(type) { + case *mcp.TextContent: + fmt.Println(msg.Role, "text:", content.Text) + case *mcp.ImageContent: + fmt.Printf("%s image: %s, %d bytes\n", msg.Role, content.MIMEType, len(content.Data)) + case *mcp.EmbeddedResource: + fmt.Printf("%s embedded resource: %s (%s)\n", msg.Role, content.Resource.URI, content.Resource.MIMEType) + } + } + // Output: + // user embedded resource: doc://style-guide (text/markdown) + // user image: image/png, 8 bytes + // user text: Review the screenshot against the style guide. +} + +// !-promptcontent + // !+logging func Example_logging() { @@ -218,3 +292,269 @@ func Example_resources() { } // !-resources + +// !+listchanged + +func Example_listChanged() { + ctx := context.Background() + + changed := make(chan string, 3) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) { + changed <- "tools" + }, + PromptListChangedHandler: func(context.Context, *mcp.PromptListChangedRequest) { + changed <- "prompts" + }, + ResourceListChangedHandler: func(context.Context, *mcp.ResourceListChangedRequest) { + changed <- "resources" + }, + }) + + // Nothing is registered before Connect, so the capabilities that carry these + // notifications have to be declared explicitly. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + HasTools: true, + HasPrompts: true, + HasResources: true, + }) + + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + mcp.AddTool(s, &mcp.Tool{Name: "greet"}, func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{}, nil, nil + }) + fmt.Println("changed:", <-changed) + + s.AddPrompt(&mcp.Prompt{Name: "review"}, func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{}, nil + }) + fmt.Println("changed:", <-changed) + + s.AddResource(&mcp.Resource{URI: "file:///a"}, func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{}, nil + }) + fmt.Println("changed:", <-changed) + + // Removing a feature notifies the client just as adding one does. + s.RemoveTools("greet") + fmt.Println("changed:", <-changed) + + // Output: + // changed: tools + // changed: prompts + // changed: resources + // changed: tools +} + +// !-listchanged + +// !+subscribe + +func Example_resourceSubscription() { + ctx := context.Background() + + config := "theme=light\n" + + updated := make(chan string, 1) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + // The notification carries only the URI, so read the resource again to + // see what it now holds. + ResourceUpdatedHandler: func(context.Context, *mcp.ResourceUpdatedNotificationRequest) { + updated <- "config://app" + }, + }) + + subscribed := make(chan string, 2) + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + SubscribeHandler: func(_ context.Context, req *mcp.SubscribeRequest) error { + subscribed <- "subscribed to " + req.Params.URI + return nil + }, + UnsubscribeHandler: func(_ context.Context, req *mcp.UnsubscribeRequest) error { + subscribed <- "unsubscribed from " + req.Params.URI + return nil + }, + }) + s.AddResource(&mcp.Resource{URI: "config://app"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{URI: "config://app", Text: config}}, + }, nil + }) + + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + if err := cs.Subscribe(ctx, &mcp.SubscribeParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + fmt.Println(<-subscribed) + + config = "theme=dark\n" + if err := s.ResourceUpdated(ctx, &mcp.ResourceUpdatedNotificationParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + uri := <-updated + + res, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: uri}) + if err != nil { + log.Fatal(err) + } + fmt.Print("re-read: ", res.Contents[0].Text) + + if err := cs.Unsubscribe(ctx, &mcp.UnsubscribeParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + fmt.Println(<-subscribed) + + // Output: + // subscribed to config://app + // re-read: theme=dark + // unsubscribed from config://app +} + +// !-subscribe + +// !+binaryresource + +func Example_binaryResource() { + ctx := context.Background() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + s.AddResource(&mcp.Resource{URI: "file:///logo.png", MIMEType: "image/png"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: "file:///logo.png", + MIMEType: "image/png", + Blob: []byte("\x89PNG\r\n\x1a\n"), + }}, + }, nil + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: "file:///logo.png"}) + if err != nil { + log.Fatal(err) + } + contents := res.Contents[0] + fmt.Printf("%s: %d bytes of %s, text is %q\n", + contents.URI, len(contents.Blob), contents.MIMEType, contents.Text) + + // Output: + // file:///logo.png: 8 bytes of image/png, text is "" +} + +// !-binaryresource + +// !+mrtr + +// Example_mrtr demonstrates the [Multi Round-Trip Requests] pattern +// (SEP-2322). A tool handler signals "I need more information from the user" +// by returning a [mcp.CallToolResult] whose `InputRequests` field carries the +// requests for the additional information. Each request can be an +// [mcp.ElicitParams] (ask the user a question), +// [mcp.CreateMessageParams] (sample from the client's LLM), or +// [mcp.ListRootsParams] (list the client's roots). +// +// On protocol version `2026-07-28` and later, the client's +// `clientMultiRoundTripMiddleware` fulfils each input request from the +// configured handler and retries the original call transparently. On earlier +// protocol versions, the server's `serverMultiRoundTripMiddleware` performs +// the equivalent dance from the server side by calling the legacy +// server-to-client API (`ServerSession.Elicit`, `CreateMessage`, or +// `ListRoots`) and re-invoking the handler with the response. Either way, +// the user-facing client call site sees only the final result. +// +// [Multi Round-Trip Requests]: https://modelcontextprotocol.io/specification/draft/basic/patterns#multi-round-trip-requests +func Example_mrtr() { + ctx := context.Background() + + // Server: a "greet" tool that asks the user for their name before + // returning a greeting. The handler runs twice per logical call: + // once to issue the elicitation, once to consume the response. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "greet"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + // First call: ask the user for their name. The map key + // ("user_name") is a label the handler chooses; the client must + // echo it back in InputResponses. The wire-level method name + // ("elicitation/create") is derived from the value's Go type. + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "user_name": &mcp.ElicitParams{ + Message: "What's your name?", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": {Type: "string"}, + }, + }, + }, + }, + // RequestState is an opaque token the client echoes back on + // the retry, letting the handler resume its work without + // per-session storage. + RequestState: "step=1", + }, nil, nil + } + // Retry: read the elicitation response and produce the final greeting. + name := req.Params.InputResponses["user_name"].(*mcp.ElicitResult).Content["name"].(string) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "Hello " + name}}, + }, nil, nil + }) + + // Client: declares an elicitation handler. The SDK's MRTR middleware + // uses it to fulfil any input request the tool handler returns. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"name": "MCP Go"}}, nil + }, + }) + + ct, st := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, st, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + // Single call site. The MRTR round trip is invisible to the caller. + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "greet"}) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) +} + +// !-mrtr diff --git a/mcp/server_test.go b/mcp/server_test.go index 85c3990..4d09cbc 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -8,6 +8,8 @@ import ( "bytes" "context" "encoding/json" + "errors" + "fmt" "log" "log/slog" "slices" @@ -18,6 +20,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) type testItem struct { @@ -508,54 +511,6 @@ func TestServerAddResourceTemplate(t *testing.T) { } } -// TestServerSessionkeepaliveCancelOverwritten is to verify that `ServerSession.keepaliveCancel` is assigned exactly once, -// ensuring that only a single goroutine is responsible for the session's keepalive ping mechanism. -func TestServerSessionkeepaliveCancelOverwritten(t *testing.T) { - // Set KeepAlive to a long duration to ensure the keepalive - // goroutine stays alive for the duration of the test without actually sending - // ping requests, since we don't have a real client connection established. - server := NewServer(testImpl, &ServerOptions{KeepAlive: 5 * time.Second}) - ss := &ServerSession{server: server} - - // 1. Initialize the session. - _, err := ss.initialize(context.Background(), &InitializeParams{}) - if err != nil { - t.Fatalf("ServerSession initialize failed: %v", err) - } - - // 2. Call 'initialized' for the first time. This should start the keepalive mechanism. - _, err = ss.initialized(context.Background(), &InitializedParams{}) - if err != nil { - t.Fatalf("First initialized call failed: %v", err) - } - if ss.keepaliveCancel == nil { - t.Fatalf("expected ServerSession.keepaliveCancel to be set after the first call of initialized") - } - - // Save the cancel function and use defer to ensure resources are cleaned up. - firstCancel := ss.keepaliveCancel - defer firstCancel() - - // 3. Manually set the field to nil. - // Do this to facilitate the test's core assertion. The goal is to verify that - // 'ss.keepaliveCancel' is not assigned a second time. By setting it to nil, - // we can easily check after the next call if a new keepalive goroutine was started. - ss.keepaliveCancel = nil - - // 4. Call 'initialized' for the second time. This should return an error. - _, err = ss.initialized(context.Background(), &InitializedParams{}) - if err == nil { - t.Fatalf("Expected 'duplicate initialized received' error on second call, got nil") - } - - // 5. Re-check the field to ensure it remains nil. - // Since 'initialized' correctly returned an error and did not call - // 'startKeepalive', the field should remain unchanged. - if ss.keepaliveCancel != nil { - t.Fatal("expected ServerSession.keepaliveCancel to be nil after we manually niled it and re-initialized") - } -} - // panicks reports whether f() panics. func panics(f func()) (b bool) { defer func() { @@ -566,7 +521,8 @@ func panics(f func()) (b bool) { } func TestAddTool(t *testing.T) { - // AddTool should panic if In or Out are not JSON objects. + // AddTool should panic if In is not a JSON object. + // Out may be of any type whose inferred JSON Schema is valid. s := NewServer(testImpl, nil) if !panics(func() { AddTool(s, &Tool{Name: "T1"}, func(context.Context, *CallToolRequest, string) (*CallToolResult, any, error) { return nil, nil, nil }) @@ -580,12 +536,12 @@ func TestAddTool(t *testing.T) { }) { t.Error("good In: expected no panic") } - if !panics(func() { - AddTool(s, &Tool{Name: "T2"}, func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, int, error) { + if panics(func() { + AddTool(s, &Tool{Name: "T3"}, func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, int, error) { return nil, 0, nil }) }) { - t.Error("bad Out: expected panic") + t.Error("primitive Out: expected no panic") } } @@ -652,6 +608,287 @@ func TestAddToolNameValidation(t *testing.T) { } } +func TestAddToolNilSchema(t *testing.T) { + var nilSchema *jsonschema.Schema + + panicMsg := func(f func()) (msg string) { + defer func() { + if r := recover(); r != nil { + msg = fmt.Sprintf("%v", r) + } + }() + f() + return msg + } + + // Call s.AddTool directly to exercise the typed-nil checks added to that method. + tests := []struct { + name string + tool *Tool + wantContain string + }{ + { + name: "typed nil InputSchema", + tool: &Tool{Name: "T", InputSchema: nilSchema}, + wantContain: "input schema is nil", + }, + { + name: "typed nil OutputSchema", + tool: &Tool{Name: "T", InputSchema: &jsonschema.Schema{Type: "object"}, OutputSchema: nilSchema}, + wantContain: "output schema is nil", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := NewServer(testImpl, nil) + + msg := panicMsg(func() { + s.AddTool(tc.tool, nil) + }) + if msg == "" { + t.Error("expected panic") + } else if !strings.Contains(msg, tc.wantContain) { + t.Errorf("panic message %q does not contain %q", msg, tc.wantContain) + } + }) + } +} + +// TestAddToolNonObjectOutputSchema verifies the low-level +// Server.AddTool accepts an output schema whose root type is not "object" +// (array or primitive) and structured content of the matching shape +// round-trips end-to-end. +func TestAddToolNonObjectOutputSchema(t *testing.T) { + ctx := context.Background() + + for _, tc := range []struct { + name string + outputSchema any + content any + want any + }{ + { + name: "array of objects", + outputSchema: &jsonschema.Schema{Type: "array", Items: &jsonschema.Schema{Type: "object"}}, + content: []any{map[string]any{"id": "u1"}, map[string]any{"id": "u2"}}, + want: []any{map[string]any{"id": "u1"}, map[string]any{"id": "u2"}}, + }, + { + name: "primitive number (map-based schema)", + outputSchema: map[string]any{"type": "number"}, + content: 42.0, + want: json.Number("42"), + }, + { + name: "primitive string (RawMessage schema)", + outputSchema: json.RawMessage(`{"type":"string"}`), + content: "hello", + want: "hello", + }, + } { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, nil) + handler := func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{StructuredContent: tc.content}, nil + } + tool := &Tool{ + Name: "t", + InputSchema: &jsonschema.Schema{Type: "object"}, + OutputSchema: tc.outputSchema, + } + // Must not panic. + server.AddTool(tool, handler) + + ct, st := NewInMemoryTransports() + if _, err := server.Connect(ctx, st, nil); err != nil { + t.Fatal(err) + } + client := NewClient(testImpl, nil) + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + res, err := cs.CallTool(ctx, &CallToolParams{Name: "t"}) + if err != nil { + t.Fatal(err) + } + if res.IsError { + t.Fatalf("unexpected tool error: %v", res.Content) + } + if diff := cmp.Diff(tc.want, res.StructuredContent); diff != "" { + t.Errorf("structured content mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestCallToolStructuredContentPreservesLargeInteger(t *testing.T) { + const want int64 = 9007199254740993 + + server := NewServer(testImpl, nil) + server.AddTool(&Tool{ + Name: "large-integer", + InputSchema: &jsonschema.Schema{Type: "object"}, + }, func(context.Context, *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{StructuredContent: map[string]any{"id": want}}, nil + }) + + clientTransport, serverTransport := NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + if err != nil { + t.Fatal(err) + } + defer serverSession.Close() + + client := NewClient(testImpl, nil) + clientSession, err := client.Connect(context.Background(), clientTransport, nil) + if err != nil { + t.Fatal(err) + } + defer clientSession.Close() + + result, err := clientSession.CallTool(context.Background(), &CallToolParams{Name: "large-integer"}) + if err != nil { + t.Fatal(err) + } + structured, ok := result.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("StructuredContent type = %T, want map[string]any", result.StructuredContent) + } + number, ok := structured["id"].(json.Number) + if !ok { + t.Fatalf("id type = %T, want json.Number", structured["id"]) + } + got, err := number.Int64() + if err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("id = %d, want %d", got, want) + } +} + +// TestAddToolGenericNonObjectOutput verifies SEP-2106 for the generic +// AddTool[In, Out] helper: Out may be a slice or primitive whose inferred +// JSON Schema has a non-object root. +func TestAddToolGenericNonObjectOutput(t *testing.T) { + ctx := context.Background() + + type item struct { + ID string `json:"id"` + } + + t.Run("slice output", func(t *testing.T) { + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "list"}, + func(ctx context.Context, req *CallToolRequest, _ struct{}) (*CallToolResult, []item, error) { + return nil, []item{{ID: "u1"}, {ID: "u2"}}, nil + }) + + ct, st := NewInMemoryTransports() + if _, err := server.Connect(ctx, st, nil); err != nil { + t.Fatal(err) + } + client := NewClient(testImpl, nil) + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Verify tools/list reports an array-rooted output schema. + // jsonschema-go infers ["null","array"] for slices since nil slices + // are valid; accept either form. + lt, err := cs.ListTools(ctx, nil) + if err != nil { + t.Fatal(err) + } + if len(lt.Tools) != 1 { + t.Fatalf("got %d tools, want 1", len(lt.Tools)) + } + gotSchema, _ := lt.Tools[0].OutputSchema.(map[string]any) + typeOK := false + switch typ := gotSchema["type"].(type) { + case string: + typeOK = typ == "array" + case []any: + for _, e := range typ { + if e == "array" { + typeOK = true + } + } + } + if !typeOK { + t.Errorf("output schema type = %v, want to include %q", gotSchema["type"], "array") + } + + res, err := cs.CallTool(ctx, &CallToolParams{Name: "list"}) + if err != nil { + t.Fatal(err) + } + if res.IsError { + t.Fatalf("unexpected tool error: %v", res.Content) + } + want := []any{map[string]any{"id": "u1"}, map[string]any{"id": "u2"}} + if diff := cmp.Diff(want, res.StructuredContent); diff != "" { + t.Errorf("structured content mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("primitive output", func(t *testing.T) { + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "count"}, + func(ctx context.Context, req *CallToolRequest, _ struct{}) (*CallToolResult, int, error) { + return nil, 42, nil + }) + + ct, st := NewInMemoryTransports() + if _, err := server.Connect(ctx, st, nil); err != nil { + t.Fatal(err) + } + client := NewClient(testImpl, nil) + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + res, err := cs.CallTool(ctx, &CallToolParams{Name: "count"}) + if err != nil { + t.Fatal(err) + } + if res.IsError { + t.Fatalf("unexpected tool error: %v", res.Content) + } + if diff := cmp.Diff(json.Number("42"), res.StructuredContent); diff != "" { + t.Errorf("structured content mismatch (-want +got):\n%s", diff) + } + }) +} + +// TestAddToolInputSchemaComposition verifies SEP-2106 (input side): composition +// keywords such as oneOf are allowed on the input schema alongside +// type:"object". +func TestAddToolInputSchemaComposition(t *testing.T) { + server := NewServer(testImpl, nil) + tool := &Tool{ + Name: "lookup", + InputSchema: &jsonschema.Schema{ + Type: "object", + OneOf: []*jsonschema.Schema{ + {Required: []string{"id"}, Properties: map[string]*jsonschema.Schema{"id": {Type: "string"}}}, + {Required: []string{"name"}, Properties: map[string]*jsonschema.Schema{"name": {Type: "string"}}}, + }, + }, + } + // Must not panic. + server.AddTool(tool, func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{}, nil + }) +} + type schema = jsonschema.Schema func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out, wantIn, wantOut any, wantErrContaining string) { @@ -826,6 +1063,92 @@ func TestClientRootCapabilities(t *testing.T) { } } +func TestServerRejectsDuplicateInitialize(t *testing.T) { + ctx := context.Background() + + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + cTransport, sTransport := NewInMemoryTransports() + ss, err := server.Connect(ctx, sTransport, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + cConn, err := cTransport.Connect(ctx) + if err != nil { + t.Fatal(err) + } + defer cConn.Close() + + firstParams := json.RawMessage(`{ + "protocolVersion": "2025-11-25", + "clientInfo": {"name": "first-client", "version": "1.0.0"} + }`) + firstReq, err := jsonrpc2.NewCall(jsonrpc2.Int64ID(1), methodInitialize, firstParams) + if err != nil { + t.Fatal(err) + } + if err := cConn.Write(ctx, firstReq); err != nil { + t.Fatalf("first initialize write failed: %v", err) + } + msg, err := cConn.Read(ctx) + if err != nil { + t.Fatalf("first initialize read failed: %v", err) + } + resp, ok := msg.(*jsonrpc2.Response) + if !ok { + t.Fatalf("expected Response, got %T", msg) + } + if resp.Error != nil { + t.Fatalf("first initialize failed: %v", resp.Error) + } + + initializedReq, err := jsonrpc2.NewNotification(notificationInitialized, &InitializedParams{}) + if err != nil { + t.Fatal(err) + } + if err := cConn.Write(ctx, initializedReq); err != nil { + t.Fatalf("initialized notification write failed: %v", err) + } + + secondParams := json.RawMessage(`{ + "protocolVersion": "2024-11-05", + "clientInfo": {"name": "second-client", "version": "2.0.0"} + }`) + secondReq, err := jsonrpc2.NewCall(jsonrpc2.Int64ID(2), methodInitialize, secondParams) + if err != nil { + t.Fatal(err) + } + if err := cConn.Write(ctx, secondReq); err != nil { + t.Fatalf("second initialize write failed: %v", err) + } + msg, err = cConn.Read(ctx) + if err != nil { + t.Fatalf("second initialize read failed: %v", err) + } + resp, ok = msg.(*jsonrpc2.Response) + if !ok { + t.Fatalf("expected Response, got %T", msg) + } + if resp.Error == nil { + t.Fatal("second initialize unexpectedly succeeded") + } + if !strings.Contains(resp.Error.Error(), `duplicate "initialize" received`) { + t.Fatalf("second initialize error = %v, want duplicate initialize", resp.Error) + } + + got := ss.InitializeParams() + if got == nil { + t.Fatal("InitializeParams is nil") + } + if got.ProtocolVersion != "2025-11-25" { + t.Fatalf("ProtocolVersion = %q, want first initialize value", got.ProtocolVersion) + } + if got.ClientInfo == nil || got.ClientInfo.Name != "first-client" { + t.Fatalf("ClientInfo = %#v, want first initialize value", got.ClientInfo) + } +} + // TODO: move this to tool_test.go func TestToolForSchemas(t *testing.T) { // Validate that toolForErr handles schemas properly. @@ -1008,3 +1331,1083 @@ func TestServerCapabilitiesOverWire(t *testing.T) { }) } } + +// SEP-2575 removes the initialization handshake. An `initialize` request +// that opts into the new protocol via `_meta.protocolVersion` must be +// rejected with `Method not found` (-32601). +func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { + + tests := []struct { + name string + params any + wantReject bool + }{ + { + name: "initialize with new-protocol _meta is rejected", + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + "protocolVersion": protocolVersion20260728, + }, + wantReject: true, + }, + { + name: "initialize without _meta is allowed (old protocol)", + params: map[string]any{ + "protocolVersion": protocolVersion20251125, + }, + wantReject: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ss := &ServerSession{server: NewServer(testImpl, nil)} + id, err := jsonrpc.MakeID("test") + if err != nil { + t.Fatal(err) + } + req := &jsonrpc.Request{ + ID: id, + Method: methodInitialize, + Params: mustMarshal(tc.params), + } + _, err = ss.handle(context.Background(), req) + if tc.wantReject { + if err == nil { + t.Fatal("expected error rejecting initialize, got nil") + } + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("error type = %T, want *jsonrpc.Error so the wire returns the right code", err) + } + if jerr.Code != jsonrpc.CodeMethodNotFound { + t.Errorf("error code = %d, want %d (CodeMethodNotFound = -32601)", jerr.Code, jsonrpc.CodeMethodNotFound) + } + if !strings.Contains(jerr.Message, "initialize") { + t.Errorf("error message %q does not mention %q", jerr.Message, "initialize") + } + } else { + // Old-protocol initialize should be dispatched normally; any + // CodeMethodNotFound here would mean the rejection branch + // fired incorrectly. + var jerr *jsonrpc.Error + if errors.As(err, &jerr) && jerr.Code == jsonrpc.CodeMethodNotFound { + t.Errorf("old-protocol initialize was incorrectly rejected: %v", err) + } + } + }) + } + + t.Run("rejection error encodes to wire as code -32601", func(t *testing.T) { + // Belt-and-braces check that the error type produced by handle() + // actually serializes to JSON-RPC code -32601, not a bare 0. + ss := &ServerSession{server: NewServer(testImpl, nil)} + id, err := jsonrpc.MakeID("test") + if err != nil { + t.Fatal(err) + } + req := &jsonrpc.Request{ + ID: id, + Method: methodInitialize, + Params: mustMarshal(map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + "protocolVersion": protocolVersion20260728, + }), + } + _, handleErr := ss.handle(context.Background(), req) + if handleErr == nil { + t.Fatal("expected rejection error, got nil") + } + data, encErr := jsonrpc.EncodeMessage(&jsonrpc.Response{ID: id, Error: handleErr.(*jsonrpc.Error)}) + if encErr != nil { + t.Fatal(encErr) + } + var wire struct { + Error struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + if wire.Error.Code != jsonrpc.CodeMethodNotFound { + t.Errorf("wire error code = %d, want %d; full response = %s", wire.Error.Code, jsonrpc.CodeMethodNotFound, data) + } + }) +} + +// newProtocolParams merges fields into the _meta that opts a request into the +// sessionless protocol (SEP-2575). +func newProtocolParams(fields map[string]any) map[string]any { + params := map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + } + for k, v := range fields { + params[k] = v + } + return params +} + +func TestServerSessionHandle_SetsResultTypeOnNewProtocol(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{ + CompletionHandler: func(context.Context, *CompleteRequest) (*CompleteResult, error) { + return &CompleteResult{ + Completion: CompletionResultDetails{ + Values: []string{"go"}, + }, + }, nil + }, + }) + AddTool(server, &Tool{Name: "tool"}, func(context.Context, *CallToolRequest, struct{}) (*CallToolResult, any, error) { + return &CallToolResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}}, + }, nil, nil + }) + server.AddPrompt(&Prompt{Name: "prompt"}, func(context.Context, *GetPromptRequest) (*GetPromptResult, error) { + return &GetPromptResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}}, + }, nil + }) + server.AddResource(&Resource{URI: "test://resource", Name: "resource"}, func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error) { + return &ReadResourceResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}}, + }, nil + }) + AddTool(server, &Tool{Name: "toolComplete"}, func(context.Context, *CallToolRequest, struct{}) (*CallToolResult, any, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + server.AddPrompt(&Prompt{Name: "promptComplete"}, func(context.Context, *GetPromptRequest) (*GetPromptResult, error) { + return &GetPromptResult{ + Messages: []*PromptMessage{{Role: "assistant", Content: &TextContent{Text: "ok"}}}, + }, nil + }) + server.AddResource(&Resource{URI: "test://resource-complete", Name: "resourceComplete"}, func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error) { + return &ReadResourceResult{ + Contents: []*ResourceContents{{URI: "test://resource-complete", Text: "ok"}}, + }, nil + }) + + tests := []struct { + name string + method string + params map[string]any + want resultType + }{ + { + name: "discover", + method: methodDiscover, + params: newProtocolParams(nil), + want: resultTypeComplete, + }, + { + name: "tools list", + method: methodListTools, + params: newProtocolParams(nil), + want: resultTypeComplete, + }, + { + name: "prompts list", + method: methodListPrompts, + params: newProtocolParams(nil), + want: resultTypeComplete, + }, + { + name: "resources list", + method: methodListResources, + params: newProtocolParams(nil), + want: resultTypeComplete, + }, + { + name: "resource templates list", + method: methodListResourceTemplates, + params: newProtocolParams(nil), + want: resultTypeComplete, + }, + { + name: "complete", + method: methodComplete, + params: newProtocolParams(map[string]any{ + "argument": map[string]any{ + "name": "language", + "value": "g", + }, + "ref": map[string]any{ + "type": "ref/prompt", + "name": "code_review", + }, + }), + want: resultTypeComplete, + }, + { + name: "tool input required", + method: methodCallTool, + params: newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}}), + want: resultTypeInputRequired, + }, + { + name: "prompt input required", + method: methodGetPrompt, + params: newProtocolParams(map[string]any{"name": "prompt"}), + want: resultTypeInputRequired, + }, + { + name: "resource input required", + method: methodReadResource, + params: newProtocolParams(map[string]any{"uri": "test://resource"}), + want: resultTypeInputRequired, + }, + { + name: "tool complete", + method: methodCallTool, + params: newProtocolParams(map[string]any{"name": "toolComplete", "arguments": map[string]any{}}), + want: resultTypeComplete, + }, + { + name: "prompt complete", + method: methodGetPrompt, + params: newProtocolParams(map[string]any{"name": "promptComplete"}), + want: resultTypeComplete, + }, + { + name: "resource complete", + method: methodReadResource, + params: newProtocolParams(map[string]any{"uri": "test://resource-complete"}), + want: resultTypeComplete, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := resultTypeOf(t, server, nil, tc.method, tc.params); got != string(tc.want) { + t.Errorf("resultType = %q, want %q", got, tc.want) + } + }) + } +} + +// TestServerSessionHandle_SetsResultTypeWhenMiddlewareShortCircuits is a +// regression test for #1225. User middleware wraps outside +// serverMultiRoundTripMiddleware, so a middleware that returns a result +// without calling next bypasses both that shim and the dispatcher. +func TestServerSessionHandle_SetsResultTypeWhenMiddlewareShortCircuits(t *testing.T) { + tests := []struct { + name string + method string + params map[string]any + result Result + want resultType + }{ + { + name: "tools/call", + method: methodCallTool, + params: newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}}), + result: &CallToolResult{Content: []Content{&TextContent{Text: "denied"}}}, + want: resultTypeComplete, + }, + { + name: "prompts/get", + method: methodGetPrompt, + params: newProtocolParams(map[string]any{"name": "prompt"}), + result: &GetPromptResult{ + Messages: []*PromptMessage{{Role: "assistant", Content: &TextContent{Text: "denied"}}}, + }, + want: resultTypeComplete, + }, + { + name: "resources/read", + method: methodReadResource, + params: newProtocolParams(map[string]any{"uri": "test://resource"}), + result: &ReadResourceResult{ + Contents: []*ResourceContents{{URI: "test://resource", Text: "denied"}}, + }, + want: resultTypeComplete, + }, + { + // setResultType is unexported, so populating InputRequests is + // the only way a middleware can ask for input. + name: "tools/call input required", + method: methodCallTool, + params: newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}}), + result: &CallToolResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}}, + }, + want: resultTypeInputRequired, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Nothing is registered: if the middleware fell through to next, + // dispatch would fail rather than return a result. + server := NewServer(testImpl, nil) + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == tc.method { + return tc.result, nil + } + return next(ctx, method, req) + } + }) + if got := resultTypeOf(t, server, nil, tc.method, tc.params); got != string(tc.want) { + t.Errorf("resultType = %q, want %q", got, tc.want) + } + }) + } +} + +// TestServerSessionHandle_ResultTypeGate pins when a result carries resultType. +// annotateResultType is the only setter, and handle gates it on the request's +// _meta rather than on the version the session negotiated. So the last case +// below gets no resultType even though the session speaks 2026-07-28. Only a +// client that mixes protocol versions reaches that, so it is recorded rather +// than fixed. +func TestServerSessionHandle_ResultTypeGate(t *testing.T) { + version := func(v string) func(*ServerSessionState) { + return func(s *ServerSessionState) { + s.InitializeParams = &InitializeParams{ProtocolVersion: v} + } + } + callParams := map[string]any{"name": "tool", "arguments": map[string]any{}} + + tests := []struct { + name string + state func(*ServerSessionState) + params map[string]any + want resultType // "" means the field must be absent + }{ + { + name: "legacy session, no _meta", + state: version(protocolVersion20251125), + params: callParams, + }, + { + name: "legacy session, new-protocol _meta", + state: version(protocolVersion20251125), + params: newProtocolParams(callParams), + want: resultTypeComplete, + }, + { + name: "new-protocol session, no _meta", + state: version(protocolVersion20260728), + params: callParams, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, nil) + // Short-circuit so this measures handle's gate, not the + // dispatcher's. + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(context.Context, string, Request) (Result, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil + } + }) + if got := resultTypeOf(t, server, tc.state, methodCallTool, tc.params); got != string(tc.want) { + t.Errorf("resultType = %q, want %q", got, tc.want) + } + }) + } +} + +// TestServerSessionHandle_NilResultFromMiddleware checks that a middleware +// returning no result does not panic. A typed nil satisfies Result, so the +// annotation helpers would otherwise dereference it. +func TestServerSessionHandle_NilResultFromMiddleware(t *testing.T) { + tests := []struct { + name string + result Result + }{ + {"nil interface", nil}, + {"typed nil", (*CallToolResult)(nil)}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, nil) + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(context.Context, string, Request) (Result, error) { + return tc.result, nil + } + }) + ss := &ServerSession{server: server} + id, err := jsonrpc.MakeID("test") + if err != nil { + t.Fatal(err) + } + res, err := ss.handle(context.Background(), &jsonrpc.Request{ + ID: id, + Method: methodCallTool, + Params: mustMarshal(newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}})), + }) + if err != nil { + t.Fatalf("handle: %v", err) + } + data, err := json.Marshal(res) + if err != nil { + t.Fatal(err) + } + if got := string(data); got != "null" { + t.Errorf("result = %s, want null", got) + } + }) + } +} + +// resultTypeOf handles one request on a transportless session and reports the +// resultType of the marshaled result, or "" if the field is absent. +func resultTypeOf(t *testing.T, server *Server, state func(*ServerSessionState), method string, params map[string]any) string { + t.Helper() + ss := &ServerSession{server: server} + if state != nil { + ss.updateState(state) + } + id, err := jsonrpc.MakeID("test") + if err != nil { + t.Fatal(err) + } + result, err := ss.handle(context.Background(), &jsonrpc.Request{ + ID: id, + Method: method, + Params: mustMarshal(params), + }) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + raw, ok := fields["resultType"] + if !ok { + return "" + } + var got string + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshaling resultType from %s: %v", data, err) + } + return got +} + +// TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol verifies that +// the methods removed by SEP-2575 (`initialize`, `notifications/initialized`, +// `ping`) all return Method not found when the request opts into the new +// protocol via `_meta.protocolVersion`. +func TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol(t *testing.T) { + + newProtoMeta := map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + } + + tests := []struct { + name string + method string + }{ + {"initialize", methodInitialize}, + {"ping", methodPing}, + {"notifications/initialized", notificationInitialized}, + } + + for _, tc := range tests { + t.Run(tc.name+" rejected on new protocol", func(t *testing.T) { + ss := &ServerSession{server: NewServer(testImpl, nil)} + id, err := jsonrpc.MakeID("test") + if err != nil { + t.Fatal(err) + } + req := &jsonrpc.Request{ + ID: id, + Method: tc.method, + Params: mustMarshal(newProtoMeta), + } + _, err = ss.handle(context.Background(), req) + if err == nil { + t.Fatalf("method %q on new protocol: got nil error, want CodeMethodNotFound", tc.method) + } + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("error type = %T, want *jsonrpc.Error", err) + } + if jerr.Code != jsonrpc.CodeMethodNotFound { + t.Errorf("method %q: code = %d, want %d", tc.method, jerr.Code, jsonrpc.CodeMethodNotFound) + } + if !strings.Contains(jerr.Message, tc.method) { + t.Errorf("method %q: message %q does not mention method name", tc.method, jerr.Message) + } + }) + } +} + +// TestServerSession_RejectsServerInitiatedRequests verifies that +// SEP-2322 / SEP-2575 is enforced at the API surface: [ServerSession.Elicit], +// [ServerSession.CreateMessage], [ServerSession.CreateMessageWithTools], and +// [ServerSession.ListRoots] must refuse to send a server-to-client request +// when the session is negotiated at protocol version >= 2026-07-28, and must +// remain functional on pre-2026-07-28 sessions. +func TestServerSession_RejectsServerInitiated(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + call func(*ServerSession) error + }{ + { + name: "Elicit", + call: func(ss *ServerSession) error { + _, err := ss.Elicit(ctx, &ElicitParams{Message: "hi"}) + return err + }, + }, + { + name: "CreateMessage", + call: func(ss *ServerSession) error { + _, err := ss.CreateMessage(ctx, &CreateMessageParams{}) + return err + }, + }, + { + name: "CreateMessageWithTools", + call: func(ss *ServerSession) error { + _, err := ss.CreateMessageWithTools(ctx, &CreateMessageWithToolsParams{}) + return err + }, + }, + { + name: "ListRoots", + call: func(ss *ServerSession) error { + _, err := ss.ListRoots(ctx, nil) + return err + }, + }, + } + + // Cover both branches of the era gate: modern must reject, legacy must let + // the call proceed to the wire (where it either succeeds or fails on + // something unrelated to the guard, such as missing client capabilities). + for _, protoVer := range []string{protocolVersion20260728, protocolVersion20251125} { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s/%s", protoVer, tc.name), func(t *testing.T) { + ct, st := NewInMemoryTransports() + s := NewServer(testImpl, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + + // Client advertises every server-to-client capability so a + // legacy-era call is not rejected for capability reasons + // instead of era reasons. + c := NewClient(testImpl, &ClientOptions{ + ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "cancel"}, nil + }, + CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { + return &CreateMessageResult{Model: "m", Role: "assistant", Content: &TextContent{Text: "ok"}}, nil + }, + }) + c.AddRoots(&Root{URI: "file:///workspace"}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protoVer}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cs.Close() }) + + gotErr := tc.call(ss) + modern := protoVer >= protocolVersion20260728 + if modern { + if gotErr == nil { + t.Fatalf("%s on %s: got nil error, want era-gate rejection", tc.name, protoVer) + } + wantSubstr := "cannot be sent while serving a request on protocol version " + protoVer + if !strings.Contains(gotErr.Error(), wantSubstr) { + t.Errorf("%s on %s: error %q does not contain %q", tc.name, protoVer, gotErr.Error(), wantSubstr) + } + if !strings.Contains(gotErr.Error(), "multi round-trip requests") { + t.Errorf("%s on %s: error %q does not steer to MRTR", tc.name, protoVer, gotErr.Error()) + } + } else { + if gotErr != nil { + t.Errorf("%s on %s: got error %v, want nil (era gate must not fire on legacy)", tc.name, protoVer, gotErr) + } + } + }) + } + } +} + +func TestServerSetCacheable(t *testing.T) { + ctx := context.Background() + // The values an opinionated resource handler sets on its own result. + handlerValue := Cacheable{TTLMs: 9_000, CacheScope: "private"} + + for _, tc := range []struct { + name string + callback func(context.Context, Request, *Cacheable) + wantSDK Cacheable // for a result whose fields nobody set + wantHandler Cacheable // for a result the handler set fields on + }{ + { + name: "no callback", + wantSDK: Cacheable{TTLMs: 0, CacheScope: "public"}, + wantHandler: handlerValue, + }, + { + name: "callback overrules the handler", + callback: func(_ context.Context, _ Request, c *Cacheable) { + c.TTLMs, c.CacheScope = 60_000, "private" + }, + wantSDK: Cacheable{TTLMs: 60_000, CacheScope: "private"}, + wantHandler: Cacheable{TTLMs: 60_000, CacheScope: "private"}, + }, + { + // The callback is handed what the handler produced, so it can + // single out the results that expressed no opinion. The scope it + // leaves empty is still filled with the protocol default. + name: "callback defers to the handler", + callback: func(_ context.Context, _ Request, c *Cacheable) { + if c.CacheScope == "" { + c.TTLMs = 1_000 + } + }, + wantSDK: Cacheable{TTLMs: 1_000, CacheScope: "public"}, + wantHandler: handlerValue, + }, + } { + t.Run(tc.name, func(t *testing.T) { + s := NewServer(testImpl, &ServerOptions{SetCacheable: tc.callback}) + s.AddResource(&Resource{URI: "test://plain", Name: "plain"}, + func(_ context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + return &ReadResourceResult{Contents: []*ResourceContents{{URI: req.Params.URI, Text: "x"}}}, nil + }) + s.AddResource(&Resource{URI: "test://opinionated", Name: "opinionated"}, + func(_ context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + return &ReadResourceResult{ + Cacheable: handlerValue, + Contents: []*ResourceContents{{URI: req.Params.URI, Text: "x"}}, + }, nil + }) + cs := mustConnect(t, s, nil) + + check := func(label string, got, want Cacheable) { + t.Helper() + if got != want { + t.Errorf("%s Cacheable = %+v, want %+v", label, got, want) + } + } + + tools, err := cs.ListTools(ctx, nil) // built by the SDK, never by a handler + if err != nil { + t.Fatal(err) + } + check("tools/list", tools.Cacheable, tc.wantSDK) + + for uri, want := range map[string]Cacheable{ + "test://plain": tc.wantSDK, + "test://opinionated": tc.wantHandler, + } { + res, err := cs.ReadResource(ctx, &ReadResourceParams{URI: uri}) + if err != nil { + t.Fatal(err) + } + check(uri, res.Cacheable, want) + } + }) + } +} + +// TestServerSupportedProtocolVersions verifies that +// [ServerOptions.SupportedProtocolVersions] narrows both the versions the +// server advertises in server/discover and the versions it negotiates, +// and that it cannot widen SDK support. +func TestServerSupportedProtocolVersions(t *testing.T) { + ctx := context.Background() + + t.Run("discover advertises the configured subset", func(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{ + // Deliberately unordered: the server must advertise newest first. + SupportedProtocolVersions: []string{protocolVersion20251125, protocolVersion20260728}, + }) + var advertised []string + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + res, err := next(ctx, method, req) + if method == methodDiscover && err == nil { + advertised = res.(*DiscoverResult).SupportedVersions + } + return res, err + } + }) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + cs, err := NewClient(testImpl, nil).Connect(ctx, ct, &ClientSessionOptions{ + ProtocolVersion: protocolVersion20260728, + }) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + want := []string{protocolVersion20260728, protocolVersion20251125} + if diff := cmp.Diff(want, advertised); diff != "" { + t.Errorf("DiscoverResult.SupportedVersions mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("initialize negotiates within the configured subset", func(t *testing.T) { + tests := []struct { + name string + supported []string + client string + want string + }{ + { + name: "requested version supported", + supported: []string{protocolVersion20251125, protocolVersion20250618}, + client: protocolVersion20250618, + want: protocolVersion20250618, + }, + { + name: "requested version excluded", + supported: []string{protocolVersion20251125, protocolVersion20250618}, + client: protocolVersion20241105, + want: protocolVersion20251125, + }, + { + name: "fallback is the newest configured version", + supported: []string{protocolVersion20250326}, + client: protocolVersion20241105, + want: protocolVersion20250326, + }, + { + // Nothing the server supports can carry an initialize + // handshake, so the answer names a handshake-era version the + // client can act on rather than one it could mistake for the + // new protocol. + name: "server with no handshake version", + supported: []string{protocolVersion20260728}, + client: protocolVersion20250618, + want: protocolVersion20251125, + }, + { + name: "unrestricted server keeps the default behavior", + supported: nil, + client: protocolVersion20241105, + want: protocolVersion20241105, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{SupportedProtocolVersions: test.supported}) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + cs, err := NewClient(testImpl, nil).Connect(ctx, ct, &ClientSessionOptions{ + ProtocolVersion: test.client, + }) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + if got := cs.InitializeResult().ProtocolVersion; got != test.want { + t.Errorf("negotiated protocol version = %q, want %q", got, test.want) + } + }) + } + }) + + t.Run("unknown version panics", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("NewServer did not panic on an unsupported protocol version") + } + }() + NewServer(testImpl, &ServerOptions{SupportedProtocolVersions: []string{"1999-01-01"}}) + }) +} + +// TestServerSupportedProtocolVersions_NewProtocol verifies that a request +// using the SEP-2575 per-request protocol is rejected with +// [CodeUnsupportedProtocolVersion] when its version is excluded by +// [ServerOptions.SupportedProtocolVersions]. +func TestServerSupportedProtocolVersions_NewProtocol(t *testing.T) { + ctx := context.Background() + server := NewServer(testImpl, &ServerOptions{ + SupportedProtocolVersions: []string{protocolVersion20251125}, + }) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + _ = ct + + params := fmt.Sprintf(`{"_meta":{%q:%q,%q:{}}}`, + MetaKeyProtocolVersion, protocolVersion20260728, MetaKeyClientCapabilities) + _, err = ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: methodListTools, + Params: json.RawMessage(params), + }) + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("handle returned %v, want a *jsonrpc.Error", err) + } + if jerr.Code != CodeUnsupportedProtocolVersion { + t.Fatalf("error code = %d, want %d", jerr.Code, CodeUnsupportedProtocolVersion) + } + var data UnsupportedProtocolVersionData + if err := json.Unmarshal(jerr.Data, &data); err != nil { + t.Fatalf("unmarshal error data: %v", err) + } + if diff := cmp.Diff([]string{protocolVersion20251125}, data.Supported); diff != "" { + t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff) + } +} + +// TestServerHandle_LegacyCallBeforeInitialize asserts that a call carrying no +// new-protocol `_meta` is refused on a session that has not completed +// 'initialize', whichever branch of the method switch it lands in. The +// handshake itself is what ends that state, and the lifecycle spec exempts +// pings, so those two are served. +// +// The gate used to sit in the default branch, so 'logging/setLevel', +// 'resources/subscribe' and 'resources/unsubscribe', which share a case that +// only refuses new-protocol requests, were served before the handshake. For +// subscribe that registered a session that never handshook as a subscriber, +// so the subtest also asserts that the refusal leaves no subscriber and runs +// no handler. +func TestServerHandle_LegacyCallBeforeInitialize(t *testing.T) { + ctx := context.Background() + const uri = "test://resource" + + tests := []struct { + name string + method string + params string + served bool + }{ + { + name: "initialize", + method: methodInitialize, + params: fmt.Sprintf(`{"protocolVersion":%q,"capabilities":{},"clientInfo":{"name":"c","version":"1"}}`, protocolVersion20251125), + served: true, + }, + {name: "ping", method: methodPing, params: `{}`, served: true}, + {name: "tools/list", method: methodListTools, params: `{}`}, + {name: "logging/setLevel", method: methodSetLevel, params: `{"level":"info"}`}, + {name: "resources/subscribe", method: methodSubscribe, params: fmt.Sprintf(`{"uri":%q}`, uri)}, + {name: "resources/unsubscribe", method: methodUnsubscribe, params: fmt.Sprintf(`{"uri":%q}`, uri)}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var handlerRan bool + server := NewServer(testImpl, &ServerOptions{ + SubscribeHandler: func(context.Context, *SubscribeRequest) error { + handlerRan = true + return nil + }, + UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error { + handlerRan = true + return nil + }, + }) + _, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + res, err := ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: tc.method, + Params: json.RawMessage(tc.params), + }) + if tc.served { + if err != nil { + t.Fatalf("handle(%q) before initialize returned %v, want it served", tc.method, err) + } + if res == nil { + t.Fatalf("handle(%q) before initialize returned no result", tc.method) + } + if tc.method == methodInitialize && ss.InitializeParams() == nil { + t.Errorf("initialize was answered without recording InitializeParams") + } + return + } + if err == nil { + t.Fatalf("handle(%q) served the call before initialize, want a refusal", tc.method) + } + if want := fmt.Sprintf("method %q is invalid during session initialization", tc.method); err.Error() != want { + t.Errorf("handle(%q) error = %q, want %q", tc.method, err.Error(), want) + } + if handlerRan { + t.Errorf("handle(%q) ran the server's handler for a call it refused", tc.method) + } + server.mu.Lock() + _, subscribed := server.resourceSubscriptions[uri][ss] + server.mu.Unlock() + if subscribed { + t.Errorf("handle(%q) left the session subscribed to %q", tc.method, uri) + } + }) + } +} + +// TestServerHandle_NewProtocolCallWithoutInitialize asserts that the gate +// stays off the SEP-2575 path. A fresh session that never sends 'initialize' +// is served a new-protocol 'tools/list', and the methods the gate now covers +// are answered, when a new-protocol request names them, with the error the +// new protocol already gave them (the method does not exist there), and not +// with the initialization refusal. +func TestServerHandle_NewProtocolCallWithoutInitialize(t *testing.T) { + ctx := context.Background() + const uri = "test://resource" + + tests := []struct { + name string + method string + params map[string]any + served bool + }{ + {name: "tools/list", method: methodListTools, params: newProtocolParams(nil), served: true}, + {name: "logging/setLevel", method: methodSetLevel, params: newProtocolParams(map[string]any{"level": "info"})}, + {name: "resources/subscribe", method: methodSubscribe, params: newProtocolParams(map[string]any{"uri": uri})}, + {name: "resources/unsubscribe", method: methodUnsubscribe, params: newProtocolParams(map[string]any{"uri": uri})}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{ + SubscribeHandler: func(context.Context, *SubscribeRequest) error { return nil }, + UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error { return nil }, + }) + _, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + params, err := json.Marshal(tc.params) + if err != nil { + t.Fatalf("marshalling params: %v", err) + } + res, err := ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: tc.method, + Params: params, + }) + if tc.served { + if err != nil { + t.Fatalf("handle(%q) on a new-protocol session returned %v, want it served", tc.method, err) + } + if res == nil { + t.Fatalf("handle(%q) on a new-protocol session returned no result", tc.method) + } + return + } + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("handle(%q) returned %v, want a *jsonrpc.Error", tc.method, err) + } + if jerr.Code != jsonrpc.CodeMethodNotFound { + t.Errorf("handle(%q) error code = %d, want %d (removed in the new protocol)", tc.method, jerr.Code, jsonrpc.CodeMethodNotFound) + } + if strings.Contains(err.Error(), "invalid during session initialization") { + t.Errorf("handle(%q) applied the initialization gate to a new-protocol request: %v", tc.method, err) + } + }) + } +} + +// TestServerUnknownProtocolVersion_NewProtocol verifies that a request whose +// `_meta.protocolVersion` names a version the SDK does not know is rejected +// with [CodeUnsupportedProtocolVersion], and not served as a legacy handshake +// because its version string happens to sort below 2026-07-28. +func TestServerUnknownProtocolVersion_NewProtocol(t *testing.T) { + ctx := context.Background() + // Each case names a version that is not in [supportedProtocolVersions] and + // sorts below 2026-07-28. + tests := []struct { + name string + version string + extraMeta string // further `_meta` entries, each with a leading comma + }{ + // The versioning spec uses 1900-01-01 to illustrate this error. + {"documented example", "1900-01-01", fmt.Sprintf(`,%q:{}`, MetaKeyClientCapabilities)}, + {"unreleased revision", "2025-11-24", fmt.Sprintf(`,%q:{}`, MetaKeyClientCapabilities)}, + {"not a date", "1.0", fmt.Sprintf(`,%q:{}`, MetaKeyClientCapabilities)}, + // The rest of the `_meta` triple is defined by the revision the request + // names, so its absence must not mask the version itself. + {"no clientCapabilities", "1900-01-01", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, nil) + _, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + params := fmt.Sprintf(`{"_meta":{%q:%q%s}}`, MetaKeyProtocolVersion, tc.version, tc.extraMeta) + _, err = ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: methodListTools, + Params: json.RawMessage(params), + }) + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("handle returned %v, want a *jsonrpc.Error", err) + } + if jerr.Code != CodeUnsupportedProtocolVersion { + t.Fatalf("error code = %d, want %d", jerr.Code, CodeUnsupportedProtocolVersion) + } + var data UnsupportedProtocolVersionData + if err := json.Unmarshal(jerr.Data, &data); err != nil { + t.Fatalf("unmarshal error data: %v", err) + } + if data.Requested != tc.version { + t.Errorf("UnsupportedProtocolVersionData.Requested = %q, want %q", data.Requested, tc.version) + } + if diff := cmp.Diff(SupportedProtocolVersions(), data.Supported); diff != "" { + t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/mcp/session.go b/mcp/session.go index dcf9888..08eccaf 100644 --- a/mcp/session.go +++ b/mcp/session.go @@ -22,6 +22,13 @@ type ServerSessionState struct { // InitializedParams are the parameters from 'notifications/initialized'. InitializedParams *InitializedParams `json:"initializedParams"` + // NegotiatedProtocolVersion is the protocol version agreed during + // 'initialize', which may differ from the version the client requested if + // the server does not support it. + // + // It is empty for sessions that never ran the initialize handshake. + NegotiatedProtocolVersion string `json:"negotiatedProtocolVersion,omitempty"` + // LogLevel is the logging level for the session. LogLevel LoggingLevel `json:"logLevel"` diff --git a/mcp/shared.go b/mcp/shared.go index 5662b12..cacfa75 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -14,6 +14,7 @@ package mcp import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "net/http" @@ -25,16 +26,29 @@ import ( "github.com/modelcontextprotocol/go-sdk/auth" internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) +// nowrapinvalidparams is a compatibility parameter that restores the previous +// behavior of [methodInfo.unmarshalParams]. When unset (the default), a +// params-decoding failure is wrapped with [jsonrpc2.ErrInvalidParams] so the +// wire response carries error code -32602 ("invalid params") rather than the +// zero-value code 0. See: +// https://github.com/modelcontextprotocol/go-sdk/issues/976#issuecomment-4829124838. +// +// See the documentation for the mcpgodebug package for instructions how to enable it. +// The option will be removed in a future version of the SDK. +var nowrapinvalidparams = mcpgodebug.Value("nowrapinvalidparams") + const ( // latestProtocolVersion is the latest protocol version that this version of // the SDK supports. // // It is the version that the client sends in the initialization request, and // the default version used by the server. - latestProtocolVersion = protocolVersion20251125 + latestProtocolVersion = protocolVersion20260728 + protocolVersion20260728 = "2026-07-28" protocolVersion20251125 = "2025-11-25" protocolVersion20250618 = "2025-06-18" protocolVersion20250326 = "2025-03-26" @@ -42,24 +56,57 @@ const ( ) var supportedProtocolVersions = []string{ + protocolVersion20260728, protocolVersion20251125, protocolVersion20250618, protocolVersion20250326, protocolVersion20241105, } +// SupportedProtocolVersions returns the protocol versions supported by this +// version of the SDK, newest first. +// +// Use it to discover the values accepted by +// [ServerOptions.SupportedProtocolVersions]. The returned slice is a copy: +// modifying it does not change what the SDK supports. +func SupportedProtocolVersions() []string { return slices.Clone(supportedProtocolVersions) } + // negotiatedVersion returns the effective protocol version to use, given a -// client version. -func negotiatedVersion(clientVersion string) string { - // In general, prefer to use the clientVersion, but if we don't support the - // client's version, use the latest version. +// client version and the versions the server supports, newest first. +func negotiatedVersion(clientVersion string, supported []string) string { + // In general, prefer to use the clientVersion, but if not supported + // by the server, use the latest version the server supports. // - // This handles the case where a new spec version is released, and the SDK - // does not support it yet. - if !slices.Contains(supportedProtocolVersions, clientVersion) { - return latestProtocolVersion + // Cap the supported versions at the legacy protocolVersion20251125, as this + // method is used by the initialize method which is deprecated in + // version protocolVersion20260728. + if slices.Contains(supported, clientVersion) && clientVersion < protocolVersion20260728 { + return clientVersion } - return clientVersion + for _, v := range supported { + if v < protocolVersion20260728 { + return v + } + } + // If the server was enforced to support only protocolVersion20260728 and + // later, the initialize function should not be called. + // If a client calls the initialize function, return the protocolVersion20251125. + // As according to spec (https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation): + // "If the server supports the requested protocol version, it MUST respond with the same version." + // "Otherwise, the server MUST respond with another protocol version it supports." + // "This SHOULD be the latest version supported by the server." + return protocolVersion20251125 +} + +// negotiateMutuallySupportedVersion returns a protocol version that is supported +// by both the client and the server. +func negotiateMutuallySupportedVersion(supported []string) string { + for _, ver := range supportedProtocolVersions { + if slices.Contains(supported, ver) { + return ver + } + } + return "" } // A MethodHandler handles MCP messages. @@ -110,8 +157,12 @@ func defaultSendingMethodHandler(ctx context.Context, method string, req Request // Create the result to unmarshal into. // The concrete type of the result is the return type of the receiving function. res := info.newResult() - if err := call(ctx, req.GetSession().getConn(), method, params, res); err != nil { - return nil, err + if method == methodSubscriptionsListen { + callSubscriptionsListen(ctx, req.GetSession().getConn(), method, params) + } else { + if err := call(ctx, req.GetSession().getConn(), method, params, res); err != nil { + return nil, err + } } return res, nil } @@ -285,7 +336,17 @@ func newMethodInfo[P paramsPtr[T], R Result, T any](flags methodFlags) methodInf var p P if m != nil { if err := internaljson.Unmarshal(m, &p); err != nil { - return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, p, err) + // Legacy behavior: pre-fix versions surfaced this as a + // plain wrapped error, which caused the wire response to + // carry code 0 instead of -32602. Restore via + // MCPGODEBUG=nowrapinvalidparams=1. + if nowrapinvalidparams == "1" { + return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, p, err) + } + // Wrap jsonrpc2.ErrInvalidParams so toWireError surfaces + // code -32602 ("invalid params") while preserving the + // descriptive message. + return nil, fmt.Errorf("%w: unmarshaling %q into a %T: %w", jsonrpc2.ErrInvalidParams, m, p, err) } } // We must check missingParamsOK here, in addition to checkRequest, to @@ -342,14 +403,31 @@ func clientSessionMethod[P Params, R Result](f func(*ClientSession, context.Cont // MCP-specific error codes. const ( - // CodeResourceNotFound indicates that a requested resource could not be found. - CodeResourceNotFound = -32002 + // CodeHeaderMismatch indicates that HTTP headers do not match the corresponding values + // in the request body, or that required headers are missing or malformed. + CodeHeaderMismatch = -32020 + // CodeMissingRequiredClientCapabilities is the JSON-RPC error code defined by + // SEP-2575 for MissingRequiredClientCapabilitiesError. + CodeMissingRequiredClientCapabilities = -32021 + // CodeUnsupportedProtocolVersion is the JSON-RPC error code defined by + // SEP-2575 for UnsupportedProtocolVersionError. + CodeUnsupportedProtocolVersion = -32022 // CodeURLElicitationRequired indicates that the server requires URL elicitation // before processing the request. The client should execute the elicitation handler // with the elicitations provided in the error data. CodeURLElicitationRequired = -32042 ) +// CodeResourceNotFound indicates that a requested resource could not be found. +// +// By default, the value is -32602 (Invalid Params), as specified in the +// MCP specification (SEP-2164). To restore the pre-1.7.0 release behavior where the +// error code was -32002, set MCPGODEBUG=customresnotfounderrcode=1. +// +// Deprecated: Use [jsonrpc.CodeInvalidParams] directly. This variable will be +// removed in a future version. +var CodeResourceNotFound int64 = jsonrpc.CodeInvalidParams + // URLElicitationRequiredError returns an error indicating that URL elicitation is required // before the request can be processed. The elicitations parameter should contain the // elicitation requests that must be completed. @@ -452,6 +530,102 @@ func setProgressToken(p Params, pt any) { m[progressTokenKey] = pt } +// extractRequestMeta performs a lightweight partial unmarshal of the `_meta` +// field from a JSON-RPC request's raw params. +func extractRequestMeta(rawParams json.RawMessage) Meta { + if len(rawParams) == 0 { + return nil + } + var meta struct { + Meta Meta `json:"_meta"` + } + if err := internaljson.Unmarshal(rawParams, &meta); err != nil { + return nil + } + return meta.Meta +} + +type validatedMeta struct { + usesNewProtocol bool + initializeParams *InitializeParams + logLevel LoggingLevel +} + +// validateRequestMeta inspects a JSON-RPC request to detect whether it follows +// the >= 2026-07-28 protocol via the `_meta` field. +// If the request has no _meta, no protocolVersion in _meta, or a protocolVersion +// naming a supported version that predates 2026-07-28, it returns a non-nil +// validatedMeta with usesNewProtocol set to false, and a nil error: such a request +// belongs to the initialize handshake. +// If the protocolVersion names a version that is not supported at all, it returns +// usesNewProtocol set to true with initializeParams carrying only that version, so +// that the caller answers with [CodeUnsupportedProtocolVersion]. The rest of the +// `_meta` triple is not validated in that case, since the revision defining it is +// unknown. +// Otherwise, it validates the presence of clientCapabilities in _meta. If it is +// missing or invalid, it returns nil and a non-nil error. clientInfo is optional; +// if present but invalid, an error is returned. Otherwise, it returns +// usesNewProtocol set to true and the populated initializeParams. +// Notifications always report usesNewProtocol false and never error. +func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { + // SEP-2575 defines the `_meta` triple for calls only: NotificationParams + // declares `_meta` optional with no protocolVersion, so a notification + // neither selects the new protocol nor is discarded for carrying an + // incomplete triple. + if !req.IsCall() { + return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil + } + meta := extractRequestMeta(req.Params) + if meta == nil { + return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil + } + protocolVersion, ok := meta[MetaKeyProtocolVersion].(string) + // An absent or empty value declares no version at all, which is also how the + // streamable handler reads `_meta.protocolVersion`. + if !ok || protocolVersion == "" { + return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil + } + // Classify by membership rather than by ordering: a version the SDK does not + // know sorts arbitrarily against 2026-07-28, and one that sorts below it would + // be served as a legacy handshake instead of reaching the + // CodeUnsupportedProtocolVersion check in ServerSession.handle. The + // streamable handler already applies the same membership rule to the + // Mcp-Protocol-Version header. + supported := slices.Contains(supportedProtocolVersions, protocolVersion) + if supported && protocolVersion < protocolVersion20260728 { + return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil + } + if !supported { + return &validatedMeta{usesNewProtocol: true, initializeParams: &InitializeParams{ + ProtocolVersion: protocolVersion, + }}, nil + } + var clientInfo *Implementation + if _, present := meta[MetaKeyClientInfo]; present { + var ok bool + clientInfo, ok = decodeMetaValue[*Implementation](meta, MetaKeyClientInfo) + if !ok { + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: fmt.Sprintf("invalid _meta field %q", MetaKeyClientInfo), + } + } + } + capabilities, ok := decodeMetaValue[*clientCapabilitiesV2](meta, MetaKeyClientCapabilities) + if !ok { + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: fmt.Sprintf("missing or invalid _meta field %q", MetaKeyClientCapabilities), + } + } + logLevel, _ := decodeMetaValue[LoggingLevel](meta, MetaKeyLogLevel) + return &validatedMeta{usesNewProtocol: true, initializeParams: &InitializeParams{ + ProtocolVersion: protocolVersion, + Capabilities: capabilities.toV1(), + ClientInfo: clientInfo, + }, logLevel: logLevel}, nil +} + // A Request is a method request with parameters and additional information, such as the session. // Request is implemented by [*ClientRequest] and [*ServerRequest]. type Request interface { @@ -490,6 +664,8 @@ type RequestExtra struct { // to configure the reconnection delay. // // [SEP-1699]: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699 + // This mechanism is deprecated in protocol version 2026-07-28 as the resumability + // feature is removed. CloseSSEStream func(CloseSSEStreamArgs) } @@ -512,6 +688,117 @@ func (r *ServerRequest[P]) GetParams() Params { return r.Params } func (r *ClientRequest[P]) GetExtra() *RequestExtra { return nil } func (r *ServerRequest[P]) GetExtra() *RequestExtra { return r.Extra } +// HasParams reports whether req carries params. +// +// The "params" member of a message may be omitted for several methods, among +// them the list methods, ping and notifications/initialized. For such a request +// [Request.GetParams] returns a non-nil [Params] holding a nil pointer, so +// comparing its result against nil reports that params are present, while +// calling a method on that result panics. Middleware that inspects params must +// ask this first: +// +// if mcp.HasParams(req) { +// log.Println(method, req.GetParams().GetMeta()) +// } +func HasParams(req Request) bool { + params := req.GetParams() + if params == nil { + return false + } + // isNil cannot answer this for every value: a params type embedding + // ParamsBase promotes isNil through a field selector, which dereferences + // the nil outer pointer before the method body runs. + v := reflect.ValueOf(params) + return v.Kind() != reflect.Pointer || !v.IsNil() +} + +// ProtocolVersion returns the protocol version negotiated for this request. +// +// For requests following the >= 2026-07-28 protocol, the value is read from +// the per-request `_meta` field. For older protocol requests, the value falls +// back to the session-level [InitializeParams] established during the +// initialize handshake. +func (r *ServerRequest[P]) ProtocolVersion() string { + if m := getRequestMeta(r); m != nil { + if v, ok := m[MetaKeyProtocolVersion].(string); ok { + return v + } + } + if r.Session != nil { + if p := r.Session.InitializeParams(); p != nil { + return p.ProtocolVersion + } + } + return "" +} + +// ClientInfo returns the [Implementation] identifying the calling client. +// +// For requests following the >= 2026-07-28 protocol, the value is read from +// the per-request `_meta` field. For older protocol requests, the value falls +// back to the session-level [InitializeParams]. +func (r *ServerRequest[P]) ClientInfo() *Implementation { + if m := getRequestMeta(r); m != nil { + if v, ok := decodeMetaValue[*Implementation](m, MetaKeyClientInfo); ok { + return v + } + } + if r.Session != nil { + if p := r.Session.InitializeParams(); p != nil { + return p.ClientInfo + } + } + return nil +} + +// ClientCapabilities returns the [ClientCapabilities] of the calling client. +// +// For requests following the >= 2026-07-28 protocol, the value is read from +// the per-request `_meta` field. For older protocol requests, the value falls +// back to the session-level [InitializeParams]. +func (r *ServerRequest[P]) ClientCapabilities() *ClientCapabilities { + if m := getRequestMeta(r); m != nil { + if v, ok := decodeMetaValue[*clientCapabilitiesV2](m, MetaKeyClientCapabilities); ok { + return v.toV1() + } + } + if r.Session != nil { + if p := r.Session.InitializeParams(); p != nil { + return p.Capabilities + } + } + return nil +} + +// getRequestMeta returns the raw `_meta` map from the request's params, or +// nil if the params are absent. +func getRequestMeta[P Params](r *ServerRequest[P]) map[string]any { + if !HasParams(r) { + return nil + } + return r.Params.GetMeta() +} + +// decodeMetaValue decodes a typed value out of a `_meta` map. Values may +// arrive either as the typed Go value (when constructed in-process) or as +// the generic JSON map produced by encoding/json after wire transit. In the +// latter case, the value is re-encoded and decoded into the target type. +func decodeMetaValue[T any](m map[string]any, key string) (T, bool) { + var zero T + raw, ok := m[key] + if !ok || raw == nil { + return zero, false + } + if v, ok := raw.(T); ok { + return v, true + } + var v T + if err := remarshal(raw, &v); err != nil { + return zero, false + } + return v, true +} + func serverRequestFor[P Params](s *ServerSession, p P) *ServerRequest[P] { return &ServerRequest[P]{Session: s, Params: p} } @@ -529,8 +816,27 @@ type Params interface { // isParams discourages implementation of Params outside of this package. isParams() + + // isNil returns true if the underlying value is nil. + isNil() bool } +// ParamsBase can be embedded in custom parameter structs to satisfy the +// [Params] interface. It provides the required [Meta] field and the unexported +// isParams marker method. +// +// type SearchParams struct { +// mcp.ParamsBase +// Query string `json:"query"` +// } +type ParamsBase struct { + Meta `json:"_meta,omitempty"` +} + +func (*ParamsBase) isParams() {} + +func (p *ParamsBase) isNil() bool { return p == nil } + // RequestParams is a parameter (input) type for an MCP request. type RequestParams interface { Params @@ -549,17 +855,36 @@ type Result interface { // isResult discourages implementation of Result outside of this package. isResult() + // isNil returns true if the underlying value is nil. + isNil() bool + // GetMeta returns metadata from a value. GetMeta() map[string]any // SetMeta sets the metadata on a value. SetMeta(map[string]any) } +// ResultBase can be embedded in custom result structs to satisfy the +// [Result] interface. It provides the required [Meta] field and the unexported +// isResult marker method. +// +// type SearchResult struct { +// mcp.ResultBase +// Hits []string `json:"hits"` +// } +type ResultBase struct { + Meta `json:"_meta,omitempty"` +} + +func (*ResultBase) isResult() {} +func (x *ResultBase) isNil() bool { return x == nil } + // emptyResult is returned by methods that have no result, like ping. // Those methods cannot return nil, because jsonrpc2 cannot handle nils. type emptyResult struct{} func (*emptyResult) isResult() {} +func (x *emptyResult) isNil() bool { return x == nil } func (*emptyResult) GetMeta() map[string]any { panic("should never be called") } func (*emptyResult) SetMeta(map[string]any) { panic("should never be called") } @@ -582,7 +907,21 @@ type keepaliveSession interface { // startKeepalive starts the keepalive mechanism for a session. // It assigns the cancel function to the provided cancelPtr and starts a goroutine // that sends ping messages at the specified interval. -func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr *context.CancelFunc) { +// +// failureThreshold is the number of consecutive ping failures tolerated before +// the session is closed; a value below 1 is treated as 1 (close on the first +// failure). A successful ping resets the counter. This mirrors the spec's +// "multiple failed pings MAY trigger a connection reset" language, letting a +// transient miss pass without tearing down an otherwise live session. +// +// logger must be non-nil; ping failures (both the tolerated ones and the final +// one that closes the session) are reported via logger so they are not silently +// dropped. +func startKeepalive(session keepaliveSession, interval time.Duration, failureThreshold int, cancelPtr *context.CancelFunc, logger *slog.Logger) { + if failureThreshold < 1 { + failureThreshold = 1 + } + ctx, cancel := context.WithCancel(context.Background()) // Assign cancel function before starting goroutine to avoid race condition. // We cannot return it because the caller may need to cancel during the @@ -593,6 +932,7 @@ func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr ticker := time.NewTicker(interval) defer ticker.Stop() + consecutiveFailures := 0 for { select { case <-ctx.Done(): @@ -601,11 +941,32 @@ func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr pingCtx, pingCancel := context.WithTimeout(context.Background(), interval/2) err := session.Ping(pingCtx, nil) pingCancel() - if err != nil { - // Ping failed, close the session - _ = session.Close() + if err == nil { + consecutiveFailures = 0 + continue + } + if errors.Is(err, jsonrpc2.ErrMethodNotFound) { + // Peer doesn't support ping, stop the keepalive process. return } + consecutiveFailures++ + if consecutiveFailures < failureThreshold { + // Tolerate transient failures below the threshold; log so + // the misses are still observable to operators. See #218. + logger.Warn("keepalive ping failed; tolerating below threshold", + "error", err, + "consecutiveFailures", consecutiveFailures, + "failureThreshold", failureThreshold) + continue + } + // Threshold reached; log before closing the session so the + // failure is observable to operators. See #218. + logger.Error("keepalive ping failed; closing session", + "error", err, + "consecutiveFailures", consecutiveFailures, + "failureThreshold", failureThreshold) + _ = session.Close() + return } } }() diff --git a/mcp/shared_test.go b/mcp/shared_test.go index 23818f8..4566768 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -4,6 +4,415 @@ package mcp +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" +) + +func TestValidateRequestMeta(t *testing.T) { + tests := []struct { + name string + method string + params any + wantUsesNew bool + wantLogLevel LoggingLevel + wantErrContains string + }{ + { + name: "no params: old protocol", + method: methodListTools, + params: nil, + wantUsesNew: false, + }, + { + name: "no _meta: old protocol", + method: methodCallTool, + params: map[string]any{"name": "x"}, + wantUsesNew: false, + }, + { + name: "_meta without protocolVersion: old protocol", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{"otherKey": "v"}, + "name": "x", + }, + wantUsesNew: false, + }, + { + name: "new protocol with all required fields", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + "name": "x", + }, + wantUsesNew: true, + }, + { + name: "new protocol missing clientInfo", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientCapabilities: map[string]any{}, + }, + "name": "x", + }, + wantUsesNew: true, + }, + { + name: "new protocol invalid clientInfo", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: "not an object", + MetaKeyClientCapabilities: map[string]any{}, + }, + "name": "x", + }, + wantUsesNew: false, + wantErrContains: MetaKeyClientInfo, + }, + { + name: "new protocol missing clientCapabilities", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + }, + "name": "x", + }, + wantUsesNew: false, + wantErrContains: MetaKeyClientCapabilities, + }, + { + name: "malformed _meta is ignored", + method: methodCallTool, + params: json.RawMessage(`{"_meta": "not an object", "name": "x"}`), + wantUsesNew: false, + }, + { + name: "supported pre-2026-07-28 version: old protocol", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20251125, + }, + "name": "x", + }, + wantUsesNew: false, + }, + { + name: "empty protocolVersion declares nothing: old protocol", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: "", + }, + "name": "x", + }, + wantUsesNew: false, + }, + { + // An unsupported version sorting below 2026-07-28 is still a + // new-protocol request, so that the caller can answer it with + // CodeUnsupportedProtocolVersion. + name: "unsupported version: new protocol", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: "1900-01-01", + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + "name": "x", + }, + wantUsesNew: true, + }, + { + // The triple is defined by a revision the SDK does not know, so an + // unsupported version is reported without validating the rest of it. + name: "unsupported version missing clientCapabilities: new protocol", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: "1900-01-01", + }, + "name": "x", + }, + wantUsesNew: true, + }, + { + name: "new protocol with logLevel", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + MetaKeyLogLevel: "warning", + }, + "name": "x", + }, + wantUsesNew: true, + wantLogLevel: "warning", + }, + { + name: "new protocol without logLevel", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + "name": "x", + }, + wantUsesNew: true, + wantLogLevel: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var raw json.RawMessage + switch p := tc.params.(type) { + case json.RawMessage: + raw = p + default: + raw = mustMarshal(tc.params) + } + id, err := jsonrpc.MakeID("test") + if err != nil { + t.Fatal(err) + } + req := &jsonrpc.Request{Method: tc.method, Params: raw, ID: id} + + vmeta, err := validateRequestMeta(req) + usesNew := vmeta != nil && vmeta.usesNewProtocol + if usesNew != tc.wantUsesNew { + t.Errorf("usesNewProtocol = %v, want %v", usesNew, tc.wantUsesNew) + } + if vmeta != nil && vmeta.logLevel != tc.wantLogLevel { + t.Errorf("logLevel = %q, want %q", vmeta.logLevel, tc.wantLogLevel) + } + if tc.wantErrContains == "" { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErrContains) + } + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("expected *jsonrpc.Error, got %T: %v", err, err) + } + if jerr.Code != jsonrpc.CodeInvalidParams { + t.Errorf("error code = %d, want %d", jerr.Code, jsonrpc.CodeInvalidParams) + } + if !strings.Contains(jerr.Message, tc.wantErrContains) { + t.Errorf("error message %q does not contain %q", jerr.Message, tc.wantErrContains) + } + }) + } +} + +func TestServerRequest_PerRequestAccessors(t *testing.T) { + // A request carrying the new-protocol _meta fields populates the + // accessors with values from _meta. + caps := &ClientCapabilities{Sampling: &SamplingCapabilities{}} + info := &Implementation{Name: "c", Version: "1"} + params := &CallToolParamsRaw{ + Meta: Meta{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: info, + MetaKeyClientCapabilities: caps, + }, + Name: "x", + } + req := &ServerRequest[*CallToolParamsRaw]{Params: params} + if got := req.ProtocolVersion(); got != protocolVersion20260728 { + t.Errorf("ProtocolVersion = %q, want %q", got, protocolVersion20260728) + } + if got := req.ClientInfo(); got == nil || got.Name != "c" { + t.Errorf("ClientInfo = %+v, want Name=c", got) + } + if got := req.ClientCapabilities(); got == nil || got.Sampling == nil { + t.Errorf("ClientCapabilities = %+v, want non-nil Sampling", got) + } +} + +func TestServerRequest_PerRequestAccessors_FromJSON(t *testing.T) { + // Values arriving over the wire are JSON maps; the accessors should + // re-decode them into typed Go values. + raw := json.RawMessage(`{ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "wire-client", "version": "9"}, + "io.modelcontextprotocol/clientCapabilities": {"sampling": {}} + }, + "name": "tool" + }`) + var params CallToolParamsRaw + if err := json.Unmarshal(raw, ¶ms); err != nil { + t.Fatal(err) + } + req := &ServerRequest[*CallToolParamsRaw]{Params: ¶ms} + if got, want := req.ProtocolVersion(), protocolVersion20260728; got != want { + t.Errorf("ProtocolVersion = %q, want %q", got, want) + } + gotInfo := req.ClientInfo() + wantInfo := &Implementation{Name: "wire-client", Version: "9"} + if diff := cmp.Diff(wantInfo, gotInfo); diff != "" { + t.Errorf("ClientInfo mismatch (-want +got):\n%s", diff) + } + gotCaps := req.ClientCapabilities() + if gotCaps == nil || gotCaps.Sampling == nil { + t.Errorf("ClientCapabilities = %+v, want non-nil Sampling", gotCaps) + } +} + +func TestServerRequest_PerRequestAccessors_FallbackToInitializeParams(t *testing.T) { + // With no _meta on the request, accessors must fall back to the + // session's InitializeParams (the old-protocol path). + ss := &ServerSession{} + ss.state.InitializeParams = &InitializeParams{ + ProtocolVersion: protocolVersion20251125, + ClientInfo: &Implementation{Name: "old", Version: "0"}, + Capabilities: &ClientCapabilities{Elicitation: &ElicitationCapabilities{}}, + } + req := &ServerRequest[*CallToolParamsRaw]{ + Session: ss, + Params: &CallToolParamsRaw{Name: "x"}, + } + if got, want := req.ProtocolVersion(), protocolVersion20251125; got != want { + t.Errorf("ProtocolVersion fallback = %q, want %q", got, want) + } + if got := req.ClientInfo(); got == nil || got.Name != "old" { + t.Errorf("ClientInfo fallback = %+v, want Name=old", got) + } + if got := req.ClientCapabilities(); got == nil || got.Elicitation == nil { + t.Errorf("ClientCapabilities fallback = %+v, want non-nil Elicitation", got) + } +} + +func TestServerRequest_PerRequestAccessors_Empty(t *testing.T) { + // With no _meta and no session, accessors return zero values. + req := &ServerRequest[*CallToolParamsRaw]{ + Params: &CallToolParamsRaw{Name: "x"}, + } + if got := req.ProtocolVersion(); got != "" { + t.Errorf("ProtocolVersion = %q, want empty", got) + } + if got := req.ClientInfo(); got != nil { + t.Errorf("ClientInfo = %+v, want nil", got) + } + if got := req.ClientCapabilities(); got != nil { + t.Errorf("ClientCapabilities = %+v, want nil", got) + } +} + +func TestHasParams(t *testing.T) { + // {"jsonrpc":"2.0","id":1,"method":"tools/list"} is a complete request: + // serverMethodInfos marks these methods missingParamsOK. Build what + // handleReceive hands the middleware chain for one. + for _, method := range []string{ + methodListTools, + methodListPrompts, + methodListResources, + notificationInitialized, + } { + t.Run(method, func(t *testing.T) { + info := serverMethodInfos[method] + params, err := info.unmarshalParams(nil) + if err != nil { + t.Fatal(err) + } + req := info.newRequest(&ServerSession{}, params, nil) + if req.GetParams() == nil { + t.Fatal("GetParams returned a nil interface, so this test no longer covers the typed-nil case") + } + if HasParams(req) { + t.Error("HasParams = true for a request whose params member was omitted") + } + }) + } + + t.Run("params present", func(t *testing.T) { + info := serverMethodInfos[methodListTools] + params, err := info.unmarshalParams(json.RawMessage(`{"cursor":"c"}`)) + if err != nil { + t.Fatal(err) + } + req := info.newRequest(&ServerSession{}, params, nil) + if !HasParams(req) { + t.Error("HasParams = false for a request carrying params") + } + }) +} + +func TestServerRequest_PerRequestAccessors_MissingParams(t *testing.T) { + // Custom methods also allow the params member to be omitted, and their + // params types embed ParamsBase, whose promoted isNil dereferences the + // typed-nil outer pointer. + type customParams struct{ ParamsBase } + req := &ServerRequest[*customParams]{} + if got := req.ProtocolVersion(); got != "" { + t.Errorf("ProtocolVersion = %q, want empty", got) + } + if got := req.ClientInfo(); got != nil { + t.Errorf("ClientInfo = %+v, want nil", got) + } + if got := req.ClientCapabilities(); got != nil { + t.Errorf("ClientCapabilities = %+v, want nil", got) + } +} + +func TestImplementationDescriptionJSON(t *testing.T) { + impl := &Implementation{ + Name: "greeter", + Title: "Greeter", + Description: "Example server for greeting tools", + Version: "v1.0.0", + } + got, err := json.Marshal(impl) + if err != nil { + t.Fatal(err) + } + want := `{"name":"greeter","title":"Greeter","description":"Example server for greeting tools","version":"v1.0.0"}` + if string(got) != want { + t.Fatalf("Implementation JSON = %s, want %s", got, want) + } + + var roundTrip Implementation + if err := json.Unmarshal(got, &roundTrip); err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(impl, &roundTrip); diff != "" { + t.Fatalf("Implementation round trip mismatch (-want +got):\n%s", diff) + } + + got, err = json.Marshal(&Implementation{Name: "greeter", Version: "v1.0.0"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(got), "description") { + t.Fatalf("empty description should be omitted, got %s", got) + } +} + // TODO(v0.3.0): rewrite this test. // func TestToolValidate(t *testing.T) { // // Check that the tool returned from NewServerTool properly validates its input schema. @@ -223,3 +632,29 @@ package mcp // } // }) // } + +// TestUnmarshalParamsInvalidParamsCode is a regression test for +// https://github.com/modelcontextprotocol/go-sdk/issues/976#issuecomment-4829124838. +// Malformed params for a registered method must surface as a JSON-RPC error +// whose code is CodeInvalidParams (-32602) rather than the zero-value 0. +func TestUnmarshalParamsInvalidParamsCode(t *testing.T) { + info, ok := serverMethodInfos[methodPing] + if !ok { + t.Fatalf("no methodInfo for %q", methodPing) + } + // Array where a struct is expected. + _, err := info.unmarshalParams(json.RawMessage(`["a","b"]`)) + if err == nil { + t.Fatal("unmarshalParams returned nil error for malformed params") + } + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("expected *jsonrpc.Error in chain, got %T: %v", err, err) + } + if jerr.Code != jsonrpc.CodeInvalidParams { + t.Errorf("error code = %d, want %d (CodeInvalidParams)", jerr.Code, jsonrpc.CodeInvalidParams) + } + if !strings.Contains(err.Error(), "unmarshaling") { + t.Errorf("error message = %q, want it to mention unmarshaling", err.Error()) + } +} diff --git a/mcp/sse.go b/mcp/sse.go index e57dad1..f5e90b8 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -8,13 +8,17 @@ import ( "bytes" "context" "crypto/rand" + "errors" "fmt" "io" + "mime" + "net" "net/http" "net/url" "sync" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/internal/util" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -52,9 +56,23 @@ type SSEHandler struct { } // SSEOptions specifies options for an [SSEHandler]. -// for now, it is empty, but may be extended in future. -// https://github.com/modelcontextprotocol/go-sdk/issues/507 -type SSEOptions struct{} +type SSEOptions struct { + // DisableLocalhostProtection disables automatic DNS rebinding protection. + // By default, requests arriving via a localhost address (127.0.0.1, [::1]) + // that have a non-localhost Host header are rejected with 403 Forbidden. + // This protects against DNS rebinding attacks regardless of whether the + // server is listening on localhost specifically or on 0.0.0.0. + // + // Only disable this if you understand the security implications. + // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise + DisableLocalhostProtection bool + + // MaxRequestBodyBytes limits the number of bytes read from an incoming + // POST body before the server responds with 413 Request Entity Too Large. + // It matches [StreamableHTTPOptions.MaxRequestBodyBytes]: if zero, + // [DefaultMaxRequestBodyBytes] is used; a negative value disables the limit. + MaxRequestBodyBytes int64 +} // NewSSEHandler returns a new [SSEHandler] that creates and manages MCP // sessions created via incoming HTTP requests. @@ -111,6 +129,12 @@ type SSEServerTransport struct { // Response is the hanging response body to the incoming GET request. Response http.ResponseWriter + // MaxRequestBodyBytes limits the number of bytes read from a POSTed + // message body before [SSEServerTransport.ServeHTTP] responds with 413 + // Request Entity Too Large. If zero, [DefaultMaxRequestBodyBytes] is used; + // a negative value disables the limit. See [SSEOptions.MaxRequestBodyBytes]. + MaxRequestBodyBytes int64 + // incoming is the queue of incoming messages. // It is never closed, and by convention, incoming is non-nil if and only if // the transport is connected. @@ -132,9 +156,22 @@ func (t *SSEServerTransport) ServeHTTP(w http.ResponseWriter, req *http.Request) return } + limit := t.MaxRequestBodyBytes + if limit == 0 { + limit = DefaultMaxRequestBodyBytes + } + if limit > 0 && req.Body != nil { + req.Body = http.MaxBytesReader(w, req.Body, limit) + } + // Read and parse the message. data, err := io.ReadAll(req.Body) if err != nil { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + http.Error(w, fmt.Sprintf("request body exceeds %d bytes", mbe.Limit), http.StatusRequestEntityTooLarge) + return + } http.Error(w, "failed to read body", http.StatusBadRequest) return } @@ -178,10 +215,35 @@ func (t *SSEServerTransport) Connect(context.Context) (Connection, error) { return &sseServerConn{t: t}, nil } -func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { - sessionID := req.URL.Query().Get("sessionid") +// SupportsProtocolVersion reports whether the HTTP+SSE transport can serve the +// given protocol version. MCP 2026-07-28 defines only the stdio and Streamable +// HTTP bindings, so this (deprecated) transport does not advertise that revision. +func (t *SSEServerTransport) SupportsProtocolVersion(version string) bool { + return version < protocolVersion20260728 +} - // TODO: consider checking Content-Type here. For now, we are lax. +func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + // DNS rebinding protection: auto-enabled for localhost servers. + // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise + if !h.opts.DisableLocalhostProtection { + if localAddr, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr); ok && localAddr != nil { + if util.IsLoopback(localAddr.String()) && !util.IsLoopback(req.Host) { + http.Error(w, fmt.Sprintf("Forbidden: invalid Host header %q", req.Host), http.StatusForbidden) + return + } + } + } + + // Validate 'Content-Type' header. + if req.Method == http.MethodPost { + mediaType, _, err := mime.ParseMediaType(req.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) + return + } + } + + sessionID := req.URL.Query().Get("sessionid") // For POST requests, the message body is a message to send to a session. if req.Method == http.MethodPost { @@ -224,7 +286,11 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - transport := &SSEServerTransport{Endpoint: endpoint.RequestURI(), Response: w} + transport := &SSEServerTransport{ + Endpoint: endpoint.RequestURI(), + Response: w, + MaxRequestBodyBytes: h.opts.MaxRequestBodyBytes, + } // The session is terminated when the request exits. h.mu.Lock() @@ -331,6 +397,11 @@ type SSEClientTransport struct { // HTTPClient is the client to use for making HTTP requests. If nil, // http.DefaultClient is used. HTTPClient *http.Client + + // MaxEventSize bounds the number of bytes buffered while reading a single + // server-sent event. A value of 0 selects [DefaultMaxEventSize], a negative + // value disables the cap. + MaxEventSize int } // Connect connects through the client endpoint. @@ -361,9 +432,16 @@ func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) { return nil, fmt.Errorf("failed to connect: %s", http.StatusText(resp.StatusCode)) } + maxEventSize := c.MaxEventSize + if maxEventSize == 0 { + maxEventSize = DefaultMaxEventSize + } + + // Reuse the scanner so events buffered after the endpoint are not lost. + events := scanEventsLimited(resp.Body, maxEventSize) msgEndpoint, err := func() (*url.URL, error) { var evt Event - for evt, err = range scanEvents(resp.Body) { + for evt, err = range events { break } if err != nil { @@ -392,7 +470,7 @@ func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) { go func() { defer s.Close() // close the transport when the GET exits - for evt, err := range scanEvents(resp.Body) { + for evt, err := range events { if err != nil { return } diff --git a/mcp/sse_example_test.go b/mcp/sse_example_test.go index 6132d31..434f6de 100644 --- a/mcp/sse_example_test.go +++ b/mcp/sse_example_test.go @@ -27,6 +27,8 @@ func Add(ctx context.Context, req *mcp.CallToolRequest, args AddParams) (*mcp.Ca }, nil, nil } +// !+ssehandler + func ExampleSSEHandler() { server := mcp.NewServer(&mcp.Implementation{Name: "adder", Version: "v0.0.1"}, nil) mcp.AddTool(server, &mcp.Tool{Name: "add", Description: "add two numbers"}, Add) @@ -55,3 +57,5 @@ func ExampleSSEHandler() { // Output: 3 } + +// !-ssehandler diff --git a/mcp/sse_test.go b/mcp/sse_test.go index 8746cc8..d9b7a2b 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -9,14 +9,82 @@ import ( "context" "fmt" "io" + "net" "net/http" "net/http/httptest" + "slices" + "strings" "sync/atomic" "testing" + "time" "github.com/google/go-cmp/cmp" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) +// TestSSEServerTransport_SupportedVersions verifies that the deprecated +// HTTP+SSE transport does not advertise protocol revisions that only define +// stdio and Streamable HTTP (see #1112). +func TestSSEServerTransport_SupportedVersions(t *testing.T) { + var tr SSEServerTransport + versions := filterSupportedVersions(&tr, supportedProtocolVersions) + if slices.Contains(versions, protocolVersion20260728) { + t.Errorf("filterSupportedVersions(SSEServerTransport) = %v, must not include %q", + versions, protocolVersion20260728) + } + if !slices.Contains(versions, protocolVersion20241105) { + t.Errorf("filterSupportedVersions(SSEServerTransport) = %v, want %q (SSE-defined revision)", + versions, protocolVersion20241105) + } + if len(versions) == 0 { + t.Error("filterSupportedVersions(SSEServerTransport) is empty; want legacy SSE revisions") + } + for _, v := range supportedProtocolVersions { + got := tr.SupportsProtocolVersion(v) + want := v < protocolVersion20260728 + if got != want { + t.Errorf("SupportsProtocolVersion(%q) = %v, want %v", v, got, want) + } + } +} + +// TestSSEHandler_DiscoverFallsBackToInitialize checks that a Modern-first +// client connecting over HTTP+SSE does not stick on 2026-07-28: discover +// advertises only SSE-defined revisions, and Connect falls back to legacy +// initialize successfully. +func TestSSEHandler_DiscoverFallsBackToInitialize(t *testing.T) { + ctx := context.Background() + server := NewServer(testImpl, nil) + sseHandler := NewSSEHandler(func(*http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(sseHandler) + defer httpServer.Close() + + client := NewClient(testImpl, nil) + // Default Client.Connect probes with the latest protocol version (2026-07-28). + cs, err := client.Connect(ctx, &SSEClientTransport{Endpoint: httpServer.URL}, nil) + if err != nil { + t.Fatalf("Connect over SSE with modern client: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after Connect") + } + if ir.ProtocolVersion == protocolVersion20260728 { + t.Errorf("InitializeResult.ProtocolVersion = %q; HTTP+SSE must not negotiate that revision", + ir.ProtocolVersion) + } + if ir.ProtocolVersion != protocolVersion20251125 { + // Client.Connect falls back to protocolVersion20251125 for legacy initialize. + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback)", + ir.ProtocolVersion, protocolVersion20251125) + } + if err := cs.Ping(ctx, nil); err != nil { + t.Errorf("Ping after SSE fallback initialize: %v", err) + } +} + func TestSSEServer(t *testing.T) { for _, closeServerFirst := range []bool{false, true} { t.Run(fmt.Sprintf("closeServerFirst=%t", closeServerFirst), func(t *testing.T) { @@ -221,3 +289,195 @@ func TestSSE405AllowHeader(t *testing.T) { }) } } + +// TestSSELocalhostProtection verifies that DNS rebinding protection +// is automatically enabled for localhost servers. +func TestSSELocalhostProtection(t *testing.T) { + server := NewServer(testImpl, nil) + + tests := []struct { + name string + listenAddr string + hostHeader string + disableProtection bool + wantStatus int + }{ + { + name: "127.0.0.1 accepts 127.0.0.1", + listenAddr: "127.0.0.1:0", + hostHeader: "127.0.0.1:1234", + wantStatus: http.StatusOK, + }, + { + name: "127.0.0.1 accepts localhost", + listenAddr: "127.0.0.1:0", + hostHeader: "localhost:1234", + wantStatus: http.StatusOK, + }, + { + name: "127.0.0.1 rejects evil.com", + listenAddr: "127.0.0.1:0", + hostHeader: "evil.com", + wantStatus: http.StatusForbidden, + }, + { + name: "127.0.0.1 rejects evil.com:80", + listenAddr: "127.0.0.1:0", + hostHeader: "evil.com:80", + wantStatus: http.StatusForbidden, + }, + { + name: "127.0.0.1 rejects localhost.evil.com", + listenAddr: "127.0.0.1:0", + hostHeader: "localhost.evil.com", + wantStatus: http.StatusForbidden, + }, + { + name: "0.0.0.0 via localhost rejects evil.com", + listenAddr: "0.0.0.0:0", + hostHeader: "evil.com", + wantStatus: http.StatusForbidden, + }, + { + name: "disabled accepts evil.com", + listenAddr: "127.0.0.1:0", + hostHeader: "evil.com", + disableProtection: true, + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &SSEOptions{ + DisableLocalhostProtection: tt.disableProtection, + } + handler := NewSSEHandler(func(req *http.Request) *Server { return server }, opts) + + listener, err := net.Listen("tcp", tt.listenAddr) + if err != nil { + t.Fatalf("Failed to listen on %s: %v", tt.listenAddr, err) + } + defer listener.Close() + + srv := &http.Server{Handler: handler} + go srv.Serve(listener) + defer srv.Close() + + // Use a GET request since it's the entry point for SSE sessions. + // For accepted requests, the response will be a hanging SSE stream, + // but we only need to check the initial status code. + req, err := http.NewRequest("GET", fmt.Sprintf("http://%s", listener.Addr().String()), nil) + if err != nil { + t.Fatal(err) + } + req.Host = tt.hostHeader + req.Header.Set("Accept", "text/event-stream") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if got := resp.StatusCode; got != tt.wantStatus { + t.Errorf("Status code: got %d, want %d", got, tt.wantStatus) + } + }) + } +} + +// TestSSEServerTransportMaxRequestBodyBytes exercises the limit on the exported +// [SSEServerTransport] directly, since it is documented as usable on its own. +func TestSSEServerTransportMaxRequestBodyBytes(t *testing.T) { + requestOfSize := func(n int) []byte { + b := bytes.Repeat([]byte(" "), n) + b[0], b[n-1] = '{', '}' + return b + } + testCases := []struct { + name string + limit int64 + request []byte + wantCode int + }{ + { + name: "over limit returns", + limit: 1024, + request: requestOfSize(2048), + wantCode: http.StatusRequestEntityTooLarge, + }, + { + name: "under limit accepted", + limit: 1024, + request: []byte(`{"jsonrpc":"2.0","id":1,"method":"ping"}`), + wantCode: http.StatusAccepted, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tr := &SSEServerTransport{ + Endpoint: "/msg", + Response: httptest.NewRecorder(), + MaxRequestBodyBytes: tc.limit, + } + if _, err := tr.Connect(context.Background()); err != nil { + t.Fatalf("Connect: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/msg", bytes.NewReader(tc.request)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + tr.ServeHTTP(rec, req) + if rec.Code != tc.wantCode { + t.Errorf("status = %d, want %d", rec.Code, tc.wantCode) + } + }) + } +} + +func TestSSEClientTransportBufferedEvents(t *testing.T) { + const endpoint = "event: endpoint\ndata: /messages\n\n" + const message = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}\n\n" + for _, coalesced := range []bool{false, true} { + t.Run(fmt.Sprintf("coalesced=%t", coalesced), func(t *testing.T) { + reader, writer := io.Pipe() + defer reader.Close() + defer writer.Close() + prefix := endpoint + if coalesced { + prefix += message + } + transport := &SSEClientTransport{ + Endpoint: "http://example.com/sse", + HTTPClient: &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"text/event-stream"}}, + Body: struct { + io.Reader + io.Closer + }{io.MultiReader(strings.NewReader(prefix), reader), reader}, + }, nil + })}, + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + conn, err := transport.Connect(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if !coalesced { + go func() { _, _ = io.WriteString(writer, message) }() + } + msg, err := conn.Read(ctx) + if err != nil { + t.Fatalf("Read: %v", err) + } + if req, ok := msg.(*jsonrpc.Request); !ok || req.Method != "notifications/tools/list_changed" { + t.Fatalf("Read = %v, want tools/list_changed notification", msg) + } + }) + } +} diff --git a/mcp/streamable.go b/mcp/streamable.go index b0b1795..f24badb 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -20,6 +20,7 @@ import ( "maps" "math" "math/rand/v2" + "mime" "net" "net/http" "slices" @@ -36,12 +37,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/internal/util" "github.com/modelcontextprotocol/go-sdk/internal/xcontext" "github.com/modelcontextprotocol/go-sdk/jsonrpc" -) - -const ( - protocolVersionHeader = "Mcp-Protocol-Version" - sessionIDHeader = "Mcp-Session-Id" - lastEventIDHeader = "Last-Event-ID" + "golang.org/x/oauth2" ) // A StreamableHTTPHandler is an http.Handler that serves streamable MCP @@ -132,12 +128,21 @@ func (i *sessionInfo) stopTimer() { type StreamableHTTPOptions struct { // Stateless controls whether the session is 'stateless'. // - // A stateless server does not validate the Mcp-Session-Id header, and uses a - // temporary session with default initialization parameters. Any + // A stateless server does not read or set the Mcp-Session-Id header, and + // uses a temporary session with default initialization parameters for each + // request. [ServerOptions.GetSessionID] is not consulted. Any // server->client request is rejected immediately as there's no way for the // client to respond. Server->Client notifications may reach the client if // they are made in the context of an incoming request, as described in the // documentation for [StreamableServerTransport]. + // In Stateless mode, GET and DELETE requests return 405 Method Not Allowed. + // + // This mode aligns with the sessionless direction of the MCP spec; see + // [SEP-2567]. The previous behavior, in which stateless servers still + // honored Mcp-Session-Id, can be restored temporarily via the + // MCPGODEBUG compatibility parameter "allowsessionsinstateless=1". + // + // [SEP-2567]: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567 Stateless bool // TODO(#148): support session retention (?) @@ -179,12 +184,45 @@ type StreamableHTTPOptions struct { // CrossOriginProtection allows to customize cross-origin protection. // The deny handler set in the CrossOriginProtection through SetDenyHandler // is ignored. - // If nil, default (zero-value) cross-origin protection will be used. - // Use `disablecrossoriginprotection` MCPGODEBUG compatibility parameter - // to disable the default protection until v1.6.0. + // If nil, no cross-origin protection is applied. + // + // Deprecated: wrap the handler with cross-origin protection middleware + // instead. For example: + // + // handler := mcp.NewStreamableHTTPHandler(...) + // protection := http.NewCrossOriginProtection() + // protectedHandler := protection.Handler(handler) CrossOriginProtection *http.CrossOriginProtection + + // MaxRequestBodyBytes limits the number of bytes read from any incoming + // HTTP request body. Requests that exceed this limit are rejected with + // 413 Request Entity Too Large. + // + // The limit is enforced during the read, so it applies uniformly to + // requests using Content-Length, Transfer-Encoding: chunked, or HTTP/2 + // (which has no Content-Length). + // + // If zero, [DefaultMaxRequestBodyBytes] is used. + // A negative value disables the limit entirely; do not use this on + // servers exposed to untrusted clients. + MaxRequestBodyBytes int64 + + // PropagateRequestCancellation, when true, ties the in-flight handler + // context to the originating HTTP request's context. Only applies to + // requests using the >= 2026-07-28 protocol, where the POST is the whole + // request lifecycle. + // The handler context cancels whenever the HTTP request context does as the + // response can no longer be delivered, so cancelling the handler is safe. + // + // Requests using older protocol versions (including those routed through + // the allowsessionsinstateless compatibility path) are unaffected. + PropagateRequestCancellation bool } +// DefaultMaxRequestBodyBytes is the default value used for +// [StreamableHTTPOptions.MaxRequestBodyBytes] when it is left at zero. +const DefaultMaxRequestBodyBytes = 4 << 20 // 4 MiB + // NewStreamableHTTPHandler returns a new [StreamableHTTPHandler]. // // The getServer function is used to create or look up servers for new @@ -201,8 +239,8 @@ func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *Strea h.opts.Logger = ensureLogger(h.opts.Logger) - if h.opts.CrossOriginProtection == nil { - h.opts.CrossOriginProtection = &http.CrossOriginProtection{} + if h.opts.MaxRequestBodyBytes == 0 { + h.opts.MaxRequestBodyBytes = DefaultMaxRequestBodyBytes } return h @@ -231,23 +269,54 @@ func (h *StreamableHTTPHandler) closeAll() { } } -// disablelocalhostprotection is a compatibility parameter that allows to disable -// DNS rebinding protection, which was added in the 1.4.0 version of the SDK. +// allowsessionsinstateless is a compatibility parameter that restores the old +// behavior of reading and using Mcp-Session-Id headers in stateless mode. When +// set to "1", stateless servers will read the session ID from the request +// header (or generate one via GetSessionID), set it on response headers, and +// accept DELETE requests. When unset (the default), stateless servers ignore +// session IDs entirely and reject DELETE with 405. // See the documentation for the mcpgodebug package for instructions how to enable it. -// The option will be removed in the 1.6.0 version of the SDK. -var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection") +// The option will be removed in the 1.9.0 version of the SDK. +var allowsessionsinstateless = mcpgodebug.Value("allowsessionsinstateless") -// disablecrossoriginprotection is a compatibility parameter that allows to disable -// the verification of the 'Origin' and 'Content-Type' headers, which was added in -// the 1.4.1 version of the SDK. See the documentation for the mcpgodebug package -// for instructions how to enable it. -// The option will be removed in the 1.6.0 version of the SDK. -var disablecrossoriginprotection = mcpgodebug.Value("disablecrossoriginprotection") +// noprotocolerrorbody is a compatibility parameter that restores the previous +// behavior of [streamableClientConn.checkResponse]. When unset (the default), +// the client attempts to decode the response body of a non-2xx response as a +// JSON-RPC error. If successful, the underlying JSON-RPC error is surfaced to +// the caller and wrapped with [jsonrpc2.ErrRejected] so that per-call +// rejections do not tear down the session. When set to "1", the client +// ignores response bodies on non-2xx responses and any non-transient error +// permanently fails the connection. +var noprotocolerrorbody = mcpgodebug.Value("noprotocolerrorbody") + +// plaintextstatefulrejection is a compatibility parameter that restores the +// previous behavior of a stateful [StreamableHTTPHandler] when it receives a +// request carrying SEP-2575 per-request metadata (i.e. an +// `io.modelcontextprotocol/protocolVersion` `_meta` field, or an +// `MCP-Protocol-Version` header >= 2026-07-28). When unset (the default), the +// server responds with a JSON-RPC error of code +// [CodeUnsupportedProtocolVersion] and an [UnsupportedProtocolVersionData] +// payload listing its supported legacy versions, as required by SEP-2575. When +// set to "1", the server instead responds with a plain-text `http.Error` body +// mentioning the `StreamableHTTPOptions.Stateless` field. +var plaintextstatefulrejection = mcpgodebug.Value("plaintextstatefulrejection") + +// writeJSONRPCError writes a JSON-RPC error response with the given HTTP +// status code, request ID (may be a zero ID for errors that occur before the +// request body has been parsed), and JSON-RPC error. +func writeJSONRPCError(w http.ResponseWriter, status int, id jsonrpc.ID, jerr *jsonrpc.Error) { + resp := &jsonrpc.Response{ID: id, Error: jerr} + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if data, err := jsonrpc2.EncodeMessage(resp); err == nil { + w.Write(data) + } +} func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { // DNS rebinding protection: auto-enabled for localhost servers. // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise - if !h.opts.DisableLocalhostProtection && disablelocalhostprotection != "1" { + if !h.opts.DisableLocalhostProtection { if localAddr, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr); ok && localAddr != nil { if util.IsLoopback(localAddr.String()) && !util.IsLoopback(req.Host) { http.Error(w, fmt.Sprintf("Forbidden: invalid Host header %q", req.Host), http.StatusForbidden) @@ -256,286 +325,417 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque } } - if disablecrossoriginprotection != "1" { - // Verify the 'Origin' header to protect against CSRF attacks. + if h.opts.CrossOriginProtection != nil { if err := h.opts.CrossOriginProtection.Check(req); err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } - // Validate 'Content-Type' header. - if req.Method == http.MethodPost { - contentType := req.Header.Get("Content-Type") - if contentType != "application/json" { - http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) - return + } + + // Bound the request body to protect against OOM attacks. + if req.Body != nil && h.opts.MaxRequestBodyBytes > 0 { + req.Body = http.MaxBytesReader(w, req.Body, h.opts.MaxRequestBodyBytes) + } + + // [§2.7] of the spec (2025-06-18): validate the MCP-Protocol-Version + // header. If provided, it must be a version the server negotiates, which + // [ServerOptions.SupportedProtocolVersions] may have narrowed. If absent, + // the version is unknown (the request may be an initialize for any + // version). + // + // [§2.7]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header + protocolVersion := req.Header.Get(protocolVersionHeader) + var supportedVersions []string + if server := h.getServer(req); server != nil { + supportedVersions = server.protocolVersions + } else { + supportedVersions = supportedProtocolVersions + } + if protocolVersion != "" && !slices.Contains(supportedVersions, protocolVersion) && protocolVersion < protocolVersion20260728 { + http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedVersions, ",")), http.StatusBadRequest) + return + } + req = req.WithContext(context.WithValue(req.Context(), protocolVersionContextKey{}, protocolVersion)) + + if h.opts.Stateless { + h.serveStateless(w, req) + } else { + h.serveStateful(w, req) + } +} + +// serveStateless handles requests for stateless servers. +// Stateless servers only support POST. Each request creates a temporary +// session that is closed when the request completes. +// +// When the allowsessionsinstateless compatibility flag is set, DELETE is also +// accepted (as a no-op) and session IDs are read from the request header. +func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http.Request) { + legacySessions := allowsessionsinstateless == "1" + + if req.Method == http.MethodDelete && legacySessions { + h.serveStatelessLegacyDELETE(w, req) + return + } + + if req.Method != http.MethodPost { + // RFC 9110 §15.5.6: 405 responses MUST include Allow header. + w.Header().Set("Allow", "POST") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + if baseMediaType(req.Header.Get("Content-Type")) != "application/json" { + http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) + return + } + + // Accept must contain both 'application/json' and 'text/event-stream'. + jsonOK, streamOK := streamableAccepts(req.Header.Values("Accept")) + if !jsonOK || !streamOK { + http.Error(w, "Accept must contain both 'application/json' and 'text/event-stream'", http.StatusBadRequest) + return + } + + server := h.getServer(req) + if server == nil { + http.Error(w, "no server available", http.StatusBadRequest) + return + } + + info, err := h.ephemeralConnectOpts(req) + if err != nil { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + http.Error(w, fmt.Sprintf("request body exceeds %d bytes", mbe.Limit), http.StatusRequestEntityTooLarge) + return + } + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var sessionID string + if legacySessions && !info.usesNewProtocol { + sessionID = req.Header.Get(sessionIDHeader) + if sessionID == "" { + sessionID = server.opts.GetSessionID() + } + } + + transport := &StreamableServerTransport{ + SessionID: sessionID, + Stateless: true, + EventStore: h.opts.EventStore, + jsonResponse: h.opts.JSONResponse, + logger: h.opts.Logger, + shouldPropagateCancellation: info.usesNewProtocol && (info.isSubscriptionsListen || h.opts.PropagateRequestCancellation), + } + + session, err := connectStreamable(req.Context(), server, transport, info.opts) + if err != nil { + h.opts.Logger.Error(fmt.Sprintf("failed to connect: %v", err)) + http.Error(w, "failed connection", http.StatusInternalServerError) + return + } + defer session.Close() + + transport.ServeHTTP(w, req) +} + +// serveStatelessLegacyDELETE handles DELETE requests in stateless mode when the +// allowsessionsinstateless compatibility flag is set. DELETE requires a +// Mcp-Session-Id header but is otherwise a no-op since stateless servers don't +// persist sessions. +func (h *StreamableHTTPHandler) serveStatelessLegacyDELETE(w http.ResponseWriter, req *http.Request) { + sessionID := req.Header.Get(sessionIDHeader) + if sessionID == "" { + http.Error(w, "Bad Request: DELETE requires an Mcp-Session-Id header", http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusNoContent) +} + +type ephemeralConnectInfo struct { + opts *ServerSessionOptions + usesNewProtocol bool + isSubscriptionsListen bool +} + +// ephemeralConnectOpts peeks at the request body to determine connection +// parameters and whether protocol version >= 2026-06-30 (SEP-2575). +// +// For old-protocol requests, default session state is synthesized so that +// the session's init gate doesn't reject the request. +// +// It is used for both stateless servers and stateful servers with no session ID. +func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (*ephemeralConnectInfo, error) { + protocolVersion := protocolVersionFromContext(req.Context()) + if protocolVersion == "" { + protocolVersion = protocolVersion20250326 + } + + var hasInitialize, hasInitialized, usesNewProtocol, isSubscriptionsListen bool + body, err := io.ReadAll(req.Body) + if err != nil { + // Preserve *http.MaxBytesError so serveStateless can respond with 413. + return nil, fmt.Errorf("failed to read body: %w", err) + } + req.Body.Close() + req.Body = io.NopCloser(bytes.NewBuffer(body)) + msgs, _, err := readBatch(body) + if err == nil { + for _, msg := range msgs { + if r, ok := msg.(*jsonrpc.Request); ok { + switch r.Method { + case methodInitialize: + hasInitialize = true + case notificationInitialized: + hasInitialized = true + case methodSubscriptionsListen: + isSubscriptionsListen = true + } + if protocolVersion >= protocolVersion20260728 { + usesNewProtocol = true + } } } } - // Allow multiple 'Accept' headers. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept#syntax - jsonOK, streamOK := streamableAccepts(req.Header.Values("Accept")) - - if req.Method == http.MethodGet { - if !streamOK { - http.Error(w, "Accept must contain 'text/event-stream' for GET requests", http.StatusBadRequest) - return + state := new(ServerSessionState) + // Only synthesize fake InitializeParams/InitializedParams for old-protocol + // requests. + if !hasInitialize && !usesNewProtocol { + state.InitializeParams = &InitializeParams{ + ProtocolVersion: protocolVersion, } - } else if (!jsonOK || !streamOK) && req.Method != http.MethodDelete { // TODO: consolidate with handling of http method below. + } + if !hasInitialized && !usesNewProtocol { + state.InitializedParams = new(InitializedParams) + } + if !usesNewProtocol { + state.LogLevel = "info" + } + return &ephemeralConnectInfo{ + opts: &ServerSessionOptions{ + State: state, + }, + usesNewProtocol: usesNewProtocol, + isSubscriptionsListen: isSubscriptionsListen, + }, nil +} + +func connectStreamable(ctx context.Context, server *Server, transport *StreamableServerTransport, opts *ServerSessionOptions) (*ServerSession, error) { + s, err := server.Connect(ctx, transport, opts) + if err != nil { + return nil, err + } + transport.connection.server = server + transport.connection.toolLookup = server.getServerTool + return s, nil +} + +// serveStateful handles requests for stateful servers. +// Stateful servers support GET, POST, and DELETE, and maintain persistent +// sessions keyed by session ID. +func (h *StreamableHTTPHandler) serveStateful(w http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + h.serveStatefulGET(w, req) + case http.MethodPost: + h.serveStatefulPOST(w, req) + case http.MethodDelete: + h.serveStatefulDELETE(w, req) + default: + // RFC 9110 §15.5.6: 405 responses MUST include Allow header. + w.Header().Set("Allow", "GET, POST, DELETE") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + } +} + +// lookupSession looks up a session by the Mcp-Session-Id header value. +// It returns the session info and whether the caller should proceed. If ok is +// false, an error response has been written. The sessionID must be non-empty; +// callers are responsible for checking this before calling lookupSession. +func (h *StreamableHTTPHandler) lookupSession(w http.ResponseWriter, req *http.Request, sessionID string) (info *sessionInfo, ok bool) { + h.mu.Lock() + info = h.sessions[sessionID] + h.mu.Unlock() + if info == nil { + http.Error(w, "session not found", http.StatusNotFound) + return nil, false + } + if info.userID != "" { + tokenInfo := auth.TokenInfoFromContext(req.Context()) + if tokenInfo == nil || tokenInfo.UserID != info.userID { + http.Error(w, "session user mismatch", http.StatusForbidden) + return nil, false + } + } + return info, true +} + +// serveStatefulGET handles GET requests for standalone SSE streams. +// GET requires a valid Mcp-Session-Id header. +func (h *StreamableHTTPHandler) serveStatefulGET(w http.ResponseWriter, req *http.Request) { + if _, streamOK := streamableAccepts(req.Header.Values("Accept")); !streamOK { + http.Error(w, "Accept must contain 'text/event-stream' for GET requests", http.StatusBadRequest) + return + } + + sessionID := req.Header.Get(sessionIDHeader) + if sessionID == "" { + http.Error(w, "Bad Request: GET requires an Mcp-Session-Id header", http.StatusBadRequest) + return + } + + sessInfo, ok := h.lookupSession(w, req, sessionID) + if !ok { + return + } + + sessInfo.transport.ServeHTTP(w, req) +} + +// serveStatefulDELETE handles DELETE requests for session termination. +// DELETE requires a valid Mcp-Session-Id header. +func (h *StreamableHTTPHandler) serveStatefulDELETE(w http.ResponseWriter, req *http.Request) { + sessionID := req.Header.Get(sessionIDHeader) + if sessionID == "" { + http.Error(w, "Bad Request: DELETE requires an Mcp-Session-Id header", http.StatusBadRequest) + return + } + + sessInfo, ok := h.lookupSession(w, req, sessionID) + if !ok { + return + } + + sessInfo.session.Close() + w.WriteHeader(http.StatusNoContent) +} + +// serveStatefulPOST handles POST requests for stateful servers. +// POST may arrive with or without a Mcp-Session-Id header. Without a session +// ID, a new session is created (this is the normal path for the first +// initialize request). +func (h *StreamableHTTPHandler) serveStatefulPOST(w http.ResponseWriter, req *http.Request) { + if baseMediaType(req.Header.Get("Content-Type")) != "application/json" { + http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) + return + } + + jsonOK, streamOK := streamableAccepts(req.Header.Values("Accept")) + if !jsonOK || !streamOK { http.Error(w, "Accept must contain both 'application/json' and 'text/event-stream'", http.StatusBadRequest) return } sessionID := req.Header.Get(sessionIDHeader) - var sessInfo *sessionInfo + + // Look up existing session if a session ID was provided. if sessionID != "" { - h.mu.Lock() - sessInfo = h.sessions[sessionID] - h.mu.Unlock() - if sessInfo == nil && !h.opts.Stateless { - // Unless we're in 'stateless' mode, which doesn't perform any Session-ID - // validation, we require that the session ID matches a known session. - // - // In stateless mode, a temporary transport is be created below. - http.Error(w, "session not found", http.StatusNotFound) + sessInfo, ok := h.lookupSession(w, req, sessionID) + if !ok { return } - // Prevent session hijacking: if the session was created with a user ID, - // verify that subsequent requests come from the same user. - if sessInfo != nil && sessInfo.userID != "" { - tokenInfo := auth.TokenInfoFromContext(req.Context()) - if tokenInfo == nil || tokenInfo.UserID != sessInfo.userID { - http.Error(w, "session user mismatch", http.StatusForbidden) - return - } - } - } - - if req.Method == http.MethodDelete { - if sessionID == "" { - http.Error(w, "Bad Request: DELETE requires an Mcp-Session-Id header", http.StatusBadRequest) - return - } - if sessInfo != nil { // sessInfo may be nil in stateless mode - // Closing the session also removes it from h.sessions, due to the - // onClose callback. - sessInfo.session.Close() - } - w.WriteHeader(http.StatusNoContent) + sessInfo.startPOST() + defer sessInfo.endPOST() + sessInfo.transport.ServeHTTP(w, req) return } - switch req.Method { - case http.MethodPost, http.MethodGet: - if req.Method == http.MethodGet && (h.opts.Stateless || sessionID == "") { - if h.opts.Stateless { - // Per MCP spec: server MUST return 405 if it doesn't offer SSE stream. - // In stateless mode, GET (SSE streaming) is not supported. - // RFC 9110 §15.5.6: 405 responses MUST include Allow header. - w.Header().Set("Allow", "POST") - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) - } else { - // In stateful mode, GET is supported but requires a session ID. - // This is a precondition error, similar to DELETE without session. - http.Error(w, "Bad Request: GET requires an Mcp-Session-Id header", http.StatusBadRequest) - } - return - } - default: - // RFC 9110 §15.5.6: 405 responses MUST include Allow header. - if h.opts.Stateless { - w.Header().Set("Allow", "POST") - } else { - w.Header().Set("Allow", "GET, POST, DELETE") - } - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + // No session ID: create a new session. + server := h.getServer(req) + if server == nil { + http.Error(w, "no server available", http.StatusBadRequest) return } + sessionID = server.opts.GetSessionID() - // [§2.7] of the spec (2025-06-18) states: - // - // "If using HTTP, the client MUST include the MCP-Protocol-Version: - // HTTP header on all subsequent requests to the MCP - // server, allowing the MCP server to respond based on the MCP protocol - // version. - // - // For example: MCP-Protocol-Version: 2025-06-18 - // The protocol version sent by the client SHOULD be the one negotiated during - // initialization. - // - // For backwards compatibility, if the server does not receive an - // MCP-Protocol-Version header, and has no other way to identify the version - - // for example, by relying on the protocol version negotiated during - // initialization - the server SHOULD assume protocol version 2025-03-26. - // - // If the server receives a request with an invalid or unsupported - // MCP-Protocol-Version, it MUST respond with 400 Bad Request." - // - // Since this wasn't present in the 2025-03-26 version of the spec, this - // effectively means: - // 1. IF the client provides a version header, it must be a supported - // version. - // 2. In stateless mode, where we've lost the state of the initialize - // request, we assume that whatever the client tells us is the truth (or - // assume 2025-03-26 if the client doesn't say anything). - // - // This logic matches the typescript SDK. - // - // [§2.7]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header - protocolVersion := req.Header.Get(protocolVersionHeader) - if protocolVersion == "" { - protocolVersion = protocolVersion20250326 - } - if !slices.Contains(supportedProtocolVersions, protocolVersion) { - http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedProtocolVersions, ",")), http.StatusBadRequest) - return + transport := &StreamableServerTransport{ + SessionID: sessionID, + Stateless: false, + EventStore: h.opts.EventStore, + jsonResponse: h.opts.JSONResponse, + logger: h.opts.Logger, } - if sessInfo == nil { - server := h.getServer(req) - if server == nil { - // The getServer argument to NewStreamableHTTPHandler returned nil. - http.Error(w, "no server available", http.StatusBadRequest) - return - } - if sessionID == "" { - // In stateless mode, sessionID may be nonempty even if there's no - // existing transport. - sessionID = server.opts.GetSessionID() - } - transport := &StreamableServerTransport{ - SessionID: sessionID, - Stateless: h.opts.Stateless, - EventStore: h.opts.EventStore, - jsonResponse: h.opts.JSONResponse, - logger: h.opts.Logger, - } - - // Sessions without a session ID are also stateless: there's no way to - // address them. - stateless := h.opts.Stateless || sessionID == "" - // To support stateless mode, we initialize the session with a default - // state, so that it doesn't reject subsequent requests. - var connectOpts *ServerSessionOptions - if stateless { - // Peek at the body to see if it is initialize or initialized. - // We want those to be handled as usual. - var hasInitialize, hasInitialized bool - { - // TODO: verify that this allows protocol version negotiation for - // stateless servers. - body, err := io.ReadAll(req.Body) - if err != nil { - http.Error(w, "failed to read body", http.StatusBadRequest) - return - } - req.Body.Close() - - // Reset the body so that it can be read later. - req.Body = io.NopCloser(bytes.NewBuffer(body)) - - msgs, _, err := readBatch(body) - if err == nil { - for _, msg := range msgs { - if req, ok := msg.(*jsonrpc.Request); ok { - switch req.Method { - case methodInitialize: - hasInitialize = true - case notificationInitialized: - hasInitialized = true - } - } - } - } - } - - // If we don't have InitializeParams or InitializedParams in the request, - // set the initial state to a default value. - state := new(ServerSessionState) - if !hasInitialize { - state.InitializeParams = &InitializeParams{ - ProtocolVersion: protocolVersion, - } - } - if !hasInitialized { - state.InitializedParams = new(InitializedParams) - } - state.LogLevel = "info" - connectOpts = &ServerSessionOptions{ - State: state, - } - } else { - // Cleanup is only required in stateful mode, as transportation is - // not stored in the map otherwise. - connectOpts = &ServerSessionOptions{ - onClose: func() { - h.mu.Lock() - defer h.mu.Unlock() - if info, ok := h.sessions[transport.SessionID]; ok { - info.stopTimer() - delete(h.sessions, transport.SessionID) - if h.onTransportDeletion != nil { - h.onTransportDeletion(transport.SessionID) - } - } - }, - } - } - - // Pass req.Context() here, to allow middleware to add context values. - // The context is detached in the jsonrpc2 library when handling the - // long-running stream. - session, err := server.Connect(req.Context(), transport, connectOpts) + // Sessions without a session ID (GetSessionID returned "") are ephemeral: + // there's no way to address them, so they are closed after the request. + // This can happen when ServerOptions.GetSessionID is explicitly set to + // return "" to suppress session IDs entirely. It also covers any request + // that arrives before a session exists (e.g. initialize or ping) on a + // server configured this way. + if sessionID == "" { + info, err := h.ephemeralConnectOpts(req) if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + session, err := connectStreamable(req.Context(), server, transport, info.opts) + if err != nil { + h.opts.Logger.Error(fmt.Sprintf("failed to connect: %v", err)) http.Error(w, "failed connection", http.StatusInternalServerError) return } - // Capture the user ID from the token info to enable session hijacking - // prevention on subsequent requests. - var userID string - if tokenInfo := auth.TokenInfoFromContext(req.Context()); tokenInfo != nil { - userID = tokenInfo.UserID - } - sessInfo = &sessionInfo{ - session: session, - transport: transport, - userID: userID, - } + defer session.Close() + transport.ServeHTTP(w, req) + return + } - if stateless { - // Stateless mode: close the session when the request exits. - defer session.Close() // close the fake session after handling the request - } else { - // Otherwise, save the transport so that it can be reused - - // Clean up the session when it times out. - // - // Note that the timer here may fire multiple times, but - // sessInfo.session.Close is idempotent. - if h.opts.SessionTimeout > 0 { - sessInfo.timeout = h.opts.SessionTimeout - sessInfo.timer = time.AfterFunc(sessInfo.timeout, func() { - sessInfo.session.Close() - }) - } + connectOpts := &ServerSessionOptions{ + onClose: func() { h.mu.Lock() - h.sessions[transport.SessionID] = sessInfo - h.mu.Unlock() - defer func() { - // If initialization failed, clean up the session (#578). - if session.InitializeParams() == nil { - // Initialization failed. - session.Close() + defer h.mu.Unlock() + if info, ok := h.sessions[transport.SessionID]; ok { + info.stopTimer() + delete(h.sessions, transport.SessionID) + if h.onTransportDeletion != nil { + h.onTransportDeletion(transport.SessionID) } - }() + } + }, + } + + // Pass req.Context() here, to allow middleware to add context values. + // The context is detached in the jsonrpc2 library when handling the + // long-running stream. + session, err := connectStreamable(req.Context(), server, transport, connectOpts) + if err != nil { + h.opts.Logger.Error(fmt.Sprintf("failed to connect: %v", err)) + http.Error(w, "failed connection", http.StatusInternalServerError) + return + } + // Capture the user ID from the token info to enable session hijacking + // prevention on subsequent requests. + var userID string + if tokenInfo := auth.TokenInfoFromContext(req.Context()); tokenInfo != nil { + userID = tokenInfo.UserID + } + sessInfo := &sessionInfo{ + session: session, + transport: transport, + userID: userID, + } + + if h.opts.SessionTimeout > 0 { + sessInfo.timeout = h.opts.SessionTimeout + sessInfo.timer = time.AfterFunc(sessInfo.timeout, func() { + sessInfo.session.Close() + }) + } + h.mu.Lock() + h.sessions[transport.SessionID] = sessInfo + h.mu.Unlock() + defer func() { + // If initialization failed, clean up the session (#578). + if session.InitializeParams() == nil { + session.Close() } - } - - if req.Method == http.MethodPost { - sessInfo.startPOST() - defer sessInfo.endPOST() - } + }() + sessInfo.startPOST() + defer sessInfo.endPOST() sessInfo.transport.ServeHTTP(w, req) } @@ -559,6 +759,14 @@ func streamableAccepts(values []string) (jsonOK, streamOK bool) { return jsonOK, streamOK } +func baseMediaType(value string) string { + mediaType, _, err := mime.ParseMediaType(value) + if err != nil { + return "" + } + return mediaType +} + // A StreamableServerTransport implements the server side of the MCP streamable // transport. // @@ -620,6 +828,10 @@ type StreamableServerTransport struct { // to write their own streamable HTTP handler. logger *slog.Logger + // shouldPropagateCancellation is forwarded to the underlying + // [streamableServerConn]. See its docstring. + shouldPropagateCancellation bool + // connection is non-nil if and only if the transport has been connected. connection *streamableServerConn } @@ -630,15 +842,16 @@ func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, er return nil, fmt.Errorf("transport already connected") } t.connection = &streamableServerConn{ - sessionID: t.SessionID, - stateless: t.Stateless, - eventStore: t.EventStore, - jsonResponse: t.jsonResponse, - logger: ensureLogger(t.logger), // see #556: must be non-nil - incoming: make(chan jsonrpc.Message, 10), - done: make(chan struct{}), - streams: make(map[string]*stream), - requestStreams: make(map[jsonrpc.ID]string), + sessionID: t.SessionID, + stateless: t.Stateless, + eventStore: t.EventStore, + jsonResponse: t.jsonResponse, + logger: ensureLogger(t.logger), // see #556: must be non-nil + shouldPropagateCancellation: t.shouldPropagateCancellation, + incoming: make(chan jsonrpc.Message, 10), + done: make(chan struct{}), + streams: make(map[string]*stream), + requestStreams: make(map[jsonrpc.ID]string), } // Stream 0 corresponds to the standalone SSE stream. // @@ -652,14 +865,34 @@ func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, er return t.connection, nil } +// The streamable HTTP transport supports every legacy SDK protocol version, +// but the SEP-2575 >= 2026-07-28 protocol is only supported when the +// transport is configured as stateless. +func (t *StreamableServerTransport) SupportsProtocolVersion(version string) bool { + if version >= protocolVersion20260728 { + return t.Stateless && slices.Contains(supportedProtocolVersions, version) + } + return slices.Contains(supportedProtocolVersions, version) +} + type streamableServerConn struct { sessionID string stateless bool jsonResponse bool eventStore EventStore + // shouldPropagateCancellation is true when the underlying HTTP request's + // lifetime IS the connection's cancellation signal (e.g., a stateless + // POST that owns a long-lived subscriptions/listen stream). It is read + // by the [cancellationPropagator] interface so the jsonrpc2 layer wires + // handler contexts to observe the carrier's cancellation. + shouldPropagateCancellation bool + logger *slog.Logger + server *Server + toolLookup func(name string) (*serverTool, bool) + incoming chan jsonrpc.Message // messages from the client to the server mu sync.Mutex // guards all fields below @@ -693,6 +926,15 @@ func (c *streamableServerConn) SessionID() string { return c.sessionID } +// propagateCancellation implements [cancellationPropagator]. It returns true +// when this connection is bound to a single HTTP request whose lifetime +// should drive request-handler cancellation — for example, a stateless POST +// carrying a long-lived subscriptions/listen stream that must unwind when +// the client TCP-disconnects. +func (c *streamableServerConn) propagateCancellation() bool { + return c.shouldPropagateCancellation +} + // A stream is a single logical stream of SSE events within a server session. // A stream begins with a client request, or with a client GET that has // no Last-Event-ID header. @@ -721,6 +963,10 @@ type stream struct { // Note: if we remove support for batching, this could just be a bool. pendingJSONMessages []json.RawMessage + // isBatch reports whether the request that opened the stream was a JSON-RPC + // batch. Batch responses must remain arrays even when they contain one item. + isBatch bool + // w is the HTTP response writer for this stream. A non-nil w indicates // that the stream is claimed by an HTTP request (the hanging POST or GET); // it is set to nil when the request completes. @@ -747,6 +993,12 @@ type stream struct { // the spec and earlier there was a concept of batching, in which POST // payloads could hold multiple requests or responses. requests map[jsonrpc.ID]struct{} + + // isListen reports whether this stream was opened by a + // subscriptions/listen request. Listen streams are always SSE, live for + // the duration of the subscription, and act as the target for + // out-of-band notifications routed through this connection. + isListen bool } // close sends a 'close' event to the client (if protocolVersion >= 2025-11-25 @@ -782,6 +1034,36 @@ func (s *stream) release() { s.done = nil // may already be nil, if the stream is done or closed } +// extractErrorStatus reports the HTTP status to send when the given +// outgoing message is a JSON-RPC error response under the SEP-2575 protocol +// (>= 2026-07-28). +// +// Per SEP-2575: +// - MethodNotFound (-32601) MUST return HTTP 404. +// - InvalidParams (-32602), UnsupportedProtocolVersion (-32022) and +// CodeMissingRequiredClientCapabilities (-32021) MUST +// return HTTP 400. +func extractErrorStatus(ctx context.Context, msg jsonrpc.Message) int { + if protocolVersionFromContext(ctx) < protocolVersion20260728 { + return 0 + } + resp, ok := msg.(*jsonrpc.Response) + if !ok || resp.Error == nil { + return 0 + } + var jerr *jsonrpc.Error + if !errors.As(resp.Error, &jerr) { + return 0 + } + switch jerr.Code { + case jsonrpc.CodeMethodNotFound: + return http.StatusNotFound + case jsonrpc.CodeInvalidParams, CodeUnsupportedProtocolVersion, CodeMissingRequiredClientCapabilities: + return http.StatusBadRequest + } + return 0 +} + // deliverLocked writes data to the stream (for SSE) or stores it in // pendingJSONMessages (for JSON mode). The eventID is used for SSE event ID; // pass "" to omit. @@ -789,11 +1071,16 @@ func (s *stream) release() { // If responseTo is valid, it is removed from the requests map. When all // requests have been responded to, the done channel is closed and set to nil. // +// If overrideStatus is non-zero, data is treated as a SEP-2575 protocol-level +// error response (>= 2026-07-28): it is written as a single raw JSON-RPC +// response body with Content-Type: application/json and HTTP status +// overrideStatus. +// // Returns true if the stream is now done (all requests have been responded to). // The done value is always accurate, even if an error is returned. // // s.mu must be held when calling this method. -func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.ID) (done bool, err error) { +func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.ID, overrideStatus int) (done bool, err error) { // First, record the response. We must do this *before* returning an error // below, as even if the stream is disconnected we want to update our // accounting. @@ -808,6 +1095,17 @@ func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.I if done { defer func() { close(s.done); s.done = nil }() } + // SEP-2575 protocol-level error override: write the error as a raw + // JSON-RPC response with the spec-mandated HTTP status, bypassing any + // SSE framing. + if overrideStatus != 0 { + s.w.Header().Set("Content-Type", "application/json") + s.w.WriteHeader(overrideStatus) + if _, err := s.w.Write(data); err != nil { + return done, err + } + return done, nil + } // Try to write to the response. // // If we get here, the request is still hanging (because s.done != nil @@ -819,7 +1117,7 @@ func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.I if done { // Flush all pending messages as JSON response. var toWrite []byte - if len(s.pendingJSONMessages) == 1 { + if len(s.pendingJSONMessages) == 1 && !s.isBatch { toWrite = s.pendingJSONMessages[0] } else { toWrite, err = json.Marshal(s.pendingJSONMessages) @@ -853,7 +1151,7 @@ func (s *stream) doneLocked() bool { } func (c *streamableServerConn) newStream(ctx context.Context, requests map[jsonrpc.ID]struct{}, id string) (*stream, error) { - if c.eventStore != nil { + if c.eventStore != nil && protocolVersionFromContext(ctx) < protocolVersion20260728 { if err := c.eventStore.Open(ctx, c.sessionID, id); err != nil { return nil, err } @@ -889,6 +1187,18 @@ func (c *streamableServerConn) newStream(ctx context.Context, requests map[jsonr // ID, we avoid having to make this API decision. type idContextKey struct{} +// protocolVersionContextKey stores the protocol version extracted from the +// MCP-Protocol-Version HTTP header for use by lower layers. +type protocolVersionContextKey struct{} + +// protocolVersionFromContext returns the protocol version from the context, or +// the empty string if not set. An empty string means the version is unknown +// (e.g. the header was absent). +func protocolVersionFromContext(ctx context.Context) string { + v, _ := ctx.Value(protocolVersionContextKey{}).(string) + return v +} + // ServeHTTP handles a single HTTP request for the session. func (t *StreamableServerTransport) ServeHTTP(w http.ResponseWriter, req *http.Request) { if t.connection == nil { @@ -934,9 +1244,7 @@ func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request ctx := req.Context() - // Read the protocol version from the header. For GET requests, this should - // always be present since GET only happens after initialization. - protocolVersion := req.Header.Get(protocolVersionHeader) + protocolVersion := protocolVersionFromContext(ctx) if protocolVersion == "" { protocolVersion = protocolVersion20250326 } @@ -1052,7 +1360,23 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons if s.id == "" { // Issue #410: the standalone SSE stream is likely not to receive messages // for a long time. Ensure that headers are flushed. + // + // On HTTP/2, headers and body travel as separate frames (HEADERS and + // DATA). Reverse proxies (e.g. Envoy, Caddy, net/http/httputil) + // commonly buffer the HEADERS frame until they have a DATA frame to + // coalesce it with — there is no HTTP/2 equivalent of HTTP/1.1's + // Transfer-Encoding: chunked signal that says "this is streaming, send + // headers now". Calling Flush() alone is not sufficient: it pushes + // the kernel buffer to the proxy, but the proxy still holds the + // HEADERS frame. + // + // Write an SSE comment (lines starting with ":" are ignored by + // clients per RFC) so a DATA frame is produced, which forces the + // proxy to forward both frames. See: + // https://github.com/golang/go/issues/31125 + // https://github.com/caddyserver/caddy/issues/4247 w.WriteHeader(http.StatusOK) + fmt.Fprint(w, ": ok\n\n") rc := http.NewResponseController(w) // Ignore returned error as flushing is best-effort. _ = rc.Flush() @@ -1097,6 +1421,11 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // Read incoming messages. body, err := io.ReadAll(req.Body) if err != nil { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + http.Error(w, fmt.Sprintf("request body exceeds %d bytes", mbe.Limit), http.StatusRequestEntityTooLarge) + return + } http.Error(w, "failed to read body", http.StatusBadRequest) return } @@ -1113,7 +1442,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques return } - protocolVersion := req.Header.Get(protocolVersionHeader) + protocolVersion := protocolVersionFromContext(req.Context()) if protocolVersion == "" { protocolVersion = protocolVersion20250326 } @@ -1133,13 +1462,29 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques calls := make(map[jsonrpc.ID]struct{}) tokenInfo := auth.TokenInfoFromContext(req.Context()) isInitialize := false + isSubscriptionsListen := false var initializeProtocolVersion string for _, msg := range incoming { if jreq, ok := msg.(*jsonrpc.Request); ok { // Preemptively check that this is a valid request, so that we can fail // the HTTP request. If we didn't do this, a request with a bad method or // missing ID could be silently swallowed. - if _, err := checkRequest(jreq, serverMethodInfos); err != nil { + // Use the server's receiving method infos (which include any custom + // methods registered via AddReceivingCustomMethod) when available; + // fall back to the standard methods otherwise, e.g. in tests that + // exercise streamableServerConn directly without a server. + methodInfos := serverMethodInfos + if c.server != nil { + methodInfos = c.server.receivingMethodInfos() + } + if _, err := checkRequest(jreq, methodInfos); err != nil { + if protocolVersion >= protocolVersion20260728 && errors.Is(err, jsonrpc2.ErrNotHandled) && jreq.IsCall() { + writeJSONRPCError(w, http.StatusNotFound, jreq.ID, &jsonrpc.Error{ + Code: jsonrpc.CodeMethodNotFound, + Message: err.Error(), + }) + return + } http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -1151,6 +1496,91 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques initializeProtocolVersion = params.ProtocolVersion } } + if jreq.Method == methodSubscriptionsListen { + isSubscriptionsListen = true + } + // SEP-2575: requests carrying `_meta.protocolVersion` require the + // Mcp-Protocol-Version HTTP header to be present and to match the + // per-request `_meta.protocolVersion` value. + // The new (>= 2026-07-28) protocol is supported on the HTTP transport + // only when [StreamableHTTPOptions.Stateless] is true. + // + // The `_meta` triple is defined for calls only, notifications are + // excluded. + // + // TODO: this validation can be moved within validateMcpHeaders. + var metaVersion string + if meta := extractRequestMeta(jreq.Params); meta != nil { + metaVersion, _ = meta[MetaKeyProtocolVersion].(string) + } + if (jreq.IsCall() && protocolVersion >= protocolVersion20260728) || metaVersion != "" { + // Extract again the protocol version from the context to see what the client + // is advertising in the Mcp-Protocol-Version HTTP header. + headerVersion := protocolVersionFromContext(req.Context()) + // server/discover is exempt from the stateful + // rejection as it should learn about the supported protocols from the + // DiscoverResult response. + if !c.stateless && jreq.Method != methodDiscover { + if plaintextstatefulrejection == "1" { + http.Error(w, fmt.Sprintf( + "Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)", + protocolVersion), + http.StatusBadRequest) + return + } + // Advertise only the legacy versions this stateful server accepts + // (i.e. exclude 2026-07-28, since that's what the request asked for + // and what we're rejecting). + advertised := supportedProtocolVersions + if c.server != nil { + advertised = c.server.protocolVersions + } + legacyVersions := slices.DeleteFunc(slices.Clone(advertised), func(v string) bool { + return v >= protocolVersion20260728 + }) + data, _ := json.Marshal(UnsupportedProtocolVersionData{ + Supported: legacyVersions, + Requested: protocolVersion, + }) + c.logger.Warn(fmt.Sprintf( + "rejecting request with protocol version %q: this server is stateful; set StreamableHTTPOptions.Stateless = true to accept it", + protocolVersion)) + writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: fmt.Sprintf("protocol version %q is not supported by this server", protocolVersion), + Data: data, + }) + return + } + if headerVersion == "" { + writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{ + Code: CodeHeaderMismatch, + Message: fmt.Sprintf( + "%s header is required for requests carrying %q", + protocolVersionHeader, MetaKeyProtocolVersion), + }) + return + } + if metaVersion == "" { + writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: fmt.Sprintf( + "missing or invalid _meta field %q", + MetaKeyProtocolVersion), + }) + return + } + if headerVersion != metaVersion { + writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{ + Code: CodeHeaderMismatch, + Message: fmt.Sprintf( + "%s header %q does not match request %s %q", + protocolVersionHeader, headerVersion, + MetaKeyProtocolVersion, metaVersion), + }) + return + } + } // Include metadata for all requests (including notifications). jreq.Extra = &RequestExtra{ TokenInfo: tokenInfo, @@ -1161,6 +1591,12 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // See the doc for CloseSSEStream: allow the request handler to // explicitly close the ongoing stream. jreq.Extra.(*RequestExtra).CloseSSEStream = func(args CloseSSEStreamArgs) { + // This mechanism was designed to trigger client reconnection with + // Last-Event-ID for server-initiated disconnect scenarios. It is + // deprecated in protocol version 2026-07-28. + if protocolVersion >= protocolVersion20260728 { + return + } c.mu.Lock() streamID, ok := c.requestStreams[jreq.ID] var stream *stream @@ -1177,6 +1613,24 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } } + // Validate MCP standard headers (Mcp-Method, Mcp-Name, Mcp-Param-*) + if !isBatch && len(incoming) == 1 { + if err := validateMcpHeaders(req.Header, incoming[0], c.toolLookup); err != nil { + resp := &jsonrpc.Response{ + Error: jsonrpc2.NewError(CodeHeaderMismatch, err.Error()), + } + if jreq, ok := incoming[0].(*jsonrpc.Request); ok { + resp.ID = jreq.ID + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + if data, err := jsonrpc2.EncodeMessage(resp); err == nil { + w.Write(data) + } + return + } + } + // The prime and close events were added in protocol version 2025-11-25 (SEP-1699). // Use the version from InitializeParams if this is an initialize request, // otherwise use the protocol version header. @@ -1218,14 +1672,23 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques http.Error(w, fmt.Sprintf("storing stream: %v", err), http.StatusInternalServerError) return } + stream.isListen = isSubscriptionsListen + stream.isBatch = isBatch + + // subscriptions/listen is inherently a long-lived SSE endpoint (SEP-2575): + // it has no synchronous result, the response stream stays open until the + // client cancels, and the server pushes notifications on it as they occur. + // Force SSE mode (bypassing JSONResponse) so the buffered application/json + // path doesn't deadlock waiting for a completion that won't come. + useSSE := !c.jsonResponse || isSubscriptionsListen // Set response headers. Accept was checked in [StreamableHTTPHandler]. w.Header().Set("Cache-Control", "no-cache, no-transform") - if c.jsonResponse { - w.Header().Set("Content-Type", "application/json") - } else { + if useSSE { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Connection", "keep-alive") + } else { + w.Header().Set("Content-Type", "application/json") } if c.sessionID != "" && isInitialize { w.Header().Set(sessionIDHeader, c.sessionID) @@ -1236,14 +1699,41 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques done := make(chan struct{}) stream.done = done stream.protocolVersion = effectiveVersion - if c.jsonResponse { + + // Reject any call whose ID is already in flight on this session, + // atomically and without partial registration. + c.mu.Lock() + for reqID := range calls { + if _, ok := c.requestStreams[reqID]; ok { + c.mu.Unlock() + writeJSONRPCError(w, http.StatusBadRequest, reqID, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidRequest, + Message: fmt.Sprintf("duplicate in-flight request ID %v", reqID.Raw()), + }) + return + } + } + c.streams[stream.id] = stream + for reqID := range calls { + c.requestStreams[reqID] = stream.id + } + c.mu.Unlock() + + // TODO(rfindley): if we have no event store, we should really cancel all + // remaining requests here, since the client will never get the results. + defer stream.release() + + if !useSSE { // JSON mode: collect messages in pendingJSONMessages until done. // Set pendingJSONMessages to a non-nil value to signal that this is an // application/json stream. stream.pendingJSONMessages = []json.RawMessage{} } else { // SSE mode: write a priming event if supported. - if c.eventStore != nil && effectiveVersion >= protocolVersion20251125 { + // + // SEP-2575 removes Last-Event-ID-based resumable streams for protocol + // version >= 2026-07-28. + if c.eventStore != nil && effectiveVersion >= protocolVersion20251125 && effectiveVersion < protocolVersion20260728 { // Write a priming event, as defined by [§2.1.6] of the spec. // // [§2.1.6]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server @@ -1261,20 +1751,6 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } } - // TODO(rfindley): if we have no event store, we should really cancel all - // remaining requests here, since the client will never get the results. - defer stream.release() - - // The stream is now set up to deliver messages. - // - // Register it before publishing incoming messages. - c.mu.Lock() - c.streams[stream.id] = stream - for reqID := range calls { - c.requestStreams[reqID] = stream.id - } - c.mu.Unlock() - // Publish incoming messages. for _, msg := range incoming { select { @@ -1387,7 +1863,18 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e s = c.streams[streamID] } } else { - s = c.streams[""] // standalone SSE stream + // In stateless mode there will always be only one stream per connection. + // If that stream was open to listen for subscription notifications, + // automatically select as the one to write the notification to. + for _, stream := range c.streams { + if stream.isListen { + s = stream + break + } + } + if s == nil { + s = c.streams[""] // standalone SSE stream + } } if responseTo.IsValid() { // Once we've responded to a request, disallow related messages by removing @@ -1417,7 +1904,8 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e // pushing down into the delivery layer. delivered := false var errs []error - if c.eventStore != nil { + protocolVersion := protocolVersionFromContext(ctx) + if c.eventStore != nil && protocolVersion < protocolVersion20260728 { if err := c.eventStore.Append(ctx, c.sessionID, s.id, data); err != nil { errs = append(errs, err) } else { @@ -1428,11 +1916,16 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e // Compute eventID for SSE streams with event store. // Use s.lastIdx + 1 because deliverLocked increments before writing. var eventID string - if c.eventStore != nil { + if c.eventStore != nil && protocolVersion < protocolVersion20260728 { eventID = formatEventID(s.id, s.lastIdx+1) } - done, err := s.deliverLocked(data, eventID, responseTo) + // SEP-2575: map protocol-level JSON-RPC error codes to HTTP status codes + // on the new protocol (>= 2026-07-28). When non-zero, deliverLocked will + // write the body as raw application/json with the override status. + overrideStatus := extractErrorStatus(ctx, msg) + + done, err := s.deliverLocked(data, eventID, responseTo, overrideStatus) if err != nil { errs = append(errs, err) } else { @@ -1500,6 +1993,11 @@ type StreamableClientTransport struct { // OAuthHandler is an optional field that, if provided, will be used to authorize the requests. OAuthHandler auth.OAuthHandler + // MaxEventSize bounds the number of bytes buffered while reading a single + // server-sent event. A value of 0 selects [DefaultMaxEventSize], a negative + // value disables the cap. + MaxEventSize int + // TODO(rfindley): propose exporting these. // If strict is set, the transport is in 'strict mode', where any violation // of the MCP spec causes a failure. @@ -1518,6 +2016,11 @@ const ( reconnectGrowFactor = 1.5 // reconnectMaxDelay caps the backoff delay, preventing it from growing indefinitely. reconnectMaxDelay = 30 * time.Second + // closeDeleteTimeout bounds the session-termination DELETE sent by Close. + // That request is best-effort: a peer that accepts the connection but never + // answers must not be able to hold teardown open until the TCP timeout. + // See issue #1183. + closeDeleteTimeout = 5 * time.Second ) var ( @@ -1534,11 +2037,13 @@ func init() { // Connect implements the [Transport] interface. // // The resulting [Connection] writes messages via POST requests to the -// transport URL with the Mcp-Session-Id header set, and reads messages from -// hanging requests. +// transport URL, and reads messages from hanging requests. If the server +// provides a session ID via the Mcp-Session-Id response header, subsequent +// requests include it; sessionless servers that omit the header are fully +// supported. // -// When closed, the connection issues a DELETE request to terminate the logical -// session. +// When closed, the connection issues a DELETE request to terminate the +// session, unless no session was established. func (t *StreamableClientTransport) Connect(ctx context.Context) (Connection, error) { client := t.HTTPClient if client == nil { @@ -1580,6 +2085,7 @@ func (t *StreamableClientTransport) Connect(ctx context.Context) (Connection, er failed: make(chan struct{}), disableStandaloneSSE: t.DisableStandaloneSSE, oauthHandler: t.OAuthHandler, + maxEventSize: t.MaxEventSize, } return conn, nil } @@ -1601,6 +2107,10 @@ type streamableClientConn struct { // oauthHandler is the OAuth handler for the connection. oauthHandler auth.OAuthHandler // from [StreamableClientTransport.OAuthHandler] + // maxEventSize bounds the number of bytes buffered while reading a single + // server-sent event. Resolved from [StreamableClientTransport.MaxEventSize]. + maxEventSize int + // Guard calls to Close, as it may be called multiple times. closeOnce sync.Once closeErr error @@ -1630,6 +2140,13 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) { c.initializedResult = state.InitializeResult c.mu.Unlock() + // Under SEP-2575 (protocol version >= 2026-07-28) the standalone HTTP GET + // SSE stream is removed. + if state.InitializeResult == nil || + state.InitializeResult.ProtocolVersion >= protocolVersion20260728 { + return + } + // Start the standalone SSE stream as soon as we have the initialized // result, if continuous listening is enabled. // @@ -1670,7 +2187,7 @@ func (c *streamableClientConn) connectStandaloneSSE() { resp.Body.Close() return } - if resp.Header.Get("Content-Type") != "text/event-stream" { + if baseMediaType(resp.Header.Get("Content-Type")) != "text/event-stream" { // modelcontextprotocol/go-sdk#736: some servers return 200 OK or redirect with // non-SSE content type instead of text/event-stream for the standalone // SSE stream. @@ -1689,7 +2206,7 @@ func (c *streamableClientConn) connectStandaloneSSE() { return } summary := "standalone SSE stream" - if err := c.checkResponse(summary, resp); err != nil { + if err := c.checkResponse(c.ctx, summary, resp); err != nil { c.fail(err) return } @@ -1752,12 +2269,14 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e } var requestSummary string + var requestMethod string var forCall *jsonrpc.Request switch msg := msg.(type) { case *jsonrpc.Request: requestSummary = fmt.Sprintf("sending %q", msg.Method) if msg.IsCall() { forCall = msg + requestMethod = msg.Method } case *jsonrpc.Response: requestSummary = fmt.Sprintf("sending jsonrpc response #%d", msg.ID) @@ -1777,18 +2296,22 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") - if err := c.setMCPHeaders(req); err != nil { + + if err := c.setMCPHeaders(req, msg); err != nil { // Failure to set headers means that the request was not sent. // Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr // and permanently break the connection. - return nil, nil, fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrRejected, err) + return nil, nil, fmt.Errorf("%s: %w: %w", requestSummary, jsonrpc2.ErrRejected, err) } + // Keep this after the setMCPHeaders call to ensure that the + // protocol version header is set. + setStandardHeaders(ctx, req.Header, msg) resp, err := c.client.Do(req) if err != nil { // Any error from client.Do means the request didn't reach the server. // Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr // and permanently break the connection. - err = fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrRejected, err) + err = fmt.Errorf("%s: %w: %w", requestSummary, jsonrpc2.ErrRejected, err) } return req, resp, err } @@ -1800,6 +2323,22 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e if (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && c.oauthHandler != nil { if err := c.oauthHandler.Authorize(ctx, req, resp); err != nil { + // If the caller's context was cancelled while we were running the + // authorization flow, treat the connection as failed so subsequent + // operations on it (e.g. the cancellation notify the call layer + // sends in response to ctx cancellation) short-circuit instead of + // re-invoking the OAuth handler. Otherwise the user gets prompted + // to authorize a request they have already abandoned. See #882. + // + // We check ctx.Err() rather than the error returned by Authorize, + // because the handler is user-implemented and may return an error + // that does not wrap context.Canceled (e.g. a custom sentinel or + // a fmt.Errorf with %v). The context itself is the authoritative + // source for whether the caller abandoned the request. + ctxErr := ctx.Err() + if errors.Is(ctxErr, context.Canceled) || errors.Is(ctxErr, context.DeadlineExceeded) { + c.fail(fmt.Errorf("%s: authorization cancelled: %w", requestSummary, err)) + } // Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr // and permanently break the connection. // Wrap the authorization error as well for client inspection. @@ -1812,10 +2351,19 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e } } - if err := c.checkResponse(requestSummary, resp); err != nil { - // Only fail the connection for non-transient errors. - // Transient errors (wrapped with ErrRejected) should not break the connection. - if !errors.Is(err, jsonrpc2.ErrRejected) { + if err := c.checkResponse(ctx, requestSummary, resp); err != nil { + if (requestMethod == methodDiscover || requestMethod == methodSubscriptionsListen) && !errors.Is(err, jsonrpc2.ErrRejected) { + // Wrap the discover or subscriptions/listen failure with ErrRejected so + // the jsonrpc2 layer doesn't set writeErr, which would break the connection + // (preventing the legacy initialize fallback from succeeding, or breaking + // subsequent RPCs if subscriptions/listen fails). This covers the case + // where a server rejects these requests with a non-JSON-RPC body (e.g. + // plain text 404 or 400), which checkResponse cannot classify as a + // per-call rejection on its own. + err = fmt.Errorf("%w: %w", err, jsonrpc2.ErrRejected) + } else if !errors.Is(err, jsonrpc2.ErrRejected) { + // Only fail the connection for non-transient errors. + // Transient errors (wrapped with ErrRejected) should not break the connection. c.fail(err) } return err @@ -1854,7 +2402,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e return nil } - contentType := strings.TrimSpace(strings.SplitN(resp.Header.Get("Content-Type"), ";", 2)[0]) + contentType := baseMediaType(resp.Header.Get("Content-Type")) switch contentType { case "application/json": go c.handleJSON(requestSummary, resp) @@ -1877,7 +2425,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e return nil } -func (c *streamableClientConn) setMCPHeaders(req *http.Request) error { +func (c *streamableClientConn) setMCPHeaders(req *http.Request, msg jsonrpc.Message) error { c.mu.Lock() defer c.mu.Unlock() @@ -1889,22 +2437,55 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request) error { if ts != nil { token, err := ts.Token() if err != nil { - return err - } - if token != nil { + // If the error is an invalid_grant oauth2.RetrieveError it indicates + // that the token source doesn't have valid authorization for the token + // endpoint, per RFC 6749 section 5.2. For example, the refresh token + // may be expired or invalid. + // + // In that case, ignore the error, skip setting the Authorization + // header, and proceed with the request. Callers that support + // authorization flows get a 401/403 response and trigger the + // Authorize() flow to refresh their token. + var retrieveErr *oauth2.RetrieveError + if !errors.As(err, &retrieveErr) || retrieveErr.ErrorCode != "invalid_grant" { + return err + } + } else if token != nil { req.Header.Set("Authorization", "Bearer "+token.AccessToken) } } } - if c.initializedResult != nil { + if pv := protocolVersionFromMessage(msg); pv != "" { + req.Header.Set(protocolVersionHeader, pv) + } else if c.initializedResult != nil { req.Header.Set(protocolVersionHeader, c.initializedResult.ProtocolVersion) + } else if pv := protocolVersionFromContext(req.Context()); pv != "" { + req.Header.Set(protocolVersionHeader, pv) } if c.sessionID != "" { req.Header.Set(sessionIDHeader, c.sessionID) } + return nil } +// protocolVersionFromMessage recovers the SEP-2575 `_meta.protocolVersion` +// value from an outgoing JSON-RPC request, if present. It returns "" for +// notifications, responses, requests without a `_meta.protocolVersion`, or a +// nil msg. +func protocolVersionFromMessage(msg jsonrpc.Message) string { + req, ok := msg.(*jsonrpc.Request) + if !ok || req == nil { + return "" + } + meta := extractRequestMeta(req.Params) + if meta == nil { + return "" + } + v, _ := meta[MetaKeyProtocolVersion].(string) + return v +} + func (c *streamableClientConn) handleJSON(requestSummary string, resp *http.Response) { body, err := io.ReadAll(resp.Body) resp.Body.Close() @@ -1982,7 +2563,7 @@ func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary str } resp = newResp - if err := c.checkResponse(requestSummary, resp); err != nil { + if err := c.checkResponse(ctx, requestSummary, resp); err != nil { c.fail(err) return } @@ -1993,25 +2574,36 @@ func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary str // translates it into an error if the request was unsuccessful. // // The response body is close if a non-nil error is returned. -func (c *streamableClientConn) checkResponse(requestSummary string, resp *http.Response) (err error) { +func (c *streamableClientConn) checkResponse(ctx context.Context, requestSummary string, resp *http.Response) (err error) { defer func() { if err != nil { resp.Body.Close() } }() - // §2.5.3: "The server MAY terminate the session at any time, after - // which it MUST respond to requests containing that session ID with HTTP - // 404 Not Found." - if resp.StatusCode == http.StatusNotFound { - // Return an ErrSessionMissing to avoid sending a redundant DELETE when the - // session is already gone. - return fmt.Errorf("%s: failed to connect (session ID: %v): %w", requestSummary, c.sessionID, ErrSessionMissing) - } // Transient server errors (502, 503, 504, 429) should not break the connection. // Wrap them with ErrRejected so the jsonrpc2 layer doesn't set writeErr. if isTransientHTTPStatus(resp.StatusCode) { return fmt.Errorf("%w: %s: %v", jsonrpc2.ErrRejected, requestSummary, http.StatusText(resp.StatusCode)) } + // By default, always try to decode the body and surface the underlying + // JSON-RPC error, wrapping it with ErrRejected to prevent the connection from closing. + // Setting MCPGODEBUG=noprotocolerrorbody=1 restores the previous behavior. + if noprotocolerrorbody != "1" && (resp.StatusCode < 200 || resp.StatusCode >= 300) { + body, _ := io.ReadAll(resp.Body) + msg, _ := jsonrpc.DecodeMessage(body) + if response, ok := msg.(*jsonrpc.Response); ok && response.Error != nil { + return fmt.Errorf("%s: %w: %w: %v", requestSummary, response.Error, jsonrpc2.ErrRejected, http.StatusText(resp.StatusCode)) + } + } + // §2.5.3: "The server MAY terminate the session at any time, after + // which it MUST respond to requests containing that session ID with HTTP + // 404 Not Found." + // Only a stateful connection with a known session ID can experience ErrSessionMissing. + if sessionID := c.SessionID(); resp.StatusCode == http.StatusNotFound && sessionID != "" { + // Return an ErrSessionMissing to avoid sending a redundant DELETE when the + // session is already gone. + return fmt.Errorf("%s: failed to connect (session ID: %v): %w", requestSummary, sessionID, ErrSessionMissing) + } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode)) } @@ -2028,7 +2620,7 @@ func (c *streamableClientConn) processStream(ctx context.Context, requestSummary io.Copy(io.Discard, resp.Body) resp.Body.Close() }() - for evt, err := range scanEvents(resp.Body) { + for evt, err := range scanEventsLimited(resp.Body, c.maxEventSize) { if err != nil { if ctx.Err() != nil { return "", 0, true // don't reconnect: client cancelled @@ -2155,7 +2747,7 @@ func (c *streamableClientConn) connectSSE(ctx context.Context, lastEventID strin if err != nil { return nil, err } - if err := c.setMCPHeaders(req); err != nil { + if err := c.setMCPHeaders(req, nil); err != nil { return nil, err } if lastEventID != "" { @@ -2183,15 +2775,22 @@ func (c *streamableClientConn) Close() error { c.closeOnce.Do(func() { if errors.Is(c.failure(), ErrSessionMissing) { // If the session is missing, no need to delete it. + } else if c.SessionID() == "" { + // No session was established (e.g. the server is stateless), + // so there is nothing to delete. } else { - req, err := http.NewRequestWithContext(c.ctx, http.MethodDelete, c.url, nil) + reqCtx, stop := context.WithTimeout(c.ctx, closeDeleteTimeout) + defer stop() + req, err := http.NewRequestWithContext(reqCtx, http.MethodDelete, c.url, nil) if err != nil { c.closeErr = err } else { - if err := c.setMCPHeaders(req); err != nil { + if err := c.setMCPHeaders(req, nil); err != nil { c.closeErr = err - } else if _, err := c.client.Do(req); err != nil { + } else if resp, err := c.client.Do(req); err != nil { c.closeErr = err + } else { + resp.Body.Close() } } } diff --git a/mcp/streamable_client.go b/mcp/streamable_client.go index c2cc25b..74940b3 100644 --- a/mcp/streamable_client.go +++ b/mcp/streamable_client.go @@ -40,8 +40,12 @@ components: # Sessions -The client maintains a session with the server, identified by a session ID -(Mcp-Session-Id header): +The client optionally maintains a session with the server, identified by a +session ID (Mcp-Session-Id header). Sessionless servers (those that never +send Mcp-Session-Id) are fully supported; the client simply omits the +header from subsequent requests and skips the DELETE on close. + +When a session is present: - Session ID is received from the server after initialization - Client includes the session ID in all subsequent requests @@ -162,6 +166,7 @@ The client must handle two response formats from POST requests: - DELETE: Terminate the session - Used by [streamableClientConn.Close] - Skipped if session is already known to be gone ([ErrSessionMissing]) + or if no session was established (sessionless server) # Error Handling @@ -176,7 +181,7 @@ Errors are categorized and handled differently: - 404 Not Found: Session terminated by server ([ErrSessionMissing]) - Message decode errors: Protocol violation - Context cancellation: Client closed connection - - Mismatched session IDs: Protocol error + - Mismatched session IDs: Protocol error (only relevant for servers that use sessions) - See issue #683: our terminal errors are too strict. Terminal errors are stored via [streamableClientConn.fail] and returned by @@ -211,7 +216,7 @@ This header (set by [streamableClientConn.setMCPHeaders]): State management: - [streamableClientConn.incoming]: Buffered channel for received messages - - [streamableClientConn.sessionID]: Server-assigned session identifier + - [streamableClientConn.sessionID]: Server-assigned session identifier (empty for sessionless servers) - [streamableClientConn.initializedResult]: Cached for protocol version header - [streamableClientConn.failed]: Channel closed on terminal error - [streamableClientConn.done]: Channel closed on graceful shutdown diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 43564fd..77ff81a 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -6,6 +6,8 @@ package mcp import ( "context" + "encoding/json" + "errors" "fmt" "io" "net/http" @@ -70,7 +72,7 @@ func (s *fakeStreamableServer) ServeHTTP(w http.ResponseWriter, req *http.Reques key := streamableRequestKey{ httpMethod: req.Method, sessionID: req.Header.Get(sessionIDHeader), - lastEventID: req.Header.Get("Last-Event-ID"), // TODO: extract this to a constant, like sessionIDHeader + lastEventID: req.Header.Get(lastEventIDHeader), } var jsonrpcReq *jsonrpc.Request if req.Method == http.MethodPost { @@ -101,6 +103,23 @@ func (s *fakeStreamableServer) ServeHTTP(w http.ResponseWriter, req *http.Reques resp, ok := s.responses[key] if !ok { + if key.jsonrpcMethod == "server/discover" { + // Return MethodNotFound to trigger fallback to legacy initialize. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + respErr := &jsonrpc.Error{ + Code: jsonrpc.CodeMethodNotFound, + Message: `method not found: "server/discover"`, + } + var id jsonrpc.ID + if jsonrpcReq != nil { + id = jsonrpcReq.ID + } + respMsg, _ := jsonrpc2.NewResponse(id, nil, respErr) + data, _ := jsonrpc2.EncodeMessage(respMsg) + w.Write(data) + return + } s.t.Errorf("missing response for %v", key) http.Error(w, "no response", http.StatusInternalServerError) return @@ -141,7 +160,8 @@ var ( Logging: &LoggingCapabilities{}, Tools: &ToolCapabilities{ListChanged: true}, }, - ProtocolVersion: latestProtocolVersion, + // Pin negotiated version to 2025-11-25 + ProtocolVersion: protocolVersion20251125, ServerInfo: &Implementation{Name: "testServer", Version: "v1.0.0"}, } initResp = resp(1, initResult, nil) @@ -174,13 +194,13 @@ func TestStreamableClientTransportLifecycle(t *testing.T) { }, {"POST", "123", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "123", "", ""}: { header: header{ "Content-Type": "text/event-stream", }, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"DELETE", "123", "", ""}: {}, }, @@ -191,7 +211,10 @@ func TestStreamableClientTransportLifecycle(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + // Pin to 2025-11-25: the fixture's canned initialize response uses + // hardcoded id=1, which only matches when initialize is the first + // request. Under 2026-07-28 the client probes server/discover first. + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -225,7 +248,7 @@ func TestStreamableClientRedundantDelete(t *testing.T) { }, {"POST", "123", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "123", "", ""}: { status: http.StatusMethodNotAllowed, @@ -241,7 +264,10 @@ func TestStreamableClientRedundantDelete(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + // Pin to 2025-11-25: the fixture's canned initialize response uses + // hardcoded id=1, which only matches when initialize is the first + // request. Under 2026-07-28 the client probes server/discover first. + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -255,6 +281,48 @@ func TestStreamableClientRedundantDelete(t *testing.T) { } } +// TestStreamableClientCloseUnresponsiveDelete checks that Close stays bounded +// when the peer accepts the session-termination DELETE but never answers it. +func TestStreamableClientCloseUnresponsiveDelete(t *testing.T) { + base := NewStreamableHTTPHandler(func(*http.Request) *Server { + return NewServer(testImpl, nil) + }, nil) + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + <-r.Context().Done() + return + } + base.ServeHTTP(w, r) + })) + t.Cleanup(func() { + // The DELETE handler only returns once its request is cancelled, so + // drop the connections before waiting for outstanding requests. + httpServer.CloseClientConnections() + httpServer.Close() + }) + + client := NewClient(testImpl, nil) + session, err := client.Connect(context.Background(), + &StreamableClientTransport{Endpoint: httpServer.URL}, nil) + if err != nil { + t.Fatalf("client.Connect() failed: %v", err) + } + if session.ID() == "" { + t.Fatal("empty session ID: Close would not send DELETE") + } + + done := make(chan struct{}) + go func() { + session.Close() + close(done) + }() + select { + case <-done: + case <-time.After(closeDeleteTimeout + 5*time.Second): + t.Fatal("Close() blocked on an unanswered DELETE") + } +} + func TestStreamableClientGETHandling(t *testing.T) { ctx := context.Background() @@ -264,6 +332,7 @@ func TestStreamableClientGETHandling(t *testing.T) { contentType string }{ {http.StatusOK, "", "text/event-stream"}, + {http.StatusOK, "", "text/event-stream; charset=utf-8"}, {http.StatusMethodNotAllowed, "", "text/event-stream"}, //// The client error status code is not treated as an error in non-strict //// mode. @@ -274,7 +343,7 @@ func TestStreamableClientGETHandling(t *testing.T) { } for _, test := range tests { - t.Run(fmt.Sprintf("status=%d", test.status), func(t *testing.T) { + t.Run(fmt.Sprintf("status=%d content_type=%q", test.status, test.contentType), func(t *testing.T) { fake := &fakeStreamableServer{ t: t, responses: fakeResponses{ @@ -287,14 +356,14 @@ func TestStreamableClientGETHandling(t *testing.T) { }, {"POST", "123", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "123", "", ""}: { header: header{ "Content-Type": test.contentType, }, status: test.status, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"DELETE", "123", "", ""}: {optional: true}, }, @@ -304,7 +373,9 @@ func TestStreamableClientGETHandling(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ + ProtocolVersion: protocolVersion20251125, + }) if err == nil { defer session.Close() } @@ -356,14 +427,14 @@ func TestStreamableClientStrictness(t *testing.T) { }, {"POST", "123", notificationInitialized, ""}: { status: test.initializedStatus, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "123", "", ""}: { header: header{ "Content-Type": "text/event-stream", }, status: test.getStatus, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"POST", "123", methodListTools, ""}: { header: header{ @@ -381,7 +452,11 @@ func TestStreamableClientStrictness(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL, strict: test.strict} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + // Pin to 2025-11-25: the fixture's canned initialize response + // uses hardcoded id=1, which only matches when initialize is + // the first request. Under 2026-07-28 the client probes + // server/discover first. + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if (err != nil) != test.wantConnectError { t.Errorf("client.Connect() returned error %v; want error: %t", err, test.wantConnectError) } @@ -421,7 +496,7 @@ func TestStreamableClientUnresumableRequest(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - cs, err := client.Connect(ctx, transport, nil) + cs, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err == nil { cs.Close() t.Fatalf("Connect succeeded unexpectedly") @@ -495,7 +570,7 @@ func TestStreamableClientResumption_Cancelled(t *testing.T) { }, {"POST", "123", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "123", "", ""}: { header: header{ @@ -538,7 +613,11 @@ data: { "jsonrpc": "2.0", "method": "notifications/message", "params": { "level" transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - cs, err := client.Connect(ctx, transport, nil) + // Pin to 2025-11-25: the fixture's canned initialize response + // uses hardcoded id=1, which only matches when initialize is + // the first request. Under 2026-07-28 the client probes + // server/discover first. + cs, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -638,7 +717,7 @@ func TestStreamableClientTransientErrors(t *testing.T) { }, {"POST", "123", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "123", "", ""}: { status: http.StatusMethodNotAllowed, @@ -666,7 +745,11 @@ func TestStreamableClientTransientErrors(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + // Pin to 2025-11-25: the fixture's canned initialize response + // uses hardcoded id=1, which only matches when initialize is + // the first request. Under 2026-07-28 the client probes + // server/discover first. + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect failed: %v", err) } @@ -727,7 +810,7 @@ func TestStreamableClientRetryWithoutProgress(t *testing.T) { }, {"POST", "test-session", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "test-session", "", ""}: { // Disable standalone SSE stream to simplify the test. @@ -769,7 +852,10 @@ data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info" MaxRetries: maxRetries, } client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + // Pin to 2025-11-25: the fixture's canned initialize response uses + // hardcoded id=1, which only matches when initialize is the first + // request. Under 2026-07-28 the client probes server/discover first. + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect failed: %v", err) } @@ -830,13 +916,13 @@ func TestStreamableClientDisableStandaloneSSE(t *testing.T) { }, {"POST", "123", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, getRequestKey: { header: header{ "Content-Type": "text/event-stream", }, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, optional: !test.expectGETRequest, }, {"DELETE", "123", "", ""}: { @@ -853,7 +939,11 @@ func TestStreamableClientDisableStandaloneSSE(t *testing.T) { DisableStandaloneSSE: test.disableStandaloneSSE, } client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + // Pin to 2025-11-25: the fixture's canned initialize response + // uses hardcoded id=1, which only matches when initialize is + // the first request. Under 2026-07-28 the client probes + // server/discover first. + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -947,7 +1037,7 @@ func TestStreamableClientOAuth_AuthorizationHeader(t *testing.T) { }, {"POST", "123", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "123", "", ""}: { header: header{ @@ -971,7 +1061,10 @@ func TestStreamableClientOAuth_AuthorizationHeader(t *testing.T) { OAuthHandler: oauthHandler, } client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + // Pin to 2025-11-25: the fixture's canned initialize response uses + // hardcoded id=1, which only matches when initialize is the first + // request. Under 2026-07-28 the client probes server/discover first. + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -1016,6 +1109,94 @@ func TestStreamableClientOAuth_401(t *testing.T) { } } +// blockingCountingOAuthHandler is an OAuthHandler that blocks inside +// Authorize until the caller's context is cancelled, then returns a custom +// error that does NOT wrap context.Canceled. This mirrors real-world OAuth +// handlers that catch the cancellation internally and surface their own +// error type. The fix for #882 checks ctx.Err() directly rather than +// relying on the error from Authorize, so this must still trigger c.fail(). +// It records how many times Authorize is invoked. +type blockingCountingOAuthHandler struct { + mu sync.Mutex + callCount int +} + +func (h *blockingCountingOAuthHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { + return nil, nil +} + +func (h *blockingCountingOAuthHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error { + h.mu.Lock() + h.callCount++ + h.mu.Unlock() + // Block until the caller's context is cancelled, mirroring an + // interactive OAuth flow that the user has abandoned. + <-ctx.Done() + // Return a custom error that does not wrap context.Canceled, as a + // real-world handler might. The code under test must check ctx.Err() + // to detect the cancellation, not this error. + return fmt.Errorf("oauth flow interrupted") +} + +func (h *blockingCountingOAuthHandler) Calls() int { + h.mu.Lock() + defer h.mu.Unlock() + return h.callCount +} + +// TestStreamableClientOAuth_CancelledAuthorize_NoReprompt is a regression +// test for #882. When OAuthHandler.Authorize returns a context-cancelled +// error, the connection must enter a failed state so that the cancellation +// notification the call layer sends in response to ctx cancellation does +// not flow back through the same broken auth path and re-invoke Authorize. +func TestStreamableClientOAuth_CancelledAuthorize_NoReprompt(t *testing.T) { + handler := &blockingCountingOAuthHandler{} + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, initResp), + }, + }, + } + verifier := func(ctx context.Context, token string, req *http.Request) (*auth.TokenInfo, error) { + return &auth.TokenInfo{Expiration: time.Now().Add(time.Hour)}, nil + } + httpServer := httptest.NewServer(auth.RequireBearerToken(verifier, nil)(fake)) + t.Cleanup(httpServer.Close) + + transport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + OAuthHandler: handler, + } + client := NewClient(testImpl, nil) + + // Use a context with a tight deadline so the cancellation path runs + // while the auth flow is in progress. + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + _, err := client.Connect(ctx, transport, nil) + if err == nil { + t.Fatal("expected client.Connect to fail") + } + + // Give the cancellation Notify path a moment to (try to) run. + time.Sleep(50 * time.Millisecond) + + // Authorize should be invoked exactly once. The bug in #882 caused + // it to be invoked a second time when the call layer sent the + // cancellation notification through the same auth-broken connection. + if got := handler.Calls(); got != 1 { + t.Errorf("expected Authorize to be called exactly 1 time, got %d", got) + } +} + func TestTokenInfo(t *testing.T) { ctx := context.Background() @@ -1067,3 +1248,784 @@ func TestTokenInfo(t *testing.T) { t.Errorf("got %q, want %q", g, w) } } + +// errTestAuthorizeFailed is a sentinel error returned by +// retrieveErrorOAuthHandler.Authorize(). +var errTestAuthorizeFailed = errors.New("authorize intentionally failed for test") + +// retrieveErrorOAuthHandler is a mock OAuthHandler that always returns +// an oauth2.RetrieveError from its TokenSource's Token() method. +type retrieveErrorOAuthHandler struct{} + +func (h *retrieveErrorOAuthHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { + return h, nil +} + +func (h *retrieveErrorOAuthHandler) Token() (*oauth2.Token, error) { + return nil, &oauth2.RetrieveError{ + Response: &http.Response{StatusCode: http.StatusBadRequest}, + Body: []byte("test retrieve error"), + ErrorCode: "invalid_grant", + } +} + +func (h *retrieveErrorOAuthHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error { + return errTestAuthorizeFailed +} + +// TestStreamableClientOAuth_RetrieveError verifies that an invalid_grant RetrieveError +// from the OAuth token source correctly skips sending Authorization header and relies on +// the server's 401 response to trigger the Authorize fallback flow. +func TestStreamableClientOAuth_RetrieveError(t *testing.T) { + ctx := context.Background() + oauthHandler := &retrieveErrorOAuthHandler{} + + // Mock MCP server returns 401 Unauthorized to simulate a server rejecting + // the request that omitted the Authorization header. + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(httpServer.Close) + + transport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + OAuthHandler: oauthHandler, + } + client := NewClient(testImpl, nil) + + // Attempt to connect. The Connect call will trigger the initialization request, + // which will fail to retrieve the token and proceed without auth header, receive 401, + // and invoke Authorize(). + _, err := client.Connect(ctx, transport, nil) + + // Expect the connection to fail with the sentinel error, not the RetrieveError. + if !errors.Is(err, errTestAuthorizeFailed) { + t.Fatalf("client.Connect() error = %v, want %v", err, errTestAuthorizeFailed) + } +} + +// discoverResult is the canned successful DiscoverResult returned by +// fakeStreamableServer setups in the tests below. +var discoverResult = &DiscoverResult{ + Meta: Meta{ + MetaKeyServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, + }, + SupportedVersions: []string{protocolVersion20260728}, + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: true}, + }, + Instructions: "test discover", +} + +// TestStreamableClientConnect_DiscoverSuccess verifies that Client.Connect on +// the streamable transport: +// - sends a POST server/discover with Mcp-Protocol-Version: 2026-07-28 and +// the SEP-2575 per-request _meta triple in the body, +// - on a successful DiscoverResult, skips the legacy initialize handshake +// entirely, and +// - seeds ClientSession.InitializeResult() from the discover response, +// picking a mutually-supported protocol version. +func TestStreamableClientConnect_DiscoverSuccess(t *testing.T) { + ctx := context.Background() + + var ( + gotDiscoverMu sync.Mutex + gotDiscover *jsonrpc.Request + ) + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "sess-1", + }, + wantProtocolVersion: protocolVersion20260728, + responseFunc: func(r *jsonrpc.Request) (string, int) { + gotDiscoverMu.Lock() + gotDiscover = r + gotDiscoverMu.Unlock() + return jsonBody(t, resp(1, discoverResult, nil)), http.StatusOK + }, + }, + {"DELETE", "sess-1", "", ""}: {optional: true}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + if missing := fake.missingRequests(); len(missing) > 0 { + t.Errorf("missing expected requests: %v", missing) + } + + gotDiscoverMu.Lock() + defer gotDiscoverMu.Unlock() + if gotDiscover == nil { + t.Fatal("server did not receive server/discover") + } + + // Inspect the discover request body for the SEP-2575 _meta triple. + var body struct { + Meta map[string]any `json:"_meta"` + } + if err := json.Unmarshal(gotDiscover.Params, &body); err != nil { + t.Fatalf("decoding discover params: %v", err) + } + if v, _ := body.Meta[MetaKeyProtocolVersion].(string); v != protocolVersion20260728 { + t.Errorf("_meta[%s] = %q, want %q", MetaKeyProtocolVersion, v, protocolVersion20260728) + } + if _, ok := body.Meta[MetaKeyClientInfo]; !ok { + t.Errorf("_meta[%s] missing", MetaKeyClientInfo) + } + if _, ok := body.Meta[MetaKeyClientCapabilities]; !ok { + t.Errorf("_meta[%s] missing", MetaKeyClientCapabilities) + } + + ir := session.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after Connect") + } + if got, want := ir.ProtocolVersion, protocolVersion20260728; got != want { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want) + } + if ir.ServerInfo == nil || ir.ServerInfo.Name != "discoverServer" { + t.Errorf("InitializeResult.ServerInfo = %+v, want Name=discoverServer", ir.ServerInfo) + } + if ir.Instructions != "test discover" { + t.Errorf("InitializeResult.Instructions = %q, want %q", ir.Instructions, "test discover") + } +} + +// TestStreamableClientConnSetMCPHeaders_ProtocolVersion covers +// streamableClientConn.setMCPHeaders' selection of the Mcp-Protocol-Version +// header value. +// +// Ordinarily initializedResult is populated by sessionUpdated, called +// through a type assertion to the unexported clientConnection interface +// (see Client.Connect). That assertion silently fails, leaving +// initializedResult nil for the life of the session, whenever the +// Connection returned by a Transport is wrapped by another type exposing +// only the exported Connection interface (a real pattern for transports +// that intercept traffic, e.g. to filter notifications): Go does not +// promote unexported interface methods across an embedded interface +// boundary. Every SEP-2575 (>= 2026-07-28) request already carries its own +// `_meta.protocolVersion` field, so setMCPHeaders falls back to reading it +// from the outgoing message when initializedResult is unset. +func TestStreamableClientConnSetMCPHeaders_ProtocolVersion(t *testing.T) { + tests := []struct { + name string + initializedResult *InitializeResult + msg jsonrpc.Message + ctxVersion string + want string + }{ + { + name: "nil checked", + initializedResult: nil, + msg: (*jsonrpc.Request)(nil), + want: "", + }, + { + name: "message meta wins when initializedResult unset", + initializedResult: nil, + msg: req(1, methodListTools, &ListToolsParams{Meta: Meta{MetaKeyProtocolVersion: protocolVersion20260728}}), + want: protocolVersion20260728, + }, + { + name: "initializedResult used when message has no meta", + initializedResult: &InitializeResult{ProtocolVersion: protocolVersion20251125}, + msg: req(1, methodListTools, &ListToolsParams{}), + want: protocolVersion20251125, + }, + { + name: "initializedResult used for nil message (GET/DELETE)", + initializedResult: &InitializeResult{ProtocolVersion: protocolVersion20251125}, + msg: nil, + want: protocolVersion20251125, + }, + { + name: "message meta preferred over stale initializedResult", + initializedResult: &InitializeResult{ProtocolVersion: protocolVersion20251125}, + msg: req(1, methodListTools, &ListToolsParams{Meta: Meta{MetaKeyProtocolVersion: protocolVersion20260728}}), + want: protocolVersion20260728, + }, + { + name: "no header when neither source is set", + initializedResult: nil, + msg: req(1, methodListTools, &ListToolsParams{}), + want: "", + }, + { + // A process that is both a server and a client (a proxy) carries the + // inbound request's version on the context of the outgoing call. The + // version this connection negotiated must win over it. + name: "initializedResult preferred over request context", + initializedResult: &InitializeResult{ProtocolVersion: protocolVersion20251125}, + msg: req(1, methodListTools, &ListToolsParams{}), + ctxVersion: protocolVersion20260728, + want: protocolVersion20251125, + }, + { + name: "request context used when initializedResult unset", + initializedResult: nil, + msg: req(1, methodListTools, &ListToolsParams{}), + ctxVersion: protocolVersion20260728, + want: protocolVersion20260728, + }, + { + name: "message meta preferred over request context", + initializedResult: nil, + msg: req(1, methodListTools, &ListToolsParams{Meta: Meta{MetaKeyProtocolVersion: protocolVersion20251125}}), + ctxVersion: protocolVersion20260728, + want: protocolVersion20251125, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conn := &streamableClientConn{initializedResult: tt.initializedResult} + httpReq, err := http.NewRequest(http.MethodPost, "http://test.invalid", nil) + if err != nil { + t.Fatal(err) + } + if tt.ctxVersion != "" { + httpReq = httpReq.WithContext(context.WithValue(httpReq.Context(), protocolVersionContextKey{}, tt.ctxVersion)) + } + if err := conn.setMCPHeaders(httpReq, tt.msg); err != nil { + t.Fatalf("setMCPHeaders: %v", err) + } + if got := httpReq.Header.Get(protocolVersionHeader); got != tt.want { + t.Errorf("Mcp-Protocol-Version header = %q, want %q", got, tt.want) + } + }) + } +} + +// TestStreamableClientConnect_DiscoverMethodNotFound verifies that Client.Connect +// falls back to the legacy initialize handshake when the server responds to +// server/discover with a JSON-RPC "Method not found" error. +func TestStreamableClientConnect_DiscoverMethodNotFound(t *testing.T) { + ctx := context.Background() + + // Each request gets a fresh jsonrpc2 ID from the same client connection. + // Use responseFunc to echo the request's ID back so the client matches + // the response to the in-flight call regardless of ordering. + echoErr := func(err error) func(*jsonrpc.Request) (string, int) { + return func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Error: err.(*jsonrpc.Error)}), http.StatusOK + } + } + echoResult := func(result any) func(*jsonrpc.Request) (string, int) { + return func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}), http.StatusOK + } + } + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{"Content-Type": "application/json"}, + wantProtocolVersion: protocolVersion20260728, + responseFunc: echoErr(&jsonrpc.Error{ + Code: jsonrpc.CodeMethodNotFound, + Message: "method not found", + }), + }, + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "fallback", + }, + responseFunc: echoResult(initResult), + }, + {"POST", "fallback", notificationInitialized, ""}: { + status: http.StatusAccepted, + wantProtocolVersion: protocolVersion20251125, + }, + {"GET", "fallback", "", ""}: { + header: header{"Content-Type": "text/event-stream"}, + wantProtocolVersion: protocolVersion20251125, + optional: true, + }, + {"DELETE", "fallback", "", ""}: {optional: true}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + // The fallback initialize explicitly requests protocolVersion20251125 + // (see client.go), so the server negotiates that version. + if got := session.InitializeResult().ProtocolVersion; got != protocolVersion20251125 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, protocolVersion20251125) + } +} + +// TestStreamableClientConnect_DiscoverUnsupportedVersion verifies that +// Client.Connect falls back to the legacy initialize handshake when the +// server responds to server/discover with the SEP-2575 +// UnsupportedProtocolVersionError JSON-RPC code. +func TestStreamableClientConnect_DiscoverUnsupportedVersion(t *testing.T) { + ctx := context.Background() + + echoErr := func(err error) func(*jsonrpc.Request) (string, int) { + return func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Error: err.(*jsonrpc.Error)}), http.StatusOK + } + } + echoResult := func(result any) func(*jsonrpc.Request) (string, int) { + return func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}), http.StatusOK + } + } + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{"Content-Type": "application/json"}, + wantProtocolVersion: protocolVersion20260728, + responseFunc: echoErr(&jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + }), + }, + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "fallback", + }, + responseFunc: echoResult(initResult), + }, + {"POST", "fallback", notificationInitialized, ""}: { + status: http.StatusAccepted, + wantProtocolVersion: protocolVersion20251125, + }, + {"GET", "fallback", "", ""}: { + header: header{"Content-Type": "text/event-stream"}, + wantProtocolVersion: protocolVersion20251125, + optional: true, + }, + {"DELETE", "fallback", "", ""}: {optional: true}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + // The fallback initialize explicitly requests protocolVersion20251125 + // (see client.go), so the server negotiates that version. + if got := session.InitializeResult().ProtocolVersion; got != protocolVersion20251125 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, protocolVersion20251125) + } +} + +// TestStreamableClientConnect_DiscoverMethodNotFoundVPre verifies that +// Client.Connect falls back to the legacy initialize handshake when a +// pre-SEP-2575 (vPre) server rejects server/discover. +func TestStreamableClientConnect_DiscoverMethodNotFoundVPre(t *testing.T) { + ctx := context.Background() + + echoResult := func(result any) func(*jsonrpc.Request) (string, int) { + return func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}), http.StatusOK + } + } + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + wantProtocolVersion: protocolVersion20260728, + // Reproduce the exact body a vPre server produces via + // http.Error(w, err.Error(), 400) where err comes from + // checkRequest. http.Error appends a trailing newline. + body: "JSON RPC not handled: \"server/discover\" unsupported\n", + status: http.StatusBadRequest, + header: header{"Content-Type": "text/plain; charset=utf-8"}, + }, + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "fallback", + }, + responseFunc: echoResult(initResult), + }, + {"POST", "fallback", notificationInitialized, ""}: { + status: http.StatusAccepted, + wantProtocolVersion: protocolVersion20251125, + }, + {"DELETE", "fallback", "", ""}: {optional: true}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + DisableStandaloneSSE: true, + } + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + // The fallback initialize explicitly requests protocolVersion20251125 + // (see client.go), so the server negotiates that version. + if got := session.InitializeResult().ProtocolVersion; got != protocolVersion20251125 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, protocolVersion20251125) + } +} + +// TestStreamableClientConnect_DiscoverUnsupportedProtocolVersion verifies that +// Client.Connect falls back to the legacy initialize handshake when a +// server rejects server/discover with a plain HTTP 400 containing "Unsupported protocol version". +func TestStreamableClientConnect_DiscoverUnsupportedVersionVPre(t *testing.T) { + ctx := context.Background() + + echoResult := func(result any) func(*jsonrpc.Request) (string, int) { + return func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}), http.StatusOK + } + } + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + wantProtocolVersion: protocolVersion20260728, + body: "Bad Request: Unsupported protocol version\n", + status: http.StatusBadRequest, + header: header{"Content-Type": "text/plain; charset=utf-8"}, + }, + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "fallback", + }, + responseFunc: echoResult(initResult), + }, + {"POST", "fallback", notificationInitialized, ""}: { + status: http.StatusAccepted, + wantProtocolVersion: protocolVersion20251125, + }, + {"DELETE", "fallback", "", ""}: {optional: true}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + DisableStandaloneSSE: true, + } + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + // The fallback initialize explicitly requests protocolVersion20251125 + // (see client.go), so the server negotiates that version. + if got := session.InitializeResult().ProtocolVersion; got != protocolVersion20251125 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, protocolVersion20251125) + } +} + +// TestStreamableClientConnect_DiscoverUnsupportedVersionNegotiation verifies that +// when Client.Connect over a streamable transport receives an +// UnsupportedProtocolVersion error containing Data.Supported, it negotiates a +// mutually supported version and retries server/discover. +func TestStreamableClientConnect_DiscoverUnsupportedVersionNegotiation(t *testing.T) { + ctx := context.Background() + + const unsupportedClientVersion = "2099-12-31" + + var discoverCalls atomic.Int32 + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{ + "Content-Type": "application/json", + }, + responseFunc: func(r *jsonrpc.Request) (string, int) { + n := discoverCalls.Add(1) + if n == 1 { + data, _ := json.Marshal(UnsupportedProtocolVersionData{ + Supported: []string{protocolVersion20260728}, + }) + respMsg := &jsonrpc.Response{ + ID: r.ID, + Error: &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + Data: data, + }, + } + return jsonBody(t, respMsg), http.StatusOK + } + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(discoverResult)}), http.StatusOK + }, + }, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: unsupportedClientVersion}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + if got, want := discoverCalls.Load(), int32(2); got != want { + t.Errorf("discover call count = %d, want %d", got, want) + } + if got := session.InitializeResult().ProtocolVersion; got != protocolVersion20260728 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, protocolVersion20260728) + } +} + +// TestStreamableClientHandlerErrorPropagation verifies that per-call +// handler-level HTTP errors carrying a JSON-RPC error body do not tear down +// the session, and that setting MCPGODEBUG=noprotocolerrorbody=1 restores the +// pre-fix behavior in which non-transient errors permanently failed the +// connection. +func TestStreamableClientHandlerErrorPropagation(t *testing.T) { + ctx := context.Background() + + // Build a JSON-RPC error response body for a given tools/call request. + jsonRPCErrorBody := func(id int64, code int64, msg string) string { + return jsonBody(t, resp(id, nil, &jsonrpc.Error{Code: code, Message: msg})) + } + + tests := []struct { + name string + callStatus int // HTTP status returned for the tools/call + callBody string // response body for the tools/call ("" for empty; may be a JSON-RPC error) + disableBodyDecode bool // if true, sets noprotocolerrorbody="1" for the duration of the test + wantErrRejected bool // whether the returned error should match errors.Is(err, jsonrpc2.ErrRejected) + wantSessionAlive bool // whether a subsequent call on the same session should succeed + }{ + { + // SEP-2575 §"Missing Required Capabilities": server returns + // -32021 at HTTP 400. Session must survive. + name: "400 with JSON-RPC error body (default)", + callStatus: http.StatusBadRequest, + callBody: jsonRPCErrorBody(1, CodeMissingRequiredClientCapabilities, "missing capability"), + wantErrRejected: true, + wantSessionAlive: true, + }, + { + // Same server response, but the user opted out via + // MCPGODEBUG=noprotocolerrorbody=1. Pre-fix behavior: the + // session is torn down. + name: "400 with JSON-RPC error body (noprotocolerrorbody=1)", + callStatus: http.StatusBadRequest, + callBody: jsonRPCErrorBody(1, CodeMissingRequiredClientCapabilities, "missing capability"), + disableBodyDecode: true, + wantErrRejected: false, + wantSessionAlive: false, + }, + { + // Legacy server returns plain-text 400 with no JSON-RPC + // body: cannot be classified as a per-call rejection, so the + // session is still torn down. + name: "400 with plain-text body", + callStatus: http.StatusBadRequest, + callBody: "Bad Request", + wantErrRejected: false, + wantSessionAlive: false, + }, + { + // SEP-2575: server returns -32601 for an unimplemented + // method at HTTP 404. Must be treated as a per-call + // rejection, not a terminated session. + name: "404 with JSON-RPC MethodNotFound body", + callStatus: http.StatusNotFound, + callBody: jsonRPCErrorBody(1, jsonrpc.CodeMethodNotFound, `method not found: "tools/call"`), + wantErrRejected: true, + wantSessionAlive: true, + }, + { + // A bare 404 with no JSON-RPC body means the session has + // been terminated. Session must not survive. + name: "404 with empty body (terminated session)", + callStatus: http.StatusNotFound, + callBody: "", + wantErrRejected: false, + wantSessionAlive: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.disableBodyDecode { + prev := noprotocolerrorbody + noprotocolerrorbody = "1" + t.Cleanup(func() { noprotocolerrorbody = prev }) + } + + // Track how many tools/call requests we've served: first + // returns the error under test, subsequent calls succeed so + // we can observe whether the session is still alive. + var callsServed atomic.Int32 + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{ + "Content-Type": "application/json", + }, + wantProtocolVersion: protocolVersion20260728, + responseFunc: func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, resp(r.ID.Raw().(int64), discoverResult, nil)), http.StatusOK + }, + }, + {"POST", "", "tools/call", ""}: { + header: header{"Content-Type": "application/json"}, + responseFunc: func(r *jsonrpc.Request) (string, int) { + if callsServed.Add(1) == 1 { + return test.callBody, test.callStatus + } + return jsonBody(t, resp(r.ID.Raw().(int64), &CallToolResult{}, nil)), 0 + }, + optional: true, + }, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + // First call: triggers the error under test. + _, err = session.CallTool(ctx, &CallToolParams{Name: "nonexistent"}) + if err == nil { + t.Fatal("first CallTool succeeded unexpectedly, want error") + } + if got := errors.Is(err, jsonrpc2.ErrRejected); got != test.wantErrRejected { + t.Errorf("errors.Is(err, ErrRejected) = %v, want %v (err = %v)", got, test.wantErrRejected, err) + } + + // Second call: verifies whether the session survived. + _, err = session.CallTool(ctx, &CallToolParams{Name: "nonexistent"}) + if test.wantSessionAlive { + if err != nil { + t.Errorf("second CallTool failed: %v (session should survive per-call rejection)", err) + } + } else { + if err == nil { + t.Error("second CallTool succeeded unexpectedly, want session torn down") + } + } + }) + } +} + +// TestStreamableClient_StatelessSubscriptionsListen404 verifies that in stateless mode +// (protocol 2026-07-28), when a server rejects subscriptions/listen with 404 (e.g. Copilot MCP server), +// the client connection survives and subsequent RPCs (such as tools/list) succeed. +func TestStreamableClient_StatelessSubscriptionsListen404(t *testing.T) { + ctx := t.Context() + + var listenServed atomic.Bool + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{ + "Content-Type": "application/json", + }, + wantProtocolVersion: protocolVersion20260728, + responseFunc: func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, resp(r.ID.Raw().(int64), discoverResult, nil)), http.StatusOK + }, + }, + {"POST", "", methodSubscriptionsListen, ""}: { + header: header{"Content-Type": "text/plain"}, + responseFunc: func(r *jsonrpc.Request) (string, int) { + listenServed.Store(true) + return "404 Not Found", http.StatusNotFound + }, + optional: true, + }, + {"POST", "", methodListTools, ""}: { + header: header{"Content-Type": "application/json"}, + responseFunc: func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, resp(r.ID.Raw().(int64), &ListToolsResult{Tools: []*Tool{}}, nil)), http.StatusOK + }, + optional: true, + }, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, &ClientOptions{ + ToolListChangedHandler: func(ctx context.Context, req *ToolListChangedRequest) {}, + }) + + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer session.Close() + + // tools/list must succeed even though subscriptions/listen returned 404. + res, err := session.ListTools(ctx, nil) + if err != nil { + t.Fatalf("ListTools failed: %v", err) + } + if res == nil { + t.Fatal("ListTools result is nil") + } + if !listenServed.Load() { + t.Fatal("subscriptions/listen was not called") + } +} diff --git a/mcp/streamable_example_test.go b/mcp/streamable_example_test.go index 7ff1612..66834f2 100644 --- a/mcp/streamable_example_test.go +++ b/mcp/streamable_example_test.go @@ -32,7 +32,7 @@ func ExampleStreamableHTTPHandler() { defer httpServer.Close() // The SDK is currently permissive of some missing keys in "params". - resp := mustPostMessage(`{"jsonrpc": "2.0", "id": 1, "method":"initialize", "params": {}}`, httpServer.URL) + resp := mustPostMessage(`{"jsonrpc": "2.0", "id": 1, "method":"initialize", "params": {"protocolVersion":"2025-11-25"}}`, httpServer.URL) fmt.Println(resp) // Output: // {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-11-25","serverInfo":{"name":"server","version":"v0.1.0"}}} diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go new file mode 100644 index 0000000..c2b8dfd --- /dev/null +++ b/mcp/streamable_headers.go @@ -0,0 +1,545 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log/slog" + "math" + "net/http" + "strconv" + "strings" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" +) + +const ( + protocolVersionHeader = "Mcp-Protocol-Version" + sessionIDHeader = "Mcp-Session-Id" + lastEventIDHeader = "Last-Event-ID" + methodHeader = "Mcp-Method" + nameHeader = "Mcp-Name" + paramHeaderPrefix = "Mcp-Param-" + minVersionForStandardHeaders = protocolVersion20260728 + base64Prefix = "=?base64?" + base64Suffix = "?=" +) + +func extractName(method string, params json.RawMessage) (string, bool) { + switch method { + case "tools/call": + var p CallToolParams + if err := internaljson.Unmarshal(params, &p); err == nil { + return p.Name, true + } + case "prompts/get": + var p GetPromptParams + if err := internaljson.Unmarshal(params, &p); err == nil { + return p.Name, true + } + case "resources/read": + var p ReadResourceParams + if err := internaljson.Unmarshal(params, &p); err == nil { + return p.URI, true + } + } + + return "", false +} + +// headerSchemaProperty captures the fields needed for x-mcp-header processing. +type headerSchemaProperty struct { + Type string `json:"type"` + XMCPHeader json.RawMessage `json:"x-mcp-header,omitempty"` + Properties map[string]headerSchemaProperty `json:"properties,omitempty"` +} + +// unmarshalSchemaProperties normalizes any InputSchema type +// (*jsonschema.Schema, map[string]any, or json.RawMessage) into a common +// representation by marshaling to JSON and unmarshaling only the fields we need. +func unmarshalSchemaProperties(schema any) map[string]headerSchemaProperty { + var s headerSchemaProperty + if err := remarshal(schema, &s); err != nil { + return nil + } + return s.Properties +} + +// paramHeaderBinding maps a (possibly nested) input-schema property to the +// HTTP header it carries. +type paramHeaderBinding struct { + Path []string + Header string +} + +// extractParamHeaderAnnotations returns the bindings for every property in +// the tool's InputSchema that has an x-mcp-header annotation +func extractParamHeaderAnnotations(tool *Tool) []paramHeaderBinding { + props := unmarshalSchemaProperties(tool.InputSchema) + if len(props) == 0 { + return nil + } + var result []paramHeaderBinding + result = collectParamHeaderAnnotations(props, nil, result) + if len(result) == 0 { + return nil + } + return result +} + +// collectParamHeaderAnnotations walks the schema properties and records every +// x-mcp-header annotation it finds, keyed by the property-name path. +func collectParamHeaderAnnotations(props map[string]headerSchemaProperty, prefix []string, out []paramHeaderBinding) []paramHeaderBinding { + for propName, prop := range props { + path := make([]string, len(prefix)+1) + copy(path, prefix) + path[len(prefix)] = propName + + var headerName string + if err := json.Unmarshal(prop.XMCPHeader, &headerName); err == nil && headerName != "" { + out = append(out, paramHeaderBinding{Path: path, Header: headerName}) + } + if len(prop.Properties) > 0 { + out = collectParamHeaderAnnotations(prop.Properties, path, out) + } + } + return out +} + +// lookupArgument navigates the arguments object using the given property-name +// path and returns the raw JSON value at that location. It reports whether +// the value was found. +func lookupArgument(args map[string]json.RawMessage, path []string) (json.RawMessage, bool) { + if len(path) == 0 { + return nil, false + } + cur, ok := args[path[0]] + if !ok { + return nil, false + } + for _, part := range path[1:] { + var obj map[string]json.RawMessage + if err := internaljson.Unmarshal(cur, &obj); err != nil { + return nil, false + } + cur, ok = obj[part] + if !ok { + return nil, false + } + } + return cur, true +} + +// maxSafeInteger and minSafeInteger bound the integer values that can be +// faithfully represented as IEEE-754 double-precision floats. +const ( + maxSafeInteger = 1<<53 - 1 // 2^53 - 1 = 9007199254740991 + minSafeInteger = -(1<<53 - 1) // -(2^53 - 1) = -9007199254740991 +) + +// unmarshalPrimitive unmarshals a JSON value into the Go representation used +// for x-mcp-header processing per SEP-2243: +// +// - JSON string -> string +// - JSON boolean -> bool +// - JSON integer (within the JavaScript safe-integer range) -> int64 +// +// JSON numbers that are non-integers (have a fractional part, NaN, or ±Inf) +// or integers outside the safe range are rejected because the `number` type +// is not permitted for x-mcp-header parameters; only integer, string, boolean +// are allowed. +func unmarshalPrimitive(raw json.RawMessage) any { + var val any + if err := internaljson.Unmarshal(raw, &val); err != nil { + return nil + } + switch v := val.(type) { + case string, bool: + return v + case float64: + if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) { + return nil + } + if v < minSafeInteger || v > maxSafeInteger { + return nil + } + return int64(v) + default: + return nil + } +} + +// primitiveToString formats an x-mcp-header value (as produced by +// [unmarshalPrimitive]) to its canonical header string representation per +// SEP-2243. Returns false if value is not one of the permitted primitive +// types (string, bool, int64). +func primitiveToString(value any) (string, bool) { + switch v := value.(type) { + case string: + return v, true + case bool: + return fmt.Sprintf("%t", v), true + case int64: + return strconv.FormatInt(v, 10), true + default: + return "", false + } +} + +// setStandardHeaders populates standard MCP headers. +// It requires the protocol version header to be set. +func setStandardHeaders(ctx context.Context, header http.Header, msg jsonrpc.Message) { + if msg == nil { + return + } + if header.Get(protocolVersionHeader) == "" || header.Get(protocolVersionHeader) < minVersionForStandardHeaders { + return + } + + switch msg := msg.(type) { + case *jsonrpc.Request: + header.Set(methodHeader, msg.Method) + if name, ok := extractName(msg.Method, msg.Params); ok { + // Names are only SHOULD-constrained to header-safe characters, so a + // name outside the safe set is Base64-wrapped with the =?base64?...?= + // sentinel, the same rule applied to Mcp-Param-* values. + if encoded, ok := encodeHeaderValue(name); ok { + header.Set(nameHeader, encoded) + } + } + if msg.Method == "tools/call" { + if tool, ok := ctx.Value(toolContextKey).(*Tool); ok && tool != nil { + for k, v := range generateParamHeaders(tool, msg.Params) { + header.Set(k, v) + } + } + } + } +} + +// generateParamHeaders reads x-mcp-header annotations from the tool's InputSchema +// and returns the Mcp-Param-{Name} headers to be set on the HTTP request. +func generateParamHeaders(tool *Tool, params json.RawMessage) map[string]string { + paramHeaders := extractParamHeaderAnnotations(tool) + if len(paramHeaders) == 0 { + return nil + } + + var raw struct { + Arguments map[string]json.RawMessage `json:"arguments"` + } + if err := internaljson.Unmarshal(params, &raw); err != nil || raw.Arguments == nil { + return nil + } + + res := make(map[string]string) + for _, b := range paramHeaders { + argRaw, ok := lookupArgument(raw.Arguments, b.Path) + if !ok { + continue + } + if string(argRaw) == "null" { + continue + } + val := unmarshalPrimitive(argRaw) + if val == nil { + continue + } + encoded, ok := encodeHeaderValue(val) + if !ok { + continue + } + res[paramHeaderPrefix+b.Header] = encoded + } + return res +} + +// filterValidTools returns only tools that have valid +// x-mcp-header annotations. Invalid tools are logged and excluded. +func filterValidTools(logger *slog.Logger, tools []*Tool) []*Tool { + logger = ensureLogger(logger) + result := make([]*Tool, 0, len(tools)) + for _, tool := range tools { + // A nil tool (e.g. from a malformed "tools":[null] response) is + // invalid; exclude it instead of dereferencing it. + if tool == nil { + logger.Error("excluding nil tool from tools/list") + continue + } + if err := validateParamHeaderAnnotations(tool); err != nil { + logger.Error("excluding tool from tools/list", "tool", tool.Name, "error", err) + continue + } + result = append(result, tool) + } + return result +} + +// validateParamHeaderAnnotations checks that a tool's x-mcp-header annotations +// are valid. Annotations may appear on properties at any nesting +// depth within the inputSchema and must be unique across all of them. +func validateParamHeaderAnnotations(tool *Tool) error { + props := unmarshalSchemaProperties(tool.InputSchema) + if len(props) == 0 { + return nil + } + seen := make(map[string]bool) + return validateParamHeadersIn(props, "", seen) +} + +func validateParamHeadersIn(props map[string]headerSchemaProperty, prefix string, seen map[string]bool) error { + for propName, prop := range props { + path := propName + if prefix != "" { + path = prefix + "." + propName + } + if prop.XMCPHeader != nil { + if prop.Type != "string" && prop.Type != "integer" && prop.Type != "boolean" { + return fmt.Errorf("property %q: x-mcp-header can only be applied to primitive types (integer, string, boolean), got %q", path, prop.Type) + } + var headerName string + if err := json.Unmarshal(prop.XMCPHeader, &headerName); err != nil || headerName == "" { + return fmt.Errorf("property %q: x-mcp-header must be a non-empty string", path) + } + if err := validateHeaderName(headerName); err != nil { + return fmt.Errorf("property %q: %w", path, err) + } + lower := strings.ToLower(headerName) + if seen[lower] { + return fmt.Errorf("property %q: duplicate x-mcp-header value %q (case-insensitive)", path, headerName) + } + seen[lower] = true + } + if len(prop.Properties) > 0 { + if err := validateParamHeadersIn(prop.Properties, path, seen); err != nil { + return err + } + } + } + return nil +} + +// validateHeaderName checks that a header name matches the HTTP field-name +// token syntax (1*tchar). +func validateHeaderName(name string) error { + if name == "" { + return fmt.Errorf("x-mcp-header value must be a non-empty string") + } + for _, c := range name { + if !isTChar(c) { + return fmt.Errorf("x-mcp-header value %q contains invalid character %q", name, c) + } + } + return nil +} + +// isTChar reports whether c is a valid HTTP token character (tchar) +// +// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / +// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA +func isTChar(c rune) bool { + switch { + case c >= '0' && c <= '9': + return true + case c >= 'A' && c <= 'Z': + return true + case c >= 'a' && c <= 'z': + return true + } + switch c { + case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', + '^', '_', '`', '|', '~': + return true + } + return false +} + +func validateMcpHeaders(header http.Header, msg jsonrpc.Message, toolLookup func(string) (*serverTool, bool)) error { + protocolVersion := header.Get(protocolVersionHeader) + if protocolVersion == "" || protocolVersion < minVersionForStandardHeaders { + return nil + } + + switch msg := msg.(type) { + case *jsonrpc.Request: + methodInHeader := header.Get(methodHeader) + if methodInHeader == "" { + return errors.New("missing required Mcp-Method header") + } + if methodInHeader != msg.Method { + return fmt.Errorf("header mismatch: Mcp-Method header value '%s' does not match body value '%s'", methodInHeader, msg.Method) + } + + var nameInBody string + if msg.Method == "tools/call" || msg.Method == "resources/read" || msg.Method == "prompts/get" { + nameInHeader := header.Get(nameHeader) + if nameInHeader == "" { + return fmt.Errorf("missing required Mcp-Name header for method %q", msg.Method) + } + // A name that is not header-safe may be Base64-wrapped by the client + // (=?base64?...?=), so decode before comparing, as with Mcp-Param-*. + decodedName, ok := decodeHeaderValue(nameInHeader) + if !ok { + return errors.New("header mismatch: Mcp-Name header contains invalid Base64 encoding") + } + nameInBody, ok = extractName(msg.Method, msg.Params) + if !ok { + return fmt.Errorf("failed to extract name from parameters for method %q", msg.Method) + } + if decodedName != nameInBody { + return fmt.Errorf("header mismatch: Mcp-Name header value '%s' does not match body value '%s'", decodedName, nameInBody) + } + } + + if msg.Method == "tools/call" && toolLookup != nil { + if st, ok := toolLookup(nameInBody); ok && st != nil { + if err := validateParamHeaders(header, msg, st.tool); err != nil { + return err + } + } + } + } + return nil +} + +func validateParamHeaders(header http.Header, msg *jsonrpc.Request, tool *Tool) error { + paramHeaders := extractParamHeaderAnnotations(tool) + if len(paramHeaders) == 0 { + return nil + } + + var raw struct { + Arguments map[string]json.RawMessage `json:"arguments"` + } + if err := internaljson.Unmarshal(msg.Params, &raw); err != nil { + return nil + } + + for _, b := range paramHeaders { + fullHeader := paramHeaderPrefix + b.Header + headerVal := header.Get(fullHeader) + argRaw, argExists := lookupArgument(raw.Arguments, b.Path) + + if !argExists || string(argRaw) == "null" { + if headerVal != "" { + return fmt.Errorf("header mismatch: unexpected %s header for absent or null parameter %q", fullHeader, strings.Join(b.Path, ".")) + } + continue + } + + if headerVal == "" { + return fmt.Errorf("header mismatch: missing %s header for parameter %q", fullHeader, strings.Join(b.Path, ".")) + } + + decoded, ok := decodeHeaderValue(headerVal) + if !ok { + return fmt.Errorf("header mismatch: %s header contains invalid Base64 encoding", fullHeader) + } + + bodyVal := unmarshalPrimitive(argRaw) + if bodyVal == nil { + return fmt.Errorf("header mismatch: %s header present but body parameter %q is not a primitive type", fullHeader, strings.Join(b.Path, ".")) + } + + if !primitiveEqual(decoded, bodyVal) { + return fmt.Errorf("header mismatch: %s header value '%s' does not match body value", fullHeader, headerVal) + } + } + return nil +} + +// primitiveEqual reports whether the (decoded) header string equals the +// JSON-derived body value. +func primitiveEqual(headerStr string, bodyVal any) bool { + if bodyInt, ok := bodyVal.(int64); ok { + headerNum, err := strconv.ParseFloat(headerStr, 64) + if err != nil { + return false + } + if math.IsNaN(headerNum) || math.IsInf(headerNum, 0) || headerNum != math.Trunc(headerNum) { + return false + } + if headerNum < minSafeInteger || headerNum > maxSafeInteger { + return false + } + return int64(headerNum) == bodyInt + } + expected, ok := primitiveToString(bodyVal) + if !ok { + return false + } + return headerStr == expected +} + +// encodeHeaderValue converts a parameter value to an HTTP header-safe string +// per the SEP-2243 encoding rules: +// - string: used as-is if safe ASCII, otherwise Base64 encoded +// - int64: decimal string representation +// - bool: lowercase "true" or "false" +// +// Values that contain non-ASCII characters, control characters, or +// leading/trailing whitespace are Base64-encoded with the =?base64?...?= wrapper. +// +// The second return value is false if the value is not a supported primitive type. +func encodeHeaderValue(value any) (string, bool) { + s, ok := primitiveToString(value) + if !ok { + return "", false + } + if requiresBase64Encoding(s) { + return encodeBase64(s), true + } + return s, true +} + +// decodeHeaderValue decodes a header value that may be Base64-encoded +// with the =?base64?...?= wrapper. +// +// The second return value is false if the header value is not a valid Base64 encoded value. +func decodeHeaderValue(headerValue string) (string, bool) { + if len(headerValue) == 0 { + return headerValue, true + } + + if encoded, ok := strings.CutPrefix(headerValue, base64Prefix); ok { + if encoded, ok = strings.CutSuffix(encoded, base64Suffix); ok { + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", false + } + return string(decoded), true + } + } + return headerValue, true +} + +func requiresBase64Encoding(s string) bool { + if len(s) == 0 { + return false + } + if s[0] == ' ' || s[0] == '\t' || s[len(s)-1] == ' ' || s[len(s)-1] == '\t' { + return true + } + for _, c := range s { + if c < 0x20 || c > 0x7E { + return true + } + } + // Per SEP-2243, plain-ASCII values that match the base64 sentinel pattern + // must also be base64-encoded to avoid ambiguity with already-encoded values. + if strings.HasPrefix(s, base64Prefix) && strings.HasSuffix(s, base64Suffix) { + return true + } + return false +} + +func encodeBase64(s string) string { + return base64Prefix + base64.StdEncoding.EncodeToString([]byte(s)) + base64Suffix +} diff --git a/mcp/streamable_headers_test.go b/mcp/streamable_headers_test.go new file mode 100644 index 0000000..97af67c --- /dev/null +++ b/mcp/streamable_headers_test.go @@ -0,0 +1,1649 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" +) + +func TestExtractName(t *testing.T) { + tests := []struct { + name string + method string + params json.RawMessage + wantName string + wantOK bool + }{ + { + name: "tools/call", + method: "tools/call", + params: mustMarshal(&CallToolParams{Name: "my-tool"}), + wantName: "my-tool", + wantOK: true, + }, + { + name: "prompts/get", + method: "prompts/get", + params: mustMarshal(&GetPromptParams{Name: "code_review"}), + wantName: "code_review", + wantOK: true, + }, + { + name: "resources/read", + method: "resources/read", + params: mustMarshal(&ReadResourceParams{URI: "file:///info.txt"}), + wantName: "file:///info.txt", + wantOK: true, + }, + { + name: "tools/call with empty name", + method: "tools/call", + params: mustMarshal(&CallToolParams{Name: ""}), + wantName: "", + wantOK: true, + }, + { + name: "tool name with hyphen", + method: "tools/call", + params: mustMarshal(&CallToolParams{Name: "my-tool-v2"}), + wantName: "my-tool-v2", + wantOK: true, + }, + { + name: "tool name with underscore", + method: "tools/call", + params: mustMarshal(&CallToolParams{Name: "my_tool_name"}), + wantName: "my_tool_name", + wantOK: true, + }, + { + name: "resource URI with special chars", + method: "resources/read", + params: mustMarshal(&ReadResourceParams{URI: "file:///path/to/file%20name.txt"}), + wantName: "file:///path/to/file%20name.txt", + wantOK: true, + }, + { + name: "resource URI with query string", + method: "resources/read", + params: mustMarshal(&ReadResourceParams{URI: "https://example.com/resource?id=123"}), + wantName: "https://example.com/resource?id=123", + wantOK: true, + }, + { + name: "unrelated method", + method: "initialize", + params: mustMarshal(&InitializeParams{ProtocolVersion: "2025-06-18"}), + wantName: "", + wantOK: false, + }, + { + name: "notification method", + method: "notifications/initialized", + params: nil, + wantName: "", + wantOK: false, + }, + { + name: "invalid JSON params", + method: "tools/call", + params: []byte("not json"), + wantName: "", + wantOK: false, + }, + { + name: "nil params", + method: "tools/call", + params: nil, + wantName: "", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotName, gotOK := extractName(tt.method, tt.params) + if gotName != tt.wantName || gotOK != tt.wantOK { + t.Errorf("extractName(%q, ...) = (%q, %v), want (%q, %v)", + tt.method, gotName, gotOK, tt.wantName, tt.wantOK) + } + }) + } +} + +func TestSetStandardHeaders(t *testing.T) { + tests := []struct { + name string + protocolVersion string + msg jsonrpc.Message + wantMethodHeader string + wantNameHeader string + }{ + { + name: "tools/call with future version", + protocolVersion: minVersionForStandardHeaders, + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantMethodHeader: "tools/call", + wantNameHeader: "my-tool", + }, + { + name: "prompts/get with future version", + protocolVersion: minVersionForStandardHeaders, + msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: "code_review"})}, + wantMethodHeader: "prompts/get", + wantNameHeader: "code_review", + }, + { + name: "resources/read with future version", + protocolVersion: minVersionForStandardHeaders, + msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///info.txt"})}, + wantMethodHeader: "resources/read", + wantNameHeader: "file:///info.txt", + }, + { + name: "initialize sets method only", + protocolVersion: minVersionForStandardHeaders, + msg: &jsonrpc.Request{Method: "initialize", Params: mustMarshal(&InitializeParams{ProtocolVersion: minVersionForStandardHeaders})}, + wantMethodHeader: "initialize", + wantNameHeader: "", + }, + { + name: "notification sets method only", + protocolVersion: minVersionForStandardHeaders, + msg: &jsonrpc.Request{Method: "notifications/initialized"}, + wantMethodHeader: "notifications/initialized", + wantNameHeader: "", + }, + { + name: "old version skips all headers", + protocolVersion: protocolVersion20251125, + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantMethodHeader: "", + wantNameHeader: "", + }, + { + name: "empty version skips all headers", + protocolVersion: "", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantMethodHeader: "", + wantNameHeader: "", + }, + { + name: "nil message is a no-op", + protocolVersion: minVersionForStandardHeaders, + msg: nil, + wantMethodHeader: "", + wantNameHeader: "", + }, + { + name: "response message is ignored", + protocolVersion: minVersionForStandardHeaders, + msg: &jsonrpc.Response{}, + wantMethodHeader: "", + wantNameHeader: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := http.Header{} + if tt.protocolVersion != "" { + header.Set(protocolVersionHeader, tt.protocolVersion) + } + + setStandardHeaders(context.Background(), header, tt.msg) + + if got := header.Get(methodHeader); got != tt.wantMethodHeader { + t.Errorf("MethodHeader = %q, want %q", got, tt.wantMethodHeader) + } + if got := header.Get(nameHeader); got != tt.wantNameHeader { + t.Errorf("NameHeader = %q, want %q", got, tt.wantNameHeader) + } + }) + } +} + +func TestValidateMcpHeaders(t *testing.T) { + + tests := []struct { + name string + version string + methodHeader string + nameHeader string + msg jsonrpc.Message + wantErr bool + wantErrContain string + }{ + { + name: "old version skips validation", + version: protocolVersion20251125, + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantErr: false, + }, + { + name: "empty version skips validation", + version: "", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantErr: false, + }, + { + name: "missing Mcp-Method header", + version: minVersionForStandardHeaders, + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantErr: true, + wantErrContain: "missing required Mcp-Method header", + }, + { + name: "missing Mcp-Name for tools/call", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantErr: true, + wantErrContain: "missing required Mcp-Name header", + }, + { + name: "missing Mcp-Name for resources/read", + version: minVersionForStandardHeaders, + methodHeader: "resources/read", + msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///info.txt"})}, + wantErr: true, + wantErrContain: "missing required Mcp-Name header", + }, + { + name: "missing Mcp-Name for prompts/get", + version: minVersionForStandardHeaders, + methodHeader: "prompts/get", + msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: "review"})}, + wantErr: true, + wantErrContain: "missing required Mcp-Name header", + }, + { + name: "method mismatch", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: "review"})}, + wantErr: true, + wantErrContain: "Mcp-Method header value 'tools/call' does not match body value 'prompts/get'", + }, + { + name: "tool name mismatch", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + nameHeader: "wrong-tool", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "right-tool"})}, + wantErr: true, + wantErrContain: "Mcp-Name header value 'wrong-tool' does not match body value 'right-tool'", + }, + { + name: "resource URI mismatch", + version: minVersionForStandardHeaders, + methodHeader: "resources/read", + nameHeader: "file:///wrong.txt", + msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///right.txt"})}, + wantErr: true, + wantErrContain: "Mcp-Name header value 'file:///wrong.txt' does not match body value 'file:///right.txt'", + }, + { + name: "prompt name mismatch", + version: minVersionForStandardHeaders, + methodHeader: "prompts/get", + nameHeader: "wrong-prompt", + msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: "right-prompt"})}, + wantErr: true, + wantErrContain: "Mcp-Name header value 'wrong-prompt' does not match body value 'right-prompt'", + }, + { + name: "method value is case-sensitive", + version: minVersionForStandardHeaders, + methodHeader: "TOOLS/CALL", + nameHeader: "my-tool", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantErr: true, + wantErrContain: "Mcp-Method header value 'TOOLS/CALL' does not match body value 'tools/call'", + }, + { + name: "valid tools/call", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + nameHeader: "my-tool", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantErr: false, + }, + { + name: "valid resources/read", + version: minVersionForStandardHeaders, + methodHeader: "resources/read", + nameHeader: "file:///info.txt", + msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///info.txt"})}, + wantErr: false, + }, + { + name: "valid prompts/get", + version: minVersionForStandardHeaders, + methodHeader: "prompts/get", + nameHeader: "code_review", + msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: "code_review"})}, + wantErr: false, + }, + { + name: "Base64-encoded Mcp-Name is decoded before comparison", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + nameHeader: base64Prefix + base64.StdEncoding.EncodeToString([]byte("café-tool")) + base64Suffix, + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "café-tool"})}, + wantErr: false, + }, + { + name: "Mcp-Name with invalid Base64 encoding", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + nameHeader: base64Prefix + "not valid base64!" + base64Suffix, + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantErr: true, + wantErrContain: "invalid Base64 encoding", + }, + { + name: "valid initialize (no name needed)", + version: minVersionForStandardHeaders, + methodHeader: "initialize", + msg: &jsonrpc.Request{Method: "initialize", Params: mustMarshal(&InitializeParams{ProtocolVersion: minVersionForStandardHeaders})}, + wantErr: false, + }, + { + name: "valid notification (no name needed)", + version: minVersionForStandardHeaders, + methodHeader: "notifications/initialized", + msg: &jsonrpc.Request{Method: "notifications/initialized"}, + wantErr: false, + }, + { + name: "tool name with hyphen", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + nameHeader: "my-tool-name", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool-name"})}, + wantErr: false, + }, + { + name: "tool name with underscore", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + nameHeader: "my_tool_name", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my_tool_name"})}, + wantErr: false, + }, + { + name: "resource URI with special chars", + version: minVersionForStandardHeaders, + methodHeader: "resources/read", + nameHeader: "file:///path/to/file%20name.txt", + msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///path/to/file%20name.txt"})}, + wantErr: false, + }, + { + name: "resource URI with query string", + version: minVersionForStandardHeaders, + methodHeader: "resources/read", + nameHeader: "https://example.com/resource?id=123", + msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "https://example.com/resource?id=123"})}, + wantErr: false, + }, + { + name: "response message is ignored", + version: minVersionForStandardHeaders, + msg: &jsonrpc.Response{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := http.Header{} + if tt.version != "" { + header.Set(protocolVersionHeader, tt.version) + } + if tt.methodHeader != "" { + header.Set(methodHeader, tt.methodHeader) + } + if tt.nameHeader != "" { + header.Set(nameHeader, tt.nameHeader) + } + + err := validateMcpHeaders(header, tt.msg, nil) + if tt.wantErr { + if err == nil { + t.Fatalf("validateMcpHeaders() = nil, want error containing %q", tt.wantErrContain) + } + if !strings.Contains(err.Error(), tt.wantErrContain) { + t.Errorf("validateMcpHeaders() error = %q, want substring %q", err.Error(), tt.wantErrContain) + } + } else if err != nil { + t.Errorf("validateMcpHeaders() = %v, want nil", err) + } + }) + } +} + +func TestValidateToolParamHeaders(t *testing.T) { + tests := []struct { + name string + tool *Tool + wantErr bool + wantErrSub string + }{ + { + name: "valid tool with x-mcp-header", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Region", + }, + }, + }, + }, + }, + { + name: "tool with no x-mcp-header annotations", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{"type": "string"}, + }, + }, + }, + }, + { + name: "empty x-mcp-header value", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "non-empty string", + }, + { + name: "x-mcp-header with space", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "My Region", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "invalid character", + }, + { + name: "x-mcp-header with colon", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Region:Primary", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "invalid character", + }, + { + name: "x-mcp-header with non-ASCII", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Région", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "invalid character", + }, + { + name: "x-mcp-header with separator char (parens) is invalid per RFC 9110", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "X-(Region)", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "invalid character", + }, + { + name: "x-mcp-header with equals sign is invalid per RFC 9110", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Region=1", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "invalid character", + }, + { + name: "x-mcp-header with all tchar specials is valid", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "!#$%&'*+-.^_`|~aZ0", + }, + }, + }, + }, + }, + { + name: "duplicate header names same case", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "a": map[string]any{"type": "string", "x-mcp-header": "Region"}, + "b": map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + }, + }, + wantErr: true, + wantErrSub: "duplicate", + }, + { + name: "duplicate header names different case", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "a": map[string]any{"type": "string", "x-mcp-header": "Region"}, + "b": map[string]any{"type": "string", "x-mcp-header": "REGION"}, + }, + }, + }, + wantErr: true, + wantErrSub: "duplicate", + }, + { + name: "x-mcp-header on array type", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "items": map[string]any{ + "type": "array", + "x-mcp-header": "Items", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "primitive types", + }, + { + name: "x-mcp-header on object type", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "nested": map[string]any{ + "type": "object", + "x-mcp-header": "Nested", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "primitive types", + }, + { + name: "x-mcp-header on number type is invalid", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{ + "type": "number", + "x-mcp-header": "Count", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "primitive types", + }, + { + name: "x-mcp-header on integer type is valid", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{ + "type": "integer", + "x-mcp-header": "Count", + }, + }, + }, + }, + }, + { + name: "x-mcp-header on boolean type is valid", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "flag": map[string]any{ + "type": "boolean", + "x-mcp-header": "Flag", + }, + }, + }, + }, + }, + { + name: "x-mcp-header on nested property inside object is valid", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Region", + }, + }, + }, + }, + }, + }, + }, + { + name: "x-mcp-header on deeply nested property is valid", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "outer": map[string]any{ + "type": "object", + "properties": map[string]any{ + "inner": map[string]any{ + "type": "object", + "properties": map[string]any{ + "value": map[string]any{ + "type": "string", + "x-mcp-header": "Value", + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "duplicate header names across nesting levels", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "duplicate", + }, + { + name: "object property without nested x-mcp-header is valid", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + }, + }, + }, + "flag": map[string]any{ + "type": "boolean", + "x-mcp-header": "Flag", + }, + }, + }, + }, + }, + { + name: "jsonschema.Schema valid x-mcp-header", + tool: &Tool{ + Name: "test", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "region": { + Type: "string", + Extra: map[string]any{"x-mcp-header": "Region"}, + }, + }, + }, + }, + }, + { + name: "jsonschema.Schema x-mcp-header on array type", + tool: &Tool{ + Name: "test", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "items": { + Type: "array", + Extra: map[string]any{"x-mcp-header": "Items"}, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "primitive types", + }, + { + name: "jsonschema.Schema nested x-mcp-header is valid", + tool: &Tool{ + Name: "test", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "config": { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "region": { + Type: "string", + Extra: map[string]any{"x-mcp-header": "Region"}, + }, + }, + }, + }, + }, + }, + }, + { + name: "json.RawMessage valid x-mcp-header", + tool: &Tool{ + Name: "test", + InputSchema: json.RawMessage(`{"type":"object","properties":{"region":{"type":"string","x-mcp-header":"Region"}}}`), + }, + }, + { + name: "json.RawMessage x-mcp-header on object type", + tool: &Tool{ + Name: "test", + InputSchema: json.RawMessage(`{"type":"object","properties":{"nested":{"type":"object","x-mcp-header":"Nested"}}}`), + }, + wantErr: true, + wantErrSub: "primitive types", + }, + { + name: "nested invalid header name is rejected", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Bad Header", // contains a space + }, + }, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "invalid character", + }, + { + name: "nested x-mcp-header on number type is rejected", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{ + "type": "number", + "x-mcp-header": "Count", + }, + }, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "primitive types", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateParamHeaderAnnotations(tt.tool) + if tt.wantErr { + if err == nil { + t.Fatal("validateToolParamHeaders() = nil, want error") + } + if !strings.Contains(err.Error(), tt.wantErrSub) { + t.Errorf("error = %q, want substring %q", err.Error(), tt.wantErrSub) + } + } else if err != nil { + t.Errorf("validateToolParamHeaders() = %v, want nil", err) + } + }) + } +} + +func TestFilterValidTools(t *testing.T) { + valid := &Tool{ + Name: "valid", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + }, + } + invalid := &Tool{ + Name: "invalid", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": ""}, + }, + }, + } + noAnnotation := &Tool{ + Name: "plain", + InputSchema: map[string]any{"type": "object", "properties": map[string]any{"q": map[string]any{"type": "string"}}}, + } + nestedValid := &Tool{ + Name: "nested-valid", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "tenant": map[string]any{"type": "string", "x-mcp-header": "TenantId"}, + }, + }, + }, + }, + } + + validJsonSchema := &Tool{ + Name: "valid-jsonschema", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "region": { + Type: "string", + Extra: map[string]any{"x-mcp-header": "Region"}, + }, + }, + }, + } + invalidJsonSchema := &Tool{ + Name: "invalid-jsonschema", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "items": { + Type: "array", + Extra: map[string]any{"x-mcp-header": "Items"}, + }, + }, + }, + } + + result := filterValidTools(nil, []*Tool{valid, invalid, noAnnotation, nestedValid, validJsonSchema, invalidJsonSchema}) + if len(result) != 4 { + t.Fatalf("filterValidTools returned %d tools, want 4", len(result)) + } + if result[0].Name != "valid" || result[1].Name != "plain" || result[2].Name != "nested-valid" || result[3].Name != "valid-jsonschema" { + t.Errorf("filterValidTools returned [%s, %s, %s, %s], want [valid, plain, nested-valid, valid-jsonschema]", + result[0].Name, result[1].Name, result[2].Name, result[3].Name) + } + + // Regression for #1119: a malformed "tools":[null] response must not panic; + // nil tools are treated as invalid and excluded. + if got := filterValidTools(nil, []*Tool{nil}); len(got) != 0 { + t.Errorf("filterValidTools([nil]) returned %d tools, want 0", len(got)) + } + if got := filterValidTools(nil, []*Tool{nil, valid, nil}); len(got) != 1 || got[0].Name != "valid" { + t.Errorf("filterValidTools([nil, valid, nil]) = %d tools, want [valid]", len(got)) + } +} + +func TestSetStandardHeadersWithParamHeaders(t *testing.T) { + toolSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Region", + }, + "query": map[string]any{ + "type": "string", + }, + "priority": map[string]any{ + "type": "string", + "x-mcp-header": "Priority", + }, + }, + } + tool := &Tool{Name: "execute_sql", InputSchema: toolSchema} + + tests := []struct { + name string + tool *Tool + params any + wantHeaders map[string]string + }{ + { + name: "sets param headers from arguments", + tool: tool, + params: &CallToolParams{ + Name: "execute_sql", + Arguments: map[string]any{"region": "us-west1", "query": "SELECT 1", "priority": "high"}, + }, + wantHeaders: map[string]string{ + "Mcp-Param-Region": "us-west1", + "Mcp-Param-Priority": "high", + }, + }, + { + name: "omits header when argument is missing", + tool: tool, + params: &CallToolParams{ + Name: "execute_sql", + Arguments: map[string]any{"query": "SELECT 1"}, + }, + wantHeaders: nil, + }, + { + name: "omits header when argument is null", + tool: tool, + params: &CallToolParams{ + Name: "execute_sql", + Arguments: map[string]any{"region": nil, "query": "SELECT 1"}, + }, + wantHeaders: nil, + }, + { + name: "encodes non-ASCII value", + tool: tool, + params: &CallToolParams{ + Name: "execute_sql", + Arguments: map[string]any{"region": "日本", "query": "SELECT 1"}, + }, + wantHeaders: map[string]string{ + "Mcp-Param-Region": "=?base64?5pel5pys?=", + }, + }, + { + name: "handles boolean argument", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "flag": map[string]any{"type": "boolean", "x-mcp-header": "Flag"}, + }, + }, + }, + params: &CallToolParams{ + Name: "test", + Arguments: map[string]any{"flag": true}, + }, + wantHeaders: map[string]string{ + "Mcp-Param-Flag": "true", + }, + }, + { + name: "handles integer argument", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{"type": "integer", "x-mcp-header": "Count"}, + }, + }, + }, + params: &CallToolParams{ + Name: "test", + Arguments: map[string]any{"count": float64(42)}, + }, + wantHeaders: map[string]string{ + "Mcp-Param-Count": "42", + }, + }, + { + name: "out-of-range integer argument produces no header", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{"type": "integer", "x-mcp-header": "Count"}, + }, + }, + }, + params: &CallToolParams{ + Name: "test", + Arguments: map[string]any{"count": float64(maxSafeInteger) + 2}, + }, + wantHeaders: nil, + }, + { + name: "no tool in extra does not add param headers", + tool: nil, + params: &CallToolParams{ + Name: "execute_sql", + Arguments: map[string]any{"region": "us-west1"}, + }, + wantHeaders: nil, + }, + { + name: "nested arguments resolve via dotted path", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + "tenant": map[string]any{"type": "string", "x-mcp-header": "TenantId"}, + }, + }, + }, + }, + }, + params: &CallToolParams{ + Name: "test", + Arguments: map[string]any{ + "config": map[string]any{ + "region": "us-west1", + "tenant": "acme", + }, + }, + }, + wantHeaders: map[string]string{ + "Mcp-Param-Region": "us-west1", + "Mcp-Param-TenantId": "acme", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := http.Header{} + header.Set(protocolVersionHeader, minVersionForStandardHeaders) + + ctx := context.Background() + if tt.tool != nil { + ctx = context.WithValue(ctx, toolContextKey, tt.tool) + } + + msg := &jsonrpc.Request{ + Method: "tools/call", + Params: mustMarshal(tt.params), + } + + setStandardHeaders(ctx, header, msg) + + if got := header.Get(methodHeader); got != "tools/call" { + t.Errorf("MethodHeader = %q, want %q", got, "tools/call") + } + + for h, want := range tt.wantHeaders { + if got := header.Get(h); got != want { + t.Errorf("%s = %q, want %q", h, got, want) + } + } + + if got := header.Get("Mcp-Param-query"); got != "" { + t.Errorf("non-annotated param got header: Mcp-Param-query = %q", got) + } + }) + } +} + +func TestExtractToolParamHeaders(t *testing.T) { + tests := []struct { + name string + tool *Tool + want map[string]string + }{ + { + name: "extracts x-mcp-header annotations", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + "query": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string", "x-mcp-header": "TenantId"}, + }, + }, + }, + want: map[string]string{"region": "Region", "tenant_id": "TenantId"}, + }, + { + name: "returns nil for tool without properties", + tool: &Tool{Name: "test", InputSchema: map[string]any{"type": "object"}}, + want: nil, + }, + { + name: "returns nil for non-map schema", + tool: &Tool{Name: "test", InputSchema: "not a map"}, + want: nil, + }, + { + name: "returns nil when no annotations", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"q": map[string]any{"type": "string"}}, + }, + }, + want: nil, + }, + { + name: "jsonschema.Schema with x-mcp-header in Extra", + tool: &Tool{ + Name: "test", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "region": { + Type: "string", + Extra: map[string]any{"x-mcp-header": "Region"}, + }, + "query": {Type: "string"}, + }, + }, + }, + want: map[string]string{"region": "Region"}, + }, + { + name: "json.RawMessage with x-mcp-header", + tool: &Tool{ + Name: "test", + InputSchema: json.RawMessage(`{"type":"object","properties":{"region":{"type":"string","x-mcp-header":"Region"},"query":{"type":"string"}}}`), + }, + want: map[string]string{"region": "Region"}, + }, + { + name: "jsonschema.Schema without x-mcp-header", + tool: &Tool{ + Name: "test", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "query": {Type: "string"}, + }, + }, + }, + want: nil, + }, + { + name: "nested x-mcp-header annotations produce path-slice bindings", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "tenant": map[string]any{"type": "string", "x-mcp-header": "TenantId"}, + "deep": map[string]any{ + "type": "object", + "properties": map[string]any{ + "flag": map[string]any{"type": "boolean", "x-mcp-header": "DeepFlag"}, + }, + }, + }, + }, + }, + }, + }, + want: map[string]string{ + "region": "Region", + "config.tenant": "TenantId", + "config.deep.flag": "DeepFlag", + }, + }, + { + name: "property name containing a dot is preserved in path", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "user.id": map[string]any{"type": "string", "x-mcp-header": "UserId"}, + }, + }, + }, + want: map[string]string{"user.id": "UserId"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractParamHeaderAnnotations(tt.tool) + if tt.want == nil { + if got != nil { + t.Errorf("extractToolParamHeaders() = %v, want nil", got) + } + return + } + if len(got) != len(tt.want) { + t.Fatalf("extractToolParamHeaders() returned %d entries, want %d", len(got), len(tt.want)) + } + // Index returned bindings by joined-path for comparison; the + // expected map uses dotted-path keys for readability. + gotMap := make(map[string]string, len(got)) + for _, b := range got { + gotMap[strings.Join(b.Path, ".")] = b.Header + } + for k, v := range tt.want { + if gotMap[k] != v { + t.Errorf("extractToolParamHeaders()[%q] = %q, want %q", k, gotMap[k], v) + } + } + }) + } +} + +func TestUnmarshalPrimitive(t *testing.T) { + tests := []struct { + name string + raw string + want any + }{ + {"string", `"hello"`, "hello"}, + {"true", `true`, true}, + {"false", `false`, false}, + + // Integer JSON numbers are promoted to int64. + {"integer", `42`, int64(42)}, + {"integer zero", `0`, int64(0)}, + {"integer negative", `-7`, int64(-7)}, + {"integer max safe", `9007199254740991`, int64(maxSafeInteger)}, + {"integer min safe", `-9007199254740991`, int64(minSafeInteger)}, + // JSON serialization of an integer-valued float (e.g. "42.0") is + // still a valid integer at the value level and must be accepted. + {"integer-valued float", `42.0`, int64(42)}, + + // Non-integer numbers, out-of-range integers, and disallowed JSON + // kinds are rejected (return nil). + {"float with fraction", `3.14`, nil}, + {"integer above max safe", `9007199254740993`, nil}, + {"integer below min safe", `-9007199254740993`, nil}, + {"null", `null`, nil}, + {"array", `[1,2]`, nil}, + {"object", `{"a":1}`, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := unmarshalPrimitive(json.RawMessage(tt.raw)) + if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", tt.want) { + t.Errorf("unmarshalPrimitive(%s) = %v (%T), want %v (%T)", tt.raw, got, got, tt.want, tt.want) + } + }) + } +} + +func TestEncodeHeaderValue(t *testing.T) { + tests := []struct { + name string + value any + want string + wantOK bool + }{ + // Strings + {"plain ASCII", "us-west1", "us-west1", true}, + {"empty string", "", "", true}, + {"string with internal spaces", "us west 1", "us west 1", true}, + {"string with leading space", " us-west1", "=?base64?IHVzLXdlc3Qx?=", true}, + {"string with trailing space", "us-west1 ", "=?base64?dXMtd2VzdDEg?=", true}, + {"string with both spaces", " us-west1 ", "=?base64?IHVzLXdlc3QxIA==?=", true}, + {"non-ASCII", "日本語", "=?base64?5pel5pys6Kqe?=", true}, + {"mixed ASCII and non-ASCII", "Hello, 世界", "=?base64?SGVsbG8sIOS4lueVjA==?=", true}, + {"string with newline", "line1\nline2", "=?base64?bGluZTEKbGluZTI=?=", true}, + {"string with carriage return", "line1\r\nline2", "=?base64?bGluZTENCmxpbmUy?=", true}, + {"string with leading tab", "\tindented", "=?base64?CWluZGVudGVk?=", true}, + + // Sentinel pattern collisions: plain-ASCII values that match the base64 + // sentinel pattern must also be base64-encoded to avoid ambiguity. + {"sentinel collision literal", "=?base64?literal?=", "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=", true}, + {"sentinel collision empty", "=?base64??=", "=?base64?PT9iYXNlNjQ/Pz0=?=", true}, + // Uppercase sentinel does NOT collide (case-sensitive markers). + {"uppercase pseudo-sentinel passes through", "=?BASE64?abc?=", "=?BASE64?abc?=", true}, + + {"integer", int64(42), "42", true}, + {"integer zero", int64(0), "0", true}, + {"integer negative", int64(-7), "-7", true}, + {"integer max safe", int64(maxSafeInteger), "9007199254740991", true}, + {"integer min safe", int64(minSafeInteger), "-9007199254740991", true}, + + // Booleans + {"true", true, "true", true}, + {"false", false, "false", true}, + + {"raw float64 rejected", float64(42), "", false}, + {"nil", nil, "", false}, + {"slice", []string{"a"}, "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := encodeHeaderValue(tt.value) + if ok != tt.wantOK { + t.Fatalf("encodeHeaderValue(%v) ok = %v, want %v", tt.value, ok, tt.wantOK) + } + if got != tt.want { + t.Errorf("encodeHeaderValue(%v) = %q, want %q", tt.value, got, tt.want) + } + }) + } +} + +func TestDecodeHeaderValue(t *testing.T) { + tests := []struct { + name string + input string + want string + wantOK bool + }{ + {"plain value", "us-west1", "us-west1", true}, + {"empty value", "", "", true}, + {"valid base64", "=?base64?SGVsbG8=?=", "Hello", true}, + {"non-ASCII decoded", "=?base64?5pel5pys6Kqe?=", "日本語", true}, + {"leading space decoded", "=?base64?IHVzLXdlc3Qx?=", " us-west1", true}, + // Per SEP-2243, the base64 sentinel markers are case-sensitive: an + // uppercase prefix is treated as a literal value, not a base64 marker. + {"uppercase prefix is literal", "=?BASE64?SGVsbG8=?=", "=?BASE64?SGVsbG8=?=", true}, + {"mixed case prefix is literal", "=?Base64?SGVsbG8=?=", "=?Base64?SGVsbG8=?=", true}, + {"invalid base64 chars", "=?base64?SGVs!!!bG8=?=", "", false}, + // Missing prefix or suffix: treated as literal values, not base64 + {"missing prefix", "SGVsbG8=", "SGVsbG8=", true}, + {"missing suffix", "=?base64?SGVsbG8=", "=?base64?SGVsbG8=", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := decodeHeaderValue(tt.input) + if ok != tt.wantOK { + t.Fatalf("decodeHeaderValue(%q) ok = %v, want %v", tt.input, ok, tt.wantOK) + } + if got != tt.want { + t.Errorf("decodeHeaderValue(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestEncodeDecodeRoundTrip(t *testing.T) { + values := []string{ + "us-west1", + "", + " leading", + "trailing ", + "Hello, 世界", + "line1\nline2", + "\ttab", + "=?base64?literal?=", // sentinel-pattern collision (SEP-2243) + } + for _, v := range values { + encoded, ok := encodeHeaderValue(v) + if !ok { + t.Fatalf("encodeHeaderValue(%q) failed", v) + } + decoded, ok := decodeHeaderValue(encoded) + if !ok { + t.Fatalf("decodeHeaderValue(%q) failed", encoded) + } + if decoded != v { + t.Errorf("round-trip failed: %q -> %q -> %q", v, encoded, decoded) + } + } +} + +// TestValidateParamHeaders_IntegerComparison verifies server-side validation +// of integer x-mcp-header parameters per SEP-2243. +func TestValidateParamHeaders_IntegerComparison(t *testing.T) { + tool := &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{"type": "integer", "x-mcp-header": "Count"}, + }, + }, + } + tests := []struct { + name string + headerVal string + bodyArg any + wantErr bool + }{ + // Canonical decimal form matches. + {"integer matches integer", "42", float64(42), false}, + {"integer header matches integer-valued float body", "42", float64(42.0), false}, + {"negative integer matches", "-7", float64(-7), false}, + {"large safe integer matches", "1000000000000", float64(1e12), false}, + + {"non-canonical '42.0' header matches integer body", "42.0", float64(42), false}, + {"scientific notation header matches integer body", "1e2", float64(100), false}, + {"negative non-canonical header matches", "-7.0", float64(-7), false}, + + // Genuine mismatches and invalid forms are still rejected. + {"different integers do not match", "42", float64(43), true}, + {"fractional header against integer body", "3.14", float64(3), true}, + {"non-numeric header fails", "not-a-number", float64(42), true}, + {"header outside safe range against integer body", "9007199254740993", float64(42), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := http.Header{} + header.Set(paramHeaderPrefix+"Count", tt.headerVal) + args := map[string]any{"count": tt.bodyArg} + msg := &jsonrpc.Request{ + Method: "tools/call", + Params: mustMarshal(&CallToolParams{Name: "test", Arguments: args}), + } + err := validateParamHeaders(header, msg, tool) + if tt.wantErr && err == nil { + t.Errorf("validateParamHeaders() = nil, want error") + } + if !tt.wantErr && err != nil { + t.Errorf("validateParamHeaders() = %v, want nil", err) + } + }) + } +} + +// TestValidateParamHeaders_NestedArguments verifies that the server-side +// validation can look up nested arguments via the dotted path produced by +// extractParamHeaderAnnotations. +func TestValidateParamHeaders_NestedArguments(t *testing.T) { + tool := &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + }, + }, + }, + } + header := http.Header{} + header.Set(paramHeaderPrefix+"Region", "us-west1") + args := map[string]any{ + "config": map[string]any{"region": "us-west1"}, + } + msg := &jsonrpc.Request{ + Method: "tools/call", + Params: mustMarshal(&CallToolParams{Name: "test", Arguments: args}), + } + if err := validateParamHeaders(header, msg, tool); err != nil { + t.Errorf("validateParamHeaders() = %v, want nil", err) + } + + // Mismatched nested value should fail. + args["config"].(map[string]any)["region"] = "eu-west1" + msg.Params = mustMarshal(&CallToolParams{Name: "test", Arguments: args}) + if err := validateParamHeaders(header, msg, tool); err == nil { + t.Error("validateParamHeaders() = nil, want error for mismatched nested value") + } +} + +// TestMcpNameHeaderEncodingRoundTrip verifies that a name which is not +// header-safe is Base64-wrapped by the client per the 2026-07-28 transport +// spec, and that the server's validation accepts what the client produced. +func TestMcpNameHeaderEncodingRoundTrip(t *testing.T) { + tests := []struct { + name string + msg *jsonrpc.Request + wantHeader string + }{ + { + name: "ascii-safe tool name is sent as-is", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantHeader: "my-tool", + }, + { + name: "non-ascii tool name is base64 encoded", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "café-tool"})}, + wantHeader: encodeBase64("café-tool"), + }, + { + name: "prompt name with a leading space is base64 encoded", + msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: " leading-space"})}, + wantHeader: encodeBase64(" leading-space"), + }, + { + name: "resource uri with a control character is base64 encoded", + msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///a\tb.txt"})}, + wantHeader: encodeBase64("file:///a\tb.txt"), + }, + { + name: "name that looks like the sentinel is base64 encoded", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "=?base64?abc?="})}, + wantHeader: encodeBase64("=?base64?abc?="), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := http.Header{} + header.Set(protocolVersionHeader, minVersionForStandardHeaders) + setStandardHeaders(context.Background(), header, tt.msg) + + if got := header.Get(nameHeader); got != tt.wantHeader { + t.Errorf("Mcp-Name = %q, want %q", got, tt.wantHeader) + } + + // The server must accept the headers the client just generated. + if err := validateMcpHeaders(header, tt.msg, nil); err != nil { + t.Errorf("validateMcpHeaders() on client-generated headers = %v, want nil", err) + } + }) + } +} diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index d6eb8e9..7af9dff 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -2,6 +2,8 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +//lint:file-ignore SA1019 tests exercise deprecated SEP-2577 APIs (roots, sampling, logging). + package mcp import ( @@ -180,7 +182,11 @@ func TestStreamableTransports(t *testing.T) { if sid == "" { t.Fatalf("empty session ID") } - if g, w := session.mcpConn.(*streamableClientConn).initializedResult.ProtocolVersion, latestProtocolVersion; g != w { + // Stateful HTTP servers reject 2026-07-28 (which requires + // StreamableHTTPOptions.Stateless = true), so the client falls + // back to the legacy initialize handshake which explicitly + // requests 2025-11-25. + if g, w := session.mcpConn.(*streamableClientConn).initializedResult.ProtocolVersion, protocolVersion20251125; g != w { t.Fatalf("got protocol version %q, want %q", g, w) } @@ -198,7 +204,10 @@ func TestStreamableTransports(t *testing.T) { if g := session.ID(); g != sid { t.Errorf("session ID: got %q, want %q", g, sid) } - if g, w := lastHeader.Get(protocolVersionHeader), latestProtocolVersion; g != w { + // Stateful HTTP requires the legacy protocol (see the earlier + // initializeResult assertion); the client sends the matching + // MCP-Protocol-Version header. + if g, w := lastHeader.Get(protocolVersionHeader), protocolVersion20251125; g != w { t.Errorf("got protocol version header %q, want %q", g, w) } want := &CallToolResult{ @@ -279,8 +288,14 @@ func TestStreamableConcurrentHandling(t *testing.T) { t.Errorf("CallTool failed: %v", err) return } - if got := int(res.StructuredContent.(map[string]any)["Count"].(float64)); got != i { - t.Errorf("got count %d, want %d", got, i) + number, ok := res.StructuredContent.(map[string]any)["Count"].(json.Number) + if !ok { + t.Errorf("count type = %T, want json.Number", res.StructuredContent.(map[string]any)["Count"]) + continue + } + got, err := number.Int64() + if err != nil || got != int64(i) { + t.Errorf("got count %d, %v; want %d", got, err, i) } } }) @@ -330,10 +345,15 @@ func TestStreamableServerShutdown(t *testing.T) { opts.KeepAlive = 50 * time.Millisecond } client := NewClient(testImpl, &opts) + // Pin to 2025-11-25: this test relies on the legacy hanging GET + // (stateful case) or Ping-based keepalive (stateless case) for + // the client to notice the server shutdown. Both mechanisms are + // removed in 2026-07-28 (SEP-2575), so the new protocol would + // leave the client waiting forever. clientSession, err := client.Connect(ctx, &StreamableClientTransport{ Endpoint: httpServer.URL, MaxRetries: -1, // avoid slow tests during exponential retries - }, nil) + }, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -363,6 +383,34 @@ func TestStreamableServerShutdown(t *testing.T) { } } +// TestStreamableStatelessKeepaliveRace verifies that there is no data race between +// ServerSession.startKeepalive and ServerSession.Close in stateless servers. +func TestStreamableStatelessKeepaliveRace(t *testing.T) { + ctx := context.Background() + server := NewServer(testImpl, &ServerOptions{KeepAlive: time.Hour}) + AddTool(server, &Tool{Name: "greet"}, sayHi) + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + for range 50 { + cs, err := NewClient(testImpl, nil).Connect(ctx, &StreamableClientTransport{ + Endpoint: httpServer.URL, + }, nil) + if err != nil { + t.Fatalf("NewClient() failed: %v", err) + } + _, _ = cs.CallTool(ctx, &CallToolParams{ + Name: "greet", + Arguments: map[string]any{"Name": "world"}, + }) + _ = cs.Close() + } +} + // TestClientReplay verifies that the client can recover from a mid-stream // network failure and receive replayed messages (if replay is configured). It // uses a proxy that is killed and restarted to simulate a recoverable network @@ -580,7 +628,7 @@ func TestStreamableServerDisconnect(t *testing.T) { }) clientSession, err := client.Connect(ctx, &StreamableClientTransport{ Endpoint: httpServer.URL, - }, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + }, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -650,7 +698,12 @@ func TestServerTransportCleanup(t *testing.T) { } } client.AddReceivingMiddleware(pingMiddleware) - clientSession, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, nil) + // Pin to 2025-11-25: this test counts server-side sessions, but + // the 2026-07-28 client probes server/discover first against a + // stateful HTTP server (which rejects the new protocol), opening + // an extra HTTP connection and therefore an extra session. Pinning + // to 2025-11-25 skips the discover probe. + clientSession, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -741,6 +794,21 @@ func req(id int64, method string, params any) *jsonrpc.Request { return r } +func completeCallToolResult() *CallToolResult { + r := &CallToolResult{Content: []Content{}} + r.resultType = resultTypeComplete + return r +} + +// completeCallToolResultWithServerInfo returns a completeCallToolResult +// annotated with the auto-populated [MetaKeyServerInfo] entry that servers +// add to every new-protocol response. +func completeCallToolResultWithServerInfo(impl *Implementation) *CallToolResult { + r := completeCallToolResult() + r.Meta = Meta{MetaKeyServerInfo: impl} + return r +} + func resp(id int64, result any, err error) *jsonrpc.Response { return &jsonrpc.Response{ ID: jsonrpc2.Int64ID(id), @@ -862,6 +930,14 @@ func TestStreamableServerTransport(t *testing.T) { wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(5, &CallToolResult{Content: []Content{}}, nil)}, }, + { + // Correct Content-Type with parameters should pass. + method: "POST", + headers: http.Header{"Content-Type": {"application/json; charset=utf-8"}}, + messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Name: "tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(5, &CallToolResult{Content: []Content{}}, nil)}, + }, }, wantSessions: 1, }, @@ -1470,11 +1546,11 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string, newSessionID := resp.Header.Get(sessionIDHeader) - contentType := resp.Header.Get("Content-Type") + contentType := baseMediaType(resp.Header.Get("Content-Type")) var respBody []byte - if strings.HasPrefix(contentType, "text/event-stream") { + if contentType == "text/event-stream" { r := readerInto{resp.Body, new(bytes.Buffer)} - for evt, err := range scanEvents(r) { + for evt, err := range scanEventsLimited(r, DefaultMaxEventSize) { if err != nil { return newSessionID, resp.StatusCode, nil, fmt.Errorf("reading events: %v", err) } @@ -1489,7 +1565,7 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string, } } respBody = r.w.Bytes() - } else if strings.HasPrefix(contentType, "application/json") { + } else if contentType == "application/json" { data, err := io.ReadAll(resp.Body) if err != nil { return newSessionID, resp.StatusCode, nil, fmt.Errorf("reading json body: %w", err) @@ -1592,7 +1668,7 @@ func TestStreamableStateless(t *testing.T) { Logging: &LoggingCapabilities{}, Tools: &ToolCapabilities{ListChanged: true}, }, - ProtocolVersion: latestProtocolVersion, + ProtocolVersion: protocolVersion20251125, ServerInfo: &Implementation{Name: "test", Version: "v1.0.0"}, }, nil) // This version of sayHi expects @@ -1667,40 +1743,16 @@ func TestStreamableStateless(t *testing.T) { } } - sessionlessHandler := NewStreamableHTTPHandler(func(*http.Request) *Server { - // Return a stateless server which never assigns a session ID. - server := NewServer(testImpl, &ServerOptions{ - GetSessionID: func() string { return "" }, - }) - AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi) - return server - }, &StreamableHTTPOptions{ - Stateless: true, - }) - - // First, test the "sessionless" stateless mode, where there is no session ID. - t.Run("sessionless", func(t *testing.T) { - testStreamableHandler(t, sessionlessHandler, requests) - testClientCompatibility(t, sessionlessHandler) - }) - - // Next, test the default stateless mode, where session IDs are permitted. - // - // This can be used by tools to look up application state preserved across - // subsequent requests. - requests[0].wantSessionID = true // now expect a session ID for initialize statelessHandler := NewStreamableHTTPHandler(func(*http.Request) *Server { - // Return a server with default options which should assign a random session ID. server := NewServer(testImpl, nil) AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi) return server }, &StreamableHTTPOptions{ Stateless: true, }) - t.Run("stateless", func(t *testing.T) { - testStreamableHandler(t, statelessHandler, requests) - testClientCompatibility(t, sessionlessHandler) - }) + + testStreamableHandler(t, statelessHandler, requests) + testClientCompatibility(t, statelessHandler) } func textContent(t *testing.T, res *CallToolResult) string { @@ -1745,7 +1797,7 @@ func TestSessionHijackingPrevention(t *testing.T) { req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set("Authorization", "Bearer "+userID) if sessionID != "" { - req.Header.Set("Mcp-Session-Id", sessionID) + req.Header.Set(sessionIDHeader, sessionID) } resp, err := http.DefaultClient.Do(req) if err != nil { @@ -1766,7 +1818,7 @@ func TestSessionHijackingPrevention(t *testing.T) { body, _ := io.ReadAll(resp.Body) t.Fatalf("initialize failed with status %d: %s", resp.StatusCode, body) } - sessionID := resp.Header.Get("Mcp-Session-Id") + sessionID := resp.Header.Get(sessionIDHeader) if sessionID == "" { t.Fatal("no session ID in response") } @@ -1879,6 +1931,489 @@ func TestStreamableGET(t *testing.T) { } } +// TestStreamableMcpHeaderValidation tests the server-side Mcp-Method, +// Mcp-Name, and Mcp-Param header validation through the full HTTP handler, +// as specified in SEP-2243. +func TestStreamableMcpHeaderValidation(t *testing.T) { + // Temporarily register the future version so the handler accepts it. + + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + server.AddTool( + &Tool{Name: "my-tool", InputSchema: &jsonschema.Schema{Type: "object"}}, + func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{}, nil + }) + server.AddTool( + &Tool{ + Name: "execute_sql", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Region", + }, + "query": map[string]any{ + "type": "string", + }, + }, + }, + }, + func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{}, nil + }) + + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ + Stateless: true, + }) + defer handler.closeAll() + + testMeta := Meta{ + MetaKeyProtocolVersion: minVersionForStandardHeaders, + MetaKeyClientInfo: map[string]any{"name": "testClient", "version": "v1.0.0"}, + MetaKeyClientCapabilities: map[string]any{}, + } + serverImpl := &Implementation{Name: "testServer", Version: "v1.0.0"} + + testStreamableHandler(t, handler, []streamableRequest{ + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {minVersionForStandardHeaders}, + methodHeader: {"tools/call"}, + nameHeader: {"my-tool"}, + }, + messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(2, completeCallToolResultWithServerInfo(serverImpl), nil)}, + }, + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {minVersionForStandardHeaders}, + methodHeader: {"prompts/get"}, + nameHeader: {"my-tool"}, + }, + messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, + wantStatusCode: http.StatusBadRequest, + wantBodyContaining: "Mcp-Method header value", + }, + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {minVersionForStandardHeaders}, + methodHeader: {"tools/call"}, + nameHeader: {"wrong-tool"}, + }, + messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, + wantStatusCode: http.StatusBadRequest, + wantBodyContaining: "Mcp-Name header value", + }, + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {minVersionForStandardHeaders}, + methodHeader: {"TOOLS/CALL"}, + nameHeader: {"my-tool"}, + }, + messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, + wantStatusCode: http.StatusBadRequest, + wantBodyContaining: "Mcp-Method header value", + }, + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {minVersionForStandardHeaders}, + methodHeader: {"tools/call"}, + nameHeader: {"my-tool"}, + }, + messages: []jsonrpc.Message{req(6, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(6, completeCallToolResultWithServerInfo(serverImpl), nil)}, + }, + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {minVersionForStandardHeaders}, + methodHeader: {"tools/call"}, + nameHeader: {"execute_sql"}, + paramHeaderPrefix + "Region": {"us-west1"}, + }, + messages: []jsonrpc.Message{req(7, "tools/call", &CallToolParams{ + Meta: testMeta, + Name: "execute_sql", + Arguments: map[string]any{"region": "us-west1", "query": "SELECT 1"}, + })}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(7, completeCallToolResultWithServerInfo(serverImpl), nil)}, + }, + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {minVersionForStandardHeaders}, + methodHeader: {"tools/call"}, + nameHeader: {"execute_sql"}, + paramHeaderPrefix + "Region": {"eu-central1"}, + }, + messages: []jsonrpc.Message{req(8, "tools/call", &CallToolParams{ + Meta: testMeta, + Name: "execute_sql", + Arguments: map[string]any{"region": "us-west1"}, + })}, + wantStatusCode: http.StatusBadRequest, + wantBodyContaining: "header mismatch", + }, + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {minVersionForStandardHeaders}, + methodHeader: {"tools/call"}, + nameHeader: {"execute_sql"}, + }, + messages: []jsonrpc.Message{req(9, "tools/call", &CallToolParams{ + Meta: testMeta, + Name: "execute_sql", + Arguments: map[string]any{"region": "us-west1"}, + })}, + wantStatusCode: http.StatusBadRequest, + wantBodyContaining: "missing", + }, + }) +} + +// TestStreamableMcpHeaderValidationErrorFormat verifies that header +// validation errors return a JSON-RPC error with code -32020 and +// Content-Type application/json, per SEP-2243. +func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { + + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + server.AddTool( + &Tool{Name: "my-tool", InputSchema: &jsonschema.Schema{Type: "object"}}, + func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{}, nil + }) + + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ + Stateless: true, + }) + defer handler.closeAll() + + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + // Use the MCP client with a custom RoundTripper to inject a bad header. + var toolCallResp *http.Response + var toolCallRespBody []byte + + customClient := &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + var originalMethodHeader string + if req.Header.Get(methodHeader) == "tools/call" { + originalMethodHeader = req.Header.Get(methodHeader) + req.Header.Set(methodHeader, "wrong-method") + } + resp, err := http.DefaultTransport.RoundTrip(req) + if err == nil && originalMethodHeader == "tools/call" { + toolCallResp = resp + toolCallRespBody, _ = io.ReadAll(resp.Body) + } + return resp, err + }), + } + + clientTransport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + HTTPClient: customClient, + } + + client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) + ctx := context.Background() + session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{ProtocolVersion: minVersionForStandardHeaders}) + if err != nil { + t.Fatal(err) + } + defer session.Close() + + _, err = session.CallTool(ctx, &CallToolParams{Name: "my-tool"}) + // We expect an error because the server should reject it. + if err == nil { + t.Error("CallTool succeeded unexpectedly") + } + + if toolCallResp == nil { + t.Fatal("no response captured") + } + + // Verify HTTP status code. + if toolCallResp.StatusCode != http.StatusBadRequest { + t.Errorf("status code = %d, want %d", toolCallResp.StatusCode, http.StatusBadRequest) + } + + // Verify Content-Type. + if baseMediaType(toolCallResp.Header.Get("Content-Type")) != "application/json" { + t.Errorf("Content-Type = %q, want %q", baseMediaType(toolCallResp.Header.Get("Content-Type")), "application/json") + } + + // Verify JSON-RPC error body contains error code -32020. + msg, err := jsonrpc2.DecodeMessage(toolCallRespBody) + if err != nil { + t.Fatalf("failed to decode message: %v", err) + } + resp, ok := msg.(*jsonrpc2.Response) + if !ok { + t.Fatalf("expected *jsonrpc2.Response, got %T", msg) + } + var wireErr *jsonrpc2.WireError + if !errors.As(resp.Error, &wireErr) { + t.Fatalf("expected *jsonrpc2.WireError, got %T", resp.Error) + } + if wireErr.Code != CodeHeaderMismatch { + t.Errorf("wireErr.Code = %d, want %d", wireErr.Code, CodeHeaderMismatch) + } + if !strings.Contains(wireErr.Message, "Mcp-Method header value") { + t.Errorf("wireErr.Message = %q, want it to contain %q", wireErr.Message, "Mcp-Method header value") + } +} + +// TestStreamableMcpHeaderVersionGating verifies that header validation +// is skipped for protocol versions older than minVersionForStandardHeaders. +func TestStreamableMcpHeaderVersionGating(t *testing.T) { + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + server.AddTool( + &Tool{Name: "my-tool", InputSchema: &jsonschema.Schema{Type: "object"}}, + func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{}, nil + }) + + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + defer handler.closeAll() + + initReq := req(1, methodInitialize, &InitializeParams{ProtocolVersion: protocolVersion20251125}) + initResp := resp(1, &InitializeResult{ + Capabilities: &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + Tools: &ToolCapabilities{ListChanged: true}, + }, + ProtocolVersion: protocolVersion20251125, + ServerInfo: &Implementation{Name: "testServer", Version: "v1.0.0"}, + }, nil) + + testStreamableHandler(t, handler, []streamableRequest{ + { + method: "POST", + messages: []jsonrpc.Message{initReq}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{initResp}, + wantSessionID: true, + }, + { + method: "POST", + headers: http.Header{protocolVersionHeader: {protocolVersion20251125}}, + messages: []jsonrpc.Message{req(0, notificationInitialized, &InitializedParams{})}, + wantStatusCode: http.StatusAccepted, + }, + // Requests with deliberately wrong MCP headers should still succeed + // because the protocol version is too old for validation. + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {protocolVersion20251125}, + methodHeader: {"wrong-method"}, + nameHeader: {"wrong-name"}, + }, + messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "my-tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, + }, + }) +} + +// TestStreamableParamHeadersClientSetsHeaders verifies that the client sets +// Mcp-Param-* headers on tool calls when the tool has x-mcp-header annotations. +func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { + + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + server.AddTool( + &Tool{ + Name: "execute_sql", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Region", + }, + "query": map[string]any{ + "type": "string", + }, + }, + }, + }, + func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil + }) + + // Install a receiving middleware on the server that opts the tools/list + // response caching by setting a positive ttlMs. + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + res, err := next(ctx, method, req) + if err == nil && method == methodListTools { + if r, ok := res.(*ListToolsResult); ok { + r.TTLMs = 60_000 + } + } + return res, err + } + }) + + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ + Stateless: true, + }) + defer handler.closeAll() + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + var capturedHeaders http.Header + customClient := &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + if req.Header.Get(methodHeader) == "tools/call" { + capturedHeaders = req.Header.Clone() + } + return http.DefaultTransport.RoundTrip(req) + }), + } + + clientTransport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + HTTPClient: customClient, + } + + client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) + ctx := context.Background() + session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{ProtocolVersion: minVersionForStandardHeaders}) + if err != nil { + t.Fatal(err) + } + defer session.Close() + + // ListTools to populate the tool cache (needed for param headers). + // Pass a non-nil params so the SEP-2575 per-request _meta triple is + // injected; injectMeta is a no-op when params is nil. + if _, err := session.ListTools(ctx, &ListToolsParams{}); err != nil { + t.Fatal(err) + } + + _, err = session.CallTool(ctx, &CallToolParams{ + Name: "execute_sql", + Arguments: map[string]any{"region": "us-west1", "query": "SELECT 1"}, + }) + if err != nil { + t.Fatal(err) + } + + if capturedHeaders == nil { + t.Fatal("no tool call headers captured") + } + if got := capturedHeaders.Get(methodHeader); got != "tools/call" { + t.Errorf("Mcp-Method = %q, want %q", got, "tools/call") + } + if got := capturedHeaders.Get(nameHeader); got != "execute_sql" { + t.Errorf("Mcp-Name = %q, want %q", got, "execute_sql") + } + if got := capturedHeaders.Get(paramHeaderPrefix + "Region"); got != "us-west1" { + t.Errorf("Mcp-Param-Region = %q, want %q", got, "us-west1") + } +} + +// TestStreamableFilterValidToolsIntegration verifies that AddTool rejects +// tools with invalid x-mcp-header annotations at registration time, and +// that valid tools are returned by ListTools. +func TestStreamableFilterValidToolsIntegration(t *testing.T) { + + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + noop := func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{}, nil + } + + // Valid tool with correct x-mcp-header annotation. + server.AddTool(&Tool{ + Name: "valid-tool", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Region", + }, + }, + }, + }, noop) + + // Invalid tool: x-mcp-header on an array type should panic. + func() { + defer func() { + r := recover() + if r == nil { + t.Fatal("AddTool with invalid x-mcp-header annotation did not panic") + } + }() + server.AddTool(&Tool{ + Name: "invalid-tool", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "items": map[string]any{ + "type": "array", + "x-mcp-header": "Items", + }, + }, + }, + }, noop) + }() + + // Tool with no x-mcp-header annotations (always valid). + server.AddTool(&Tool{ + Name: "plain-tool", + InputSchema: &jsonschema.Schema{Type: "object"}, + }, noop) + + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ + Stateless: true, + }) + defer handler.closeAll() + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) + ctx := context.Background() + session, err := client.Connect(ctx, &StreamableClientTransport{ + Endpoint: httpServer.URL, + }, &ClientSessionOptions{ProtocolVersion: minVersionForStandardHeaders}) + if err != nil { + t.Fatal(err) + } + defer session.Close() + + // Pass non-nil params so the SEP-2575 per-request _meta triple is + // injected; injectMeta is a no-op when params is nil. + result, err := session.ListTools(ctx, &ListToolsParams{}) + if err != nil { + t.Fatal(err) + } + + toolNames := make([]string, len(result.Tools)) + for i, tool := range result.Tools { + toolNames[i] = tool.Name + } + sort.Strings(toolNames) + + wantNames := []string{"plain-tool", "valid-tool"} + if !slices.Equal(toolNames, wantNames) { + t.Errorf("ListTools returned %v, want %v", toolNames, wantNames) + } +} + // TestStreamable405AllowHeader verifies RFC 9110 §15.5.6 compliance: // 405 Method Not Allowed responses MUST include an Allow header. func TestStreamable405AllowHeader(t *testing.T) { @@ -1906,13 +2441,13 @@ func TestStreamable405AllowHeader(t *testing.T) { wantAllow: "POST", }, { - // DELETE without session returns 400 Bad Request (not 405) - // because DELETE is a valid method, just requires a session ID. + // In stateless mode, only POST is supported. + // DELETE returns 405, consistent with Allow: POST. name: "DELETE without session stateless", stateless: true, method: "DELETE", - wantStatus: http.StatusBadRequest, - wantAllow: "", // No Allow header for 400 responses + wantStatus: http.StatusMethodNotAllowed, + wantAllow: "POST", }, } @@ -2011,6 +2546,28 @@ func TestStreamableGETWithoutEventStreamAccept(t *testing.T) { } } +func TestBaseMediaType(t *testing.T) { + tests := []struct { + name string + value string + want string + }{ + {name: "empty", want: ""}, + {name: "json", value: "application/json", want: "application/json"}, + {name: "json with params", value: "Application/JSON; charset=utf-8", want: "application/json"}, + {name: "event stream with params", value: "Text/Event-Stream; charset=utf-8", want: "text/event-stream"}, + {name: "invalid", value: "application/json; charset", want: ""}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := baseMediaType(test.value); got != test.want { + t.Errorf("baseMediaType(%q) = %q, want %q", test.value, got, test.want) + } + }) + } +} + func TestStreamableClientContextPropagation(t *testing.T) { type contextKey string const testKey = contextKey("test-key") @@ -2024,7 +2581,7 @@ func TestStreamableClientContextPropagation(t *testing.T) { switch req.Method { case "POST": w.Header().Set("Content-Type", "application/json") - w.Header().Set("Mcp-Session-Id", "test-session") + w.Header().Set(sessionIDHeader, "test-session") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26","capabilities":{},"serverInfo":{"name":"test","version":"1.0"}}}`)) case "GET": @@ -2088,8 +2645,12 @@ func TestStreamableSessionTimeout(t *testing.T) { defer httpServer.Close() // Connect a client to create a session. + // Pin to 2025-11-25: under 2026-07-28 the client probes server/discover + // first against a stateful HTTP server (which rejects the new + // protocol), opening an extra HTTP connection and therefore an extra + // session. Pinning to 2025-11-25 skips the discover probe. client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, nil) + session, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -2301,6 +2862,69 @@ data: {"jsonrpc":"2.0","id":1,"result":{}} } } +// TestProcessStreamMaxEventSize verifies that a streamableClientConn honors its +// configured maxEventSize. +func TestProcessStreamMaxEventSize(t *testing.T) { + jsonrpcEventOfSize := func(padSize int) string { + msg := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"result":{"pad":%q}}`, strings.Repeat("A", padSize)) + return "data: " + msg + "\n\n" + } + + tests := []struct { + name string + maxEventSize int + event string + wantFail bool + }{ + { + name: "event over cap is rejected", + event: jsonrpcEventOfSize(4096), + maxEventSize: 1024, + wantFail: true, + }, + { + name: "event under cap is accepted", + event: jsonrpcEventOfSize(1024), + maxEventSize: 4096, + wantFail: false, + }, + { + name: "negative cap disables the limit", + event: jsonrpcEventOfSize(4096), + maxEventSize: -1, + wantFail: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := t.Context() + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(tt.event)), + } + conn := &streamableClientConn{ + ctx: ctx, + done: make(chan struct{}), + incoming: make(chan jsonrpc.Message, 10), + failed: make(chan struct{}), + logger: ensureLogger(nil), + maxEventSize: tt.maxEventSize, + } + conn.processStream(ctx, "test", resp, nil) + + err := conn.failure() + if tt.wantFail && err == nil { + t.Fatal("failure() = nil, want a non-nil error for an oversized event") + } + if !tt.wantFail && err != nil { + t.Fatalf("failure() = %v, want nil", err) + } + }) + } +} + // TestScanEventsPingFiltering is a unit test for the low-level event scanning // with ping events to verify scanEvents properly parses all event types. func TestScanEventsPingFiltering(t *testing.T) { @@ -2323,7 +2947,7 @@ data: {"jsonrpc":"2.0","method":"test2","params":{}} var events []Event // Scan all events - for evt, err := range scanEvents(reader) { + for evt, err := range scanEventsLimited(reader, DefaultMaxEventSize) { if err != nil { if err != io.EOF { t.Fatalf("scanEvents error: %v", err) @@ -2379,7 +3003,11 @@ func Test_ExportErrSessionMissing(t *testing.T) { Endpoint: ts.URL, } client := NewClient(impl, nil) - session, err := client.Connect(ctx, clientTransport, nil) + // Pin to 2025-11-25: under 2026-07-28 the client probes server/discover + // first against a stateful HTTP server (which rejects the new + // protocol), opening an extra HTTP connection and therefore an extra + // session. Pinning to 2025-11-25 skips the discover probe. + session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect failed: %v", err) } @@ -2529,10 +3157,10 @@ func TestStreamableOriginProtection(t *testing.T) { wantStatusCode int }{ { - name: "default protection with Origin header", + name: "no protection with Origin header", protection: nil, requestOrigin: "https://example.com", - wantStatusCode: http.StatusForbidden, + wantStatusCode: http.StatusOK, }, { name: "custom protection with trusted origin and same Origin", @@ -2591,3 +3219,1237 @@ func TestStreamableOriginProtection(t *testing.T) { }) } } + +// TestStandaloneSSEEmitsCommentForHTTP2Flush is a regression test for the +// HTTP/2 reverse-proxy header-buffering bug. The standalone SSE GET stream +// must emit at least one body byte (an SSE comment) immediately after the +// response headers. Without a DATA frame, HTTP/2 reverse proxies (Envoy, +// Caddy, net/http/httputil, etc.) buffer the HEADERS frame indefinitely +// because they have nothing to coalesce it with — calling Flush() alone is +// not sufficient. +// +// SSE explicitly defines lines starting with ":" as comments that clients +// ignore, so this is behavior-preserving for the SSE protocol while +// producing the DATA frame that HTTP/2 proxies need. +func TestStandaloneSSEEmitsCommentForHTTP2Flush(t *testing.T) { + server := NewServer(testImpl, nil) + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Initialize a session so we have a valid Mcp-Session-Id to use on the + // standalone SSE GET. + initBody := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) + initReq, err := http.NewRequestWithContext(ctx, http.MethodPost, httpServer.URL, initBody) + if err != nil { + t.Fatal(err) + } + initReq.Header.Set("Content-Type", "application/json") + initReq.Header.Set("Accept", "application/json, text/event-stream") + initResp, err := http.DefaultClient.Do(initReq) + if err != nil { + t.Fatal(err) + } + io.Copy(io.Discard, initResp.Body) + initResp.Body.Close() + sessionID := initResp.Header.Get(sessionIDHeader) + if sessionID == "" { + t.Fatalf("initialize response missing %s header", sessionIDHeader) + } + + // Open the standalone SSE stream. + getReq, err := http.NewRequestWithContext(ctx, http.MethodGet, httpServer.URL, nil) + if err != nil { + t.Fatal(err) + } + getReq.Header.Set("Accept", "text/event-stream") + getReq.Header.Set(sessionIDHeader, sessionID) + + resp, err := http.DefaultClient.Do(getReq) + if err != nil { + t.Fatalf("GET standalone SSE: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d; want 200", resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/event-stream") { + t.Fatalf("Content-Type = %q; want text/event-stream", got) + } + + // Read the first chunk. With the fix, an SSE comment (": ok\n\n") is + // written immediately. Without the fix, the body has no bytes to read + // until the server pushes an event, which won't happen in this test. + type readResult struct { + n int + err error + buf []byte + } + ch := make(chan readResult, 1) + go func() { + buf := make([]byte, 64) + n, err := resp.Body.Read(buf) + ch <- readResult{n: n, err: err, buf: buf} + }() + + select { + case r := <-ch: + if r.err != nil && r.err != io.EOF { + t.Fatalf("reading first SSE chunk: %v", r.err) + } + if r.n == 0 { + t.Fatal("first SSE chunk was empty; expected an SSE comment to flush HTTP/2 proxy HEADERS frame") + } + got := string(r.buf[:r.n]) + // SSE spec: lines starting with ":" are comments, ignored by clients. + // We don't pin the exact comment text; just require the first byte to + // be the SSE comment marker so HTTP/2 proxies have a DATA frame. + if !strings.HasPrefix(got, ":") { + t.Errorf("first SSE chunk = %q; want it to start with %q (SSE comment marker, so HTTP/2 proxies receive a DATA frame and forward HEADERS)", got, ":") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for first SSE bytes; the standalone SSE stream must emit a DATA frame immediately so HTTP/2 reverse proxies don't buffer the HEADERS frame") + } +} + +// newProtocolBody builds a raw JSON body for a tools/call request that +// carries the >= 2026-07-28 per-request _meta fields. +func newProtocolBody(t *testing.T, toolName string, args any) []byte { + t.Helper() + rawArgs, err := json.Marshal(args) + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: map[string]any{"name": "new-proto-client", "version": "9.9"}, + MetaKeyClientCapabilities: map[string]any{"sampling": map[string]any{}}, + }, + "name": toolName, + "arguments": json.RawMessage(rawArgs), + }, + }) + if err != nil { + t.Fatal(err) + } + return body +} + +func TestEphemeralConnectOpts(t *testing.T) { + mkReq := func(body []byte) *http.Request { + r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + return r + } + + h := &StreamableHTTPHandler{opts: StreamableHTTPOptions{}} + + oldProtocolBody, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{"name": "x", "arguments": map[string]any{}}, + }) + if err != nil { + t.Fatal(err) + } + initializeBody, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": methodInitialize, + "params": map[string]any{"protocolVersion": protocolVersion20250618}, + }) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + body []byte + wantUsesNew bool + wantInitializeParams bool + wantInitializedParams bool + }{ + { + name: "new-protocol request: no synthetic state", + body: newProtocolBody(t, "x", struct{}{}), + wantUsesNew: true, + wantInitializeParams: false, + wantInitializedParams: false, + }, + { + name: "old-protocol request: synthetic state populated", + body: oldProtocolBody, + wantUsesNew: false, + wantInitializeParams: true, + wantInitializedParams: true, + }, + { + name: "initialize request: no synthetic InitializeParams", + body: initializeBody, + wantUsesNew: false, + wantInitializeParams: false, + wantInitializedParams: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := mkReq(tt.body) + var pver string + if tt.wantUsesNew { + pver = protocolVersion20260728 + } else { + pver = protocolVersion20250326 + } + req.Header.Set(protocolVersionHeader, pver) + req = req.WithContext(context.WithValue(req.Context(), protocolVersionContextKey{}, pver)) + info, err := h.ephemeralConnectOpts(req) + if err != nil { + t.Fatal(err) + } + if info.usesNewProtocol != tt.wantUsesNew { + t.Errorf("usesNewProtocol = %v, want %v", info.usesNewProtocol, tt.wantUsesNew) + } + if got := info.opts.State.InitializeParams != nil; got != tt.wantInitializeParams { + t.Errorf("InitializeParams non-nil = %v, want %v (value = %+v)", + got, tt.wantInitializeParams, info.opts.State.InitializeParams) + } + if got := info.opts.State.InitializedParams != nil; got != tt.wantInitializedParams { + t.Errorf("InitializedParams non-nil = %v, want %v (value = %+v)", + got, tt.wantInitializedParams, info.opts.State.InitializedParams) + } + }) + } +} + +// statelessHandlerCapture builds a stateless server with a single tool whose +// handler captures everything we want to assert about the per-request view of +// the session and the new-protocol accessors. +type statelessHandlerCapture struct { + mu sync.Mutex + sessionInitParams *InitializeParams + reqProtocolVersion string + reqClientInfo *Implementation + reqClientCapabilities *ClientCapabilities +} + +func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) { + // SEP-2575: the MCP-Protocol-Version header is mandatory for new-protocol + // requests and must be a supported version. The 2026-07-28 version is + // not yet in the global list, so register it for the duration of the test. + + capture := &statelessHandlerCapture{} + mcpServer := NewServer(testImpl, nil) + AddTool(mcpServer, &Tool{Name: "capture", Description: "captures request info"}, + func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { + capture.mu.Lock() + defer capture.mu.Unlock() + capture.sessionInitParams = req.Session.InitializeParams() + capture.reqProtocolVersion = req.ProtocolVersion() + capture.reqClientInfo = req.ClientInfo() + capture.reqClientCapabilities = req.ClientCapabilities() + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return mcpServer }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + body := newProtocolBody(t, "capture", struct{}{}) + httpReq, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json, text/event-stream") + httpReq.Header.Set(protocolVersionHeader, protocolVersion20260728) + // >= 2026-07-28 also requires the Mcp-Method and Mcp-Name standard + // headers (see streamable_headers.go). + httpReq.Header.Set(methodHeader, "tools/call") + httpReq.Header.Set(nameHeader, "capture") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) + } + + capture.mu.Lock() + defer capture.mu.Unlock() + if capture.sessionInitParams == nil { + t.Errorf("Session.InitializeParams() is nil, want populated initializeParams for new-protocol session") + } else { + if got, want := capture.sessionInitParams.ProtocolVersion, protocolVersion20260728; got != want { + t.Errorf("Session.InitializeParams().ProtocolVersion = %q, want %q", got, want) + } + if got, want := capture.sessionInitParams.ClientInfo.Name, "new-proto-client"; got != want { + t.Errorf("Session.InitializeParams().ClientInfo.Name = %q, want %q", got, want) + } + } + if got, want := capture.reqProtocolVersion, protocolVersion20260728; got != want { + t.Errorf("req.ProtocolVersion() = %q, want %q", got, want) + } + if capture.reqClientInfo == nil || capture.reqClientInfo.Name != "new-proto-client" { + t.Errorf("req.ClientInfo() = %+v, want Name=new-proto-client", capture.reqClientInfo) + } + if capture.reqClientCapabilities == nil || capture.reqClientCapabilities.Sampling == nil { + t.Errorf("req.ClientCapabilities() = %+v, want non-nil Sampling", capture.reqClientCapabilities) + } +} + +// TestStreamableStateful_RejectsNewProtocol verifies that a stateful HTTP +// server rejects requests carrying _meta.protocolVersion (i.e. >= 2026-07-28 +// requests) with HTTP 400. The new protocol is +// supported on HTTP only when StreamableHTTPOptions.Stateless=true. +func TestStreamableStateful_RejectsNewProtocol(t *testing.T) { + // Make 2026-07-28 a "known" version so that the request reaches servePOST + // (otherwise the early header validation at ServeHTTP rejects it). + + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "noop"}, + func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + // Initialize a legacy session first. + initBody := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) + initReq, err := http.NewRequest(http.MethodPost, httpServer.URL, initBody) + if err != nil { + t.Fatal(err) + } + initReq.Header.Set("Content-Type", "application/json") + initReq.Header.Set("Accept", "application/json, text/event-stream") + initResp, err := http.DefaultClient.Do(initReq) + if err != nil { + t.Fatal(err) + } + io.Copy(io.Discard, initResp.Body) + initResp.Body.Close() + sessionID := initResp.Header.Get(sessionIDHeader) + if sessionID == "" { + t.Fatalf("initialize response missing %s header", sessionIDHeader) + } + + // Drive the existing session with a new-protocol request whose header and + // body agree. The cross-check passes; the stateful-rejection check fires. + body := newProtocolBody(t, "noop", struct{}{}) + req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set(sessionIDHeader, sessionID) + req.Header.Set(protocolVersionHeader, protocolVersion20260728) + req.Header.Set(methodHeader, "tools/call") + req.Header.Set(nameHeader, "noop") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, respBody) + } + msg, err := jsonrpc.DecodeMessage(respBody) + if err != nil { + t.Fatalf("decoding response body as JSON-RPC: %v; body = %s", err, respBody) + } + jresp, ok := msg.(*jsonrpc.Response) + if !ok || jresp.Error == nil { + t.Fatalf("response is not a JSON-RPC error: %s", respBody) + } + var jerr *jsonrpc.Error + if !errors.As(jresp.Error, &jerr) { + t.Fatalf("response error is not *jsonrpc.Error: %v", jresp.Error) + } + if jerr.Code != CodeUnsupportedProtocolVersion { + t.Errorf("error code = %d, want %d (CodeUnsupportedProtocolVersion)", jerr.Code, CodeUnsupportedProtocolVersion) + } + var data UnsupportedProtocolVersionData + if err := json.Unmarshal(jerr.Data, &data); err != nil { + t.Fatalf("decoding error data: %v; data = %s", err, jerr.Data) + } + if data.Requested != protocolVersion20260728 { + t.Errorf("data.Requested = %q, want %q", data.Requested, protocolVersion20260728) + } + if slices.Contains(data.Supported, protocolVersion20260728) { + t.Errorf("data.Supported = %v, must not contain the rejected version %q", + data.Supported, protocolVersion20260728) + } + if len(data.Supported) == 0 { + t.Errorf("data.Supported is empty; expected at least one legacy version") + } +} + +// TestStreamableStateful_RejectsNewProtocol_LegacyPlainText verifies that +// MCPGODEBUG=plaintextstatefulrejection=1 restores the pre-v1.8.0 plain-text +// 400 body for the stateful/new-protocol rejection. +func TestStreamableStateful_RejectsNewProtocol_LegacyPlainText(t *testing.T) { + prev := plaintextstatefulrejection + plaintextstatefulrejection = "1" + t.Cleanup(func() { plaintextstatefulrejection = prev }) + + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "noop"}, + func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + initBody := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) + initReq, err := http.NewRequest(http.MethodPost, httpServer.URL, initBody) + if err != nil { + t.Fatal(err) + } + initReq.Header.Set("Content-Type", "application/json") + initReq.Header.Set("Accept", "application/json, text/event-stream") + initResp, err := http.DefaultClient.Do(initReq) + if err != nil { + t.Fatal(err) + } + io.Copy(io.Discard, initResp.Body) + initResp.Body.Close() + sessionID := initResp.Header.Get(sessionIDHeader) + if sessionID == "" { + t.Fatalf("initialize response missing %s header", sessionIDHeader) + } + + body := newProtocolBody(t, "noop", struct{}{}) + req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set(sessionIDHeader, sessionID) + req.Header.Set(protocolVersionHeader, protocolVersion20260728) + req.Header.Set(methodHeader, "tools/call") + req.Header.Set(nameHeader, "noop") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, respBody) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { + t.Errorf("Content-Type = %q, want text/plain; body = %s", ct, respBody) + } + if !strings.Contains(string(respBody), "stateless") { + t.Errorf("body = %q, want a message mentioning 'stateless'", respBody) + } +} + +// TestCallCancellation_FastReturn verifies that a cancelled tool call +// returns as soon as its context is cancelled, even when the peer is slow +// to accept the follow-up notifications/cancelled POST. +func TestCallCancellation_FastReturn(t *testing.T) { + // stallDuration is the time the middleware holds a + // notifications/cancelled POST. It must be larger than + // notifyCancellationTimeout so the blocking branch clearly overshoots + // even after cushioning for scheduler jitter. + const stallDuration = notifyCancellationTimeout + 2*time.Second + + // callerDeadline is the deadline the client passes into CallTool. It + // must be short enough that any waiting on the cancel notification is + // obvious in the measured elapsed time. + const callerDeadline = 100 * time.Millisecond + + // slack accounts for scheduling jitter around the deadline. + const slack = 500 * time.Millisecond + + stallCancelNotify := func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.Body != nil { + body, _ := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(body)) + if bytes.Contains(body, []byte(notificationCancelled)) { + time.Sleep(stallDuration) + } + } + h.ServeHTTP(w, r) + }) + } + + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "slow"}, + func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { + // Bound the handler above the caller deadline so the test + // always exercises the cancellation path, but not so long + // that a stuck cancellation makes the test slow. + select { + case <-ctx.Done(): + case <-time.After(2 * stallDuration): + } + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(stallCancelNotify(handler)) + t.Cleanup(httpServer.Close) + + client := NewClient(testImpl, nil) + cs, err := client.Connect(context.Background(), + &StreamableClientTransport{Endpoint: httpServer.URL}, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + t.Cleanup(func() { cs.Close() }) + + callCtx, cancel := context.WithTimeout(context.Background(), callerDeadline) + defer cancel() + + start := time.Now() + _, err = cs.CallTool(callCtx, &CallToolParams{Name: "slow"}) + elapsed := time.Since(start) + + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("CallTool error = %v, want context.DeadlineExceeded", err) + } + // The caller should return within slack of its own deadline. + if elapsed > callerDeadline+slack { + t.Errorf("CallTool returned after %v; want <= %v (caller deadline %v + slack %v)", + elapsed, callerDeadline+slack, callerDeadline, slack) + } +} + +// TestStreamableStateless_AcceptsNewProtocol is the positive control: +// confirms that a stateless server still accepts new-protocol requests +// (the rejection in TestStreamableStateful_RejectsNewProtocol must not +// fire on Stateless: true). +func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) { + + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "noop"}, + func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + body := newProtocolBody(t, "noop", struct{}{}) + req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set(protocolVersionHeader, protocolVersion20260728) + req.Header.Set(methodHeader, "tools/call") + req.Header.Set(nameHeader, "noop") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) + } +} + +// TestStreamableStateless_NotificationMetaValidation checks that the SEP-2575 +// per-request `_meta` triple is required of calls only. NotificationParams +// declares `_meta` optional with no protocolVersion, so a notification that +// omits it is well-formed and must be accepted with 202. +func TestStreamableStateless_NotificationMetaValidation(t *testing.T) { + newProtocolMeta := map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: map[string]any{"name": "new-proto-client", "version": "9.9"}, + MetaKeyClientCapabilities: map[string]any{}, + } + + tests := []struct { + name string + message map[string]any + wantStatus int + }{ + { + name: "cancelled without meta", + message: map[string]any{ + "jsonrpc": "2.0", + "method": notificationCancelled, + "params": map[string]any{"requestID": 1, "reason": "context canceled"}, + }, + wantStatus: http.StatusAccepted, + }, + { + name: "progress without meta", + message: map[string]any{ + "jsonrpc": "2.0", + "method": notificationProgress, + "params": map[string]any{"progressToken": "t", "progress": 1}, + }, + wantStatus: http.StatusAccepted, + }, + { + name: "notification with matching meta", + message: map[string]any{ + "jsonrpc": "2.0", + "method": notificationCancelled, + "params": map[string]any{ + "_meta": newProtocolMeta, + "requestID": 1, + }, + }, + wantStatus: http.StatusAccepted, + }, + { + // A notification that volunteers a version is still held to the + // header-match rule, so relaxing the requirement does not open a + // hole for inconsistent messages. + name: "notification with mismatched meta", + message: map[string]any{ + "jsonrpc": "2.0", + "method": notificationCancelled, + "params": map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: "2025-06-18", + MetaKeyClientCapabilities: map[string]any{}, + }, + "requestID": 1, + }, + }, + wantStatus: http.StatusBadRequest, + }, + { + // Calls remain subject to the requirement. + name: "call without meta", + message: map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": map[string]any{}, + }, + wantStatus: http.StatusBadRequest, + }, + } + + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "noop"}, + func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body, err := json.Marshal(test.message) + if err != nil { + t.Fatal(err) + } + req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set(protocolVersionHeader, protocolVersion20260728) + req.Header.Set(methodHeader, test.message["method"].(string)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != test.wantStatus { + t.Fatalf("status = %d, want %d; body = %s", resp.StatusCode, test.wantStatus, respBody) + } + if test.wantStatus == http.StatusAccepted && len(respBody) > 0 { + t.Errorf("accepted notification returned body %q, want empty", respBody) + } + }) + } +} + +// TestStreamableClientUnsupportedVersionFallback exercises the full +// SEP-2575 fallback. The client requests protocolVersion20260728, which the +// server is configured to NOT advertise in supportedProtocolVersions for the +// duration of this test. The server therefore rejects the server/discover +// POST at the transport-level header validation with a plain HTTP 400 ("Bad +// Request: Unsupported protocol version ..."). The streamable client must +// recognize this body, keep the connection alive, and successfully complete +// the legacy initialize handshake. +func TestStreamableClientUnsupportedVersionFallback(t *testing.T) { + // Remove protocolVersion20260728 from the server-side supported list so + // the fallback path is exercised. + orig := supportedProtocolVersions + supportedProtocolVersions = slices.DeleteFunc(slices.Clone(orig), func(v string) bool { + return v == protocolVersion20260728 + }) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + ctx := context.Background() + + server := NewServer(testImpl, nil) + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + nil, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + client := NewClient(testImpl, nil) + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + ir := session.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil; expected legacy initialize to populate it") + } + // The fallback initialize explicitly requests protocolVersion20251125 + // (see client.go), so the server negotiates that version. + if ir.ProtocolVersion != protocolVersion20251125 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback)", + ir.ProtocolVersion, protocolVersion20251125) + } + + // Verify the session is fully usable after the fallback by issuing a + // real call against the server. + if err := session.Ping(ctx, nil); err != nil { + t.Errorf("Ping after fallback initialize: %v", err) + } +} + +// TestStreamableStateful_AcceptsDiscover verifies that a stateful HTTP server +// accepts a server/discover probe carrying MCP-Protocol-Version: 2026-07-28 +// (and the matching _meta.protocolVersion), instead of rejecting it with the +// "stateless required" 400. The SEP-2575 client flow has the client probing +// the server with the new protocol version to learn which versions are +// supported. +func TestStreamableStateful_AcceptsDiscover(t *testing.T) { + + server := NewServer(testImpl, nil) + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": methodDiscover, + "params": map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: map[string]any{"name": "new-proto-client", "version": "9.9"}, + MetaKeyClientCapabilities: map[string]any{"sampling": map[string]any{}}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set(protocolVersionHeader, protocolVersion20260728) + req.Header.Set(methodHeader, methodDiscover) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) + } + + // Parse the JSON-RPC response. The body may arrive as a bare JSON object + // or as a single SSE event depending on the Accept negotiation; both + // shapes are valid here. + jsonPayload := respBody + if i := bytes.Index(respBody, []byte("data: ")); i >= 0 { + jsonPayload = respBody[i+len("data: "):] + if j := bytes.IndexByte(jsonPayload, '\n'); j >= 0 { + jsonPayload = jsonPayload[:j] + } + } + var rpcResp struct { + Result *DiscoverResult `json:"result"` + Error *jsonrpc.Error `json:"error"` + } + if err := json.Unmarshal(jsonPayload, &rpcResp); err != nil { + t.Fatalf("unmarshal response %q: %v", respBody, err) + } + if rpcResp.Error != nil { + t.Fatalf("discover returned error: %+v (body = %s)", rpcResp.Error, respBody) + } + if rpcResp.Result == nil { + t.Fatalf("discover returned no result; body = %s", respBody) + } + if slices.Contains(rpcResp.Result.SupportedVersions, protocolVersion20260728) { + t.Errorf("DiscoverResult.SupportedVersions = %v, must not include %q on a stateful transport", + rpcResp.Result.SupportedVersions, protocolVersion20260728) + } + if len(rpcResp.Result.SupportedVersions) == 0 { + t.Errorf("DiscoverResult.SupportedVersions is empty; want at least one legacy version") + } +} + +// On a stateful StreamableHTTPHandler, a v1.7.0-style client always +// probes server/discover before falling back to initialize. That probe used +// to create a ServerSession that was registered in the handler's session +// map but never surfaced via Mcp-Session-Id, so the client could not DELETE +// it and it was leaked for the lifetime of the process. +// +// The fix in Server.discover skips populating InitializeParams when the +// transport cannot serve the new protocol, which lets the safety-net +// cleanup in serveStatefulPOST close the otherwise-unaddressable session. +func TestStreamableStateful_DiscoverDoesNotLeakSession(t *testing.T) { + ctx := context.Background() + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "noop", Description: "noop"}, + func(context.Context, *CallToolRequest, struct{}) (*CallToolResult, any, error) { + return &CallToolResult{}, nil, nil + }) + + // Stateful (default). SessionTimeout unset so nothing masks the leak + // via idle eviction. + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + countSessions := func() int { + n := 0 + for range server.Sessions() { + n++ + } + return n + } + + const clients = 3 + for i := 1; i <= clients; i++ { + c := NewClient(&Implementation{Name: "c", Version: "1.0.0"}, nil) + sess, err := c.Connect(ctx, &StreamableClientTransport{ + Endpoint: httpServer.URL, + DisableStandaloneSSE: true, + MaxRetries: -1, + }, nil) + if err != nil { + t.Fatalf("client %d Connect: %v", i, err) + } + if _, err := sess.ListTools(ctx, nil); err != nil { + t.Fatalf("client %d ListTools: %v", i, err) + } + // Exactly one addressable session should exist while this client + // is connected. Anything more means a discover-only session + // slipped through. + if got := countSessions(); got != 1 { + t.Errorf("client %d connected: sessions = %d, want 1", i, got) + } + if err := sess.Close(); err != nil { + t.Fatalf("client %d Close: %v", i, err) + } + if got := countSessions(); got != 0 { + t.Errorf("client %d closed: sessions = %d, want 0", i, got) + } + } + + if got := countSessions(); got != 0 { + t.Errorf("final leaked sessions = %d, want 0", got) + } +} + +// TestStreamableHTTP_E2E_DiscoverSuccess is a full end-to-end smoke test for +// SEP-2575 over the streamable HTTP transport. +func TestStreamableHTTP_E2E_DiscoverSuccess(t *testing.T) { + ctx := context.Background() + server := NewServer(&Implementation{Name: "e2e-server", Version: "v1"}, nil) + // Register a simple tool so we can prove the session is usable end-to-end. + AddTool(server, &Tool{Name: "echo", Description: "echoes its input"}, + func(_ context.Context, _ *CallToolRequest, args struct { + Msg string `json:"msg"` + }) (*CallToolResult, struct{}, error) { + return &CallToolResult{ + Content: []Content{&TextContent{Text: args.Msg}}, + }, struct{}{}, nil + }, + ) + + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + client := NewClient(&Implementation{Name: "e2e-client", Version: "v1"}, nil) + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + cs, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after Connect; discover should have populated it") + } + if ir.ProtocolVersion != protocolVersion20260728 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (negotiated via discover)", + ir.ProtocolVersion, protocolVersion20260728) + } + if ir.ServerInfo == nil || ir.ServerInfo.Name != "e2e-server" { + t.Errorf("InitializeResult.ServerInfo = %+v, want name=e2e-server", ir.ServerInfo) + } + + // Prove the session is fully usable: list tools and call one. + tools, err := cs.ListTools(ctx, nil) + if err != nil { + t.Fatalf("ListTools: %v", err) + } + if len(tools.Tools) != 1 || tools.Tools[0].Name != "echo" { + t.Errorf("ListTools = %+v, want one tool named 'echo'", tools.Tools) + } + res, err := cs.CallTool(ctx, &CallToolParams{ + Name: "echo", + Arguments: map[string]any{"msg": "hello"}, + }) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + if len(res.Content) != 1 { + t.Fatalf("CallTool result content = %+v, want 1 entry", res.Content) + } + tc, ok := res.Content[0].(*TextContent) + if !ok || tc.Text != "hello" { + t.Errorf("CallTool result[0] = %+v, want TextContent{Text:\"hello\"}", res.Content[0]) + } +} + +// A POST whose call ID is already in flight on the same session must be rejected +// with a JSON-RPC error, must not overwrite the existing request-to-stream +// mapping, and must not publish the duplicate message to the session's incoming +// channel. +func TestStreamableServerRejectsDuplicateInFlightRequestID(t *testing.T) { + id := jsonrpc2.Int64ID(1) + conn := &streamableServerConn{ + logger: ensureLogger(nil), + incoming: make(chan jsonrpc.Message, 1), + done: make(chan struct{}), + streams: map[string]*stream{ + "existing": { + id: "existing", + logger: ensureLogger(nil), + requests: map[jsonrpc.ID]struct{}{id: {}}, + }, + }, + requestStreams: map[jsonrpc.ID]string{id: "existing"}, + } + + data, err := jsonrpc2.EncodeMessage(req(1, methodPing, &PingParams{})) + if err != nil { + t.Fatalf("EncodeMessage() error = %v", err) + } + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + httpReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/", bytes.NewReader(data)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json, text/event-stream") + + rec := httptest.NewRecorder() + conn.servePOST(rec, httpReq) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + msg, err := jsonrpc2.DecodeMessage(rec.Body.Bytes()) + if err != nil { + t.Fatalf("DecodeMessage() error = %v; body = %s", err, rec.Body.String()) + } + resp, ok := msg.(*jsonrpc.Response) + if !ok { + t.Fatalf("response type = %T, want *jsonrpc.Response", msg) + } + if got := resp.ID.Raw(); got != int64(1) { + t.Fatalf("response ID = %v, want 1", got) + } + var jerr *jsonrpc.Error + if !errors.As(resp.Error, &jerr) { + t.Fatalf("response error = %v, want *jsonrpc.Error", resp.Error) + } + if jerr.Code != jsonrpc.CodeInvalidRequest { + t.Fatalf("error code = %d, want %d", jerr.Code, jsonrpc.CodeInvalidRequest) + } + if got := conn.requestStreams[id]; got != "existing" { + t.Fatalf("requestStreams[%v] = %q, want %q", id, got, "existing") + } + select { + case msg := <-conn.incoming: + t.Fatalf("duplicate request was published to incoming: %v", msg) + default: + } +} + +// TestStreamableServerPreservesSingleRequestBatchResponse verifies that a +// single-request JSON-RPC batch is returned as a one-element JSON array. +func TestStreamableServerPreservesSingleRequestBatchResponse(t *testing.T) { + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{JSONResponse: true}, + ) + defer handler.closeAll() + + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + body := []byte(`[{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0"}}}]`) + req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", resp.StatusCode, http.StatusOK, data) + } + + var messages []json.RawMessage + if err := json.Unmarshal(data, &messages); err != nil { + t.Fatalf("response is not a JSON array: %v; body = %s", err, data) + } + if len(messages) != 1 { + t.Fatalf("response contains %d messages, want 1; body = %s", len(messages), data) + } + if _, err := jsonrpc2.DecodeMessage(messages[0]); err != nil { + t.Fatalf("response item is not a JSON-RPC message: %v", err) + } +} + +// TestStreamableMaxRequestBodyBytes verifies that the streamable HTTP handler +// enforces StreamableHTTPOptions.MaxRequestBodyBytes on incoming request +// bodies. The limit must apply uniformly regardless of transfer encoding: +// Content-Length, Transfer-Encoding: chunked, or HTTP/2 (which has no +// Content-Length). +func TestStreamableMaxRequestBodyBytes(t *testing.T) { + // A minimally-valid JSON-RPC initialize request. The body-size check runs + // before parsing, so exact contents don't matter for the reject cases, + // but we use a real one for the "under limit" success case. + validInit := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"v1"}}}`) + + // Build a payload of the requested size by padding a JSON object. The + // server doesn't need to parse it; the MaxBytesReader trips first. + oversizedBody := func(n int) []byte { + b := make([]byte, n) + b[0] = '{' + for i := 1; i < n-1; i++ { + b[i] = ' ' + } + b[n-1] = '}' + return b + } + + tests := []struct { + name string + limit int64 // value assigned to MaxRequestBodyBytes + body []byte + wantStatus int + wantStatusDesc string + }{ + { + name: "default limit rejects oversized", + limit: 0, // triggers DefaultMaxRequestBodyBytes (4 MiB) + body: oversizedBody(DefaultMaxRequestBodyBytes + 1), + wantStatus: http.StatusRequestEntityTooLarge, + wantStatusDesc: "413 for body > 4MiB default", + }, + { + name: "custom limit rejects oversized", + limit: 1024, + body: oversizedBody(2048), + wantStatus: http.StatusRequestEntityTooLarge, + wantStatusDesc: "413 for body > custom 1KiB limit", + }, + { + name: "chunked encoding does not bypass limit", + limit: 1024, + body: oversizedBody(4096), + wantStatus: http.StatusRequestEntityTooLarge, + wantStatusDesc: "413 for chunked body > limit (regression: Content-Length-only checks are bypassable)", + }, + { + name: "negative limit disables the check", + limit: -1, + body: oversizedBody(2 * DefaultMaxRequestBodyBytes), + wantStatus: http.StatusBadRequest, // body is not valid JSON-RPC; but the size check must not fire + wantStatusDesc: "not 413 when limit is disabled", + }, + { + name: "under limit succeeds", + limit: 64 * 1024, + body: validInit, + wantStatus: http.StatusOK, + wantStatusDesc: "200 for a valid initialize under the limit", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, nil) + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{ + Stateless: true, + MaxRequestBodyBytes: tc.limit, + }, + ) + ts := httptest.NewServer(handler) + defer ts.Close() + + req, err := http.NewRequest(http.MethodPost, ts.URL, bytes.NewReader(tc.body)) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != tc.wantStatus { + t.Fatalf("%s: status = %d, want %d (body=%q)", + tc.wantStatusDesc, resp.StatusCode, tc.wantStatus, string(body)) + } + }) + } +} + +// TestStreamableSupportedProtocolVersions_Header verifies that the +// MCP-Protocol-Version header is validated against +// [ServerOptions.SupportedProtocolVersions], and not merely against the +// versions the SDK knows about. +func TestStreamableSupportedProtocolVersions_Header(t *testing.T) { + const initBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}` + + newServer := func(t *testing.T, stateless bool) *httptest.Server { + server := NewServer(testImpl, &ServerOptions{ + SupportedProtocolVersions: []string{protocolVersion20251125}, + }) + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: stateless}) + httpServer := httptest.NewServer(handler) + t.Cleanup(httpServer.Close) + return httpServer + } + post := func(t *testing.T, url, version, sessionID string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(initBody)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set(protocolVersionHeader, version) + if sessionID != "" { + req.Header.Set(sessionIDHeader, sessionID) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + return resp + } + + for _, stateless := range []bool{false, true} { + for _, test := range []struct { + version string + wantStatus int + }{ + {protocolVersion20251125, http.StatusOK}, + // Supported by the SDK, but excluded by the server. + {protocolVersion20250618, http.StatusBadRequest}, + // Unknown to the SDK: rejected before any server is consulted. + {"1999-01-01", http.StatusBadRequest}, + } { + t.Run(fmt.Sprintf("stateless=%v/%s", stateless, test.version), func(t *testing.T) { + resp := post(t, newServer(t, stateless).URL, test.version, "") + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != test.wantStatus { + t.Errorf("status = %d, want %d; body = %s", resp.StatusCode, test.wantStatus, body) + } + }) + } + } + + t.Run("existing session", func(t *testing.T) { + httpServer := newServer(t, false) + resp := post(t, httpServer.URL, protocolVersion20251125, "") + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + sessionID := resp.Header.Get(sessionIDHeader) + if sessionID == "" { + t.Fatalf("initialize response missing %s header", sessionIDHeader) + } + + resp = post(t, httpServer.URL, protocolVersion20250618, sessionID) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("status = %d, want %d; body = %s", resp.StatusCode, http.StatusBadRequest, body) + } + }) +} diff --git a/mcp/testdata/conformance/server/discover.txtar b/mcp/testdata/conformance/server/discover.txtar new file mode 100644 index 0000000..9159563 --- /dev/null +++ b/mcp/testdata/conformance/server/discover.txtar @@ -0,0 +1,44 @@ +The client sends a discover request carrying the per-request _meta +(protocolVersion, clientInfo, clientCapabilities), and the server +replies with the protocol versions it supports, its capabilities, and its +identity via `_meta.io.modelcontextprotocol/serverInfo`. + +-- client -- +{ + "jsonrpc": "2.0", + "id": 1, + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +} +-- server -- +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "resultType": "complete", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "testServer", + "version": "v1.0.0" + } + }, + "ttlMs": 0, + "cacheScope": "public", + "supportedVersions": [ + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26", + "2024-11-05" + ], + "capabilities": { + "logging": {} + } + } +} diff --git a/mcp/testdata/conformance/server/lifecycle.txtar b/mcp/testdata/conformance/server/lifecycle.txtar index 0a8cf34..652aa7f 100644 --- a/mcp/testdata/conformance/server/lifecycle.txtar +++ b/mcp/testdata/conformance/server/lifecycle.txtar @@ -53,6 +53,8 @@ See also modelcontextprotocol/go-sdk#225. "jsonrpc": "2.0", "id": 2, "result": { + "ttlMs": 0, + "cacheScope": "public", "tools": [] } } @@ -60,6 +62,8 @@ See also modelcontextprotocol/go-sdk#225. "jsonrpc": "2.0", "id": 3, "result": { + "ttlMs": 0, + "cacheScope": "public", "tools": [] } } diff --git a/mcp/testdata/conformance/server/mrtr.txtar b/mcp/testdata/conformance/server/mrtr.txtar new file mode 100644 index 0000000..046a79b --- /dev/null +++ b/mcp/testdata/conformance/server/mrtr.txtar @@ -0,0 +1,94 @@ +Check the multi-round-trip request pattern under protocol version +2026-07-28. The server's tool handler returns an +InputRequiredResult on the first call, requesting elicitation input from the +client and providing an opaque RequestState token. The client fulfills the +input request and retries the tool call with the responses and the echoed +RequestState; the handler then returns the final result. + +-- tools -- +confirmThenGreet +-- client -- +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" }, + "io.modelcontextprotocol/clientCapabilities": { "elicitation": {} } + }, + "name": "confirmThenGreet", + "arguments": {} + } +} +{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" }, + "io.modelcontextprotocol/clientCapabilities": { "elicitation": {} } + }, + "name": "confirmThenGreet", + "arguments": {}, + "requestState": "step=1", + "inputResponses": { + "who": { "action": "accept", "content": { "name": "MCP Go" } } + } + } +} +-- server -- +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "testServer", + "version": "v1.0.0" + } + }, + "content": null, + "requestState": "step=1", + "resultType": "input_required", + "inputRequests": { + "who": { + "method": "elicitation/create", + "params": { + "mode": "form", + "message": "What is your name?", + "requestedSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } + } + } +} +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "testServer", + "version": "v1.0.0" + } + }, + "content": [ + { + "type": "text", + "text": "hello MCP Go" + } + ], + "resultType": "complete" + } +} diff --git a/mcp/testdata/conformance/server/prompts.txtar b/mcp/testdata/conformance/server/prompts.txtar index fdaf793..2d8e168 100644 --- a/mcp/testdata/conformance/server/prompts.txtar +++ b/mcp/testdata/conformance/server/prompts.txtar @@ -45,6 +45,8 @@ code_review "jsonrpc": "2.0", "id": 2, "result": { + "ttlMs": 0, + "cacheScope": "public", "tools": [] } } @@ -52,6 +54,8 @@ code_review "jsonrpc": "2.0", "id": 4, "result": { + "ttlMs": 0, + "cacheScope": "public", "prompts": [ { "arguments": [ diff --git a/mcp/testdata/conformance/server/resources.txtar b/mcp/testdata/conformance/server/resources.txtar index 314817b..1c3ed80 100644 --- a/mcp/testdata/conformance/server/resources.txtar +++ b/mcp/testdata/conformance/server/resources.txtar @@ -67,6 +67,8 @@ info.txt "jsonrpc": "2.0", "id": 2, "result": { + "ttlMs": 0, + "cacheScope": "public", "resources": [ { "mimeType": "text/plain", @@ -85,6 +87,8 @@ info.txt "jsonrpc": "2.0", "id": 3, "result": { + "ttlMs": 0, + "cacheScope": "public", "contents": [ { "uri": "embedded:info", @@ -103,6 +107,8 @@ info.txt "jsonrpc": "2.0", "id": 3, "result": { + "ttlMs": 0, + "cacheScope": "public", "contents": [ { "uri": "file:///info.txt", diff --git a/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar b/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar index 2add92e..356be18 100644 --- a/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar +++ b/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar @@ -87,6 +87,8 @@ infoWithIcon "jsonrpc": "2.0", "id": 2, "result": { + "ttlMs": 0, + "cacheScope": "public", "tools": [ { "description": "return resourceLink content with Icon", @@ -140,6 +142,8 @@ infoWithIcon "jsonrpc": "2.0", "id": 3, "result": { + "ttlMs": 0, + "cacheScope": "public", "resources": [ { "mimeType": "text/plain", @@ -164,6 +168,8 @@ infoWithIcon "jsonrpc": "2.0", "id": 4, "result": { + "ttlMs": 0, + "cacheScope": "public", "prompts": [ { "arguments": [ diff --git a/mcp/testdata/conformance/server/tools.txtar b/mcp/testdata/conformance/server/tools.txtar index aadad12..ba6fd33 100644 --- a/mcp/testdata/conformance/server/tools.txtar +++ b/mcp/testdata/conformance/server/tools.txtar @@ -64,6 +64,8 @@ inc "jsonrpc": "2.0", "id": 2, "result": { + "ttlMs": 0, + "cacheScope": "public", "tools": [ { "description": "say hi", @@ -169,6 +171,8 @@ inc "jsonrpc": "2.0", "id": 3, "result": { + "ttlMs": 0, + "cacheScope": "public", "resources": [] } } @@ -176,6 +180,8 @@ inc "jsonrpc": "2.0", "id": 4, "result": { + "ttlMs": 0, + "cacheScope": "public", "prompts": [] } } diff --git a/mcp/tool.go b/mcp/tool.go index 3ecb59d..11ad100 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -43,7 +43,7 @@ type ToolHandler func(context.Context, *CallToolRequest) (*CallToolResult, error // - If the Out type is not the empty interface [any], it provides the // default output schema for the tool (which again may be overridden in // [AddTool]). -// - The Out value is used to populate result.StructuredOutput. +// - The Out value is used to populate result.StructuredContent. // - If [CallToolResult.Content] is unset, it is populated with the JSON // content of the output. // - An error result is treated as a tool error, rather than a protocol @@ -65,8 +65,14 @@ type serverTool struct { // applySchema validates whether data is valid JSON according to the provided // schema, after applying schema defaults. // -// Returns the JSON value augmented with defaults. -func applySchema(data json.RawMessage, resolved *jsonschema.Resolved) (json.RawMessage, error) { +// If forOutput is false, the data is treated as tool input: the schema's root +// type must be "object" and the value is unmarshaled into a map. +// +// If forOutput is true, the data is treated as tool output: the schema's root +// may be of any type (object, array, primitive, composition). +// +// Returns the JSON value, augmented with defaults where applicable. +func applySchema(data json.RawMessage, resolved *jsonschema.Resolved, forOutput bool) (json.RawMessage, error) { // TODO: use reflection to create the struct type to unmarshal into. // Separate validation from assignment. @@ -75,33 +81,81 @@ func applySchema(data json.RawMessage, resolved *jsonschema.Resolved) (json.RawM // This avoids inconsistent representation due to custom marshallers, such as // time.Time (issue #449). // - // Additionally, unmarshalling into a map ensures that the resulting JSON is + // For input, unmarshalling into a map ensures that the resulting JSON is // at least {}, even if data is empty. For example, arguments is technically // an optional property of callToolParams, and we still want to apply the // defaults in this case. // // TODO(rfindley): in which cases can resolved be nil? - if resolved != nil { + if resolved == nil { + return data, nil + } + + var unmarshaled any + if !forOutput { v := make(map[string]any) if len(data) > 0 { if err := internaljson.Unmarshal(data, &v); err != nil { return nil, fmt.Errorf("unmarshaling arguments: %w", err) } } - if err := resolved.ApplyDefaults(&v); err != nil { - return nil, fmt.Errorf("applying schema defaults:\n%w", err) - } - if err := resolved.Validate(&v); err != nil { - return nil, err - } - // We must re-marshal with the default values applied. - var err error - data, err = json.Marshal(v) - if err != nil { - return nil, fmt.Errorf("marshalling with defaults: %v", err) + unmarshaled = v + } else { + if len(data) > 0 { + if err := internaljson.Unmarshal(data, &unmarshaled); err != nil { + return nil, fmt.Errorf("unmarshaling output: %w", err) + } } } - return data, nil + + // Apply defaults only when the value is a map: jsonschema.Resolved.ApplyDefaults + // only operates on object properties. For object-rooted output schemas, + // coerce a nil result (from "null" or empty data) into {} so handlers that + // return a typed-nil map still validate. + appliedDefaults := false + if _, ok := unmarshaled.(map[string]any); ok { + if err := resolved.ApplyDefaults(&unmarshaled); err != nil { + return nil, fmt.Errorf("applying schema defaults:\n%w", err) + } + appliedDefaults = true + } else if forOutput && unmarshaled == nil && resolved.Schema().Type == "object" { + unmarshaled = make(map[string]any) + if err := resolved.ApplyDefaults(&unmarshaled); err != nil { + return nil, fmt.Errorf("applying schema defaults:\n%w", err) + } + appliedDefaults = true + } + + if err := resolved.Validate(&unmarshaled); err != nil { + return nil, err + } + + // Re-marshal only when defaults may have changed the value. + if !appliedDefaults { + return data, nil + } + out, err := json.Marshal(unmarshaled) + if err != nil { + return nil, fmt.Errorf("marshalling with defaults: %v", err) + } + return out, nil +} + +// isObjectJSON reports whether data is a JSON object (i.e., starts with '{' +// after any leading whitespace). Returns false for arrays, primitives, null, +// or empty input. +func isObjectJSON(data json.RawMessage) bool { + for _, b := range data { + switch b { + case ' ', '\t', '\n', '\r': + continue + case '{': + return true + default: + return false + } + } + return false } // validateToolName checks whether name is a valid tool name, reporting a diff --git a/mcp/tool_example_test.go b/mcp/tool_example_test.go index 8efc3ee..610755b 100644 --- a/mcp/tool_example_test.go +++ b/mcp/tool_example_test.go @@ -253,6 +253,74 @@ func ExampleAddTool_complexSchema() { // weather types: [sun partly_cloudy clouds rain snow] } +// !+contenttypes + +func ExampleAddTool_contentTypes() { + chartPNG := []byte("\x89PNG\r\n\x1a\n") + summaryWAV := []byte("RIFF....WAVE") + + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: "report"}, func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Q3 revenue by region."}, + &mcp.ImageContent{Data: chartPNG, MIMEType: "image/png"}, + &mcp.AudioContent{Data: summaryWAV, MIMEType: "audio/wav"}, + // An embedded resource inlines the contents, so the client + // needs no follow-up resources/read. Binary contents go in + // ResourceContents.Blob instead of Text. + &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ + URI: "file:///reports/q3.csv", + MIMEType: "text/csv", + Text: "region,revenue\nEMEA,42\n", + }, + }, + // A resource link only points at a resource. Prefer it when the + // client may not need the contents, since an embedded resource + // is transferred either way. + &mcp.ResourceLink{URI: "file:///reports/q3.pdf", MIMEType: "application/pdf"}, + }, + }, nil, nil + }) + + ctx := context.Background() + session, err := connect(ctx, server) + if err != nil { + log.Fatal(err) + } + defer session.Close() + + res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "report"}) + if err != nil { + log.Fatal(err) + } + + // Clients type-switch over the content they receive. + for _, content := range res.Content { + switch c := content.(type) { + case *mcp.TextContent: + fmt.Println("text:", c.Text) + case *mcp.ImageContent: + fmt.Printf("image: %s, %d bytes\n", c.MIMEType, len(c.Data)) + case *mcp.AudioContent: + fmt.Printf("audio: %s, %d bytes\n", c.MIMEType, len(c.Data)) + case *mcp.EmbeddedResource: + fmt.Printf("embedded resource: %s (%s)\n", c.Resource.URI, c.Resource.MIMEType) + case *mcp.ResourceLink: + fmt.Printf("resource link: %s (%s)\n", c.URI, c.MIMEType) + } + } + // Output: + // text: Q3 revenue by region. + // image: image/png, 8 bytes + // audio: audio/wav, 12 bytes + // embedded resource: file:///reports/q3.csv (text/csv) + // resource link: file:///reports/q3.pdf (application/pdf) +} + +// !-contenttypes + func connect(ctx context.Context, server *mcp.Server) (*mcp.ClientSession, error) { t1, t2 := mcp.NewInMemoryTransports() if _, err := server.Connect(ctx, t1, nil); err != nil { diff --git a/mcp/tool_test.go b/mcp/tool_test.go index dfd859b..7a82c96 100644 --- a/mcp/tool_test.go +++ b/mcp/tool_test.go @@ -46,7 +46,7 @@ func TestApplySchema(t *testing.T) { {`{"x": 0}`, new(map[string]any), &map[string]any{"x": 0.0}}, } { raw := json.RawMessage(tt.data) - raw, err = applySchema(raw, resolved) + raw, err = applySchema(raw, resolved, false) if err != nil { t.Fatal(err) } @@ -59,6 +59,83 @@ func TestApplySchema(t *testing.T) { } } +func TestApplySchemaOutput(t *testing.T) { + // SEP-2106: when forOutput is true, the schema may have a non-object root. + for _, tc := range []struct { + name string + schema *jsonschema.Schema + data string + want string + wantErr string + }{ + { + name: "array root accepts array", + schema: &jsonschema.Schema{Type: "array", Items: &jsonschema.Schema{Type: "integer"}}, + data: `[1,2,3]`, + want: `[1,2,3]`, + }, + { + name: "array root rejects wrong element type", + schema: &jsonschema.Schema{Type: "array", Items: &jsonschema.Schema{Type: "integer"}}, + data: `[1,"two",3]`, + wantErr: `"integer"`, + }, + { + name: "primitive number root", + schema: &jsonschema.Schema{Type: "number"}, + data: `42`, + want: `42`, + }, + { + name: "primitive string root", + schema: &jsonschema.Schema{Type: "string"}, + data: `"hello"`, + want: `"hello"`, + }, + { + name: "object root with defaults", + schema: &jsonschema.Schema{Type: "object", Properties: map[string]*jsonschema.Schema{"x": {Type: "integer", Default: json.RawMessage("3")}}}, + data: `{}`, + want: `{"x":3}`, + }, + { + name: "empty object root with defaults", + schema: &jsonschema.Schema{Type: "object", Properties: map[string]*jsonschema.Schema{"x": {Type: "integer", Default: json.RawMessage("3")}}}, + data: ``, + want: `{"x":3}`, + }, + { + name: "object root accepts null as empty object", + schema: &jsonschema.Schema{Type: "object"}, + data: `null`, + want: `{}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + resolved, err := tc.schema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) + if err != nil { + t.Fatal(err) + } + got, err := applySchema(json.RawMessage(tc.data), resolved, true) + if tc.wantErr != "" { + if err == nil { + t.Fatalf("got %s, want error containing %q", got, tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("got error %q, want containing %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if string(got) != tc.want { + t.Errorf("got %s, want %s", got, tc.want) + } + }) + } +} + func TestToolErrorHandling(t *testing.T) { // Construct server and add both tools at the top level server := NewServer(testImpl, nil) diff --git a/mcp/transport.go b/mcp/transport.go index 23dccf8..08d3b15 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -10,17 +10,32 @@ import ( "errors" "fmt" "io" - "log" + "log/slog" "net" "os" "sync" + "time" internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" - "github.com/modelcontextprotocol/go-sdk/internal/xcontext" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) +// notifyCancellationTimeout bounds the cancellation notification we send to +// the peer when the caller's context is cancelled. The notification is +// best-effort: a degraded connection (e.g. an OAuth flow that has been +// abandoned) must not be able to block the caller's return path or +// re-trigger expensive recovery on its behalf. See issue #882. +const notifyCancellationTimeout = 5 * time.Second + +// blockingcancelnotify, when set to "1" via MCPGODEBUG, restores the previous +// behavior of blocking the caller's return on delivery of the best-effort +// notifications/cancelled message (up to notifyCancellationTimeout). By +// default, the call is retired immediately and the notification is sent +// asynchronously so the caller returns as soon as its context is cancelled. +var blockingcancelnotify = mcpgodebug.Value("blockingcancelnotify") + // ErrConnectionClosed is returned when sending a message to a connection that // is closed or in the process of closing. var ErrConnectionClosed = errors.New("connection closed") @@ -41,6 +56,19 @@ type Transport interface { Connect(ctx context.Context) (Connection, error) } +// ProtocolVersionSupporter is an optional capability that a [Transport] may +// implement to declare which MCP protocol versions it can serve. +// +// [Server.Connect] consults this interface to filter the +// list of versions advertised in server/discover responses. Transports that +// do not implement this interface are assumed to support every protocol +// version known to the SDK. +type ProtocolVersionSupporter interface { + // SupportsProtocolVersion reports whether the transport can serve + // requests using the given protocol version. + SupportsProtocolVersion(version string) bool +} + // A Connection is a logical bidirectional JSON-RPC connection. type Connection interface { // Read reads the next message to process off the connection. @@ -86,16 +114,27 @@ type clientConnection interface { // TODO: should this interface be exported? type serverConnection interface { Connection + + // sessionUpdated is called whenever the server session state changes. sessionUpdated(ServerSessionState) } +// DefaultMaxLineLength is the default maximum number of bytes buffered while +// decoding a single inbound JSON-RPC frame. +const DefaultMaxLineLength = 16 * 1024 * 1024 + // A StdioTransport is a [Transport] that communicates over stdin/stdout using // newline-delimited JSON. -type StdioTransport struct{} +type StdioTransport struct { + // MaxLineLength bounds the number of bytes that may be buffered while + // decoding a single inbound JSON-RPC frame. A value of 0 selects [DefaultMaxLineLength], + // a negative value disables the cap. + MaxLineLength int +} // Connect implements the [Transport] interface. -func (*StdioTransport) Connect(context.Context) (Connection, error) { - return newIOConn(rwc{os.Stdin, nopCloserWriter{os.Stdout}}), nil +func (t *StdioTransport) Connect(context.Context) (Connection, error) { + return newIOConnLimited(rwc{os.Stdin, nopCloserWriter{os.Stdout}}, t.MaxLineLength), nil } // nopCloserWriter is an io.WriteCloser with a trivial Close method. @@ -110,11 +149,15 @@ func (nopCloserWriter) Close() error { return nil } type IOTransport struct { Reader io.ReadCloser Writer io.WriteCloser + // MaxLineLength bounds the number of bytes that may be buffered while + // decoding a single inbound JSON-RPC frame. A value of 0 selects [DefaultMaxLineLength], + // a negative value disables the cap. + MaxLineLength int } // Connect implements the [Transport] interface. func (t *IOTransport) Connect(context.Context) (Connection, error) { - return newIOConn(rwc{t.Reader, t.Writer}), nil + return newIOConnLimited(rwc{t.Reader, t.Writer}, t.MaxLineLength), nil } // An InMemoryTransport is a [Transport] that communicates over an in-memory @@ -152,7 +195,9 @@ type handler interface { handle(ctx context.Context, req *jsonrpc.Request) (any, error) } -func connect[H handler, State any](ctx context.Context, t Transport, b binder[H, State], s State, onClose func()) (H, error) { +// connect wires a transport to a binder. logger must be non-nil; it receives +// jsonrpc2 internal errors that would otherwise be dropped (see #218). +func connect[H handler, State any](ctx context.Context, t Transport, b binder[H, State], s State, onClose func(), logger *slog.Logger) (H, error) { var zero H mcpConn, err := t.Connect(ctx) if err != nil { @@ -162,13 +207,20 @@ func connect[H handler, State any](ctx context.Context, t Transport, b binder[H, reader, writer := jsonrpc2.Reader(mcpConn), jsonrpc2.Writer(mcpConn) var ( h H - preempter canceller + preempter = canceller{logger: logger} ) bind := func(conn *jsonrpc2.Connection) jsonrpc2.Handler { h = b.bind(mcpConn, conn, s, onClose) preempter.conn = conn return jsonrpc2.HandlerFunc(h.handle) } + // Transports may opt in to propagating cancellation of ctx into request + // handler contexts when their own lifecycle IS the cancellation signal + // (e.g., a connection bound to a single HTTP request). + var propagateCancellation bool + if cp, ok := mcpConn.(cancellationPropagator); ok { + propagateCancellation = cp.propagateCancellation() + } _ = jsonrpc2.NewConnection(ctx, jsonrpc2.ConnectionConfig{ Reader: reader, Writer: writer, @@ -178,16 +230,30 @@ func connect[H handler, State any](ctx context.Context, t Transport, b binder[H, OnDone: func() { b.disconnect(h) }, - OnInternalError: func(err error) { log.Printf("jsonrpc2 error: %v", err) }, + OnInternalError: func(err error) { + logger.Error("jsonrpc2 internal error", "error", err) + }, + PropagateCancellation: propagateCancellation, }) assert(preempter.conn != nil, "unbound preempter") return h, nil } +// cancellationPropagator is an optional interface implemented by a +// [Connection] whose own lifecycle should propagate cancellation into request +// handler contexts. The default jsonrpc2 behavior is to suppress propagation; +// transports that bind a connection to a single short-lived carrier (such as +// a one-shot HTTP request) should return true here so that handlers unwind +// when the carrier observes the peer going away. +type cancellationPropagator interface { + propagateCancellation() bool +} + // A canceller is a jsonrpc2.Preempter that cancels in-flight requests on MCP // cancelled notifications. type canceller struct { - conn *jsonrpc2.Connection + conn *jsonrpc2.Connection + logger *slog.Logger } // Preempt implements [jsonrpc2.Preempter]. @@ -201,11 +267,51 @@ func (c *canceller) Preempt(ctx context.Context, req *jsonrpc.Request) (result a if err != nil { return nil, err } - go c.conn.Cancel(id) + // The spec says implementations should log cancellation reasons, and + // the handler is the one place that can act on one, so the reason + // travels as the cause of the request's context rather than being + // dropped here. + c.logger.Debug("request cancelled by the peer", "id", id.Raw(), "reason", params.Reason) + go c.conn.CancelCause(id, &peerCancelledError{reason: params.Reason}) } return nil, jsonrpc2.ErrNotHandled } +// A peerCancelledError is the cause a request's context carries once the peer +// has sent a cancelled notification for it: [context.Cause] returns it to the +// handler with the reason the peer gave, and it unwraps to [context.Canceled] +// so that [errors.Is] keeps classifying the cancellation as one. +type peerCancelledError struct { + reason string +} + +func (e *peerCancelledError) Error() string { + if e.reason == "" { + return "request cancelled by the peer" + } + return "request cancelled by the peer: " + e.reason +} + +func (e *peerCancelledError) Unwrap() error { return context.Canceled } + +// callSubscriptionsListen issues a "subscriptions/listen" call (SEP-2575) +// without awaiting its JSON-RPC response. The call's logical lifetime is the +// stream of notifications that follow on the same channel — the empty +// response, if ever delivered, only marks subscription teardown — so the +// caller has nothing useful to block on. +// +// Cancellation is driven by ctx: when it is cancelled, a background goroutine +// sends a "notifications/cancelled" notification referencing the listen's +// request ID and retires the call from the connection's outgoing-calls map. +func callSubscriptionsListen(ctx context.Context, conn *jsonrpc2.Connection, method string, params Params) { + call := conn.Call(ctx, method, params) + + go func() { + <-ctx.Done() + _ = cancelCall(ctx, conn, call) + }() +} + // call executes and awaits a jsonrpc2 call on the given connection, // translating errors into the mcp domain. func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params Params, result Result) error { @@ -216,30 +322,58 @@ func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params case errors.Is(err, jsonrpc2.ErrClientClosing), errors.Is(err, jsonrpc2.ErrServerClosing): return fmt.Errorf("%w: calling %q: %v", ErrConnectionClosed, method, err) case ctx.Err() != nil: - // Notify the peer of cancellation. - err := conn.Notify(xcontext.Detach(ctx), notificationCancelled, &CancelledParams{ - Reason: ctx.Err().Error(), - RequestID: call.ID().Raw(), - }) - // By default, the jsonrpc2 library waits for graceful shutdown when the - // connection is closed, meaning it expects all outgoing and incoming - // requests to complete. However, for MCP this expectation is unrealistic, - // and can lead to hanging shutdown. For example, if a streamable client is - // killed, the server will not be able to detect this event, except via - // keepalive pings (if they are configured), and so outgoing calls may hang - // indefinitely. + // The notifications/cancelled message is best-effort. Retire the call + // immediately (so an unresponsive peer cannot delay the eager + // retirement that cancelCall's docstring promises) and send the + // notification off the caller's return path so a slow or unresponsive + // peer cannot delay the caller past its own deadline. See issue #1150. // - // Therefore, we choose to eagerly retire calls, removing them from the - // outgoingCalls map, when the caller context is cancelled: if the caller - // will never receive the response, there's no need to track it. + // Setting MCPGODEBUG=blockingcancelnotify=1 restores the previous + // behavior of waiting synchronously for delivery inside cancelCall. + if blockingcancelnotify == "1" { + err := cancelCall(ctx, conn, call) + return errors.Join(ctx.Err(), err) + } conn.Retire(call, ctx.Err()) - return errors.Join(ctx.Err(), err) + go func() { + notifyCtx, stop := context.WithTimeout(context.WithoutCancel(ctx), notifyCancellationTimeout) + defer stop() + _ = conn.Notify(notifyCtx, notificationCancelled, &CancelledParams{ + Reason: ctx.Err().Error(), + RequestID: call.ID().Raw(), + }) + }() + return ctx.Err() case err != nil: return fmt.Errorf("calling %q: %w", method, err) } return nil } +// cancelCall sends a "notifications/cancelled" notification for call and eagerly +// retires it from conn. +// +// By default, the jsonrpc2 library waits for graceful shutdown when the +// connection is closed, meaning it expects all outgoing and incoming requests +// to complete. However, for MCP this expectation is unrealistic, and can lead +// to hanging shutdown. For example, if a streamable client is killed, the +// server will not be able to detect this event, except via keepalive pings (if +// they are configured), and so outgoing calls may hang indefinitely. +// +// Therefore, we choose to eagerly retire calls, removing them from the +// outgoingCalls map, when the caller context is cancelled: if the caller will +// never receive the response, there's no need to track it. +func cancelCall(ctx context.Context, conn *jsonrpc2.Connection, call *jsonrpc2.AsyncCall) error { + notifyCtx, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), notifyCancellationTimeout) + defer cancelNotify() + err := conn.Notify(notifyCtx, notificationCancelled, &CancelledParams{ + Reason: ctx.Err().Error(), + RequestID: call.ID().Raw(), + }) + conn.Retire(call, ctx.Err()) + return err +} + // A LoggingTransport is a [Transport] that delegates to another transport, // writing RPC logs to an io.Writer. type LoggingTransport struct { @@ -380,6 +514,17 @@ type msgOrErr struct { } func newIOConn(rwc io.ReadWriteCloser) *ioConn { + return newIOConnLimited(rwc, DefaultMaxLineLength) +} + +// newIOConnLimited builds an [ioConn] over rwc that bounds the number of bytes +// buffered while decoding a single inbound JSON-RPC frame to maxLineLength. +// maxLineLength == 0 selects [DefaultMaxLineLength], a negative value means no cap. +func newIOConnLimited(rwc io.ReadWriteCloser, maxLineLength int) *ioConn { + limit := maxLineLength + if limit == 0 { + limit = DefaultMaxLineLength + } var ( incoming = make(chan msgOrErr) closed = make(chan struct{}) @@ -391,7 +536,15 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn { // but that is unavoidable since AFAIK there is no (easy and portable) way to // guarantee that reads of stdin are unblocked when closed. go func() { - dec := json.NewDecoder(rwc) + var ( + reader io.Reader = rwc + limiter *frameLimitReader + ) + if limit > 0 { + limiter = &frameLimitReader{r: rwc, limit: limit} + reader = limiter + } + dec := json.NewDecoder(reader) for { var raw json.RawMessage err := dec.Decode(&raw) @@ -417,6 +570,9 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn { if err != nil { return } + if limiter != nil { + limiter.resetFrame() + } } }() return &ioConn{ @@ -426,20 +582,42 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn { } } +// errFrameTooLarge means that a single inbound JSON-RPC frame exceeded the configured byte +// limit before the value was completely received. +var errFrameTooLarge = errors.New("inbound JSON-RPC frame exceeded the configured maximum line length") + +// frameLimitReader bounds the number of bytes [json.Decoder] may buffer while +// decoding a single JSON value. Read returns [errFrameTooLarge] once the budget is exhausted. +type frameLimitReader struct { + r io.Reader + limit int + count int +} + +func (r *frameLimitReader) Read(p []byte) (int, error) { + if r.count >= r.limit { + return 0, errFrameTooLarge + } + if len(p) > r.limit-r.count { + p = p[:r.limit-r.count] + } + n, err := r.r.Read(p) + r.count += n + return n, err +} + +func (r *frameLimitReader) resetFrame() { r.count = 0 } + func (c *ioConn) SessionID() string { return "" } func (c *ioConn) sessionUpdated(state ServerSessionState) { - protocolVersion := "" - if state.InitializeParams != nil { - protocolVersion = state.InitializeParams.ProtocolVersion - } + protocolVersion := state.NegotiatedProtocolVersion if protocolVersion == "" { // 2025-03-26 is used, because it's the last spec version // where specifying the protocol version in the HTTP header // was not required. protocolVersion = protocolVersion20250326 } - protocolVersion = negotiatedVersion(protocolVersion) c.sessionMu.Lock() c.protocolVersion = protocolVersion c.sessionMu.Unlock() @@ -556,7 +734,7 @@ func (t *ioConn) Read(ctx context.Context) (jsonrpc.Message, error) { if batch { var respBatch *msgBatch // track incoming requests in the batch for _, msg := range msgs { - if req, ok := msg.(*jsonrpc.Request); ok { + if req, ok := msg.(*jsonrpc.Request); ok && req.IsCall() { if respBatch == nil { respBatch = &msgBatch{ unresolved: make(map[jsonrpc2.ID]int), diff --git a/mcp/transport_example_test.go b/mcp/transport_example_test.go index fa05fc0..e264133 100644 --- a/mcp/transport_example_test.go +++ b/mcp/transport_example_test.go @@ -42,9 +42,8 @@ func ExampleLoggingTransport() { } // Output: - // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-11-25","serverInfo":{"name":"server","version":"v0.0.1"}}} - // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-11-25","capabilities":{"roots":{"listChanged":true}}}} - // write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} + // read: {"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"server","version":"v0.0.1"}},"ttlMs":0,"cacheScope":"public","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"capabilities":{"logging":{}}}} + // write: {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/clientCapabilities":{"roots":{"listChanged":true}},"io.modelcontextprotocol/clientInfo":{"name":"client","version":"v0.0.1"},"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}} } // !-loggingtransport diff --git a/mcp/transport_test.go b/mcp/transport_test.go index 515b8c1..a20ee5f 100644 --- a/mcp/transport_test.go +++ b/mcp/transport_test.go @@ -5,10 +5,14 @@ package mcp import ( + "bytes" "context" + "errors" + "fmt" "io" "strings" "testing" + "time" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" @@ -56,10 +60,15 @@ func TestBatchFraming(t *testing.T) { func TestIOConnRead(t *testing.T) { tests := []struct { - name string - input string - want string + name string + input string + want string + // protocolVersion is the version negotiated by 'initialize'; empty + // means no session state was pushed to the connection. protocolVersion string + // requested is the version the client asked for, which differs from + // protocolVersion when the server counter-offered. + requested string }{ { name: "valid json input", @@ -100,6 +109,16 @@ func TestIOConnRead(t *testing.T) { want: "JSON-RPC batching is not supported in 2025-06-18 and later (request version: 2025-06-18)", protocolVersion: protocolVersion20250618, }, + { + // The client asked for a version the server does not negotiate, so + // the connection must follow the server's counter-offer (which + // forbids batching) rather than the client's request. + name: "batching at a counter-offered version", + input: `[{"jsonrpc":"2.0","id":1,"method":"test1"},{"jsonrpc":"2.0","id":2,"method":"test2"}]`, + want: "JSON-RPC batching is not supported in 2025-06-18 and later (request version: 2025-11-25)", + requested: protocolVersion20241105, + protocolVersion: protocolVersion20251125, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -108,10 +127,15 @@ func TestIOConnRead(t *testing.T) { }) t.Cleanup(func() { tr.Close() }) if tt.protocolVersion != "" { + requested := tt.requested + if requested == "" { + requested = tt.protocolVersion + } tr.sessionUpdated(ServerSessionState{ InitializeParams: &InitializeParams{ - ProtocolVersion: tt.protocolVersion, + ProtocolVersion: requested, }, + NegotiatedProtocolVersion: tt.protocolVersion, }) } _, err := tr.Read(context.Background()) @@ -124,3 +148,210 @@ func TestIOConnRead(t *testing.T) { }) } } + +// go-sdk#976: stdio must surface empty-method calls as requests, not responses. +func TestIOConnRead_EmptyMethod(t *testing.T) { + tr := newIOConn(rwc{ + rc: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":5,"method":"","params":{}}`)), + }) + t.Cleanup(func() { tr.Close() }) + + msg, err := tr.Read(context.Background()) + if err != nil { + t.Fatalf("ioConn.Read() error = %v", err) + } + req, ok := msg.(*jsonrpc.Request) + if !ok { + t.Fatalf("message type = %T, want *jsonrpc.Request", msg) + } + if req.Method != "" { + t.Errorf("Method = %q, want empty string", req.Method) + } + if req.ID != jsonrpc2.Int64ID(5) { + t.Errorf("ID = %v, want 5", req.ID.Raw()) + } +} + +// TestIOConnReadBatchNotifications is a regression test for go-sdk#1256: +// notifications carried inside a JSON-RPC batch must not be tracked as awaiting +// a response. Only calls belong in a batch's response array, so a batch's +// notifications must neither keep it from completing nor pollute the +// per-connection duplicate-ID tracking. +func TestIOConnReadBatchNotifications(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + // input is one or more newline-separated batch payloads. + input string + // wantCalls records, for each message Read returns in order, whether it + // is a call (true) or a notification (false). Reading every message + // must succeed. + wantCalls []bool + // wantTrackedCalls is the number of in-flight call requests tracked across + // batches awaiting a response once every message has been read, before any + // response is written. + wantTrackedCalls int + // respondIDs are the call IDs to respond to, in order. + respondIDs []int64 + // wantWrites are the batch response payloads expected on the wire, each + // matched as a substring. + wantWrites []string + }{ + { + name: "call and notification is answered", + input: `[{"jsonrpc":"2.0","id":2,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]`, + wantCalls: []bool{true, false}, + wantTrackedCalls: 1, + respondIDs: []int64{2}, + wantWrites: []string{`[{"jsonrpc":"2.0","id":2,"result":{}}]`}, + }, + { + name: "only notifications keeps the connection open", + input: `[{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":98}},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]`, + wantCalls: []bool{false, false}, + wantTrackedCalls: 0, + }, + { + name: "repeated batches holding a notification", + input: `[{"jsonrpc":"2.0","id":2,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]` + "\n" + + `[{"jsonrpc":"2.0","id":3,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]`, + wantCalls: []bool{true, false, true, false}, + wantTrackedCalls: 2, + respondIDs: []int64{2, 3}, + wantWrites: []string{ + `[{"jsonrpc":"2.0","id":2,"result":{}}]`, + `[{"jsonrpc":"2.0","id":3,"result":{}}]`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sink := new(bytes.Buffer) + tr := newIOConn(rwc{ + rc: io.NopCloser(strings.NewReader(tt.input)), + wc: nopCloserWriter{sink}, + }) + t.Cleanup(func() { tr.Close() }) + + for i, wantCall := range tt.wantCalls { + msg, err := tr.Read(ctx) + if err != nil { + t.Fatalf("Read() #%d: unexpected error: %v", i, err) + } + req, ok := msg.(*jsonrpc.Request) + if !ok { + t.Fatalf("Read() #%d = %T, want *jsonrpc.Request", i, msg) + } + if req.IsCall() != wantCall { + t.Fatalf("Read() #%d IsCall() = %t, want %t", i, req.IsCall(), wantCall) + } + } + + // Only calls are tracked as awaiting a response; notifications must + // not linger in the batch bookkeeping. + tr.batchMu.Lock() + gotTrackedCalls := len(tr.batches) + tr.batchMu.Unlock() + if gotTrackedCalls != tt.wantTrackedCalls { + t.Errorf("tracked calls = %d, want %d", gotTrackedCalls, tt.wantTrackedCalls) + } + + for _, id := range tt.respondIDs { + resp := &jsonrpc.Response{ID: jsonrpc2.Int64ID(id), Result: []byte("{}")} + if err := tr.Write(ctx, resp); err != nil { + t.Fatalf("Write(response id=%d): %v", id, err) + } + } + + got := sink.String() + for _, want := range tt.wantWrites { + if !strings.Contains(got, want) { + t.Errorf("batch responses = %q, missing %q", got, want) + } + } + }) + } +} + +// TestIOConnFrameCap verifies single inbound frame cap enforcement. +func TestIOConnFrameCap(t *testing.T) { + tests := []struct { + name string + r io.ReadCloser + limit int + wantMessageCount int + wantErr bool + }{ + { + name: "infinite string", + r: &endlessReader{prefix: `"`, repeat: "A"}, + limit: 1024, + wantErr: true, + }, + { + name: "cap does not reset per new line", + r: &endlessReader{prefix: "[", repeat: "0,\n"}, + limit: 1024, + wantErr: true, + }, + { + name: "cap resets per message", + limit: 1024, // < 600 * 3 + r: io.NopCloser(strings.NewReader(func() string { + var b strings.Builder + for i := range 3 { + fmt.Fprintf(&b, `{"jsonrpc":"2.0","id":%d,"method":"test","params":{"pad":"`, i) + b.WriteString(strings.Repeat("A", 600)) + b.WriteString(`"}}`) + b.WriteByte('\n') + } + return b.String() + }())), + wantMessageCount: 3, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tr := newIOConnLimited(rwc{rc: tt.r}, tt.limit) + t.Cleanup(func() { tr.Close() }) + + read := func() (jsonrpc.Message, error) { + type result struct { + msg jsonrpc.Message + err error + } + done := make(chan result, 1) + + go func() { + msg, err := tr.Read(context.Background()) + done <- result{msg, err} + }() + + select { + case r := <-done: + return r.msg, r.err + case <-time.After(10 * time.Second): + t.Fatal("Read() did not return: frame cap failed to trip") + return nil, nil + } + } + + for i := range tt.wantMessageCount { + msg, err := read() + if err != nil { + t.Fatalf("Read() #%d error = %v, want nil", i, err) + } + if msg == nil { + t.Fatalf("Read() #%d returned nil message", i) + } + } + if tt.wantErr { + if _, err := read(); !errors.Is(err, errFrameTooLarge) { + t.Fatalf("Read() error = %v, want errFrameTooLarge", err) + } + } + }) + } +} diff --git a/mkdocs.yaml b/mkdocs.yaml new file mode 100644 index 0000000..e978c85 --- /dev/null +++ b/mkdocs.yaml @@ -0,0 +1,98 @@ +site_name: MCP Go SDK +site_url: https://go.sdk.modelcontextprotocol.io/ +site_description: Golang SDK for the Model Context Protocol - standardized integration between AI models and tools + +repo_url: https://github.com/modelcontextprotocol/go-sdk +repo_name: modelcontextprotocol/go-sdk +edit_uri: edit/main/docs/ + +theme: + name: material + favicon: images/logo-dark.svg + logo: images/logo-light.svg + palette: + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: cyan + accent: teal + toggle: + icon: material/lightbulb + name: "Switch to light mode" + - media: "(prefers-color-scheme: light)" + scheme: default + primary: cyan + accent: teal + toggle: + icon: material/lightbulb-outline + name: "Switch to dark mode" + + features: + - navigation.instant + - navigation.instant.progress + - navigation.tabs + - navigation.tabs.sticky + - navigation.sections + - navigation.top + - navigation.path + - navigation.indexes + - toc.follow + - search.suggest + - search.highlight + - content.code.copy + - content.code.annotate + + +nav: + - Documentation: + - Overview: README.md + - Quick Start: quick_start.md + - LifeCycle: protocol.md + - MCP Components: + - MCP Client: client.md + - MCP Server: server.md + - Troubleshooting: troubleshooting.md + - Backwards Compatibility: mcpgodebug.md + - Rough Edges: rough_edges.md + - Contributing: + - Contributing Guide: contributing.md + - API Reference: https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk + + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + - pymdownx.inlinehilite + - pymdownx.snippets: + base_path: . + - pymdownx.mark + - pymdownx.critic + - pymdownx.caret + - pymdownx.keys + - pymdownx.tilde + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - attr_list + - md_in_html + - tables + - toc: + permalink: true + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/modelcontextprotocol/go-sdk + generator: false + +plugins: + - search \ No newline at end of file diff --git a/oauthex/audience.go b/oauthex/audience.go new file mode 100644 index 0000000..cad1303 --- /dev/null +++ b/oauthex/audience.go @@ -0,0 +1,44 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package oauthex + +import "strings" + +// MatchesResource reports whether any of claims matches resource under +// RFC 3986 §6.2.3 scheme-based normalization, narrowed to the empty-path +// case: a URI with an empty path is treated as equivalent to one with a +// path of "/". All other URI components (scheme, host case, port, query, +// fragment) must match exactly — this is intentionally narrower than the +// full §6.2.3 rung, which would also fold scheme/host case and default +// ports. +// +// RFC 9728 §3.3 normatively requires "simple string comparison" per +// RFC 3986 §6.2.1 (byte-equal). Callers that need strict byte-equal +// semantics should compare claims to resource directly: +// +// for _, c := range claims { if c == resource { return true } } +// +// Background: RFC 9728 §3.3 canonicalises the protected-resource +// identifier with a trailing slash, but RFC 8707 resource indicators +// sometimes omit it, and upstream IdPs vary in which form they emit in +// `aud` claims (Google trims, Auth0 retains, claude.ai round-trips +// whichever it received). Strict byte equality therefore fails routinely +// on legitimate setups; this helper is the most common pragmatic relaxation +// while keeping path/scheme/host strict so token confusion across distinct +// resources still fails closed. +// +// Returns false when claims is empty. +func MatchesResource(claims []string, resource string) bool { + expected := strings.TrimSuffix(resource, "/") + for _, c := range claims { + if c == resource { + return true + } + if strings.TrimSuffix(c, "/") == expected { + return true + } + } + return false +} diff --git a/oauthex/audience_test.go b/oauthex/audience_test.go new file mode 100644 index 0000000..7d5acfb --- /dev/null +++ b/oauthex/audience_test.go @@ -0,0 +1,101 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package oauthex + +import "testing" + +func TestMatchesResource(t *testing.T) { + tests := []struct { + name string + claims []string + resource string + want bool + }{ + { + name: "exact match", + claims: []string{"https://mcp.example.com/"}, + resource: "https://mcp.example.com/", + want: true, + }, + { + name: "claim has trailing slash, resource does not", + claims: []string{"https://mcp.example.com/"}, + resource: "https://mcp.example.com", + want: true, + }, + { + name: "claim missing trailing slash, resource has one", + claims: []string{"https://mcp.example.com"}, + resource: "https://mcp.example.com/", + want: true, + }, + { + name: "multiple claims, one matches", + claims: []string{"https://other.example.com/", "https://mcp.example.com"}, + resource: "https://mcp.example.com/", + want: true, + }, + { + name: "no claims", + claims: nil, + resource: "https://mcp.example.com/", + want: false, + }, + { + name: "claim does not match resource", + claims: []string{"https://attacker.example.com/"}, + resource: "https://mcp.example.com/", + want: false, + }, + { + name: "path mismatch is not tolerated", + claims: []string{"https://mcp.example.com/v2"}, + resource: "https://mcp.example.com", + want: false, + }, + { + name: "scheme mismatch is not tolerated", + claims: []string{"http://mcp.example.com/"}, + resource: "https://mcp.example.com/", + want: false, + }, + // The following document the intentional boundaries: §6.2.3 also + // permits scheme/host case folding and default-port elision, but + // MatchesResource deliberately does NOT, so two distinct + // registered resources cannot collide via these normalizations. + { + name: "host case difference is not tolerated", + claims: []string{"https://MCP.example.com/"}, + resource: "https://mcp.example.com/", + want: false, + }, + { + name: "default-port elision is not tolerated", + claims: []string{"https://mcp.example.com:443/"}, + resource: "https://mcp.example.com/", + want: false, + }, + { + name: "query string difference is not tolerated", + claims: []string{"https://mcp.example.com/?x=y"}, + resource: "https://mcp.example.com/", + want: false, + }, + { + name: "surrounding whitespace is not tolerated (malformed claim)", + claims: []string{" https://mcp.example.com/ "}, + resource: "https://mcp.example.com/", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MatchesResource(tt.claims, tt.resource) + if got != tt.want { + t.Errorf("MatchesResource(%v, %q) = %v, want %v", tt.claims, tt.resource, got, tt.want) + } + }) + } +} diff --git a/oauthex/auth_meta.go b/oauthex/auth_meta.go index df1a0aa..711b17a 100644 --- a/oauthex/auth_meta.go +++ b/oauthex/auth_meta.go @@ -12,6 +12,8 @@ import ( "errors" "fmt" "net/http" + + "github.com/modelcontextprotocol/go-sdk/internal/authutil" ) // AuthServerMeta represents the metadata for an OAuth 2.0 authorization server, @@ -113,6 +115,13 @@ type AuthServerMeta struct { // ClientIDMetadataDocumentSupported is a boolean indicating whether the authorization server // supports client ID metadata documents. ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported,omitempty"` + + // AuthorizationResponseIssParameterSupported indicates whether the authorization server + // provides the "iss" parameter in authorization responses per [RFC 9207]. + // When true, clients must verify the "iss" parameter is present and matches the Issuer field. + // + // [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207 + AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported,omitempty"` } // GetAuthServerMeta issues a GET request to retrieve authorization server metadata @@ -145,8 +154,7 @@ func GetAuthServerMeta(ctx context.Context, metadataURL, issuer string, c *http. } return nil, fmt.Errorf("%v", err) // Do not expose error types. } - if asm.Issuer != issuer { - // Validate the Issuer field (see RFC 8414, section 3.3). + if !authutil.IssuersEqual(asm.Issuer, issuer) { return nil, fmt.Errorf("metadata issuer %q does not match issuer URL %q", asm.Issuer, issuer) } diff --git a/oauthex/auth_meta_test.go b/oauthex/auth_meta_test.go index f390676..5c6c4ad 100644 --- a/oauthex/auth_meta_test.go +++ b/oauthex/auth_meta_test.go @@ -35,9 +35,10 @@ func TestAuthMetaParse(t *testing.T) { func TestGetAuthServerMetaPKCESupport(t *testing.T) { ctx := context.Background() tests := []struct { - name string - hasPKCESupport bool - wantError string + name string + hasPKCESupport bool + wantError string + issuerWithTrailingSlash bool }{ { name: "server_with_pkce_support", @@ -48,6 +49,12 @@ func TestGetAuthServerMetaPKCESupport(t *testing.T) { hasPKCESupport: false, wantError: "does not implement PKCE", }, + { + // ProtectedResourceMetadata may contain AuthorizationServers with a trailing slash (see Issue #953) + name: "issuer_with_trailing_slash", + hasPKCESupport: true, + issuerWithTrailingSlash: true, + }, } for _, tt := range tests { @@ -85,6 +92,9 @@ func TestGetAuthServerMetaPKCESupport(t *testing.T) { u, _ := url.Parse(ts.URL) issuer := "https://localhost:" + u.Port() metadataURL := issuer + "/.well-known/oauth-authorization-server" + if tt.issuerWithTrailingSlash { + issuer += "/" + } // The fake server presents a cert for example.com; set ServerName accordingly. httpClient := ts.Client() diff --git a/oauthex/client.go b/oauthex/client.go index e8f9918..1b19eed 100644 --- a/oauthex/client.go +++ b/oauthex/client.go @@ -18,6 +18,15 @@ type ClientCredentials struct { // This is the most common authentication method for confidential clients. // OPTIONAL. If not provided, the client is treated as a public client. ClientSecretAuth *ClientSecretAuth + + // Issuer is the issuer identifier of the authorization server these + // credentials are registered with. Pre-registered credentials are bound + // to a specific authorization server; when set, an error is returned if + // the discovered authorization server does not match, per SEP-2352. + // The comparison ignores a single trailing slash, matching the + // tolerance applied during RFC 8414 Section 3.3 metadata validation. + // OPTIONAL. + Issuer string } // ClientSecretAuth holds client secret authentication credentials. diff --git a/oauthex/client_test.go b/oauthex/client_test.go new file mode 100644 index 0000000..34d8188 --- /dev/null +++ b/oauthex/client_test.go @@ -0,0 +1,88 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package oauthex + +import ( + "reflect" + "strings" + "testing" +) + +func TestClientCredentials_Validate(t *testing.T) { + tests := []struct { + name string + creds ClientCredentials + wantErr string + }{ + { + name: "valid public client", + creds: ClientCredentials{ClientID: "my-client"}, + }, + { + name: "valid confidential client", + creds: ClientCredentials{ + ClientID: "my-client", + ClientSecretAuth: &ClientSecretAuth{ + ClientSecret: "my-secret", + }, + }, + }, + { + name: "empty client ID", + creds: ClientCredentials{}, + wantErr: "ClientID is required", + }, + { + name: "empty secret in ClientSecretAuth", + creds: ClientCredentials{ + ClientID: "my-client", + ClientSecretAuth: &ClientSecretAuth{}, + }, + wantErr: "ClientSecret is required", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.creds.Validate() + if tt.wantErr == "" { + if err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatalf("Validate() expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Validate() error = %v, want error containing %q", err, tt.wantErr) + } + }) + } +} + +// TestClientCredentials_ValidateCoversAllAuthFields uses reflection to detect +// when new authentication method fields are added to ClientCredentials without +// updating Validate. If this test fails, update Validate() to handle the new +// field and increment knownAuthMethods. +func TestClientCredentials_ValidateCoversAllAuthFields(t *testing.T) { + const knownAuthMethods = 1 // ClientSecretAuth + + typ := reflect.TypeOf(ClientCredentials{}) + var pointerFields int + for i := range typ.NumField() { + f := typ.Field(i) + if f.Name == "ClientID" || f.Name == "Issuer" { + continue + } + if f.Type.Kind() != reflect.Ptr { + t.Errorf("field %q is %v, expected a pointer to an auth method struct", f.Name, f.Type.Kind()) + } + pointerFields++ + } + + if pointerFields != knownAuthMethods { + t.Fatalf("ClientCredentials has %d auth method fields but Validate only knows about %d -- update Validate() and this test", pointerFields, knownAuthMethods) + } +} diff --git a/oauthex/dcr.go b/oauthex/dcr.go index 653cf5b..f95c55b 100644 --- a/oauthex/dcr.go +++ b/oauthex/dcr.go @@ -89,6 +89,11 @@ type ClientRegistrationMetadata struct { // SoftwareStatement is an OPTIONAL JWT that asserts client metadata values. // Values in the software statement take precedence over other metadata values. SoftwareStatement string `json:"software_statement,omitempty"` + + // ApplicationType is an OPTIONAL string that indicates the type of application. + // Valid values are "native" and "web". + // If omitted, OIDC-compliant authorization servers default to "web". + ApplicationType string `json:"application_type,omitempty"` } // ClientRegistrationResponse represents the fields returned by the Authorization Server @@ -170,6 +175,10 @@ func (e *ClientRegistrationError) Error() string { return fmt.Sprintf("registration failed: %s (%s)", e.ErrorCode, e.ErrorDescription) } +// maxRegistrationResponseBytes bounds the dynamic client registration response +// body read from the authorization server. +const maxRegistrationResponseBytes = 1 << 20 // 1 MiB + // RegisterClient performs Dynamic Client Registration according to RFC 7591. func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta *ClientRegistrationMetadata, c *http.Client) (*ClientRegistrationResponse, error) { if registrationEndpoint == "" { @@ -199,12 +208,12 @@ func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxRegistrationResponseBytes)) if err != nil { return nil, fmt.Errorf("failed to read registration response body: %w", err) } - if resp.StatusCode == http.StatusCreated { + if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK { var regResponse ClientRegistrationResponse if err := internaljson.Unmarshal(body, ®Response); err != nil { return nil, fmt.Errorf("failed to decode successful registration response: %w (%s)", err, string(body)) diff --git a/oauthex/dcr_test.go b/oauthex/dcr_test.go index ec94c49..afff34c 100644 --- a/oauthex/dcr_test.go +++ b/oauthex/dcr_test.go @@ -70,6 +70,30 @@ func TestRegisterClient(t *testing.T) { clientMeta: &ClientRegistrationMetadata{ClientName: "Test App", RedirectURIs: []string{"http://localhost/cb"}}, wantClientID: "test-client-id", }, + { + name: "Success with 200", + handler: func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Expected POST, got %s", r.Method) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + var receivedMeta ClientRegistrationMetadata + if err := json.Unmarshal(body, &receivedMeta); err != nil { + t.Fatalf("Failed to unmarshal request body: %v", err) + } + if receivedMeta.ClientName != "Test App" { + t.Errorf("Expected ClientName 'Test App', got '%s'", receivedMeta.ClientName) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"client_id":"test-client-id","client_secret":"test-client-secret","client_name":"Test App"}`)) + }, + clientMeta: &ClientRegistrationMetadata{ClientName: "Test App", RedirectURIs: []string{"http://localhost/cb"}}, + wantClientID: "test-client-id", + }, { name: "Missing ClientID in Response", handler: func(w http.ResponseWriter, r *http.Request) { diff --git a/oauthex/discovery_test.go b/oauthex/discovery_test.go new file mode 100644 index 0000000..8b64d0c --- /dev/null +++ b/oauthex/discovery_test.go @@ -0,0 +1,158 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package oauthex + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCheckHTTPSOrLoopback(t *testing.T) { + tests := []struct { + name string + addr string + wantErr bool + }{ + {name: "empty", addr: "", wantErr: false}, + {name: "https dns allowed", addr: "https://example.com/prm", wantErr: false}, + {name: "cleartext dns disallowed", addr: "http://example.com/prm", wantErr: true}, + {name: "https loopback ip allowed", addr: "http://127.0.0.1:8080/prm", wantErr: false}, + {name: "http localhost allowed", addr: "http://localhost/prm", wantErr: false}, + {name: "https private ip disallowed", addr: "https://10.0.0.1/prm", wantErr: true}, + {name: "link local metadata disallowed", addr: "https://169.254.169.254/", wantErr: true}, + {name: "mapped private address disallowed", addr: "https://[::ffff:10.0.0.1]/prm", wantErr: true}, + {name: "https public ip allowed", addr: "https://8.8.8.8/prm", wantErr: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkHTTPSOrLoopback(tt.addr) + if (err != nil) != tt.wantErr { + t.Errorf("checkHTTPSOrLoopback(%q) error = %v, wantErr %v", tt.addr, err, tt.wantErr) + } + }) + } +} + +func TestDiscoveryClientRedirect(t *testing.T) { + newRequestTo := func(u string) *http.Request { + t.Helper() + r, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + t.Fatalf("NewRequest(%q): %v", u, err) + } + return r + } + testCases := []struct { + name string + custom *http.Client + req *http.Request + via []*http.Request + wantBlocked bool + }{ + { + name: "redirect to public allowed", + req: newRequestTo("https://8.8.8.8/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: false, + }, + { + name: "downgrade blocked", + req: newRequestTo("http://93.184.216.34/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: true, + }, + { + name: "redirect to loopback blocked", + req: newRequestTo("https://127.0.0.1/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: true, + }, + { + name: "redirect to hostname resolving to loopback blocked", + req: newRequestTo("https://localhost/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: true, + }, + { + name: "custom client with no custom check", + custom: &http.Client{}, + req: newRequestTo("https://127.0.0.1/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: true, + }, + { + name: "custom client check disable", + custom: &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return nil + }, + }, + req: newRequestTo("https://127.0.0.1/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: false, + }, + { + name: "too many redirects", + req: newRequestTo("https://8.8.8.8/"), + via: func() []*http.Request { + via := make([]*http.Request, maxDiscoveryRedirects) + for i := range via { + via[i] = newRequestTo("https://8.8.8.8/") + } + return via + }(), + wantBlocked: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + client := newDiscoveryClient(tc.custom) + gotErr := client.CheckRedirect(tc.req, tc.via) + if gotErr != nil && !tc.wantBlocked { + t.Fatalf("client.CheckRedirect() error = %v", gotErr) + } + if gotErr == nil && tc.wantBlocked { + t.Fatal("client.CheckRedirect() error = nil, want blocked") + } + }) + } +} + +func TestDiscoveryBlocksInternalTarget(t *testing.T) { + ctx := t.Context() + for _, target := range []string{ + "https://169.254.169.254/latest/meta-data/", + "https://10.0.0.1/.well-known/oauth-protected-resource", + } { + t.Run(target, func(t *testing.T) { + _, err := GetProtectedResourceMetadata(ctx, target, target, nil) + if err == nil { + t.Fatalf("GetProtectedResourceMetadata(%q) succeeded, want SSRF rejection", target) + } + if !strings.Contains(err.Error(), "non-public") { + t.Errorf("error = %v, want it to mention a non-public address", err) + } + }) + } +} + +func TestDiscoveryAllowsLoopbackInitial(t *testing.T) { + ctx := t.Context() + h := &fakeResourceHandler{} + server := httptest.NewTLSServer(h) + defer server.Close() + h.installHandlers(server.URL) + + metadataURL := server.URL + "/.well-known/oauth-protected-resource" + prm, err := GetProtectedResourceMetadata(ctx, metadataURL, server.URL, server.Client()) + if err != nil { + t.Fatalf("GetProtectedResourceMetadata on loopback failed: %v", err) + } + if prm == nil { + t.Fatal("nil prm") + } +} diff --git a/oauthex/oauth2.go b/oauthex/oauth2.go index 689f17a..0e77149 100644 --- a/oauthex/oauth2.go +++ b/oauthex/oauth2.go @@ -11,14 +11,31 @@ import ( "fmt" "io" "mime" + "net" "net/http" + "net/netip" "net/url" "strings" + "sync" + "syscall" + "time" "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/util" ) +const maxDiscoveryRedirects = 10 + +// Build this on first use, not when the package loads. Building it checks the +// proxy via http.ProxyFromEnvironment, and net/http only reads the proxy +// environment once and then remembers it for the whole run. If that happened +// at import time, it would lock in the proxy setting before the app had a +// chance to set HTTPS_PROXY. sync.OnceValue waits until first use and still +// builds the transport just once. See #1276. +var defaultDiscoveryTransport = sync.OnceValue(func() http.RoundTripper { + return newDiscoveryTransport(http.DefaultTransport) +}) + type httpStatusError struct { StatusCode int } @@ -35,10 +52,7 @@ func getJSON[T any](ctx context.Context, c *http.Client, url string, limit int64 if err != nil { return nil, err } - if c == nil { - c = http.DefaultClient - } - res, err := c.Do(req) + res, err := newDiscoveryClient(c).Do(req) if err != nil { return nil, err } @@ -91,5 +105,112 @@ func checkHTTPSOrLoopback(addr string) error { if !util.IsLoopback(u.Host) && u.Scheme != "https" { return fmt.Errorf("URL %q does not use HTTPS or is not a loopback address", addr) } + if ip, err := netip.ParseAddr(u.Hostname()); err == nil && util.IsPrivateOrReserved(ip) { + return fmt.Errorf("URL %q refers to a non-public address", addr) + } + return nil +} + +func newDiscoveryClient(c *http.Client) *http.Client { + transport := defaultDiscoveryTransport() + if c != nil && c.Transport != nil { + // a caller that has taken over dialing or TLS opts out of the checks + if t, ok := c.Transport.(*http.Transport); ok && t.DialContext == nil && t.DialTLSContext == nil { + transport = newDiscoveryTransport(t) + } else { + transport = c.Transport + } + } + discoveryClient := &http.Client{ + Transport: transport, + + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if c != nil && c.CheckRedirect != nil { + return c.CheckRedirect(req, via) + } + if len(via) >= maxDiscoveryRedirects { + return fmt.Errorf("max redirects exceeded (%d)", maxDiscoveryRedirects) + } + if prev := via[len(via)-1]; prev.URL.Scheme == "https" && req.URL.Scheme != "https" { + return fmt.Errorf("redirect to non-HTTPS scheme %q is a security downgrade", req.URL.Scheme) + } + return checkLoopbackRedirect(req.Context(), req.URL.Hostname()) + }, + } + if c != nil { + discoveryClient.Timeout = c.Timeout + discoveryClient.Jar = c.Jar + } + return discoveryClient +} + +// newDiscoveryTransport creates an http.RoundTripper with extra protections enabled, +// unless the provided RoundTripper is not *http.Transport. +func newDiscoveryTransport(rt http.RoundTripper) http.RoundTripper { + base, ok := rt.(*http.Transport) + if !ok { + return rt + } + // If a proxy is configured Control checks should be disabled, because they will + // basically be running for proxy dials. + if proxyConfigured(base) { + return rt + } + result := base.Clone() + result.DialContext = (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + Control: func(_, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + host = address + } + ip, err := netip.ParseAddr(host) + if err != nil { + return fmt.Errorf("non-ip address: %w", err) + } + if util.IsPrivateOrReserved(ip) { + return fmt.Errorf("non-public ip address: %q", ip) + } + return nil + }, + }).DialContext + return result +} + +func proxyConfigured(t *http.Transport) bool { + if t.Proxy == nil { + return false + } + req, err := http.NewRequest(http.MethodGet, "https://check-proxy-existence.com", nil) + if err != nil { + return false + } + u, err := t.Proxy(req) + return err == nil && u != nil +} + +func checkLoopbackRedirect(ctx context.Context, host string) error { + doCheck := func(ip netip.Addr) error { + if ip.Unmap().IsLoopback() { + return fmt.Errorf("redirect into loopback address %q", host) + } + if util.IsPrivateOrReserved(ip) { + return fmt.Errorf("redirect into non-public address %q", host) + } + return nil + } + if ip, err := netip.ParseAddr(host); err == nil { + return doCheck(ip) + } + ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host) + if err != nil { + return fmt.Errorf("cannot resolve redirect host %q: %w", host, err) + } + for _, ip := range ips { + if err := doCheck(ip); err != nil { + return err + } + } return nil } diff --git a/scripts/server-conformance.sh b/scripts/server-conformance.sh index f31d8df..e8fe6e1 100755 --- a/scripts/server-conformance.sh +++ b/scripts/server-conformance.sh @@ -69,9 +69,14 @@ fi # Build the conformance server. go build -o "$WORKDIR/conformance-server" ./conformance/everything-server -# Start the server in the background +# Start the server in the background. +# -stateless=false pins the server to the stateful transport so that +# server-initiated sampling/elicitation scenarios (which are not supported on +# stateless streamable HTTP) work against the current @latest conformance +# suite. Drop this flag once the 0.2.x line is promoted to the @latest +# dist-tag on npm and the stateless leg becomes viable. echo "Starting conformance server on localhost:$PORT..." -"$WORKDIR/conformance-server" -http="localhost:$PORT" & +"$WORKDIR/conformance-server" -http="localhost:$PORT" -stateless=false & SERVER_PID=$! echo "Server pid is $SERVER_PID" @@ -90,11 +95,13 @@ if [ -n "$CONFORMANCE_REPO" ]; then (cd "$WORKDIR" && \ npm --prefix "$CONFORMANCE_REPO" run start -- \ server --url "http://localhost:$PORT" \ + --spec-version 2025-11-25 \ ${RESULT_DIR:+--output-dir "$RESULT_DIR"}) || FINAL_EXIT_CODE=$? else (cd "$WORKDIR" && \ npx @modelcontextprotocol/conformance@latest \ server --url "http://localhost:$PORT" \ + --spec-version 2025-11-25 \ ${RESULT_DIR:+--output-dir "$RESULT_DIR"}) || FINAL_EXIT_CODE=$? fi