Skip to the solution
HardSystem Design19 min read

MCP authoring: how do you build a Model Context Protocol server in 2025-26?

777 views

The problem

Question presented to candidate: "Say your company wants its product to be usable from Claude, ChatGPT, Cursor and in-house agents, so you are asked to build an MCP server for it. Walk me through how you would build it today: transport, SDK, tool design, errors, auth, testing and deployment. What changed in the protocol recently that affects your design?"

What a strong answer should cover:

  • Choosing the transport: stdio for a local, per-user process versus Streamable HTTP for a hosted, multi-user service, and what each implies for auth, scaling and logging (stdout is the protocol channel on stdio)
  • The SDK and version landscape as of late 2026: TypeScript v2 packages (@modelcontextprotocol/server) implementing the 2026-07-28 spec while still serving 2025-era clients, the v1 @modelcontextprotocol/sdk maintenance line, and Python mcp 2.x where FastMCP became MCPServer
  • Tool design: few task-shaped tools, model-oriented descriptions, strict input schemas, outputSchema plus structuredContent, and annotations as hints
  • Error semantics: input-validation and execution failures as isError results the model can read and correct, versus JSON-RPC protocol errors such as unknown method or unknown tool
  • Auth and safety for remote servers: OAuth 2.1 style bearer tokens validated by the server, no token passthrough, least-privilege downstream credentials, Origin/Host validation, and treating tool output as untrusted
  • Testing and operations: the MCP Inspector, scripted contract tests against the JSON-RPC surface, and the 2026-07-28 move to a stateless core that removes sticky-session requirements behind load balancers

Clarifying questions expected:

  • Local (runs on the user machine, acts as the user) or remote (we host it for many tenants)?
  • Which hosts must it work with, and which protocol versions do they speak today?
  • Read-only access, or will tools change data or cost money?

Code / implementation expected: Yes โ€” a small server with one or two tools, either with an SDK or as a hand-rolled JSON-RPC loop, plus how you would test it.

  • mcp
  • tooling

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

19 min read ยท 15 sections

Read the code

A minimal MCP server over stdio framing, built by hand
// A minimal MCP server over stdio framing, written by hand so every byte is visible.
// stdio framing = one JSON-RPC message per line; stdout carries protocol only.
const SUPPORTED = ["2025-11-25", "2025-06-18", "2025-03-26"];
const ORDERS = { ord_1001: { status: "shipped", total_cents: 4599 }, ord_1002: { status: "pending", total_cents: 1250 } };
const TOOL = {
  name: "lookup_order",
  title: "Look up an order",
  description: "Return status and total for one order id like ord_1001. Read-only.",
  inputSchema: { type: "object", properties: { order_id: { type: "string", pattern: "^ord_[0-9]+$" } }, required: ["order_id"], additionalProperties: false },
  outputSchema: { type: "object", properties: { status: { type: "string" }, total_cents: { type: "integer" } }, required: ["status", "total_cents"] },
  annotations: { readOnlyHint: true, openWorldHint: false },
};

function validate(args) {  // tiny subset of JSON Schema, enough for this tool
  if (typeof args !== "object" || args === null) return "arguments must be an object";
  for (const k of Object.keys(args)) if (!(k in TOOL.inputSchema.properties)) return "unexpected property " + k;
  if (!("order_id" in args)) return "order_id is required";
  if (typeof args.order_id !== "string") return "order_id must be a string";
  if (!/^ord_[0-9]+$/.test(args.order_id)) return "order_id must match ^ord_[0-9]+$";
  return null;
}
const textResult = (text, isError) => (isError ? { content: [{ type: "text", text }], isError: true } : { content: [{ type: "text", text }] });

function handle(msg) {
  const ok = (result) => ({ jsonrpc: "2.0", id: msg.id, result });
  const err = (code, message) => ({ jsonrpc: "2.0", id: msg.id, error: { code, message } });
  if (msg.id === undefined) return null;  // notifications (e.g. notifications/initialized) get no reply
  switch (msg.method) {
    case "initialize": {
      const asked = msg.params.protocolVersion;
      const version = SUPPORTED.includes(asked) ? asked : SUPPORTED[0];  // else offer our latest
      return ok({ protocolVersion: version, capabilities: { tools: { listChanged: false } }, serverInfo: { name: "orders", version: "1.0.0" } });
    }
    case "tools/list": return ok({ tools: [TOOL] });
    case "tools/call": {
      if (msg.params.name !== TOOL.name) return err(-32602, "Unknown tool: " + msg.params.name);
      const problem = validate(msg.params.arguments);
      if (problem) return ok(textResult("Input validation error: " + problem, true));
      const order = ORDERS[msg.params.arguments.order_id];
      if (!order) return ok(textResult("No order " + msg.params.arguments.order_id + ". Check the id and try again.", true));
      return ok({ content: [{ type: "text", text: JSON.stringify(order) }], structuredContent: order });
    }
    default: return err(-32601, "Method not found: " + msg.method);
  }
}

function serveLine(line) {  // what the stdin read loop does for each line
  let msg;
  try { msg = JSON.parse(line); } catch (e) { return JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }); }
  const res = handle(msg);
  return res === null ? null : JSON.stringify(res);
}

