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,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).
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user