Skip to content

Commit a51c9f5

Browse files
committed
Move server identity to result _meta and stamp every 2026-era result (spec #3002)
The 2026-07-28 draft removed the serverInfo body field from DiscoverResult: servers now report identity by stamping io.modelcontextprotocol/serverInfo into every result's _meta (new ResultMetaObject type), and clients treat it as optional, display-only metadata. Server side: - DiscoverResult loses server_info (monolith and generated surface); the default discover handler no longer passes identity. - Every 2026-era result is stamped at the runner's single exit point, after the middleware chain, so custom methods, empty results, and middleware short-circuits are covered by construction. Stamping builds a shallow copy rather than mutating a dict the handler may retain, a handler-authored value wins, and the dumped stamp is cached per server. Notifications, error responses, and handshake-era results are never stamped. - Opt-out per the spec's 'unless specifically configured not to do so': Server(include_server_info=False), also exposed on MCPServer. Client side: - ClientSession.server_info reads the discover result's _meta stamp, parsed once at adopt time; absent or malformed reads as None (the spec forbids acting on the value, so a bad stamp must not fail the connection). The wire surface keeps the field untyped for the same reason, via a shape-driven transform in the generator that stays lenient for future result-meta definitions. - Client.server_info is now Implementation | None. This also fixes a live interop break: servers that already shipped the new shape omit the body field, which previously failed our discover validation and silently downgraded auto mode to the legacy handshake. Tests: the interaction matrix runs with stamping off (the stamp would bake the commit-dependent package version into every snapshot); stamping has dedicated unit and wire-level coverage in tests/server/, and the tests that exercise identity assert the new _meta form.
1 parent 46c200d commit a51c9f5

56 files changed

Lines changed: 586 additions & 143 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs_src/protocol_versions/tutorial004.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ async def main() -> None:
1616

1717
async with Client(mcp, mode="2026-07-28", prior_discover=saved) as client:
1818
print(client.protocol_version)
19-
print(client.server_info.name)
19+
if client.server_info is not None:
20+
print(client.server_info.name)

examples/stories/_harness.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ def run_client(main: Callable[..., Awaitable[None]]) -> None:
162162
if cfg["era"] == "dual-in-body":
163163
# The story pins its connection modes inside ``main`` itself, so hand it "auto"
164164
# (the ``Client`` default) and let those in-body pins decide. A hard version pin
165-
# here would skip the discover probe and leave ``server_info`` blank.
165+
# here would skip the discover probe and leave `server_info` None.
166166
era = "in-body"
167167
mode = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy", "in-body": "auto"}[era]
168168

examples/stories/dual_era/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ leg fails there today — run over `--http`.
3030
at construction; no date strings appear in the body.
3131
- `client.py``client.protocol_version` / `client.server_info` /
3232
`client.server_capabilities` are era-neutral: populated by `initialize` *or*
33-
`server/discover`, whichever ran.
33+
`server/discover`, whichever ran. On the 2026 era `server_info` comes from
34+
the optional `serverInfo` `_meta` stamp (`None` for a server that does not
35+
identify itself); `initialize` always carries it.
3436
- `server.py``ctx.request_context.protocol_version` is the era branch key
3537
(lowlevel: `ctx.protocol_version` directly). Compare against
3638
`MODERN_PROTOCOL_VERSIONS`, never a date literal.

examples/stories/dual_era/client.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ async def main(targets: TargetFactory, *, mode: str = "auto") -> None:
1313
# The version/info/capabilities accessors are era-neutral.
1414
async with Client(targets(), mode=mode) as modern:
1515
assert modern.protocol_version == LATEST_MODERN_VERSION
16-
assert modern.server_info.name == "dual-era-example"
16+
# On the 2026 era, server identity is an optional serverInfo stamp in the
17+
# result _meta (None for an anonymous server); this server stamps it.
18+
info = modern.server_info
19+
assert info is not None, "the server stamps serverInfo into its results"
20+
assert info.name == "dual-era-example"
1721
assert modern.server_capabilities.tools is not None
1822

1923
listed = await modern.list_tools()
@@ -28,7 +32,9 @@ async def main(targets: TargetFactory, *, mode: str = "auto") -> None:
2832
# The same accessors are populated identically — here by ``initialize``.
2933
async with Client(targets(), mode="legacy") as legacy:
3034
assert legacy.protocol_version == LATEST_HANDSHAKE_VERSION
31-
assert legacy.server_info.name == "dual-era-example"
35+
info = legacy.server_info
36+
assert info is not None, "initialize always carries serverInfo"
37+
assert info.name == "dual-era-example"
3238
assert legacy.server_capabilities.tools is not None
3339

3440
result = await legacy.call_tool("greet", {"name": "2025 client"})

examples/stories/reconnect/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ uv run python -m stories.reconnect.client --http --server server_lowlevel
3535
## Caveats
3636

3737
- `mode=<version-pin>` *without* `prior_discover=` synthesizes a placeholder
38-
whose `server_info` is `Implementation(name="", version="")`. Pass the cached
38+
with no `serverInfo` stamp, so `server_info` reads `None`. Pass the cached
3939
result to get real identity on reconnect. Whether `Client` should expose a
4040
public synthesizer (or refuse the bare pin) is open.
4141
- `client.session.discover_result` is a one-hop reach into the mechanics layer;

examples/stories/reconnect/client.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ async def main(targets: TargetFactory, *, mode: str = "auto") -> None:
1515
discovered = client.session.discover_result
1616
assert discovered is not None, "mode='auto' against a modern server populates discover_result"
1717
assert client.protocol_version == LATEST_MODERN_VERSION
18-
assert client.server_info.name == "reconnect-example"
18+
# On the 2026 era, server identity is an optional serverInfo stamp in the
19+
# result _meta; an anonymous server reads as None. This one stamps it.
20+
info = client.server_info
21+
assert info is not None, "the server stamps serverInfo into its results"
22+
assert info.name == "reconnect-example"
1923
assert LATEST_MODERN_VERSION in discovered.supported_versions
2024

2125
result = await client.call_tool("add", {"a": 2, "b": 3})
@@ -28,11 +32,13 @@ async def main(targets: TargetFactory, *, mode: str = "auto") -> None:
2832

2933
# Reconnect: a version pin plus the cached DiscoverResult adopts the prior state with
3034
# zero round-trips on entry. A Client cannot be re-entered after exit, so targets()
31-
# yields a fresh one. Without prior_discover= a bare pin would synthesize a blank
32-
# server_info — the cache is what makes the era-neutral accessors useful here.
35+
# yields a fresh one. Without prior_discover= a bare pin would leave server_info
36+
# None — the cache is what carries the server's identity stamp across reconnects.
3337
async with Client(targets(), mode=LATEST_MODERN_VERSION, prior_discover=rehydrated) as second:
3438
assert second.protocol_version == LATEST_MODERN_VERSION
35-
assert second.server_info.name == "reconnect-example"
39+
info = second.server_info
40+
assert info is not None, "the cached DiscoverResult carries the serverInfo stamp"
41+
assert info.name == "reconnect-example"
3642
assert second.server_capabilities.tools is not None
3743
assert second.session.discover_result == rehydrated
3844

scripts/gen_surface_types.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
SCHEMA_DIR = REPO_ROOT / "schema"
2626
TYPES_DIR = REPO_ROOT / "src" / "mcp-types" / "mcp_types"
2727

28+
# The result-meta serverInfo stamp: every `$defs` entry carrying this property
29+
# gets its typed `$ref` stripped by `make_server_info_opaque` below.
30+
SERVER_INFO_META_PROPERTY = "io.modelcontextprotocol/serverInfo"
31+
2832
# schema.ts -> schema.json renders TypeScript `number` as JSON Schema
2933
# `integer` at these sites; patch the JSON before codegen so floats validate.
3034
# Patched to `["integer", "number"]` (not bare `"number"`) so codegen emits
@@ -129,9 +133,14 @@ def load_pinned() -> list[dict[str, str]]:
129133

130134

131135
def patch_schema(schema: dict[str, Any], patches: list[tuple[str, Any, Any]]) -> None:
132-
"""Apply `(path, old, new)` JSON-pointer-ish patches in place, asserting the old value."""
136+
"""Apply `(path, old, new)` JSON-pointer-ish patches in place, asserting the old value.
137+
138+
Path segments use JSON-pointer escaping (`~1` for `/`, `~0` for `~`) so keys
139+
that themselves contain a slash (the reserved `io.modelcontextprotocol/*`
140+
`_meta` keys) are addressable.
141+
"""
133142
for path, old, new in patches:
134-
*parts, leaf = path.split("/")
143+
*parts, leaf = (part.replace("~1", "/").replace("~0", "~") for part in path.split("/"))
135144
node: Any = schema
136145
for part in parts:
137146
node = node[int(part) if part.isdigit() else part]
@@ -140,6 +149,23 @@ def patch_schema(schema: dict[str, Any], patches: list[tuple[str, Any, Any]]) ->
140149
node[leaf] = new
141150

142151

152+
def make_server_info_opaque(schema: dict[str, Any]) -> None:
153+
"""Strip the typed `$ref` from every result-meta serverInfo property.
154+
155+
The stamp is display-only: the spec forbids acting on it, so a malformed
156+
value must never fail a whole response (clients validate every inbound
157+
result against this surface). Walking every `$defs` entry keeps future
158+
result-meta definitions lenient by construction instead of relying on an
159+
enumerated list; the typed, lenient parse happens at the read edge
160+
(`ClientSession.server_info`). typescript-sdk does the same with a
161+
schema-level catch-to-undefined.
162+
"""
163+
for definition in schema.get("$defs", {}).values():
164+
prop = definition.get("properties", {}).get(SERVER_INFO_META_PROPERTY)
165+
if prop is not None and "$ref" in prop:
166+
del prop["$ref"]
167+
168+
143169
def run_codegen(schema_path: Path, output_path: Path) -> None:
144170
"""Run datamodel-code-generator at the version pinned in the `codegen` dependency group."""
145171
# fmt: off
@@ -197,6 +223,7 @@ def build(entry: dict[str, str]) -> str:
197223
version = entry["protocol_version"]
198224
schema = json.loads((SCHEMA_DIR / f"{version}.json").read_text())
199225
patch_schema(schema, SCHEMA_PATCHES.get(version, []))
226+
make_server_info_opaque(schema)
200227

201228
with tempfile.TemporaryDirectory() as tmp:
202229
patched = Path(tmp) / "schema.json"

src/mcp-types/mcp_types/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
DEFAULT_NEGOTIATED_VERSION,
1313
LOG_LEVEL_META_KEY,
1414
PROTOCOL_VERSION_META_KEY,
15+
SERVER_INFO_META_KEY,
1516
Annotations,
1617
AudioContent,
1718
BaseMetadata,
@@ -231,6 +232,8 @@
231232
"CLIENT_INFO_META_KEY",
232233
"CLIENT_CAPABILITIES_META_KEY",
233234
"LOG_LEVEL_META_KEY",
235+
# Reserved result _meta keys
236+
"SERVER_INFO_META_KEY",
234237
# Type aliases and variables
235238
"CORE_RESULT_TYPES",
236239
"ContentBlock",

src/mcp-types/mcp_types/_types.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ class MCPModel(BaseModel):
6969
introduces it. If absent, the server must not send log notifications.
7070
"""
7171

72+
SERVER_INFO_META_KEY = "io.modelcontextprotocol/serverInfo"
73+
"""Reserved result `_meta` key: the server `Implementation` (2026-07-28). SDK-managed.
74+
75+
Servers SHOULD stamp it on every result. The value is self-reported and
76+
unverified - display, logging, and debugging only; never behavior or security.
77+
"""
78+
7279

7380
class RequestParamsMeta(TypedDict, extra_items=Any):
7481
"""The `_meta` object on request params (schema name: `RequestMetaObject`).
@@ -591,8 +598,6 @@ class DiscoverResult(CacheableResult):
591598

592599
capabilities: ServerCapabilities
593600

594-
server_info: Implementation
595-
596601
instructions: str | None = None
597602
"""Natural-language guidance describing the server and its features, e.g. for
598603
a system prompt. Should not duplicate information already in tool descriptions."""

src/mcp-types/mcp_types/v2026_07_28/__init__.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -743,9 +743,7 @@ class ResultMetaObject(WireModel):
743743
model_config = ConfigDict(
744744
extra="allow",
745745
)
746-
io_modelcontextprotocol_server_info: Annotated[
747-
Implementation | None, Field(alias="io.modelcontextprotocol/serverInfo")
748-
] = None
746+
io_modelcontextprotocol_server_info: Annotated[Any | None, Field(alias="io.modelcontextprotocol/serverInfo")] = None
749747
"""
750748
Identifies the server software producing the response. Servers SHOULD
751749
include this field on every response unless specifically configured not
@@ -901,9 +899,7 @@ class SubscriptionsListenResultMeta(WireModel):
901899
model_config = ConfigDict(
902900
extra="allow",
903901
)
904-
io_modelcontextprotocol_server_info: Annotated[
905-
Implementation | None, Field(alias="io.modelcontextprotocol/serverInfo")
906-
] = None
902+
io_modelcontextprotocol_server_info: Annotated[Any | None, Field(alias="io.modelcontextprotocol/serverInfo")] = None
907903
"""
908904
Identifies the server software producing the response. Servers SHOULD
909905
include this field on every response unless specifically configured not

0 commit comments

Comments
 (0)