Mcp

MCP Protocol Specification Deep Dive

An advanced deep dive into the Model Context Protocol specification, covering JSON-RPC wire format, capability negotiation, tool/resource/prompt lifecycles, transports, pagination, cancellation, and building a raw protocol server.

MCP Protocol Specification Deep Dive

Overview

The Model Context Protocol (MCP) is built on top of JSON-RPC 2.0, and everything that happens between a client and server -- capability negotiation, tool discovery, resource reads, progress reporting -- is expressed as JSON-RPC messages over a transport layer. This article dissects the protocol specification at the wire level: every message format, every lifecycle, every error code, and every edge case you will encounter when implementing MCP without an SDK. If you want to truly understand what is happening under the hood when Claude calls a tool or reads a resource, this is where you need to be.

Prerequisites

  • Node.js 18+ installed
  • Strong understanding of JSON-RPC 2.0 (request/response/notification patterns)
  • Familiarity with MCP concepts (tools, resources, prompts) -- see the MCP Fundamentals article
  • Experience building TCP or stdio-based servers in Node.js
  • Comfort reading protocol specifications and wire-level message dumps

JSON-RPC 2.0: The Wire Protocol

MCP uses JSON-RPC 2.0 as its message framing layer. Every single message exchanged between client and server is one of four JSON-RPC object types. There are no exceptions.

Request

A request expects a response. The id field ties them together.

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {}
}

The id can be a string or an integer. The params field is optional -- if the method takes no arguments, you can omit it entirely. But I always include it explicitly as an empty object for clarity.

Response (Success)

A successful response carries a result field:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [
      {
        "name": "get_weather",
        "description": "Get current weather for a city",
        "inputSchema": {
          "type": "object",
          "properties": {
            "city": { "type": "string" }
          },
          "required": ["city"]
        }
      }
    ]
  }
}

Response (Error)

An error response carries an error field instead of result. Never both.

{
  "jsonrpc": "2.0",
  "id": 2,
  "error": {
    "code": -32602,
    "message": "Invalid params: 'city' is required",
    "data": {
      "field": "city",
      "constraint": "required"
    }
  }
}

The data field is optional but extremely useful for debugging. Put structured information there.

Notification

A notification is a request with no id. The sender does not expect a response. Notifications are fire-and-forget.

{
  "jsonrpc": "2.0",
  "method": "notifications/tools/list_changed",
  "params": {}
}

This is a critical distinction. If you send a notification with an id field, the receiver will treat it as a request and try to send a response. If you send a request without an id, the receiver will treat it as a notification and never respond. Getting this wrong creates silent failures that are extremely difficult to debug.

The Initialize Handshake

Every MCP session begins with an initialize request from the client. This is non-negotiable. No other method may be called before initialization completes. The handshake serves three purposes: protocol version agreement, capability negotiation, and identity exchange.

Client Sends Initialize Request

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-03-26",
    "capabilities": {
      "roots": {
        "listChanged": true
      },
      "sampling": {}
    },
    "clientInfo": {
      "name": "my-ai-app",
      "version": "1.0.0"
    }
  }
}

The protocolVersion is a date string, not a semver version. The protocol uses date-based versioning because the spec evolves in discrete snapshots. As of this writing, 2025-03-26 is the current version. The client sends the version it wants to speak. The server can accept it or reject it.

Server Responds

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-03-26",
    "capabilities": {
      "tools": {
        "listChanged": true
      },
      "resources": {
        "subscribe": true,
        "listChanged": true
      },
      "prompts": {
        "listChanged": true
      },
      "logging": {}
    },
    "serverInfo": {
      "name": "my-data-server",
      "version": "2.1.0"
    }
  }
}

Client Sends Initialized Notification

After receiving the initialize response, the client sends an initialized notification to signal that it has processed the server's capabilities and is ready to proceed:

{
  "jsonrpc": "2.0",
  "method": "notifications/initialized",
  "params": {}
}

This is a notification (no id), not a request. The server does not respond to it. After this notification, the session is live and both sides can start sending requests.

The full handshake flow:

Client                          Server
  |                               |
  |--- initialize (request) ----->|
  |                               |
  |<-- initialize (response) -----|
  |                               |
  |--- initialized (notify) ----->|
  |                               |
  |   [session is now active]     |

Capability Negotiation

Capabilities tell each side what the other supports. The client and server each declare their capabilities independently. You must not call methods that the other side has not declared support for.

