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,60 @@
|
||||
# parallel-calls
|
||||
|
||||
Two `Client`s connected to the same server, each with a `call_tool` in flight
|
||||
at once. The `meet` tool is a rendezvous: a handler signals its own arrival,
|
||||
then blocks until every named peer has arrived too — so neither call can return
|
||||
unless the server runs both handlers concurrently. Each caller's
|
||||
`progress_callback=` sees only the notifications for *its* request — each
|
||||
`Client` is a separate connection, so there's no shared wire for them to cross
|
||||
on.
|
||||
|
||||
## Run it
|
||||
|
||||
The tested legs run in-memory (`Client(server)`); the identical `main` body
|
||||
works unchanged over HTTP — both clients just reach the same server. Under
|
||||
`--http` the client self-hosts that server on a free port, runs, then tears it
|
||||
down:
|
||||
|
||||
```bash
|
||||
# --legacy because handler-emitted progress is dropped on the modern
|
||||
# streamable-HTTP path today (see Caveats).
|
||||
uv run python -m stories.parallel_calls.client --http --legacy
|
||||
# same, against the lowlevel-API server variant
|
||||
uv run python -m stories.parallel_calls.client --http --legacy --server server_lowlevel
|
||||
```
|
||||
|
||||
There is no stdio run for this story: the stdio default spawns a fresh server
|
||||
subprocess per connection, so two clients there could never rendezvous.
|
||||
|
||||
## What to look at
|
||||
|
||||
- **`client.py` — the two visible `Client(targets(), mode=...)` blocks.** Each
|
||||
connection is constructed inside `attend(...)`; `targets()` yields a fresh
|
||||
target on every call and both land on the same server instance. The two
|
||||
blocks run in one `anyio` task group.
|
||||
- **`server.py` — the `arrivals` barrier.** Each handler sets its own
|
||||
`anyio.Event` then waits for every peer's. A server that processed requests
|
||||
sequentially would never set the second event, so the client would time out —
|
||||
the timeout *is* the concurrency assertion. No sleeps.
|
||||
- **`client.py` — `progress_callback=` per call.** Each call passes its own
|
||||
callback; `received == {"a": ["a"], "b": ["b"]}` shows each connection
|
||||
delivered its own progress, and — combined with the rendezvous — that both
|
||||
calls were genuinely in flight at once.
|
||||
- **`server_lowlevel.py`** — same wire contract on the lowlevel `Server`,
|
||||
reporting via `ctx.session.report_progress(...)`.
|
||||
|
||||
## Caveats
|
||||
|
||||
- Over Streamable HTTP in the modern (2026-07-28) era, handler-emitted progress
|
||||
is currently dropped (the single-exchange dispatch context no-ops `notify()`).
|
||||
In-memory (both eras) and legacy-era HTTP deliver progress correctly — hence
|
||||
the `--legacy` above.
|
||||
|
||||
## Spec
|
||||
|
||||
[Progress flow](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress)
|
||||
|
||||
## See also
|
||||
|
||||
`streaming/` (progress + cancellation on one call), `reconnect/` (the other
|
||||
multi-connection client), `tools/` (basics).
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Two concurrent `Client`s, so `main` takes `targets`; their rendezvous in one tool proves concurrent dispatch."""
|
||||
|
||||
import anyio
|
||||
from mcp_types import TextContent
|
||||
|
||||
from mcp.client import Client
|
||||
from stories._harness import TargetFactory, run_client
|
||||
|
||||
|
||||
async def main(targets: TargetFactory, *, mode: str = "auto") -> None:
|
||||
party = ["a", "b"]
|
||||
results: dict[str, str] = {}
|
||||
received: dict[str, list[str | None]] = {tag: [] for tag in party}
|
||||
|
||||
async def attend(tag: str) -> None:
|
||||
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
|
||||
received[tag].append(message)
|
||||
|
||||
# targets() yields a fresh connection target on every call; both land on the SAME
|
||||
# server instance, so the two `meet` handlers can observe each other's arrival.
|
||||
async with Client(targets(), mode=mode) as client:
|
||||
result = await client.call_tool("meet", {"tag": tag, "party": party}, progress_callback=on_progress)
|
||||
assert not result.is_error, result
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
results[tag] = result.content[0].text
|
||||
|
||||
# Neither call can return until both handlers are running at once; a server that processed
|
||||
# requests one-at-a-time would never set the second event and we'd time out here.
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(attend, "a")
|
||||
tg.start_soon(attend, "b")
|
||||
|
||||
assert results == {"a": "a", "b": "b"}, results
|
||||
# Progress is routed by progress token: each callback saw only its own tag, never the sibling's.
|
||||
assert received == {"a": ["a"], "b": ["b"]}, received
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_client(main)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""One tool that rendezvouses with named peers, proving the server dispatches calls concurrently."""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
import anyio
|
||||
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from stories._hosting import run_server_from_args
|
||||
|
||||
|
||||
def build_server() -> MCPServer:
|
||||
mcp = MCPServer("parallel-calls-example")
|
||||
# One Event per tag, shared across every call to this server instance. A handler sets its
|
||||
# own tag's event, then waits for every peer's — so no call can return until all named
|
||||
# peers are concurrently in-flight. A sequential dispatcher would deadlock here.
|
||||
arrivals: dict[str, anyio.Event] = defaultdict(anyio.Event)
|
||||
|
||||
@mcp.tool()
|
||||
async def meet(tag: str, party: list[str], ctx: Context) -> str:
|
||||
"""Signal arrival as `tag`, block until every tag in `party` has also arrived, then return."""
|
||||
arrivals[tag].set()
|
||||
for peer in party:
|
||||
await arrivals[peer].wait()
|
||||
await ctx.report_progress(1.0, total=1.0, message=tag)
|
||||
return tag
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server_from_args(build_server)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Rendezvous tool on the lowlevel `Server`, proving concurrent dispatch without `MCPServer`."""
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
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
|
||||
|
||||
MEET_INPUT_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tag": {"type": "string"},
|
||||
"party": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["tag", "party"],
|
||||
}
|
||||
|
||||
|
||||
def build_server() -> Server[Any]:
|
||||
arrivals: dict[str, anyio.Event] = defaultdict(anyio.Event)
|
||||
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(
|
||||
tools=[types.Tool(name="meet", description="Rendezvous with peers.", input_schema=MEET_INPUT_SCHEMA)]
|
||||
)
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
assert params.name == "meet"
|
||||
assert params.arguments is not None
|
||||
tag = params.arguments["tag"]
|
||||
assert isinstance(tag, str)
|
||||
arrivals[tag].set()
|
||||
for peer in params.arguments["party"]:
|
||||
await arrivals[peer].wait()
|
||||
await ctx.session.report_progress(1.0, total=1.0, message=tag)
|
||||
return types.CallToolResult(content=[types.TextContent(text=tag)])
|
||||
|
||||
return Server("parallel-calls-example", 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