Skip to content

Servidor MCP ​

O Futuros publica um servidor Model Context Protocol (MCP) para que qualquer agente externo: Claude Code, Claude Desktop, Cursor, ou seu próprio cliente, possa consultar o mesmo corpus citado de desenvolvimento da LATAM que o chat do Futuros usa. Cada número que uma ferramenta retorna carrega um citation_id; as ferramentas apenas leem dados assados e calculam de forma determinística, sem escritas, sem chamada oculta de LLM.

  • Endpoint: POST https://futuros.xyz/api/mcp
  • Transporte: Streamable HTTP (JSON-RPC 2.0)
  • Versão do protocolo: 2025-06-18
  • Identidade do servidor: futuros-corpus v1.0.0: "Futuros — LATAM governed-data corpus"
  • Auth: cada POST exige uma chave de API de conta (Authorization: Bearer ft_live_… ou X-Api-Key), criada em /cuenta/claves. Sem chave → 401 {"error":"api_key_required"}; chave inválida, expirada ou revogada → 401 {"error":"invalid_api_key"}; mais de 600 solicitações por minuto com a mesma chave → 429. O modo pré-pago por créditos (ACCESS_ENABLED, /acceso) continua no código, mas desligado; voltar a usá-lo como camada de medição sobre as chaves de conta é trabalho futuro. Ver Chaves de API. Um deploy sem a pilha de contas (desenvolvimento local, auto-hospedado sem DATABASE_URL) continua anônimo. Os dados abertos estáticos (/api/v1) também exigem chave de conta, ver Autenticação.
  • Gate de deploy: MCP_ENABLED deve valer "true". Falha fechado: sem ela, cada POST devolve 403 {"error":"mcp_disabled"}: um deploy novo ou um .env vazio não expõe o endpoint por acidente. GET ?health=1 continua respondendo e reporta o estado em seu campo enabled.
  • Estado: sem estado: cada requisição é autocontida
  • Métodos: initialize, ping, tools/list, tools/call
  • Teto de corpo: 256 KB, aplicado antes do parse e antes de cobrar o rate limiter; acima disso, 413 com -32700. Um lote legítimo de MAX_BATCH mensagens tem poucos KB.
  • Rate limit: 60 mensagens/minuto por IP de cliente (ou por chave de conta, se autenticado), cobradas uma vez por mensagem JSON-RPC contida, um batch de N custa N unidades, então agrupar não multiplica o orçamento. Entre instâncias (via Upstash) quando configurado; caso contrário, em memória por instância. Este endpoint cobra fail-closed: se o store compartilhado está configurado e não pode ser consultado, a requisição é recusada com 503 em vez de degradar para um teto por instância. Tanto o 429 (acima da cota) quanto o 503 (limitador indisponível) carregam Retry-After.

Semântica de transporte ​

O servidor implementa o mínimo de Streamable HTTP exigido para um servidor de ferramentas sem estado:

Método em /api/mcpComportamento
POSTJSON-RPC 2.0 — uma única mensagem ou um batch (array JSON). Retorna o objeto de resposta, ou um array para um batch.
POST (somente notificações)HTTP 202 Accepted, corpo vazio (sem id → sem resposta).
OPTIONSPreflight CORS → 204.
GET ?health=1200 — { ok, service: "mcp", enabled, transport: "streamable-http", max_batch: 20 }, uma sonda barata para monitores de uptime. ok/enabled refletem MCP_ENABLED, então a sonda distingue "caído" de "desabilitado de propósito".
GET (puro)405 — servidor sem estado, sem stream SSE iniciado pelo servidor.

O CORS é totalmente aberto (Access-Control-Allow-Origin: *, métodos POST, OPTIONS, cabeçalhos permitidos Content-Type, Mcp-Session-Id, Mcp-Protocol-Version), então agentes baseados em navegador podem chamá-lo cross-origin.

Códigos de erro JSON-RPC usados: -32700 erro de parse, -32600 requisição vazia/inválida (incluindo um batch acima do teto de 20 mensagens), -32601 método não encontrado / GET, -32602 ferramenta desconhecida, -32000 rate limit (HTTP 429) ou limitador indisponível (HTTP 503), -32603 erro interno, dentro de um batch, um throw da ferramenta de uma mensagem vira um -32603 vinculado ao id daquela mensagem, com isolamento por mensagem: as mensagens irmãs ainda são respondidas, e o endpoint nunca retorna um 500 fora do JSON-RPC.

Ciclo de vida JSON-RPC ​

