← Volver al blog
steply / blog · mcp-por-baixo-dos-panos-protocolo-completo.md
$ steply blog open mcp-por-baixo-dos-panos-protocolo-completo
▸ loading article…
✓ ready

MCP por dentro: el protocolo completo, sin dejar ningún detalle fuera

porSteply10 min de lectura

Quien ya leyó el tutorial de MCP en TypeScript aquí en el blog sabe cómo levantar un servidor funcional. Este post es el complemento denso: el protocolo MCP desmenuzado por dentro, desde la capa de transporte hasta el handshake, desde las primitivas hasta las notificaciones, desde JSON-RPC hasta OAuth. Sin recortar nada. Si vas a escribir un servidor para producción, integrarte con un cliente custom, o simplemente entender qué pasa cuando Claude Desktop llama a una tool, es esto.

MCP (Model Context Protocol) es un protocolo abierto de Anthropic, lanzado en noviembre de 2024 y en iteración activa. La versión estable actual es 2025-06-18, con la revisión 2025-03-26 todavía soportada para retrocompatibilidad. Toda la especificación vive en modelcontextprotocol.io. El resto de este post es lo que está ahí, traducido a un lenguaje directo y organizado para una lectura secuencial.

1. Arquitectura: host, client, server

Tres roles. Host: aplicación que orquesta el LLM (Claude Desktop, Cursor, Windsurf, tu agente custom). Client: componente dentro del host que mantiene una conexión 1:1 con un servidor. Server: proceso que expone capacidades (tools, resources, prompts). Un host puede tener N clients, cada client conectado a 1 server. Los servers son ignorantes sobre el host, conocen solo su propio alcance.

La separación importa por seguridad. El host decide a qué servers conectarse, gestiona el consentimiento del usuario, y enruta lo que cada conversación del LLM alcanza a ver. El server nunca conversa directamente con otro server. Toda interacción cruzada pasa por el host. Esto evita la escalada lateral de permisos y mantiene el blast radius previsible.

2. Las tres capas: transport, protocol, application

Transport es el canal físico de mensajes (stdio, HTTP). Protocol es la sintaxis y la semántica de los mensajes (JSON-RPC 2.0). Application es el vocabulario del MCP en sí (métodos como tools/call, notificaciones como notifications/resources/updated). Las capas son independientes: cambiar el transport no cambia el payload, cambiar el payload no cambia el transport.

3. Transports oficiales

Dos transportes estandarizados por la spec.

stdio: el client hace spawn del server como proceso hijo. JSON-RPC va y vuelve por stdin y stdout, un mensaje por línea (delimitado por \n, sin embedded newlines en el JSON). stderr queda libre para logs. Es el transport por defecto para integración local (Claude Desktop, IDE plugins). Latencia mínima, sin red, sin auth (el proceso hereda el permiso del host).

Streamable HTTP: introducido en 2025-03-26, reemplaza el antiguo HTTP+SSE. El server expone un único endpoint que acepta POST y GET. El cliente manda mensajes vía POST con Content-Type: application/json. El server responde inline (JSON simple) o hace upgrade de la respuesta a text/event-stream cuando necesita streaming. El GET en el mismo endpoint abre un canal SSE para server-initiated messages (notificaciones, sampling inverso). Las sesiones se identifican por el header Mcp-Session-Id devuelto en initialize y reutilizado en llamadas subsecuentes. Es el transport recomendado para cualquier server remoto.

Las implementaciones pueden tener transports custom (WebSocket, named pipes, gRPC bridges), pero pierden interop. La spec recomienda quedarse con los dos oficiales.

4. JSON-RPC 2.0: el sobre

Todo el tráfico MCP es JSON-RPC 2.0. Tres tipos de mensaje.

Request: pide algo y espera respuesta. Tiene id (string o número, único en la sesión), method, y params opcional.

{
 "jsonrpc": "2.0",
 "id": 42,
 "method": "tools/call",
 "params": { "name": "create_ticket", "arguments": { "title": "x" } }
}

Response: coincide con el request por el mismo id. Tiene result en caso de éxito, o error en caso de falla. Nunca los dos.

{ "jsonrpc": "2.0", "id": 42, "result": { "content": [...] } }
{ "jsonrpc": "2.0", "id": 42, "error": { "code": -32602, "message": "invalid params" } }

Notification: fire-and-forget. Sin id, sin respuesta esperada. Se usa para progreso, cambio de estado, log.

{ "jsonrpc": "2.0", "method": "notifications/progress", "params": {...} }

Errores estándar de JSON-RPC: -32700 parse error, -32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal. MCP reserva el rango -32000 a -32099 para errores específicos (ej: -32002 resource not found).

5. Lifecycle: initialize, operate, shutdown

Toda sesión MCP sigue tres fases.

Initialize: el primer request que manda el client. Negocia la versión de protocolo e intercambia capabilities. El server elige la versión (entre las soportadas) y devuelve sus propias capabilities. El client confirma con la notificación notifications/initialized. Antes de esa confirmación, el server no debe procesar ningún request normal (excepto ping).

