Compare commits

..
82 changed files with 800 additions and 9744 deletions

View file

@ -5,11 +5,3 @@ updates:
directory: "/"
schedule:
interval: "monthly"
# Group all github/codeql-action/* sub-paths (init, analyze, ...) into a
# single PR so they are always bumped together. Bumping them independently
# leaves the workflow with mismatched versions across steps, which
# github/codeql-action rejects at runtime.
groups:
codeql-action:
patterns:
- "github/codeql-action/*"

View file

@ -36,13 +36,13 @@ jobs:
build-mode: autobuild
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
category: "/language:${{matrix.language}}"

View file

@ -14,25 +14,25 @@ permissions:
contents: read
env:
CONFORMANCE_VERSION: "0.2.0-alpha.11"
CONFORMANCE_VERSION: "0.2.0-alpha.5"
jobs:
server-conformance:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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)
run: |
./everything-server -http=":3001" -stateless &
SERVER_PID=$!
@ -50,25 +51,26 @@ 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 active \
--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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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)
- name: Run conformance tests
run: |
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \
--command "go run ./conformance/everything-client" \
--suite all \
--suite core \
--expected-failures ./conformance/baseline.yml

View file

@ -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@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "^1.26"
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check docs are up-to-date
run: |
go generate ./...

View file

@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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

View file

@ -13,16 +13,16 @@ jobs:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: 3.x
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache

View file

@ -34,12 +34,12 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
sarif_file: results.sarif

View file

@ -14,9 +14,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.26"
- name: Test with -race

View file

@ -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,

View file

@ -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.
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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

View file

@ -1,14 +1,2 @@
client:
# OAuth mechanisms not implemented by the Go MCP SDK.
- auth/client-credentials-basic
- auth/client-credentials-jwt
- auth/enterprise-managed-authorization
# SEP-1932 (DPoP Sender-Constrained Tokens) client-side support not yet
# implemented by the Go MCP SDK.
- auth/dpop
- auth/dpop-nonce
# SEP-1933 (Workload Identity Federation JWT-bearer) client-side support
# not yet implemented by the Go MCP SDK.
- auth/wif-jwt-bearer
server: [] # All tests pass!
client: [] # All tests pass!

View file