Pelo que um POST passa, em ordem (api/mcp.ts → server/mcp/handler.ts → o executor de ferramentas de corpus compartilhado):

Classificação de mensagens (handleMcpMessage):

Mensagem recebidaTratamento
Não é um objeto JSON (string, número, null, array aninhado)-32600 com id: null
Tem id mas jsonrpc !== "2.0" ou method não é string-32600 vinculado àquele id
Sem id (uma notificação) — qualquer método, mesmo um que lance throwprocessada, nunca respondida
method começando com notifications/ (mesmo com id)sem resposta
Método desconhecido com id-32601
tools/call nomeando uma ferramenta fora da lista exposta de 15 ferramentas-32602
Ferramenta lança throw no meio da chamada-32603 vinculado ao id daquela mensagem; irmãs não afetadas; se nenhum id for recuperável, a resposta é descartada (regra de notificação)

Mensagens em um batch executam sequencialmente, na ordem do array, e as respostas voltam na mesma ordem (somente requisições, notificações não contribuem com nada). Um corpo só de notificações, portanto, produz um conjunto de respostas vazio → HTTP 202 sem corpo. Cada invocação roda sob um maxDuration de função de 30 s.

Mecânica do rate limit: o limitador roda antes do handler:

  • Chave: mcp:<client-ip>, onde o IP é x-real-ip / a entrada mais à direita de x-forwarded-for: nunca a mais à esquerda, que é forjável.
  • O teto de tamanho de batch é verificado antes de cobrar, então um array grande demais é um único -32600 barato que consome zero orçamento. Cada mensagem contida cobra então uma unidade; no momento em que uma cobrança estoura o limite, a cobrança para (as viagens restantes ao store só empurrariam o contador) e a requisição inteira é recusada com o Retry-After daquela cobrança; as unidades já cobradas permanecem consumidas.
  • Backend: uma única viagem pipelined de INCR + EXPIRE … NX + PTTL ao Upstash/Vercel-KV quando UPSTASH_REDIS_REST_* / KV_REST_API_* está configurado (teto entre instâncias); caso contrário, um limitador em memória com fronteiras (teto rígido de 50.000 entradas, varreduras de 30 s, remoção do mais antigo primeiro). O limitador nunca lança throw; o que muda quando o store falha é qual veredicto ele devolve (abaixo).
  • Disjuntor (circuit breaker). Depois de 5 erros consecutivos do store, o limitador para de chamá-lo por um resfriamento de 30 s e então o re-sonda na requisição seguinte. Sem isso, cada requisição acrescentaria mais uma viagem condenada ao mesmo store que o limitador precisa de volta (o middleware e os contadores de scraping compartilham essa Redis). O disjuntor nunca alarga um teto em silêncio: enquanto está aberto, quem pede fail-open recebe limitação por instância e quem pede fail-closed recebe o teto degradado.
  • Fail-closed: store configurado que não responde → teto degradado, depois 503. /api/mcp cobra com failClosed: true. Se o store está configurado e a viagem falha (ou o disjuntor está aberto), o endpoint não degrada para o teto completo por isolate — em serverless isso significa teto × número de instâncias vivas, ou seja, teto nenhum, numa superfície pública e sem autenticação cujos clientes são agentes em laço de retry — mas também não recusa tudo: cai para 10 mensagens por minuto por IP (em vez de 60), contadas na memória da instância. Um agente em laço de retry é justamente o cliente a apertar, não a cortar. Passando desse teto, a resposta é HTTP 503 com -32000 e a mensagem "rate limiting is temporarily unavailable; retry shortly", mais Retry-After: 30 (o resfriamento do disjuntor). É uma recusa deliberadamente distinta do 429 de cota estourada: 429 significa que quem chama realmente passou da sua cota, 503 significa que a degradação é nossa e o cliente não fez nada errado (um agente faz backoff diferente em cada caso). RATE_LIMIT_REQUIRE_STORE=1 devolve a recusa dura sem teto degradado.
  • Store que nunca foi configurado continua fail-open. "Não configurado" não é o mesmo que "falhando": é um estado estável de operação (dev local, um self-hoster, vercel dev), não uma transição alcançável por um atacante. Aí o limitador cai para o modo por instância e avisa uma vez nos logs, porque dar 503 em toda requisição por causa de uma variável de ambiente ausente seria uma interrupção pior do que o gasto que isso evita. Um operador que já confirmou que o store está provisionado define RATE_LIMIT_REQUIRE_STORE=1 para tornar o store ausente uma falta dura também (só num deploy gerenciado: VERCEL_ENV=development e uma máquina local ficam sempre fail-open).