Server Capabilities

Capability Meaning
tools Server exposes callable tools. If listChanged is true, the server will send notifications/tools/list_changed when the tool list changes at runtime.
resources Server exposes readable resources. subscribe means the server supports resources/subscribe for real-time updates. listChanged means it will notify when the resource list changes.
prompts Server exposes prompt templates. listChanged follows the same pattern.
logging Server supports logging/setLevel and will send notifications/message log entries.
completions Server supports argument autocompletion via completion/complete.

Client Capabilities

Capability Meaning
roots Client can provide filesystem roots via roots/list. If listChanged is true, the client will notify when roots change.
sampling Client supports server-initiated LLM calls via sampling/createMessage. This is powerful and dangerous -- more on this later.

A well-behaved implementation checks capabilities before making calls. If the server did not declare tools in its capabilities, do not call tools/list. If the client did not declare sampling, do not attempt sampling/createMessage. Calling unsupported methods should result in a -32601 Method not found error.

Tools Lifecycle

Tools are the most commonly used MCP primitive. They represent functions the AI model can call.

tools/list

The client discovers available tools:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/list",
  "params": {}
}

Response:

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "query_database",
        "description": "Execute a read-only SQL query against the analytics database",
        "inputSchema": {
          "type": "object",
          "properties": {
            "sql": {
              "type": "string",
              "description": "The SQL query to execute"
            },
            "limit": {
              "type": "integer",
              "description": "Maximum rows to return",
              "default": 100
            }
          },
          "required": ["sql"]
        }
      }
    ]
  }
}

The inputSchema follows JSON Schema draft 2020-12. The model uses this schema to understand what arguments to pass. Write descriptive, specific description fields -- the model reads them.

tools/call

The client invokes a tool:

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "query_database",
    "arguments": {
      "sql": "SELECT COUNT(*) as total FROM orders WHERE status = 'completed'",
      "limit": 1
    }
  }
}

Response:

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Query returned 1 row:\n\ntotal: 14,892"
      }
    ],
    "isError": false
  }
}

The content array uses the same content block format as the rest of the protocol. Content blocks can be text, image (with base64 data and mimeType), or resource (an embedded resource). The isError flag is critical -- when set to true, it tells the model that the tool execution failed but the protocol-level call succeeded. This distinction matters: a JSON-RPC error means the protocol itself failed (bad method, bad params). An isError: true result means the tool ran but produced an error (database connection failed, file not found, etc.).

{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Error: Connection refused to database at 10.0.1.5:5432. The analytics database may be down for maintenance."
      }
    ],
    "isError": true
  }
}

tools/list_changed Notification

If the server declared tools: { listChanged: true }, it can notify the client when tools are added, removed, or modified at runtime:

{
  "jsonrpc": "2.0",
  "method": "notifications/tools/list_changed",
  "params": {}
}

The client should respond by calling tools/list again to get the updated list.

Resources Lifecycle

Resources represent data the model can read. Unlike tools, resources are not actions -- they are data sources identified by URIs.

resources/list

{
  "jsonrpc": "2.0",
  "id": 5,
  "method": "resources/list",
  "params": {}
}

Response:

{
  "jsonrpc": "2.0",
  "id": 5,
  "result": {
    "resources": [
      {
        "uri": "file:///var/log/app/server.log",
        "name": "Application Server Log",
        "description": "Most recent application server log file",
        "mimeType": "text/plain"
      },
      {
        "uri": "postgres://analytics/schema",
        "name": "Database Schema",
        "description": "Current table and column definitions",
        "mimeType": "application/json"
      }
    ]
  }
}

Resource URIs can use any scheme. The file:// scheme is common for local files. Custom schemes like postgres:// or github:// are perfectly valid -- the server defines how to resolve them.

resources/read

{
  "jsonrpc": "2.0",
  "id": 6,
  "method": "resources/read",
  "params": {
    "uri": "postgres://analytics/schema"
  }
}

Response:

{
  "jsonrpc": "2.0",
  "id": 6,
  "result": {
    "contents": [
      {
        "uri": "postgres://analytics/schema",
        "mimeType": "application/json",
        "text": "{\"tables\":[{\"name\":\"orders\",\"columns\":[{\"name\":\"id\",\"type\":\"integer\"},{\"name\":\"status\",\"type\":\"varchar(50)\"}]}]}"
      }
    ]
  }
}

