mcp-stdio-server-guide

Joseph19820124/mcp-stdio-server-guide

3.2

If you are the rightful owner of mcp-stdio-server-guide and would like to certify it and/or have it hosted online, please leave a comment on the right or send an email to henry@mcphub.com.

This guide provides a comprehensive walkthrough for building a Model Context Protocol (MCP) server using JSON-RPC 2.0 over STDIO, with a focus on message framing and lifecycle management.

Tools
1
Resources
0
Prompts
0

Build a STDIO MCP Server

A concise, end-to-end guide to implement a Model Context Protocol (MCP) server over STDIO, focusing on JSON-RPC 2.0 message framing and robust lifecycle management. Includes runnable Node.js/TypeScript examples.


What you'll build

  • A minimal MCP server speaking JSON-RPC 2.0 over STDIN/STDOUT
  • Proper message framing using Content-Length headers
  • Capability and tool registration
  • Request/response and notifications handling
  • Graceful shutdown and health checks

Prerequisites

  • Node.js ≥ 18
  • npm or pnpm

Protocol overview (quick)

MCP servers commonly speak JSON-RPC 2.0 with HTTP-like headers for framing. A typical message looks like:

Content-Type: application/json

Content-Length: 123\r\n
\r\n
{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}

Key rules:

  • Each JSON message is preceded by headers (at least Content-Length)
  • A blank line (\r\n\r\n) separates headers from the JSON body
  • Body length matches Content-Length in bytes
  • Bidirectional: both sides can send requests and notifications

Project scaffold

mkdir mcp-stdio-server && cd $_
npm init -y
npm i -D typescript ts-node @types/node
npx tsc --init --rootDir src --outDir dist --esModuleInterop true
mkdir src

Minimal server (TypeScript)

Create src/server.ts:

import { TextDecoder } from 'node:util';

interface JsonRpcRequest {
  jsonrpc: '2.0';
  id?: number | string;
  method: string;
  params?: unknown;
}

interface JsonRpcResponse {
  jsonrpc: '2.0';
  id: number | string | null;
  result?: unknown;
  error?: { code: number; message: string; data?: unknown };
}

const decoder = new TextDecoder('utf-8');
let buffer = Buffer.alloc(0);

process.stdin.on('data', (chunk) => {
  buffer = Buffer.concat([buffer, chunk]);
  parseFrames();
});

function parseFrames() {
  while (true) {
    const headerEnd = buffer.indexOf('\r\n\r\n');
    if (headerEnd === -1) return;

    const header = buffer.slice(0, headerEnd).toString('utf8');
    const match = /Content-Length:\s*(\d+)/i.exec(header);
    if (!match) {
      // invalid frame, drop
      buffer = buffer.slice(headerEnd + 4);
      continue;
    }

    const length = Number(match[1]);
    const total = headerEnd + 4 + length;
    if (buffer.length < total) return; // wait for more data

    const body = buffer.slice(headerEnd + 4, total);
    buffer = buffer.slice(total);

    handleMessage(JSON.parse(decoder.decode(body)) as JsonRpcRequest);
  }
}

function send(message: JsonRpcRequest | JsonRpcResponse) {
  const payload = Buffer.from(JSON.stringify(message), 'utf8');
  const headers = `Content-Length: ${payload.byteLength}\r\n\r\n`;
  process.stdout.write(headers);
  process.stdout.write(payload);
}

function handleMessage(msg: JsonRpcRequest) {
  if (msg.method === 'ping') {
    if (msg.id !== undefined) send({ jsonrpc: '2.0', id: msg.id, result: { pong: true } });
    return;
  }

  // Example: server advertises capabilities
  if (msg.method === 'initialize') {
    send({
      jsonrpc: '2.0',
      id: msg.id ?? null,
      result: {
        serverInfo: { name: 'example-stdio-mcp', version: '0.1.0' },
        capabilities: {
          tools: [
            { name: 'echo', description: 'Echo back your input' },
          ],
          resources: [],
        },
      },
    });
    return;
  }

  if (msg.method === 'callTool') {
    const { name, args } = (msg.params as any) ?? {};
    if (name === 'echo') {
      send({ jsonrpc: '2.0', id: msg.id ?? null, result: { output: args } });
    } else {
      send({ jsonrpc: '2.0', id: msg.id ?? null, error: { code: -32601, message: 'Tool not found' } });
    }
    return;
  }

  // Fallback for unknown methods
  if (msg.id !== undefined) {
    send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `Unknown method: ${msg.method}` } });
  }
}

process.on('SIGINT', () => graceful('SIGINT'));
process.on('SIGTERM', () => graceful('SIGTERM'));

function graceful(reason: string) {
  // optional: send a shutdown notification before exiting
  send({ jsonrpc: '2.0', id: null, method: 'shutdown' } as any);
  setTimeout(() => process.exit(0), 50);
}

Add run scripts to package.json:

{
  "type": "module",
  "scripts": {
    "dev": "ts-node src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js"
  }
}

Quick test

In one terminal, run the server:

npm run dev

In another terminal, send a framed JSON-RPC request:

node -e "const m=JSON.stringify({jsonrpc:'2.0',id:1,method:'ping'});process.stdout.write('Content-Length: '+Buffer.byteLength(m)+'\r\n\r\n'+m)" | npm run dev

You should get a framed response with { pong: true }.

Tips for production-readiness

  • Validate headers and reject oversize payloads
  • Add request timeouts and heartbeat (periodic ping)
  • Log framing/parse errors with hex dumps
  • Use structured logging; avoid console noise on STDIO
  • Consider MessagePack for body encoding if both sides support it
  • Implement backpressure and concurrency limits for tool calls

Useful references