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 @@
# stickynotes
The "real app" capstone: tools mutate a sticky-notes board held in the
server's lifespan context, each note is a `note:///{id}` resource,
`notifications/resources/list_changed` fires on add/remove, and `remove_all`
blocks on a form-mode elicitation so the user must explicitly confirm a
destructive clear.
## Run it
```bash
# stdio (default — the client spawns the server as a subprocess)
uv run python -m stories.stickynotes.client
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
uv run python -m stories.stickynotes.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.stickynotes.client --http --server server_lowlevel
```
## What to look at
- **`client.py` `main` → `Client(target, mode=mode, elicitation_callback=...,
message_handler=...)`** — the construction is the example: callbacks are
plain constructor kwargs, and `mode=` is explicit. The scripted elicitation
answer and the `list_changed` event are locals of `main`, so every
connection starts clean.
- **`server.py` `lifespan` → `Board`** — long-lived mutable state belongs in
the lifespan context, never a module global. Tools reach it via
`ctx.request_context.lifespan_context`; this 2-hop path is interim and will
shorten to `ctx.state.*` in a later release.
- **`add_note` / `remove_note`** — `mcp.add_resource(FunctionResource(...))`
registers a concrete resource at runtime; `ctx.session.send_resource_list_changed()`
tells connected clients to re-list. **Gap:** `MCPServer` has no public
`remove_resource()` yet, so `remove_note` reaches a private attribute — do
not copy that line. `server_lowlevel.py` shows the clean equivalent:
`on_list_resources` reads the board and builds the list fresh per call, so
removal is just `board.notes.pop(...)` with no registry mutation.
- **`remove_all` → `ctx.elicit(...)`** — push-style server→client elicitation
needs a back-channel and an advertised client capability, so it only runs on
the legacy-era legs. On a modern connection there is no server→client
request channel; the modern equivalent is the multi-round-trip
`InputRequiredResult` flow (see `mrtr/`, not yet implemented). The client
branches on `client.protocol_version`.
## Caveats
- `list_changed` and `ctx.elicit()` are skipped on modern legs: the
notification needs a standalone stream and `ctx.elicit()` would raise
`NoBackChannelError`. `main` branches on
`client.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS`.
## Spec
- [Tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)
- [Resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources)
- [Elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation)
## See also
`tools/`, `resources/`, `legacy_elicitation/`, `lifespan/`, `standalone_get/`
(`list_changed` over the GET stream).
+81
View File
@@ -0,0 +1,81 @@
"""Drive the sticky-notes board end to end and prove `remove_all` clears only on a confirmed elicitation."""
import anyio
import mcp_types as types
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS
from mcp.client import Client, ClientRequestContext
from stories._harness import Target, run_client
async def main(target: Target, *, mode: str = "auto") -> None:
# Scripted reply for the server's `remove_all` elicitation; rebound between calls below.
answer = "cancel"
list_changed = anyio.Event()
async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
if answer == "cancel":
return types.ElicitResult(action="cancel")
return types.ElicitResult(action="accept", content={"confirm": answer == "confirm"})
async def on_message(message: object) -> None:
if isinstance(message, types.ResourceListChangedNotification):
list_changed.set()
async with Client(target, mode=mode, elicitation_callback=on_elicit, message_handler=on_message) as client:
legacy = client.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS
# Add two notes.
first = await client.call_tool("add_note", {"text": "Buy milk"})
assert first.structured_content is not None
first_id, first_uri = first.structured_content["id"], first.structured_content["uri"]
assert first_uri.startswith("note:///")
second = await client.call_tool("add_note", {"text": "Walk the dog"})
assert second.structured_content is not None
second_id, second_uri = second.structured_content["id"], second.structured_content["uri"]
assert first_id != second_id
# List + read — both notes appear as resources; first reads back its text.
listed = await client.list_resources()
uris = {str(r.uri) for r in listed.resources}
assert first_uri in uris and second_uri in uris, uris
read = await client.read_resource(first_uri)
assert isinstance(read.contents[0], types.TextResourceContents)
assert read.contents[0].text == "Buy milk"
# list_changed rides the standalone stream — only deliverable on a legacy-era connection.
if legacy:
with anyio.fail_after(5):
await list_changed.wait()
# Remove one.
removed = await client.call_tool("remove_note", {"note_id": first_id})
assert removed.structured_content == {"result": True}
after = await client.list_resources()
assert first_uri not in {str(r.uri) for r in after.resources}
# remove_all uses push-style elicitation: legacy-era only (modern equivalent lands with the mrtr/ story).
if not legacy:
gone = await client.call_tool("remove_note", {"note_id": second_id})
assert gone.structured_content == {"result": True}
return
cancelled = await client.call_tool("remove_all", {})
assert cancelled.structured_content == {"status": "cancelled", "removed": 0}
answer = "unchecked"
declined = await client.call_tool("remove_all", {})
assert declined.structured_content == {"status": "declined", "removed": 0}
answer = "confirm"
cleared = await client.call_tool("remove_all", {})
assert cleared.structured_content == {"status": "cleared", "removed": 1}
final = await client.list_resources()
assert not [r for r in final.resources if str(r.uri).startswith("note:///")]
empty = await client.call_tool("remove_all", {})
assert empty.structured_content == {"status": "empty", "removed": 0}
if __name__ == "__main__":
run_client(main)
+99
View File
@@ -0,0 +1,99 @@
"""Capstone sticky-notes board: tools mutate lifespan state, one resource per note,
`resources/list_changed` on add/remove, elicitation-guarded clear."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from pydantic import BaseModel
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.resources import FunctionResource
from stories._hosting import run_server_from_args
@dataclass
class Board:
notes: dict[str, str] = field(default_factory=dict[str, str])
_next: int = 1
def claim_id(self) -> str:
nid, self._next = str(self._next), self._next + 1
return nid
class AddResult(BaseModel):
id: str
uri: str
class ClearResult(BaseModel):
status: str
removed: int
class ConfirmClear(BaseModel):
confirm: bool
def build_server() -> MCPServer:
@asynccontextmanager
async def lifespan(_: MCPServer) -> AsyncIterator[Board]:
yield Board()
mcp = MCPServer("stickynotes-example", lifespan=lifespan)
def unregister_note(note_id: str) -> None:
# DO NOT copy this line into your own server. `MCPServer` has no public
# `remove_resource()` yet (only `add_resource`), so unregistering a runtime-added
# resource has to reach a private attribute. `server_lowlevel.py` shows the clean
# shape: `on_list_resources` rebuilds the list from the board on every call, so
# removal never touches a registry at all.
mcp._resource_manager._resources.pop(f"note:///{note_id}", None) # pyright: ignore[reportPrivateUsage]
@mcp.tool()
async def add_note(text: str, ctx: Context[Board]) -> AddResult:
"""Add a sticky note and register a `note:///{id}` resource for it."""
board = ctx.request_context.lifespan_context
note_id = board.claim_id()
uri = f"note:///{note_id}"
board.notes[note_id] = text
mcp.add_resource(
FunctionResource(uri=uri, name=f"note-{note_id}", mime_type="text/plain", fn=lambda: board.notes[note_id])
)
await ctx.session.send_resource_list_changed()
return AddResult(id=note_id, uri=uri)
@mcp.tool()
async def remove_note(note_id: str, ctx: Context[Board]) -> bool:
"""Remove one sticky note and unregister its resource."""
board = ctx.request_context.lifespan_context
removed = board.notes.pop(note_id, None) is not None
if removed:
unregister_note(note_id)
await ctx.session.send_resource_list_changed()
return removed
@mcp.tool()
async def remove_all(ctx: Context[Board]) -> ClearResult:
"""Remove every note after a confirmed form-mode elicitation (handshake-era only)."""
board = ctx.request_context.lifespan_context
if not board.notes:
return ClearResult(status="empty", removed=0)
answer = await ctx.elicit(f"Remove all {len(board.notes)} note(s)? This cannot be undone.", ConfirmClear)
if answer.action == "cancel":
return ClearResult(status="cancelled", removed=0)
if answer.action != "accept" or not answer.data.confirm:
return ClearResult(status="declined", removed=0)
count = len(board.notes)
for nid in list(board.notes):
unregister_note(nid)
board.notes.clear()
await ctx.session.send_resource_list_changed()
return ClearResult(status="cleared", removed=count)
return mcp
if __name__ == "__main__":
run_server_from_args(build_server)
@@ -0,0 +1,119 @@
"""Capstone sticky-notes board on the lowlevel `Server`: handlers read lifespan state directly."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
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
@dataclass
class Board:
notes: dict[str, str] = field(default_factory=dict[str, str])
_next: int = 1
def claim_id(self) -> str:
nid, self._next = str(self._next), self._next + 1
return nid
CONFIRM_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"confirm": {"type": "boolean", "title": "Yes, permanently delete every sticky note"}},
"required": ["confirm"],
}
TOOLS = [
types.Tool(
name="add_note",
description="Add a sticky note.",
input_schema={"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]},
),
types.Tool(
name="remove_note",
description="Remove one sticky note.",
input_schema={"type": "object", "properties": {"note_id": {"type": "string"}}, "required": ["note_id"]},
),
types.Tool(name="remove_all", description="Remove every note after confirmation.", input_schema={"type": "object"}),
]
def _result(text: str, structured: dict[str, Any]) -> types.CallToolResult:
return types.CallToolResult(content=[types.TextContent(text=text)], structured_content=structured)
def build_server() -> Server[Board]:
@asynccontextmanager
async def lifespan(_: Server[Board]) -> AsyncIterator[Board]:
yield Board()
async def list_tools(
ctx: ServerRequestContext[Board], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=TOOLS)
async def list_resources(
ctx: ServerRequestContext[Board], params: types.PaginatedRequestParams | None
) -> types.ListResourcesResult:
board = ctx.lifespan_context
return types.ListResourcesResult(
resources=[
types.Resource(uri=f"note:///{nid}", name=f"note-{nid}", mime_type="text/plain") for nid in board.notes
]
)
async def read_resource(
ctx: ServerRequestContext[Board], params: types.ReadResourceRequestParams
) -> types.ReadResourceResult:
board = ctx.lifespan_context
nid = str(params.uri).removeprefix("note:///")
return types.ReadResourceResult(
contents=[types.TextResourceContents(uri=params.uri, mime_type="text/plain", text=board.notes[nid])]
)
async def call_tool(ctx: ServerRequestContext[Board], params: types.CallToolRequestParams) -> types.CallToolResult:
board = ctx.lifespan_context
args = params.arguments or {}
if params.name == "add_note":
nid = board.claim_id()
board.notes[nid] = args["text"]
await ctx.session.send_resource_list_changed()
return _result(f"added #{nid}", {"id": nid, "uri": f"note:///{nid}"})
if params.name == "remove_note":
removed = board.notes.pop(args["note_id"], None) is not None
if removed:
await ctx.session.send_resource_list_changed()
return _result("removed" if removed else "not found", {"result": removed})
if params.name == "remove_all":
if not board.notes:
return _result("empty", {"status": "empty", "removed": 0})
answer = await ctx.session.elicit_form(
f"Remove all {len(board.notes)} note(s)? This cannot be undone.", CONFIRM_SCHEMA, ctx.request_id
)
if answer.action == "cancel":
return _result("cancelled", {"status": "cancelled", "removed": 0})
if answer.action != "accept" or not (answer.content or {}).get("confirm"):
return _result("declined", {"status": "declined", "removed": 0})
count = len(board.notes)
board.notes.clear()
await ctx.session.send_resource_list_changed()
return _result(f"cleared {count}", {"status": "cleared", "removed": count})
raise NotImplementedError
return Server(
"stickynotes-example",
lifespan=lifespan,
on_list_tools=list_tools,
on_call_tool=call_tool,
on_list_resources=list_resources,
on_read_resource=read_resource,
)
if __name__ == "__main__":
run_server_from_args(build_server)