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.
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.
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 callparams: 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.
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.
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.
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.
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
{
"method": "tools/call",
"params": {...},
"id": 1
}
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {...},
"id": 1
}
Pitfall 2: Including Both result and error
{
"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
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.
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.
Fetching 10 independent files in one batch request.
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
Post a Comment