Course EN

Capítulo 8 de 22 · intermedio

Servidores MCP y el Model Context Protocol

Qué cubre esta sesión

La semana pasada le diste a un modelo un dict de dispatch de funciones de Python y lo dejaste hacer loop. Eso funciona hasta el momento en que quieres reutilizar esas herramientas. OpenAI quiere los schemas de funciones en una forma, Gemini quiere FunctionDeclarations en otra, el siguiente framework quiere una tercera, y la herramienta de abajo — leer un archivo, consultar una base de datos, pegarle a una API — es idéntica cada vez. Terminas reescribiendo el mismo cableado para cada modelo que soportas. Multiplícalo por cada herramienta y la superficie de integración se come el proyecto.

El Model Context Protocol es el intento de convertir eso en un problema resuelto. Es un estándar pequeño, presentado por Anthropic, para cómo el runtime de un modelo descubre y llama capacidades externas. Un servidor MCP es un proceso que expone herramientas — y opcionalmente resources y prompts — detrás de una interfaz fija. Un cliente MCP es lo que sea que envuelve al modelo: se conecta a los servidores, descubre lo que ofrecen y lo invoca. El transporte es JSON-RPC, sobre stdio para un proceso local o HTTP para uno remoto. Escribe una herramienta una vez como servidor MCP y cualquier cliente que hable MCP puede usarla, con cualquier modelo detrás.

Esta hora construimos ambos lados desde cero — sin SDK — para que puedas ver el protocolo sobre el cable. Es menos código de lo que supondrías, porque MCP no es más que mensajes JSON-RPC con un handshake y tres verbos.

OpenAI

El script de enseñanza juega ambos roles en un solo archivo: córrelo y genera una copia de sí mismo como el servidor, y luego habla con él. Empieza por el servidor. Es una diminuta red de estaciones meteorológicas con dos herramientas, y lee las requests JSON-RPC línea por línea desde stdin y escribe respuestas a stdout — el transporte stdio que habla todo servidor MCP local.

# A minimal MCP server: a weather-station network exposing two tools. It reads
# JSON-RPC requests line by line from stdin and writes responses to stdout.
# Nothing here mentions a model — that separation is the whole point of MCP.
_STATIONS = {
    "PDX-01": {"name": "Portland", "temp_c": 14.2, "humidity": 71},
    "SFO-02": {"name": "San Francisco", "temp_c": 17.5, "humidity": 65},
    "PHX-03": {"name": "Phoenix", "temp_c": 33.6, "humidity": 12},
}

_TOOLS = [
    {"name": "list_stations",
     "description": "List every weather station id and name. Call this first.",
     "inputSchema": {"type": "object", "properties": {}}},
    {"name": "get_reading",
     "description": "Latest reading for one station by id (temp °C, humidity %).",
     "inputSchema": {"type": "object",
                     "properties": {"station_id": {"type": "string"}},
                     "required": ["station_id"]}},
]


def _serve() -> None:
    def call(name, args):
        if name == "list_stations":
            return {"stations": [{"id": k, "name": v["name"]} for k, v in _STATIONS.items()]}
        if name == "get_reading":
            s = _STATIONS.get(str(args.get("station_id", "")).upper())
            return s or {"error": "unknown station"}
        return {"error": f"unknown tool {name}"}

    for line in sys.stdin:
        if not line.strip():
            continue
        msg = json.loads(line)
        mid = msg.get("id")
        if mid is None:  # notification, no reply
            continue
        method, params = msg.get("method"), msg.get("params") or {}
        if method == "initialize":
            result = {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}},
                      "serverInfo": {"name": "weather", "version": "1.0"}}
        elif method == "tools/list":
            result = {"tools": _TOOLS}
        elif method == "tools/call":
            payload = call(params.get("name"), params.get("arguments") or {})
            result = {"content": [{"type": "text", "text": json.dumps(payload)}]}
        else:
            result = {}
        sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": mid, "result": result}) + "\n")
        sys.stdout.flush()

Fíjate en lo que no está ahí: ninguna mención de un modelo. El servidor declara sus herramientas con tools/list y las corre con tools/call, y se comportaría exactamente igual sea que el que llama es GPT, Gemini, un IDE o un script de shell. Esa separación es todo el punto.

El cliente genera el servidor, hace el handshake de initialize — versión más capacidades, luego una notificación initialized — y después de eso es solo pedir-y-leer-la-respuesta-que-corresponde.

