Skip to main content

Understanding JSON-RPC 2.0 in Model Context Protocol

Calculating read time…

If you're building AI applications that need to connect with external tools, databases, or APIs, understanding this foundation is absolutely essential.

This article will take you from complete beginner to confident practitioner, step by step.

⚠️ Important Context:

MCP (Model Context Protocol) is Anthropic's open standard that allows AI models to communicate with external systems.

JSON-RPC 2.0 is the messaging language that powers all MCP communication.

Think of MCP as the blueprint for AI connections, and JSON-RPC as the actual conversation format.

Part 1: What is JSON-RPC 2.0?

The Big Picture

JSON-RPC stands for JavaScript Object Notation Remote Procedure Call.

Let me break that down in plain English.

Imagine you have two computers that need to work together. Computer A wants Computer B to perform a specific task, like calculating something or fetching data from a database.

Instead of Computer A doing the work itself, it sends a message to Computer B saying: "Hey, please run this function with these inputs, and send me back the result."

That's a Remote Procedure Call. You're calling a procedure (function) that runs remotely (on another machine or process).

JSON-RPC is simply a standardized way to format these messages using JSON, which is lightweight, human-readable, and universally supported.

Real-World Analogy

Think of JSON-RPC like ordering food at a restaurant.

  • You (the client) send a request: "I want a burger with fries."
  • The waiter (the protocol) takes your order in a standardized format to the kitchen.
  • The kitchen (the server) prepares the food and sends it back.
  • The waiter returns with either your meal (success) or an apology if something went wrong (error).

The menu tells you what's available. JSON-RPC works the same way—the client discovers what methods are available and calls them with specific parameters.

Why JSON-RPC 2.0 Specifically?

There have been multiple versions of JSON-RPC. Version 2.0 is the current standard and offers several key improvements:

  • Clear version identification: Every message includes "jsonrpc": "2.0" so systems know exactly which spec to follow.
  • Batch requests: You can send multiple requests at once, reducing network overhead.
  • Notifications: One-way messages where you don't need a response.
  • Structured errors: Consistent error reporting with error codes, messages, and optional debugging data.
  • Transport agnostic: Works over HTTP, WebSockets, stdio (standard input/output), or any message-passing system.
✅ DO THIS:

Always include "jsonrpc": "2.0" in every request and response.

This ensures compatibility and makes debugging easier when things go wrong.

Part 2: The Three Message Types

JSON-RPC 2.0 defines three types of messages. Understanding these is crucial.

1. Request Messages

A request is sent when you want the server to do something and send back a result.

Every request must have four components:

  • jsonrpc: Must always be "2.0"
  • method: The name of the function/procedure to call
  • params: The inputs for that function (can be an object or array)
  • id: A unique identifier to match the response with this request

Here's what a real request looks like:

{
  "jsonrpc": "2.0",
  "method": "calculate_sum",
  "params": {
    "numbers": [10, 20, 30]
  },
  "id": 1
}

This request says: "Please call the calculate_sum method with an array of numbers [10, 20, 30], and send the response back matching ID 1."

2. Response Messages

When the server completes your request, it sends back a response.

A successful response looks like this:

{
  "jsonrpc": "2.0",
  "result": 60,
  "id": 1
}

The id field matches the original request, so the client knows this response belongs to request #1.

The result field contains the actual answer (in this case, 60).

If something goes wrong, you get an error response instead:

{
  "jsonrpc": "2.0",
  "error": {
    "code": -32602,
    "message": "Invalid params: 'numbers' must be an array"
  },
  "id": 1
}

Notice there's no result field when there's an error. A response must have EITHER result OR error, never both.

3. Notification Messages

Sometimes you want to send a message but don't need a response. These are called notifications.

Notifications are identical to requests, except they have NO id field:

{
  "jsonrpc": "2.0",
  "method": "log_event",
  "params": {
    "level": "info",
    "message": "User logged in"
  }
}

The server receives this message, processes it, but never sends a response back.

Notifications are useful for logging, fire-and-forget events, or updates where you don't care about confirmation.

❌ DON'T DO THIS:

Never expect a response from a notification.

If you need confirmation that something happened, use a regular request instead.

Part 3: How MCP Uses JSON-RPC 2.0

Now that you understand JSON-RPC basics, let's see how MCP leverages it.

The MCP Architecture

MCP has three key players:

  • Host: The AI application (like Claude Desktop or an AI-powered IDE)
  • Client: Lives inside the host, manages MCP connections
  • Server: External programs that expose tools, resources, and prompts

All communication between the MCP client and server happens through JSON-RPC 2.0 messages.

