49b9bb6724
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
120 lines
4.5 KiB
Python
120 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)
|