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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
+67
View File
@@ -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).
+40
View File
@@ -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)
+30
View File
@@ -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)