Butecho

PaposPapo cabeça

MCP não é wrapper de API

Como desenhei as tools do servidor MCP da Ternary pra quem vai usar de verdade, um agent com contexto limitado e ninguém pra perguntar.

João · · 15 min de leitura

  • Publicado originalmente em inglês no Substack.

Sem MCP, o único caminho até os seus dados na Ternary é a nossa interface. Com MCP, o agent que você já usa chega lá sozinho. Esse papo é sobre o que deu pra aprender construindo esse caminho.

Por que a gente construiu isso

Contexto rápido: a Ternary faz Technology Investment Intelligence. A gente dá pras empresas a infraestrutura de dados pra governar todo o gasto com tecnologia: cloud, SaaS e IA. Esses dados chegam bagunçados, de vários cloud providers, clusters Kubernetes, gateways de modelos de IA e feeds de cobrança separados. Nosso trabalho é juntar tudo isso pra que o time de finanças consiga entender.

Começamos a perceber que as pessoas queriam fazer perguntas sobre esses dados no Claude, no ChatGPT e em outros agents, onde já tinham suas skills, seus fluxos e as regras da empresa configuradas. Os dados eram delas, mas pra usar nesses agents elas precisavam exportar primeiro. Dava pra construir um agent nosso dentro do produto, só que isso seria mais uma camada pra manter enquanto os modelos mudam. E ainda pediria pro usuário largar o agent em que ele já confia. Então decidimos ir até onde ele já estava.

O MCP (Model Context Protocol) torna isso possível. O cliente conecta com uma URL e o login que já tem, e o agent dele passa a usar as capacidades da Ternary direto. Depois desse setup, os dados aparecem onde a pessoa já trabalha, com as skills e preferências dela no lugar.

Essa primeira parte fala do design das tools. A parte 2 fala de testar e colocar no ar, incluindo uma description em cache que fez o resultado do nosso benchmark parecer regressão de modelo.

O servidor e os agents em volta dele mudam o tempo todo, então partes dessa série podem estar desatualizadas quando você ler. A lista de tools e os guias de conexão ficam na documentação de MCP da Ternary.

MCP em um parágrafo

MCP é um protocolo JSON-RPC entre um cliente de IA e um servidor que você roda. JSON-RPC deixa um programa chamar funções de outro usando mensagens em JSON. Seu servidor publica tools, e cada tool tem um nome, uma description e um schema pros argumentos. O modelo lê as descriptions, escolhe uma tool, preenche os argumentos e lê o resultado. Esse é o fluxo básico de tool calling. Dentro da aplicação de IA que o usuário já tem, existe um cliente pra cada servidor conectado. No MCP, o que importa é o C: Context. O protocolo existe pra levar contexto até um modelo, não só pra expor endpoints.

Não é só um wrapper de API?

Quando você ouve falar de MCP pela primeira vez, a reação comum é: “então é um wrapper de API pra LLM”. Eu fui uma dessas pessoas, e eu estava errado. Mas se você fizer do jeito errado, é exatamente isso que você ganha: um wrapper de API.

A gente fez uma prova de conceito (PoC) rápida pra ver se a ideia do MCP era viável. Era um servicinho separado que recebia uma API key e um tenant ID colados na configuração do cliente. Ele copiava nossos schemas da especificação OpenAPI e repassava as chamadas pra API. Funcionou, e até convenceu gente. Mas em poucos dias apareceu o primeiro problema de verdade: as respostas eram tão grandes que o modelo engasgava no próprio contexto. A PoC mostrou que a ideia podia funcionar, e também o quanto faltava pra alguém usar aquilo como produto.

Pra deixar claro: uma API REST é desenhada pra quem já sabe o que quer.

  • Quem chama sabe qual é o endpoint.
  • Sabe que um GET recebe um ID e que uma busca recebe um body com X parâmetros.
  • A spec OpenAPI descreve o formato de cada request e de cada response.

É um ótimo contrato pra um dev, e agents chamam endpoints REST numa boa. O problema vem antes da chamada. Pega uma pergunta que alguém de finanças faz de verdade:

Por que a nossa conta da Azure subiu no mês passado?

Qual endpoint responde isso? Quais dos setecentos campos de um dataset de cobrança você precisa? Como fica o filtro? O que é “mês passado”? Um dev responde tudo isso antes de escrever o request. O agent tem que descobrir a partir do prompt, na hora, sem nenhum colega de mesa pra perguntar. A OpenAPI até descreve o que uma operação faz, mas gerar tools a partir da nossa spec não deu ao modelo orientação suficiente pra escolher o fluxo certo. A gente também precisava controlar quanto dado voltava.

