Skip to content

English · Español

05 — MCP sobre el wire + un servidor de ~100 líneas

🇪🇸 MCP es JSON-RPC 2.0 sobre stdio (o WebSocket / SSE). Esta sección muestra exactamente qué bytes viajan en un tool call, y construye un servidor MCP funcional en ~100 líneas de Python — sin librería externa, sólo json, sys, asyncio (opcional).

Anchors: theory/01-function-calling-formats.md, theory/02-mcp-architecture.md, theory/03-authn-authz.md.


Qué es MCP, en un párrafo

El Model Context Protocol (MCP) (Anthropic, 2024) es un formato de wire para exponer tools, recursos y prompts a clientes LLM. Concretamente es JSON-RPC 2.0 transportado sobre stdio (por defecto) o HTTP + SSE (server-sent events) o WebSocket. Estandariza:

  • cómo un cliente descubre qué puede hacer un servidor (tools/list, resources/list, prompts/list);
  • cómo un cliente invoca una capability (tools/call);
  • cómo un servidor devuelve resultados estructurados, errores o parciales en streaming.

MCP es intencionadamente mínimo: no prescribe el bucle del agente, el formato del prompt, la estrategia de muestreo, ni el modelo. Estandariza el enchufe, no el aparato. La Fase 32 (agentes) construye el bucle del agente encima.

El formato del wire: 4 tipos de mensajes

JSON-RPC 2.0 transporta cuatro clases de mensajes. Cada uno es un objeto JSON por línea sobre stdio.

Request (cliente → servidor)

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "conjugate",
    "arguments": {"verb": "eat", "tense": "past simple", "person": "3rd singular"}
  }
}

id es un token de correlación elegido por el cliente. method es un namespace con puntos; MCP define initialize, tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get, y unos pocos más.

Response (servidor → cliente, con resultado)

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {"type": "text", "text": "ate"}
    ],
    "isError": false
  }
}

El result para tools/call es siempre un objeto {content: [...], isError: bool}. Los items de contenido tienen un type (text, image, resource) — el protocolo es multi-modal en el nivel del wire.

Response (servidor → cliente, con error)

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32602,
    "message": "Invalid params: 'verb' must be one of the 20 allowed verbs"
  }
}

Los códigos de error siguen el rango reservado de JSON-RPC 2.0 (-32700 parse error, -32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal error) más códigos específicos de MCP por encima de -32000.

Notification (cliente → servidor, sin respuesta esperada)

{
  "jsonrpc": "2.0",
  "method": "notifications/progress",
  "params": {"progressToken": "abc", "progress": 0.5}
}

Sin campo id. Usado para señales de progreso o cancelación en streaming.

El handshake

Antes de cualquier tool call, el cliente y el servidor deben negociar:

  1. initialize (cliente → servidor):

    {"jsonrpc": "2.0", "id": 0, "method": "initialize",
     "params": {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}},
                "clientInfo": {"name": "lynx-cortex-agent", "version": "0.1.0"}}}
    

  2. Respuesta a initialize (servidor → cliente) con las capabilities + versión del servidor:

    {"jsonrpc": "2.0", "id": 0, "result": {
        "protocolVersion": "2024-11-05",
        "capabilities": {"tools": {"listChanged": true}},
        "serverInfo": {"name": "lynx-cortex-grammar-mcp", "version": "0.1.0"}}}
    

  3. notifications/initialized (cliente → servidor, sin respuesta). El handshake se completa; el cliente ya puede llamar a tools/list y tools/call.

Un round-trip completo de tool call

client                                 server
  │                                      │
  │── initialize ─────────────────────▶  │
  │                                      │
  │  ◀── initialize result ─────────────│
  │                                      │
  │── notifications/initialized ──────▶  │
  │                                      │
  │── tools/list ─────────────────────▶  │
  │                                      │
  │  ◀── result: [{name: "conjugate",   │
  │                inputSchema: {...}}] │
  │                                      │
  │── tools/call (conjugate, eat) ────▶  │
  │                                      │
  │                            (server runs the tool)
  │                                      │
  │  ◀── result: {content: [{text:"ate"}]}
  │                                      │