const session = [
  { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2099-01-01", capabilities: {}, clientInfo: { name: "test-client", version: "0" } } },
  { jsonrpc: "2.0", method: "notifications/initialized" },
  { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
  { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "lookup_order", arguments: { order_id: "ord_1001" } } },
  { jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "lookup_order", arguments: { order_id: 1001 } } },
  { jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "lookup_order", arguments: { order_id: "ord_9999" } } },
  { jsonrpc: "2.0", id: 6, method: "tools/call", params: { name: "drop_tables", arguments: {} } },
  { jsonrpc: "2.0", id: 7, method: "resources/list", params: {} },
];
const lines = session.map((m) => JSON.stringify(m)).concat(["{not json"]);
let replies = 0;
let rawExample = "";
for (const line of lines) {
  const out = serveLine(line);
  const m = line.startsWith("{not") ? {} : JSON.parse(line);
  const label = m.method ? m.method + (m.params && m.params.name ? " " + m.params.name + " " + JSON.stringify(m.params.arguments) : "") : line;
  console.log("C> " + label);
  if (out === null) { console.log("S> (no reply)"); continue; }
  replies++;
  const r = JSON.parse(out);
  if (r.error) console.log("S> error " + r.error.code + " " + r.error.message);
  else if (m.method === "initialize") console.log("S> protocolVersion " + r.result.protocolVersion + " (client asked " + m.params.protocolVersion + ")");
  else if (m.method === "tools/list") console.log("S> tools: " + r.result.tools.map((t) => t.name + " requires " + t.inputSchema.required.join(",")).join("; "));
  else if (r.result.isError) { console.log("S> isError: " + r.result.content[0].text); if (!rawExample) rawExample = out; }
  else console.log("S> structuredContent " + JSON.stringify(r.result.structuredContent));
}
console.log("");
console.log("raw wire line for the first tool error:");
console.log(rawExample);
console.log("");
console.log(lines.length + " lines in, " + replies + " lines out; nothing else was written to stdout");
Stateful sessions vs the stateless 2026-07-28 envelope behind a load balancer
// Why the 2026-07-28 MCP revision dropped sessions: two server instances
// behind a load balancer, simulated in-process (no HTTP, no real clock).
function makeInstance(name) {
  let sessions = new Map();  // in-memory session state, lost on restart
  let counter = 0;
  return {
    name,
    restart() { sessions = new Map(); },
    // 2025-era Streamable HTTP, stateful: initialize creates a session id
    legacy(req) {
      if (req.body.method === "initialize") {
        const sid = name + "-s" + ++counter;
        sessions.set(sid, { clientInfo: req.body.params.clientInfo });
        return { status: 200, headers: { "Mcp-Session-Id": sid } };
      }
      const sid = req.headers["Mcp-Session-Id"];
      if (!sid) return { status: 400, note: "Mcp-Session-Id header is required" };
      if (!sessions.has(sid)) return { status: 404, note: "Session not found" };
      return { status: req.body.id === undefined ? 202 : 200 };
    },
    // 2026-07-28 style: everything the server needs rides on each request
    modern(req) {
      const meta = (req.body.params && req.body.params._meta) || {};
      if (!meta["io.modelcontextprotocol/protocolVersion"]) return { status: 400, note: "missing protocol version" };
      if (req.headers["Mcp-Method"] !== req.body.method) return { status: 400, note: "header/body mismatch" };
      return { status: 200 };
    },
  };
}

function run(title, mode, requests, pick, restartAfter) {
  const a = makeInstance("A"), b = makeInstance("B");
  const pool = [a, b];
  let sid = null, ok = 0, i = 0;
  console.log(title);
  for (const body of requests) {
    if (i === restartAfter) { a.restart(); b.restart(); console.log("   (deploy: both instances restart)"); }
    const inst = pool[pick(i)];
    const headers = {};
    if (mode === "legacy" && sid) headers["Mcp-Session-Id"] = sid;
    if (mode === "modern") headers["Mcp-Method"] = body.method;
    const res = inst[mode]({ headers, body });
    if (res.headers && res.headers["Mcp-Session-Id"]) sid = res.headers["Mcp-Session-Id"];
    if (res.status < 300) ok++;
    console.log("   " + String(i + 1).padStart(2) + " " + body.method.padEnd(26) + "-> " + inst.name + " " + res.status + (res.note ? " " + res.note : ""));
    i++;
  }
  console.log("   succeeded " + ok + "/" + requests.length);
  console.log("");
}

const call = (id, name) => ({ jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: {} } });
const legacyReqs = [
  { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "agent", version: "1" } } },
  { jsonrpc: "2.0", method: "notifications/initialized" },
  { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
  call(3, "search"), call(4, "search"), call(5, "fetch"),
];
const roundRobin = (i) => i % 2;
const sticky = () => 0;
run("1. stateful sessions, round-robin balancer", "legacy", legacyReqs, roundRobin, -1);
run("2. stateful sessions, sticky balancer, deploy mid-conversation", "legacy", legacyReqs, sticky, 4);

const meta = { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { name: "agent", version: "1" }, "io.modelcontextprotocol/clientCapabilities": {} };
const withMeta = (r) => ({ ...r, params: { ...r.params, _meta: meta } });
const modernReqs = [
  { jsonrpc: "2.0", id: 1, method: "server/discover", params: {} },
  { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
  call(3, "search"), call(4, "search"), call(5, "fetch"), call(6, "fetch"),
].map(withMeta);
run("3. per-request envelope (2026-07-28 style), round-robin, deploy mid-conversation", "modern", modernReqs, roundRobin, 4);

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 70 of 83 in AI engineering

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextHard

Why are BLEU and ROUGE poor metrics for LLM outputs, and what replaced them?

Continue