Butecho

PaposPapo cabeça

Corrigi o servidor e o agent nem ficou sabendo

Como testamos, observamos e colocamos no ar o servidor MCP da Ternary, e por que uma description em cache parecia regressão de modelo.

João · · 18 min de leitura

  • Publicado originalmente em inglês no Substack.

Na Ternary, a gente construiu um servidor MCP (Model Context Protocol) pra que os clientes trabalhem com os dados de gasto em tecnologia pelos próprios agents.

A parte 1 conta como desenhamos as tools: descoberta, limites de resposta, totais, pagination e dado ambíguo. No fim daquele trabalho, a gente tinha uma interface de tools que um agent conseguia seguir. Faltava descobrir se os agents iam usar direito e o que seria preciso pra rodar o servidor depois do lançamento.

Esta parte cobre o que veio depois: teste ao vivo, evals, observabilidade, deploy e autenticação. A primeira surpresa veio quando a gente corrigiu uma description no servidor e o agent continuou lendo a antiga.

O servidor e os clientes mudam o tempo todo. Se você for configurar isso mais pra frente, a lista de tools e os guias de conexão estão na documentação de MCP da Ternary.

Quando a correção no servidor não chega no agent

Alguns clientes com que trabalhamos guardavam em cache os nomes, descriptions e parâmetros das tools. Um cliente ao vivo, tipo uma sessão de IDE, conseguia atualizar ao reconectar. Já integrações no estilo connector, como o ChatGPT, tiravam um snapshot na hora em que o usuário adicionava o servidor nas configurações.

Um exemplo. Uma rodada do benchmark expôs uma fronteira de data mal explicada. Pra consultar o gasto de junho, um agent usou 1º de julho como data final, assumindo que esse dia ficaria de fora. Nosso servidor incluía o dia final inteiro, então a consulta contava um dia a mais de gasto. A description da tool não explicava isso, e rastreamos 33 casos falhando até essa lacuna.

Atualizamos a description pra explicar a regra e mostrar 30 de junho como a data final correta. Aí descobrimos que o connector do ChatGPT ainda servia a versão antiga. Fazer a correção chegar nos clientes afetados mexeu as notas deles em uns 50 pontos. Até a gente entender o cache, aquilo parecia mudança no modelo.

Uma notificação do servidor consegue avisar um agent conectado que ele precisa atualizar as tools. Mas ela não atualiza o snapshot guardado pela plataforma do connector.

A gente lida com isso dos dois lados. Nosso guia de setup pro ChatGPT pede pro usuário atualizar o plugin da Ternary quando tools são adicionadas ou mudam. Atualizar o connector e abrir um chat novo faz o ChatGPT descobrir as tools e definições novas. Mas também temos que contar com quem ainda não atualizou. As descriptions antigas dessas pessoas continuam chamando nosso servidor atual, então dá pra devolver orientação atualizada junto com o resultado e impor restrições no handler. Isso virou quatro regras:

  • Coloque a orientação de que o agent depende numa note da resposta, gerada em toda chamada. Só a description não basta.
  • Imponha o comportamento no handler, em vez de estreitar o schema. O cliente pode cachear o schema, mas o handler roda o nosso código atual.
  • Mudança de schema é sempre aditiva, com campo novo opcional. Uma breaking change ganha um nome de tool novo, ao lado do antigo.
  • Converta inputs numéricos quando um cliente desatualizado mandar como string.

Por exemplo, quando as datas são omitidas, a resposta da consulta de custo explica que usamos os últimos 30 dias e como manter essa escolha ao salvar um relatório. O agent recebe essa orientação junto com o resultado, mesmo com a description velha. Isso ajuda depois de uma chamada, mas não faz uma tool nova aparecer numa lista em cache.

Também adicionamos uma versão do servidor específica de cada deploy e uma checagem que compara as definições em cache que a gente consegue inspecionar com o que o servidor está servindo agora. Isso ajuda a pegar definição desatualizada durante os testes, antes de culpar o modelo pelo efeito.

Como eu sei que isso funciona?

Nesse ponto, as tools existem e os testes unitários estão verdes, mas você ainda tem um servidor que ninguém usou. A gente leva precisão a sério. Um valor errado em dólar na frente de um time de finanças custa uma confiança difícil de recuperar. Então, antes de lançar, a gente avaliou.