Esa es toda la superficie del protocolo para un tool call síncrono.

Un servidor MCP de ~100 líneas

A continuación: un servidor MCP completo de conjugación de gramática, sin librería más allá de la stdlib de Python. Borja lo escribe en src/minimcp/server.py — el fichero se muestra aquí como objetivo, no como copia.

"""A minimal MCP server exposing a §A13 verb-conjugation tool.

Wire format: JSON-RPC 2.0 over stdio.
Usage: python -m src.minimcp.server  (the client connects via stdin/stdout)
"""
from __future__ import annotations
import json, sys
from typing import Any, Callable

PROTOCOL_VERSION = "2024-11-05"

# Tool implementations live as plain Python functions.
_TOOLS: dict[str, tuple[Callable[[dict], str], dict[str, Any]]] = {}

def register(name: str, schema: dict[str, Any]):
    """Decorator: register a tool with its JSON-Schema input spec."""
    def decorator(fn: Callable[[dict], str]) -> Callable[[dict], str]:
        _TOOLS[name] = (fn, schema)
        return fn
    return decorator

CONJUGATE_SCHEMA = {
    "type": "object",
    "required": ["verb", "tense", "person"],
    "properties": {
        "verb":   {"enum": ["work","play","walk","talk","listen","watch",
                            "study","finish","start","look","want","like",
                            "be","have","do","go","come","see","eat","write"]},
        "tense":  {"enum": ["infinitive","present simple","past simple",
                            "past participle","simple future"]},
        "person": {"enum": ["1st singular","2nd singular","3rd singular"]},
    },
}

# The actual lookup table (§A13). 20 verbs × 5 tenses × 3 persons = 300 entries.
_LOOKUP = {("eat","past simple","3rd singular"): "ate",
           ("write","past simple","3rd singular"): "wrote",
           # ... 298 more rows; in production this is loaded from data/a13.yaml
          }

@register("conjugate", CONJUGATE_SCHEMA)
def conjugate(args: dict) -> str:
    """Return the English conjugation for (verb, tense, person)."""
    key = (args["verb"], args["tense"], args["person"])
    return _LOOKUP.get(key, "unknown")

def _send(msg: dict) -> None:
    sys.stdout.write(json.dumps(msg) + "\n")
    sys.stdout.flush()

def _error(req_id: Any, code: int, message: str) -> dict:
    return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}

def _handle(req: dict) -> dict | None:
    method = req.get("method")
    rid = req.get("id")
    if method == "initialize":
        return {"jsonrpc": "2.0", "id": rid, "result": {
            "protocolVersion": PROTOCOL_VERSION,
            "capabilities": {"tools": {"listChanged": False}},
            "serverInfo": {"name": "minimcp-grammar", "version": "0.1.0"}}}
    if method == "notifications/initialized":
        return None        # notifications have no response
    if method == "tools/list":
        tools = [{"name": n, "inputSchema": s} for n, (_, s) in _TOOLS.items()]
        return {"jsonrpc": "2.0", "id": rid, "result": {"tools": tools}}
    if method == "tools/call":
        name = req["params"]["name"]
        args = req["params"].get("arguments", {})
        if name not in _TOOLS:
            return _error(rid, -32601, f"Tool '{name}' not found")
        fn, _ = _TOOLS[name]
        try:
            out = fn(args)
        except KeyError as e:
            return _error(rid, -32602, f"Missing argument: {e}")
        return {"jsonrpc": "2.0", "id": rid, "result": {
            "content": [{"type": "text", "text": out}], "isError": False}}
    return _error(rid, -32601, f"Unknown method: {method}")

def main() -> int:
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            req = json.loads(line)
        except json.JSONDecodeError as e:
            _send(_error(None, -32700, f"Parse error: {e}"))
            continue
        resp = _handle(req)
        if resp is not None:
            _send(resp)
    return 0

if __name__ == "__main__":
    sys.exit(main())

Conteo de líneas: ~85 líneas incluyendo comentarios y la tool conjugate. Añade validación del input schema (10-15 líneas usando jsonschema) y llegas a 100.