@ -45,44 +45,32 @@ func registerScenario(name string, handler scenarioHandler) {
func init() {
registerScenario("initialize", runBasicClient)
registerScenario("tools_call", runToolsCallClient)
registerScenario("request-metadata", runRequestMetadataClient)
registerScenario("elicitation-sep1034-client-defaults", runElicitationDefaultsClient)
registerScenario("sse-retry", runSSERetryClient)
registerScenario("json-schema-ref-no-deref", runJSONSchemaRefNoDerefClient)
registerScenario("json-schema-2020-12-preservation", runJSONSchemaPreservationClient)
registerScenario("sep-2322-client-request-state", runMrtrClient)
registerScenario("http-standard-headers", runHTTPStandardHeadersClient)
registerScenario("http-custom-headers", runHTTPCustomHeadersClient)
registerScenario("http-invalid-tool-headers", runHTTPInvalidToolHeadersClient)
authScenarios := []string{
"auth/2025-03-26-oauth-metadata-backcompat",
"auth/2025-03-26-oauth-endpoint-fallback",
"auth/authorization-server-migration",
"auth/basic-cimd",
"auth/iss-normalized",
"auth/iss-not-advertised",
"auth/iss-supported",
"auth/iss-supported-missing",
"auth/iss-unexpected",
"auth/iss-wrong-issuer",
"auth/metadata-default",
"auth/metadata-issuer-mismatch",
"auth/metadata-var1",
"auth/metadata-var2",
"auth/metadata-var3",
"auth/offline-access-not-supported",
"auth/offline-access-scope",
"auth/pre-registration",
"auth/resource-mismatch",
"auth/scope-from-scopes-supported",
"auth/scope-from-www-authenticate",
"auth/scope-from-scopes-supported",
"auth/scope-omitted-when-undefined",
"auth/scope-retry-limit",
"auth/scope-step-up",
"auth/scope-retry-limit",
"auth/token-endpoint-auth-basic",
"auth/token-endpoint-auth-none",
"auth/token-endpoint-auth-post",
"auth/token-endpoint-auth-none",
"auth/iss-supported",
"auth/iss-not-advertised",
"auth/iss-supported-missing",
"auth/iss-wrong-issuer",
"auth/iss-unexpected",
}
for _, scenario := range authScenarios {
registerScenario(scenario, runAuthClient)
@ -305,238 +293,6 @@ func runAuthClient(ctx context.Context, serverURL string, configCtx map[string]a
return nil
}
// ============================================================================
// request-metadata scenario (SEP-2575)
// ============================================================================
// runRequestMetadataClient exercises the SEP-2575 wire-level negotiation:
// every request must carry the MCP-Protocol-Version header and the per-request
// _meta envelope, and the client must retry with a supported version when its
// first choice is rejected with -32022. The Go SDK's Connect() drives
// server/discover unconditionally for 2026-07-28, which is exactly that
// mechanism.
func runRequestMetadataClient(ctx context.Context, serverURL string, _ map[string]any) error {
session, err := connectToServer(ctx, serverURL)
if err != nil {
return err
}
defer session.Close()
return nil
}
// ============================================================================
// json-schema-ref-no-deref scenario (SEP-2106)
// ============================================================================
// runJSONSchemaRefNoDerefClient asserts that listTools does not dereference
// network $ref URLs in tool schemas. The Go SDK never fetches external refs;
// a plain connect → listTools is sufficient — if the SDK ever regressed and
// tried to GET the canary URL the conformance referee would record the
// failure.
func runJSONSchemaRefNoDerefClient(ctx context.Context, serverURL string, _ map[string]any) error {
session, err := connectToServer(ctx, serverURL)
if err != nil {
return err
}
defer session.Close()
_, _ = session.ListTools(ctx, nil)
return nil
}
// ============================================================================
// json-schema-2020-12-preservation scenario (SEP-1613, SEP-2106)
// ============================================================================
// runJSONSchemaPreservationClient echoes the focal tool's inputSchema back to
// the referee verbatim, through the permissive json_schema_echo tool, so that
// it can observe which 2020-12 keywords survived the client's parse of
// tools/list. The Go SDK surfaces a server's schema as the raw JSON object it
// received, so nothing is stripped.
func runJSONSchemaPreservationClient(ctx context.Context, serverURL string, _ map[string]any) error {
session, err := connectToServer(ctx, serverURL)
if err != nil {
return err
}
defer session.Close()
tools, err := session.ListTools(ctx, nil)
if err != nil {
return fmt.Errorf("session.ListTools(): %v", err)
}
const focalTool = "json_schema_2020_12_tool"
idx := slices.IndexFunc(tools.Tools, func(t *mcp.Tool) bool {
return t.Name == focalTool
})
if idx == -1 {
return fmt.Errorf("tool %q not found", focalTool)
}
const echoTool = "json_schema_echo"
if _, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: echoTool,
Arguments: map[string]any{"schema": tools.Tools[idx].InputSchema},
}); err != nil {
return fmt.Errorf("session.CallTool(%q): %v", echoTool, err)
}
return nil
}
// ============================================================================
// SEP-2322 multi-round-trip client scenario
// ============================================================================
// runMrtrClient drives the SEP-2322 client-request-state scenario. The
// SDK's built-in client-side MRTR middleware transparently fulfills embedded
// elicitation requests via ElicitationHandler and echoes requestState.
func runMrtrClient(ctx context.Context, serverURL string, _ map[string]any) error {
elicitationHandler := func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
return &mcp.ElicitResult{
Action: "accept",
Content: map[string]any{"confirmed": true},
}, nil
}
session, err := connectToServer(ctx, serverURL,
withClientOptions(&mcp.ClientOptions{
Capabilities: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{}},
ElicitationHandler: elicitationHandler,
}),
)
if err != nil {
return err
}
defer session.Close()
toolNames := []string{
"test_mrtr_echo_state",
"test_mrtr_no_state",
"test_mrtr_unrelated",
"test_mrtr_no_result_type",
}
for _, name := range toolNames {
if _, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: name,
Arguments: map[string]any{},
}); err != nil {
log.Printf("CallTool(%q) rejected locally (expected for some cases): %v", name, err)
}
}
return nil
}
// ============================================================================
// SEP-2243 HTTP header scenarios
// ============================================================================
// runHTTPStandardHeadersClient (SEP-2243 standard headers) drives tools,
// resources and prompts once each so the referee sees Mcp-Method / Mcp-Name
// headers on every verb the SDK supports.
func runHTTPStandardHeadersClient(ctx context.Context, serverURL string, _ map[string]any) error {
session, err := connectToServer(ctx, serverURL)
if err != nil {
return err
}
defer session.Close()
if tools, err := session.ListTools(ctx, nil); err == nil && len(tools.Tools) > 0 {
_, _ = session.CallTool(ctx, &mcp.CallToolParams{
Name: tools.Tools[0].Name,
Arguments: map[string]any{},
})
}
if res, err := session.ListResources(ctx, nil); err == nil && len(res.Resources) > 0 {
_, _ = session.ReadResource(ctx, &mcp.ReadResourceParams{URI: res.Resources[0].URI})
}
if prompts, err := session.ListPrompts(ctx, nil); err == nil && len(prompts.Prompts) > 0 {
_, _ = session.GetPrompt(ctx, &mcp.GetPromptParams{
Name: prompts.Prompts[0].Name,
Arguments: map[string]string{},
})
}
return nil
}
// runHTTPCustomHeadersClient (SEP-2243 custom headers) lists tools (so the
// SDK caches inputSchema and its x-mcp-header annotations), then makes the
// runner-supplied tool calls so the referee validates the Mcp-Param-*
// headers.
func runHTTPCustomHeadersClient(ctx context.Context, serverURL string, configCtx map[string]any) error {
session, err := connectToServer(ctx, serverURL)
if err != nil {
return err
}
defer session.Close()
if _, err := session.ListTools(ctx, nil); err != nil {
return fmt.Errorf("session.ListTools(): %v", err)
}
for _, call := range readToolCallsContext(configCtx) {
if _, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: call.Name,
Arguments: call.Arguments,
}); err != nil {
log.Printf("CallTool(%q): %v", call.Name, err)
}
}
return nil
}
// runHTTPInvalidToolHeadersClient (SEP-2243 invalid-tool filtering) lists
// tools — a correct SDK leaves only the valid ones — then calls each survivor
// so the referee records SUCCESS for every excluded tool never called.
func runHTTPInvalidToolHeadersClient(ctx context.Context, serverURL string, _ map[string]any) error {
session, err := connectToServer(ctx, serverURL)
if err != nil {
return err
}
defer session.Close()
tools, err := session.ListTools(ctx, nil)
if err != nil {
return fmt.Errorf("session.ListTools(): %v", err)
}
for _, tool := range tools.Tools {
_, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: tool.Name,
Arguments: map[string]any{"region": "us-west1"},
})
if err != nil {
log.Printf("call %q rejected: %v", tool.Name, err)
}
}
return nil
}
type toolCall struct {
Name string
Arguments map[string]any
}
// readToolCallsContext parses the `toolCalls` array from MCP_CONFORMANCE_CONTEXT.
// Format: {"toolCalls": [{"name":"...", "arguments":{...}}, ...]}.
func readToolCallsContext(configCtx map[string]any) []toolCall {
raw, ok := configCtx["toolCalls"].([]any)
if !ok {
return nil
}
out := make([]toolCall, 0, len(raw))
for _, item := range raw {
obj, ok := item.(map[string]any)
if !ok {
continue
}
name, _ := obj["name"].(string)
args, _ := obj["arguments"].(map[string]any)
if args == nil {
args = map[string]any{}
}
out = append(out, toolCall{Name: name, Arguments: args})
}
return out
}
// ============================================================================
// Main entry point
// ============================================================================

