All MCP communication happens over JSON-RPC 2.0. For most data engineers, this is an implementation detail that stays invisible — SDKs handle serialization, and tools like the MCP Inspector let you test servers without touching raw messages. But when something breaks in production, or when you’re building a client from scratch, knowing what’s actually on the wire saves hours.
This note documents the key message types with real examples. Use it as a debugging reference.
The JSON-RPC 2.0 Format
Every MCP message follows the same structure:
{ "jsonrpc": "2.0", "id": <integer or string>, "method": "<method-name>", "params": { ... }}jsonrpc: Always"2.0". The version string is required in every message.id: A unique identifier for this request. The response will include the sameid, so you can match asynchronous responses to requests. Use sequential integers for simplicity.method: The RPC method being called (e.g.,initialize,tools/list,tools/call).params: Method-specific parameters. Some methods have no params and omit this field.
Responses mirror the request id and include either a result (success) or an error (failure):
{ "jsonrpc": "2.0", "id": 1, "result": { ... }}{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32600, "message": "Invalid Request" }}JSON-RPC also supports notifications — messages with no id that expect no response. MCP uses notifications for events like notifications/tools/list_changed (the server’s tool list changed, client should re-fetch).
The Initialization Handshake
Every MCP session begins with an initialization exchange. This is capability negotiation: the client announces what it can do, the server responds with what it offers. Neither side can assume capabilities beyond what the other declares.
Client sends:
{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": { "elicitation": {} }, "clientInfo": { "name": "example-client", "version": "1.0.0" } }}The capabilities object declares what the client can do. In this example, the client supports elicitation — meaning the server can ask it for additional user input. A client without this capability would omit it, and a well-behaved server would never send elicitation requests to that client.
protocolVersion is the MCP spec version the client implements. If client and server negotiate incompatible versions, the handshake fails. The current spec version as of early 2026 is 2025-06-18.
Server responds:
{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-06-18", "capabilities": { "tools": {"listChanged": true}, "resources": {} }, "serverInfo": { "name": "example-server", "version": "1.0.0" } }}The server’s capabilities declares what it exposes:
"tools": {"listChanged": true}— the server has tools, and it will notify the client if the tool list changes dynamically"resources": {}— the server has resources (the empty object means basic support without change notifications)
If the server also had prompts, it would include "prompts": {}. If it supported sampling (letting the server request LLM completions from the client), it would include "sampling": {}.
After both sides receive the handshake, the client sends an initialized notification to signal it’s ready:
{ "jsonrpc": "2.0", "method": "notifications/initialized"}Note the absence of id — this is a notification, not a request. No response expected.
Tool Discovery
Once initialized, the client can ask the server what tools it exposes:
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list"}The server returns a list of tool definitions — each with a name, description, and JSON Schema for its inputs:
{ "jsonrpc": "2.0", "id": 2, "result": { "tools": [ { "name": "query_database", "description": "Execute a SQL query against the specified database.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "description": "The SQL query to execute" }, "database": { "type": "string", "description": "Target database name", "default": "production" } }, "required": ["query"] } }, { "name": "list_tables", "description": "List all tables in the specified schema.", "inputSchema": { "type": "object", "properties": { "schema": { "type": "string", "description": "Schema name to list tables from" } }, "required": ["schema"] } } ] }}The AI model reads these definitions to understand what each tool does and what arguments to provide. This is why docstrings and descriptions matter so much — the description field in the tool schema is how the AI knows when and how to use a tool. See MCP Tool Design Patterns for how to write descriptions that actually work.
The same pattern applies for resources (resources/list) and prompts (prompts/list).
Tool Invocation
When the AI decides to call a tool, the client sends:
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "query_database", "arguments": { "query": "SELECT * FROM customers LIMIT 10", "database": "production" } }}The server executes the tool and responds:
{ "jsonrpc": "2.0", "id": 3, "result": { "content": [ { "type": "text", "text": "Query returned 10 rows:\nid | name | email\n1 | Alice | alice@example.com\n..." } ], "isError": false }}The content array can contain multiple items of different types: text, image, or resource references. Most data engineering tools return text. The isError flag distinguishes between a successful execution that returned a result and an execution that failed — both use the JSON-RPC result field, but isError: true signals the tool itself encountered a problem (as opposed to a JSON-RPC-level error, which would use the error field).
Error Handling
JSON-RPC errors use negative error codes:
| Code | Meaning |
|---|---|
-32700 | Parse error (invalid JSON) |
-32600 | Invalid request |
-32601 | Method not found |
-32602 | Invalid params |
-32603 | Internal error |
MCP adds application-level errors on top of these. When a tool fails, the server returns a result with isError: true and an error message in the content:
{ "jsonrpc": "2.0", "id": 3, "result": { "content": [ { "type": "text", "text": "Query failed: relation 'customers' does not exist" } ], "isError": true }}This distinction matters for debugging: a JSON-RPC error means the protocol failed; an isError: true result means the tool ran but encountered an application error.
Inspecting Messages in Practice
For stdio transport, messages flow through stdin/stdout as newline-delimited JSON. You cannot easily intercept them without modifying the server or client. The MCP Inspector is the right tool — it acts as a client, sends these messages, and displays the responses in a UI.
For HTTP transport, you can use standard HTTP debugging tools. The messages are JSON-RPC over POST requests to the server’s endpoint. A proxy like Wireshark, mitmproxy, or your browser’s network tab can capture them.
The most common debugging scenario: a tool works in the Inspector but fails in Claude Desktop or Claude Code. In that case, the issue is usually in the initialization handshake (capability mismatch), argument formatting (the AI is providing different arguments than you tested with), or environment (credentials available in one context but not another). Understanding the wire format helps you reason about which layer the problem is in.
Why JSON-RPC
JSON-RPC 2.0 is not a new or exotic choice. It’s a 15-year-old standard with implementations in every language. MCP’s adoption of it means:
- Existing tooling works. JSON-RPC clients, servers, and test utilities already exist in every ecosystem. MCP didn’t have to invent a testing story.
- Debugging is straightforward. The messages are human-readable JSON. You don’t need a specialized protocol analyzer to understand what’s happening.
- The spec is stable. JSON-RPC 2.0 has been stable since 2013. The serialization format is not going to change.
The two-layer architecture — JSON-RPC for message format, with stdio or HTTP as the transport — means MCP works across any network topology. The same message format works whether the server is a subprocess on the same machine or a cloud service behind OAuth. You learn JSON-RPC once; the transport is a configuration choice.