Skip to content

Commit 837ef90

Browse files
authored
Align with spec #3002: optional clientInfo, serverInfo in result _meta (#3143)
1 parent 3a6f299 commit 837ef90

99 files changed

Lines changed: 1757 additions & 567 deletions

File tree

Some content is hidden

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

.github/actions/conformance/expected-failures.yml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,24 @@
1010
# scenarios start passing and MUST be removed from this list (the runner fails
1111
# on stale entries), so the baseline burns down per milestone.
1212

13-
client: []
13+
client:
14+
# SEP-1932 (DPoP): the SDK's OAuth client does not implement DPoP proofs.
15+
# These scenarios are new in the da56f663 referee pin. The entries are
16+
# per-check (conformance #406) because both scenarios pass their
17+
# non-DPoP checks (discovery, token acquisition, request flow) live.
18+
- auth/dpop:sep-1932-client-token-request-proof
19+
- auth/dpop:sep-1932-client-dpop-auth-scheme
20+
- auth/dpop:sep-1932-client-fresh-proof
21+
- auth/dpop-nonce:sep-1932-client-token-request-proof
22+
- auth/dpop-nonce:sep-1932-client-dpop-auth-scheme
23+
- auth/dpop-nonce:sep-1932-client-fresh-proof
24+
- auth/dpop-nonce:sep-1932-client-as-nonce
25+
- auth/dpop-nonce:sep-1932-client-rs-nonce
26+
# Workload identity federation: the OAuth client does not implement the
27+
# urn:ietf:params:oauth:grant-type:jwt-bearer grant (it answers with
28+
# authorization_code). Also new in the da56f663 referee pin; per-check for
29+
# the same reason.
30+
- auth/wif-jwt-bearer:wif-grant-type
1431

1532
server:
1633
# SEP-2663 (io.modelcontextprotocol/tasks): the SDK does not implement the

.github/workflows/conformance.yml

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,19 @@ env:
1919
# Bump deliberately and reconcile both
2020
# .github/actions/conformance/expected-failures*.yml files in the same change.
2121
#
22-
# Temporarily pinned to the pkg.pr.new build of conformance main@4944b268
23-
# (0.2.0-alpha.8, which includes #372: fail checks whose prerequisite is
24-
# missing instead of skipping them) — alpha.8 is not published to npm yet.
25-
# Pinned by commit SHA so the tarball cannot move under us;
22+
# Temporarily pinned to the pkg.pr.new build of conformance main@da56f663,
23+
# which includes #403 (sep-2575 checks flipped to the post-spec-#3002 shape:
24+
# clientInfo optional on requests, serverInfo in result _meta instead of the
25+
# discover body) and #406 (per-check expected-failures granularity) — the
26+
# last published npm release (0.2.0-alpha.9) still enforces the pre-#3002
27+
# shape. Pinned by commit SHA so the tarball cannot move under us;
2628
# CONFORMANCE_PKG_SHA256 pins the bytes and the fetch-and-verify step below
2729
# downloads, checks the digest, and repoints CONFORMANCE_PKG at the
2830
# verified local copy. Repin to the next published @modelcontextprotocol/
29-
# conformance release (>=0.2.0-alpha.8) once it ships, then drop
31+
# conformance release (>=0.2.0-alpha.10) once it ships, then drop
3032
# CONFORMANCE_PKG_SHA256 and the fetch-and-verify steps.
31-
CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@4944b268"
32-
CONFORMANCE_PKG_SHA256: "0f70c035782d319d72ab427653c5275db5c50429d59fae0241a645b33aeda1a7"
33+
CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@da56f663"
34+
CONFORMANCE_PKG_SHA256: "bbc94678033071df4bc9851ce2054f9dff918f4501661d1d60e2d09d8c515e6d"
3335

3436
jobs:
3537
server-conformance:

docs/advanced/extensions.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,21 @@ or veto a tool call:
133133
```
134134

135135
* `params` is the validated `CallToolRequestParams`: you get `params.name` and
136-
`params.arguments` without touching raw JSON.
137-
* `call_next(ctx)` runs the rest of the chain. Return its result unchanged (observe),
138-
return something else (replace), or raise an `MCPError` (refuse).
136+
`params.arguments` without touching raw JSON. It is also what decides which
137+
tool call runs: passing a rewritten context through `call_next` changes what
138+
the handler observes on `ctx`, not the tool invocation. Wire-level request
139+
rewriting belongs to [Middleware](middleware.md).
140+
* `call_next(ctx)` runs the rest of the chain and returns the handler's result.
141+
Return it unchanged (observe), return something else (replace), or raise an
142+
`MCPError` (refuse). Whatever you return is serialized like any handler
143+
result, including the 2026-era `serverInfo` identity stamp, so a
144+
short-circuiting interceptor never produces an anonymous or off-schema
145+
response.
139146
* With several extensions, interceptors nest in registration order: the first
140147
extension in `extensions=[...]` is outermost.
141148
* The default implementation is a pass-through, and a server whose extensions never
142-
override this hook installs **no** middleware at all. You don't pay for what
143-
you don't use.
149+
override this hook keeps the bare `tools/call` handler untouched. You don't
150+
pay for what you don't use.
144151

145152
The hook wraps `tools/call` and nothing else. For every-message concerns, use
146153
[Middleware](middleware.md). That is what it is for.

docs/advanced/low-level-server.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,13 @@ Call it and the result carries both representations:
102102
"content": [{"type": "text", "text": "Found 3 books matching 'dune'."}],
103103
"structuredContent": {"matches": 3, "query": "dune"},
104104
"isError": false,
105-
"resultType": "complete"
105+
"resultType": "complete",
106+
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}}
106107
}
107108
```
108109

110+
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.
111+
109112
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)**.
110113

111114
## `_meta`: for the application, not the model

docs/advanced/middleware.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ In increasing order of how much you should hesitate:
6363
`initialize`: the result the client gets back is built from your rewritten params, but the
6464
server commits its connection state from the original wire params. The two sides can finish
6565
the handshake disagreeing about what they negotiated.
66+
* **Answer.** Return a result without calling `call_next(ctx)` and it goes to the client as
67+
your response. `call_next` hands you the finished wire form, and the pipeline never patches
68+
what you return, so the whole envelope is yours: on a 2026-era connection that includes the
69+
`serverInfo` `_meta` stamp, which the SDK adds to handler results but not to yours.
6670

6771
!!! check
6872
`initialize` is one of the things middleware wraps, and it is the *only* hook you get

docs/client/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Everything else on this page is identical across all three. Headers, subprocesse
3030

3131
Four read-only properties, populated the moment you enter the block:
3232

33-
* `client.server_info`: the server's identity. `server_info.name` here is `"Bookshop"`, `server_info.version` is whatever the server reports.
33+
* `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.
3434
* `client.server_capabilities`: what the server can do (`tools`, `resources`, `prompts`, `completions`, ...). A capability the server doesn't have is `None`.
3535
* `client.protocol_version`: the protocol version the two sides agreed on. Here it is `"2026-07-28"`.
3636
* `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
202202
## Recap
203203

204204
* `Client(x)` connects in-memory to a server object, over Streamable HTTP to a URL string, and over anything else via a transport.
205-
* `async with` is the whole lifecycle. Inside it, `server_info`, `server_capabilities`, `protocol_version` and `instructions` are already populated.
205+
* `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.
206206
* `list_tools()` gives you each tool's `name`, `title`, `description` and `input_schema`.
207207
* `call_tool()` returns `content` for the model, `structured_content` for your code, and `is_error`. A raising tool is a result, not an exception.
208208
* `content` is a union of block types; narrow with `isinstance` before reading.

docs/client/session-groups.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ Run it again. `print(sorted(group.tools))` now shows both:
6464

6565
`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.
6666

