Build an MCP stdio server with the standard library only
Goal
You will write an MCP server using only json, sys and sqlite3. It handles, in order, the initialize → notifications/initialized → tools/list → tools/call sequence that a client sends, and at the end you will also write a client that launches that server as a child process.
Why it matters
With an SDK this takes five lines. But incidents happen in the place the SDK hides from you: a single debug line printed to stdout kills the client's JSON parser, a reply sent to a notification creates a response with no match, and a tool that failed but is returned as a protocol error makes the model conclude "the server is broken". Framing (one line = one message), matching by id, ignoring notifications and telling the two kinds of errors apart are the substance of MCP, and once you have built them by hand, you can read what the SDK's logs are saying.
Steps
- Save
/root/mcp/seed.sqland load it into/root/mcp/shop.db. There must be 5 rows in customers and 8 rows in orders. - Create
/root/mcp/server.py. It must read JSON-RPC requests from stdin one line at a time and answerinitializewith a result whoseprotocolVersionis2025-06-18and that hascapabilities.toolsandserverInfo.name. - In the same file, make it not answer notifications (messages without an id), answer an unknown method with
-32601, and answer broken JSON with a-32700error (id is null). The server must not die. - Return two tools from
tools/list:list_customersandcount_orders(inputSchema.properties.status, a string, required). Each tool must have adescriptionand aninputSchemawithtype: object. - Implement
tools/call. Whencount_ordersis given{"status":"paid"}, the number of paid rows in the DB must appear incontent[0].text, andlist_customersmust include all five customer names. For the DB path, use the environment variableMCP_DBif it is set, and/root/mcp/shop.dbotherwise. - Split errors into two kinds. A nonexistent tool name gets the JSON-RPC error
-32602; ifcount_ordersis given an unknown status value (for examplebanana), the result must containisError: trueand explanatory text. - Create
/root/mcp/client.py. Launch the server withsubprocess, send initialize →notifications/initialized→ tools/list → tools/call(count_orders, paid), and write the outcome to/root/mcp/session.jsonastools(the list of names) andpaid_orders(the response text). If the environment variableMCP_SESSION_OUTis set, write to that path. - Send all of the server's logs to stderr. For each request, one line with the method name must be printed to stderr, and nothing other than JSON responses may appear on stdout.
Notes
- A loop that reads one line at a time can simply be
for line in sys.stdin:. Aftersys.stdout.write(json.dumps(...) + "\n"), alwaysflush()— a pipe is not line-buffered, so without a flush the client waits forever. - You decide whether a message is a notification with
"id" in msg. If you check whethermsg.get("id")is None, you cannot tell it apart from an (invalid) request whose id really is null. - If you do not catch the exception that
json.loadsraises, one broken line kills the whole server. The client sees that as "the server disappeared". - To test by hand:
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | python3 /root/mcp/server.py - Common mistake 1: printing debug statements to stdout with
print(). The grading of step 8 catches it. - Common mistake 2: returning a nonexistent tool with
isError: true. That is not a failure while the tool was running; the request itself is invalid, so it is a protocol error.
Create the shop DB
Save /root/mcp/seed.sql and load it into /root/mcp/shop.db. There must be 5 rows in customers and 8 rows in orders.
With sqlite3, sqlite3 shop.db < seed.sql runs the whole file. In Python, use sqlite3.connect(...).executescript(open(...).read()). Loading again into a DB that already exists gives an error saying the table exists, so delete it first.
Answer initialize
Create /root/mcp/server.py. It must read JSON-RPC requests from stdin one line at a time and answer initialize with a result whose protocolVersion is 2025-06-18 and that has capabilities.tools and serverInfo.name.
In the loop from the skeleton example, when method == "initialize", build a result dictionary and send it with reply(msg["id"], result=...). The result holds three things: protocolVersion, capabilities (an object with a tools key) and serverInfo (name and version).
Do not answer notifications
In the same file, make it not answer notifications (messages without an id), answer an unknown method with -32601, and answer broken JSON with a -32700 error (id is null). The server must not die.
In JSON-RPC, a notification is a request without an id member, and the server must not answer it. Wrap json.loads in a try, and on JSONDecodeError send -32700 with id null and continue. If you also catch the remaining exceptions and answer with -32603, the server survives.
Return the tool list
Return two tools from tools/list: list_customers and count_orders (inputSchema.properties.status, a string, required). Each tool must have a description and an inputSchema with type: object.
The result is {"tools": [...]}, and one tool has three keys: name, description and inputSchema. The inputSchema is a JSON Schema object, so it has "type": "object" and properties. Even a tool with no arguments keeps properties: {}.
Actually run the tools
Implement tools/call. When count_orders is given {"status":"paid"}, the number of paid rows in the DB must appear in content[0].text, and list_customers must include all five customer names. For the DB path, use the environment variable MCP_DB if it is set, and /root/mcp/shop.db otherwise.
params is {"name": 도구이름, "arguments": {...}}, where the placeholder stands for the name of the tool. The result must have the shape {"content": [{"type": "text", "text": "..."}]}. Get the path with os.environ.get("MCP_DB", "/root/mcp/shop.db"), and run sqlite3.connect for each request and close it.
There are two kinds of errors
Split errors into two kinds. A nonexistent tool name gets the JSON-RPC error -32602; if count_orders is given an unknown status value (for example banana), the result must contain isError: true and explanatory text.
The specification separates "an unknown tool or bad arguments" as a protocol error (the error member, example code -32602) from "a failure while the tool was running" as a tool execution error (isError: true inside the result). For the latter, write the reason as text so that the model can read it and try again.
Run one session with the client
Create /root/mcp/client.py. Launch the server with subprocess, send initialize → notifications/initialized → tools/list → tools/call(count_orders, paid), and write the outcome to /root/mcp/session.json as tools (the list of names) and paid_orders (the response text). If the environment variable MCP_SESSION_OUT is set, write to that path.
Launch it with subprocess.Popen([...], stdin=PIPE, stdout=PIPE, text=True), call stdin.flush() after each send, and on receive read one line with stdout.readline() and pass it to json.loads. Do not call readline after you send a notification — no answer comes, so you would wait forever. When you finish, close stdin and call wait().
stdout is for the protocol only
Send all of the server's logs to stderr. For each request, one line with the method name must be printed to stderr, and nothing other than JSON responses may appear on stdout.
Use sys.stderr.write(...) or print(..., file=sys.stderr). The specification (stdio transport) says the server must not write anything other than valid MCP messages to stdout, and that stderr may be used for logs. The grader tries to parse every line of stdout as JSON.