Note the field is contents (plural), and each content item includes its uri. For binary data, use blob instead of text with a base64-encoded string.

resources/subscribe

If the server supports subscriptions, the client can watch a resource for changes:

{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "resources/subscribe",
  "params": {
    "uri": "file:///var/log/app/server.log"
  }
}

When the resource changes, the server sends:

{
  "jsonrpc": "2.0",
  "method": "notifications/resources/updated",
  "params": {
    "uri": "file:///var/log/app/server.log"
  }
}

The notification tells the client that the resource changed, not what changed. The client must call resources/read again to get the new content.

Resource Templates

Servers can also expose resource templates -- URI patterns with parameters:

{
  "jsonrpc": "2.0",
  "id": 8,
  "method": "resources/templates/list",
  "params": {}
}
{
  "jsonrpc": "2.0",
  "id": 8,
  "result": {
    "resourceTemplates": [
      {
        "uriTemplate": "github://repos/{owner}/{repo}/issues/{issue_number}",
        "name": "GitHub Issue",
        "description": "A specific GitHub issue",
        "mimeType": "application/json"
      }
    ]
  }
}

The model fills in the template parameters and calls resources/read with the resolved URI.

Prompts Lifecycle

Prompts are reusable templates that guide model behavior. They are the least understood MCP primitive, but they are incredibly useful for encoding domain-specific instructions.

prompts/list

{
  "jsonrpc": "2.0",
  "id": 9,
  "method": "prompts/list",
  "params": {}
}

Response:

{
  "jsonrpc": "2.0",
  "id": 9,
  "result": {
    "prompts": [
      {
        "name": "analyze_logs",
        "description": "Analyze application logs for errors and anomalies",
        "arguments": [
          {
            "name": "timeframe",
            "description": "Time range to analyze (e.g., '1h', '24h', '7d')",
            "required": true
          },
          {
            "name": "severity",
            "description": "Minimum severity level to include",
            "required": false
          }
        ]
      }
    ]
  }
}

prompts/get

{
  "jsonrpc": "2.0",
  "id": 10,
  "method": "prompts/get",
  "params": {
    "name": "analyze_logs",
    "arguments": {
      "timeframe": "24h",
      "severity": "error"
    }
  }
}

Response:

{
  "jsonrpc": "2.0",
  "id": 10,
  "result": {
    "description": "Analyze application logs for the last 24 hours",
    "messages": [
      {
        "role": "user",
        "content": {
          "type": "text",
          "text": "Analyze the following application logs from the last 24 hours. Focus on errors with severity 'error' or higher. Identify patterns, root causes, and recommend fixes.\n\n[Log data would be embedded here by the server]"
        }
      }
    ]
  }
}

The messages array returns content in the familiar role/content format. The server can embed resource data directly into prompt messages, creating rich, context-aware interactions.

Notifications

MCP defines several notification types. These are all fire-and-forget -- no id, no response.

List Changed Notifications

{"jsonrpc":"2.0","method":"notifications/tools/list_changed","params":{}}
{"jsonrpc":"2.0","method":"notifications/resources/list_changed","params":{}}
{"jsonrpc":"2.0","method":"notifications/prompts/list_changed","params":{}}

These tell the client to re-fetch the corresponding list. Useful when your server dynamically adds or removes capabilities.

Progress Notifications

For long-running tool calls, the server sends progress updates:

{
  "jsonrpc": "2.0",
  "method": "notifications/progress",
  "params": {
    "progressToken": "op-42",
    "progress": 75,
    "total": 100,
    "message": "Processing row 750 of 1000"
  }
}

The progressToken links the notification to the original request (see Progress Reporting below).

Log Messages

{
  "jsonrpc": "2.0",
  "method": "notifications/message",
  "params": {
    "level": "warning",
    "logger": "database",
    "data": "Connection pool at 90% capacity (9/10 connections in use)"
  }
}

Log levels follow syslog convention: debug, info, notice, warning, error, critical, alert, emergency.

Error Codes

MCP uses standard JSON-RPC error codes plus its own extensions.

Standard JSON-RPC Codes

Code Name Meaning
-32700 Parse error Invalid JSON received
-32600 Invalid Request JSON is valid but not a valid JSON-RPC request
-32601 Method not found The method does not exist or is not available
-32602 Invalid params Invalid method parameters
-32603 Internal error Internal JSON-RPC error

