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.