Para onde vão as leituras das ferramentas. O handler constrói um FetchDataSource apontado para a própria origem do deployment, resolvida por selfOrigin(): ela prefere a URL de deployment definida pela plataforma e só recorre aos cabeçalhos x-forwarded-proto / x-forwarded-host fora da Vercel (dev local). A ordem importa e é uma defesa, não um detalhe: como essa leitura é a verdade de base por trás de cada resposta de ferramenta, um x-forwarded-host ou host falsificado não pode reapontar o fetch do servidor para um host escolhido por um atacante. Uma chamada de ferramenta lê assim o mesmo JSON de public/data cacheado na CDN que o app web usa, sem banco de dados, sem terceiro host, e leituras repetidas dentro de uma invocação acertam um cache LRU.

Normalização de argumentos. Antes do dispatch, o executor reconcilia argumentos de país e pilar contra o catálogo real: "Brasil" → BRA, "mex" → MEX, "educación" → educacion. Puramente corretiva, valores não resolvíveis passam intocados, e ferramentas de texto livre / endereçadas por dataset não são afetadas.

Conectando um cliente ​

Crie uma chave em /cuenta/claves e guarde-a: é mostrada uma única vez.

Claude Code ​

bash
claude mcp add --transport http futuros https://futuros.xyz/api/mcp \
  --header "Authorization: Bearer $FUTUROS_API_KEY"

Cursor ​

Em .cursor/mcp.json (por projeto) ou ~/.cursor/mcp.json (global):

jsonc
{
  "mcpServers": {
    "futuros": {
      "url": "https://futuros.xyz/api/mcp",
      "headers": { "Authorization": "Bearer $FUTUROS_API_KEY" }
    }
  }
}

Claude Desktop / qualquer cliente com arquivo de config ​

Adicione à config do seu cliente MCP (o claude_desktop_config.json do Claude Desktop, ou equivalente). Um cliente nativo com suporte a HTTP:

jsonc
{
  "mcpServers": {
    "futuros": {
      "type": "http",
      "url": "https://futuros.xyz/api/mcp",
      "headers": { "Authorization": "Bearer $FUTUROS_API_KEY" }
    }
  }
}

Para um cliente que só fala stdio, faça ponte com mcp-remote:

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

Chaves de API ​

