Skip to content

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-corpus v1.0.0: "Futuros — LATAM governed-data corpus"
  • Auth: every POST requires an account API key (Authorization: Bearer ft_live_… or X-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 without DATABASE_URL) stays anonymous. Static open data (/api/v1) also requires an account key, see Authentication.
  • Deployment gate: MCP_ENABLED must be "true". Fails closed: without it, every POST returns 403 {"error":"mcp_disabled"}: a fresh deployment or an empty .env does not expose the endpoint by accident. GET ?health=1 still answers and reports the state in its enabled field (and the access block).
  • 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, 413 with -32700. A legitimate batch of MAX_BATCH messages 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 503 instead of degrading to a per-instance cap. Both the 429 (over quota) and the 503 (limiter unavailable) carry Retry-After.

Transport semantics ​

The server implements the minimum of Streamable HTTP required for a stateless tool server:

Method on /api/mcpBehavior
POSTJSON-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).
OPTIONSCORS preflight → 204.
GET ?health=1200 — { 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 messageTreatment
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 throwsprocessed, 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 is x-real-ip / the rightmostx-forwarded-for entry, never the forgeable leftmost one.
  • The batch-size cap is checked before charging, so an oversized array is a single cheap -32600 that 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's Retry-After; units already charged stay consumed.
  • Backend: one pipelined INCR + EXPIRE … NX + PTTL round trip to Upstash/Vercel-KV when UPSTASH_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/mcp charges with failClosed: 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 HTTP 503 with -32000 and the message "rate limiting is temporarily unavailable; retry shortly", plus Retry-After: 30 (the breaker cooldown). It is a deliberately distinct refusal from the over-quota 429: 429 means the caller really is over its quota, 503 means we are degraded and the caller did nothing wrong (agents back off differently on each). RATE_LIMIT_REQUIRE_STORE=1 restores 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 sets RATE_LIMIT_REQUIRE_STORE=1 to make a missing store a hard fault too (on a managed deployment only: VERCEL_ENV=development and 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 ​

bash
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):

jsonc
{
  "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:

jsonc
{
  "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:

jsonc
{
  "mcpServers": {
    "futuros": {
      "command": "npx",
      "args": [
        "-y", "mcp-remote", "https://futuros.xyz/api/mcp",
        "--header", "Authorization: Bearer $FUTUROS_API_KEY"
      ]
    }
  }
}

API keys ​

SurfaceCredentialCreated 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/mcpAccount key/cuenta/claves
POST /api/chat from scripts / other originsAccount 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:

ToolWhat it returnsKey parameters
resolve_metricMaps 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_dataFull 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_countriesOne pillar's indicators across 2–8 countries in one call, each figure cited.parameter (req), countries[] (req, 2–8)
get_newsCurated recent news for a pillar+country; each item's id is its citation_id.parameter (req), country (req), limit
get_regional_pulseLATAM regional aggregate for one pillar: value, trend, leaders/laggards, movers, documented source contradictions.parameter (req)
computeA 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_healthHow 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_discoveriesProactively surfaces the biggest anomalies and source contradictions, ranked by magnitude, each cited.parameter, country, limit (all optional)
find_pilotsSearch the bankable pilot portfolio; returns slug, pitch, country, maturity, capex, impact basis.country, parameter, query, limit (all optional)
get_pilotFull design sheet for one pilot: economics, bankability evidence, risks, financing, decision-makers.slug (req)
recommend_actionThe un-copyable action chain for a country+pillar: best-fit pilot → financing instruments → ranked decision-makers with tailored asks.country (req), pillar (req)
search_corpusLexical 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_datasetGeneric accessor for any catalogued dataset by dataset_id + that dataset's keys.dataset_id (req), country/slug/id/role/layer/status…
get_seriesMCP-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_citationsMCP-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 mode field is always bm25 over MCP, never hybrid-rrf, bm25+rerank or hybrid-rrf+rerank. semantic_index_drift is absent too, since only the semantic arm produces it.
  • The override is applied after the client's own arguments, so sending keyword_only: false does not unpin it. The cost profile of /api/mcp is a server-side decision, not a client-negotiable one, and the flag is advertised in no inputSchema.
  • 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:

json
{ "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

bash
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"}}}'
jsonc
{ "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

bash
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)

bash
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

bash
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"]}}}'
jsonc
{ "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

bash
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

bash
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.

Every figure with its source — traceability is the contract.