// client -> server
{
 "jsonrpc": "2.0", "id": 1, "method": "initialize",
 "params": {
 "protocolVersion": "2025-06-18",
 "capabilities": { "sampling": {}, "roots": { "listChanged": true } },
 "clientInfo": { "name": "my-host", "version": "1.0.0" }
 }
}

// server -> client
{
 "jsonrpc": "2.0", "id": 1,
 "result": {
 "protocolVersion": "2025-06-18",
 "capabilities": {
 "tools": { "listChanged": true },
 "resources": { "subscribe": true, "listChanged": true },
 "prompts": {},
 "logging": {}
 },
 "serverInfo": { "name": "mcp-tickets", "version": "0.1.0" }
 }
}

// client -> server (notification)
{ "jsonrpc": "2.0", "method": "notifications/initialized" }

Operate: intercambio normal de mensajes. Dura mientras la sesión esté viva.

Shutdown: stdio cierra por EOF en stdin; HTTP cierra por timeout de sesión o DELETE en el endpoint con Mcp-Session-Id. No existe un método shutdown en el protocolo.

6. Capability negotiation: quién soporta qué

Cada lado anuncia lo que ofrece. El server expone combinaciones de tools, resources, prompts, logging, completions. El client expone sampling, roots, elicitation (nueva en 2025-06-18). Las subkeys habilitan features finas: listChanged: true en tools significa que el server puede emitir notifications/tools/list_changed cuando la lista cambia. subscribe: true en resources habilita resources/subscribe.

Regla de oro: no llames a un método sin una capability anunciada. Si el server no declaró prompts, mandar prompts/list es un error.

7. Primitivas del server: tools, resources, prompts

Tools: funciones que el LLM llama para ejecutar una acción (crear un registro, correr una query, mandar un mensaje). Cada tool tiene name (snake_case, único en el server), description (texto que el LLM lee para elegir), e inputSchema (JSON Schema de la entrada). 2025-06-18 agrega un outputSchema opcional y annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) que ayudan al host a decidir la UX de confirmación.

Métodos: tools/list (con paginación por cursor), tools/call. La respuesta de tools/call tiene content (array de TextContent, ImageContent, AudioContent o EmbeddedResource) e isError (un boolean que indica una falla lógica de la tool, distinta de un error de protocolo). 2025-06-18 añade un structuredContent opcional para un retorno tipado conforme al outputSchema.