# The client side: spawn the server, do the initialize handshake, then send
# requests and read the matching responses. This is all MCP is on the wire —
# JSON-RPC objects, one per line.
class MCP:
    def __init__(self):
        self.p = subprocess.Popen([sys.executable, __file__, "serve"],
                                  stdin=subprocess.PIPE, stdout=subprocess.PIPE,
                                  text=True, bufsize=1)
        self.n = 0
        self._req("initialize", {"protocolVersion": "2025-06-18",
                                 "capabilities": {}, "clientInfo": {"name": "demo"}})
        self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})

    def _send(self, m):
        self.p.stdin.write(json.dumps(m) + "\n")
        self.p.stdin.flush()

    def _req(self, method, params):
        self.n += 1
        self._send({"jsonrpc": "2.0", "id": self.n, "method": method, "params": params})
        while True:
            msg = json.loads(self.p.stdout.readline())
            if msg.get("id") == self.n:
                return msg["result"]

    def list_tools(self):
        return self._req("tools/list", {})["tools"]

    def call_tool(self, name, args):
        blocks = self._req("tools/call", {"name": name, "arguments": args})["content"]
        return "".join(b.get("text", "") for b in blocks)

    def close(self):
        self.p.stdin.close()
        self.p.wait()

Ahora la parte que toca al modelo. MCP describe los inputs de las herramientas con JSON Schema plano, que es lo mismo que quieren las function tools de OpenAI, así que la traducción es casi un rename. Luego es el loop acotado de la semana pasada — salvo que cada llamada se despacha por MCP en lugar de a una función local.

# Translate MCP tools into OpenAI function tools, then run the ordinary bounded
# loop — except every tool call goes over MCP, not to a local function.
def main():
    if not os.environ.get("OPENAI_API_KEY"):
        sys.exit("OPENAI_API_KEY is not set. Put it in the course-root .env file.")
    from openai import OpenAI

    client = OpenAI()
    mcp = MCP()
    try:
        tools = [{"type": "function", "name": t["name"],
                  "description": t["description"], "parameters": t["inputSchema"]}
                 for t in mcp.list_tools()]
        question = "Which station is warmest, and what is its humidity?"
        input_list = [{"role": "user", "content": question}]
        for _ in range(6):  # bounded — never ship an open loop
            response = client.responses.create(
                model="gpt-5.4-nano",
                instructions="Answer using the weather tools. Base numbers on tool results.",
                input=input_list, tools=tools,
            )
            calls = [i for i in response.output if i.type == "function_call"]
            if not calls:
                break
            input_list += response.output
            for c in calls:
                out = mcp.call_tool(c.name, json.loads(c.arguments))
                print(f"tool: {c.name}({c.arguments}) -> {out}")
                input_list.append({"type": "function_call_output",
                                   "call_id": c.call_id, "output": out})
        print("\n" + response.output_text)
    finally:
        mcp.close()

El modelo nunca se entera de que está usando MCP. Ve function tools, emite llamadas y lee resultados. El cliente es la única pieza que habla ambos idiomas, y eso es lo que hace la herramienta portable.

Gemini

Apunta un modelo distinto al mismo servidor y nada del lado del servidor se mueve. El servidor y el cliente son byte por byte los del script de OpenAI; solo cambia la capa de traducción — las herramientas MCP se vuelven FunctionDeclarations de Gemini, y los resultados de las herramientas regresan como partes de function-response.

# The only real difference from the OpenAI script: MCP tools become Gemini
# FunctionDeclarations, and tool results go back as function-response parts.
def main():
    if not os.environ.get("GEMINI_API_KEY"):
        sys.exit("GEMINI_API_KEY is not set. Put it in the course-root .env file.")
    from google import genai
    from google.genai import types

    _T = {"string": types.Type.STRING, "object": types.Type.OBJECT}

    def to_schema(js):
        props = {k: types.Schema(type=_T.get(v.get("type"), types.Type.STRING))
                 for k, v in (js.get("properties") or {}).items()}
        return types.Schema(type=types.Type.OBJECT, properties=props,
                            required=js.get("required") or None)

    client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
    mcp = MCP()
    try:
        decls = [types.FunctionDeclaration(name=t["name"], description=t["description"],
                                           parameters=to_schema(t["inputSchema"]))
                 for t in mcp.list_tools()]
        config = types.GenerateContentConfig(
            system_instruction="Answer using the weather tools. Base numbers on tool results.",
            tools=[types.Tool(function_declarations=decls)])
        question = "Which station is warmest, and what is its humidity?"
        contents = [types.Content(role="user", parts=[types.Part(text=question)])]
        for _ in range(6):  # bounded — never ship an open loop
            response = client.models.generate_content(
                model="gemini-3.1-flash-lite", contents=contents, config=config)
            if not response.function_calls:
                break
            contents.append(response.candidates[0].content)
            parts = []
            for fc in response.function_calls:
                out = mcp.call_tool(fc.name, dict(fc.args))
                print(f"tool: {fc.name}({dict(fc.args)}) -> {out}")
                parts.append(types.Part.from_function_response(name=fc.name, response={"result": out}))
            contents.append(types.Content(role="user", parts=parts))
        print("\n" + (response.text or ""))
    finally:
        mcp.close()

