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,73 @@
|
||||
# streaming
|
||||
|
||||
The three in-flight server→client channels during a tool call: **progress**
|
||||
(`ctx.report_progress` → the caller's `progress_callback=`), **logging**
|
||||
(`notifications/message` → the client's `logging_callback=`), and
|
||||
**cancellation** (abandoning the client's awaiting scope interrupts the server
|
||||
handler). One `countdown(steps)` tool emits a progress notification and a log
|
||||
line per step; the client asserts both streams arrive in order, then cancels a
|
||||
long call mid-flight by cancelling the enclosing `anyio.CancelScope` from
|
||||
inside the progress callback (event-driven, no `sleep`).
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
# stdio (default — the client spawns the server as a subprocess)
|
||||
uv run python -m stories.streaming.client
|
||||
uv run python -m stories.streaming.client --server server_lowlevel
|
||||
|
||||
# HTTP — the client self-hosts the server on a free port, runs, then tears it
|
||||
# down
|
||||
uv run python -m stories.streaming.client --http
|
||||
# same, against the lowlevel-API server variant
|
||||
uv run python -m stories.streaming.client --http --server server_lowlevel
|
||||
```
|
||||
|
||||
## What to look at
|
||||
|
||||
- `client.py` `main` — opens with `async with Client(target, mode=mode,
|
||||
logging_callback=on_log)`. The story owns that construction; the harness only
|
||||
picks the target and era. `logging_callback` is constructor-only on `Client`
|
||||
(no setter after connect), so the callback and the `logs` list it fills are
|
||||
closed over right above the `Client(...)` call.
|
||||
- `server.py` — `ctx.report_progress(i, steps, msg)` is a silent no-op when the
|
||||
caller passed no `progress_callback`; the SDK reads the token from the
|
||||
request's `_meta` for you. The log notification is sent via the raw
|
||||
`session.send_notification(...)` because the `ctx.log()` / `ctx.info()`
|
||||
shorthands are deprecated (SEP-2577) with no non-deprecated replacement yet.
|
||||
`related_request_id=` keeps the log on this request's response stream — over
|
||||
streamable HTTP an unrelated notification would ride the standalone GET
|
||||
stream instead.
|
||||
- `server.py` — `ctx.request_context.session` / `ctx.request_context.request_id`
|
||||
is the interim 2-hop path; a later release will shorten these.
|
||||
- `server.py` — the `except anyio.get_cancelled_exc_class(): raise` block is
|
||||
where a real handler would release resources before re-raising. **Never
|
||||
swallow** the cancellation exception.
|
||||
- `client.py` — cancellation is just cancelling the `anyio` scope around
|
||||
`await client.call_tool(...)`; the SDK sends `notifications/cancelled` for
|
||||
you on stateful transports. There is no `client.cancel(request_id)` API.
|
||||
- `server_lowlevel.py` — the same wire contract built by hand against
|
||||
`ServerRequestContext.session` directly.
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Logging is deprecated** in the 2026-07-28 protocol (SEP-2577); functional
|
||||
through the deprecation window. Migration: write to stderr or emit
|
||||
OpenTelemetry instead of `notifications/message`. It is shown here because
|
||||
servers still need to support 2025-era clients during that window. Progress
|
||||
and cancellation are **not** deprecated. TODO(maxisbey): revisit before beta.
|
||||
- When a request is cancelled the server currently replies with
|
||||
`ErrorData(code=0, message="Request cancelled")`; the spec says it should not
|
||||
reply at all. The client never observes it (its awaiting task is already
|
||||
cancelled), so this story does not assert on the reply.
|
||||
|
||||
## Spec
|
||||
|
||||
[Progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress),
|
||||
[cancellation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation),
|
||||
[logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging)
|
||||
|
||||
## See also
|
||||
|
||||
`parallel_calls/` (concurrent in-flight calls), `error_handling/` (the
|
||||
cancellation error path), `tools/` (the basics this builds on).
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Asserts progress + log notifications arrive in order, then cancels a call mid-flight."""
|
||||
|
||||
import anyio
|
||||
from mcp_types import LoggingMessageNotificationParams
|
||||
|
||||
from mcp.client import Client
|
||||
from stories._harness import Target, run_client
|
||||
|
||||
|
||||
async def main(target: Target, *, mode: str = "auto") -> None:
|
||||
# `logging_callback` is constructor-only on `Client`, so the list it fills
|
||||
# has to exist before the connection does.
|
||||
logs: list[LoggingMessageNotificationParams] = []
|
||||
|
||||
async def on_log(params: LoggingMessageNotificationParams) -> None:
|
||||
logs.append(params)
|
||||
|
||||
async with Client(target, mode=mode, logging_callback=on_log) as client:
|
||||
# ── progress + logging: a short countdown delivers exactly `steps` of each, in order ──
|
||||
updates: list[tuple[float, float | None, str | None]] = []
|
||||
|
||||
async def collect(progress: float, total: float | None, message: str | None) -> None:
|
||||
updates.append((progress, total, message))
|
||||
|
||||
result = await client.call_tool("countdown", {"steps": 3}, progress_callback=collect)
|
||||
assert result.structured_content == {"completed": 3, "total": 3}, result
|
||||
assert updates == [(1.0, 3.0, "step 1/3"), (2.0, 3.0, "step 2/3"), (3.0, 3.0, "step 3/3")]
|
||||
assert [(m.level, m.logger, m.data) for m in logs] == [
|
||||
("info", "countdown", "step 1/3"),
|
||||
("info", "countdown", "step 2/3"),
|
||||
("info", "countdown", "step 3/3"),
|
||||
]
|
||||
|
||||
# ── cancellation: abandon the awaiting scope once the call is provably in flight ──
|
||||
in_flight = anyio.Event()
|
||||
with anyio.fail_after(5):
|
||||
with anyio.CancelScope() as scope:
|
||||
|
||||
async def cancel_once_in_flight(progress: float, total: float | None, message: str | None) -> None:
|
||||
in_flight.set()
|
||||
scope.cancel()
|
||||
|
||||
await client.call_tool("countdown", {"steps": 1_000}, progress_callback=cancel_once_in_flight)
|
||||
|
||||
assert in_flight.is_set(), "the call must have started before it was cancelled"
|
||||
assert scope.cancelled_caught, "abandoning the scope should have cancelled the in-flight call"
|
||||
|
||||
# The session survives cancellation: a follow-up call still works.
|
||||
after = await client.call_tool("countdown", {"steps": 1}, progress_callback=collect)
|
||||
assert after.structured_content == {"completed": 1, "total": 1}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_client(main)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Progress, in-flight logging, and cancellation from a single long-running tool."""
|
||||
|
||||
import anyio
|
||||
import mcp_types as types
|
||||
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from stories._hosting import run_server_from_args
|
||||
|
||||
|
||||
def build_server() -> MCPServer:
|
||||
mcp = MCPServer("streaming-example")
|
||||
|
||||
@mcp.tool()
|
||||
async def countdown(steps: int, ctx: Context) -> dict[str, int]:
|
||||
"""Emit one progress + one log notification per step; observes cancellation."""
|
||||
try:
|
||||
for i in range(1, steps + 1):
|
||||
await ctx.report_progress(float(i), float(steps), f"step {i}/{steps}")
|
||||
# No non-deprecated logging helper on Context yet, so send the raw
|
||||
# notification. `related_request_id` keeps it on this request's response
|
||||
# stream (matters over streamable HTTP).
|
||||
await ctx.request_context.session.send_notification(
|
||||
types.LoggingMessageNotification(
|
||||
params=types.LoggingMessageNotificationParams(
|
||||
level="info", logger="countdown", data=f"step {i}/{steps}"
|
||||
)
|
||||
),
|
||||
related_request_id=ctx.request_context.request_id,
|
||||
)
|
||||
except anyio.get_cancelled_exc_class():
|
||||
# The client abandoned the call. Release resources here, then re-raise so
|
||||
# the dispatcher unwinds the request — never swallow cancellation.
|
||||
raise
|
||||
return {"completed": steps, "total": steps}
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server_from_args(build_server)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Progress, in-flight logging, and cancellation against the low-level Server."""
|
||||
|
||||
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
|
||||
|
||||
COUNTDOWN_INPUT_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {"steps": {"type": "integer"}},
|
||||
"required": ["steps"],
|
||||
}
|
||||
|
||||
|
||||
def build_server() -> Server[Any]:
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="countdown",
|
||||
description="Emit one progress + one log notification per step; observes cancellation.",
|
||||
input_schema=COUNTDOWN_INPUT_SCHEMA,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
assert params.name == "countdown" and params.arguments is not None
|
||||
steps = int(params.arguments["steps"])
|
||||
try:
|
||||
for i in range(1, steps + 1):
|
||||
await ctx.session.report_progress(float(i), float(steps), f"step {i}/{steps}")
|
||||
await ctx.session.send_notification(
|
||||
types.LoggingMessageNotification(
|
||||
params=types.LoggingMessageNotificationParams(
|
||||
level="info", logger="countdown", data=f"step {i}/{steps}"
|
||||
)
|
||||
),
|
||||
related_request_id=ctx.request_id,
|
||||
)
|
||||
except anyio.get_cancelled_exc_class():
|
||||
raise
|
||||
return types.CallToolResult(
|
||||
content=[types.TextContent(text=f"completed {steps}/{steps}")],
|
||||
structured_content={"completed": steps, "total": steps},
|
||||
)
|
||||
|
||||
async def set_logging_level(
|
||||
ctx: ServerRequestContext[Any], params: types.SetLevelRequestParams
|
||||
) -> types.EmptyResult:
|
||||
"""Registered so the server advertises the `logging` capability; never called."""
|
||||
raise NotImplementedError
|
||||
|
||||
return Server( # pyright: ignore[reportDeprecated]
|
||||
"streaming-example",
|
||||
on_list_tools=list_tools,
|
||||
on_call_tool=call_tool,
|
||||
on_set_logging_level=set_logging_level,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server_from_args(build_server)
|
||||
Reference in New Issue
Block a user