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: none (read-only public corpus)
  • 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.
  • 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, charged once per contained JSON-RPC message — a batch of N costs N units, so bundling can't multiply the budget. 429 responses carry a Retry-After header. 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/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, -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.tsserver/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; if any charge trips the limit, the whole request gets 429 with the largest observed Retry-After (charged units 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). 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

bash
claude mcp add --transport http futuros https://futuros.xyz/api/mcp

Cursor

In .cursor/mcp.json (per-project) or ~/.cursor/mcp.json (global):

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

jsonc
{
  "mcpServers": {
    "futuros": {
      "type": "http",
      "url": "https://futuros.xyz/api/mcp"
    }
  }
}

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"]
    }
  }
}

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_corpusSemantic 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_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)

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