A primeira rodada de teste ao vivo contra um tenant real achou problemas que os testes contra fakes não tinham pego:

  • Toda consulta de custo falhava, porque o intervalo de datas padrão gerava strings onde o motor precisava de datas.
  • A amostragem de valores não devolvia nada pra campos comuns.
  • O catálogo de campos tinha 270KB, uma quantidade enorme de tokens pra janela de contexto.
  • Uma linha de total causava um erro off-by-one.
  • As respostas traziam floats com precisão total, tipo 24316.679554000006.
  • As listas de relatórios não tinham limite de tamanho.
  • Duas tools pulavam a checagem de permissão, deixando um buraco no isolamento entre tenants.

Aí montamos um benchmark com dois tipos de teste. Eles respondem perguntas diferentes, e juntar os dois numa nota só esconderia isso. A conformidade verifica o contrato do servidor. A avaliação do agent verifica se um modelo consegue chegar na resposta certa através dele.

O servidor cumpre o contrato?

A metade determinística verifica conformidade. Pros dados de consulta, a gente manda requests equivalentes pelo MCP e direto pra nossa API de analytics, e compara os resultados. Não guardamos valores fixos esperados em dólar, então a comparação continua funcionando conforme os dados mudam. Outras checagens cobrem autenticação, isolamento entre tenants, descoberta e pagination.

O harness repete cada caso determinístico três vezes e registra latência e tamanho completo da resposta no caminho. Cada tool tem seus próprios tetos. Essas checagens rodam sem supervisão e precisam passar antes do lançamento.

O harness também tem uma implementação separada das regras de formatação de resposta. Se a gente cometer o mesmo erro nas duas implementações, o teste ainda pode passar. Os dois caminhos de dados também usam o mesmo motor de analytics, então isso verifica a camada MCP sem verificar de forma independente os dados de cobrança originais.

O agent chega na resposta?

Não dá pra tornar um modelo determinístico, mas dá pra antecipar as perguntas que os usuários vão fazer e montar casos em volta dos jeitos que um agent pode errar.

  • Cada caso pontuado tem uma armadilha, tipo uma premissa falsa, um denominador zero, um total que o servidor segurou ou uma região com nome parecido com o de outro provider.
  • Repetimos checagens básicas de fluxo, chamadas de canaries. Uma checagem que falha em todas as tentativas bloqueia a qualificação. Uma falha intermitente precisa de uma decisão registrada antes da rodada valer.
  • As respostas recebem nota de 0 a 2 em quatro critérios: correção, completude, eficiência no uso das tools e honestidade. Ser breve não ganha ponto por si só.
  • Todo desconto ganha uma causa raiz: mau uso pelo agent, lacuna na description, defeito no servidor, defeito no produto, drift da suíte, falta de dado ou caso ambíguo.

O avaliador recebe a instrução de não assumir que a culpa é do agent. Se o contrato que ele conseguia ver levou ele pro caminho errado, isso é um achado sobre o nosso produto.

Description de tool é código de produção

A description de uma tool afeta o que o agent faz. Mude uma frase ou renomeie um parâmetro e ele pode escolher outra tool, mesmo com o handler igual. Por isso a gente testa e versiona descriptions junto com o código.

No começo de uma rodada, o harness registra um hash do catálogo completo de tools do servidor, incluindo nomes, descriptions e input schemas. Esse hash identifica a versão em teste. Se o catálogo mudar, os resultados anteriores dos agents não servem mais pra qualificar. A gente também faz hash das suítes de teste, então remover casos exige uma atualização explícita do perfil de release.

A falha da fronteira de data mostrou por que isso era necessário. A data final inclusiva mal explicada causou 33 descontos. Classificamos como lacunas de description, corrigimos o contrato e rodamos de novo os casos afetados. Depois tivemos que lidar com as descriptions em cache: alguns clientes ainda mostravam a versão antiga pro agent depois do deploy da correção.

Um hash do catálogo não pega toda mudança. Valores ou notes da resposta podem mudar a resposta do agent enquanto as definições das tools continuam iguais. A gente registra a revisão em produção, mas um deploy sem relação não invalida uma rodada. Pra uma mudança de resposta, quem está preparando o release verifica se ela pode afetar uma resposta pontuada. Se puder, rodamos os casos de agent de novo.

