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: every
POSTrequires an account API key (Authorization: Bearer ft_live_…orX-Api-Key), created at/cuenta/claves. Missing key →401 {"error":"api_key_required"}; invalid, expired or revoked key →401 {"error":"invalid_api_key"}; more than 600 requests per minute on one key →429. The prepaid-credit mode (ACCESS_ENABLED,/acceso) remains in the code but off; bringing it back as a metering layer on top of account keys is future work. See API keys. A deployment without the accounts stack (local development, self-hosted withoutDATABASE_URL) stays anonymous. Static open data (/api/v1) also requires an account key, see Authentication. - 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 (and theaccessblock). - 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 (or per account key when authenticated), charged once per contained JSON-RPC message, a batch of N costs N units, so bundling can't multiply the budget. Cross-instance (Upstash-backed) when configured, else per-instance in-memory. This endpoint charges fail-closed: when a shared store is configured and cannot be consulted, the request is refused with
503instead of degrading to a per-instance cap. Both the429(over quota) and the503(limiter unavailable) carryRetry-After.
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 (HTTP 429) or limiter unavailable (HTTP 503), -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; the moment a charge trips the limit, charging stops (further store round trips could only push the counter higher) and the whole request is refused with that charge'sRetry-After; units already charged 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). The limiter never throws; what changes when the store fails is the verdict it returns (below). - Circuit breaker. After 5 consecutive store errors the limiter stops calling it for a 30 s cooldown, then re-probes on the next request. Without it every request would add another doomed round trip to the very store the limiter needs back (the middleware and the scrape counters share that Redis). The breaker never silently widens a cap: while it is open, fail-open callers get per-instance limiting and fail-closed callers get the degraded ceiling.
- Fail-closed: a configured store that cannot be reached → degraded ceiling, then
503./api/mcpcharges withfailClosed: true. When the store is configured and the round trip fails (or the breaker is open), the endpoint does not degrade to the full per-isolate cap — on serverless that means cap × live instance count, i.e. no cap, on an unauthenticated public surface whose callers are agents on a retry loop — but it does not refuse everything either: it falls back to 10 messages per minute per IP (instead of 60), counted in the instance's own memory. An agent on a retry loop is exactly the caller to squeeze rather than cut off. Past that ceiling the response is HTTP503with-32000and the message "rate limiting is temporarily unavailable; retry shortly", plusRetry-After: 30(the breaker cooldown). It is a deliberately distinct refusal from the over-quota429:429means the caller really is over its quota,503means we are degraded and the caller did nothing wrong (agents back off differently on each).RATE_LIMIT_REQUIRE_STORE=1restores the hard refusal with no degraded ceiling. - A store that was never configured stays fail-open. "Unconfigured" is not "erroring": it is an operator steady state (local dev, a self-hoster,
vercel dev), not an attacker-reachable transition. There the limiter falls back to per-instance mode and warns once in the logs, because 503-ing every request over a missing env var would be a worse outage than the spend it prevents. An operator who has confirmed the store really is provisioned setsRATE_LIMIT_REQUIRE_STORE=1to make a missing store a hard fault too (on a managed deployment only:VERCEL_ENV=developmentand a local machine stay fail-open regardless).
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
Create a key at /cuenta/claves and keep it: it is shown once.
Claude Code
claude mcp add --transport http futuros https://futuros.xyz/api/mcp \
--header "Authorization: Bearer $FUTUROS_API_KEY"Cursor
In .cursor/mcp.json (per-project) or ~/.cursor/mcp.json (global):
{
"mcpServers": {
"futuros": {
"url": "https://futuros.xyz/api/mcp",
"headers": { "Authorization": "Bearer $FUTUROS_API_KEY" }
}
}
}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",
"headers": { "Authorization": "Bearer $FUTUROS_API_KEY" }
}
}
}For a client that only speaks stdio, bridge with mcp-remote:
{
"mcpServers": {
"futuros": {
"command": "npx",
"args": [
"-y", "mcp-remote", "https://futuros.xyz/api/mcp",
"--header", "Authorization: Bearer $FUTUROS_API_KEY"
]
}
}
}API keys
| Surface | Credential | Created at |
|---|---|---|
GET /api/v1/**, /feeds/** (open data) | Account key (or a browser session) | /cuenta/claves |
| On-site chat at futuros.xyz (logged-in browser) | Session cookie | — |
POST /api/mcp | Account key | /cuenta/claves |
POST /api/chat from scripts / other origins | Account key. The turn is stored in the key owner's history, in a conversation titled by the client (title in the body, 60 characters max) or "API" | /cuenta/claves |
Limits: 600 requests per minute per key, charged on every request in Routing Middleware (lever API_KEY_MAX_PER_MINUTE), and, for chat, 500 messages per day per account (lever CHAT_USER_DAILY_MAX). On top of that sits this endpoint's own limit (60 messages per minute), which for a key is counted per key (mcp:key:<id>) instead of per IP. A 429 carries Retry-After. A key is never a browser session: it works only on the surfaces in the table. Creating and revoking each key is written to the audit log.
Prepaid mode (ACCESS_ENABLED=true, packs at /acceso): off in production since the accounts release. Its code and access:* keys stay intact so it can return as a metering layer on top of account keys.
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 | Lexical BM25 search across the entire corpus (personas, pilots, cases, signals, financing, regulation…); returns hits with dataset_id + keys to fetch via get_dataset. Over MCP the semantic arm is pinned off server-side and the response's mode field always comes back bm25 (see the next section). | 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) |
Zero cost per call: search_corpus runs BM25
This endpoint's design rule: every tool reachable over /api/mcp must cost nothing beyond static corpus reads. search_corpus was the one exception. Its default arm is hybrid, and it sends the query to Voyage's embeddings API and the candidate pool to Voyage's reranker, both metered and billed to Futuros' key. Because this endpoint used to be public and unauthenticated, that path was a paid-API amplification primitive: caller-chosen text, billed to us, at the endpoint's full request rate. Account API keys (see API keys) now gate who can call MCP; the BM25 pin remains so even a paying caller cannot route Voyage spend through this surface.
So the handler pins search_corpus to its keyword-only (BM25) arm:
- The response's
modefield is alwaysbm25over MCP, neverhybrid-rrf,bm25+rerankorhybrid-rrf+rerank.semantic_index_driftis absent too, since only the semantic arm produces it. - The override is applied after the client's own arguments, so sending
keyword_only: falsedoes not unpin it. The cost profile of/api/mcpis a server-side decision, not a client-negotiable one, and the flag is advertised in noinputSchema. - Query text that could reach a metered API is capped at 512 characters (
MAX_QUERY_CHARS). Over MCP that path does not run at all: BM25 ranking is local computation over the index files. - Nothing real is lost. BM25 ranks the live index files, so if anything it is fresher than the baked embeddings the semantic arm depends on.
The invariant to preserve when adding tools: any tool with a metered path is either kept off the allowlist or pinned to a free arm the client cannot unpin.
The restriction is MCP-only. The Futuros chat (/api/chat) keeps the full path, hybrid semantic + lexical with cross-encoder rerank: it is IP- and globally rate-limited, and it is the intended paying consumer.
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 '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) using the resolved id
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\":{ …per-country figures… },\"citations\":[ … ]}" } ],
"isError": false } }5 (tools/call (get_series)) the full country-year panel
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}}}'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 '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"]}}}'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.