chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
# reconnect
|
||||
|
||||
Probe `server/discover` once, persist the `DiscoverResult`, and reconnect with
|
||||
**zero round-trips**. The first client connects at `mode="auto"` (one
|
||||
`server/discover` request inside `__aenter__`); a second client at
|
||||
`mode=LATEST_MODERN_VERSION, prior_discover=<cached>` enters with no wire
|
||||
traffic and has `server_info` / `server_capabilities` available immediately.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
# over HTTP — Streamable HTTP only; in-memory has no "round-trip" to skip.
|
||||
# The client self-hosts the server on a free port, runs, then tears it down.
|
||||
uv run python -m stories.reconnect.client --http
|
||||
# same, against the lowlevel-API server variant
|
||||
uv run python -m stories.reconnect.client --http --server server_lowlevel
|
||||
```
|
||||
|
||||
## What to look at
|
||||
|
||||
- `client.py` — the first `Client(targets(), mode="auto")`. The `mode="auto"`
|
||||
connect ladder runs `server/discover` inside `__aenter__`;
|
||||
`client.session.discover_result` is the cached result. Round-trip it through
|
||||
`model_dump_json()` / `DiscoverResult.model_validate_json()` to model an
|
||||
on-disk cache.
|
||||
- `client.py` — `Client(targets(), mode=LATEST_MODERN_VERSION,
|
||||
prior_discover=rehydrated)`. A version pin plus a prior `DiscoverResult`
|
||||
installs the cached state via `ClientSession.adopt()` with no `initialize`
|
||||
and no `server/discover` on the wire — the era-neutral `client.server_info` /
|
||||
`.server_capabilities` accessors are populated before the first request.
|
||||
- `client.py` — `targets()`. A `Client` cannot be re-entered after exit; each
|
||||
call yields a fresh target against the same server, so the reconnect is a
|
||||
genuinely new connection.
|
||||
|
||||
## Caveats
|
||||
|
||||
- `mode=<version-pin>` *without* `prior_discover=` synthesizes a placeholder
|
||||
whose `server_info` is `Implementation(name="", version="")`. 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;
|
||||
`Client` does not yet surface the cached result directly.
|
||||
- The wire-level proof that the second entry sends zero requests lives in the
|
||||
interaction suite (`test_prior_discover_populates_state_with_zero_connect_time_traffic`);
|
||||
this story asserts only what's observable through the public `Client`
|
||||
surface.
|
||||
|
||||
## Spec
|
||||
|
||||
- [`server/discover`](https://modelcontextprotocol.io/specification/draft/server/discover)
|
||||
- [Versioning — backward compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning)
|
||||
|
||||
## See also
|
||||
|
||||
`dual_era/` (auto-discover + era-neutral accessors), `parallel_calls/` (the
|
||||
other multi-connection client).
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Probe server/discover once, persist the result, reconnect with zero round-trips — a fresh `Client` via `targets`."""
|
||||
|
||||
from mcp_types import DiscoverResult
|
||||
from mcp_types.version import LATEST_MODERN_VERSION
|
||||
|
||||
from mcp.client import Client
|
||||
from stories._harness import TargetFactory, run_client
|
||||
|
||||
|
||||
async def main(targets: TargetFactory, *, mode: str = "auto") -> None:
|
||||
# The caller's mode (the real-user "auto" default) probes server/discover inside
|
||||
# __aenter__ and caches the result; a hard version pin would skip the probe and
|
||||
# never see the server's real DiscoverResult.
|
||||
async with Client(targets(), mode=mode) as client:
|
||||
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"
|
||||
assert LATEST_MODERN_VERSION in discovered.supported_versions
|
||||
|
||||
result = await client.call_tool("add", {"a": 2, "b": 3})
|
||||
assert result.structured_content == {"result": 5}, result
|
||||
|
||||
# Round-trip through JSON to model loading the result from an on-disk cache.
|
||||
saved = discovered.model_dump_json(by_alias=True)
|
||||
rehydrated = DiscoverResult.model_validate_json(saved)
|
||||
assert rehydrated == discovered
|
||||
|
||||
# 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.
|
||||
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"
|
||||
assert second.server_capabilities.tools is not None
|
||||
assert second.session.discover_result == rehydrated
|
||||
|
||||
result = await second.call_tool("add", {"a": 1, "b": 1})
|
||||
assert result.structured_content == {"result": 2}, result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_client(main)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""A small modern server whose DiscoverResult a client persists for zero-RTT reconnect."""
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from stories._hosting import run_server_from_args
|
||||
|
||||
|
||||
def build_server() -> MCPServer:
|
||||
mcp = MCPServer(
|
||||
"reconnect-example",
|
||||
version="1.0.0",
|
||||
instructions="Call add(a, b) to sum two integers.",
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Add two integers."""
|
||||
return a + b
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server_from_args(build_server)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""A small modern server whose DiscoverResult a client persists for zero-RTT reconnect (lowlevel API)."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import mcp_types as types
|
||||
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel import Server
|
||||
from stories._hosting import run_server_from_args
|
||||
|
||||
ADD = types.Tool(
|
||||
name="add",
|
||||
description="Add two integers.",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
|
||||
"required": ["a", "b"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def build_server() -> Server[Any]:
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(tools=[ADD])
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
assert params.arguments is not None
|
||||
if params.name == "add":
|
||||
total = int(params.arguments["a"]) + int(params.arguments["b"])
|
||||
return types.CallToolResult(
|
||||
content=[types.TextContent(text=str(total))],
|
||||
structured_content={"result": total},
|
||||
)
|
||||
raise NotImplementedError
|
||||
|
||||
return Server(
|
||||
"reconnect-example",
|
||||
version="1.0.0",
|
||||
instructions="Call add(a, b) to sum two integers.",
|
||||
on_list_tools=list_tools,
|
||||
on_call_tool=call_tool,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server_from_args(build_server)
|
||||
Reference in New Issue
Block a user