MCP server
Futuros ships a Model Context Protocol (MCP) server so any external agent — Claude Code, Claude Desktop, Cursor, or your own client — can query the same cited LATAM-development corpus the Futuros chat uses. Every figure a tool returns carries a citation_id; the tools only read baked data and compute deterministically — no writes, no hidden LLM call.
- Endpoint:
POST https://futuros.xyz/api/mcp - Transport: Streamable HTTP (JSON-RPC 2.0)
- Protocol version:
2025-06-18 - Server identity:
futuros-corpusv1.0.0— "Futuros — LATAM governed-data corpus" - Auth: none (read-only public corpus)
- Deployment gate:
MCP_ENABLEDmust be"true". Fails closed: without it, everyPOSTreturns403 {"error":"mcp_disabled"}— a fresh deployment or an empty.envdoes not expose the endpoint by accident.GET ?health=1still answers and reports the state in itsenabledfield. - State: stateless — every request is self-contained
- Methods:
initialize,ping,tools/list,tools/call - Body cap: 256 KB, enforced before parsing and before the rate limiter is charged; over it,
413with-32700. A legitimate batch ofMAX_BATCHmessages is a few KB. - Rate limit: 60 messages/minute per client IP, charged once per contained JSON-RPC message — a batch of N costs N units, so bundling can't multiply the budget.
429responses carry aRetry-Afterheader. Cross-instance (Upstash-backed) when configured, else per-instance in-memory.
Transport semantics
The server implements the minimum of Streamable HTTP required for a stateless tool server:
Method on /api/mcp | Behavior |
|---|---|
POST | JSON-RPC 2.0 — a single message or a batch (JSON array). Returns the response object, or an array for a batch. |
POST (notifications only) | HTTP 202 Accepted, empty body (no id → no reply). |
OPTIONS | CORS preflight → 204. |
GET ?health=1 | 200 — { ok, service: "mcp", enabled, transport: "streamable-http", max_batch: 20 }, a cheap probe for uptime monitors. ok/enabled reflect MCP_ENABLED, so the probe tells "down" apart from "deliberately disabled". |
GET (bare) | 405 — stateless server, no server-initiated SSE stream. |
CORS is fully open (Access-Control-Allow-Origin: *, methods POST, OPTIONS, allowed headers Content-Type, Mcp-Session-Id, Mcp-Protocol-Version), so browser-based agents can call it cross-origin.
JSON-RPC error codes used: -32700 parse error, -32600 empty/invalid request (including a batch over the 20-message cap), -32601 method not found / GET, -32602 unknown tool, -32000 rate limited, -32603 internal error — inside a batch, a throw from one message's tool becomes a -32603 bound to that message's id with per-message isolation: sibling messages are still answered, and the endpoint never returns a non-JSON-RPC 500.
JSON-RPC lifecycle
What one POST goes through, in order (api/mcp.ts → server/mcp/handler.ts → the shared corpus tool executor):
Message classification (handleMcpMessage):
| Incoming message | Treatment |
|---|---|
Not a JSON object (string, number, null, nested array) | -32600 with id: null |
Has an id but jsonrpc !== "2.0" or method not a string | -32600 bound to that id |
No id (a notification) — any method, even one that throws | processed, never replied to |
method starting notifications/ (even with an id) | no reply |
Unknown method with an id | -32601 |
tools/call naming a tool outside the 15-tool exposed set | -32602 |
| Tool throws mid-call | -32603 bound to that message's id; siblings unaffected; if no id is recoverable the response is dropped (notification rule) |
Messages in a batch execute sequentially, in array order, and responses come back in the same order (requests only — notifications contribute nothing). A notifications-only body therefore yields an empty response set → HTTP 202 with no body. Each invocation runs under a 30 s function maxDuration.
Rate-limit mechanics — the limiter runs before the handler:
- Key:
mcp:<client-ip>, where the IP isx-real-ip/ the rightmostx-forwarded-forentry — never the forgeable leftmost one. - The batch-size cap is checked before charging, so an oversized array is a single cheap
-32600that consumes zero budget. Each contained message then charges one unit; if any charge trips the limit, the whole request gets429with the largest observedRetry-After(charged units stay consumed). - Backend: one pipelined
INCR+EXPIRE … NX+PTTLround trip to Upstash/Vercel-KV whenUPSTASH_REDIS_REST_*/KV_REST_API_*is configured (cross-instance ceiling); otherwise a bounded in-memory limiter (50,000-entry hard cap, 30 s sweeps, oldest-first eviction). Any store error silently falls back to in-memory — the limiter never throws, so a flaky store degrades to per-instance limiting rather than 500s.
Where tool reads go. The handler builds a FetchDataSource pointed at the deployment's own origin, resolved by selfOrigin(): it prefers the platform-set deployment URL and only falls back to the x-forwarded-proto / x-forwarded-host headers off-Vercel (local dev). The order matters and is a defense, not a detail: because that read is the ground truth behind every tool response, a spoofed x-forwarded-host or host header cannot re-point the server-side fetch at an attacker-chosen host. A tool call therefore reads the same CDN-cached public/data JSON the web app uses — no database, no third host, and repeated reads inside one invocation hit an LRU cache.
Argument normalization. Before dispatch, the executor reconciles country and pillar arguments against the real catalog: "Brasil" → BRA, "mex" → MEX, "educación" → educacion. Purely corrective — unresolvable values pass through untouched, and free-text/dataset-addressed tools are unaffected.
Connecting a client
Claude Code
claude mcp add --transport http futuros https://futuros.xyz/api/mcpCursor
In .cursor/mcp.json (per-project) or ~/.cursor/mcp.json (global):
{
"mcpServers": {
"futuros": { "url": "https://futuros.xyz/api/mcp" }
}
}Claude Desktop / any config-file client
Add to your MCP client config (Claude Desktop's claude_desktop_config.json, or equivalent). A native HTTP-capable client:
{
"mcpServers": {
"futuros": {
"type": "http",
"url": "https://futuros.xyz/api/mcp"
}
}
}For a client that only speaks stdio, bridge with mcp-remote:
{
"mcpServers": {
"futuros": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://futuros.xyz/api/mcp"]
}
}
}On initialize the server returns its instructions string, which tells the agent how to drive the corpus: resolve a metric named in words with resolve_metric, then call get_parameter_data / compare_countries / compute; every figure carries a citation_id; if the corpus lacks a figure the tools say so rather than inventing one.
Tools
Exposure is 15 read-only corpus tools: the 13 allowlisted chat tools, plus 2 MCP-only tools (server/mcp/tools.ts) the chat deliberately lacks — get_series (the full country-year panel for one indicator; the chat truncates series to the last 6 points for token-budget reasons) and resolve_citations (resolve stored citation_ids without leaving MCP for the file API). The open-web tools (web_search, fetch_url) and the paid LLM-backed perplexity_search are never exposed — the endpoint can't double as an SSRF proxy, can't spend the deployment's external-search budget, and stays scoped to cited Futuros data. The design fails closed on both paths: a new tool added to the chat toolset does not leak to MCP until it is explicitly allowlisted, and an MCP-only tool exists only if defined in MCP_EXTRA_TOOLS.
Every tool also accepts an optional lang parameter (es | en | pt, default es — the corpus source language). It localizes prose fields (narratives, pilot names, claims); numbers, ids and citations are language-independent. tools/list advertises it explicitly: the handler injects the lang enum into every advertised object input schema, so a client can discover that non-Spanish output exists rather than having to know it; per call, lang is read from the tool arguments, with any other value falling back to es.
The fifteen tools:
| Tool | What it returns | Key parameters |
|---|---|---|
resolve_metric | Maps a metric named in words to a governed indicator id (from the metric registry) — ranked candidates with canonical id, unit, good_direction, pillar, coverage, vintage. Call this first when you need an id. | query (req), pillar, limit |
get_parameter_data | Full picture for one pillar in one country: cited narrative, all indicators (value/trend/year/citation_id), governance, correlations, evidence grade. | parameter (req), country (req) |
compare_countries | One pillar's indicators across 2–8 countries in one call, each figure cited. | parameter (req), countries[] (req, 2–8) |
get_news | Curated recent news for a pillar+country; each item's id is its citation_id. | parameter (req), country (req), limit |
get_regional_pulse | LATAM regional aggregate for one pillar: value, trend, leaders/laggards, movers, documented source contradictions. | parameter (req) |
compute | A reproducible calculation cited to its exact input cells. Ops: change, cagr, rank, gap_to_frontier, correlation, forecast, explain_change. | op+parameter+indicator (req); then country/countries[]/from_year/to_year/year/order/frontier/method/indicator_b… |
get_data_health | How fresh/reliable/complete the data is. With parameter+country: trust score, provenance %, vintage, anomalies, coverage gaps. With no args: the regional summary + refresh worklist. | parameter, country (both optional) |
get_discoveries | Proactively surfaces the biggest anomalies and source contradictions, ranked by magnitude, each cited. | parameter, country, limit (all optional) |
find_pilots | Search the bankable pilot portfolio; returns slug, pitch, country, maturity, capex, impact basis. | country, parameter, query, limit (all optional) |
get_pilot | Full design sheet for one pilot: economics, bankability evidence, risks, financing, decision-makers. | slug (req) |
recommend_action | The un-copyable action chain for a country+pillar: best-fit pilot → financing instruments → ranked decision-makers with tailored asks. | country (req), pillar (req) |
search_corpus | Semantic search across the entire corpus (personas, pilots, cases, signals, financing, regulation…); returns hits with dataset_id + keys to fetch via get_dataset. | query (req), datasets[], country, parameter, limit |
get_dataset | Generic accessor for any catalogued dataset by dataset_id + that dataset's keys. | dataset_id (req), country/slug/id/role/layer/status… |
get_series | MCP-only. The full year-by-year panel for one governed indicator across 1–8 countries — every annual point, plus source name, deep-link and citation_id per country. (get_parameter_data returns only the last 6 points as series_tail.) Countries lacking the figure are listed under missing, never filled in. | parameter+indicator+countries[] (req), from_year, to_year |
resolve_citations | MCP-only. Resolves stored citation_ids (from an earlier response or the public API) to their full source record: source, title, url, year, retrieved_at. Ids the global registry lacks come back under unresolved — either synthetic per-response ids (news-*, pulse-*, civic-*…), which only exist inside the response that minted them, or ids whose record travels inline within a dataset payload (re-call the tool that returned the id to receive its record). | ids[] (req, 1–50) |
Citation semantics
tools/call returns MCP tool content as a single text block whose payload is a JSON string:
{ "result": { /* the tool's answer */ }, "citations": [ /* resolved sources */ ] }isError is true when a tool reports a problem (e.g. an unknown indicator); the payload is then {"result":{"error":"…"},"citations":[]} — the failure message travels inside result.error as a tool-level error, not a protocol-level JSON-RPC error object (those are reserved for the -32xxx codes above). Every numeric figure inside result is bound to a citation_id that appears in citations (or resolves against the public citations.json). The design contract: never surface a Futuros figure without its citation. MCP has no live-web tool at all, so everything a tool returns is corpus-verified.
Worked example
List the tools, then resolve a metric and fetch a cited comparison.
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) using the resolved id
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\":{ …per-country figures… },\"citations\":[ … ]}" } ],
"isError": false } }5 — tools/call (get_series) — the full country-year panel
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}}}'Returns, per country, every annual {year, value} point from 2010 with the indicator's source, deep-link and citation_id — the full series that get_parameter_data truncates to series_tail.
6 — tools/call (resolve_citations) — resolve a stored id
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"]}}}'Batch — send an array to run several calls in one round-trip; the response is an array of results in order. A batch is capped at 20 messages (MAX_BATCH); a larger array is rejected with a single -32600 before any tool runs. Each contained message is charged one unit of the 60/min rate budget, and a tool throw inside a batch yields -32603 for that id only — siblings still answer.
For flat file downloads of the same corpus (CSV/SDMX/XLSX), use the Public API v1.