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,67 @@
|
||||
# standalone-get
|
||||
|
||||
> **Legacy mechanism (2025 handshake era).** The 2026-07-28 protocol delivers
|
||||
> server-initiated notifications over a `subscriptions/listen` stream instead
|
||||
> of the standalone GET stream. TODO(maxisbey): unify once
|
||||
> `subscriptions/listen` lands
|
||||
> ([#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901)).
|
||||
|
||||
Server-initiated `notifications/resources/list_changed` delivered over the
|
||||
**standalone GET SSE stream** of a sessionful Streamable-HTTP connection. The
|
||||
`add_note` tool mutates the resource list and emits the notification with no
|
||||
related request; the client's `message_handler` receives it on the GET stream,
|
||||
awaits it on an `anyio.Event`, then re-lists to observe the change.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
# HTTP only — the standalone GET stream is a Streamable-HTTP feature. The
|
||||
# client self-hosts the server on a free port, runs, then tears it down.
|
||||
uv run python -m stories.standalone_get.client --http --legacy
|
||||
# same, against the lowlevel-API server variant
|
||||
uv run python -m stories.standalone_get.client --http --legacy --server server_lowlevel
|
||||
|
||||
# against a server you run yourself
|
||||
uv run python -m stories.standalone_get.server --http --port 8000 &
|
||||
SERVER_PID=$!
|
||||
uv run python -m stories.standalone_get.client --http http://127.0.0.1:8000/mcp --legacy
|
||||
kill "$SERVER_PID"
|
||||
```
|
||||
|
||||
## What to look at
|
||||
|
||||
- **`client.py` — `Client(target, mode=mode, message_handler=on_message)`.**
|
||||
Unsolicited notifications have no typed callback, so the catch-all
|
||||
`message_handler` is wired at construction — it (and the `anyio.Event` it
|
||||
sets) must exist *before* the connection does. The notification is not
|
||||
guaranteed to arrive before the tool result (different streams), so the body
|
||||
`await`s the event, bounded by `anyio.fail_after(5)`.
|
||||
- **`server.py` — `await ctx.session.send_resource_list_changed()`.**
|
||||
`MCPServer.add_resource` does **not** auto-emit (unlike the TypeScript SDK's
|
||||
`registerResource`); the explicit call is the teaching point. Because
|
||||
`send_*_list_changed()` carries no `related_request_id`, the only route to the
|
||||
client is the standalone GET stream.
|
||||
|
||||
## Caveats
|
||||
|
||||
- DNS-rebinding protection is disabled via `transport_security=NO_DNS_REBIND`
|
||||
because the in-process httpx client sends no `Origin` header. Drop the kwarg
|
||||
for a real deployment.
|
||||
- Neither `MCPServer` nor lowlevel `Server` auto-advertises
|
||||
`resources.listChanged: true` in capabilities, and `MCPServer` exposes no knob
|
||||
to set it. A spec-conformant client that gates on the capability flag would
|
||||
skip the handler.
|
||||
- `ctx.session.*` is the interim path; a later release will shorten it.
|
||||
- Tool-triggered, not timer-driven, for harness determinism. "Server pushes on
|
||||
its own schedule" is not demonstrated.
|
||||
|
||||
## Spec
|
||||
|
||||
[List Changed Notification](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#list-changed-notification),
|
||||
[Streamable HTTP — Listening for Messages](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server)
|
||||
|
||||
## See also
|
||||
|
||||
`stickynotes/` (list_changed inside a feature capstone), `sse_polling/` (the
|
||||
other GET-stream story — resumability), `json_response/` (what happens when the
|
||||
server can't stream).
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Receive `notifications/resources/list_changed` over the standalone GET stream, then re-list."""
|
||||
|
||||
import anyio
|
||||
import mcp_types as types
|
||||
|
||||
from mcp.client import Client
|
||||
from stories._harness import Target, run_client
|
||||
|
||||
|
||||
async def main(target: Target, *, mode: str = "auto") -> None:
|
||||
# `message_handler` is constructor-only on `Client`, so the event it sets
|
||||
# has to exist before the connection does.
|
||||
received: list[types.ResourceListChangedNotification] = []
|
||||
seen = anyio.Event()
|
||||
|
||||
async def on_message(message: object) -> None:
|
||||
if isinstance(message, types.ResourceListChangedNotification):
|
||||
received.append(message)
|
||||
seen.set()
|
||||
|
||||
async with Client(target, mode=mode, message_handler=on_message) as client:
|
||||
before = await client.list_resources()
|
||||
assert len(before.resources) >= 1, before
|
||||
|
||||
result = await client.call_tool("add_note", {"content": "hello"})
|
||||
assert not result.is_error, result
|
||||
|
||||
# The notification rides the standalone GET stream, not the call's POST stream —
|
||||
# delivery order vs the tool result is not guaranteed, so wait.
|
||||
with anyio.fail_after(5):
|
||||
await seen.wait()
|
||||
assert len(received) == 1, received
|
||||
|
||||
after = await client.list_resources()
|
||||
assert len(after.resources) == len(before.resources) + 1, after
|
||||
assert {r.name for r in after.resources} >= {"initial", "note-1"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_client(main)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Sessionful Streamable HTTP: a tool mutates resources and emits `list_changed` over the standalone GET stream."""
|
||||
|
||||
import itertools
|
||||
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.server.mcpserver.resources import TextResource
|
||||
from stories._hosting import run_server_from_args
|
||||
|
||||
|
||||
def build_server() -> MCPServer:
|
||||
mcp = MCPServer("standalone-get-example")
|
||||
counter = itertools.count(1)
|
||||
|
||||
mcp.add_resource(TextResource(uri="note://initial", name="initial", text="initial content"))
|
||||
|
||||
@mcp.tool()
|
||||
async def add_note(content: str, ctx: Context) -> str:
|
||||
"""Register a new resource and announce it via `notifications/resources/list_changed`."""
|
||||
name = f"note-{next(counter)}"
|
||||
mcp.add_resource(TextResource(uri=f"note://{name}", name=name, text=content))
|
||||
# MCPServer does not auto-emit on add_resource; send explicitly. With no
|
||||
# related_request_id this routes to the standalone GET stream.
|
||||
await ctx.session.send_resource_list_changed()
|
||||
return f"registered {name}"
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server_from_args(build_server)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Sessionful Streamable HTTP (lowlevel `Server`): tool-triggered `list_changed` over the standalone GET stream."""
|
||||
|
||||
import itertools
|
||||
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_NOTE_INPUT_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {"content": {"type": "string"}},
|
||||
"required": ["content"],
|
||||
}
|
||||
|
||||
|
||||
def build_server() -> Server[Any]:
|
||||
counter = itertools.count(1)
|
||||
resources: list[types.Resource] = [types.Resource(uri="note://initial", name="initial", mime_type="text/plain")]
|
||||
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(tools=[types.Tool(name="add_note", input_schema=ADD_NOTE_INPUT_SCHEMA)])
|
||||
|
||||
async def list_resources(
|
||||
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListResourcesResult:
|
||||
return types.ListResourcesResult(resources=list(resources))
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
assert params.name == "add_note" and params.arguments is not None
|
||||
name = f"note-{next(counter)}"
|
||||
resources.append(types.Resource(uri=f"note://{name}", name=name, mime_type="text/plain"))
|
||||
await ctx.session.send_resource_list_changed()
|
||||
return types.CallToolResult(content=[types.TextContent(text=f"registered {name}")])
|
||||
|
||||
return Server(
|
||||
"standalone-get-example",
|
||||
on_list_tools=list_tools,
|
||||
on_list_resources=list_resources,
|
||||
on_call_tool=call_tool,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server_from_args(build_server)
|
||||
Reference in New Issue
Block a user