Course EN
← back to chapter

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 expone list_stations y get_reading. Sin modelo, sin framework — solo el protocolo.
  • backend/mcp_client.py — un cliente MCP hecho desde cero: levanta el servidor, hace el handshake initialize, y llama tools/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.

Descargar mcp-tools.zip

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

  • .gitignore
  • README.es.md
  • README.md
  • backend/Dockerfile
  • backend/ai_gemini.py
  • backend/ai_openai.py
  • backend/main.py
  • backend/mcp_client.py
  • backend/mcp_server.py
  • backend/requirements.txt
  • bootstrap-secrets.sh
  • docker-compose.yml
  • frontend/Dockerfile
  • frontend/app/layout.tsx
  • frontend/app/page.tsx
  • frontend/next.config.ts
  • frontend/package.json
  • frontend/tsconfig.json