Understanding the JSON-RPC protocol and how it's used in MCP

Kashish Hora
Co-founder of MCPcat
The Quick Answer
MCP uses JSON-RPC 2.0 for all client-server communication. Every interaction follows this request-response pattern:
// Client request
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}
// Server response
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [{"name": "calculator", "description": "Basic math"}]
}
}
MCP extends JSON-RPC with specific methods like initialize
, resources/list
, and tools/call
to enable AI assistants to interact with external systems through a standardized protocol.
Prerequisites
- Basic understanding of JSON format
- Node.js 18+ or Python 3.8+ for MCP server development
- Familiarity with client-server architecture concepts
Understanding JSON-RPC Structure
JSON-RPC 2.0 forms the backbone of MCP communication. Every message exchanged between MCP clients and servers follows strict JSON-RPC specifications, ensuring reliable and predictable interactions across different implementations.
The protocol defines three message types that MCP leverages:
// 1. Request - Client asks server to do something
{
"jsonrpc": "2.0",
"id": "unique-request-id",
"method": "resources/read",
"params": {
"uri": "file:///config.json"
}
}
// 2. Response - Server replies with result or error
{
"jsonrpc": "2.0",
"id": "unique-request-id",
"result": {
"contents": [{"text": "config data..."}]
}
}
// 3. Notification - One-way message, no response expected
{
"jsonrpc": "2.0",
"method": "notifications/resources/changed",
"params": {
"uri": "file:///config.json"
}
}
Each message type serves a specific purpose in MCP. Requests enable clients to invoke server capabilities, responses deliver results or errors back to clients, and notifications allow servers to proactively inform clients about state changes without blocking for acknowledgment.
MCP Session Lifecycle
Every MCP connection begins with a handshake sequence that establishes protocol compatibility and negotiates capabilities. This initialization phase ensures both client and server understand each other's features before proceeding with actual work.
// Step 1: Client sends initialize request
const initRequest = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {
"roots": { "listChanged": true },
"sampling": {}
},
"clientInfo": {
"name": "claude-desktop",
"version": "1.0.0"
}
}
};
// Step 2: Server responds with its capabilities
const initResponse = {
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-03-26",
"capabilities": {
"resources": { "subscribe": true },
"tools": { "listChanged": true }
},
"serverInfo": {
"name": "my-mcp-server",
"version": "2.1.0"
}
}
};
// Step 3: Client confirms initialization
const initialized = {
"jsonrpc": "2.0",
"method": "initialized"
};
The handshake establishes critical parameters like protocol version compatibility and feature support. Servers advertise their capabilities (resources, tools, prompts) while clients indicate which notifications they can handle. This negotiation prevents runtime errors from unsupported operations.
Core MCP Methods
MCP defines standard JSON-RPC methods that every implementation must support. These methods enable discovery and interaction with server-provided capabilities.
Resource Discovery and Access
Resources represent any data the server can provide - files, database records, API responses, or computed values. The discovery flow allows clients to explore available resources before requesting specific content.
// Discover available resources with pagination
const listResourcesRequest = {
"jsonrpc": "2.0",
"id": 2,
"method": "resources/list",
"params": {
"cursor": null // First page
}
};
const listResourcesResponse = {
"jsonrpc": "2.0",
"id": 2,
"result": {
"resources": [
{
"uri": "db://users/profile",
"name": "User Profiles",
"mimeType": "application/json"
},
{
"uri": "api://weather/current",
"name": "Current Weather",
"mimeType": "application/json"
}
],
"nextCursor": "page2" // More results available
}
};
// Read specific resource content
const readRequest = {
"jsonrpc": "2.0",
"id": 3,
"method": "resources/read",
"params": {
"uri": "db://users/profile"
}
};
const readResponse = {
"jsonrpc": "2.0",
"id": 3,
"result": {
"contents": [
{
"uri": "db://users/profile",
"mimeType": "application/json",
"text": "{\"name\": \"Alice\", \"role\": \"admin\"}"
}
]
}
};
Resources use URI schemes to indicate their type and location. Common schemes include file://
for local files, https://
for web resources, and custom schemes like db://
for database access. The MIME type helps clients interpret the content correctly.
Tool Execution
Tools expose executable functions that AI assistants can invoke. Each tool declares its parameters through JSON Schema, enabling type-safe invocations.
# Server-side tool definition
tools = [{
"name": "calculate_compound_interest",
"description": "Calculate compound interest over time",
"inputSchema": {
"type": "object",
"properties": {
"principal": {"type": "number", "minimum": 0},
"rate": {"type": "number", "minimum": 0, "maximum": 1},
"years": {"type": "integer", "minimum": 1}
},
"required": ["principal", "rate", "years"]
}
}]
# Client discovers and calls the tool
list_tools_request = {
"jsonrpc": "2.0",
"id": 4,
"method": "tools/list"
}
list_tools_response = {
"jsonrpc": "2.0",
"id": 4,
"result": {"tools": tools}
}
# Execute the tool with validated parameters
call_request = {
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "calculate_compound_interest",
"arguments": {
"principal": 10000,
"rate": 0.05,
"years": 10
}
}
}
call_response = {
"jsonrpc": "2.0",
"id": 5,
"result": {
"content": [{
"type": "text",
"text": "Final amount: $16,288.95 (63% return)"
}],
"isError": False
}
}
Tool execution follows a deliberate two-step process: discovery then invocation. This separation allows clients to validate parameters before execution and present available tools to users for selection.
Error Handling
MCP inherits JSON-RPC's standardized error format while adding protocol-specific error scenarios. Understanding these patterns helps build robust error recovery.
// Standard JSON-RPC errors MCP uses
const errorCodes = {
PARSE_ERROR: -32700, // Invalid JSON
INVALID_REQUEST: -32600, // Missing required fields
METHOD_NOT_FOUND: -32601, // Unknown method
INVALID_PARAMS: -32602, // Parameter validation failed
INTERNAL_ERROR: -32603 // Server-side failure
};
// Example error response
const errorResponse = {
"jsonrpc": "2.0",
"id": 6,
"error": {
"code": -32602,
"message": "Invalid params",
"data": {
"field": "principal",
"reason": "Must be a positive number",
"received": -100
}
}
};
// Tool execution errors use a different pattern
const toolError = {
"jsonrpc": "2.0",
"id": 7,
"result": {
"content": [{
"type": "text",
"text": "Database connection failed: timeout after 30s"
}],
"isError": true // Indicates tool failure
}
};
// Client-side error handling
async function callMcpMethod(method, params) {
try {
const response = await sendRequest({ method, params });
if (response.error) {
// Handle JSON-RPC errors
console.error(`RPC Error ${response.error.code}: ${response.error.message}`);
if (response.error.code === -32602) {
// Show specific parameter errors to user
const details = response.error.data;
throw new ValidationError(`Invalid ${details.field}: ${details.reason}`);
}
}
if (response.result?.isError) {
// Handle tool execution errors
throw new ToolExecutionError(response.result.content[0].text);
}
return response.result;
} catch (e) {
// Handle transport errors
if (e.code === 'ECONNREFUSED') {
throw new ConnectionError('MCP server not running');
}
throw e;
}
}
Error handling requires checking multiple layers: transport failures, JSON-RPC protocol errors, and application-level errors. Each layer provides different information for troubleshooting and recovery.
Common Issues
Connection Refused Errors
MCP servers must be running before clients connect. The "connection refused" error typically indicates the server isn't started or is listening on the wrong port.
# Wrong: Starting client before server$$ claude-desktop # Fails with ECONNREFUSEDÂ# Correct: Start server first$$ mcp-server-postgres --port 3000 &$$ claude-desktop # Now connects successfully
Always verify server startup logs show successful binding to the expected transport (stdio, HTTP port, or named pipe). Check firewall rules if using network transports.
Invalid Method Errors
Method names in MCP are case-sensitive and use forward slashes as separators. Common mistakes include using incorrect casing or forgetting the namespace prefix.
// Wrong: Incorrect method names
{ "method": "Resources/List" } // Wrong casing
{ "method": "list_resources" } // Wrong format
{ "method": "list" } // Missing namespace
// Correct: Proper method format
{ "method": "resources/list" } // Lowercase with namespace
{ "method": "tools/call" }
{ "method": "notifications/resources/updated" }
Implement method validation early in your request processing pipeline to catch these errors before they reach business logic.
Parameter Type Mismatches
JSON-RPC requires exact type matching for parameters. Numbers must be numbers, not strings containing digits.
# Common parameter type error
wrong_params = {
"jsonrpc": "2.0",
"id": 8,
"method": "tools/call",
"params": {
"name": "calculate_tax",
"arguments": {
"amount": "1000", # Wrong: string instead of number
"rate": "0.08" # Wrong: string instead of number
}
}
}
# Server validates and returns error
validation_error = {
"jsonrpc": "2.0",
"id": 8,
"error": {
"code": -32602,
"message": "Invalid params",
"data": {
"validation_errors": [
{"field": "amount", "expected": "number", "received": "string"},
{"field": "rate", "expected": "number", "received": "string"}
]
}
}
}
# Correct parameter types
correct_params = {
"arguments": {
"amount": 1000, # Correct: actual number
"rate": 0.08 # Correct: actual number
}
}
Use JSON Schema validation on both client and server sides to catch type mismatches early and provide helpful error messages.
Examples
Building a Database Query Server
This example demonstrates a complete MCP server that exposes database access through JSON-RPC methods. The server handles the full lifecycle from initialization through query execution.
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { Pool } from 'pg';
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const server = new Server({
name: "postgres-mcp-server",
version: "1.0.0"
}, {
capabilities: {
resources: { subscribe: false },
tools: { listChanged: false }
}
});
// Handle resources/list - expose tables as resources
server.setRequestHandler("resources/list", async (params) => {
const cursor = params.cursor || 0;
const pageSize = 10;
const tables = await db.query(`
SELECT table_name, table_schema
FROM information_schema.tables
WHERE table_schema = 'public'
LIMIT $1 OFFSET $2
`, [pageSize + 1, cursor]);
const hasMore = tables.rows.length > pageSize;
const resources = tables.rows.slice(0, pageSize).map(table => ({
uri: `db://table/${table.table_name}`,
name: `Table: ${table.table_name}`,
mimeType: "application/json"
}));
return {
resources,
nextCursor: hasMore ? cursor + pageSize : null
};
});
// Handle resources/read - return table schema
server.setRequestHandler("resources/read", async (params) => {
const match = params.uri.match(/^db:\/\/table\/(.+)$/);
if (!match) throw new Error("Invalid resource URI");
const tableName = match[1];
const columns = await db.query(`
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = $1
`, [tableName]);
return {
contents: [{
uri: params.uri,
mimeType: "application/json",
text: JSON.stringify(columns.rows, null, 2)
}]
};
});
// Handle tools/list - expose query tool
server.setRequestHandler("tools/list", async () => {
return {
tools: [{
name: "query_database",
description: "Execute a read-only SQL query",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "SQL SELECT query"
},
limit: {
type: "integer",
minimum: 1,
maximum: 1000,
default: 100
}
},
required: ["query"]
}
}]
};
});
// Handle tools/call - execute queries safely
server.setRequestHandler("tools/call", async (params) => {
if (params.name !== "query_database") {
throw new Error(`Unknown tool: ${params.name}`);
}
const { query, limit = 100 } = params.arguments;
// Basic SQL injection prevention
if (!query.trim().toUpperCase().startsWith("SELECT")) {
return {
content: [{
type: "text",
text: "Only SELECT queries are allowed"
}],
isError: true
};
}
try {
const result = await db.query(`${query} LIMIT ${limit}`);
return {
content: [{
type: "text",
text: JSON.stringify({
rowCount: result.rowCount,
rows: result.rows
}, null, 2)
}],
isError: false
};
} catch (error) {
return {
content: [{
type: "text",
text: `Query error: ${error.message}`
}],
isError: true
};
}
});
// Start server with stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
This server exposes database tables as browsable resources and provides a query tool for data access. The pagination in resources/list
handles large schemas efficiently, while the query tool enforces read-only access for security.
Implementing Change Notifications
Real-time notifications keep clients synchronized with server state changes. This pattern uses JSON-RPC notifications to push updates without client polling.
import asyncio
import json
from datetime import datetime
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
class FileWatcher(FileSystemEventHandler):
def __init__(self, mcp_server):
self.server = mcp_server
self.resource_map = {} # path -> uri mapping
def on_modified(self, event):
if event.is_directory:
return
# Convert file path to MCP resource URI
uri = f"file://{event.src_path}"
# Send notification to all connected clients
notification = {
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": {
"uri": uri,
"timestamp": datetime.utcnow().isoformat()
}
}
# Broadcast to all clients
asyncio.create_task(
self.server.send_notification(notification)
)
class MCPFileServer:
def __init__(self, watch_dir):
self.watch_dir = watch_dir
self.clients = set()
self.file_watcher = FileWatcher(self)
async def handle_client(self, reader, writer):
"""Handle JSON-RPC messages from a client"""
self.clients.add(writer)
try:
while True:
# Read JSON-RPC message
line = await reader.readline()
if not line:
break
message = json.loads(line)
# Process based on method
if message.get("method") == "resources/subscribe":
# Client wants notifications for this resource
await self.handle_subscribe(message, writer)
elif message.get("method") == "resources/list":
# Return watched files
response = await self.list_resources(message)
writer.write(json.dumps(response).encode() + b'\n')
await writer.drain()
finally:
self.clients.remove(writer)
writer.close()
async def handle_subscribe(self, message, writer):
"""Set up file watching for requested resource"""
uri = message["params"]["uri"]
# Extract file path from URI
if uri.startswith("file://"):
path = uri[7:] # Remove file:// prefix
# Start watching this specific file
observer = Observer()
observer.schedule(
self.file_watcher,
path=os.path.dirname(path),
recursive=False
)
observer.start()
# Send success response
response = {
"jsonrpc": "2.0",
"id": message["id"],
"result": {"subscribed": True}
}
writer.write(json.dumps(response).encode() + b'\n')
await writer.drain()
async def send_notification(self, notification):
"""Broadcast notification to all connected clients"""
dead_clients = []
for client in self.clients:
try:
client.write(json.dumps(notification).encode() + b'\n')
await client.drain()
except:
dead_clients.append(client)
# Clean up disconnected clients
for client in dead_clients:
self.clients.remove(client)
# Start server
async def main():
server = MCPFileServer("/watched/directory")
# Listen for JSON-RPC connections
server_coro = await asyncio.start_server(
server.handle_client,
'localhost',
3000
)
async with server_coro:
await server_coro.serve_forever()
asyncio.run(main())
This notification system enables responsive UIs that update immediately when server state changes. The pattern scales to any resource type - database records, API responses, or computed values.
Next Steps
Understanding JSON-RPC's role in MCP opens the door to building powerful integrations. To continue your journey:
- Explore the official MCP specification for complete protocol details
- Review debugging MCP servers guide for troubleshooting techniques
- Study MCP transport options to choose the right communication method
- Build your first server using the MCP TypeScript SDK guide
The JSON-RPC foundation makes MCP predictable and debuggable. With this knowledge, you can confidently build servers that integrate AI assistants with any system or service.
Related Guides
Debugging message serialization errors in MCP protocol
Debug and fix MCP message serialization errors with proven troubleshooting techniques.
Fixing "Method not found (-32601)" JSON-RPC errors
Troubleshoot JSON-RPC method not found errors in MCP servers with detailed debugging strategies.
Fixing "MCP error -32001: Request timed out" errors
Fix MCP error 32001 request timeouts with timeout configuration and performance optimization strategies.