Building an MCP Server
Validate, dispatch, wrap and cache a stateless MCP 2026-07-28 server. Tools, resources, prompts, policy.
- Concepts and terms
- Tasks and commands
- Annotated code
- Diagrams
- Comparisons
- Defaults and numbers
- Troubleshooting
- Questions and tips
Run the lesson servers#
| Task | Code |
|---|---|
| Serve on stdio | python3 main.py |
| Run the finite demo | python3 main.py --demo |
| Run the TypeScript port | npx tsx main.ts --demo |
| Run the lesson tests | python3 -m unittest discover tests -v |
Build a request with _meta | make_request(2, |
| Run the capstone model | python3 code/ |
| Check the capstone TypeScript | npm run typecheck |
The dispatch loop#
Validate and wrap every request#
meta = params.get("_meta")-32602 when missingrequested = meta.get(VERSION_KEY)protocolVersion keyif requested not in SUPPORTED_VERSIONS: raise RpcProblem(-32022, "Unsupported protocol version", {"requested": requested, "supported": SUPPORTED_VERSIONS.copy()})if not isinstance(meta.get(CAPABILITIES_KEY), dict): raise RpcProblem(-32602, f"{CAPABILITIES_KEY} is required")result = {"resultType": "complete", **payload, "_meta": {SERVER_INFO_KEY: SERVER_INFO.copy()}}if ttl_ms is not None:lists, reads, discovery result["ttlMs"] = ttl_ms result["cacheScope"] = cache_scopedefault "private"Register a tool and its executor#
TOOL_EXECUTORS = {"notes_search": exec_notes_search}name to functiondef handle_tools_list(params): return complete({"tools": sorted(TOOLS, key=lambda tool: tool["name"])}, ttl_ms=60_000, cache_scope="public")def handle_tools_call(params): executor = TOOL_EXECUTORS.get(name) if executor is None:result, not RPC error return complete({"content": [...], "isError": True}) try: return complete({"content": executor(arguments), "isError": False}) except (KeyError, TypeError, ValueError) as exc: return complete({"content": [...], "isError": True})Tool, resource or prompt#
| Factor | Tool | Resource | Prompt |
|---|---|---|---|
| Intent | Perform an operation | Read content at a URI | Start a message workflow |
| Chooser | Model or application | Host, application or user | User through host UI |
| Result | Structured action result | Text or binary content | Prompt messages |
| Methods | tools/list, tools/call | resources/list, resources/read | prompts/list, prompts/get |
| Example | delete_note | notes:/ | review_note |
Tips#
- Use
cacheScope: "private"for user data. UsettlMs: 0for secrets. - Reject descriptors that mirror
password,secret,tokenorauthorization. - Cap request bodies. The memory server sends 413 above 50000 bytes.
Cache hints in the lessons#
| Setting | Default | Note |
|---|---|---|
server/discover ttl | 3_600_000 | ms, cacheScope public |
tools/list ttl | 60_000 | public. Capstone uses private |
resources/list ttl | 10_000 | Lesson 07, private |
resources/read ttl | 5_000 | Lesson 07, private |
prompts/list ttl | 600_000 | Lesson 10, public |
cache_scope | private | complete() default in lesson 07 |
limit of notes_search | 10 | Integer from 1 through 50 |
MAX_TOOL_LIST_PAGES | 100 | Lesson 28 client page cap |
Error codes#
| Symptom | Cause | Fix |
|---|---|---|
-32700 Parse error | Line is not JSON | Send one JSON object per line |
-32600 Invalid Request | No jsonrpc 2.0 or no method | Fix the envelope |
-32602 invalid params | params._meta missing | Send version and capabilities each time |
-32022 | Unsupported version string | Retry with a version from supported |
-32601 | Method not in HANDLERS | Add the handler or fix the name |
-32602 on resources/read | Unknown or invalid URI | Never return an empty read |
HTTP 400 and -32020 | Mcp-Param-* header differs from body | Reject before dispatch |
| Client parse fails | Logs on stdout | Write diagnostics to stderr |
Tool contracts#
- inputSchema
- JSON Schema. With no
$schema, the dialect is 2020-12. - No arguments
{"type": "object", "additionalProperties": false}.- outputSchema
- Optional. Once published, every result carries conforming
structuredContent. - structuredContent
- Any JSON value. Also send the JSON as a text block.
- Content blocks
text,image,audio,resource_link,resource.- x-mcp-header
- Mirrors a top-level argument to
Mcp-Param-{name}. String, integer or boolean only. - Header encoding
- Non-ASCII or padded values use
=?base64?{Base64UTF8}?=. - Cursors
- Opaque. An empty
nextCursoris valid. Stop only onNone.
Memory server project#
| Command | What it does |
|---|---|
project_test.py --init <workspace> | Creates your workspace. A fresh starter fails |
project_test.py --all --path <workspace> --strict | Grades all four stages |
cli.ts --data-dir <dir> --put <file> | Appends revision 1 to the JSONL log |
cli.ts --data-dir <dir> --query "<text>" | 0.6 lexical plus 0.4 cosine score |
cli.ts --data-dir <dir> --history <id> | Every revision with its source |
--put <file> --revision 1 | Appends revision 2. Revision 0 conflicts |
cli.ts --data-dir <dir> --serve --port 8788 | REST and MCP on one store. Needs MEMORY_TOKEN |
Capstone policy gate#
Questions#
- Tool error or protocol error?
- A failed valid call returns
isError: true. A bad envelope returns a JSON-RPC error. - Does discovery unlock the server?
- No.
tools/listcarries the same request metadata. - Can the server ask the client for input?
- It returns
input_required. It never sends its own request. - How does a client learn a resource changed?
- It opens
subscriptions/listen. It then callsresources/readagain. - Do annotations authorize?
- No.
readOnlyHintanddestructiveHintare hints only.