Como o benchmark virou parte do desenvolvimento

O benchmark agora segue o mesmo ciclo pra uma tool nova, uma mudança de description ou uma correção no contrato de resposta:

  • Definir o que a tool promete, os limites de resposta e de latência, e as restrições que estamos aceitando.
  • Adicionar cobertura determinística pro comportamento que dá pra afirmar com exatidão. Isso inclui permissões, paginação, recusas e idas e voltas entre tools relacionadas.
  • Adicionar casos de agent pras decisões que um teste unitário não consegue tomar. Casos pontuados têm um caminho errado plausível. Canaries cobrem fluxos básicos que precisam continuar funcionando.
  • Classificar toda falha antes de mudar qualquer coisa. Defeito no servidor, lacuna na description, limitação do produto, problema de dado e erro do agent têm donos diferentes e correções diferentes.
  • Manter o menor caso que pegaria a falha de novo, e então qualificar o release contra o contrato fixado.

Mudanças em tools e em descriptions passam pelas mesmas checagens. Falhas viram correções e casos de regressão.

Quando adicionamos ternary_create_report, criamos testes pro fluxo completo de salvar. O caso determinístico cria um relatório, verifica uma escrita negada e lê de volta a configuração salva. Um canary de agent pede pra salvar e confere se o agent escreve exatamente uma vez e devolve o ID e o link. Outro pede pra salvar o mesmo relatório duas vezes. A gente espera uma escrita só e uma explicação de que o relatório já existe.

A avaliação aberta também mostrou onde mudar as próprias tools. Expressões como “semana passada” deixavam os agents calculando as próprias fronteiras de data. Adicionamos presets de data com nome, que o servidor resolve, junto com uma checagem determinística da janela, um caso pontuado e um canary pra testar como os agents usam isso.

Os testes do próprio benchmark verificam validação de contrato, hashes de suíte e regras de release no nosso CI normal. Uma qualificação completa ao vivo precisa também de credenciais de staging, sessões novas de agent e avaliação. Isso a gente roda separado, quando está preparando um release.

Fazendo o benchmark merecer confiança

Fazer uma rodada merecer confiança foi um projeto à parte. A gente mede de novo as respostas esperadas a cada execução, seguindo uma receita definida, porque até um mês fechado pode mudar quando dados de cobrança chegam atrasados. Dois runners (dois modelos de LLM diferentes, por exemplo) recebem as mesmas perguntas, a mesma data de referência e o mesmo contexto permitido, divididos em grupos chamados shards. Os avaliadores veem resultados anônimos.

A gente também confere os transcripts, e se um shard não tem transcript, a rodada é invalidada. Analisando transcripts, jogamos fora uma execução inteira porque o setup de teste tinha entregado as respostas esperadas pra todos os shards, sem que esse acesso aparecesse nas tool calls registradas. Benchmark é software, e tem bug como qualquer outro.

A qualificação tem seis portões independentes, e um não compensa o outro:

  • Cobertura: toda tool anunciada tem casos.
  • Proveniência: a execução usa um commit limpo, identificado pelo hash.
  • Suíte fixada: um hash registra os arquivos de caso, então a cobertura não encolhe sem uma atualização explícita.
  • Conformidade: todo caso passa três vezes.
  • Performance: cada tool respeita seus limites de latência e de tamanho de resposta.
  • Evidência dos agents: todo resultado despachado e todo transcript estão presentes, os canaries estão contabilizados e nenhum resultado aceito contém defeito de servidor ou lacuna de description.

A nota agregada dos agents serve pra investigar uma execução. Depois a gente inspeciona as respostas e as tool calls pra entender o que falhou, corrige a causa e roda de novo os casos afetados. Comparar execuções exige os mesmos casos e condições comparáveis, pra saber se uma mudança ajudou de verdade.

O que o benchmark deixou passar

A suíte cresce junto com o produto. Expandimos quando a v2 trouxe budgets, dashboards, comparação de dashboards, recomendações e criação de relatórios. Cada feature adicionou cobertura determinística e de agent ao perfil de release.

Mais tarde, um colega testou o MCP usando o Cursor com o Grok, fazendo 33 perguntas abertas fora da nossa suíte. Isso achou problemas de escopo e ambiguidade que a nossa suíte, mais focada em aritmética, não estava pegando. Fizemos a triagem e fizemos merge de um lote de correções no contrato de resposta na mesma semana. Alguns achados foram pro roadmap, alguns precisavam de decisão de produto, e outros a gente rejeitou. Resumindo:

Eval é parte do trabalho de produto. Continue testando com perguntas abertas, observe como os agents usam suas tools e transforme falha inesperada em caso novo do benchmark.

Saber o que acontece depois do lançamento

Até um servidor só de leitura precisa responder “quem leu o quê, e quando?”. Cada chamada que chega no handler de uma tool escreve um registro de auditoria estruturado com quem chamou, a tool, o resultado e a duração. Depois de uma chamada bem-sucedida, ele também registra os IDs do tenant e dos recursos envolvidos. Requests rejeitados antes do handler, como argumentos inválidos barrados pelo schema, não passam por esse mesmo registro. Recusas recuperáveis são warnings. Falhas inesperadas no handler são erros e vão pro Sentry, e o monitoramento alerta em falhas repetidas. Ninguém do plantão deveria ser acionado porque um modelo chutou um nome de campo.

A lacuna que achamos depois foi a intenção. A gente via qual tool rodou, mas o que o usuário estava tentando fazer? Então adicionamos um argumento opcional context em toda tool, onde o agent pode dizer qual é o objetivo. Guardamos esse texto do jeito que vem, com limite de tamanho. Isso dá um jeito de entender o que as pessoas tentam fazer com os dados delas, e quando as tools não dão conta, esses pedidos ajudam a decidir o que construir em seguida. Também fizemos uma tool pequena de feedback, pra que o agent reporte uma capacidade que precisou e não achou. Essa tool fica disponível onde o tracing está ligado, também.

Como colocar no ar

Com as tools funcionando, você ainda tem que decidir onde o servidor roda e como os clientes conectam e fazem login. Essas escolhas dependem do seu produto. Pra gente, manter o deploy simples importava, e o protocolo depois foi na mesma direção de duas das escolhas que fizemos.

Onde roda?

Começamos com um único endpoint /mcp na API que já existia, mantendo o código de MCP atrás de uma fronteira pequena. Isso deixou a gente reaproveitar a infraestrutura de autenticação e de acesso a dados sem fazer deploy de outro serviço. Marcamos os logs de MCP com um label de componente, pra ver quanto tráfego e trabalho vinha dessas chamadas. Um serviço separado pode vir depois, se esse tráfego precisar escalar diferente ou começar a afetar o resto da API.

Qual transport?

O MCP tem dois transports:

stdio Streamable HTTP
Roda Como processo local na máquina do usuário Como servidor remoto
Credenciais Na máquina do usuário Nunca saem da sua infraestrutura
Alcance Só o cliente que iniciou o processo Qualquer app de chat, IDE ou agent com uma URL
Correções Cada usuário atualiza Um deploy

Pra um produto multi-tenant, HTTP remoto é a única escolha que faz sentido. A gente também deixou cada request independente: qualquer instância do servidor pode responder, sem prender o cliente numa instância nem manter um store compartilhado de sessões MCP. Isso combinava com o jeito que a nossa API já roda.

A atualização da especificação do MCP de 28 de julho de 2026 levou essa ideia mais longe, tornando o core do protocolo stateless. Ela removeu o handshake de inicialização e os session IDs do protocolo, e cada request passou a carregar a informação necessária pra ser tratado. Isso reforçou nossa escolha de evitar sessões no transport.

Como os agents fazem login?

A maioria das surpresas aqui veio do identity provider. Se você for conectar um agent, confira a lista de agents suportados e as instruções de setup, porque o suporte dos clientes e a configuração mudam.

O servidor é um OAuth protected resource. Ele valida tokens e diz onde fazer login, e o identity provider faz o resto. Se um request chega sem autenticação, o servidor devolve um 401 apontando pra um documento de metadados. O cliente usa esse documento pra descobrir o authorization server, leva o usuário pelo login e volta com um token destinado à URL desse servidor. A audience do token, que identifica pra qual recurso ele foi emitido, tem que bater com a URL exata do servidor naquele ambiente, porque os clientes comparam as duas.

O authorization server também precisa reconhecer o cliente que está pedindo acesso. Uma abordagem do OAuth é o Dynamic Client Registration (DCR), em que os clientes se registram sozinhos. Com Client ID Metadata Documents (CIMD), o client ID é a URL HTTPS de um documento JSON que descreve aquele cliente, normalmente hospedado pelo fornecedor dele.