SuperfícieCredencialOnde criar
GET /api/v1/**, /feeds/** (dados abertos)Chave de conta (ou sessão do navegador)/cuenta/claves
Chat em futuros.xyz (navegador com sessão)Cookie de sessão—
POST /api/mcpChave de conta/cuenta/claves
POST /api/chat a partir de scripts / outras origensChave de conta. O turno é guardado no histórico do dono da chave, em uma conversa intitulada pelo cliente (title no corpo, máximo de 60 caracteres) ou "API"/cuenta/claves

Limites: 600 solicitações por minuto por chave, cobradas em cada solicitação no Routing Middleware (variável API_KEY_MAX_PER_MINUTE), e, para o chat, 500 mensagens por dia por conta (variável CHAT_USER_DAILY_MAX). Somado a isso há o limite próprio deste endpoint (60 mensagens por minuto), que com uma chave é contado por chave (mcp:key:<id>) em vez de por IP. Um 429 traz Retry-After. Uma chave nunca é uma sessão de navegador: só vale nas superfícies da tabela. A criação e a revogação de cada chave ficam no registro de auditoria.

Modo pré-pago (ACCESS_ENABLED=true, pacotes em /acceso): desligado em produção desde a release de contas. Seu código e suas chaves access:* ficam intactos para poder voltar como camada de medição sobre as chaves de conta.

No initialize o servidor retorna sua string instructions, que diz ao agente como conduzir o corpus: resolva uma métrica nomeada em palavras com resolve_metric, depois chame get_parameter_data / compare_countries / compute; cada número carrega um citation_id; se o corpus não tiver um número, as ferramentas dizem isso em vez de inventar um.

Ferramentas ​

A exposição são 15 ferramentas de corpus somente-leitura: as 13 da allowlist do toolset do chat, mais 2 exclusivas do MCP (server/mcp/tools.ts) que o chat deliberadamente não tem, get_series (o painel país-ano completo de um indicador; o chat trunca a série aos últimos 6 pontos por orçamento de tokens) e resolve_citations (resolver citation_ids guardados sem sair do MCP para a API de arquivos). As ferramentas de web aberta (web_search, fetch_url) e a paga perplexity_search, apoiada em LLM, nunca são expostas, o endpoint não pode servir de proxy de SSRF, não pode gastar o orçamento de busca externa do deployment, e permanece restrito aos dados citados do Futuros. O design falha fechado nas duas vias: uma ferramenta nova adicionada ao toolset do chat não vaza para o MCP até ser explicitamente incluída na allowlist, e uma ferramenta exclusiva do MCP só existe se estiver definida em MCP_EXTRA_TOOLS.

Toda ferramenta também aceita um parâmetro opcional lang (es | en | pt, padrão es: a língua-fonte do corpus). Ele localiza campos de prosa (narrativas, nomes de pilotos, afirmações); números, ids e citações são independentes de língua. tools/list o anuncia explicitamente: o handler injeta o enum lang em todo input schema de objeto anunciado, para que um cliente possa descobrir que existe saída não-espanhola em vez de ter que sabê-lo; por chamada, lang é lido dos argumentos da ferramenta, e qualquer outro valor cai de volta para es.

As quinze ferramentas:

FerramentaO que retornaParâmetros-chave
resolve_metricMapeia uma métrica nomeada em palavras para um id de indicador governado (do registro de métricas) — candidatos ranqueados com id canônico, unidade, good_direction, pilar, cobertura, safra. Chame-a primeiro quando precisar de um id.query (obrig.), pillar, limit
get_parameter_dataQuadro completo de um pilar em um país: narrativa citada, todos os indicadores (valor/tendência/ano/citation_id), governança, correlações, grau de evidência.parameter (obrig.), country (obrig.)
compare_countriesOs indicadores de um pilar em 2–8 países numa só chamada, cada número citado.parameter (obrig.), countries[] (obrig., 2–8)
get_newsNotícias recentes curadas para um pilar+país; o id de cada item é seu citation_id.parameter (obrig.), country (obrig.), limit
get_regional_pulseAgregado regional LATAM para um pilar: valor, tendência, líderes/retardatários, movimentos, contradições de fontes documentadas.parameter (obrig.)
computeUm cálculo reproduzível, citado às suas células de entrada exatas. Ops: change, cagr, rank, gap_to_frontier, correlation, forecast, explain_change.op+parameter+indicator (obrig.); depois country/countries[]/from_year/to_year/year/order/frontier/method/indicator_b…
get_data_healthQuão frescos/confiáveis/completos os dados estão. Com parameter+country: pontuação de confiança, % de proveniência, safra, anomalias, lacunas de cobertura. Sem args: o resumo regional + a lista de trabalho de atualização.parameter, country (ambos opcionais)
get_discoveriesTraz proativamente as maiores anomalias e contradições de fontes, ranqueadas por magnitude, cada uma citada.parameter, country, limit (todos opcionais)
find_pilotsBusca no portfólio de pilotos bancáveis; retorna slug, pitch, país, maturidade, capex, base de impacto.country, parameter, query, limit (todos opcionais)
get_pilotFicha de design completa de um piloto: economia, evidência de bancabilidade, riscos, financiamento, tomadores de decisão.slug (obrig.)
recommend_actionA cadeia de ação incopiável para um país+pilar: piloto mais adequado → instrumentos de financiamento → tomadores de decisão ranqueados com pedidos sob medida.country (obrig.), pillar (obrig.)
search_corpusBusca léxica BM25 em todo o corpus (personas, pilotos, casos, sinais, financiamento, regulação…); retorna acertos com dataset_id + chaves para buscar via get_dataset. No MCP o braço semântico fica desligado pelo servidor e o campo mode da resposta sempre volta bm25 (ver a seção seguinte).query (obrig.), datasets[], country, parameter, limit
get_datasetAcessador genérico para qualquer dataset catalogado por dataset_id + as chaves daquele dataset.dataset_id (obrig.), country/slug/id/role/layer/status…
get_seriesExclusiva do MCP. O painel país-ano completo de um indicador governado em 1–8 países — cada ponto anual com seu valor, mais fonte, deep-link e citation_id por país. (get_parameter_data retorna só os últimos 6 pontos como series_tail.) Países sem o dado são listados sob missing, nunca preenchidos.parameter+indicator+countries[] (obrig.), from_year, to_year
resolve_citationsExclusiva do MCP. Resolve citation_ids guardados contra o registro global de citações: source, title, url, year, retrieved_at. Ids que o registro global não tem voltam sob unresolved — ou ids sintéticos por-resposta (news-*, pulse-*, civic-*…), que só existem dentro da resposta que os cunhou, ou ids cujo registro viaja inline dentro do payload de um dataset (rechame a ferramenta que retornou o id para receber seu registro).ids[] (obrig., 1–50)

Custo zero por chamada: search_corpus roda em BM25 ​

A regra de design deste endpoint: toda ferramenta alcançável por /api/mcp não pode custar nada além de leituras estáticas do corpus. search_corpus era a única exceção. Seu braço padrão é híbrido e manda a consulta à API de embeddings da Voyage e o pool de candidatos ao reranker dela, ambas medidas e faturadas na chave do Futuros. Como este endpoint costumava ser público e sem autenticação, esse caminho era uma primitiva de amplificação de gasto: texto escolhido por quem chama, cobrado de nós, no ritmo completo de requisições do endpoint. As chaves de conta (ver Chaves de API) agora controlam quem pode chamar o MCP; o pin BM25 permanece para que mesmo um chamador pagante não roteie gasto Voyage por esta superfície.

Por isso o handler fixa search_corpus em seu braço de somente palavras-chave (BM25):

  • O campo mode da resposta sempre vale bm25 no MCP, nunca hybrid-rrf, bm25+rerank nem hybrid-rrf+rerank. semantic_index_drift também não aparece, já que só o braço semântico o produz.
  • O override é aplicado depois dos argumentos do cliente, então mandar keyword_only: false não o desliga. O perfil de custo de /api/mcp é uma decisão do servidor, não algo negociável pelo cliente, e o flag não é anunciado em nenhum inputSchema.
  • O texto de consulta que poderia chegar a uma API medida tem teto de 512 caracteres (MAX_QUERY_CHARS). No MCP esse caminho nem roda: o ranking BM25 é cálculo local sobre os arquivos de índice.
  • Nada real se perde. O BM25 ranqueia os arquivos de índice vivos, então, se muda algo, é que ele fica mais fresco do que os embeddings assados de que o braço semântico depende.

A invariante a preservar ao adicionar ferramentas: qualquer ferramenta com um caminho medido fica fora da allowlist, ou fixada em um braço gratuito que o cliente não consiga desfixar.

A restrição é só do MCP. O chat do Futuros (/api/chat) mantém o caminho completo, híbrido semântico + léxico com rerank de cross-encoder: é uma superfície limitada por IP e globalmente, e é o consumidor pagante previsto.

Semântica de citações ​

tools/call retorna o conteúdo de ferramenta MCP como um único bloco de texto cujo payload é uma string JSON:

json
{ "result": { /* the tool's answer */ }, "citations": [ /* resolved sources */ ] }

