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-corpusv1.0.0: "Futuros — LATAM governed-data corpus" - Auth: cada
POSTexige uma chave de API de conta (Authorization: Bearer ft_live_…ouX-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 semDATABASE_URL) continua anônimo. Os dados abertos estáticos (/api/v1) também exigem chave de conta, ver Autenticação. - Gate de deploy:
MCP_ENABLEDdeve valer"true". Falha fechado: sem ela, cadaPOSTdevolve403 {"error":"mcp_disabled"}: um deploy novo ou um.envvazio não expõe o endpoint por acidente.GET ?health=1continua respondendo e reporta o estado em seu campoenabled. - 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,
413com-32700. Um lote legítimo deMAX_BATCHmensagens 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
503em vez de degradar para um teto por instância. Tanto o429(acima da cota) quanto o503(limitador indisponível) carregamRetry-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/mcp | Comportamento |
|---|---|
POST | JSON-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). |
OPTIONS | Preflight CORS → 204. |
GET ?health=1 | 200 — { 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 recebida | Tratamento |
|---|---|
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 throw | processada, 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 dex-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
-32600barato 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 oRetry-Afterdaquela cobrança; as unidades já cobradas permanecem consumidas. - Backend: uma única viagem pipelined de
INCR+EXPIRE … NX+PTTLao Upstash/Vercel-KV quandoUPSTASH_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/mcpcobra comfailClosed: 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 é HTTP503com-32000e a mensagem "rate limiting is temporarily unavailable; retry shortly", maisRetry-After: 30(o resfriamento do disjuntor). É uma recusa deliberadamente distinta do429de cota estourada:429significa que quem chama realmente passou da sua cota,503significa 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=1devolve 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 defineRATE_LIMIT_REQUIRE_STORE=1para tornar o store ausente uma falta dura também (só num deploy gerenciado:VERCEL_ENV=developmente 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
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):
{
"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:
{
"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:
{
"mcpServers": {
"futuros": {
"command": "npx",
"args": [
"-y", "mcp-remote", "https://futuros.xyz/api/mcp",
"--header", "Authorization: Bearer $FUTUROS_API_KEY"
]
}
}
}Chaves de API
| Superfície | Credencial | Onde 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/mcp | Chave de conta | /cuenta/claves |
POST /api/chat a partir de scripts / outras origens | Chave 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:
| Ferramenta | O que retorna | Parâmetros-chave |
|---|---|---|
resolve_metric | Mapeia 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_data | Quadro 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_countries | Os indicadores de um pilar em 2–8 países numa só chamada, cada número citado. | parameter (obrig.), countries[] (obrig., 2–8) |
get_news | Notícias recentes curadas para um pilar+país; o id de cada item é seu citation_id. | parameter (obrig.), country (obrig.), limit |
get_regional_pulse | Agregado regional LATAM para um pilar: valor, tendência, líderes/retardatários, movimentos, contradições de fontes documentadas. | parameter (obrig.) |
compute | Um 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_health | Quã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_discoveries | Traz proativamente as maiores anomalias e contradições de fontes, ranqueadas por magnitude, cada uma citada. | parameter, country, limit (todos opcionais) |
find_pilots | Busca no portfólio de pilotos bancáveis; retorna slug, pitch, país, maturidade, capex, base de impacto. | country, parameter, query, limit (todos opcionais) |
get_pilot | Ficha de design completa de um piloto: economia, evidência de bancabilidade, riscos, financiamento, tomadores de decisão. | slug (obrig.) |
recommend_action | A 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_corpus | Busca 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_dataset | Acessador genérico para qualquer dataset catalogado por dataset_id + as chaves daquele dataset. | dataset_id (obrig.), country/slug/id/role/layer/status… |
get_series | Exclusiva 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_citations | Exclusiva 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
modeda resposta sempre valebm25no MCP, nuncahybrid-rrf,bm25+reranknemhybrid-rrf+rerank.semantic_index_drifttambé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: falsenã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 nenhuminputSchema. - 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:
{ "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
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) usando o id resolvido
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\":{ …números por país… },\"citations\":[ … ]}" } ],
"isError": false } }5 (tools/call (get_series)) o painel país-ano completo
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
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.