Model Context Protocol (MCP): La Capa de Estandarización que Faltaba para Integrar IA con Herramientas
Arquitectura del Protocolo MCP
Model Context Protocol es un protocolo basado en JSON-RPC 2.0 que define cómo las aplicaciones de IA (clientes) se comunican con capacidades externas (servidores). Esa es la versión de una oración. La realidad tiene más matices.
El protocolo define tres tipos de primitivas que un servidor puede exponer:
- Resources — Datos de solo lectura que el cliente puede traer al contexto (considerar en archivos, registros de base de datos, respuestas de APIs)
- Tools — Funciones ejecutables que el modelo puede invocar con parámetros
- Prompts — Templates de prompts reutilizables con argumentos
La mayoría solo le da bola a los tools. Ese es un error inicial común. Los resources son arguably más importantes porque te permiten alimentar contexto al modelo sin quemar un round trip de tool-call.
Acá va cómo se ve la negociación de capabilities a nivel protocolo:
// Client -> Server (initialize request)
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {
"roots": { "listChanged": true }
},
"clientInfo": {
"name": "mi-agente",
"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"
}
}
}
Observar en el flag de capability listChanged. Este es uno de ese es detalles que nadie menciona — significa que el servidor puede notificar al cliente cuando su lista de tools cambia en runtime. Crítico para ambientes dinámicos donde las herramientas aparecen y desaparecen.
La Capa de Transporte — Consideraciones Clave
MCP define dos transportes oficiales, y entender cuándo usar cada uno es crítico para evitar incidentes en producción.
Transporte stdio
La opción más simple. El cliente levanta el servidor como proceso hijo y se comunica por stdin/stdout. Cada mensaje JSON-RPC es una sola línea terminada en \n.
import { spawn } from 'child_process';
const server = spawn('node', ['./mi-mcp-server.js'], {
stdio: ['pipe', 'pipe', 'pipe'] // stdin, stdout, stderr
});
// CRÍTICO: stderr NO es parte del protocolo
// Si tu server hace console.log(), rompe el stream JSON-RPC
server.stdout.on('data', (chunk) => {
const lines = chunk.toString().split('\n').filter(Boolean);
for (const line of lines) {
const message = JSON.parse(line); // Acá explota
handleMessage(message); // si loggeás a stdout
}
});
Un error frecuente: un console.log('Server started') en la inicialización del servidor que solo se dispara en producción por una diferencia en la ruta de carga de la versión de Node.js. El parser de JSON se atraganta con Server started\n{"jsonrpc":"2.0"... y el mensaje de error es completamente inútil: "Unexpected token S in JSON at position 0". Esto puede consumir horas de debugging.
Transporte Streamable HTTP
Esto es lo que se desea para deployments en producción. El cliente se comunica vía HTTP POST a un único endpoint, y el servidor opcionalmente puede upgradear a Server-Sent Events para respuestas streaming.
// Implementación del server con express
import express from 'express';
const app = express();
app.use(express.json());
// Un solo endpoint maneja todo el tráfico MCP
app.post('/mcp', async (req, res) => {
const message = req.body;
// Chequear si el cliente acepta SSE
const acceptsSSE = req.headers.accept?.includes('text/event-stream');
if (acceptsSSE && isLongRunningTool(message)) {
// Streamear resultados vía 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 {
// Respuesta JSON-RPC simple
const result = await handleMessage(message);
res.json(result);
}
});
El header Mcp-Session-Id es como el servidor trackea las sesiones de clientes. Sin él, no es posible implementar tools con estado o subscripciones a resources a través de múltiples requests HTTP.
Armando un MCP Server de Verdad
A continuación se presenta un servidor MCP de producción — una herramienta de query PostgreSQL que expone tablas de la base como resources y queries de lectura seguras como 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'
});
// Exponer schemas de tablas como 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: ejecutar SQL de solo lectura
server.tool(
'query',
'Ejecutar una query SQL de solo lectura contra la base',
{
sql: z.string().describe('Query SQL SELECT a ejecutar'),
params: z.array(z.unknown()).optional()
.describe('Valores parametrizados de la query')
},
async ({ sql, params }) => {
// SEGURIDAD: enforce read-only en múltiples niveles
const normalized = sql.trim().toLowerCase();
const forbidden = ['insert', 'update', 'delete', 'drop', 'alter',
'create', 'truncate', 'grant', 'revoke'];
for (const keyword of forbidden) {
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
if (regex.test(normalized)) {
return {
content: [{
type: 'text',
text: `Rechazado: la query contiene keyword prohibido "${keyword}"`
}],
isError: true
};
}
}
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)
}, null, 2)
}]
};
} catch (err) {
await client.query('ROLLBACK');
return {
content: [{ type: 'text', text: `Error en query: ${err.message}` }],
isError: true
};
} finally {
client.release();
}
}
);
// Template de prompt para patrones de análisis comunes
server.prompt(
'analyze-table',
'Generar queries de análisis para una tabla específica',
{ tableName: z.string() },
({ tableName }) => ({
messages: [{
role: 'user',
content: {
type: 'text',
text: `Analizá la tabla "${tableName}". Primero leé su schema usando el resource table-schema, después ejecutar queries para entender la distribución de datos, tasas de nulls, e identificar anomalías. Enfocate en insights accionables.`
}
}]
})
);
const transport = new StdioServerTransport();
await server.connect(transport);
Las Primitivas en la Práctica
Resources vs Tools — El Framework de Decisión
Un error común es exponer todo como tools. Acá va un modelo mental útil:
- Resource: Datos que el modelo necesita leer para tomar decisiones. Pull-based. El modelo decide cuándo fetchear.
- Tool: Una acción con side effects o computación. Invocado explícitamente por el modelo con parámetros específicos.
Si exponers una tabla de configuración de 200 filas como un tool call que devuelve JSON, el modelo tiene que invocarlo, esperar la respuesta, y después seguir razonando. Si lo exponers como resource, el cliente puede prefetchearlo al contexto antes de que el modelo arranque a generar.
Prompt Templates — La Primitiva Subutilizada
Los prompts en MCP no son solo texto estático. Soportan argumentos y pueden referenciar resources:
server.prompt(
'incident-response',
'Guiar a través de investigación de incidentes',
{
severity: z.enum(['p1', 'p2', 'p3']),
service: z.string()
},
({ severity, service }) => ({
messages: [{
role: 'user',
content: {
type: 'text',
text: `Respuesta a incidente para ${severity.toUpperCase()} en ${service}.\n` +
`1. Consultá logs de error recientes usando el tool de logs\n` +
`2. Verificar historial de deploys vía el resource de deploys\n` +
`3. Evaluá blast radius y sugerí mitigación`
}
}]
})
);
Esto es enormemente útil para estandarizar el comportamiento de agentes en tu equipo. En vez de que todos escriban system prompts ligeramente distintos, se define templates de prompts del lado del servidor y son descubribles a través del protocolo.
Manejo Robusto de Errores
La spec define códigos de error JSON-RPC estándar, pero también agrega unos específicos de MCP. Este es un patrón recomendado:
server.tool('deploy', 'Deployar un servicio', { /* ... */ }, async (args) => {
try {
const result = await deployService(args);
return {
content: [{ type: 'text', text: JSON.stringify(result) }]
};
} catch (err) {
if (err.code === 'RATE_LIMITED') {
// Devolver como error de tool — el modelo puede reintentar o informar al usuario
return {
content: [{
type: 'text',
text: `Rate limited. Reintentá después de ${err.retryAfter}s`
}],
isError: true
};
}
// Errores inesperados — throw para triggear respuesta de error JSON-RPC
throw err;
}
});
La distinción entre isError: true en un resultado de tool vs throwear un error es sutil pero importante. Los errores de tool son "fallas esperadas" — el modelo puede razonar sobre ellos. Los errores throweados son fallas a nivel protocolo que típicamente abortan la interacción actual.
Manejo de Sesiones y Estado
Un detalle importante: las sesiones MCP tienen estado. El header Mcp-Session-Id no es solo para logging — es cómo el servidor correlaciona subscripciones a resources, trackea qué tools fueron listados, y maneja 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;
}
// Limpiar sesiones viejas cada 5 minutos
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);
Problemas Frecuentes en Producción
La calidad de la descripción del tool importa más de lo que considerars. El modelo usa las descripciones de tools para decidir cuándo llamar a un tool. Un tool descrito como "Buscar en la base de datos" se llama para cada pregunta, incluso cuando un resource read más simple alcanza. Cambiarlo a "Ejecutar queries SQL SELECT para filtrado complejo, agregación o joins que no se pueden resolver leyendo resources de tablas directamente" reduce las llamadas innecesarias un 60%.
El JSON Schema para parámetros de tools tiene que ser preciso. No uses z.any() ni z.record(z.unknown()). Cuanto más restringido sea tu schema, mejor el modelo llena los parámetros. Los modelos alucinan objetos anidados enteros cuando el schema es demasiado permisivo.
Colocar timeout a las ejecuciones de tus tools. MCP no define un mecanismo de timeout a nivel protocolo — eso es responsabilidad del servidor. Se recomienda wrappear cada handler de tool:
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);
}
};
}
Hacia Dónde Va Todo Esto
MCP todavía es joven, pero la trayectoria es clara. El protocolo ya tiene buy-in de los grandes vendors de IDEs, frameworks de agentes y proveedores de modelos. La spec está avanzando hacia OAuth 2.1 para autenticación, lo que va a hacer viables los servidores MCP multi-tenant para productos SaaS.
El aspecto más prometedor es el patrón de notificación listChanged. Imaginá un servidor MCP que expone tools dinámicamente basándose en los permise es del usuario, el contexto del proyecto actual, o incluso la competencia demostrada del modelo. El protocolo soporta esto hoy — la mayoría de las implementaciones simplemente no alcanzaron todavía.
Para cualquier tipo de sistema de agentes de IA, construir servidores MCP es preferible a escribir integraciones custom. La inversión se justifica cuando el cliente decide cambiar de modelo por tercera vez.