This commit is contained in:
parent
3a6f2996cd
commit
837ef904f8
99 changed files with 1757 additions and 567 deletions
|
|
@ -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
|
||||
|
|
|
|||
16
.github/workflows/conformance.yml
vendored
16
.github/workflows/conformance.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)**.
|
||||
|
|
|
|||
|
|
@ -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"])]
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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)**.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ _REQUEST_STATE_KEY = b"everything-server-fixture-request-state-key"
|
|||
|
||||
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."
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ uv run python -m stories.reconnect.client --http --server server_lowlevel
|
|||
## Caveats
|
||||
|
||||
- `mode=<version-pin>` *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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue