diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index aa31cb757b..2c17178330 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -10,7 +10,24 @@ # scenarios start passing and MUST be removed from this list (the runner fails # on stale entries), so the baseline burns down per milestone. -client: [] +client: + # SEP-1932 (DPoP): the SDK's OAuth client does not implement DPoP proofs. + # These scenarios are new in the da56f663 referee pin. The entries are + # per-check (conformance #406) because both scenarios pass their + # non-DPoP checks (discovery, token acquisition, request flow) live. + - auth/dpop:sep-1932-client-token-request-proof + - auth/dpop:sep-1932-client-dpop-auth-scheme + - auth/dpop:sep-1932-client-fresh-proof + - auth/dpop-nonce:sep-1932-client-token-request-proof + - auth/dpop-nonce:sep-1932-client-dpop-auth-scheme + - auth/dpop-nonce:sep-1932-client-fresh-proof + - auth/dpop-nonce:sep-1932-client-as-nonce + - auth/dpop-nonce:sep-1932-client-rs-nonce + # Workload identity federation: the OAuth client does not implement the + # urn:ietf:params:oauth:grant-type:jwt-bearer grant (it answers with + # authorization_code). Also new in the da56f663 referee pin; per-check for + # the same reason. + - auth/wif-jwt-bearer:wif-grant-type server: # SEP-2663 (io.modelcontextprotocol/tasks): the SDK does not implement the diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index dd132698dd..9dd50debea 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -19,17 +19,19 @@ env: # Bump deliberately and reconcile both # .github/actions/conformance/expected-failures*.yml files in the same change. # - # Temporarily pinned to the pkg.pr.new build of conformance main@4944b268 - # (0.2.0-alpha.8, which includes #372: fail checks whose prerequisite is - # missing instead of skipping them) — alpha.8 is not published to npm yet. - # Pinned by commit SHA so the tarball cannot move under us; + # Temporarily pinned to the pkg.pr.new build of conformance main@da56f663, + # which includes #403 (sep-2575 checks flipped to the post-spec-#3002 shape: + # clientInfo optional on requests, serverInfo in result _meta instead of the + # discover body) and #406 (per-check expected-failures granularity) — the + # last published npm release (0.2.0-alpha.9) still enforces the pre-#3002 + # shape. Pinned by commit SHA so the tarball cannot move under us; # CONFORMANCE_PKG_SHA256 pins the bytes and the fetch-and-verify step below # downloads, checks the digest, and repoints CONFORMANCE_PKG at the # verified local copy. Repin to the next published @modelcontextprotocol/ - # conformance release (>=0.2.0-alpha.8) once it ships, then drop + # conformance release (>=0.2.0-alpha.10) once it ships, then drop # CONFORMANCE_PKG_SHA256 and the fetch-and-verify steps. - CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@4944b268" - CONFORMANCE_PKG_SHA256: "0f70c035782d319d72ab427653c5275db5c50429d59fae0241a645b33aeda1a7" + CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@da56f663" + CONFORMANCE_PKG_SHA256: "bbc94678033071df4bc9851ce2054f9dff918f4501661d1d60e2d09d8c515e6d" jobs: server-conformance: diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index 0358ba5a83..715c5bbdc8 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -133,14 +133,21 @@ or veto a tool call: ``` * `params` is the validated `CallToolRequestParams`: you get `params.name` and - `params.arguments` without touching raw JSON. -* `call_next(ctx)` runs the rest of the chain. Return its result unchanged (observe), - return something else (replace), or raise an `MCPError` (refuse). + `params.arguments` without touching raw JSON. It is also what decides which + tool call runs: passing a rewritten context through `call_next` changes what + the handler observes on `ctx`, not the tool invocation. Wire-level request + rewriting belongs to [Middleware](middleware.md). +* `call_next(ctx)` runs the rest of the chain and returns the handler's result. + Return it unchanged (observe), return something else (replace), or raise an + `MCPError` (refuse). Whatever you return is serialized like any handler + result, including the 2026-era `serverInfo` identity stamp, so a + short-circuiting interceptor never produces an anonymous or off-schema + response. * With several extensions, interceptors nest in registration order: the first extension in `extensions=[...]` is outermost. * The default implementation is a pass-through, and a server whose extensions never - override this hook installs **no** middleware at all. You don't pay for what - you don't use. + override this hook keeps the bare `tools/call` handler untouched. You don't + pay for what you don't use. The hook wraps `tools/call` and nothing else. For every-message concerns, use [Middleware](middleware.md). That is what it is for. diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 26df8f6123..dd5fb427a2 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -102,10 +102,13 @@ Call it and the result carries both representations: "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], "structuredContent": {"matches": 3, "query": "dune"}, "isError": false, - "resultType": "complete" + "resultType": "complete", + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}} } ``` +The `_meta` block is the server's identity stamp: the SDK adds it to every 2026-era result, with the `version` from the constructor (a server that sets none reports an empty string). A server that must not identify itself can strip the key with a middleware, which owns the results it returns. + 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)**. ## `_meta`: for the application, not the model diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index 4e57bae82d..3d5808550a 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -63,6 +63,10 @@ In increasing order of how much you should hesitate: `initialize`: the result the client gets back is built from your rewritten params, but the server commits its connection state from the original wire params. The two sides can finish the handshake disagreeing about what they negotiated. +* **Answer.** Return a result without calling `call_next(ctx)` and it goes to the client as + your response. `call_next` hands you the finished wire form, and the pipeline never patches + what you return, so the whole envelope is yours: on a 2026-era connection that includes the + `serverInfo` `_meta` stamp, which the SDK adds to handler results but not to yours. !!! check `initialize` is one of the things middleware wraps, and it is the *only* hook you get diff --git a/docs/client/index.md b/docs/client/index.md index ae47508359..f70966e1ab 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -30,7 +30,7 @@ Everything else on this page is identical across all three. Headers, subprocesse Four read-only properties, populated the moment you enter the block: -* `client.server_info`: the server's identity. `server_info.name` here is `"Bookshop"`, `server_info.version` is whatever the server reports. +* `client.server_info`: the server's identity, or `None` for a 2026-era server that does not report one (python-sdk servers do by default). `server_info.name` here is `"Bookshop"`, `server_info.version` is whatever the server reports. * `client.server_capabilities`: what the server can do (`tools`, `resources`, `prompts`, `completions`, ...). A capability the server doesn't have is `None`. * `client.protocol_version`: the protocol version the two sides agreed on. Here it is `"2026-07-28"`. * `client.instructions`: the server's `instructions=` string, or `None` if it didn't set one. @@ -202,7 +202,7 @@ There is one constructor flag built for that: `Client(mcp, raise_exceptions=True ## Recap * `Client(x)` connects in-memory to a server object, over Streamable HTTP to a URL string, and over anything else via a transport. -* `async with` is the whole lifecycle. Inside it, `server_info`, `server_capabilities`, `protocol_version` and `instructions` are already populated. +* `async with` is the whole lifecycle. Inside it, `server_capabilities` and `protocol_version` are already populated; `server_info` and `instructions` are too when the server provides them. * `list_tools()` gives you each tool's `name`, `title`, `description` and `input_schema`. * `call_tool()` returns `content` for the model, `structured_content` for your code, and `is_error`. A raising tool is a result, not an exception. * `content` is a union of block types; narrow with `isinstance` before reading. diff --git a/docs/client/session-groups.md b/docs/client/session-groups.md index c7a1434fb1..70ad86833a 100644 --- a/docs/client/session-groups.md +++ b/docs/client/session-groups.md @@ -64,7 +64,7 @@ Run it again. `print(sorted(group.tools))` now shows both: `connect_to_server` returns the `ClientSession` it opened. Keep it if you ever want that server gone: `await group.disconnect_from_server(session)` removes its tools, resources, and prompts from the group. -If you already hold a connected `ClientSession` (`Client.session` is one), hand it to `await group.connect_with_session(server_info, session)` instead of opening a new transport. It aggregates the same way. The group never closes a session it didn't open. +If you already hold a connected `ClientSession` (`Client.session` is one), hand it to `await group.connect_with_session(server_info, session)` instead of opening a new transport. It aggregates the same way. The group never closes a session it didn't open. `server_info` names the server for component prefixes; on a 2026-era connection `client.server_info` can be `None` (identity is optional), so pass your own `Implementation(name=..., version=...)` in that case. ## The classic handshake diff --git a/docs/get-started/testing.md b/docs/get-started/testing.md index 82a113cfb1..2c3f9fca74 100644 --- a/docs/get-started/testing.md +++ b/docs/get-started/testing.md @@ -59,6 +59,8 @@ async def client(): # (2)! @pytest.mark.anyio async def test_call_add_tool(client: Client): result = await client.call_tool("add", {"a": 1, "b": 2}) + # Drop the server identity stamp in `_meta`; it is not what this test is about. + result.meta = None assert result == snapshot( CallToolResult( content=[TextContent(type="text", text="3")], diff --git a/docs/migration.md b/docs/migration.md index 876608db70..2f2594c4c9 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -606,6 +606,14 @@ mcp = MCPServer("Demo", instructions="You answer questions about the weather.") Keep `name` positional and pass everything else by keyword. +### Unversioned servers report an empty version + +In v1, a server constructed without a `version` reported the installed `mcp` +package's version as its own in the `initialize` result's `serverInfo`. In v2 +it reports an empty string instead: the SDK's version is not your server's +version. Pass `version="..."` to `Server(...)` or `MCPServer(...)` to identify +your server properly. The field is display-only; nothing breaks either way. + ### `mount_path` parameter removed from MCPServer The `mount_path` parameter has been removed from `MCPServer.__init__()`, `MCPServer.run()`, `MCPServer.run_sse_async()`, and `MCPServer.sse_app()`. It was also removed from the `Settings` class. @@ -1498,7 +1506,7 @@ version = session.protocol_version The raw handshake result is also retained: `session.initialize_result` is set after `initialize()` (≤2025-11-25 servers — including `stateless_http=True` servers, which still answer `initialize`); `session.discover_result` is set after `discover()` (2026-07-28+ servers). At most one is non-`None`. -On the high-level `Client`, `client.server_capabilities`, `client.server_info`, and `client.protocol_version` are non-nullable inside the context manager. `client.instructions` remains `str | None` since the server may omit it. (The lowlevel `ClientSession` still lets you call methods before any handshake, as in v1; `Client` always connects on enter — by default it probes `server/discover` and falls back to the initialize handshake.) +On the high-level `Client`, `client.server_capabilities` and `client.protocol_version` are non-nullable inside the context manager. `client.instructions` remains `str | None` since the server may omit it, and `client.server_info` is `Implementation | None`: on 2026-era connections identity is optional wire metadata, so a server that does not report it reads as `None`. (The lowlevel `ClientSession` still lets you call methods before any handshake, as in v1; `Client` always connects on enter — by default it probes `server/discover` and falls back to the initialize handshake.) ### `cursor` parameter removed from `ClientSession` list methods diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 221a87dc41..9ef19a7cf7 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -68,10 +68,10 @@ A pin is a promise *you* make: you already know the server speaks that version. A pin is not a discovery. Print `client.server_info` and the price is right there: ```text - name='' title=None version='' description=None website_url=None icons=None + None ``` - The client never asked the server who it is, so `server_info` is a blank. `client.server_capabilities` + The client never asked the server who it is, so `server_info` is `None`. `client.server_capabilities` is the same story: every capability is `None`. Tool calls still work (the protocol needs none of it); code that reads `server_capabilities` to decide what to offer does not. @@ -87,7 +87,7 @@ ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-0 The probe is cheap, but it is still a round trip you pay on every reconnect, and the answer almost never changes. -So keep it. After an `auto` connection, `client.session.discover_result` holds the exact `DiscoverResult` the server sent: its `supported_versions`, its `capabilities`, its `server_info`, its `instructions`. Hand it back as `prior_discover=` the next time: +So keep it. After an `auto` connection, `client.session.discover_result` holds the exact `DiscoverResult` the server sent: its `supported_versions`, its `capabilities`, its `instructions`, and the identity the server stamped into the result's `_meta`. Hand it back as `prior_discover=` the next time: ```python title="client.py" hl_lines="15 17" --8<-- "docs_src/protocol_versions/tutorial004.py" @@ -112,7 +112,7 @@ The second connection made **zero** negotiation round trips and still knows exac | --- | --- | --- | | `Client(target)` | one `server/discover` probe; the `initialize` handshake if it fails | the newest version both sides speak, whichever era | | `Client(target, mode="legacy")` | the `initialize` handshake | a handshake-era version; server-initiated requests work | -| `Client(target, mode="2026-07-28")` | none | that version, pinned, with a blank `server_info` | +| `Client(target, mode="2026-07-28")` | none | that version, pinned, with `server_info` as `None` | | `Client(target, mode="2026-07-28", prior_discover=saved)` | none | that version, pinned, *and* the identity you saved last time | ## Recap @@ -121,7 +121,7 @@ The second connection made **zero** negotiation round trips and still knows exac * `mode="auto"` is the default: probe, fall back. Leave it alone unless one of the other three rows describes you. * `client.protocol_version` is always the answer to "what did I get?". * `mode="legacy"` forces the handshake. It is what you need for server-initiated requests: sampling, push elicitation, `message_handler`. -* A version pin (`mode="2026-07-28"`) sends no negotiation traffic at all, at the cost of a blank `server_info`. +* A version pin (`mode="2026-07-28"`) sends no negotiation traffic at all, at the cost of `client.server_info` being `None`. * `prior_discover=` pays that cost back: save `client.session.discover_result`, reconnect with it, get both. A modern connection has no push channel, so how does a 2026 server ask you a question mid-call? It returns it: **[Multi-round-trip requests](handlers/multi-round-trip.md)**. diff --git a/docs/servers/media.md b/docs/servers/media.md index df23966078..e8042b83ab 100644 --- a/docs/servers/media.md +++ b/docs/servers/media.md @@ -97,9 +97,10 @@ The same `icons=[...]` keyword is accepted by `MCPServer(...)`, `@mcp.tool()`, ` ### Where a client sees them -Icons travel with whatever they decorate. The server's arrive when the client connects, on `client.server_info`: +Icons travel with whatever they decorate. The server's arrive when the client connects, on `client.server_info` (optional on 2026-era connections, so narrow it first): ```python +assert client.server_info is not None # python-sdk servers identify themselves by default client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] ``` diff --git a/docs/whats-new.md b/docs/whats-new.md index de29f0aefc..9c7a9be911 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -44,7 +44,7 @@ v1 handed you three nested layers: a transport context manager yielding raw stre --8<-- "docs_src/client/tutorial001.py" ``` -`Client` takes a server object (in memory, no transport: the testing story), a URL (Streamable HTTP), or any transport context manager such as `stdio_client(...)`. Entering `async with` connects and negotiates the protocol version, whichever era the server speaks; `client.server_info`, `client.server_capabilities`, and `client.protocol_version` are simply there afterwards. The sampling and elicitation callbacks you registered in v1 still work (their bodies see the same snake_case attribute rename as everything else on this page), they now also answer the 2026-style requests-inside-results (below), and they run concurrently instead of one at a time. `ClientSession` is still underneath for anyone who wants the low-level surface, and `client.session` hands it to you; it moved too (it runs on the new dispatcher engine, and some of its own signatures changed), so read the **[Migration Guide](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** before you drop down. +`Client` takes a server object (in memory, no transport: the testing story), a URL (Streamable HTTP), or any transport context manager such as `stdio_client(...)`. Entering `async with` connects and negotiates the protocol version, whichever era the server speaks; `client.server_capabilities` and `client.protocol_version` are simply there afterwards, and `client.server_info` is too when the server identifies itself (it is `Implementation | None` now, since 2026-era identity is optional). The sampling and elicitation callbacks you registered in v1 still work (their bodies see the same snake_case attribute rename as everything else on this page), they now also answer the 2026-style requests-inside-results (below), and they run concurrently instead of one at a time. `ClientSession` is still underneath for anyone who wants the low-level surface, and `client.session` hands it to you; it moved too (it runs on the new dispatcher engine, and some of its own signatures changed), so read the **[Migration Guide](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** before you drop down. **[The Client](client/index.md)** introduces it, **[Client transports](client/transports.md)** covers the three connection forms, **[Client callbacks](client/callbacks.md)** covers the callbacks themselves, and **[Testing](get-started/testing.md)** shows the in-memory pattern that replaces v1's `create_connected_server_and_client_session()` helper. @@ -197,6 +197,7 @@ At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are repla ### The rest, quickly +* **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 low-level Server](advanced/low-level-server.md)** shows the stamp on the wire. * **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. * **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)**. * **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)**. diff --git a/docs_src/lowlevel/tutorial003.py b/docs_src/lowlevel/tutorial003.py index f350397006..65d89f198c 100644 --- a/docs_src/lowlevel/tutorial003.py +++ b/docs_src/lowlevel/tutorial003.py @@ -38,4 +38,4 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> ) -server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) +server = Server("Bookshop", version="2.0.0", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/protocol_versions/tutorial004.py b/docs_src/protocol_versions/tutorial004.py index c1b8fc6b5b..dd0443b972 100644 --- a/docs_src/protocol_versions/tutorial004.py +++ b/docs_src/protocol_versions/tutorial004.py @@ -16,4 +16,5 @@ async def main() -> None: async with Client(mcp, mode="2026-07-28", prior_discover=saved) as client: print(client.protocol_version) - print(client.server_info.name) + if client.server_info is not None: + print(client.server_info.name) diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index 4b56a671c7..4b03421f44 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -102,6 +102,7 @@ async def replay_events_after(self, last_event_id: EventId, send_callback: Event mcp = MCPServer( name="mcp-conformance-test-server", + version="0.1.0", request_state_security=RequestStateSecurity(keys=[_REQUEST_STATE_KEY]), ) @@ -357,13 +358,13 @@ async def test_missing_capability(ctx: Context) -> str: ``CallToolResult.isError``) so the conformance harness observes a protocol-level error response with ``data.requiredCapabilities``. """ - client_params = ctx.session.client_params - sampling_declared = client_params is not None and client_params.capabilities.sampling is not None + capabilities = ctx.session.client_capabilities + sampling_declared = capabilities is not None and capabilities.sampling is not None if not sampling_declared: raise MCPError( code=MISSING_REQUIRED_CLIENT_CAPABILITY, message="This tool requires the client 'sampling' capability", - data={"requiredCapabilities": ["sampling"]}, + data={"requiredCapabilities": {"sampling": {}}}, ) return "Client declared sampling capability; proceeding." diff --git a/examples/stories/_harness.py b/examples/stories/_harness.py index 3ef52b3239..62e02e70ab 100644 --- a/examples/stories/_harness.py +++ b/examples/stories/_harness.py @@ -162,7 +162,7 @@ def run_client(main: Callable[..., Awaitable[None]]) -> None: if cfg["era"] == "dual-in-body": # The story pins its connection modes inside ``main`` itself, so hand it "auto" # (the ``Client`` default) and let those in-body pins decide. A hard version pin - # here would skip the discover probe and leave ``server_info`` blank. + # here would skip the discover probe and leave `server_info` None. era = "in-body" mode = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy", "in-body": "auto"}[era] diff --git a/examples/stories/dual_era/README.md b/examples/stories/dual_era/README.md index f14f164027..6eca876934 100644 --- a/examples/stories/dual_era/README.md +++ b/examples/stories/dual_era/README.md @@ -30,7 +30,9 @@ leg fails there today — run over `--http`. at construction; no date strings appear in the body. - `client.py` — `client.protocol_version` / `client.server_info` / `client.server_capabilities` are era-neutral: populated by `initialize` *or* - `server/discover`, whichever ran. + `server/discover`, whichever ran. On the 2026 era `server_info` comes from + the optional `serverInfo` `_meta` stamp (`None` for a server that does not + identify itself); `initialize` always carries it. - `server.py` — `ctx.request_context.protocol_version` is the era branch key (lowlevel: `ctx.protocol_version` directly). Compare against `MODERN_PROTOCOL_VERSIONS`, never a date literal. diff --git a/examples/stories/dual_era/client.py b/examples/stories/dual_era/client.py index ba9acf5d99..b884c70609 100644 --- a/examples/stories/dual_era/client.py +++ b/examples/stories/dual_era/client.py @@ -13,7 +13,11 @@ async def main(targets: TargetFactory, *, mode: str = "auto") -> None: # The version/info/capabilities accessors are era-neutral. async with Client(targets(), mode=mode) as modern: assert modern.protocol_version == LATEST_MODERN_VERSION - assert modern.server_info.name == "dual-era-example" + # On the 2026 era, server identity is an optional serverInfo stamp in the + # result _meta (None for an anonymous server); this server stamps it. + info = modern.server_info + assert info is not None, "the server stamps serverInfo into its results" + assert info.name == "dual-era-example" assert modern.server_capabilities.tools is not None listed = await modern.list_tools() @@ -28,7 +32,9 @@ async def main(targets: TargetFactory, *, mode: str = "auto") -> None: # The same accessors are populated identically — here by ``initialize``. async with Client(targets(), mode="legacy") as legacy: assert legacy.protocol_version == LATEST_HANDSHAKE_VERSION - assert legacy.server_info.name == "dual-era-example" + info = legacy.server_info + assert info is not None, "initialize always carries serverInfo" + assert info.name == "dual-era-example" assert legacy.server_capabilities.tools is not None result = await legacy.call_tool("greet", {"name": "2025 client"}) diff --git a/examples/stories/reconnect/README.md b/examples/stories/reconnect/README.md index 78d281e7a9..a5d3d8f595 100644 --- a/examples/stories/reconnect/README.md +++ b/examples/stories/reconnect/README.md @@ -35,7 +35,7 @@ uv run python -m stories.reconnect.client --http --server server_lowlevel ## Caveats - `mode=` *without* `prior_discover=` synthesizes a placeholder - whose `server_info` is `Implementation(name="", version="")`. Pass the cached + with no `serverInfo` stamp, so `server_info` reads `None`. Pass the cached result to get real identity on reconnect. Whether `Client` should expose a public synthesizer (or refuse the bare pin) is open. - `client.session.discover_result` is a one-hop reach into the mechanics layer; diff --git a/examples/stories/reconnect/client.py b/examples/stories/reconnect/client.py index aab2312dc9..0bf3c7af95 100644 --- a/examples/stories/reconnect/client.py +++ b/examples/stories/reconnect/client.py @@ -15,7 +15,11 @@ async def main(targets: TargetFactory, *, mode: str = "auto") -> None: discovered = client.session.discover_result assert discovered is not None, "mode='auto' against a modern server populates discover_result" assert client.protocol_version == LATEST_MODERN_VERSION - assert client.server_info.name == "reconnect-example" + # On the 2026 era, server identity is an optional serverInfo stamp in the + # result _meta; an anonymous server reads as None. This one stamps it. + info = client.server_info + assert info is not None, "the server stamps serverInfo into its results" + assert info.name == "reconnect-example" assert LATEST_MODERN_VERSION in discovered.supported_versions result = await client.call_tool("add", {"a": 2, "b": 3}) @@ -28,11 +32,13 @@ async def main(targets: TargetFactory, *, mode: str = "auto") -> None: # Reconnect: a version pin plus the cached DiscoverResult adopts the prior state with # zero round-trips on entry. A Client cannot be re-entered after exit, so targets() - # yields a fresh one. Without prior_discover= a bare pin would synthesize a blank - # server_info — the cache is what makes the era-neutral accessors useful here. + # yields a fresh one. Without prior_discover= a bare pin would leave server_info + # None — the cache is what carries the server's identity stamp across reconnects. async with Client(targets(), mode=LATEST_MODERN_VERSION, prior_discover=rehydrated) as second: assert second.protocol_version == LATEST_MODERN_VERSION - assert second.server_info.name == "reconnect-example" + info = second.server_info + assert info is not None, "the cached DiscoverResult carries the serverInfo stamp" + assert info.name == "reconnect-example" assert second.server_capabilities.tools is not None assert second.session.discover_result == rehydrated diff --git a/schema/2026-07-28.json b/schema/2026-07-28.json index 87116a420e..7b0d05712d 100644 --- a/schema/2026-07-28.json +++ b/schema/2026-07-28.json @@ -123,7 +123,7 @@ "description": "A result that supports a time-to-live (TTL) hint for client-side caching.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -208,7 +208,7 @@ "description": "The result returned by the server for a {@link CallToolRequesttools/call} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "content": { "description": "A list of content objects that represent the unstructured result of the tool call.", @@ -501,7 +501,7 @@ "description": "The result returned by the server for a {@link CompleteRequestcompletion/complete} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "completion": { "properties": { @@ -740,7 +740,7 @@ "description": "The result returned by the server for a {@link DiscoverRequestserver/discover} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -762,10 +762,6 @@ "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", "type": "string" }, - "serverInfo": { - "$ref": "#/$defs/Implementation", - "description": "Information about the server software implementation." - }, "supportedVersions": { "description": "MCP Protocol Versions this server supports. The client should choose a\nversion from this list for use in subsequent requests.", "items": { @@ -783,7 +779,6 @@ "cacheScope", "capabilities", "resultType", - "serverInfo", "supportedVersions", "ttlMs" ], @@ -1084,7 +1079,7 @@ "description": "The result returned by the server for a {@link GetPromptRequestprompts/get} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "description": { "description": "An optional description for the prompt.", @@ -1310,7 +1305,7 @@ "description": "An InputRequiredResult sent by the server to indicate that additional input is needed\nbefore the request can be completed.\n\nAt least one of `inputRequests` or `requestState` MUST be present.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "inputRequests": { "$ref": "#/$defs/InputRequests" @@ -1644,7 +1639,7 @@ "description": "The result returned by the server for a {@link ListPromptsRequestprompts/list} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -1733,7 +1728,7 @@ "description": "The result returned by the server for a {@link ListResourceTemplatesRequestresources/templates/list} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -1822,7 +1817,7 @@ "description": "The result returned by the server for a {@link ListResourcesRequestresources/list} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -1947,7 +1942,7 @@ "description": "The result returned by the server for a {@link ListToolsRequesttools/list} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -2299,7 +2294,7 @@ "PaginatedResult": { "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "nextCursor": { "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", @@ -2600,7 +2595,7 @@ "description": "The result returned by the server for a {@link ReadResourceRequestresources/read} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -2700,7 +2695,7 @@ }, "io.modelcontextprotocol/clientInfo": { "$ref": "#/$defs/Implementation", - "description": "Identifies the client software making the request. Required.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional." + "description": "Identifies the client software making the request. Clients SHOULD\ninclude this field on every request unless specifically configured not\nto do so.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional.\n\nThe value is self-reported by the client and is not verified by the\nprotocol. It is intended for display, logging, and debugging. Servers\nSHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for\nsecurity decisions." }, "io.modelcontextprotocol/logLevel": { "$ref": "#/$defs/LoggingLevel", @@ -2717,7 +2712,6 @@ }, "required": [ "io.modelcontextprotocol/clientCapabilities", - "io.modelcontextprotocol/clientInfo", "io.modelcontextprotocol/protocolVersion" ], "type": "object" @@ -3005,7 +2999,7 @@ "description": "Common result fields.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "resultType": { "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", @@ -3017,6 +3011,16 @@ ], "type": "object" }, + "ResultMetaObject": { + "description": "Extends {@link MetaObject} with additional result-specific fields. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/serverInfo": { + "$ref": "#/$defs/Implementation", + "description": "Identifies the server software producing the response. Servers SHOULD\ninclude this field on every response unless specifically configured not\nto do so.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional.\n\nThe value is self-reported by the server and is not verified by the\nprotocol. It is intended for display, logging, and debugging. Clients\nSHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for\nsecurity decisions." + } + }, + "type": "object" + }, "ResultType": { "description": "Indicates the type of a {@link Result} object, allowing the client to\ndetermine how to parse the response.\n\ncomplete - the request completed successfully and the result contains the final content.\ninput_required - the request requires additional input and the result contains an {@link InputRequiredResult} object with instructions for the client to provide additional input before retrying the original request.", "type": "string" @@ -3312,7 +3316,7 @@ "type": "object" }, "SubscriptionsAcknowledgedNotification": { - "description": "Sent by the server as the first message on a\n{@link SubscriptionsListenRequestsubscriptions/listen} stream to acknowledge\nthat the subscription has been established and to report which notification\ntypes it agreed to honor.", + "description": "Sent by the server to acknowledge that a\n{@link SubscriptionsListenRequestsubscriptions/listen} subscription has been\nestablished and to report which notification types it agreed to honor.\n\nThis notification MUST be the first message the server sends carrying the\nsubscription's ID in `io.modelcontextprotocol/subscriptionId`. The server MUST\nNOT send any notification on the subscription before acknowledging it. On\nstdio, where every subscription shares one channel, this ordering is defined\nper subscription ID and not per channel: messages belonging to other\nsubscriptions MAY be interleaved before it.", "properties": { "jsonrpc": { "const": "2.0", @@ -3410,8 +3414,12 @@ "type": "object" }, "SubscriptionsListenResultMeta": { - "description": "Extends {@link MetaObject} with the subscription-stream identifier carried by a\n{@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.", + "description": "Extends {@link ResultMetaObject} with the subscription-stream identifier carried by a\n{@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.", "properties": { + "io.modelcontextprotocol/serverInfo": { + "$ref": "#/$defs/Implementation", + "description": "Identifies the server software producing the response. Servers SHOULD\ninclude this field on every response unless specifically configured not\nto do so.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional.\n\nThe value is self-reported by the server and is not verified by the\nprotocol. It is intended for display, logging, and debugging. Clients\nSHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for\nsecurity decisions." + }, "io.modelcontextprotocol/subscriptionId": { "$ref": "#/$defs/RequestId", "description": "Identifies the subscription stream this response closes, so the client can\ncorrelate it with the originating subscription — mirroring the same key on\nthe stream's notifications. The value is the JSON-RPC ID of the\n`subscriptions/listen` request that opened the stream (and equals this\nresponse's `id`)." diff --git a/schema/PINNED.json b/schema/PINNED.json index 9b1739d29d..1c3e2c235a 100644 --- a/schema/PINNED.json +++ b/schema/PINNED.json @@ -8,7 +8,7 @@ { "protocol_version": "2026-07-28", "source_path_in_spec_repo": "schema/draft/schema.json", - "spec_commit": "ead35b59b4fda8b32e276810025d8f92bdcec1b6", - "sha256": "e00f675287e8cf078688c26c8a89d283ff2613da3b76d5cd15aff9d189df639c" + "spec_commit": "71e306956a4959c9655e5036be215d41986596e6", + "sha256": "6293cdfe015c14bd36eda4b1331ce37bda377609e58ed6d09d16f28e7d3c7ad4" } ] diff --git a/scripts/gen_surface_types.py b/scripts/gen_surface_types.py index f338629095..0c7b85289b 100644 --- a/scripts/gen_surface_types.py +++ b/scripts/gen_surface_types.py @@ -25,6 +25,10 @@ SCHEMA_DIR = REPO_ROOT / "schema" TYPES_DIR = REPO_ROOT / "src" / "mcp-types" / "mcp_types" +# The result-meta serverInfo stamp: every `$defs` entry carrying this property +# gets its typed `$ref` stripped by `make_server_info_opaque` below. +SERVER_INFO_META_PROPERTY = "io.modelcontextprotocol/serverInfo" + # schema.ts -> schema.json renders TypeScript `number` as JSON Schema # `integer` at these sites; patch the JSON before codegen so floats validate. # Patched to `["integer", "number"]` (not bare `"number"`) so codegen emits @@ -89,6 +93,7 @@ "MetaObject", "NotificationMetaObject", "RequestMetaObject", + "ResultMetaObject", "SubscriptionsListenResultMeta", "InputSchema", "OutputSchema", @@ -128,9 +133,14 @@ def load_pinned() -> list[dict[str, str]]: def patch_schema(schema: dict[str, Any], patches: list[tuple[str, Any, Any]]) -> None: - """Apply `(path, old, new)` JSON-pointer-ish patches in place, asserting the old value.""" + """Apply `(path, old, new)` JSON-pointer-ish patches in place, asserting the old value. + + Path segments use JSON-pointer escaping (`~1` for `/`, `~0` for `~`) so keys + that themselves contain a slash (the reserved `io.modelcontextprotocol/*` + `_meta` keys) are addressable. + """ for path, old, new in patches: - *parts, leaf = path.split("/") + *parts, leaf = (part.replace("~1", "/").replace("~0", "~") for part in path.split("/")) node: Any = schema for part in parts: node = node[int(part) if part.isdigit() else part] @@ -139,6 +149,23 @@ def patch_schema(schema: dict[str, Any], patches: list[tuple[str, Any, Any]]) -> node[leaf] = new +def make_server_info_opaque(schema: dict[str, Any]) -> None: + """Strip the typed `$ref` from every result-meta serverInfo property. + + The stamp is display-only: the spec forbids acting on it, so a malformed + value must never fail a whole response (clients validate every inbound + result against this surface). Walking every `$defs` entry keeps future + result-meta definitions lenient by construction instead of relying on an + enumerated list; the typed, lenient parse happens at the read edge + (`ClientSession.server_info`). typescript-sdk does the same with a + schema-level catch-to-undefined. + """ + for definition in schema.get("$defs", {}).values(): + prop = definition.get("properties", {}).get(SERVER_INFO_META_PROPERTY) + if prop is not None and "$ref" in prop: + del prop["$ref"] + + def run_codegen(schema_path: Path, output_path: Path) -> None: """Run datamodel-code-generator at the version pinned in the `codegen` dependency group.""" # fmt: off @@ -196,6 +223,7 @@ def build(entry: dict[str, str]) -> str: version = entry["protocol_version"] schema = json.loads((SCHEMA_DIR / f"{version}.json").read_text()) patch_schema(schema, SCHEMA_PATCHES.get(version, [])) + make_server_info_opaque(schema) with tempfile.TemporaryDirectory() as tmp: patched = Path(tmp) / "schema.json" diff --git a/src/mcp-types/mcp_types/__init__.py b/src/mcp-types/mcp_types/__init__.py index 87c0c5d594..41cec2edf3 100644 --- a/src/mcp-types/mcp_types/__init__.py +++ b/src/mcp-types/mcp_types/__init__.py @@ -12,6 +12,7 @@ DEFAULT_NEGOTIATED_VERSION, LOG_LEVEL_META_KEY, PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, Annotations, AudioContent, BaseMetadata, @@ -231,6 +232,8 @@ "CLIENT_INFO_META_KEY", "CLIENT_CAPABILITIES_META_KEY", "LOG_LEVEL_META_KEY", + # Reserved result _meta keys + "SERVER_INFO_META_KEY", # Type aliases and variables "CORE_RESULT_TYPES", "ContentBlock", diff --git a/src/mcp-types/mcp_types/_types.py b/src/mcp-types/mcp_types/_types.py index 9c0516836a..e19ebe0b94 100644 --- a/src/mcp-types/mcp_types/_types.py +++ b/src/mcp-types/mcp_types/_types.py @@ -69,6 +69,13 @@ class MCPModel(BaseModel): introduces it. If absent, the server must not send log notifications. """ +SERVER_INFO_META_KEY = "io.modelcontextprotocol/serverInfo" +"""Reserved result `_meta` key: the server `Implementation` (2026-07-28). SDK-managed. + +Servers SHOULD stamp it on every result. The value is self-reported and +unverified - display, logging, and debugging only; never behavior or security. +""" + class RequestParamsMeta(TypedDict, extra_items=Any): """The `_meta` object on request params (schema name: `RequestMetaObject`). @@ -591,8 +598,6 @@ class DiscoverResult(CacheableResult): capabilities: ServerCapabilities - server_info: Implementation - instructions: str | None = None """Natural-language guidance describing the server and its features, e.g. for a system prompt. Should not duplicate information already in tool descriptions.""" diff --git a/src/mcp-types/mcp_types/v2026_07_28/__init__.py b/src/mcp-types/mcp_types/v2026_07_28/__init__.py index 2963c13232..fb168b3059 100644 --- a/src/mcp-types/mcp_types/v2026_07_28/__init__.py +++ b/src/mcp-types/mcp_types/v2026_07_28/__init__.py @@ -1,7 +1,7 @@ """Internal wire-shape models for protocol 2026-07-28. Generated; do not edit. Regenerate with `scripts/gen_surface_types.py` from `schema/2026-07-28.json` -(sha256 `e00f675287e8cf078688c26c8a89d283ff2613da3b76d5cd15aff9d189df639c`).""" +(sha256 `6293cdfe015c14bd36eda4b1331ce37bda377609e58ed6d09d16f28e7d3c7ad4`).""" # pyright: reportIncompatibleVariableOverride=false, reportGeneralTypeIssues=false from __future__ import annotations @@ -602,28 +602,6 @@ class NumberSchema(WireModel): type: Literal["integer", "number"] -class PaginatedResult(WireModel): - model_config = ConfigDict( - extra="ignore", - ) - meta: Annotated[MetaObject | None, Field(alias="_meta")] = None - next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None - """ - An opaque token representing the pagination position after the last returned result. - If present, there may be more results available. - """ - result_type: Annotated[str, Field(alias="resultType")] - """ - Indicates the type of the result, which allows the client to determine - how to parse the result object. - - Servers implementing this protocol version MUST include this field. - For backward compatibility, when a client receives a result from a - server implementing an earlier protocol version (which does not include - `resultType`), the client MUST treat the absent field as `"complete"`. - """ - - class ParseError(WireModel): """ A JSON-RPC error indicating that invalid JSON was received by the server. This error is returned when the server cannot parse the JSON text of a message. @@ -757,24 +735,27 @@ class ResourceTemplateReference(WireModel): """ -class Result(WireModel): +class ResultMetaObject(WireModel): """ - Common result fields. + Extends {@link MetaObject} with additional result-specific fields. All key naming rules from `MetaObject` apply. """ model_config = ConfigDict( extra="allow", ) - meta: Annotated[MetaObject | None, Field(alias="_meta")] = None - result_type: Annotated[str, Field(alias="resultType")] + io_modelcontextprotocol_server_info: Annotated[Any | None, Field(alias="io.modelcontextprotocol/serverInfo")] = None """ - Indicates the type of the result, which allows the client to determine - how to parse the result object. + Identifies the server software producing the response. Servers SHOULD + include this field on every response unless specifically configured not + to do so. - Servers implementing this protocol version MUST include this field. - For backward compatibility, when a client receives a result from a - server implementing an earlier protocol version (which does not include - `resultType`), the client MUST treat the absent field as `"complete"`. + The {@link Implementation} schema requires `name` and `version`; other + fields are optional. + + The value is self-reported by the server and is not verified by the + protocol. It is intended for display, logging, and debugging. Clients + SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for + security decisions. """ @@ -911,13 +892,27 @@ class SubscriptionFilter(WireModel): class SubscriptionsListenResultMeta(WireModel): """ - Extends {@link MetaObject} with the subscription-stream identifier carried by a + Extends {@link ResultMetaObject} with the subscription-stream identifier carried by a {@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply. """ model_config = ConfigDict( extra="allow", ) + io_modelcontextprotocol_server_info: Annotated[Any | None, Field(alias="io.modelcontextprotocol/serverInfo")] = None + """ + Identifies the server software producing the response. Servers SHOULD + include this field on every response unless specifically configured not + to do so. + + The {@link Implementation} schema requires `name` and `version`; other + fields are optional. + + The value is self-reported by the server and is not verified by the + protocol. It is intended for display, logging, and debugging. Clients + SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for + security decisions. + """ io_modelcontextprotocol_subscription_id: Annotated[RequestId, Field(alias="io.modelcontextprotocol/subscriptionId")] """ Identifies the subscription stream this response closes, so the client can @@ -1401,7 +1396,7 @@ class CacheableResult(WireModel): model_config = ConfigDict( extra="ignore", ) - meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")] """ Indicates the intended scope of the cached response, analogous to HTTP @@ -1438,13 +1433,6 @@ class CacheableResult(WireModel): """ -class ClientResult(RootModel[Result]): - root: Result - """ - Common result fields. - """ - - class CompleteResult(WireModel): """ The result returned by the server for a {@link CompleteRequestcompletion/complete} request. @@ -1453,7 +1441,7 @@ class CompleteResult(WireModel): model_config = ConfigDict( extra="ignore", ) - meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None completion: Completion result_type: Annotated[str, Field(alias="resultType")] """ @@ -1507,13 +1495,6 @@ class EmbeddedResource(WireModel): type: Literal["resource"] -class EmptyResult(RootModel[Result]): - root: Result - """ - Common result fields. - """ - - class EnumSchema( RootModel[ UntitledSingleSelectEnumSchema @@ -1599,19 +1580,6 @@ class JSONRPCRequest(WireModel): params: dict[str, Any] | None = None -class JSONRPCResultResponse(WireModel): - """ - A successful (non-error) response to a request. - """ - - model_config = ConfigDict( - extra="ignore", - ) - id: RequestId - jsonrpc: Literal["2.0"] - result: Result - - class Params(WireModel): model_config = ConfigDict( extra="ignore", @@ -1690,6 +1658,28 @@ class NotificationParams(WireModel): meta: Annotated[NotificationMetaObject | None, Field(alias="_meta")] = None +class PaginatedResult(WireModel): + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None + """ + An opaque token representing the pagination position after the last returned result. + If present, there may be more results available. + """ + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + + class PrimitiveSchemaDefinition( RootModel[ StringSchema @@ -1810,7 +1800,7 @@ class ReadResourceResult(WireModel): model_config = ConfigDict( extra="ignore", ) - meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")] """ Indicates the intended scope of the cached response, analogous to HTTP @@ -2053,6 +2043,27 @@ class ResourceUpdatedNotificationParams(WireModel): """ +class Result(WireModel): + """ + Common result fields. + """ + + model_config = ConfigDict( + extra="allow", + ) + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + + class SingleSelectEnumSchema(RootModel[UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema]): root: UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema @@ -2245,6 +2256,13 @@ class ClientNotification(WireModel): params: CancelledNotificationParams +class ClientResult(RootModel[Result]): + root: Result + """ + Common result fields. + """ + + class ContentBlock(RootModel[TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource]): root: TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource @@ -2261,19 +2279,25 @@ class ElicitRequest(WireModel): params: ElicitRequestParams -class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse]): - root: JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse +class EmptyResult(RootModel[Result]): + root: Result """ - Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. + Common result fields. """ -class JSONRPCResponse(RootModel[JSONRPCResultResponse | JSONRPCErrorResponse]): - root: JSONRPCResultResponse | JSONRPCErrorResponse +class JSONRPCResultResponse(WireModel): """ - A response to a request, containing either the result or error. + A successful (non-error) response to a request. """ + model_config = ConfigDict( + extra="ignore", + ) + id: RequestId + jsonrpc: Literal["2.0"] + result: Result + class ListPromptsResult(WireModel): """ @@ -2283,7 +2307,7 @@ class ListPromptsResult(WireModel): model_config = ConfigDict( extra="ignore", ) - meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")] """ Indicates the intended scope of the cached response, analogous to HTTP @@ -2347,7 +2371,7 @@ class ListResourceTemplatesResult(WireModel): model_config = ConfigDict( extra="ignore", ) - meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")] """ Indicates the intended scope of the cached response, analogous to HTTP @@ -2411,7 +2435,7 @@ class ListResourcesResult(WireModel): model_config = ConfigDict( extra="ignore", ) - meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")] """ Indicates the intended scope of the cached response, analogous to HTTP @@ -2475,7 +2499,7 @@ class ListToolsResult(WireModel): model_config = ConfigDict( extra="ignore", ) - meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")] """ Indicates the intended scope of the cached response, analogous to HTTP @@ -2597,10 +2621,16 @@ class ResourceUpdatedNotification(WireModel): class SubscriptionsAcknowledgedNotification(WireModel): """ - Sent by the server as the first message on a - {@link SubscriptionsListenRequestsubscriptions/listen} stream to acknowledge - that the subscription has been established and to report which notification - types it agreed to honor. + Sent by the server to acknowledge that a + {@link SubscriptionsListenRequestsubscriptions/listen} subscription has been + established and to report which notification types it agreed to honor. + + This notification MUST be the first message the server sends carrying the + subscription's ID in `io.modelcontextprotocol/subscriptionId`. The server MUST + NOT send any notification on the subscription before acknowledging it. On + stdio, where every subscription shares one channel, this ordering is defined + per subscription ID and not per channel: messages belonging to other + subscriptions MAY be interleaved before it. """ model_config = ConfigDict( @@ -2662,7 +2692,7 @@ class CallToolResult(WireModel): model_config = ConfigDict( extra="ignore", ) - meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None content: list[ContentBlock] """ A list of content objects that represent the unstructured result of the tool call. @@ -2728,7 +2758,7 @@ class GetPromptResult(WireModel): model_config = ConfigDict( extra="ignore", ) - meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None description: str | None = None """ An optional description for the prompt. @@ -2746,6 +2776,20 @@ class GetPromptResult(WireModel): """ +class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse]): + root: JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse + """ + Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. + """ + + +class JSONRPCResponse(RootModel[JSONRPCResultResponse | JSONRPCErrorResponse]): + root: JSONRPCResultResponse | JSONRPCErrorResponse + """ + A response to a request, containing either the result or error. + """ + + class LoggingMessageNotification(WireModel): """ JSONRPCNotification of a log message passed from server to client. The client opts in by setting `"io.modelcontextprotocol/logLevel"` in a request's `_meta`. @@ -3100,7 +3144,7 @@ class DiscoverResult(WireModel): model_config = ConfigDict( extra="ignore", ) - meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None cache_scope: Annotated[Literal["private", "public"], Field(alias="cacheScope")] """ Indicates the intended scope of the cached response, analogous to HTTP @@ -3137,10 +3181,6 @@ class DiscoverResult(WireModel): server implementing an earlier protocol version (which does not include `resultType`), the client MUST treat the absent field as `"complete"`. """ - server_info: Annotated[Implementation, Field(alias="serverInfo")] - """ - Information about the server software implementation. - """ supported_versions: Annotated[list[str], Field(alias="supportedVersions")] """ MCP Protocol Versions this server supports. The client should choose a @@ -3231,7 +3271,7 @@ class InputRequiredResult(WireModel): model_config = ConfigDict( extra="ignore", ) - meta: Annotated[MetaObject | None, Field(alias="_meta")] = None + meta: Annotated[ResultMetaObject | None, Field(alias="_meta")] = None input_requests: Annotated[InputRequests | None, Field(alias="inputRequests")] = None request_state: Annotated[str | None, Field(alias="requestState")] = None result_type: Annotated[str, Field(alias="resultType")] @@ -3442,12 +3482,21 @@ class RequestMetaObject(WireModel): an empty object means the client supports no optional capabilities. Servers MUST NOT infer capabilities from prior requests. """ - io_modelcontextprotocol_client_info: Annotated[Implementation, Field(alias="io.modelcontextprotocol/clientInfo")] + io_modelcontextprotocol_client_info: Annotated[ + Implementation | None, Field(alias="io.modelcontextprotocol/clientInfo") + ] = None """ - Identifies the client software making the request. Required. + Identifies the client software making the request. Clients SHOULD + include this field on every request unless specifically configured not + to do so. The {@link Implementation} schema requires `name` and `version`; other fields are optional. + + The value is self-reported by the client and is not verified by the + protocol. It is intended for display, logging, and debugging. Servers + SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for + security decisions. """ io_modelcontextprotocol_log_level: Annotated[ LoggingLevel | None, Field(alias="io.modelcontextprotocol/logLevel") diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index d519106a63..e840675011 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -176,7 +176,6 @@ def _synthesize_discover(protocol_version: str) -> types.DiscoverResult: return types.DiscoverResult( supported_versions=[protocol_version], capabilities=types.ServerCapabilities(), - server_info=types.Implementation(name="", version=""), result_type="complete", ttl_ms=0, cache_scope="public", @@ -453,7 +452,8 @@ async def __aenter__(self) -> Client: session.adopt(self.prior_discover or _synthesize_discover(self.mode)) # Only publish the session after the handshake succeeds, so `_session is not None` - # implies the protocol_version/server_info/server_capabilities are populated. If the + # implies the protocol_version/server_capabilities are populated (server_info + # stays optional: 2026-era servers may not identify themselves). If the # handshake raised above, the local exit_stack unwinds the transport for us. self._session = session self._exit_stack = exit_stack.pop_all() @@ -479,18 +479,24 @@ def session(self) -> ClientSession: return self._session # TODO(maxisbey): the by-construction shape is for __aenter__ to return a connected-view - # type whose protocol_version/server_info/server_capabilities are non-Optional fields, + # type whose protocol_version/server_capabilities are non-Optional fields, # eliminating these guards (and the one in .session). Same family as resolving the # transport/connector at __post_init__ so the Optional internal fields disappear. + # (server_info stays Optional even connected: the 2026-era stamp is optional.) @property def protocol_version(self) -> str: """Negotiated protocol version (set by initialize/discover/adopt during ``__aenter__``).""" return _connected(self.session.protocol_version) @property - def server_info(self) -> Implementation: - """Server name/version (set by initialize/discover/adopt during ``__aenter__``).""" - return _connected(self.session.server_info) + def server_info(self) -> Implementation | None: + """Server name/version, or `None` when the server did not identify itself. + + Legacy connections always carry it (`InitializeResult.serverInfo` is + required); on 2026-era connections the `_meta` `serverInfo` stamp is + optional, so an anonymous server reads as `None`. + """ + return self.session.server_info @property def server_capabilities(self) -> ServerCapabilities: diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 097ade1c91..36cb78df5d 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -20,6 +20,7 @@ INTERNAL_ERROR, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, UNSUPPORTED_PROTOCOL_VERSION, RequestId, RequestParamsMeta, @@ -77,6 +78,21 @@ def _preconnect_stamp(data: dict[str, Any], opts: CallOptions) -> None: opts["cancel_on_abandon"] = False +def _parse_server_info_stamp(result: types.DiscoverResult) -> types.Implementation | None: + """The typed identity from a discover result's `_meta` serverInfo stamp. + + The stamp is display-only per the spec, so absent and malformed both read + as `None` rather than failing the connection. + """ + raw = (result.meta or {}).get(SERVER_INFO_META_KEY) + if raw is None: + return None + try: + return types.Implementation.model_validate(raw) + except ValidationError: + return None + + def _make_handshake_stamp(protocol_version: str) -> Callable[[dict[str, Any], CallOptions], None]: def stamp(data: dict[str, Any], opts: CallOptions) -> None: opts.setdefault("headers", {})[MCP_PROTOCOL_VERSION_HEADER] = protocol_version @@ -360,6 +376,7 @@ def __init__( self._x_mcp_header_maps: dict[str, dict[tuple[str, ...], str]] = {} self._initialize_result: types.InitializeResult | None = None self._discover_result: types.DiscoverResult | None = None + self._discover_server_info: types.Implementation | None = None self._negotiated_version: str | None = None self._stamp: Callable[[dict[str, Any], CallOptions], None] = _preconnect_stamp self._task_group: anyio.abc.TaskGroup | None = None @@ -605,12 +622,14 @@ def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None: capabilities = self._build_capabilities(version).model_dump(by_alias=True, mode="json", exclude_none=True) self._stamp = _make_modern_stamp(version, client_info, capabilities, self._resolve_param_headers) self._discover_result = result + self._discover_server_info = _parse_server_info_stamp(result) self._initialize_result = None else: version = result.protocol_version self._stamp = _make_handshake_stamp(version) self._initialize_result = result self._discover_result = None + self._discover_server_info = None self._negotiated_version = version # Both arms reach here, so re-adoption resets cleanly; legacy versions activate no claims. # Core-vocabulary tags are unconstructible (ResultClaim.__post_init__), so no exclusion needed. @@ -719,9 +738,15 @@ def protocol_version(self) -> str | None: @property def server_info(self) -> types.Implementation | None: - """Server name/version. None until `initialize()`, `discover()`, or `adopt()`.""" + """Server name/version. None until `initialize()`, `discover()`, or `adopt()`. + + On 2026-era connections this is the discover result's optional `_meta` + `serverInfo` stamp, parsed once at adopt time; `None` when the server + did not identify itself. The stamp is display-only per the spec, so a + malformed value reads as absent rather than failing the connection. + """ if self._discover_result is not None: - return self._discover_result.server_info + return self._discover_server_info if self._initialize_result is not None: return self._initialize_result.server_info return None diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index f612511568..1db6b35f8a 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -218,9 +218,11 @@ async def _tool_input_schema( """ meta = { PROTOCOL_VERSION_META_KEY: verdict.protocol_version, - CLIENT_INFO_META_KEY: verdict.client_info, CLIENT_CAPABILITIES_META_KEY: verdict.client_capabilities, } + if verdict.client_info is not None: + # Optional key: a conforming pair-only caller omits it rather than sending null. + meta[CLIENT_INFO_META_KEY] = verdict.client_info list_params: dict[str, Any] = {"_meta": meta} try: _methods.validate_client_request("tools/list", verdict.protocol_version, list_params) diff --git a/src/mcp/server/apps.py b/src/mcp/server/apps.py index d5b9d9ed85..583e203ac0 100644 --- a/src/mcp/server/apps.py +++ b/src/mcp/server/apps.py @@ -233,8 +233,7 @@ def client_supports_apps(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> def _client_capabilities(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> Any: if isinstance(ctx, Context): return ctx.client_capabilities - client_params = ctx.session.client_params - return client_params.capabilities if client_params else None + return ctx.session.client_capabilities def _require_ui_scheme(uri: str) -> None: diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index 8cb7dc4213..ca05928dff 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -147,9 +147,13 @@ class Connection: session_id: str | None - client_params: InitializeRequestParams | None - """The full `initialize` request params, or the equivalent built from the - 2026-era envelope. `None` when no client info was supplied.""" + client_capabilities: ClientCapabilities | None + """The capabilities the peer declared: the handshake's on the loop path, + the request envelope's on the modern path. `None` when none were declared. + Kept in lockstep with `client_params` by its setter, and settable on its + own for the modern envelope, where capabilities are required but client + info is optional (spec PR #3002) - capability checks must not depend on the + peer having identified itself.""" protocol_version: str """The protocol version this connection speaks. Populated at construction @@ -180,11 +184,29 @@ def __init__( self.outbound = outbound self.protocol_version = protocol_version self.session_id = session_id + self.client_capabilities = None self.client_params = client_params self.initialized = anyio.Event() self.state = {} self.exit_stack = AsyncExitStack() + @property + def client_params(self) -> InitializeRequestParams | None: + """The full `initialize` request params, or the equivalent built from the + 2026-era envelope. `None` when no client info was supplied.""" + return self._client_params + + @client_params.setter + def client_params(self, value: InitializeRequestParams | None) -> None: + # Assignment is the sync point: recording full client params (the + # handshake commit, or a modern envelope carrying client info) also + # records the capabilities fact, so the two can never drift. Clearing + # to `None` leaves `client_capabilities` alone - the modern envelope + # declares capabilities without client info. + self._client_params = value + if value is not None: + self.client_capabilities = value.capabilities + @classmethod def from_envelope( cls, @@ -201,13 +223,15 @@ def from_envelope( values. `client_info` and `client_capabilities` are the raw envelope values: this constructor owns turning them into connection identity, identically on every modern entry, so a mis-shaped value degrades to - not-supplied rather than failing the request. `initialized` - is set and the info/capabilities (when both supplied and well-formed) - are recorded as `client_params` so capability checks work. `outbound` - defaults to the no-channel sentinel for the single-exchange HTTP path; - duplex modern transports (e.g. stdio) pass a notify-only wrapper - around the dispatcher so server notifications ride the pipe while - server-initiated requests stay refused. + not-supplied rather than failing the request. `initialized` is set, + well-formed capabilities are recorded as `client_capabilities` (client + info is optional per spec PR #3002, so capability checks never depend on + it), and the full `client_params` is additionally synthesized when + client info was supplied too. `outbound` defaults to the no-channel + sentinel for the single-exchange HTTP path; duplex modern transports + (e.g. stdio) pass a notify-only wrapper around the dispatcher so + server notifications ride the pipe while server-initiated requests + stay refused. """ info = _typed(Implementation, client_info) capabilities = _typed(ClientCapabilities, client_capabilities) @@ -219,6 +243,7 @@ def from_envelope( client_info=info, ) connection = cls(outbound, protocol_version=protocol_version, client_params=client_params) + connection.client_capabilities = capabilities connection.initialized.set() return connection @@ -369,13 +394,13 @@ async def send_resource_updated(self, uri: str, *, meta: Meta | None = None) -> def check_capability(self, capability: ClientCapabilities) -> bool: """Return whether the connected client declared the given capability. - Returns `False` when no client info has been recorded. + Returns `False` when no capabilities have been recorded. """ # TODO(L53): redesign - mirrors v1 ServerSession.check_client_capability # verbatim for parity. - if self.client_params is None: + if self.client_capabilities is None: return False - have = self.client_params.capabilities + have = self.client_capabilities if capability.roots is not None: if have.roots is None: return False diff --git a/src/mcp/server/context.py b/src/mcp/server/context.py index b5c356075a..903b7ef6f8 100644 --- a/src/mcp/server/context.py +++ b/src/mcp/server/context.py @@ -116,8 +116,11 @@ async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, * all three to a result dict.""" CallNext = Callable[["ServerRequestContext[Any, Any]"], Awaitable[HandlerResult]] -"""Invokes the rest of the chain. Pass the `ctx` through; rewrite `method` or -`params` with `dataclasses.replace(ctx, ...)` to alter what the handler sees.""" +"""Invokes the rest of the chain with the given context. What a context +rewrite (`dataclasses.replace(ctx, ...)`) can alter depends on the tier: +`ServerMiddleware` runs before params validation, so its rewrites change what +the handler is invoked with; an `Extension` interceptor runs after, so its +rewrites change only what the handler observes on `ctx`.""" _MwLifespanT = TypeVar("_MwLifespanT") diff --git a/src/mcp/server/extension.py b/src/mcp/server/extension.py index c87d93b006..6205a7033d 100644 --- a/src/mcp/server/extension.py +++ b/src/mcp/server/extension.py @@ -27,7 +27,7 @@ from mcp_types.methods import SPEC_CLIENT_METHODS from pydantic import BaseModel -from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext # Re-exported from `mcp.shared.extension` (shared with the client surface) for existing importers. from mcp.shared.extension import validate_extension_identifier as validate_extension_identifier @@ -144,30 +144,38 @@ async def intercept_tool_call( Override to short-circuit (return a result without calling `call_next`) or to observe the call. `params` is the validated `tools/call` params; - `call_next(ctx)` runs the rest of the chain and the real handler. + `call_next(ctx)` runs the rest of the chain and the real handler, and + returns the handler's domain result. Interceptors run at the handler + layer: whatever they return is serialized like any handler result, + including the 2026-era `serverInfo` `_meta` stamp. The `params` this + interceptor received is what the wrapped handler is invoked with - + passing a rewritten context through `call_next` adjusts what the + handler observes on `ctx`, not the tool invocation. Wire-level request + rewriting belongs to `Server.middleware`, above params validation. """ return await call_next(ctx) -def compose_tool_call_interceptor(extensions: Sequence[Extension]) -> ServerMiddleware[Any]: - """Fold every extension's `intercept_tool_call` into one `ServerMiddleware`. +def compose_tool_call_handler(extensions: Sequence[Extension], handler: RequestHandler) -> RequestHandler: + """Fold every extension's `intercept_tool_call` around the `tools/call` handler. - The returned middleware nests the interceptors (first extension outermost) - and is a no-op for any method other than `tools/call`. It validates the - `tools/call` params once and threads them to each interceptor. + The returned handler nests the interceptors (first extension outermost) and + replaces the plain `tools/call` registration. Interception happens at the + handler layer, below the runner's outbound envelope pass, so a + short-circuiting interceptor's result is sieved and stamped exactly like + the wrapped handler's would be. """ - async def middleware(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult: - if ctx.method != "tools/call": - return await call_next(ctx) - params = CallToolRequestParams.model_validate({} if ctx.params is None else ctx.params, by_name=False) + async def wrapped(ctx: ServerRequestContext[Any, Any], params: CallToolRequestParams) -> HandlerResult: + async def innermost(inner_ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return await handler(inner_ctx, params) - chain = call_next + chain: CallNext = innermost for extension in reversed(extensions): chain = _bind_interceptor(extension, params, chain) return await chain(ctx) - return middleware + return wrapped def _bind_interceptor(extension: Extension, params: CallToolRequestParams, call_next: CallNext) -> CallNext: diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index abf98c6fdb..5088c92788 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -36,12 +36,13 @@ async def main(): from __future__ import annotations +import copy import logging import warnings from collections.abc import AsyncIterator, Awaitable, Callable, Mapping from contextlib import AbstractAsyncContextManager, asynccontextmanager from dataclasses import dataclass -from importlib.metadata import version as importlib_version +from functools import cached_property from typing import Any, Generic, overload import mcp_types as types @@ -124,22 +125,13 @@ async def _ping_handler(ctx: ServerRequestContext[Any], params: types.RequestPar return types.EmptyResult() -def _package_version(package: str) -> str: - try: - return importlib_version(package) - except Exception: # pragma: no cover - pass - - return "unknown" # pragma: no cover - - class Server(Generic[LifespanResultT]): @overload def __init__( self, name: str, *, - version: str | None = None, + version: str = "", title: str | None = None, description: str | None = None, instructions: str | None = None, @@ -222,7 +214,7 @@ def __init__( self, name: str, *, - version: str | None = None, + version: str = "", title: str | None = None, description: str | None = None, instructions: str | None = None, @@ -314,7 +306,7 @@ def __init__( self, name: str, *, - version: str | None = None, + version: str = "", title: str | None = None, description: str | None = None, instructions: str | None = None, @@ -547,7 +539,7 @@ def create_initialization_options( """ return InitializationOptions( server_name=self.name, - server_version=self.version if self.version else _package_version("mcp"), + server_version=self.version, title=self.title, description=self.description, capabilities=self.get_capabilities( @@ -636,18 +628,35 @@ def get_capabilities( def server_info(self) -> types.Implementation: """The `serverInfo` block describing this implementation. - Derived from the constructor's identity fields. `version` falls back to - the installed `mcp` package version when not supplied explicitly. + Derived from the constructor's identity fields. An unversioned server + reports an empty `version`; the SDK never substitutes its own. """ return types.Implementation( name=self.name, - version=self.version if self.version else _package_version("mcp"), + version=self.version, title=self.title, description=self.description, website_url=self.website_url, icons=self.icons, ) + @cached_property + def _server_info_stamp_source(self) -> dict[str, Any]: + # Identity is fixed at construction, so the dump is computed once per + # server instead of per request. Never handed out directly: nested + # values (`icons`) would alias the cache into stamped responses. + return self.server_info.model_dump(by_alias=True, mode="json", exclude_none=True) + + @property + def server_info_stamp(self) -> dict[str, Any]: + """A fresh wire dump of `server_info`; callers own the returned dict. + + Each access materializes a deep copy of the once-per-server dump, so + a caller mutating a stamped response can never corrupt the identity + stamped into later responses. + """ + return copy.deepcopy(self._server_info_stamp_source) + async def _handle_discover( self, ctx: ServerRequestContext[LifespanResultT], params: types.RequestParams | None ) -> types.DiscoverResult: @@ -662,7 +671,6 @@ async def _handle_discover( return types.DiscoverResult( supported_versions=list(MODERN_PROTOCOL_VERSIONS), capabilities=self.get_capabilities(protocol_version=ctx.protocol_version), - server_info=self.server_info, instructions=self.instructions, ) diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index 2b7fdf35ee..97ac905bdb 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -326,11 +326,11 @@ def request_state(self) -> str | None: def client_capabilities(self) -> ClientCapabilities | None: """The client's declared capabilities for this connection. - `None` when the client supplied no client info (e.g. an anonymous - stateless request without the reserved `_meta` keys). + `None` when the client declared none (e.g. an anonymous stateless + request without the reserved `_meta` keys). Client info is not + required for capabilities to be recorded. """ - client_params = self.request_context.session.client_params - return client_params.capabilities if client_params else None + return self.request_context.session.client_capabilities @property def session(self): diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 7fc1cd1948..c6a75ec925 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -64,7 +64,7 @@ Extension, MethodBinding, RequestHandler, - compose_tool_call_interceptor, + compose_tool_call_handler, validate_extension_identifier, ) from mcp.server.lowlevel.helper_types import ReadResourceContents @@ -165,7 +165,7 @@ def __init__( instructions: str | None = None, website_url: str | None = None, icons: list[Icon] | None = None, - version: str | None = None, + version: str = "", auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None, token_verifier: TokenVerifier | None = None, *, @@ -228,8 +228,9 @@ def __init__( # We need to create a Lifespan type that is a generic on the server type, like Starlette does. lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore ) - # Ordering: inside OpenTelemetry (spans record the sealed wire form), - # outside extension interceptors (extensions see plaintext). + # Ordering: inside OpenTelemetry (spans record the sealed wire form). + # Extension interceptors run at the handler layer, inside this + # boundary, so they see plaintext. if request_state_security is None: security = RequestStateSecurity.ephemeral() else: @@ -289,7 +290,7 @@ def icons(self) -> list[Icon] | None: return self._lowlevel_server.icons @property - def version(self) -> str | None: + def version(self) -> str: return self._lowlevel_server.version @property @@ -334,13 +335,20 @@ def _apply_extension(self, extension: Extension) -> None: self._lowlevel_server.extensions[extension.identifier] = extension.settings() def _install_extension_interceptor(self) -> None: - """Compose every extension's `tools/call` interceptor into one middleware. + """Wrap the `tools/call` handler with every extension's interceptor. Installed only when at least one extension overrides `intercept_tool_call`, - so a server with purely additive extensions adds no middleware. + so a server with purely additive extensions keeps the bare handler. The + chain wraps the handler itself, below the runner's outbound envelope + pass, so a short-circuiting interceptor's result is sieved and stamped + exactly like a handler result. """ if any(type(e).intercept_tool_call is not Extension.intercept_tool_call for e in self._extensions): - self._lowlevel_server.middleware.append(compose_tool_call_interceptor(self._extensions)) + self._lowlevel_server.add_request_handler( + "tools/call", + CallToolRequestParams, + compose_tool_call_handler(self._extensions, self._handle_call_tool), + ) @overload def run(self, transport: Literal["stdio"] = ...) -> None: ... @@ -1320,8 +1328,8 @@ def require_client_extension(ctx: ServerRequestContext[Any, Any], identifier: st MCPError: With code `MISSING_REQUIRED_CLIENT_CAPABILITY` if the client did not advertise `identifier`. """ - client_params = ctx.session.client_params - declared = client_params.capabilities.extensions if client_params else None + capabilities = ctx.session.client_capabilities + declared = capabilities.extensions if capabilities else None if not declared or identifier not in declared: data = MissingRequiredClientCapabilityErrorData( required_capabilities=ClientCapabilities(extensions={identifier: {}}) diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 3b53335ae4..5045c8bf1c 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -24,11 +24,13 @@ from mcp_types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, + CORE_RESULT_TYPES, INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, UNSUPPORTED_PROTOCOL_VERSION, CacheableResult, ErrorData, @@ -111,7 +113,10 @@ def _dump_result(result: Any) -> dict[str, Any]: if isinstance(result, BaseModel): return result.model_dump(by_alias=True, mode="json", exclude_none=True) if isinstance(result, dict): - return cast(dict[str, Any], result) + # Copied so callers own the returned dict: handlers and middleware may + # retain the object they returned, and the outbound pipeline shapes the + # wire form without reaching into anything the handler still holds. + return dict(cast(dict[str, Any], result)) raise TypeError(f"handler returned {type(result).__name__}; expected BaseModel, dict, or None") @@ -209,22 +214,15 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult: if isinstance(result, ErrorData): # Raise inside the chain so middleware observes the failure. raise MCPError.from_error_data(result) - # Fill cache hints on the handler result, before the serialize sieve - # decides whether the negotiated version carries the fields at all. - # MRTR carve-out: `input_required` interim results, typed or mapping, never get hints. - if (hint := self.server.cache_hints.get(method)) is not None: - if isinstance(result, CacheableResult): - result = apply_cache_hint(result, hint) - elif isinstance(result, Mapping) and not _methods.is_input_required(result): - # Hint keys first so wire keys the handler set win, matching `apply_cache_hint` precedence. - result = {"ttlMs": hint.ttl_ms, "cacheScope": hint.scope, **result} - # Dump and serialize inside the chain so the OpenTelemetry span (the + # Shape for the wire inside the chain so the OpenTelemetry span (the # outermost middleware) records a failing handler return shape too. return self._serialize(method, version, result) call = self._compose_server_middleware(_inner) # `_inner` already produced the wire dict; a middleware that short-circuited - # without `call_next` is trusted to return its own well-formed result. + # without `call_next` is trusted to return its own well-formed result - + # including its response envelope. The pipeline never patches it up after + # the fact. result = _dump_result(await call(ctx)) if method == "initialize": # Commit only on chain success, so a middleware veto leaves no state. @@ -333,27 +331,77 @@ def _make_context( close_standalone_sse_stream=close_standalone_sse_stream, ) - @staticmethod - def _serialize(method: str, version: str, result: HandlerResult) -> dict[str, Any]: - """Dump a handler result to the wire dict, serializing spec methods. - - Runs inside the middleware chain so the OpenTelemetry span observes a - failing return shape (unsupported type, malformed spec result) as an - error rather than closing on a request that the client sees fail. + def _serialize(self, method: str, version: str, result: HandlerResult) -> dict[str, Any]: + """Shape a handler result into its wire form: the outbound counterpart + of the inbound classification ladder. + + One pass owns the whole response envelope, in order: cache hints fill + `ttlMs`/`cacheScope` the handler left unset, core-vocabulary spec-method + results are validated and sieved by the per-version surface (a claimed + extension `resultType` shape is the extension's to own), and 2026-era + results get the `serverInfo` `_meta` stamp (spec #3002). Runs inside the + middleware chain so the OpenTelemetry span observes a failing return + shape (unsupported type, malformed spec result) as an error rather + than closing on a request that the client sees fail - and so a + middleware that short-circuits without `call_next` owns its result, + envelope included. """ + # MRTR carve-out: `input_required` interim results, typed or mapping, never get hints. + if (hint := self.server.cache_hints.get(method)) is not None: + if isinstance(result, CacheableResult): + result = apply_cache_hint(result, hint) + elif isinstance(result, Mapping) and not _methods.is_input_required(result): + # Hint keys first so wire keys the handler set win, matching `apply_cache_hint` precedence. + result = {"ttlMs": hint.ttl_ms, "cacheScope": hint.scope, **result} dumped = _dump_result(result) - # TODO(L56): reject resultType values outside {"complete", "input_required"} unless the - # corresponding extension is in this request's _meta clientCapabilities.extensions; the + # A modern-era extension `resultType` (outside the core vocabulary) marks + # a claimed shape owned by the extension that defined it: the per-version + # surface doesn't describe it, so the sieve applies to core results only. + # Legacy connections sieve everything - claimed shapes are 2026-era + # vocabulary and cannot be delivered on a legacy wire (mirrors the + # client-side ResultClaim rule). + # TODO(L56): reject extension resultType values unless the corresponding + # extension is in this request's _meta clientCapabilities.extensions; the # explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively. - if method not in _methods.SPEC_CLIENT_METHODS: - return dumped - try: - return _methods.serialize_server_result(method, version, dumped) - except ValidationError: - # Server bug, not client fault. Detail stays in the server log: - # pydantic messages echo the result body. - logger.exception("handler for %r returned an invalid result", method) - raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None + result_type = dumped.get("resultType") + core_shape = ( + version not in MODERN_PROTOCOL_VERSIONS + or not isinstance(result_type, str) + or result_type in CORE_RESULT_TYPES + ) + if method in _methods.SPEC_CLIENT_METHODS and core_shape: + try: + dumped = _methods.serialize_server_result(method, version, dumped) + except ValidationError: + # Server bug, not client fault. Detail stays in the server log: + # pydantic messages echo the result body. + logger.exception("handler for %r returned an invalid result", method) + raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None + return self._stamp_server_info(version, dumped) + + def _stamp_server_info(self, version: str, result: dict[str, Any]) -> dict[str, Any]: + """Fill the `serverInfo` `_meta` stamp on a 2026-era result (spec #3002). + + A handler-authored value wins; an explicit `null` reads as absent and + is stamped over, mirroring the request-side `clientInfo` posture (a + `null` is not a valid `Implementation`, so presence means a value). A + non-mapping `_meta` is the handler's to own, and handshake-era results + are never stamped. `result` is + pipeline-owned (`_dump_result` copies dicts; the spec-method sieve + re-dumps), but `_meta` may still be the handler's object, so the stamp + replaces it rather than writing into it. `server_info_stamp` is a + fresh dict per access, so the response never aliases server state. + """ + if version not in MODERN_PROTOCOL_VERSIONS: + return result + raw_meta = result.get("_meta") + if raw_meta is None: + result["_meta"] = {SERVER_INFO_META_KEY: self.server.server_info_stamp} + elif isinstance(raw_meta, dict): + meta = cast("dict[str, Any]", raw_meta) + if meta.get(SERVER_INFO_META_KEY) is None: + result["_meta"] = {**meta, SERVER_INFO_META_KEY: self.server.server_info_stamp} + return result @staticmethod def _negotiate_initialize(params: Mapping[str, Any] | None) -> tuple[InitializeRequestParams, str]: @@ -439,19 +487,23 @@ async def serve_loop( ) -_MODERN_ENVELOPE_KEYS = (PROTOCOL_VERSION_META_KEY, CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY) - - def _has_modern_envelope(params: Mapping[str, Any] | None) -> bool: - """Whether `params._meta` carries every reserved modern-envelope key. - - Era evidence is the FULL key triple - bare `_meta` is not (legacy traffic - carries `progressToken` there). + """Whether `params._meta` carries the reserved protocol-version key. + + Era evidence is the client's explicit version declaration: the + `io.modelcontextprotocol/protocolVersion` key exists only in 2026-07-28+ + envelopes, and the `io.modelcontextprotocol/` prefix is spec-reserved, so + legacy traffic never mints it (bare `_meta` is NOT evidence - legacy + requests carry `progressToken` there). Presence of the version key alone + is the rule, not the full required pair, so a half-built envelope + (version present, capabilities missing) still routes modern and gets the + classifier's INVALID_PARAMS naming the missing key instead of the legacy + path's generic one - and, like every failed classification, locks no era. """ if not params: return False meta = params.get("_meta") - return isinstance(meta, Mapping) and all(key in meta for key in _MODERN_ENVELOPE_KEYS) + return isinstance(meta, Mapping) and PROTOCOL_VERSION_META_KEY in meta def _initialize_after_modern_data(params: Mapping[str, Any] | None) -> dict[str, Any]: @@ -557,8 +609,8 @@ async def serve_dual_era_loop( like `serve_loop` for its lifetime, and modern envelope traffic is then rejected with INVALID_REQUEST. `initialize` never routes modern - the method is legacy-distinctive by definition - even when a confused - client stamps the envelope triple on it. - - A request carrying the modern `_meta` envelope triple - or + client stamps the envelope keys on it. + - A request whose `_meta` declares the modern protocol version - or `server/discover`, a modern-only method - is classified (`classify_inbound_request`) and served single-exchange via `serve_one` with a born-ready per-request `Connection`, the same dispatch model as @@ -676,7 +728,7 @@ async def on_request( return await serve_modern(dctx, method, params) # Unlocked. `initialize` is legacy-distinctive by definition (the # method does not exist at modern versions), so it takes the handshake - # path even when the envelope triple is stamped on it. + # path even when the envelope keys are stamped on it. if method != "initialize" and (method == "server/discover" or _has_modern_envelope(params)): return await serve_modern(dctx, method, params) result = await loop_runner.on_request(dctx, method, params) diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 0a61689eb5..69ad5ecadb 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -45,6 +45,16 @@ def client_params(self) -> types.InitializeRequestParams | None: """The client's `initialize` request params; `None` when no client info was supplied.""" return self._connection.client_params + @property + def client_capabilities(self) -> types.ClientCapabilities | None: + """The capabilities the client declared; `None` when none were declared. + + Prefer this over `client_params.capabilities`: on 2026-07-28+ the + request envelope declares capabilities while client info stays + optional, so capabilities can be present without `client_params`. + """ + return self._connection.client_capabilities + @property def can_send_request(self) -> bool: """Whether this request's channel can currently deliver a server-initiated request.""" @@ -236,8 +246,7 @@ async def create_message( NoBackChannelError: The connection has no back-channel for server-initiated requests. """ - client_caps = self.client_params.capabilities if self.client_params else None - validate_sampling_tools(client_caps, tools, tool_choice) + validate_sampling_tools(self.client_capabilities, tools, tool_choice) validate_tool_use_result_messages(messages) request = types.CreateMessageRequest( diff --git a/src/mcp/shared/inbound.py b/src/mcp/shared/inbound.py index c3e0ea338f..c28aa7fb71 100644 --- a/src/mcp/shared/inbound.py +++ b/src/mcp/shared/inbound.py @@ -327,9 +327,10 @@ def _value_at_path(arguments: Mapping[str, Any], path: tuple[str, ...]) -> Any: class InboundModernRoute: """A modern-protocol request whose envelope passed every ladder rung. - `client_info` and `client_capabilities` are the raw envelope values; - the classifier checks presence only, not shape. Method existence is not a - ladder rung — kernel dispatch is the single source of truth for that. + `client_info` and `client_capabilities` are the raw envelope values; the + classifier checks presence only, not shape, and `client_info` is `None` + when the (optional, SHOULD-include) key is absent. Method existence is not + a ladder rung — kernel dispatch is the single source of truth for that. """ protocol_version: str @@ -376,9 +377,11 @@ def classify_inbound_request( Rungs, in order — first failure wins: - 1. `params._meta` is a mapping carrying every reserved envelope key - (protocol version, client info, client capabilities) → else - :data:`~mcp_types.jsonrpc.INVALID_PARAMS`. + 1. `params._meta` is a mapping carrying the required envelope pair + (protocol version, client capabilities) → else + :data:`~mcp_types.jsonrpc.INVALID_PARAMS` naming the missing key(s) + (basic/index.mdx "Per-request protocol fields"). Client info is + optional (SHOULD-include, spec PR #3002); absent reads as `None`. 2. When `headers` is given, `MCP-Protocol-Version` equals the envelope's protocol version, `Mcp-Method` equals `body.method`, and — for the methods in :data:`NAME_BEARING_METHODS` — `Mcp-Name` equals the named @@ -404,16 +407,24 @@ def classify_inbound_request( accepts on the per-request-envelope path. """ try: - meta = body["params"]["_meta"] - protocol_version = meta[PROTOCOL_VERSION_META_KEY] - client_info = meta[CLIENT_INFO_META_KEY] - client_capabilities = meta[CLIENT_CAPABILITIES_META_KEY] + meta_value = body["params"]["_meta"] except (KeyError, TypeError): + meta_value = None + if not isinstance(meta_value, Mapping): return InboundLadderRejection( code=INVALID_PARAMS, - message="params._meta must carry the reserved protocol-version, client-info and " - "client-capabilities envelope keys", + message="params._meta must be an object carrying the required " + f"{PROTOCOL_VERSION_META_KEY!r} and {CLIENT_CAPABILITIES_META_KEY!r} envelope keys", ) + meta = cast("Mapping[str, Any]", meta_value) + if missing := [key for key in (PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY) if key not in meta]: + return InboundLadderRejection( + code=INVALID_PARAMS, + message=f"params._meta is missing the required envelope key(s): {', '.join(missing)}", + ) + protocol_version: Any = meta[PROTOCOL_VERSION_META_KEY] + client_info: Any = meta.get(CLIENT_INFO_META_KEY) + client_capabilities: Any = meta[CLIENT_CAPABILITIES_META_KEY] if headers is not None: version_header = headers.get(MCP_PROTOCOL_VERSION_HEADER) # Presence is checked explicitly: a null body version would otherwise @@ -431,8 +442,8 @@ def classify_inbound_request( ) name_key = NAME_BEARING_METHODS.get(method) if name_key is not None: - # Rung 1 already proved body["params"] is a mapping. - body_value = body["params"].get(name_key) + # Rung 1 already proved body["params"] is a mapping (its `_meta` is one). + body_value = cast("Mapping[str, Any]", body["params"]).get(name_key) if body_value is not None and decode_header_value(headers.get(MCP_NAME_HEADER)) != body_value: return InboundLadderRejection( code=HEADER_MISMATCH, diff --git a/tests/_stamp.py b/tests/_stamp.py new file mode 100644 index 0000000000..2dc380a36c --- /dev/null +++ b/tests/_stamp.py @@ -0,0 +1,42 @@ +"""Shared helper: strip the 2026-era serverInfo `_meta` stamp from a result. + +Servers stamp `io.modelcontextprotocol/serverInfo` into every 2026-era +result's `_meta` and never into handshake-era ones. Suites whose expected +payloads should stay identity-free strip the stamp before exact comparison - +and the strip is strict, so a modern result that lost its stamp fails the +test instead of passing silently. + +The interaction matrix does not use this function directly: its `unstamped` +fixture (tests/interaction/conftest.py) resolves per cell to this strict +strip on modern cells and to a must-not-be-stamped assertion on +handshake-era cells, so one comparison line enforces both eras. +""" + +from typing import Any, Protocol, TypeVar + +from mcp_types import SERVER_INFO_META_KEY, Result + +R = TypeVar("R", bound=Result) + + +class Unstamp(Protocol): + """An era-appropriate stamp normalizer: strips or forbids the stamp.""" + + def __call__(self, result: R) -> R: ... + + +def unstamped(result: R) -> R: + """Assert the result carries a well-formed serverInfo stamp, then remove it. + + Returns the result for inline use in comparisons. Use only where a stamp + is required (a 2026-era result); the interaction matrix's `unstamped` + fixture handles the era split. + """ + meta = result.meta + assert meta is not None and SERVER_INFO_META_KEY in meta, "expected a serverInfo stamp on this result" + stamp: Any = meta.pop(SERVER_INFO_META_KEY) + assert isinstance(stamp, dict) + assert "name" in stamp and "version" in stamp + if not meta: + result.meta = None + return result diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 6c78503b97..088c714001 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -119,6 +119,7 @@ async def test_client_is_initialized(app: MCPServer): tools=ToolsCapability(list_changed=False), ) ) + assert client.server_info is not None assert client.server_info.name == "test" @@ -134,7 +135,8 @@ async def test_client_with_simple_server(simple_server: Server): resources = await client.list_resources() assert resources == snapshot( ListResourcesResult( - resources=[Resource(name="Test Resource", uri="memory://test", description="A test resource")] + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test_server", "version": ""}}, + resources=[Resource(name="Test Resource", uri="memory://test", description="A test resource")], ) ) @@ -150,6 +152,7 @@ async def test_client_list_tools(app: MCPServer): result = await client.list_tools() assert result == snapshot( ListToolsResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, tools=[ Tool( name="greet", @@ -167,7 +170,7 @@ async def test_client_list_tools(app: MCPServer): "type": "object", }, ) - ] + ], ) ) @@ -177,6 +180,7 @@ async def test_client_call_tool(app: MCPServer): result = await client.call_tool("greet", {"name": "World"}) assert result == snapshot( CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, content=[TextContent(text="Hello, World!")], structured_content={"result": "Hello, World!"}, ) @@ -189,7 +193,8 @@ async def test_read_resource(app: MCPServer): result = await client.read_resource("test://resource") assert result == snapshot( ReadResourceResult( - contents=[TextResourceContents(uri="test://resource", mime_type="text/plain", text="Test content")] + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + contents=[TextResourceContents(uri="test://resource", mime_type="text/plain", text="Test content")], ) ) @@ -269,6 +274,7 @@ async def test_get_prompt(app: MCPServer): result = await client.get_prompt("greeting_prompt", {"name": "Alice"}) assert result == snapshot( GetPromptResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, description="A greeting prompt.", messages=[PromptMessage(role="user", content=TextContent(text="Please greet Alice warmly."))], ) @@ -335,6 +341,7 @@ async def test_client_list_resources_with_params(app: MCPServer): result = await client.list_resources() assert result == snapshot( ListResourcesResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, resources=[ Resource( name="test_resource", @@ -342,7 +349,7 @@ async def test_client_list_resources_with_params(app: MCPServer): description="A test resource.", mime_type="text/plain", ) - ] + ], ) ) @@ -351,7 +358,11 @@ async def test_client_list_resource_templates(app: MCPServer): """Test listing resource templates with params parameter.""" async with Client(app) as client: result = await client.list_resource_templates() - assert result == snapshot(ListResourceTemplatesResult(resource_templates=[])) + assert result == snapshot( + ListResourceTemplatesResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, resource_templates=[] + ) + ) async def test_list_prompts(app: MCPServer): @@ -360,13 +371,14 @@ async def test_list_prompts(app: MCPServer): result = await client.list_prompts() assert result == snapshot( ListPromptsResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, prompts=[ Prompt( name="greeting_prompt", description="A greeting prompt.", arguments=[PromptArgument(name="name", required=True)], ) - ] + ], ) ) @@ -376,7 +388,12 @@ async def test_complete_with_prompt_reference(simple_server: Server): async with Client(simple_server) as client: ref = types.PromptReference(type="ref/prompt", name="test_prompt") result = await client.complete(ref=ref, argument={"name": "arg", "value": "test"}) - assert result == snapshot(types.CompleteResult(completion=types.Completion(values=[]))) + assert result == snapshot( + types.CompleteResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test_server", "version": ""}}, + completion=types.Completion(values=[]), + ) + ) def test_client_with_url_initializes_streamable_http_transport(): @@ -573,6 +590,7 @@ async def scripted_transport() -> AsyncIterator[TransportStreams]: with anyio.fail_after(5): async with Client(scripted_transport(), mode="auto") as client: assert client.protocol_version == LATEST_HANDSHAKE_VERSION + assert client.server_info is not None assert client.server_info.name == "legacy-only" assert methods_seen == ["server/discover", "initialize", "notifications/initialized"] @@ -754,7 +772,11 @@ async def elicitation_callback( result = await client.call_tool("greet") assert result == snapshot( - CallToolResult(content=[TextContent(text="Hello, Ada!")], structured_content={"result": "Hello, Ada!"}) + CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + content=[TextContent(text="Hello, Ada!")], + structured_content={"result": "Hello, Ada!"}, + ) ) assert len(callback_params) == 1 assert isinstance(callback_params[0], types.ElicitRequestFormParams) @@ -800,7 +822,9 @@ async def sampling_callback( assert result == snapshot( CallToolResult( - content=[TextContent(text="Model said: Paris")], structured_content={"result": "Model said: Paris"} + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + content=[TextContent(text="Model said: Paris")], + structured_content={"result": "Model said: Paris"}, ) ) assert len(callback_params) == 1 @@ -833,6 +857,7 @@ async def list_roots_callback(context: ClientRequestContext) -> types.ListRootsR assert result == snapshot( CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, content=[TextContent(text="Client exposed 1 root(s).")], structured_content={"result": "Client exposed 1 root(s)."}, ) @@ -917,7 +942,12 @@ async def elicitation_callback( with anyio.fail_after(5): async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client: result = await client.get_prompt("summary") - assert result == snapshot(GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="ok"))])) + assert result == snapshot( + GetPromptResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + messages=[PromptMessage(role="user", content=TextContent(text="ok"))], + ) + ) async def test_read_resource_auto_loop_resolves_input_required_via_callbacks() -> None: @@ -944,5 +974,8 @@ async def elicitation_callback( async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client: result = await client.read_resource("memory://gated") assert result == snapshot( - ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")]) + ReadResourceResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + contents=[TextResourceContents(uri="memory://gated", text="unlocked")], + ) ) diff --git a/tests/client/test_client_caching.py b/tests/client/test_client_caching.py index 953ea005ad..4ebf6483ed 100644 --- a/tests/client/test_client_caching.py +++ b/tests/client/test_client_caching.py @@ -24,7 +24,6 @@ ElicitRequest, ElicitRequestFormParams, ElicitResult, - Implementation, InputRequiredResult, ListPromptsResult, ListResourcesResult, @@ -845,7 +844,6 @@ async def on_request(request: httpx2.Request) -> None: discover = DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="srv", version="0"), ) with anyio.fail_after(5): diff --git a/tests/client/test_probe.py b/tests/client/test_probe.py index e7fe49dc43..d4436a7b71 100644 --- a/tests/client/test_probe.py +++ b/tests/client/test_probe.py @@ -25,8 +25,8 @@ METHOD_NOT_FOUND, PARSE_ERROR, REQUEST_TIMEOUT, + SERVER_INFO_META_KEY, UNSUPPORTED_PROTOCOL_VERSION, - Implementation, ServerCapabilities, ) from mcp_types.version import ( @@ -85,7 +85,7 @@ def _discover_dict(versions: list[str] | None = None) -> dict[str, Any]: return types.DiscoverResult( supported_versions=versions or list(MODERN_PROTOCOL_VERSIONS), capabilities=ServerCapabilities(), - server_info=Implementation(name="stub", version="0"), + _meta={SERVER_INFO_META_KEY: {"name": "stub", "version": "0"}}, ).model_dump(by_alias=True, mode="json", exclude_none=True) @@ -101,11 +101,13 @@ def _err_32022(supported: Any) -> MCPError: async def test_a_valid_discover_result_is_adopted_without_initializing() -> None: - """A parseable `DiscoverResult` from the probe is adopted; `initialize()` is never called.""" + """A parseable `DiscoverResult` from the probe is adopted intact — including the + `_meta` serverInfo stamp — and `initialize()` is never called.""" session = _StubSession(_discover_dict()) await _negotiate(session) assert session.adopted is not None - assert session.adopted.server_info.name == "stub" + assert session.adopted.meta is not None + assert session.adopted.meta[SERVER_INFO_META_KEY] == {"name": "stub", "version": "0"} assert not session.initialized assert session.probed_at == [LATEST_MODERN_VERSION] diff --git a/tests/client/test_send_request_mcp_name.py b/tests/client/test_send_request_mcp_name.py index e22ec4015b..27c41e6614 100644 --- a/tests/client/test_send_request_mcp_name.py +++ b/tests/client/test_send_request_mcp_name.py @@ -101,7 +101,6 @@ def _adopt_modern(session: ClientSession) -> None: types.DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="stub", version="0"), ) ) diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 507c8f69e3..9c935ef18a 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -16,6 +16,7 @@ METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, REQUEST_TIMEOUT, + SERVER_INFO_META_KEY, UNSUPPORTED_PROTOCOL_VERSION, CallToolResult, Implementation, @@ -1323,7 +1324,6 @@ def test_adopt_raises_when_no_mutual_modern_version_is_supported() -> None: types.DiscoverResult( supported_versions=["1999-01-01"], capabilities=types.ServerCapabilities(), - server_info=types.Implementation(name="s", version="0"), result_type="complete", ttl_ms=0, cache_scope="public", @@ -1514,7 +1514,6 @@ def _discover_result_dict() -> dict[str, Any]: return types.DiscoverResult( supported_versions=["2026-07-28"], capabilities=ServerCapabilities(), - server_info=Implementation(name="stub", version="0"), ).model_dump(by_alias=True, mode="json", exclude_none=True) @@ -1654,12 +1653,13 @@ def test_era_neutral_properties_are_none_before_any_handshake() -> None: @pytest.mark.anyio async def test_era_neutral_properties_after_discover() -> None: """SDK-defined: after `discover()` the era-neutral accessors read from the - DiscoverResult; `initialize_result` stays None.""" + DiscoverResult; `server_info` comes from the `_meta` serverInfo stamp and + `initialize_result` stays None.""" raw = types.DiscoverResult( supported_versions=["2026-07-28"], capabilities=ServerCapabilities(tools=types.ToolsCapability(list_changed=True)), - server_info=Implementation(name="discovered", version="2.0"), instructions="hello", + _meta={SERVER_INFO_META_KEY: {"name": "discovered", "version": "2.0"}}, ).model_dump(by_alias=True, mode="json", exclude_none=True) dispatcher = _ScriptedDispatcher(raw) with anyio.fail_after(5): @@ -1673,6 +1673,40 @@ async def test_era_neutral_properties_after_discover() -> None: assert isinstance(session.discover_result, types.DiscoverResult) +@pytest.mark.anyio +async def test_server_info_is_none_when_the_discover_result_carries_no_stamp() -> None: + """Spec-mandated (2026-07-28, #3002): the serverInfo result-`_meta` stamp is + optional, so a server that does not identify itself reads as `None` rather + than failing the connection.""" + raw = types.DiscoverResult( + supported_versions=["2026-07-28"], + capabilities=ServerCapabilities(), + ).model_dump(by_alias=True, mode="json", exclude_none=True) + dispatcher = _ScriptedDispatcher(raw) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + await session.discover() + assert session.protocol_version == "2026-07-28" + assert session.server_info is None + + +@pytest.mark.anyio +async def test_a_malformed_server_info_stamp_reads_as_absent() -> None: + """Spec-mandated (2026-07-28, #3002): the stamp is self-reported and + display-only, so a value that is not an `Implementation` must not fail the + call; it reads as if the server sent none.""" + raw = types.DiscoverResult( + supported_versions=["2026-07-28"], + capabilities=ServerCapabilities(), + _meta={SERVER_INFO_META_KEY: {"version": "no name makes this invalid"}}, + ).model_dump(by_alias=True, mode="json", exclude_none=True) + dispatcher = _ScriptedDispatcher(raw) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + await session.discover() + assert session.server_info is None + + @pytest.mark.anyio async def test_discover_reraises_unsupported_version_with_malformed_error_data() -> None: """SDK-defined: a -32022 reply whose `data` is not a valid diff --git a/tests/client/test_session_claims.py b/tests/client/test_session_claims.py index 94ebd7946e..f61fc3710e 100644 --- a/tests/client/test_session_claims.py +++ b/tests/client/test_session_claims.py @@ -107,7 +107,6 @@ def _adopt_modern(session: ClientSession) -> None: types.DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="stub", version="0"), ) ) diff --git a/tests/client/test_session_concurrency.py b/tests/client/test_session_concurrency.py index 0a0ae62dde..beb1d6d4d5 100644 --- a/tests/client/test_session_concurrency.py +++ b/tests/client/test_session_concurrency.py @@ -68,9 +68,21 @@ async def call_and_record(tag: str) -> None: assert completion_order == ["c", "b", "a"] assert results == snapshot( { - "c": CallToolResult(content=[TextContent(text="result:c")], structured_content={"result": "result:c"}), - "b": CallToolResult(content=[TextContent(text="result:b")], structured_content={"result": "result:b"}), - "a": CallToolResult(content=[TextContent(text="result:a")], structured_content={"result": "result:a"}), + "c": CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "parking", "version": ""}}, + content=[TextContent(text="result:c")], + structured_content={"result": "result:c"}, + ), + "b": CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "parking", "version": ""}}, + content=[TextContent(text="result:b")], + structured_content={"result": "result:b"}, + ), + "a": CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "parking", "version": ""}}, + content=[TextContent(text="result:a")], + structured_content={"result": "result:a"}, + ), } ) diff --git a/tests/client/test_session_notification_bindings.py b/tests/client/test_session_notification_bindings.py index 2bed2bd64c..45e09998b8 100644 --- a/tests/client/test_session_notification_bindings.py +++ b/tests/client/test_session_notification_bindings.py @@ -7,7 +7,7 @@ import anyio import mcp_types as types import pytest -from mcp_types import EmptyResult, Implementation, ServerCapabilities +from mcp_types import EmptyResult, ServerCapabilities from mcp_types.version import LATEST_MODERN_VERSION from pydantic import BaseModel @@ -42,7 +42,6 @@ def _adopt_modern(session: ClientSession) -> None: types.DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="stub", version="0"), ) ) diff --git a/tests/client/test_subscriptions.py b/tests/client/test_subscriptions.py index 0cc4f133e4..698e1eb013 100644 --- a/tests/client/test_subscriptions.py +++ b/tests/client/test_subscriptions.py @@ -408,7 +408,6 @@ async def test_listen_on_a_never_entered_session_raises_runtime_error(): types.DiscoverResult( supported_versions=["2026-07-28"], capabilities=types.ServerCapabilities(), - server_info=types.Implementation(name="stub", version="0"), ) ) with pytest.raises(RuntimeError, match="entered session"): diff --git a/tests/docs_src/_helpers.py b/tests/docs_src/_helpers.py new file mode 100644 index 0000000000..0905c6f00a --- /dev/null +++ b/tests/docs_src/_helpers.py @@ -0,0 +1,23 @@ +"""Shared helpers for the docs_src tests.""" + +from typing import TypeVar + +from mcp_types import SERVER_INFO_META_KEY, Result + +from mcp.server import Server +from mcp.server.mcpserver import MCPServer + +R = TypeVar("R", bound=Result) + + +def strip_server_info(result: R, server: Server | MCPServer) -> R: + """Assert the 2026-era serverInfo stamp, then drop it so snapshots stay focused. + + The doc snippets set no explicit version, so the stamp's version is empty; + the fenced outputs in the docs pages leave the stamp out, and the tests + mirror the fences. + """ + assert result.meta is not None + assert result.meta[SERVER_INFO_META_KEY] == {"name": server.name, "version": ""} + result.meta = None + return result diff --git a/tests/docs_src/test_client.py b/tests/docs_src/test_client.py index 3d70371f53..c8292d989b 100644 --- a/tests/docs_src/test_client.py +++ b/tests/docs_src/test_client.py @@ -27,6 +27,7 @@ async def test_every_client_program_on_the_page_runs(capsys: pytest.CaptureFixtu async def test_connected_properties_are_populated_inside_the_block() -> None: """tutorial001: server_info, server_capabilities, protocol_version and instructions are just there.""" async with Client(tutorial001.mcp) as client: + assert client.server_info is not None assert client.server_info.name == "Bookshop" assert client.protocol_version == "2026-07-28" assert client.instructions == "Search the catalog before recommending a book." @@ -38,6 +39,7 @@ async def test_a_client_is_not_reusable_after_the_block_ends() -> None: """tutorial001: `async with` is the whole lifecycle. Construct a new Client per connection.""" client = Client(tutorial001.mcp) async with client: + assert client.server_info is not None assert client.server_info.name == "Bookshop" with pytest.raises(RuntimeError, match="cannot reenter"): await client.__aenter__() diff --git a/tests/docs_src/test_client_transports.py b/tests/docs_src/test_client_transports.py index 848eddd52e..4da0da1f42 100644 --- a/tests/docs_src/test_client_transports.py +++ b/tests/docs_src/test_client_transports.py @@ -22,6 +22,7 @@ async def test_the_in_memory_program_on_the_page_runs(capsys: pytest.CaptureFixt async def test_in_memory_client_talks_to_the_server_object() -> None: """tutorial001: passing the server object connects in-process. No subprocess, no port.""" async with Client(tutorial001.mcp) as client: + assert client.server_info is not None assert client.server_info.name == "Bookshop" assert client.protocol_version == "2026-07-28" result = await client.call_tool("search_books", {"query": "dune"}) diff --git a/tests/docs_src/test_index.py b/tests/docs_src/test_index.py index 3012ae1a08..332184971c 100644 --- a/tests/docs_src/test_index.py +++ b/tests/docs_src/test_index.py @@ -6,6 +6,7 @@ from docs_src.index.tutorial001 import mcp from mcp import Client +from tests.docs_src._helpers import strip_server_info # `pyproject.toml` globally downgrades `mcp.MCPDeprecationWarning` to *ignore* because the # SDK still calls those methods internally. A documentation example must never lean on @@ -18,6 +19,7 @@ async def test_add_tool() -> None: async with Client(mcp) as client: result = await client.call_tool("add", {"a": 1, "b": 2}) + result = strip_server_info(result, mcp) assert result == snapshot( CallToolResult(content=[TextContent(type="text", text="3")], structured_content={"result": 3}) ) diff --git a/tests/docs_src/test_logging.py b/tests/docs_src/test_logging.py index bed5c234b6..4571983329 100644 --- a/tests/docs_src/test_logging.py +++ b/tests/docs_src/test_logging.py @@ -9,6 +9,7 @@ from docs_src.logging import tutorial001 from mcp import Client from mcp.server import MCPServer +from tests.docs_src._helpers import strip_server_info # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -28,6 +29,7 @@ async def test_the_log_line_never_reaches_the_client() -> None: """tutorial001: the result is only the return value. Log output is invisible to the model.""" async with Client(tutorial001.mcp) as client: result = await client.call_tool("search_books", {"query": "dune"}) + result = strip_server_info(result, tutorial001.mcp) assert result == snapshot( CallToolResult( content=[TextContent(type="text", text="Found 3 books matching 'dune'.")], diff --git a/tests/docs_src/test_lowlevel.py b/tests/docs_src/test_lowlevel.py index 34746dd0b3..7d58e941d2 100644 --- a/tests/docs_src/test_lowlevel.py +++ b/tests/docs_src/test_lowlevel.py @@ -2,7 +2,15 @@ import pytest from inline_snapshot import snapshot -from mcp_types import INTERNAL_ERROR, CallToolRequestParams, CallToolResult, ErrorData, RequestParams, TextContent +from mcp_types import ( + INTERNAL_ERROR, + SERVER_INFO_META_KEY, + CallToolRequestParams, + CallToolResult, + ErrorData, + RequestParams, + TextContent, +) from docs_src.lowlevel import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006 from mcp import Client, MCPError @@ -79,8 +87,17 @@ async def test_output_schema_and_structured_content_are_both_yours_to_build() -> } ) result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) - assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")] - assert result.structured_content == {"matches": 3, "query": "dune"} + # The page shows this exact payload; tutorial003 pins `version="2.0.0"` so + # the identity stamp is deterministic and the fence is proved verbatim. + assert result.model_dump(by_alias=True, exclude_none=True) == snapshot( + { + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}}, + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": False, + "resultType": "complete", + } + ) async def test_the_client_checks_the_schema_you_promised() -> None: @@ -99,6 +116,10 @@ async def test_meta_reaches_the_client_application() -> None: """tutorial004: `_meta=` on the result comes back as `result.meta` and serialises under `_meta`.""" async with Client(tutorial004.server) as client: result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + assert result.meta is not None + # The server identity stamp shares `_meta` with the handler's keys without clobbering + # them. Remove it before the exact compares: the page's fence leaves the stamp out. + del result.meta[SERVER_INFO_META_KEY] assert result.meta == {"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]} assert result.model_dump(by_alias=True, exclude_none=True) == snapshot( { diff --git a/tests/docs_src/test_media.py b/tests/docs_src/test_media.py index 7ea89eb790..7ccb4c8a24 100644 --- a/tests/docs_src/test_media.py +++ b/tests/docs_src/test_media.py @@ -93,6 +93,7 @@ def test_raw_data_without_a_format_falls_back_to_a_default_mime_type() -> None: async def test_icons_are_visible_where_they_were_declared() -> None: """tutorial004: server icons land on `server_info`, tool icons on the `Tool`, resource icons on the `Resource`.""" async with Client(tutorial004.mcp) as client: + assert client.server_info is not None assert client.server_info.icons == [ Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"]) ] diff --git a/tests/docs_src/test_mrtr.py b/tests/docs_src/test_mrtr.py index 50a9e53d9d..ddebd80090 100644 --- a/tests/docs_src/test_mrtr.py +++ b/tests/docs_src/test_mrtr.py @@ -22,6 +22,7 @@ from mcp import Client, MCPError from mcp.client import ClientRequestContext from mcp.server.mcpserver import InvalidRequestState +from tests.docs_src._helpers import strip_server_info # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -31,6 +32,7 @@ async def test_first_call_returns_an_input_required_result() -> None: """tutorial001: a tool that is missing input returns `InputRequiredResult` instead of calling back.""" async with Client(tutorial001.server) as client: result = await client.session.call_tool("provision", {"name": "orders"}, allow_input_required=True) + result = strip_server_info(result, tutorial001.server) assert result == snapshot( InputRequiredResult( result_type="input_required", @@ -57,6 +59,7 @@ async def test_the_auto_loop_drives_the_call_to_completion() -> None: """tutorial003: register `elicitation_callback`, call the tool, get a plain `CallToolResult` back.""" async with Client(tutorial001.server, elicitation_callback=tutorial003.handle_elicitation) as client: result = await client.call_tool("provision", {"name": "orders"}) + result = strip_server_info(result, tutorial001.server) assert result == snapshot( CallToolResult(content=[TextContent(type="text", text="Provisioned 'orders' in eu-west-1.")]) ) @@ -80,6 +83,7 @@ async def test_retry_with_input_responses_and_request_state_completes_the_call() input_responses={"region": ElicitResult(action="accept", content={"region": "eu-west-1"})}, request_state="provision-v1", ) + result = strip_server_info(result, tutorial001.server) assert result == snapshot( CallToolResult(content=[TextContent(type="text", text="Provisioned 'orders' in eu-west-1.")]) ) @@ -89,6 +93,7 @@ async def test_the_manual_loop_drives_the_call_to_completion() -> None: """tutorial002: `client.session.call_tool(..., allow_input_required=True)` for callers who own the loop.""" async with Client(tutorial001.server) as client: result = await tutorial002.provision(client, "billing") + result = strip_server_info(result, tutorial001.server) assert result == snapshot( CallToolResult(content=[TextContent(type="text", text="Provisioned 'billing' in eu-west-1.")]) ) @@ -121,6 +126,7 @@ async def test_a_prompt_returns_an_input_required_result_on_the_first_round() -> returns the `InputRequiredResult` itself.""" async with Client(tutorial004.mcp) as client: result = await client.session.get_prompt("briefing", allow_input_required=True) + result = strip_server_info(result, tutorial004.mcp) assert result == snapshot( InputRequiredResult( result_type="input_required", @@ -151,6 +157,7 @@ async def test_the_prompt_auto_loop_returns_the_final_messages() -> None: caller sees only the complete `GetPromptResult`.""" async with Client(tutorial004.mcp, elicitation_callback=_answer_audience) as client: result = await client.get_prompt("briefing") + result = strip_server_info(result, tutorial004.mcp) assert result == snapshot( GetPromptResult( description="Draft a briefing tuned to its audience.", diff --git a/tests/docs_src/test_prompts.py b/tests/docs_src/test_prompts.py index 3b0ad571a0..c375ab6149 100644 --- a/tests/docs_src/test_prompts.py +++ b/tests/docs_src/test_prompts.py @@ -8,6 +8,7 @@ from docs_src.prompts import tutorial001, tutorial002, tutorial003 from mcp import Client, MCPError +from tests.docs_src._helpers import strip_server_info # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -30,6 +31,7 @@ async def test_returned_string_becomes_one_user_message() -> None: """tutorial001: a `str` return value is rendered as a single `user` message.""" async with Client(tutorial001.mcp) as client: result = await client.get_prompt("review_code", {"code": "def add(a, b): return a + b"}) + result = strip_server_info(result, tutorial001.mcp) assert result.model_dump(mode="json", by_alias=True, exclude_none=True) == snapshot( { "description": "Review a piece of code.", diff --git a/tests/docs_src/test_protocol_versions.py b/tests/docs_src/test_protocol_versions.py index 73366a9840..06b8a3a0c0 100644 --- a/tests/docs_src/test_protocol_versions.py +++ b/tests/docs_src/test_protocol_versions.py @@ -3,7 +3,7 @@ import re import pytest -from mcp_types import DiscoverResult, Implementation, ServerCapabilities +from mcp_types import SERVER_INFO_META_KEY, DiscoverResult, Implementation, ServerCapabilities from docs_src.protocol_versions import tutorial001, tutorial002, tutorial003, tutorial004 from mcp import Client @@ -16,6 +16,7 @@ async def test_auto_lands_on_the_modern_version() -> None: """tutorial001: the default `mode="auto"` probes `server/discover` and adopts the result.""" async with Client(tutorial001.mcp) as client: assert client.protocol_version == "2026-07-28" + assert client.server_info is not None assert client.server_info.name == "Bookshop" assert client.session.discover_result is not None assert client.session.initialize_result is None @@ -25,18 +26,18 @@ async def test_legacy_forces_the_initialize_handshake() -> None: """tutorial002: `mode="legacy"` runs `initialize` against the very same server.""" async with Client(tutorial002.mcp, mode="legacy") as client: assert client.protocol_version == "2025-11-25" + assert client.server_info is not None assert client.server_info.name == "Bookshop" assert client.session.initialize_result is not None assert client.session.discover_result is None async def test_version_pin_sends_nothing_and_knows_nothing() -> None: - """tutorial003: a pin adopts the version locally; `server_info` and capabilities are blank.""" + """tutorial003: a pin adopts the version locally; `server_info` is None and capabilities are blank.""" async with Client(tutorial003.mcp, mode="2026-07-28") as client: assert client.protocol_version == "2026-07-28" - assert client.server_info == Implementation(name="", version="") - # The `!!! check` fence is the literal `print(client.server_info)` output. - assert str(client.server_info) == "name='' title=None version='' description=None website_url=None icons=None" + # The `!!! check` fence is the literal `print(client.server_info)` output: None. + assert client.server_info is None assert client.server_capabilities == ServerCapabilities() result = await client.call_tool("search_books", {"query": "dune"}) assert result.structured_content == {"result": "Found 3 books matching 'dune'."} @@ -63,6 +64,7 @@ async def test_prior_discover_round_trips() -> None: async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=saved) as client: assert client.protocol_version == "2026-07-28" + assert client.server_info is not None assert client.server_info.name == "Bookshop" assert client.server_capabilities.tools is not None @@ -77,6 +79,7 @@ async def test_discover_result_survives_json() -> None: assert restored == saved async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=restored) as client: + assert client.server_info is not None assert client.server_info.name == "Bookshop" @@ -85,9 +88,14 @@ async def test_prior_discover_is_ignored_unless_mode_is_a_pin() -> None: stale = DiscoverResult( supported_versions=["2026-07-28"], capabilities=ServerCapabilities(), - server_info=Implementation(name="Stale", version="0.0.0"), + _meta={ + SERVER_INFO_META_KEY: Implementation(name="Stale", version="0.0.0").model_dump( + by_alias=True, mode="json", exclude_none=True + ) + }, ) async with Client(tutorial004.mcp, prior_discover=stale) as client: + assert client.server_info is not None assert client.server_info.name == "Bookshop" async with Client(tutorial004.mcp, mode="legacy", prior_discover=stale) as client: assert client.session.discover_result is None diff --git a/tests/docs_src/test_run.py b/tests/docs_src/test_run.py index 4b9a8926ad..fb9ab908ed 100644 --- a/tests/docs_src/test_run.py +++ b/tests/docs_src/test_run.py @@ -9,6 +9,7 @@ from docs_src.run import tutorial001, tutorial002, tutorial003 from mcp import Client from mcp.server import MCPServer +from tests.docs_src._helpers import strip_server_info # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -18,6 +19,7 @@ async def test_the_run_call_is_guarded_so_importing_does_not_start_a_server() -> """tutorial001: `run()` sits under `__main__`, so the module imports cleanly and serves in-memory.""" async with Client(tutorial001.mcp) as client: result = await client.call_tool("search_books", {"query": "dune"}) + result = strip_server_info(result, tutorial001.mcp) assert result == snapshot( CallToolResult( content=[TextContent(type="text", text="Found 3 books matching 'dune'.")], diff --git a/tests/docs_src/test_session_groups.py b/tests/docs_src/test_session_groups.py index 79721c6138..b8d54db176 100644 --- a/tests/docs_src/test_session_groups.py +++ b/tests/docs_src/test_session_groups.py @@ -16,6 +16,12 @@ pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] +def _server_info(client: Client) -> Implementation: + """Narrow `client.server_info` for `connect_with_session`: these servers all identify themselves.""" + assert client.server_info is not None + return client.server_info + + async def test_both_servers_call_their_tool_search() -> None: """tutorial001 + tutorial002: two unrelated servers, one colliding tool name.""" async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: @@ -29,7 +35,7 @@ async def test_a_connected_server_is_aggregated_into_the_group() -> None: """tutorial003: the group exposes every component of every connected server as a dict.""" async with Client(tutorial001.mcp) as library: group = ClientSessionGroup() - await group.connect_with_session(library.server_info, library.session) + await group.connect_with_session(_server_info(library), library.session) assert sorted(group.tools) == ["search"] assert sorted(group.resources) == ["hours"] assert group.prompts == {} @@ -40,9 +46,9 @@ async def test_colliding_names_are_rejected() -> None: """tutorial003: without a hook the second `search` raises, and nothing from `Web` is kept.""" async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: group = ClientSessionGroup() - await group.connect_with_session(library.server_info, library.session) + await group.connect_with_session(_server_info(library), library.session) with pytest.raises(MCPError) as exc_info: - await group.connect_with_session(web.server_info, web.session) + await group.connect_with_session(_server_info(web), web.session) assert str(exc_info.value) == "{'search'} already exist in group tools." assert exc_info.value.error.code == INVALID_PARAMS assert sorted(group.tools) == ["search"] @@ -56,8 +62,8 @@ async def test_component_name_hook_prefixes_every_name() -> None: """tutorial004: the hook rewrites every registered name, so both servers coexist.""" async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: group = ClientSessionGroup(component_name_hook=tutorial004.by_server) - await group.connect_with_session(library.server_info, library.session) - await group.connect_with_session(web.server_info, web.session) + await group.connect_with_session(_server_info(library), library.session) + await group.connect_with_session(_server_info(web), web.session) assert sorted(group.tools) == ["Library.search", "Web.search"] assert sorted(group.resources) == ["Library.hours"] @@ -71,7 +77,7 @@ async def test_the_key_is_prefixed_but_the_wire_name_is_not() -> None: """tutorial004: the dict key is yours; the `Tool` inside keeps the name the server declared.""" async with Client(tutorial002.mcp) as web: group = ClientSessionGroup(component_name_hook=tutorial004.by_server) - await group.connect_with_session(web.server_info, web.session) + await group.connect_with_session(_server_info(web), web.session) assert group.tools["Web.search"].name == "search" @@ -79,8 +85,8 @@ async def test_call_tool_routes_to_the_owning_server() -> None: """tutorial004: `group.call_tool` resolves the prefixed name to the session that owns it.""" async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: group = ClientSessionGroup(component_name_hook=tutorial004.by_server) - await group.connect_with_session(library.server_info, library.session) - await group.connect_with_session(web.server_info, web.session) + await group.connect_with_session(_server_info(library), library.session) + await group.connect_with_session(_server_info(web), web.session) web_result = await group.call_tool("Web.search", {"query": "model context protocol"}) assert web_result.structured_content == {"result": "12 pages match 'model context protocol'."} library_result = await group.call_tool("Library.search", {"query": "dune"}) @@ -91,8 +97,8 @@ async def test_disconnect_removes_every_component_of_that_server() -> None: """tutorial004: `disconnect_from_server` takes the session back out of all three dicts.""" async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: group = ClientSessionGroup(component_name_hook=tutorial004.by_server) - await group.connect_with_session(library.server_info, library.session) - web_session = await group.connect_with_session(web.server_info, web.session) + await group.connect_with_session(_server_info(library), library.session) + web_session = await group.connect_with_session(_server_info(web), web.session) await group.disconnect_from_server(web_session) assert sorted(group.tools) == ["Library.search"] assert sorted(group.resources) == ["Library.hours"] diff --git a/tests/docs_src/test_testing.py b/tests/docs_src/test_testing.py index 5ab73e2e94..a6104840fc 100644 --- a/tests/docs_src/test_testing.py +++ b/tests/docs_src/test_testing.py @@ -10,6 +10,7 @@ from docs_src.testing.tutorial001 import mcp from mcp import Client +from tests.docs_src._helpers import strip_server_info # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -18,6 +19,7 @@ async def test_call_add_tool() -> None: async with Client(mcp, raise_exceptions=True) as client: result = await client.call_tool("add", {"a": 1, "b": 2}) + result = strip_server_info(result, mcp) assert result == snapshot( CallToolResult(content=[TextContent(type="text", text="3")], structured_content={"result": 3}) ) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 752c17aa4f..a9fa7ea87f 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -354,8 +354,8 @@ def __post_init__(self) -> None: "lifecycle:stateless:request-envelope": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation", behavior=( - "At protocol_version 2026-07-28, every request carries io.modelcontextprotocol/protocolVersion, " - "/clientInfo, and /clientCapabilities in params._meta; no initialize handshake occurs." + "At protocol_version 2026-07-28, every request carries io.modelcontextprotocol/protocolVersion " + "and /clientCapabilities in params._meta (/clientInfo is optional); no initialize handshake occurs." ), added_in="2026-07-28", ), @@ -408,7 +408,8 @@ def __post_init__(self) -> None: source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#discover", behavior=( "Calling discover() sends server/discover with no params and returns a typed DiscoverResult " - "carrying protocolVersion, capabilities, serverInfo and the cache hint fields." + "carrying supportedVersions, capabilities and the cache hint fields; the server's identity " + "travels as the io.modelcontextprotocol/serverInfo stamp in the result _meta." ), added_in="2026-07-28", ), @@ -3261,8 +3262,10 @@ def __post_init__(self) -> None: "hosting:http:modern:discover-response-shape": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/index", behavior=( - "A 2026-07-28 server/discover response carries supportedVersions, capabilities, and " - "serverInfo, with supportedVersions naming the modern protocol revisions the server accepts." + "A 2026-07-28 server/discover response carries supportedVersions and capabilities in the " + "result body, with supportedVersions naming the modern protocol revisions the server " + "accepts; serverInfo is not a body field and travels as the io.modelcontextprotocol/serverInfo " + "result _meta stamp." ), added_in="2026-07-28", transports=("streamable-http",), diff --git a/tests/interaction/conftest.py b/tests/interaction/conftest.py index b918daf008..1e0e879bbb 100644 --- a/tests/interaction/conftest.py +++ b/tests/interaction/conftest.py @@ -6,10 +6,18 @@ bounds, and known-failure xfails declaratively. """ -from functools import partial +from contextlib import AbstractAsyncContextManager +from typing import Any import pytest +from mcp_types import SERVER_INFO_META_KEY +from mcp_types.version import MODERN_PROTOCOL_VERSIONS +from mcp.client.client import Client +from mcp.server import Server +from mcp.server.mcpserver import MCPServer +from tests._stamp import R, Unstamp +from tests._stamp import unstamped as _strip_required_stamp from tests.interaction._connect import ( Connect, connect_in_memory, @@ -35,8 +43,23 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: metafunc.parametrize("connect", compute_cells(requirements), indirect=True) +class CellConnect: + """The cell's connection factory, also naming the cell's `spec_version`. + + Callable exactly like the `Connect` factories it wraps; the attribute lets + sibling fixtures (`unstamped`) key on the cell's era without re-deriving it. + """ + + def __init__(self, factory: Connect, spec_version: str) -> None: + self._factory = factory + self.spec_version = spec_version + + def __call__(self, server: Server | MCPServer, **kwargs: Any) -> AbstractAsyncContextManager[Client]: + return self._factory(server, spec_version=self.spec_version, **kwargs) + + @pytest.fixture -def connect(request: pytest.FixtureRequest) -> Connect: +def connect(request: pytest.FixtureRequest) -> CellConnect: """The transport-parametrized connection factory: a test using it runs once per matrix cell. Tests that are tied to one transport (the wire-recording tests, the bare-ClientSession tests, @@ -45,4 +68,24 @@ def connect(request: pytest.FixtureRequest) -> Connect: transport, spec_version = request.param assert isinstance(transport, str) assert isinstance(spec_version, str) - return partial(_FACTORIES[transport], spec_version=spec_version) + return CellConnect(_FACTORIES[transport], spec_version) + + +@pytest.fixture +def unstamped(connect: CellConnect) -> Unstamp: + """The cell's era-aware serverInfo-stamp normalizer, for full-result comparisons. + + On a modern cell the stamp MUST be present (asserted) and is stripped so + one expected payload stays valid across eras; on a handshake-era cell the + result must not be stamped at all. Either direction failing is a runner + regression, so the same comparison line enforces both. + """ + if connect.spec_version in MODERN_PROTOCOL_VERSIONS: + return _strip_required_stamp + + def _assert_never_stamped(result: R) -> R: + meta = result.meta + assert meta is None or SERVER_INFO_META_KEY not in meta, "handshake-era results are never stamped" + return result + + return _assert_never_stamped diff --git a/tests/interaction/lowlevel/test_cancellation.py b/tests/interaction/lowlevel/test_cancellation.py index 0e9d81afbc..8361865db0 100644 --- a/tests/interaction/lowlevel/test_cancellation.py +++ b/tests/interaction/lowlevel/test_cancellation.py @@ -33,6 +33,7 @@ from mcp.server import Server, ServerRequestContext from mcp.shared.memory import MessageStream, create_client_server_memory_streams from mcp.shared.message import SessionMessage +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -137,7 +138,7 @@ async def call_and_swallow_cancellation_error() -> None: @requirement("protocol:cancel:unknown-id-ignored") -async def test_cancellation_for_unknown_request_is_ignored(connect: Connect) -> None: +async def test_cancellation_for_unknown_request_is_ignored(connect: Connect, unstamped: Unstamp) -> None: """A cancellation referencing a request id that is not in flight is ignored without error.""" async def list_tools( @@ -157,7 +158,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara ) result = await client.call_tool("echo", {}) - assert result == snapshot(CallToolResult(content=[TextContent(text="unbothered")])) + assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="unbothered")])) @requirement("protocol:cancel:server-to-client") @@ -350,7 +351,7 @@ async def scripted_server(streams: MessageStream) -> None: @requirement("protocol:cancel:abort-signal") -async def test_abandoning_a_call_stops_the_server_handler(connect: Connect) -> None: +async def test_abandoning_a_call_stops_the_server_handler(connect: Connect, unstamped: Unstamp) -> None: """Cancelling the task that awaits a call cancels the request itself, not just the local wait: the server-side handler is interrupted, and the session serves later requests normally. @@ -397,11 +398,11 @@ async def call_and_abandon() -> None: # dropped while the client is still open, so teardown never races its delivery. await anyio.wait_all_tasks_blocked() result = await client.call_tool("echo", {}) - assert result == snapshot(CallToolResult(content=[TextContent(text="ok")])) + assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="ok")])) @requirement("protocol:cancel:abort-scoped") -async def test_abandoning_one_call_leaves_a_concurrent_call_running(connect: Connect) -> None: +async def test_abandoning_one_call_leaves_a_concurrent_call_running(connect: Connect, unstamped: Unstamp) -> None: """Cancellation is scoped to the request it names: with two calls genuinely in flight, abandoning the first interrupts only its handler and the second returns its result. @@ -464,4 +465,6 @@ async def survivor_call() -> None: # dropped while the client is still open, so teardown never races its delivery. await anyio.wait_all_tasks_blocked() - assert results == snapshot([CallToolResult(content=[TextContent(text="survived")])]) + assert [unstamped(result) for result in results] == snapshot( + [CallToolResult(content=[TextContent(text="survived")])] + ) diff --git a/tests/interaction/lowlevel/test_client_connect.py b/tests/interaction/lowlevel/test_client_connect.py index eda1b8423c..a992027a23 100644 --- a/tests/interaction/lowlevel/test_client_connect.py +++ b/tests/interaction/lowlevel/test_client_connect.py @@ -25,6 +25,7 @@ INVALID_REQUEST, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, UNSUPPORTED_PROTOCOL_VERSION, DiscoverResult, Implementation, @@ -123,13 +124,13 @@ async def test_prior_discover_populates_state_with_zero_connect_time_traffic() - """`Client(..., mode=, prior_discover=...)` sends nothing on entry and exposes the prior server_info. Requirement `lifecycle:mode:prior-discover-zero-rtt` (sdk-defined): a previously-obtained - DiscoverResult is installed via `adopt()` so server_info and capabilities are available - immediately with zero round trips. + DiscoverResult is installed via `adopt()` so server_info (read from the result's `_meta` + serverInfo stamp) and capabilities are available immediately with zero round trips. """ prior = DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(tools=ToolsCapability(list_changed=False)), - server_info=Implementation(name="cached-server", version="9.9.9"), + _meta={SERVER_INFO_META_KEY: {"name": "cached-server", "version": "9.9.9"}}, ) requests, on_request = _request_recorder() @@ -156,7 +157,8 @@ async def test_auto_mode_probes_server_discover_and_adopts_the_result() -> None: Requirement `lifecycle:discover:basic` (spec basic/lifecycle#discover): the probe is a single `server/discover` request whose result carries supported versions, capabilities, - server_info and the cache-hint fields, after which the session is modern-negotiated. + the cache-hint fields, and the `_meta` serverInfo stamp, after which the session is + modern-negotiated. """ requests, on_request = _request_recorder() server = _tools_server("discoverable") @@ -167,6 +169,7 @@ async def test_auto_mode_probes_server_discover_and_adopts_the_result() -> None: Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client, ): assert client.protocol_version == LATEST_MODERN_VERSION + assert client.server_info is not None assert client.server_info.name == "discoverable" await client.list_tools() @@ -198,7 +201,6 @@ async def discover(ctx: ServerRequestContext, params: types.RequestParams | None return DiscoverResult( supported_versions=list(MODERN_PROTOCOL_VERSIONS), capabilities=ServerCapabilities(), - server_info=Implementation(name="picky", version="1.0.0"), ) server = _tools_server("picky") @@ -309,6 +311,7 @@ async def scripted_transport() -> AsyncIterator[TransportStreams]: with anyio.fail_after(5): async with Client(scripted_transport(), mode="auto") as client: assert client.protocol_version == LATEST_HANDSHAKE_VERSION + assert client.server_info is not None assert client.server_info.name == "legacy-only" assert methods_seen == ["server/discover", "initialize", "notifications/initialized"] diff --git a/tests/interaction/lowlevel/test_completion.py b/tests/interaction/lowlevel/test_completion.py index d75865a2f0..1ed5542734 100644 --- a/tests/interaction/lowlevel/test_completion.py +++ b/tests/interaction/lowlevel/test_completion.py @@ -15,6 +15,7 @@ from mcp import MCPError from mcp.server import Server, ServerRequestContext +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -23,7 +24,7 @@ @requirement("completion:prompt-arg") @requirement("completion:result-shape") -async def test_complete_prompt_argument(connect: Connect) -> None: +async def test_complete_prompt_argument(connect: Connect, unstamped: Unstamp) -> None: """Completing a prompt argument delivers the ref, argument name, and current value to the handler. The returned values are filtered by the argument's value, proving the value reached the handler. @@ -44,13 +45,13 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar PromptReference(name="code_review"), argument={"name": "language", "value": "py"} ) - assert result == snapshot( + assert unstamped(result) == snapshot( CompleteResult(completion=Completion(values=["python", "pytorch"], total=2, has_more=False)) ) @requirement("completion:resource-template-arg") -async def test_complete_resource_template_variable(connect: Connect) -> None: +async def test_complete_resource_template_variable(connect: Connect, unstamped: Unstamp) -> None: """Completing a URI template variable delivers the template URI and variable name to the handler.""" async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> CompleteResult: @@ -67,11 +68,11 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar argument={"name": "owner", "value": "model"}, ) - assert result == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol"]))) + assert unstamped(result) == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol"]))) @requirement("completion:context-arguments") -async def test_complete_receives_context_arguments(connect: Connect) -> None: +async def test_complete_receives_context_arguments(connect: Connect, unstamped: Unstamp) -> None: """Previously-resolved arguments passed as completion context reach the handler. The returned value is derived from the context, proving it arrived. @@ -92,7 +93,9 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar context_arguments={"owner": "modelcontextprotocol"}, ) - assert result == snapshot(CompleteResult(completion=Completion(values=["modelcontextprotocol/python-sdk"]))) + assert unstamped(result) == snapshot( + CompleteResult(completion=Completion(values=["modelcontextprotocol/python-sdk"])) + ) @requirement("completion:error:invalid-ref") diff --git a/tests/interaction/lowlevel/test_flows.py b/tests/interaction/lowlevel/test_flows.py index 19788db4a1..9a6db1a45a 100644 --- a/tests/interaction/lowlevel/test_flows.py +++ b/tests/interaction/lowlevel/test_flows.py @@ -32,6 +32,7 @@ from mcp.client import ClientRequestContext from mcp.server import Server, ServerRequestContext from mcp.server.session import ServerSession +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -53,7 +54,9 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa @requirement("flow:tool-result:resource-link-follow") -async def test_a_resource_link_returned_by_a_tool_can_be_followed_with_read(connect: Connect) -> None: +async def test_a_resource_link_returned_by_a_tool_can_be_followed_with_read( + connect: Connect, unstamped: Unstamp +) -> None: """A tool returns a resource_link; reading that link's URI returns the referenced contents. Steps: (1) call the tool, (2) extract the link from its content, (3) read_resource on the @@ -78,8 +81,10 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq assert isinstance(link, ResourceLink) read = await client.read_resource(link.uri) - assert called == snapshot(CallToolResult(content=[ResourceLink(name="report", uri="file:///report.txt")])) - assert read == snapshot( + assert unstamped(called) == snapshot( + CallToolResult(content=[ResourceLink(name="report", uri="file:///report.txt")]) + ) + assert unstamped(read) == snapshot( ReadResourceResult(contents=[TextResourceContents(uri="file:///report.txt", text="generated")]) ) diff --git a/tests/interaction/lowlevel/test_logging.py b/tests/interaction/lowlevel/test_logging.py index bfc86509d2..7f2f89fd1a 100644 --- a/tests/interaction/lowlevel/test_logging.py +++ b/tests/interaction/lowlevel/test_logging.py @@ -11,6 +11,7 @@ from mcp_types import CallToolResult, EmptyResult, LoggingMessageNotificationParams, TextContent from mcp.server import Server, ServerRequestContext +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -46,7 +47,7 @@ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelReq @requirement("logging:message:fields") @requirement("tools:call:logging-mid-execution") -async def test_log_messages_reach_logging_callback_in_order(connect: Connect) -> None: +async def test_log_messages_reach_logging_callback_in_order(connect: Connect, unstamped: Unstamp) -> None: """Log messages sent during a tool call arrive at the logging callback, in order, before the call returns. The two messages pin the full notification shape: severity, optional logger name, and both @@ -83,7 +84,7 @@ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelReq async with connect(server, logging_callback=collect) as client: result = await client.call_tool("chatty", {}) - assert result == snapshot(CallToolResult(content=[TextContent(text="done")])) + assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="done")])) assert received == snapshot( [ LoggingMessageNotificationParams(level="info", logger="app.lifecycle", data="starting up"), diff --git a/tests/interaction/lowlevel/test_meta.py b/tests/interaction/lowlevel/test_meta.py index 27cf25e30c..6e1403fea4 100644 --- a/tests/interaction/lowlevel/test_meta.py +++ b/tests/interaction/lowlevel/test_meta.py @@ -2,7 +2,8 @@ Meta is opaque pass-through data, so these tests assert identity against the value that was sent rather than snapshotting a literal: the expected value and the sent value are the same variable, -which also proves the SDK injected nothing alongside it. +which also proves the SDK injected nothing alongside it beyond the 2026-era serverInfo stamp, +stripped via `unstamped` before comparison. """ import mcp_types as types @@ -10,6 +11,7 @@ from mcp_types import CallToolResult, RequestParamsMeta, TextContent from mcp.server import Server, ServerRequestContext +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -42,7 +44,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara @requirement("meta:result-to-client") -async def test_result_meta_reaches_client(connect: Connect) -> None: +async def test_result_meta_reaches_client(connect: Connect, unstamped: Unstamp) -> None: """The _meta object a handler attaches to its result is delivered to the client unchanged.""" result_meta = {"example.com/cost": 3} @@ -60,4 +62,4 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: result = await client.call_tool("metered", {}) - assert result == CallToolResult(content=[TextContent(text="done")], _meta=result_meta) + assert unstamped(result) == CallToolResult(content=[TextContent(text="done")], _meta=result_meta) diff --git a/tests/interaction/lowlevel/test_pagination.py b/tests/interaction/lowlevel/test_pagination.py index 01bc0a99bd..e59d99c083 100644 --- a/tests/interaction/lowlevel/test_pagination.py +++ b/tests/interaction/lowlevel/test_pagination.py @@ -22,6 +22,7 @@ from mcp import MCPError from mcp.server import Server, ServerRequestContext +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -29,7 +30,7 @@ @requirement("tools:list:pagination") -async def test_next_cursor_round_trips_through_the_client(connect: Connect) -> None: +async def test_next_cursor_round_trips_through_the_client(connect: Connect, unstamped: Unstamp) -> None: """The next_cursor a list handler returns reaches the client, and the cursor the client sends back on the following call reaches the handler verbatim. """ @@ -55,7 +56,9 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa assert first_page.next_cursor == cursor assert seen_cursors == [None, cursor] assert [tool.name for tool in first_page.tools] == ["alpha"] - assert second_page == snapshot(ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})])) + assert unstamped(second_page) == snapshot( + ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})]) + ) @requirement("pagination:exhaustion") diff --git a/tests/interaction/lowlevel/test_progress.py b/tests/interaction/lowlevel/test_progress.py index 7f75e18eeb..025dded156 100644 --- a/tests/interaction/lowlevel/test_progress.py +++ b/tests/interaction/lowlevel/test_progress.py @@ -18,6 +18,7 @@ from mcp.server import Server, ServerRequestContext from mcp.server.session import ServerSession from mcp.shared.session import ProgressFnT +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -27,7 +28,7 @@ @requirement("protocol:progress:callback") @requirement("tools:call:progress") -async def test_progress_during_tool_call_reaches_callback_in_order(connect: Connect) -> None: +async def test_progress_during_tool_call_reaches_callback_in_order(connect: Connect, unstamped: Unstamp) -> None: """Progress notifications emitted by a tool handler reach the caller's progress callback in order.""" received: list[tuple[float, float | None, str | None]] = [] @@ -51,7 +52,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: result = await client.call_tool("download", {}, progress_callback=collect) - assert result == snapshot(CallToolResult(content=[TextContent(text="downloaded")])) + assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="downloaded")])) assert received == snapshot([(1.0, 3.0, "first chunk"), (2.0, 3.0, "second chunk"), (3.0, 3.0, "done")]) @@ -83,7 +84,7 @@ async def ignore(progress: float, total: float | None, message: str | None) -> N @requirement("protocol:progress:no-token") -async def test_no_progress_callback_means_no_token(connect: Connect) -> None: +async def test_no_progress_callback_means_no_token(connect: Connect, unstamped: Unstamp) -> None: """Without a progress callback the request carries no progress token. The low-level API has no way to report request-scoped progress without a token, so a handler @@ -105,7 +106,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: result = await client.call_tool("inspect", {}) - assert result == snapshot(CallToolResult(content=[TextContent(text="None")])) + assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="None")])) @requirement("protocol:progress:client-to-server") diff --git a/tests/interaction/lowlevel/test_prompts.py b/tests/interaction/lowlevel/test_prompts.py index eb19d4d60d..6048c8b24d 100644 --- a/tests/interaction/lowlevel/test_prompts.py +++ b/tests/interaction/lowlevel/test_prompts.py @@ -21,6 +21,7 @@ from mcp import MCPError from mcp.server import Server, ServerRequestContext +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -28,7 +29,7 @@ @requirement("prompts:list:basic") -async def test_list_prompts_returns_registered_prompts(connect: Connect) -> None: +async def test_list_prompts_returns_registered_prompts(connect: Connect, unstamped: Unstamp) -> None: """The prompts returned by the handler reach the client with their argument declarations intact.""" async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult: @@ -52,7 +53,7 @@ async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequest async with connect(server) as client: result = await client.list_prompts() - assert result == snapshot( + assert unstamped(result) == snapshot( ListPromptsResult( prompts=[ Prompt( @@ -71,7 +72,7 @@ async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequest @requirement("prompts:get:with-args") -async def test_get_prompt_substitutes_arguments(connect: Connect) -> None: +async def test_get_prompt_substitutes_arguments(connect: Connect, unstamped: Unstamp) -> None: """Arguments supplied by the client reach the prompt handler; the templated message comes back.""" async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult: @@ -87,7 +88,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa async with connect(server) as client: result = await client.get_prompt("greet", {"name": "Ada"}) - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult( description="A personalised greeting.", messages=[PromptMessage(role="user", content=TextContent(text="Hello, Ada!"))], @@ -96,7 +97,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa @requirement("prompts:get:multi-message") -async def test_get_prompt_multiple_messages_preserve_roles_and_order(connect: Connect) -> None: +async def test_get_prompt_multiple_messages_preserve_roles_and_order(connect: Connect, unstamped: Unstamp) -> None: """A prompt returning a user/assistant conversation reaches the client with roles and order intact.""" async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult: @@ -114,7 +115,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa async with connect(server) as client: result = await client.get_prompt("geography_quiz") - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult( messages=[ PromptMessage(role="user", content=TextContent(text="What is the capital of France?")), @@ -126,7 +127,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa @requirement("prompts:get:no-args") -async def test_get_prompt_without_arguments_returns_the_messages(connect: Connect) -> None: +async def test_get_prompt_without_arguments_returns_the_messages(connect: Connect, unstamped: Unstamp) -> None: """A prompt fetched with no arguments delivers None as the handler's arguments and returns its messages.""" async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult: @@ -139,7 +140,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa async with connect(server) as client: result = await client.get_prompt("static") - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="Say hello."))]) ) @@ -147,7 +148,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa @requirement("prompts:get:content:image") @requirement("prompts:get:content:audio") @requirement("prompts:get:content:embedded-resource") -async def test_get_prompt_with_non_text_content_round_trips(connect: Connect) -> None: +async def test_get_prompt_with_non_text_content_round_trips(connect: Connect, unstamped: Unstamp) -> None: """Prompt messages can carry image, audio, and embedded-resource content; all reach the client. A single full-result snapshot proves all three content types round-trip: each block in the result @@ -175,7 +176,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa async with connect(server) as client: result = await client.get_prompt("media", {}) - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult( messages=[ PromptMessage(role="user", content=ImageContent(data="aW1n", mime_type="image/png")), diff --git a/tests/interaction/lowlevel/test_resources.py b/tests/interaction/lowlevel/test_resources.py index db7d4dfe60..e911c65876 100644 --- a/tests/interaction/lowlevel/test_resources.py +++ b/tests/interaction/lowlevel/test_resources.py @@ -27,6 +27,7 @@ from mcp import MCPError from mcp.server import Server, ServerRequestContext +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -36,7 +37,7 @@ @requirement("resources:list:basic") @requirement("resources:annotations") -async def test_list_resources_returns_registered_resources(connect: Connect) -> None: +async def test_list_resources_returns_registered_resources(connect: Connect, unstamped: Unstamp) -> None: """Listed resources reach the client with their URIs, names, and optional descriptive fields intact. The fully-populated entry includes annotations, so the snapshot also proves they round-trip. @@ -71,7 +72,7 @@ async def list_resources( async with connect(server) as client: result = await client.list_resources() - assert result == snapshot( + assert unstamped(result) == snapshot( ListResourcesResult( resources=[ Resource(uri="memo://minimal", name="minimal"), @@ -93,7 +94,7 @@ async def list_resources( @requirement("resources:read:text") -async def test_read_resource_text(connect: Connect) -> None: +async def test_read_resource_text(connect: Connect, unstamped: Unstamp) -> None: """Reading a text resource returns its contents with the URI, MIME type, and text supplied by the handler.""" async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult: @@ -106,7 +107,7 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq async with connect(server) as client: result = await client.read_resource("file:///greeting.txt") - assert result == snapshot( + assert unstamped(result) == snapshot( ReadResourceResult( contents=[TextResourceContents(uri="file:///greeting.txt", mime_type="text/plain", text="Hello, world!")] ) @@ -114,7 +115,7 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq @requirement("resources:read:blob") -async def test_read_resource_binary(connect: Connect) -> None: +async def test_read_resource_binary(connect: Connect, unstamped: Unstamp) -> None: """Reading a binary resource returns its contents base64-encoded in the blob field.""" async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult: @@ -133,7 +134,7 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq async with connect(server) as client: result = await client.read_resource("file:///pixel.png") - assert result == snapshot( + assert unstamped(result) == snapshot( ReadResourceResult( contents=[BlobResourceContents(uri="file:///pixel.png", mime_type="image/png", blob="iVBORw==")] ) @@ -161,7 +162,7 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq @requirement("resources:templates:list") -async def test_list_resource_templates_returns_registered_templates(connect: Connect) -> None: +async def test_list_resource_templates_returns_registered_templates(connect: Connect, unstamped: Unstamp) -> None: """Listed resource templates reach the client with their URI templates and descriptive fields intact.""" async def list_resource_templates( @@ -186,7 +187,7 @@ async def list_resource_templates( async with connect(server) as client: result = await client.list_resource_templates() - assert result == snapshot( + assert unstamped(result) == snapshot( ListResourceTemplatesResult( resource_templates=[ ResourceTemplate(uri_template="users://{user_id}", name="user"), diff --git a/tests/interaction/lowlevel/test_tools.py b/tests/interaction/lowlevel/test_tools.py index 861dd75e44..86fec356bc 100644 --- a/tests/interaction/lowlevel/test_tools.py +++ b/tests/interaction/lowlevel/test_tools.py @@ -22,6 +22,7 @@ from mcp import MCPError from mcp.server import Server, ServerRequestContext +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -29,7 +30,7 @@ @requirement("tools:call:content:text") -async def test_call_tool_returns_text_content(connect: Connect) -> None: +async def test_call_tool_returns_text_content(connect: Connect, unstamped: Unstamp) -> None: """Arguments reach the tool handler; its content comes back as the call result.""" async def list_tools( @@ -49,11 +50,11 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: result = await client.call_tool("add", {"a": 2, "b": 3}) - assert result == snapshot(CallToolResult(content=[TextContent(text="5")])) + assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="5")])) @requirement("tools:call:is-error") -async def test_call_tool_execution_error_is_returned_as_result(connect: Connect) -> None: +async def test_call_tool_execution_error_is_returned_as_result(connect: Connect, unstamped: Unstamp) -> None: """A tool reporting its own failure with is_error=True reaches the client as a result, not an exception. Tool execution errors are part of the result so the caller (typically a model) can see @@ -69,7 +70,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: result = await client.call_tool("flux", {}) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult(content=[TextContent(text="the flux capacitor is offline")], is_error=True) ) @@ -117,7 +118,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara @requirement("tools:list:basic") -async def test_list_tools_returns_registered_tools(connect: Connect) -> None: +async def test_list_tools_returns_registered_tools(connect: Connect, unstamped: Unstamp) -> None: """The tools advertised by the server's list handler arrive at the client unchanged.""" async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: @@ -141,7 +142,7 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa async with connect(server) as client: result = await client.list_tools() - assert result == snapshot( + assert unstamped(result) == snapshot( ListToolsResult( tools=[ Tool( @@ -163,7 +164,7 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa @requirement("tools:input-schema:preserve-additional-properties") @requirement("tools:input-schema:preserve-defs") @requirement("tools:input-schema:preserve-schema-dialect") -async def test_tools_list_preserves_arbitrary_input_schema_keywords(connect: Connect) -> None: +async def test_tools_list_preserves_arbitrary_input_schema_keywords(connect: Connect, unstamped: Unstamp) -> None: """A rich JSON Schema 2020-12 inputSchema reaches the client unchanged and the tool is callable. The single identity assertion below proves all four pass-through behaviours at once: the same @@ -202,11 +203,11 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara called = await client.call_tool("typed", {"count": 3, "options": {"verbose": True}}) assert listed.tools[0].input_schema == schema - assert called == snapshot(CallToolResult(content=[TextContent(text="ok")])) + assert unstamped(called) == snapshot(CallToolResult(content=[TextContent(text="ok")])) @requirement("tools:list:metadata") -async def test_list_tools_optional_fields_round_trip(connect: Connect) -> None: +async def test_list_tools_optional_fields_round_trip(connect: Connect, unstamped: Unstamp) -> None: """Every optional Tool field the server supplies reaches the client unchanged.""" tool = Tool( @@ -228,7 +229,7 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa async with connect(server) as client: result = await client.list_tools() - assert result == snapshot( + assert unstamped(result) == snapshot( ListToolsResult( tools=[ Tool( @@ -251,7 +252,7 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa @requirement("tools:call:content:audio") @requirement("tools:call:content:resource-link") @requirement("tools:call:content:embedded-resource") -async def test_call_tool_multiple_content_block_types(connect: Connect) -> None: +async def test_call_tool_multiple_content_block_types(connect: Connect, unstamped: Unstamp) -> None: """A tool result can mix every content block type; all of them arrive in order. The payloads are tiny fixed base64 strings ("aW1n" is b"img", "YXVk" is b"aud") so the @@ -280,7 +281,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: result = await client.call_tool("render", {}) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult( content=[ TextContent(text="all five content block types"), @@ -296,7 +297,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara @requirement("tools:call:structured-content") -async def test_call_tool_structured_content(connect: Connect) -> None: +async def test_call_tool_structured_content(connect: Connect, unstamped: Unstamp) -> None: """A tool result carrying structured content alongside content delivers both to the client.""" async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: @@ -311,11 +312,13 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with connect(server) as client: result = await client.call_tool("sum", {}) - assert result == snapshot(CallToolResult(content=[TextContent(text="the sum is 5")], structured_content={"sum": 5})) + assert unstamped(result) == snapshot( + CallToolResult(content=[TextContent(text="the sum is 5")], structured_content={"sum": 5}) + ) @requirement("tools:call:concurrent") -async def test_concurrent_tool_calls_complete_independently(connect: Connect) -> None: +async def test_concurrent_tool_calls_complete_independently(connect: Connect, unstamped: Unstamp) -> None: """Two tool calls in flight at once run concurrently and each caller gets its own answer. Both handlers are held on a shared event after signalling that they have started, and the test @@ -347,7 +350,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara async with anyio.create_task_group() as task_group: # pragma: no branch async def call_and_record(tag: str) -> None: - results[tag] = await client.call_tool("echo", {"tag": tag}) + results[tag] = unstamped(await client.call_tool("echo", {"tag": tag})) task_group.start_soon(call_and_record, "first") task_group.start_soon(call_and_record, "second") @@ -403,7 +406,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara @requirement("client:output-schema:skip-on-error") -async def test_is_error_result_bypasses_client_output_schema_validation(connect: Connect) -> None: +async def test_is_error_result_bypasses_client_output_schema_validation(connect: Connect, unstamped: Unstamp) -> None: """A tool result with isError true is returned as-is even when its structured content violates the schema. The schema is cached up front so the client could validate, proving the bypass is specifically the @@ -437,7 +440,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara await client.list_tools() result = await client.call_tool("forecast", {}) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult(content=[TextContent(text="boom")], structured_content={"temperature": "warm"}, is_error=True) ) @@ -475,7 +478,9 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara @requirement("client:output-schema:auto-list") -async def test_call_tool_populates_the_output_schema_cache_via_an_implicit_tools_list(connect: Connect) -> None: +async def test_call_tool_populates_the_output_schema_cache_via_an_implicit_tools_list( + connect: Connect, unstamped: Unstamp +) -> None: """Calling a tool whose schema is not cached issues exactly one implicit tools/list to populate it. The first call_tool of an uncached tool triggers a tools/list the caller never asked for; the @@ -509,5 +514,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara second = await client.call_tool("forecast", {}) assert list_calls == ["called"] - assert first == snapshot(CallToolResult(content=[TextContent(text="21 C")], structured_content={"temperature": 21})) - assert second == first + assert unstamped(first) == snapshot( + CallToolResult(content=[TextContent(text="21 C")], structured_content={"temperature": 21}) + ) + assert unstamped(second) == first diff --git a/tests/interaction/mcpserver/test_context.py b/tests/interaction/mcpserver/test_context.py index 27c0c70cc1..24728e2da8 100644 --- a/tests/interaction/mcpserver/test_context.py +++ b/tests/interaction/mcpserver/test_context.py @@ -20,6 +20,7 @@ from mcp.client import ClientRequestContext from mcp.server.elicitation import AcceptedElicitation from mcp.server.mcpserver import Context, MCPServer +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -68,7 +69,7 @@ async def collect(params: LoggingMessageNotificationParams) -> None: @requirement("mcpserver:context:progress") -async def test_context_report_progress_sends_progress_notifications(connect: Connect) -> None: +async def test_context_report_progress_sends_progress_notifications(connect: Connect, unstamped: Unstamp) -> None: """Context.report_progress sends progress notifications correlated to the calling request. The caller's progress callback receives each report, in order, before the tool call returns. @@ -88,7 +89,7 @@ async def on_progress(progress: float, total: float | None, message: str | None) async with connect(mcp) as client: result = await client.call_tool("crunch", {}, progress_callback=on_progress) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult(content=[TextContent(text="crunched")], structured_content={"result": "crunched"}) ) assert received == snapshot([(1.0, 3.0, None), (2.0, 3.0, "halfway there")]) @@ -123,7 +124,7 @@ async def whoami(ctx: Context) -> str: @requirement("mcpserver:context:logging") @requirement("protocol:progress:no-token") -async def test_report_progress_without_a_progress_token_sends_nothing(connect: Connect) -> None: +async def test_report_progress_without_a_progress_token_sends_nothing(connect: Connect, unstamped: Unstamp) -> None: """When the caller supplied no progress callback, Context.report_progress is a silent no-op. The tool also emits one log message as a sentinel: the message handler receives only that, @@ -145,7 +146,7 @@ async def collect(message: IncomingMessage) -> None: async with connect(mcp, message_handler=collect) as client: result = await client.call_tool("mill", {}) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult(content=[TextContent(text="milled")], structured_content={"result": "milled"}) ) assert received == snapshot( @@ -207,7 +208,7 @@ async def answer_form(context: ClientRequestContext, params: ElicitRequestParams @requirement("mcpserver:context:read-resource") -async def test_context_read_resource_reads_registered_resource(connect: Connect) -> None: +async def test_context_read_resource_reads_registered_resource(connect: Connect, unstamped: Unstamp) -> None: """Context.read_resource lets a tool read a resource registered on the same server. The tool reports the MIME type and content it read, proving the resource function ran and its @@ -228,7 +229,7 @@ async def show_config(ctx: Context) -> str: async with connect(mcp) as client: result = await client.call_tool("show_config", {}) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult( content=[TextContent(text="text/plain: 'theme = dark'")], structured_content={"result": "text/plain: 'theme = dark'"}, diff --git a/tests/interaction/mcpserver/test_extensions.py b/tests/interaction/mcpserver/test_extensions.py index 205a7fd6ea..129324538f 100644 --- a/tests/interaction/mcpserver/test_extensions.py +++ b/tests/interaction/mcpserver/test_extensions.py @@ -15,6 +15,7 @@ from mcp.server.context import CallNext, HandlerResult, ServerRequestContext from mcp.server.extension import Extension from mcp.server.mcpserver import Context, MCPServer, require_client_extension +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -79,7 +80,9 @@ def redeem(token: str) -> str: @requirement("extensions:client:claimed-result-resolved") -async def test_claimed_result_is_finished_by_the_owning_extensions_resolver(connect: Connect) -> None: +async def test_claimed_result_is_finished_by_the_owning_extensions_resolver( + connect: Connect, unstamped: Unstamp +) -> None: """The owning extension's claim resolver redeems the substituted `receipt` through `ctx.session`, and `call_tool` returns the resolver's plain `CallToolResult`.""" received: list[ReceiptResult] = [] @@ -92,7 +95,7 @@ async def redeem_receipt(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolR result = await client.call_tool("buy", {"item": "lamp"}) assert [claimed.receipt_token for claimed in received] == ["r-117"] - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult(content=[TextContent(text="goods for r-117")], structured_content={"result": "goods for r-117"}) ) diff --git a/tests/interaction/mcpserver/test_prompts.py b/tests/interaction/mcpserver/test_prompts.py index 58c8b48c7f..8409e50207 100644 --- a/tests/interaction/mcpserver/test_prompts.py +++ b/tests/interaction/mcpserver/test_prompts.py @@ -14,6 +14,7 @@ from mcp import MCPError from mcp.server.mcpserver import MCPServer +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -21,7 +22,7 @@ @requirement("mcpserver:prompt:decorated") -async def test_list_prompts_derives_arguments_from_signature(connect: Connect) -> None: +async def test_list_prompts_derives_arguments_from_signature(connect: Connect, unstamped: Unstamp) -> None: """A decorated prompt is listed with arguments derived from the function signature. Parameters without a default are required; the description comes from the docstring. @@ -36,7 +37,7 @@ def code_review(code: str, style_guide: str = "pep8") -> str: async with connect(mcp) as client: result = await client.list_prompts() - assert result == snapshot( + assert unstamped(result) == snapshot( ListPromptsResult( prompts=[ Prompt( @@ -53,7 +54,7 @@ def code_review(code: str, style_guide: str = "pep8") -> str: @requirement("mcpserver:prompt:decorated") -async def test_get_prompt_renders_function_return(connect: Connect) -> None: +async def test_get_prompt_renders_function_return(connect: Connect, unstamped: Unstamp) -> None: """The decorated function's string return value is rendered as a single user message.""" mcp = MCPServer("prompter") @@ -65,7 +66,7 @@ def greet(name: str) -> str: async with connect(mcp) as client: result = await client.get_prompt("greet", {"name": "Ada"}) - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult( description="A personalised greeting.", messages=[PromptMessage(role="user", content=TextContent(text="Say hello to Ada."))], @@ -141,7 +142,9 @@ def repeat(phrase: str, count: int) -> str: @requirement("mcpserver:prompt:optional-args") -async def test_get_prompt_with_an_optional_argument_omitted_uses_the_default(connect: Connect) -> None: +async def test_get_prompt_with_an_optional_argument_omitted_uses_the_default( + connect: Connect, unstamped: Unstamp +) -> None: """A prompt rendered without one of its optional arguments uses that parameter's default value.""" mcp = MCPServer("prompter") @@ -153,7 +156,7 @@ def review(code: str, style: str = "pep8") -> str: async with connect(mcp) as client: result = await client.get_prompt("review", {"code": "x = 1"}) - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult( description="Review a snippet of code against a style guide.", messages=[PromptMessage(role="user", content=TextContent(text="Review x = 1 per pep8."))], @@ -162,7 +165,9 @@ def review(code: str, style: str = "pep8") -> str: @requirement("mcpserver:prompt:duplicate-name") -async def test_registering_a_duplicate_prompt_name_warns_and_keeps_the_first(connect: Connect) -> None: +async def test_registering_a_duplicate_prompt_name_warns_and_keeps_the_first( + connect: Connect, unstamped: Unstamp +) -> None: """Registering a second prompt with an already-used name keeps the first registration. The intended behaviour is rejection at registration time; MCPServer instead logs a warning @@ -187,7 +192,7 @@ def greet_second() -> str: result = await client.get_prompt("greet") assert [prompt.name for prompt in listed.prompts] == ["greet"] - assert result == snapshot( + assert unstamped(result) == snapshot( GetPromptResult( description="The first registration; this is the one that wins.", messages=[PromptMessage(role="user", content=TextContent(text="first"))], diff --git a/tests/interaction/mcpserver/test_resources.py b/tests/interaction/mcpserver/test_resources.py index d7fd996051..eadf4794e6 100644 --- a/tests/interaction/mcpserver/test_resources.py +++ b/tests/interaction/mcpserver/test_resources.py @@ -14,6 +14,7 @@ from mcp import MCPError from mcp.server.mcpserver import MCPServer +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -21,7 +22,7 @@ @requirement("mcpserver:resource:static") -async def test_read_static_resource(connect: Connect) -> None: +async def test_read_static_resource(connect: Connect, unstamped: Unstamp) -> None: """A function registered for a fixed URI is served at that URI with its return value as text.""" mcp = MCPServer("library") @@ -33,7 +34,7 @@ def app_config() -> str: async with connect(mcp) as client: result = await client.read_resource("config://app") - assert result == snapshot( + assert unstamped(result) == snapshot( ReadResourceResult( contents=[TextResourceContents(uri="config://app", mime_type="text/plain", text="theme = dark")] ) @@ -41,7 +42,7 @@ def app_config() -> str: @requirement("mcpserver:resource:static") -async def test_list_static_and_templated_resources(connect: Connect) -> None: +async def test_list_static_and_templated_resources(connect: Connect, unstamped: Unstamp) -> None: """Statically-registered resources appear in resources/list; templated ones only in templates/list. The name and description are derived from the function name and docstring; the MIME type @@ -63,7 +64,7 @@ def user_profile(user_id: str) -> str: resources = await client.list_resources() templates = await client.list_resource_templates() - assert resources == snapshot( + assert unstamped(resources) == snapshot( ListResourcesResult( resources=[ Resource( @@ -75,7 +76,7 @@ def user_profile(user_id: str) -> str: ] ) ) - assert templates == snapshot( + assert unstamped(templates) == snapshot( ListResourceTemplatesResult( resource_templates=[ ResourceTemplate( @@ -91,7 +92,7 @@ def user_profile(user_id: str) -> str: @requirement("mcpserver:resource:template") @requirement("resources:read:template-vars") -async def test_read_templated_resource(connect: Connect) -> None: +async def test_read_templated_resource(connect: Connect, unstamped: Unstamp) -> None: """Reading a URI that matches a registered template invokes the function with the extracted parameters.""" mcp = MCPServer("library") @@ -103,7 +104,7 @@ def user_profile(user_id: str) -> str: async with connect(mcp) as client: result = await client.read_resource("users://42/profile") - assert result == snapshot( + assert unstamped(result) == snapshot( ReadResourceResult( contents=[TextResourceContents(uri="users://42/profile", mime_type="text/plain", text="profile for 42")] ) @@ -152,7 +153,9 @@ def boom() -> str: @requirement("mcpserver:resource:duplicate-name") -async def test_registering_a_duplicate_resource_uri_warns_and_keeps_the_first(connect: Connect) -> None: +async def test_registering_a_duplicate_resource_uri_warns_and_keeps_the_first( + connect: Connect, unstamped: Unstamp +) -> None: """Registering a second static resource at an already-used URI keeps the first registration. The intended behaviour is rejection at registration time; MCPServer instead logs a warning @@ -178,6 +181,6 @@ def config_second() -> str: assert [resource.uri for resource in listed.resources] == ["config://app"] assert listed.resources[0].name == "config_first" - assert result == snapshot( + assert unstamped(result) == snapshot( ReadResourceResult(contents=[TextResourceContents(uri="config://app", mime_type="text/plain", text="first")]) ) diff --git a/tests/interaction/mcpserver/test_tools.py b/tests/interaction/mcpserver/test_tools.py index a7791d7cda..ad5db520b9 100644 --- a/tests/interaction/mcpserver/test_tools.py +++ b/tests/interaction/mcpserver/test_tools.py @@ -20,6 +20,7 @@ from mcp.server.mcpserver import Context, MCPServer from mcp.server.mcpserver.exceptions import ToolError from mcp.shared.exceptions import UrlElicitationRequiredError +from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage from tests.interaction._requirements import requirement @@ -28,7 +29,7 @@ @requirement("tools:call:content:text") -async def test_call_tool_returns_text_content(connect: Connect) -> None: +async def test_call_tool_returns_text_content(connect: Connect, unstamped: Unstamp) -> None: """Arguments reach the tool function; its return value comes back as text content. MCPServer also derives an output schema from the return annotation and attaches the @@ -43,11 +44,15 @@ def add(a: int, b: int) -> str: async with connect(mcp) as client: result = await client.call_tool("add", {"a": 2, "b": 3}) - assert result == snapshot(CallToolResult(content=[TextContent(text="5")], structured_content={"result": "5"})) + assert unstamped(result) == snapshot( + CallToolResult(content=[TextContent(text="5")], structured_content={"result": "5"}) + ) @requirement("mcpserver:tool:schema-variants") -async def test_complex_parameter_types_are_validated_and_coerced_before_the_tool_runs(connect: Connect) -> None: +async def test_complex_parameter_types_are_validated_and_coerced_before_the_tool_runs( + connect: Connect, unstamped: Unstamp +) -> None: """Literal, nested-model, and constrained parameters are validated and coerced from the wire arguments. The string "3" is coerced to `int` and the `point` dict to a `Point` instance before the function @@ -67,7 +72,7 @@ def place(mode: Literal["fast", "slow"], point: Point, count: Annotated[int, Fie async with connect(mcp) as client: result = await client.call_tool("place", {"mode": "fast", "point": {"x": "3", "y": 4}, "count": 5}) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult( content=[TextContent(text="fast at (3, 4) x5")], structured_content={"result": "fast at (3, 4) x5"} ) @@ -76,7 +81,7 @@ def place(mode: Literal["fast", "slow"], point: Point, count: Annotated[int, Fie @requirement("mcpserver:tool:handler-throws") @requirement("mcpserver:output-schema:skip-on-error") -async def test_call_tool_function_exception_becomes_error_result(connect: Connect) -> None: +async def test_call_tool_function_exception_becomes_error_result(connect: Connect, unstamped: Unstamp) -> None: """An exception raised by a tool function is returned as an is_error result, not a JSON-RPC error. The function's `-> str` annotation gives the tool a derived output schema, but the error @@ -92,13 +97,13 @@ def explode() -> str: async with connect(mcp) as client: result = await client.call_tool("explode", {}) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult(content=[TextContent(text="Error executing tool explode: boom")], is_error=True) ) @requirement("mcpserver:tool:handler-throws") -async def test_call_tool_tool_error_becomes_error_result(connect: Connect) -> None: +async def test_call_tool_tool_error_becomes_error_result(connect: Connect, unstamped: Unstamp) -> None: """A ToolError raised by a tool function is returned as an is_error result, not a JSON-RPC error.""" mcp = MCPServer("errors") @@ -109,13 +114,13 @@ def flux() -> str: async with connect(mcp) as client: result = await client.call_tool("flux", {}) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult(content=[TextContent(text="Error executing tool flux: flux capacitor offline")], is_error=True) ) @requirement("mcpserver:tool:unknown-name") -async def test_call_tool_unknown_name_returns_error_result(connect: Connect) -> None: +async def test_call_tool_unknown_name_returns_error_result(connect: Connect, unstamped: Unstamp) -> None: """Calling a tool name that was never registered is reported as an is_error result. The spec classifies unknown tools as a protocol error; see the divergence note on the @@ -130,12 +135,14 @@ def add() -> None: async with connect(mcp) as client: result = await client.call_tool("nope", {}) - assert result == snapshot(CallToolResult(content=[TextContent(text="Unknown tool: nope")], is_error=True)) + assert unstamped(result) == snapshot( + CallToolResult(content=[TextContent(text="Unknown tool: nope")], is_error=True) + ) @requirement("mcpserver:tool:output-schema:model") @requirement("tools:call:structured-content:text-mirror") -async def test_call_tool_model_return_becomes_structured_content(connect: Connect) -> None: +async def test_call_tool_model_return_becomes_structured_content(connect: Connect, unstamped: Unstamp) -> None: """A tool returning a pydantic model advertises the model's schema as the tool's output schema and returns the model's fields as structured content alongside a serialised text block. """ @@ -164,7 +171,7 @@ def get_weather() -> Weather: "type": "object", } ) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult( content=[ TextContent( @@ -182,7 +189,7 @@ def get_weather() -> Weather: @requirement("mcpserver:tool:output-schema:wrapped") -async def test_call_tool_list_return_is_wrapped_in_result_key(connect: Connect) -> None: +async def test_call_tool_list_return_is_wrapped_in_result_key(connect: Connect, unstamped: Unstamp) -> None: """A tool returning a list wraps the value under a "result" key in both the generated output schema and the structured content. """ @@ -204,7 +211,7 @@ def primes() -> list[int]: "type": "object", } ) - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult( content=[TextContent(text="2"), TextContent(text="3"), TextContent(text="5")], structured_content={"result": [2, 3, 5]}, @@ -279,7 +286,9 @@ def missing() -> Annotated[CallToolResult, Weather]: @requirement("mcpserver:tool:duplicate-name") -async def test_registering_a_duplicate_tool_name_warns_and_keeps_the_first(connect: Connect) -> None: +async def test_registering_a_duplicate_tool_name_warns_and_keeps_the_first( + connect: Connect, unstamped: Unstamp +) -> None: """Registering a second tool with an already-used name keeps the first registration. The intended behaviour is rejection at registration time; MCPServer instead logs a warning @@ -304,14 +313,14 @@ def echo_second() -> str: result = await client.call_tool("echo", {}) assert [tool.name for tool in listed.tools] == ["echo"] - assert result == snapshot( + assert unstamped(result) == snapshot( CallToolResult(content=[TextContent(text="first")], structured_content={"result": "first"}) ) @requirement("mcpserver:tool:naming-validation") async def test_registering_a_tool_with_a_spec_invalid_name_warns_but_does_not_reject( - connect: Connect, caplog: pytest.LogCaptureFixture + connect: Connect, caplog: pytest.LogCaptureFixture, unstamped: Unstamp ) -> None: """A tool name that violates the SEP-986 rules logs a warning at registration but is still registered. @@ -340,7 +349,9 @@ def bad() -> str: result = await client.call_tool("bad name!", {}) assert [tool.name for tool in listed.tools] == ["bad name!"] - assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"})) + assert unstamped(result) == snapshot( + CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}) + ) @requirement("mcpserver:tool:url-elicitation-error") diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index 301af558c1..9ebaa71460 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -22,6 +22,7 @@ INVALID_PARAMS, METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, + SERVER_INFO_META_KEY, CallToolRequestParams, CallToolResult, DiscoverResult, @@ -77,7 +78,11 @@ def _meta_envelope() -> dict[str, object]: def _server(*, on_meta: Callable[[dict[str, Any]], None] | None = None) -> Server: - """A low-level server with one ``add`` tool for the raw-httpx2 tests below.""" + """A low-level server with one `add` tool for the raw-httpx2 tests below. + + The explicit version gives the `_meta` serverInfo stamp every 2026 result + carries a non-empty value for the wire-level snapshots. + """ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: tool = Tool(name="add", input_schema={"type": "object"}) @@ -91,7 +96,7 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> on_meta(dict(ctx.meta)) return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))]) - return Server("modern", on_list_tools=list_tools, on_call_tool=call_tool) + return Server("modern", version="1.0.0", on_list_tools=list_tools, on_call_tool=call_tool) @requirement("hosting:http:modern:tools-call-stateless") @@ -99,9 +104,10 @@ async def test_modern_tools_call_returns_result_type_complete_without_initialize """A 2026-07-28 tools/call is served without an initialize handshake and returns resultType: complete. Spec-mandated under the draft transport: the per-request ``_meta`` envelope replaces initialize, - and ``resultType`` is the 2026 result-envelope discriminator (``complete`` for the monolith - result). Asserted at the wire because the SDK client never surfaces ``resultType`` and because - the absence of any prior request on the connection is the assertion. + `resultType` is the 2026 result-envelope discriminator (`complete` for the monolith + result), and the server identifies itself via the result `_meta` serverInfo stamp. Asserted at + the wire because the SDK client never surfaces `resultType` and because the absence of any + prior request on the connection is the assertion. """ body = { "jsonrpc": "2.0", @@ -117,7 +123,12 @@ async def test_modern_tools_call_returns_result_type_complete_without_initialize parsed = JSONRPCResponse.model_validate(response.json()) assert parsed.id == 1 assert parsed.result == snapshot( - {"content": [{"text": "5", "type": "text"}], "isError": False, "resultType": "complete"} + { + "content": [{"text": "5", "type": "text"}], + "isError": False, + "resultType": "complete", + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "modern", "version": "1.0.0"}}, + } ) @@ -213,12 +224,13 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> @requirement("hosting:http:modern:discover-response-shape") async def test_modern_server_discover_returns_capabilities_and_supported_versions() -> None: - """A 2026-07-28 server/discover POST returns capabilities, serverInfo, and supportedVersions. + """A 2026-07-28 server/discover POST returns capabilities and supportedVersions, with serverInfo in `_meta`. Spec-mandated under the draft: server/discover is the 2026 advertisement method that replaces the initialize-response payload, and ``supportedVersions`` is the field a client picks its - per-request envelope version from. Asserted at the wire because the SDK client never exposes - the raw result body. + per-request envelope version from. The server's identity is no longer a result-body field: it + travels as the io.modelcontextprotocol/serverInfo result `_meta` stamp. Asserted at the wire + because the SDK client never exposes the raw result body. """ body = {"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": _meta_envelope()}} async with mounted_app(_server()) as (http, _): @@ -227,7 +239,8 @@ async def test_modern_server_discover_returns_capabilities_and_supported_version assert response.status_code == 200 result = JSONRPCResponse.model_validate(response.json()).result assert result["supportedVersions"] == snapshot(["2026-07-28"]) - assert result["serverInfo"]["name"] == "modern" + assert "serverInfo" not in result + assert result["_meta"][SERVER_INFO_META_KEY] == {"name": "modern", "version": "1.0.0"} assert "capabilities" in result @@ -282,7 +295,7 @@ async def cap_check(ctx: ServerRequestContext, params: RequestParams) -> EmptyRe raise MCPError( code=MISSING_REQUIRED_CLIENT_CAPABILITY, message="sampling required", - data={"requiredCapabilities": ["sampling"]}, + data={"requiredCapabilities": {"sampling": {}}}, ) server = _server() @@ -294,7 +307,7 @@ async def cap_check(ctx: ServerRequestContext, params: RequestParams) -> EmptyRe assert response.status_code == 400 error = JSONRPCError.model_validate(response.json()).error assert error.code == MISSING_REQUIRED_CLIENT_CAPABILITY - assert error.data == {"requiredCapabilities": ["sampling"]} + assert error.data == {"requiredCapabilities": {"sampling": {}}} @requirement("hosting:http:modern:tools-call-stateless") @@ -340,7 +353,6 @@ async def on_response(response: httpx2.Response) -> None: DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="srv", version="0"), ) ) result = await session.call_tool( @@ -350,7 +362,12 @@ async def on_response(response: httpx2.Response) -> None: ) assert result.model_dump(by_alias=True, mode="json", exclude_none=True) == snapshot( - {"content": [{"type": "text", "text": "5"}], "isError": False, "resultType": "complete"} + { + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "modern", "version": "1.0.0"}}, + "content": [{"type": "text", "text": "5"}], + "isError": False, + "resultType": "complete", + } ) # Exactly the tools/call POST and the implicit tools/list POST -- no initialize, no @@ -440,7 +457,6 @@ async def on_request(request: httpx2.Request) -> None: discover = DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="srv", version="0"), ) with anyio.fail_after(5): async with ( @@ -487,7 +503,6 @@ async def on_request(request: httpx2.Request) -> None: discover = DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="srv", version="0"), ) with anyio.fail_after(5): async with ( @@ -541,7 +556,6 @@ async def on_request(request: httpx2.Request) -> None: discover = DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="srv", version="0"), ) with anyio.fail_after(5): async with ( @@ -596,7 +610,6 @@ async def on_request(request: httpx2.Request) -> None: discover = DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), - server_info=Implementation(name="srv", version="0"), ) with anyio.fail_after(5): async with ( diff --git a/tests/interaction/transports/test_stdio.py b/tests/interaction/transports/test_stdio.py index dbb7de3459..7577b0c408 100644 --- a/tests/interaction/transports/test_stdio.py +++ b/tests/interaction/transports/test_stdio.py @@ -90,6 +90,7 @@ async def collect(params: LoggingMessageNotificationParams) -> None: # Must exceed session time plus the patched PROCESS_TERMINATION_TIMEOUT (20s). with anyio.fail_after(30): async with Client(transport, mode="legacy", logging_callback=collect) as client: + assert client.server_info is not None assert client.server_info.name == "stdio-echo" result = await client.call_tool("echo", {"text": "across\nprocesses"}) diff --git a/tests/interaction/transports/test_streamable_http.py b/tests/interaction/transports/test_streamable_http.py index 779a46054a..bb6dec5695 100644 --- a/tests/interaction/transports/test_streamable_http.py +++ b/tests/interaction/transports/test_streamable_http.py @@ -69,6 +69,7 @@ async def announce(ctx: Context) -> str: async def test_tool_call_over_streamable_http_with_json_responses() -> None: """The round trip works when the server answers with a single JSON body instead of an SSE stream.""" async with connect_over_streamable_http(_smoke_server(), json_response=True) as client: + assert client.server_info is not None assert client.server_info.name == "smoke" result = await client.call_tool("echo", {"text": "as json"}) diff --git a/tests/server/lowlevel/test_server_discover.py b/tests/server/lowlevel/test_server_discover.py index 05d57d846a..23a29327ee 100644 --- a/tests/server/lowlevel/test_server_discover.py +++ b/tests/server/lowlevel/test_server_discover.py @@ -2,17 +2,27 @@ These call the registered handler via the public `Server.get_request_handler` accessor without spinning up a `ServerRunner` or any transport, so they verify -the handler's contract in isolation from the dispatch pipeline. +the handler's contract in isolation from the dispatch pipeline. The exception +is the server-identity pair: the serverInfo `_meta` stamp is applied by the +runner (spec 2026-07-28, #3002), not the handler, so those two drive one +request through `serve_one` to observe it. """ -import importlib.metadata +from collections.abc import Mapping +from dataclasses import dataclass, field from typing import Any, cast +import anyio import mcp_types as types import pytest from mcp_types.version import MODERN_PROTOCOL_VERSIONS from mcp.server import NotificationOptions, Server, ServerRequestContext +from mcp.server.connection import Connection +from mcp.server.runner import serve_one +from mcp.shared.dispatcher import CallOptions +from mcp.shared.message import MessageMetadata +from mcp.shared.transport_context import TransportContext # `Server._handle_discover` reads only `ctx.protocol_version` (capabilities are @@ -36,6 +46,47 @@ async def _discover(server: Server[Any], protocol_version: str = MODERN_PROTOCOL return result +@dataclass +class _StubDispatchContext: + """Minimal `DispatchContext` for the `serve_one`-driven identity tests. + + Satisfies the protocol structurally; the discover handler never touches + the back-channel. + """ + + request_id: int | str | None = 1 + transport: TransportContext = field(default_factory=lambda: TransportContext(kind="direct", can_send_request=False)) + message_metadata: MessageMetadata = None + cancel_requested: anyio.Event = field(default_factory=anyio.Event) + can_send_request: bool = False + + async def send_raw_request( + self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None + ) -> dict[str, Any]: + raise NotImplementedError + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + raise NotImplementedError + + async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + raise NotImplementedError + + +async def _discover_over_runner(server: Server[Any]) -> dict[str, Any]: + """Serve one `server/discover` through the runner - the layer that stamps + server identity into the result `_meta`.""" + connection = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None) + params: dict[str, Any] = { + "_meta": { + types.PROTOCOL_VERSION_META_KEY: MODERN_PROTOCOL_VERSIONS[0], + types.CLIENT_CAPABILITIES_META_KEY: {}, + } + } + return await serve_one( + server, _StubDispatchContext(), "server/discover", params, connection=connection, lifespan_state={} + ) + + def test_registered_by_default() -> None: """SDK-defined: a bare `Server` registers a `server/discover` handler out of the box, typed for the base `RequestParams`.""" @@ -55,7 +106,8 @@ async def test_supported_versions_is_modern_set() -> None: @pytest.mark.anyio async def test_server_info_reflects_constructor_fields() -> None: - """SDK-defined: `serverInfo` is built field-for-field from the `Server` + """Server identity travels as the discover result's `_meta` serverInfo + stamp (spec 2026-07-28, #3002), built field-for-field from the `Server` constructor arguments.""" icons = [types.Icon(src="https://example.test/icon.png")] server = Server( @@ -66,8 +118,9 @@ async def test_server_info_reflects_constructor_fields() -> None: website_url="https://example.test", icons=icons, ) - result = await _discover(server) - assert result.server_info == types.Implementation( + result = await _discover_over_runner(server) + stamp = result["_meta"][types.SERVER_INFO_META_KEY] + assert types.Implementation.model_validate(stamp) == types.Implementation( name="info-server", version="9.9.9", title="Info Server", @@ -78,11 +131,12 @@ async def test_server_info_reflects_constructor_fields() -> None: @pytest.mark.anyio -async def test_server_info_version_falls_back_to_package() -> None: - """SDK-defined: when no explicit version is supplied, `serverInfo.version` - falls back to the installed `mcp` package version.""" - result = await _discover(Server("unversioned")) - assert result.server_info.version == importlib.metadata.version("mcp") +async def test_an_unversioned_server_reports_an_empty_version() -> None: + """SDK-defined: when no explicit version is supplied, the stamped + `serverInfo` version is an empty string - the SDK never substitutes its + own package version for the server's.""" + result = await _discover_over_runner(Server("unversioned")) + assert result["_meta"][types.SERVER_INFO_META_KEY] == {"name": "unversioned", "version": ""} @pytest.mark.anyio @@ -143,7 +197,6 @@ async def test_overridable_via_add_request_handler() -> None: custom = types.DiscoverResult( supported_versions=list(MODERN_PROTOCOL_VERSIONS), capabilities=types.ServerCapabilities(), - server_info=types.Implementation(name="custom-server", version="1.0.0"), instructions="overridden", ttl_ms=60_000, cache_scope="public", diff --git a/tests/server/mcpserver/test_extension.py b/tests/server/mcpserver/test_extension.py index b6ff0283d6..5aa05b1e3d 100644 --- a/tests/server/mcpserver/test_extension.py +++ b/tests/server/mcpserver/test_extension.py @@ -2,11 +2,11 @@ These exercise the closed set of extension contribution kinds - tools, resources, request methods, and the single `tools/call` interceptor - through -the highest-level public surface (in-memory `Client`), plus the -`compose_tool_call_interceptor` helper directly. +the highest-level public surface (in-memory `Client`). """ -from typing import Any, Literal, cast +from dataclasses import replace +from typing import Any, Literal import mcp_types as types import pytest @@ -14,6 +14,7 @@ from mcp_types import ( METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, + SERVER_INFO_META_KEY, CallToolResult, TextContent, ) @@ -26,7 +27,6 @@ MethodBinding, ResourceBinding, ToolBinding, - compose_tool_call_interceptor, ) from mcp.server.mcpserver import Context, MCPServer, require_client_extension from mcp.server.mcpserver.resources import TextResource @@ -150,10 +150,17 @@ async def test_additive_extension_registers_its_tool_and_resource() -> None: assert [t.name for t in tools.tools] == ["ping"] assert tools.tools[0].meta == _TOOL_META - assert called == snapshot(CallToolResult(content=[TextContent(text="pong")], structured_content={"result": "pong"})) + assert called == snapshot( + CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + content=[TextContent(text="pong")], + structured_content={"result": "pong"}, + ) + ) assert resources == snapshot( types.ListResourcesResult( - resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")] + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")], ) ) @@ -195,7 +202,9 @@ async def test_extension_method_reachable_via_session_send_request() -> None: request = _PingRequest(params=_PingParams()) result = await client.session.send_request(request, _PingResult) - assert result == snapshot(_PingResult(pong=True)) + assert result == snapshot( + _PingResult(_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, pong=True) + ) async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None: @@ -207,7 +216,13 @@ async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None: async with Client(server) as client: result = await client.call_tool("echo", {"value": "hi"}) - assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"})) + assert result == snapshot( + CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + content=[TextContent(text="hi")], + structured_content={"result": "hi"}, + ) + ) async def test_short_circuiting_interceptor_replaces_tool_result() -> None: @@ -219,26 +234,36 @@ async def test_short_circuiting_interceptor_replaces_tool_result() -> None: async with Client(server) as client: result = await client.call_tool("echo", {"value": "hi"}) - assert result == snapshot(CallToolResult(content=[TextContent(text="intercepted")])) + assert result == snapshot( + CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + content=[TextContent(text="intercepted")], + ) + ) -def test_plain_extension_installs_no_tool_call_interceptor() -> None: - """SDK-defined: an extension that does not override `intercept_tool_call` adds no - middleware - the composed interceptor exists only when at least one extension - overrides it.""" - baseline = len(MCPServer("test")._lowlevel_server.middleware) +def test_plain_extension_leaves_the_tool_call_handler_bare() -> None: + """SDK-defined: an extension that does not override `intercept_tool_call` leaves + `tools/call` registered as the server's own handler - the interceptor chain is + composed only when at least one extension overrides it.""" server = MCPServer("test", extensions=[_AdditiveExt()]) - assert len(server._lowlevel_server.middleware) == baseline + entry = server._lowlevel_server.get_request_handler("tools/call") + assert entry is not None + assert entry.handler == server._handle_call_tool -def test_overriding_extension_installs_one_tool_call_interceptor() -> None: - """SDK-defined: an extension that overrides `intercept_tool_call` composes exactly - one additional `tools/call` middleware.""" +def test_overriding_extension_wraps_the_tool_call_handler() -> None: + """SDK-defined: an extension that overrides `intercept_tool_call` re-registers + `tools/call` with the interceptor chain wrapped around the server's own handler, + and installs no middleware.""" baseline = len(MCPServer("test")._lowlevel_server.middleware) server = MCPServer("test", extensions=[_ReplacingExt()]) - assert len(server._lowlevel_server.middleware) == baseline + 1 + entry = server._lowlevel_server.get_request_handler("tools/call") + assert entry is not None + assert entry.handler != server._handle_call_tool + assert len(server._lowlevel_server.middleware) == baseline async def test_default_interceptor_passes_through_alongside_an_overriding_one() -> None: @@ -251,11 +276,17 @@ async def test_default_interceptor_passes_through_alongside_an_overriding_one() async with Client(server) as client: result = await client.call_tool("echo", {"value": "hi"}) - assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"})) + assert result == snapshot( + CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + content=[TextContent(text="hi")], + structured_content={"result": "hi"}, + ) + ) async def test_interceptors_run_in_registration_order_with_threaded_params() -> None: - """SDK-defined: `compose_tool_call_interceptor` nests extensions first-outermost, so + """SDK-defined: `compose_tool_call_handler` nests extensions first-outermost, so two passing-through interceptors record in registration order, each seeing the validated `tools/call` params (the real tool name).""" log: list[tuple[str, str]] = [] @@ -271,26 +302,48 @@ async def test_interceptors_run_in_registration_order_with_threaded_params() -> assert log == [("com.example/first", "echo"), ("com.example/second", "echo")] -async def test_compose_tool_call_interceptor_passes_through_non_tools_call() -> None: - """SDK-defined: the composed middleware is a no-op for any method other than - `tools/call` - it forwards to `call_next` without touching the interceptors.""" - sentinel = types.EmptyResult() +async def test_an_interceptor_context_rewrite_does_not_change_the_tool_invocation() -> None: + """SDK-defined: the validated `params` argument is authoritative - an + interceptor passing a rewritten context through `call_next` adjusts what + the handler observes on `ctx`, not which tool call runs. Wire-level + request rewriting belongs to `Server.middleware`, above params validation.""" + + class _RewritingExt(Extension): + identifier = "com.example/rewriting" - async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: - return sentinel + async def intercept_tool_call( + self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + rewritten = {**(ctx.params or {}), "arguments": {"value": "rewritten"}} + return await call_next(replace(ctx, params=rewritten)) - middleware = compose_tool_call_interceptor([_ReplacingExt()]) - ctx = ServerRequestContext( - session=cast("Any", None), - lifespan_context={}, - protocol_version="2026-07-28", - method="tasks/get", - params={"taskId": "t-1"}, + server = MCPServer("test", extensions=[_RewritingExt()]) + server.tool(name="echo")(_echo) + + async with Client(server) as client: + result = await client.call_tool("echo", {"value": "original"}) + + assert result == snapshot( + CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + content=[TextContent(text="original")], + structured_content={"result": "original"}, + ) ) - result = await middleware(ctx, call_next) - assert result is sentinel +async def test_short_circuited_interceptor_result_carries_the_server_info_stamp() -> None: + """Spec-mandated (2026-07-28, #3002): an interceptor that answers without running + the tool still produces a stamped result - interception happens at the handler + layer, below the runner's outbound envelope pass.""" + server = MCPServer("test", extensions=[_ReplacingExt()]) + server.tool(name="echo", structured_output=False)(_echo) + + async with Client(server) as client: + result = await client.call_tool("echo", {"value": "hi"}) + + assert result.content == [TextContent(text="intercepted")] + assert result.meta == {SERVER_INFO_META_KEY: {"name": "test", "version": ""}} def test_extension_subclass_without_prefixed_identifier_is_rejected_at_definition() -> None: @@ -345,7 +398,9 @@ async def test_version_pinned_method_is_served_at_an_allowed_version() -> None: request = _VersionPinnedRequest(params=_VersionPinnedParams()) result = await client.session.send_request(request, _VersionPinnedResult) - assert result == snapshot(_VersionPinnedResult(ok=True)) + assert result == snapshot( + _VersionPinnedResult(_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, ok=True) + ) async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version() -> None: @@ -425,7 +480,13 @@ async def test_require_client_extension_passes_when_client_declared_it() -> None async with Client(server, extensions=[advertise(_NEEDS_EXT)]) as client: result = await client.call_tool("guarded", {}) - assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"})) + assert result == snapshot( + CallToolResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, + content=[TextContent(text="ok")], + structured_content={"result": "ok"}, + ) + ) async def test_require_client_extension_raises_minus_32021_when_client_did_not_declare_it() -> None: diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 3103a50f38..98d59e98cb 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -998,7 +998,8 @@ def get_csv(user: str) -> str: result = await client.read_resource("resource://bob/csv") assert result == snapshot( ReadResourceResult( - contents=[TextResourceContents(uri="resource://bob/csv", mime_type="text/csv", text="csv for bob")] + _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}}, + contents=[TextResourceContents(uri="resource://bob/csv", mime_type="text/csv", text="csv for bob")], ) ) @@ -1065,6 +1066,7 @@ def get_data() -> str: result = await client.read_resource("resource://data") assert result == snapshot( ReadResourceResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}}, contents=[ TextResourceContents( uri="resource://data", @@ -1072,7 +1074,7 @@ def get_data() -> str: meta={"version": "1.0", "category": "config"}, # type: ignore[reportUnknownMemberType] text="test data", ) - ] + ], ) ) @@ -1234,11 +1236,12 @@ def resource_no_context(name: str) -> str: result = await client.read_resource("resource://nocontext/test") assert result == snapshot( ReadResourceResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}}, contents=[ TextResourceContents( uri="resource://nocontext/test", mime_type="text/plain", text="Resource test works" ) - ] + ], ) ) @@ -1262,11 +1265,12 @@ def resource_custom_ctx(id: str, my_ctx: Context) -> str: result = await client.read_resource("resource://custom/123") assert result == snapshot( ReadResourceResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}}, contents=[ TextResourceContents( uri="resource://custom/123", mime_type="text/plain", text="Resource 123 with context" ) - ] + ], ) ) @@ -1394,6 +1398,7 @@ def fn(name: str, optional: str = "default") -> str: ... # pragma: no branch result = await client.list_prompts() assert result == snapshot( ListPromptsResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}}, prompts=[ Prompt( name="fn", @@ -1403,7 +1408,7 @@ def fn(name: str, optional: str = "default") -> str: ... # pragma: no branch PromptArgument(name="optional", required=False), ], ) - ] + ], ) ) @@ -1419,6 +1424,7 @@ def fn(name: str) -> str: result = await client.get_prompt("fn", {"name": "World"}) assert result == snapshot( GetPromptResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}}, description="", messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))], ) @@ -1449,6 +1455,7 @@ def fn(name: str) -> str: result = await client.get_prompt("fn", {"name": "World"}) assert result == snapshot( GetPromptResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}}, description="This is the function docstring.", messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))], ) @@ -1471,6 +1478,7 @@ def fn() -> Message: result = await client.get_prompt("fn") assert result == snapshot( GetPromptResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "mcp-server", "version": ""}}, description="", messages=[ PromptMessage( diff --git a/tests/server/mcpserver/test_title.py b/tests/server/mcpserver/test_title.py index 3e36f22579..ff76bdc0af 100644 --- a/tests/server/mcpserver/test_title.py +++ b/tests/server/mcpserver/test_title.py @@ -25,10 +25,12 @@ async def test_server_name_title_description_version(): # Start server and connect client async with Client(mcp) as client: - assert client.server_info.name == "TestServer" - assert client.server_info.title == "Test Server Title" - assert client.server_info.description == "This is a test server description." - assert client.server_info.version == "1.0" + server_info = client.server_info + assert server_info is not None + assert server_info.name == "TestServer" + assert server_info.title == "Test Server Title" + assert server_info.description == "This is a test server description." + assert server_info.version == "1.0" @pytest.mark.anyio diff --git a/tests/server/test_apps.py b/tests/server/test_apps.py index 262bdfe7a1..8d8bf74b74 100644 --- a/tests/server/test_apps.py +++ b/tests/server/test_apps.py @@ -62,13 +62,14 @@ async def test_add_html_resource_serves_ui_resource_at_app_mime_type() -> None: result = await client.read_resource("ui://clock/app.html") assert result == snapshot( ReadResourceResult( + _meta={"io.modelcontextprotocol/serverInfo": {"name": "clock", "version": ""}}, contents=[ TextResourceContents( uri="ui://clock/app.html", mime_type="text/html;profile=mcp-app", text="Clock", ) - ] + ], ) ) assert isinstance(result.contents[0], TextResourceContents) diff --git a/tests/server/test_caching.py b/tests/server/test_caching.py index 0a6adc2aa1..9095f6172f 100644 --- a/tests/server/test_caching.py +++ b/tests/server/test_caching.py @@ -133,19 +133,26 @@ async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestPar assert "cache_scope" not in result.model_fields_set -async def test_an_input_required_shaped_dict_is_never_stamped() -> None: +async def test_an_input_required_shaped_dict_never_gets_cache_hints() -> None: """Spec carve-out: interim `input_required` results carry no cache hints, even on a hinted method.""" async def read_resource(ctx: ServerRequestContext[Any], params: ReadResourceRequestParams) -> dict[str, Any]: return {"resultType": "input_required", "requestState": "s1"} - server = Server("srv", cache_hints={"resources/read": CacheHint(ttl_ms=60_000, scope="public")}) + server = Server( + "srv", + cache_hints={"resources/read": CacheHint(ttl_ms=60_000, scope="public")}, + ) server.add_request_handler("resources/read", ReadResourceRequestParams, read_resource) async with Client(server) as client: result = await client.session.read_resource("res://x", allow_input_required=True) assert isinstance(result, InputRequiredResult) assert result.model_dump(by_alias=True, exclude_none=True) == snapshot( - {"resultType": "input_required", "requestState": "s1"} + { + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "srv", "version": ""}}, + "resultType": "input_required", + "requestState": "s1", + } ) diff --git a/tests/server/test_connection.py b/tests/server/test_connection.py index d448905a96..31caf87bf2 100644 --- a/tests/server/test_connection.py +++ b/tests/server/test_connection.py @@ -22,6 +22,7 @@ ElicitationCapability, EmptyResult, Implementation, + InitializeRequestParams, ListRootsRequest, ListRootsResult, PingRequest, @@ -321,8 +322,8 @@ async def test_connection_send_tool_list_changed_with_meta_includes_meta_only_pa # --- check_capability ---------------------------------------------------------- -def test_connection_check_capability_false_when_no_client_params_recorded(): - """SDK-defined: `check_capability` returns False when no `client_params` +def test_connection_check_capability_false_when_no_capabilities_recorded(): + """SDK-defined: `check_capability` returns False when no capabilities were recorded, regardless of which factory built the connection.""" conn = Connection.for_loop(StubOutbound()) assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is False @@ -330,6 +331,33 @@ def test_connection_check_capability_false_when_no_client_params_recorded(): assert Connection.from_envelope(LATEST_MODERN_VERSION, None, None).check_capability(ClientCapabilities()) is False +def test_from_envelope_records_capabilities_without_client_info(): + """Spec-mandated (spec PR #3002): the envelope requires capabilities but not + client info, so a pair-only request still gets working capability checks - + `client_capabilities` is recorded on its own while `client_params` stays + `None`.""" + caps = ClientCapabilities(sampling=SamplingCapability()) + conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, caps) + assert conn.client_params is None + assert conn.client_capabilities == caps + assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is True + + +def test_client_params_assignment_keeps_capabilities_in_lockstep(): + """SDK-defined: recording `client_params` (the loop path's handshake + commit) is the sync point that also records `client_capabilities`, so the + two facts cannot drift.""" + conn = Connection.for_loop(StubOutbound()) + assert conn.client_capabilities is None + conn.client_params = InitializeRequestParams( + protocol_version=LATEST_HANDSHAKE_VERSION, + capabilities=ClientCapabilities(roots=RootsCapability()), + client_info=Implementation(name="c", version="0"), + ) + assert conn.client_capabilities == ClientCapabilities(roots=RootsCapability()) + assert conn.check_capability(ClientCapabilities(roots=RootsCapability())) is True + + @pytest.mark.parametrize( ("have", "want", "expected"), [ diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index 29d3f07fa6..359a05a007 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -25,9 +25,13 @@ LATEST_PROTOCOL_VERSION, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, UNSUPPORTED_PROTOCOL_VERSION, + CallToolRequestParams, ClientCapabilities, + EmptyResult, ErrorData, + Icon, Implementation, InitializeRequestParams, JSONRPCRequest, @@ -953,7 +957,12 @@ async def echo(ctx: Ctx, params: RequestParams) -> dict[str, Any]: born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) async with connected_runner(server, initialized=False, connection=born_ready) as (client, _): result = await client.send_raw_request("myorg/echo", None) - assert result == {"echoed": True} + # Custom-method results served at a modern version carry the serverInfo + # `_meta` stamp like any other result (spec 2026-07-28, #3002). + assert result == { + "echoed": True, + "_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}}, + } @pytest.mark.anyio @@ -987,6 +996,150 @@ async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]: assert result == {"anything": "goes"} +@pytest.mark.anyio +async def test_modern_short_circuit_middleware_owns_its_result_envelope(server: SrvT): + """SDK-defined: a middleware that answers without calling `call_next` is + trusted to return its own well-formed result, response envelope included - + the outbound pipeline (and its serverInfo stamp) never patches it up.""" + + async def short_circuit(ctx: Ctx, call_next: Any) -> Any: + return {"ok": True} + + server.middleware.append(short_circuit) + born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) + async with connected_runner(server, initialized=False, connection=born_ready) as (client, _): + result = await client.send_raw_request("myorg/anything", None) + assert result == {"ok": True} + + +@pytest.mark.anyio +async def test_a_handler_authored_server_info_stamp_is_not_overwritten(server: SrvT): + """SDK-defined: a handler that stamps its own serverInfo `_meta` value owns + it; the runner fills the key only when it is absent (spec 2026-07-28, #3002).""" + + async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]: + return {"ok": True, "_meta": {SERVER_INFO_META_KEY: {"name": "authored", "version": "9"}}} + + server.add_request_handler("myorg/authored", RequestParams, custom) + born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) + async with connected_runner(server, initialized=False, connection=born_ready) as (client, _): + result = await client.send_raw_request("myorg/authored", None) + assert result["_meta"][SERVER_INFO_META_KEY] == {"name": "authored", "version": "9"} + + +@pytest.mark.anyio +async def test_a_non_mapping_custom_result_meta_is_left_alone(server: SrvT): + """SDK-defined: a custom-method handler that returns a non-mapping `_meta` + owns that shape; the stamp neither clobbers it nor fails the request.""" + + async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]: + return {"_meta": "not-a-mapping"} + + server.add_request_handler("myorg/odd-meta", RequestParams, custom) + born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) + async with connected_runner(server, initialized=False, connection=born_ready) as (client, _): + result = await client.send_raw_request("myorg/odd-meta", None) + assert result == {"_meta": "not-a-mapping"} + + +@pytest.mark.anyio +async def test_stamping_never_mutates_a_handler_retained_result_dict(server: SrvT): + """SDK-defined: the outbound pass dumps the handler's dict to a copy + (`_dump_result`) and the stamp writes into that copy, so a dict the handler + retains (module-level, cached, shared) is never mutated underneath it.""" + + retained: dict[str, Any] = {"ok": True} + + async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]: + return retained + + server.add_request_handler("myorg/cached", RequestParams, custom) + born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) + async with connected_runner(server, initialized=False, connection=born_ready) as (client, _): + result = await client.send_raw_request("myorg/cached", None) + assert result["_meta"][SERVER_INFO_META_KEY] == {"name": "test-server", "version": "0.0.1"} + assert retained == {"ok": True} + + +@pytest.mark.anyio +async def test_mutating_a_stamped_response_never_corrupts_later_stamps(): + """SDK-defined: every response gets a fresh stamp dict, nested values + included - a caller mutating one stamped response (a middleware, or an + application holding an in-memory result) cannot corrupt the identity + stamped into later responses.""" + + async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]: + return {} + + server: SrvT = Server(name="test-server", version="0.0.1", icons=[Icon(src="https://example.com/icon.png")]) + server.add_request_handler("myorg/empty", RequestParams, custom) + born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) + async with connected_runner(server, initialized=False, connection=born_ready) as (client, _): + first = await client.send_raw_request("myorg/empty", None) + first["_meta"][SERVER_INFO_META_KEY]["icons"][0]["src"] = "https://evil.example/pwned.png" + second = await client.send_raw_request("myorg/empty", None) + assert second["_meta"][SERVER_INFO_META_KEY] == { + "name": "test-server", + "version": "0.0.1", + "icons": [{"src": "https://example.com/icon.png"}], + } + + +@pytest.mark.anyio +async def test_a_claimed_result_type_on_a_legacy_session_is_sieved_not_leaked(server: SrvT): + """SDK-defined: claimed extension shapes are 2026-era vocabulary, so on a + handshake-era session the per-version sieve still applies - a claimed + shape (not a valid legacy result) surfaces as INTERNAL_ERROR instead of + leaking a shape the client cannot resolve.""" + + async def custom(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]: + return {"resultType": "voucher", "voucherCode": "v-42"} + + server.add_request_handler("tools/call", CallToolRequestParams, custom) + async with connected_runner(server) as (client, _): + with pytest.raises(MCPError) as exc: + await client.send_raw_request("tools/call", {"name": "issue"}) + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Handler returned an invalid result" + + +@pytest.mark.anyio +async def test_a_claimed_extension_result_type_bypasses_the_sieve_and_is_stamped(server: SrvT): + """SDK-defined: a spec-method result carrying an extension `resultType` is a + claimed shape the extension owns - the per-version sieve would strip its + vendor fields, so it applies to core-vocabulary results only. The identity + stamp still lands: claimed shapes are results like any other.""" + + async def custom(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]: + return {"resultType": "voucher", "voucherCode": "v-42"} + + server.add_request_handler("tools/call", CallToolRequestParams, custom) + born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) + async with connected_runner(server, initialized=False, connection=born_ready) as (client, _): + result = await client.send_raw_request("tools/call", _modern_params(name="issue")) + assert result == { + "resultType": "voucher", + "voucherCode": "v-42", + "_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}}, + } + + +@pytest.mark.anyio +async def test_an_empty_result_on_the_modern_path_carries_only_the_stamp(server: SrvT): + """Spec-mandated (2026-07-28, #3002): serverInfo is stamped into every + result, including empty ones — a result that dumps as `{}` goes on the + modern wire as just the identity `_meta`.""" + + async def custom(ctx: Ctx, params: RequestParams) -> EmptyResult: + return EmptyResult() + + server.add_request_handler("myorg/empty", RequestParams, custom) + born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) + async with connected_runner(server, initialized=False, connection=born_ready) as (client, _): + result = await client.send_raw_request("myorg/empty", None) + assert result == {"_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}}} + + @pytest.mark.anyio async def test_runner_initialize_result_reflects_init_options(): async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult: @@ -1447,6 +1600,44 @@ async def test_dual_era_loop_malformed_envelope_content_never_locks(server: SrvT assert exc_info.value.error.code != UNSUPPORTED_PROTOCOL_VERSION +@pytest.mark.anyio +async def test_dual_era_loop_pair_only_envelope_serves_modern_and_locks(server: SrvT): + """Spec-mandated (spec PR #3002): the required envelope pair (protocol version + + client capabilities) without the optional clientInfo is a complete + modern request - it is served, records the declared capabilities without + client params, locks the era modern, and a later legacy `initialize` is + rejected with -32022.""" + params = _modern_params() + del params["_meta"][CLIENT_INFO_META_KEY] + async with dual_era_client(server) as (client, _): + result = await client.send_raw_request("tools/list", params) + assert result["tools"][0]["name"] == "t" + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("initialize", _initialize_params()) + assert exc_info.value.error.code == UNSUPPORTED_PROTOCOL_VERSION + ctx = _seen_ctx[-1] + assert ctx.session.client_params is None + assert ctx.session.client_capabilities == ClientCapabilities() + + +@pytest.mark.anyio +async def test_dual_era_loop_version_without_capabilities_rejects_naming_the_key_and_never_locks(server: SrvT): + """A `_meta` declaring the protocol version but missing the required + client-capabilities key routes modern - never the legacy path with its + generic 'Invalid request parameters' - and is rejected INVALID_PARAMS + naming the missing key; like every failed classification it locks no era, + so the legacy handshake stays available.""" + params = _modern_params() + del params["_meta"][CLIENT_CAPABILITIES_META_KEY] + async with dual_era_client(server) as (client, _): + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("tools/list", params) + init = await client.send_raw_request("initialize", _initialize_params()) + assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION + assert exc_info.value.error.code == INVALID_PARAMS + assert CLIENT_CAPABILITIES_META_KEY in exc_info.value.error.message + + @pytest.mark.anyio async def test_dual_era_loop_failed_modern_request_never_locks(server: SrvT): """A well-formed modern request for an unknown method fails without @@ -1688,17 +1879,23 @@ async def greet(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]: params["_meta"][CLIENT_INFO_META_KEY] = "not-an-object" async with dual_era_client(greeter) as (client, _): result = await client.send_raw_request("custom/greet", params) - assert result == {"ok": True} + assert result == {"ok": True, "_meta": {SERVER_INFO_META_KEY: {"name": "greeter-server", "version": "0.0.1"}}} assert seen == [None] -def test_has_modern_envelope_requires_the_full_key_triple(): +def test_has_modern_envelope_keys_on_the_protocol_version_key(): + """Era evidence is the reserved protocol-version `_meta` key: legacy traffic + never mints it (bare `_meta` / `progressToken` is not evidence), and a + half-built envelope still routes modern so the classifier - not the legacy + path - names its missing required key.""" assert not _has_modern_envelope(None) assert not _has_modern_envelope({}) assert not _has_modern_envelope({"_meta": None}) assert not _has_modern_envelope({"_meta": {"progressToken": 1}}) - partial_meta = {k: v for k, v in _modern_envelope().items() if k != CLIENT_CAPABILITIES_META_KEY} - assert not _has_modern_envelope({"_meta": partial_meta}) + version_only = {PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION} + assert _has_modern_envelope({"_meta": version_only}) + pair_only = {k: v for k, v in _modern_envelope().items() if k != CLIENT_INFO_META_KEY} + assert _has_modern_envelope({"_meta": pair_only}) assert _has_modern_envelope(_modern_params()) diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 218e34d5ac..24d9dcf75c 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -11,6 +11,7 @@ CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, JSONRPCMessage, JSONRPCRequest, JSONRPCResponse, @@ -264,11 +265,14 @@ def test_mcpserver_run_stdio_serves_a_modern_connection(monkeypatch: pytest.Monk discover = JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={"_meta": envelope}) tools = JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={"_meta": envelope}) - responses = _serve_stdio_and_collect(monkeypatch, MCPServer(name="ModernStdioServer"), [discover, tools], 2) + server = MCPServer(name="ModernStdioServer", version="1.2.3") + responses = _serve_stdio_and_collect(monkeypatch, server, [discover, tools], 2) assert isinstance(responses[0], JSONRPCResponse) and responses[0].id == 1 assert "2026-07-28" in responses[0].result["supportedVersions"] - assert responses[0].result["serverInfo"]["name"] == "ModernStdioServer" + # Server identity travels as the result `_meta` stamp, not a DiscoverResult + # body field (spec 2026-07-28, #3002). + assert responses[0].result["_meta"][SERVER_INFO_META_KEY] == {"name": "ModernStdioServer", "version": "1.2.3"} assert isinstance(responses[1], JSONRPCResponse) and responses[1].id == 2 # `resultType` is the modern-only wire field: its presence proves the # request was served at the discovered version, not the handshake era. diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 45a1e7e1c4..845c3f6436 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -24,8 +24,10 @@ METHOD_NOT_FOUND, PARSE_ERROR, PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, CallToolRequestParams, CallToolResult, + ClientCapabilities, ErrorData, JSONRPCError, JSONRPCResponse, @@ -158,6 +160,40 @@ def _list_tools_body() -> dict[str, Any]: return {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": meta}} +async def test_handle_modern_request_serves_pair_only_envelope_without_client_info() -> None: + """Spec-mandated (spec PR #3002): `clientInfo` is optional - a request whose + `_meta` carries only the protocol-version + client-capabilities pair is + served, with the declared capabilities recorded and `client_params` None.""" + seen: list[tuple[object, object]] = [] + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + seen.append((ctx.session.client_params, ctx.session.client_capabilities)) + return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public") + + server: Server[Any] = Server("test", on_list_tools=list_tools) + body = _list_tools_body() + del body["params"]["_meta"][CLIENT_INFO_META_KEY] + async with _asgi_client(server) as http: + response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "tools/list"}) + assert response.status_code == 200 + assert response.json()["result"]["tools"] == [] + assert seen == [(None, ClientCapabilities())] + + +async def test_handle_modern_request_missing_capabilities_rejects_naming_the_key() -> None: + """Spec-mandated (basic/index.mdx): the protocol version without the + required client-capabilities key is malformed - INVALID_PARAMS (HTTP 400) + with a message naming the missing key.""" + body = _list_tools_body() + del body["params"]["_meta"][CLIENT_CAPABILITIES_META_KEY] + async with _asgi_client(Server("test")) as http: + response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "tools/list"}) + assert response.status_code == 400 + error = response.json()["error"] + assert error["code"] == INVALID_PARAMS + assert CLIENT_CAPABILITIES_META_KEY in error["message"] + + async def test_handle_modern_request_routes_with_mis_shaped_envelope_client_info() -> None: """SDK-defined: a mis-shaped ``clientInfo`` envelope value is treated as not supplied — the request still routes (200 + result) and the handler observes ``client_params is None`` @@ -178,7 +214,8 @@ async def greet(ctx: ServerRequestContext, params: PaginatedRequestParams) -> di async with _asgi_client(server) as http: response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "custom/greet"}) assert response.status_code == 200 - assert response.json()["result"] == {"ok": True} + result = response.json()["result"] + assert result == {"_meta": {SERVER_INFO_META_KEY: {"name": "test", "version": ""}}, "ok": True} assert seen == [None] @@ -709,6 +746,20 @@ async def test_modern_tools_call_accepts_matching_mcp_param_header() -> None: assert response.json()["result"]["content"] == [] +async def test_modern_tools_call_validates_mcp_param_headers_for_a_pair_only_envelope() -> None: + """The schema-resolving `tools/list` walk builds its synthetic envelope from the caller's: + a pair-only caller (spec PR #3002, no clientInfo) omits the optional key rather than sending + null, so header validation still runs and a mismatched header is still rejected.""" + body = _tool_call_body({"region": "east"}) + del body["params"]["_meta"][CLIENT_INFO_META_KEY] + async with _asgi_client(_x_mcp_server()) as http: + matched = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "east"}) + mismatched = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "west"}) + assert matched.status_code == 200 + assert mismatched.status_code == 400 + assert mismatched.json()["error"]["code"] == HEADER_MISMATCH + + @pytest.mark.parametrize("json_response", [True, False]) async def test_modern_tools_call_rejects_mcp_param_mismatch_with_400_and_header_mismatch( json_response: bool, @@ -1063,7 +1114,7 @@ async def test_json_response_mode_still_streams_subscriptions_listen() -> None: the SSE path, acks first, and ends with the stamped result on close().""" bus = _OpenSignalBus() handler = ListenHandler(bus) - server = Server("test", on_subscriptions_listen=handler) + server = Server("test", version="1.2.3", on_subscriptions_listen=handler) body = _listen_body() responses: list[httpx2.Response] = [] @@ -1086,4 +1137,9 @@ async def post() -> None: events = _sse_payloads(response.text) assert events[0]["method"] == "notifications/subscriptions/acknowledged" assert events[1]["id"] == 9 - assert events[1]["result"]["_meta"] == {"io.modelcontextprotocol/subscriptionId": 9} + # The terminal listen result is a modern-era result like any other, so it + # carries the serverInfo stamp alongside the subscription id. + assert events[1]["result"]["_meta"] == { + "io.modelcontextprotocol/subscriptionId": 9, + SERVER_INFO_META_KEY: {"name": "test", "version": "1.2.3"}, + } diff --git a/tests/shared/test_inbound.py b/tests/shared/test_inbound.py index 2bf9c36411..7712e73e5e 100644 --- a/tests/shared/test_inbound.py +++ b/tests/shared/test_inbound.py @@ -98,19 +98,55 @@ def assert_rejected(result: object, code: int) -> InboundLadderRejection: @pytest.mark.parametrize( - "body", + ("body", "named"), [ - pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, id="no-params"), - pytest.param({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, id="no-meta"), - pytest.param(envelope(drop=frozenset({PROTOCOL_VERSION_META_KEY})), id="meta-missing-version"), - pytest.param(envelope(drop=frozenset({CLIENT_INFO_META_KEY})), id="meta-missing-client-info"), - pytest.param(envelope(drop=frozenset({CLIENT_CAPABILITIES_META_KEY})), id="meta-missing-client-caps"), + pytest.param( + {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + [PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY], + id="no-params", + ), + pytest.param( + {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, + [PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY], + id="no-meta", + ), + pytest.param( + envelope(drop=frozenset({PROTOCOL_VERSION_META_KEY})), + [PROTOCOL_VERSION_META_KEY], + id="meta-missing-version", + ), + pytest.param( + envelope(drop=frozenset({CLIENT_CAPABILITIES_META_KEY})), + [CLIENT_CAPABILITIES_META_KEY], + id="meta-missing-client-caps", + ), + pytest.param( + envelope(drop=frozenset({PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY})), + [PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY], + id="meta-missing-both", + ), ], ) -def test_envelope_rung_rejects_missing_keys(body: dict[str, Any]) -> None: - """Spec-mandated: a modern request lacking any of the three reserved `_meta` keys is rejected INVALID_PARAMS.""" +def test_envelope_rung_rejects_missing_required_keys(body: dict[str, Any], named: list[str]) -> None: + """Spec-mandated (basic/index.mdx per-request protocol fields): a modern + request lacking a required `_meta` envelope key (protocol version or + client capabilities) is rejected INVALID_PARAMS with a message naming the + missing key(s).""" rejection = assert_rejected(classify_inbound_request(body), INVALID_PARAMS) assert rejection.data is None + for key in named: + assert key in rejection.message + + +def test_envelope_rung_accepts_pair_only_envelope_without_client_info() -> None: + """Spec-mandated (spec PR #3002): `clientInfo` is optional - a request whose + `_meta` carries only the protocol-version + client-capabilities pair + routes, with `client_info` read as `None`.""" + result = classify_inbound_request(envelope(drop=frozenset({CLIENT_INFO_META_KEY}))) + assert isinstance(result, InboundModernRoute) + assert result.protocol_version == LATEST_MODERN_VERSION + assert result.client_info is None + assert result.client_capabilities == CLIENT_CAPS @pytest.mark.parametrize( diff --git a/tests/test_examples.py b/tests/test_examples.py index 0104d5398b..5b2997fdf1 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -9,19 +9,31 @@ import pytest from inline_snapshot import snapshot -from mcp_types import CallToolResult, TextContent, TextResourceContents +from mcp_types import SERVER_INFO_META_KEY, CallToolResult, TextContent, TextResourceContents from pytest_examples import CodeExample, EvalExample, find_examples from mcp import Client +def strip_server_info(result: CallToolResult, server_name: str) -> CallToolResult: + """Assert the 2026-era serverInfo stamp, then drop it from the result's meta. + + The example servers set no explicit version, so the stamp's version is + empty; the snapshots stay about the behavior under test, not identity. + """ + assert result.meta is not None + assert result.meta[SERVER_INFO_META_KEY] == {"name": server_name, "version": ""} + remaining = {k: v for k, v in result.meta.items() if k != SERVER_INFO_META_KEY} + return result.model_copy(update={"meta": remaining or None}) + + @pytest.mark.anyio async def test_simple_echo(): """Test the simple echo server""" from examples.mcpserver.simple_echo import mcp async with Client(mcp) as client: - result = await client.call_tool("echo", {"text": "hello"}) + result = strip_server_info(await client.call_tool("echo", {"text": "hello"}), "Echo Server") assert result == snapshot( CallToolResult(content=[TextContent(text="hello")], structured_content={"result": "hello"}) ) @@ -35,6 +47,7 @@ async def test_complex_inputs(): async with Client(mcp) as client: tank = {"shrimp": [{"name": "bob"}, {"name": "alice"}]} result = await client.call_tool("name_shrimp", {"tank": tank, "extra_names": ["charlie"]}) + result = strip_server_info(result, "Shrimp Tank") assert result == snapshot( CallToolResult( content=[ @@ -53,7 +66,8 @@ async def test_direct_call_tool_result_return(): from examples.mcpserver.direct_call_tool_result_return import mcp async with Client(mcp) as client: - result = await client.call_tool("echo", {"text": "hello"}) + # The serverInfo stamp merges alongside the handler-authored meta. + result = strip_server_info(await client.call_tool("echo", {"text": "hello"}), "Echo Server") assert result == snapshot( CallToolResult( meta={"some": "metadata"}, # type: ignore[reportUnknownMemberType] @@ -80,7 +94,7 @@ async def test_desktop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): async with Client(mcp) as client: # Test the sum function - result = await client.call_tool("sum", {"a": 1, "b": 2}) + result = strip_server_info(await client.call_tool("sum", {"a": 1, "b": 2}), "Demo") assert result == snapshot(CallToolResult(content=[TextContent(text="3")], structured_content={"result": 3})) # Test the desktop resource diff --git a/tests/test_types.py b/tests/test_types.py index 3083774200..ad481d2c0a 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -394,7 +394,6 @@ def test_concrete_wire_results_always_dump_result_type_complete(): DiscoverResult( supported_versions=["2026-07-28"], capabilities=ServerCapabilities(), - server_info=Implementation(name="server", version="1.0"), ), ] for result in carriers: @@ -414,7 +413,6 @@ def test_cacheable_results_default_to_immediately_stale_private(): DiscoverResult( supported_versions=["2026-07-28"], capabilities=ServerCapabilities(), - server_info=Implementation(name="server", version="1.0"), ), ] for result in cacheable: diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index 126e06c291..959743e596 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -302,12 +302,14 @@ "2026-07-28": "mcp_types.v2026_07_28", } -# The three reserved `params._meta` entries the 2026 surface requires on every request. +# The reserved `params._meta` entries the 2026 surface accepts on every request. +# `clientInfo` is optional (SHOULD-include, spec PR #3002); the other two are required. META_TRIPLE: dict[str, Any] = { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": {"name": "client", "version": "1.0"}, "io.modelcontextprotocol/clientCapabilities": {}, } +META_REQUIRED_KEYS = ("io.modelcontextprotocol/protocolVersion", "io.modelcontextprotocol/clientCapabilities") # One minimal valid params mapping per surface request class. REQUEST_PARAMS_FIXTURES: dict[type[BaseModel], dict[str, Any] | None] = { @@ -397,7 +399,6 @@ v2026.DiscoverResult: { "supportedVersions": ["2026-07-28"], "capabilities": {}, - "serverInfo": {"name": "server", "version": "1.0"}, "resultType": "complete", "ttlMs": 0, "cacheScope": "private", @@ -651,8 +652,8 @@ def test_unknown_version_strings_raise_value_error_on_every_parse_function(): assert "2099-01-01" in str(excinfo.value) -def test_2026_07_28_requests_missing_a_reserved_meta_entry_reject_as_missing(): - for absent_key in META_TRIPLE: +def test_2026_07_28_requests_missing_a_required_meta_entry_reject_as_missing(): + for absent_key in META_REQUIRED_KEYS: partial_meta = {key: value for key, value in META_TRIPLE.items() if key != absent_key} with pytest.raises(pydantic.ValidationError) as excinfo: methods.parse_client_request("tools/list", "2026-07-28", {"_meta": partial_meta}) @@ -661,6 +662,14 @@ def test_2026_07_28_requests_missing_a_reserved_meta_entry_reject_as_missing(): ] +def test_2026_07_28_requests_accept_meta_without_the_optional_client_info(): + """spec PR #3002: `clientInfo` is optional on the 2026 surface - the required + pair alone validates.""" + pair_meta = {key: value for key, value in META_TRIPLE.items() if key != "io.modelcontextprotocol/clientInfo"} + parsed = methods.parse_client_request("tools/list", "2026-07-28", {"_meta": pair_meta}) + assert isinstance(parsed, types.ListToolsRequest) + + def test_2026_07_28_results_require_result_type(): with pytest.raises(pydantic.ValidationError): methods.parse_server_result("tools/call", "2026-07-28", {"content": []}) @@ -861,7 +870,6 @@ def test_validate_functions_accept_reject_and_gate_like_their_parse_siblings(): "server/discover": types.DiscoverResult( supported_versions=["2026-07-28"], capabilities=types.ServerCapabilities(), - server_info=types.Implementation(name="server", version="1.0"), ttl_ms=0, cache_scope="private", ), @@ -927,6 +935,24 @@ def test_serialize_server_result_preserves_open_type_extras(): assert sieved["tools"][0]["_meta"] == nested_meta +def test_serialize_server_result_drops_top_level_server_info_on_discover_but_keeps_the_meta_stamp(): + """Server identity moved from the discover body to result `_meta` (spec PR #3002): + the sieve drops the removed body key and preserves the `_meta` stamp.""" + stamp = {"name": "server", "version": "1.0"} + dumped: dict[str, Any] = { + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "serverInfo": stamp, + "_meta": {types.SERVER_INFO_META_KEY: stamp}, + "resultType": "complete", + "ttlMs": 0, + "cacheScope": "private", + } + sieved = methods.serialize_server_result("server/discover", "2026-07-28", dumped) + assert "serverInfo" not in sieved + assert sieved["_meta"] == {types.SERVER_INFO_META_KEY: stamp} + + def test_serialize_server_result_drops_an_unknown_nested_tool_field(): tool = {"name": "echo", "inputSchema": {"type": "object"}, "unknownField": 1} sieved = methods.serialize_server_result("tools/list", "2025-11-25", {"tools": [tool], "resultType": "complete"}) diff --git a/tests/types/test_parity.py b/tests/types/test_parity.py index 080f343c3d..8c75205636 100644 --- a/tests/types/test_parity.py +++ b/tests/types/test_parity.py @@ -121,6 +121,7 @@ "v2026_07_28.RequestMetaObject", "v2026_07_28.RequestedSchema", "v2026_07_28.ResourceRequestParams", + "v2026_07_28.ResultMetaObject", "v2026_07_28.StringSchema", "v2026_07_28.SubscriptionsListenResultMeta", "v2026_07_28.TitledMultiSelectEnumSchema",