{"course":{"slug":"agents-and-automation","title":"Agents and Automation","url":"https://addaly.com/learn/agents-and-automation","level":"intermediate","track":"build"},"lesson":{"slug":"writing-an-mcp-server","title":"Writing a small MCP server","url":"https://addaly.com/learn/agents-and-automation/writing-an-mcp-server","position":56,"of":72,"minutes":10,"answer":"An MCP server is a small process answering JSON-RPC over a pipe; the docstring is the interface, and anything printed to standard output breaks the protocol.","keyIdea":"An MCP server is a small process answering JSON-RPC over a pipe; the docstring is the interface, and anything printed to standard output breaks the protocol."},"body":{"format":"markdown","text":"## The shape of the thing\n\nStrip the diagrams and a Model Context Protocol server is a program that speaks JSON-RPC 2.0 down a pipe. Two transports matter: **stdio**, where the client starts your program as a subprocess and talks over standard input and output, and **streamable HTTP**, for a server running somewhere else.\n\nThree primitives are worth knowing:\n\n- **Tools**: model-invoked functions. This is what you will write.\n- **Resources**: data the application pulls in as context, addressed by URI.\n- **Prompts**: templates a *user* explicitly chooses.\n\nMost servers are three tools and nothing else. Start there.\n\n## A minimal server\n\n```python\nfrom mcp.server.fastmcp import FastMCP\n\nmcp = FastMCP(\"orders\")\n\n@mcp.tool()\ndef get_order(order_id: str) -> str:\n    \"\"\"Look up one order by id.\n\n    Returns status, amount paid and refund eligibility.\n    order_id looks like ORD-48213. If you only have an email\n    address, call find_orders_by_email first.\n    \"\"\"\n    row = db.fetch_one(order_id)\n    if row is None:\n        return f\"No order found with id {order_id!r}. Ids look like ORD-48213.\"\n    return f\"status={row.status} amount={row.amount} refunded={row.refunded}\"\n\nif __name__ == \"__main__\":\n    mcp.run()\n```\n\nLook at what the framework does with that. **The type hints become the input schema. The docstring becomes the description the model reads.** Everything from the tool-design lessons applies here and is now enforced by the language: a vague docstring is a vague interface, and a `str` where you meant an enum is a free-text field the model will fill with whatever it likes. Use `Literal[\"pending\", \"shipped\"]` and get an enum in the schema for free.\n\n## Wiring it up\n\nA client is configured with a command, its arguments, and an environment:\n\n```json\n{\n  \"mcpServers\": {\n    \"orders\": {\n      \"command\": \"python\",\n      \"args\": [\"/opt/agents/orders_server.py\"],\n      \"env\": { \"DATABASE_URL\": \"postgres://...\" }\n    }\n  }\n}\n```\n\nThe trap is right there in `env`. A stdio server receives **the environment the client gives it**, not the one in your interactive shell. A variable exported in your shell profile, which works perfectly when you run the script by hand, is absent when the client launches it. The symptom is a server that starts and then fails on its first call, and the cause is invisible from either side.\n\n## The single most common bug\n\n**Never print to standard output.** On a stdio server, stdout *is* the protocol channel. One stray `print()` (a debug line, a library's progress bar, a warning) corrupts the JSON-RPC stream and the client disconnects with a parse error that names none of this.\n\nLog to stderr. Every time. Configure any library that might print to do the same.\n\n## Debug it before an agent touches it\n\nThe reference **MCP Inspector** gives you a local interface that lists your tools and lets you call them by hand with arguments you choose. Use it first, always. Debugging a tool through an agent means debugging two non-deterministic systems at once, and you will blame the wrong one.\n\n## When to write one, and when not to\n\nIf one codebase uses the tool, a plain function is simpler and you should write a plain function. Write a server when several different clients need the same tools, or when you want them available inside an editor or desktop application you did not build. That is the whole value: it is the N-times-M problem again, and the server is the once-per-N side.\n\n## One security note carried forward\n\nA remote server can change its tool descriptions after you have approved them, and tool descriptions are prompt. Pin versions where you can, read what you connect to, and treat a third-party server the way you would treat a dependency that runs with your credentials, because that is what it is."},"check":{"question":"A developer writes a stdio MCP server that works when run by hand but disconnects with a JSON parse error as soon as the client calls a tool. The server logs progress with `print()`. What is happening?","options":[{"text":"The tool schema is invalid, so the client cannot parse the tool list.","correct":false,"tempting":"Would fail at listing time, before any call, and would produce a schema error rather than a parse error mid-session."},{"text":"The environment variables are missing, so the tool raises and returns malformed output.","correct":false,"tempting":"A genuine and common stdio failure, but a raised exception is reported as a tool error, not as a corrupted protocol stream."},{"text":"The print statements write to standard output, which is the protocol channel, so the log lines are interleaved with JSON-RPC messages and corrupt them.","correct":true},{"text":"The server needs the HTTP transport; stdio does not support tool calls.","correct":false,"tempting":"Misstates the protocol. Stdio supports the full feature set and is the usual transport for a local server."}]},"prev":{"slug":"mcp-and-tool-standards","title":"MCP, and why a standard for tools matters","url":"https://addaly.com/learn/agents-and-automation/mcp-and-tool-standards"},"next":{"slug":"triggers-and-schedules","title":"How the thing starts","url":"https://addaly.com/learn/agents-and-automation/triggers-and-schedules"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'Writing a small MCP server', in Agents and Automation. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/agents-and-automation/writing-an-mcp-server (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"Writing a small MCP server","courseTitle":"Agents and Automation","module":{"index":7,"slug":"shipping-and-operating","title":"Shipping it, and keeping it alive"},"answer":null,"keyIdea":"An MCP server is a small process answering JSON-RPC over a pipe; the docstring is the interface, and anything printed to standard output breaks the protocol.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"the-shape-of-the-thing","runs":[{"text":"The shape of the thing"}]},{"t":"p","runs":[{"text":"Strip the diagrams and a Model Context Protocol server is a program that speaks JSON-RPC 2.0 down a pipe. Two transports matter: "},{"text":"stdio","b":true},{"text":", where the client starts your program as a subprocess and talks over standard input and output, and "},{"text":"streamable HTTP","b":true},{"text":", for a server running somewhere else."}]},{"t":"p","runs":[{"text":"Three primitives are worth knowing:"}]},{"t":"ul","items":[[{"text":"Tools","b":true},{"text":": model-invoked functions. This is what you will write."}],[{"text":"Resources","b":true},{"text":": data the application pulls in as context, addressed by URI."}],[{"text":"Prompts","b":true},{"text":": templates a "},{"text":"user","i":true},{"text":" explicitly chooses."}]]},{"t":"p","runs":[{"text":"Most servers are three tools and nothing else. Start there."}]},{"t":"h2","id":"a-minimal-server","runs":[{"text":"A minimal server"}]},{"t":"code","lang":"python","label":"Python","text":"from mcp.server.fastmcp import FastMCP\n\nmcp = FastMCP(\"orders\")\n\n@mcp.tool()\ndef get_order(order_id: str) -> str:\n    \"\"\"Look up one order by id.\n\n    Returns status, amount paid and refund eligibility.\n    order_id looks like ORD-48213. If you only have an email\n    address, call find_orders_by_email first.\n    \"\"\"\n    row = db.fetch_one(order_id)\n    if row is None:\n        return f\"No order found with id {order_id!r}. Ids look like ORD-48213.\"\n    return f\"status={row.status} amount={row.amount} refunded={row.refunded}\"\n\nif __name__ == \"__main__\":\n    mcp.run()"},{"t":"p","runs":[{"text":"Look at what the framework does with that. "},{"text":"The type hints become the input schema. The docstring becomes the description the model reads.","b":true},{"text":" Everything from the tool-design lessons applies here and is now enforced by the language: a vague docstring is a vague interface, and a "},{"text":"str","code":true},{"text":" where you meant an enum is a free-text field the model will fill with whatever it likes. Use "},{"text":"Literal[\"pending\", \"shipped\"]","code":true},{"text":" and get an enum in the schema for free."}]},{"t":"h2","id":"wiring-it-up","runs":[{"text":"Wiring it up"}]},{"t":"p","runs":[{"text":"A client is configured with a command, its arguments, and an environment:"}]},{"t":"code","lang":"json","label":"JSON","text":"{\n  \"mcpServers\": {\n    \"orders\": {\n      \"command\": \"python\",\n      \"args\": [\"/opt/agents/orders_server.py\"],\n      \"env\": { \"DATABASE_URL\": \"postgres://...\" }\n    }\n  }\n}"},{"t":"p","runs":[{"text":"The trap is right there in "},{"text":"env","code":true},{"text":". A stdio server receives "},{"text":"the environment the client gives it","b":true},{"text":", not the one in your interactive shell. A variable exported in your shell profile, which works perfectly when you run the script by hand, is absent when the client launches it. The symptom is a server that starts and then fails on its first call, and the cause is invisible from either side."}]},{"t":"h2","id":"the-single-most-common-bug","runs":[{"text":"The single most common bug"}]},{"t":"p","runs":[{"text":"Never print to standard output.","b":true},{"text":" On a stdio server, stdout "},{"text":"is","i":true},{"text":" the protocol channel. One stray "},{"text":"print()","code":true},{"text":" (a debug line, a library’s progress bar, a warning) corrupts the JSON-RPC stream and the client disconnects with a parse error that names none of this."}]},{"t":"p","runs":[{"text":"Log to stderr. Every time. Configure any library that might print to do the same."}]},{"t":"h2","id":"debug-it-before-an-agent-touches-it","runs":[{"text":"Debug it before an agent touches it"}]},{"t":"p","runs":[{"text":"The reference "},{"text":"MCP Inspector","b":true},{"text":" gives you a local interface that lists your tools and lets you call them by hand with arguments you choose. Use it first, always. Debugging a tool through an agent means debugging two non-deterministic systems at once, and you will blame the wrong one."}]},{"t":"h2","id":"when-to-write-one-and-when-not-to","runs":[{"text":"When to write one, and when not to"}]},{"t":"p","runs":[{"text":"If one codebase uses the tool, a plain function is simpler and you should write a plain function. Write a server when several different clients need the same tools, or when you want them available inside an editor or desktop application you did not build. That is the whole value: it is the N-times-M problem again, and the server is the once-per-N side."}]},{"t":"h2","id":"one-security-note-carried-forward","runs":[{"text":"One security note carried forward"}]},{"t":"p","runs":[{"text":"A remote server can change its tool descriptions after you have approved them, and tool descriptions are prompt. Pin versions where you can, read what you connect to, and treat a third-party server the way you would treat a dependency that runs with your credentials, because that is what it is."}]}],"check":{"question":[{"text":"A developer writes a stdio MCP server that works when run by hand but disconnects with a JSON parse error as soon as the client calls a tool. The server logs progress with "},{"text":"print()","code":true},{"text":". What is happening?"}],"options":[{"text":[{"text":"The tool schema is invalid, so the client cannot parse the tool list."}],"correct":false,"tempting":[{"text":"Would fail at listing time, before any call, and would produce a schema error rather than a parse error mid-session."}]},{"text":[{"text":"The environment variables are missing, so the tool raises and returns malformed output."}],"correct":false,"tempting":[{"text":"A genuine and common stdio failure, but a raised exception is reported as a tool error, not as a corrupted protocol stream."}]},{"text":[{"text":"The print statements write to standard output, which is the protocol channel, so the log lines are interleaved with JSON-RPC messages and corrupt them."}],"correct":true,"tempting":null},{"text":[{"text":"The server needs the HTTP transport; stdio does not support tool calls."}],"correct":false,"tempting":[{"text":"Misstates the protocol. Stdio supports the full feature set and is the usual transport for a local server."}]}],"order":[1,2,3,0],"why":null},"prev":{"slug":"mcp-and-tool-standards","title":"MCP, and why a standard for tools matters","minutes":8},"next":{"slug":"triggers-and-schedules","title":"How the thing starts","minutes":8}}}