The host doesn't talk directly to the server. The client acts as a translator, converting the host's needs into JSON-RPC requests and converting JSON-RPC responses back into usable data for the host.

MCP's Core Methods

MCP defines specific method names that servers must support. Here are the most important ones:

1. initialize (Connection Setup)

When a client first connects to an MCP server, it sends an initialize request:

{
  "jsonrpc": "2.0",
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "tools": {}
    },
    "clientInfo": {
      "name": "my-ai-app",
      "version": "1.0.0"
    }
  },
  "id": 1
}

The server responds with its own capabilities:

{
  "jsonrpc": "2.0",
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "tools": {},
      "resources": {}
    },
    "serverInfo": {
      "name": "filesystem-server",
      "version": "2.1.0"
    }
  },
  "id": 1
}

This handshake tells both sides what features they support, ensuring they can communicate effectively.

2. tools/list (Discovering Available Tools)

After initialization, the client asks: "What tools do you have?"

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

The server responds with a catalog of everything it can do:

{
  "jsonrpc": "2.0",
  "result": {
    "tools": [
      {
        "name": "read_file",
        "description": "Reads content from a file",
        "inputSchema": {
          "type": "object",
          "properties": {
            "path": {
              "type": "string",
              "description": "File path to read"
            }
          },
          "required": ["path"]
        }
      },
      {
        "name": "write_file",
        "description": "Writes content to a file",
        "inputSchema": {
          "type": "object",
          "properties": {
            "path": {"type": "string"},
            "content": {"type": "string"}
          },
          "required": ["path", "content"]
        }
      }
    ]
  },
  "id": 2
}

This response tells the AI exactly what functions are available and what parameters each one needs.

3. tools/call (Executing a Tool)

When the AI decides to use a tool, it sends a tools/call request:

{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "read_file",
    "arguments": {
      "path": "/documents/report.txt"
    }
  },
  "id": 3
}

The server executes the tool and returns the result:

{
  "jsonrpc": "2.0",
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Q4 Sales Report: Revenue increased by 23%..."
      }
    ]
  },
  "id": 3
}

The AI now has the file content and can use it to answer the user's question.

✅ DO THIS:

Always validate the inputSchema before calling a tool.

Send only the parameters the tool expects, in the correct format.

This prevents errors and makes debugging much easier.

Part 4: Error Handling in JSON-RPC

Errors are inevitable. JSON-RPC 2.0 has a standardized error system.

Standard Error Codes

JSON-RPC defines several built-in error codes:

  • -32700: Parse error (invalid JSON)
  • -32600: Invalid request (malformed JSON-RPC message)
  • -32601: Method not found
  • -32602: Invalid params
  • -32603: Internal error (something went wrong on the server)

You can also define custom error codes. MCP uses the range -32000 to -32099 for implementation-specific errors.

Example Error Response

{
  "jsonrpc": "2.0",
  "error": {
    "code": -32602,
    "message": "Invalid params",
    "data": {
      "field": "path",
      "issue": "Path does not exist: /nonexistent/file.txt"
    }
  },
  "id": 5
}

The data field is optional but extremely useful for debugging. It can contain any additional context about what went wrong.

⚠️ Important:

Always check for the error field in responses before trying to use result.

A response with an error will NOT have a result field, and vice versa.

Part 5: Practical Examples

Let's walk through complete, real-world scenarios.

Example 1: Simple Calculator Tool

Imagine you're building an MCP server that provides math operations.

Step 1: Client discovers tools

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

Response:
{
  "jsonrpc": "2.0",
  "result": {
    "tools": [
      {
        "name": "add",
        "description": "Adds two numbers",
        "inputSchema": {
          "type": "object",
          "properties": {
            "a": {"type": "number"},
            "b": {"type": "number"}
          },
          "required": ["a", "b"]
        }
      }
    ]
  },
  "id": 1
}

Step 2: Client calls the tool

Request:
{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "add",
    "arguments": {
      "a": 15,
      "b": 27
    }
  },
  "id": 2
}

Response:
{
  "jsonrpc": "2.0",
  "result": {
    "content": [
      {
        "type": "text",
        "text": "42"
      }
    ]
  },
  "id": 2
}

Example 2: Database Query Tool

Here's a more complex example involving database access.

Request:
{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "query_database",
    "arguments": {
      "table": "customers",
      "filter": {
        "country": "USA",
        "active": true
      },
      "limit": 10
    }
  },
  "id": 7
}

Response:
{
  "jsonrpc": "2.0",
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Found 10 active customers in USA:\n1. John Doe\n2. Jane Smith\n..."
      }
    ]
  },
  "id": 7
}