Este es un servidor MCP completo, conforme a la especificación. Maneja el handshake, expone una tool, valida el despacho, devuelve errores con los códigos correctos, y corre sobre stdio. No implementa recursos, prompts, sampling, ni notifications de progreso — esas son extensiones, no requisitos.

Recorriendo un round-trip con bytes en el wire

Stdin desde el cliente:

{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":1,"method":"tools/list"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"conjugate","arguments":{"verb":"eat","tense":"past simple","person":"3rd singular"}}}

Stdout desde el servidor:

{"jsonrpc": "2.0", "id": 0, "result": {"protocolVersion": "2024-11-05", "capabilities": {"tools": {"listChanged": false}}, "serverInfo": {"name": "minimcp-grammar", "version": "0.1.0"}}}
{"jsonrpc": "2.0", "id": 1, "result": {"tools": [{"name": "conjugate", "inputSchema": {...}}]}}
{"jsonrpc": "2.0", "id": 2, "result": {"content": [{"type": "text", "text": "ate"}], "isError": false}}

Cuatro bytes que importan: el id de JSON-RPC mantiene a cliente y servidor en lockstep. Todo lo demás es estructural.

Por qué MCP, y no un protocolo a medida

El mundo pre-MCP: cada proveedor de LLM inventaba su propio schema de tool call — function_call de OpenAI, tool_use de Anthropic, function_call de Google, cada uno sutilmente distinto. El coste era integración N × M: cada framework de agente tenía que soportar el formato de cada modelo.

La propuesta de MCP: estandarizar el transporte (JSON-RPC) y el formato de descripción de tools (JSON-Schema), de modo que cualquier cliente pueda hablar con cualquier servidor. El modelo todavía produce los args del tool call; la capa MCP está entre el bucle del agente y la implementación de la tool, no entre el modelo y el agente.

Para el tutor de gramática de la Fase 32, esto significa: el bucle del agente llama a mcp_client.call("conjugate", {...}) independientemente de qué modelo esté generando los argumentos del tool call. Cambia Mini-GPT por Claude o GPT-4 y el servidor MCP de arriba no cambia.

Qué omite intencionadamente este servidor

  1. Auth. Un servidor MCP de producción real verifica credenciales en initialize. Para stdio local, la frontera de confianza es el proceso; para transporte HTTP, no lo es, y necesitas OAuth o PSK. Ver theory/03-authn-authz.md.
  2. Streaming. tools/call aquí devuelve una respuesta. Para tools de larga duración, MCP usa notifications/progress y un resultado final; no implementado aquí.
  3. Recursos y prompts. MCP también expone recursos de solo lectura (p. ej., un montaje de filesystem) y plantillas de prompt. La Fase 31 cubre tools; recursos/prompts son una extensión dig-deeper.
  4. Sampling. MCP permite a un servidor pedir al cliente que muestree del LLM (p. ej., para una tool gramática-aware que necesita que el modelo genere opciones). Fuera de alcance aquí.

Las 85 líneas son el suelo de un servidor MCP útil. Los servidores de producción añaden ~500-1000 líneas de validación, auth, retry y observabilidad — pero la forma se mantiene.

Citas

Recap en un párrafo

MCP es JSON-RPC 2.0 sobre stdio con un pequeño número de nombres de método (initialize, tools/list, tools/call, más recursos / prompts). Un servidor completo y conforme a la especificación cabe en ~100 líneas de Python stdlib: una tabla de despacho de nombre de método a handler, JSON-Schema para los argumentos de las tools, y los códigos de error estándar. El valor del protocolo no es lo que añade sino lo que estandariza — haciendo que cualquier agente pueda hablar con cualquier tool, sin integración a medida por proveedor. Para el tutor de gramática §A13, esto significa que el bucle del agente en la Fase 32 no se preocupa por el modelo detrás; solo de que el servidor MCP devuelva {"text": "ate"} cuando se le pide el past simple de eat.

Siguiente: lab/01-mcp-server.md para implementar el servidor de arriba y lab/02-mcp-roundtrip.md para conducirlo desde un cliente.