View file

@ -19,11 +19,9 @@ import (
"log"
"net/http"
"os"
"strings"
"time"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/yosida95/uritemplate/v3"
)
@ -156,19 +154,14 @@ func registerTools(server *mcp.Server) {
Description: "Tests elicitation with enum schema improvements per SEP-1330",
}, testElicitationEnumsHandler)
// SEP-1613 / SEP-2106: JSON Schema 2020-12 conformance test tool.
// The scenario verifies that $schema/$defs/additionalProperties (SEP-1613)
// and the broader 2020-12 vocabulary — $anchor, allOf/anyOf, if/then/else —
// (SEP-2106) survive tools/list verbatim.
mcp.AddTool(server, &mcp.Tool{
Name: "json_schema_2020_12_tool",
Description: "Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613, SEP-2106)",
Description: "Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)",
InputSchema: json.RawMessage(`{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"$defs": {
"address": {
"$anchor": "addressDef",
"type": "object",
"properties": {
"street": { "type": "string" },
@ -178,20 +171,8 @@ func registerTools(server *mcp.Server) {
},
"properties": {
"name": { "type": "string" },
"address": { "$ref": "#/$defs/address" },
"contactMethod": { "type": "string", "enum": ["phone", "email"] },
"phone": { "type": "string" },
"email": { "type": "string" }
"address": { "$ref": "#/$defs/address" }
},
"allOf": [
{ "anyOf": [{ "required": ["phone"] }, { "required": ["email"] }] }
],
"if": {
"properties": { "contactMethod": { "const": "phone" } },
"required": ["contactMethod"]
},
"then": { "required": ["phone"] },
"else": { "required": ["email"] },
"additionalProperties": false
}`),
}, jsonSchema202012Handler)
@ -200,87 +181,6 @@ func registerTools(server *mcp.Server) {
Name: "test_reconnection",
Description: "Tests SSE stream disconnection and client reconnection (SEP-1699). Server will close the stream mid-call and send the result after client reconnects.",
}, testReconnectionHandler)
// SEP-2243 x-mcp-header tool — arms the http-custom-header-server-validation
// conformance scenario (which skips when no tool with an x-mcp-header
// annotation is found).
mcp.AddTool(server, &mcp.Tool{
Name: "test_x_mcp_header",
Description: "Tests SEP-2243 Mcp-Param-* server-side validation",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"region": { "type": "string", "description": "mirrored into Mcp-Param-Region", "x-mcp-header": "Region" },
"level": { "type": "integer", "description": "non-mirrored argument" }
}
}`),
}, testXMcpHeaderHandler)
// SEP-2575 diagnostic tools used by the stateless conformance scenario.
mcp.AddTool(server, &mcp.Tool{
Name: "test_missing_capability",
Description: "Requires the sampling client capability; used to verify MissingRequiredClientCapabilityError (-32021) (SEP-2575)",
}, testMissingCapabilityHandler)
mcp.AddTool(server, &mcp.Tool{
Name: "test_streaming_elicitation",
Description: "Streams progress notifications while a call is in flight; used to verify response streams carry only notifications, never independent JSON-RPC requests (SEP-2575)",
}, testStreamingElicitationHandler)
mcp.AddTool(server, &mcp.Tool{
Name: "test_logging_tool",
Description: "Attempts to emit a log message; the framework must drop it when the client did not set _meta.../logLevel (SEP-2575)",
}, testLoggingToolHandler)
mcp.AddTool(server, &mcp.Tool{
Name: "test_trigger_tool_change",
Description: "Mutates the tool list to trigger a notifications/tools/list_changed on active subscription streams (SEP-2575)",
}, makeTriggerToolChangeHandler(server))
mcp.AddTool(server, &mcp.Tool{
Name: "test_trigger_prompt_change",
Description: "Mutates the prompt list to trigger a notifications/prompts/list_changed on active subscription streams (SEP-2575)",
}, makeTriggerPromptChangeHandler(server))
mcp.AddTool(server, &mcp.Tool{
Name: "test_input_required_result_elicitation",
Description: "MRTR (SEP-2322): asks for the caller name via an in-band elicitation request",
}, testInputRequiredElicitationHandler)
mcp.AddTool(server, &mcp.Tool{
Name: "test_input_required_result_sampling",
Description: "MRTR (SEP-2322): asks for an LLM completion via an in-band sampling request",
}, testInputRequiredSamplingHandler)
mcp.AddTool(server, &mcp.Tool{
Name: "test_input_required_result_list_roots",
Description: "MRTR (SEP-2322): asks for the client roots via an in-band roots/list request",
}, testInputRequiredListRootsHandler)
mcp.AddTool(server, &mcp.Tool{
Name: "test_input_required_result_request_state",
Description: "MRTR (SEP-2322): round-trips integrity-protected requestState alongside an elicitation request",
}, testInputRequiredRequestStateHandler)
mcp.AddTool(server, &mcp.Tool{
Name: "test_input_required_result_multiple_inputs",
Description: "MRTR (SEP-2322): asks for elicitation, sampling and roots input in a single round",
}, testInputRequiredMultipleInputsHandler)
mcp.AddTool(server, &mcp.Tool{
Name: "test_input_required_result_multi_round",
Description: "MRTR (SEP-2322): two elicitation rounds with evolving requestState before completing",
}, testInputRequiredMultiRoundHandler)
mcp.AddTool(server, &mcp.Tool{
Name: "test_input_required_result_tampered_state",
Description: "MRTR (SEP-2322): rejects retries whose requestState fails integrity verification",
}, testInputRequiredTamperedStateHandler)
mcp.AddTool(server, &mcp.Tool{
Name: "test_input_required_result_capabilities",
Description: "MRTR (SEP-2322): only requests input kinds the declared client capabilities cover",
}, testInputRequiredCapabilitiesHandler)
}
// Tool handlers
@ -577,466 +477,33 @@ func testElicitationEnumsHandler(ctx context.Context, req *mcp.CallToolRequest,
}, nil, nil
}
func jsonSchema202012Handler(ctx context.Context, req *mcp.CallToolRequest, input json.RawMessage) (*mcp.CallToolResult, any, error) {
type jsonSchemaAddress struct {
Street string `json:"street"`
City string `json:"city"`
}
type jsonSchemaInput struct {
Name string `json:"name"`
Address *jsonSchemaAddress `json:"address"`
}
func jsonSchema202012Handler(ctx context.Context, req *mcp.CallToolRequest, input jsonSchemaInput) (*mcp.CallToolResult, any, error) {
// Echo back the arguments received
var addressStr string
if input.Address != nil {
addressStr = fmt.Sprintf("{street: %q, city: %q}", input.Address.Street, input.Address.City)
} else {
addressStr = "nil"
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{
Text: fmt.Sprintf("JSON Schema 2020-12 tool called with: %s", input),
Text: fmt.Sprintf("Received: name=%q, address=%s", input.Name, addressStr),
},
},
}, nil, nil
}
type testXMcpHeaderInput struct {
Region string `json:"region,omitempty"`
Level int `json:"level,omitempty"`
}
func testXMcpHeaderHandler(ctx context.Context, req *mcp.CallToolRequest, input testXMcpHeaderInput) (*mcp.CallToolResult, any, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: fmt.Sprintf("region=%s", input.Region)},
},
}, nil, nil
}
// testMissingCapabilityHandler requires the client to have declared the
// sampling capability. When absent, it returns a MissingRequiredClientCapabilityError
// with data.requiredCapabilities = {"sampling": {}} per SEP-2575.
func testMissingCapabilityHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
caps := req.ClientCapabilities()
if caps != nil && caps.Sampling != nil {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "Client declared the sampling capability; tool executed."},
},
}, nil, nil
}
missingCapabilityData := mcp.MissingRequiredClientCapabilityData{
RequiredCapabilities: &mcp.ClientCapabilities{
Sampling: &mcp.SamplingCapabilities{},
},
}
dataBytes, err := json.Marshal(missingCapabilityData)
if err != nil {
return nil, nil, err
}
return nil, nil, &jsonrpc.Error{
Code: mcp.CodeMissingRequiredClientCapabilities,
Message: "sampling capability required but not declared by client",
Data: dataBytes,
}
}
// testStreamingElicitationHandler returns a plain result. Per SEP-2575, the
// response stream must carry no independent top-level JSON-RPC requests; a
// plain response trivially satisfies this. The scenario declares no
// `elicitation` capability, so this handler must not call req.Session.Elicit.
func testStreamingElicitationHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "stream observed: result frames only, no top-level requests"},
},
}, nil, nil
}
// testLoggingToolHandler attempts to emit a log message. The SDK's
// ServerSession.Log gates on the per-request _meta.../logLevel, so this is a
// no-op notification-wise when the client did not opt in.
func testLoggingToolHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
req.Session.Log(ctx, &mcp.LoggingMessageParams{
Level: "info",
Data: "test_logging_tool executed",
})
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "Log attempted; framework gates on _meta.../logLevel."},
},
}, nil, nil
}
// makeTriggerToolChangeHandler returns a handler that (re-)registers a
// no-op transient tool. Every call to Server.AddTool dispatches a
// notifications/tools/list_changed to active subscription streams (even when
// it replaces a tool with the same name), which is what the SEP-2575
// subscription checks assert.
func makeTriggerToolChangeHandler(server *mcp.Server) mcp.ToolHandlerFor[any, any] {
return func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
mcp.AddTool(server, &mcp.Tool{
Name: "__transient_tool_for_list_changed",
Description: "Transient tool used to trigger tools/list_changed",
}, func(context.Context, *mcp.CallToolRequest, any) (*mcp.CallToolResult, any, error) {
return &mcp.CallToolResult{}, nil, nil
})
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "tools_list_changed published"},
},
}, nil, nil
}
}
// makeTriggerPromptChangeHandler is the prompt analogue of
// makeTriggerToolChangeHandler.
func makeTriggerPromptChangeHandler(server *mcp.Server) mcp.ToolHandlerFor[any, any] {
return func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
server.AddPrompt(&mcp.Prompt{
Name: "__transient_prompt_for_list_changed",
Description: "Transient prompt used to trigger prompts/list_changed",
}, func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
return &mcp.GetPromptResult{}, nil
})
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "prompts_list_changed published"},
},
}, nil, nil
}
}
// =============================================================================
// SEP-2322 Multi-Round-Trip helpers
// =============================================================================
// requestStateTamperedSuffix is what the tampered-state conformance scenario
// appends to a valid requestState. Detecting this suffix is sufficient to
// pass the check; a production server would use a real integrity mechanism
// (HMAC / AEAD, per SEP-2322 § "Server Requirements").
const requestStateTamperedSuffix = "-TAMPERED"
// requestStateValid reports whether the state looks like one this server
// minted (i.e. the harness did not append the tampered marker).
func requestStateValid(state string) bool {
return state != "" && !strings.HasSuffix(state, requestStateTamperedSuffix)
}
// acceptedContent returns the "content" field of an ElicitResult only when the
// user accepted the elicitation. Returns nil if the response is missing, of the
// wrong type, or the action is not "accept".
func acceptedContent(responses mcp.InputResponseMap, key string) map[string]any {
if responses == nil {
return nil
}
resp, ok := responses[key]
if !ok {
return nil
}
elicit, ok := resp.(*mcp.ElicitResult)
if !ok || elicit == nil || elicit.Action != "accept" {
return nil
}
return elicit.Content
}
// =============================================================================
// SEP-2322 Multi-Round-Trip tool handlers
// =============================================================================
// nameElicitSchema is the requestedSchema used by tools that elicit a name.
func nameElicitSchema() *jsonschema.Schema {
return &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"name": {Type: "string"},
},
Required: []string{"name"},
}
}
// testInputRequiredElicitationHandler asks for the caller name via elicitation.
// Reused by the result-type, missing-input-response, ignore-extra-params, and
// validate-input scenarios: anything that does not carry an accepted
// "user_name" response is answered with a fresh InputRequiredResult.
func testInputRequiredElicitationHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
if content := acceptedContent(req.Params.InputResponses, "user_name"); content != nil {
name, _ := content["name"].(string)
if name != "" {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Hello, %s!", name)}},
}, nil, nil
}
}
return &mcp.CallToolResult{
InputRequests: mcp.InputRequestMap{
"user_name": &mcp.ElicitParams{
Message: "What is your name?",
RequestedSchema: nameElicitSchema(),
},
},
}, nil, nil
}
// samplingResponseText extracts the first text content from a sampling
// response. The Go SDK unmarshals responses with a "role" field into
// *CreateMessageWithToolsResult (a superset of the legacy single-content
// CreateMessageResult).
func samplingResponseText(responses mcp.InputResponseMap, key string) (string, bool) {
resp, ok := responses[key].(*mcp.CreateMessageWithToolsResult)
if !ok || resp == nil {
return "", false
}
for _, c := range resp.Content {
if tc, ok := c.(*mcp.TextContent); ok {
return tc.Text, true
}
}
return "(non-text response)", true
}
// testInputRequiredSamplingHandler asks for an LLM completion via sampling.
func testInputRequiredSamplingHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
if text, ok := samplingResponseText(req.Params.InputResponses, "capital_question"); ok {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Sampling response: %s", text)}},
}, nil, nil
}
return &mcp.CallToolResult{
InputRequests: mcp.InputRequestMap{
"capital_question": &mcp.CreateMessageParams{
Messages: []*mcp.SamplingMessage{
{Role: "user", Content: &mcp.TextContent{Text: "What is the capital of France?"}},
},
MaxTokens: 100,
},
},
}, nil, nil
}
// testInputRequiredListRootsHandler asks for the client roots.
func testInputRequiredListRootsHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
if resp, ok := req.Params.InputResponses["client_roots"].(*mcp.ListRootsResult); ok && resp != nil {
var uris []string
for _, r := range resp.Roots {
uris = append(uris, r.URI)
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: fmt.Sprintf("Client exposed %d root(s): %s", len(resp.Roots), strings.Join(uris, ", "))},
},
}, nil, nil
}
return &mcp.CallToolResult{
InputRequests: mcp.InputRequestMap{
"client_roots": &mcp.ListRootsParams{},
},
}, nil, nil
}
// testInputRequiredRequestStateHandler round-trips an opaque requestState
// alongside an elicitation request.
func testInputRequiredRequestStateHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
if content := acceptedContent(req.Params.InputResponses, "confirm"); content != nil {
if requestStateValid(req.Params.RequestState) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "state-ok: requestState received and confirmation accepted"}},
}, nil, nil
}
return nil, nil, &jsonrpc.Error{
Code: jsonrpc.CodeInvalidParams,
Message: "invalid requestState",
}
}
return &mcp.CallToolResult{
InputRequests: mcp.InputRequestMap{
"confirm": &mcp.ElicitParams{
Message: "Please confirm",
RequestedSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{"ok": {Type: "boolean"}},
Required: []string{"ok"},
},
},
},
RequestState: "request_state",
}, nil, nil
}
// testInputRequiredMultipleInputsHandler asks for elicitation, sampling, and
// roots input in a single round.
func testInputRequiredMultipleInputsHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
nameContent := acceptedContent(req.Params.InputResponses, "user_name")
name, _ := nameContent["name"].(string)
greetingText, hasGreeting := samplingResponseText(req.Params.InputResponses, "greeting")
var rootsCount int
rootsResp, rootsOK := req.Params.InputResponses["client_roots"].(*mcp.ListRootsResult)
if rootsOK && rootsResp != nil {
rootsCount = len(rootsResp.Roots)
}
if name != "" && hasGreeting && rootsOK {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: fmt.Sprintf("%s %s — %d root(s) visible", greetingText, name, rootsCount)},
},
}, nil, nil
}
return &mcp.CallToolResult{
InputRequests: mcp.InputRequestMap{
"user_name": &mcp.ElicitParams{
Message: "What is your name?",
RequestedSchema: nameElicitSchema(),
},
"greeting": &mcp.CreateMessageParams{
Messages: []*mcp.SamplingMessage{
{Role: "user", Content: &mcp.TextContent{Text: "Generate a greeting"}},
},
MaxTokens: 50,
},
"client_roots": &mcp.ListRootsParams{},
},
RequestState: "multiple_inputs",
}, nil, nil
}
// testInputRequiredMultiRoundHandler runs two elicitation rounds with
// evolving requestState before completing. The round number (and the name
// elicited in round 1) lives in the state, since the stateless server keeps
// no per-session memory.
func testInputRequiredMultiRoundHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
state := req.Params.RequestState
// State format: "round=1" or "round=2;name=<name>".
switch {
case state == "":
return &mcp.CallToolResult{
InputRequests: mcp.InputRequestMap{
"step1": &mcp.ElicitParams{
Message: "Step 1: What is your name?",
RequestedSchema: nameElicitSchema(),
},
},
RequestState: "round=1",
}, nil, nil
case state == "round=1":
name, _ := acceptedContent(req.Params.InputResponses, "step1")["name"].(string)
if name == "" {
name = "unknown"
}
return &mcp.CallToolResult{
InputRequests: mcp.InputRequestMap{
"step2": &mcp.ElicitParams{
Message: "Step 2: What is your favorite color?",
RequestedSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{"color": {Type: "string"}},
Required: []string{"color"},
},
},
},
RequestState: "round=2;name=" + name,
}, nil, nil
default:
color, _ := acceptedContent(req.Params.InputResponses, "step2")["color"].(string)
if color == "" {
color = "unknown"
}
name := strings.TrimPrefix(state, "round=2;name=")
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: fmt.Sprintf("Multi-round complete: %s likes %s", name, color)},
},
}, nil, nil
}
}
// testInputRequiredTamperedStateHandler rejects retries whose requestState
// looks tampered. The conformance harness tampers by appending "-TAMPERED";
// production servers should use a real integrity mechanism (HMAC / AEAD).
func testInputRequiredTamperedStateHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
if req.Params.RequestState != "" {
if !requestStateValid(req.Params.RequestState) {
return nil, nil, &jsonrpc.Error{
Code: jsonrpc.CodeInvalidParams,
Message: "invalid requestState: tampered",
}
}
if acceptedContent(req.Params.InputResponses, "confirm") != nil {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "state-ok: requestState received and confirmation accepted"}},
}, nil, nil
}
}
return &mcp.CallToolResult{
InputRequests: mcp.InputRequestMap{
"confirm": &mcp.ElicitParams{
Message: "Please confirm",
RequestedSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{"ok": {Type: "boolean"}},
Required: []string{"ok"},
},
},
},
RequestState: "tampered_state",
}, nil, nil
}
// testInputRequiredCapabilitiesHandler only requests input kinds the client
// declared capabilities cover. Reads capabilities from the per-request
// _meta["io.modelcontextprotocol/clientCapabilities"] envelope.
func testInputRequiredCapabilitiesHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
if len(req.Params.InputResponses) > 0 {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "Capability-aware input requests fulfilled"}},
}, nil, nil
}
caps := req.ClientCapabilities()
reqs := mcp.InputRequestMap{}
if caps != nil && caps.Elicitation != nil {
reqs["user_name"] = &mcp.ElicitParams{
Message: "What is your name?",
RequestedSchema: nameElicitSchema(),
}
}
if caps != nil && caps.Sampling != nil {
reqs["greeting"] = &mcp.CreateMessageParams{
Messages: []*mcp.SamplingMessage{
{Role: "user", Content: &mcp.TextContent{Text: "Generate a short greeting"}},
},
MaxTokens: 50,
}
}
if caps != nil && (caps.RootsV2 != nil || caps.Roots.ListChanged) {
reqs["client_roots"] = &mcp.ListRootsParams{}
}
if len(reqs) == 0 {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "No declared client capability supports an in-band input request"}},
}, nil, nil
}
return &mcp.CallToolResult{InputRequests: reqs}, nil, nil
}
// testInputRequiredPromptHandler is the prompts/get analogue: elicits context
// on the first call and returns a GetPromptResult with messages on retry.
func testInputRequiredPromptHandler(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
if content := acceptedContent(req.Params.InputResponses, "user_context"); content != nil {
contextStr, _ := content["context"].(string)
return &mcp.GetPromptResult{
Description: "A prompt with elicited context",
Messages: []*mcp.PromptMessage{
{
Role: "user",
Content: &mcp.TextContent{Text: fmt.Sprintf("Context: %s", contextStr)},
},
},
}, nil
}
return &mcp.GetPromptResult{
InputRequests: mcp.InputRequestMap{
"user_context": &mcp.ElicitParams{
Message: "Please provide context for the prompt",
RequestedSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{"context": {Type: "string"}},
Required: []string{"context"},
},
},
},
}, nil
}
func testReconnectionHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
// Close the SSE stream to trigger client reconnection (SEP-1699)
if req.Extra != nil && req.Extra.CloseSSEStream != nil {
@ -1184,11 +651,6 @@ func registerPrompts(server *mcp.Server) {
Title: "Prompt With Image",
Description: "A prompt that includes image content",
}, promptWithImageHandler)
server.AddPrompt(&mcp.Prompt{
Name: "test_input_required_result_prompt",
Description: "MRTR (SEP-2322): asks for context via an in-band elicitation request",
}, testInputRequiredPromptHandler)
}
func simplePromptHandler(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {

View file

@ -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,34 +38,33 @@ 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
func Example_roots() {
ctx := context.Background()
// Create a client with two roots.
// Create a client with a single root.
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil)
c.AddRoots(&mcp.Root{URI: "file://a"}, &mcp.Root{URI: "file://b"})
c.AddRoots(&mcp.Root{URI: "file://a"})
// Create a server with a tool that requests roots via the multi round-trip
// pattern (SEP-2322): server-to-client requests are no longer sent as
// standalone JSON-RPC calls on protocol version >= 2026-07-28.
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil)
mcp.AddTool(s, &mcp.Tool{Name: "roots"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
if len(req.Params.InputResponses) == 0 {
return &mcp.CallToolResult{
InputRequests: mcp.InputRequestMap{"roots": &mcp.ListRootsParams{}},
}, nil, nil
// Now create a server with a handler to receive notifications about roots.
rootsChanged := make(chan struct{})
handleRootsChanged := func(ctx context.Context, req *mcp.RootsListChangedRequest) {
rootList, err := req.Session.ListRoots(ctx, nil)
if err != nil {
log.Fatal(err)
}
rootList := req.Params.InputResponses["roots"].(*mcp.ListRootsResult)
var roots []string
for _, root := range rootList.Roots {
roots = append(roots, root.URI)
}
fmt.Println(roots)
return &mcp.CallToolResult{}, nil, nil
close(rootsChanged)
}
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{
RootsListChangedHandler: handleRootsChanged,
})
// Connect the server and client...
@ -85,76 +81,13 @@ func Example_roots() {
}
defer clientSession.Close()
// ...and call the tool. The client's multi round-trip driver fulfils the
// embedded roots/list request and retries the call.
if _, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "roots"}); err != nil {
log.Fatal(err)
}
// ...and add a root. The server is notified about the change.
c.AddRoots(&mcp.Root{URI: "file://b"})
<-rootsChanged
// Output: [file://a file://b]
}
```
### Roots list changed
`Client.AddRoots` and `Client.RemoveRoots` notify every connected server that
the list changed. Servers observe this through
[`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler);
as with the server-side list-changed notifications, it reports only that
something changed, so read the list back with `ServerSession.ListRoots`.
```go
func Example_rootsListChanged() {
ctx := context.Background()
changed := make(chan struct{}, 2)
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{
RootsListChangedHandler: func(context.Context, *mcp.RootsListChangedRequest) {
changed <- struct{}{}
},
})
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil)
c.AddRoots(&mcp.Root{URI: "file:///project"})
t1, t2 := mcp.NewInMemoryTransports()
ss, err := s.Connect(ctx, t1, nil)
if err != nil {
log.Fatal(err)
}
defer ss.Close()
// ListRoots is a server-initiated request, so this session negotiates a
// protocol version that still allows one.
cs, err := c.Connect(ctx, t2, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"})
if err != nil {
log.Fatal(err)
}
defer cs.Close()
// Roots added after the client connects notify every connected server.
c.AddRoots(&mcp.Root{URI: "file:///scratch"})
<-changed
// The notification says only that the list changed, so read it back.
res, err := ss.ListRoots(ctx, nil)
if err != nil {
log.Fatal(err)
}
for _, root := range res.Roots {
fmt.Println(root.URI)
}
c.RemoveRoots("file:///scratch")
<-changed
fmt.Println("roots changed again")
// Output:
// file:///project
// file:///scratch
// roots changed again
}
```
## Sampling
> **Note:** The sampling feature is deprecated as of protocol version
@ -177,7 +110,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
@ -197,35 +130,22 @@ func Example_sampling() {
// Connect the server and client...
ct, st := mcp.NewInMemoryTransports()
// Create a server with a tool that requests sampling via the multi
// round-trip pattern (SEP-2322): server-to-client requests are no longer
// sent as standalone JSON-RPC calls on protocol version >= 2026-07-28.
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil)
mcp.AddTool(s, &mcp.Tool{Name: "sample"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
if len(req.Params.InputResponses) == 0 {
return &mcp.CallToolResult{
InputRequests: mcp.InputRequestMap{"msg": &mcp.CreateMessageParams{}},
}, nil, nil
}
msg := req.Params.InputResponses["msg"].(*mcp.CreateMessageWithToolsResult)
return &mcp.CallToolResult{Content: msg.Content}, nil, nil
})
session, err := s.Connect(ctx, st, nil)
if err != nil {
log.Fatal(err)
}
defer session.Close()
clientSession, err := c.Connect(ctx, ct, nil)
if err != nil {
if _, err := c.Connect(ctx, ct, nil); err != nil {
log.Fatal(err)
}
res, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "sample"})
msg, err := session.CreateMessage(ctx, &mcp.CreateMessageParams{})
if err != nil {
log.Fatal(err)
}
fmt.Println(res.Content[0].(*mcp.TextContent).Text)
fmt.Println(msg.Content.(*mcp.TextContent).Text)
// Output: would have created a message
}
```
@ -247,7 +167,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
@ -255,28 +175,7 @@ func Example_elicitation() {
ctx := context.Background()
ct, st := mcp.NewInMemoryTransports()
// Create a server with a tool that requests elicitation via the multi
// round-trip pattern (SEP-2322): server-to-client requests are no longer
// sent as standalone JSON-RPC calls on protocol version >= 2026-07-28.
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil)
mcp.AddTool(s, &mcp.Tool{Name: "ask"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
if len(req.Params.InputResponses) == 0 {
return &mcp.CallToolResult{
InputRequests: mcp.InputRequestMap{"input": &mcp.ElicitParams{
Message: "This should fail",
RequestedSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"test": {Type: "string"},
},
},
}},
}, nil, nil
}
res := req.Params.InputResponses["input"].(*mcp.ElicitResult)
fmt.Println(res.Content["test"])
return &mcp.CallToolResult{}, nil, nil
})
ss, err := s.Connect(ctx, st, nil)
if err != nil {
log.Fatal(err)
@ -288,165 +187,26 @@ func Example_elicitation() {
return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil
},
})
clientSession, err := c.Connect(ctx, ct, nil)
if _, err := c.Connect(ctx, ct, nil); err != nil {
log.Fatal(err)
}
res, err := ss.Elicit(ctx, &mcp.ElicitParams{
Message: "This should fail",
RequestedSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"test": {Type: "string"},
},
},
})
if err != nil {
log.Fatal(err)
}
if _, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "ask"}); err != nil {
log.Fatal(err)
}
fmt.Println(res.Content["test"])
// Output: value
}
```
### Schema defaults and enums
`ElicitParams.RequestedSchema` is a flat schema of primitive fields, which the
client renders as a form. Two field keywords shape that form.
A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034--support-default-values-for-all-primitive-types-in)) prefills a
field. When the user accepts without supplying it, the SDK fills the field in
from the schema before the result reaches either side's caller — the client
does so after its elicitation handler returns, and `ServerSession.Elicit` does
so again on receipt. This is unconditional; there is no opt-in flag. Marking a
defaulted field `Required` defeats it: accepted content is validated against
the schema before defaults are applied, so an answer that omits the field is
rejected rather than defaulted.
An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330-elicitation-enum-schema-improvements-and-standards)) restricts a
field to a fixed set of values, which the client renders as a choice. Enums
are supported only on `"string"` fields; declaring one on another type is
rejected. To label the choices, set the legacy `enumNames` keyword through
`Schema.Extra`, with exactly one name per enum value — a mismatched length is
rejected.
```go
func Example_elicitationSchema() {
ctx := context.Background()
ct, st := mcp.NewInMemoryTransports()
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil)
mcp.AddTool(s, &mcp.Tool{Name: "export_report"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
if len(req.Params.InputResponses) == 0 {
return &mcp.CallToolResult{
InputRequests: mcp.InputRequestMap{"format": &mcp.ElicitParams{
Message: "Export quarterly-sales as which format?",
RequestedSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"format": {
Type: "string",
Title: "Format",
Enum: []any{"pdf", "csv"},
Default: json.RawMessage(`"pdf"`),
Extra: map[string]any{"enumNames": []any{"PDF document", "CSV spreadsheet"}},
},
},
},
}},
}, nil, nil
}
res := req.Params.InputResponses["format"].(*mcp.ElicitResult)
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "Exported as " + res.Content["format"].(string)}},
}, nil, nil
})
if _, err := s.Connect(ctx, st, nil); err != nil {
log.Fatal(err)
}
// The user accepts without filling anything in.
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{
ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
return &mcp.ElicitResult{Action: "accept", Content: map[string]any{}}, nil
},
})
cs, err := c.Connect(ctx, ct, nil)
if err != nil {
log.Fatal(err)
}
defer cs.Close()
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "export_report"})
if err != nil {
log.Fatal(err)
}
fmt.Println(res.Content[0].(*mcp.TextContent).Text)
// Output: Exported as pdf
}
```
### Completing a URL elicitation
In URL mode the user finishes out of band, in a browser, so nothing in the
elicitation result tells the client when they are done. The server signals that
with
[`ServerSession.NotifyElicitationComplete`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.NotifyElicitationComplete),
passing the same `ElicitationID` the request carried; the client observes it
through
[`ClientOptions.ElicitationCompleteHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ElicitationCompleteHandler).
Send it from whatever endpoint the hosted flow redirects back to.
The notification matters most when a handler rejects a request with
[`URLElicitationRequiredError`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#URLElicitationRequiredError):
the client parks the original request until a notification names that
`ElicitationID`, and then retries it automatically. Until one arrives, the
client waits.
```go
func Example_elicitationComplete() {
ctx := context.Background()
ct, st := mcp.NewInMemoryTransports()
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil)
ss, err := s.Connect(ctx, st, nil)
if err != nil {
log.Fatal(err)
}
defer ss.Close()
done := make(chan struct{})
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{
Capabilities: &mcp.ClientCapabilities{
Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}},
},
ElicitationHandler: func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
fmt.Println("opening", req.Params.URL)
return &mcp.ElicitResult{Action: "accept"}, nil
},
ElicitationCompleteHandler: func(_ context.Context, req *mcp.ElicitationCompleteNotificationRequest) {
fmt.Println("flow finished:", req.Params.ElicitationID)
close(done)
},
})
cs, err := c.Connect(ctx, ct, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"})
if err != nil {
log.Fatal(err)
}
defer cs.Close()
const elicitationID = "connect-calendar-1"
if _, err := ss.Elicit(ctx, &mcp.ElicitParams{
Message: "Grant calendar access",
URL: "https://calendar.example.com/consent?state=" + elicitationID,
ElicitationID: elicitationID,
}); err != nil {
log.Fatal(err)
}
// The hosted page redirects back to the server, whose callback endpoint
// signals that the user is done.
if err := ss.NotifyElicitationComplete(ctx, &mcp.ElicitationCompleteParams{ElicitationID: elicitationID}); err != nil {
log.Fatal(err)
}
<-done
// Output:
// opening https://calendar.example.com/consent?state=connect-calendar-1
// flow finished: connect-calendar-1
}
```
## Multi Round-Trip Requests
[SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)
@ -469,18 +229,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

View file

@ -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

View file

@ -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

Some files were not shown because too many files have changed in this diff Show more