MCP-Specific Codes

Code Name Meaning
-32001 Resource not found The requested resource URI does not exist
-32002 Tool not found The requested tool name does not exist
-32003 Prompt not found The requested prompt name does not exist

An important protocol-level distinction: if a tool exists but its execution fails (database down, network timeout, etc.), return a success response with isError: true in the result. Reserve JSON-RPC error codes for actual protocol-level failures -- the tool does not exist, the parameters are malformed, the server has an internal bug.

Transport Details

MCP supports two official transports. The protocol itself is transport-agnostic -- the same JSON-RPC messages flow regardless of how they are delivered.

Stdio Transport

The most common transport for local integrations. The client spawns the server as a child process. Messages flow over stdin/stdout.

Framing: Each JSON-RPC message is a single line of JSON followed by a newline character (\n). No length prefixes, no HTTP headers. One line, one message.

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}\n

Critical rule: stderr is reserved for logging. Never write protocol messages to stderr. Never write log messages to stdout. Mixing these streams will corrupt the protocol.

stdout: JSON-RPC messages only
stderr: human-readable log output only

Streamable HTTP Transport

For remote servers, MCP uses Streamable HTTP. This replaced the older SSE-based transport.

The server exposes a single HTTP endpoint (typically /mcp). The client sends JSON-RPC messages as POST requests with Content-Type: application/json. The server can respond in two ways:

  1. Direct response: Return Content-Type: application/json with the JSON-RPC response in the body.
  2. Streaming response: Return Content-Type: text/event-stream and send the response (plus any notifications) as SSE events.

Session management uses the Mcp-Session-Id header. The server generates a session ID during initialization and returns it in the response header. The client includes it in all subsequent requests.

POST /mcp HTTP/1.1
Content-Type: application/json
Mcp-Session-Id: sess_abc123

{"jsonrpc":"2.0","id":5,"method":"tools/list","params":{}}

The client can also open a long-lived GET request to the same endpoint to receive server-initiated notifications (like tools/list_changed) as SSE events.

Pagination with Cursors

Any list method (tools/list, resources/list, prompts/list) supports cursor-based pagination. This matters when a server exposes hundreds or thousands of items.

First request:

{
  "jsonrpc": "2.0",
  "id": 11,
  "method": "tools/list",
  "params": {}
}

Response with cursor:

{
  "jsonrpc": "2.0",
  "id": 11,
  "result": {
    "tools": [ /* first 50 tools */ ],
    "nextCursor": "eyJvZmZzZXQiOjUwfQ=="
  }
}

Next page:

{
  "jsonrpc": "2.0",
  "id": 12,
  "method": "tools/list",
  "params": {
    "cursor": "eyJvZmZzZXQiOjUwfQ=="
  }
}

When there is no nextCursor in the response, you have reached the last page. The cursor format is opaque -- the client should treat it as an opaque string and never try to parse or construct it.

Cancellation with $/cancelRequest

Either side can cancel an in-flight request:

{
  "jsonrpc": "2.0",
  "method": "$/cancelRequest",
  "params": {
    "id": 3
  }
}

This is a notification (no id field). The id in params refers to the original request being cancelled. The receiver should abort the operation if possible and respond with an error:

{
  "jsonrpc": "2.0",
  "id": 3,
  "error": {
    "code": -32800,
    "message": "Request cancelled"
  }
}

Error code -32800 is reserved for cancellation. There is no guarantee that the cancellation arrives before the response -- the receiver may have already sent the result. The client must handle both cases.

Progress Reporting with progressToken

To enable progress reporting, the client includes a progressToken in the _meta field of the request:

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "import_csv",
    "arguments": {
      "file": "/data/large-dataset.csv"
    },
    "_meta": {
      "progressToken": "import-op-1"
    }
  }
}

The server then sends progress notifications referencing that token:

{
  "jsonrpc": "2.0",
  "method": "notifications/progress",
  "params": {
    "progressToken": "import-op-1",
    "progress": 250,
    "total": 1000,
    "message": "Imported 250 of 1000 records"
  }
}

The total field is optional. If you do not know the total ahead of time, omit it and just send progress as an increasing counter.

Roots for Filesystem Access

Roots let the client tell the server which filesystem directories it is allowed to access. This is a security boundary.

When the server needs to know its working directories, it calls roots/list on the client:

{
  "jsonrpc": "2.0",
  "id": 20,
  "method": "roots/list",
  "params": {}
}

Response from client:

{
  "jsonrpc": "2.0",
  "id": 20,
  "result": {
    "roots": [
      {
        "uri": "file:///home/user/project",
        "name": "Project Directory"
      },
      {
        "uri": "file:///home/user/data",
        "name": "Data Directory"
      }
    ]
  }
}

The server should restrict file operations to these roots. If the client declared roots: { listChanged: true }, it will send notifications/roots/list_changed when the user opens a different project.

Sampling: Server-Initiated LLM Calls

Sampling is the most powerful and most dangerous feature in MCP. It lets the server ask the client to run an LLM inference. This enables agentic patterns where the server can reason about its own data.

The server sends a sampling/createMessage request to the client:

{
  "jsonrpc": "2.0",
  "id": 30,
  "method": "sampling/createMessage",
  "params": {
    "messages": [
      {
        "role": "user",
        "content": {
          "type": "text",
          "text": "Classify this log entry as INFO, WARNING, ERROR, or CRITICAL:\n\nFailed to connect to database after 3 retries. Last error: ECONNREFUSED 10.0.1.5:5432"
        }
      }
    ],
    "maxTokens": 50,
    "modelPreferences": {
      "hints": [
        { "name": "claude-3-5-haiku" }
      ],
      "speedPriority": 0.8,
      "costPriority": 0.9
    },
    "systemPrompt": "You are a log classifier. Respond with exactly one word: INFO, WARNING, ERROR, or CRITICAL."
  }
}

The client processes this -- typically showing the user a consent dialog -- and returns:

{
  "jsonrpc": "2.0",
  "id": 30,
  "result": {
    "role": "assistant",
    "content": {
      "type": "text",
      "text": "ERROR"
    },
    "model": "claude-3-5-haiku-20241022",
    "stopReason": "end_turn"
  }
}

The modelPreferences field is advisory -- the client is free to use whatever model it wants. The hints array suggests preferred models, and the priority fields (0.0 to 1.0) indicate relative importance of speed, cost, and intelligence.

I want to be very clear about the security implications: sampling means an MCP server can trigger arbitrary LLM calls through the client, potentially at the user's expense. This is why the client capability sampling must be explicitly declared and why most clients show a consent dialog before executing sampling requests.

Complete Working Example: Raw Protocol MCP Server

Here is a complete MCP server that handles JSON-RPC directly over stdio, without the SDK. This implements initialize, tools/list, tools/call, and resources/read from scratch so you can see exactly what happens on the wire.

var readline = require("readline");

// Server state
var initialized = false;
var SERVER_INFO = {
  name: "raw-mcp-server",
  version: "1.0.0"
};
var PROTOCOL_VERSION = "2025-03-26";

// Define our tools
var tools = [
  {
    name: "calculate",
    description: "Evaluate a mathematical expression",
    inputSchema: {
      type: "object",
      properties: {
        expression: {
          type: "string",
          description: "Math expression to evaluate (e.g., '2 + 3 * 4')"
        }
      },
      required: ["expression"]
    }
  },
  {
    name: "get_timestamp",
    description: "Get the current UTC timestamp in ISO 8601 format",
    inputSchema: {
      type: "object",
      properties: {}
    }
  }
];

// Define our resources
var resources = [
  {
    uri: "server://info",
    name: "Server Information",
    description: "Current server status and configuration",
    mimeType: "application/json"
  }
];

// Send a JSON-RPC message to stdout
function sendMessage(message) {
  var json = JSON.stringify(message);
  process.stdout.write(json + "\n");
}

// Send a JSON-RPC success response
function sendResult(id, result) {
  sendMessage({
    jsonrpc: "2.0",
    id: id,
    result: result
  });
}

// Send a JSON-RPC error response
function sendError(id, code, message, data) {
  var error = { code: code, message: message };
  if (data !== undefined) {
    error.data = data;
  }
  sendMessage({
    jsonrpc: "2.0",
    id: id,
    error: error
  });
}

// Send a notification (no id, no response expected)
function sendNotification(method, params) {
  sendMessage({
    jsonrpc: "2.0",
    method: method,
    params: params || {}
  });
}

// Log to stderr (never stdout -- that is the protocol channel)
function log(msg) {
  process.stderr.write("[raw-mcp-server] " + msg + "\n");
}

