TT Lab
Get started
Learn Learning paths Courses

The agent dropped my database

Build an MCP stdio server with the standard library only

Continue in TT Lab

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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

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.