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
+62
View File
@@ -0,0 +1,62 @@
# subscriptions
Server-originated change notifications on the 2026-07-28 protocol. A client
opens one `subscriptions/listen` request whose response **is** the stream; the
server publishes with `ctx.notify_resource_updated(uri)` /
`ctx.notify_tools_changed()` and the SDK does the wire work (ack-first,
per-stream filtering, subscription-id tagging). Replaces the handshake-era
`resources/subscribe` + standalone-GET notification path.
The client opens the stream with `client.listen(...)`, edits a note it did
not subscribe to (silence), edits the one it did (a typed `ResourceUpdated`),
registers a tool at runtime (a typed `ToolsListChanged`, then re-lists and
calls it), and finally leaves the `async with` block, which ends the
subscription while the connection lives on.
## Run it
```bash
# HTTP: the client self-hosts the server on a free port, runs, then tears it
# down (subscriptions/listen is 2026-era only)
uv run python -m stories.subscriptions.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.subscriptions.client --http --server server_lowlevel
```
## What to look at
- `client.py`: the whole subscription is one context manager,
`async with client.listen(...) as sub`. Entering waits for the server's
acknowledgment, so `sub.honored` is already in hand on the first line of the
block. Events arrive as typed values from `anext(sub)`; the edit to the
unsubscribed note never shows up, because the filter is enforced
server-side. Leaving the block ends the subscription (over HTTP the SDK
closes that request's response stream) and the session carries on, which the
final `search` call proves.
- `server.py`: publishing is one `await ctx.notify_*()` line per change; the
filter, the tagging, and the ack ordering are the SDK's job. Publishing with
no subscribers is a no-op.
- `server_lowlevel.py`: the same machinery held by hand: an
`InMemorySubscriptionBus`, handlers that `await bus.publish(...)`, and
`ListenHandler(bus)` passed as `on_subscriptions_listen=`. A multi-replica
deployment swaps the bus for one backed by its own pub/sub
(`MCPServer(subscriptions=...)` on the high-level server).
## Caveats
- 2026-era only: on a 2025 connection the method does not exist (clients there
use `resources/subscribe` and unsolicited notifications instead), so the
story pins the modern era and has no legacy leg.
- No replay: events published while no stream is open are not queued. The
contract after a dropped stream is re-listen and re-fetch.
## Spec
[Subscriptions, basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions)
## See also
`streaming/` (request-scoped notifications), `events/` (the events extension
on top of this channel, deferred), and the narrative versions:
`docs/handlers/subscriptions.md` (server) and `docs/client/subscriptions.md`
(client).
+43
View File
@@ -0,0 +1,43 @@
"""Open a `subscriptions/listen` stream, watch one URI and the tool list, then close it."""
import anyio
import mcp_types as types
from mcp.client import Client
from mcp.client.subscriptions import ResourceUpdated, ToolsListChanged
from stories._harness import Target, run_client
async def main(target: Target, *, mode: str = "auto") -> None:
async with Client(target, mode=mode) as client:
before = await client.list_tools()
assert "search" not in {tool.name for tool in before.tools}
async with client.listen(tools_list_changed=True, resource_subscriptions=["note://todo"]) as sub:
# ── entering waited for the ack: the honored filter is already in hand ──
assert sub.honored.tools_list_changed is True
assert sub.honored.resource_subscriptions == ["note://todo"]
# ── exact-URI filtering: an unsubscribed note edit stays silent ──
await client.call_tool("edit_note", {"name": "journal", "text": "day two"})
# ── the subscribed URI delivers ──
await client.call_tool("edit_note", {"name": "todo", "text": "water plants"})
with anyio.fail_after(10):
event = await anext(sub)
assert event == ResourceUpdated(uri="note://todo"), "the journal edit must not have been delivered"
# ── a runtime tool registration announces itself ──
await client.call_tool("enable_search", {})
with anyio.fail_after(10):
assert await anext(sub) == ToolsListChanged()
# ── leaving the block closed the stream; the session lives on ──
tools = await client.list_tools()
assert "search" in {tool.name for tool in tools.tools}
result = await client.call_tool("search", {"query": "water"})
content = result.content[0]
assert isinstance(content, types.TextContent) and content.text == "todo", result
if __name__ == "__main__":
run_client(main)
+41
View File
@@ -0,0 +1,41 @@
"""A notebook whose edits and tool changes reach `subscriptions/listen` streams."""
from mcp.server.mcpserver import Context, MCPServer
from stories._hosting import run_server_from_args
def build_server() -> MCPServer:
mcp = MCPServer("subscriptions-example")
notes = {"todo": "buy milk", "journal": "day one"}
@mcp.resource("note://{name}")
def note(name: str) -> str:
return notes[name]
@mcp.tool()
async def edit_note(name: str, text: str, ctx: Context) -> str:
"""Replace a note's text and tell subscribers that URI changed."""
notes[name] = text
await ctx.notify_resource_updated(f"note://{name}")
return "saved"
def search(query: str) -> list[str]:
return [name for name, text in notes.items() if query in text]
enabled = False
@mcp.tool()
async def enable_search(ctx: Context) -> str:
"""Register the `search` tool at runtime and tell subscribers the list changed."""
nonlocal enabled
if not enabled:
enabled = True
mcp.add_tool(search)
await ctx.notify_tools_changed()
return "search is live"
return mcp
if __name__ == "__main__":
run_server_from_args(build_server)
@@ -0,0 +1,72 @@
"""The same notebook against the low-level Server: an explicit bus + ListenHandler."""
from typing import Any
import mcp_types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.server.subscriptions import (
InMemorySubscriptionBus,
ListenHandler,
ResourceUpdated,
ToolsListChanged,
)
from stories._hosting import run_server_from_args
EDIT_NOTE_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"name": {"type": "string"}, "text": {"type": "string"}},
"required": ["name", "text"],
}
EMPTY_SCHEMA: dict[str, Any] = {"type": "object", "properties": {}}
SEARCH_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
}
def build_server() -> Server[Any]:
# The bus lives wherever your handlers can reach it; the lifespan is the
# natural home in a bigger app. The closure is enough here.
bus = InMemorySubscriptionBus()
notes = {"todo": "buy milk", "journal": "day one"}
search_enabled = False
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
tools = [
types.Tool(name="edit_note", description="Replace a note's text.", input_schema=EDIT_NOTE_SCHEMA),
types.Tool(name="enable_search", description="Register the search tool.", input_schema=EMPTY_SCHEMA),
]
if search_enabled:
tools.append(types.Tool(name="search", description="Find notes.", input_schema=SEARCH_SCHEMA))
return types.ListToolsResult(tools=tools)
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
nonlocal search_enabled
args = params.arguments or {}
if params.name == "edit_note":
notes[args["name"]] = args["text"]
await bus.publish(ResourceUpdated(uri=f"note://{args['name']}"))
return types.CallToolResult(content=[types.TextContent(text="saved")])
if params.name == "enable_search":
search_enabled = True
await bus.publish(ToolsListChanged())
return types.CallToolResult(content=[types.TextContent(text="search is live")])
assert params.name == "search" and search_enabled
matches = [name for name, text in notes.items() if args["query"] in text]
return types.CallToolResult(content=[types.TextContent(text=", ".join(matches))])
return Server(
"subscriptions-example",
on_list_tools=list_tools,
on_call_tool=call_tool,
on_subscriptions_listen=ListenHandler(bus),
)
if __name__ == "__main__":
run_server_from_args(build_server)