DEV Community

Todd Doane
Todd Doane

Posted on Originally published at calyx-ai.net

A zero-dependency MCP server in one file: what the protocol actually needs

We wanted two checks from our publish gate available inside any MCP client: "is every number in this draft in my facts file" and "does every link in it load". The official SDKs are fine, but for two tools we did not want a dependency tree, so we wrote the server against the protocol directly. It is one file, 119 lines, and it imports only node:fs and node:readline. We also got one part of the protocol wrong on the first pass, which is the most useful section below.

What a stdio MCP server has to do

For a tools-only server on the stdio transport, the job is small:

  • Read JSON-RPC 2.0 messages from stdin, one per line.
  • Write one JSON reply per line to stdout. The spec says the server must not write anything to stdout that is not a valid MCP message; logs go to stderr. [src: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports]
  • Answer four methods: initialize, tools/list, tools/call and ping.
  • Stay silent on notifications (messages without an id), such as notifications/initialized.

The read loop is readline over stdin. This is the same logic as server.js, restructured for reading:

const rl = createInterface({ input: process.stdin });
rl.on("line", async (line) => {
  let msg;
  try { msg = JSON.parse(line); } catch { return; }   // blank or bad line: ignore
  const reply = (body) => {
    if (msg.id === undefined) return;                 // notification: never reply
    process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, ...body }) + "\n");
  };
  try {
    const result = await handle(msg);
    if (result === undefined) return;
    reply({ result });
  } catch (e) {
    reply({ error: { code: e.code ?? -32603, message: e.message } });
  }
});
Enter fullscreen mode Exit fullscreen mode

And handle is a switch:

switch (method) {
  case "initialize":
    return { protocolVersion: params?.protocolVersion ?? "2025-06-18",
             capabilities: { tools: {} },
             serverInfo: { name: "publish-gate", version: VERSION } };
  case "tools/list": return { tools: TOOLS };
  case "tools/call": /* run the tool, see below */
  case "ping": return {};
  default:
    if (id === undefined) return undefined;            // notification: no reply
    throw Object.assign(new Error(`method not found: ${method}`), { code: -32601 });
}
Enter fullscreen mode Exit fullscreen mode

initialize echoes the client's protocol version. That is a shortcut: it works because a tools-only server uses so little of the protocol, and a server that relies on newer features should negotiate properly.

Two kinds of error, and the one we got wrong

The MCP spec splits tool errors in two. Protocol errors are ordinary JSON-RPC errors, for things like an unknown tool or invalid arguments. Tool execution errors are a normal result with isError: true, for failures while the tool runs. [src: https://modelcontextprotocol.io/specification/2025-06-18/server/tools]

Our first version put everything in the second bucket: an unknown tool name or a call with no draft came back as an isError result. That does not follow the spec. The fix is a coded error for bad calls and a rethrow, so the read loop turns it into a JSON-RPC error:

const invalidParams = (message) => Object.assign(new Error(message), { code: -32602 });

// in callTool: reject bad calls before doing any work
if (TOOLS.some((t) => t.name === name) === false) throw invalidParams(`unknown tool: ${name}`);
if (args.draft_text == null && args.draft_path == null) throw invalidParams("give draft_text or draft_path");

// in handle, case "tools/call":
try { return await callTool(params?.name, params?.arguments); }
catch (e) {
  if (e.code === -32602) throw e;                       // protocol error
  return { content: [{ type: "text", text: `error: ${e.message}` }], isError: true };
}
Enter fullscreen mode Exit fullscreen mode

Now an unknown tool or a missing draft gets -32602, an unknown method gets -32601, an unreadable draft_path file comes back as an isError result starting with error:, and anything else thrown becomes -32603. A new stdio test sends an unknown tool, a call without a draft, an unreadable draft_path and an unknown method, and checks each reply.

Each tool returns plain text: PASS, or FAIL (n) followed by one line per finding. That is a design choice for a model reading the result, not something we measured.

Replies can arrive out of order

Handlers are async, so a slow call can finish after a fast one. In a test session today, request 4 (an unknown method) was answered before request 3 (a tools/call to check_numbers). That is allowed: clients match replies by id, never by order. Do not "fix" it with a queue unless a tool really needs ordering.

Test it without a client

You can drive the server from a shell, which is the fastest way to see what a client sees:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
  | node server.js
Enter fullscreen mode Exit fullscreen mode

For unit tests, the server exports its check functions (checkNumbers, checkLinks) and injects what touches the outside world: checkLinks takes a fetchFn, so node --test runs without the network. The server has 5 tests and they pass on Node v26.7.0.

Install it

The server is not on npm yet. From a clone, in Claude Code:

git clone https://github.com/skibuff22/agent-ops-kit-lite
claude mcp add publish-gate -- node "$PWD/agent-ops-kit-lite/mcp/publish-gate/server.js"
Enter fullscreen mode Exit fullscreen mode

We have tested it in Claude Code (claude mcp list shows Connected); other stdio MCP clients should take the same setup (command node, the path to server.js as the argument), but we have not tested them. check_numbers takes the draft and your facts.md as text or paths; check_links takes the draft. The source is in agent-ops-kit-lite (MIT), next to the free Claude Code plugin it belongs to.

These are two of the deterministic checks in our publish gate. The paid Publish Gate adds the rest: style and voice checks, an LLM claim-by-claim check that fails closed, a SHA-256 lock, and publishers that refuse a draft that changed after it passed. $19 one-time.

Written by Calyx AI, a product line of Quality Tester Solutions LLC. Drafted with AI assistance and checked by the gate it describes; the code and the test session are from our own repo.

Top comments (0)