Esa es la promesa hecha concreta. Un servidor, dos modelos, cero cambios a la cosa que de verdad hace el trabajo. En un cliente real esta traducción es donde el SDK se gana el sueldo: mapea los schemas de MCP al formato de herramienta que sea que espere el modelo del otro lado, y nunca la escribes a mano.

Ponlo a trabajar

Tres apps de docker-compose bajo code/showcase/<slug>/, la misma rutina: bash bootstrap-secrets.sh, docker compose up --build, http://localhost:3000. Cada una construye un servidor MCP real y el mismo cliente hecho desde cero, y cada una demuestra una de las tres primitivas de MCP. Juntas son todo el vocabulario del servidor: las herramientas hacen cosas, los resources cargan contexto, los prompts guardan instrucciones.

Showcase 1 — Herramientas MCP

El servidor de estaciones meteorológicas, ahora un proceso aparte con el que habla el backend. Pregunta "cuál estación es la más cálida y cómo se compara su humedad con la de Seattle" y el modelo descubre las estaciones, lee las que necesita y responde — cada llamada cruzando el transporte stdio como tools/call. Cambia PROVIDER y el mismo servidor responde a través del otro modelo. Esta es la primitiva del script de enseñanza, sacada a su propio archivo para que veas que es genuinamente separable.

Showcase 2 — Resources MCP

Los resources no son acciones, son datos de solo lectura que el cliente jala al contexto — archivos, registros, docs, cada uno con una URI. Este servidor es una base de conocimiento de soporte: docs de envíos, devoluciones, garantía y cuentas detrás de URIs doc://. El backend los lista con resources/list, los lee con resources/read y aterriza al modelo en ese texto. Pregunta "lo abrí hace tres días, ¿puedo obtener un reembolso en efectivo?" y responde desde la política de devoluciones — solo crédito en tienda, dentro de catorce días — y cuando preguntas algo que los docs no cubren, lo dice en lugar de inventar una respuesta.

Showcase 3 — Prompts MCP

La tercera primitiva, y la que la gente olvida. Un prompt es una plantilla que el servidor posee. Pega un snippet de código y el backend llama prompts/get("code_review"); el servidor renderiza sus propias instrucciones de revisión alrededor de tu código y devuelve mensajes listos para enviar. La expertise de revisión vive con el servidor, versionada junto a las herramientas a las que pertenece — mejora el prompt ahí y cada cliente que lo obtiene mejora de inmediato, sin redeploy. Es como un proveedor de herramientas envía el prompt optimizado junto a la herramienta.

Las tres corren el mismo backend/main.py, despachando según PROVIDER, así que una corrida con Gemini es una variable de entorno. Y las tres comparten un mismo mcp_client.py — la prueba de que el cliente es genérico y los servidores son la parte interesante.

Córrelo

El README de esta carpeta tiene la versión de Python, la línea de instalación, las dos variables de entorno y los comandos exactos para los scripts independientes y cada showcase. Las keys vienen del .env sin trackear en la raíz del curso, igual que cada otra semana. El cliente y los servidores MCP son librería estándar pura — json y subprocess — así que los únicos instalables de terceros son los SDKs de los modelos.

Lo que te llevas

Quítale la marca y MCP es JSON-RPC con un handshake y tres verbos: tools/* para acciones, resources/* para contexto, prompts/* para instrucciones. El function calling define el formato de una sola llamada a herramienta; MCP define todo el ciclo de vida — descubrimiento, invocación, resultados, errores — a través de muchas herramientas y muchos servidores, y no le importa qué modelo esté del otro lado. Por eso un agente puede conectarse a un servidor de base de datos, uno de filesystem y uno de browser a la vez y razonar sobre la unión, y por eso el ecosistema ya tiene servidores para GitHub, Slack, Postgres y demás: construye la herramienta una vez, publícala y cada cliente MCP puede alcanzarla. Ya viste los mensajes que esos clientes mandan — Claude Desktop, tu IDE, el propio endpoint /mcp de este blog hablan exactamente lo que acabas de escribir a mano. La próxima semana las herramientas dejan de ser el tema y lo son los datos: embeddings, y el camino hacia el retrieval.