# ADR-016: Envelope único na resposta de tool e versão por tool

**Status:** Aceito (2026-10-25, TB-22).

## Contexto

A resposta de uma tool built-in no gateway `/mcp` tinha seis formas convivendo
(medição feita antes de mudar nada):

- **sucesso**, três: `jsonContent` (89 chamadas + a `gh` que serve as 80 tools
  do GitHub), `compactJson` duplicado em dois arquivos (4 chamadas:
  `tools/core/context.ts` e `mcp/meta-tools.ts`) e o `renderTool` (62 tools);
- **erro**, três: `errorContent` (27 call sites, texto solto `isError` **sem
  `code`**), `toolFailure` (32 usos em 5 arquivos, já na forma boa `{ ok:false,
error:{…} }`) e o `toolError` do despacho (toda exceção);
- e quatro pontos devolviam `ToolResult` cru, sem helper nenhum.

Um agente que aprende uma troca de idioma no meio da conversa erra a leitura:
a mesma tool podia responder com o dado no topo, com o dado dentro de um JSON
em texto puro, ou com `ok`/`error`. Sem versão por tool, também não havia como
dizer o que mudou: só o `APP_VERSION` global, com a descrição espalhada em ~27
arquivos.

## Decisão

- **Envelope único** (`ToolEnvelope` em `src/mcp/tool-kit.ts`): toda tool
  built-in responde `{ ok: true, data }` ou `{ ok: false, error: { code,
message, details?, retryable?, hint?, errorId? } }`, com
  `meta: { tool, version }`, no `structuredContent` e, idêntico, no bloco de
  texto. `isError: true` quando `ok` é `false`. As imagens continuam PRIMEIRAS
  no `content` (o pedido é "olhe isto").
- **Um só lugar constrói o envelope**: os helpers (`jsonContent`,
  `errorContent` com `code`, `failureContent`) emitem o corpo SEM `meta`, e o
  selo é `sealEnvelope`, chamado pelo despacho (`mcp/tool-call.ts`). O despacho
  é o ponto que passa por TODA resposta — inclusive a exceção capturada ali —
  e por isso mora nele: um selo só nos wrappers deixaria a exceção (o caso mais
  comum) fora do envelope. O selo acrescenta `meta`, re-serializa o texto a
  partir do `structuredContent` (texto ≡ envelope) e preserva os blocos de
  imagem na ordem.
- **Versão é dado declarado**, obrigatório e sem default em `ToolDefinition`,
  `BuiltinToolEntry` e `RenderToolMeta`. É publicada DUAS vezes: no
  `tools/list` como `_meta.version` e em cada resposta como `meta.version`.
  `_meta` e não um campo `version` no topo porque o `ToolSchema` do SDK
  `@modelcontextprotocol/sdk@1.32.1` não tem `version` e o zod dele apaga
  chaves desconhecidas (`dist/esm/types.d.ts:2381-2419`; `_meta` é
  `ZodRecord<z.ZodString, z.ZodUnknown>`, linha 2407, e passa intacto).
  `agentpack_describe_tool` devolve o mesmo descritor, versão inclusive.
- **Guarda do bump**: UM snapshot versionado, `tests/__snapshots__/mcp-tools.json`
  (uma linha por tool), gerado por `npm run test:tool-snapshot` e lido pelos
  DOIS testes que fazem parte do contrato da tool:
  `tests/mcp-tool-versions.test.ts` confere `version` e `contract`;
  `tests/mcp-registry.test.ts` confere `schema`. Cada tool tem duas
  impressões digitais, com as DUAS políticas que existiam separadas antes:
  - `schema` — sha16 do `inputSchema` publicado **sem** as descriptions (a
    forma do input). As descriptions são texto para o modelo, não contrato:
    melhorá-las não exige regravar o snapshot;
  - `contract` — sha16 do descritor publicado **inteiro** (nome, título,
    descrição, inputSchema, annotations — menos `_meta`, que é onde a versão
    vive) somado ao contrato do envelope. É o "o contrato versionado mudou?",
    e a resposta é a que exige subir a versão.

  Antes havia DOIS arquivos com bases de hash OPOSTAS (o snapshot do jest
  excluía a prosa; o das versões a incluía) e DUAS regenerações, sem nada que
  amarrasse os dois. Agora é UM arquivo, UMA regeneração, DUAS perguntas — e
  `tests/mcp-tool-versions.test.ts` reprova o `contract` novo com a versão
  velha, nomeando a tool e os dois hashes.