O agent tem uma janela de contexto limitada, então cada byte que a gente devolve ocupa espaço que ele poderia usar pra outra coisa. Quando o contrato é ambíguo, ele chuta com confiança, e cada chamada extra tem custo. Um primeiro resultado errado pode virar a resposta final. A gente precisava de fluxos que o modelo conseguisse seguir, com respostas que ele interpretasse sem ter que reconstruir o contexto que faltava.

E CLI com skills?

Tem gente que vai te dizer que você nem precisa de servidor MCP. Dá uma ferramenta de linha de comando e uma skill pro agent e pronto. Eu levo isso a sério, porque pra alguns produtos essa pessoa pode estar certa.

Dois termos antes. Uma CLI (command-line interface) é um programa que o agent roda num shell, do mesmo jeito que um dev digitaria:

ternary costs --group-by service

Uma skill é um arquivo de instruções que o agent lê quando precisa. Ela ensina quando e como usar esse programa. Juntas, dão uma capacidade ao agent sem precisar de MCP, e podem sair mais baratas de construir do que um servidor. Os trade-offs, do jeito que a gente pesou:

CLI e skills MCP
Onde roda Na máquina do usuário, precisa de shell e instalação Remoto, o cliente conecta numa URL
Quem consegue usar Agents de terminal: Claude Code, Codex e parecidos Apps de chat, IDEs e agents de terminal
Credenciais Uma API key no disco Login OAuth, incluindo SSO corporativo, nada guardado localmente
Contrato O que o programa imprimir; a skill explica Schemas tipados que o cliente valida antes de chamar
Custo de contexto Skills carregam sob demanda As descriptions das tools vão em todo turno
Corrigir um bug Cada usuário atualiza Corrige no servidor uma vez, todo mundo recebe

A escolha entre MCP e CLI costuma depender de onde seus usuários trabalham. Se eles usam um agent de terminal, uma CLI pode bastar, e carregar skills sob demanda é uma vantagem real. Mas se eles usam um cliente que conecta numa URL e não consegue rodar seu binário, CLI nem é opção. Nossos usuários são times de finanças e FinOps, e eles trabalham no chat. MCP ganhou.

Se eu tivesse que escolher uma abordagem só hoje, ainda começaria com MCP, porque alcança mais agents. E construiria o core de um jeito que uma CLI pudesse reaproveitar depois. A maior parte da lógica serve pros dois. O que muda é como você apresenta isso pro agent, e essa é a parte interessante.

O que você só vê quando começa a construir

Uma tool é um contrato que o modelo precisa ler

Uma tool tem três partes principais: nome, description e schema. A gente prefixa as tools de domínio com ternary_ pra não colidir com tools de outros servidores conectados. O schema diz ao modelo quais argumentos preencher. A description ajuda ele a decidir quando chamar a tool e como usar. Um exemplo simplificado:

defineMcpTool({
  name: "ternary_run_cost_query",
  description:
    "Run a cost/usage query for a tenant. Returns grouped results " +
    "with a server-computed summary. Discover valid fields first " +
    "with ternary_list_dimensions.",
  inputSchema: {
    tenantID: z.uuid().describe("Tenant UUID to query."),
    startTime: isoDateStringSchema
      .optional()
      .describe("Start of the range, interpreted as UTC. Defaults to 30 days ago."),
    endTime: isoDateStringSchema
      .optional()
      .describe("End of the range. A date-only value includes the whole end day."),
    dimensions: z.array(z.string().max(256))
      .max(20)
      .optional()
      .describe("Field names to group by, from ternary_list_dimensions."),
    // Outros argumentos omitidos.
  },
  handler: runCostQuery,
});

O modelo usa a description na hora de decidir o que fazer, então a gente trata ela como código de produção. É fácil deixar essa parte pro final e dar menos atenção do que pro handler, mesmo sendo ela que o modelo precisa pra usar o handler direito.

Cada tool é definida uma vez só, com um schema tipado e uma description pra cada campo. Essa definição única gera tanto o que o modelo vê quanto o que o servidor valida. Isso elimina uma classe inteira de bugs em que o parâmetro documentado e o parâmetro validado discordam. E te obriga a perguntar, enquanto escreve o campo: “o que um modelo precisa saber pra preencher isso aqui?”

Reaproveite os handlers, não as rotas

Você não precisa reescrever o código do produto. Nossas tools de dados reaproveitam a lógica de negócio e as checagens de permissão que o web app usa. Se um usuário não vê um tenant na interface, o agent também não vê pelo MCP. Não existe caminho privilegiado nem um segundo modelo de autorização pra manter sincronizado.

Mas isso não quer dizer criar uma tool por endpoint. Uma tool é um fluxo que o agent precisa, e quem decide quais fluxos expor somos nós. Gerar uma tool por endpoint leva de volta ao wrapper, com os mesmos problemas da PoC. A spec OpenAPI já descrevia esses endpoints.

O cliente é um agent, não um usuário

Quando você aceita que quem lê é um modelo, começa a esbarrar em coisas que normalmente não importam quando você constrói pra um humano. Estas foram as que mais custaram.

Filtros sem ambiguidade

Na nossa API de analytics, um filtro é ou uma condição só, tipo provider = Azure, ou um grupo de condições ligadas por AND ou OR, e esses grupos podem se aninhar. O schema da API diz “um desses dois formatos”, e um dev lê isso sem pensar muito. O modelo tem que descobrir qual formato produzir, e alguns clientes de IA rejeitam um schema do tipo “ou isso ou aquilo” de cara.

Então demos ao filtro da tool um campo kind explícito, que é single ou composite, e uma profundidade máxima. O servidor traduz isso pro formato da API. Assim o schema da API continua otimizado pra devs, e o modelo recebe um formato com que consegue trabalhar.

Descobrir os campos antes de consultar

Um modelo que pula a descoberta pode inventar nome de campo. A gente ensina o fluxo nas instruções do próprio servidor e em cada tool. As instruções que cada cliente recebe ao conectar dizem quase exatamente isto:

  • ternary_list_tenants: escolha o tenant e pegue o ID dele.
  • ternary_list_dimensions com search: "provider": descubra o nome exato do campo nos dados daquele tenant.
  • ternary_get_dimension_values: veja valores de exemplo, como AWS, Azure e Google Cloud, antes de escrever um filtro.
  • ternary_run_cost_query: filtre por Azure, agrupe por serviço e mês e compare os dois últimos meses. Use o summary do servidor em vez de somar as linhas retornadas.

A gente também teve que redesenhar a própria descoberta de campos. A primeira versão devolvia uns 270KB pro catálogo de campos de um único tenant, porque algumas descriptions do padrão de cobrança tinham quatro kilobytes. Manter só a primeira frase e adicionar um orçamento de resposta derrubou isso pra uns 38KB. Adicionar busca fez uma diferença muito maior quando o agent sabia o que estava procurando: uma busca por “region” voltava uns 2KB.

Dar ao agent um jeito de se recuperar

Quando um modelo pede algo que não pode ter, isso não é necessariamente erro do servidor. Pode ser um resultado válido, mas a resposta precisa de um código legível por máquina e de um próximo passo:

{
  "error": "unknown_fields",
  "message": "Unknown fields for FOCUS_BILLING: Service.",
  "recover": "Call ternary_list_dimensions and use exact schemaFieldName values, e.g. FOCUS_SPEC_FIELD/ServiceName."
}

Criamos um erro separado pra tenants sem dados de cobrança. Antes eles recebiam “unknown fields”, e isso mandava os modelos pra um loop redescobrindo campos que nunca iam aparecer. Devolver um erro não bastava. Ele tinha que apontar o agent pra uma direção útil.

Manter a resposta dentro do orçamento de contexto

Uma resposta de custo agrupado, encurtada e sem as linhas de dados:

{
  "data": [ /* 20 linhas omitidas */ ],
  "rowCount": 20,
  "summary": {
    "total": 184203.17,
    "sumReturned": 171509.40,
    "pctOfTotalShown": 93.11,
    "currency": "USD",
    "returned": 20,
    "measure": "FOCUS_SPEC_FIELD/BilledCost"
  },
  "other": {
    "sum": 12693.77,
    "pct": 6.89
  },
  "hasMore": true,
  "nextPageToken": "...",
  "note": "Top 20 groups by FOCUS_SPEC_FIELD/BilledCost = 93.11% of the total. 
          Increase `limit` (max 1000) or pass the returned nextPageToken as 
          `pageToken` to get more."
}

