Capstone: A Task Server, End to End
Everything in one server: two tools, a resource, and a prompt over shared state — a small task tracker that exercises the whole protocol, in Python and TypeScript.
Series: Building MCP Servers — Part 11 of 12
Ten posts of primitives in isolation; now one server that uses them together. We’ll build a task tracker — tools to add and complete tasks, a resource that exposes the list, and a prompt that drafts a standup from what’s still open — all over the same state. It’s deliberately small, but it’s complete: every primitive from the series, wired into one coherent thing you could actually point a host at.
The whole server
Here it is, end to end. Two tools that mutate state and return structured results, one resource that reads it, one prompt that uses it.
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel
mcp = FastMCP("tasks")
class Task(BaseModel):
id: int
title: str
done: bool = False
# A real server would back this with a database.
_tasks: dict[int, Task] = {}
_next_id = {"value": 1}
@mcp.tool()
def add_task(title: str) -> Task:
"""Add a task and return it."""
task = Task(id=_next_id["value"], title=title)
_tasks[task.id] = task
_next_id["value"] += 1
return task
@mcp.tool()
def complete_task(id: int) -> Task:
"""Mark a task as done."""
if id not in _tasks:
raise ValueError(f"no task with id {id}")
_tasks[id].done = True
return _tasks[id]
@mcp.resource("tasks://all")
def all_tasks() -> str:
"""Every task, as a checklist."""
if not _tasks:
return "(no tasks yet)"
return "\n".join(
f"[{'x' if t.done else ' '}] {t.id}. {t.title}" for t in _tasks.values()
)
@mcp.prompt()
def standup() -> str:
"""Draft a standup update from the open tasks."""
open_titles = [t.title for t in _tasks.values() if not t.done]
return f"Write a short standup update. Open tasks: {', '.join(open_titles) or 'none'}"
if __name__ == "__main__":
mcp.run()import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "tasks", version: "1.0.0" });
type Task = { id: number; title: string; done: boolean };
// A real server would back this with a database.
const tasks = new Map<number, Task>();
let nextId = 1;
const taskShape = { id: z.number(), title: z.string(), done: z.boolean() };
server.registerTool(
"add_task",
{ title: "Add task", description: "Add a task and return it.", inputSchema: { title: z.string() }, outputSchema: taskShape },
async ({ title }) => {
const task: Task = { id: nextId++, title, done: false };
tasks.set(task.id, task);
return { content: [{ type: "text", text: JSON.stringify(task) }], structuredContent: task };
}
);
server.registerTool(
"complete_task",
{ title: "Complete task", description: "Mark a task as done.", inputSchema: { id: z.number() }, outputSchema: taskShape },
async ({ id }) => {
const task = tasks.get(id);
if (!task) throw new Error(`no task with id ${id}`);
task.done = true;
return { content: [{ type: "text", text: JSON.stringify(task) }], structuredContent: task };
}
);
server.registerResource(
"tasks", "tasks://all", { title: "All tasks", mimeType: "text/plain" },
async (uri) => {
const body = tasks.size
? [...tasks.values()].map((t) => `[${t.done ? "x" : " "}] ${t.id}. ${t.title}`).join("\n")
: "(no tasks yet)";
return { contents: [{ uri: uri.href, text: body }] };
}
);
server.registerPrompt(
"standup", { title: "Standup", description: "Draft a standup update from the open tasks." },
() => {
const open = [...tasks.values()].filter((t) => !t.done).map((t) => t.title);
return {
messages: [{ role: "user", content: { type: "text", text: `Write a short standup update. Open tasks: ${open.join(", ") || "none"}` } }],
};
}
);
await server.connect(new StdioServerTransport());Nothing here is new — that’s the point. add_task returns a typed Task, so it carries structured output (Part 3). complete_task raises on a bad id, which the host sees as a recoverable tool error. tasks://all is a read-only resource (Part 4); standup is a user-invoked prompt (Part 5) that reads the same state the tools write. Eleven posts of pieces, assembled.
Using it
Drive it from a client (or the Inspector) and the primitives interlock — tools change the state, the resource and prompt reflect it:
add_task("write post 9") → { id: 1, title: "write post 9", done: false }
add_task("ship the series") → { id: 2, title: "ship the series", done: false }
complete_task(1) → { id: 1, ..., done: true }
read tasks://all:
[x] 1. write post 9
[ ] 2. ship the series
get standup:
"Write a short standup update. Open tasks: ship the series"
The standup prompt naming only the open task is the whole design in miniature: three different primitives, one source of truth. A host can call the tools as the user works, surface the resource in a sidebar, and offer the prompt as a command — and they all stay consistent because they read and write the same place.
Where the dict goes
The one line doing the hand-waving is the in-memory store. Swap that dict/Map for your database and nothing else moves — the tools become inserts and updates, the resource becomes a query, and the prompt reads from the same source. That’s the honest shape of most real MCP servers: a thin protocol layer over a datastore or an API you already have. The protocol work is what this series covered; the rest is your domain, which you already know.
Final thoughts
If this server feels anticlimactic, good — that means the protocol got out of the way. The hard part of an MCP integration was never the MCP; it’s deciding what to expose and keeping it coherent. Once the primitives are second nature, a new server is a couple of hours: model the state, expose the verbs as tools, the nouns as resources, the recurring requests as prompts, and point a host at it. You can build the real thing now.
Next: FastMCP 3.x and Where the Spec Is Heading, a look past the official SDK at what’s coming.
Target keyword(s): mcp server example, mcp tools resources prompts.
Comments