Add the todos-server reference example#3048
Conversation
There was a problem hiding this comment.
2 issues found across 8 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="examples/servers/todos-server/mcp_todos_server/todos.py">
<violation number="1" location="examples/servers/todos-server/mcp_todos_server/todos.py:121">
P2: A 2026-era `clear_done` confirmation can delete tasks that were completed after the user saw the prompt, because the retry recomputes `done` from the current global board instead of carrying the confirmed task IDs in `request_state`. Preserving the IDs/count in the `InputRequiredResult` state and deleting only that confirmed set would keep the elicitation semantics accurate under concurrent HTTP sessions or board changes between rounds.</violation>
<violation number="2" location="examples/servers/todos-server/mcp_todos_server/todos.py:643">
P2: `complete_task` can complete the wrong task when called with an empty `task` argument, because empty-string substring matching succeeds on the first title. Validating non-empty input before substring lookup would prevent unintended board mutations.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
| task: Annotated[str, Field(description="Task id, or part of its title")], | ||
| ctx: Context, | ||
| ) -> CallToolResult: | ||
| needle = task.lower() |
There was a problem hiding this comment.
P2: complete_task can complete the wrong task when called with an empty task argument, because empty-string substring matching succeeds on the first title. Validating non-empty input before substring lookup would prevent unintended board mutations.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/servers/todos-server/mcp_todos_server/todos.py, line 643:
<comment>`complete_task` can complete the wrong task when called with an empty `task` argument, because empty-string substring matching succeeds on the first title. Validating non-empty input before substring lookup would prevent unintended board mutations.</comment>
<file context>
@@ -0,0 +1,824 @@
+ task: Annotated[str, Field(description="Task id, or part of its title")],
+ ctx: Context,
+) -> CallToolResult:
+ needle = task.lower()
+ found = tasks.get(task) or next(
+ (candidate for candidate in tasks.values() if needle in candidate.title.lower()), None
</file context>
|
|
||
|
|
||
| def render_board() -> str: | ||
| done = [task for task in tasks.values() if task.status == "done"] |
There was a problem hiding this comment.
P2: A 2026-era clear_done confirmation can delete tasks that were completed after the user saw the prompt, because the retry recomputes done from the current global board instead of carrying the confirmed task IDs in request_state. Preserving the IDs/count in the InputRequiredResult state and deleting only that confirmed set would keep the elicitation semantics accurate under concurrent HTTP sessions or board changes between rounds.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/servers/todos-server/mcp_todos_server/todos.py, line 121:
<comment>A 2026-era `clear_done` confirmation can delete tasks that were completed after the user saw the prompt, because the retry recomputes `done` from the current global board instead of carrying the confirmed task IDs in `request_state`. Preserving the IDs/count in the `InputRequiredResult` state and deleting only that confirmed set would keep the elicitation semantics accurate under concurrent HTTP sessions or board changes between rounds.</comment>
<file context>
@@ -0,0 +1,824 @@
+
+
+def render_board() -> str:
+ done = [task for task in tasks.values() if task.status == "done"]
+ lines = [
+ "# Todo board",
</file context>
| async def handle_completion( | ||
| ref: PromptReference | ResourceTemplateReference, | ||
| argument: CompletionArgument, | ||
| context: CompletionContext | None, | ||
| ) -> Completion | None: | ||
| if isinstance(ref, PromptReference): | ||
| if ref.name == "seed-board" and argument.name == "theme": | ||
| return Completion(values=[theme for theme in THEME_SUGGESTIONS if theme.startswith(argument.value)]) | ||
| if ref.name == "plan-my-day" and argument.name == "focus": | ||
| return Completion(values=[project for project in projects() if project.startswith(argument.value)]) | ||
| if isinstance(ref, ResourceTemplateReference) and ref.uri == TASK_URI_TEMPLATE and argument.name == "id": | ||
| return Completion(values=[task_id for task_id in tasks if task_id.startswith(argument.value)]) | ||
| return None |
There was a problem hiding this comment.
🟡 The task-id completion for todos://tasks/{id} returns every matching task id with no cap, but completion values are limited to 100 items by the spec (and enforced by the 2026-07-28 wire model's max_length=100), so once the board exceeds 100 tasks a short/empty prefix produces an oversized result — spec-violating on 2025-era connections and a failed request on 2026-era ones. Slicing the list to 100 (optionally with total/has_more) matches what the TypeScript reference does.
Extended reasoning...
What the bug is. In handle_completion (todos.py:444-456), the ResourceTemplateReference branch for the id argument returns Completion(values=[task_id for task_id in tasks if task_id.startswith(argument.value)]) — every matching task id, uncapped. The MCP spec limits completion.values to at most 100 entries, and the SDK's own Completion docstring in mcp_types._types says "Must not exceed 100 items". The other two completion branches (theme suggestions, project names) stay small naturally, but the task-id one scales with the board.
How the board exceeds 100 tasks. brainstorm_tasks accepts counts up to 100 per call, add_tasks is unbounded, and the module-level tasks dict accumulates across calls — and, over Streamable HTTP, across sessions in the same process. All ids share the t prefix (t1, t2, …), so a completion request for the id argument with an empty prefix or just t matches the whole board.
Why nothing else prevents it. The @mcp.completion() plumbing (src/mcp/server/mcpserver/server.py:698-705) wraps the returned Completion verbatim into a CompleteResult with no truncation, and the SDK-level Completion model has no max_length constraint despite its docstring. However, the 2026-07-28 wire surface does enforce it: mcp_types/v2026_07_28/__init__.py:92 declares values with Field(max_length=100), and server results are serialized against the versioned surface (_methods.serialize_server_result, called from server/runner.py).
Impact. With >100 tasks on the board:
- On a 2025-era connection, the server emits a spec-violating completion result with more than 100 values and no
total/hasMorepagination hints. - On a 2026-07-28 connection, serialization against the versioned wire model raises a
ValidationError, so thecompletion/completerequest fails with an internal error instead of returning any values at all.
This also diverges from the TypeScript todos-server this PR ports: the TS SDK's completion path truncates to 100 values and sets total/hasMore, so the TS server never emits an oversized completion.
Step-by-step proof. 1) Client calls brainstorm_tasks, answers the count elicitation with custom → 100, and the sampling round returns 100 lines — the board now holds 100 tasks (t1…t100). 2) Client calls add_task once more → 101 tasks. 3) Client sends completion/complete with ref = {type: "ref/resource", uri: "todos://tasks/{id}"}, argument = {name: "id", value: "t"}. 4) Every id starts with t, so the list comprehension yields 101 values. 5) On a 2026-07-28 connection, CompleteResult serialization against the v2026_07_28 model trips max_length=100 and the request errors out; on a 2025-era connection, a 101-value result goes on the wire, exceeding the spec limit.
How to fix. Slice the matches to 100, e.g. matches = [task_id for task_id in tasks if task_id.startswith(argument.value)] then return Completion(values=matches[:100], total=len(matches), has_more=len(matches) > 100) — mirroring the TypeScript reference's behaviour.
Severity. This is example-only code, requires accumulating more than 100 tasks plus a short completion prefix, and the fix is a one-liner — nice to fix for parity and spec compliance, but not merge-blocking.
A Python port of the TypeScript SDK's examples/todos-server: a small project todo board where every server-side MCP feature has a real job — CRUD tools, structured output, resources and a task template, prompts with completions, sampling- and elicitation-backed interactive tools written once as input_required state machines with a legacy fulfilment driver, progress, request-tied logging, and per-resource subscriptions — served to both protocol revisions over stdio and Streamable HTTP.
Rewrite clear_done, prioritize, and brainstorm_tasks from hand-rolled InputRequiredResult state machines onto Resolve dependencies, now that Sample landed alongside Elicit: the framework carries the rounds on 2026-07-28 connections and push-style requests on pre-2026 ones, seals the multi-round state, validates answers against the form schemas, and gates on client capabilities. This deletes the example's local legacy fulfilment driver, literal schema dicts, and JSON step state. The elicitation forms render byte-identical to the TypeScript reference's literals; the count-form theme field advertises its default via json_schema_extra while staying nullable, so an omitted answer still falls back to the tool argument like the reference. Resolver outcomes, not board recounts, drive the empty-board paths so concurrent mutations cannot desync the resolver from the tool body.
12bc70f to
d1812a9
Compare
Adds
examples/servers/todos-server/— a Python port of the TypeScript SDK's reference server,examples/todos-server: a small project todo board where every server-side MCP feature has a real job, built on the high-levelMCPServer.Motivation and Context
The TypeScript SDK ships a reference host/server pair (
cli-client+todos-server) that exercises tools, resources, prompts, sampling, elicitation, multi-roundinput_requiredflows, progress, logging, and subscriptions in one small, readable app, serving both protocol revisions (2026-07-28 and 2025-11-25) over stdio and Streamable HTTP. This PR brings the server half to the Python SDK so the two SDKs have a directly comparable reference workload — the TScli-clientconnects to this server out of the box over HTTP.Notable porting decisions (details in the example README's "Fidelity" section):
clear_done,prioritize,brainstorm_tasks) are written once as resolver dependencies (Resolve+Elicit/Sample, the latter from Extend resolver DI to sampling and roots requests #3049): the framework carries the questions asInputRequiredResultrounds on 2026-07-28 connections and as push-style requests on pre-2026 ones, so no handler branches on the era and the example does no round bookkeeping.brainstorm_tasksshows the multi-round shape as a resolver chain — count/theme form → conditional custom-amount form → sampling round derived from the recorded answers.resources/subscribe/unsubscribe,logging/setLevel, and a dynamicresources/list(one entry per task, like the TSResourceTemplatelist callback) are registered on the low-level server viamcp._lowlevel_server.add_request_handler— the same pattern (andTODO(felix)gap) as the everything-server.logging/setLevelon 2025 sessions and the per-requestio.modelcontextprotocol/logLevel_metaopt-in on 2026-07-28 sessions, matching the TS server's semantics.REQUEST_STATE_SECRETmaps toRequestStateSecurity(keys=[...])like the TS example's HMAC env key.How Has This Been Tested?
Drove this server and the TS reference server through an identical scripted scenario (~38 steps: all eight tools including the three-round brainstorm flow and its decline/cancel branches, resources, templates, prompts, completions, progress, logging thresholds, subscriptions) with the same client and scripted elicitation/sampling callbacks:
descriptioninprompts/getresults and advertisesexperimental: {}on legacy initialize).subscriptions/listenstreams (ack + per-mutation board updates on both servers). On the legacy HTTP leg the Python server's default stateful sessions serve push-style elicitation/sampling, where the TS server's stateless posture refuses (its documented caveat).-32021error naming the missing capability — both stricter than the TS handlers' manual parsing, documented in the README.work_through_tasksmid-flight stops the loop on both eras (after Make client-side cancellation work over the 2026 transports #3046); the README documents the remaining granularity difference vs TS.uv run --frozen ruff format --check,ruff check,pyright(strict, root config), and the full pre-commit suite pass.Breaking Changes
None — example only; no
src/changes.Types of changes
Checklist
Additional context
Two SDK gaps surfaced while verifying parity, documented in the example README rather than worked around:
subscriptions/listenis not served over stdio (2026-era stdio clients get METHOD_NOT_FOUND, so board-change notifications over stdio reach 2025-era subscribers only), and there is no public seam to advertiselistChangedcapabilities to pre-2026 HTTP clients (serve_stdiopatches the stdio path via the low-level server). A third gap noted in the first revision — no courtesynotifications/cancelledfrom the client on 2026-era transports — was fixed by #3046.AI Disclaimer