- **`toolError` sai de `tools/render/common.ts`** para `src/mcp/tool-envelope.ts`:
  o núcleo (despacho e proxy) importava o adaptador de um domínio. O Render
  importa de lá.
- **Tools proxied não são envelopadas** (`mcp/proxy-call.ts` sem mudança de
  lógica): a resposta vem byte a byte do servidor de origem e é validada
  contra o `outputSchema` que ele declarou.
- **Teto de resposta cobre o envelope**: `project_context` (20 KB),
  `task_timeline_list` (64 KB) e o bundle da task (`budgetBytes`) descontam
  `ENVELOPE_RESERVE_BYTES` — a regra é do TEXTO que sai, e o texto agora tem o
  envelope por fora.
- **Quebra de forma no Render**, proposital: o sucesso passou de
  `{ ok: true, ...campos }` para `{ ok: true, data: <campos> }`.

## Consequências

- Os ~228 handlers não mudaram: continuam devolvendo `ToolResult`. O que mudou
  é o invólucro que o despacho põe na saída.
- `errorContent` ganhou `code` opcional e os 27 call sites existentes receberam
  o código do caso (`SKILL_NOT_FOUND`, `PROJECT_NOT_FOUND`,
  `HUMAN_DECISION_REQUIRED`, `VALIDATION_ERROR`, …). Esse código vira o rótulo
  `error_type` da métrica `mcp_tool_duration_seconds`.
- A auditoria passou a ler o `data` do envelope
  (`services/audit.service.ts`), porque o `id`/`projectId` do registro escrito
  agora moram dentro dele.
- Mudou a descrição, o schema, o título ou as annotations de uma tool? O hash
  muda e a versão precisa subir, junto com o snapshot (uma linha).
- **Pendência explícita**: nenhuma das 236 built-in publica `outputSchema`, então
  o envelope ainda não é um contrato que o cliente CONSIGA validar contra um
  schema. Publicar o `outputSchema` do envelope é o passo seguinte, fora desta
  decisão.
- `backend/docs/mcp-server.md` (descrição do gateway) **não está versionado no
  repositório** — não pôde ser atualizado no mesmo PR; as skills seed que
  documentavam o formato antigo foram atualizadas
  (`prisma/seed-skills/agentpack-render-*/…`).

## Revisão de drift — o que só vivia no relatório passa a viver aqui

A correção dos 4 achados de revisão da TB-22 deixou 5 declarações que a
revisão de drift mediu e que SÓ existiam no relatório da execução. O ADR é o
documento do contrato: elas passam a viver aqui, para o próximo executor não
re-litigar o que já foi medido.

1. **A strictness NÃO foi virada, de propósito.** `defineTool` continua
   `z.object(input)` (argumento desconhecido DESCARTADO no parse) e o
   `renderTool` continua `.strict()` (argumento desconhecido RECUSADO). Medição
   feita: ligar `.strict()` no `defineTool` deu **5 falhas / 2755 passando**
   contra **3 / 2757** sem — exatamente **2 call sites**, ambos em
   `tests/task-decision-auth.test.ts`, e o comentário do teste diz que *"os
   campos a mais são ignorados"*. É um **contrato de compatibilidade
   deliberado** (o handler do TODO confirma), não descuido: virar a strictness
   quebra o formato antigo e é **decisão de produto**, fora desta TB.
2. **`budget.usedBytes` mudou de semântica.** Antes media o texto que sai;
   agora mede o `data` (o bundle). O envelope consome ~146 bytes do orçamento
   e o número publicado não mostra. Escolha feita: **o número continua medindo
   o `data`**, porque é ele que o teto do bundle limita — e o teste
   correspondente foi ajustado para a nova semântica. A dívida de mostrar o
   texto (o que o agente recebe) fica aberta.
