A stdio server written with the standard library
In one line
An MCP stdio server is a loop that reads one line from stdin, decodes it as JSON, replies if there is an id, and writes one line to stdout and flushes. If you write that loop with only json, sys and sqlite3, four things the SDK hides become visible: framing, matching responses by id, ignoring notifications, and the two kinds of errors.
Why this was needed
When you build a server with an SDK, it is one decorator and one function. That convenience is nice, but when something goes wrong you cannot read what the logs are saying. Sentences like "cannot parse the response", "response without a matching request" and "received a request before initialize" all come from the protocol layer, and to someone who has never touched that layer directly they are noise.
And incidents happen in that layer. Where a job posting mentions MCP, the actual work is closer to finding out why a server built by another team will not connect to our app than to building a server. To do that work you need experience with hand-building, sending and receiving the message shapes the specification defines. This module builds that experience with only the standard library.
How it works
Framing. The stdio section of the transports specification says messages are delimited by newlines and must not contain newlines. That is why the skeleton of the server is for line in sys.stdin:. A response must also be one line, so you write the result of json.dumps followed by \n and always flush(). Unlike a terminal, a pipe is not line-buffered, so without a flush the response stays trapped in the buffer and the client waits forever. Half of all "the server seems frozen" reports are this.
Three branches by id. When you decode one line with json.loads, three cases come out. If there is an id, it is a request, and you must answer with the same id. If there is no id member, it is a notification, and you do not answer. Testing with msg.get("id") is None cannot tell it apart from an invalid request whose id really is null, so check with "id" in msg. And if json.loads itself fails, you answer with -32700 Parse error and id: null, as section 5 of JSON-RPC 2.0 says. If you do not wrap these three branches in a try, one broken line kills the server, and the client sees that as "the server disappeared".
for line in sys.stdin:
try:
msg = json.loads(line)
except json.JSONDecodeError:
reply(None, error={"code": -32700, "message": "Parse error"}); continue
if "id" not in msg: # 알림 — 답하지 않는다
continue
try:
reply(msg["id"], result=handle(msg))
except RpcError as e: # 프로토콜 오류
reply(msg["id"], error={"code": e.code, "message": e.message})
initialize is the first request. As the lifecycle describes, you return a result containing protocolVersion, capabilities and serverInfo. A server that offers tools must declare capabilities.tools (MUST) — even if that object is empty, the key has to be there. The client then sends notifications/initialized; it is a notification, so the server does not answer. The most common mistake in hand-written servers is to send a response here.
tools/list and tools/call. Both follow the format of the tools specification. Each tool in the list has a name, a description and an inputSchema, and the inputSchema is a JSON Schema with type: object. Because the model reads this schema to build the arguments, the description is effectively the user manual you give the model. A call takes the name and arguments in params and answers with a content array. A nonexistent tool name is a protocol error (the specification's example is -32602), and a failure while the tool is running is isError: true. A request to count orders with an unknown status value belongs to the latter — the model must be able to read the text and call again with a correct value.
Write the client by the same rules. Launch the server with subprocess.Popen, write to its stdin and readline from its stdout. Do not call readline after sending a notification — no answer is coming, so you would wait forever. When you finish, follow the lifecycle's stdio shutdown order: close stdin first, then wait for the server to end on its own.
What it looks like in the field
An MCP server built by one team connected from one host app but failed with "initialization failed" in another. The cause was that the server printed a banner line to stdout on startup. A lenient host discarded the non-JSON line, while a strict host dropped the connection when the first line could not be parsed. A single sentence of the specification ("do not write anything other than MCP messages to stdout") explains the difference between the two hosts. The last step of this module grades that hygiene.
Another case is a server that sends a response to the initialized notification. While the host only logged a warning about an "unmatched id", the response got mixed up in order with the tools/list response that followed, and the symptom was a tool list that looked empty. The consequence of breaking the rule that notifications are not answered surfaced somewhere entirely different.
What you will do in the next lab
You will create a shop DB, build step by step a server that handles everything from initialize to tools/call, then write a client that launches that server as a child process and run one full session. At the end you will check stdout hygiene.