Example 3: Handling Errors Gracefully

Request (invalid parameters):
{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "divide",
    "arguments": {
      "a": 10,
      "b": 0
    }
  },
  "id": 8
}

Response:
{
  "jsonrpc": "2.0",
  "error": {
    "code": -32000,
    "message": "Division by zero",
    "data": {
      "attempted_operation": "10 / 0",
      "suggestion": "Please provide a non-zero divisor"
    }
  },
  "id": 8
}

Notice how the error provides helpful context. This makes debugging far easier.

Part 6: Advanced Concepts

Batch Requests

JSON-RPC 2.0 allows you to send multiple requests at once by wrapping them in an array:

[
  {
    "jsonrpc": "2.0",
    "method": "add",
    "params": {"a": 5, "b": 3},
    "id": 1
  },
  {
    "jsonrpc": "2.0",
    "method": "multiply",
    "params": {"a": 4, "b": 7},
    "id": 2
  },
  {
    "jsonrpc": "2.0",
    "method": "log_event",
    "params": {"message": "Batch processed"}
  }
]

The server processes all three (two requests + one notification) and returns responses for the two requests:

[
  {
    "jsonrpc": "2.0",
    "result": 8,
    "id": 1
  },
  {
    "jsonrpc": "2.0",
    "result": 28,
    "id": 2
  }
]

Notice the notification doesn't get a response, even in batch mode.

Transport Layers in MCP

MCP supports multiple ways to transmit JSON-RPC messages:

  • stdio (Standard Input/Output): Used for local servers running as child processes. Messages are sent via stdin/stdout.
  • HTTP + SSE: Used for remote servers. Requests go via HTTP POST, responses come back via Server-Sent Events for streaming.
  • WebSockets: Bidirectional, persistent connections for real-time communication.

The beauty of JSON-RPC is that the message format stays exactly the same regardless of transport. You can switch from stdio to HTTP without changing your tool logic.

Stateful vs Stateless

JSON-RPC 2.0 itself is stateless—each message is independent.

However, MCP connections are stateful. Once initialized, the connection stays open, and the server remembers the client's capabilities.

This is why the initialize handshake is so important. It establishes the session context that both sides maintain.

✅ DO THIS:

Use batch requests when you need to perform multiple independent operations.

This reduces network roundtrips and improves performance significantly.

Part 7: Common Pitfalls and How to Avoid Them

Pitfall 1: Forgetting the Version Field

❌ Wrong:
{
  "method": "tools/call",
  "params": {...},
  "id": 1
}
✅ Correct:
{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {...},
  "id": 1
}

Pitfall 2: Including Both result and error

❌ Wrong:
{
  "jsonrpc": "2.0",
  "result": 42,
  "error": {"code": -32603, "message": "Something went wrong"},
  "id": 1
}

You must have ONE or the other, never both.

Pitfall 3: Expecting a Response from Notifications

❌ Wrong:

Sending a notification and waiting for a response will cause your code to hang forever.

Notifications are fire-and-forget by design.

Pitfall 4: Not Validating Input Schemas

Always validate parameters against the inputSchema before sending them.

If a tool expects a string but you send a number, the server will reject it with a -32602 error (Invalid params).

Pitfall 5: Mismatched IDs

The id in the response must exactly match the id in the request.

If you send multiple concurrent requests, you need unique IDs to correctly pair responses with their requests.

✅ Pro Tip:

Use incrementing integers (1, 2, 3...) or UUIDs for request IDs.

This makes debugging easier and prevents ID collisions.

Part 8: Building Your First JSON-RPC Client

Let's build a simple client in JavaScript that talks to an MCP server.

Basic Client Example

class MCPClient {
  constructor() {
    this.requestId = 1;
  }

  // Send a request and return a promise for the response
  async sendRequest(method, params = {}) {
    const request = {
      jsonrpc: "2.0",
      method: method,
      params: params,
      id: this.requestId++
    };

    // In a real implementation, this would send via HTTP, WebSocket, or stdio
    const response = await this.transport.send(request);

    if (response.error) {
      throw new Error(`RPC Error ${response.error.code}: ${response.error.message}`);
    }

    return response.result;
  }

  // Send a notification (no response expected)
  sendNotification(method, params = {}) {
    const notification = {
      jsonrpc: "2.0",
      method: method,
      params: params
      // Note: no 'id' field for notifications
    };

    this.transport.send(notification);
    // Don't wait for a response
  }

