Servidor MCP
Futuros incluye un servidor Model Context Protocol (MCP) para que cualquier agente externo — Claude Code, Claude Desktop, Cursor, o su propio cliente — pueda consultar el mismo corpus citado de desarrollo de LATAM que usa el chat de Futuros. Cada cifra que devuelve una herramienta lleva un citation_id; las herramientas solo leen datos horneados y computan de forma determinista — sin escrituras, sin llamadas ocultas a un LLM.
- Endpoint:
POST https://futuros.xyz/api/mcp - Transporte: Streamable HTTP (JSON-RPC 2.0)
- Versión de protocolo:
2025-06-18 - Identidad del servidor:
futuros-corpusv1.0.0— "Futuros — LATAM governed-data corpus" - Auth: ninguna (corpus público de solo lectura)
- Compuerta de despliegue:
MCP_ENABLEDdebe valer"true". Falla cerrado: sin la variable, cadaPOSTdevuelve403 {"error":"mcp_disabled"}— un despliegue nuevo o un.envvacío no expone el endpoint por accidente.GET ?health=1sigue respondiendo y reporta el estado en su campoenabled. - Estado: sin estado — cada petición es autocontenida
- Métodos:
initialize,ping,tools/list,tools/call - Tope de cuerpo: 256 KB, aplicado antes de parsear y antes de cobrar el límite de tasa; por encima,
413con-32700. Un lote legítimo deMAX_BATCHmensajes son unos pocos KB. - Límite de tasa: 60 mensajes/minuto por IP de cliente, cobrado una vez por mensaje JSON-RPC contenido — un lote de N cuesta N unidades, así que agrupar no multiplica el presupuesto. Las respuestas
429traen una cabeceraRetry-After. Entre instancias (respaldado por Upstash) cuando está configurado; si no, en memoria por instancia.
Semántica de transporte
El servidor implementa el mínimo de Streamable HTTP requerido para un servidor de herramientas sin estado:
Método en /api/mcp | Comportamiento |
|---|---|
POST | JSON-RPC 2.0 — un solo mensaje o un lote (arreglo JSON). Devuelve el objeto de respuesta, o un arreglo para un lote. |
POST (solo notificaciones) | HTTP 202 Accepted, cuerpo vacío (sin id → sin respuesta). |
OPTIONS | Preflight CORS → 204. |
GET ?health=1 | 200 — { ok, service: "mcp", enabled, transport: "streamable-http", max_batch: 20 }, una sonda barata para monitores de uptime. ok/enabled reflejan MCP_ENABLED, así que la sonda distingue "caído" de "deshabilitado a propósito". |
GET (a secas) | 405 — servidor sin estado, sin stream SSE iniciado por el servidor. |
CORS está totalmente abierto (Access-Control-Allow-Origin: *, métodos POST, OPTIONS, cabeceras permitidas Content-Type, Mcp-Session-Id, Mcp-Protocol-Version), así que los agentes basados en navegador pueden llamarlo entre orígenes.
Códigos de error JSON-RPC usados: -32700 error de parseo, -32600 petición vacía/inválida (incluido un lote sobre el tope de 20 mensajes), -32601 método no encontrado / GET, -32602 herramienta desconocida, -32000 límite de tasa, -32603 error interno — dentro de un lote, una excepción lanzada por la herramienta de un mensaje se vuelve un -32603 ligado al id de ese mensaje, con aislamiento por mensaje: los mensajes hermanos igual se responden, y el endpoint nunca devuelve un 500 que no sea JSON-RPC.
Ciclo de vida JSON-RPC
Lo que atraviesa un POST, en orden (api/mcp.ts → server/mcp/handler.ts → el ejecutor compartido de herramientas del corpus):
Clasificación de mensajes (handleMcpMessage):
| Mensaje entrante | Tratamiento |
|---|---|
No es un objeto JSON (cadena, número, null, arreglo anidado) | -32600 con id: null |
Tiene id pero jsonrpc !== "2.0" o method no es una cadena | -32600 ligado a ese id |
Sin id (una notificación) — cualquier método, incluso uno que lanza excepción | procesado, nunca respondido |
method que empieza con notifications/ (incluso con id) | sin respuesta |
Método desconocido con id | -32601 |
tools/call que nombra una herramienta fuera de la lista expuesta de 15 | -32602 |
| La herramienta lanza excepción a mitad de llamada | -32603 ligado al id de ese mensaje; los hermanos no se afectan; si no hay id recuperable la respuesta se descarta (regla de notificación) |
Los mensajes de un lote se ejecutan secuencialmente, en orden del arreglo, y las respuestas vuelven en el mismo orden (solo las peticiones — las notificaciones no aportan nada). Un cuerpo de solo notificaciones produce por tanto un conjunto de respuestas vacío → HTTP 202 sin cuerpo. Cada invocación corre bajo un maxDuration de función de 30 s.
Mecánica del límite de tasa — el limitador corre antes del handler:
- Clave:
mcp:<client-ip>, donde la IP esx-real-ip/ la entrada más a la derecha dex-forwarded-for— nunca la más a la izquierda, que es falsificable. - El tope de tamaño de lote se verifica antes de cobrar, así que un arreglo sobredimensionado es un único
-32600barato que consume cero presupuesto. Cada mensaje contenido cobra entonces una unidad; si algún cobro dispara el límite, toda la petición recibe429con el mayorRetry-Afterobservado (las unidades cobradas quedan consumidas). - Backend: un viaje de ida y vuelta con pipeline
INCR+EXPIRE … NX+PTTLa Upstash/Vercel-KV cuandoUPSTASH_REDIS_REST_*/KV_REST_API_*está configurado (techo entre instancias); si no, un limitador en memoria acotado (tope duro de 50.000 entradas, barridos cada 30 s, expulsión del más antiguo primero). Cualquier error del store cae en silencio a memoria — el limitador nunca lanza excepción, así que un store inestable degrada a límite por instancia en vez de dar 500s.
Adónde van las lecturas de las herramientas. El handler construye un FetchDataSource apuntado al propio origen del deployment, resuelto por selfOrigin(): prefiere la URL de deployment que fija la plataforma, y solo cae a las cabeceras x-forwarded-proto / x-forwarded-host fuera de Vercel (desarrollo local). El orden importa y es una defensa, no un detalle: como esa lectura es la verdad de base detrás de cada respuesta de herramienta, un x-forwarded-host o un host falsificado no puede reapuntar el fetch del servidor a un host elegido por un atacante. Una llamada de herramienta lee así el mismo JSON de public/data cacheado en el CDN que usa la web app — sin base de datos, sin un tercer host, y las lecturas repetidas dentro de una invocación golpean una caché LRU.
Normalización de argumentos. Antes del despacho, el ejecutor reconcilia los argumentos de país y pilar contra el catálogo real: "Brasil" → BRA, "mex" → MEX, "educación" → educacion. Puramente correctivo — los valores no resolubles pasan sin tocar, y las herramientas de texto libre o direccionadas por dataset no se ven afectadas.
Conectar un cliente
Claude Code
claude mcp add --transport http futuros https://futuros.xyz/api/mcpCursor
En .cursor/mcp.json (por proyecto) o ~/.cursor/mcp.json (global):
{
"mcpServers": {
"futuros": { "url": "https://futuros.xyz/api/mcp" }
}
}Claude Desktop / cualquier cliente con archivo de config
Añada a la config de su cliente MCP (el claude_desktop_config.json de Claude Desktop, o equivalente). Un cliente nativo con soporte HTTP:
{
"mcpServers": {
"futuros": {
"type": "http",
"url": "https://futuros.xyz/api/mcp"
}
}
}Para un cliente que solo habla stdio, use el puente mcp-remote:
{
"mcpServers": {
"futuros": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://futuros.xyz/api/mcp"]
}
}
}En initialize el servidor devuelve su cadena instructions, que le dice al agente cómo conducir el corpus: resolver una métrica nombrada en palabras con resolve_metric, luego llamar get_parameter_data / compare_countries / compute; cada cifra lleva un citation_id; si el corpus carece de una cifra, las herramientas lo dicen en vez de inventarla.
Herramientas
La exposición son 15 herramientas de corpus de solo lectura: las 13 de la lista blanca del toolset del chat, más 2 exclusivas de MCP (server/mcp/tools.ts) que el chat deliberadamente no tiene — get_series (el panel país-año completo de un indicador; el chat trunca la serie a los últimos 6 puntos por presupuesto de tokens) y resolve_citations (resolver citation_ids guardados sin salir de MCP hacia la API de archivos). Las herramientas de web abierta (web_search, fetch_url) y la paga perplexity_search, respaldada por LLM, nunca se exponen — el endpoint no puede fungir como proxy SSRF, no puede gastar el presupuesto de búsqueda externa del deployment, y queda acotado a datos citados de Futuros. El diseño falla cerrado en ambas vías: una herramienta nueva añadida al toolset del chat no se filtra a MCP hasta que se agrega explícitamente a la lista blanca, y una herramienta exclusiva de MCP solo existe si está definida en MCP_EXTRA_TOOLS.
Cada herramienta acepta además un parámetro opcional lang (es | en | pt, por defecto es — el idioma fuente del corpus). Localiza los campos de prosa (narrativas, nombres de pilotos, claims); los números, ids y citas son independientes del idioma. tools/list lo anuncia explícitamente: el handler inyecta el enum lang en el input schema de cada objeto anunciado, así un cliente puede descubrir que existe salida en otros idiomas en vez de tener que saberlo; por llamada, lang se lee de los argumentos de la herramienta, y cualquier otro valor cae de vuelta a es.
Las quince herramientas:
| Herramienta | Qué devuelve | Parámetros clave |
|---|---|---|
resolve_metric | Mapea una métrica nombrada en palabras a un id de indicador gobernado (del registro de métricas) — candidatos rankeados con id canónico, unidad, good_direction, pilar, cobertura, vintage. Llámela primero cuando necesite un id. | query (req), pillar, limit |
get_parameter_data | El cuadro completo de un pilar en un país: narrativa citada, todos los indicadores (valor/tendencia/año/citation_id), gobernanza, correlaciones, grado de evidencia. | parameter (req), country (req) |
compare_countries | Los indicadores de un pilar en 2–8 países en una sola llamada, cada cifra citada. | parameter (req), countries[] (req, 2–8) |
get_news | Noticias recientes curadas para un pilar+país; el id de cada ítem es su citation_id. | parameter (req), country (req), limit |
get_regional_pulse | Agregado regional LATAM de un pilar: valor, tendencia, líderes/rezagados, movimientos, contradicciones documentadas entre fuentes. | parameter (req) |
compute | Un cálculo reproducible citado a sus celdas de entrada exactas. Ops: change, cagr, rank, gap_to_frontier, correlation, forecast, explain_change. | op+parameter+indicator (req); luego country/countries[]/from_year/to_year/year/order/frontier/method/indicator_b… |
get_data_health | Qué tan frescos/confiables/completos son los datos. Con parameter+country: puntaje de confianza, % de provenance, vintage, anomalías, brechas de cobertura. Sin argumentos: el resumen regional + la lista de trabajo de refresco. | parameter, country (ambos opcionales) |
get_discoveries | Aflora proactivamente las mayores anomalías y contradicciones entre fuentes, rankeadas por magnitud, cada una citada. | parameter, country, limit (todos opcionales) |
find_pilots | Busca en el portafolio de pilotos bancables; devuelve slug, pitch, país, madurez, capex, base del impacto. | country, parameter, query, limit (todos opcionales) |
get_pilot | La ficha de diseño completa de un piloto: economía, evidencia de bancabilidad, riesgos, financiamiento, tomadores de decisión. | slug (req) |
recommend_action | La cadena de acción no copiable para un país+pilar: piloto más adecuado → instrumentos de financiamiento → tomadores de decisión rankeados con pedidos a la medida. | country (req), pillar (req) |
search_corpus | Búsqueda semántica sobre el corpus entero (personas, pilotos, casos, señales, financiamiento, regulación…); devuelve hits con dataset_id + claves para traer vía get_dataset. | query (req), datasets[], country, parameter, limit |
get_dataset | Accesor genérico de cualquier dataset catalogado por dataset_id + las claves de ese dataset. | dataset_id (req), country/slug/id/role/layer/status… |
get_series | Exclusiva de MCP. El panel país-año completo de un indicador gobernado en 1–8 países — cada punto anual con su valor, más fuente, deep-link y citation_id por país. (get_parameter_data devuelve solo los últimos 6 puntos como series_tail.) Los países sin el dato se listan bajo missing, nunca se rellenan. | parameter+indicator+countries[] (req), from_year, to_year |
resolve_citations | Exclusiva de MCP. Resuelve citation_ids guardados (de una respuesta anterior o de la API pública) a su registro de fuente completo: source, title, url, year, retrieved_at. Los ids que el registro global no tiene vuelven bajo unresolved — o ids sintéticos por-respuesta (news-*, pulse-*, civic-*…), que solo existen dentro de la respuesta que los acuñó, o ids cuyo registro viaja inline dentro del payload de un dataset (vuelva a llamar la herramienta que devolvió el id para recibir su registro). | ids[] (req, 1–50) |
Semántica de citas
tools/call devuelve el contenido de herramienta MCP como un único bloque de texto cuyo payload es una cadena JSON:
{ "result": { /* la respuesta de la herramienta */ }, "citations": [ /* fuentes resueltas */ ] }isError es true cuando una herramienta reporta un problema (p. ej. un indicador desconocido); el payload es entonces {"result":{"error":"…"},"citations":[]} — el mensaje de fallo viaja dentro de result.error como error de nivel herramienta, no como objeto error JSON-RPC de nivel protocolo (esos quedan reservados para los códigos -32xxx de arriba). Cada cifra numérica dentro de result está ligada a un citation_id que aparece en citations (o resuelve contra el citations.json público). El contrato de diseño: nunca mostrar una cifra de Futuros sin su cita. MCP no tiene herramienta de web en vivo en absoluto, así que todo lo que devuelve una herramienta está verificado contra el corpus.
Ejemplo completo
Listar las herramientas, luego resolver una métrica y traer una comparación citada.
1 — initialize
curl -s https://futuros.xyz/api/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"demo","version":"0"}}}'{ "jsonrpc": "2.0", "id": 1, "result": {
"protocolVersion": "2025-06-18",
"capabilities": { "tools": { "listChanged": false } },
"serverInfo": { "name": "futuros-corpus", "version": "1.0.0",
"title": "Futuros — LATAM governed-data corpus" },
"instructions": "Read-only access to the Futuros corpus: …" } }2 — tools/list
curl -s https://futuros.xyz/api/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'3 — tools/call (resolve_metric)
curl -s https://futuros.xyz/api/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"resolve_metric","arguments":{"query":"homicidios"}}}'4 — tools/call (compare_countries) con el id resuelto
curl -s https://futuros.xyz/api/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call",
"params":{"name":"compare_countries",
"arguments":{"parameter":"seguridad","countries":["MEX","BRA","COL"]}}}'{ "jsonrpc": "2.0", "id": 4, "result": {
"content": [ { "type": "text",
"text": "{\"result\":{ …cifras por país… },\"citations\":[ … ]}" } ],
"isError": false } }5 — tools/call (get_series) — el panel país-año completo
curl -s https://futuros.xyz/api/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":5,"method":"tools/call",
"params":{"name":"get_series",
"arguments":{"parameter":"salud","indicator":"sp_dyn_le00_in",
"countries":["MEX","COL"],"from_year":2010}}}'Devuelve, por país, cada punto anual {year, value} desde 2010 con la fuente, el deep-link y el citation_id del indicador — la serie completa que get_parameter_data trunca a series_tail.
6 — tools/call (resolve_citations) — resolver un id guardado
curl -s https://futuros.xyz/api/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":6,"method":"tools/call",
"params":{"name":"resolve_citations",
"arguments":{"ids":["wb-sp-dyn-le00-in-mex-2024"]}}}'Lote — envíe un arreglo para correr varias llamadas en un solo viaje; la respuesta es un arreglo de resultados en orden. Un lote está topado en 20 mensajes (MAX_BATCH); un arreglo mayor se rechaza con un único -32600 antes de que corra herramienta alguna. Cada mensaje contenido cobra una unidad del presupuesto de 60/min, y una excepción de herramienta dentro de un lote produce -32603 solo para ese id — los hermanos igual responden.
Para descargas de archivos planos del mismo corpus (CSV/SDMX/XLSX), use la API pública v1.