// Handle the initialize request
function handleInitialize(id, params) {
  var clientVersion = params.protocolVersion;
  log("Client requested protocol version: " + clientVersion);
  log("Client info: " + JSON.stringify(params.clientInfo));

  // Accept the protocol version
  sendResult(id, {
    protocolVersion: PROTOCOL_VERSION,
    capabilities: {
      tools: { listChanged: false },
      resources: { subscribe: false, listChanged: false }
    },
    serverInfo: SERVER_INFO
  });

  initialized = true;
  log("Initialization complete");
}

// Handle tools/list
function handleToolsList(id, params) {
  if (!initialized) {
    sendError(id, -32600, "Server not initialized");
    return;
  }
  sendResult(id, { tools: tools });
}

// Handle tools/call
function handleToolsCall(id, params) {
  if (!initialized) {
    sendError(id, -32600, "Server not initialized");
    return;
  }

  var toolName = params.name;
  var args = params.arguments || {};

  if (toolName === "calculate") {
    try {
      // WARNING: eval is used here for demonstration only.
      // In production, use a proper math parser like mathjs.
      var expression = args.expression;
      if (!expression) {
        sendResult(id, {
          content: [{ type: "text", text: "Error: expression is required" }],
          isError: true
        });
        return;
      }

      // Basic sanitization -- only allow math characters
      if (!/^[\d\s+\-*/().%^]+$/.test(expression)) {
        sendResult(id, {
          content: [{ type: "text", text: "Error: expression contains invalid characters" }],
          isError: true
        });
        return;
      }

      var result = Function('"use strict"; return (' + expression + ")")();
      sendResult(id, {
        content: [{ type: "text", text: String(result) }],
        isError: false
      });
    } catch (err) {
      sendResult(id, {
        content: [{ type: "text", text: "Error evaluating expression: " + err.message }],
        isError: true
      });
    }

  } else if (toolName === "get_timestamp") {
    var timestamp = new Date().toISOString();
    sendResult(id, {
      content: [{ type: "text", text: timestamp }],
      isError: false
    });

  } else {
    sendError(id, -32002, "Tool not found: " + toolName);
  }
}

// Handle resources/list
function handleResourcesList(id, params) {
  if (!initialized) {
    sendError(id, -32600, "Server not initialized");
    return;
  }
  sendResult(id, { resources: resources });
}

// Handle resources/read
function handleResourcesRead(id, params) {
  if (!initialized) {
    sendError(id, -32600, "Server not initialized");
    return;
  }

  var uri = params.uri;

  if (uri === "server://info") {
    var info = {
      server: SERVER_INFO,
      uptime: process.uptime(),
      memoryUsage: process.memoryUsage().heapUsed,
      nodeVersion: process.version,
      platform: process.platform,
      pid: process.pid
    };
    sendResult(id, {
      contents: [
        {
          uri: uri,
          mimeType: "application/json",
          text: JSON.stringify(info, null, 2)
        }
      ]
    });
  } else {
    sendError(id, -32001, "Resource not found: " + uri);
  }
}

// Handle cancellation notifications
function handleCancelRequest(params) {
  var cancelledId = params.id;
  log("Received cancellation for request " + cancelledId);
  // In a real server, you would abort any in-flight work for this request ID.
  // For this simple example, all operations are synchronous so there is nothing to cancel.
}

// Route incoming messages to the appropriate handler
function handleMessage(message) {
  // Validate JSON-RPC structure
  if (message.jsonrpc !== "2.0") {
    if (message.id !== undefined) {
      sendError(message.id, -32600, "Invalid Request: missing or incorrect jsonrpc version");
    }
    return;
  }

  // Notifications (no id) -- handle and return
  if (message.id === undefined) {
    if (message.method === "notifications/initialized") {
      log("Client sent initialized notification");
    } else if (message.method === "$/cancelRequest") {
      handleCancelRequest(message.params || {});
    } else {
      log("Received unknown notification: " + message.method);
    }
    return;
  }

  // Requests (have id) -- dispatch by method
  var method = message.method;
  var params = message.params || {};

  switch (method) {
    case "initialize":
      handleInitialize(message.id, params);
      break;
    case "tools/list":
      handleToolsList(message.id, params);
      break;
    case "tools/call":
      handleToolsCall(message.id, params);
      break;
    case "resources/list":
      handleResourcesList(message.id, params);
      break;
    case "resources/read":
      handleResourcesRead(message.id, params);
      break;
    case "ping":
      sendResult(message.id, {});
      break;
    default:
      sendError(message.id, -32601, "Method not found: " + method);
  }
}