  // Initialize connection with server
  async initialize(clientInfo) {
    return await this.sendRequest("initialize", {
      protocolVersion: "2024-11-05",
      capabilities: { tools: {} },
      clientInfo: clientInfo
    });
  }

  // List available tools
  async listTools() {
    const response = await this.sendRequest("tools/list");
    return response.tools;
  }

  // Call a specific tool
  async callTool(name, arguments) {
    return await this.sendRequest("tools/call", {
      name: name,
      arguments: arguments
    });
  }
}

Using the Client

const client = new MCPClient();

// Initialize connection
await client.initialize({
  name: "my-app",
  version: "1.0.0"
});

// Discover tools
const tools = await client.listTools();
console.log("Available tools:", tools);

// Call a tool
const result = await client.callTool("read_file", {
  path: "/data/config.json"
});
console.log("File contents:", result);

Part 9: Best Practices

1. Always Include Error Handling

Wrap every RPC call in try-catch blocks. Network failures, server errors, and validation issues can all occur.

try {
  const result = await client.callTool("process_data", params);
  // Use result
} catch (error) {
  console.error("Tool call failed:", error.message);
  // Fallback logic here
}

2. Use Timeouts

Don't let requests hang forever. Set reasonable timeouts.

const timeout = (ms) => new Promise((_, reject) => 
  setTimeout(() => reject(new Error('Timeout')), ms)
);

const result = await Promise.race([
  client.callTool("slow_operation", params),
  timeout(5000) // 5 second timeout
]);

3. Log All Messages for Debugging

During development, log every request and response. This makes debugging much easier.

async sendRequest(method, params) {
  const request = {...};
  console.log("Sending:", JSON.stringify(request, null, 2));
  
  const response = await this.transport.send(request);
  console.log("Received:", JSON.stringify(response, null, 2));
  
  return response.result;
}

4. Validate Before Sending

Use JSON Schema validation to ensure your parameters match the expected schema before sending them to the server.

5. Use Batch Requests Wisely

Batch requests are great for performance, but don't batch operations that depend on each other's results. Those need to run sequentially.

✅ Good Batching:

Fetching 10 independent files in one batch request.

❌ Bad Batching:

Batching "create user" and "send welcome email" when the email needs the user ID from the first operation.

Part 10: Real-World MCP Scenario

Let's walk through a complete, real-world example: building an AI assistant that manages GitHub repositories.

The Scenario

A user asks: "What are the open issues in my repo, and create a new branch to fix the oldest one?"

Step-by-Step Flow

Step 1: Client initializes connection with GitHub MCP server

Request:
{
  "jsonrpc": "2.0",
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "capabilities": {"tools": {}}
  },
  "id": 1
}

Response:
{
  "jsonrpc": "2.0",
  "result": {
    "capabilities": {"tools": {}},
    "serverInfo": {"name": "github-mcp-server"}
  },
  "id": 1
}

Step 2: Client discovers available tools

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

Response:
{
  "jsonrpc": "2.0",
  "result": {
    "tools": [
      {
        "name": "list_issues",
        "description": "List all open issues in a repository",
        "inputSchema": {
          "type": "object",
          "properties": {
            "repo": {"type": "string"}
          }
        }
      },
      {
        "name": "create_branch",
        "description": "Create a new branch",
        "inputSchema": {
          "type": "object",
          "properties": {
            "repo": {"type": "string"},
            "branch_name": {"type": "string"},
            "base_branch": {"type": "string"}
          }
        }
      }
    ]
  },
  "id": 2
}

Step 3: AI calls list_issues tool

Request:
{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "list_issues",
    "arguments": {
      "repo": "myorg/myrepo"
    }
  },
  "id": 3
}

Response:
{
  "jsonrpc": "2.0",
  "result": {
    "content": [{
      "type": "text",
      "text": "Open issues:\n1. Bug in login form (created 30 days ago)\n2. Dashboard loading slow (created 15 days ago)"
    }]
  },
  "id": 3
}

Step 4: AI determines the oldest issue and calls create_branch

Request:
{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "create_branch",
    "arguments": {
      "repo": "myorg/myrepo",
      "branch_name": "fix-login-form-bug",
      "base_branch": "main"
    }
  },
  "id": 4
}

Response:
{
  "jsonrpc": "2.0",
  "result": {
    "content": [{
      "type": "text",
      "text": "Branch 'fix-login-form-bug' created successfully from 'main'"
    }]
  },
  "id": 4
}

Final Response to User:

"I found 2 open issues in your repository. The oldest is 'Bug in login form' from 30 days ago. I've created a new branch called 'fix-login-form-bug' based on main. You can start working on the fix there."

Comments