Cada campo está ali por um motivo:

  • As linhas são ordenadas pela medida principal antes de aplicar o limite. Uma série temporal pura fica em ordem cronológica, então o gasto diário volta por data, e não com o dia mais caro primeiro.
  • Dinheiro é arredondado pra centavos na exibição. O servidor calcula o total agregado antes desse arredondamento, então o modelo não precisa reconstruir o total a partir das linhas exibidas.
  • O orçamento de resposta é aplicado antes de calcular a cobertura, então a porcentagem descreve as linhas que o modelo realmente vê.
  • other é o resto calculado a partir do total publicado e da soma retornada, então os números fecham do jeito que aparecem.
  • A note é gerada em toda chamada. Isso fica especialmente útil quando um cliente guardou em cache uma description antiga da tool.

Esses campos do summary nem sempre aparecem. Se o total não está disponível, ou discorda do conjunto completo de linhas além da margem de arredondamento, a gente segura o total e explica por quê. Também deixamos a cobertura de fora quando somar os valores daquela medida não faz sentido, ou quando o total é zero ou negativo. Nas páginas seguintes, omitimos other, porque ele misturaria linhas que o agent já viu com linhas que ele ainda não alcançou.

Tudo isso acontece antes do modelo ver qualquer coisa: a resposta passa por esse pipeline de ordenação, arredondamento, orçamento e cálculo de cobertura no servidor.

Calcular o total separado

A gente tentou inferir o total a partir das linhas. A maior linha sem valor de dimensão tem que ser o total geral, né?

Só que um desconto de compromisso ou um reembolso é uma linha negativa. Uma conta da Azure de US$ 1.000 com um crédito de US$ 500 não se comportava como esperado. Corrigimos esse caso, aí um crédito numa página seguinte quebrou a correção, e depois um grupo sem tag com crédito foi confundido com o total. A gente ficou remendando caso a caso, mas a abordagem em si estava errada.

Agora, consultas agrupadas e com buckets de tempo pegam o total de um agregado separado, sobre o mesmo intervalo e os mesmos filtros. Uma consulta sem agrupamento e sem buckets de tempo já devolve um agregado, então não precisa de segunda consulta.

Ser dono da pagination

Pagination pra um agent tem necessidades diferentes de pagination pra exportação. Nosso motor de analytics usa páginas grandes. Um agent pode precisar de vinte grupos por vez, com um cursor pra chegar na próxima página. Nosso servidor MCP cuida dessa paginação. Os cursores ficam amarrados à entidade e aos filtros do request original, então reaproveitar um token com outra consulta falha, em vez de devolver a página errada.

Dois detalhes importaram. O resultado precisa de uma ordem totalmente definida antes de ser dividido em páginas, senão a paginação por offset pode repetir e pular linhas. E o próximo cursor tem que avançar pelo número de linhas que couberam no orçamento de resposta, não pelo número pedido.

Valor ausente tem que ser explícito

Digamos que você pede custo agrupado por time, mas parte do gasto não tem tag de time. Nossa description explicava que uma linha sem valor pra uma dimensão agrupada era gasto real sem tag. O problema era como a gente devolvia isso: a linha simplesmente não tinha o campo team, em vez de ter team: null.

Quando todas as linhas estavam sem tag, não existia campo team em lugar nenhum. Parecia que a consulta nunca tinha sido agrupada por time. A avaliação mostrou como isso podia ser confundido com agrupamento faltando ou com um campo inexistente.

Agora toda dimensão pedida aparece em toda linha, com null quando não tem valor. A própria resposta mostra o agrupamento, sem depender de uma frase na description da tool.

Próximo passo: descobrir se funciona

Essas escolhas definiram a interface das nossas tools. Pra decidir o que estava pronto pra ir pro ar, a gente também precisava de evidência de agents usando dados reais.

Um resultado deixou isso bem claro: corrigir uma description no servidor não significava que o agent recebia a correção. O connector dele guardava a cópia antiga, e o efeito parecia regressão do modelo. Na parte 2, eu conto essa falha, como avaliamos o servidor e o que foi preciso pra colocar ele no ar.

Se quiser testar, dá pra usar nosso servidor MCP contra um ledger corporativo de exemplo já carregado, em ternary.app.

A saideira

Sai pelo Substack. Dá pra cancelar quando quiser.

Mais papo na mesa

balcãoCtrl K
$ echo "puxa uma cadeira"
puxa uma cadeira
Terminal de verdade: digita help ou usa um atalho.