// Set up line-by-line reading from stdin
var rl = readline.createInterface({
  input: process.stdin,
  terminal: false
});

rl.on("line", function (line) {
  var trimmed = line.trim();
  if (!trimmed) return;

  try {
    var message = JSON.parse(trimmed);
    handleMessage(message);
  } catch (err) {
    // Parse error -- send error with null id per JSON-RPC spec
    sendError(null, -32700, "Parse error: " + err.message);
  }
});

rl.on("close", function () {
  log("stdin closed, shutting down");
  process.exit(0);
});

log("Raw MCP server started, waiting for JSON-RPC messages on stdin");

Save this as raw-mcp-server.js and test it by piping JSON-RPC messages directly:

echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' | node raw-mcp-server.js

You will see the initialize response on stdout and log messages on stderr. To run a full conversation, you can use a simple test script:

var child_process = require("child_process");

var server = child_process.spawn("node", ["raw-mcp-server.js"], {
  stdio: ["pipe", "pipe", "pipe"]
});

var messageId = 0;
var buffer = "";

server.stdout.on("data", function (data) {
  buffer += data.toString();
  var lines = buffer.split("\n");
  buffer = lines.pop(); // keep incomplete line in buffer

  lines.forEach(function (line) {
    if (line.trim()) {
      var response = JSON.parse(line);
      console.log("<<< " + JSON.stringify(response, null, 2));
    }
  });
});

server.stderr.on("data", function (data) {
  process.stderr.write("[server log] " + data.toString());
});

function sendRequest(method, params) {
  messageId++;
  var request = {
    jsonrpc: "2.0",
    id: messageId,
    method: method,
    params: params || {}
  };
  console.log(">>> " + JSON.stringify(request));
  server.stdin.write(JSON.stringify(request) + "\n");
  return messageId;
}

function sendNotification(method, params) {
  var notification = {
    jsonrpc: "2.0",
    method: method,
    params: params || {}
  };
  console.log(">>> " + JSON.stringify(notification));
  server.stdin.write(JSON.stringify(notification) + "\n");
}

// Run the full MCP lifecycle
setTimeout(function () {
  sendRequest("initialize", {
    protocolVersion: "2025-03-26",
    capabilities: {},
    clientInfo: { name: "test-client", version: "1.0.0" }
  });
}, 100);

setTimeout(function () {
  sendNotification("notifications/initialized");
}, 300);

setTimeout(function () {
  sendRequest("tools/list");
}, 500);

setTimeout(function () {
  sendRequest("tools/call", {
    name: "calculate",
    arguments: { expression: "2 + 3 * 4" }
  });
}, 700);

setTimeout(function () {
  sendRequest("resources/read", {
    uri: "server://info"
  });
}, 900);

setTimeout(function () {
  sendRequest("tools/call", {
    name: "nonexistent_tool",
    arguments: {}
  });
}, 1100);

setTimeout(function () {
  server.stdin.end();
}, 1500);

Run it and you will see the complete message exchange:

node test-client.js
>>> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}
<<< {
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-03-26",
    "capabilities": {
      "tools": { "listChanged": false },
      "resources": { "subscribe": false, "listChanged": false }
    },
    "serverInfo": { "name": "raw-mcp-server", "version": "1.0.0" }
  }
}
>>> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"calculate","arguments":{"expression":"2 + 3 * 4"}}}
<<< {
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [{ "type": "text", "text": "14" }],
    "isError": false
  }
}

This is exactly what happens inside the SDK. Every server.tool() call you have written using the MCP TypeScript SDK is dispatching to a handler just like this handleToolsCall function. The SDK just adds schema validation, transport abstraction, and lifecycle management on top.

Common Issues and Troubleshooting

1. Writing Log Output to stdout Corrupts the Protocol

This is the most common mistake when building stdio-based MCP servers. Any console.log() call writes to stdout, which is the protocol channel. The client will try to parse your log message as JSON-RPC and fail.

Parse error: Unexpected token S in JSON at position 0

The fix: use process.stderr.write() or redirect console.log to stderr:

console.log = function () {
  var args = Array.prototype.slice.call(arguments);
  process.stderr.write(args.join(" ") + "\n");
};

2. Sending a Response to a Notification

