Files

119 lines
4.5 KiB
Python

"""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)