67-
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.
67+
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.
6868

6969
## The classic handshake
7070

docs/get-started/testing.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ async def client(): # (2)!
5959
@pytest.mark.anyio
6060
async def test_call_add_tool(client: Client):
6161
result = await client.call_tool("add", {"a": 1, "b": 2})
62+
# Drop the server identity stamp in `_meta`; it is not what this test is about.
63+
result.meta = None
6264
assert result == snapshot(
6365
CallToolResult(
6466
content=[TextContent(type="text", text="3")],

docs/migration.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,14 @@ mcp = MCPServer("Demo", instructions="You answer questions about the weather.")
606606

607607
Keep `name` positional and pass everything else by keyword.
608608

609+
### Unversioned servers report an empty version
610+
611+
In v1, a server constructed without a `version` reported the installed `mcp`
612+
package's version as its own in the `initialize` result's `serverInfo`. In v2
613+
it reports an empty string instead: the SDK's version is not your server's
614+
version. Pass `version="..."` to `Server(...)` or `MCPServer(...)` to identify
615+
your server properly. The field is display-only; nothing breaks either way.
616+
609617
### `mount_path` parameter removed from MCPServer
610618

611619
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
14981506

14991507
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`.
15001508

1501-
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.)
1509+
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.)
15021510

15031511
### `cursor` parameter removed from `ClientSession` list methods
15041512

docs/protocol-versions.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,10 @@ A pin is a promise *you* make: you already know the server speaks that version.
6868
A pin is not a discovery. Print `client.server_info` and the price is right there:
6969

7070
```text
71-
name='' title=None version='' description=None website_url=None icons=None
71+
None
7272
```
7373

74-
The client never asked the server who it is, so `server_info` is a blank. `client.server_capabilities`
74+
The client never asked the server who it is, so `server_info` is `None`. `client.server_capabilities`
7575
is the same story: every capability is `None`. Tool calls still work (the protocol needs none of it);
7676
code that reads `server_capabilities` to decide what to offer does not.
7777

@@ -87,7 +87,7 @@ ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-0
8787

8888
The probe is cheap, but it is still a round trip you pay on every reconnect, and the answer almost never changes.
8989

90-
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:
90+
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:
9191

9292
```python title="client.py" hl_lines="15 17"
9393
--8<-- "docs_src/protocol_versions/tutorial004.py"
@@ -112,7 +112,7 @@ The second connection made **zero** negotiation round trips and still knows exac
112112
| --- | --- | --- |
113113
| `Client(target)` | one `server/discover` probe; the `initialize` handshake if it fails | the newest version both sides speak, whichever era |
114114
| `Client(target, mode="legacy")` | the `initialize` handshake | a handshake-era version; server-initiated requests work |
115-
| `Client(target, mode="2026-07-28")` | none | that version, pinned, with a blank `server_info` |
115+
| `Client(target, mode="2026-07-28")` | none | that version, pinned, with `server_info` as `None` |
116116
| `Client(target, mode="2026-07-28", prior_discover=saved)` | none | that version, pinned, *and* the identity you saved last time |
117117

118118
## Recap
@@ -121,7 +121,7 @@ The second connection made **zero** negotiation round trips and still knows exac
121121
* `mode="auto"` is the default: probe, fall back. Leave it alone unless one of the other three rows describes you.
122122
* `client.protocol_version` is always the answer to "what did I get?".
123123
* `mode="legacy"` forces the handshake. It is what you need for server-initiated requests: sampling, push elicitation, `message_handler`.
124-
* A version pin (`mode="2026-07-28"`) sends no negotiation traffic at all, at the cost of a blank `server_info`.
124+
* A version pin (`mode="2026-07-28"`) sends no negotiation traffic at all, at the cost of `client.server_info` being `None`.
125125
* `prior_discover=` pays that cost back: save `client.session.discover_result`, reconnect with it, get both.
126126

127127
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)**.

0 commit comments

Comments
 (0)