Skip to main content
AI/ML

Model Context Protocol (MCP): The Missing Standardization Layer for AI-Tool Integration

9 min read
LD
Lucio Durán
Engineering Manager & AI Solutions Architect
Also available in: Español, Italiano

MCP Protocol Architecture

Model Context Protocol is a JSON-RPC 2.0 based protocol that defines how AI applications (clients) communicate with external capabilities (servers). That's the one-sentence version. The reality is more nuanced.

The protocol defines three primitive types that a server can expose:

  1. Resources — Read-only data that the client can pull into context (think files, database records, API responses)
  2. Tools — Executable functions the model can invoke with parameters
  3. Prompts — Reusable prompt templates with arguments

Most people only care about tools. That is a common initial mistake. Resources are arguably more important because they let you feed context to the model without burning a tool-call round trip.

The capability negotiation at the protocol level:

// Client -> Server (initialize request)
{
 "jsonrpc": "2.0",
 "id": 1,
 "method": "initialize",
 "params": {
 "protocolVersion": "2025-03-26",
 "capabilities": {
 "roots": { "listChanged": true }
 },
 "clientInfo": {
 "name": "my-agent",
 "version": "1.0.0"
 }
 }
}

// Server -> Client (initialize response)
{
 "jsonrpc": "2.0",
 "id": 1,
 "result": {
 "protocolVersion": "2025-03-26",
 "capabilities": {
 "tools": { "listChanged": true },
 "resources": { "subscribe": true },
 "prompts": { "listChanged": true }
 },
 "serverInfo": {
 "name": "postgres-mcp",
 "version": "0.3.1"
 }
 }
}

Notice the listChanged capability flag. This is one of those details nobody talks about — it means the server can notify the client when its tool list changes at runtime. Critical for dynamic environments where tools come and go.

The Transport Layer — Where Things Get Interesting

MCP defines two official transports, and understanding when to use each one is critical for avoiding production incidents.

stdio Transport

The simplest option. The client spawns the server as a child process and communicates over stdin/stdout. Each JSON-RPC message is a single line terminated by \n.

import { spawn } from 'child_process';

const server = spawn('node', ['./my-mcp-server.js'], {
 stdio: ['pipe', 'pipe', 'pipe'] // stdin, stdout, stderr
});

// CRITICAL: stderr is NOT part of the protocol
// If your server console.log()s, it breaks the JSON-RPC stream
server.stdout.on('data', (chunk) => {
 const lines = chunk.toString().split('\n').filter(Boolean);
 for (const line of lines) {
 const message = JSON.parse(line); // This is where it blows up
 handleMessage(message); // if you log to stdout
 }
});

