Skip to content

Commit fb9b64c

Browse files
committed
Always stamp serverInfo; drop the include_server_info opt-out
The spec's opt-out language ("unless specifically configured not to do so") is already expressible at the layer that owns response envelopes: a middleware that strips the key from the results it passes along. A dedicated constructor flag duplicated that mechanism, so it is removed; neither the go nor the typescript SDK shipped one. The interaction matrix now pins a server version and strips the stamp at comparison sites (tests/_stamp.unstamped) instead of running a non-default config. Also from review: - server_info_stamp returns a deep copy per access: the cached dump's nested values (icons) previously aliased into every stamped response, so mutating one response's stamp corrupted all later ones. - The claimed-shape sieve bypass is now modern-only: a resultType outside the core vocabulary on a legacy session fails the per-version surface (INTERNAL_ERROR) instead of leaking a shape legacy clients cannot resolve. - The validated params argument of Extension.intercept_tool_call is documented as authoritative: a context rewrite through call_next adjusts what the handler observes, not which tool call runs (pinned by test).
1 parent 9f54d31 commit fb9b64c

33 files changed

Lines changed: 327 additions & 157 deletions

docs/advanced/low-level-server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ Call it and the result carries both representations:
107107
}
108108
```
109109

110-
The `_meta` block is the server's identity stamp: the SDK adds it to every 2026-era result, with the `version` taken from the constructor or the installed package. Pass `include_server_info=False` to the server to turn it off.
110+
The `_meta` block is the server's identity stamp: the SDK adds it to every 2026-era result, with the `version` taken from the constructor or the installed package. A server that must not identify itself can strip the key with a middleware, which owns the results it returns.
111111

112112
The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **[Structured Output](../servers/structured-output.md)**.
113113

docs/migration.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -481,13 +481,14 @@ What changed in the SDK:
481481

482482
- `DiscoverResult` no longer has a `server_info` field. Read or write identity
483483
through result `_meta`; the key is exported as `mcp_types.SERVER_INFO_META_KEY`.
484-
- Servers stamp `serverInfo` into every 2026-era result's `_meta` by default,
485-
built from the constructor identity fields (`name`, `version`, `title`, and
486-
so on). Pass `include_server_info=False` to `Server(...)` or `MCPServer(...)`
487-
to turn it off. A `serverInfo` value your handler already set in `_meta` is
488-
never overwritten. Handshake-era responses are unchanged, and notifications
489-
and error responses are never stamped. A middleware that answers a request
490-
itself (without `call_next`) owns its result envelope, stamp included.
484+
- Servers stamp `serverInfo` into every 2026-era result's `_meta`, built from
485+
the constructor identity fields (`name`, `version`, `title`, and so on). A
486+
`serverInfo` value your handler already set in `_meta` is never overwritten.
487+
Handshake-era responses are unchanged, and notifications and error responses
488+
are never stamped. A middleware that answers a request itself (without
489+
`call_next`) owns its result envelope, stamp included; a server that must
490+
not identify itself can strip the key the same way, with a middleware that
491+
removes it from the results it passes along.
491492
- `client.server_info` and `session.server_info` are `Implementation | None`
492493
on 2026-era connections: identity is optional on the wire, so a server that
493494
does not stamp it reads as `None`. Handshake-era connections still always

docs/whats-new.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are repla
197197

198198
### The rest, quickly
199199

200-
* **Identity is optional, per-message metadata.** The request-side `clientInfo` `_meta` key is optional (the required pair is `protocolVersion` + `clientCapabilities`), and `serverInfo` moved out of the `server/discover` result body: servers stamp it into every 2026-era result's `_meta` instead (since `2.0.0b3`; [spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002)). The SDK stamps by default and `include_server_info=False` turns it off; `client.server_info` is `None` when a server chooses not to identify itself. The **[Migration Guide](migration.md#server-identity-moved-from-the-serverdiscover-result-body-to-result-_meta)** has the details.
200+
* **Identity is optional, per-message metadata.** The request-side `clientInfo` `_meta` key is optional (the required pair is `protocolVersion` + `clientCapabilities`), and `serverInfo` moved out of the `server/discover` result body: servers stamp it into every 2026-era result's `_meta` instead (since `2.0.0b3`; [spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002)). The SDK always stamps; `client.server_info` is `None` when a server does not identify itself (for example, a middleware stripped the key). The **[Migration Guide](migration.md#server-identity-moved-from-the-serverdiscover-result-body-to-result-_meta)** has the details.
201201
* **Requests are routable without parsing bodies.** Modern HTTP requests carry `Mcp-Method` (and, for the three tool-ish calls, `Mcp-Name`); a tool input-schema property annotated with `x-mcp-header` is mirrored into an `Mcp-Param-*` header and cross-checked by the server ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Gateways and rate limiters can route on headers alone; the **[Migration Guide](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)** has the rules.
202202
* **Results carry cache hints.** List and read results declare `ttlMs` and `cacheScope` ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); you set them per method with `cache_hints=`, and `Client` honors them with a built-in response cache. A server that sends no hints (every pre-2026 server) sees identical, uncached traffic. **[Caching hints](client/caching.md)**.
203203
* **Extensions are first class.** Servers and clients declare optional capability bundles under reverse-DNS identifiers ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); the built-in `Apps` extension (MCP Apps) is the reference. **[Extensions](advanced/extensions.md)** and **[MCP Apps](advanced/apps.md)**.

src/mcp/server/context.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,11 @@ async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *
116116
all three to a result dict."""
117117

118118
CallNext = Callable[["ServerRequestContext[Any, Any]"], Awaitable[HandlerResult]]
119-
"""Invokes the rest of the chain. Pass the `ctx` through; rewrite `method` or
120-
`params` with `dataclasses.replace(ctx, ...)` to alter what the handler sees."""
119+
"""Invokes the rest of the chain with the given context. What a context
120+
rewrite (`dataclasses.replace(ctx, ...)`) can alter depends on the tier:
121+
`ServerMiddleware` runs before params validation, so its rewrites change what
122+
the handler is invoked with; an `Extension` interceptor runs after, so its
123+
rewrites change only what the handler observes on `ctx`."""
121124

122125
_MwLifespanT = TypeVar("_MwLifespanT")
123126

src/mcp/server/extension.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,11 @@ async def intercept_tool_call(
147147
`call_next(ctx)` runs the rest of the chain and the real handler, and
148148
returns the handler's domain result. Interceptors run at the handler
149149
layer: whatever they return is serialized like any handler result,
150-
including the 2026-era `serverInfo` `_meta` stamp.
150+
including the 2026-era `serverInfo` `_meta` stamp. The `params` this
151+
interceptor received is what the wrapped handler is invoked with -
152+
passing a rewritten context through `call_next` adjusts what the
153+
handler observes on `ctx`, not the tool invocation. Wire-level request
154+
rewriting belongs to `Server.middleware`, above params validation.
151155
"""
152156
return await call_next(ctx)
153157

src/mcp/server/lowlevel/server.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ async def main():
3636

3737
from __future__ import annotations
3838

39+
import copy
3940
import logging
4041
import warnings
4142
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
@@ -147,7 +148,6 @@ def __init__(
147148
website_url: str | None = None,
148149
icons: list[types.Icon] | None = None,
149150
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
150-
include_server_info: bool = True,
151151
lifespan: Callable[
152152
[Server[LifespanResultT]],
153153
AbstractAsyncContextManager[LifespanResultT],
@@ -231,7 +231,6 @@ def __init__(
231231
website_url: str | None = None,
232232
icons: list[types.Icon] | None = None,
233233
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
234-
include_server_info: bool = True,
235234
lifespan: Callable[
236235
[Server[LifespanResultT]],
237236
AbstractAsyncContextManager[LifespanResultT],
@@ -324,7 +323,6 @@ def __init__(
324323
website_url: str | None = None,
325324
icons: list[types.Icon] | None = None,
326325
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
327-
include_server_info: bool = True,
328326
lifespan: Callable[
329327
[Server[LifespanResultT]],
330328
AbstractAsyncContextManager[LifespanResultT],
@@ -432,10 +430,6 @@ def __init__(
432430
self.instructions = instructions
433431
self.website_url = website_url
434432
self.icons = icons
435-
# Spec 2026-07-28: servers SHOULD stamp `serverInfo` into every result's
436-
# `_meta` "unless specifically configured not to do so" - this is that
437-
# configuration. Consumed at the single stamping site in `ServerRunner`.
438-
self.include_server_info = include_server_info
439433
# Per-method `ttl_ms`/`cache_scope` fills, applied by `ServerRunner`
440434
# after the handler returns; fields the handler set explicitly win.
441435
self.cache_hints: dict[str, CacheHint] = validate_cache_hints(cache_hints)
@@ -657,13 +651,21 @@ def server_info(self) -> types.Implementation:
657651
)
658652

659653
@cached_property
654+
def _server_info_stamp_source(self) -> dict[str, Any]:
655+
# Identity is fixed at construction, so the dump is computed once per
656+
# server instead of per request. Never handed out directly: nested
657+
# values (`icons`) would alias the cache into stamped responses.
658+
return self.server_info.model_dump(by_alias=True, mode="json", exclude_none=True)
659+
660+
@property
660661
def server_info_stamp(self) -> dict[str, Any]:
661-
"""The wire dump of `server_info`, computed once per server.
662+
"""A fresh wire dump of `server_info`; callers own the returned dict.
662663
663-
Identity is fixed at construction, so the per-result `_meta` stamping
664-
site (`ServerRunner`) reads this instead of re-dumping per request.
664+
Each access materializes a deep copy of the once-per-server dump, so
665+
a caller mutating a stamped response can never corrupt the identity
666+
stamped into later responses.
665667
"""
666-
return self.server_info.model_dump(by_alias=True, mode="json", exclude_none=True)
668+
return copy.deepcopy(self._server_info_stamp_source)
667669

668670
async def _handle_discover(
669671
self, ctx: ServerRequestContext[LifespanResultT], params: types.RequestParams | None

src/mcp/server/mcpserver/server.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,6 @@ def __init__(
183183
resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY,
184184
request_state_security: RequestStateSecurity | None = None,
185185
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
186-
include_server_info: bool = True,
187186
subscriptions: SubscriptionBus | None = None,
188187
):
189188
self._resource_security = resource_security
@@ -217,7 +216,6 @@ def __init__(
217216
icons=icons,
218217
version=version,
219218
cache_hints=cache_hints,
220-
include_server_info=include_server_info,
221219
on_list_tools=self._handle_list_tools,
222220
on_call_tool=self._handle_call_tool,
223221
on_list_resources=self._handle_list_resources,

src/mcp/server/runner.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -354,14 +354,21 @@ def _serialize(self, method: str, version: str, result: HandlerResult) -> dict[s
354354
# Hint keys first so wire keys the handler set win, matching `apply_cache_hint` precedence.
355355
result = {"ttlMs": hint.ttl_ms, "cacheScope": hint.scope, **result}
356356
dumped = _dump_result(result)
357-
# An extension `resultType` (outside the core vocabulary) marks a claimed
358-
# shape owned by the extension that defined it: the per-version surface
359-
# doesn't describe it, so the sieve applies to core results only.
357+
# A modern-era extension `resultType` (outside the core vocabulary) marks
358+
# a claimed shape owned by the extension that defined it: the per-version
359+
# surface doesn't describe it, so the sieve applies to core results only.
360+
# Legacy connections sieve everything - claimed shapes are 2026-era
361+
# vocabulary and cannot be delivered on a legacy wire (mirrors the
362+
# client-side ResultClaim rule).
360363
# TODO(L56): reject extension resultType values unless the corresponding
361364
# extension is in this request's _meta clientCapabilities.extensions; the
362365
# explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively.
363366
result_type = dumped.get("resultType")
364-
core_shape = not isinstance(result_type, str) or result_type in CORE_RESULT_TYPES
367+
core_shape = (
368+
version not in MODERN_PROTOCOL_VERSIONS
369+
or not isinstance(result_type, str)
370+
or result_type in CORE_RESULT_TYPES
371+
)
365372
if method in _methods.SPEC_CLIENT_METHODS and core_shape:
366373
try:
367374
dumped = _methods.serialize_server_result(method, version, dumped)
@@ -379,17 +386,18 @@ def _stamp_server_info(self, version: str, result: dict[str, Any]) -> dict[str,
379386
to own, and handshake-era results are never stamped. `result` is
380387
pipeline-owned (`_dump_result` copies dicts; the spec-method sieve
381388
re-dumps), but `_meta` may still be the handler's object, so the stamp
382-
replaces it rather than writing into it.
389+
replaces it rather than writing into it. `server_info_stamp` is a
390+
fresh dict per access, so the response never aliases server state.
383391
"""
384-
if not self.server.include_server_info or version not in MODERN_PROTOCOL_VERSIONS:
392+
if version not in MODERN_PROTOCOL_VERSIONS:
385393
return result
386394
raw_meta = result.get("_meta")
387395
if raw_meta is None:
388-
result["_meta"] = {SERVER_INFO_META_KEY: dict(self.server.server_info_stamp)}
396+
result["_meta"] = {SERVER_INFO_META_KEY: self.server.server_info_stamp}
389397
elif isinstance(raw_meta, dict):
390398
meta = cast("dict[str, Any]", raw_meta)
391399
if meta.get(SERVER_INFO_META_KEY) is None:
392-
result["_meta"] = {**meta, SERVER_INFO_META_KEY: dict(self.server.server_info_stamp)}
400+
result["_meta"] = {**meta, SERVER_INFO_META_KEY: self.server.server_info_stamp}
393401
return result
394402

395403
@staticmethod

tests/_stamp.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Shared helper: drop the 2026-era serverInfo `_meta` stamp from a result.
2+
3+
Servers stamp `io.modelcontextprotocol/serverInfo` into every 2026-era
4+
result's `_meta` and never into handshake-era ones, and the stamp's `version`
5+
defaults to the installed package version. Suites that share one expected
6+
payload across eras (the interaction matrix) or that construct servers
7+
without a pinned version strip the stamp before exact comparison. Stamp
8+
semantics themselves have dedicated coverage in tests/server/test_runner.py.
9+
"""
10+
11+
from typing import Any, TypeVar
12+
13+
from mcp_types import SERVER_INFO_META_KEY, Result
14+
15+
R = TypeVar("R", bound=Result)
16+
17+
18+
def unstamped(result: R) -> R:
19+
"""Remove the serverInfo stamp in place if present, asserting its shape.
20+
21+
Present-versus-absent is era-dependent and belongs to the runner tests;
22+
this only guarantees that when a stamp exists it is a well-formed
23+
identity object, then returns the result for inline use in comparisons.
24+
"""
25+
meta = result.meta
26+
if meta is not None and SERVER_INFO_META_KEY in meta:
27+
stamp: Any = meta.pop(SERVER_INFO_META_KEY)
28+
assert isinstance(stamp, dict)
29+
assert "name" in stamp and "version" in stamp
30+
if not meta:
31+
result.meta = None
32+
return result

0 commit comments

Comments
 (0)