3. **Os 4 documentos que o `docs:gen` lê não estão no git.** São
   `AGENTS.md`, `README.md`, `CHANGELOG.md` e `backend/docs/mcp-server.md` —
   os outros 11 do `LLMS_DOCS` (`scripts/lib/docs-files.ts`) são versionados,
   verificados nesta sandbox. Nenhum deles é symlink aqui (o relatório
   citava 2; neste checkout não existe). Sem eles o `docs:gen`/`docs:check`
   não rodam num clone limpo: o `readFileSync` estoura ENOENT. É **dívida da
   `main`**, não da TB-22 — mas quem lê este ADR tem de saber que o
   `backend/public/llms{,-full}.txt` (versionado) é GERADO a partir deles.
   Por isso o `backend/docs/mcp-server.md` foi corrigido aqui em memória e
   regenerou o `public/llms-full.txt` versionado: o texto que o agente lê em
   `/llms-full.txt` é o que sai deste arquivo, e ele já estava descrevendo o
   formato anterior ao envelope.
4. **As `description` das tools ainda prometem a forma velha.** Ex.:
   `src/tools/core/knowledge.ts:70` diz "Returns { items, total, page,
   pageSize, pages }" — depois do envelope isso está em **`data.items**, não
   no topo. É o furo por onde o agente real vai errar: ele lê a descrição,
   não o ADR. As 236 descriptions NÃO foram corrigidas aqui — é mudança de
   design (toda tool mudaria de descrição E de hash), obriga bump de versão em
   massa, e é tarefa própria. Fica registrada como dívida conhecida: o que
   sobrou da migração do envelope.
5. **A guarda do bump não cobre o comportamento do selo.** O hash cobre o
   descritor publicado + a constante `TOOL_ENVELOPE_CONTRACT`, então mexer no
   `sealEnvelope` sem tocar a constante não reprova nada. O que segura o selo
   agora é o teste do caminho selado
   (`tests/mcp-envelope-seal.test.ts`, achados 2 e 3): texto ≡
   `structuredContent`, `isError === !ok`, `meta` com nome de wire e versão da
   tool chamada, invariantes do contrato e ordem das imagens — tudo afirmado
   pelo gateway. A guarda segura o DESCRITOR; o teste segura o COMPORTAMENTO.


## Revisão de padronização — arestas que esta mudança congela

A revisão de padrão que fechou sobre a mesma leva mediu 3 pontos que, ou são
decisão (e precisam ficar registrados para não serem re-litigados), ou são
dívida que o trabalho congelou. Nenhum deles altera o formato.

6. **O classificador do núcleo herda a taxonomia de conflito do Render, de
   propósito.** `src/mcp/tool-envelope.ts` (o classificador de exceção de toda
   tool) importa `../lib/render-conflict.js` para montar `retryable`/`hint`
   a partir da `details.reason` (`VERSION_CONFLICT`, `CONCURRENT_WRITE`). Ou
   seja: o núcleo carrega vocabulário do Render. Antes da TB-22 o `toolError`
   morava em `tools/render/common.ts` — dentro do domínio — e a mudança lhe deu
   endereço próprio sem trocar a dependência. **O arquivo NÃO foi movido**
   (fora de escopo). **Nenhum teste assere esse mapeamento de `details.reason`
   para `retryable`/`hint`**: é a única aresta de comportamento do selo que
   cobre sem teste. O teste do caminho selado cobre a FORMA do envelope, não
   esse mapa. Remover a dependência (ou trazer `lib/render-conflict.ts` para
   fora do domínio do Render) é tarefa própria.
7. **`services/` ganhou uma aresta de VALOR para `src/mcp/`.**
   `src/services/task-context.service.ts:43` importa `ENVELOPE_RESERVE_BYTES`
   de `mcp/tool-kit.js`. O motivo é um formato de WIRE (o envelope come bytes
   do orçamento do bundle), não regra de negócio — mas a contagem de §8 item 3
   do `PADRAO-MCP.md` (7 services importando `mcp/`) continua 7 e agora há uma
   aresta de valor a mais para quem medir isso depois.
8. **Dois rótulos para a mesma falha.** `TOOL_ERROR_CODE = "TOOL_ERROR"`
   (`mcp/tool-kit.ts`: o `code` do envelope quando não há código de domínio) e
   `TOOL_ERROR = "tool_error"` (`mcp/instrument.ts`: o rótulo `error_type` da
   métrica para `isError` sem código) convivem em maiúsculas diferentes, e um
   deles vira dimensão da série. **Não foram unificados**: `tool_error` já está
   publicado na série de `mcp_tool_duration_seconds`, e trocar rótulo é mudança
   de histórico de métrica. Fica registrado como dívida.