Dynamic Client Registration Client ID Metadata Documents
Quem pode se registrar Qualquer um que ache o endpoint Só as URLs que você coloca na allowlist
Onboarding de um cliente Zero-touch Uma entrada na allowlist mais os grants
Risco Registro aberto contra dados de clientes Lista limitada e revisada
Status na spec (2026-07) Deprecated Recomendado

Escolhemos CIMD e deixamos o DCR aberto desligado. No nosso setup, um cliente entra quando colocamos a URL de metadados dele na allowlist e damos acesso aos ambientes de que ele precisa. Já usávamos essa abordagem quando a mesma revisão de 28 de julho deprecou o DCR em favor do CIMD. Essa atualização reforçou tanto a nossa escolha de registro de cliente quanto a decisão de tratar requests HTTP sem sessão no transport.

Se você usa Auth0, estas foram três coisas em que a gente esbarrou:

  • Registrar é só metade do trabalho. Um cliente de terceiro também precisa de um grant explícito por audience. Sem isso, descoberta, login e deploy parecem saudáveis enquanto a emissão do token falha. Temos audiences separadas pros ambientes EU, US e staging, então todo cliente novo precisa de um grant pra cada uma.
  • Clientes de terceiros só usam connections marcadas como domain-level. Nosso web app é first-party, então as connections de SSO corporativo que tínhamos criado ficavam invisíveis pro Claude e pro ChatGPT. Pra deixar esses clientes usarem as suas connections de SSO existentes, você precisa marcar as connections como domain-level.
  • Alguns agents não conseguem publicar um documento de metadados próprio. Cursor, Cline, Gemini CLI, Kiro e OpenCode deixam o usuário colar um client ID, mas não hospedam esse documento. A gente hospeda um pra eles numa URL do nosso domínio. Isso funciona porque o Auth0 aceita qualquer porta ao comparar redirects de loopback pra documentos de metadados, e dois desses agents precisam disso. Pras callback URLs de uma aplicação estática, ele exige match exato.

Onde isso deixa a gente

Quase toda tool hoje é só de leitura. Começamos assim pra manter a autorização simples e chegar rápido em produção, e acho que é um bom ponto de partida pra um produto de dados. Mas a gente não vai parar aí. A v2 já está em andamento, e desde então adicionamos recomendações de economia e uma tool que salva uma consulta como relatório privado. Nosso time anda muito, muito rápido, então parte disso pode já estar desatualizada quando você ler. Dá pra acompanhar as tools disponíveis na documentação.

No momento em que escrevi, o mapa era este: leitura (consultas, budgets, dashboards) e recomendações (recomendações de economia, alertas de custo) prontas; comparação parcial, com comparação de dashboards agora e comparações avulsas em seguida; escrita parcial, com salvar relatórios agora e mais tipos de recurso em seguida. O que falta inclui:

  • Mais domínios de leitura: comparação de períodos sem precisar de dashboard, presets de data etc.
  • Mais caminhos de escrita: criar recursos na Ternary direto do seu agent, com cada ação sujeita a revisão.
  • Skills: skills escritas pela Ternary que o cliente baixa pro agent dele, pra que um fluxo recorrente, tipo a revisão mensal, rode em cima do MCP sem ninguém precisar ensinar o agent de novo. As planejadas cobrem consulta de dados, budgets, dashboards, comparações, anomalias e criação de recursos.
  • Distribuição: listagens oficiais de connector, pra conectar ser um clique em vez de colar uma URL.

Sempre vai ter mais coisa pra fazer, porque os modelos, os clientes e a especificação do MCP continuam mudando. Mas o motivo pelo qual a gente começou não mudou: as pessoas já têm agents que querem usar, e querem acesso aos dados delas ali. Não necessariamente querem mais uma janela de chat dentro do nosso produto.

Construir isso me ensinou o quanto o agent depende dos dados que a gente entrega. O formato da resposta, um contrato com comportamento previsível e handlers que impõem as regras em tempo de execução importam tanto quanto a capacidade do modelo. Se a sua base de dados não tem nível de finanças, o agent mais esperto do mundo ainda vai alucinar em cima do seu gasto.

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.