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 precalculados 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: cada
POSTexige una clave de API de cuenta (Authorization: Bearer ft_live_…oX-Api-Key), que se crea en/cuenta/claves. Sin clave →401 {"error":"api_key_required"}; clave inválida, vencida o revocada →401 {"error":"invalid_api_key"}; más de 600 solicitudes por minuto con la misma clave →429. El modo prepago por créditos (ACCESS_ENABLED,/acceso) sigue en el código pero apagado; volver a usarlo como capa de medición sobre las claves de cuenta es trabajo futuro. Ver Claves de API. Un despliegue sin pila de cuentas (desarrollo local, autoalojado sinDATABASE_URL) sigue anónimo. Los datos abiertos estáticos (/api/v1) también exigen clave de cuenta, ver Autenticación. - 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(y el bloqueaccess). - 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 (o por clave de cuenta si hay autenticación), cobrado una vez por mensaje JSON-RPC contenido, un lote de N cuesta N unidades, así que agrupar no multiplica el presupuesto. Entre instancias (respaldado por Upstash) cuando está configurado; si no, en memoria por instancia. Este endpoint cobra fail-closed: si el store compartido está configurado y no se puede consultar, la petición se rechaza con
503en vez de degradar a un tope por instancia. Tanto el429(sobre cuota) como el503(limitador no disponible) traenRetry-After.
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 (HTTP 429) o limitador no disponible (HTTP 503), -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; en cuanto un cobro dispara el límite se deja de cobrar (los viajes restantes al store solo empujarían el contador) y toda la petición se rechaza con elRetry-Afterde ese cobro; las unidades ya 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). El limitador nunca lanza excepción; lo que cambia es qué veredicto devuelve cuando el store falla (abajo). - Cortacircuitos. Tras 5 errores consecutivos del store, el limitador deja de llamarlo por un enfriamiento de 30 s y luego lo vuelve a sondear con la siguiente petición. Sin esto, cada petición añadiría un viaje condenado al mismo store que se necesita de vuelta (el middleware y los contadores de scraping comparten esa Redis). El cortacircuitos nunca ensancha en silencio un tope: mientras está abierto, quien pide fail-open recibe límite por instancia y quien pide fail-closed recibe el techo degradado.
- Fail-closed: store configurado que no responde → techo degradado, luego
503./api/mcpcobra confailClosed: true. Si el store está configurado y el viaje falla (o el cortacircuitos está abierto), el endpoint no degrada al tope completo por instancia — en serverless eso significa tope × número de instancias vivas, es decir ningún tope, sobre una superficie pública y sin autenticación cuyos clientes son agentes en bucle de reintento — pero tampoco rechaza todo: cae a 10 mensajes por minuto y por IP (en vez de 60), contados en la memoria de la instancia. Un agente en bucle es justo el cliente que conviene estrechar, no cortar. Al rebasar ese techo la respuesta es HTTP503con-32000y el mensaje "rate limiting is temporarily unavailable; retry shortly", conRetry-After: 30(el enfriamiento del cortacircuitos). Es un rechazo distinto del429de sobre-cuota a propósito:429significa que quien llama realmente excedió su cuota,503que el degradado es nuestro y el cliente no hizo nada mal (un agente hace backoff distinto en cada caso).RATE_LIMIT_REQUIRE_STORE=1devuelve el rechazo duro sin techo degradado. - Store nunca configurado → sigue fail-open. "Sin configurar" no es lo mismo que "fallando": es un estado estable de operación (dev local, un self-hoster,
vercel dev), no una transición alcanzable por un atacante. En ese caso el limitador cae al modo por instancia y avisa una vez en los logs, porque 503-ear cada petición por una variable de entorno faltante sería una interrupción peor que el gasto que evita. Un operador que ya confirmó que el store está provisionado poneRATE_LIMIT_REQUIRE_STORE=1para convertir también el store ausente en falta dura (solo en un despliegue gestionado:VERCEL_ENV=developmenty una máquina local quedan siempre fail-open).
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
Cree una clave en /cuenta/claves y guárdela: se muestra una sola vez.
Claude Code
claude mcp add --transport http futuros https://futuros.xyz/api/mcp \
--header "Authorization: Bearer $FUTUROS_API_KEY"Cursor
En .cursor/mcp.json (por proyecto) o ~/.cursor/mcp.json (global):
{
"mcpServers": {
"futuros": {
"url": "https://futuros.xyz/api/mcp",
"headers": { "Authorization": "Bearer $FUTUROS_API_KEY" }
}
}
}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",
"headers": { "Authorization": "Bearer $FUTUROS_API_KEY" }
}
}
}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",
"--header", "Authorization: Bearer $FUTUROS_API_KEY"
]
}
}
}Claves de API
| Superficie | Credencial | Dónde se crea |
|---|---|---|
GET /api/v1/**, /feeds/** (datos abiertos) | Clave de cuenta (o sesión de navegador) | /cuenta/claves |
| Chat en futuros.xyz (navegador con sesión) | Cookie de sesión | — |
POST /api/mcp | Clave de cuenta | /cuenta/claves |
POST /api/chat desde scripts / otros orígenes | Clave de cuenta. El turno se guarda en el historial del dueño de la clave, en una conversación titulada por el cliente (title en el cuerpo, máximo 60 caracteres) o "API" | /cuenta/claves |
Límites: 600 solicitudes por minuto por clave, cobradas en cada solicitud en el Routing Middleware (variable API_KEY_MAX_PER_MINUTE), y, para el chat, 500 mensajes por día por cuenta (variable CHAT_USER_DAILY_MAX). A eso se suma el límite propio de este endpoint (60 mensajes por minuto), que con una clave se cuenta por clave (mcp:key:<id>) en lugar de por IP. Un 429 trae Retry-After. Una clave nunca es una sesión de navegador: solo sirve en las superficies de la tabla. La creación y la revocación de cada clave quedan en el registro de auditoría.
Modo prepago (ACCESS_ENABLED=true, paquetes en /acceso): apagado en producción desde la release de cuentas. Su código y sus claves access:* quedan intactos para poder reactivarlo como capa de medición sobre las claves de cuenta.
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 ordenados 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, ordenadas 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 ordenados por relevancia, con pedidos a la medida. | country (req), pillar (req) |
search_corpus | Búsqueda léxica BM25 sobre el corpus entero (personas, pilotos, casos, señales, financiamiento, regulación…); devuelve hits con dataset_id + claves para traer vía get_dataset. Sobre MCP el brazo semántico queda apagado por el servidor y el campo mode de la respuesta siempre vuelve bm25 (ver la sección siguiente). | 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) |
Costo cero por llamada: search_corpus corre en BM25
La regla de diseño de este endpoint: toda herramienta alcanzable por /api/mcp no puede costar nada más allá de lecturas estáticas del corpus.search_corpus era la única excepción. Su brazo por defecto es híbrido y manda la consulta a la API de embeddings de Voyage y el pool de candidatos a su reranker, ambas medidas y facturadas a la llave de Futuros. Como este endpoint solía ser público y sin autenticación, ese camino era una primitiva de amplificación de gasto: texto elegido por quien llama, cobrado a nosotros, al ritmo completo de peticiones del endpoint. Las claves de cuenta (véase Claves de API) ahora filtran quién puede llamar al MCP; el pin BM25 se mantiene para que ni un cliente de pago pueda enrutar gasto de Voyage por esta superficie.
Por eso el handler fija search_corpus a su brazo de solo palabras clave (BM25):
- El campo
modede la respuesta siempre valebm25sobre MCP, nuncahybrid-rrf,bm25+reranknihybrid-rrf+rerank. Tampoco aparecesemantic_index_drift, que solo lo produce el brazo semántico. - El override se aplica después de los argumentos del cliente, así que mandar
keyword_only: falseno lo desactiva. El perfil de costo de/api/mcpes una decisión del servidor, no algo negociable por el cliente; el flag no se anuncia en ningúninputSchema. - El texto de consulta que puede llegar a una API medida está topado en 512 caracteres (
MAX_QUERY_CHARS). Sobre MCP ese camino ni siquiera corre: el ranking BM25 es cómputo local sobre los archivos de índice. - No se pierde cobertura. BM25 ordena los índices vivos, así que si algo cambia es que resulta más fresco que los embeddings precalculados de los que depende el brazo semántico.
La invariante que se preserva al agregar herramientas: cualquier herramienta con un camino medido queda fuera de la lista blanca, o fijada a un brazo gratuito que el cliente no pueda desfijar.
La restricción es solo de MCP. El chat de Futuros (/api/chat) conserva el camino completo, híbrido semántico + léxico con rerank de cross-encoder: es una superficie limitada por IP y globalmente, y es el consumidor de pago previsto.
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 'Authorization: Bearer $FUTUROS_API_KEY' \
-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 'Authorization: Bearer $FUTUROS_API_KEY' -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 'Authorization: Bearer $FUTUROS_API_KEY' -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 'Authorization: Bearer $FUTUROS_API_KEY' -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 'Authorization: Bearer $FUTUROS_API_KEY' -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 'Authorization: Bearer $FUTUROS_API_KEY' -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.