Herramientas MCP
Pregunta sobre una pequeña red de estaciones meteorológicas — "¿cuál estación es
Showcase — Herramientas MCP
Pregunta sobre una pequeña red de estaciones meteorológicas — "¿cuál estación es
la más cálida?", "¿cómo se compara el viento de Denver con el de San Francisco?" —
y el modelo responde llamando tools en un servidor MCP. El servidor es un
proceso aparte (mcp_server.py) que habla JSON-RPC sobre stdio; el backend es un
cliente MCP que descubre las tools con tools/list, traduce su JSON Schema al
formato de function-calling del modelo, y despacha cada llamada con tools/call.
El mismo servidor maneja tanto OpenAI como Gemini — esa portabilidad es todo el punto.
Córrelo
bash bootstrap-secrets.sh # reads ../../../../.env, writes secrets/
docker compose up --build # default: PROVIDER=openai
Abre http://localhost:3000.
Para correrlo con Gemini en su lugar:
PROVIDER=gemini docker compose up --build
Qué hay aquí
backend/mcp_server.py— el servidor MCP: una red de estaciones meteorológicas que exponelist_stationsyget_reading. Sin modelo, sin framework — solo el protocolo.backend/mcp_client.py— un cliente MCP hecho desde cero: levanta el servidor, hace el handshakeinitialize, y llamatools/list/tools/call.backend/ai_openai.py/backend/ai_gemini.py— traducen las tools MCP al formato de tools de cada proveedor y corren el agent loop acotado.backend/main.py— loader de FastAPI idéntico; lee PROVIDER y despacha.frontend/app/page.tsx— textarea + resultado.
El loop está topado en 8 iteraciones — un agent loop sin límite es una caída esperando a un modelo confundido.
Detenlo
docker compose down
Ejecútalo en tu máquina
Descarga el proyecto como ZIP y córrelo con Docker. Levanta un backend FastAPI y un frontend Next.js en localhost:3000.
unzip mcp-tools.zip
cd mcp-tools
bash bootstrap-secrets.sh # one-time: pulls API keys into ./secrets
docker compose up --build # default provider: openai
# or: PROVIDER=gemini docker compose up --build
Escribe algo, elige un proveedor y ejecuta el mismo código de Código contra la API real. Requiere iniciar sesión.
Los mismos módulos que ejecuta el botón Run. El proyecto completo (frontend, Dockerfile, compose) está en el ZIP, pestaña README.
backend/ai_openai.py
"""Showcase 1 (OpenAI): a model reaching an MCP server's tools.
The MCP client discovers tools over the protocol; we translate their JSON
Schema into OpenAI's function-tool format and run the usual bounded loop —
except every call is dispatched over MCP (`tools/call`), not to a local
Python function. The model never knows MCP is involved; the client does the
translation. That is the whole promise: one server, any model.
"""
import json
from openai import OpenAI
from mcp_client import MCPClient, content_text
_client = OpenAI()
_MODEL = "gpt-5.4-nano"
def _to_openai_tools(mcp_tools: list[dict]) -> list[dict]:
"""MCP tool → OpenAI function tool. inputSchema is already JSON Schema."""
return [{
"type": "function",
"name": t["name"],
"description": t.get("description", ""),
"parameters": t.get("inputSchema", {"type": "object", "properties": {}}),
} for t in mcp_tools]
def run(question: str) -> str:
with MCPClient() as mcp:
tools = _to_openai_tools(mcp.list_tools()) # discovered at runtime
input_list = [{"role": "user", "content": question.strip()}]
response = None
for _ in range(8): # bounded — never ship an open loop
response = _client.responses.create(
model=_MODEL,
instructions="Answer using the weather-station tools. Start from "
"list_stations if unsure of the ids. Base every number "
"on tool results.",
input=input_list,
tools=tools,
)
calls = [item for item in response.output if item.type == "function_call"]
if not calls:
break
input_list += response.output
for call in calls:
args = json.loads(call.arguments)
result = mcp.call_tool(call.name, args) # ← dispatched over MCP
input_list.append({
"type": "function_call_output",
"call_id": call.call_id,
"output": content_text(result),
})
return response.output_text if response else ""
backend/ai_gemini.py
"""Showcase 1 (Gemini): the same MCP server, a different model.
Nothing about the server changes. Only the translation layer differs: MCP's
JSON-Schema tools become Gemini `FunctionDeclaration`s instead of OpenAI
function tools. Point either model at the same weather-station server and it
just works — which is exactly why MCP exists.
"""
import os
from google import genai
from google.genai import types
from mcp_client import MCPClient, content_text
_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
_MODEL = "gemini-3.1-flash-lite"
_TYPES = {
"string": types.Type.STRING,
"number": types.Type.NUMBER,
"integer": types.Type.INTEGER,
"boolean": types.Type.BOOLEAN,
"array": types.Type.ARRAY,
"object": types.Type.OBJECT,
}
def _to_gemini_schema(js: dict) -> types.Schema:
"""MCP JSON Schema → Gemini types.Schema (the fields a tool needs)."""
js = js or {}
t = js.get("type", "object")
if t == "object":
props = {k: _to_gemini_schema(v) for k, v in (js.get("properties") or {}).items()}
return types.Schema(
type=types.Type.OBJECT,
properties=props,
required=js.get("required") or None,
)
if t == "array":
return types.Schema(type=types.Type.ARRAY, items=_to_gemini_schema(js.get("items") or {}))
return types.Schema(
type=_TYPES.get(t, types.Type.STRING),
enum=js.get("enum"),
description=js.get("description"),
)
def _config(mcp_tools: list[dict]) -> types.GenerateContentConfig:
decls = [
types.FunctionDeclaration(
name=t["name"],
description=t.get("description", ""),
parameters=_to_gemini_schema(t.get("inputSchema", {})),
)
for t in mcp_tools
]
return types.GenerateContentConfig(
system_instruction="Answer using the weather-station tools. Start from "
"list_stations if unsure of the ids. Base every number "
"on tool results.",
tools=[types.Tool(function_declarations=decls)],
)
def run(question: str) -> str:
with MCPClient() as mcp:
config = _config(mcp.list_tools()) # discovered at runtime
contents = [types.Content(role="user", parts=[types.Part(text=question.strip())])]
response = None
for _ in range(8): # bounded — never ship an open loop
response = _client.models.generate_content(
model=_MODEL, contents=contents, config=config,
)
if not response.function_calls:
break
contents.append(response.candidates[0].content)
parts = []
for fc in response.function_calls:
result = mcp.call_tool(fc.name, dict(fc.args)) # ← over MCP
parts.append(types.Part.from_function_response(
name=fc.name, response={"result": content_text(result)},
))
contents.append(types.Content(role="user", parts=parts))
return (response.text or "") if response else ""
Archivos del proyecto
.gitignoreREADME.es.mdREADME.mdbackend/Dockerfilebackend/ai_gemini.pybackend/ai_openai.pybackend/main.pybackend/mcp_client.pybackend/mcp_server.pybackend/requirements.txtbootstrap-secrets.shdocker-compose.ymlfrontend/Dockerfilefrontend/app/layout.tsxfrontend/app/page.tsxfrontend/next.config.tsfrontend/package.jsonfrontend/tsconfig.json