If the client sends notifications/initialized (no id) and your server sends a response with id: null, the client will be confused. Notifications do not get responses. Period.

{
  "jsonrpc": "2.0",
  "id": null,
  "error": { "code": -32600, "message": "Unexpected response to notification" }
}

The fix: check for the absence of id and never send a response for notifications.

3. Calling Methods Before Initialize

If you call tools/list before the initialize handshake completes, a well-behaved server will return:

{
  "jsonrpc": "2.0",
  "id": 2,
  "error": {
    "code": -32600,
    "message": "Server not initialized. Call 'initialize' first."
  }
}

Some buggy servers will crash or return garbage. Always complete the full handshake: initialize request, wait for response, then send notifications/initialized.

4. Confusing Protocol Errors with Tool Errors

When a tool execution fails, do not return a JSON-RPC error. Return a success response with isError: true:

Wrong:

{
  "jsonrpc": "2.0",
  "id": 5,
  "error": {
    "code": -32603,
    "message": "Database connection failed"
  }
}

Right:

{
  "jsonrpc": "2.0",
  "id": 5,
  "result": {
    "content": [{ "type": "text", "text": "Database connection failed: ECONNREFUSED 10.0.1.5:5432" }],
    "isError": true
  }
}

A JSON-RPC error tells the client that the protocol itself broke -- the message was malformed, the method does not exist, the server has a bug. An isError: true result tells the model that the tool ran but the operation failed. The model can reason about tool failures and retry or try a different approach. It cannot reason about protocol errors.

5. Missing Content-Type Header in Streamable HTTP

When implementing the Streamable HTTP transport, forgetting the Content-Type header results in the client failing to parse responses:

Error: Unexpected content type: undefined. Expected application/json or text/event-stream.

Always set Content-Type: application/json for direct responses and Content-Type: text/event-stream for streaming responses.

6. Cursor Pagination State Lost Between Restarts

If your server uses cursor-based pagination and the cursors encode server-side state (like database offsets), restarting the server invalidates all outstanding cursors. The client will send an old cursor and get an error:

{
  "jsonrpc": "2.0",
  "id": 15,
  "error": {
    "code": -32602,
    "message": "Invalid cursor: pagination state expired or not found"
  }
}

The fix: encode cursor state entirely in the cursor string itself (e.g., base64-encode an offset). This makes cursors stateless and survives server restarts.

Best Practices

  • Always validate the initialize handshake. Never process any method call before initialization is complete. Track initialization state in a boolean flag and check it at the top of every handler.

  • Use isError: true for tool failures, JSON-RPC errors for protocol failures. This distinction is not cosmetic. Models can reason about tool failures and adjust their strategy. Protocol errors are opaque to the model and typically result in the conversation being aborted.

  • Keep tool input schemas precise and descriptive. The model reads the description fields in your JSON Schema to decide how to call your tool. Vague descriptions lead to wrong arguments. Include examples, valid ranges, and format expectations directly in the description strings.

  • Implement ping as a no-op response. The client will periodically send ping requests to check if the server is alive. Return an empty result: sendResult(id, {}). If you do not handle ping, the client may assume the server has crashed.

  • Log to stderr, never stdout, on stdio transport. This cannot be overstated. A single console.log("debug info") will corrupt the protocol stream. Redirect all logging to stderr from the start of your server.

  • Use progress tokens for any operation that takes more than 2-3 seconds. Without progress reporting, the client has no way to know if the server is working or stuck. Send progress notifications with meaningful message fields so the user sees what is happening.

  • Declare capabilities honestly. Do not declare tools: { listChanged: true } if you never actually send notifications/tools/list_changed. Do not declare resources: { subscribe: true } if you have not implemented resources/subscribe. Clients will call methods based on your declared capabilities and get confused when they fail.

  • Handle $/cancelRequest gracefully. Even if your operations are too fast to meaningfully cancel, acknowledge the cancellation pattern. For long-running operations, check a cancellation flag periodically and abort early when possible.

  • Make cursors stateless. Encode pagination state (offset, filter params) directly in the cursor string rather than storing it server-side. Base64-encode a JSON object containing the pagination parameters. This survives server restarts and scales horizontally.

  • Version your server in serverInfo. When debugging protocol issues between a client and server, the first question is always "what version are you running?" Make sure serverInfo.version is meaningful and updated with each release.

References

Powered by Contentful