A common pitfall: a console.log('Server started') in the server initialization that only triggers in production because of a different Node.js version loading path. The JSON parser chokes on Server started\n{"jsonrpc":"2.0"... and the error message is completely unhelpful: "Unexpected token S in JSON at position 0". This can consume hours of debugging.

Streamable HTTP Transport

This is what you want for production deployments. The client communicates via HTTP POST to a single endpoint, and the server can optionally upgrade to Server-Sent Events for streaming responses.

// Server implementation with express
import express from 'express';

const app = express();
app.use(express.json());

// Single endpoint handles all MCP traffic
app.post('/mcp', async (req, res) => {
 const message = req.body;

 // Check if client accepts SSE
 const acceptsSSE = req.headers.accept?.includes('text/event-stream');

 if (acceptsSSE && isLongRunningTool(message)) {
 // Stream results back via SSE
 res.writeHead(200, {
 'Content-Type': 'text/event-stream',
 'Cache-Control': 'no-cache',
 'Connection': 'keep-alive',
 'Mcp-Session-Id': sessionId
 });

 await streamToolExecution(message, res);
 res.end();
 } else {
 // Simple JSON-RPC response
 const result = await handleMessage(message);
 res.json(result);
 }
});

The Mcp-Session-Id header is how the server tracks client sessions. Without it, you can't implement stateful tools or resource subscriptions across multiple HTTP requests.

Building a Real MCP Server

The following walks through a production MCP server — a PostgreSQL query tool that exposes database tables as resources and safe read queries as tools.

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import pg from 'pg';
import { z } from 'zod';

const pool = new pg.Pool({
 connectionString: process.env.DATABASE_URL,
 max: 5,
 idleTimeoutMillis: 30000
});

const server = new McpServer({
 name: 'postgres-readonly',
 version: '1.0.0'
});

// Expose table schemas as resources
server.resource(
 'table-schema',
 'schema://tables/{tableName}',
 async (uri, { tableName }) => {
 const result = await pool.query(`
 SELECT column_name, data_type, is_nullable, column_default
 FROM information_schema.columns
 WHERE table_name = $1
 ORDER BY ordinal_position
 `, [tableName]);

 return {
 contents: [{
 uri: uri.href,
 mimeType: 'application/json',
 text: JSON.stringify(result.rows, null, 2)
 }]
 };
 }
);

// Tool: execute read-only SQL
server.tool(
 'query',
 'Execute a read-only SQL query against the database',
 {
 sql: z.string().describe('SQL SELECT query to execute'),
 params: z.array(z.unknown()).optional()
 .describe('Parameterized query values')
 },
 async ({ sql, params }) => {
 // SECURITY: enforce read-only at multiple levels
 const normalized = sql.trim().toLowerCase();
 const forbidden = ['insert', 'update', 'delete', 'drop', 'alter',
 'create', 'truncate', 'grant', 'revoke'];

 for (const keyword of forbidden) {
 // Check for keyword at word boundary, not inside identifiers
 const regex = new RegExp(`\\b${keyword}\\b`, 'i');
 if (regex.test(normalized)) {
 return {
 content: [{
 type: 'text',
 text: `Refused: query contains forbidden keyword "${keyword}"`
 }],
 isError: true
 };
 }
 }

 // Also use a read-only transaction as defense in depth
 const client = await pool.connect();
 try {
 await client.query('SET TRANSACTION READ ONLY');
 await client.query('BEGIN');

 const result = await client.query(sql, params || []);

 await client.query('COMMIT');

 return {
 content: [{
 type: 'text',
 text: JSON.stringify({
 rowCount: result.rowCount,
 rows: result.rows.slice(0, 100) // Cap output
 }, null, 2)
 }]
 };
 } catch (err) {
 await client.query('ROLLBACK');
 return {
 content: [{ type: 'text', text: `Query error: ${err.message}` }],
 isError: true
 };
 } finally {
 client.release();
 }
 }
);

// Prompt template for common analysis patterns
server.prompt(
 'analyze-table',
 'Generate analysis queries for a specific table',
 { tableName: z.string() },
 ({ tableName }) => ({
 messages: [{
 role: 'user',
 content: {
 type: 'text',
 text: `Analyze the "${tableName}" table. First read its schema using the table-schema resource, then run queries to understand data distribution, null rates, and identify any anomalies. Focus on actionable insights.`
 }
 }]
 })
);

// Start server
const transport = new StdioServerTransport();
await server.connect(transport);

The Primitives in Practice

Resources vs Tools — The Decision Framework

A common mistake is exposing everything as tools. Here is a useful mental model:

  • Resource: Data the model needs to read to make decisions. Pull-based. Model decides when to fetch.
  • Tool: An action with side effects or computation. Called explicitly by the model with specific parameters.

If you expose a 200-row config table as a tool call that returns JSON, the model has to invoke it, wait for the response, then continue reasoning. If you expose it as a resource, the client can prefetch it into context before the model even starts generating.

Prompt Templates — The Underused Primitive

Prompts in MCP aren't just static text. They support arguments and can reference resources:

server.prompt(
 'incident-response',
 'Guide through incident investigation',
 {
 severity: z.enum(['p1', 'p2', 'p3']),
 service: z.string()
 },
 ({ severity, service }) => ({
 messages: [{
 role: 'user',
 content: {
 type: 'text',
 text: `Incident response for ${severity.toUpperCase()} on ${service}.\n` +
 `1. Query recent error logs using the logs tool\n` +
 `2. Check deployment history via the deploys resource\n` +
 `3. Assess blast radius and suggest mitigation`
 }
 }]
 })
);

This is enormously useful for standardizing agent behavior across your team. Instead of everyone writing slightly different system prompts, you define prompt templates on the server side and they're discoverable via the protocol.

Robust Error Handling

The spec defines standard JSON-RPC error codes, but also adds MCP-specific ones. Here is a recommended pattern:

server.tool('deploy', 'Deploy a service', { /* ... */ }, async (args) => {
 try {
 const result = await deployService(args);
 return {
 content: [{ type: 'text', text: JSON.stringify(result) }]
 };
 } catch (err) {
 if (err.code === 'RATE_LIMITED') {
 // Return as tool error — model can retry or inform user
 return {
 content: [{
 type: 'text',
 text: `Rate limited. Retry after ${err.retryAfter}s`
 }],
 isError: true
 };
 }
 // Unexpected errors — throw to trigger JSON-RPC error response
 throw err;
 }
});

The distinction between isError: true in a tool result vs throwing an error is subtle but important. Tool errors are "expected failures" — the model can reason about them. Thrown errors are protocol-level failures that typically abort the current interaction.

Session Management and Statefulness

An important detail: MCP sessions are stateful. The Mcp-Session-Id header isn't just for logging — it's how the server correlates resource subscriptions, tracks which tools have been listed, and manages cleanup.

const sessions = new Map();

function getOrCreateSession(sessionId) {
 if (!sessions.has(sessionId)) {
 sessions.set(sessionId, {
 id: sessionId,
 subscriptions: new Set(),
 createdAt: Date.now(),
 lastActivity: Date.now()
 });
 }
 const session = sessions.get(sessionId);
 session.lastActivity = Date.now();
 return session;
}

// Cleanup stale sessions every 5 minutes
setInterval(() => {
 const cutoff = Date.now() - 30 * 60 * 1000; // 30 min TTL
 for (const [id, session] of sessions) {
 if (session.lastActivity < cutoff) {
 session.subscriptions.forEach(sub => sub.unsubscribe());
 sessions.delete(id);
 }
 }
}, 5 * 60 * 1000);

Real-World Gotchas

Tool description quality matters more than expected. The model uses tool descriptions to decide when to call a tool. A tool described as "Search the database" gets called for every question, even when a simpler resource read would suffice. Changing it to "Execute SQL SELECT queries for complex filtering, aggregation, or joins that can't be answered by reading table resources directly" reduces unnecessary calls by 60%.

JSON Schema for tool parameters needs to be precise. Don't use z.any() or z.record(z.unknown()). The more constrained your schema, the better the model fills in parameters. Models hallucinate entire nested objects when the schema is too permissive.

Timeout tool executions. MCP does not define a timeout mechanism at the protocol level — that is the server's responsibility. Wrapping every tool handler is recommended:

function withTimeout(fn, ms = 30000) {
 return async (...args) => {
 const controller = new AbortController();
 const timer = setTimeout(() => controller.abort(), ms);
 try {
 return await fn(...args, { signal: controller.signal });
 } finally {
 clearTimeout(timer);
 }
 };
}

Where This Is All Going

MCP is still young, but the trajectory is clear. The protocol already has buy-in from major IDE vendors, agent frameworks, and model providers. The spec is moving toward OAuth 2.1 for authentication, which will make multi-tenant MCP servers viable for SaaS products.

The most promising aspect is the listChanged notification pattern. Imagine an MCP server that dynamically exposes tools based on the user's permissions, the current project context, or even the model's demonstrated competence. The protocol supports this today — most implementations just haven't caught up yet.

For any AI agent system, building MCP servers is preferable to writing custom integrations. The investment pays off when the client decides to switch models for the third time.

mcpai-integrationllm-toolinganthropicprotocol-designjson-rpc

Tools mentioned in this article

AnthropicTry Anthropic
VercelTry Vercel
Disclosure: Some links in this article are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. I only recommend tools I personally use and trust.
Compartir
Seguime