isError é true quando uma ferramenta reporta um problema (por exemplo, um indicador desconhecido); o payload é então {"result":{"error":"…"},"citations":[]}: a mensagem de falha viaja dentro de result.error como um erro de nível de ferramenta, não como um objeto error JSON-RPC de nível de protocolo (esses ficam reservados para os códigos -32xxx acima). Cada número dentro de result está vinculado a um citation_id que aparece em citations (ou resolve contra o citations.json público). O contrato de design: nunca exibir um número do Futuros sem sua citação. O MCP não tem nenhuma ferramenta de web ao vivo, então tudo o que uma ferramenta retorna é verificado no corpus.

Exemplo passo a passo ​

Liste as ferramentas, depois resolva uma métrica e busque uma comparação citada.

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) usando o id resolvido

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\":{ …números por país… },\"citations\":[ … ]}" } ],
    "isError": false } }

5 (tools/call (get_series)) o painel país-ano completo

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

Retorna, por país, cada ponto anual {year, value} desde 2010 com a fonte, o deep-link e o citation_id do indicador: a série completa que get_parameter_data trunca a series_tail.

6 (tools/call (resolve_citations)) resolver um id guardado

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: envie um array para executar várias chamadas em uma só ida e volta; a resposta é um array de resultados em ordem. Um batch é limitado a 20 mensagens (MAX_BATCH); um array maior é rejeitado com um único -32600 antes de qualquer ferramenta rodar. Cada mensagem contida cobra uma unidade do orçamento de 60/min, e um throw de ferramenta dentro de um batch produz -32603 só para aquele id: as irmãs ainda respondem.

Para downloads de arquivos planos do mesmo corpus (CSV/SDMX/XLSX), use a API Pública v1.

Cada número com sua fonte — a rastreabilidade é o contrato.