add extra checks for the default oauth discovery client:
* disallow redirects which downgrade to http
* disallow redirects to localhost or private ip
* disallow the initial call to a private ip
* proxy configuration opts out of dial time checking
The "server is closing" error produced when a connection shuts down
because its write side failed formatted the underlying write error with
`%v`, so the cause - typically `io.EOF` when a stdio host closes its
pipe - was not part of the error chain. A caller that wants to
distinguish a clean host disconnect from a real failure via
`errors.Is(err, io.EOF)` therefore always got `false`.
This changes both `s.writeErr` wrappings to use `%w`, preserving the
write error in the error chain while keeping the same message text
("server is closing: EOF").
A regression test exercises the write-failure branch of `shuttingDown`
and asserts both `errors.Is(err, ErrServerClosing)` and `errors.Is(err,
io.EOF)`.
Fixes#1098
---------
Co-authored-by: Bo Zhang <187063395+Tethys0@users.noreply.github.com>
Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
## Summary
Fixes#1152.
Sentry, Linear, and Notion omit
`authorization_response_iss_parameter_supported` from their
authorization-server metadata. Sentry still sends a correct `iss` in the
authorization response, so sign-in fails after the browser step: the
user has already consented and an authorization code has been issued,
but `validateIssuerResponse` rejects the exchange solely because the
server did not advertise RFC 9207 support.
RFC 9207 §2.4 says clients MUST extract and compare `iss` when the
parameter is present. §2.4 also notes that clients SHOULD discard an
unadvertised `iss`, but that is a recommendation and local policy may
accept servers that send `iss` without advertising. Validating a
matching `iss` is interoperable with those hosted MCP servers and still
catches mix-up attacks: a wrong `iss` is rejected whether or not support
was advertised.
When `iss` is present, this change always compares it to the expected
issuer (simple string equality). A match is accepted even if the server
did not advertise support. A mismatch still returns `authorization
response issuer %q does not match expected issuer %q`. Absence of `iss`
remains an error only when the server advertised support.
## Test plan
- [x] `go test ./auth/ -run TestValidateIssuerResponse -count=1`
- [x] `go test ./auth/...`
New `TestValidateIssuerResponse` cases:
- `UnadvertisedIssCorrect`: `iss == expectedIssuer`, `issSupported ==
false` → no error
- `UnadvertisedIssWrong`: `iss == "https://attacker.example.com"`,
`issSupported == false` → error containing `does not match expected
issuer`
Existing advertised+match / advertised+mismatch / missing-iss cases are
unchanged.
---------
Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
Hi — I am an AI agent (Claude), working as Anton Dzyatkovsky's synthetic
co-founder. Same author as #1159 (dead MRTR links in `client.md`), same
genre, but this one has a cause worth naming rather than five links
worth patching.
**AI disclosure** (per
[AI_POLICY.md](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/AI_POLICY.md)):
this contribution — the investigation, the diff, the test and this
description — was written by Claude Code running autonomously; Anton is
the accountable human and reachable on this thread. Every measurement
quoted below was produced by running the thing, not inferred: the id
tables are scraped from the two rendered surfaces, the test's slug
function was checked against 31 of GitHub's real ids, and the failure
output is a real run against the pre-fix tree. Any reply from this
account is written the same way unless it says otherwise. I edited only
`internal/docs/**` and regenerated, per `AGENTS.md`.
## What is broken
Five table-of-contents entries in `docs/protocol.md` point at anchors
that no heading produces. They are dead on **both** surfaces — GitHub's
render of the file, and the published site.
| ToC entry links to | heading actually gets |
|---|---|
| `#discovery-(server/discover)` | `discovery-serverdiscover` |
| `#per-request-meta-keys` | `per-request-_meta-keys` |
| `#per-response-meta-keys` | `per-response-_meta-keys` |
| `#subscriptions-(subscriptions/listen)` |
`subscriptions-subscriptionslisten` |
| `#sse-transport-(legacy)` | `sse-transport-legacy` |
Right-hand column is scraped, not derived: `user-content-*` ids from
GitHub's rendered `docs/protocol.md`, and `<h* id=...>` from
`https://go.sdk.modelcontextprotocol.io/protocol`. Four of the five are
dead on the live site too; `sse-transport-legacy` is not in the
currently deployed build, so I could only confirm that one on GitHub.
## Why
`%toc` and the renderer slug the same heading by different rules, and
they disagree on exactly the punctuation these headings use:
* **weave** keeps `(`, `)` and `/`, and drops `_`
* **GitHub and python-markdown's `toc`** drop `(`, `)` and `/`, and keep
`_`
So the two agree on plain words and part ways the moment a heading
carries a method name in parentheses or a `_meta` field. This is a
class, not five typos: any future heading with those characters gets a
dead ToC entry, silently — the docs regenerate fine, `docs-check`
passes, and only a reader clicking the ToC finds out.
Someone already hit this, by hand: `internal/docs/server.src.md` links
to `protocol.md#subscriptions-subscriptionslisten`, i.e. the real
renderer id, not the one the ToC uses. That cross-link is the odd one
out today; the ToC is what is wrong.
## The fix
The characters have to leave the headings, because weave computes the
ToC and it is not in this repo. Each of the five sections already names
its RPC or field in its first sentence, so the heading loses nothing:
| was | becomes | the name is still in the first sentence |
|---|---|---|
| Discovery (``server/discover``) | **Discovery** | "the
`server/discover` RPC lets a client discover…" |
| Per-request ``_meta`` keys | **Per-request metadata keys** | "carries
these keys inside its `_meta` map" |
| Per-response ``_meta`` keys | **Per-response metadata keys** |
"servers SHOULD identify themselves on every response" |
| Subscriptions (``subscriptions/listen``) | **Subscriptions** |
"`subscriptions/listen` replaces the legacy…" |
| SSE Transport (legacy) | **Legacy SSE Transport** | "Before the
streamable transport, the 2024-11-05 spec…" |
Plus the one hand-written cross-link in `server.src.md`, updated to
`protocol.md#subscriptions`.
**The cost, stated plainly:** renaming a heading changes its id, so any
external deep link to `#discovery-serverdiscover` or
`#per-request-_meta-keys` breaks. Those ids are only reachable today by
someone who scrolled and copied the permalink — the ToC never produced
them — so I think the trade is worth it, but it is your call. **The
alternative is to fix the slug in weave**
(`golang.org/x/example/internal/cmd/weave`) and leave the headings
alone. I am happy to take it there instead if you would rather keep the
ids; say the word and I will close this.
## The guard
`internal/docs/toc_test.go` — reads the generated docs and checks every
`%toc` entry against the id its heading will actually get. No network,
no new dependency.
The slug function in it is validated, not asserted: run over all 31
headings of the current `docs/protocol.md`, it reproduces **31 of 31**
of the ids GitHub actually emitted for that file (and 30 of 31 on the
live site — the miss is the not-yet-deployed SSE section above).
Behaviour on the pre-fix tree, which is the point of it:
```
--- FAIL: TestTOCAnchorsResolve
protocol.md: table-of-contents entry "Discovery (`server/discover`)" points at #discovery-(server/discover),
which no heading produces (want #discovery-serverdiscover); drop (, ), / and _ from the heading
... and the other four, each named
```
All five, by name, with the id they should have had. On this branch it
passes.
## Runs
`go generate ./...` → no diff (idempotent, so `docs-check` stays green)
· `go vet ./...` clean · `go build ./...` clean · `go test ./...` → 14
packages ok, 0 failures, 26 without tests.
Docs and one new test only; no SDK code touched.
Co-authored-by: Anton Dziatkovskii <194927794+tonydzi@users.noreply.github.com>
# Problem
WireError.Is backs errors.Is checks used across the SDK against
sentinels such as ErrMethodNotFound, ErrRejected, and ErrClientClosing,
but had no direct test. Add a table-driven test for it.
While doing so, errors.Is(err, (*WireError)(nil)) was found to panic on
a nil pointer dereference, because Is read w.Code without a nil check.
Guard against a nil *WireError target so the comparison returns false.
```go
errors.Is(err, (*WireError)(nil)) // panic: nil pointer dereference
```
# Approach
Is read w.Code after a successful type assertion without checking
whether w itself was nil. The fix guards against a nil *WireError target
so the comparison returns false instead of panicking.
# Test plan
- go test ./internal/jsonrpc2/ passes.
- The new typed nil WireError case panics without the one-line fix and
passes with it, confirming the test exercises the guarded path.
Co-authored-by: Yaroslav <yarolegovich@gmail.com>
Closes#1100.
## What
`handleAsync` in `internal/jsonrpc2/conn.go` reported *why* an in-flight
request had been canceled by inspecting the connection-level
`s.writeErr`, guarded by a TODO waiting on golang/go#51365:
```go
// Only deliver to the Handler if not already canceled.
if err := req.ctx.Err(); err != nil {
c.updateInFlight(func(s *inFlightState) {
if s.writeErr != nil {
// Assume that req.ctx was canceled due to s.writeErr.
// TODO(#51365): use a Context API to plumb this through req.ctx.
err = fmt.Errorf("%w: %v", ErrServerClosing, s.writeErr)
}
})
...
}
```
That API shipped as `context.WithCancelCause` / `context.Cause` in Go
1.21, and this module already targets Go 1.25, so the TODO is now
actionable.
This PR carries the cancellation cause on each request's context instead
of inferring it from global state:
- `incomingRequest.cancel` becomes a `context.CancelCauseFunc` (built
via `context.WithCancelCause`).
- The write-failure and read-side teardowns cancel each in-flight
request with a cause wrapping `ErrServerClosing`.
- The remaining cancel sites (peer cancel via `Cancel`, normal
completion) pass `nil`.
- `handleAsync` reads `context.Cause(req.ctx)` instead of looking up
`s.writeErr`.
## Why
Besides resolving the TODO, this fixes a latent mis-attribution.
`s.writeErr` is connection-level state, not per-request: a request
canceled for an unrelated reason (e.g. a peer cancel notification via
`Cancel`) was reported as `ErrServerClosing` whenever `s.writeErr`
happened to be non-nil at that moment. Tying the cause to the specific
request's context removes that coupling.
`ctx.Err()` still returns `context.Canceled` in all cases, so external
behavior is unchanged; only the reported cause becomes accurate and
per-request.
## Test plan
- `go build ./...`
- `go test ./internal/... ./mcp/` (including the longer `mcp`
integration tests) — all pass.
- Verified `context.WithCancelCause` semantics: with a cause, `Err()`
stays `context.Canceled` while `Cause()` returns the wrapped
`ErrServerClosing`; with `nil`, both resolve to `context.Canceled` —
matching prior behavior.
## Note
Per the discussion in #1100, the read-side teardown also carries a
descriptive cause (wrapping `ErrServerClosing`) rather than a bare
`context.Canceled`.
---------
Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
Co-authored-by: Yaroslav <yarolegovich@gmail.com>
### What was broken
Four links in `docs/client.md` point at a section that does not exist.
Three of them sit right where a reader needs them — at the end of the
**Roots**, **Sampling**
and **Elicitation** sections, on the sentence explaining that from
protocol version
`2026-07-28` these requests are delivered via the Multi Round-Trip
Requests pattern:
```
[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr)
```
`protocol.md` has no MRTR section. Following the link drops you at the
top of a page that
never mentions the pattern — while the explanation you were promised is
one screen further
down the *same* page you were already on (`client.md` → *Multi
Round-Trip Requests*).
The fourth one is in that MRTR section itself, sending you to the server
side:
```
[server-side documentation](server.md#multi-round-trip-requests-mrtr).
```
`server.md` does have the section, but its heading is `## Multi
Round-Trip Requests`, so the
slug is `#multi-round-trip-requests` — the trailing `-mrtr` is stale.
### How I checked
Against the published site, not just the source:
```
$ curl -sL https://go.sdk.modelcontextprotocol.io/protocol | grep -o 'id="[^"]*round[^"]*"'
# (no output)
$ curl -sL https://go.sdk.modelcontextprotocol.io/client | grep -o 'id="[^"]*round[^"]*"'
id="multi-round-trip-requests"
$ curl -sL https://go.sdk.modelcontextprotocol.io/server | grep -o 'id="[^"]*round[^"]*"'
id="multi-round-trip-requests"
```
So `/protocol` carries no anchor containing `round` at all, and both
live targets use the
plain slug.
`git log -S` says the links arrived with b9e6fc5 ("mcp: add
documentation for new protocol
version", #1019, 2026-06-23) and that `protocol.src.md` has never
contained a
`Multi Round-Trip Requests` heading — the section landed in
`client.src.md` and
`server.src.md` instead, and these four references were never repointed.
I also ran a small link/anchor checker over `docs/`: 4 bad anchors
before, 0 after. (Two
"broken links" it also reports in `server.md` are its own false
positives — Go generics
inside a fence, `jsonschema.For[WeatherInput](opts)`, read as markdown
link syntax. Not
touched.)
### What it is now
The three per-feature links become same-page references, matching how
`client.md` already
links its own sections (`(see [Capabilities](#capabilities))`):
```
[Multi Round-Trip Requests](#multi-round-trip-requests)
```
and the cross-page one drops the stale suffix:
```
[server-side documentation](server.md#multi-round-trip-requests).
```
Change is in `internal/docs/client.src.md`; `docs/client.md` regenerated
with
`go generate ./...`, both committed together. Locally `go generate
./...` leaves the tree
clean afterwards, which is what `docs-check` asserts. Docs-only, four
link targets, no prose
and no code paths touched.
---
Assisted-by: Claude Opus 5 — this PR was drafted and verified by Anton's
AI cofounder running
on his account; every command and output above is from a real run on
this machine, not a
reconstruction.
Co-authored-by: tonydzi <dzyatkovskiy.a@gmail.com>
### What was broken
`docs/quick_start.md` → *Installation* says:
```
go get github.com/modelcontextprotocol/go-sdk
```
The module root holds no importable package (only `copyright_test.go`),
so `go get` on the
module path records the requirement as `// indirect` and writes none of
the `go.sum` entries
the `mcp` package needs. A reader who runs that command and then pastes
the server snippet
from the next section hits six errors on the first build — before
writing a line of their own.
### How I checked
Followed the page verbatim on Go 1.26.4, in an empty directory:
```
$ go mod init example.com/qs
$ go get github.com/modelcontextprotocol/go-sdk
go: added github.com/modelcontextprotocol/go-sdk v1.7.0
```
`go.mod` now reads `require github.com/modelcontextprotocol/go-sdk
v1.7.0 // indirect`.
Then the two snippets from *Getting started*, saved as `server/main.go`
and `client/main.go`:
```
$ go build ./...
missing go.sum entry for module providing package github.com/google/jsonschema-go/jsonschema (imported by github.com/modelcontextprotocol/go-sdk/mcp)
missing go.sum entry for module providing package golang.org/x/oauth2 (imported by github.com/modelcontextprotocol/go-sdk/mcp)
missing go.sum entry for module providing package github.com/segmentio/encoding/json (imported by github.com/modelcontextprotocol/go-sdk/internal/json)
missing go.sum entry for module providing package github.com/yosida95/uritemplate/v3 (imported by github.com/modelcontextprotocol/go-sdk/mcp)
missing go.sum entry for module providing package golang.org/x/sync/errgroup (imported by github.com/modelcontextprotocol/go-sdk/mcp)
missing go.sum entry for module providing package golang.org/x/time/rate (imported by github.com/modelcontextprotocol/go-sdk/mcp)
```
Same directory, same snippets, asking for the package instead:
```
$ go get github.com/modelcontextprotocol/go-sdk/mcp
$ go build -o myserver ./server && go build -o myclient ./client
$ ./myclient
{"greeting":"Hi you"}
```
`go mod tidy` after the fact fixes it too — that is the escape hatch the
note mentions,
but it is not what the page tells you to do.
### What it is now
```
go get github.com/modelcontextprotocol/go-sdk/mcp
```
plus four lines saying why the module path alone is not enough, so the
next person who
copies the module path from `go.mod` or from a search result knows what
they are looking at.
Change is in `internal/docs/quick_start.src.md`; `docs/quick_start.md`
regenerated with
`go generate ./internal/docs`, both committed together. Docs-only, no
code paths touched.
---
Assisted-by: Claude Opus 5 — this PR was drafted and verified by Anton's
AI cofounder running
on his account; every command and output above is from a real run on
this machine, not a
reconstruction.
---------
Co-authored-by: Palo-Alto-AI-Research-Lab <Palo-Alto-AI-Research-Lab@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
### What was broken
`docs/protocol.md` → *Streamable Transport* → client side shows:
```go
client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...})
```
There is no top-level `mcp.Connect`. The package has
`(*Client).Connect`, whose
third parameter is `*ClientSessionOptions`, not `*ClientOptions`. So the
one
snippet a reader reaches for when wiring up the client side of
streamable HTTP
does not build.
It is also the only outlier in the docs: `quick_start.md`, `client.md`,
`server.md`, `troubleshooting.md` and `protocol.md` itself (lines 470,
538) all
already use `client.Connect(ctx, transport, nil)`.
### How I checked
Pasted the snippet verbatim into a fresh module requiring the released
SDK
(`github.com/modelcontextprotocol/go-sdk v1.7.0`):
```
$ go build ./...
./broken.go:15:21: undefined: mcp.Connect
```
Then ran the proposed replacement end to end against a real streamable
server
(`mcp.NewStreamableHTTPHandler` behind `httptest.NewServer`), connecting
and
calling a tool over the session:
```
BUILD OK
tool result: {"greeting":"Hi you"}
```
### What it is now
```go
transport := &mcp.StreamableClientTransport{
Endpoint: "http://localhost:8080/mcp",
}
client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1.0.0"}, nil)
session, err := client.Connect(ctx, transport, nil)
```
Change is in `internal/docs/protocol.src.md`; `docs/protocol.md`
regenerated with
`go generate ./internal/docs`, both committed together. Docs-only, no
code paths
touched.
---
Assisted-by: Claude Opus 5 — this PR was drafted and verified by Anton's
AI
cofounder running on his account; the commands and outputs above are
from real
runs, not reconstructions.
Co-authored-by: Anton Dzyatkovsky <dzyatkovskiy.a@gmail.com>
This PR updates the error codes to align with the updated specifications
in
https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2907
ErrServerClosing and ErrClientClosing are reverted to their 1.6.0
version codes, as they do not overlap anymore with the newly introduced
error codes.
To guarantee a better compatibility with legacy server, enable the
fallback to the legacy initialize rpc in case of any error returned by
the server, in case of `server/discover` request.
## Problem
`AuthorizationCodeHandler.exchangeAuthorizationCode` builds the
long-lived token source from the **request context** passed to
`Authorize()`:
```go
clientCtx := context.WithValue(ctx, oauth2.HTTPClient, h.config.Client)
...
h.tokenSource = cfg.TokenSource(clientCtx, token)
```
`golang.org/x/oauth2` captures that context and reuses it for **every**
future refresh:
- `oauth2.go`: `Config.TokenSource(ctx, …)` stores `ctx` in
`tokenRefresher{ctx}`
- `tokenRefresher.Token()` (no ctx arg) calls `retrieveToken(tf.ctx, …)`
- `internal/token.go`: `ContextClient(ctx).Do(req.WithContext(ctx))`
`Authorize()` is normally invoked from a request- or connect-scoped
context — e.g. the streamable client transport calls it during
`setMCPHeaders`/`initialize`, and that context is cancelled once the
operation completes. The cached access token works until expiry; the
**first refresh after expiry** then fails instantly with `context
canceled`, before any HTTP request is sent. The connection can no longer
refresh.
`setMCPHeaders` only re-invokes `Authorize()` for an `invalid_grant`
`oauth2.RetrieveError` (#917). A `context canceled` is not a
`RetrieveError`, so it is never recovered — the connection is wedged
until the client reconnects.
### Reproduce
1. Connect a `StreamableClientTransport` with an
`AuthorizationCodeHandler` to a server whose `initialize` returns 401
(so `Authorize()` runs during connect, under the connect context).
2. Let the connect context be cancelled after connect returns (the
common pattern: a `context.WithTimeout(parent, …)` + `defer cancel()`
bounding the interactive flow).
3. Wait for the access token to expire and issue a request.
4. Every request fails with `Post "<token endpoint>": context canceled`
and `dur≈0` — the refresh never hits the network.
## Fix
A token source outlives the request that created it, so bind its
refreshes to `context.Background()` (still carrying the configured HTTP
client via `oauth2.HTTPClient`). The one-shot token exchange keeps using
the request context.
```go
refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, h.config.Client)
h.tokenSource = cfg.TokenSource(refreshCtx, token)
```
`auth` package tests pass. Behaviour is otherwise unchanged: the same
HTTP client value is carried; only the cancellation parent differs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
## Summary
Fixes#976.
`DecodeMessage` used `msg.Method != ""` to classify messages. A wire
payload with `"method":""` was decoded as a **response**, so stdio
servers dropped it without a correlated JSON-RPC error.
## Approach
Decode into a `wireDecode` struct whose `method` field is
`json.RawMessage`. Treat the message as a **request** when the `method`
key is **present on the wire** (including `""`), which matches JSON-RPC
semantics.
This revision:
- Uses a **single JSON parse** per message (dedicated decode struct, no
secondary fields-map unmarshal)
- Documents why `json.RawMessage` is used for `method`
- Adds regression coverage so normal responses and id-only payloads
without a `method` key stay on the response path
## Test plan
- [x] `go test ./internal/jsonrpc2/... -v`
- [x] `go test ./mcp/... -run TestIOConnRead_EmptyMethod -v`
- [x] `go test ./... -count=1`
- [x] `go test -race ./internal/jsonrpc2/... -count=1`
- [x] Server conformance (`npx @modelcontextprotocol/conformance server
--url http://127.0.0.1:3000/mcp`) — 40 passed, 0 failed
- [x] Client conformance (`./scripts/client-conformance.sh`) — 217
passed, 0 failed
## Scope note
This PR addresses the primary repro in #976 (empty `method` with valid
`id`). Other cases listed in the issue (`id: null`, structurally invalid
envelopes) are unchanged.
Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
## Summary
Part 3 of the SEP-2575 ("Make MCP Stateless") implementation. Builds on
PR #975 which landed the client-side discover/fallback flow and the
per-request `_meta` plumbing. This PR adds the **server-side**
counterparts:
- `server/discover` handler (replaces the
stub),ProtocolVersionSupporter`
- transport hook for per-transport version filtering,
- transport-agnostic version validation and method-removal enforcement
in `ServerSession.handle`,
- HTTP-level wire compliance for SEP-2575 error codes/status codes,
- bug fix in `internal/jsonrpc2`,`processResult` with an `MCPGODEBUG`
opt-out for backward compatibility.
Implements the client half of the SEP-2575 / `2026-06-30` protocol. This
CL is client-only; server-side `server/discover` is still a
`MethodNotFound` stub.
### Changes
- **`server/discover` probe in `Client.Connect()`** (`client.go`). When
the caller opts into `protocolVersion >= 2026-06-30`, the client probes
the server with `server/discover` before falling back to the legacy
`initialize`
handshake.
- **Per-request `_meta` injection** (`shared.go`, `injectRequestMeta`).
For`>= 2026-06-30` sessions every outgoing request gets the SEP-2575
triple (`protocolVersion`, `clientInfo`, `clientCapabilities`).
- **Server-side fix**: `ServerSession.handle` no longer pre-populates
`state.InitializeParams` from a `server/discover` request's `_meta`, so
a subsequent fallback `initialize` is not rejected as a duplicate.
### Fallback rules
Client falls back to the legacy `initialize` handshake when discover
returns:
- `MethodNotFound` (-32601),
- `UnsupportedProtocolVersionError` (-32004),
- an HTTP `Bad Request` (substring-matched on the error message, to
interop
with pre-2026-06-30 servers).
Fixes#966
This PR functions as a Reference implementation of
[SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)
/ [RFC9207](https://datatracker.ietf.org/doc/rfc9207/).
This PR hardens the MCP OAuth Client functionality against Mix-Up
attacks:
> Mix-up attacks aim to steal an authorization code or access token by
> tricking the client into sending the authorization code or access
> token to the attacker instead of the honest authorization or resource
> server
This PR hardens the client by adding support for a new `iss` parameter
in authorization responses:
- Authorization Servers broadcast support for the `iss` parameter via
the `authorization_response_iss_parameter_supported` metadata parameter
- If the parameter is supported, clients expect to receive the `iss`
parameter in the authorization response
- Clients compare the `iss` parameter in the authorization response to
the `Issuer` parameter in the authorization metadata. The two must match
exactly for the response to be processed.
Fixes#941
`ReadOnlyHint` is a `bool` with omitempty, so the zero value (false) is
indistinguishable from unset, and **drops out** of marshaled JSON.
The current behavior causes an issue with the OpenAI MCP app submission
because they require explicit hints.
Consumers that explicitly set `ReadOnlyHint: false` on write tools lose
the field on the wire. Removing `omitempty` ensures `false` is always
serialized, which matches the MCP spec default.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Maciej Kisiel <mkisiel@google.com>
Co-authored-by: guglielmoc <guglielmoc@google.com>
Change the error code returned by ResourceNotFoundError from -32002 to
-32602 (jsonrpc.CodeInvalidParams), aligning with
[SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164).
* Not gated with a protocol version check, because the previous error
code was never formally specified and the behaviour varied across SDKs.
* In case clients which rely on the old error code exist, the previous
behaviour can be restored with `MCPGODEBUG=customresnotfounderrcode=1`.
* `CodeResourceNotFound` deprecated since its only purpose going forward
is backward compatibility. The new code should use
`jsonrpc.CodeInvalidParams` directly.
* `CodeResourceNotFound` changed to `var` so it can stay in sync with
`ResourceNotFoundError` and existing code doing `code ==
mcp.CodeResourceNotFound` keeps working in both the default and compat
modes.
## Summary
- Add `ClientCredentialsHandler` implementing `auth.OAuthHandler` using
the OAuth 2.0 Client Credentials grant (RFC 6749 Section 4.4) for
service-to-service authentication with pre-registered credentials.
- Bypasses both dynamic client registration and the authorization code
flow — the handler takes a client ID + secret directly and exchanges
them at the token endpoint.
- Supports two modes: direct `TokenEndpoint` URL, or metadata discovery
via `AuthServerURL` (RFC 8414).
- Add `client_credentials` grant type support to the fake authorization
server in `internal/oauthtest` for testing.
## Context
Per @jba's [comment on
#627](https://github.com/modelcontextprotocol/go-sdk/issues/627#issuecomment-3640982691):
> Our default implementation for that framework should support client ID
and client secret as options, to handle that variant. [...] If those are
set, our implementation would bypass dynamic client reservation. That is
just a couple of lines of code, I believe.
The client-side OAuth scaffolding (#785) that this was blocked on is now
complete, as @brkane noted.
Implements the client credentials variant of
[SEP-1046](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1046).
The JWT Assertions variant (RFC 7523) is left for a follow-up as its API
surface needs more design discussion.
## Test plan
- [x] `TestNewClientCredentialsHandler_Validation` — validates all
config error cases (nil config, missing fields, mutual exclusivity)
- [x] `TestClientCredentialsHandler_Authorize/direct_token_endpoint` —
end-to-end with fake auth server
- [x] `TestClientCredentialsHandler_Authorize/metadata_discovery` —
discovers token endpoint via RFC 8414 metadata
- [x] `TestClientCredentialsHandler_Authorize/bad_credentials` —
verifies failure with wrong secret
- [x] `go test ./... -count=1` passes
- [x] `go vet ./...` clean
Refs #627
Co-authored-by: Maciej Kisiel <mkisiel@google.com>