Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4608cda997 |
||
|
|
3b917b466c |
||
|
|
b58aa8d8d8 |
||
|
|
826e653c27 |
||
|
|
3785c500b5 |
||
|
|
fbd36cb787 |
||
|
|
c1918e19ad |
||
|
|
0c0eb23625 |
||
|
|
3f43bcf012 |
||
|
|
a82c86a6cd |
||
|
|
3210c2b34c |
||
|
|
e40f35d137 |
||
|
|
5bc078a795 |
||
|
|
f99cdb649b |
||
|
|
a5026ea7b7 |
||
|
|
3f3b699b2b |
||
|
|
830f0b76e7 |
||
|
|
12cbafe54d |
||
|
|
3632967404 |
||
|
|
0d3036f785 |
||
|
|
cb0de64136 |
||
|
|
2fdabde139 |
||
|
|
59185e6969 |
||
|
|
a6764cf838 |
||
|
|
8227246472 |
||
|
|
f47d6ce156 |
||
|
|
ce3a92a0a8 |
||
|
|
69c17de411 |
||
|
|
d19bfebab9 |
||
|
|
21c18c6229 |
||
|
|
5a89b63764 |
||
|
|
165121e53e |
||
|
|
b55bcbd610 |
||
|
|
2732839dba |
||
|
|
c68ad9a4e6 |
||
|
|
32404652e6 |
||
|
|
810e6cb751 |
||
|
|
b8c53fedd6 |
||
|
|
6f0a645c20 |
||
|
|
3d6450fccf |
||
|
|
b074c7f3af |
||
|
|
ce0b198202 |
||
|
|
fe27fa9151 |
||
|
|
b0408f2821 |
||
|
|
1fcaf0c393 |
||
|
|
ba1496a9aa |
||
|
|
0eb1600eef |
||
|
|
64e454e35c |
||
|
|
4282916668 |
||
|
|
e9a85e285f |
||
|
|
039119057f |
||
|
|
71dfbc0d3e |
||
|
|
958dfcc532 |
||
|
|
d462fbd303 |
||
|
|
6b1518cb0b |
||
|
|
725694100a |
||
|
|
c4a4ce4543 |
||
|
|
1acd66e9d7 |
||
|
|
0d0cdbc943 |
||
|
|
ec29d60bb9 |
||
|
|
02610a2075 |
||
|
|
7b3bbc0f32 |
||
|
|
5d1040061b |
||
|
|
d32302ee7a |
||
|
|
0c004ee48a |
||
|
|
7743b71b1c |
||
|
|
bc72835f62 |
||
|
|
cc4db25347 |
||
|
|
e761950d97 |
||
|
|
2781b8f7e3 |
71 changed files with 7076 additions and 570 deletions
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
|
|
@ -36,13 +36,13 @@ jobs:
|
|||
build-mode: autobuild
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
|
|
|||
24
.github/workflows/conformance.yml
vendored
24
.github/workflows/conformance.yml
vendored
|
|
@ -14,25 +14,25 @@ permissions:
|
|||
contents: read
|
||||
|
||||
env:
|
||||
CONFORMANCE_VERSION: "0.2.0-alpha.9"
|
||||
CONFORMANCE_VERSION: "0.2.0-alpha.11"
|
||||
|
||||
jobs:
|
||||
server-conformance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Build everything-server
|
||||
run: go build -o everything-server ./conformance/everything-server
|
||||
- name: Run conformance tests (stateful, legacy spec versions)
|
||||
- name: Run conformance tests (2025-11-25, stateful)
|
||||
run: |
|
||||
./everything-server -http=":3001" -stateless=false &
|
||||
SERVER_PID=$!
|
||||
|
|
@ -40,10 +40,9 @@ jobs:
|
|||
timeout 30 bash -c 'until curl -s http://localhost:3001/mcp > /dev/null; do sleep 0.5; done'
|
||||
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \
|
||||
--url http://localhost:3001/mcp \
|
||||
--suite active \
|
||||
--spec-version 2025-11-25 \
|
||||
--requirements 2025-11-25 \
|
||||
--expected-failures ./conformance/baseline.yml
|
||||
- name: Run conformance tests (stateless, 2026-07-28, full suite)
|
||||
- name: Run conformance tests (2026-07-28, stateless)
|
||||
run: |
|
||||
./everything-server -http=":3001" -stateless &
|
||||
SERVER_PID=$!
|
||||
|
|
@ -51,21 +50,20 @@ jobs:
|
|||
timeout 30 bash -c 'until curl -s http://localhost:3001/mcp > /dev/null; do sleep 0.5; done'
|
||||
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \
|
||||
--url http://localhost:3001/mcp \
|
||||
--suite all \
|
||||
--spec-version 2026-07-28 \
|
||||
--requirements 2026-07-28 \
|
||||
--expected-failures ./conformance/baseline.yml
|
||||
|
||||
client-conformance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Run conformance tests (full suite)
|
||||
|
|
|
|||
4
.github/workflows/docs-check.yml
vendored
4
.github/workflows/docs-check.yml
vendored
|
|
@ -16,11 +16,11 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
- name: Check out code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check docs are up-to-date
|
||||
run: |
|
||||
go generate ./...
|
||||
|
|
|
|||
12
.github/workflows/nightly.yml
vendored
12
.github/workflows/nightly.yml
vendored
|
|
@ -11,25 +11,29 @@ permissions:
|
|||
|
||||
jobs:
|
||||
vulncheck:
|
||||
# Don't run scheduled builds on forks.
|
||||
if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Run govulncheck
|
||||
uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4
|
||||
uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1.1.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
go-package: ./...
|
||||
|
||||
conformance:
|
||||
# Don't run scheduled builds on forks.
|
||||
if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Run server conformance tests
|
||||
|
|
|
|||
4
.github/workflows/publish-docs.yml
vendored
4
.github/workflows/publish-docs.yml
vendored
|
|
@ -13,12 +13,12 @@ jobs:
|
|||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Configure Git Credentials
|
||||
run: |
|
||||
git config user.name github-actions[bot]
|
||||
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: 3.x
|
||||
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
||||
|
|
|
|||
6
.github/workflows/scorecard.yml
vendored
6
.github/workflows/scorecard.yml
vendored
|
|
@ -34,12 +34,12 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
|
|
@ -73,6 +73,6 @@ jobs:
|
|||
# Upload the results to GitHub's code scanning dashboard (optional).
|
||||
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
|
|
|||
12
.github/workflows/test.yml
vendored
12
.github/workflows/test.yml
vendored
|
|
@ -14,9 +14,9 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
- name: Check formatting
|
||||
|
|
@ -43,9 +43,9 @@ jobs:
|
|||
go: ["1.25", "1.26"]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
- name: Test
|
||||
|
|
@ -55,9 +55,9 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "1.26"
|
||||
- name: Test with -race
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ transparently, while adhering to these goals.
|
|||
1. [Contributing code](#contributing-code)
|
||||
1. [Adding and updating dependencies](#adding-and-updating-dependencies)
|
||||
1. [Updating the README](#updating-the-readme)
|
||||
1. [Versioning](#versioning)
|
||||
1. [What counts as a breaking change](#what-counts-as-a-breaking-change)
|
||||
1. [Deprecation](#deprecation)
|
||||
1. [Timeouts](#timeouts)
|
||||
1. [Code of conduct](#code-of-conduct)
|
||||
1. [Governance](#governance)
|
||||
|
|
@ -188,6 +191,57 @@ The CI system will automatically check that the README is up-to-date by running
|
|||
`go generate ./internal/readme` and verifying no changes result. If you see a CI failure about the
|
||||
README being out of sync, follow the steps above to regenerate it.
|
||||
|
||||
## Versioning
|
||||
|
||||
The SDK follows [semantic versioning](https://semver.org), as the Go module
|
||||
system requires: the major version is part of the module path, so a breaking
|
||||
release would import as `github.com/modelcontextprotocol/go-sdk/v2`. A breaking
|
||||
change therefore cannot reach existing users by accident; they have to change
|
||||
their import path to receive one.
|
||||
|
||||
This policy covers the exported API of the SDK's importable packages — `mcp`,
|
||||
`jsonrpc`, `auth`, `auth/extauth` and `oauthex`. Everything under `internal/`
|
||||
is not importable outside the module and may change in any release.
|
||||
|
||||
Which MCP spec revisions each SDK version speaks is documented in the
|
||||
[README](README.md#version-compatibility). Support for a new revision arrives
|
||||
in a minor release; dropping a revision the SDK previously negotiated is a
|
||||
breaking change.
|
||||
|
||||
### What counts as a breaking change
|
||||
|
||||
- Removing or renaming an exported identifier.
|
||||
- Changing the signature of an exported function or method.
|
||||
- Removing or renaming a field of an exported struct, or changing its type.
|
||||
- Adding a method to an exported interface that users are meant to implement.
|
||||
- Changing documented behavior that callers could reasonably rely on.
|
||||
- Dropping support for an MCP spec revision.
|
||||
|
||||
The following are not breaking, and may land in a minor or patch release:
|
||||
|
||||
- Adding an exported identifier, or a field to an exported struct.
|
||||
- Fixing behavior that contradicted its documentation.
|
||||
- Refactoring that leaves the exported API unchanged.
|
||||
- Adding support for a new MCP spec revision.
|
||||
- Raising the minimum Go version to one still supported upstream, per the
|
||||
[README](README.md#version-compatibility).
|
||||
|
||||
### Deprecation
|
||||
|
||||
An API on its way out is marked with a `// Deprecated:` comment naming its
|
||||
replacement, which `gopls` and `staticcheck` surface at call sites. It keeps
|
||||
working until the next major version, since removing it would itself be a
|
||||
breaking change.
|
||||
|
||||
Protocol features that the *specification* deprecates are a separate matter and
|
||||
follow the spec's timeline rather than this policy. The features deprecated by
|
||||
[SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging)
|
||||
— roots, sampling and logging — remain supported for at least twelve months, as
|
||||
noted in the [README](README.md#version-compatibility).
|
||||
|
||||
Any change to the exported API, breaking or not, goes through the
|
||||
[proposal process](#proposals).
|
||||
|
||||
## Timeouts
|
||||
|
||||
If a contributor hasn't responded to issue questions or PR comments in two weeks,
|
||||
|
|
|
|||
|
|
@ -98,6 +98,15 @@ type AuthorizationCodeHandlerConfig struct {
|
|||
// See [AuthorizationCodeFetcher] for details.
|
||||
AuthorizationCodeFetcher AuthorizationCodeFetcher
|
||||
|
||||
// ScopeFilter, if non-nil, is called with the scopes discovered from the
|
||||
// protected resource metadata (or WWW-Authenticate challenge) and returns
|
||||
// the scopes to request during authorization. It gives the client full
|
||||
// control to narrow, extend, or reorder that set — e.g. dropping Gmail's
|
||||
// gmail.metadata, which the Gmail API refuses to combine with the search "q"
|
||||
// parameter even alongside gmail.readonly. It runs before offline_access
|
||||
// (see RequestRefreshToken) and the step-up union, so neither is affected.
|
||||
ScopeFilter func(discovered []string) []string
|
||||
|
||||
// RequestRefreshToken indicates that the client intends to use refresh
|
||||
// tokens and is capable of storing them securely.
|
||||
//
|
||||
|
|
@ -115,6 +124,11 @@ type AuthorizationCodeHandlerConfig struct {
|
|||
// See https://modelcontextprotocol.io/seps/2207-oidc-refresh-token-guidance.
|
||||
RequestRefreshToken bool
|
||||
|
||||
// AcceptUnadvertisedIss accepts a matching RFC 9207 iss even when the
|
||||
// authorization server metadata omits authorization_response_iss_parameter_supported.
|
||||
// The zero value (false) keeps the historical reject-unadvertised-iss behavior.
|
||||
AcceptUnadvertisedIss bool
|
||||
|
||||
// Client is an optional HTTP client to use for HTTP requests.
|
||||
// It is used for the following requests:
|
||||
// - Fetching Protected Resource Metadata
|
||||
|
|
@ -318,6 +332,12 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
|
|||
requestedScopes = prm.ScopesSupported
|
||||
}
|
||||
|
||||
// Let the client adjust the discovered scopes before offline_access and the
|
||||
// step-up union below so neither is affected.
|
||||
if h.config.ScopeFilter != nil {
|
||||
requestedScopes = h.config.ScopeFilter(requestedScopes)
|
||||
}
|
||||
|
||||
// SEP-2207: when the client desires refresh tokens and the Authorization
|
||||
// Server advertises offline_access support, add it to the requested scopes.
|
||||
if h.config.RequestRefreshToken &&
|
||||
|
|
@ -352,7 +372,7 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
|
|||
// Purposefully leaving the error unwrappable so it can be handled by the caller.
|
||||
return err
|
||||
}
|
||||
if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported); err != nil {
|
||||
if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported, h.config.AcceptUnadvertisedIss); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -601,10 +621,14 @@ func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg
|
|||
}
|
||||
|
||||
// validateIssuerResponse validates the "iss" parameter in an authorization response
|
||||
// per [RFC 9207].
|
||||
// per [RFC 9207]. When the server advertises authorization_response_iss_parameter_supported,
|
||||
// iss is required and must match expectedIssuer. When it does not advertise support,
|
||||
// an empty iss is accepted; a present iss is compared to expectedIssuer (RFC 9207 §2.4)
|
||||
// and a mismatch is always rejected. A matching unadvertised iss is accepted only when
|
||||
// acceptUnadvertisedIss is true (local policy); the default is the historical reject.
|
||||
//
|
||||
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
|
||||
func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported bool) error {
|
||||
func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported, acceptUnadvertisedIss bool) error {
|
||||
if issParameterSupported {
|
||||
if iss == "" {
|
||||
return fmt.Errorf("authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response")
|
||||
|
|
@ -612,12 +636,17 @@ func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported bo
|
|||
if iss != expectedIssuer {
|
||||
return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer)
|
||||
}
|
||||
} else {
|
||||
if iss != "" {
|
||||
return fmt.Errorf("authorization server does not advertise RFC 9207 iss parameter support but iss was received in the authorization response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if iss == "" {
|
||||
return nil
|
||||
}
|
||||
if iss != expectedIssuer {
|
||||
return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer)
|
||||
}
|
||||
if !acceptUnadvertisedIss {
|
||||
return fmt.Errorf("authorization server does not advertise RFC 9207 iss parameter support but iss was received in the authorization response")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -919,11 +919,12 @@ func TestValidateIssuerResponse(t *testing.T) {
|
|||
const expectedIssuer = "https://auth.example.com"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
iss string
|
||||
issSupported bool
|
||||
wantErr bool
|
||||
wantErrContains string
|
||||
name string
|
||||
iss string
|
||||
issSupported bool
|
||||
acceptUnadvertisedIss bool
|
||||
wantErr bool
|
||||
wantErrContains string
|
||||
}{
|
||||
{
|
||||
name: "ValidIss",
|
||||
|
|
@ -949,11 +950,41 @@ func TestValidateIssuerResponse(t *testing.T) {
|
|||
iss: "",
|
||||
issSupported: false,
|
||||
},
|
||||
{
|
||||
// Default / zero-value policy: reject a matching unadvertised iss
|
||||
// (released v1.7.0 behavior).
|
||||
name: "UnadvertisedIssCorrectRejectedByDefault",
|
||||
iss: expectedIssuer,
|
||||
issSupported: false,
|
||||
wantErr: true,
|
||||
wantErrContains: "does not advertise",
|
||||
},
|
||||
{
|
||||
name: "UnadvertisedIssCorrectAcceptedWhenOptedIn",
|
||||
iss: expectedIssuer,
|
||||
issSupported: false,
|
||||
acceptUnadvertisedIss: true,
|
||||
},
|
||||
{
|
||||
name: "UnadvertisedIssWrong",
|
||||
iss: "https://attacker.example.com",
|
||||
issSupported: false,
|
||||
wantErr: true,
|
||||
wantErrContains: "does not match expected issuer",
|
||||
},
|
||||
{
|
||||
name: "UnadvertisedIssWrongStillRejectedWhenOptedIn",
|
||||
iss: "https://attacker.example.com",
|
||||
issSupported: false,
|
||||
acceptUnadvertisedIss: true,
|
||||
wantErr: true,
|
||||
wantErrContains: "does not match expected issuer",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported)
|
||||
err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported, tt.acceptUnadvertisedIss)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("validateIssuerResponse() = nil, want error containing %q", tt.wantErrContains)
|
||||
|
|
@ -968,6 +999,112 @@ func TestValidateIssuerResponse(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestAuthorize_AcceptUnadvertisedIssPlumbing checks that Authorize passes
|
||||
// AuthorizationCodeHandlerConfig.AcceptUnadvertisedIss through to
|
||||
// validateIssuerResponse. The fake server returns iss without advertising
|
||||
// authorization_response_iss_parameter_supported.
|
||||
func TestAuthorize_AcceptUnadvertisedIssPlumbing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
acceptUnadvertisedIss bool
|
||||
wantErrContains string
|
||||
}{
|
||||
{
|
||||
name: "ZeroValueRejects",
|
||||
wantErrContains: "does not advertise",
|
||||
},
|
||||
{
|
||||
name: "OptInAccepts",
|
||||
acceptUnadvertisedIss: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
||||
UnadvertiseIssParameter: true,
|
||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
||||
"test_client_id": {
|
||||
Secret: "test_client_secret",
|
||||
RedirectURIs: []string{"http://localhost:12345/callback"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
authServer.Start(t)
|
||||
|
||||
resourceMux := http.NewServeMux()
|
||||
resourceServer := httptest.NewServer(resourceMux)
|
||||
t.Cleanup(resourceServer.Close)
|
||||
resourceURL := resourceServer.URL + "/resource"
|
||||
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
||||
Resource: resourceURL,
|
||||
AuthorizationServers: []string{authServer.URL()},
|
||||
}))
|
||||
|
||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
||||
RedirectURL: "http://localhost:12345/callback",
|
||||
AcceptUnadvertisedIss: tt.acceptUnadvertisedIss,
|
||||
PreregisteredClient: &oauthex.ClientCredentials{
|
||||
ClientID: "test_client_id",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "test_client_secret",
|
||||
},
|
||||
},
|
||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
resp, err := client.Get(args.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to visit auth URL: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
location, err := resp.Location()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get location header: %v", err)
|
||||
}
|
||||
return &AuthorizationResult{
|
||||
Code: location.Query().Get("code"),
|
||||
State: location.Query().Get("state"),
|
||||
Iss: location.Query().Get("iss"),
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}
|
||||
resp.Header.Set(
|
||||
"WWW-Authenticate",
|
||||
"Bearer resource_metadata="+resourceServer.URL+"/.well-known/oauth-protected-resource/resource",
|
||||
)
|
||||
err = handler.Authorize(context.Background(), req, resp)
|
||||
if tt.wantErrContains != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("Authorize() = nil, want error containing %q", tt.wantErrContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErrContains) {
|
||||
t.Fatalf("Authorize() error = %q, want it to contain %q", err.Error(), tt.wantErrContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Authorize() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferApplicationType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -1222,6 +1359,107 @@ func TestAuthorize_OfflineAccessScope(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_ScopeFilter(t *testing.T) {
|
||||
// advertised is delivered via the WWW-Authenticate "scope" challenge, which
|
||||
// the handler treats as the discovered scopes passed to ScopeFilter.
|
||||
const advertised = "gmail.metadata gmail.readonly gmail.compose"
|
||||
tests := []struct {
|
||||
name string
|
||||
filter func(discovered []string) []string
|
||||
want []string // requested scopes, order-independent
|
||||
}{
|
||||
{
|
||||
name: "nil filter leaves scopes unchanged",
|
||||
filter: nil,
|
||||
want: []string{"gmail.metadata", "gmail.readonly", "gmail.compose"},
|
||||
},
|
||||
{
|
||||
name: "filter drops a scope",
|
||||
filter: func(d []string) []string {
|
||||
return slices.DeleteFunc(slices.Clone(d), func(s string) bool { return s == "gmail.metadata" })
|
||||
},
|
||||
want: []string{"gmail.readonly", "gmail.compose"},
|
||||
},
|
||||
{
|
||||
name: "filter replaces the set entirely",
|
||||
filter: func([]string) []string { return []string{"gmail.readonly"} },
|
||||
want: []string{"gmail.readonly"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
||||
"test_client_id": {
|
||||
Secret: "test_client_secret",
|
||||
RedirectURIs: []string{"http://localhost:12345/callback"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
authServer.Start(t)
|
||||
|
||||
resourceMux := http.NewServeMux()
|
||||
resourceServer := httptest.NewServer(resourceMux)
|
||||
t.Cleanup(resourceServer.Close)
|
||||
resourceURL := resourceServer.URL + "/resource"
|
||||
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
||||
Resource: resourceURL,
|
||||
AuthorizationServers: []string{authServer.URL()},
|
||||
}))
|
||||
|
||||
var capturedAuthURL string
|
||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
||||
RedirectURL: "http://localhost:12345/callback",
|
||||
PreregisteredClient: &oauthex.ClientCredentials{
|
||||
ClientID: "test_client_id",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test_client_secret"},
|
||||
},
|
||||
ScopeFilter: tt.filter,
|
||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||
capturedAuthURL = args.URL
|
||||
return nil, fmt.Errorf("stop after capturing URL")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}
|
||||
resp.Header.Set("WWW-Authenticate", fmt.Sprintf(
|
||||
"Bearer resource_metadata=%s/.well-known/oauth-protected-resource/resource, scope=%q",
|
||||
resourceServer.URL, advertised))
|
||||
|
||||
handler.Authorize(context.Background(), req, resp)
|
||||
|
||||
if capturedAuthURL == "" {
|
||||
t.Fatal("AuthorizationCodeFetcher was not called")
|
||||
}
|
||||
u, err := url.Parse(capturedAuthURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse captured auth URL: %v", err)
|
||||
}
|
||||
// Compare as a set: UnionScopes (applied downstream) returns map keys,
|
||||
// so the order of the requested scope parameter is not deterministic.
|
||||
got := strings.Fields(u.Query().Get("scope"))
|
||||
want := slices.Clone(tt.want)
|
||||
slices.Sort(got)
|
||||
slices.Sort(want)
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("requested scopes = %v, want %v (any order)", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// validConfig for test to create an AuthorizationCodeHandler using its constructor.
|
||||
// Values that are relevant to the test should be set explicitly.
|
||||
func validConfig() *AuthorizationCodeHandlerConfig {
|
||||
|
|
|
|||
|
|
@ -44,7 +44,12 @@ func authorizationServerMetadataURLs(issuerURL string) []string {
|
|||
return nil
|
||||
}
|
||||
|
||||
if baseURL.Path == "" {
|
||||
// RFC 8414, section 3.1 requires any terminating "/" to be removed from the
|
||||
// issuer identifier before the well-known suffix is inserted, so an issuer
|
||||
// such as "https://auth.example.com/" has no path component at all.
|
||||
issuerPath := strings.Trim(baseURL.Path, "/")
|
||||
|
||||
if issuerPath == "" {
|
||||
// "OAuth 2.0 Authorization Server Metadata".
|
||||
baseURL.Path = "/.well-known/oauth-authorization-server"
|
||||
urls = append(urls, baseURL.String())
|
||||
|
|
@ -54,15 +59,14 @@ func authorizationServerMetadataURLs(issuerURL string) []string {
|
|||
return urls
|
||||
}
|
||||
|
||||
originalPath := baseURL.Path
|
||||
// "OAuth 2.0 Authorization Server Metadata with path insertion".
|
||||
baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/")
|
||||
baseURL.Path = "/.well-known/oauth-authorization-server/" + issuerPath
|
||||
urls = append(urls, baseURL.String())
|
||||
// "OpenID Connect Discovery 1.0 with path insertion".
|
||||
baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/")
|
||||
baseURL.Path = "/.well-known/openid-configuration/" + issuerPath
|
||||
urls = append(urls, baseURL.String())
|
||||
// "OpenID Connect Discovery 1.0 with path appending".
|
||||
baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration"
|
||||
baseURL.Path = "/" + issuerPath + "/.well-known/openid-configuration"
|
||||
urls = append(urls, baseURL.String())
|
||||
|
||||
return urls
|
||||
|
|
|
|||
|
|
@ -6,11 +6,81 @@ package auth
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
|
||||
)
|
||||
|
||||
func TestAuthorizationServerMetadataURLs(t *testing.T) {
|
||||
tests := []struct {
|
||||
issuerURL string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
issuerURL: "https://auth.example.com",
|
||||
want: []string{
|
||||
"https://auth.example.com/.well-known/oauth-authorization-server",
|
||||
"https://auth.example.com/.well-known/openid-configuration",
|
||||
},
|
||||
},
|
||||
{
|
||||
issuerURL: "https://auth.example.com/",
|
||||
want: []string{
|
||||
"https://auth.example.com/.well-known/oauth-authorization-server",
|
||||
"https://auth.example.com/.well-known/openid-configuration",
|
||||
},
|
||||
},
|
||||
{
|
||||
issuerURL: "https://auth.example.com/tenant1",
|
||||
want: []string{
|
||||
"https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
|
||||
"https://auth.example.com/.well-known/openid-configuration/tenant1",
|
||||
"https://auth.example.com/tenant1/.well-known/openid-configuration",
|
||||
},
|
||||
},
|
||||
{
|
||||
issuerURL: "https://auth.example.com/tenant1/",
|
||||
want: []string{
|
||||
"https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
|
||||
"https://auth.example.com/.well-known/openid-configuration/tenant1",
|
||||
"https://auth.example.com/tenant1/.well-known/openid-configuration",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.issuerURL, func(t *testing.T) {
|
||||
if got := authorizationServerMetadataURLs(tt.issuerURL); !slices.Equal(got, tt.want) {
|
||||
t.Errorf("authorizationServerMetadataURLs(%q) =\n%v\nwant\n%v", tt.issuerURL, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAuthServerMetadataTrailingSlashIssuer checks that an issuer identifier
|
||||
// written with a terminating slash still finds metadata served at the root
|
||||
// well-known path.
|
||||
func TestGetAuthServerMetadataTrailingSlashIssuer(t *testing.T) {
|
||||
s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
||||
MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{
|
||||
ServeOAuthInsertedEndpoint: true,
|
||||
},
|
||||
})
|
||||
s.Start(t)
|
||||
|
||||
got, err := GetAuthServerMetadata(t.Context(), s.URL()+"/", http.DefaultClient)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAuthServerMetadata() error = %v, want nil", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("GetAuthServerMetadata() got nil, want metadata")
|
||||
}
|
||||
if got.Issuer != s.URL() {
|
||||
t.Errorf("GetAuthServerMetadata() issuer = %q, want %q", got.Issuer, s.URL())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthServerMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -1,29 +1,14 @@
|
|||
server:
|
||||
# The `server-stateless` scenario ships with the `@modelcontextprotocol/conformance@0.2.0-alpha.9`
|
||||
# suite pinned by the workflow, which still enforces the pre-PR-3002
|
||||
# (https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002) spec:
|
||||
# - `clientInfo` in the per-request `_meta` is required (MUST).
|
||||
# - `serverInfo` is a body field on `DiscoverResult`.
|
||||
# PR #3002 relaxes `clientInfo` to SHOULD and moves `serverInfo` into every
|
||||
# response's `_meta`. The Go SDK follows the updated spec, so the following
|
||||
# checks in the `server-stateless` scenario fail against the pinned suite:
|
||||
# - sep-2575-request-meta-invalid-missing-client-info
|
||||
# - sep-2575-http-server-meta-invalid-400
|
||||
# - sep-2575-server-implements-discover
|
||||
# Remove this entry when the conformance suite is bumped to a release that
|
||||
# incorporates PR #3002.
|
||||
- server-stateless
|
||||
|
||||
client:
|
||||
# OAuth mechanisms not implemented by the Go MCP SDK.
|
||||
- auth/client-credentials-basic
|
||||
- auth/client-credentials-jwt
|
||||
- auth/enterprise-managed-authorization
|
||||
|
||||
# Upstream conformance mock inconsistency: the scenario's hand-rolled
|
||||
# server/discover handler advertises "2026-07-28" but delegates every
|
||||
# other method to a pinned @modelcontextprotocol/sdk that rejects that
|
||||
# version at the HTTP layer.
|
||||
# Remove this entry when the conformance suite is bumped to a release that
|
||||
# fixes this issue.
|
||||
- json-schema-ref-no-deref
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ func init() {
|
|||
registerScenario("elicitation-sep1034-client-defaults", runElicitationDefaultsClient)
|
||||
registerScenario("sse-retry", runSSERetryClient)
|
||||
registerScenario("json-schema-ref-no-deref", runJSONSchemaRefNoDerefClient)
|
||||
registerScenario("json-schema-2020-12-preservation", runJSONSchemaPreservationClient)
|
||||
registerScenario("sep-2322-client-request-state", runMrtrClient)
|
||||
registerScenario("http-standard-headers", runHTTPStandardHeadersClient)
|
||||
registerScenario("http-custom-headers", runHTTPCustomHeadersClient)
|
||||
|
|
@ -343,6 +344,46 @@ func runJSONSchemaRefNoDerefClient(ctx context.Context, serverURL string, _ map[
|
|||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// json-schema-2020-12-preservation scenario (SEP-1613, SEP-2106)
|
||||
// ============================================================================
|
||||
|
||||
// runJSONSchemaPreservationClient echoes the focal tool's inputSchema back to
|
||||
// the referee verbatim, through the permissive json_schema_echo tool, so that
|
||||
// it can observe which 2020-12 keywords survived the client's parse of
|
||||
// tools/list. The Go SDK surfaces a server's schema as the raw JSON object it
|
||||
// received, so nothing is stripped.
|
||||
func runJSONSchemaPreservationClient(ctx context.Context, serverURL string, _ map[string]any) error {
|
||||
session, err := connectToServer(ctx, serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
tools, err := session.ListTools(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("session.ListTools(): %v", err)
|
||||
}
|
||||
|
||||
const focalTool = "json_schema_2020_12_tool"
|
||||
idx := slices.IndexFunc(tools.Tools, func(t *mcp.Tool) bool {
|
||||
return t.Name == focalTool
|
||||
})
|
||||
if idx == -1 {
|
||||
return fmt.Errorf("tool %q not found", focalTool)
|
||||
}
|
||||
|
||||
const echoTool = "json_schema_echo"
|
||||
if _, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: echoTool,
|
||||
Arguments: map[string]any{"schema": tools.Tools[idx].InputSchema},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("session.CallTool(%q): %v", echoTool, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SEP-2322 multi-round-trip client scenario
|
||||
// ============================================================================
|
||||
|
|
|
|||
220
docs/client.md
220
docs/client.md
|
|
@ -2,8 +2,11 @@
|
|||
# Support for MCP client features
|
||||
|
||||
1. [Roots](#roots)
|
||||
1. [Roots list changed](#roots-list-changed)
|
||||
1. [Sampling](#sampling)
|
||||
1. [Elicitation](#elicitation)
|
||||
1. [Schema defaults and enums](#schema-defaults-and-enums)
|
||||
1. [Completing a URL elicitation](#completing-a-url-elicitation)
|
||||
1. [Multi Round-Trip Requests](#multi-round-trip-requests)
|
||||
1. [Capabilities](#capabilities)
|
||||
1. [Capability inference](#capability-inference)
|
||||
|
|
@ -38,7 +41,7 @@ method. To receive notifications about root changes, set
|
|||
[`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler).
|
||||
For protocol versions `2026-07-28` and later, `ListRoots` requests are
|
||||
delivered via the
|
||||
[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr)
|
||||
[Multi Round-Trip Requests](#multi-round-trip-requests)
|
||||
pattern.
|
||||
|
||||
```go
|
||||
|
|
@ -91,6 +94,67 @@ func Example_roots() {
|
|||
}
|
||||
```
|
||||
|
||||
### Roots list changed
|
||||
|
||||
`Client.AddRoots` and `Client.RemoveRoots` notify every connected server that
|
||||
the list changed. Servers observe this through
|
||||
[`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler);
|
||||
as with the server-side list-changed notifications, it reports only that
|
||||
something changed, so read the list back with `ServerSession.ListRoots`.
|
||||
|
||||
```go
|
||||
func Example_rootsListChanged() {
|
||||
ctx := context.Background()
|
||||
|
||||
changed := make(chan struct{}, 2)
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{
|
||||
RootsListChangedHandler: func(context.Context, *mcp.RootsListChangedRequest) {
|
||||
changed <- struct{}{}
|
||||
},
|
||||
})
|
||||
|
||||
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil)
|
||||
c.AddRoots(&mcp.Root{URI: "file:///project"})
|
||||
|
||||
t1, t2 := mcp.NewInMemoryTransports()
|
||||
ss, err := s.Connect(ctx, t1, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer ss.Close()
|
||||
|
||||
// ListRoots is a server-initiated request, so this session negotiates a
|
||||
// protocol version that still allows one.
|
||||
cs, err := c.Connect(ctx, t2, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
// Roots added after the client connects notify every connected server.
|
||||
c.AddRoots(&mcp.Root{URI: "file:///scratch"})
|
||||
<-changed
|
||||
|
||||
// The notification says only that the list changed, so read it back.
|
||||
res, err := ss.ListRoots(ctx, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, root := range res.Roots {
|
||||
fmt.Println(root.URI)
|
||||
}
|
||||
|
||||
c.RemoveRoots("file:///scratch")
|
||||
<-changed
|
||||
fmt.Println("roots changed again")
|
||||
|
||||
// Output:
|
||||
// file:///project
|
||||
// file:///scratch
|
||||
// roots changed again
|
||||
}
|
||||
```
|
||||
|
||||
## Sampling
|
||||
|
||||
> **Note:** The sampling feature is deprecated as of protocol version
|
||||
|
|
@ -113,7 +177,7 @@ This function is invoked whenever the server requests sampling.
|
|||
|
||||
For protocol versions `2026-07-28` and later, sampling requests are
|
||||
delivered via the
|
||||
[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr)
|
||||
[Multi Round-Trip Requests](#multi-round-trip-requests)
|
||||
pattern.
|
||||
|
||||
```go
|
||||
|
|
@ -183,7 +247,7 @@ you must declare that capability explicitly (see [Capabilities](#capabilities))
|
|||
|
||||
For protocol versions `2026-07-28` and later, elicitation requests are
|
||||
delivered via the
|
||||
[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr)
|
||||
[Multi Round-Trip Requests](#multi-round-trip-requests)
|
||||
pattern.
|
||||
|
||||
```go
|
||||
|
|
@ -235,6 +299,154 @@ func Example_elicitation() {
|
|||
}
|
||||
```
|
||||
|
||||
### Schema defaults and enums
|
||||
|
||||
`ElicitParams.RequestedSchema` is a flat schema of primitive fields, which the
|
||||
client renders as a form. Two field keywords shape that form.
|
||||
|
||||
A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034--support-default-values-for-all-primitive-types-in)) prefills a
|
||||
field. When the user accepts without supplying it, the SDK fills the field in
|
||||
from the schema before the result reaches either side's caller — the client
|
||||
does so after its elicitation handler returns, and `ServerSession.Elicit` does
|
||||
so again on receipt. This is unconditional; there is no opt-in flag. Marking a
|
||||
defaulted field `Required` defeats it: accepted content is validated against
|
||||
the schema before defaults are applied, so an answer that omits the field is
|
||||
rejected rather than defaulted.
|
||||
|
||||
An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330-elicitation-enum-schema-improvements-and-standards)) restricts a
|
||||
field to a fixed set of values, which the client renders as a choice. Enums
|
||||
are supported only on `"string"` fields; declaring one on another type is
|
||||
rejected. To label the choices, set the legacy `enumNames` keyword through
|
||||
`Schema.Extra`, with exactly one name per enum value — a mismatched length is
|
||||
rejected.
|
||||
|
||||
```go
|
||||
func Example_elicitationSchema() {
|
||||
ctx := context.Background()
|
||||
ct, st := mcp.NewInMemoryTransports()
|
||||
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil)
|
||||
mcp.AddTool(s, &mcp.Tool{Name: "export_report"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
|
||||
if len(req.Params.InputResponses) == 0 {
|
||||
return &mcp.CallToolResult{
|
||||
InputRequests: mcp.InputRequestMap{"format": &mcp.ElicitParams{
|
||||
Message: "Export quarterly-sales as which format?",
|
||||
RequestedSchema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"format": {
|
||||
Type: "string",
|
||||
Title: "Format",
|
||||
Enum: []any{"pdf", "csv"},
|
||||
Default: json.RawMessage(`"pdf"`),
|
||||
Extra: map[string]any{"enumNames": []any{"PDF document", "CSV spreadsheet"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}, nil, nil
|
||||
}
|
||||
res := req.Params.InputResponses["format"].(*mcp.ElicitResult)
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: "Exported as " + res.Content["format"].(string)}},
|
||||
}, nil, nil
|
||||
})
|
||||
if _, err := s.Connect(ctx, st, nil); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// The user accepts without filling anything in.
|
||||
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{
|
||||
ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
|
||||
return &mcp.ElicitResult{Action: "accept", Content: map[string]any{}}, nil
|
||||
},
|
||||
})
|
||||
cs, err := c.Connect(ctx, ct, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "export_report"})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(res.Content[0].(*mcp.TextContent).Text)
|
||||
// Output: Exported as pdf
|
||||
}
|
||||
```
|
||||
|
||||
### Completing a URL elicitation
|
||||
|
||||
In URL mode the user finishes out of band, in a browser, so nothing in the
|
||||
elicitation result tells the client when they are done. The server signals that
|
||||
with
|
||||
[`ServerSession.NotifyElicitationComplete`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.NotifyElicitationComplete),
|
||||
passing the same `ElicitationID` the request carried; the client observes it
|
||||
through
|
||||
[`ClientOptions.ElicitationCompleteHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ElicitationCompleteHandler).
|
||||
Send it from whatever endpoint the hosted flow redirects back to.
|
||||
|
||||
The notification matters most when a handler rejects a request with
|
||||
[`URLElicitationRequiredError`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#URLElicitationRequiredError):
|
||||
the client parks the original request until a notification names that
|
||||
`ElicitationID`, and then retries it automatically. Until one arrives, the
|
||||
client waits.
|
||||
|
||||
```go
|
||||
func Example_elicitationComplete() {
|
||||
ctx := context.Background()
|
||||
ct, st := mcp.NewInMemoryTransports()
|
||||
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil)
|
||||
ss, err := s.Connect(ctx, st, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer ss.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{
|
||||
Capabilities: &mcp.ClientCapabilities{
|
||||
Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}},
|
||||
},
|
||||
ElicitationHandler: func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
|
||||
fmt.Println("opening", req.Params.URL)
|
||||
return &mcp.ElicitResult{Action: "accept"}, nil
|
||||
},
|
||||
ElicitationCompleteHandler: func(_ context.Context, req *mcp.ElicitationCompleteNotificationRequest) {
|
||||
fmt.Println("flow finished:", req.Params.ElicitationID)
|
||||
close(done)
|
||||
},
|
||||
})
|
||||
cs, err := c.Connect(ctx, ct, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
const elicitationID = "connect-calendar-1"
|
||||
if _, err := ss.Elicit(ctx, &mcp.ElicitParams{
|
||||
Message: "Grant calendar access",
|
||||
URL: "https://calendar.example.com/consent?state=" + elicitationID,
|
||||
ElicitationID: elicitationID,
|
||||
}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// The hosted page redirects back to the server, whose callback endpoint
|
||||
// signals that the user is done.
|
||||
if err := ss.NotifyElicitationComplete(ctx, &mcp.ElicitationCompleteParams{ElicitationID: elicitationID}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
<-done
|
||||
|
||||
// Output:
|
||||
// opening https://calendar.example.com/consent?state=connect-calendar-1
|
||||
// flow finished: connect-calendar-1
|
||||
}
|
||||
```
|
||||
|
||||
## Multi Round-Trip Requests
|
||||
|
||||
[SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)
|
||||
|
|
@ -268,7 +480,7 @@ For legacy (`<= 2025-11-25`) servers, the SDK transparently sends server
|
|||
requests on the legacy server-initiated channel; the MRTR machinery is a
|
||||
no-op in that direction. For legacy clients talking to MRTR-style servers,
|
||||
the server SDK applies the inverse compatibility shim — see the
|
||||
[server-side documentation](server.md#multi-round-trip-requests-mrtr).
|
||||
[server-side documentation](server.md#multi-round-trip-requests).
|
||||
|
||||
## Capabilities
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,59 @@
|
|||
|
||||
## `MCPGODEBUG` history
|
||||
|
||||
### 1.9.0
|
||||
|
||||
Options listed below were added and will be removed in the 1.11.0 version of the SDK.
|
||||
|
||||
- `structuredcontentfloat64` added. If set to `1`, numbers in
|
||||
`CallToolResult.StructuredContent` are decoded as `float64`, restoring the
|
||||
previous behavior. The default behavior decodes numbers as `json.Number` so
|
||||
that large integers and other exact JSON values do not silently lose
|
||||
precision.
|
||||
|
||||
### 1.8.0
|
||||
|
||||
Options listed below were added and will be removed in the 1.9.0 version of the SDK.
|
||||
|
||||
- `plaintextstatefulrejection` added. If set to `1`, a stateful
|
||||
`StreamableHTTPHandler` will respond with a plain-text `http.Error` 400 body
|
||||
when it receives a request carrying per-request metadata (i.e. an
|
||||
`io.modelcontextprotocol/protocolVersion` `_meta` field, or an
|
||||
`MCP-Protocol-Version` header >= `2026-07-28`), restoring the previous
|
||||
behavior. The default behavior was changed so that the server responds with a
|
||||
JSON-RPC error of code `CodeUnsupportedProtocolVersion` (`-32022`) carrying
|
||||
an `UnsupportedProtocolVersionData` payload that advertises the legacy
|
||||
versions the server supports. This lets the client's
|
||||
existing renegotiation logic recover and prevents the failure from tearing
|
||||
down the underlying connection.
|
||||
|
||||
- `blockingcancelnotify` added. If set to `1`, a cancelled call waits
|
||||
synchronously for the best-effort `notifications/cancelled` message to be
|
||||
delivered (up to `notifyCancellationTimeout`, currently 5 s) before
|
||||
returning to the caller, restoring the previous behavior. The delivery
|
||||
error is joined into the caller's returned error. The default behavior was
|
||||
changed so that the call is retired immediately and the notification is
|
||||
sent asynchronously off the caller's return path: the caller returns as
|
||||
soon as its context is cancelled and cannot be delayed by a slow or
|
||||
unresponsive peer. See issue #1150.
|
||||
|
||||
Options below were removed, according to plan:
|
||||
|
||||
- `seterroroverwrite`. `SetError` now always preserves existing `Content`.
|
||||
|
||||
- `enableoriginverification`. A nil
|
||||
`StreamableHTTPOptions.CrossOriginProtection` now always means no
|
||||
cross-origin protection. Wrap the handler with cross-origin protection
|
||||
middleware instead.
|
||||
|
||||
- `disablecontenttypecheck`. Content-Type validation on HTTP POST requests can
|
||||
no longer be disabled.
|
||||
|
||||
- `disablelocalhostprotection`, whose removal had been postponed to this
|
||||
release. Set the `DisableLocalhostProtection` field in
|
||||
`StreamableHTTPOptions` or `SSEOptions` to opt out of DNS rebinding
|
||||
protection.
|
||||
|
||||
### 1.7.0
|
||||
|
||||
Options listed below were added and will be removed in the 1.9.0 version of the SDK.
|
||||
|
|
@ -46,9 +99,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the
|
|||
requested method in STDIO transport is not found.
|
||||
|
||||
- `noprotocolerrorbody` added. If set to `1`, the streamable HTTP client will
|
||||
not attempt to decode the JSON-RPC error body of a non-2xx HTTP response,
|
||||
restoring the previous behavior. The default behavior was changed so that
|
||||
the client always attempts to surface the underlying JSON-RPC error.
|
||||
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
|
||||
|
|
|
|||
117
docs/protocol.md
117
docs/protocol.md
|
|
@ -2,13 +2,14 @@
|
|||
# Support for the MCP base protocol
|
||||
|
||||
1. [Lifecycle](#lifecycle)
|
||||
1. [Discovery (`server/discover`)](#discovery-(server/discover))
|
||||
1. [Per-request `_meta` keys](#per-request-meta-keys)
|
||||
1. [Per-response `_meta` keys](#per-response-meta-keys)
|
||||
1. [Subscriptions (`subscriptions/listen`)](#subscriptions-(subscriptions/listen))
|
||||
1. [Discovery](#discovery)
|
||||
1. [Per-request metadata keys](#per-request-metadata-keys)
|
||||
1. [Per-response metadata keys](#per-response-metadata-keys)
|
||||
1. [Subscriptions](#subscriptions)
|
||||
1. [Transports](#transports)
|
||||
1. [Stdio Transport](#stdio-transport)
|
||||
1. [Streamable Transport](#streamable-transport)
|
||||
1. [Legacy SSE Transport](#legacy-sse-transport)
|
||||
1. [Custom transports](#custom-transports)
|
||||
1. [Concurrency](#concurrency)
|
||||
1. [Authorization](#authorization)
|
||||
|
|
@ -110,7 +111,7 @@ func Example_lifecycle() {
|
|||
}
|
||||
```
|
||||
|
||||
### Discovery (`server/discover`)
|
||||
### Discovery
|
||||
|
||||
Introduced in `2026-07-28` by
|
||||
[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575),
|
||||
|
|
@ -127,7 +128,21 @@ request. Servers implementing `2026-07-28` MUST implement it.
|
|||
fails or the server does not support the latest version, the client falls back to the
|
||||
legacy `initialize` handshake.
|
||||
|
||||
### Per-request `_meta` keys
|
||||
A server advertises and negotiates every protocol version the SDK supports;
|
||||
set [`ServerOptions.SupportedProtocolVersions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions)
|
||||
to narrow that set, for example to stop serving a revision your deployment has
|
||||
retired.
|
||||
|
||||
```go
|
||||
server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v1.0.0"}, &mcp.ServerOptions{
|
||||
SupportedProtocolVersions: []string{"2026-07-28", "2025-11-25"},
|
||||
})
|
||||
```
|
||||
|
||||
The set can only narrow support, never widen it; the field's documentation
|
||||
covers how each protocol era answers a request at an excluded version.
|
||||
|
||||
### Per-request metadata keys
|
||||
|
||||
When the negotiated protocol version is `2026-07-28` or later, every request
|
||||
carries these keys inside its `_meta` map (constants live in
|
||||
|
|
@ -146,7 +161,7 @@ request, and populates `clientInfo` when configured with an `*Implementation`
|
|||
`ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, and
|
||||
`ServerRequest[P].ClientCapabilities()`.
|
||||
|
||||
### Per-response `_meta` keys
|
||||
### Per-response metadata keys
|
||||
|
||||
Under the same protocol version, servers SHOULD identify themselves on every
|
||||
response. The SDK populates this
|
||||
|
|
@ -156,7 +171,7 @@ key automatically on every outgoing response:
|
|||
|---|---|---|---|
|
||||
| `MetaKeyServerInfo` | `io.modelcontextprotocol/serverInfo` | `*Implementation` | No |
|
||||
|
||||
### Subscriptions (`subscriptions/listen`)
|
||||
### Subscriptions
|
||||
|
||||
Introduced in `2026-07-28` by
|
||||
[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575),
|
||||
|
|
@ -256,7 +271,8 @@ connect to the server:
|
|||
transport := &mcp.StreamableClientTransport{
|
||||
Endpoint: "http://localhost:8080/mcp",
|
||||
}
|
||||
client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...})
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1.0.0"}, nil)
|
||||
session, err := client.Connect(ctx, transport, nil)
|
||||
```
|
||||
|
||||
The `StreamableClientTransport` handles the HTTP requests and communicates with
|
||||
|
|
@ -341,6 +357,63 @@ _See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sd
|
|||
an example using stateless mode to implement a server distributed across
|
||||
multiple processes._
|
||||
|
||||
### Legacy SSE Transport
|
||||
|
||||
Before the streamable transport, the
|
||||
[2024-11-05](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports)
|
||||
spec defined an HTTP transport built from two endpoints: a hanging GET that
|
||||
streams server-to-client messages as server-sent events, and a per-session
|
||||
endpoint the client POSTs its messages to. The SDK still implements it, for
|
||||
talking to peers that predate the streamable transport. New deployments should
|
||||
use the [streamable transport](#streamable-transport) instead.
|
||||
|
||||
It is implemented across three types, mirroring the streamable ones:
|
||||
|
||||
- [`SSEHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEHandler):
|
||||
an `http.Handler` that creates a session per incoming GET and routes POSTs to
|
||||
the right session. Construct it with
|
||||
[`NewSSEHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#NewSSEHandler),
|
||||
which takes a function returning the `Server` to serve a given request.
|
||||
- [`SSEServerTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEServerTransport):
|
||||
one such session. Reach for it directly only when serving the two endpoints
|
||||
yourself; `SSEHandler` creates these for you.
|
||||
- [`SSEClientTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEClientTransport):
|
||||
the client side, which needs only the `Endpoint` of the GET.
|
||||
|
||||
```go
|
||||
func ExampleSSEHandler() {
|
||||
server := mcp.NewServer(&mcp.Implementation{Name: "adder", Version: "v0.0.1"}, nil)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "add", Description: "add two numbers"}, Add)
|
||||
|
||||
handler := mcp.NewSSEHandler(func(*http.Request) *mcp.Server { return server }, nil)
|
||||
httpServer := httptest.NewServer(handler)
|
||||
defer httpServer.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
transport := &mcp.SSEClientTransport{Endpoint: httpServer.URL}
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v1.0.0"}, nil)
|
||||
cs, err := client.Connect(ctx, transport, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
res, err := cs.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: "add",
|
||||
Arguments: map[string]any{"x": 1, "y": 2},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(res.Content[0].(*mcp.TextContent).Text)
|
||||
|
||||
// Output: 3
|
||||
}
|
||||
```
|
||||
|
||||
_See [examples/server/sse](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/sse/main.go)
|
||||
for a standalone server._
|
||||
|
||||
### Custom transports
|
||||
|
||||
The SDK supports [custom
|
||||
|
|
@ -566,24 +639,24 @@ provided to
|
|||
|
||||
### Server-Side Request Forgery
|
||||
|
||||
The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) are as follows:
|
||||
The OAuth discovery helpers apply these [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) by default:
|
||||
|
||||
- _Enforce HTTPS_. The OAuth helpers provided by the SDK reject the `http://` URLs
|
||||
except loopback addresses (`localhost`, `127.0.0.1`, `::1`).
|
||||
- _Enforce HTTPS_. Reject `http://` URLs (except loopback: `localhost`, `127.0.0.1`, `::1`)
|
||||
and redirects that downgrade `https` to a non-`https` scheme.
|
||||
|
||||
- _Block Private IP Ranges_. The OAuth helpers provided by the SDK allow passing
|
||||
a custom `http.Client`. Developers are advised to customize the client it with
|
||||
appropriate network protections, including IP range blocking. The SDK does not provide
|
||||
this capability out of the box.
|
||||
- _Block Private IP Ranges_. Reject targets that resolve to a non-public address
|
||||
(private, link-local including the `169.254.169.254` metadata endpoint, CGNAT, multicast,
|
||||
unspecified). The check runs on the initial URL and at dial time, guarding against DNS rebinding.
|
||||
|
||||
- _Validate Redirect Targets_. Similarly to previous point, customized `http.Client`
|
||||
can be used to validate network hops. The SDK does not provide this capability out
|
||||
of the box.
|
||||
- _Validate Redirect Targets_. Reject redirects to non-public addresses and cap the redirect count.
|
||||
|
||||
- _Use Egress Proxies_. This is out of scope for the SDK and can be configured separately.
|
||||
- _Use Egress Proxies_. Out of scope for the SDK; configure separately.
|
||||
|
||||
- _DNS Resolution Considerations_. The SDK has DNS rebinding protection on the server side which is enabled by default. For the client side, consider providing
|
||||
a custom `http.Client` that would implement DNS pinning.
|
||||
**Opting out.** Passing a custom `http.Client` can bypass these checks, making SSRF
|
||||
protection your responsibility. Specifically: a custom `Transport.DialContext`/`DialTLSContext`,
|
||||
a non-`*http.Transport`, or a configured `Proxy` disables the dial-time IP check; setting
|
||||
`CheckRedirect` replaces all redirect validation. In these cases, implement your own IP
|
||||
blocking, redirect validation, and DNS pinning.
|
||||
|
||||
### Session Hijacking
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
## Installation
|
||||
|
||||
```
|
||||
go get github.com/modelcontextprotocol/go-sdk
|
||||
go get github.com/modelcontextprotocol/go-sdk/mcp
|
||||
```
|
||||
|
||||
## Getting started
|
||||
|
|
|
|||
458
docs/server.md
458
docs/server.md
|
|
@ -2,8 +2,13 @@
|
|||
# Support for MCP server features
|
||||
|
||||
1. [Prompts](#prompts)
|
||||
1. [Prompt message content](#prompt-message-content)
|
||||
1. [Resources](#resources)
|
||||
1. [Binary resources](#binary-resources)
|
||||
1. [Resource subscriptions](#resource-subscriptions)
|
||||
1. [Tools](#tools)
|
||||
1. [Tool result content](#tool-result-content)
|
||||
1. [List changed notifications](#list-changed-notifications)
|
||||
1. [Multi Round-Trip Requests](#multi-round-trip-requests)
|
||||
1. [Talking to legacy clients](#talking-to-legacy-clients)
|
||||
1. [Example](#example)
|
||||
|
|
@ -115,6 +120,91 @@ func Example_prompts() {
|
|||
}
|
||||
```
|
||||
|
||||
### Prompt message content
|
||||
|
||||
A
|
||||
[`PromptMessage`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#PromptMessage)
|
||||
has two fields: a `Role`, either `"user"` or `"assistant"`, and a single
|
||||
`Content`. That is the same interface a tool result uses (see [Tool result
|
||||
content](#tool-result-content)), so a prompt message can hold an image, audio,
|
||||
or a resource's contents as readily as text.
|
||||
|
||||
Media on its own says nothing about what the model should do with it, so send a
|
||||
text message alongside it. Embedding a resource puts its contents directly in
|
||||
the message, sparing the client a separate `resources/read`. Note that
|
||||
`ResourceContents.URI` is not checked against the resources the server has
|
||||
registered: it records where the contents came from, so use the URI of a real
|
||||
resource when there is one, as in the example below.
|
||||
|
||||
```go
|
||||
func Example_promptContent() {
|
||||
ctx := context.Background()
|
||||
|
||||
const styleGuide = "- Prefer clarity over cleverness.\n"
|
||||
screenshotPNG := []byte("\x89PNG\r\n\x1a\n")
|
||||
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil)
|
||||
|
||||
s.AddResource(&mcp.Resource{URI: "doc://style-guide", MIMEType: "text/markdown"},
|
||||
func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
|
||||
return &mcp.ReadResourceResult{
|
||||
Contents: []*mcp.ResourceContents{{
|
||||
URI: "doc://style-guide",
|
||||
MIMEType: "text/markdown",
|
||||
Text: styleGuide,
|
||||
}},
|
||||
}, nil
|
||||
})
|
||||
|
||||
s.AddPrompt(&mcp.Prompt{Name: "review_screenshot"},
|
||||
func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
|
||||
return &mcp.GetPromptResult{
|
||||
Messages: []*mcp.PromptMessage{
|
||||
{Role: "user", Content: &mcp.EmbeddedResource{
|
||||
Resource: &mcp.ResourceContents{
|
||||
URI: "doc://style-guide",
|
||||
MIMEType: "text/markdown",
|
||||
Text: styleGuide,
|
||||
},
|
||||
}},
|
||||
{Role: "user", Content: &mcp.ImageContent{Data: screenshotPNG, MIMEType: "image/png"}},
|
||||
{Role: "user", Content: &mcp.TextContent{Text: "Review the screenshot against the style guide."}},
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
|
||||
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil)
|
||||
t1, t2 := mcp.NewInMemoryTransports()
|
||||
if _, err := s.Connect(ctx, t1, nil); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cs, err := c.Connect(ctx, t2, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
res, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{Name: "review_screenshot"})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, msg := range res.Messages {
|
||||
switch content := msg.Content.(type) {
|
||||
case *mcp.TextContent:
|
||||
fmt.Println(msg.Role, "text:", content.Text)
|
||||
case *mcp.ImageContent:
|
||||
fmt.Printf("%s image: %s, %d bytes\n", msg.Role, content.MIMEType, len(content.Data))
|
||||
case *mcp.EmbeddedResource:
|
||||
fmt.Printf("%s embedded resource: %s (%s)\n", msg.Role, content.Resource.URI, content.Resource.MIMEType)
|
||||
}
|
||||
}
|
||||
// Output:
|
||||
// user embedded resource: doc://style-guide (text/markdown)
|
||||
// user image: image/png, 8 bytes
|
||||
// user text: Review the screenshot against the style guide.
|
||||
}
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
In MCP terms, a _resource_ is some data referenced by a URI.
|
||||
|
|
@ -151,7 +241,7 @@ to be notified of changes to subscribed resources. On `2026-07-28` and
|
|||
later sessions the SDK delivers these notifications over a
|
||||
`subscriptions/listen` stream instead of the legacy `resources/subscribe`
|
||||
RPC; see [Subscriptions
|
||||
(`subscriptions/listen`)](protocol.md#subscriptions-subscriptionslisten)
|
||||
(`subscriptions/listen`)](protocol.md#subscriptions)
|
||||
for the wire-level details.
|
||||
|
||||
**Server-side**:
|
||||
|
|
@ -246,6 +336,145 @@ func Example_resources() {
|
|||
}
|
||||
```
|
||||
|
||||
### Binary resources
|
||||
|
||||
A
|
||||
[`ResourceContents`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceContents)
|
||||
carries its data in either `Text` or `Blob`. Binary data goes in `Blob` as
|
||||
plain bytes — the SDK base64-encodes it on the wire — and leaves `Text` empty,
|
||||
so a client tells the two apart by which field is populated.
|
||||
|
||||
```go
|
||||
func Example_binaryResource() {
|
||||
ctx := context.Background()
|
||||
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil)
|
||||
s.AddResource(&mcp.Resource{URI: "file:///logo.png", MIMEType: "image/png"},
|
||||
func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
|
||||
return &mcp.ReadResourceResult{
|
||||
Contents: []*mcp.ResourceContents{{
|
||||
URI: "file:///logo.png",
|
||||
MIMEType: "image/png",
|
||||
Blob: []byte("\x89PNG\r\n\x1a\n"),
|
||||
}},
|
||||
}, nil
|
||||
})
|
||||
|
||||
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil)
|
||||
t1, t2 := mcp.NewInMemoryTransports()
|
||||
if _, err := s.Connect(ctx, t1, nil); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cs, err := c.Connect(ctx, t2, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
res, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: "file:///logo.png"})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
contents := res.Contents[0]
|
||||
fmt.Printf("%s: %d bytes of %s, text is %q\n",
|
||||
contents.URI, len(contents.Blob), contents.MIMEType, contents.Text)
|
||||
|
||||
// Output:
|
||||
// file:///logo.png: 8 bytes of image/png, text is ""
|
||||
}
|
||||
```
|
||||
|
||||
### Resource subscriptions
|
||||
|
||||
A client interested in changes to one resource subscribes to its URI with
|
||||
[`ClientSession.Subscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Subscribe)
|
||||
and stops with
|
||||
[`ClientSession.Unsubscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Unsubscribe).
|
||||
Set
|
||||
[`ServerOptions.SubscribeHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.SubscribeHandler)
|
||||
and
|
||||
[`UnsubscribeHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.UnsubscribeHandler)
|
||||
to track who is listening; setting either one gives the server the
|
||||
`resources.subscribe` capability.
|
||||
|
||||
Publish a change with
|
||||
[`Server.ResourceUpdated`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.ResourceUpdated),
|
||||
which notifies every session subscribed to that URI. The notification carries
|
||||
only the URI, not the new contents, so a client that wants them reads the
|
||||
resource again.
|
||||
|
||||
```go
|
||||
func Example_resourceSubscription() {
|
||||
ctx := context.Background()
|
||||
|
||||
config := "theme=light\n"
|
||||
|
||||
updated := make(chan string, 1)
|
||||
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{
|
||||
// The notification carries only the URI, so read the resource again to
|
||||
// see what it now holds.
|
||||
ResourceUpdatedHandler: func(context.Context, *mcp.ResourceUpdatedNotificationRequest) {
|
||||
updated <- "config://app"
|
||||
},
|
||||
})
|
||||
|
||||
subscribed := make(chan string, 2)
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{
|
||||
SubscribeHandler: func(_ context.Context, req *mcp.SubscribeRequest) error {
|
||||
subscribed <- "subscribed to " + req.Params.URI
|
||||
return nil
|
||||
},
|
||||
UnsubscribeHandler: func(_ context.Context, req *mcp.UnsubscribeRequest) error {
|
||||
subscribed <- "unsubscribed from " + req.Params.URI
|
||||
return nil
|
||||
},
|
||||
})
|
||||
s.AddResource(&mcp.Resource{URI: "config://app"},
|
||||
func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
|
||||
return &mcp.ReadResourceResult{
|
||||
Contents: []*mcp.ResourceContents{{URI: "config://app", Text: config}},
|
||||
}, nil
|
||||
})
|
||||
|
||||
t1, t2 := mcp.NewInMemoryTransports()
|
||||
if _, err := s.Connect(ctx, t1, nil); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cs, err := c.Connect(ctx, t2, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
if err := cs.Subscribe(ctx, &mcp.SubscribeParams{URI: "config://app"}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(<-subscribed)
|
||||
|
||||
config = "theme=dark\n"
|
||||
if err := s.ResourceUpdated(ctx, &mcp.ResourceUpdatedNotificationParams{URI: "config://app"}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
uri := <-updated
|
||||
|
||||
res, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: uri})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Print("re-read: ", res.Contents[0].Text)
|
||||
|
||||
if err := cs.Unsubscribe(ctx, &mcp.UnsubscribeParams{URI: "config://app"}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(<-subscribed)
|
||||
|
||||
// Output:
|
||||
// subscribed to config://app
|
||||
// re-read: theme=dark
|
||||
// unsubscribed from config://app
|
||||
}
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
MCP servers can provide
|
||||
|
|
@ -297,7 +526,7 @@ In order to implement a tool, the user must do all of the following:
|
|||
- Unmarshal the input schema into a Go value
|
||||
- Execute the tool logic.
|
||||
- Marshal the tool's structured output (if any) to JSON, and store it in the
|
||||
result's `StructuredOutput` field as well as the unstructured `Content` field.
|
||||
result's `StructuredContent` field as well as the unstructured `Content` field.
|
||||
- Validate that output JSON against the tool's output schema.
|
||||
- If any tool errors occurred, pack them into the unstructured content and set
|
||||
`IsError` to `true.`
|
||||
|
|
@ -332,7 +561,7 @@ This does the following automatically:
|
|||
- Tool arguments are validated against the input schema.
|
||||
- Tool arguments are marshaled into the `In` value.
|
||||
- Tool output (the `Out` value) is marshaled into the result's
|
||||
`StructuredOutput`, as well as the unstructured `Content`.
|
||||
`StructuredContent`, as well as the unstructured `Content`.
|
||||
- Output is validated against the tool's output schema.
|
||||
- If an ordinary error is returned, it is stored int the `CallToolResult` and
|
||||
`IsError` is set to `true`.
|
||||
|
|
@ -417,6 +646,103 @@ _See [mcp/tool_example_test.go](https://github.com/modelcontextprotocol/go-sdk/b
|
|||
example, or [examples/server/toolschemas](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/toolschemas/main.go)
|
||||
for more examples of customizing tool schemas._
|
||||
|
||||
### Tool result content
|
||||
|
||||
Alongside its structured output, a tool result carries a list of
|
||||
[`Content`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Content)
|
||||
blocks in `CallToolResult.Content`, and may mix as many kinds as it needs:
|
||||
|
||||
| Type | Carries |
|
||||
| --- | --- |
|
||||
| [`TextContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#TextContent) | plain text |
|
||||
| [`ImageContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ImageContent) | image data, with a MIME type |
|
||||
| [`AudioContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#AudioContent) | audio data, with a MIME type |
|
||||
| [`EmbeddedResource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#EmbeddedResource) | the contents of a resource, inline |
|
||||
| [`ResourceLink`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceLink) | a reference to a resource the client can read separately |
|
||||
|
||||
`ImageContent.Data` and `AudioContent.Data` are plain `[]byte`; the SDK
|
||||
base64-encodes them on the wire, so handlers never encode by hand, and base64
|
||||
carried over from another MCP SDK must be decoded before it is assigned. An
|
||||
`EmbeddedResource` wraps a
|
||||
[`ResourceContents`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceContents),
|
||||
which holds either `Text` or, for binary data, `Blob`. Prefer a `ResourceLink`
|
||||
when the client may not need the contents, since an embedded resource is
|
||||
transferred whether it is used or not.
|
||||
|
||||
When a handler bound with `AddTool` leaves `Content` unset, the SDK fills it
|
||||
with the JSON encoding of the output value; set `Content` explicitly, as below,
|
||||
to return anything else. `StructuredContent` is still populated from the output
|
||||
value either way.
|
||||
|
||||
```go
|
||||
func ExampleAddTool_contentTypes() {
|
||||
chartPNG := []byte("\x89PNG\r\n\x1a\n")
|
||||
summaryWAV := []byte("RIFF....WAVE")
|
||||
|
||||
server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "report"}, func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, any, error) {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: "Q3 revenue by region."},
|
||||
&mcp.ImageContent{Data: chartPNG, MIMEType: "image/png"},
|
||||
&mcp.AudioContent{Data: summaryWAV, MIMEType: "audio/wav"},
|
||||
// An embedded resource inlines the contents, so the client
|
||||
// needs no follow-up resources/read. Binary contents go in
|
||||
// ResourceContents.Blob instead of Text.
|
||||
&mcp.EmbeddedResource{
|
||||
Resource: &mcp.ResourceContents{
|
||||
URI: "file:///reports/q3.csv",
|
||||
MIMEType: "text/csv",
|
||||
Text: "region,revenue\nEMEA,42\n",
|
||||
},
|
||||
},
|
||||
// A resource link only points at a resource. Prefer it when the
|
||||
// client may not need the contents, since an embedded resource
|
||||
// is transferred either way.
|
||||
&mcp.ResourceLink{URI: "file:///reports/q3.pdf", MIMEType: "application/pdf"},
|
||||
},
|
||||
}, nil, nil
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
session, err := connect(ctx, server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "report"})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Clients type-switch over the content they receive.
|
||||
for _, content := range res.Content {
|
||||
switch c := content.(type) {
|
||||
case *mcp.TextContent:
|
||||
fmt.Println("text:", c.Text)
|
||||
case *mcp.ImageContent:
|
||||
fmt.Printf("image: %s, %d bytes\n", c.MIMEType, len(c.Data))
|
||||
case *mcp.AudioContent:
|
||||
fmt.Printf("audio: %s, %d bytes\n", c.MIMEType, len(c.Data))
|
||||
case *mcp.EmbeddedResource:
|
||||
fmt.Printf("embedded resource: %s (%s)\n", c.Resource.URI, c.Resource.MIMEType)
|
||||
case *mcp.ResourceLink:
|
||||
fmt.Printf("resource link: %s (%s)\n", c.URI, c.MIMEType)
|
||||
}
|
||||
}
|
||||
// Output:
|
||||
// text: Q3 revenue by region.
|
||||
// image: image/png, 8 bytes
|
||||
// audio: audio/wav, 12 bytes
|
||||
// embedded resource: file:///reports/q3.csv (text/csv)
|
||||
// resource link: file:///reports/q3.pdf (application/pdf)
|
||||
}
|
||||
```
|
||||
|
||||
`ToolUseContent` and `ToolResultContent` also implement `Content`, but are only
|
||||
valid in sampling messages, not in tool results.
|
||||
|
||||
**Stateless server deployments:** Some deployments create a new
|
||||
[`Server`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server)
|
||||
for each incoming request, re-registering tools every time. To avoid repeated
|
||||
|
|
@ -434,6 +760,86 @@ func handleRequest(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
```
|
||||
|
||||
## List changed notifications
|
||||
|
||||
Adding or removing a feature on a connected server sends the matching
|
||||
`notifications/*/list_changed` to every client, which dispatches it to
|
||||
[`ClientOptions.ToolListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ToolListChangedHandler),
|
||||
[`PromptListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.PromptListChangedHandler),
|
||||
or
|
||||
[`ResourceListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ResourceListChangedHandler).
|
||||
A notification reports only that the list changed, so a client that needs the
|
||||
new contents lists them again.
|
||||
|
||||
These notifications ride on capabilities, and capabilities are inferred from
|
||||
what is registered before [`Server.Connect`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.Connect)
|
||||
(see [Capabilities](#capabilities)). A server that registers its features
|
||||
afterwards, as the example below does, has to declare
|
||||
`ServerOptions.HasTools`, `HasPrompts`, or `HasResources` itself, or the
|
||||
client is never told.
|
||||
|
||||
```go
|
||||
func Example_listChanged() {
|
||||
ctx := context.Background()
|
||||
|
||||
changed := make(chan string, 3)
|
||||
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{
|
||||
ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) {
|
||||
changed <- "tools"
|
||||
},
|
||||
PromptListChangedHandler: func(context.Context, *mcp.PromptListChangedRequest) {
|
||||
changed <- "prompts"
|
||||
},
|
||||
ResourceListChangedHandler: func(context.Context, *mcp.ResourceListChangedRequest) {
|
||||
changed <- "resources"
|
||||
},
|
||||
})
|
||||
|
||||
// Nothing is registered before Connect, so the capabilities that carry these
|
||||
// notifications have to be declared explicitly.
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{
|
||||
HasTools: true,
|
||||
HasPrompts: true,
|
||||
HasResources: true,
|
||||
})
|
||||
|
||||
t1, t2 := mcp.NewInMemoryTransports()
|
||||
if _, err := s.Connect(ctx, t1, nil); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cs, err := c.Connect(ctx, t2, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer cs.Close()
|
||||
|
||||
mcp.AddTool(s, &mcp.Tool{Name: "greet"}, func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, any, error) {
|
||||
return &mcp.CallToolResult{}, nil, nil
|
||||
})
|
||||
fmt.Println("changed:", <-changed)
|
||||
|
||||
s.AddPrompt(&mcp.Prompt{Name: "review"}, func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
|
||||
return &mcp.GetPromptResult{}, nil
|
||||
})
|
||||
fmt.Println("changed:", <-changed)
|
||||
|
||||
s.AddResource(&mcp.Resource{URI: "file:///a"}, func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
|
||||
return &mcp.ReadResourceResult{}, nil
|
||||
})
|
||||
fmt.Println("changed:", <-changed)
|
||||
|
||||
// Removing a feature notifies the client just as adding one does.
|
||||
s.RemoveTools("greet")
|
||||
fmt.Println("changed:", <-changed)
|
||||
|
||||
// Output:
|
||||
// changed: tools
|
||||
// changed: prompts
|
||||
// changed: resources
|
||||
// changed: tools
|
||||
}
|
||||
```
|
||||
|
||||
## Multi Round-Trip Requests
|
||||
|
||||
[SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)
|
||||
|
|
@ -569,27 +975,33 @@ results: clients use `ttlMs` as a freshness hint to reduce polling,
|
|||
and `cacheScope` (`"public"` or `"private"`) controls whether shared
|
||||
intermediaries may cache the response.
|
||||
|
||||
**Server-side defaults.** After paginating, the SDK sets `CacheScope = "public"`
|
||||
and leaves `TTLMs = 0` (which the client cache treats as "immediately
|
||||
stale"). A handler that wants its responses cached must set `TTLMs`
|
||||
explicitly on the returned result.
|
||||
**Server-side defaults.** The SDK fills in `CacheScope = "public"` whenever a
|
||||
result leaves it empty, since the field is required on the wire. Everything
|
||||
else is left as produced: a `ResourceHandler` that sets `TTLMs` or `CacheScope`
|
||||
on its `ReadResourceResult` keeps those values. The list results and
|
||||
`server/discover` are built by the SDK itself, so they start out with
|
||||
`TTLMs = 0`, which a client cache treats as "immediately stale".
|
||||
|
||||
**Setting a policy.** `ServerOptions.SetCacheable` decides the fields for every
|
||||
result that carries them. It runs after the corresponding handler, receives
|
||||
what that handler produced, and may keep, adjust, or replace it. The request is
|
||||
passed in so that policy can vary by method, session, or authorization context.
|
||||
|
||||
```go
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "expensive"}, func(...) (*mcp.CallToolResult, any, error) {
|
||||
// ...
|
||||
})
|
||||
// Override the default for tools/list:
|
||||
server.AddSendingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler {
|
||||
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
|
||||
res, err := next(ctx, method, req)
|
||||
if lr, ok := res.(*mcp.ListToolsResult); ok {
|
||||
lr.TTLMs = 30_000 // 30s cache freshness hint
|
||||
server := mcp.NewServer(impl, &mcp.ServerOptions{
|
||||
SetCacheable: func(_ context.Context, req mcp.Request, c *mcp.Cacheable) {
|
||||
// A 30s freshness hint, except where the handler chose its own.
|
||||
if c.TTLMs == 0 {
|
||||
c.TTLMs = 30_000
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`SetCacheable` may run with the server's lock held, so it must not call back
|
||||
into the `Server`: adding or removing a feature, or ranging over
|
||||
`Server.Sessions`, deadlocks.
|
||||
|
||||
## Utilities
|
||||
|
||||
### Completion
|
||||
|
|
@ -654,8 +1066,12 @@ server produces logs that remain server-side, for use by server maintainers.)
|
|||
**Server-side**:
|
||||
The minimum log level is part of the server state.
|
||||
For stateful sessions, there is no default log level: no log messages will be sent
|
||||
until the client calls `SetLevel` (see below).
|
||||
For stateful sessions, the level defaults to "info".
|
||||
until the client calls `SetLoggingLevel` (see below).
|
||||
For legacy stateless sessions the level defaults to "info".
|
||||
For sessionless 2026-07-28 requests there is no server-side default: each request
|
||||
carries its level in `_meta` (`io.modelcontextprotocol/logLevel`), and nothing is
|
||||
logged until the client sends one.
|
||||
(Note: logging is deprecated as of 2026-07-28 / SEP-2577.)
|
||||
|
||||
[`ServerSession.Log`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Log) is the low-level way for servers to log to clients.
|
||||
It sends a logging notification to the client if the level of the message
|
||||
|
|
@ -671,7 +1087,7 @@ Servers always report the logging capability.
|
|||
**Client-side**:
|
||||
Set [`ClientOptions.LoggingMessageHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.LoggingMessageHandler) to receive log messages.
|
||||
|
||||
Call [`ClientSession.SetLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLevel) to change the log level for a session.
|
||||
Call [`ClientSession.SetLoggingLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLoggingLevel) to change the log level for a session.
|
||||
|
||||
```go
|
||||
func Example_logging() {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ func main() {
|
|||
logger.Info("MCP method started",
|
||||
"method", method,
|
||||
"session_id", req.GetSession().ID(),
|
||||
"has_params", req.GetParams() != nil,
|
||||
"has_params", mcp.HasParams(req),
|
||||
)
|
||||
// Log more for tool calls.
|
||||
if ctr, ok := req.(*mcp.CallToolRequest); ok {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue