Compare commits
82 changed files with 800 additions and 9744 deletions
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
|
|
@ -5,11 +5,3 @@ updates:
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "monthly"
|
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
|
build-mode: autobuild
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
build-mode: ${{ matrix.build-mode }}
|
build-mode: ${{ matrix.build-mode }}
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
with:
|
with:
|
||||||
category: "/language:${{matrix.language}}"
|
category: "/language:${{matrix.language}}"
|
||||||
|
|
|
||||||
28
.github/workflows/conformance.yml
vendored
28
.github/workflows/conformance.yml
vendored
|
|
@ -14,25 +14,25 @@ permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CONFORMANCE_VERSION: "0.2.0-alpha.11"
|
CONFORMANCE_VERSION: "0.2.0-alpha.5"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
server-conformance:
|
server-conformance:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26"
|
go-version: "^1.26"
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
- name: Build everything-server
|
- name: Build everything-server
|
||||||
run: go build -o everything-server ./conformance/everything-server
|
run: go build -o everything-server ./conformance/everything-server
|
||||||
- name: Run conformance tests (2025-11-25, stateful)
|
- name: Run conformance tests (stateful, legacy spec versions)
|
||||||
run: |
|
run: |
|
||||||
./everything-server -http=":3001" -stateless=false &
|
./everything-server -http=":3001" -stateless=false &
|
||||||
SERVER_PID=$!
|
SERVER_PID=$!
|
||||||
|
|
@ -40,9 +40,10 @@ jobs:
|
||||||
timeout 30 bash -c 'until curl -s http://localhost:3001/mcp > /dev/null; do sleep 0.5; done'
|
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 \
|
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \
|
||||||
--url http://localhost:3001/mcp \
|
--url http://localhost:3001/mcp \
|
||||||
--requirements 2025-11-25 \
|
--suite active \
|
||||||
|
--spec-version 2025-11-25 \
|
||||||
--expected-failures ./conformance/baseline.yml
|
--expected-failures ./conformance/baseline.yml
|
||||||
- name: Run conformance tests (2026-07-28, stateless)
|
- name: Run conformance tests (stateless, 2026-07-28)
|
||||||
run: |
|
run: |
|
||||||
./everything-server -http=":3001" -stateless &
|
./everything-server -http=":3001" -stateless &
|
||||||
SERVER_PID=$!
|
SERVER_PID=$!
|
||||||
|
|
@ -50,25 +51,26 @@ jobs:
|
||||||
timeout 30 bash -c 'until curl -s http://localhost:3001/mcp > /dev/null; do sleep 0.5; done'
|
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 \
|
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \
|
||||||
--url http://localhost:3001/mcp \
|
--url http://localhost:3001/mcp \
|
||||||
--requirements 2026-07-28 \
|
--suite active \
|
||||||
|
--spec-version 2026-07-28 \
|
||||||
--expected-failures ./conformance/baseline.yml
|
--expected-failures ./conformance/baseline.yml
|
||||||
|
|
||||||
client-conformance:
|
client-conformance:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26"
|
go-version: "^1.26"
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
- name: Run conformance tests (full suite)
|
- name: Run conformance tests
|
||||||
run: |
|
run: |
|
||||||
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \
|
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \
|
||||||
--command "go run ./conformance/everything-client" \
|
--command "go run ./conformance/everything-client" \
|
||||||
--suite all \
|
--suite core \
|
||||||
--expected-failures ./conformance/baseline.yml
|
--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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26"
|
go-version: "^1.26"
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Check docs are up-to-date
|
- name: Check docs are up-to-date
|
||||||
run: |
|
run: |
|
||||||
go generate ./...
|
go generate ./...
|
||||||
|
|
|
||||||
12
.github/workflows/nightly.yml
vendored
12
.github/workflows/nightly.yml
vendored
|
|
@ -11,29 +11,25 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
vulncheck:
|
vulncheck:
|
||||||
# Don't run scheduled builds on forks.
|
|
||||||
if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Run govulncheck
|
- name: Run govulncheck
|
||||||
uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1.1.0
|
uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
go-package: ./...
|
go-package: ./...
|
||||||
|
|
||||||
conformance:
|
conformance:
|
||||||
# Don't run scheduled builds on forks.
|
|
||||||
if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26"
|
go-version: "^1.26"
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
- name: Run server conformance tests
|
- 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
|
contents: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Configure Git Credentials
|
- name: Configure Git Credentials
|
||||||
run: |
|
run: |
|
||||||
git config user.name github-actions[bot]
|
git config user.name github-actions[bot]
|
||||||
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
||||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: 3.x
|
python-version: 3.x
|
||||||
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
||||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
key: mkdocs-material-${{ env.cache_id }}
|
key: mkdocs-material-${{ env.cache_id }}
|
||||||
path: .cache
|
path: .cache
|
||||||
|
|
|
||||||
6
.github/workflows/scorecard.yml
vendored
6
.github/workflows/scorecard.yml
vendored
|
|
@ -34,12 +34,12 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: "Run analysis"
|
- name: "Run analysis"
|
||||||
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
|
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||||
with:
|
with:
|
||||||
results_file: results.sarif
|
results_file: results.sarif
|
||||||
results_format: sarif
|
results_format: sarif
|
||||||
|
|
@ -73,6 +73,6 @@ jobs:
|
||||||
# Upload the results to GitHub's code scanning dashboard (optional).
|
# Upload the results to GitHub's code scanning dashboard (optional).
|
||||||
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
||||||
- name: "Upload to code-scanning"
|
- name: "Upload to code-scanning"
|
||||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26"
|
go-version: "^1.26"
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
|
|
@ -43,9 +43,9 @@ jobs:
|
||||||
go: ["1.25", "1.26"]
|
go: ["1.25", "1.26"]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version: ${{ matrix.go }}
|
go-version: ${{ matrix.go }}
|
||||||
- name: Test
|
- name: Test
|
||||||
|
|
@ -55,9 +55,9 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version: "1.26"
|
go-version: "1.26"
|
||||||
- name: Test with -race
|
- name: Test with -race
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,6 @@ transparently, while adhering to these goals.
|
||||||
1. [Contributing code](#contributing-code)
|
1. [Contributing code](#contributing-code)
|
||||||
1. [Adding and updating dependencies](#adding-and-updating-dependencies)
|
1. [Adding and updating dependencies](#adding-and-updating-dependencies)
|
||||||
1. [Updating the README](#updating-the-readme)
|
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. [Timeouts](#timeouts)
|
||||||
1. [Code of conduct](#code-of-conduct)
|
1. [Code of conduct](#code-of-conduct)
|
||||||
1. [Governance](#governance)
|
1. [Governance](#governance)
|
||||||
|
|
@ -191,57 +188,6 @@ The CI system will automatically check that the README is up-to-date by running
|
||||||
`go generate ./internal/readme` and verifying no changes result. If you see a CI failure about the
|
`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.
|
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
|
## Timeouts
|
||||||
|
|
||||||
If a contributor hasn't responded to issue questions or PR comments in two weeks,
|
If a contributor hasn't responded to issue questions or PR comments in two weeks,
|
||||||
|
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
|
||||||
// Use of this source code is governed by the license
|
|
||||||
// that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package auth_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/auth"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// savingTokenSource is an oauth2.TokenSource that passes the oauth2 config and
|
|
||||||
// token to the given saver function each time the access token value changes.
|
|
||||||
type savingTokenSource struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
src oauth2.TokenSource
|
|
||||||
saver func(*oauth2.Config, *oauth2.Token) error
|
|
||||||
config *oauth2.Config
|
|
||||||
accessToken string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *savingTokenSource) Token() (*oauth2.Token, error) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
tok, err := s.src.Token()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if s.accessToken != tok.AccessToken {
|
|
||||||
s.accessToken = tok.AccessToken
|
|
||||||
// This saver implementation always returns nil.
|
|
||||||
_ = s.saver(s.config, tok)
|
|
||||||
}
|
|
||||||
return tok, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSavingTokenSource persists OAuth 2.0 sessions by intercepting token
|
|
||||||
// changes from the wrapped oauth2.TokenSource. When this wrapper detects an
|
|
||||||
// access token change, it calls the provided session saver with the
|
|
||||||
// oauth2.Config and the new oauth2.Token.
|
|
||||||
//
|
|
||||||
// initial is an optional access token the caller may already hold (such as a
|
|
||||||
// token loaded from storage). It initialises the wrapper's state so that if
|
|
||||||
// the first wrapped.Token() call returns the same token, does not trigger a
|
|
||||||
// redundant call to saver(). Pass nil when there is no existing token, in
|
|
||||||
// which case the first token produced by wrapped.Token() is saved.
|
|
||||||
func NewSavingTokenSource(wrapped oauth2.TokenSource, config *oauth2.Config, initial *oauth2.Token, saver func(*oauth2.Config, *oauth2.Token) error) oauth2.TokenSource {
|
|
||||||
if wrapped == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if saver == nil {
|
|
||||||
return wrapped
|
|
||||||
}
|
|
||||||
var accessToken string
|
|
||||||
if initial != nil {
|
|
||||||
accessToken = initial.AccessToken
|
|
||||||
}
|
|
||||||
return &savingTokenSource{
|
|
||||||
src: wrapped,
|
|
||||||
saver: saver,
|
|
||||||
config: config,
|
|
||||||
accessToken: accessToken,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionStore is an in-memory OAuth 2.0 session store used to demonstrate
|
|
||||||
// persistence. A production implementation would persist the session to disk or
|
|
||||||
// a secret store.
|
|
||||||
type sessionStore struct {
|
|
||||||
config *oauth2.Config
|
|
||||||
token *oauth2.Token
|
|
||||||
}
|
|
||||||
|
|
||||||
// save persists the given OAuth 2.0 config and token.
|
|
||||||
func (s *sessionStore) save(config *oauth2.Config, token *oauth2.Token) error {
|
|
||||||
fmt.Printf("Saving token: %s\n", token.AccessToken)
|
|
||||||
s.config = config
|
|
||||||
s.token = token
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// restore loads a previously persisted OAuth 2.0 session, if one exists.
|
|
||||||
func (s *sessionStore) restore() (*oauth2.Config, *oauth2.Token, error) {
|
|
||||||
if s.config != nil && s.token != nil {
|
|
||||||
fmt.Println("Restoring session.")
|
|
||||||
} else {
|
|
||||||
fmt.Println("No session found to restore.")
|
|
||||||
}
|
|
||||||
return s.config, s.token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newMockAuthServer returns an httptest.Server that simulates both an MCP
|
|
||||||
// resource server requiring authorization and its OAuth authorization server.
|
|
||||||
func newMockAuthServer() *httptest.Server {
|
|
||||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/.well-known/oauth-authorization-server":
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = fmt.Fprintf(w, `{"issuer": "http://%s", "authorization_endpoint": "http://%s/auth", "token_endpoint": "http://%s/token", "code_challenge_methods_supported": ["S256"]}`, r.Host, r.Host, r.Host)
|
|
||||||
case "/token":
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"access_token": "mock-token", "token_type": "bearer"}`))
|
|
||||||
default:
|
|
||||||
// The mock MCP endpoint returns 401 until the client presents a valid
|
|
||||||
// bearer token.
|
|
||||||
if r.Header.Get("Authorization") == "Bearer mock-token" {
|
|
||||||
// A real server would return a valid MCP message here. The empty
|
|
||||||
// response causes Connect to return an error after authorization,
|
|
||||||
// which is ignored for this example which demonstrates token
|
|
||||||
// persistence only.
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// This example shows how OAuth2 session persistence might be implemented. It
|
|
||||||
// connects twice using a shared in-memory session store: the first connection
|
|
||||||
// authorizes and saves the session, and the second restores and reuses it.
|
|
||||||
func Example_persistence() {
|
|
||||||
// Simulate an MCP server that requires authorization and an OAuth server.
|
|
||||||
mockServer := newMockAuthServer()
|
|
||||||
defer mockServer.Close()
|
|
||||||
|
|
||||||
// store persists the OAuth2 session across both connections.
|
|
||||||
store := &sessionStore{}
|
|
||||||
|
|
||||||
// connect performs a single client connection, restoring any saved session
|
|
||||||
// beforehand and saving any new session acquired during authorization.
|
|
||||||
connect := func() {
|
|
||||||
// Load the OAuth2 session if available, and use it.
|
|
||||||
var initialTS oauth2.TokenSource
|
|
||||||
cfg, tok, err := store.restore()
|
|
||||||
if err == nil && cfg != nil && tok != nil {
|
|
||||||
initialTS = NewSavingTokenSource(
|
|
||||||
cfg.TokenSource(context.Background(), tok),
|
|
||||||
cfg, tok, store.save,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure and initialize the AuthorizationCodeHandler with a
|
|
||||||
// NewTokenSource that saves the session when the access token changes,
|
|
||||||
// and the InitialTokenSource set to the session loaded via restore().
|
|
||||||
config := &auth.AuthorizationCodeHandlerConfig{
|
|
||||||
RedirectURL: "http://localhost/callback",
|
|
||||||
PreregisteredClient: &oauthex.ClientCredentials{
|
|
||||||
ClientID: "example",
|
|
||||||
},
|
|
||||||
Client: mockServer.Client(),
|
|
||||||
AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
|
||||||
fmt.Println("No token source found. Transport is calling Authorize()...")
|
|
||||||
// Extract the generated state from the authorization URL
|
|
||||||
u, _ := url.Parse(args.URL)
|
|
||||||
state := u.Query().Get("state")
|
|
||||||
return &auth.AuthorizationResult{Code: "mock-code", State: state}, nil
|
|
||||||
},
|
|
||||||
NewTokenSource: func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) {
|
|
||||||
// This save implementation always returns nil.
|
|
||||||
_ = store.save(cfg, token)
|
|
||||||
return NewSavingTokenSource(
|
|
||||||
cfg.TokenSource(ctx, token), cfg, token, store.save,
|
|
||||||
), nil
|
|
||||||
},
|
|
||||||
InitialTokenSource: initialTS,
|
|
||||||
}
|
|
||||||
handler, err := auth.NewAuthorizationCodeHandler(config)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error creating handler: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the constructed handler on a transport.
|
|
||||||
transport := &mcp.StreamableClientTransport{
|
|
||||||
Endpoint: mockServer.URL + "/sse",
|
|
||||||
OAuthHandler: handler,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a client and attempt to connect using the configured
|
|
||||||
// transport. The transport will automatically:
|
|
||||||
// 1. Call TokenSource() to check for an existing session. This will
|
|
||||||
// return InitialTokenSource, if set.
|
|
||||||
// 2. Try the MCP endpoint, and encounter a 401 response from the mock
|
|
||||||
// server.
|
|
||||||
// 3. Call Authorize() to perform the OAuth flow, which calls
|
|
||||||
// NewTokenSource. In this example NewTokenSource saves the newly
|
|
||||||
// acquired session.
|
|
||||||
// 4. Retry the MCP endpoint with a valid bearer token and get a 200.
|
|
||||||
client := mcp.NewClient(
|
|
||||||
&mcp.Implementation{Name: "example", Version: "1.0.0"}, nil,
|
|
||||||
)
|
|
||||||
// Response ignored: this example asserts authorization only.
|
|
||||||
_, _ = client.Connect(context.Background(), transport, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The first connection has no saved session, so it authorizes and saves.
|
|
||||||
fmt.Println("--- First connect ---")
|
|
||||||
connect()
|
|
||||||
// The second connection restores the saved session and reuses it, so no
|
|
||||||
// further authorization or save occurs.
|
|
||||||
fmt.Println("--- Second connect ---")
|
|
||||||
connect()
|
|
||||||
|
|
||||||
// Output:
|
|
||||||
// --- First connect ---
|
|
||||||
// No session found to restore.
|
|
||||||
// No token source found. Transport is calling Authorize()...
|
|
||||||
// Saving token: mock-token
|
|
||||||
// --- Second connect ---
|
|
||||||
// Restoring session.
|
|
||||||
}
|
|
||||||
|
|
@ -14,7 +14,6 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
|
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
||||||
|
|
@ -98,15 +97,6 @@ type AuthorizationCodeHandlerConfig struct {
|
||||||
// See [AuthorizationCodeFetcher] for details.
|
// See [AuthorizationCodeFetcher] for details.
|
||||||
AuthorizationCodeFetcher AuthorizationCodeFetcher
|
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
|
// RequestRefreshToken indicates that the client intends to use refresh
|
||||||
// tokens and is capable of storing them securely.
|
// tokens and is capable of storing them securely.
|
||||||
//
|
//
|
||||||
|
|
@ -124,11 +114,6 @@ type AuthorizationCodeHandlerConfig struct {
|
||||||
// See https://modelcontextprotocol.io/seps/2207-oidc-refresh-token-guidance.
|
// See https://modelcontextprotocol.io/seps/2207-oidc-refresh-token-guidance.
|
||||||
RequestRefreshToken bool
|
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.
|
// Client is an optional HTTP client to use for HTTP requests.
|
||||||
// It is used for the following requests:
|
// It is used for the following requests:
|
||||||
// - Fetching Protected Resource Metadata
|
// - Fetching Protected Resource Metadata
|
||||||
|
|
@ -141,24 +126,6 @@ type AuthorizationCodeHandlerConfig struct {
|
||||||
// https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#server-side-request-forgery-ssrf
|
// https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#server-side-request-forgery-ssrf
|
||||||
// If not provided, http.DefaultClient will be used.
|
// If not provided, http.DefaultClient will be used.
|
||||||
Client *http.Client
|
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
|
// AuthorizationCodeHandler is an implementation of [OAuthHandler] that uses
|
||||||
|
|
@ -166,9 +133,6 @@ type AuthorizationCodeHandlerConfig struct {
|
||||||
type AuthorizationCodeHandler struct {
|
type AuthorizationCodeHandler struct {
|
||||||
config *AuthorizationCodeHandlerConfig
|
config *AuthorizationCodeHandlerConfig
|
||||||
|
|
||||||
// mu protects concurrent access to tokenSource and grantedScopes.
|
|
||||||
mu sync.RWMutex
|
|
||||||
|
|
||||||
// tokenSource is the token source to use for authorization.
|
// tokenSource is the token source to use for authorization.
|
||||||
tokenSource oauth2.TokenSource
|
tokenSource oauth2.TokenSource
|
||||||
|
|
||||||
|
|
@ -179,8 +143,6 @@ type AuthorizationCodeHandler struct {
|
||||||
var _ OAuthHandler = (*AuthorizationCodeHandler)(nil)
|
var _ OAuthHandler = (*AuthorizationCodeHandler)(nil)
|
||||||
|
|
||||||
func (h *AuthorizationCodeHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
|
func (h *AuthorizationCodeHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
|
||||||
h.mu.RLock()
|
|
||||||
defer h.mu.RUnlock()
|
|
||||||
return h.tokenSource, nil
|
return h.tokenSource, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -237,7 +199,6 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
|
||||||
}
|
}
|
||||||
return &AuthorizationCodeHandler{
|
return &AuthorizationCodeHandler{
|
||||||
config: config,
|
config: config,
|
||||||
tokenSource: config.InitialTokenSource,
|
|
||||||
grantedScopes: make(map[string][]string),
|
grantedScopes: make(map[string][]string),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -332,12 +293,6 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
|
||||||
requestedScopes = prm.ScopesSupported
|
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
|
// SEP-2207: when the client desires refresh tokens and the Authorization
|
||||||
// Server advertises offline_access support, add it to the requested scopes.
|
// Server advertises offline_access support, add it to the requested scopes.
|
||||||
if h.config.RequestRefreshToken &&
|
if h.config.RequestRefreshToken &&
|
||||||
|
|
@ -349,10 +304,7 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
|
||||||
// Accumulate scopes: union previously granted scopes with the newly
|
// Accumulate scopes: union previously granted scopes with the newly
|
||||||
// challenged scopes so that step-up authorization does not lose
|
// challenged scopes so that step-up authorization does not lose
|
||||||
// permissions granted in earlier rounds (SEP-2350).
|
// permissions granted in earlier rounds (SEP-2350).
|
||||||
h.mu.RLock()
|
requestedScopes = authutil.UnionScopes(h.grantedScopes[asm.Issuer], requestedScopes)
|
||||||
granted := h.grantedScopes[asm.Issuer]
|
|
||||||
h.mu.RUnlock()
|
|
||||||
requestedScopes = authutil.UnionScopes(granted, requestedScopes)
|
|
||||||
|
|
||||||
cfg := &oauth2.Config{
|
cfg := &oauth2.Config{
|
||||||
ClientID: resolvedClientConfig.clientID,
|
ClientID: resolvedClientConfig.clientID,
|
||||||
|
|
@ -372,7 +324,7 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
|
||||||
// Purposefully leaving the error unwrappable so it can be handled by the caller.
|
// Purposefully leaving the error unwrappable so it can be handled by the caller.
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported, h.config.AcceptUnadvertisedIss); err != nil {
|
if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -621,14 +573,10 @@ func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateIssuerResponse validates the "iss" parameter in an authorization response
|
// validateIssuerResponse validates the "iss" parameter in an authorization response
|
||||||
// per [RFC 9207]. When the server advertises authorization_response_iss_parameter_supported,
|
// per [RFC 9207].
|
||||||
// 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
|
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
|
||||||
func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported, acceptUnadvertisedIss bool) error {
|
func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported bool) error {
|
||||||
if issParameterSupported {
|
if issParameterSupported {
|
||||||
if iss == "" {
|
if iss == "" {
|
||||||
return fmt.Errorf("authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response")
|
return fmt.Errorf("authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response")
|
||||||
|
|
@ -636,17 +584,12 @@ func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported, a
|
||||||
if iss != expectedIssuer {
|
if iss != expectedIssuer {
|
||||||
return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer)
|
return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer)
|
||||||
}
|
}
|
||||||
return nil
|
} else {
|
||||||
}
|
if iss != "" {
|
||||||
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 != 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -672,42 +615,23 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context
|
||||||
// completes. Use a background context that still carries the configured HTTP
|
// completes. Use a background context that still carries the configured HTTP
|
||||||
// client so refreshes keep working for the life of the token source.
|
// client so refreshes keep working for the life of the token source.
|
||||||
refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, h.config.Client)
|
refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, h.config.Client)
|
||||||
var ts oauth2.TokenSource
|
h.tokenSource = cfg.TokenSource(refreshCtx, token)
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateGrantedScopes updates the granted scopes based on the token source and requested scopes.
|
// updateGrantedScopes updates the granted scopes based on the token source and requested scopes.
|
||||||
func (h *AuthorizationCodeHandler) updateGrantedScopes(issuer string, requestedScopes []string) error {
|
func (h *AuthorizationCodeHandler) updateGrantedScopes(issuer string, requestedScopes []string) error {
|
||||||
h.mu.RLock()
|
if h.tokenSource == nil {
|
||||||
ts := h.tokenSource
|
|
||||||
h.mu.RUnlock()
|
|
||||||
|
|
||||||
if ts == nil {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
tok, err := ts.Token()
|
tok, err := h.tokenSource.Token()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
h.mu.Lock()
|
|
||||||
if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil {
|
if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil {
|
||||||
h.grantedScopes[issuer] = requestedScopes
|
h.grantedScopes[issuer] = requestedScopes
|
||||||
} else {
|
} else {
|
||||||
h.grantedScopes[issuer] = tokenScopes
|
h.grantedScopes[issuer] = tokenScopes
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -919,12 +919,11 @@ func TestValidateIssuerResponse(t *testing.T) {
|
||||||
const expectedIssuer = "https://auth.example.com"
|
const expectedIssuer = "https://auth.example.com"
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
iss string
|
iss string
|
||||||
issSupported bool
|
issSupported bool
|
||||||
acceptUnadvertisedIss bool
|
wantErr bool
|
||||||
wantErr bool
|
wantErrContains string
|
||||||
wantErrContains string
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "ValidIss",
|
name: "ValidIss",
|
||||||
|
|
@ -950,41 +949,11 @@ func TestValidateIssuerResponse(t *testing.T) {
|
||||||
iss: "",
|
iss: "",
|
||||||
issSupported: false,
|
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported, tt.acceptUnadvertisedIss)
|
err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported)
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("validateIssuerResponse() = nil, want error containing %q", tt.wantErrContains)
|
t.Fatalf("validateIssuerResponse() = nil, want error containing %q", tt.wantErrContains)
|
||||||
|
|
@ -999,112 +968,6 @@ func TestValidateIssuerResponse(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAuthorize_AcceptUnadvertisedIssPlumbing checks that Authorize passes
|
|
||||||
// AuthorizationCodeHandlerConfig.AcceptUnadvertisedIss through to
|
|
||||||
// validateIssuerResponse. The fake server returns iss without advertising
|
|
||||||
// authorization_response_iss_parameter_supported.
|
|
||||||
func TestAuthorize_AcceptUnadvertisedIssPlumbing(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
acceptUnadvertisedIss bool
|
|
||||||
wantErrContains string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "ZeroValueRejects",
|
|
||||||
wantErrContains: "does not advertise",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "OptInAccepts",
|
|
||||||
acceptUnadvertisedIss: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
|
||||||
UnadvertiseIssParameter: true,
|
|
||||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
|
||||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
|
||||||
"test_client_id": {
|
|
||||||
Secret: "test_client_secret",
|
|
||||||
RedirectURIs: []string{"http://localhost:12345/callback"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
authServer.Start(t)
|
|
||||||
|
|
||||||
resourceMux := http.NewServeMux()
|
|
||||||
resourceServer := httptest.NewServer(resourceMux)
|
|
||||||
t.Cleanup(resourceServer.Close)
|
|
||||||
resourceURL := resourceServer.URL + "/resource"
|
|
||||||
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
|
||||||
Resource: resourceURL,
|
|
||||||
AuthorizationServers: []string{authServer.URL()},
|
|
||||||
}))
|
|
||||||
|
|
||||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
|
||||||
RedirectURL: "http://localhost:12345/callback",
|
|
||||||
AcceptUnadvertisedIss: tt.acceptUnadvertisedIss,
|
|
||||||
PreregisteredClient: &oauthex.ClientCredentials{
|
|
||||||
ClientID: "test_client_id",
|
|
||||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
|
||||||
ClientSecret: "test_client_secret",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
|
||||||
client := &http.Client{
|
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
||||||
return http.ErrUseLastResponse
|
|
||||||
},
|
|
||||||
}
|
|
||||||
resp, err := client.Get(args.URL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to visit auth URL: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
location, err := resp.Location()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get location header: %v", err)
|
|
||||||
}
|
|
||||||
return &AuthorizationResult{
|
|
||||||
Code: location.Query().Get("code"),
|
|
||||||
State: location.Query().Get("state"),
|
|
||||||
Iss: location.Query().Get("iss"),
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusUnauthorized,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: http.NoBody,
|
|
||||||
Request: req,
|
|
||||||
}
|
|
||||||
resp.Header.Set(
|
|
||||||
"WWW-Authenticate",
|
|
||||||
"Bearer resource_metadata="+resourceServer.URL+"/.well-known/oauth-protected-resource/resource",
|
|
||||||
)
|
|
||||||
err = handler.Authorize(context.Background(), req, resp)
|
|
||||||
if tt.wantErrContains != "" {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("Authorize() = nil, want error containing %q", tt.wantErrContains)
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), tt.wantErrContains) {
|
|
||||||
t.Fatalf("Authorize() error = %q, want it to contain %q", err.Error(), tt.wantErrContains)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Authorize() unexpected error = %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInferApplicationType(t *testing.T) {
|
func TestInferApplicationType(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -1359,107 +1222,6 @@ func TestAuthorize_OfflineAccessScope(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthorize_ScopeFilter(t *testing.T) {
|
|
||||||
// advertised is delivered via the WWW-Authenticate "scope" challenge, which
|
|
||||||
// the handler treats as the discovered scopes passed to ScopeFilter.
|
|
||||||
const advertised = "gmail.metadata gmail.readonly gmail.compose"
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
filter func(discovered []string) []string
|
|
||||||
want []string // requested scopes, order-independent
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "nil filter leaves scopes unchanged",
|
|
||||||
filter: nil,
|
|
||||||
want: []string{"gmail.metadata", "gmail.readonly", "gmail.compose"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "filter drops a scope",
|
|
||||||
filter: func(d []string) []string {
|
|
||||||
return slices.DeleteFunc(slices.Clone(d), func(s string) bool { return s == "gmail.metadata" })
|
|
||||||
},
|
|
||||||
want: []string{"gmail.readonly", "gmail.compose"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "filter replaces the set entirely",
|
|
||||||
filter: func([]string) []string { return []string{"gmail.readonly"} },
|
|
||||||
want: []string{"gmail.readonly"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
|
||||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
|
||||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
|
||||||
"test_client_id": {
|
|
||||||
Secret: "test_client_secret",
|
|
||||||
RedirectURIs: []string{"http://localhost:12345/callback"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
authServer.Start(t)
|
|
||||||
|
|
||||||
resourceMux := http.NewServeMux()
|
|
||||||
resourceServer := httptest.NewServer(resourceMux)
|
|
||||||
t.Cleanup(resourceServer.Close)
|
|
||||||
resourceURL := resourceServer.URL + "/resource"
|
|
||||||
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
|
||||||
Resource: resourceURL,
|
|
||||||
AuthorizationServers: []string{authServer.URL()},
|
|
||||||
}))
|
|
||||||
|
|
||||||
var capturedAuthURL string
|
|
||||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
|
||||||
RedirectURL: "http://localhost:12345/callback",
|
|
||||||
PreregisteredClient: &oauthex.ClientCredentials{
|
|
||||||
ClientID: "test_client_id",
|
|
||||||
ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test_client_secret"},
|
|
||||||
},
|
|
||||||
ScopeFilter: tt.filter,
|
|
||||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
|
||||||
capturedAuthURL = args.URL
|
|
||||||
return nil, fmt.Errorf("stop after capturing URL")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusUnauthorized,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: http.NoBody,
|
|
||||||
Request: req,
|
|
||||||
}
|
|
||||||
resp.Header.Set("WWW-Authenticate", fmt.Sprintf(
|
|
||||||
"Bearer resource_metadata=%s/.well-known/oauth-protected-resource/resource, scope=%q",
|
|
||||||
resourceServer.URL, advertised))
|
|
||||||
|
|
||||||
handler.Authorize(context.Background(), req, resp)
|
|
||||||
|
|
||||||
if capturedAuthURL == "" {
|
|
||||||
t.Fatal("AuthorizationCodeFetcher was not called")
|
|
||||||
}
|
|
||||||
u, err := url.Parse(capturedAuthURL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to parse captured auth URL: %v", err)
|
|
||||||
}
|
|
||||||
// Compare as a set: UnionScopes (applied downstream) returns map keys,
|
|
||||||
// so the order of the requested scope parameter is not deterministic.
|
|
||||||
got := strings.Fields(u.Query().Get("scope"))
|
|
||||||
want := slices.Clone(tt.want)
|
|
||||||
slices.Sort(got)
|
|
||||||
slices.Sort(want)
|
|
||||||
if !slices.Equal(got, want) {
|
|
||||||
t.Errorf("requested scopes = %v, want %v (any order)", got, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// validConfig for test to create an AuthorizationCodeHandler using its constructor.
|
// validConfig for test to create an AuthorizationCodeHandler using its constructor.
|
||||||
// Values that are relevant to the test should be set explicitly.
|
// Values that are relevant to the test should be set explicitly.
|
||||||
func validConfig() *AuthorizationCodeHandlerConfig {
|
func validConfig() *AuthorizationCodeHandlerConfig {
|
||||||
|
|
@ -1471,92 +1233,3 @@ func validConfig() *AuthorizationCodeHandlerConfig {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewTokenSource(t *testing.T) {
|
|
||||||
// mock the /token endpoint to successfully return an access token on code exchange
|
|
||||||
mockTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/token" {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`{"access_token": "test_token", "token_type": "bearer"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}))
|
|
||||||
defer mockTS.Close()
|
|
||||||
|
|
||||||
// configure the handler and set NewTokenSource
|
|
||||||
var called bool
|
|
||||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
|
||||||
RedirectURL: "http://localhost/callback",
|
|
||||||
PreregisteredClient: &oauthex.ClientCredentials{
|
|
||||||
ClientID: "test_client",
|
|
||||||
},
|
|
||||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
|
||||||
u, _ := url.Parse(args.URL)
|
|
||||||
return &AuthorizationResult{
|
|
||||||
Code: "test_code",
|
|
||||||
State: u.Query().Get("state"),
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
NewTokenSource: func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) {
|
|
||||||
called = true
|
|
||||||
return oauth2.StaticTokenSource(token), nil
|
|
||||||
},
|
|
||||||
Client: mockTS.Client(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate a 401 response from a resource server.
|
|
||||||
// The WWW-Authenticate: Bearer header triggers the authorization logic.
|
|
||||||
req := httptest.NewRequest(http.MethodGet, mockTS.URL, nil)
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusUnauthorized,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: http.NoBody,
|
|
||||||
Request: req,
|
|
||||||
}
|
|
||||||
resp.Header.Set("WWW-Authenticate", "Bearer")
|
|
||||||
|
|
||||||
// Authorize and confirm NewTokenSource was called.
|
|
||||||
err = handler.Authorize(t.Context(), req, resp)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !called {
|
|
||||||
t.Error("expected NewTokenSource to be called")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitialTokenSource(t *testing.T) {
|
|
||||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
|
||||||
RedirectURL: "http://localhost:12345/callback",
|
|
||||||
PreregisteredClient: &oauthex.ClientCredentials{
|
|
||||||
ClientID: "test_client_id",
|
|
||||||
},
|
|
||||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
|
||||||
return nil, nil
|
|
||||||
},
|
|
||||||
InitialTokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "set_token"}),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ts, err := handler.TokenSource(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to get token source: %v", err)
|
|
||||||
}
|
|
||||||
if ts == nil {
|
|
||||||
t.Fatal("expected token source to be non-nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
tok, err := ts.Token()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to get Token: %v", err)
|
|
||||||
}
|
|
||||||
if tok.AccessToken != "set_token" {
|
|
||||||
t.Errorf("expected access token 'set_token', got '%s'", tok.AccessToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,7 @@ func authorizationServerMetadataURLs(issuerURL string) []string {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RFC 8414, section 3.1 requires any terminating "/" to be removed from the
|
if baseURL.Path == "" {
|
||||||
// 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".
|
// "OAuth 2.0 Authorization Server Metadata".
|
||||||
baseURL.Path = "/.well-known/oauth-authorization-server"
|
baseURL.Path = "/.well-known/oauth-authorization-server"
|
||||||
urls = append(urls, baseURL.String())
|
urls = append(urls, baseURL.String())
|
||||||
|
|
@ -59,14 +54,15 @@ func authorizationServerMetadataURLs(issuerURL string) []string {
|
||||||
return urls
|
return urls
|
||||||
}
|
}
|
||||||
|
|
||||||
|
originalPath := baseURL.Path
|
||||||
// "OAuth 2.0 Authorization Server Metadata with path insertion".
|
// "OAuth 2.0 Authorization Server Metadata with path insertion".
|
||||||
baseURL.Path = "/.well-known/oauth-authorization-server/" + issuerPath
|
baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/")
|
||||||
urls = append(urls, baseURL.String())
|
urls = append(urls, baseURL.String())
|
||||||
// "OpenID Connect Discovery 1.0 with path insertion".
|
// "OpenID Connect Discovery 1.0 with path insertion".
|
||||||
baseURL.Path = "/.well-known/openid-configuration/" + issuerPath
|
baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/")
|
||||||
urls = append(urls, baseURL.String())
|
urls = append(urls, baseURL.String())
|
||||||
// "OpenID Connect Discovery 1.0 with path appending".
|
// "OpenID Connect Discovery 1.0 with path appending".
|
||||||
baseURL.Path = "/" + issuerPath + "/.well-known/openid-configuration"
|
baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration"
|
||||||
urls = append(urls, baseURL.String())
|
urls = append(urls, baseURL.String())
|
||||||
|
|
||||||
return urls
|
return urls
|
||||||
|
|
|
||||||
|
|
@ -6,81 +6,11 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
|
"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) {
|
func TestGetAuthServerMetadata(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,2 @@
|
||||||
client:
|
server: [] # All tests pass!
|
||||||
# OAuth mechanisms not implemented by the Go MCP SDK.
|
client: [] # All tests pass!
|
||||||
- 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,44 +45,32 @@ func registerScenario(name string, handler scenarioHandler) {
|
||||||
func init() {
|
func init() {
|
||||||
registerScenario("initialize", runBasicClient)
|
registerScenario("initialize", runBasicClient)
|
||||||
registerScenario("tools_call", runToolsCallClient)
|
registerScenario("tools_call", runToolsCallClient)
|
||||||
registerScenario("request-metadata", runRequestMetadataClient)
|
|
||||||
registerScenario("elicitation-sep1034-client-defaults", runElicitationDefaultsClient)
|
registerScenario("elicitation-sep1034-client-defaults", runElicitationDefaultsClient)
|
||||||
registerScenario("sse-retry", runSSERetryClient)
|
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{
|
authScenarios := []string{
|
||||||
"auth/2025-03-26-oauth-metadata-backcompat",
|
"auth/2025-03-26-oauth-metadata-backcompat",
|
||||||
"auth/2025-03-26-oauth-endpoint-fallback",
|
"auth/2025-03-26-oauth-endpoint-fallback",
|
||||||
"auth/authorization-server-migration",
|
|
||||||
"auth/basic-cimd",
|
"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-default",
|
||||||
"auth/metadata-issuer-mismatch",
|
|
||||||
"auth/metadata-var1",
|
"auth/metadata-var1",
|
||||||
"auth/metadata-var2",
|
"auth/metadata-var2",
|
||||||
"auth/metadata-var3",
|
"auth/metadata-var3",
|
||||||
"auth/offline-access-not-supported",
|
|
||||||
"auth/offline-access-scope",
|
|
||||||
"auth/pre-registration",
|
"auth/pre-registration",
|
||||||
"auth/resource-mismatch",
|
"auth/resource-mismatch",
|
||||||
"auth/scope-from-scopes-supported",
|
|
||||||
"auth/scope-from-www-authenticate",
|
"auth/scope-from-www-authenticate",
|
||||||
|
"auth/scope-from-scopes-supported",
|
||||||
"auth/scope-omitted-when-undefined",
|
"auth/scope-omitted-when-undefined",
|
||||||
"auth/scope-retry-limit",
|
|
||||||
"auth/scope-step-up",
|
"auth/scope-step-up",
|
||||||
|
"auth/scope-retry-limit",
|
||||||
"auth/token-endpoint-auth-basic",
|
"auth/token-endpoint-auth-basic",
|
||||||
"auth/token-endpoint-auth-none",
|
|
||||||
"auth/token-endpoint-auth-post",
|
"auth/token-endpoint-auth-post",
|
||||||
|
"auth/token-endpoint-auth-none",
|
||||||
|
"auth/iss-supported",
|
||||||
|
"auth/iss-not-advertised",
|
||||||
|
"auth/iss-supported-missing",
|
||||||
|
"auth/iss-wrong-issuer",
|
||||||
|
"auth/iss-unexpected",
|
||||||
}
|
}
|
||||||
for _, scenario := range authScenarios {
|
for _, scenario := range authScenarios {
|
||||||
registerScenario(scenario, runAuthClient)
|
registerScenario(scenario, runAuthClient)
|
||||||
|
|
@ -305,238 +293,6 @@ func runAuthClient(ctx context.Context, serverURL string, configCtx map[string]a
|
||||||
return nil
|
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
|
// Main entry point
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,9 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/jsonschema-go/jsonschema"
|
"github.com/google/jsonschema-go/jsonschema"
|
||||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
"github.com/yosida95/uritemplate/v3"
|
"github.com/yosida95/uritemplate/v3"
|
||||||
)
|
)
|
||||||
|
|
@ -156,19 +154,14 @@ func registerTools(server *mcp.Server) {
|
||||||
Description: "Tests elicitation with enum schema improvements per SEP-1330",
|
Description: "Tests elicitation with enum schema improvements per SEP-1330",
|
||||||
}, testElicitationEnumsHandler)
|
}, 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{
|
mcp.AddTool(server, &mcp.Tool{
|
||||||
Name: "json_schema_2020_12_tool",
|
Name: "json_schema_2020_12_tool",
|
||||||
Description: "Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613, SEP-2106)",
|
Description: "Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)",
|
||||||
InputSchema: json.RawMessage(`{
|
InputSchema: json.RawMessage(`{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"$defs": {
|
"$defs": {
|
||||||
"address": {
|
"address": {
|
||||||
"$anchor": "addressDef",
|
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"street": { "type": "string" },
|
"street": { "type": "string" },
|
||||||
|
|
@ -178,20 +171,8 @@ func registerTools(server *mcp.Server) {
|
||||||
},
|
},
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": { "type": "string" },
|
"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
|
"additionalProperties": false
|
||||||
}`),
|
}`),
|
||||||
}, jsonSchema202012Handler)
|
}, jsonSchema202012Handler)
|
||||||
|
|
@ -200,87 +181,6 @@ func registerTools(server *mcp.Server) {
|
||||||
Name: "test_reconnection",
|
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.",
|
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)
|
}, 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
|
// Tool handlers
|
||||||
|
|
@ -577,466 +477,33 @@ func testElicitationEnumsHandler(ctx context.Context, req *mcp.CallToolRequest,
|
||||||
}, nil, nil
|
}, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func jsonSchema202012Handler(ctx context.Context, req *mcp.CallToolRequest, input json.RawMessage) (*mcp.CallToolResult, any, error) {
|
type jsonSchemaAddress struct {
|
||||||
|
Street string `json:"street"`
|
||||||
|
City string `json:"city"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonSchemaInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Address *jsonSchemaAddress `json:"address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonSchema202012Handler(ctx context.Context, req *mcp.CallToolRequest, input jsonSchemaInput) (*mcp.CallToolResult, any, error) {
|
||||||
|
// Echo back the arguments received
|
||||||
|
var addressStr string
|
||||||
|
if input.Address != nil {
|
||||||
|
addressStr = fmt.Sprintf("{street: %q, city: %q}", input.Address.Street, input.Address.City)
|
||||||
|
} else {
|
||||||
|
addressStr = "nil"
|
||||||
|
}
|
||||||
return &mcp.CallToolResult{
|
return &mcp.CallToolResult{
|
||||||
Content: []mcp.Content{
|
Content: []mcp.Content{
|
||||||
&mcp.TextContent{
|
&mcp.TextContent{
|
||||||
Text: fmt.Sprintf("JSON Schema 2020-12 tool called with: %s", input),
|
Text: fmt.Sprintf("Received: name=%q, address=%s", input.Name, addressStr),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil, nil
|
}, 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) {
|
func testReconnectionHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
|
||||||
// Close the SSE stream to trigger client reconnection (SEP-1699)
|
// Close the SSE stream to trigger client reconnection (SEP-1699)
|
||||||
if req.Extra != nil && req.Extra.CloseSSEStream != nil {
|
if req.Extra != nil && req.Extra.CloseSSEStream != nil {
|
||||||
|
|
@ -1184,11 +651,6 @@ func registerPrompts(server *mcp.Server) {
|
||||||
Title: "Prompt With Image",
|
Title: "Prompt With Image",
|
||||||
Description: "A prompt that includes image content",
|
Description: "A prompt that includes image content",
|
||||||
}, promptWithImageHandler)
|
}, 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) {
|
func simplePromptHandler(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
|
||||||
|
|
|
||||||
318
docs/client.md
318
docs/client.md
|
|
@ -2,11 +2,8 @@
|
||||||
# Support for MCP client features
|
# Support for MCP client features
|
||||||
|
|
||||||
1. [Roots](#roots)
|
1. [Roots](#roots)
|
||||||
1. [Roots list changed](#roots-list-changed)
|
|
||||||
1. [Sampling](#sampling)
|
1. [Sampling](#sampling)
|
||||||
1. [Elicitation](#elicitation)
|
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. [Multi Round-Trip Requests](#multi-round-trip-requests)
|
||||||
1. [Capabilities](#capabilities)
|
1. [Capabilities](#capabilities)
|
||||||
1. [Capability inference](#capability-inference)
|
1. [Capability inference](#capability-inference)
|
||||||
|
|
@ -41,34 +38,33 @@ method. To receive notifications about root changes, set
|
||||||
[`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler).
|
[`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
|
For protocol versions `2026-07-28` and later, `ListRoots` requests are
|
||||||
delivered via the
|
delivered via the
|
||||||
[Multi Round-Trip Requests](#multi-round-trip-requests)
|
[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr)
|
||||||
pattern.
|
pattern.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func Example_roots() {
|
func Example_roots() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create a client with two roots.
|
// Create a client with a single root.
|
||||||
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil)
|
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil)
|
||||||
c.AddRoots(&mcp.Root{URI: "file://a"}, &mcp.Root{URI: "file://b"})
|
c.AddRoots(&mcp.Root{URI: "file://a"})
|
||||||
|
|
||||||
// Create a server with a tool that requests roots via the multi round-trip
|
// Now create a server with a handler to receive notifications about roots.
|
||||||
// pattern (SEP-2322): server-to-client requests are no longer sent as
|
rootsChanged := make(chan struct{})
|
||||||
// standalone JSON-RPC calls on protocol version >= 2026-07-28.
|
handleRootsChanged := func(ctx context.Context, req *mcp.RootsListChangedRequest) {
|
||||||
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil)
|
rootList, err := req.Session.ListRoots(ctx, nil)
|
||||||
mcp.AddTool(s, &mcp.Tool{Name: "roots"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
|
if err != nil {
|
||||||
if len(req.Params.InputResponses) == 0 {
|
log.Fatal(err)
|
||||||
return &mcp.CallToolResult{
|
|
||||||
InputRequests: mcp.InputRequestMap{"roots": &mcp.ListRootsParams{}},
|
|
||||||
}, nil, nil
|
|
||||||
}
|
}
|
||||||
rootList := req.Params.InputResponses["roots"].(*mcp.ListRootsResult)
|
|
||||||
var roots []string
|
var roots []string
|
||||||
for _, root := range rootList.Roots {
|
for _, root := range rootList.Roots {
|
||||||
roots = append(roots, root.URI)
|
roots = append(roots, root.URI)
|
||||||
}
|
}
|
||||||
fmt.Println(roots)
|
fmt.Println(roots)
|
||||||
return &mcp.CallToolResult{}, nil, nil
|
close(rootsChanged)
|
||||||
|
}
|
||||||
|
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{
|
||||||
|
RootsListChangedHandler: handleRootsChanged,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Connect the server and client...
|
// Connect the server and client...
|
||||||
|
|
@ -85,76 +81,13 @@ func Example_roots() {
|
||||||
}
|
}
|
||||||
defer clientSession.Close()
|
defer clientSession.Close()
|
||||||
|
|
||||||
// ...and call the tool. The client's multi round-trip driver fulfils the
|
// ...and add a root. The server is notified about the change.
|
||||||
// embedded roots/list request and retries the call.
|
c.AddRoots(&mcp.Root{URI: "file://b"})
|
||||||
if _, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "roots"}); err != nil {
|
<-rootsChanged
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
// Output: [file://a file://b]
|
// 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
|
## Sampling
|
||||||
|
|
||||||
> **Note:** The sampling feature is deprecated as of protocol version
|
> **Note:** The sampling feature is deprecated as of protocol version
|
||||||
|
|
@ -177,7 +110,7 @@ This function is invoked whenever the server requests sampling.
|
||||||
|
|
||||||
For protocol versions `2026-07-28` and later, sampling requests are
|
For protocol versions `2026-07-28` and later, sampling requests are
|
||||||
delivered via the
|
delivered via the
|
||||||
[Multi Round-Trip Requests](#multi-round-trip-requests)
|
[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr)
|
||||||
pattern.
|
pattern.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|
@ -197,35 +130,22 @@ func Example_sampling() {
|
||||||
|
|
||||||
// Connect the server and client...
|
// Connect the server and client...
|
||||||
ct, st := mcp.NewInMemoryTransports()
|
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)
|
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)
|
session, err := s.Connect(ctx, st, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
defer session.Close()
|
defer session.Close()
|
||||||
|
|
||||||
clientSession, err := c.Connect(ctx, ct, nil)
|
if _, err := c.Connect(ctx, ct, nil); err != nil {
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "sample"})
|
msg, err := session.CreateMessage(ctx, &mcp.CreateMessageParams{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
fmt.Println(res.Content[0].(*mcp.TextContent).Text)
|
fmt.Println(msg.Content.(*mcp.TextContent).Text)
|
||||||
// Output: would have created a message
|
// Output: would have created a message
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -247,7 +167,7 @@ you must declare that capability explicitly (see [Capabilities](#capabilities))
|
||||||
|
|
||||||
For protocol versions `2026-07-28` and later, elicitation requests are
|
For protocol versions `2026-07-28` and later, elicitation requests are
|
||||||
delivered via the
|
delivered via the
|
||||||
[Multi Round-Trip Requests](#multi-round-trip-requests)
|
[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr)
|
||||||
pattern.
|
pattern.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|
@ -255,28 +175,7 @@ func Example_elicitation() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
ct, st := mcp.NewInMemoryTransports()
|
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)
|
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)
|
ss, err := s.Connect(ctx, st, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|
@ -288,165 +187,26 @@ func Example_elicitation() {
|
||||||
return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil
|
return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
clientSession, err := c.Connect(ctx, ct, nil)
|
if _, err := c.Connect(ctx, ct, nil); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err := ss.Elicit(ctx, &mcp.ElicitParams{
|
||||||
|
Message: "This should fail",
|
||||||
|
RequestedSchema: &jsonschema.Schema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]*jsonschema.Schema{
|
||||||
|
"test": {Type: "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "ask"}); err != nil {
|
fmt.Println(res.Content["test"])
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
// Output: value
|
// 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
|
## Multi Round-Trip Requests
|
||||||
|
|
||||||
[SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)
|
[SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)
|
||||||
|
|
@ -469,18 +229,14 @@ default. The middleware:
|
||||||
|
|
||||||
The middleware is enabled by default. To opt out, set
|
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);
|
[`ClientOptions.MultiRoundTrip.Disabled = true`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#MultiRoundTripOptions);
|
||||||
the client will then surface input-required results directly to the caller
|
the client will then surface `InputRequiredResult` values directly to the
|
||||||
(the returned `CallToolResult`, `GetPromptResult`, or `ReadResourceResult`
|
caller and your code must fulfil the requests manually.
|
||||||
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
|
For legacy (`<= 2025-11-25`) servers, the SDK transparently sends server
|
||||||
requests on the legacy server-initiated channel; the MRTR machinery is a
|
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,
|
no-op in that direction. For legacy clients talking to MRTR-style servers,
|
||||||
the server SDK applies the inverse compatibility shim — see the
|
the server SDK applies the inverse compatibility shim — see the
|
||||||
[server-side documentation](server.md#multi-round-trip-requests).
|
[server-side documentation](server.md#multi-round-trip-requests-mrtr).
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,59 +18,6 @@
|
||||||
|
|
||||||
## `MCPGODEBUG` history
|
## `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
|
### 1.7.0
|
||||||
|
|
||||||
Options listed below were added and will be removed in the 1.9.0 version of the SDK.
|
Options listed below were added and will be removed in the 1.9.0 version of the SDK.
|
||||||
|
|
@ -99,25 +46,9 @@ Options listed below were added and will be removed in the 1.9.0 version of the
|
||||||
requested method in STDIO transport is not found.
|
requested method in STDIO transport is not found.
|
||||||
|
|
||||||
- `noprotocolerrorbody` added. If set to `1`, the streamable HTTP client will
|
- `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
|
restoring the previous behavior. The default behavior was changed so that
|
||||||
the server always reports params-decoding failures with the standard
|
the client always attempts to surface the underlying JSON-RPC error.
|
||||||
`-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
|
### 1.6.1
|
||||||
|
|
||||||
|
|
|
||||||
164
docs/protocol.md
164
docs/protocol.md
|
|
@ -2,14 +2,11 @@
|
||||||
# Support for the MCP base protocol
|
# Support for the MCP base protocol
|
||||||
|
|
||||||
1. [Lifecycle](#lifecycle)
|
1. [Lifecycle](#lifecycle)
|
||||||
1. [Discovery](#discovery)
|
1. [Discovery (`server/discover`)](#discovery-(server/discover))
|
||||||
1. [Per-request metadata keys](#per-request-metadata-keys)
|
1. [Per-request `_meta` keys](#per-request-meta-keys)
|
||||||
1. [Per-response metadata keys](#per-response-metadata-keys)
|
|
||||||
1. [Subscriptions](#subscriptions)
|
|
||||||
1. [Transports](#transports)
|
1. [Transports](#transports)
|
||||||
1. [Stdio Transport](#stdio-transport)
|
1. [Stdio Transport](#stdio-transport)
|
||||||
1. [Streamable Transport](#streamable-transport)
|
1. [Streamable Transport](#streamable-transport)
|
||||||
1. [Legacy SSE Transport](#legacy-sse-transport)
|
|
||||||
1. [Custom transports](#custom-transports)
|
1. [Custom transports](#custom-transports)
|
||||||
1. [Concurrency](#concurrency)
|
1. [Concurrency](#concurrency)
|
||||||
1. [Authorization](#authorization)
|
1. [Authorization](#authorization)
|
||||||
|
|
@ -42,10 +39,8 @@ transparently based on the negotiated protocol version:
|
||||||
- A **stateless** model introduced in `2026-07-28` by
|
- A **stateless** model introduced in `2026-07-28` by
|
||||||
[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575),
|
[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575),
|
||||||
in which there is no `initialize`/`notifications/initialized` handshake, and
|
in which there is no `initialize`/`notifications/initialized` handshake, and
|
||||||
each request carries its protocol version and client capabilities in
|
each request carries its protocol version, client identity, and client
|
||||||
`_meta`. Clients SHOULD also include their identity (`clientInfo`) on every
|
capabilities in `_meta`.
|
||||||
request, and servers SHOULD include their identity (`serverInfo`) on every
|
|
||||||
response.
|
|
||||||
|
|
||||||
In both models, the SDK exposes the same API:
|
In both models, the SDK exposes the same API:
|
||||||
|
|
||||||
|
|
@ -111,7 +106,7 @@ func Example_lifecycle() {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Discovery
|
### Discovery (`server/discover`)
|
||||||
|
|
||||||
Introduced in `2026-07-28` by
|
Introduced in `2026-07-28` by
|
||||||
[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575),
|
[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575),
|
||||||
|
|
@ -128,64 +123,23 @@ request. Servers implementing `2026-07-28` MUST implement it.
|
||||||
fails or the server does not support the latest version, the client falls back to the
|
fails or the server does not support the latest version, the client falls back to the
|
||||||
legacy `initialize` handshake.
|
legacy `initialize` handshake.
|
||||||
|
|
||||||
A server advertises and negotiates every protocol version the SDK supports;
|
### Per-request `_meta` keys
|
||||||
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
|
When the negotiated protocol version is `2026-07-28` or later, every request
|
||||||
carries these keys inside its `_meta` map (constants live in
|
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)):
|
[`mcp/protocol.go`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#pkg-constants)):
|
||||||
|
|
||||||
| Constant | Wire key | Type | Required |
|
| Constant | Wire key | Type |
|
||||||
|---|---|---|---|
|
|---|---|---|
|
||||||
| `MetaKeyProtocolVersion` | `io.modelcontextprotocol/protocolVersion` | `string` | Yes |
|
| `MetaKeyProtocolVersion` | `io.modelcontextprotocol/protocolVersion` | `string` |
|
||||||
| `MetaKeyClientCapabilities` | `io.modelcontextprotocol/clientCapabilities` | `*ClientCapabilities` | Yes |
|
| `MetaKeyClientInfo` | `io.modelcontextprotocol/clientInfo` | `*Implementation` |
|
||||||
| `MetaKeyClientInfo` | `io.modelcontextprotocol/clientInfo` | `*Implementation` | No |
|
| `MetaKeyClientCapabilities` | `io.modelcontextprotocol/clientCapabilities` | `*ClientCapabilities` |
|
||||||
| `MetaKeyLogLevel` | `io.modelcontextprotocol/logLevel` | `LoggingLevel` (deprecated by SEP-2577) | No |
|
| `MetaKeyLogLevel` | `io.modelcontextprotocol/logLevel` | `LoggingLevel` (deprecated by SEP-2577) |
|
||||||
|
|
||||||
The client populates the required keys automatically on every outgoing
|
The client populates these keys automatically on every outgoing request.
|
||||||
request, and populates `clientInfo` when configured with an `*Implementation`
|
Server-side handlers can read them
|
||||||
(the default). Server-side handlers can read them through
|
through `ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`,
|
||||||
`ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, and
|
and `ServerRequest[P].ClientCapabilities()`.
|
||||||
`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
|
## Transports
|
||||||
|
|
||||||
|
|
@ -271,8 +225,7 @@ connect to the server:
|
||||||
transport := &mcp.StreamableClientTransport{
|
transport := &mcp.StreamableClientTransport{
|
||||||
Endpoint: "http://localhost:8080/mcp",
|
Endpoint: "http://localhost:8080/mcp",
|
||||||
}
|
}
|
||||||
client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1.0.0"}, nil)
|
client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...})
|
||||||
session, err := client.Connect(ctx, transport, nil)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The `StreamableClientTransport` handles the HTTP requests and communicates with
|
The `StreamableClientTransport` handles the HTTP requests and communicates with
|
||||||
|
|
@ -357,63 +310,6 @@ _See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sd
|
||||||
an example using stateless mode to implement a server distributed across
|
an example using stateless mode to implement a server distributed across
|
||||||
multiple processes._
|
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
|
### Custom transports
|
||||||
|
|
||||||
The SDK supports [custom
|
The SDK supports [custom
|
||||||
|
|
@ -639,24 +535,24 @@ provided to
|
||||||
|
|
||||||
### Server-Side Request Forgery
|
### Server-Side Request Forgery
|
||||||
|
|
||||||
The OAuth discovery helpers apply these [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) by default:
|
The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) are as follows:
|
||||||
|
|
||||||
- _Enforce HTTPS_. Reject `http://` URLs (except loopback: `localhost`, `127.0.0.1`, `::1`)
|
- _Enforce HTTPS_. The OAuth helpers provided by the SDK reject the `http://` URLs
|
||||||
and redirects that downgrade `https` to a non-`https` scheme.
|
except loopback addresses (`localhost`, `127.0.0.1`, `::1`).
|
||||||
|
|
||||||
- _Block Private IP Ranges_. Reject targets that resolve to a non-public address
|
- _Block Private IP Ranges_. The OAuth helpers provided by the SDK allow passing
|
||||||
(private, link-local including the `169.254.169.254` metadata endpoint, CGNAT, multicast,
|
a custom `http.Client`. Developers are advised to customize the client it with
|
||||||
unspecified). The check runs on the initial URL and at dial time, guarding against DNS rebinding.
|
appropriate network protections, including IP range blocking. The SDK does not provide
|
||||||
|
this capability out of the box.
|
||||||
|
|
||||||
- _Validate Redirect Targets_. Reject redirects to non-public addresses and cap the redirect count.
|
- _Validate Redirect Targets_. Similarly to previous point, customized `http.Client`
|
||||||
|
can be used to validate network hops. The SDK does not provide this capability out
|
||||||
|
of the box.
|
||||||
|
|
||||||
- _Use Egress Proxies_. Out of scope for the SDK; configure separately.
|
- _Use Egress Proxies_. This is out of scope for the SDK and can be configured separately.
|
||||||
|
|
||||||
**Opting out.** Passing a custom `http.Client` can bypass these checks, making SSRF
|
- _DNS Resolution Considerations_. The SDK has DNS rebinding protection on the server side which is enabled by default. For the client side, consider providing
|
||||||
protection your responsibility. Specifically: a custom `Transport.DialContext`/`DialTLSContext`,
|
a custom `http.Client` that would implement DNS pinning.
|
||||||
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
|
### Session Hijacking
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue