mcp: add configurable keepalive failure threshold
Introduce `KeepAliveFailureThreshold` option in both `ClientOptions` and
`ServerOptions` to control how many consecutive keepalive ping failures
are tolerated before closing the session.
This aligns with the MCP spec's guidance that "multiple failed pings MAY
trigger a connection reset," allowing operators to tune resilience
against transient network hiccups without immediately tearing down
otherwise healthy sessions.
A threshold of 0 or 1 (the default) closes on the first failure,
preserving existing behavior. Higher values let isolated misses pass
while still closing the session once consecutive failures reach the
threshold. A successful ping resets the counter.
Tolerated failures are logged at WARN level; the final failure that
closes the session is logged at ERROR level.
This is rework of #979.
### Context
Design (`design/mrtr.md`) and implementation proposal for Multi
Round-Trip Requests (MRTR) per
[SEP-2322](de6d76fba3/seps/2322-MRTR.md).
### Changes
- Introduce `InputRequest` and `InputResponse` sealed interfaces with
corresponding `InputRequestMap` and `InputResponseMap` types for custom
map JSON codec implementation.
- Extend `CallToolParams`, `GetPromptParams`, and `ReadResourceParams`
with `InputResponses`, `RequestState` and an unexported `resultType`
("complete" or "input_required") for retry round-trips. The type is set
by the SDK based on input requests presence.
- Add client-side MRTR middleware enabled by default that automatically
fulfills input requests and retries.
- Added MRTROptions on ClientOptions to allow disabling the middleware
and configuring the number of retries.
- Add server-side MRTR middleware for backward compatibility:
transparently bridges input requests to direct
Elicit/CreateMessage/ListRoots calls for older clients
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
## Description
This PR lays the foundational server-side groundwork for the `>=
2026-06-30` sessionless and stateless feature introduced by
[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)
and SEP-2567, tracked in
[design/stateless.md](614460a225/design/stateless.md).
### SEP-2575: Stateless MCP
* **Per-request protocol detection in `ServerSession.handle()`**
Unmarshal `_meta` from the raw JSON-RPC params determines whether each
request follows the new protocol.
* **Per-request typed accessors on `ServerRequest[P]`**
```go
func (r *ServerRequest[P]) ProtocolVersion() string
func (r *ServerRequest[P]) ClientInfo() *Implementation
func (r *ServerRequest[P]) ClientCapabilities() *ClientCapabilities
```
* **Reject client->server `initialize`, `initialized` and `ping` for
new-protocol requests**
* **Per-request `_meta` field name constants**
Three constants for the wire-protocol field names
(`MetaKeyProtocolVersion`, `MetaKeyClientInfo`,
`MetaKeyClientCapabilities`)
* **Stop synthesizing fake `InitializeParams` for new-protocol
requests**
Fixes: #966
`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>
## Summary
- reject a second `initialize` request on an already initialized server
session
- keep the original `ServerSession.InitializeParams()` instead of
replacing it with later client parameters
- add a raw JSON-RPC regression test for the duplicate-initialize path
Fixes#961.
## To verify
- `go test ./mcp -run TestServerRejectsDuplicateInitialize -count=1`
- `go test ./mcp -count=1`
- `go test ./...`
---------
Co-authored-by: guglielmoc <guglielmoc@google.com>
oauthex/auth_meta.go: fixes metadata issuer comparison to ignore
trailing slash difference
Google's gmail MCP server returns `https://accounts.google.com/` as the
Authorization Server in the Protected Resource Metadata, but returns
`https://accounts.google.com` in the Auth Server Metadata. This causes a
comparison failure per rfc8414 section-3.3: Authorization Server
Metadata Validation. The authorization server URL is meant to be a base
to build other URLs from, it does not seem necessary to enforce that the
trailing slash (or lack thereof) matches with the issuer URL. Please see
#953 for more details.
Fixes#953
---------
Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
Fixes#937.
### What this changes
Adds a single line — an SSE comment (`: ok\n\n`) — after `WriteHeader`
and before `Flush` on the standalone SSE GET stream. SSE comments are
explicitly ignored by clients (any line starting with `:` per the SSE
spec), but they produce an HTTP/2 DATA frame, which is what HTTP/2
reverse proxies need before they will forward the HEADERS frame.
### Why the existing fix isn't sufficient
Issue #410 was closed by #413, which added `WriteHeader(StatusOK)` +
`Flush()` to the same site. #870 refactored the `Flush()` to use
`http.NewResponseController`. Both correctly address HTTP/1.1: `Flush()`
writes the headers as text on the TCP stream and any HTTP/1.1-aware
proxy forwards them.
In HTTP/2, headers and body are separate frame types (HEADERS, DATA).
Reverse proxies batch them for efficiency and won't forward the HEADERS
frame on its own. `Flush()` on the response writer only pushes the
in-process buffer to the HTTP/2 stack; the proxy still holds the HEADERS
frame waiting for DATA. The standalone SSE stream never sends body data
on connect (the whole point — it's the long-poll listener for
server-pushed notifications), so without a deliberate "kick" the headers
never reach the client until the proxy tears down the stream on timeout.
The SSE-comment trick is the established mitigation for SSE servers
behind HTTP/2 proxies. It's used in Caddy
(https://github.com/caddyserver/caddy/issues/4247), referenced in the Go
issue tracker (https://github.com/golang/go/issues/31125), and is
independent of the proxy implementation.
### The diff
```go
if s.id == "" {
// Issue #410: the standalone SSE stream is likely not to receive messages
// for a long time. Ensure that headers are flushed.
+ //
+ // On HTTP/2, headers and body travel as separate frames (HEADERS and
+ // DATA). Reverse proxies (e.g. Envoy, Caddy, net/http/httputil)
+ // commonly buffer the HEADERS frame until they have a DATA frame to
+ // coalesce it with — there is no HTTP/2 equivalent of HTTP/1.1's
+ // Transfer-Encoding: chunked signal that says "this is streaming, send
+ // headers now". Calling Flush() alone is not sufficient: it pushes
+ // the kernel buffer to the proxy, but the proxy still holds the
+ // HEADERS frame.
+ //
+ // Write an SSE comment (lines starting with ":" are ignored by
+ // clients per RFC) so a DATA frame is produced, which forces the
+ // proxy to forward both frames. See:
+ // https://github.com/golang/go/issues/31125
+ // https://github.com/caddyserver/caddy/issues/4247
w.WriteHeader(http.StatusOK)
+ fmt.Fprint(w, ": ok\n\n")
rc := http.NewResponseController(w)
// Ignore returned error as flushing is best-effort.
_ = rc.Flush()
}
```
`fmt` is already imported in this file; no other dependencies change.
### Test
Added `TestStandaloneSSEEmitsCommentForHTTP2Flush`. It initializes a
streamable session via raw POST, then opens the standalone SSE GET and
asserts the first body byte starts with `:` (the SSE comment marker,
which is what produces the DATA frame HTTP/2 proxies need).
The test catches the bug behaviorally without requiring a full HTTP/2
reverse-proxy setup: it verifies the SDK's contract (emit body bytes
after headers on the standalone SSE stream). HTTP/2-specific behavior is
then a property of any conforming proxy.
I confirmed the test fails on `main` without the patch — it times out at
2s waiting for the first body byte — and passes in <1ms with the patch.
Full streamable test suite still passes.
### Reproduction matrix (from #937)
| Configuration | TTFB | Result |
|---|---|---|
| HTTP/2 + 30s request timeout | ~31s | Headers only on stream tear-down
|
| HTTP/2 + no request timeout | never | Headers never arrive |
| HTTP/1.1 + 30s request timeout | ~300ms | Headers immediate |
| HTTP/1.1 + no request timeout | ~270ms | Headers immediate |
| HTTP/2 POST (response has body) | ~350ms | Works (DATA frame
coalesces) |
| **HTTP/2 + this PR** | ~1ms | Headers immediate |
### Risk
- Behavior-preserving for HTTP/1.1 clients (a leading `:` line is a
valid SSE comment and is ignored by every conforming SSE client).
- No protocol-level concern: SSE explicitly defines comment lines as a
no-op for the consumer.
- The DATA frame adds 7 bytes per standalone SSE connection, sent once
on connect.
Co-authored-by: Maciej Kisiel <mkisiel@google.com>
Replaces the simple time-interval check in `LoggingHandler` with a
standard `rate.Limiter` from `golang.org/x/time/rate`, as suggested by
the existing TODO comment.
This provides a well-tested implementation and removes the need for
manual `lastMessageSent` tracking. The rate (`1/MinInterval`) and burst
(1) match the previous behavior exactly.
/cc @jba
Closes#924
---------
Signed-off-by: wucm667 <stevenwucongmin@gmail.com>
Co-authored-by: Jonathan Amsterdam <jba@users.noreply.github.com>
Co-authored-by: Maciej Kisiel <mkisiel@google.com>
## Bug
`mcp.AddTool` crashes the server with SIGSEGV when input/output struct
tags trigger a nil schema.
## Root Cause
In `mcp/server.go`, the function `setSchema` calls
`internalSchema.Resolve(...)` without checking if `internalSchema` is
nil. If the schema inference (e.g., via `jsonschema.ForType`) or a
provided schema results in a nil value, this dereference causes a panic.
## Fix
Added nil checks for `internalSchema` before calling `Resolve` in two
locations within `setSchema`:
1. After schema generation via `jsonschema.ForType`.
2. After processing a provided schema (which might be nil or fail to
unmarshal).
If `internalSchema` is nil, the function now returns a descriptive error
instead of crashing.
## Verification
- `go test ./mcp/...` passes.
- The fix prevents the crash by handling the nil case gracefully.
Closes#916.
---------
Signed-off-by: wucm667 <stevenwucongmin@gmail.com>
Co-authored-by: Guglielmo Colombo <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.
Bumps [actions/cache](https://github.com/actions/cache) from 5.0.4 to
5.0.5.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/cache/releases">actions/cache's
releases</a>.</em></p>
<blockquote>
<h2>v5.0.5</h2>
<h2>What's Changed</h2>
<ul>
<li>Update ts-http-runtime dependency by <a
href="https://github.com/yacaovsnc"><code>@yacaovsnc</code></a> in <a
href="https://redirect.github.com/actions/cache/pull/1747">actions/cache#1747</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/cache/compare/v5...v5.0.5">https://github.com/actions/cache/compare/v5...v5.0.5</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/actions/cache/blob/main/RELEASES.md">actions/cache's
changelog</a>.</em></p>
<blockquote>
<h1>Releases</h1>
<h2>How to prepare a release</h2>
<blockquote>
<p>[!NOTE]<br />
Relevant for maintainers with write access only.</p>
</blockquote>
<ol>
<li>Switch to a new branch from <code>main</code>.</li>
<li>Run <code>npm test</code> to ensure all tests are passing.</li>
<li>Update the version in <a
href="https://github.com/actions/cache/blob/main/package.json"><code>https://github.com/actions/cache/blob/main/package.json</code></a>.</li>
<li>Run <code>npm run build</code> to update the compiled files.</li>
<li>Update this <a
href="https://github.com/actions/cache/blob/main/RELEASES.md"><code>https://github.com/actions/cache/blob/main/RELEASES.md</code></a>
with the new version and changes in the <code>## Changelog</code>
section.</li>
<li>Run <code>licensed cache</code> to update the license report.</li>
<li>Run <code>licensed status</code> and resolve any warnings by
updating the <a
href="https://github.com/actions/cache/blob/main/.licensed.yml"><code>https://github.com/actions/cache/blob/main/.licensed.yml</code></a>
file with the exceptions.</li>
<li>Commit your changes and push your branch upstream.</li>
<li>Open a pull request against <code>main</code> and get it reviewed
and merged.</li>
<li>Draft a new release <a
href="https://github.com/actions/cache/releases">https://github.com/actions/cache/releases</a>
use the same version number used in <code>package.json</code>
<ol>
<li>Create a new tag with the version number.</li>
<li>Auto generate release notes and update them to match the changes you
made in <code>RELEASES.md</code>.</li>
<li>Toggle the set as the latest release option.</li>
<li>Publish the release.</li>
</ol>
</li>
<li>Navigate to <a
href="https://github.com/actions/cache/actions/workflows/release-new-action-version.yml">https://github.com/actions/cache/actions/workflows/release-new-action-version.yml</a>
<ol>
<li>There should be a workflow run queued with the same version
number.</li>
<li>Approve the run to publish the new version and update the major tags
for this action.</li>
</ol>
</li>
</ol>
<h2>Changelog</h2>
<h3>5.0.4</h3>
<ul>
<li>Bump <code>minimatch</code> to v3.1.5 (fixes ReDoS via globstar
patterns)</li>
<li>Bump <code>undici</code> to v6.24.1 (WebSocket decompression bomb
protection, header validation fixes)</li>
<li>Bump <code>fast-xml-parser</code> to v5.5.6</li>
</ul>
<h3>5.0.3</h3>
<ul>
<li>Bump <code>@actions/cache</code> to v5.0.5 (Resolves: <a
href="https://github.com/actions/cache/security/dependabot/33">https://github.com/actions/cache/security/dependabot/33</a>)</li>
<li>Bump <code>@actions/core</code> to v2.0.3</li>
</ul>
<h3>5.0.2</h3>
<ul>
<li>Bump <code>@actions/cache</code> to v5.0.3 <a
href="https://redirect.github.com/actions/cache/pull/1692">#1692</a></li>
</ul>
<h3>5.0.1</h3>
<ul>
<li>Update <code>@azure/storage-blob</code> to <code>^12.29.1</code> via
<code>@actions/cache@5.0.1</code> <a
href="https://redirect.github.com/actions/cache/pull/1685">#1685</a></li>
</ul>
<h3>5.0.0</h3>
<blockquote>
<p>[!IMPORTANT]
<code>actions/cache@v5</code> runs on the Node.js 24 runtime and
requires a minimum Actions Runner version of <code>2.327.1</code>.</p>
</blockquote>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="27d5ce7f10"><code>27d5ce7</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/cache/issues/1747">#1747</a>
from actions/yacaovsnc/update-dependency</li>
<li><a
href="f280785d7b"><code>f280785</code></a>
licensed changes</li>
<li><a
href="619aeb1606"><code>619aeb1</code></a>
npm run build generated dist files</li>
<li><a
href="bcf16c2893"><code>bcf16c2</code></a>
Update ts-http-runtime to 0.3.5</li>
<li>See full diff in <a
href="668228422a...27d5ce7f10">compare
view</a></li>
</ul>
</details>
<br />
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
Previously, an expired refresh token in the oauth2.Token returned from
OAuthHandler.TokenSource() would cause the connection to fail.
From the client perspective, this meant that the MCP connection was in a
hard-failed state with no way to re-authorize.
The change in this commit causes Authorize() to be called in the event
of both an oauth2.RetrieveError from the token endpoint, as well as in
the pre-existing case of a 401/403 HTTP response from the MCP server.
Clients will handle this in their existing Authorize() flows to get a
new valid token for the connection.
Supersedes and closes: #909.
Co-authored-by: Yaroslav <yarolegovich@gmail.com>
## Summary
The DELETE request that terminates a streamable HTTP session discards
the `*http.Response` without closing its body. Per the `net/http`
documentation, this prevents the underlying TCP connection from being
reused and leaks it.
## The bug
```go
// mcp/streamable.go:2239 (before)
} else if _, err := c.client.Do(req); err != nil {
c.closeErr = err
}
```
On the success path (no error), the response is silently discarded. The
default client is `http.DefaultClient` with keep-alives enabled, so the
leaked connection stays open until GC or process exit.
## The fix
```go
// mcp/streamable.go:2239 (after)
} else if resp, err := c.client.Do(req); err != nil {
c.closeErr = err
} else {
resp.Body.Close()
}
```
All 10 other `client.Do` call sites in this file close the response
body. This one was missed.
## Test plan
- [x] `go build ./...` passes
- [x] `go test ./... -short` passes
Fixes#928
## 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>
When `OAuthHandler.Authorize` returns a context-cancelled error, the
cancellation notification that the call layer sends in response to the
same ctx cancellation is routed back through the streamable client
transport, re-triggering the `401` → `Authorize` path and prompting the
user a second time for a request they have already abandoned.
Fix in two layers:
1. **`mcp/transport.go`** — bound the cancellation `Notify` with a fresh
`context.Background()`-derived timeout instead of reusing the cancelled
caller context. This guarantees the caller never blocks on a doomed
notify against a degraded connection.
2. **`mcp/streamable.go`** — when `Authorize` returns a
context-cancelled error, mark the streamable client connection as failed
via `c.fail()`. The existing failure check in `Write()` then
short-circuits the cancellation notification without re-invoking the
OAuth handler, matching the bug reporter's expectation that cancelling
`Authorize` abandons the connection.
Includes a regression test
(`TestStreamableClientOAuth_CancelledAuthorize_NoReprompt`) with a
blocking `OAuthHandler` that asserts `Authorize` is invoked exactly
once. The test was verified to fail on unfixed code (`got 2` calls) and
pass with the fix.
Fixes#882
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mcp: preserve transport errors in Write error chain
Custom RoundTrippers that return typed errors (e.g. for auth failures)
have those errors flattened to strings by `%v`, making `errors.As`
unusable for callers inspecting transport-level errors.
This is inconsistent with the OAuth handler path (line 1807) which
already uses `%w` to preserve errors for client inspection.
Relates to #579
Co-authored-by: Maciej Kisiel <mkisiel@google.com>
correct the default address of the locally hosted server, according to
`main.go:21` the default is `:8000` not `:8080`
Co-authored-by: Maciej Kisiel <mkisiel@google.com>
Use a shared helper for Content-Type parsing in streamable transport
request validation and client response handling.
Follow up to https://github.com/modelcontextprotocol/go-sdk/pull/853,
where we did this for `Accept` headers.
This allows the streamable mcp server to validate the following header
`Content-Type: application/json;charset=utf-8`
Co-authored-by: Maciej Kisiel <mkisiel@google.com>
Since ping is optional per the MCP spec, a peer that doesn't implement
it will respond with a -32601 (method not found) error. Previously,
startKeepalive treated all ping errors identically, closing the session
even when the peer was perfectly healthy.
Now, a method-not-found response stops the keepalive goroutine without
closing the session.
Fixes#897
## Summary
Two error sites in the MCP runtime were previously unobservable:
- **`mcp/shared.go` `startKeepalive`** — when a keepalive ping failed,
the session was closed silently with no indication of why.
- **`mcp/transport.go` `connect`** — `jsonrpc2.OnInternalError` printed
via the package-level `log.Printf`, bypassing the `*slog.Logger`
configured on `ServerOptions`/`ClientOptions`.
Both sites now report through the existing `*slog.Logger` that `Server`
and `Client` already guarantee non-nil via `ensureLogger`. The logger is
threaded into the unexported `startKeepalive` and `connect` helpers as a
parameter — **no new public API surface**.
## Context
Per @jba's [suggestion on
#865](https://github.com/modelcontextprotocol/go-sdk/pull/865#issuecomment-4156166474):
> Maybe we can just use the logger in all the places we currently drop
the error. Then it's still reported, but we don't have to add any API.
This PR is that smaller alternative. It addresses the concrete
observability gap in #218 without expanding `ServerOptions`. PR #865
(the `ErrorHandler` callback proposal) remains open pending community
input on whether a structured callback API is also wanted on top of
logging.
`notifySessions` already logs at `Warn` level on delivery errors and is
intentionally left untouched.
## Test plan
- [x] New regression test `TestKeepAliveFailure_Logged` asserts the
keepalive failure produces a log line on the configured logger.
- [x] Verified the test fails when the new `logger.Error(...)` call is
removed (sanity check that it pins the behavior).
- [x] `go test ./mcp/... -count=1` passes.
- [x] `go vet ./...` clean.
- [x] `go build ./...` clean.
Refs #218
---------
Co-authored-by: Maciej Kisiel <mkisiel@google.com>
This PR fixes a race condition where `ServerSession.keepaliveCancel` was
accessed in `Close` concurrently with its initialization in
`ServerSession.startKeepalive`. It also removes a now redundant test.
The fix moves keepalive initialization into `ServerSession.Connect`
similar to the implementation in `ClientSession.Connect`. This is safe
because the MCP spec allows keepalive pings before the initialization
messages. From the MCP
[spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle)
(emphasis added):
> The client SHOULD NOT send requests **other than pings** before the
server has responded to the initialize request.
The PR includes a reproducer test case, which fails with the following
error without this fix:
```
> go test -count=10 -race -run ^TestStreamableStatelessKeepaliveRace$ github.com/modelcontextprotocol/go-sdk/mcp 8s
==================
WARNING: DATA RACE
Write at 0x00c00007fbf0 by goroutine 1095:
github.com/modelcontextprotocol/go-sdk/mcp.startKeepalive()
/Users/benjamin/Documents/go-sdk/mcp/shared.go:590 +0x58
github.com/modelcontextprotocol/go-sdk/mcp.(*ServerSession).startKeepalive()
/Users/benjamin/Documents/go-sdk/mcp/server.go:1526 +0x130
github.com/modelcontextprotocol/go-sdk/mcp.(*ServerSession).initialized()
/Users/benjamin/Documents/go-sdk/mcp/server.go:1059 +0xf8
github.com/modelcontextprotocol/go-sdk/mcp.init.serverSessionMethod[go.shape.*uint8,go.shape.interface { GetMeta() map[string]interface {}; SetMeta(map[string]interface {}); github.com/modelcontextprotocol/go-sdk/mcp.isResult() }].func27()
/Users/benjamin/Documents/go-sdk/mcp/shared.go:332 +0x98
github.com/modelcontextprotocol/go-sdk/mcp.newServerMethodInfo[go.shape.*github.com/modelcontextprotocol/go-sdk/mcp.InitializedParams,go.shape.interface { GetMeta() map[string]interface {}; SetMeta(map[string]interface {}); github.com/modelcontextprotocol/go-sdk/mcp.isResult() },go.shape.struct { github.com/modelcontextprotocol/go-sdk/mcp.Meta "json:\"_meta,omitempty\"" }].func2()
/Users/benjamin/Documents/go-sdk/mcp/shared.go:272 +0x84
github.com/modelcontextprotocol/go-sdk/mcp.defaultReceivingMethodHandler[go.shape.*uint8]()
/Users/benjamin/Documents/go-sdk/mcp/shared.go:152 +0xe8
github.com/modelcontextprotocol/go-sdk/mcp.defaultReceivingMethodHandler[*github.com/modelcontextprotocol/go-sdk/mcp.ServerSession]()
/Users/benjamin/Documents/go-sdk/mcp/shared.go:146 +0x60
github.com/modelcontextprotocol/go-sdk/mcp.handleReceive[go.shape.*uint8]()
/Users/benjamin/Documents/go-sdk/mcp/shared.go:169 +0x228
github.com/modelcontextprotocol/go-sdk/mcp.(*ServerSession).handle()
/Users/benjamin/Documents/go-sdk/mcp/server.go:1445 +0x370
github.com/modelcontextprotocol/go-sdk/mcp.connect[go.shape.*uint8,go.shape.*uint8].func1.1()
/Users/benjamin/Documents/go-sdk/mcp/transport.go:170 +0x5c
github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2.HandlerFunc.Handle()
/Users/benjamin/Documents/go-sdk/internal/jsonrpc2/jsonrpc2.go:84 +0x48
github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2.(*Connection).handleAsync.func3()
/Users/benjamin/Documents/go-sdk/internal/jsonrpc2/conn.go:717 +0xd8
Previous read at 0x00c00007fbf0 by goroutine 1089:
github.com/modelcontextprotocol/go-sdk/mcp.(*ServerSession).Close()
/Users/benjamin/Documents/go-sdk/mcp/server.go:1502 +0x34
github.com/modelcontextprotocol/go-sdk/mcp.(*StreamableHTTPHandler).ServeHTTP.deferwrap1()
/Users/benjamin/Documents/go-sdk/mcp/streamable.go:519 +0x34
runtime.deferreturn()
/opt/homebrew/Cellar/go/1.25.4/libexec/src/runtime/panic.go:589 +0x5c
github.com/modelcontextprotocol/go-sdk/mcp.TestStreamableStatelessKeepaliveRace.mustNotPanic.func2()
/Users/benjamin/Documents/go-sdk/mcp/streamable_test.go:2156 +0x94
net/http.HandlerFunc.ServeHTTP()
/opt/homebrew/Cellar/go/1.25.4/libexec/src/net/http/server.go:2322 +0x48
net/http.serverHandler.ServeHTTP()
/opt/homebrew/Cellar/go/1.25.4/libexec/src/net/http/server.go:3340 +0x270
net/http.(*conn).serve()
/opt/homebrew/Cellar/go/1.25.4/libexec/src/net/http/server.go:2109 +0x9b0
net/http.(*Server).Serve.gowrap3()
/opt/homebrew/Cellar/go/1.25.4/libexec/src/net/http/server.go:3493 +0x48
Goroutine 1095 (running) created at:
github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2.(*Connection).handleAsync()
/Users/benjamin/Documents/go-sdk/internal/jsonrpc2/conn.go:715 +0x334
github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2.(*Connection).acceptRequest.func2.gowrap1()
/Users/benjamin/Documents/go-sdk/internal/jsonrpc2/conn.go:676 +0x34
Goroutine 1089 (running) created at:
net/http.(*Server).Serve()
/opt/homebrew/Cellar/go/1.25.4/libexec/src/net/http/server.go:3493 +0x5dc
net/http/httptest.(*Server).goServe.func1()
/opt/homebrew/Cellar/go/1.25.4/libexec/src/net/http/httptest/server.go:311 +0x98
==================
--- FAIL: TestStreamableStatelessKeepaliveRace (0.14s)
testing.go:1617: race detected during execution of test
FAIL
FAIL github.com/modelcontextprotocol/go-sdk/mcp 1.727s
FAIL
```
---------
Co-authored-by: Maciej Kisiel <mkisiel@google.com>
SetError now only populates the Content field when it has not already
been populated. This allows callers to provide a user-friendly message
while still recording the underlying error for middleware inspection
via GetError.
The len check (rather than nil check) ensures both nil and empty-slice
Content are treated as "not yet populated."
Fixes#858
Use `mime.ParseMediaType` to parse the `Content-Type` header instead of
exact string matching. Previously, the server required an exact match on
`application/json`.
This allows the server to accept `application/json` with parameters
(e.g. `charset=utf-8`), even though [they are not
required](https://datatracker.ietf.org/doc/html/rfc4627.html#section-6),
following the robustness principle.
Per [RFC 8259 (section
8.1)](https://datatracker.ietf.org/doc/html/rfc8259.html#section-8.1),
`application/json` is always UTF-8, so such parameters are technically
redundant. However, parsing the media type makes the server more
tolerant of client behaviour, improving compatibility without
compromising security.
## Summary
Accepts HTTP 200 as a valid response in Dynamic Client Registration
(DCR), in addition to 201. Some authorization servers return 200 instead
of the RFC-specified 201 on successful registration.
Fixes https://github.com/modelcontextprotocol/go-sdk/issues/876
## Changes
- `oauthex/dcr.go`: accept 200 status code in client registration
response
- `oauthex/dcr_test.go`: add test coverage for the 200 response case
Bumps
[dominikh/staticcheck-action](https://github.com/dominikh/staticcheck-action)
from 1.4.0 to 1.4.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/dominikh/staticcheck-action/releases">dominikh/staticcheck-action's
releases</a>.</em></p>
<blockquote>
<h2>v1.4.1</h2>
<p>Version 1.4.1</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/dominikh/staticcheck-action/blob/master/CHANGES.md">dominikh/staticcheck-action's
changelog</a>.</em></p>
<blockquote>
<h1>1.4.1</h1>
<ul>
<li>Switch from WillAbides/setup-go-faster to actions/setup-go</li>
<li>Pin our dependencies</li>
</ul>
<h1>1.4.0</h1>
<ul>
<li>Add <code>use-cache</code> option to disable automatic caching</li>
<li>Update README to document all options</li>
</ul>
<h1>1.3.1</h1>
<ul>
<li>Update README to be clearer</li>
<li>Update README to provide timeless examples</li>
<li>Update setup-go-faster dependency from v1.8.0 to v1.14.0</li>
<li>Update actions/cache dependency from v3 to v4</li>
<li>Update installed Go version from 1.19.x to "stable"</li>
</ul>
<h1>1.3.0</h1>
<ul>
<li>Add the output-format option</li>
<li>Add the output-file option</li>
<li>Add the merge-files option</li>
<li>Update action/cache dependency from v2 to v3</li>
<li>Update setup-go-faster dependency from v1.7.0 to v1.8.0</li>
<li>Update installed Go version from 1.17.x to 1.19.x</li>
</ul>
<h1>1.2.0</h1>
<ul>
<li>Add the <code>checks</code> option</li>
<li>Add the <code>working-directory</code> option</li>
</ul>
<h1>1.1.0</h1>
<ul>
<li>The action will store and restore Go's cache if install-go is set to
true.</li>
</ul>
<h1>1.0.0</h1>
<p>Initial release.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="9716614d41"><code>9716614</code></a>
Update changelog</li>
<li><a
href="00d98ae742"><code>00d98ae</code></a>
Pin our dependencies</li>
<li><a
href="203eeae4a9"><code>203eeae</code></a>
Switch from WillAbides/setup-go-faster to actions/setup-go</li>
<li>See full diff in <a
href="024238d289...9716614d41">compare
view</a></li>
</ul>
</details>
<br />
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Maciek Kisiel <mkisiel@google.com>