A real MCP server (stdio transport, mcp SDK)#
Track 08 - Production · Notebook 06 · Runtime: ~10s LIVE, <1s replay
Builds an MCP server with the official mcp Python package, drives it from a Python client over stdio, then prints the config snippet any MCP-aware editor needs to attach to it. Replaces the hand-rolled JSON-RPC in 04_agents/05 with the real protocol implementation.
from llm_systems_cookbook.nb import bootstrap
from llm_systems_cookbook._utils import repo_root
import asyncio
import json
import os
from pathlib import Path
s = bootstrap("08_production_06_mcp_real_server")
try:
import mcp # noqa: F401
HAS_MCP = True
except ImportError:
HAS_MCP = False
LIVE = HAS_MCP and bool(os.environ.get("MCP_LIVE"))
FIXTURE = json.loads((Path(repo_root()) / "notebooks/08_production/_fixtures/06_mcp.json").read_text())
print(f"mode={'LIVE' if LIVE else 'REPLAY'} mcp_installed={HAS_MCP}")
The server#
Server.list_tools() and Server.call_tool() decorators register handlers; mcp.server.stdio.stdio_server() is the JSON-RPC-over-stdio transport. The server below runs as python lab_mcp_server.py and speaks the MCP protocol on stdin/stdout — exactly what Cursor or any other MCP client expects.
SERVER_PATH = Path("/tmp") / "lab_mcp_server.py"
SERVER_PATH.write_text('"""A minimal MCP server with three tools, runnable via stdio.\n\n python lab_mcp_server.py # talks JSON-RPC on stdin/stdout\n\nCursor / Claude Code / any MCP-aware client can attach by pointing at this\nfile with `command: python` and `args: [path/to/lab_mcp_server.py]`.\n"""\nfrom __future__ import annotations\n\nimport asyncio\nimport ast\nimport operator as op\nfrom datetime import datetime, timedelta, timezone\n\nimport mcp.server.stdio\nimport mcp.types as types\nfrom mcp.server import Server, NotificationOptions\nfrom mcp.server.models import InitializationOptions\n\nserver = Server("lab-mcp-server")\n\n_OPS = {ast.Add: op.add, ast.Sub: op.sub, ast.Mult: op.mul, ast.Div: op.truediv,\n ast.Pow: op.pow, ast.USub: op.neg, ast.UAdd: op.pos}\n\ndef _safe_eval(node):\n if isinstance(node, ast.Constant): return node.value\n if isinstance(node, ast.BinOp): return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))\n if isinstance(node, ast.UnaryOp): return _OPS[type(node.op)](_safe_eval(node.operand))\n raise ValueError(type(node).__name__)\n\n\nFACTS = {\n "capital of france": "Paris", "capital of japan": "Tokyo",\n "tallest mountain": "Mount Everest",\n}\nNOW = datetime(2026, 4, 17, 12, 0, tzinfo=timezone.utc)\n\n\n@server.list_tools()\nasync def list_tools() -> list[types.Tool]:\n return [\n types.Tool(\n name="calculator",\n description="Evaluate a simple arithmetic expression like \\"2 + 3 * 4\\".",\n inputSchema={"type": "object", "required": ["expression"],\n "properties": {"expression": {"type": "string"}}},\n ),\n types.Tool(\n name="wiki_lookup",\n description="Look up a short fact (capitals, authors, etc.).",\n inputSchema={"type": "object", "required": ["query"],\n "properties": {"query": {"type": "string"}}},\n ),\n types.Tool(\n name="get_date",\n description="Return the date for a relative spec (today, tomorrow, year, ...).",\n inputSchema={"type": "object", "required": ["spec"],\n "properties": {"spec": {"type": "string",\n "enum": ["today","tomorrow","yesterday","year","month","day"]}}},\n ),\n ]\n\n\n@server.call_tool()\nasync def call_tool(name: str, arguments: dict) -> list[types.TextContent]:\n if name == "calculator":\n v = _safe_eval(ast.parse(arguments["expression"], mode="eval").body)\n out = str(int(v)) if isinstance(v, float) and v.is_integer() else str(v)\n elif name == "wiki_lookup":\n out = FACTS.get(arguments["query"].lower().strip(), "no match")\n elif name == "get_date":\n s = arguments["spec"].lower()\n out = {\n "year": str(NOW.year), "month": NOW.strftime("%B"), "day": NOW.strftime("%A"),\n "today": NOW.date().isoformat(),\n "tomorrow": (NOW + timedelta(days=1)).date().isoformat(),\n "yesterday": (NOW - timedelta(days=1)).date().isoformat(),\n }.get(s, f"unknown {s!r}")\n else:\n raise ValueError(f"unknown tool {name!r}")\n return [types.TextContent(type="text", text=out)]\n\n\nasync def main():\n async with mcp.server.stdio.stdio_server() as (read, write):\n await server.run(\n read, write,\n InitializationOptions(\n server_name="lab-mcp-server", server_version="0.1.0",\n capabilities=server.get_capabilities(\n notification_options=NotificationOptions(), experimental_capabilities={}),\n ),\n )\n\n\nif __name__ == "__main__":\n asyncio.run(main())\n')
print(f"server saved to {SERVER_PATH} ({SERVER_PATH.stat().st_size:,} bytes)")
print(SERVER_PATH.read_text().splitlines()[0]) # first line
The client#
stdio_client(StdioServerParameters) spawns the server as a subprocess and yields (read, write) streams. Wrap them in a ClientSession and you get initialize, list_tools, call_tool as awaitables.
async def drive_server() -> dict:
"""Spawn the server (LIVE) and run a small protocol session, or replay."""
if not LIVE:
return {
"tools_list": FIXTURE["tools_list"],
"calls": [{"req": tc, "result": tc["result"]} for tc in FIXTURE["tool_calls"]],
"server_info": {"name": FIXTURE["server_name"], "version": FIXTURE["server_version"]},
}
from mcp import ClientSession # noqa: PLC0415
from mcp.client.stdio import StdioServerParameters, stdio_client # noqa: PLC0415
params = StdioServerParameters(command="python", args=[str(SERVER_PATH)])
out: dict = {"tools_list": [], "calls": [], "server_info": None}
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as sess:
init = await sess.initialize()
out["server_info"] = {"name": init.serverInfo.name,
"version": init.serverInfo.version}
tools = await sess.list_tools()
out["tools_list"] = [
{"name": t.name, "description": t.description, "inputSchema": t.inputSchema}
for t in tools.tools
]
for tc in FIXTURE["tool_calls"]:
resp = await sess.call_tool(tc["name"], tc["arguments"])
text = next((c.text for c in resp.content if c.type == "text"), "")
out["calls"].append({"req": tc, "result": text})
return out
# Jupyter has a running loop, so use top-level await rather than asyncio.run.
session = await drive_server()
print(f"server: {session['server_info']}")
print(f"tools: {[t['name'] for t in session['tools_list']]}")
Tool calls round-tripped#
for entry in session["calls"]:
req = entry["req"]
print(f" {req['name']}({req['arguments']}) → {entry['result']}")
Attaching from an MCP client#
Drop this block into ~/.config/claude-desktop/claude_desktop_config.json, .cursor/mcp.json, or any MCP-compatible client. The same server, now exposed as tools to whatever model the client drives.
config = {
"mcpServers": {
"lab-mcp-server": {
"command": "python",
"args": [str(SERVER_PATH)]
}
}
}
print(json.dumps(config, indent=2))
Checks#
s.check(
"server_advertised_three_tools",
lambda: {t["name"] for t in session["tools_list"]} == {"calculator", "wiki_lookup", "get_date"},
msg=f"got {[t['name'] for t in session['tools_list']]}",
)
s.check(
"calculator_returns_1029",
lambda: any(c["req"]["name"] == "calculator" and "1029" in c["result"] for c in session["calls"]),
msg=f"calls = {session['calls']}",
)
s.check(
"wiki_returns_paris",
lambda: any(c["req"]["name"] == "wiki_lookup" and c["result"] == "Paris" for c in session["calls"]),
)
s.check(
"every_tool_has_input_schema",
lambda: all(
isinstance(t["inputSchema"], dict) and t["inputSchema"].get("type") == "object"
for t in session["tools_list"]
),
)
s.check(
"server_self_identifies",
lambda: session["server_info"]["name"] == "lab-mcp-server",
msg=f"server_info={session['server_info']}",
)
Notes for production#
Stdio is the default for desktop integrations (Claude Code, Cursor, Cody, Zed). For network/multi-tenant deployments use the streamable HTTP transport (
mcp.server.streamable_http), which replaced HTTP+SSE in the 2025-03 spec.Resources and prompts, not just tools. The MCP spec covers three primitives: tools (callables), resources (readable URIs), and prompts (templates). Most servers only need tools, but resources are how you expose a docs corpus or a database read-only.
Auth: stdio inherits the spawning user’s credentials. For HTTP transports the spec uses OAuth 2.1 with PKCE and resource-indicators per RFC 8707.
Real servers worth reading:
github-mcp-server(github.com/github/github-mcp-server),mcp-server-postgres,mcp-server-filesystem. All three are good templates for tools over a system you don’t control.
s.summary()
s.save()