A06: MCP Protocol Deep Dive
Related chapters: Ch7 MCP — Connect Claude to Everything, Ch13 Agent SDK
The Model Context Protocol (MCP) is an open standard that defines how AI agents communicate with external tools, data sources, and services. Chapter 7 covers practical MCP usage in Claude Code. This appendix goes deeper into the protocol itself: the specification, transport mechanisms, message lifecycle, and the design decisions you need to understand when building or evaluating MCP servers.
MCP Specification Overview
Purpose and Design Goals
MCP was created to solve a fragmentation problem. Before MCP, every AI tool integration was bespoke: each IDE plugin, CLI tool, or agent framework defined its own way to connect to external services. MCP provides a universal interface between AI clients and tool providers, similar to how USB standardized peripheral connections.
The core design goals are:
- Simplicity --- JSON-RPC 2.0 over well-understood transports
- Composability --- multiple servers can be connected simultaneously
- Discoverability --- servers declare their capabilities at startup
- Security --- clients control what the AI model can see and do
Architecture
MCP follows a client-server architecture with three layers:
┌─────────────────────────────────────────┐
│ AI Application │
│ (Claude Code, IDE plugin, Agent SDK) │
│ │
│ ┌───────────────────────────────────┐ │
│ │ MCP Client │ │
│ │ - Manages server connections │ │
│ │ - Routes tool calls │ │
│ │ - Enforces security policies │ │
│ └──────────┬────────────────────────┘ │
└─────────────┼───────────────────────────┘
│ JSON-RPC 2.0
│ (stdio / SSE / streamable HTTP)
┌─────────────┼───────────────────────────┐
│ ┌──────────┴────────────────────────┐ │
│ │ MCP Server │ │
│ │ - Declares tools, resources, │ │
│ │ prompts at startup │ │
│ │ - Executes tool calls │ │
│ │ - Returns structured results │ │
│ └───────────────────────────────────┘ │
│ Tool Provider │
│ (Database, API, filesystem, etc.) │
└─────────────────────────────────────────┘A single client can connect to multiple servers simultaneously. Claude Code does this routinely --- you might have a database server, a documentation server, and a deployment server all active in one session.
Message Format (JSON-RPC 2.0)
All MCP communication uses JSON-RPC 2.0. There are three message types:
Request (client to server, or server to client):
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "query_database",
"arguments": {
"sql": "SELECT count(*) FROM users WHERE active = true"
}
}
}Response (reply to a request):
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "Query returned: 4,271 active users"
}
]
}
}Notification (one-way, no response expected):
{
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"progressToken": "abc123",
"progress": 75,
"total": 100
}
}The id field distinguishes requests (which expect replies) from notifications (which do not).
Transport Types
MCP defines three transport mechanisms. The choice depends on your deployment model.
stdio (Standard I/O)
The client launches the server as a child process and communicates over stdin/stdout. Each JSON-RPC message is a single line terminated by \n.
Client Server (child process)
│ │
│──── spawn process ────────────>│
│ │
│── {"jsonrpc":"2.0",...}\n ──-->│ (stdin)
│<── {"jsonrpc":"2.0",...}\n ────│ (stdout)
│ │
│──── close stdin ──────────────>│ (shutdown signal)When to use stdio:
- Local development tools
- Single-user scenarios
- Claude Code's default mode for local MCP servers
- When the server needs access to the local filesystem
Advantages: No network setup, no ports, no authentication needed. The OS process model provides natural isolation.
Limitations: Cannot serve multiple clients. The server dies if the client dies.
Configuration in Claude Code (.claude/settings.json):
{
"mcpServers": {
"my-db-tool": {
"command": "node",
"args": ["./mcp-servers/db-tool/index.js"],
"env": {
"DATABASE_URL": "postgresql://localhost:5432/mydb"
}
}
}
}SSE (Server-Sent Events)
The client connects to the server over HTTP. Server-to-client messages flow over an SSE stream; client-to-server messages are sent as HTTP POST requests.
When to use SSE:
- Shared servers accessed by multiple clients
- Remote servers behind a load balancer
- Situations where the server lifecycle is independent of the client
Advantages: Works over standard HTTP infrastructure. Supports multiple concurrent clients. Server can outlive any individual client.
Limitations: Requires HTTP infrastructure. More complex to set up and secure. Being superseded by streamable HTTP in newer implementations.
Streamable HTTP
The newest transport, designed to combine the simplicity of stdio with the flexibility of HTTP. Each request-response pair is a single HTTP roundtrip, with optional streaming via SSE for long-running operations.
Client Server
│ │
│── POST /mcp (JSON-RPC) ──────>│
│<── 200 OK (JSON-RPC) ─────────│ (simple case)
│ │
│── POST /mcp (JSON-RPC) ──────>│
│<── 200 OK (SSE stream) ───────│ (streaming case)
│ data: {"jsonrpc":"2.0",...} │
│ data: {"jsonrpc":"2.0",...} │
│ │When to use streamable HTTP:
- New server implementations (this is the recommended transport going forward)
- When you need both simplicity and remote access
- Production deployments
Configuration in Claude Code:
{
"mcpServers": {
"remote-tool": {
"type": "url",
"url": "https://mcp.example.com/my-tool"
}
}
}Server Lifecycle Management
Startup and Capability Negotiation
When a client connects to a server, the first exchange is an initialize handshake:
Client ──> initialize request (protocol version, client capabilities)
Server <── initialize response (protocol version, server capabilities)
Client ──> initialized notification (handshake complete)The server's initialize response declares what it supports:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-03-26",
"capabilities": {
"tools": { "listChanged": true },
"resources": { "subscribe": true, "listChanged": true },
"prompts": { "listChanged": true }
},
"serverInfo": {
"name": "my-database-server",
"version": "1.2.0"
}
}
}The listChanged capability tells the client that the server may dynamically add or remove tools/resources during the session.
Health Checks
For long-running servers (SSE and streamable HTTP), clients can send periodic ping requests. If the server does not respond within a timeout, the client considers the connection dead and may attempt reconnection.
{ "jsonrpc": "2.0", "id": 99, "method": "ping" }Shutdown
For stdio servers, closing stdin signals the server to shut down. For HTTP-based servers, the client simply stops sending requests. Well-behaved servers should handle SIGTERM gracefully, completing in-progress operations before exiting.
Resources and Prompts Primitives
MCP defines three core primitives. Most developers know about tools, but resources and prompts are equally important.
Tools
Tools are executable functions the AI can call. They are the most common primitive.
{
"name": "query_database",
"description": "Run a read-only SQL query against the application database",
"inputSchema": {
"type": "object",
"properties": {
"sql": {
"type": "string",
"description": "SQL query to execute (SELECT only)"
}
},
"required": ["sql"]
}
}Resources
Resources are data that the AI can read. Unlike tools, resources don't execute code --- they provide content. Think of them as files or documents the server exposes.
{
"uri": "db://schema/users",
"name": "Users table schema",
"mimeType": "application/json",
"description": "The current schema of the users table"
}Resources support subscriptions: the client can ask to be notified when a resource changes. This is useful for live dashboards, log tails, or monitoring.
Prompts
Prompts are reusable templates that the server provides. The AI (or user) can invoke them to get pre-structured interactions.
{
"name": "explain_query",
"description": "Explain a SQL query execution plan in plain language",
"arguments": [
{
"name": "sql",
"description": "The SQL query to explain",
"required": true
}
]
}Prompts are particularly useful for standardizing common interactions. Instead of the user writing "explain this query" each time, the server provides a structured prompt template that produces consistent results.
Building Production MCP Servers
Error Handling
MCP uses JSON-RPC error codes. Your server should return structured errors, not crash:
// Good: structured error
return {
error: {
code: -32602, // Invalid params
message: "Table 'nonexistent' does not exist",
data: {
availableTables: ["users", "orders", "products"]
}
}
};
// Bad: unhandled exception crashes the serverStandard JSON-RPC error codes:
-32700Parse error-32600Invalid request-32601Method not found-32602Invalid params-32603Internal error
Use custom codes (positive numbers) for domain-specific errors.
Logging
MCP provides a notifications/message method for servers to send log messages to the client:
{
"jsonrpc": "2.0",
"method": "notifications/message",
"params": {
"level": "warning",
"logger": "db-server",
"data": "Slow query detected: 3.2s for SELECT * FROM orders"
}
}Log levels follow the standard: debug, info, notice, warning, error, critical, alert, emergency.
For stdio servers, never write to stdout for logging --- stdout is the protocol channel. Use stderr or the MCP notification mechanism.
Rate Limiting
If your MCP server wraps an external API with rate limits, implement queuing:
class RateLimitedServer {
private queue: Array<() => Promise<void>> = [];
private processing = false;
private requestsPerMinute = 60;
private requestCount = 0;
async handleToolCall(params: ToolCallParams): Promise<ToolCallResult> {
if (this.requestCount >= this.requestsPerMinute) {
return {
content: [{
type: "text",
text: "Rate limit reached. Please retry in 60 seconds."
}],
isError: true
};
}
this.requestCount++;
// ... execute the actual tool call
}
}MCP Security Model
Principle of Least Privilege
Servers should expose the minimum set of tools needed. A database server for code review should expose query (read-only) but not execute (write).
Sandboxing via Claude Code Permissions
Claude Code wraps MCP tool calls in its permission system. Even if a server exposes a dangerous tool, the user must approve its use. This provides a human-in-the-loop safety layer.
In .claude/settings.json, you can pre-approve specific MCP tools:
{
"permissions": {
"allow": [
"mcp__my-db-tool__query_database"
],
"deny": [
"mcp__my-db-tool__drop_table"
]
}
}Input Validation
Every MCP server should validate inputs rigorously. The AI model might generate unexpected arguments:
function validateSqlQuery(sql: string): void {
const normalized = sql.trim().toUpperCase();
if (!normalized.startsWith("SELECT")) {
throw new McpError(
-32602,
"Only SELECT queries are allowed"
);
}
// Check for SQL injection patterns
const forbidden = ["DROP", "DELETE", "UPDATE", "INSERT", "ALTER"];
for (const keyword of forbidden) {
if (normalized.includes(keyword)) {
throw new McpError(
-32602,
`Forbidden SQL keyword: ${keyword}`
);
}
}
}Authentication for Remote Servers
For streamable HTTP servers exposed on a network, use standard HTTP authentication. MCP does not define its own auth layer --- it relies on the transport:
{
"mcpServers": {
"remote-tool": {
"type": "url",
"url": "https://mcp.example.com/my-tool",
"headers": {
"Authorization": "Bearer ${MCP_API_TOKEN}"
}
}
}
}Example: Minimal MCP Server
TypeScript (stdio transport)
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// Create the server
const server = new McpServer({
name: "word-counter",
version: "1.0.0",
});
// Register a tool
server.tool(
"count_words",
"Count the number of words in a text string",
{
text: z.string().describe("The text to count words in"),
},
async ({ text }) => {
const wordCount = text.trim().split(/\s+/).filter(Boolean).length;
return {
content: [
{
type: "text",
text: `Word count: ${wordCount}`,
},
],
};
}
);
// Register a resource
server.resource(
"server-info",
"info://server",
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify({
name: "word-counter",
uptime: process.uptime(),
nodeVersion: process.version,
}),
},
],
})
);
// Start the server
const transport = new StdioServerTransport();
await server.connect(transport);Python (stdio transport)
from mcp.server.fastmcp import FastMCP
# Create the server
mcp = FastMCP("word-counter")
# Register a tool
@mcp.tool()
def count_words(text: str) -> str:
"""Count the number of words in a text string."""
word_count = len(text.strip().split())
return f"Word count: {word_count}"
# Register a resource
@mcp.resource("info://server")
def server_info() -> str:
"""Return server status information."""
import json, time
return json.dumps({
"name": "word-counter",
"timestamp": time.time(),
})
# Start the server (stdio transport)
if __name__ == "__main__":
mcp.run(transport="stdio")Claude Code configuration for either server:
{
"mcpServers": {
"word-counter-ts": {
"command": "npx",
"args": ["tsx", "./mcp-servers/word-counter/index.ts"]
},
"word-counter-py": {
"command": "python",
"args": ["./mcp-servers/word-counter/server.py"]
}
}
}MCP vs Other Integration Approaches
| Approach | Scope | Discovery | Multi-client | Protocol |
|---|---|---|---|---|
| MCP | Universal standard | Server declares capabilities | Yes (HTTP transports) | JSON-RPC 2.0 |
| OpenAI Function Calling | OpenAI API only | Client defines functions | N/A (API-level) | OpenAI API |
| LangChain Tools | LangChain framework | Framework registry | No | Python objects |
| Custom REST APIs | Your application | Manual documentation | Yes | HTTP/REST |
MCP's advantage is that a server written once works with any MCP client --- Claude Code, Cursor, Windsurf, custom agents, and any future client that implements the protocol.
Key Takeaways
- MCP standardizes AI-tool communication using JSON-RPC 2.0 over three transports: stdio for local tools, SSE for shared servers, and streamable HTTP for production deployments.
- Capability negotiation at startup lets servers declare exactly what they support, enabling graceful degradation when features are missing.
- Resources and prompts extend MCP beyond simple tool calls --- resources provide read-only data, prompts provide reusable interaction templates.
- Production servers need structured error handling, proper logging (never stdout for stdio servers), input validation, and rate limiting.
- Security is layered: the server validates inputs, the transport handles authentication, and Claude Code's permission system provides human-in-the-loop approval.
See also: Ch7 MCP — Connect Claude to Everything for practical setup, and A05 Tool Calling Internals for how Claude decides which MCP tool to invoke.