Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4608cda997 |
||
|
|
3b917b466c |
||
|
|
b58aa8d8d8 |
||
|
|
826e653c27 |
||
|
|
3785c500b5 |
||
|
|
fbd36cb787 |
||
|
|
c1918e19ad |
||
|
|
0c0eb23625 |
||
|
|
3f43bcf012 |
||
|
|
a82c86a6cd |
||
|
|
3210c2b34c |
||
|
|
e40f35d137 |
||
|
|
5bc078a795 |
||
|
|
f99cdb649b |
||
|
|
a5026ea7b7 |
||
|
|
3f3b699b2b |
||
|
|
830f0b76e7 |
||
|
|
12cbafe54d |
||
|
|
3632967404 |
||
|
|
0d3036f785 |
||
|
|
cb0de64136 |
||
|
|
2fdabde139 |
||
|
|
59185e6969 |
||
|
|
a6764cf838 |
||
|
|
8227246472 |
||
|
|
f47d6ce156 |
||
|
|
ce3a92a0a8 |
||
|
|
69c17de411 |
||
|
|
d19bfebab9 |
||
|
|
21c18c6229 |
||
|
|
5a89b63764 |
||
|
|
165121e53e |
||
|
|
b55bcbd610 |
||
|
|
2732839dba |
||
|
|
c68ad9a4e6 |
||
|
|
32404652e6 |
||
|
|
810e6cb751 |
||
|
|
b8c53fedd6 |
||
|
|
6f0a645c20 |
||
|
|
3d6450fccf |
||
|
|
b074c7f3af |
||
|
|
ce0b198202 |
||
|
|
fe27fa9151 |
||
|
|
b0408f2821 |
||
|
|
1fcaf0c393 |
||
|
|
ba1496a9aa |
||
|
|
0eb1600eef |
||
|
|
64e454e35c |
||
|
|
4282916668 |
||
|
|
e9a85e285f |
||
|
|
039119057f |
||
|
|
71dfbc0d3e |
||
|
|
958dfcc532 |
||
|
|
d462fbd303 |
||
|
|
6b1518cb0b |
||
|
|
725694100a |
||
|
|
c4a4ce4543 |
||
|
|
1acd66e9d7 |
||
|
|
0d0cdbc943 |
||
|
|
ec29d60bb9 |
||
|
|
02610a2075 |
||
|
|
7b3bbc0f32 |
||
|
|
5d1040061b |
||
|
|
d32302ee7a |
||
|
|
0c004ee48a |
||
|
|
7743b71b1c |
||
|
|
bc72835f62 |
||
|
|
cc4db25347 |
||
|
|
e761950d97 |
||
|
|
2781b8f7e3 |
||
|
|
827f90ba0c |
||
|
|
5cb8339210 |
||
|
|
fa548d7401 |
||
|
|
8baa4ccebe |
||
|
|
9576afc114 |
||
|
|
0a8953d71c |
||
|
|
7fde7d3452 |
||
|
|
8f54783331 |
||
|
|
beb3d1ab7a |
||
|
|
c79a30ae0a |
||
|
|
dd79ca3d44 |
||
|
|
40f034322f |
||
|
|
ce393acb95 |
||
|
|
b426fbf615 |
||
|
|
703b0f4875 |
||
|
|
acbc1099d1 |
||
|
|
1dcf38f76f |
||
|
|
1af1a087ef |
||
|
|
deecf8a432 |
||
|
|
7f67c851d1 |
||
|
|
4c2aece5d7 |
||
|
|
6de85ec3ed |
||
|
|
d651e2d2e1 |
||
|
|
ec564f9ccb |
||
|
|
224a240875 |
||
|
|
a901f97b6d |
||
|
|
b53d2e3dc3 |
||
|
|
b03fae3155 |
||
|
|
ede1894174 |
||
|
|
65ece59643 |
82 changed files with 9744 additions and 800 deletions
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
|
|
@ -5,3 +5,11 @@ updates:
|
|||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
# Group all github/codeql-action/* sub-paths (init, analyze, ...) into a
|
||||
# single PR so they are always bumped together. Bumping them independently
|
||||
# leaves the workflow with mismatched versions across steps, which
|
||||
# github/codeql-action rejects at runtime.
|
||||
groups:
|
||||
codeql-action:
|
||||
patterns:
|
||||
- "github/codeql-action/*"
|
||||
|
|
|
|||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
|
|
@ -36,13 +36,13 @@ jobs:
|
|||
build-mode: autobuild
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
|
|
|||
28
.github/workflows/conformance.yml
vendored
28
.github/workflows/conformance.yml
vendored
|
|
@ -14,25 +14,25 @@ permissions:
|
|||
contents: read
|
||||
|
||||
env:
|
||||
CONFORMANCE_VERSION: "0.2.0-alpha.5"
|
||||
CONFORMANCE_VERSION: "0.2.0-alpha.11"
|
||||
|
||||
jobs:
|
||||
server-conformance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Build everything-server
|
||||
run: go build -o everything-server ./conformance/everything-server
|
||||
- name: Run conformance tests (stateful, legacy spec versions)
|
||||
- name: Run conformance tests (2025-11-25, stateful)
|
||||
run: |
|
||||
./everything-server -http=":3001" -stateless=false &
|
||||
SERVER_PID=$!
|
||||
|
|
@ -40,10 +40,9 @@ 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 \
|
||||
--suite active \
|
||||
--spec-version 2025-11-25 \
|
||||
--requirements 2025-11-25 \
|
||||
--expected-failures ./conformance/baseline.yml
|
||||
- name: Run conformance tests (stateless, 2026-07-28)
|
||||
- name: Run conformance tests (2026-07-28, stateless)
|
||||
run: |
|
||||
./everything-server -http=":3001" -stateless &
|
||||
SERVER_PID=$!
|
||||
|
|
@ -51,26 +50,25 @@ 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 \
|
||||
--suite active \
|
||||
--spec-version 2026-07-28 \
|
||||
--requirements 2026-07-28 \
|
||||
--expected-failures ./conformance/baseline.yml
|
||||
|
||||
client-conformance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Run conformance tests
|
||||
- name: Run conformance tests (full suite)
|
||||
run: |
|
||||
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \
|
||||
--command "go run ./conformance/everything-client" \
|
||||
--suite core \
|
||||
--suite all \
|
||||
--expected-failures ./conformance/baseline.yml
|
||||
|
|
|
|||
4
.github/workflows/docs-check.yml
vendored
4
.github/workflows/docs-check.yml
vendored
|
|
@ -16,11 +16,11 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check docs are up-to-date
|
||||
run: |
|
||||
go generate ./...
|
||||
|
|
|
|||
12
.github/workflows/nightly.yml
vendored
12
.github/workflows/nightly.yml
vendored
|
|
@ -11,25 +11,29 @@ permissions:
|
|||
|
||||
jobs:
|
||||
vulncheck:
|
||||
# Don't run scheduled builds on forks.
|
||||
if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Run govulncheck
|
||||
uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4
|
||||
uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1.1.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
go-package: ./...
|
||||
|
||||
conformance:
|
||||
# Don't run scheduled builds on forks.
|
||||
if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Run server conformance tests
|
||||
|
|
|
|||
6
.github/workflows/publish-docs.yml
vendored
6
.github/workflows/publish-docs.yml
vendored
|
|
@ -13,16 +13,16 @@ jobs:
|
|||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Configure Git Credentials
|
||||
run: |
|
||||
git config user.name github-actions[bot]
|
||||
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: 3.x
|
||||
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
||||
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
key: mkdocs-material-${{ env.cache_id }}
|
||||
path: .cache
|
||||
|
|
|
|||
6
.github/workflows/scorecard.yml
vendored
6
.github/workflows/scorecard.yml
vendored
|
|
@ -34,12 +34,12 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
|
|
@ -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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
|
|
|||
12
.github/workflows/test.yml
vendored
12
.github/workflows/test.yml
vendored
|
|
@ -14,9 +14,9 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
- name: Test
|
||||
|
|
@ -55,9 +55,9 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "1.26"
|
||||
- name: Test with -race
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ 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)
|
||||
|
|
@ -188,6 +191,57 @@ The CI system will automatically check that the README is up-to-date by running
|
|||
`go generate ./internal/readme` and verifying no changes result. If you see a CI failure about the
|
||||
README being out of sync, follow the steps above to regenerate it.
|
||||
|
||||
## Versioning
|
||||
|
||||
The SDK follows [semantic versioning](https://semver.org), as the Go module
|
||||
system requires: the major version is part of the module path, so a breaking
|
||||
release would import as `github.com/modelcontextprotocol/go-sdk/v2`. A breaking
|
||||
change therefore cannot reach existing users by accident; they have to change
|
||||
their import path to receive one.
|
||||
|
||||
This policy covers the exported API of the SDK's importable packages — `mcp`,
|
||||
`jsonrpc`, `auth`, `auth/extauth` and `oauthex`. Everything under `internal/`
|
||||
is not importable outside the module and may change in any release.
|
||||
|
||||
Which MCP spec revisions each SDK version speaks is documented in the
|
||||
[README](README.md#version-compatibility). Support for a new revision arrives
|
||||
in a minor release; dropping a revision the SDK previously negotiated is a
|
||||
breaking change.
|
||||
|
||||
### What counts as a breaking change
|
||||
|
||||
- Removing or renaming an exported identifier.
|
||||
- Changing the signature of an exported function or method.
|
||||
- Removing or renaming a field of an exported struct, or changing its type.
|
||||
- Adding a method to an exported interface that users are meant to implement.
|
||||
- Changing documented behavior that callers could reasonably rely on.
|
||||
- Dropping support for an MCP spec revision.
|
||||
|
||||
The following are not breaking, and may land in a minor or patch release:
|
||||
|
||||
- Adding an exported identifier, or a field to an exported struct.
|
||||
- Fixing behavior that contradicted its documentation.
|
||||
- Refactoring that leaves the exported API unchanged.
|
||||
- Adding support for a new MCP spec revision.
|
||||
- Raising the minimum Go version to one still supported upstream, per the
|
||||
[README](README.md#version-compatibility).
|
||||
|
||||
### Deprecation
|
||||
|
||||
An API on its way out is marked with a `// Deprecated:` comment naming its
|
||||
replacement, which `gopls` and `staticcheck` surface at call sites. It keeps
|
||||
working until the next major version, since removing it would itself be a
|
||||
breaking change.
|
||||
|
||||
Protocol features that the *specification* deprecates are a separate matter and
|
||||
follow the spec's timeline rather than this policy. The features deprecated by
|
||||
[SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging)
|
||||
— roots, sampling and logging — remain supported for at least twelve months, as
|
||||
noted in the [README](README.md#version-compatibility).
|
||||
|
||||
Any change to the exported API, breaking or not, goes through the
|
||||
[proposal process](#proposals).
|
||||
|
||||
## Timeouts
|
||||
|
||||
If a contributor hasn't responded to issue questions or PR comments in two weeks,
|
||||
|
|
|
|||
222
auth/auth_example_test.go
Normal file
222
auth/auth_example_test.go
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/auth"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// savingTokenSource is an oauth2.TokenSource that passes the oauth2 config and
|
||||
// token to the given saver function each time the access token value changes.
|
||||
type savingTokenSource struct {
|
||||
mu sync.Mutex
|
||||
src oauth2.TokenSource
|
||||
saver func(*oauth2.Config, *oauth2.Token) error
|
||||
config *oauth2.Config
|
||||
accessToken string
|
||||
}
|
||||
|
||||
func (s *savingTokenSource) Token() (*oauth2.Token, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
tok, err := s.src.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.accessToken != tok.AccessToken {
|
||||
s.accessToken = tok.AccessToken
|
||||
// This saver implementation always returns nil.
|
||||
_ = s.saver(s.config, tok)
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// NewSavingTokenSource persists OAuth 2.0 sessions by intercepting token
|
||||
// changes from the wrapped oauth2.TokenSource. When this wrapper detects an
|
||||
// access token change, it calls the provided session saver with the
|
||||
// oauth2.Config and the new oauth2.Token.
|
||||
//
|
||||
// initial is an optional access token the caller may already hold (such as a
|
||||
// token loaded from storage). It initialises the wrapper's state so that if
|
||||
// the first wrapped.Token() call returns the same token, does not trigger a
|
||||
// redundant call to saver(). Pass nil when there is no existing token, in
|
||||
// which case the first token produced by wrapped.Token() is saved.
|
||||
func NewSavingTokenSource(wrapped oauth2.TokenSource, config *oauth2.Config, initial *oauth2.Token, saver func(*oauth2.Config, *oauth2.Token) error) oauth2.TokenSource {
|
||||
if wrapped == nil {
|
||||
return nil
|
||||
}
|
||||
if saver == nil {
|
||||
return wrapped
|
||||
}
|
||||
var accessToken string
|
||||
if initial != nil {
|
||||
accessToken = initial.AccessToken
|
||||
}
|
||||
return &savingTokenSource{
|
||||
src: wrapped,
|
||||
saver: saver,
|
||||
config: config,
|
||||
accessToken: accessToken,
|
||||
}
|
||||
}
|
||||
|
||||
// sessionStore is an in-memory OAuth 2.0 session store used to demonstrate
|
||||
// persistence. A production implementation would persist the session to disk or
|
||||
// a secret store.
|
||||
type sessionStore struct {
|
||||
config *oauth2.Config
|
||||
token *oauth2.Token
|
||||
}
|
||||
|
||||
// save persists the given OAuth 2.0 config and token.
|
||||
func (s *sessionStore) save(config *oauth2.Config, token *oauth2.Token) error {
|
||||
fmt.Printf("Saving token: %s\n", token.AccessToken)
|
||||
s.config = config
|
||||
s.token = token
|
||||
return nil
|
||||
}
|
||||
|
||||
// restore loads a previously persisted OAuth 2.0 session, if one exists.
|
||||
func (s *sessionStore) restore() (*oauth2.Config, *oauth2.Token, error) {
|
||||
if s.config != nil && s.token != nil {
|
||||
fmt.Println("Restoring session.")
|
||||
} else {
|
||||
fmt.Println("No session found to restore.")
|
||||
}
|
||||
return s.config, s.token, nil
|
||||
}
|
||||
|
||||
// newMockAuthServer returns an httptest.Server that simulates both an MCP
|
||||
// resource server requiring authorization and its OAuth authorization server.
|
||||
func newMockAuthServer() *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/.well-known/oauth-authorization-server":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"issuer": "http://%s", "authorization_endpoint": "http://%s/auth", "token_endpoint": "http://%s/token", "code_challenge_methods_supported": ["S256"]}`, r.Host, r.Host, r.Host)
|
||||
case "/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"access_token": "mock-token", "token_type": "bearer"}`))
|
||||
default:
|
||||
// The mock MCP endpoint returns 401 until the client presents a valid
|
||||
// bearer token.
|
||||
if r.Header.Get("Authorization") == "Bearer mock-token" {
|
||||
// A real server would return a valid MCP message here. The empty
|
||||
// response causes Connect to return an error after authorization,
|
||||
// which is ignored for this example which demonstrates token
|
||||
// persistence only.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// This example shows how OAuth2 session persistence might be implemented. It
|
||||
// connects twice using a shared in-memory session store: the first connection
|
||||
// authorizes and saves the session, and the second restores and reuses it.
|
||||
func Example_persistence() {
|
||||
// Simulate an MCP server that requires authorization and an OAuth server.
|
||||
mockServer := newMockAuthServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
// store persists the OAuth2 session across both connections.
|
||||
store := &sessionStore{}
|
||||
|
||||
// connect performs a single client connection, restoring any saved session
|
||||
// beforehand and saving any new session acquired during authorization.
|
||||
connect := func() {
|
||||
// Load the OAuth2 session if available, and use it.
|
||||
var initialTS oauth2.TokenSource
|
||||
cfg, tok, err := store.restore()
|
||||
if err == nil && cfg != nil && tok != nil {
|
||||
initialTS = NewSavingTokenSource(
|
||||
cfg.TokenSource(context.Background(), tok),
|
||||
cfg, tok, store.save,
|
||||
)
|
||||
}
|
||||
|
||||
// Configure and initialize the AuthorizationCodeHandler with a
|
||||
// NewTokenSource that saves the session when the access token changes,
|
||||
// and the InitialTokenSource set to the session loaded via restore().
|
||||
config := &auth.AuthorizationCodeHandlerConfig{
|
||||
RedirectURL: "http://localhost/callback",
|
||||
PreregisteredClient: &oauthex.ClientCredentials{
|
||||
ClientID: "example",
|
||||
},
|
||||
Client: mockServer.Client(),
|
||||
AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
||||
fmt.Println("No token source found. Transport is calling Authorize()...")
|
||||
// Extract the generated state from the authorization URL
|
||||
u, _ := url.Parse(args.URL)
|
||||
state := u.Query().Get("state")
|
||||
return &auth.AuthorizationResult{Code: "mock-code", State: state}, nil
|
||||
},
|
||||
NewTokenSource: func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) {
|
||||
// This save implementation always returns nil.
|
||||
_ = store.save(cfg, token)
|
||||
return NewSavingTokenSource(
|
||||
cfg.TokenSource(ctx, token), cfg, token, store.save,
|
||||
), nil
|
||||
},
|
||||
InitialTokenSource: initialTS,
|
||||
}
|
||||
handler, err := auth.NewAuthorizationCodeHandler(config)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating handler: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the constructed handler on a transport.
|
||||
transport := &mcp.StreamableClientTransport{
|
||||
Endpoint: mockServer.URL + "/sse",
|
||||
OAuthHandler: handler,
|
||||
}
|
||||
|
||||
// Create a client and attempt to connect using the configured
|
||||
// transport. The transport will automatically:
|
||||
// 1. Call TokenSource() to check for an existing session. This will
|
||||
// return InitialTokenSource, if set.
|
||||
// 2. Try the MCP endpoint, and encounter a 401 response from the mock
|
||||
// server.
|
||||
// 3. Call Authorize() to perform the OAuth flow, which calls
|
||||
// NewTokenSource. In this example NewTokenSource saves the newly
|
||||
// acquired session.
|
||||
// 4. Retry the MCP endpoint with a valid bearer token and get a 200.
|
||||
client := mcp.NewClient(
|
||||
&mcp.Implementation{Name: "example", Version: "1.0.0"}, nil,
|
||||
)
|
||||
// Response ignored: this example asserts authorization only.
|
||||
_, _ = client.Connect(context.Background(), transport, nil)
|
||||
}
|
||||
|
||||
// The first connection has no saved session, so it authorizes and saves.
|
||||
fmt.Println("--- First connect ---")
|
||||
connect()
|
||||
// The second connection restores the saved session and reuses it, so no
|
||||
// further authorization or save occurs.
|
||||
fmt.Println("--- Second connect ---")
|
||||
connect()
|
||||
|
||||
// Output:
|
||||
// --- First connect ---
|
||||
// No session found to restore.
|
||||
// No token source found. Transport is calling Authorize()...
|
||||
// Saving token: mock-token
|
||||
// --- Second connect ---
|
||||
// Restoring session.
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
||||
|
|
@ -97,6 +98,15 @@ 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.
|
||||
//
|
||||
|
|
@ -114,6 +124,11 @@ 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
|
||||
|
|
@ -126,6 +141,24 @@ type AuthorizationCodeHandlerConfig struct {
|
|||
// https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#server-side-request-forgery-ssrf
|
||||
// If not provided, http.DefaultClient will be used.
|
||||
Client *http.Client
|
||||
|
||||
// NewTokenSource is an optional function that can be set to construct the
|
||||
// token source that will be used by the [AuthorizationCodeHandler]. If
|
||||
// non-nil, it is called after the authorization code is successfully
|
||||
// exchanged for a token in [AuthorizationCodeHandler.Authorize]
|
||||
// to obtain the [oauth2.TokenSource] returned by
|
||||
// [AuthorizationCodeHandler.TokenSource]. Implementations must use the
|
||||
// provided context, which is properly configured for constructing a
|
||||
// TokenSource. The default is to call [oauth2.Config.TokenSource].
|
||||
NewTokenSource func(context.Context, *oauth2.Config, *oauth2.Token) (oauth2.TokenSource, error)
|
||||
|
||||
// InitialTokenSource is an optional field that can be set to inject the
|
||||
// token source that will be used by the [AuthorizationCodeHandler]. If
|
||||
// non-nil, it is set as the token source that will be returned by
|
||||
// [AuthorizationCodeHandler.TokenSource] during handler initialization.
|
||||
// The default is nil, which means no token source has been set initially,
|
||||
// and will trigger a call to [AuthorizationCodeHandler.Authorize].
|
||||
InitialTokenSource oauth2.TokenSource
|
||||
}
|
||||
|
||||
// AuthorizationCodeHandler is an implementation of [OAuthHandler] that uses
|
||||
|
|
@ -133,6 +166,9 @@ 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
|
||||
|
||||
|
|
@ -143,6 +179,8 @@ 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
|
||||
}
|
||||
|
||||
|
|
@ -199,6 +237,7 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
|
|||
}
|
||||
return &AuthorizationCodeHandler{
|
||||
config: config,
|
||||
tokenSource: config.InitialTokenSource,
|
||||
grantedScopes: make(map[string][]string),
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -293,6 +332,12 @@ 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 &&
|
||||
|
|
@ -304,7 +349,10 @@ 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).
|
||||
requestedScopes = authutil.UnionScopes(h.grantedScopes[asm.Issuer], requestedScopes)
|
||||
h.mu.RLock()
|
||||
granted := h.grantedScopes[asm.Issuer]
|
||||
h.mu.RUnlock()
|
||||
requestedScopes = authutil.UnionScopes(granted, requestedScopes)
|
||||
|
||||
cfg := &oauth2.Config{
|
||||
ClientID: resolvedClientConfig.clientID,
|
||||
|
|
@ -324,7 +372,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); err != nil {
|
||||
if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported, h.config.AcceptUnadvertisedIss); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -573,10 +621,14 @@ func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg
|
|||
}
|
||||
|
||||
// validateIssuerResponse validates the "iss" parameter in an authorization response
|
||||
// per [RFC 9207].
|
||||
// per [RFC 9207]. When the server advertises authorization_response_iss_parameter_supported,
|
||||
// iss is required and must match expectedIssuer. When it does not advertise support,
|
||||
// an empty iss is accepted; a present iss is compared to expectedIssuer (RFC 9207 §2.4)
|
||||
// and a mismatch is always rejected. A matching unadvertised iss is accepted only when
|
||||
// acceptUnadvertisedIss is true (local policy); the default is the historical reject.
|
||||
//
|
||||
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
|
||||
func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported bool) error {
|
||||
func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported, acceptUnadvertisedIss bool) error {
|
||||
if issParameterSupported {
|
||||
if iss == "" {
|
||||
return fmt.Errorf("authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response")
|
||||
|
|
@ -584,12 +636,17 @@ func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported bo
|
|||
if iss != expectedIssuer {
|
||||
return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer)
|
||||
}
|
||||
} 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
|
||||
}
|
||||
if iss == "" {
|
||||
return nil
|
||||
}
|
||||
if iss != expectedIssuer {
|
||||
return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer)
|
||||
}
|
||||
if !acceptUnadvertisedIss {
|
||||
return fmt.Errorf("authorization server does not advertise RFC 9207 iss parameter support but iss was received in the authorization response")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -615,23 +672,42 @@ 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)
|
||||
h.tokenSource = cfg.TokenSource(refreshCtx, token)
|
||||
var ts oauth2.TokenSource
|
||||
if h.config.NewTokenSource == nil {
|
||||
ts = cfg.TokenSource(refreshCtx, token)
|
||||
} else {
|
||||
var err error
|
||||
ts, err = h.config.NewTokenSource(refreshCtx, cfg, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("constructing token source failed: %w", err)
|
||||
}
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.tokenSource = ts
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateGrantedScopes updates the granted scopes based on the token source and requested scopes.
|
||||
func (h *AuthorizationCodeHandler) updateGrantedScopes(issuer string, requestedScopes []string) error {
|
||||
if h.tokenSource == nil {
|
||||
h.mu.RLock()
|
||||
ts := h.tokenSource
|
||||
h.mu.RUnlock()
|
||||
|
||||
if ts == nil {
|
||||
return nil
|
||||
}
|
||||
tok, err := h.tokenSource.Token()
|
||||
tok, err := ts.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil {
|
||||
h.grantedScopes[issuer] = requestedScopes
|
||||
} else {
|
||||
h.grantedScopes[issuer] = tokenScopes
|
||||
}
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -919,11 +919,12 @@ func TestValidateIssuerResponse(t *testing.T) {
|
|||
const expectedIssuer = "https://auth.example.com"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
iss string
|
||||
issSupported bool
|
||||
wantErr bool
|
||||
wantErrContains string
|
||||
name string
|
||||
iss string
|
||||
issSupported bool
|
||||
acceptUnadvertisedIss bool
|
||||
wantErr bool
|
||||
wantErrContains string
|
||||
}{
|
||||
{
|
||||
name: "ValidIss",
|
||||
|
|
@ -949,11 +950,41 @@ 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)
|
||||
err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported, tt.acceptUnadvertisedIss)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("validateIssuerResponse() = nil, want error containing %q", tt.wantErrContains)
|
||||
|
|
@ -968,6 +999,112 @@ 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
|
||||
|
|
@ -1222,6 +1359,107 @@ 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 {
|
||||
|
|
@ -1233,3 +1471,92 @@ func validConfig() *AuthorizationCodeHandlerConfig {
|
|||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTokenSource(t *testing.T) {
|
||||
// mock the /token endpoint to successfully return an access token on code exchange
|
||||
mockTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/token" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"access_token": "test_token", "token_type": "bearer"}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer mockTS.Close()
|
||||
|
||||
// configure the handler and set NewTokenSource
|
||||
var called bool
|
||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
||||
RedirectURL: "http://localhost/callback",
|
||||
PreregisteredClient: &oauthex.ClientCredentials{
|
||||
ClientID: "test_client",
|
||||
},
|
||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||
u, _ := url.Parse(args.URL)
|
||||
return &AuthorizationResult{
|
||||
Code: "test_code",
|
||||
State: u.Query().Get("state"),
|
||||
}, nil
|
||||
},
|
||||
NewTokenSource: func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) {
|
||||
called = true
|
||||
return oauth2.StaticTokenSource(token), nil
|
||||
},
|
||||
Client: mockTS.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Simulate a 401 response from a resource server.
|
||||
// The WWW-Authenticate: Bearer header triggers the authorization logic.
|
||||
req := httptest.NewRequest(http.MethodGet, mockTS.URL, nil)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}
|
||||
resp.Header.Set("WWW-Authenticate", "Bearer")
|
||||
|
||||
// Authorize and confirm NewTokenSource was called.
|
||||
err = handler.Authorize(t.Context(), req, resp)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Error("expected NewTokenSource to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialTokenSource(t *testing.T) {
|
||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
||||
RedirectURL: "http://localhost:12345/callback",
|
||||
PreregisteredClient: &oauthex.ClientCredentials{
|
||||
ClientID: "test_client_id",
|
||||
},
|
||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||
return nil, nil
|
||||
},
|
||||
InitialTokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "set_token"}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
||||
}
|
||||
|
||||
ts, err := handler.TokenSource(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get token source: %v", err)
|
||||
}
|
||||
if ts == nil {
|
||||
t.Fatal("expected token source to be non-nil")
|
||||
}
|
||||
|
||||
tok, err := ts.Token()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get Token: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "set_token" {
|
||||
t.Errorf("expected access token 'set_token', got '%s'", tok.AccessToken)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,12 @@ func authorizationServerMetadataURLs(issuerURL string) []string {
|
|||
return nil
|
||||
}
|
||||
|
||||
if baseURL.Path == "" {
|
||||
// RFC 8414, section 3.1 requires any terminating "/" to be removed from the
|
||||
// issuer identifier before the well-known suffix is inserted, so an issuer
|
||||
// such as "https://auth.example.com/" has no path component at all.
|
||||
issuerPath := strings.Trim(baseURL.Path, "/")
|
||||
|
||||
if issuerPath == "" {
|
||||
// "OAuth 2.0 Authorization Server Metadata".
|
||||
baseURL.Path = "/.well-known/oauth-authorization-server"
|
||||
urls = append(urls, baseURL.String())
|
||||
|
|
@ -54,15 +59,14 @@ func authorizationServerMetadataURLs(issuerURL string) []string {
|
|||
return urls
|
||||
}
|
||||
|
||||
originalPath := baseURL.Path
|
||||
// "OAuth 2.0 Authorization Server Metadata with path insertion".
|
||||
baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/")
|
||||
baseURL.Path = "/.well-known/oauth-authorization-server/" + issuerPath
|
||||
urls = append(urls, baseURL.String())
|
||||
// "OpenID Connect Discovery 1.0 with path insertion".
|
||||
baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/")
|
||||
baseURL.Path = "/.well-known/openid-configuration/" + issuerPath
|
||||
urls = append(urls, baseURL.String())
|
||||
// "OpenID Connect Discovery 1.0 with path appending".
|
||||
baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration"
|
||||
baseURL.Path = "/" + issuerPath + "/.well-known/openid-configuration"
|
||||
urls = append(urls, baseURL.String())
|
||||
|
||||
return urls
|
||||
|
|
|
|||
|
|
@ -6,11 +6,81 @@ package auth
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
|
||||
)
|
||||
|
||||
func TestAuthorizationServerMetadataURLs(t *testing.T) {
|
||||
tests := []struct {
|
||||
issuerURL string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
issuerURL: "https://auth.example.com",
|
||||
want: []string{
|
||||
"https://auth.example.com/.well-known/oauth-authorization-server",
|
||||
"https://auth.example.com/.well-known/openid-configuration",
|
||||
},
|
||||
},
|
||||
{
|
||||
issuerURL: "https://auth.example.com/",
|
||||
want: []string{
|
||||
"https://auth.example.com/.well-known/oauth-authorization-server",
|
||||
"https://auth.example.com/.well-known/openid-configuration",
|
||||
},
|
||||
},
|
||||
{
|
||||
issuerURL: "https://auth.example.com/tenant1",
|
||||
want: []string{
|
||||
"https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
|
||||
"https://auth.example.com/.well-known/openid-configuration/tenant1",
|
||||
"https://auth.example.com/tenant1/.well-known/openid-configuration",
|
||||
},
|
||||
},
|
||||
{
|
||||
issuerURL: "https://auth.example.com/tenant1/",
|
||||
want: []string{
|
||||
"https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
|
||||
"https://auth.example.com/.well-known/openid-configuration/tenant1",
|
||||
"https://auth.example.com/tenant1/.well-known/openid-configuration",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.issuerURL, func(t *testing.T) {
|
||||
if got := authorizationServerMetadataURLs(tt.issuerURL); !slices.Equal(got, tt.want) {
|
||||
t.Errorf("authorizationServerMetadataURLs(%q) =\n%v\nwant\n%v", tt.issuerURL, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAuthServerMetadataTrailingSlashIssuer checks that an issuer identifier
|
||||
// written with a terminating slash still finds metadata served at the root
|
||||
// well-known path.
|
||||
func TestGetAuthServerMetadataTrailingSlashIssuer(t *testing.T) {
|
||||
s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
||||
MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{
|
||||
ServeOAuthInsertedEndpoint: true,
|
||||
},
|
||||
})
|
||||
s.Start(t)
|
||||
|
||||
got, err := GetAuthServerMetadata(t.Context(), s.URL()+"/", http.DefaultClient)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAuthServerMetadata() error = %v, want nil", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("GetAuthServerMetadata() got nil, want metadata")
|
||||
}
|
||||
if got.Issuer != s.URL() {
|
||||
t.Errorf("GetAuthServerMetadata() issuer = %q, want %q", got.Issuer, s.URL())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthServerMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -1,2 +1,14 @@
|
|||
server: [] # All tests pass!
|
||||
client: [] # All tests pass!
|
||||
client:
|
||||
# OAuth mechanisms not implemented by the Go MCP SDK.
|
||||
- auth/client-credentials-basic
|
||||
- auth/client-credentials-jwt
|
||||
- auth/enterprise-managed-authorization
|
||||
|
||||
# SEP-1932 (DPoP Sender-Constrained Tokens) client-side support not yet
|
||||
# implemented by the Go MCP SDK.
|
||||
- auth/dpop
|
||||
- auth/dpop-nonce
|
||||
|
||||
# SEP-1933 (Workload Identity Federation JWT-bearer) client-side support
|
||||
# not yet implemented by the Go MCP SDK.
|
||||
- auth/wif-jwt-bearer
|
||||
|
|
|
|||
|
|
@ -45,32 +45,44 @@ func registerScenario(name string, handler scenarioHandler) {
|
|||
func init() {
|
||||
registerScenario("initialize", runBasicClient)
|
||||
registerScenario("tools_call", runToolsCallClient)
|
||||
registerScenario("request-metadata", runRequestMetadataClient)
|
||||
registerScenario("elicitation-sep1034-client-defaults", runElicitationDefaultsClient)
|
||||
registerScenario("sse-retry", runSSERetryClient)
|
||||
registerScenario("json-schema-ref-no-deref", runJSONSchemaRefNoDerefClient)
|
||||
registerScenario("json-schema-2020-12-preservation", runJSONSchemaPreservationClient)
|
||||
registerScenario("sep-2322-client-request-state", runMrtrClient)
|
||||
registerScenario("http-standard-headers", runHTTPStandardHeadersClient)
|
||||
registerScenario("http-custom-headers", runHTTPCustomHeadersClient)
|
||||
registerScenario("http-invalid-tool-headers", runHTTPInvalidToolHeadersClient)
|
||||
|
||||
authScenarios := []string{
|
||||
"auth/2025-03-26-oauth-metadata-backcompat",
|
||||
"auth/2025-03-26-oauth-endpoint-fallback",
|
||||
"auth/authorization-server-migration",
|
||||
"auth/basic-cimd",
|
||||
"auth/iss-normalized",
|
||||
"auth/iss-not-advertised",
|
||||
"auth/iss-supported",
|
||||
"auth/iss-supported-missing",
|
||||
"auth/iss-unexpected",
|
||||
"auth/iss-wrong-issuer",
|
||||
"auth/metadata-default",
|
||||
"auth/metadata-issuer-mismatch",
|
||||
"auth/metadata-var1",
|
||||
"auth/metadata-var2",
|
||||
"auth/metadata-var3",
|
||||
"auth/offline-access-not-supported",
|
||||
"auth/offline-access-scope",
|
||||
"auth/pre-registration",
|
||||
"auth/resource-mismatch",
|
||||
"auth/scope-from-www-authenticate",
|
||||
"auth/scope-from-scopes-supported",
|
||||
"auth/scope-from-www-authenticate",
|
||||
"auth/scope-omitted-when-undefined",
|
||||
"auth/scope-step-up",
|
||||
"auth/scope-retry-limit",
|
||||
"auth/scope-step-up",
|
||||
"auth/token-endpoint-auth-basic",
|
||||
"auth/token-endpoint-auth-post",
|
||||
"auth/token-endpoint-auth-none",
|
||||
"auth/iss-supported",
|
||||
"auth/iss-not-advertised",
|
||||
"auth/iss-supported-missing",
|
||||
"auth/iss-wrong-issuer",
|
||||
"auth/iss-unexpected",
|
||||
"auth/token-endpoint-auth-post",
|
||||
}
|
||||
for _, scenario := range authScenarios {
|
||||
registerScenario(scenario, runAuthClient)
|
||||
|
|
@ -293,6 +305,238 @@ func runAuthClient(ctx context.Context, serverURL string, configCtx map[string]a
|
|||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// request-metadata scenario (SEP-2575)
|
||||
// ============================================================================
|
||||
|
||||
// runRequestMetadataClient exercises the SEP-2575 wire-level negotiation:
|
||||
// every request must carry the MCP-Protocol-Version header and the per-request
|
||||
// _meta envelope, and the client must retry with a supported version when its
|
||||
// first choice is rejected with -32022. The Go SDK's Connect() drives
|
||||
// server/discover unconditionally for 2026-07-28, which is exactly that
|
||||
// mechanism.
|
||||
func runRequestMetadataClient(ctx context.Context, serverURL string, _ map[string]any) error {
|
||||
session, err := connectToServer(ctx, serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// json-schema-ref-no-deref scenario (SEP-2106)
|
||||
// ============================================================================
|
||||
|
||||
// runJSONSchemaRefNoDerefClient asserts that listTools does not dereference
|
||||
// network $ref URLs in tool schemas. The Go SDK never fetches external refs;
|
||||
// a plain connect → listTools is sufficient — if the SDK ever regressed and
|
||||
// tried to GET the canary URL the conformance referee would record the
|
||||
// failure.
|
||||
func runJSONSchemaRefNoDerefClient(ctx context.Context, serverURL string, _ map[string]any) error {
|
||||
session, err := connectToServer(ctx, serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
_, _ = session.ListTools(ctx, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// json-schema-2020-12-preservation scenario (SEP-1613, SEP-2106)
|
||||
// ============================================================================
|
||||
|
||||
// runJSONSchemaPreservationClient echoes the focal tool's inputSchema back to
|
||||
// the referee verbatim, through the permissive json_schema_echo tool, so that
|
||||
// it can observe which 2020-12 keywords survived the client's parse of
|
||||
// tools/list. The Go SDK surfaces a server's schema as the raw JSON object it
|
||||
// received, so nothing is stripped.
|
||||
func runJSONSchemaPreservationClient(ctx context.Context, serverURL string, _ map[string]any) error {
|
||||
session, err := connectToServer(ctx, serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
tools, err := session.ListTools(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("session.ListTools(): %v", err)
|
||||
}
|
||||
|
||||
const focalTool = "json_schema_2020_12_tool"
|
||||
idx := slices.IndexFunc(tools.Tools, func(t *mcp.Tool) bool {
|
||||
return t.Name == focalTool
|
||||
})
|
||||
if idx == -1 {
|
||||
return fmt.Errorf("tool %q not found", focalTool)
|
||||
}
|
||||
|
||||
const echoTool = "json_schema_echo"
|
||||
if _, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: echoTool,
|
||||
Arguments: map[string]any{"schema": tools.Tools[idx].InputSchema},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("session.CallTool(%q): %v", echoTool, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SEP-2322 multi-round-trip client scenario
|
||||
// ============================================================================
|
||||
|
||||
// runMrtrClient drives the SEP-2322 client-request-state scenario. The
|
||||
// SDK's built-in client-side MRTR middleware transparently fulfills embedded
|
||||
// elicitation requests via ElicitationHandler and echoes requestState.
|
||||
func runMrtrClient(ctx context.Context, serverURL string, _ map[string]any) error {
|
||||
elicitationHandler := func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
|
||||
return &mcp.ElicitResult{
|
||||
Action: "accept",
|
||||
Content: map[string]any{"confirmed": true},
|
||||
}, nil
|
||||
}
|
||||
session, err := connectToServer(ctx, serverURL,
|
||||
withClientOptions(&mcp.ClientOptions{
|
||||
Capabilities: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{}},
|
||||
ElicitationHandler: elicitationHandler,
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
toolNames := []string{
|
||||
"test_mrtr_echo_state",
|
||||
"test_mrtr_no_state",
|
||||
"test_mrtr_unrelated",
|
||||
"test_mrtr_no_result_type",
|
||||
}
|
||||
for _, name := range toolNames {
|
||||
if _, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: name,
|
||||
Arguments: map[string]any{},
|
||||
}); err != nil {
|
||||
log.Printf("CallTool(%q) rejected locally (expected for some cases): %v", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SEP-2243 HTTP header scenarios
|
||||
// ============================================================================
|
||||
|
||||
// runHTTPStandardHeadersClient (SEP-2243 standard headers) drives tools,
|
||||
// resources and prompts once each so the referee sees Mcp-Method / Mcp-Name
|
||||
// headers on every verb the SDK supports.
|
||||
func runHTTPStandardHeadersClient(ctx context.Context, serverURL string, _ map[string]any) error {
|
||||
session, err := connectToServer(ctx, serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
if tools, err := session.ListTools(ctx, nil); err == nil && len(tools.Tools) > 0 {
|
||||
_, _ = session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: tools.Tools[0].Name,
|
||||
Arguments: map[string]any{},
|
||||
})
|
||||
}
|
||||
if res, err := session.ListResources(ctx, nil); err == nil && len(res.Resources) > 0 {
|
||||
_, _ = session.ReadResource(ctx, &mcp.ReadResourceParams{URI: res.Resources[0].URI})
|
||||
}
|
||||
if prompts, err := session.ListPrompts(ctx, nil); err == nil && len(prompts.Prompts) > 0 {
|
||||
_, _ = session.GetPrompt(ctx, &mcp.GetPromptParams{
|
||||
Name: prompts.Prompts[0].Name,
|
||||
Arguments: map[string]string{},
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runHTTPCustomHeadersClient (SEP-2243 custom headers) lists tools (so the
|
||||
// SDK caches inputSchema and its x-mcp-header annotations), then makes the
|
||||
// runner-supplied tool calls so the referee validates the Mcp-Param-*
|
||||
// headers.
|
||||
func runHTTPCustomHeadersClient(ctx context.Context, serverURL string, configCtx map[string]any) error {
|
||||
session, err := connectToServer(ctx, serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
if _, err := session.ListTools(ctx, nil); err != nil {
|
||||
return fmt.Errorf("session.ListTools(): %v", err)
|
||||
}
|
||||
for _, call := range readToolCallsContext(configCtx) {
|
||||
if _, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: call.Name,
|
||||
Arguments: call.Arguments,
|
||||
}); err != nil {
|
||||
log.Printf("CallTool(%q): %v", call.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runHTTPInvalidToolHeadersClient (SEP-2243 invalid-tool filtering) lists
|
||||
// tools — a correct SDK leaves only the valid ones — then calls each survivor
|
||||
// so the referee records SUCCESS for every excluded tool never called.
|
||||
func runHTTPInvalidToolHeadersClient(ctx context.Context, serverURL string, _ map[string]any) error {
|
||||
session, err := connectToServer(ctx, serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
tools, err := session.ListTools(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("session.ListTools(): %v", err)
|
||||
}
|
||||
for _, tool := range tools.Tools {
|
||||
_, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: tool.Name,
|
||||
Arguments: map[string]any{"region": "us-west1"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("call %q rejected: %v", tool.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type toolCall struct {
|
||||
Name string
|
||||
Arguments map[string]any
|
||||
}
|
||||
|
||||
// readToolCallsContext parses the `toolCalls` array from MCP_CONFORMANCE_CONTEXT.
|
||||
// Format: {"toolCalls": [{"name":"...", "arguments":{...}}, ...]}.
|
||||
func readToolCallsContext(configCtx map[string]any) []toolCall {
|
||||
raw, ok := configCtx["toolCalls"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]toolCall, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
obj, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name, _ := obj["name"].(string)
|
||||
args, _ := obj["arguments"].(map[string]any)
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
out = append(out, toolCall{Name: name, Arguments: args})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main entry point
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ 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"
|
||||
)
|
||||
|
|
@ -154,14 +156,19 @@ func registerTools(server *mcp.Server) {
|
|||
Description: "Tests elicitation with enum schema improvements per SEP-1330",
|
||||
}, testElicitationEnumsHandler)
|
||||
|
||||
// SEP-1613 / SEP-2106: JSON Schema 2020-12 conformance test tool.
|
||||
// The scenario verifies that $schema/$defs/additionalProperties (SEP-1613)
|
||||
// and the broader 2020-12 vocabulary — $anchor, allOf/anyOf, if/then/else —
|
||||
// (SEP-2106) survive tools/list verbatim.
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "json_schema_2020_12_tool",
|
||||
Description: "Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)",
|
||||
Description: "Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613, SEP-2106)",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"$defs": {
|
||||
"address": {
|
||||
"$anchor": "addressDef",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"street": { "type": "string" },
|
||||
|
|
@ -171,8 +178,20 @@ func registerTools(server *mcp.Server) {
|
|||
},
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"address": { "$ref": "#/$defs/address" }
|
||||
"address": { "$ref": "#/$defs/address" },
|
||||
"contactMethod": { "type": "string", "enum": ["phone", "email"] },
|
||||
"phone": { "type": "string" },
|
||||
"email": { "type": "string" }
|
||||
},
|
||||
"allOf": [
|
||||
{ "anyOf": [{ "required": ["phone"] }, { "required": ["email"] }] }
|
||||
],
|
||||
"if": {
|
||||
"properties": { "contactMethod": { "const": "phone" } },
|
||||
"required": ["contactMethod"]
|
||||
},
|
||||
"then": { "required": ["phone"] },
|
||||
"else": { "required": ["email"] },
|
||||
"additionalProperties": false
|
||||
}`),
|
||||
}, jsonSchema202012Handler)
|
||||
|
|
@ -181,6 +200,87 @@ func registerTools(server *mcp.Server) {
|
|||
Name: "test_reconnection",
|
||||
Description: "Tests SSE stream disconnection and client reconnection (SEP-1699). Server will close the stream mid-call and send the result after client reconnects.",
|
||||
}, testReconnectionHandler)
|
||||
|
||||
// SEP-2243 x-mcp-header tool — arms the http-custom-header-server-validation
|
||||
// conformance scenario (which skips when no tool with an x-mcp-header
|
||||
// annotation is found).
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "test_x_mcp_header",
|
||||
Description: "Tests SEP-2243 Mcp-Param-* server-side validation",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"region": { "type": "string", "description": "mirrored into Mcp-Param-Region", "x-mcp-header": "Region" },
|
||||
"level": { "type": "integer", "description": "non-mirrored argument" }
|
||||
}
|
||||
}`),
|
||||
}, testXMcpHeaderHandler)
|
||||
|
||||
// SEP-2575 diagnostic tools used by the stateless conformance scenario.
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "test_missing_capability",
|
||||
Description: "Requires the sampling client capability; used to verify MissingRequiredClientCapabilityError (-32021) (SEP-2575)",
|
||||
}, testMissingCapabilityHandler)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "test_streaming_elicitation",
|
||||
Description: "Streams progress notifications while a call is in flight; used to verify response streams carry only notifications, never independent JSON-RPC requests (SEP-2575)",
|
||||
}, testStreamingElicitationHandler)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "test_logging_tool",
|
||||
Description: "Attempts to emit a log message; the framework must drop it when the client did not set _meta.../logLevel (SEP-2575)",
|
||||
}, testLoggingToolHandler)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "test_trigger_tool_change",
|
||||
Description: "Mutates the tool list to trigger a notifications/tools/list_changed on active subscription streams (SEP-2575)",
|
||||
}, makeTriggerToolChangeHandler(server))
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "test_trigger_prompt_change",
|
||||
Description: "Mutates the prompt list to trigger a notifications/prompts/list_changed on active subscription streams (SEP-2575)",
|
||||
}, makeTriggerPromptChangeHandler(server))
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "test_input_required_result_elicitation",
|
||||
Description: "MRTR (SEP-2322): asks for the caller name via an in-band elicitation request",
|
||||
}, testInputRequiredElicitationHandler)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "test_input_required_result_sampling",
|
||||
Description: "MRTR (SEP-2322): asks for an LLM completion via an in-band sampling request",
|
||||
}, testInputRequiredSamplingHandler)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "test_input_required_result_list_roots",
|
||||
Description: "MRTR (SEP-2322): asks for the client roots via an in-band roots/list request",
|
||||
}, testInputRequiredListRootsHandler)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "test_input_required_result_request_state",
|
||||
Description: "MRTR (SEP-2322): round-trips integrity-protected requestState alongside an elicitation request",
|
||||
}, testInputRequiredRequestStateHandler)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "test_input_required_result_multiple_inputs",
|
||||
Description: "MRTR (SEP-2322): asks for elicitation, sampling and roots input in a single round",
|
||||
}, testInputRequiredMultipleInputsHandler)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "test_input_required_result_multi_round",
|
||||
Description: "MRTR (SEP-2322): two elicitation rounds with evolving requestState before completing",
|
||||
}, testInputRequiredMultiRoundHandler)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "test_input_required_result_tampered_state",
|
||||
Description: "MRTR (SEP-2322): rejects retries whose requestState fails integrity verification",
|
||||
}, testInputRequiredTamperedStateHandler)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "test_input_required_result_capabilities",
|
||||
Description: "MRTR (SEP-2322): only requests input kinds the declared client capabilities cover",
|
||||
}, testInputRequiredCapabilitiesHandler)
|
||||
}
|
||||
|
||||
// Tool handlers
|
||||
|
|
@ -477,33 +577,466 @@ func testElicitationEnumsHandler(ctx context.Context, req *mcp.CallToolRequest,
|
|||
}, nil, nil
|
||||
}
|
||||
|
||||
type jsonSchemaAddress struct {
|
||||
Street string `json:"street"`
|
||||
City string `json:"city"`
|
||||
}
|
||||
|
||||
type jsonSchemaInput struct {
|
||||
Name string `json:"name"`
|
||||
Address *jsonSchemaAddress `json:"address"`
|
||||
}
|
||||
|
||||
func jsonSchema202012Handler(ctx context.Context, req *mcp.CallToolRequest, input jsonSchemaInput) (*mcp.CallToolResult, any, error) {
|
||||
// Echo back the arguments received
|
||||
var addressStr string
|
||||
if input.Address != nil {
|
||||
addressStr = fmt.Sprintf("{street: %q, city: %q}", input.Address.Street, input.Address.City)
|
||||
} else {
|
||||
addressStr = "nil"
|
||||
}
|
||||
func jsonSchema202012Handler(ctx context.Context, req *mcp.CallToolRequest, input json.RawMessage) (*mcp.CallToolResult, any, error) {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{
|
||||
Text: fmt.Sprintf("Received: name=%q, address=%s", input.Name, addressStr),
|
||||
Text: fmt.Sprintf("JSON Schema 2020-12 tool called with: %s", input),
|
||||
},
|
||||
},
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
type testXMcpHeaderInput struct {
|
||||
Region string `json:"region,omitempty"`
|
||||
Level int `json:"level,omitempty"`
|
||||
}
|
||||
|
||||
func testXMcpHeaderHandler(ctx context.Context, req *mcp.CallToolRequest, input testXMcpHeaderInput) (*mcp.CallToolResult, any, error) {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: fmt.Sprintf("region=%s", input.Region)},
|
||||
},
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
// testMissingCapabilityHandler requires the client to have declared the
|
||||
// sampling capability. When absent, it returns a MissingRequiredClientCapabilityError
|
||||
// with data.requiredCapabilities = {"sampling": {}} per SEP-2575.
|
||||
func testMissingCapabilityHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
||||
caps := req.ClientCapabilities()
|
||||
if caps != nil && caps.Sampling != nil {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: "Client declared the sampling capability; tool executed."},
|
||||
},
|
||||
}, nil, nil
|
||||
}
|
||||
missingCapabilityData := mcp.MissingRequiredClientCapabilityData{
|
||||
RequiredCapabilities: &mcp.ClientCapabilities{
|
||||
Sampling: &mcp.SamplingCapabilities{},
|
||||
},
|
||||
}
|
||||
dataBytes, err := json.Marshal(missingCapabilityData)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return nil, nil, &jsonrpc.Error{
|
||||
Code: mcp.CodeMissingRequiredClientCapabilities,
|
||||
Message: "sampling capability required but not declared by client",
|
||||
Data: dataBytes,
|
||||
}
|
||||
}
|
||||
|
||||
// testStreamingElicitationHandler returns a plain result. Per SEP-2575, the
|
||||
// response stream must carry no independent top-level JSON-RPC requests; a
|
||||
// plain response trivially satisfies this. The scenario declares no
|
||||
// `elicitation` capability, so this handler must not call req.Session.Elicit.
|
||||
func testStreamingElicitationHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: "stream observed: result frames only, no top-level requests"},
|
||||
},
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
// testLoggingToolHandler attempts to emit a log message. The SDK's
|
||||
// ServerSession.Log gates on the per-request _meta.../logLevel, so this is a
|
||||
// no-op notification-wise when the client did not opt in.
|
||||
func testLoggingToolHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
||||
req.Session.Log(ctx, &mcp.LoggingMessageParams{
|
||||
Level: "info",
|
||||
Data: "test_logging_tool executed",
|
||||
})
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: "Log attempted; framework gates on _meta.../logLevel."},
|
||||
},
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
// makeTriggerToolChangeHandler returns a handler that (re-)registers a
|
||||
// no-op transient tool. Every call to Server.AddTool dispatches a
|
||||
// notifications/tools/list_changed to active subscription streams (even when
|
||||
// it replaces a tool with the same name), which is what the SEP-2575
|
||||
// subscription checks assert.
|
||||
func makeTriggerToolChangeHandler(server *mcp.Server) mcp.ToolHandlerFor[any, any] {
|
||||
return func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "__transient_tool_for_list_changed",
|
||||
Description: "Transient tool used to trigger tools/list_changed",
|
||||
}, func(context.Context, *mcp.CallToolRequest, any) (*mcp.CallToolResult, any, error) {
|
||||
return &mcp.CallToolResult{}, nil, nil
|
||||
})
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: "tools_list_changed published"},
|
||||
},
|
||||
}, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// makeTriggerPromptChangeHandler is the prompt analogue of
|
||||
// makeTriggerToolChangeHandler.
|
||||
func makeTriggerPromptChangeHandler(server *mcp.Server) mcp.ToolHandlerFor[any, any] {
|
||||
return func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
||||
server.AddPrompt(&mcp.Prompt{
|
||||
Name: "__transient_prompt_for_list_changed",
|
||||
Description: "Transient prompt used to trigger prompts/list_changed",
|
||||
}, func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
|
||||
return &mcp.GetPromptResult{}, nil
|
||||
})
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: "prompts_list_changed published"},
|
||||
},
|
||||
}, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SEP-2322 Multi-Round-Trip helpers
|
||||
// =============================================================================
|
||||
|
||||
// requestStateTamperedSuffix is what the tampered-state conformance scenario
|
||||
// appends to a valid requestState. Detecting this suffix is sufficient to
|
||||
// pass the check; a production server would use a real integrity mechanism
|
||||
// (HMAC / AEAD, per SEP-2322 § "Server Requirements").
|
||||
const requestStateTamperedSuffix = "-TAMPERED"
|
||||
|
||||
// requestStateValid reports whether the state looks like one this server
|
||||
// minted (i.e. the harness did not append the tampered marker).
|
||||
func requestStateValid(state string) bool {
|
||||
return state != "" && !strings.HasSuffix(state, requestStateTamperedSuffix)
|
||||
}
|
||||
|
||||
// acceptedContent returns the "content" field of an ElicitResult only when the
|
||||
// user accepted the elicitation. Returns nil if the response is missing, of the
|
||||
// wrong type, or the action is not "accept".
|
||||
func acceptedContent(responses mcp.InputResponseMap, key string) map[string]any {
|
||||
if responses == nil {
|
||||
return nil
|
||||
}
|
||||
resp, ok := responses[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
elicit, ok := resp.(*mcp.ElicitResult)
|
||||
if !ok || elicit == nil || elicit.Action != "accept" {
|
||||
return nil
|
||||
}
|
||||
return elicit.Content
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SEP-2322 Multi-Round-Trip tool handlers
|
||||
// =============================================================================
|
||||
|
||||
// nameElicitSchema is the requestedSchema used by tools that elicit a name.
|
||||
func nameElicitSchema() *jsonschema.Schema {
|
||||
return &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"name": {Type: "string"},
|
||||
},
|
||||
Required: []string{"name"},
|
||||
}
|
||||
}
|
||||
|
||||
// testInputRequiredElicitationHandler asks for the caller name via elicitation.
|
||||
// Reused by the result-type, missing-input-response, ignore-extra-params, and
|
||||
// validate-input scenarios: anything that does not carry an accepted
|
||||
// "user_name" response is answered with a fresh InputRequiredResult.
|
||||
func testInputRequiredElicitationHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
||||
if content := acceptedContent(req.Params.InputResponses, "user_name"); content != nil {
|
||||
name, _ := content["name"].(string)
|
||||
if name != "" {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Hello, %s!", name)}},
|
||||
}, nil, nil
|
||||
}
|
||||
}
|
||||
return &mcp.CallToolResult{
|
||||
InputRequests: mcp.InputRequestMap{
|
||||
"user_name": &mcp.ElicitParams{
|
||||
Message: "What is your name?",
|
||||
RequestedSchema: nameElicitSchema(),
|
||||
},
|
||||
},
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
// samplingResponseText extracts the first text content from a sampling
|
||||
// response. The Go SDK unmarshals responses with a "role" field into
|
||||
// *CreateMessageWithToolsResult (a superset of the legacy single-content
|
||||
// CreateMessageResult).
|
||||
func samplingResponseText(responses mcp.InputResponseMap, key string) (string, bool) {
|
||||
resp, ok := responses[key].(*mcp.CreateMessageWithToolsResult)
|
||||
if !ok || resp == nil {
|
||||
return "", false
|
||||
}
|
||||
for _, c := range resp.Content {
|
||||
if tc, ok := c.(*mcp.TextContent); ok {
|
||||
return tc.Text, true
|
||||
}
|
||||
}
|
||||
return "(non-text response)", true
|
||||
}
|
||||
|
||||
// testInputRequiredSamplingHandler asks for an LLM completion via sampling.
|
||||
func testInputRequiredSamplingHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
||||
if text, ok := samplingResponseText(req.Params.InputResponses, "capital_question"); ok {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Sampling response: %s", text)}},
|
||||
}, nil, nil
|
||||
}
|
||||
return &mcp.CallToolResult{
|
||||
InputRequests: mcp.InputRequestMap{
|
||||
"capital_question": &mcp.CreateMessageParams{
|
||||
Messages: []*mcp.SamplingMessage{
|
||||
{Role: "user", Content: &mcp.TextContent{Text: "What is the capital of France?"}},
|
||||
},
|
||||
MaxTokens: 100,
|
||||
},
|
||||
},
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
// testInputRequiredListRootsHandler asks for the client roots.
|
||||
func testInputRequiredListRootsHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
||||
if resp, ok := req.Params.InputResponses["client_roots"].(*mcp.ListRootsResult); ok && resp != nil {
|
||||
var uris []string
|
||||
for _, r := range resp.Roots {
|
||||
uris = append(uris, r.URI)
|
||||
}
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: fmt.Sprintf("Client exposed %d root(s): %s", len(resp.Roots), strings.Join(uris, ", "))},
|
||||
},
|
||||
}, nil, nil
|
||||
}
|
||||
return &mcp.CallToolResult{
|
||||
InputRequests: mcp.InputRequestMap{
|
||||
"client_roots": &mcp.ListRootsParams{},
|
||||
},
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
// testInputRequiredRequestStateHandler round-trips an opaque requestState
|
||||
// alongside an elicitation request.
|
||||
func testInputRequiredRequestStateHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
||||
if content := acceptedContent(req.Params.InputResponses, "confirm"); content != nil {
|
||||
if requestStateValid(req.Params.RequestState) {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: "state-ok: requestState received and confirmation accepted"}},
|
||||
}, nil, nil
|
||||
}
|
||||
return nil, nil, &jsonrpc.Error{
|
||||
Code: jsonrpc.CodeInvalidParams,
|
||||
Message: "invalid requestState",
|
||||
}
|
||||
}
|
||||
return &mcp.CallToolResult{
|
||||
InputRequests: mcp.InputRequestMap{
|
||||
"confirm": &mcp.ElicitParams{
|
||||
Message: "Please confirm",
|
||||
RequestedSchema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{"ok": {Type: "boolean"}},
|
||||
Required: []string{"ok"},
|
||||
},
|
||||
},
|
||||
},
|
||||
RequestState: "request_state",
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
// testInputRequiredMultipleInputsHandler asks for elicitation, sampling, and
|
||||
// roots input in a single round.
|
||||
func testInputRequiredMultipleInputsHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
||||
nameContent := acceptedContent(req.Params.InputResponses, "user_name")
|
||||
name, _ := nameContent["name"].(string)
|
||||
|
||||
greetingText, hasGreeting := samplingResponseText(req.Params.InputResponses, "greeting")
|
||||
|
||||
var rootsCount int
|
||||
rootsResp, rootsOK := req.Params.InputResponses["client_roots"].(*mcp.ListRootsResult)
|
||||
if rootsOK && rootsResp != nil {
|
||||
rootsCount = len(rootsResp.Roots)
|
||||
}
|
||||
|
||||
if name != "" && hasGreeting && rootsOK {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: fmt.Sprintf("%s %s — %d root(s) visible", greetingText, name, rootsCount)},
|
||||
},
|
||||
}, nil, nil
|
||||
}
|
||||
return &mcp.CallToolResult{
|
||||
InputRequests: mcp.InputRequestMap{
|
||||
"user_name": &mcp.ElicitParams{
|
||||
Message: "What is your name?",
|
||||
RequestedSchema: nameElicitSchema(),
|
||||
},
|
||||
"greeting": &mcp.CreateMessageParams{
|
||||
Messages: []*mcp.SamplingMessage{
|
||||
{Role: "user", Content: &mcp.TextContent{Text: "Generate a greeting"}},
|
||||
},
|
||||
MaxTokens: 50,
|
||||
},
|
||||
"client_roots": &mcp.ListRootsParams{},
|
||||
},
|
||||
RequestState: "multiple_inputs",
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
// testInputRequiredMultiRoundHandler runs two elicitation rounds with
|
||||
// evolving requestState before completing. The round number (and the name
|
||||
// elicited in round 1) lives in the state, since the stateless server keeps
|
||||
// no per-session memory.
|
||||
func testInputRequiredMultiRoundHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
||||
state := req.Params.RequestState
|
||||
// State format: "round=1" or "round=2;name=<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 {
|
||||
|
|
@ -651,6 +1184,11 @@ func registerPrompts(server *mcp.Server) {
|
|||
Title: "Prompt With Image",
|
||||
Description: "A prompt that includes image content",
|
||||
}, promptWithImageHandler)
|
||||
|
||||
server.AddPrompt(&mcp.Prompt{
|
||||
Name: "test_input_required_result_prompt",
|
||||
Description: "MRTR (SEP-2322): asks for context via an in-band elicitation request",
|
||||
}, testInputRequiredPromptHandler)
|
||||
}
|
||||
|
||||
func simplePromptHandler(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
|
||||
|
|
|
|||
324
docs/client.md
324
docs/client.md
|
|
@ -2,8 +2,11 @@
|
|||
# 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)
|
||||
|
|
@ -38,33 +41,34 @@ 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](protocol.md#multi-round-trip-requests-mrtr)
|
||||
[Multi Round-Trip Requests](#multi-round-trip-requests)
|
||||
pattern.
|
||||
|
||||
```go
|
||||
func Example_roots() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a client with a single root.
|
||||
// Create a client with two roots.
|
||||
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil)
|
||||
c.AddRoots(&mcp.Root{URI: "file://a"})
|
||||
c.AddRoots(&mcp.Root{URI: "file://a"}, &mcp.Root{URI: "file://b"})
|
||||
|
||||
// Now create a server with a handler to receive notifications about roots.
|
||||
rootsChanged := make(chan struct{})
|
||||
handleRootsChanged := func(ctx context.Context, req *mcp.RootsListChangedRequest) {
|
||||
rootList, err := req.Session.ListRoots(ctx, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
// Create a server with a tool that requests roots via the multi round-trip
|
||||
// pattern (SEP-2322): server-to-client requests are no longer sent as
|
||||
// standalone JSON-RPC calls on protocol version >= 2026-07-28.
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil)
|
||||
mcp.AddTool(s, &mcp.Tool{Name: "roots"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
|
||||
if len(req.Params.InputResponses) == 0 {
|
||||
return &mcp.CallToolResult{
|
||||
InputRequests: mcp.InputRequestMap{"roots": &mcp.ListRootsParams{}},
|
||||
}, nil, nil
|
||||
}
|
||||
rootList := req.Params.InputResponses["roots"].(*mcp.ListRootsResult)
|
||||
var roots []string
|
||||
for _, root := range rootList.Roots {
|
||||
roots = append(roots, root.URI)
|
||||
}
|
||||
fmt.Println(roots)
|
||||
close(rootsChanged)
|
||||
}
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{
|
||||
RootsListChangedHandler: handleRootsChanged,
|
||||
return &mcp.CallToolResult{}, nil, nil
|
||||
})
|
||||
|
||||
// Connect the server and client...
|
||||
|
|
@ -81,13 +85,76 @@ func Example_roots() {
|
|||
}
|
||||
defer clientSession.Close()
|
||||
|
||||
// ...and add a root. The server is notified about the change.
|
||||
c.AddRoots(&mcp.Root{URI: "file://b"})
|
||||
<-rootsChanged
|
||||
// ...and call the tool. The client's multi round-trip driver fulfils the
|
||||
// embedded roots/list request and retries the call.
|
||||
if _, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "roots"}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// Output: [file://a file://b]
|
||||
}
|
||||
```
|
||||
|
||||
### Roots list changed
|
||||
|
||||
`Client.AddRoots` and `Client.RemoveRoots` notify every connected server that
|
||||
the list changed. Servers observe this through
|
||||
[`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler);
|
||||
as with the server-side list-changed notifications, it reports only that
|
||||
something changed, so read the list back with `ServerSession.ListRoots`.
|
||||
|
||||
```go
|
||||
func Example_rootsListChanged() {
|
||||
ctx := context.Background()
|
||||
|
||||
changed := make(chan struct{}, 2)
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{
|
||||
RootsListChangedHandler: func(context.Context, *mcp.RootsListChangedRequest) {
|
||||
changed <- struct{}{}
|
||||
},
|
||||
})
|
||||
|
||||
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil)
|
||||
c.AddRoots(&mcp.Root{URI: "file:///project"})
|
||||
|
||||
t1, t2 := mcp.NewInMemoryTransports()
|
||||
ss, err := s.Connect(ctx, t1, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer ss.Close()
|
||||
|
||||
// ListRoots is a server-initiated request, so this session negotiates a
|
||||
// protocol version that still allows one.
|
||||
cs, err := c.Connect(ctx, t2, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
// Roots added after the client connects notify every connected server.
|
||||
c.AddRoots(&mcp.Root{URI: "file:///scratch"})
|
||||
<-changed
|
||||
|
||||
// The notification says only that the list changed, so read it back.
|
||||
res, err := ss.ListRoots(ctx, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, root := range res.Roots {
|
||||
fmt.Println(root.URI)
|
||||
}
|
||||
|
||||
c.RemoveRoots("file:///scratch")
|
||||
<-changed
|
||||
fmt.Println("roots changed again")
|
||||
|
||||
// Output:
|
||||
// file:///project
|
||||
// file:///scratch
|
||||
// roots changed again
|
||||
}
|
||||
```
|
||||
|
||||
## Sampling
|
||||
|
||||
> **Note:** The sampling feature is deprecated as of protocol version
|
||||
|
|
@ -110,7 +177,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](protocol.md#multi-round-trip-requests-mrtr)
|
||||
[Multi Round-Trip Requests](#multi-round-trip-requests)
|
||||
pattern.
|
||||
|
||||
```go
|
||||
|
|
@ -130,22 +197,35 @@ func Example_sampling() {
|
|||
|
||||
// Connect the server and client...
|
||||
ct, st := mcp.NewInMemoryTransports()
|
||||
// Create a server with a tool that requests sampling via the multi
|
||||
// round-trip pattern (SEP-2322): server-to-client requests are no longer
|
||||
// sent as standalone JSON-RPC calls on protocol version >= 2026-07-28.
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil)
|
||||
mcp.AddTool(s, &mcp.Tool{Name: "sample"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
|
||||
if len(req.Params.InputResponses) == 0 {
|
||||
return &mcp.CallToolResult{
|
||||
InputRequests: mcp.InputRequestMap{"msg": &mcp.CreateMessageParams{}},
|
||||
}, nil, nil
|
||||
}
|
||||
msg := req.Params.InputResponses["msg"].(*mcp.CreateMessageWithToolsResult)
|
||||
return &mcp.CallToolResult{Content: msg.Content}, nil, nil
|
||||
})
|
||||
session, err := s.Connect(ctx, st, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
if _, err := c.Connect(ctx, ct, nil); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
msg, err := session.CreateMessage(ctx, &mcp.CreateMessageParams{})
|
||||
clientSession, err := c.Connect(ctx, ct, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(msg.Content.(*mcp.TextContent).Text)
|
||||
|
||||
res, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "sample"})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(res.Content[0].(*mcp.TextContent).Text)
|
||||
// Output: would have created a message
|
||||
}
|
||||
```
|
||||
|
|
@ -167,7 +247,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](protocol.md#multi-round-trip-requests-mrtr)
|
||||
[Multi Round-Trip Requests](#multi-round-trip-requests)
|
||||
pattern.
|
||||
|
||||
```go
|
||||
|
|
@ -175,7 +255,28 @@ func Example_elicitation() {
|
|||
ctx := context.Background()
|
||||
ct, st := mcp.NewInMemoryTransports()
|
||||
|
||||
// Create a server with a tool that requests elicitation via the multi
|
||||
// round-trip pattern (SEP-2322): server-to-client requests are no longer
|
||||
// sent as standalone JSON-RPC calls on protocol version >= 2026-07-28.
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil)
|
||||
mcp.AddTool(s, &mcp.Tool{Name: "ask"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
|
||||
if len(req.Params.InputResponses) == 0 {
|
||||
return &mcp.CallToolResult{
|
||||
InputRequests: mcp.InputRequestMap{"input": &mcp.ElicitParams{
|
||||
Message: "This should fail",
|
||||
RequestedSchema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"test": {Type: "string"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}, nil, nil
|
||||
}
|
||||
res := req.Params.InputResponses["input"].(*mcp.ElicitResult)
|
||||
fmt.Println(res.Content["test"])
|
||||
return &mcp.CallToolResult{}, nil, nil
|
||||
})
|
||||
ss, err := s.Connect(ctx, st, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
|
@ -187,26 +288,165 @@ func Example_elicitation() {
|
|||
return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil
|
||||
},
|
||||
})
|
||||
if _, err := c.Connect(ctx, ct, nil); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
res, err := ss.Elicit(ctx, &mcp.ElicitParams{
|
||||
Message: "This should fail",
|
||||
RequestedSchema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"test": {Type: "string"},
|
||||
},
|
||||
},
|
||||
})
|
||||
clientSession, err := c.Connect(ctx, ct, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(res.Content["test"])
|
||||
if _, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "ask"}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// Output: value
|
||||
}
|
||||
```
|
||||
|
||||
### Schema defaults and enums
|
||||
|
||||
`ElicitParams.RequestedSchema` is a flat schema of primitive fields, which the
|
||||
client renders as a form. Two field keywords shape that form.
|
||||
|
||||
A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034--support-default-values-for-all-primitive-types-in)) prefills a
|
||||
field. When the user accepts without supplying it, the SDK fills the field in
|
||||
from the schema before the result reaches either side's caller — the client
|
||||
does so after its elicitation handler returns, and `ServerSession.Elicit` does
|
||||
so again on receipt. This is unconditional; there is no opt-in flag. Marking a
|
||||
defaulted field `Required` defeats it: accepted content is validated against
|
||||
the schema before defaults are applied, so an answer that omits the field is
|
||||
rejected rather than defaulted.
|
||||
|
||||
An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330-elicitation-enum-schema-improvements-and-standards)) restricts a
|
||||
field to a fixed set of values, which the client renders as a choice. Enums
|
||||
are supported only on `"string"` fields; declaring one on another type is
|
||||
rejected. To label the choices, set the legacy `enumNames` keyword through
|
||||
`Schema.Extra`, with exactly one name per enum value — a mismatched length is
|
||||
rejected.
|
||||
|
||||
```go
|
||||
func Example_elicitationSchema() {
|
||||
ctx := context.Background()
|
||||
ct, st := mcp.NewInMemoryTransports()
|
||||
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil)
|
||||
mcp.AddTool(s, &mcp.Tool{Name: "export_report"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
|
||||
if len(req.Params.InputResponses) == 0 {
|
||||
return &mcp.CallToolResult{
|
||||
InputRequests: mcp.InputRequestMap{"format": &mcp.ElicitParams{
|
||||
Message: "Export quarterly-sales as which format?",
|
||||
RequestedSchema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"format": {
|
||||
Type: "string",
|
||||
Title: "Format",
|
||||
Enum: []any{"pdf", "csv"},
|
||||
Default: json.RawMessage(`"pdf"`),
|
||||
Extra: map[string]any{"enumNames": []any{"PDF document", "CSV spreadsheet"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}, nil, nil
|
||||
}
|
||||
res := req.Params.InputResponses["format"].(*mcp.ElicitResult)
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: "Exported as " + res.Content["format"].(string)}},
|
||||
}, nil, nil
|
||||
})
|
||||
if _, err := s.Connect(ctx, st, nil); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// The user accepts without filling anything in.
|
||||
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{
|
||||
ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
|
||||
return &mcp.ElicitResult{Action: "accept", Content: map[string]any{}}, nil
|
||||
},
|
||||
})
|
||||
cs, err := c.Connect(ctx, ct, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "export_report"})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(res.Content[0].(*mcp.TextContent).Text)
|
||||
// Output: Exported as pdf
|
||||
}
|
||||
```
|
||||
|
||||
### Completing a URL elicitation
|
||||
|
||||
In URL mode the user finishes out of band, in a browser, so nothing in the
|
||||
elicitation result tells the client when they are done. The server signals that
|
||||
with
|
||||
[`ServerSession.NotifyElicitationComplete`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.NotifyElicitationComplete),
|
||||
passing the same `ElicitationID` the request carried; the client observes it
|
||||
through
|
||||
[`ClientOptions.ElicitationCompleteHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ElicitationCompleteHandler).
|
||||
Send it from whatever endpoint the hosted flow redirects back to.
|
||||
|
||||
The notification matters most when a handler rejects a request with
|
||||
[`URLElicitationRequiredError`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#URLElicitationRequiredError):
|
||||
the client parks the original request until a notification names that
|
||||
`ElicitationID`, and then retries it automatically. Until one arrives, the
|
||||
client waits.
|
||||
|
||||
```go
|
||||
func Example_elicitationComplete() {
|
||||
ctx := context.Background()
|
||||
ct, st := mcp.NewInMemoryTransports()
|
||||
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil)
|
||||
ss, err := s.Connect(ctx, st, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer ss.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{
|
||||
Capabilities: &mcp.ClientCapabilities{
|
||||
Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}},
|
||||
},
|
||||
ElicitationHandler: func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
|
||||
fmt.Println("opening", req.Params.URL)
|
||||
return &mcp.ElicitResult{Action: "accept"}, nil
|
||||
},
|
||||
ElicitationCompleteHandler: func(_ context.Context, req *mcp.ElicitationCompleteNotificationRequest) {
|
||||
fmt.Println("flow finished:", req.Params.ElicitationID)
|
||||
close(done)
|
||||
},
|
||||
})
|
||||
cs, err := c.Connect(ctx, ct, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
const elicitationID = "connect-calendar-1"
|
||||
if _, err := ss.Elicit(ctx, &mcp.ElicitParams{
|
||||
Message: "Grant calendar access",
|
||||
URL: "https://calendar.example.com/consent?state=" + elicitationID,
|
||||
ElicitationID: elicitationID,
|
||||
}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// The hosted page redirects back to the server, whose callback endpoint
|
||||
// signals that the user is done.
|
||||
if err := ss.NotifyElicitationComplete(ctx, &mcp.ElicitationCompleteParams{ElicitationID: elicitationID}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
<-done
|
||||
|
||||
// Output:
|
||||
// opening https://calendar.example.com/consent?state=connect-calendar-1
|
||||
// flow finished: connect-calendar-1
|
||||
}
|
||||
```
|
||||
|
||||
## Multi Round-Trip Requests
|
||||
|
||||
[SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)
|
||||
|
|
@ -229,14 +469,18 @@ 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 `InputRequiredResult` values directly to the
|
||||
caller and your code must fulfil the requests manually.
|
||||
the client will then surface input-required results directly to the caller
|
||||
(the returned `CallToolResult`, `GetPromptResult`, or `ReadResourceResult`
|
||||
will report `NeedsInput() == true` and expose the server's `InputRequests`
|
||||
and opaque `RequestState`). Your code must fulfil each request and re-issue
|
||||
the original call with `InputResponses` set and `RequestState` echoed
|
||||
back.
|
||||
|
||||
For legacy (`<= 2025-11-25`) servers, the SDK transparently sends server
|
||||
requests on the legacy server-initiated channel; the MRTR machinery is a
|
||||
no-op in that direction. For legacy clients talking to MRTR-style servers,
|
||||
the server SDK applies the inverse compatibility shim — see the
|
||||
[server-side documentation](server.md#multi-round-trip-requests-mrtr).
|
||||
[server-side documentation](server.md#multi-round-trip-requests).
|
||||
|
||||
## Capabilities
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,59 @@
|
|||
|
||||
## `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.
|
||||
|
|
@ -46,9 +99,25 @@ 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,
|
||||
not attempt to decode the JSON-RPC error body of a non-2xx HTTP response,
|
||||
and any non-transient error will permanently fail the connection, restoring
|
||||
the previous behavior. The default behavior was changed so that the client
|
||||
attempts to decode the JSON-RPC error body of a non-2xx response, surfaces
|
||||
the underlying JSON-RPC error, and wraps it with `jsonrpc2.ErrRejected` so
|
||||
that per-call rejections do not tear down the session.
|
||||
|
||||
- `nowrapinvalidparams` added. If set to `1`, the server will not wrap
|
||||
params-decoding failures with `jsonrpc2.ErrInvalidParams`, so wire responses
|
||||
carry the zero-value error code `0` instead of `-32602` ("invalid params"),
|
||||
restoring the previous behavior. The default behavior was changed so that
|
||||
the client always attempts to surface the underlying JSON-RPC error.
|
||||
the server always reports params-decoding failures with the standard
|
||||
`-32602` code.
|
||||
|
||||
- `disablecompleteparamsvalidation` added. If set to `1`, `Server.complete`
|
||||
will not validate that the required `ref` and `argument.name` fields on
|
||||
`CompleteParams` are present, restoring the previous behavior of dispatching
|
||||
the request to the completion handler unconditionally. The default behavior
|
||||
was changed to reject malformed requests with `-32602` (Invalid Params).
|
||||
|
||||
### 1.6.1
|
||||
|
||||
|
|
|
|||
164
docs/protocol.md
164
docs/protocol.md
|
|
@ -2,11 +2,14 @@
|
|||
# Support for the MCP base protocol
|
||||
|
||||
1. [Lifecycle](#lifecycle)
|
||||
1. [Discovery (`server/discover`)](#discovery-(server/discover))
|
||||
1. [Per-request `_meta` keys](#per-request-meta-keys)
|
||||
1. [Discovery](#discovery)
|
||||
1. [Per-request metadata keys](#per-request-metadata-keys)
|
||||
1. [Per-response metadata keys](#per-response-metadata-keys)
|
||||
1. [Subscriptions](#subscriptions)
|
||||
1. [Transports](#transports)
|
||||
1. [Stdio Transport](#stdio-transport)
|
||||
1. [Streamable Transport](#streamable-transport)
|
||||
1. [Legacy SSE Transport](#legacy-sse-transport)
|
||||
1. [Custom transports](#custom-transports)
|
||||
1. [Concurrency](#concurrency)
|
||||
1. [Authorization](#authorization)
|
||||
|
|
@ -39,8 +42,10 @@ 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, client identity, and client
|
||||
capabilities in `_meta`.
|
||||
each request carries its protocol version and client capabilities in
|
||||
`_meta`. Clients SHOULD also include their identity (`clientInfo`) on every
|
||||
request, and servers SHOULD include their identity (`serverInfo`) on every
|
||||
response.
|
||||
|
||||
In both models, the SDK exposes the same API:
|
||||
|
||||
|
|
@ -106,7 +111,7 @@ func Example_lifecycle() {
|
|||
}
|
||||
```
|
||||
|
||||
### Discovery (`server/discover`)
|
||||
### Discovery
|
||||
|
||||
Introduced in `2026-07-28` by
|
||||
[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575),
|
||||
|
|
@ -123,23 +128,64 @@ 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.
|
||||
|
||||
### Per-request `_meta` keys
|
||||
A server advertises and negotiates every protocol version the SDK supports;
|
||||
set [`ServerOptions.SupportedProtocolVersions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions)
|
||||
to narrow that set, for example to stop serving a revision your deployment has
|
||||
retired.
|
||||
|
||||
```go
|
||||
server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v1.0.0"}, &mcp.ServerOptions{
|
||||
SupportedProtocolVersions: []string{"2026-07-28", "2025-11-25"},
|
||||
})
|
||||
```
|
||||
|
||||
The set can only narrow support, never widen it; the field's documentation
|
||||
covers how each protocol era answers a request at an excluded version.
|
||||
|
||||
### Per-request metadata keys
|
||||
|
||||
When the negotiated protocol version is `2026-07-28` or later, every request
|
||||
carries these keys inside its `_meta` map (constants live in
|
||||
[`mcp/protocol.go`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#pkg-constants)):
|
||||
|
||||
| Constant | Wire key | Type |
|
||||
|---|---|---|
|
||||
| `MetaKeyProtocolVersion` | `io.modelcontextprotocol/protocolVersion` | `string` |
|
||||
| `MetaKeyClientInfo` | `io.modelcontextprotocol/clientInfo` | `*Implementation` |
|
||||
| `MetaKeyClientCapabilities` | `io.modelcontextprotocol/clientCapabilities` | `*ClientCapabilities` |
|
||||
| `MetaKeyLogLevel` | `io.modelcontextprotocol/logLevel` | `LoggingLevel` (deprecated by SEP-2577) |
|
||||
| Constant | Wire key | Type | Required |
|
||||
|---|---|---|---|
|
||||
| `MetaKeyProtocolVersion` | `io.modelcontextprotocol/protocolVersion` | `string` | Yes |
|
||||
| `MetaKeyClientCapabilities` | `io.modelcontextprotocol/clientCapabilities` | `*ClientCapabilities` | Yes |
|
||||
| `MetaKeyClientInfo` | `io.modelcontextprotocol/clientInfo` | `*Implementation` | No |
|
||||
| `MetaKeyLogLevel` | `io.modelcontextprotocol/logLevel` | `LoggingLevel` (deprecated by SEP-2577) | No |
|
||||
|
||||
The client populates these keys automatically on every outgoing request.
|
||||
Server-side handlers can read them
|
||||
through `ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`,
|
||||
and `ServerRequest[P].ClientCapabilities()`.
|
||||
The client populates the required keys automatically on every outgoing
|
||||
request, and populates `clientInfo` when configured with an `*Implementation`
|
||||
(the default). Server-side handlers can read them through
|
||||
`ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, and
|
||||
`ServerRequest[P].ClientCapabilities()`.
|
||||
|
||||
### Per-response metadata keys
|
||||
|
||||
Under the same protocol version, servers SHOULD identify themselves on every
|
||||
response. The SDK populates this
|
||||
key automatically on every outgoing response:
|
||||
|
||||
| Constant | Wire key | Type | Required |
|
||||
|---|---|---|---|
|
||||
| `MetaKeyServerInfo` | `io.modelcontextprotocol/serverInfo` | `*Implementation` | No |
|
||||
|
||||
### Subscriptions
|
||||
|
||||
Introduced in `2026-07-28` by
|
||||
[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575),
|
||||
`subscriptions/listen` replaces the legacy `resources/subscribe` RPC and
|
||||
the GET-based SSE endpoint with a single long-lived request that
|
||||
multiplexes every kind of server-to-client change notification a client
|
||||
opts in to. On the wire the client sends one `subscriptions/listen`
|
||||
request whose `notifications` field enumerates what it wants
|
||||
(`toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, and/or
|
||||
a list of resource URIs in `resourceSubscriptions`); the server replies
|
||||
first with a `notifications/subscriptions/acknowledged` notification
|
||||
reporting the honored subset, then streams every change notification on
|
||||
the same request, and finally closes with a `SubscriptionsListenResult`
|
||||
when it tears the subscription down.
|
||||
|
||||
## Transports
|
||||
|
||||
|
|
@ -225,7 +271,8 @@ connect to the server:
|
|||
transport := &mcp.StreamableClientTransport{
|
||||
Endpoint: "http://localhost:8080/mcp",
|
||||
}
|
||||
client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...})
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1.0.0"}, nil)
|
||||
session, err := client.Connect(ctx, transport, nil)
|
||||
```
|
||||
|
||||
The `StreamableClientTransport` handles the HTTP requests and communicates with
|
||||
|
|
@ -310,6 +357,63 @@ _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
|
||||
|
|
@ -535,24 +639,24 @@ provided to
|
|||
|
||||
### Server-Side Request Forgery
|
||||
|
||||
The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) are as follows:
|
||||
The OAuth discovery helpers apply these [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) by default:
|
||||
|
||||
- _Enforce HTTPS_. The OAuth helpers provided by the SDK reject the `http://` URLs
|
||||
except loopback addresses (`localhost`, `127.0.0.1`, `::1`).
|
||||
- _Enforce HTTPS_. Reject `http://` URLs (except loopback: `localhost`, `127.0.0.1`, `::1`)
|
||||
and redirects that downgrade `https` to a non-`https` scheme.
|
||||
|
||||
- _Block Private IP Ranges_. The OAuth helpers provided by the SDK allow passing
|
||||
a custom `http.Client`. Developers are advised to customize the client it with
|
||||
appropriate network protections, including IP range blocking. The SDK does not provide
|
||||
this capability out of the box.
|
||||
- _Block Private IP Ranges_. Reject targets that resolve to a non-public address
|
||||
(private, link-local including the `169.254.169.254` metadata endpoint, CGNAT, multicast,
|
||||
unspecified). The check runs on the initial URL and at dial time, guarding against DNS rebinding.
|
||||
|
||||
- _Validate Redirect Targets_. Similarly to previous point, customized `http.Client`
|
||||
can be used to validate network hops. The SDK does not provide this capability out
|
||||
of the box.
|
||||
- _Validate Redirect Targets_. Reject redirects to non-public addresses and cap the redirect count.
|
||||
|
||||
- _Use Egress Proxies_. This is out of scope for the SDK and can be configured separately.
|
||||
- _Use Egress Proxies_. Out of scope for the SDK; configure separately.
|
||||
|
||||
- _DNS Resolution Considerations_. The SDK has DNS rebinding protection on the server side which is enabled by default. For the client side, consider providing
|
||||
a custom `http.Client` that would implement DNS pinning.
|
||||
**Opting out.** Passing a custom `http.Client` can bypass these checks, making SSRF
|
||||
protection your responsibility. Specifically: a custom `Transport.DialContext`/`DialTLSContext`,
|
||||
a non-`*http.Transport`, or a configured `Proxy` disables the dial-time IP check; setting
|
||||
`CheckRedirect` replaces all redirect validation. In these cases, implement your own IP
|
||||
blocking, redirect validation, and DNS pinning.
|
||||
|
||||
### Session Hijacking
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue