diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c4aa91c..d65e6ab 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Initialize CodeQL - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 6f54df4..77276d7 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -14,25 +14,25 @@ permissions: contents: read env: - CONFORMANCE_VERSION: "0.2.0-alpha.11" + CONFORMANCE_VERSION: "0.2.0-alpha.9" jobs: server-conformance: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: "^1.26" - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 22 - name: Build everything-server run: go build -o everything-server ./conformance/everything-server - - name: Run conformance tests (2025-11-25, stateful) + - name: Run conformance tests (stateful, legacy spec versions) run: | ./everything-server -http=":3001" -stateless=false & SERVER_PID=$! @@ -40,9 +40,10 @@ jobs: 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 \ + --suite active \ + --spec-version 2025-11-25 \ --expected-failures ./conformance/baseline.yml - - name: Run conformance tests (2026-07-28, stateless) + - name: Run conformance tests (stateless, 2026-07-28, full suite) run: | ./everything-server -http=":3001" -stateless & SERVER_PID=$! @@ -50,20 +51,21 @@ jobs: 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 \ + --suite all \ + --spec-version 2026-07-28 \ --expected-failures ./conformance/baseline.yml client-conformance: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: "^1.26" - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 22 - name: Run conformance tests (full suite) diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index d255e63..6e6972e 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@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: "^1.26" - name: Check out code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check docs are up-to-date run: | go generate ./... diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 2daa939..21d0420 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -11,29 +11,25 @@ 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@032d45514ae346b1db93c04b0c90b841c370344f # v1.1.0 + uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: "^1.26" - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.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 4d6d31e..e64faa2 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -13,12 +13,12 @@ jobs: contents: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - 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 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: 3.x - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 98db4c4..4eca0b9 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -34,12 +34,12 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: 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@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: sarif_file: results.sarif diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5c351b3..32ef66a 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: "^1.26" - name: Check formatting @@ -43,9 +43,9 @@ jobs: go: ["1.25", "1.26"] steps: - name: Check out code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: "1.26" - name: Test with -race diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d8b090..b327087 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,9 +21,6 @@ transparently, while adhering to these goals. 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) @@ -191,57 +188,6 @@ 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/auth/auth_example_test.go b/auth/auth_example_test.go deleted file mode 100644 index bf97eb8..0000000 --- a/auth/auth_example_test.go +++ /dev/null @@ -1,222 +0,0 @@ -// 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/authorization_code.go b/auth/authorization_code.go index f349842..963884d 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -14,7 +14,6 @@ import ( "net/url" "slices" "strings" - "sync" "github.com/modelcontextprotocol/go-sdk/internal/authutil" "github.com/modelcontextprotocol/go-sdk/internal/util" @@ -98,15 +97,6 @@ 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. // @@ -124,11 +114,6 @@ type AuthorizationCodeHandlerConfig struct { // 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 @@ -141,24 +126,6 @@ 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 @@ -166,9 +133,6 @@ 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 @@ -179,8 +143,6 @@ type AuthorizationCodeHandler struct { 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 } @@ -237,7 +199,6 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho } return &AuthorizationCodeHandler{ config: config, - tokenSource: config.InitialTokenSource, grantedScopes: make(map[string][]string), }, nil } @@ -332,12 +293,6 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ 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 && @@ -349,10 +304,7 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ // 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) + requestedScopes = authutil.UnionScopes(h.grantedScopes[asm.Issuer], requestedScopes) cfg := &oauth2.Config{ ClientID: resolvedClientConfig.clientID, @@ -372,7 +324,7 @@ 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 { + if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported); err != nil { return err } @@ -621,14 +573,10 @@ func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg } // 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. +// per [RFC 9207]. // // [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207 -func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported, acceptUnadvertisedIss bool) error { +func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported 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") @@ -636,17 +584,12 @@ func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported, a 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") + } else { + if iss != "" { + return fmt.Errorf("authorization server does not advertise RFC 9207 iss parameter support but iss was received in the authorization response") + } } + return nil } @@ -672,42 +615,23 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context // 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() + h.tokenSource = cfg.TokenSource(refreshCtx, token) 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 { + if h.tokenSource == nil { return nil } - tok, err := ts.Token() + tok, err := h.tokenSource.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 69a7ca6..15ca69f 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -919,12 +919,11 @@ 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 string + iss string + issSupported bool + wantErr bool + wantErrContains string }{ { name: "ValidIss", @@ -950,41 +949,11 @@ func TestValidateIssuerResponse(t *testing.T) { 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) + err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported) if tt.wantErr { if err == nil { t.Fatalf("validateIssuerResponse() = nil, want error containing %q", tt.wantErrContains) @@ -999,112 +968,6 @@ func TestValidateIssuerResponse(t *testing.T) { } } -// 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 @@ -1359,107 +1222,6 @@ func TestAuthorize_OfflineAccessScope(t *testing.T) { } } -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 { @@ -1471,92 +1233,3 @@ 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/shared.go b/auth/shared.go index e58d438..8c5e10c 100644 --- a/auth/shared.go +++ b/auth/shared.go @@ -44,12 +44,7 @@ func authorizationServerMetadataURLs(issuerURL string) []string { return nil } - // 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 == "" { + if baseURL.Path == "" { // "OAuth 2.0 Authorization Server Metadata". baseURL.Path = "/.well-known/oauth-authorization-server" urls = append(urls, baseURL.String()) @@ -59,14 +54,15 @@ 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/" + issuerPath + baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/") urls = append(urls, baseURL.String()) // "OpenID Connect Discovery 1.0 with path insertion". - baseURL.Path = "/.well-known/openid-configuration/" + issuerPath + baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/") urls = append(urls, baseURL.String()) // "OpenID Connect Discovery 1.0 with path appending". - baseURL.Path = "/" + issuerPath + "/.well-known/openid-configuration" + baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration" urls = append(urls, baseURL.String()) return urls diff --git a/auth/shared_test.go b/auth/shared_test.go index 34e0a35..16b5054 100644 --- a/auth/shared_test.go +++ b/auth/shared_test.go @@ -6,81 +6,11 @@ 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 4867fe9..49f68b6 100644 --- a/conformance/baseline.yml +++ b/conformance/baseline.yml @@ -1,14 +1,13 @@ +server: [] # All tests pass! + client: - # OAuth mechanisms not implemented by the Go MCP SDK. + # OAuth mechanisms not yet 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 + # Upstream conformance mock inconsistency: the scenario's hand-rolled + # server/discover handler advertises "2026-07-28" but delegates every + # other method to a pinned @modelcontextprotocol/sdk that rejects that + # version at the HTTP layer. + - json-schema-ref-no-deref diff --git a/conformance/everything-client/main.go b/conformance/everything-client/main.go index b7d8ba0..3776522 100644 --- a/conformance/everything-client/main.go +++ b/conformance/everything-client/main.go @@ -49,7 +49,6 @@ func init() { 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) @@ -344,46 +343,6 @@ func runJSONSchemaRefNoDerefClient(ctx context.Context, serverURL string, _ map[ 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 // ============================================================================ diff --git a/docs/client.md b/docs/client.md index 23d57be..e262f34 100644 --- a/docs/client.md +++ b/docs/client.md @@ -2,11 +2,8 @@ # 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) @@ -41,7 +38,7 @@ 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) +[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) pattern. ```go @@ -94,67 +91,6 @@ func Example_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`. - -```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 @@ -177,7 +113,7 @@ This function is invoked whenever the server requests sampling. For protocol versions `2026-07-28` and later, sampling requests are delivered via the -[Multi Round-Trip Requests](#multi-round-trip-requests) +[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) pattern. ```go @@ -247,7 +183,7 @@ you must declare that capability explicitly (see [Capabilities](#capabilities)) For protocol versions `2026-07-28` and later, elicitation requests are delivered via the -[Multi Round-Trip Requests](#multi-round-trip-requests) +[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) pattern. ```go @@ -299,154 +235,6 @@ func Example_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. - -```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) @@ -469,18 +257,14 @@ default. The middleware: 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. +the client will then surface `InputRequiredResult` values directly to the +caller and your code must fulfil the requests manually. 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). +[server-side documentation](server.md#multi-round-trip-requests-mrtr). ## Capabilities diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 43a62ce..7082518 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -18,59 +18,6 @@ ## `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. @@ -99,25 +46,9 @@ Options listed below were added and will be removed in the 1.9.0 version of 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"), + not attempt to decode the JSON-RPC error body of a non-2xx HTTP response, 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). + the client always attempts to surface the underlying JSON-RPC error. ### 1.6.1 diff --git a/docs/protocol.md b/docs/protocol.md index 06c1b77..408442e 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -2,14 +2,11 @@ # 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. [Discovery (`server/discover`)](#discovery-(server/discover)) + 1. [Per-request `_meta` keys](#per-request-meta-keys) 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) @@ -42,10 +39,8 @@ transparently based on the negotiated protocol version: - 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. + each request carries its protocol version, client identity, and client + capabilities in `_meta`. In both models, the SDK exposes the same API: @@ -111,7 +106,7 @@ func Example_lifecycle() { } ``` -### Discovery +### Discovery (`server/discover`) Introduced in `2026-07-28` by [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), @@ -128,64 +123,23 @@ request. Servers implementing `2026-07-28` MUST implement it. 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 +### Per-request `_meta` 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 | +| Constant | Wire key | Type | +|---|---|---| +| `MetaKeyProtocolVersion` | `io.modelcontextprotocol/protocolVersion` | `string` | +| `MetaKeyClientInfo` | `io.modelcontextprotocol/clientInfo` | `*Implementation` | +| `MetaKeyClientCapabilities` | `io.modelcontextprotocol/clientCapabilities` | `*ClientCapabilities` | +| `MetaKeyLogLevel` | `io.modelcontextprotocol/logLevel` | `LoggingLevel` (deprecated by SEP-2577) | -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. +The client populates these keys automatically on every outgoing request. +Server-side handlers can read them +through `ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, +and `ServerRequest[P].ClientCapabilities()`. ## Transports @@ -271,8 +225,7 @@ connect to the server: transport := &mcp.StreamableClientTransport{ Endpoint: "http://localhost:8080/mcp", } -client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1.0.0"}, nil) -session, err := client.Connect(ctx, transport, nil) +client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...}) ``` The `StreamableClientTransport` handles the HTTP requests and communicates with @@ -357,63 +310,6 @@ _See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sd 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 @@ -639,24 +535,24 @@ provided to ### Server-Side Request Forgery -The OAuth discovery helpers apply these [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) by default: +The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) are as follows: -- _Enforce HTTPS_. Reject `http://` URLs (except loopback: `localhost`, `127.0.0.1`, `::1`) -and redirects that downgrade `https` to a non-`https` scheme. +- _Enforce HTTPS_. The OAuth helpers provided by the SDK reject the `http://` URLs +except loopback addresses (`localhost`, `127.0.0.1`, `::1`). -- _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. +- _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. -- _Validate Redirect Targets_. Reject redirects to non-public addresses and cap the redirect count. +- _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. -- _Use Egress Proxies_. Out of scope for the SDK; configure separately. +- _Use Egress Proxies_. This is out of scope for the SDK and can be configured separately. -**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. +- _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. ### Session Hijacking diff --git a/docs/quick_start.md b/docs/quick_start.md index 995b378..c76c58c 100644 --- a/docs/quick_start.md +++ b/docs/quick_start.md @@ -7,7 +7,7 @@ ## Installation ``` -go get github.com/modelcontextprotocol/go-sdk/mcp +go get github.com/modelcontextprotocol/go-sdk ``` ## Getting started diff --git a/docs/server.md b/docs/server.md index 8973e3a..573c846 100644 --- a/docs/server.md +++ b/docs/server.md @@ -2,13 +2,8 @@ # 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) @@ -120,91 +115,6 @@ 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. @@ -237,12 +147,7 @@ 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. 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. +to be notified of changes to subscribed resources. **Server-side**: Use @@ -336,145 +241,6 @@ 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 @@ -526,7 +292,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 `StructuredContent` field as well as the unstructured `Content` field. + result's `StructuredOutput` 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.` @@ -561,7 +327,7 @@ This does the following automatically: - 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 - `StructuredContent`, as well as the unstructured `Content`. + `StructuredOutput`, 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`. @@ -646,103 +412,6 @@ _See [mcp/tool_example_test.go](https://github.com/modelcontextprotocol/go-sdk/b 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 @@ -760,86 +429,6 @@ 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) @@ -975,33 +564,27 @@ 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. +**Server-side defaults.** After paginating, the SDK sets `CacheScope = "public"` +and leaves `TTLMs = 0` (which the client cache treats as "immediately +stale"). A handler that wants its responses cached must set `TTLMs` +explicitly on the returned result. ```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 +mcp.AddTool(server, &mcp.Tool{Name: "expensive"}, func(...) (*mcp.CallToolResult, any, error) { + // ... +}) +// Override the default for tools/list: +server.AddSendingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + res, err := next(ctx, method, req) + if lr, ok := res.(*mcp.ListToolsResult); ok { + lr.TTLMs = 30_000 // 30s cache freshness hint } - }, + return res, err + } }) ``` -`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 @@ -1066,12 +649,8 @@ 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 `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.) +until the client calls `SetLevel` (see below). +For stateful sessions, the level defaults to "info". [`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 @@ -1087,7 +666,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.SetLoggingLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLoggingLevel) to change the log level for a session. +Call [`ClientSession.SetLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLevel) to change the log level for a session. ```go func Example_logging() { diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 950eef7..0e792a3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -50,7 +50,7 @@ func ExampleLoggingTransport() { } // Output: - // 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":{}}}} + // read: {"jsonrpc":"2.0","id":1,"result":{"ttlMs":0,"cacheScope":"public","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"capabilities":{"logging":{}},"serverInfo":{"name":"server","version":"v0.0.1"}}} // 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/server/middleware/main.go b/examples/server/middleware/main.go index 262d726..4e37471 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", mcp.HasParams(req), + "has_params", req.GetParams() != nil, ) // Log more for tool calls. if ctr, ok := req.(*mcp.CallToolRequest); ok { diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index c0f64b4..e0613eb 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -30,21 +30,11 @@ 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) +[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) 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 @@ -67,7 +57,7 @@ This function is invoked whenever the server requests sampling. For protocol versions `2026-07-28` and later, sampling requests are delivered via the -[Multi Round-Trip Requests](#multi-round-trip-requests) +[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) pattern. %include ../../mcp/client_example_test.go sampling - @@ -89,53 +79,11 @@ you must declare that capability explicitly (see [Capabilities](#capabilities)) For protocol versions `2026-07-28` and later, elicitation requests are delivered via the -[Multi Round-Trip Requests](#multi-round-trip-requests) +[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) 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) @@ -158,18 +106,14 @@ default. The middleware: 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. +the client will then surface `InputRequiredResult` values directly to the +caller and your code must fulfil the requests manually. 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). +[server-side documentation](server.md#multi-round-trip-requests-mrtr). ## Capabilities diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index 8d1385f..1f15072 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -17,59 +17,6 @@ ## `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. @@ -98,25 +45,9 @@ Options listed below were added and will be removed in the 1.9.0 version of 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"), + not attempt to decode the JSON-RPC error body of a non-2xx HTTP response, 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). + the client always attempts to surface the underlying JSON-RPC error. ### 1.6.1 diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 7d6b01b..5bdbac3 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -17,10 +17,8 @@ transparently based on the negotiated protocol version: - 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. + each request carries its protocol version, client identity, and client + capabilities in `_meta`. In both models, the SDK exposes the same API: @@ -55,7 +53,7 @@ it is the client's responsibility to end the session. %include ../../mcp/mcp_example_test.go lifecycle - -### Discovery +### Discovery (`server/discover`) Introduced in `2026-07-28` by [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), @@ -72,64 +70,23 @@ request. Servers implementing `2026-07-28` MUST implement it. 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 +### Per-request `_meta` 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 | +| Constant | Wire key | Type | +|---|---|---| +| `MetaKeyProtocolVersion` | `io.modelcontextprotocol/protocolVersion` | `string` | +| `MetaKeyClientInfo` | `io.modelcontextprotocol/clientInfo` | `*Implementation` | +| `MetaKeyClientCapabilities` | `io.modelcontextprotocol/clientCapabilities` | `*ClientCapabilities` | +| `MetaKeyLogLevel` | `io.modelcontextprotocol/logLevel` | `LoggingLevel` (deprecated by SEP-2577) | -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. +The client populates these keys automatically on every outgoing request. +Server-side handlers can read them +through `ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, +and `ServerRequest[P].ClientCapabilities()`. ## Transports @@ -195,8 +152,7 @@ connect to the server: transport := &mcp.StreamableClientTransport{ Endpoint: "http://localhost:8080/mcp", } -client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1.0.0"}, nil) -session, err := client.Connect(ctx, transport, nil) +client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...}) ``` The `StreamableClientTransport` handles the HTTP requests and communicates with @@ -281,34 +237,6 @@ _See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sd 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 @@ -534,24 +462,24 @@ provided to ### Server-Side Request Forgery -The OAuth discovery helpers apply these [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) by default: +The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) are as follows: -- _Enforce HTTPS_. Reject `http://` URLs (except loopback: `localhost`, `127.0.0.1`, `::1`) -and redirects that downgrade `https` to a non-`https` scheme. +- _Enforce HTTPS_. The OAuth helpers provided by the SDK reject the `http://` URLs +except loopback addresses (`localhost`, `127.0.0.1`, `::1`). -- _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. +- _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. -- _Validate Redirect Targets_. Reject redirects to non-public addresses and cap the redirect count. +- _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. -- _Use Egress Proxies_. Out of scope for the SDK; configure separately. +- _Use Egress Proxies_. This is out of scope for the SDK and can be configured separately. -**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. +- _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. ### Session Hijacking diff --git a/internal/docs/quick_start.src.md b/internal/docs/quick_start.src.md index c0f1546..7b1849f 100644 --- a/internal/docs/quick_start.src.md +++ b/internal/docs/quick_start.src.md @@ -5,7 +5,7 @@ ## Installation ``` -go get github.com/modelcontextprotocol/go-sdk/mcp +go get github.com/modelcontextprotocol/go-sdk ``` ## Getting started diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index ff66383..f2e3f3a 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -33,24 +33,6 @@ 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. @@ -83,12 +65,7 @@ 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. 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. +to be notified of changes to subscribed resources. **Server-side**: Use @@ -112,37 +89,6 @@ 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 @@ -194,7 +140,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 `StructuredContent` field as well as the unstructured `Content` field. + result's `StructuredOutput` 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.` @@ -229,7 +175,7 @@ This does the following automatically: - 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 - `StructuredContent`, as well as the unstructured `Content`. + `StructuredOutput`, 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`. @@ -251,39 +197,6 @@ _See [mcp/tool_example_test.go](https://github.com/modelcontextprotocol/go-sdk/b 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 @@ -301,26 +214,6 @@ 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) @@ -374,33 +267,27 @@ 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. +**Server-side defaults.** After paginating, the SDK sets `CacheScope = "public"` +and leaves `TTLMs = 0` (which the client cache treats as "immediately +stale"). A handler that wants its responses cached must set `TTLMs` +explicitly on the returned result. ```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 +mcp.AddTool(server, &mcp.Tool{Name: "expensive"}, func(...) (*mcp.CallToolResult, any, error) { + // ... +}) +// Override the default for tools/list: +server.AddSendingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + res, err := next(ctx, method, req) + if lr, ok := res.(*mcp.ListToolsResult); ok { + lr.TTLMs = 30_000 // 30s cache freshness hint } - }, + return res, err + } }) ``` -`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 @@ -437,12 +324,8 @@ 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 `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.) +until the client calls `SetLevel` (see below). +For stateful sessions, the level defaults to "info". [`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 @@ -458,7 +341,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.SetLoggingLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLoggingLevel) to change the log level for a session. +Call [`ClientSession.SetLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLevel) to change the log level for a session. %include ../../mcp/server_example_test.go logging - diff --git a/internal/docs/toc_test.go b/internal/docs/toc_test.go deleted file mode 100644 index 23b02e7..0000000 --- a/internal/docs/toc_test.go +++ /dev/null @@ -1,70 +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 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 3a0e52b..b4c897f 100644 --- a/internal/json/json.go +++ b/internal/json/json.go @@ -8,21 +8,11 @@ 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 } @@ -38,56 +28,5 @@ 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 b1d110c..d037e88 100644 --- a/internal/json/json_test.go +++ b/internal/json/json_test.go @@ -7,11 +7,9 @@ package json import ( "bytes" "encoding/json" - "errors" "fmt" "strings" "testing" - "time" "github.com/google/go-cmp/cmp" ) @@ -142,51 +140,6 @@ 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 94da6ae..4994c63 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -158,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: %w", errClosing, s.readErr) + return fmt.Errorf("%w: %v", 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: %w", errClosing, s.writeErr) + return fmt.Errorf("%w: %v", errClosing, s.writeErr) } return nil } @@ -172,7 +172,7 @@ func (s *inFlightState) shuttingDown(errClosing error) error { type incomingRequest struct { *Request // the request being processed ctx context.Context - cancel context.CancelCauseFunc + cancel context.CancelFunc } // Reader abstracts the transport mechanics from the JSON RPC protocol. @@ -453,19 +453,12 @@ 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(cause) + req.cancel() } } @@ -559,11 +552,9 @@ func (c *Connection) readIncoming(ctx context.Context, reader Reader, preempter // 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. + // Mirrors the equivalent cleanup on write failure. for _, r := range s.incomingByID { - r.cancel(err) + r.cancel() } }) } @@ -573,7 +564,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.WithCancelCause(ctx) + reqCtx, cancel := context.WithCancel(ctx) req := &incomingRequest{ Request: msg, ctx: reqCtx, @@ -674,14 +665,15 @@ func (c *Connection) handleAsync() { return } - // 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. + // Only deliver to the Handler if not already canceled. if err := req.ctx.Err(); err != nil { - if cause := context.Cause(req.ctx); cause != nil { - err = cause - } + 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) + } + }) c.processResult("handleAsync", req, nil, err) continue } @@ -742,7 +734,7 @@ func (c *Connection) processResult(from any, req *incomingRequest, result any, e } // Cancel the request to free any associated resources. - req.cancel(nil) + req.cancel() c.updateInFlight(func(s *inFlightState) { if s.incoming == 0 { panic("jsonrpc2: processResult called when incoming count is already zero") @@ -785,7 +777,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(fmt.Errorf("%w: %v", ErrServerClosing, err)) + r.cancel() } } }) diff --git a/internal/jsonrpc2/conn_test.go b/internal/jsonrpc2/conn_test.go deleted file mode 100644 index afeb4c0..0000000 --- a/internal/jsonrpc2/conn_test.go +++ /dev/null @@ -1,38 +0,0 @@ -// 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/wire.go b/internal/jsonrpc2/wire.go index 499b026..4d123f2 100644 --- a/internal/jsonrpc2/wire.go +++ b/internal/jsonrpc2/wire.go @@ -87,7 +87,7 @@ func (err *WireError) Error() string { func (err *WireError) Is(other error) bool { w, ok := other.(*WireError) - if !ok || w == nil { + if !ok { return false } return err.Code == w.Code diff --git a/internal/jsonrpc2/wire_test.go b/internal/jsonrpc2/wire_test.go index ef47144..cf7e2b8 100644 --- a/internal/jsonrpc2/wire_test.go +++ b/internal/jsonrpc2/wire_test.go @@ -7,7 +7,6 @@ package jsonrpc2_test import ( "bytes" "encoding/json" - "errors" "reflect" "testing" @@ -148,29 +147,6 @@ func TestDecodeIDOnlyMessageIsResponse(t *testing.T) { } } -// 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 4cd6826..e5134fb 100644 --- a/internal/oauthtest/fake_authorization_server.go +++ b/internal/oauthtest/fake_authorization_server.go @@ -97,11 +97,6 @@ type Config struct { // 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 @@ -204,9 +199,8 @@ func (s *FakeAuthorizationServer) handleMetadata(w http.ResponseWriter, r *http. 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, + // Advertise RFC 9207 support: the authorize endpoint includes "iss" in responses. + AuthorizationResponseIssParameterSupported: true, } // Set CORS headers for cross-origin client discovery. w.Header().Set("Access-Control-Allow-Origin", "*") diff --git a/internal/readme/contributing.src.md b/internal/readme/contributing.src.md index f2e931b..08fc9b5 100644 --- a/internal/readme/contributing.src.md +++ b/internal/readme/contributing.src.md @@ -173,57 +173,6 @@ 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/internal/util/net.go b/internal/util/net.go index 593012d..6858614 100644 --- a/internal/util/net.go +++ b/internal/util/net.go @@ -24,26 +24,3 @@ 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 21b7179..fd3187b 100644 --- a/internal/util/net_test.go +++ b/internal/util/net_test.go @@ -3,10 +3,7 @@ // that can be found in the LICENSE file. package util -import ( - "net/netip" - "testing" -) +import "testing" // TestIsLoopback tests the IsLoopback helper function. func TestIsLoopback(t *testing.T) { @@ -36,52 +33,3 @@ 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/mcp/client.go b/mcp/client.go index 1ee13e8..4c0a458 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -245,12 +245,11 @@ func (e unsupportedProtocolVersionError) Error() string { return fmt.Sprintf("unsupported protocol version: %q", e.version) } -// ClientSessionOptions configures a client session created by [Client.Connect]. +// ClientSessionOptions is reserved for future use. type ClientSessionOptions struct { - // 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 + // protocolVersion overrides the protocol version sent in the initialize + // request, for testing. If empty, latestProtocolVersion is used. + protocolVersion string } func (c *Client) capabilities(protocolVersion string) *ClientCapabilities { @@ -312,8 +311,8 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp } protocolVersion := latestProtocolVersion - if opts != nil && opts.ProtocolVersion != "" { - protocolVersion = opts.ProtocolVersion + if opts != nil && opts.protocolVersion != "" { + protocolVersion = opts.protocolVersion } if protocolVersion >= protocolVersion20260728 { @@ -334,17 +333,13 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp subscribeParams := &SubscriptionsListenParams{ Notifications: &NotificationSubscriptions{}, } - caps := discRes.Capabilities - if caps == nil { - caps = &ServerCapabilities{} - } - if c.opts.ToolListChangedHandler != nil && caps.Tools != nil && caps.Tools.ListChanged { + if c.opts.ToolListChangedHandler != nil { subscribeParams.Notifications.ToolsListChanged = true } - if c.opts.PromptListChangedHandler != nil && caps.Prompts != nil && caps.Prompts.ListChanged { + if c.opts.PromptListChangedHandler != nil { subscribeParams.Notifications.PromptsListChanged = true } - if c.opts.ResourceListChangedHandler != nil && caps.Resources != nil && caps.Resources.ListChanged { + if c.opts.ResourceListChangedHandler != nil { subscribeParams.Notifications.ResourcesListChanged = true } if subscribeParams.Notifications.ToolsListChanged || @@ -393,7 +388,6 @@ 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 @@ -449,15 +443,11 @@ func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeRe } } - 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, + ServerInfo: res.ServerInfo, }, nil } @@ -519,12 +509,9 @@ func (cs *ClientSession) usesNewProtocol() bool { 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. +// injectRequestMeta populates the SEP-2575 per-request `_meta` triple +// (protocolVersion, clientInfo, clientCapabilities) on the given outgoing +// request params. Keys already present in params.Meta are not overwritten. func injectRequestMeta[T any, P interface { *T Params @@ -540,7 +527,7 @@ func injectRequestMeta[T any, P interface { if _, ok := m[MetaKeyProtocolVersion]; !ok { m[MetaKeyProtocolVersion] = res.ProtocolVersion } - if _, ok := m[MetaKeyClientInfo]; !ok && cs.client.impl != nil { + if _, ok := m[MetaKeyClientInfo]; !ok { m[MetaKeyClientInfo] = cs.client.impl } if _, ok := m[MetaKeyClientCapabilities]; !ok { @@ -1147,9 +1134,6 @@ 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() @@ -1459,7 +1443,7 @@ func (cs *ClientSession) cancelAllResourceSubscriptions() { // 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))) + _, err := handleSend[*emptyResult](ctx, methodSubscriptionsListen, newClientRequest(cs, orZero[Params](params))) return err } diff --git a/mcp/client_example_test.go b/mcp/client_example_test.go index 98ac3b1..a741bbf 100644 --- a/mcp/client_example_test.go +++ b/mcp/client_example_test.go @@ -6,7 +6,6 @@ package mcp_test import ( "context" - "encoding/json" "fmt" "log" @@ -66,61 +65,6 @@ func Example_roots() { // !-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() { @@ -223,117 +167,3 @@ func Example_elicitation() { } // !-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 f02b905..fe1d3a2 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -662,13 +662,11 @@ func TestClientConnectDiscover(t *testing.T) { 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}, }, + ServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, }, nil }, wantInitialize: false, @@ -697,11 +695,9 @@ func TestClientConnectDiscover(t *testing.T) { 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{}, + ServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, }, nil }, wantInitialize: true, @@ -740,7 +736,7 @@ func TestClientConnectDiscover(t *testing.T) { defer ss.Close() c := NewClient(testImpl, nil) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -805,7 +801,7 @@ func TestClientConnectDiscover_RequestContents(t *testing.T) { return nil, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -851,7 +847,7 @@ func TestInMemory_E2E_DiscoverSuccess(t *testing.T) { defer ss.Close() client := NewClient(&Implementation{Name: "stdio-like-client", Version: "v1"}, nil) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect: %v", err) } @@ -889,11 +885,9 @@ func TestInMemory_E2E_DiscoverFallback_NoOverlap(t *testing.T) { 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{}, + ServerInfo: &Implementation{Name: "vpre-like-server", Version: "v1"}, }, nil } return next(ctx, method, req) @@ -908,7 +902,7 @@ func TestInMemory_E2E_DiscoverFallback_NoOverlap(t *testing.T) { defer ss.Close() client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect: %v", err) } @@ -955,7 +949,7 @@ func TestInMemory_E2E_DiscoverFallback_MethodNotFound(t *testing.T) { defer ss.Close() client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect: %v", err) } @@ -1005,7 +999,7 @@ func TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion(t *testing.T) defer ss.Close() client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect: %v", err) } @@ -1068,7 +1062,7 @@ func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) { defer ss.Close() c := NewClient(testImpl, nil) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: unsupportedClientVersion}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: unsupportedClientVersion}) if err != nil { t.Fatalf("Connect: %v", err) } diff --git a/mcp/content_nil_test.go b/mcp/content_nil_test.go index f759986..86bb86a 100644 --- a/mcp/content_nil_test.go +++ b/mcp/content_nil_test.go @@ -224,12 +224,4 @@ func TestContentUnmarshalNilWithInvalidContent(t *testing.T) { } } -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"), -} +var ctrCmpOpts = []cmp.Option{cmpopts.IgnoreUnexported(mcp.CallToolResult{}, mcp.GetPromptResult{}, mcp.ReadResourceResult{})} diff --git a/mcp/elicitation_test.go b/mcp/elicitation_test.go index 098fce3..a20947f 100644 --- a/mcp/elicitation_test.go +++ b/mcp/elicitation_test.go @@ -12,7 +12,6 @@ import ( "testing/synctest" "github.com/google/jsonschema-go/jsonschema" - "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -115,7 +114,7 @@ func TestElicitationURLMode(t *testing.T) { }, ElicitationHandler: tc.handler, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -173,7 +172,7 @@ func TestElicitationCompleteNotification(t *testing.T) { t.Fatal(err) } defer ss.Close() - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -195,9 +194,9 @@ func TestElicitationCompleteNotification(t *testing.T) { } // 2. Server sends elicitation complete notification (simulating out-of-band completion) - err = ss.NotifyElicitationComplete(ctx, &ElicitationCompleteParams{ + err = handleNotify(ctx, notificationElicitationComplete, newServerRequest(ss, &ElicitationCompleteParams{ ElicitationID: elicitID, - }) + })) if err != nil { t.Fatalf("failed to send elicitation complete notification: %v", err) } @@ -210,41 +209,6 @@ 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() @@ -291,7 +255,7 @@ func TestElicitationNoValidationWithoutAccept(t *testing.T) { return &ElicitResult{Action: tc.action, Content: tc.content}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + 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 c69fc4b..2b68d75 100644 --- a/mcp/error_test.go +++ b/mcp/error_test.go @@ -127,32 +127,6 @@ func TestResourceNotFoundErrorCode(t *testing.T) { } } -// 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. @@ -300,9 +274,10 @@ 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 := ss.NotifyElicitationComplete(ctx, &ElicitationCompleteParams{ - ElicitationID: elicitID, - }) + err := handleNotify(ctx, notificationElicitationComplete, + newServerRequest(ss, &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 f0cbb2f..41ff4ae 100644 --- a/mcp/event.go +++ b/mcp/event.go @@ -52,12 +52,7 @@ func writeEvent(w http.ResponseWriter, evt Event) (int, error) { if evt.Retry != "" { fmt.Fprintf(&b, "retry: %s\n", evt.Retry) } - // 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") + fmt.Fprintf(&b, "data: %s\n\n", string(evt.Data)) n, err := w.Write(b.Bytes()) rc := http.NewResponseController(w) // Ignore returned error as flushing is best-effort. @@ -65,21 +60,13 @@ func writeEvent(w http.ResponseWriter, evt Event) (int, error) { return n, err } -// 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. +// 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. // // TODO(rfindley): consider a different API here that makes failure modes more // apparent. -func scanEventsLimited(r io.Reader, maxEventSize int) iter.Seq2[Event, error] { +func scanEvents(r io.Reader) iter.Seq2[Event, error] { reader := bufio.NewReader(r) // TODO: investigate proper behavior when events are out of order, or have @@ -102,9 +89,8 @@ func scanEventsLimited(r io.Reader, maxEventSize int) 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 - eventBytes int // bytes read for the current, in-progress event + evt Event + dataBuf *bytes.Buffer // if non-nil, preceding field was also data ) yieldEvent := func() bool { if dataBuf != nil { @@ -121,20 +107,11 @@ func scanEventsLimited(r io.Reader, maxEventSize int) iter.Seq2[Event, error] { return true } for { - 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 - } + line, err := reader.ReadBytes('\n') 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) @@ -142,7 +119,6 @@ func scanEventsLimited(r io.Reader, maxEventSize int) iter.Seq2[Event, error] { if !yieldEvent() { return } - eventBytes = 0 // reset the budget between events if isEOF { return } @@ -341,31 +317,6 @@ 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. @@ -381,15 +332,12 @@ 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) - dl.first - if start < 0 { + start := index + 1 + if dl.first > start { return nil, fmt.Errorf("MemoryEventStore.After: index %d, stream ID %v, session %q: %w", index, streamID, sessionID, ErrEventsPurged) } - if start >= len(dl.data) { - return nil, nil - } - return slices.Clone(dl.data[start:]), nil + return slices.Clone(dl.data[start-dl.first:]), nil } return func(yield func([]byte, error) bool) { diff --git a/mcp/event_test.go b/mcp/event_test.go index d326415..91ddb9d 100644 --- a/mcp/event_test.go +++ b/mcp/event_test.go @@ -5,12 +5,9 @@ package mcp import ( - "bytes" "context" "crypto/rand" - "errors" "fmt" - "io" "slices" "strings" "testing" @@ -137,7 +134,7 @@ func TestScanEvents(t *testing.T) { r := strings.NewReader(tt.input) var got []Event var err error - for e, err2 := range scanEventsLimited(r, DefaultMaxEventSize) { + for e, err2 := range scanEvents(r) { if err2 != nil { err = err2 break @@ -178,136 +175,6 @@ 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() @@ -432,11 +299,7 @@ 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 e8d0cd7..402e243 100644 --- a/mcp/logging.go +++ b/mcp/logging.go @@ -68,13 +68,6 @@ 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 @@ -153,9 +146,6 @@ 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() diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 7b10b30..a019839 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -174,7 +174,7 @@ func TestEndToEnd(t *testing.T) { // // Pin the session to 2025-11-25 so the legacy // semantics apply. - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -710,13 +710,13 @@ func TestCancellation(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 + cancelled = make(chan struct{}, 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) + cancelled <- struct{}{} case <-time.After(5 * time.Second): return nil, nil, nil } @@ -732,72 +732,7 @@ func TestCancellation(t *testing.T) { <-start cancel() - // 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) - } + <-cancelled }) } @@ -829,7 +764,7 @@ func TestMiddleware(t *testing.T) { // 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}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -919,7 +854,7 @@ func TestNoJSONNull(t *testing.T) { } c := NewClient(testImpl, nil) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1088,7 +1023,7 @@ func TestElicitationUnsupportedMethod(t *testing.T) { return &CreateMessageResult{Model: "aModel", Content: &TextContent{}}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1137,7 +1072,7 @@ func TestElicitationSchemaValidation(t *testing.T) { return &ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1623,7 +1558,7 @@ func TestElicitContentValidation(t *testing.T) { return &ElicitResult{Action: "accept", Content: map[string]any{"test": "potato"}}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1706,7 +1641,7 @@ func TestElicitationProgressToken(t *testing.T) { return &ElicitResult{Action: "accept"}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1748,7 +1683,7 @@ func TestElicitationCapabilityDeclaration(t *testing.T) { } defer ss.Close() - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1785,7 +1720,7 @@ func TestElicitationCapabilityDeclaration(t *testing.T) { } defer ss.Close() - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1822,7 +1757,7 @@ func TestElicitationDefaultValues(t *testing.T) { return &ElicitResult{Action: "accept", Content: map[string]any{"default": "response"}}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1977,7 +1912,7 @@ func TestKeepAliveFailure_Logged(t *testing.T) { // 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}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -2152,7 +2087,7 @@ func TestSynchronousNotifications(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = ss.Close() }) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -2540,15 +2475,7 @@ func TestSetErrorPreservesContent(t *testing.T) { } } -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"), -} +var ctrCmpOpts = []cmp.Option{cmpopts.IgnoreUnexported(CallToolResult{}, GetPromptResult{}, ReadResourceResult{})} // runSubscriptionsListenTest exercises the SEP-2575 auto-listen flow end-to-end // against the supplied transport pair. It captures every notification and the @@ -2566,7 +2493,7 @@ func runSubscriptionsListenTest(t *testing.T, client *Client, server *Server, ct ctx, topCancel := context.WithTimeout(context.Background(), 30*time.Second) defer topCancel() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -2724,7 +2651,7 @@ func TestSubscriptionsListen_NoHandlersNoListen(t *testing.T) { return next(ctx, method, req) } }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -2847,7 +2774,7 @@ func TestResourceSubscriptions_Streamable(t *testing.T) { }, }) cs, err := c.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, - &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -2916,7 +2843,7 @@ func TestResourceSubscriptions_InMemory(t *testing.T) { events <- resourceSubEvent{uri: req.Params.URI, id: id} }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -2993,7 +2920,7 @@ func TestResourceSubscriptions_Subscribe_Idempotent(t *testing.T) { c := NewClient(testImpl, &ClientOptions{ ResourceUpdatedHandler: func(context.Context, *ResourceUpdatedNotificationRequest) {}, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -3065,7 +2992,7 @@ func TestResourceSubscriptions_MultipleURIs(t *testing.T) { events <- resourceSubEvent{uri: req.Params.URI, id: id} }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -3170,7 +3097,7 @@ func TestSubscriptionsListen_MultipleSessions(t *testing.T) { } c := newSubListenClient(events) cs, err := c.Connect(context.Background(), ct, - &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -3270,7 +3197,7 @@ func TestSubscriptionsListen_ResourceListChanged(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -3314,7 +3241,7 @@ func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) { } c := newSubListenClient(events) cs, err := c.Connect(context.Background(), ct, - &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -3363,376 +3290,6 @@ func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) { } } -// 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 @@ -3863,7 +3420,7 @@ func TestCallCustomMethodTypedNilParams(t *testing.T) { if err := AddSendingCustomMethod[*pingParams, *pingResult](c, "acme/ping"); err != nil { t.Fatal(err) } - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatal(err) } @@ -3874,79 +3431,3 @@ func TestCallCustomMethodTypedNilParams(t *testing.T) { 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 index fdf98ec..5f65f56 100644 --- a/mcp/mrtr.go +++ b/mcp/mrtr.go @@ -36,20 +36,29 @@ type multiRoundTripResponse interface { 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 { +func handleMultiRoundTripResult(ss *ServerSession, logger *slog.Logger, res multiRoundTripResponse) error { if res == nil { return nil } - if res.inputRequests() != nil && res.hasContent() { + hasInputRequests := res.inputRequests() != nil + + if hasInputRequests && 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", } } + + if clientSupportsMultiRoundTrip(ss) { + // For older clients the resultType is left unset. Input requests will be handled + // by serverMultiRoundTripMiddleware client calls and handler reinvocation. + if hasInputRequests { + res.setResultType(resultTypeInputRequired) + } else { + res.setResultType(resultTypeComplete) + } + } return nil } @@ -99,10 +108,7 @@ func clientMultiRoundTripMiddleware() Middleware { if err != nil { return nil, err } - req, err = setMultiRoundTripRetryParams(method, req, responses, mrtrResult.requestState()) - if err != nil { - return nil, err - } + setMultiRoundTripRetryParams(req, responses, mrtrResult.requestState()) } } } @@ -146,10 +152,7 @@ func serverMultiRoundTripMiddleware() Middleware { if err != nil { return nil, err } - req, err = setMultiRoundTripRetryParams(method, req, responses, mrtrResult.requestState()) - if err != nil { - return nil, err - } + setMultiRoundTripRetryParams(req, responses, mrtrResult.requestState()) return next(ctx, method, req) } } @@ -210,54 +213,20 @@ func createMessageParamsToWithTools(p *CreateMessageParams) *CreateMessageWithTo } } -// 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 +func setMultiRoundTripRetryParams(req Request, responses InputResponseMap, state string) { switch p := req.GetParams().(type) { case *CallToolParams: - cp := *p - cp.InputResponses = responses - cp.RequestState = state - retryParams = &cp + p.InputResponses = responses + p.RequestState = state case *CallToolParamsRaw: - cp := *p - cp.InputResponses = responses - cp.RequestState = state - retryParams = &cp + p.InputResponses = responses + p.RequestState = state case *GetPromptParams: - cp := *p - cp.InputResponses = responses - cp.RequestState = state - retryParams = &cp + p.InputResponses = responses + p.RequestState = state 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) + p.InputResponses = responses + p.RequestState = state } } diff --git a/mcp/mrtr_test.go b/mcp/mrtr_test.go index d966219..238bc09 100644 --- a/mcp/mrtr_test.go +++ b/mcp/mrtr_test.go @@ -10,7 +10,6 @@ import ( "context" "fmt" "slices" - "strings" "sync/atomic" "testing" @@ -197,198 +196,6 @@ func TestMultiRoundTrip_AutoRetry(t *testing.T) { } } -// 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 @@ -505,7 +312,7 @@ func TestMultiRoundTrip_ServerMiddleware(t *testing.T) { }, }) c.AddRoots(&Root{URI: "file:///workspace", Name: "workspace"}) - cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() error = %v", err) } @@ -522,41 +329,6 @@ func TestMultiRoundTrip_ServerMiddleware(t *testing.T) { } } -// 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() @@ -731,138 +503,6 @@ func TestMultiRoundTrip_ReadResource_ManualRetry(t *testing.T) { } } -// 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() @@ -876,33 +516,7 @@ func mustConnect(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSess }) 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}) + cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect() error = %v", err) } diff --git a/mcp/protocol.go b/mcp/protocol.go index c340e60..a68ef42 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -19,7 +19,7 @@ type resultType string const ( // resultTypeComplete indicates the result is final. - // This is the default when resultType is empty. + // This is the default when ResultType is empty. resultTypeComplete resultType = "complete" // resultTypeInputRequired indicates the server needs additional client @@ -28,35 +28,6 @@ const ( 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]. @@ -92,9 +63,6 @@ func (m InputRequestMap) MarshalJSON() ([]byte, error) { 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) @@ -237,8 +205,9 @@ type CallToolParams struct { // 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 maps input request IDs to responses, provided when + // retrying a call after receiving a result with ResultType + // ResultTypeInputRequired. InputResponses InputResponseMap `json:"inputResponses,omitempty"` // RequestState is the opaque state from the previous input-required result. // The client must echo this back when retrying. @@ -259,8 +228,9 @@ type CallToolParamsRaw struct { // 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 maps input request IDs to responses, provided when + // retrying a call after receiving a result with ResultType + // ResultTypeInputRequired. InputResponses InputResponseMap `json:"inputResponses,omitempty"` // RequestState is the opaque state from the previous input-required result. // The client must echo this back when retrying. @@ -289,8 +259,6 @@ type CallToolResult struct { // 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 @@ -316,9 +284,9 @@ type CallToolResult struct { 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. + // Populated only when ResultType is ResultTypeInputRequired. // The client must fulfill these and echo the IDs back in InputResponses - // when it retries the call. + // when retrying the call. InputRequests InputRequestMap `json:"inputRequests,omitempty"` // RequestState is an opaque string the client must echo back when @@ -328,10 +296,10 @@ type CallToolResult struct { // 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 indicates whether this result is complete or requires further + // client input. Empty or ResultTypeComplete means the call succeeded + // normally. ResultTypeInputRequired means the client should fulfill the + // InputRequests and retry the call. resultType resultType // The error passed to setError, if any. // It is not marshaled, and therefore it is only visible on the server. @@ -340,13 +308,23 @@ type CallToolResult struct { err error } +// seterroroverwrite is a compatibility parameter that restores the pre-1.6.0 +// behavior of [CallToolResult.SetError], where Content was always overwritten +// with the error text. See the documentation for the mcpgodebug package for +// instructions on how to enable it. +// The option will be removed in the 1.8.0 version of the SDK. +var seterroroverwrite = mcpgodebug.Value("seterroroverwrite") + // 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. +// +// To restore the previous behavior where Content was always overwritten, +// set MCPGODEBUG=seterroroverwrite=1. func (r *CallToolResult) SetError(err error) { - if len(r.Content) == 0 { + if len(r.Content) == 0 || seterroroverwrite == "1" { r.Content = []Content{&TextContent{Text: err.Error()}} } r.IsError = true @@ -359,8 +337,7 @@ func (r *CallToolResult) GetError() error { return r.err } -func (*CallToolResult) isResult() {} -func (x *CallToolResult) isNil() bool { return x == nil } +func (*CallToolResult) isResult() {} func (r *CallToolResult) setResultType(rt resultType) { r.resultType = rt } func (r *CallToolResult) requestState() string { return r.RequestState } @@ -375,18 +352,12 @@ func (r *CallToolResult) hasContent() bool { } // NeedsInput reports whether this result requires further client input. -// This is true when the server returned a resultType of "input_required". +// This is true when the server returned ResultType "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 { @@ -409,22 +380,12 @@ func (x *CallToolResult) UnmarshalJSON(data []byte) error { type res CallToolResult // avoid recursion var wire struct { res - Content []*wireContent `json:"content"` - StructuredContent json.RawMessage `json:"structuredContent"` - ResultType resultType `json:"resultType"` + Content []*wireContent `json:"content"` + 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 @@ -676,15 +637,13 @@ 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 (x *CompleteResult) isNil() bool { return x == nil } +func (*CompleteResult) isResult() {} // CreateMessageParams holds parameters for a sampling/createMessage request. // @@ -868,7 +827,6 @@ type CreateMessageResult struct { } 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 @@ -920,7 +878,6 @@ var createMessageWithToolsResultAllow = map[string]bool{ } 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 @@ -981,8 +938,9 @@ type GetPromptParams struct { // 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 maps input request IDs to responses, provided when + // retrying a call after receiving a result with ResultType + // ResultTypeInputRequired. InputResponses InputResponseMap `json:"inputResponses,omitempty"` // RequestState is the opaque state from the previous input-required result. RequestState string `json:"requestState,omitempty"` @@ -1002,20 +960,19 @@ type GetPromptResult struct { Description string `json:"description,omitempty"` Messages []*PromptMessage `json:"messages"` - // InputRequests is set when the result needs more client input. + // InputRequests is populated when ResultType is ResultTypeInputRequired. // 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 indicates whether this result is complete or requires further + // client input. See [CallToolResult.ResultType] for details. resultType resultType } -func (*GetPromptResult) isResult() {} -func (x *GetPromptResult) isNil() bool { return x == nil } +func (*GetPromptResult) isResult() {} func (r *GetPromptResult) setResultType(rt resultType) { r.resultType = rt } func (r *GetPromptResult) requestState() string { return r.RequestState } @@ -1125,8 +1082,7 @@ type InitializeResult struct { ServerInfo *Implementation `json:"serverInfo"` } -func (*InitializeResult) isResult() {} -func (x *InitializeResult) isNil() bool { return x == nil } +func (*InitializeResult) isResult() {} type InitializedParams struct { // Meta is reserved by the protocol to allow clients and servers to attach @@ -1158,19 +1114,19 @@ 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"` + // Information about the server implementation. + ServerInfo *Implementation `json:"serverInfo"` // 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 (*DiscoverResult) isResult() {} func (x *ListPromptsParams) isParams() {} func (x *ListPromptsParams) isNil() bool { return x == nil } @@ -1214,21 +1170,13 @@ 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" - } +// setDefaultCacheableValues sets the default values for the cacheable fields. +func (c *Cacheable) setDefaultCacheableValues() { + 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"` @@ -1240,7 +1188,6 @@ 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 { @@ -1260,7 +1207,6 @@ 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"` @@ -1272,7 +1218,6 @@ 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 { @@ -1292,7 +1237,6 @@ 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"` @@ -1304,7 +1248,6 @@ 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. @@ -1341,7 +1284,6 @@ type ListRootsResult struct { } func (*ListRootsResult) isResult() {} -func (x *ListRootsResult) isNil() bool { return x == nil } func (*ListRootsResult) isInputResponse() {} type ListToolsParams struct { @@ -1361,7 +1303,6 @@ 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"` @@ -1373,7 +1314,6 @@ 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. @@ -1613,8 +1553,9 @@ type ReadResourceParams struct { // 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 maps input request IDs to responses, provided when + // retrying a call after receiving a result with ResultType + // ResultTypeInputRequired. InputResponses InputResponseMap `json:"inputResponses,omitempty"` // RequestState is the opaque state from the previous input-required result. RequestState string `json:"requestState,omitempty"` @@ -1633,20 +1574,19 @@ type ReadResourceResult struct { Cacheable Contents []*ResourceContents `json:"contents"` - // InputRequests is set when the result needs more client input. + // InputRequests is populated when ResultType is ResultTypeInputRequired. // 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 indicates whether this result is complete or requires further + // client input. See [CallToolResult.ResultType] for details. resultType resultType } -func (*ReadResourceResult) isResult() {} -func (x *ReadResourceResult) isNil() bool { return x == nil } +func (*ReadResourceResult) isResult() {} func (r *ReadResourceResult) setResultType(rt resultType) { r.resultType = rt } func (r *ReadResourceResult) requestState() string { return r.RequestState } @@ -2142,19 +2082,6 @@ type SubscriptionsAcknowledgedParams struct { 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. // A request from the server to elicit additional information from the user via the client. @@ -2199,21 +2126,6 @@ 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 @@ -2230,7 +2142,6 @@ type ElicitResult struct { } 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. @@ -2283,7 +2194,7 @@ type PromptCapabilities struct { // ResourceCapabilities describes the server's support for resources. type ResourceCapabilities struct { - // ListChanged reports whether this server supports notifications for + // ListChanged reports whether the client supports notifications for // changes to the resource list. ListChanged bool `json:"listChanged,omitempty"` // Subscribe reports whether this server supports subscribing to resource @@ -2293,7 +2204,7 @@ type ResourceCapabilities struct { // ToolCapabilities describes the server's support for tools. type ToolCapabilities struct { - // ListChanged reports whether this server supports notifications for + // ListChanged reports whether the client supports notifications for // changes to the tool list. ListChanged bool `json:"listChanged,omitempty"` } @@ -2400,8 +2311,6 @@ const ( 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. diff --git a/mcp/protocol_test.go b/mcp/protocol_test.go index 4f0c345..38fd48f 100644 --- a/mcp/protocol_test.go +++ b/mcp/protocol_test.go @@ -10,7 +10,6 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" ) func TestParamsMeta(t *testing.T) { @@ -492,7 +491,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, cmpopts.IgnoreUnexported(CompleteResult{})); diff != "" { + if diff := cmp.Diff(test.want, got); diff != "" { t.Errorf("CompleteResult unmarshal mismatch (-want +got):\n%s", diff) } }) @@ -1285,7 +1284,7 @@ func TestContentUnmarshal(t *testing.T) { Meta: Meta{"m": true}, Content: content, IsError: true, - StructuredContent: json.Number("3"), + StructuredContent: 3.0, } var gotf CallToolResult roundtrip(ctrf, &gotf) @@ -1298,24 +1297,6 @@ func TestContentUnmarshal(t *testing.T) { 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 } diff --git a/mcp/sampling_test.go b/mcp/sampling_test.go index 7a3ecab..2adb0fd 100644 --- a/mcp/sampling_test.go +++ b/mcp/sampling_test.go @@ -53,7 +53,7 @@ func TestSamplingWithTools_ToolUse(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -124,7 +124,7 @@ func TestSamplingWithTools_ToolResult(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -288,7 +288,7 @@ func TestSamplingWithTools_ToolResultWithError(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -350,7 +350,7 @@ func TestSamplingWithTools_ParallelToolCalls(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -420,7 +420,7 @@ func TestCreateMessage_MultipleContentError(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + 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 569abab..c59f793 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -27,7 +27,6 @@ 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" @@ -44,10 +43,6 @@ 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] @@ -173,31 +168,6 @@ type ServerOptions struct { // 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: @@ -234,18 +204,6 @@ 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) } @@ -256,7 +214,6 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { 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 }), @@ -704,23 +661,7 @@ 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 } @@ -878,7 +819,7 @@ func (s *Server) Sessions() iter.Seq[*ServerSession] { return slices.Values(clients) } -func (s *Server) listPrompts(ctx context.Context, req *ListPromptsRequest) (*ListPromptsResult, error) { +func (s *Server) listPrompts(_ context.Context, req *ListPromptsRequest) (*ListPromptsResult, error) { s.mu.Lock() defer s.mu.Unlock() if req.Params == nil { @@ -893,7 +834,7 @@ func (s *Server) listPrompts(ctx context.Context, req *ListPromptsRequest) (*Lis if err != nil { return nil, err } - s.resolveCacheable(ctx, req, &res.Cacheable) + res.setDefaultCacheableValues() return res, nil } @@ -910,7 +851,7 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm } res, err := prompt.handler(ctx, req) if err == nil && res != nil { - if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil { + if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { return nil, err } } @@ -923,52 +864,40 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm // 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) { +func (s *Server) discover(_ 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 - }) + versions = slices.Clone(supportedProtocolVersions) } + req.Session.updateState(func(state *ServerSessionState) { + state.InitializeParams = &InitializeParams{ + ProtocolVersion: req.ProtocolVersion(), + Capabilities: req.ClientCapabilities(), + ClientInfo: req.ClientInfo(), + } + }) res := &DiscoverResult{ SupportedVersions: versions, Capabilities: s.capabilities(), + ServerInfo: s.impl, Instructions: s.opts.Instructions, } - s.resolveCacheable(ctx, req, &res.Cacheable) + res.setDefaultCacheableValues() 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 { +// filterSupportedVersions returns the subset of [supportedProtocolVersions] +// that the Transport can serve. If t does not implement [ProtocolVersionSupporter], every +// SDK-supported version is included. +func filterSupportedVersions(t Transport) []string { pvs, ok := t.(ProtocolVersionSupporter) if !ok { - return slices.Clone(versions) + return slices.Clone(supportedProtocolVersions) } - out := make([]string, 0, len(versions)) - for _, v := range versions { + out := make([]string, 0, len(supportedProtocolVersions)) + for _, v := range supportedProtocolVersions { if pvs.SupportsProtocolVersion(v) { out = append(out, v) } @@ -976,7 +905,7 @@ func filterSupportedVersions(t Transport, versions []string) []string { return out } -func (s *Server) listTools(ctx context.Context, req *ListToolsRequest) (*ListToolsResult, error) { +func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListToolsResult, error) { s.mu.Lock() defer s.mu.Unlock() if req.Params == nil { @@ -991,7 +920,7 @@ func (s *Server) listTools(ctx context.Context, req *ListToolsRequest) (*ListToo if err != nil { return nil, err } - s.resolveCacheable(ctx, req, &res.Cacheable) + res.setDefaultCacheableValues() return res, nil } @@ -1012,10 +941,10 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR } res, err := st.handler(ctx, req) if err == nil && res != nil { - if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil { + if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { return nil, err } - if res.Content == nil && res.InputRequests == nil { + if res.Content == nil && res.resultType != resultTypeInputRequired { res2 := *res res2.Content = []Content{} // avoid "null" res = &res2 @@ -1024,7 +953,7 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR return res, err } -func (s *Server) listResources(ctx context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) { +func (s *Server) listResources(_ context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) { s.mu.Lock() defer s.mu.Unlock() if req.Params == nil { @@ -1039,11 +968,11 @@ func (s *Server) listResources(ctx context.Context, req *ListResourcesRequest) ( if err != nil { return nil, err } - s.resolveCacheable(ctx, req, &res.Cacheable) + res.setDefaultCacheableValues() return res, nil } -func (s *Server) listResourceTemplates(ctx context.Context, req *ListResourceTemplatesRequest) (*ListResourceTemplatesResult, error) { +func (s *Server) listResourceTemplates(_ context.Context, req *ListResourceTemplatesRequest) (*ListResourceTemplatesResult, error) { s.mu.Lock() defer s.mu.Unlock() if req.Params == nil { @@ -1059,7 +988,7 @@ func (s *Server) listResourceTemplates(ctx context.Context, req *ListResourceTem if err != nil { return nil, err } - s.resolveCacheable(ctx, req, &res.Cacheable) + res.setDefaultCacheableValues() return res, nil } @@ -1077,17 +1006,14 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R if err != nil { return nil, err } - 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 { + if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { return nil, err } - s.resolveCacheable(ctx, req, &res.Cacheable) - if res.InputRequests != nil { + res.setDefaultCacheableValues() + if res.resultType == resultTypeInputRequired { return res, nil } - if res.Contents == nil { + if res == nil || res.Contents == nil { return nil, fmt.Errorf("reading resource %s: read handler returned nil information", uri) } // As a convenience, populate some fields. @@ -1102,22 +1028,6 @@ 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) { @@ -1253,7 +1163,7 @@ func (s *Server) unsubscribe(ctx context.Context, req *UnsubscribeRequest) (*emp return &emptyResult{}, nil } -func (s *Server) subscriptionsListen(ctx context.Context, req *SubscriptionsListenRequest) (*SubscriptionsListenResult, error) { +func (s *Server) subscriptionsListen(ctx context.Context, req *SubscriptionsListenRequest) (*emptyResult, 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) @@ -1276,19 +1186,10 @@ func (s *Server) subscriptionsListen(ctx context.Context, req *SubscriptionsList } 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) - } + delete(s.toolChangeSubscriptions, req.Session) + delete(s.promptChangeSubscriptions, req.Session) + delete(s.resourceChangeSubscriptions, req.Session) s.mu.Unlock() }() @@ -1324,9 +1225,7 @@ func (s *Server) subscriptionsListen(ctx context.Context, req *SubscriptionsList if len(allowed.ResourceSubscriptions) > 0 || allowed.ToolsListChanged || allowed.PromptsListChanged || allowed.ResourcesListChanged { <-ctx.Done() } - return &SubscriptionsListenResult{ - Meta: Meta{MetaKeySubscriptionID: requestID.Raw()}, - }, nil + return &emptyResult{}, nil } func (s *Server) allowedSubscriptions(want *NotificationSubscriptions) NotificationSubscriptions { @@ -1465,7 +1364,7 @@ func (s *Server) Connect(ctx context.Context, t Transport, opts *ServerSessionOp // 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.supportedVersions = filterSupportedVersions(t) ss.mu.Unlock() // Start keepalive before returning the session to avoid race conditions with Close. @@ -1559,7 +1458,7 @@ type ServerSession struct { mcpConn Connection keepaliveCancel context.CancelFunc - // supportedVersions is the subset of [Server.protocolVersions] that the + // supportedVersions is the subset of [supportedProtocolVersions] 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. @@ -1567,12 +1466,6 @@ type ServerSession struct { 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)) { @@ -1746,7 +1639,15 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli return nil, fmt.Errorf("%w: params cannot be nil", jsonrpc2.ErrInvalidParams) } - params = params.inferElicitMode() + if params.Mode == "" { + params2 := *params + if params.URL != "" || params.ElicitationID != "" { + params2.Mode = "url" + } else { + params2.Mode = "form" + } + params = ¶ms2 + } if iparams := ss.InitializeParams(); iparams == nil || iparams.Capabilities == nil || iparams.Capabilities.Elicitation == nil { return nil, fmt.Errorf("client does not support elicitation") @@ -1770,7 +1671,7 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli return nil, err } - if res.Action != "accept" || res.Content == nil { + if res.Action != "accept" { return res, nil } @@ -1800,18 +1701,6 @@ 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. // // For new-protocol (>= 2026-07-28) requests, the level is taken from the @@ -1824,12 +1713,9 @@ func (ss *ServerSession) NotifyElicitationComplete(ctx context.Context, params * // (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 { - logLevel, ok := logLevelFromContext(ctx) - if !ok { - ss.mu.Lock() - logLevel = ss.state.LogLevel - ss.mu.Unlock() - } + 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. @@ -1866,9 +1752,6 @@ 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() @@ -1966,9 +1849,9 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } if validatedMeta.usesNewProtocol && - !slices.Contains(ss.server.protocolVersions, validatedMeta.initializeParams.ProtocolVersion) { + !slices.Contains(supportedProtocolVersions, validatedMeta.initializeParams.ProtocolVersion) { data, _ := json.Marshal(UnsupportedProtocolVersionData{ - Supported: ss.server.protocolVersions, + Supported: supportedProtocolVersions, Requested: validatedMeta.initializeParams.ProtocolVersion, }) return nil, &jsonrpc.Error{ @@ -1991,13 +1874,11 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, // 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 { + 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) + } if !initialized && validatedMeta.usesNewProtocol && validatedMeta.initializeParams != nil { ss.updateState(func(state *ServerSessionState) { state.InitializeParams = validatedMeta.initializeParams @@ -2005,16 +1886,6 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } } - // 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 @@ -2026,65 +1897,11 @@ 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) + // For new-protocol requests, propagate the per-request log level. if validatedMeta.usesNewProtocol { - ctx = context.WithValue(ctx, logLevelContextKey{}, validatedMeta.logLevel) + ss.setLevel(ctx, &SetLoggingLevelParams{Level: 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) + return handleReceive(ctx, ss, req) } // InitializeParams returns the InitializeParams provided during the client's @@ -2099,17 +1916,12 @@ 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 - ) + var wasInit bool ss.updateState(func(state *ServerSessionState) { 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") @@ -2120,7 +1932,7 @@ func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParam return &InitializeResult{ // TODO(rfindley): alter behavior when falling back to an older version: // reject unsupported features. - ProtocolVersion: negotiated, + ProtocolVersion: negotiatedVersion(params.ProtocolVersion), Capabilities: s.capabilities(), Instructions: s.opts.Instructions, ServerInfo: s.impl, @@ -2163,18 +1975,6 @@ func (ss *ServerSession) Close() error { // 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) { diff --git a/mcp/server_example_test.go b/mcp/server_example_test.go index ae4318f..5e95585 100644 --- a/mcp/server_example_test.go +++ b/mcp/server_example_test.go @@ -88,77 +88,6 @@ 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() { @@ -293,186 +222,6 @@ 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 diff --git a/mcp/server_test.go b/mcp/server_test.go index 4d09cbc..ac303be 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -677,7 +677,7 @@ func TestAddToolNonObjectOutputSchema(t *testing.T) { name: "primitive number (map-based schema)", outputSchema: map[string]any{"type": "number"}, content: 42.0, - want: json.Number("42"), + want: 42.0, }, { name: "primitive string (RawMessage schema)", @@ -724,52 +724,6 @@ func TestAddToolNonObjectOutputSchema(t *testing.T) { } } -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. @@ -862,7 +816,7 @@ func TestAddToolGenericNonObjectOutput(t *testing.T) { if res.IsError { t.Fatalf("unexpected tool error: %v", res.Content) } - if diff := cmp.Diff(json.Number("42"), res.StructuredContent); diff != "" { + if diff := cmp.Diff(float64(42), res.StructuredContent); diff != "" { t.Errorf("structured content mismatch (-want +got):\n%s", diff) } }) @@ -1445,367 +1399,6 @@ func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { }) } -// 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 @@ -1928,7 +1521,7 @@ func TestServerSession_RejectsServerInitiated(t *testing.T) { }, }) c.AddRoots(&Root{URI: "file:///workspace"}) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protoVer}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protoVer}) if err != nil { t.Fatal(err) } @@ -1956,458 +1549,3 @@ func TestServerSession_RejectsServerInitiated(t *testing.T) { } } } - -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 08eccaf..dcf9888 100644 --- a/mcp/session.go +++ b/mcp/session.go @@ -22,13 +22,6 @@ 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 cacfa75..0fc19c5 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -26,21 +26,9 @@ 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. @@ -63,38 +51,18 @@ var supportedProtocolVersions = []string{ 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 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. +// 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. // // 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 { + if slices.Contains(supportedProtocolVersions, clientVersion) && clientVersion < protocolVersion20260728 { 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 } @@ -336,17 +304,7 @@ 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 { - // 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) + return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, p, err) } } // We must check missingParamsOK here, in addition to checkRequest, to @@ -553,62 +511,26 @@ type validatedMeta struct { // 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. +// If the request has no _meta, or no protocolVersion in _meta, it returns a non-nil +// validatedMeta with usesNewProtocol set to false, and a nil error. +// If the request has a protocolVersion in _meta it validates the presence of clientInfo +// and clientCapabilities in _meta. If either is missing or invalid, it returns nil and +// a non-nil error. Otherwise, it returns usesNewProtocol set to true and the populated +// initializeParams. 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 == "" { + if !ok || protocolVersion < protocolVersion20260728 { 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), - } + clientInfo, ok := decodeMetaValue[*Implementation](meta, MetaKeyClientInfo) + if !ok { + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: fmt.Sprintf("missing or invalid _meta field %q", MetaKeyClientInfo), } } capabilities, ok := decodeMetaValue[*clientCapabilitiesV2](meta, MetaKeyClientCapabilities) @@ -688,30 +610,6 @@ 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 @@ -773,7 +671,8 @@ func (r *ServerRequest[P]) ClientCapabilities() *ClientCapabilities { // 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) { + // In practice P is a pointer type implementing Params. + if any(r.Params) == nil || r.Params.isNil() { return nil } return r.Params.GetMeta() @@ -855,9 +754,6 @@ 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. @@ -876,15 +772,13 @@ type ResultBase struct { Meta `json:"_meta,omitempty"` } -func (*ResultBase) isResult() {} -func (x *ResultBase) isNil() bool { return x == nil } +func (*ResultBase) isResult() {} // 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") } diff --git a/mcp/shared_test.go b/mcp/shared_test.go index 4566768..1d0ec70 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -67,19 +67,6 @@ func TestValidateRequestMeta(t *testing.T) { }, "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, }, @@ -102,57 +89,6 @@ func TestValidateRequestMeta(t *testing.T) { 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, @@ -324,62 +260,6 @@ func TestServerRequest_PerRequestAccessors_Empty(t *testing.T) { } } -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", @@ -632,29 +512,3 @@ func TestImplementationDescriptionJSON(t *testing.T) { // } // }) // } - -// 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 f5e90b8..7efaa95 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -8,7 +8,6 @@ import ( "bytes" "context" "crypto/rand" - "errors" "fmt" "io" "mime" @@ -66,12 +65,6 @@ type SSEOptions struct { // 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 @@ -129,12 +122,6 @@ 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. @@ -156,22 +143,9 @@ 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 } @@ -215,17 +189,10 @@ func (t *SSEServerTransport) Connect(context.Context) (Connection, error) { return &sseServerConn{t: t}, nil } -// 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 -} - 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 !h.opts.DisableLocalhostProtection && disablelocalhostprotection != "1" { 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) @@ -235,7 +202,7 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { } // Validate 'Content-Type' header. - if req.Method == http.MethodPost { + if disablecontenttypecheck != "1" && 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) @@ -286,11 +253,7 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - transport := &SSEServerTransport{ - Endpoint: endpoint.RequestURI(), - Response: w, - MaxRequestBodyBytes: h.opts.MaxRequestBodyBytes, - } + transport := &SSEServerTransport{Endpoint: endpoint.RequestURI(), Response: w} // The session is terminated when the request exits. h.mu.Lock() @@ -397,11 +360,6 @@ 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. @@ -432,16 +390,9 @@ 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 events { + for evt, err = range scanEvents(resp.Body) { break } if err != nil { @@ -470,7 +421,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 events { + for evt, err := range scanEvents(resp.Body) { if err != nil { return } diff --git a/mcp/sse_example_test.go b/mcp/sse_example_test.go index 434f6de..6132d31 100644 --- a/mcp/sse_example_test.go +++ b/mcp/sse_example_test.go @@ -27,8 +27,6 @@ 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) @@ -57,5 +55,3 @@ func ExampleSSEHandler() { // Output: 3 } - -// !-ssehandler diff --git a/mcp/sse_test.go b/mcp/sse_test.go index d9b7a2b..86b2cf1 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -12,79 +12,12 @@ import ( "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) { @@ -386,98 +319,3 @@ func TestSSELocalhostProtection(t *testing.T) { }) } } - -// 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 f24badb..7e0dddd 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -184,7 +184,8 @@ type StreamableHTTPOptions struct { // CrossOriginProtection allows to customize cross-origin protection. // The deny handler set in the CrossOriginProtection through SetDenyHandler // is ignored. - // If nil, no cross-origin protection is applied. + // If nil, no cross-origin protection is applied. Use the `enableoriginverification` + // MCPGODEBUG compatibility parameter to enable the default protection until v1.8.0. // // Deprecated: wrap the handler with cross-origin protection middleware // instead. For example: @@ -206,17 +207,6 @@ type StreamableHTTPOptions struct { // 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 @@ -239,6 +229,10 @@ func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *Strea h.opts.Logger = ensureLogger(h.opts.Logger) + if h.opts.CrossOriginProtection == nil && enableoriginverification == "1" { + h.opts.CrossOriginProtection = &http.CrossOriginProtection{} + } + if h.opts.MaxRequestBodyBytes == 0 { h.opts.MaxRequestBodyBytes = DefaultMaxRequestBodyBytes } @@ -269,6 +263,20 @@ 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. +// See the documentation for the mcpgodebug package for instructions how to enable it. +// The option will be removed in the 1.8.0 version of the SDK. +var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection") + +// enableoriginverification is a compatibility parameter that restores the +// default cross-origin protection behavior from v1.4.1-v1.5.0. When set to +// "1", a zero-value CrossOriginProtection will be applied if none is +// explicitly provided in StreamableHTTPOptions. +// See the documentation for the mcpgodebug package for instructions how to enable it. +// The option will be removed in the 1.8.0 version of the SDK. +var enableoriginverification = mcpgodebug.Value("enableoriginverification") + // 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 @@ -281,25 +289,14 @@ var allowsessionsinstateless = mcpgodebug.Value("allowsessionsinstateless") // 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. +// the client always attempts to surface the underlying JSON-RPC error. 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") +// disablecontenttypecheck is a compatibility parameter that allows to disable +// Content-Type validation on POST requests. +// See the documentation for the mcpgodebug package for instructions how to enable it. +// The option will be removed in the 1.8.0 version of the SDK. +var disablecontenttypecheck = mcpgodebug.Value("disablecontenttypecheck") // 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 @@ -316,7 +313,7 @@ func writeJSONRPCError(w http.ResponseWriter, status int, id jsonrpc.ID, jerr *j 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 { + if !h.opts.DisableLocalhostProtection && disablelocalhostprotection != "1" { 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) @@ -338,21 +335,13 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque } // [§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). + // header. If provided, it must be a supported version. 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) + if protocolVersion != "" && !slices.Contains(supportedProtocolVersions, protocolVersion) && protocolVersion < protocolVersion20260728 { + http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedProtocolVersions, ",")), http.StatusBadRequest) return } req = req.WithContext(context.WithValue(req.Context(), protocolVersionContextKey{}, protocolVersion)) @@ -385,7 +374,7 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http. return } - if baseMediaType(req.Header.Get("Content-Type")) != "application/json" { + if disablecontenttypecheck != "1" && baseMediaType(req.Header.Get("Content-Type")) != "application/json" { http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) return } @@ -428,7 +417,7 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http. EventStore: h.opts.EventStore, jsonResponse: h.opts.JSONResponse, logger: h.opts.Logger, - shouldPropagateCancellation: info.usesNewProtocol && (info.isSubscriptionsListen || h.opts.PropagateRequestCancellation), + shouldPropagateCancellation: info.isSubscriptionsListen && info.usesNewProtocol, } session, err := connectStreamable(req.Context(), server, transport, info.opts) @@ -619,7 +608,7 @@ func (h *StreamableHTTPHandler) serveStatefulDELETE(w http.ResponseWriter, req * // 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" { + if disablecontenttypecheck != "1" && baseMediaType(req.Header.Get("Content-Type")) != "application/json" { http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) return } @@ -963,10 +952,6 @@ 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. @@ -1117,7 +1102,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 && !s.isBatch { + if len(s.pendingJSONMessages) == 1 { toWrite = s.pendingJSONMessages[0] } else { toWrite, err = json.Marshal(s.pendingJSONMessages) @@ -1505,51 +1490,23 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // 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 + if protocolVersion >= protocolVersion20260728 || metaVersion != "" { + // Extract again the protcol 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, - }) + 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 } if headerVersion == "" { @@ -1673,7 +1630,6 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques 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 @@ -1993,11 +1949,6 @@ 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. @@ -2016,11 +1967,6 @@ 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 ( @@ -2085,7 +2031,6 @@ 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 } @@ -2107,10 +2052,6 @@ 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 @@ -2297,7 +2238,7 @@ 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, msg); err != nil { + if err := c.setMCPHeaders(req); 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. @@ -2352,14 +2293,10 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e } 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. + if requestMethod == methodDiscover { + // Wrap the discover failure with ErrRejected so the jsonrpc2 layer + // doesn't set writeErr, which would prevent the legacy initialize + // fallback from succeeding on the same connection. err = fmt.Errorf("%w: %w", err, jsonrpc2.ErrRejected) } else if !errors.Is(err, jsonrpc2.ErrRejected) { // Only fail the connection for non-transient errors. @@ -2425,7 +2362,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e return nil } -func (c *streamableClientConn) setMCPHeaders(req *http.Request, msg jsonrpc.Message) error { +func (c *streamableClientConn) setMCPHeaders(req *http.Request) error { c.mu.Lock() defer c.mu.Unlock() @@ -2455,12 +2392,10 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request, msg jsonrpc.Mess } } } - if pv := protocolVersionFromMessage(msg); pv != "" { - req.Header.Set(protocolVersionHeader, pv) - } else if c.initializedResult != nil { + if c.initializedResult != nil { req.Header.Set(protocolVersionHeader, c.initializedResult.ProtocolVersion) - } else if pv := protocolVersionFromContext(req.Context()); pv != "" { - req.Header.Set(protocolVersionHeader, pv) + } else if v := protocolVersionFromContext(req.Context()); v != "" { + req.Header.Set(protocolVersionHeader, v) } if c.sessionID != "" { req.Header.Set(sessionIDHeader, c.sessionID) @@ -2469,23 +2404,6 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request, msg jsonrpc.Mess 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() @@ -2580,31 +2498,31 @@ func (c *streamableClientConn) checkResponse(ctx context.Context, requestSummary 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) { + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // By default, always try to decode the body and surface the underlying + // JSON-RPC error. + // Setting MCPGODEBUG=noprotocolerrorbody=1 restores the previous behavior. + if noprotocolerrorbody == "1" { + return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode)) + } 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)) + return fmt.Errorf("%s: %w: %v", requestSummary, response.Error, 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)) } return nil @@ -2620,7 +2538,7 @@ func (c *streamableClientConn) processStream(ctx context.Context, requestSummary io.Copy(io.Discard, resp.Body) resp.Body.Close() }() - for evt, err := range scanEventsLimited(resp.Body, c.maxEventSize) { + for evt, err := range scanEvents(resp.Body) { if err != nil { if ctx.Err() != nil { return "", 0, true // don't reconnect: client cancelled @@ -2747,7 +2665,7 @@ func (c *streamableClientConn) connectSSE(ctx context.Context, lastEventID strin if err != nil { return nil, err } - if err := c.setMCPHeaders(req, nil); err != nil { + if err := c.setMCPHeaders(req); err != nil { return nil, err } if lastEventID != "" { @@ -2779,13 +2697,11 @@ func (c *streamableClientConn) Close() error { // No session was established (e.g. the server is stateless), // so there is nothing to delete. } else { - reqCtx, stop := context.WithTimeout(c.ctx, closeDeleteTimeout) - defer stop() - req, err := http.NewRequestWithContext(reqCtx, http.MethodDelete, c.url, nil) + req, err := http.NewRequestWithContext(c.ctx, http.MethodDelete, c.url, nil) if err != nil { c.closeErr = err } else { - if err := c.setMCPHeaders(req, nil); err != nil { + if err := c.setMCPHeaders(req); err != nil { c.closeErr = err } else if resp, err := c.client.Do(req); err != nil { c.closeErr = err diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 77ff81a..59bf1bd 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -214,7 +214,7 @@ func TestStreamableClientTransportLifecycle(t *testing.T) { // 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}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -267,7 +267,7 @@ func TestStreamableClientRedundantDelete(t *testing.T) { // 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}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -281,48 +281,6 @@ 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() @@ -374,7 +332,7 @@ func TestStreamableClientGETHandling(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) session, err := client.Connect(ctx, transport, &ClientSessionOptions{ - ProtocolVersion: protocolVersion20251125, + protocolVersion: protocolVersion20251125, }) if err == nil { defer session.Close() @@ -456,7 +414,7 @@ func TestStreamableClientStrictness(t *testing.T) { // 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}) + 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) } @@ -496,7 +454,7 @@ func TestStreamableClientUnresumableRequest(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - cs, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err == nil { cs.Close() t.Fatalf("Connect succeeded unexpectedly") @@ -617,7 +575,7 @@ data: { "jsonrpc": "2.0", "method": "notifications/message", "params": { "level" // 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}) + cs, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -749,7 +707,7 @@ func TestStreamableClientTransientErrors(t *testing.T) { // 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}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect failed: %v", err) } @@ -855,7 +813,7 @@ data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info" // 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}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect failed: %v", err) } @@ -943,7 +901,7 @@ func TestStreamableClientDisableStandaloneSSE(t *testing.T) { // 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}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -1064,7 +1022,7 @@ func TestStreamableClientOAuth_AuthorizationHeader(t *testing.T) { // 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}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -1307,13 +1265,11 @@ func TestStreamableClientOAuth_RetrieveError(t *testing.T) { // 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}, }, + ServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, Instructions: "test discover", } @@ -1358,7 +1314,7 @@ func TestStreamableClientConnect_DiscoverSuccess(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1406,110 +1362,6 @@ func TestStreamableClientConnect_DiscoverSuccess(t *testing.T) { } } -// 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. @@ -1566,7 +1418,7 @@ func TestStreamableClientConnect_DiscoverMethodNotFound(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1633,7 +1485,7 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersion(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1693,7 +1545,7 @@ func TestStreamableClientConnect_DiscoverMethodNotFoundVPre(t *testing.T) { DisableStandaloneSSE: true, } client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1750,7 +1602,7 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionVPre(t *testing.T) { DisableStandaloneSSE: true, } client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1808,7 +1660,7 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionNegotiation(t *testin transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: unsupportedClientVersion}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: unsupportedClientVersion}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1821,211 +1673,3 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionNegotiation(t *testin 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_headers.go b/mcp/streamable_headers.go index c2b8dfd..3989c01 100644 --- a/mcp/streamable_headers.go +++ b/mcp/streamable_headers.go @@ -207,12 +207,7 @@ func setStandardHeaders(ctx context.Context, header http.Header, msg jsonrpc.Mes 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) - } + header.Set(nameHeader, name) } if msg.Method == "tools/call" { if tool, ok := ctx.Value(toolContextKey).(*Tool); ok && tool != nil { @@ -267,12 +262,6 @@ 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 @@ -383,18 +372,13 @@ func validateMcpHeaders(header http.Header, msg jsonrpc.Message, toolLookup func 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") - } + var ok bool 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 nameInHeader != nameInBody { + return fmt.Errorf("header mismatch: Mcp-Name header value '%s' does not match body value '%s'", nameInHeader, nameInBody) } } diff --git a/mcp/streamable_headers_test.go b/mcp/streamable_headers_test.go index 97af67c..52d5244 100644 --- a/mcp/streamable_headers_test.go +++ b/mcp/streamable_headers_test.go @@ -6,7 +6,6 @@ package mcp import ( "context" - "encoding/base64" "encoding/json" "fmt" "net/http" @@ -336,23 +335,6 @@ func TestValidateMcpHeaders(t *testing.T) { 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, @@ -987,15 +969,6 @@ func TestFilterValidTools(t *testing.T) { 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) { @@ -1593,57 +1566,3 @@ func TestValidateParamHeaders_NestedArguments(t *testing.T) { 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 7af9dff..c1c7943 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -288,14 +288,8 @@ func TestStreamableConcurrentHandling(t *testing.T) { t.Errorf("CallTool failed: %v", err) return } - 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) + if got := int(res.StructuredContent.(map[string]any)["Count"].(float64)); got != i { + t.Errorf("got count %d, want %d", got, i) } } }) @@ -353,7 +347,7 @@ func TestStreamableServerShutdown(t *testing.T) { clientSession, err := client.Connect(ctx, &StreamableClientTransport{ Endpoint: httpServer.URL, MaxRetries: -1, // avoid slow tests during exponential retries - }, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + }, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -628,7 +622,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) } @@ -703,7 +697,7 @@ func TestServerTransportCleanup(t *testing.T) { // 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}) + clientSession, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -800,15 +794,6 @@ func completeCallToolResult() *CallToolResult { 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), @@ -1550,7 +1535,7 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string, var respBody []byte if contentType == "text/event-stream" { r := readerInto{resp.Body, new(bytes.Buffer)} - for evt, err := range scanEventsLimited(r, DefaultMaxEventSize) { + for evt, err := range scanEvents(r) { if err != nil { return newSessionID, resp.StatusCode, nil, fmt.Errorf("reading events: %v", err) } @@ -1973,7 +1958,6 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { 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{ { @@ -1985,7 +1969,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { }, messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusOK, - wantMessages: []jsonrpc.Message{resp(2, completeCallToolResultWithServerInfo(serverImpl), nil)}, + wantMessages: []jsonrpc.Message{resp(2, completeCallToolResult(), nil)}, }, { method: "POST", @@ -2029,7 +2013,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { }, messages: []jsonrpc.Message{req(6, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusOK, - wantMessages: []jsonrpc.Message{resp(6, completeCallToolResultWithServerInfo(serverImpl), nil)}, + wantMessages: []jsonrpc.Message{resp(6, completeCallToolResult(), nil)}, }, { method: "POST", @@ -2045,7 +2029,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { Arguments: map[string]any{"region": "us-west1", "query": "SELECT 1"}, })}, wantStatusCode: http.StatusOK, - wantMessages: []jsonrpc.Message{resp(7, completeCallToolResultWithServerInfo(serverImpl), nil)}, + wantMessages: []jsonrpc.Message{resp(7, completeCallToolResult(), nil)}, }, { method: "POST", @@ -2128,7 +2112,7 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) ctx := context.Background() - session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{ProtocolVersion: minVersionForStandardHeaders}) + session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) if err != nil { t.Fatal(err) } @@ -2291,7 +2275,7 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) ctx := context.Background() - session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{ProtocolVersion: minVersionForStandardHeaders}) + session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) if err != nil { t.Fatal(err) } @@ -2389,7 +2373,7 @@ func TestStreamableFilterValidToolsIntegration(t *testing.T) { ctx := context.Background() session, err := client.Connect(ctx, &StreamableClientTransport{ Endpoint: httpServer.URL, - }, &ClientSessionOptions{ProtocolVersion: minVersionForStandardHeaders}) + }, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) if err != nil { t.Fatal(err) } @@ -2650,7 +2634,7 @@ func TestStreamableSessionTimeout(t *testing.T) { // 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}, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -2862,69 +2846,6 @@ 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) { @@ -2947,7 +2868,7 @@ data: {"jsonrpc":"2.0","method":"test2","params":{}} var events []Event // Scan all events - for evt, err := range scanEventsLimited(reader, DefaultMaxEventSize) { + for evt, err := range scanEvents(reader) { if err != nil { if err != io.EOF { t.Fatalf("scanEvents error: %v", err) @@ -3007,7 +2928,7 @@ func Test_ExportErrSessionMissing(t *testing.T) { // 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}) + session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect failed: %v", err) } @@ -3573,174 +3494,11 @@ func TestStreamableStateful_RejectsNewProtocol(t *testing.T) { 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 @@ -3781,126 +3539,6 @@ func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) { } } -// 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 @@ -3931,7 +3569,7 @@ func TestStreamableClientUnsupportedVersionFallback(t *testing.T) { client := NewClient(testImpl, nil) transport := &StreamableClientTransport{Endpoint: httpServer.URL} - session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -4034,70 +3672,6 @@ func TestStreamableStateful_AcceptsDiscover(t *testing.T) { } } -// 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) { @@ -4123,7 +3697,7 @@ func TestStreamableHTTP_E2E_DiscoverSuccess(t *testing.T) { client := NewClient(&Implementation{Name: "e2e-client", Version: "v1"}, nil) transport := &StreamableClientTransport{Endpoint: httpServer.URL} - cs, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -4229,52 +3803,6 @@ func TestStreamableServerRejectsDuplicateInFlightRequestID(t *testing.T) { } } -// 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: @@ -4376,80 +3904,3 @@ func TestStreamableMaxRequestBodyBytes(t *testing.T) { }) } } - -// 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 index 9159563..9b8276c 100644 --- a/mcp/testdata/conformance/server/discover.txtar +++ b/mcp/testdata/conformance/server/discover.txtar @@ -1,7 +1,7 @@ -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`. +The client sends a discover request +carrying the required per-request _meta triple (protocolVersion, clientInfo, +clientCapabilities), and the server replies with the protocol versions it +supports, its capabilities, and its identity. -- client -- { @@ -21,13 +21,6 @@ identity via `_meta.io.modelcontextprotocol/serverInfo`. "jsonrpc": "2.0", "id": 1, "result": { - "resultType": "complete", - "_meta": { - "io.modelcontextprotocol/serverInfo": { - "name": "testServer", - "version": "v1.0.0" - } - }, "ttlMs": 0, "cacheScope": "public", "supportedVersions": [ @@ -39,6 +32,10 @@ identity via `_meta.io.modelcontextprotocol/serverInfo`. ], "capabilities": { "logging": {} + }, + "serverInfo": { + "name": "testServer", + "version": "v1.0.0" } } } diff --git a/mcp/testdata/conformance/server/mrtr.txtar b/mcp/testdata/conformance/server/mrtr.txtar index 046a79b..7ee6dc5 100644 --- a/mcp/testdata/conformance/server/mrtr.txtar +++ b/mcp/testdata/conformance/server/mrtr.txtar @@ -45,12 +45,6 @@ confirmThenGreet "jsonrpc": "2.0", "id": 1, "result": { - "_meta": { - "io.modelcontextprotocol/serverInfo": { - "name": "testServer", - "version": "v1.0.0" - } - }, "content": null, "requestState": "step=1", "resultType": "input_required", @@ -58,7 +52,7 @@ confirmThenGreet "who": { "method": "elicitation/create", "params": { - "mode": "form", + "mode": "", "message": "What is your name?", "requestedSchema": { "type": "object", @@ -77,12 +71,6 @@ confirmThenGreet "jsonrpc": "2.0", "id": 2, "result": { - "_meta": { - "io.modelcontextprotocol/serverInfo": { - "name": "testServer", - "version": "v1.0.0" - } - }, "content": [ { "type": "text", diff --git a/mcp/tool.go b/mcp/tool.go index 11ad100..04cf551 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.StructuredContent. +// - The Out value is used to populate result.StructuredOutput. // - 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 diff --git a/mcp/tool_example_test.go b/mcp/tool_example_test.go index 610755b..8efc3ee 100644 --- a/mcp/tool_example_test.go +++ b/mcp/tool_example_test.go @@ -253,74 +253,6 @@ 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/transport.go b/mcp/transport.go index 08d3b15..55c73d7 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -18,7 +18,6 @@ import ( 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" ) @@ -29,13 +28,6 @@ import ( // 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") @@ -114,27 +106,16 @@ 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 { - // 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 -} +type StdioTransport struct{} // Connect implements the [Transport] interface. -func (t *StdioTransport) Connect(context.Context) (Connection, error) { - return newIOConnLimited(rwc{os.Stdin, nopCloserWriter{os.Stdout}}, t.MaxLineLength), nil +func (*StdioTransport) Connect(context.Context) (Connection, error) { + return newIOConn(rwc{os.Stdin, nopCloserWriter{os.Stdout}}), nil } // nopCloserWriter is an io.WriteCloser with a trivial Close method. @@ -149,15 +130,11 @@ 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 newIOConnLimited(rwc{t.Reader, t.Writer}, t.MaxLineLength), nil + return newIOConn(rwc{t.Reader, t.Writer}), nil } // An InMemoryTransport is a [Transport] that communicates over an in-memory @@ -207,7 +184,7 @@ 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{logger: logger} + preempter canceller ) bind := func(conn *jsonrpc2.Connection) jsonrpc2.Handler { h = b.bind(mcpConn, conn, s, onClose) @@ -252,8 +229,7 @@ type cancellationPropagator interface { // A canceller is a jsonrpc2.Preempter that cancels in-flight requests on MCP // cancelled notifications. type canceller struct { - conn *jsonrpc2.Connection - logger *slog.Logger + conn *jsonrpc2.Connection } // Preempt implements [jsonrpc2.Preempter]. @@ -267,33 +243,11 @@ func (c *canceller) Preempt(ctx context.Context, req *jsonrpc.Request) (result a if err != nil { return nil, err } - // 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}) + go c.conn.Cancel(id) } 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 @@ -322,28 +276,8 @@ 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: - // 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. - // - // 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()) - 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() + err := cancelCall(ctx, conn, call) + return errors.Join(ctx.Err(), err) case err != nil: return fmt.Errorf("calling %q: %w", method, err) } @@ -514,17 +448,6 @@ 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{}) @@ -536,15 +459,7 @@ func newIOConnLimited(rwc io.ReadWriteCloser, maxLineLength int) *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() { - var ( - reader io.Reader = rwc - limiter *frameLimitReader - ) - if limit > 0 { - limiter = &frameLimitReader{r: rwc, limit: limit} - reader = limiter - } - dec := json.NewDecoder(reader) + dec := json.NewDecoder(rwc) for { var raw json.RawMessage err := dec.Decode(&raw) @@ -570,9 +485,6 @@ func newIOConnLimited(rwc io.ReadWriteCloser, maxLineLength int) *ioConn { if err != nil { return } - if limiter != nil { - limiter.resetFrame() - } } }() return &ioConn{ @@ -582,42 +494,20 @@ func newIOConnLimited(rwc io.ReadWriteCloser, maxLineLength int) *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 := state.NegotiatedProtocolVersion + protocolVersion := "" + if state.InitializeParams != nil { + protocolVersion = state.InitializeParams.ProtocolVersion + } 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() @@ -734,7 +624,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 && req.IsCall() { + if req, ok := msg.(*jsonrpc.Request); ok { 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 e264133..1920233 100644 --- a/mcp/transport_example_test.go +++ b/mcp/transport_example_test.go @@ -42,7 +42,7 @@ func ExampleLoggingTransport() { } // Output: - // 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":{}}}} + // read: {"jsonrpc":"2.0","id":1,"result":{"ttlMs":0,"cacheScope":"public","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"capabilities":{"logging":{}},"serverInfo":{"name":"server","version":"v0.0.1"}}} // 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/mcp/transport_test.go b/mcp/transport_test.go index a20ee5f..730400e 100644 --- a/mcp/transport_test.go +++ b/mcp/transport_test.go @@ -5,14 +5,10 @@ package mcp import ( - "bytes" "context" - "errors" - "fmt" "io" "strings" "testing" - "time" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" @@ -60,15 +56,10 @@ func TestBatchFraming(t *testing.T) { func TestIOConnRead(t *testing.T) { tests := []struct { - name string - input string - want string - // protocolVersion is the version negotiated by 'initialize'; empty - // means no session state was pushed to the connection. + name string + input string + want string 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", @@ -109,16 +100,6 @@ 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) { @@ -127,15 +108,10 @@ 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: requested, + ProtocolVersion: tt.protocolVersion, }, - NegotiatedProtocolVersion: tt.protocolVersion, }) } _, err := tr.Read(context.Background()) @@ -171,187 +147,3 @@ func TestIOConnRead_EmptyMethod(t *testing.T) { 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/oauthex/dcr.go b/oauthex/dcr.go index f95c55b..f46d0e8 100644 --- a/oauthex/dcr.go +++ b/oauthex/dcr.go @@ -175,10 +175,6 @@ 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 == "" { @@ -208,7 +204,7 @@ func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta } defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, maxRegistrationResponseBytes)) + body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read registration response body: %w", err) } diff --git a/oauthex/discovery_test.go b/oauthex/discovery_test.go deleted file mode 100644 index 8b64d0c..0000000 --- a/oauthex/discovery_test.go +++ /dev/null @@ -1,158 +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 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 0e77149..689f17a 100644 --- a/oauthex/oauth2.go +++ b/oauthex/oauth2.go @@ -11,31 +11,14 @@ 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 } @@ -52,7 +35,10 @@ func getJSON[T any](ctx context.Context, c *http.Client, url string, limit int64 if err != nil { return nil, err } - res, err := newDiscoveryClient(c).Do(req) + if c == nil { + c = http.DefaultClient + } + res, err := c.Do(req) if err != nil { return nil, err } @@ -105,112 +91,5 @@ 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 e8fe6e1..f31d8df 100755 --- a/scripts/server-conformance.sh +++ b/scripts/server-conformance.sh @@ -69,14 +69,9 @@ fi # Build the conformance server. go build -o "$WORKDIR/conformance-server" ./conformance/everything-server -# 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. +# Start the server in the background echo "Starting conformance server on localhost:$PORT..." -"$WORKDIR/conformance-server" -http="localhost:$PORT" -stateless=false & +"$WORKDIR/conformance-server" -http="localhost:$PORT" & SERVER_PID=$! echo "Server pid is $SERVER_PID" @@ -95,13 +90,11 @@ 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