Resources: datos que el cliente lee para alimentar el contexto. Identificados por URI (file://, https://, o un scheme custom como tickets://open). Tienen name, description, mimeType. Métodos: resources/list, resources/read, resources/templates/list (resources parametrizados vía URI template RFC 6570, ej: tickets://{id}), resources/subscribe y resources/unsubscribe. El server emite notifications/resources/updated con la URI cuando un resource suscrito cambia.

Prompts: templates parametrizados que el cliente inyecta en el LLM. Tiene name, description, arguments (con tipo y required). Métodos: prompts/list, prompts/get. prompts/get devuelve messages (array de {role, content}) ya interpolado, listo para enviar al modelo.

8. Primitivas del client: sampling, roots, elicitation

La simetría que mucha gente no percibe: el protocolo es bidireccional. El server puede pedir cosas al client.

Sampling: el server invoca el LLM del client vía sampling/createMessage. Útil cuando la tool necesita razonamiento del LLM en medio de la ejecución (resumir, clasificar, elegir) sin que el server tenga su propio LLM. El cliente intercepta, se lo muestra al usuario (consentimiento obligatorio), y devuelve la respuesta. Los params incluyen messages, modelPreferences (hints de costo/velocidad/calidad), systemPrompt, includeContext (none/thisServer/allServers).

Roots: el client expone al server qué directorios/URIs tiene permiso de operar. El server llama a roots/list y descubre el alcance. Un cambio emite notifications/roots/list_changed. Sin roots, el server no sabe qué archivo puede tocar.

Elicitation (nuevo en 2025-06-18): el server le pide input estructurado adicional al usuario durante la ejecución. elicitation/create con message y requestedSchema (JSON Schema). El cliente renderiza un form y devuelve la respuesta validada. Reemplaza el anti-pattern de una tool con 30 parámetros opcionales.

9. Utilities: ping, progress, cancellation, logging, completion

Ping: ping sin params, respuesta vacía. Cualquier lado puede llamarlo para chequear liveness.

Progress: para requests largos, quien llama incluye _meta.progressToken. El otro lado emite notifications/progress con {progressToken, progress, total} a lo largo de la ejecución.

Cancellation: notifications/cancelled con {requestId, reason}. Best-effort, el server debe parar lo antes posible pero no está obligado.

Logging: el client fija el nivel con logging/setLevel (debug, info, notice, warning, error, critical, alert, emergency). El server emite notifications/message con {level, logger, data}.

Completion: autocomplete para un argumento de prompt o una URI de resource template. completion/complete con ref (tipo e identificador) y argument (nombre y valor parcial). El server devuelve hasta 100 sugerencias.

10. Autenticación: OAuth 2.1 en HTTP

stdio no autentica (hereda el permiso del proceso). HTTP lo necesita. La spec 2025-06-18 estandariza OAuth 2.1 con PKCE obligatorio. El servidor MCP actúa como Resource Server. El discovery vía /.well-known/oauth-protected-resource apunta al Authorization Server. El cliente hace el Authorization Code Flow con PKCE, recibe un access token, y lo envía en Authorization: Bearer en cada llamada.

Dynamic Client Registration (RFC 7591) se recomienda para no exigir setup manual. Los tokens deben tener el audience chequeado (RFC 8707) para evitar un passthrough attack. Los refresh tokens son opcionales pero recomendados para una sesión larga.

11. Streamable HTTP en detalle

El endpoint único acepta los dos métodos.

POST: el cuerpo es JSON-RPC (single o batch). El server responde con application/json (respuesta única, modo síncrono) O hace upgrade a text/event-stream y streamea. En el segundo modo, cada SSE event tiene data: conteniendo JSON-RPC. El stream termina cuando el server lo cierra. Útil para una tool larga que emite progreso en el medio.

GET: abre un SSE stream para mensajes iniciados por el server (notificaciones, sampling). El cliente lo mantiene abierto. El server puede opcionalmente exigir Last-Event-ID para reanudar la reconexión sin perder un evento.

DELETE: termina la sesión (envía Mcp-Session-Id en el header).

El header Mcp-Session-Id vuelve en el response del initialize y debe enviarse en cada request subsecuente. Las sesiones están aisladas: dos clients en el mismo server tienen sesiones separadas. El server puede invalidar una sesión en cualquier momento devolviendo 404, y el client debe reinicializar.

12. Seguridad: tres trampas que matan

Confused deputy: el server corre con credenciales privilegiadas, el LLM lo convence de ejecutar una acción que el usuario nunca pidió. Defensa: el server confirma la intención vía elicitation/create antes de una acción destructiva. El host marca las tools destructiveHint: true y pide confirmación humana.

Token passthrough: el client manda el token del usuario al server, el server lo reutiliza en una llamada upstream. Defensa: validar el claim aud. Un token emitido para el MCP server X no puede ser aceptado por la API Y.

Indirect prompt injection: el server expone un resource (ej: un e-mail, un issue de GitHub) cuyo contenido tiene una instrucción maliciosa para el LLM. El LLM lo lee, la sigue. Defensa: sanitizar/marcar la frontera del contenido externo, el host debe aislar las tool calls disparadas a partir de contenido leído versus una instrucción directa del usuario.

13. Versionado y evolución

La versión del protocolo es un string con fecha (YYYY-MM-DD). Negociada en initialize: el client manda una preferencia, el server elige entre las soportadas. Los breaking changes generan una nueva versión. 2025-06-18 trajo elicitation, structured tool output, OAuth 2.1 obligatorio, y la eliminación del batch de JSON-RPC (que existía en 2025-03-26). 2025-11-25 está en draft.

14. El flujo completo de una llamada de tool, en orden

  1. El host inicializa el client, abre el transport (spawn del proceso o HTTP connection).
  2. El client manda initialize, el server responde con capabilities.
  3. El client manda notifications/initialized.
  4. El client manda tools/list. El server devuelve el catálogo.
  5. El host inyecta el catálogo en el contexto del LLM (formato propietario del host, pero generalmente se vuelve una definición de función para el modelo).
  6. El LLM decide llamar a una tool, emite un tool_call.
  7. El host valida el permiso, opcionalmente le pide confirmación al usuario.
  8. El client manda tools/call con name y arguments.
  9. El server ejecuta. Si demora, emite notifications/progress. Si necesita input extra, llama a un elicitation/create inverso.
  10. El server responde con content e isError.
  11. El host inyecta el content de vuelta en el contexto del LLM como una observación.
  12. El LLM continúa el loop (otra tool call, o una respuesta final).

Ese es el protocolo entero. Toda la complejidad adicional (multiplexación, retry, rate limit, cache) vive en el host o en el server, fuera de la spec. MCP en sí es deliberadamente delgado. Es justamente eso lo que le da la chance de volverse un estándar de mercado: lo bastante pequeño para implementarlo en una tarde, lo bastante estructurado para soportar todo lo que un agente serio necesita hacer.

Si estás construyendo un server para producción, el checklist mínimo es: implementar el lifecycle correcto (initialize + initialized antes de cualquier otra cosa), respetar la capability negotiation (no emitir una notificación que el client no pidió), retornar un error estructurado en vez de una exception, marcar las tools con las annotations apropiadas, soportar paginación en listas grandes, y nunca confiar en el input del LLM sin validación de schema. El resto es detalle de dominio.