CodeGym /Cursos /ChatGPT Apps /Descrição de ferramentas: JSON Schema, tipagem, anotações...

Descrição de ferramentas: JSON Schema, tipagem, anotações

ChatGPT Apps
Nível 4 , Lição 1
Disponível

1. Ferramenta como contrato: o que exatamente estamos descrevendo

Quando você registra uma ferramenta no servidor MCP, você a descreve com um pequeno objeto. A estrutura simplificada para o SDK de TypeScript é assim:

server.registerTool(
  "suggest_gifts",
  {
    title: "Suggest gifts",
    description: "Seleciona presentes com base no perfil do destinatário.",
    inputSchema: {
      type: "object",
      // é aqui que vamos nos aprofundar agora
    },
  },
  async ({ input }) => {
    // seu código
  }
);

O modelo não sabe o que há dentro do handler async ({ input }) => { ... }. Para ele, existem apenas três coisas:

  1. name/title — como a ferramenta se chama.
  2. description — quando é apropriado usá-la.
  3. inputSchema — quais argumentos devem ser passados e em que formato.

Tudo o que fazemos nesta aula se refere ao item 3 (e um pouco aos metadados _meta/annotations, sobre os quais falaremos depois).

É importante entender: o JSON Schema no contexto do ChatGPT App não é apenas um validador sem graça, mas parte do prompt para o modelo. O modelo de fato lê o description dos campos, entende o que é enum, percebe minItems, format etc.

Ou seja, você não está apenas protegendo o backend de dados ruins — você está explicando ao modelo de IA como chamar sua função corretamente.

2. JSON Schema básico para a ferramenta suggest_gifts

Vamos começar pelo simples. Digamos que temos este cenário:

O usuário escreve:
“Escolha um presente para o irmão de 25 anos, orçamento de 50–70 dólares, gosta de videogames e jogos de tabuleiro.”

A ferramenta suggest_gifts deve receber aproximadamente estes argumentos:

  • idade do destinatário;
  • tipo de relacionamento (irmão, colega, parceiro etc.);
  • orçamento mínimo e máximo;
  • lista de interesses.

Vamos descrever isso como um JSON Schema “direto”, sem Zod, em um objeto puro:

const suggestGiftsInputSchema = {
  type: "object",
  properties: {
    age: {
      type: "integer",
      minimum: 0,
      maximum: 120,
      description: "Idade do destinatário do presente em anos.",
    },
    relationship: {
      type: "string",
      enum: ["friend", "partner", "sibling", "colleague", "parent"],
      description:
        "Tipo de relacionamento com o destinatário: friend, partner, sibling (irmão/irmã), colleague, parent.",
    },
    minBudget: {
      type: "number",
      minimum: 0,
      description: "Orçamento mínimo na moeda do usuário.",
    },
    maxBudget: {
      type: "number",
      minimum: 0,
      description: "Orçamento máximo na moeda do usuário.",
    },
    interests: {
      type: "array",
      items: {
        type: "string",
        description:
          "Nome curto do interesse, por exemplo: videogames, boardgames, books.",
      },
      minItems: 1,
      description: "Lista de interesses do destinatário.",
    },
  },
  required: ["relationship", "maxBudget"],
};

Alguns pontos importantes que valem ser mencionados desde já.

Em primeiro lugar, o description dos campos. Em um API comum você poderia até não escrevê-los — o desenvolvedor frontend leria o Swagger e entenderia. Mas aqui o “cliente” é o modelo, que tenta inferir o significado a partir do nome e da descrição. Quanto mais claramente você disser: “idade em anos”, “orçamento na moeda do usuário”, “enum com valores fixos”, menos argumentos estranhos você verá em tempo de execução.

Em segundo lugar, enum é uma das ferramentas mais poderosas para guiar o modelo. Se você permitir ao modelo escrever qualquer string em relationship, você receberá “bro”, “girlfriend”, “bestie”, “teammate” e algo ainda mais criativo. Se você definir um enum, o modelo, com altíssima probabilidade, escolherá apenas entre esses valores. Isso reduz diretamente a quantidade de “alucinações” nos argumentos.

Em terceiro lugar, nem tudo precisa ser required. Por exemplo, age pode ser opcional: se o usuário não informar, o modelo não irá “inventar uma idade aproximada” do nada (se você formular a descrição dessa forma). Aqui começa a arte: o equilíbrio entre flexibilidade e rigor.

Agora usemos esse schema no registro da ferramenta:

server.registerTool(
  "suggest_gifts",
  {
    title: "Suggest gifts",
    description:
      "Seleciona ideias de presentes com base no orçamento, tipo de relacionamento e interesses do destinatário.",
    inputSchema: suggestGiftsInputSchema,
  },
  async ({ input }) => {
    // aqui o input já corresponde aproximadamente ao schema
    // ...
  }
);

Esse objeto “manual” é ótimo para experimentos rápidos. Mas, à medida que o aplicativo cresce, ele vira um mundo à parte que pode facilmente se desalinhar dos seus tipos TypeScript. Voltaremos a esse problema em breve e veremos como resolvê-lo com Zod e geração de JSON Schema a partir dos tipos.

3. JSON Schema como prompt: como escrever o description para o modelo não sofrer

Formalmente, JSON Schema é sobre validação. Informalmente, no mundo das LLMs, é também um prompt estruturado. Algumas regras práticas:

  1. O campo description deve responder à pergunta “o que colocar aqui e em qual formato”.
    A formulação “Data” não ajuda. “Data em ISO 8601 no formato YYYY-MM-DD, por exemplo "2025-02-14"” — ajuda muito.
  2. Se o campo está relacionado a dinheiro — especifique as unidades.
    É melhor escrever explicitamente “Valor na moeda do usuário” ou “Valor em dólares americanos”. Caso contrário, o modelo pode honestamente escrever 50 e você ficará na dúvida se são 50 ienes ou 50 euros.
  3. “Categorias” em strings quase sempre ficam melhores com enum.
    Se o campo é uma string com “categoria”, é melhor fazer um enum e descrever cada valor no description da ferramenta. Por exemplo, para relationship, você pode escrever na descrição da ferramenta: “relationship: um dentre friend (amigo), partner (parceiro romântico), sibling (irmão ou irmã), colleague (colega de trabalho), parent (pai/mãe). Não invente outros valores.”
  4. Para arrays, é útil definir minItems e explicar o que é essa lista.
    Se o campo é um array, é útil indicar minItems e explicar brevemente o que exatamente é essa lista. Por exemplo, interests não é “uma descrição em prosa da pessoa”, mas “um conjunto de tags curtas”.

Tudo isso parece meio chato, mas, na prática, a diferença entre “há descrições” e “não há descrições” é a diferença entre um aplicativo estável e a eterna loteria “o que o modelo vai mandar hoje”.

Insight

As ferramentas MCP têm limites rígidos de tamanho — e são eles que, na maioria das vezes, causam falhas “místicas”, erros estranhos e o fato de o assistente de repente parar de enxergar seus tools.

A regra principal é simples: a ferramenta deve caber em ~4 KB de JSON por completo. Isso não é só o texto do description, mas toda a estrutura:

  • descrição da ferramenta,
  • schema dos argumentos (inputSchema),
  • objetos aninhados e enum,
  • _meta e anotações.

Se a sua ferramenta cresce demais, a plataforma começa a se comportar de forma imprevisível: surgem erros como "Tool description is too long", "Schema validation failed", "Manifest exceeds size limits", e às vezes o ChatGPT simplesmente deixa de carregar a ferramenta ou “esquece” que ela existe.

Recomendação: mantenha o description entre 10002000 caracteres, e a ferramenta inteira dentro de ~4 KB “seguros”. Se a descrição ficar longa demais, isso quase sempre é sinal de que a ferramenta faz coisas demais ao mesmo tempo. Ferramentas separadas devem ser focadas e bem definidas — assim o modelo entende melhor seus limites e erra menos nos dados de entrada.

4. TypeScript e Zod: uma única fonte de verdade em vez de duas

Escrever JSON Schema manualmente é doloroso para quem desenvolve com TypeScript. É preciso manter dois mundos paralelos:

  • os tipos no código TS;
  • o JSON Schema para o modelo.

Com o crescimento do app, eles começam a divergir. Hoje você muda um campo no tipo TypeScript, amanhã esquece de atualizar o schema — e, na semana seguinte, toma um tombo em produção.

O approach padrão de fato no mundo TS é usar Zod e converter de Zod -> JSON Schema.

Instale as dependências (se ainda não tiver):

npm install zod zod-to-json-schema

Descrevamos o schema de entrada para suggest_gifts com Zod:

import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";

const SuggestGiftsInputZod = z.object({
  age: z
    .number()
    .int()
    .min(0)
    .max(120)
    .describe("Idade do destinatário do presente em anos."),
  relationship: z
    .enum(["friend", "partner", "sibling", "colleague", "parent"])
    .describe(
      "Tipo de relacionamento: friend (amigo), partner (parceiro), sibling (irmão/irmã), colleague (colega), parent (pai/mãe)."
    ),
  minBudget: z
    .number()
    .min(0)
    .optional()
    .describe("Orçamento mínimo na moeda do usuário."),
  maxBudget: z
    .number()
    .min(0)
    .describe("Orçamento máximo na moeda do usuário."),
  interests: z
    .array(
      z
        .string()
        .min(1)
        .describe(
          "Tag curta de interesse, por exemplo: videogames, boardgames, books."
        )
    )
    .min(1)
    .describe("Lista de interesses do destinatário."),
});

Agora você tem:

  1. Validação em tempo de execução: SuggestGiftsInputZod.parse(input);
  2. Tipo TypeScript: type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;
  3. JSON Schema para o modelo: zodToJsonSchema(SuggestGiftsInputZod).

Use isso ao registrar a ferramenta:

type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;

const suggestGiftsInputSchemaJson = zodToJsonSchema(
  SuggestGiftsInputZod,
  "SuggestGiftsInput"
);

server.registerTool(
  "suggest_gifts",
  {
    title: "Suggest gifts",
    description:
      "Seleciona ideias de presentes com base no orçamento, tipo de relacionamento e interesses do destinatário.",
    inputSchema: suggestGiftsInputSchemaJson,
  },
  async ({ input }) => {
    // aqui o input pode ser validado adicionalmente com Zod:
    const args = SuggestGiftsInputZod.parse(input) as SuggestGiftsInput;

    // a seguir, trabalhamos com args tipados
  }
);

Essa abordagem dá exatamente a single source of truth — uma única fonte de verdade: você descreve o schema uma vez, e o tipo TypeScript e o JSON Schema são gerados automaticamente.

No mundo real, você ainda adicionará testes para verificar se o zodToJsonSchema está gerando a estrutura esperada, mas isso é assunto do módulo sobre testes.

Insight: o ChatGPT lida mal com parâmetros opcionais

Uma das dores em produção: assim que você começa a usar campos optional nas schemas das ferramentas, a qualidade das tool-calls cai visivelmente. O modelo, em teoria, “entende” o que são parâmetros opcionais, mas na prática geralmente não os envia — mesmo quando, pela lógica de negócio, você precisa muito deles.

O Response API resolveu isso de forma elegante: simplesmente removeu campos opcionais — todos os parâmetros da ferramenta devem ser declarados como required. Mas o problema permanece: a ideia “vou marcar metade dos campos como opcionais, e o modelo decidirá o que preencher” bate na realidade — normalmente ele simplesmente não envia nada.

5. Onde termina o “schema” e começa o “design de interface”

Até aqui falamos o tempo todo sobre o inputSchema — ou seja, sobre quais argumentos o modelo deve gerar para executar a ferramenta. Mas depois de chamar a ferramenta a vida continua: o resultado ainda precisa ser renderizado no UI.

Aqui é útil separar dois níveis:

  • O schema da ferramenta descreve os argumentos de entrada que o modelo deve gerar. Isso é sempre JSON, que vive no espaço MCP / tool-call.
  • O componente de UI (widget) lê toolOutput.structuredContent e, com base nele, constrói a interface. O formato de structuredContent você também projeta, mas isso já não é JSON Schema para o modelo (embora você possa formalizar isso para si mesmo).

Às vezes, desenvolvedores tentam matar dois coelhos com um único objeto JSON — combinar entradas para o modelo e o formato de dados para o UI. Isso raramente termina bem. É mais conveniente separar:

  • inputSchema — sobre o que o modelo precisa para iniciar a ferramenta;
  • structuredContent — sobre o que o UI precisa para renderizar o resultado.

Por exemplo, o inputSchema de suggest_gifts não contém nenhum id de presentes. Já o structuredContent contém uma lista de cards com id, title, price, link de compra etc.

6. Anotações e _meta: como influenciar o UX e a segurança

Além do schema de parâmetros e da estrutura de resposta, há outra camada — como a plataforma trata a ferramenta e a mostra ao usuário. Quem cuida disso são os metadados e as anotações.

Além dos campos padrão title, description, inputSchema, a ferramenta pode ter metadados adicionais e anotações. No Apps SDK e no MCP, parte disso vive em _meta (por exemplo, securitySchemes), e outra parte em campos especiais, como hints específicos do OpenAI, por exemplo readOnlyHint e destructiveHint.

Importante: essas anotações não alteram o JSON Schema, mas afetam como o ChatGPT mostra a ferramenta ao usuário e como lida com sua execução.

Exemplo: readOnlyHint e destructiveHint

Suponha que você tenha duas ferramentas:

  • list_gifts — apenas obter uma lista de presentes (seguro);
  • create_order — criar um pedido (potencialmente perigoso: dinheiro, endereço, é coisa séria).

Você pode marcá-las assim (pseudocódigo):

server.registerTool(
  "list_gifts",
  {
    title: "List gift suggestions",
    description: "Obtém a lista de presentes disponíveis conforme os filtros informados.",
    inputSchema: listGiftsInputSchema,
    _meta: {
      readOnlyHint: true,
    },
  },
  async ({ input }) => { /* ... */ }
);

server.registerTool(
  "create_order",
  {
    title: "Create gift order",
    description:
      "Cria um pedido para um presente específico em nome do usuário. Use somente após confirmação explícita.",
    inputSchema: createOrderInputSchema,
    _meta: {
      destructiveHint: true,
    },
  },
  async ({ input }) => { /* ... */ }
);

A semântica é a seguinte. readOnlyHint sinaliza ao ChatGPT que a ferramenta não altera nada e é segura; o modelo e o UI podem chamá-la com mais liberdade. destructiveHint indica que a ferramenta realiza ações irreversíveis ou críticas, portanto o usuário verá mais confirmações e o modelo será mais cauteloso.

No seu app de presentes, suggest_gifts é claramente read‑only, enquanto ferramentas de checkout, cobrança e alteração de dados do usuário devem ser marcadas como potencialmente destructive.

openWorldHint e campos semelhantes

Em alguns casos, você quer indicar ao modelo que a ferramenta opera em “mundo aberto”, isto é, que seus resultados não são exaustivos. Por exemplo, search_products nunca retornará todos os produtos existentes no mundo, apenas os relevantes.

Tais anotações ajudam o modelo a não tirar conclusões fortes como “se o produto não foi encontrado em search_products, então ele não existe”. É um detalhe sutil de UX, mas em apps de produção a diferença é perceptível.

_meta em torno da exibição no UI

Quando sua ferramenta retorna um resultado, você pode ainda indicar em _meta configurações que influenciam o widget. Por exemplo: qual template HTML usar como output-template, se precisa de bordas, qual mensagem mostrar durante a chamada etc.

No exemplo oficial, o servidor registra separadamente o HTML do widget como um recurso MCP e então faz referência a ele por meio de _meta["openai/outputTemplate"].

server.registerTool(
  "suggest_gifts",
  {
    title: "Suggest gifts",
    description: "Seleciona ideias de presentes.",
    inputSchema: suggestGiftsInputSchemaJson,
    _meta: {
      "openai/outputTemplate": "ui://widget/gifts.html", // Este é o id do recurso MCP: server.registerResource(...)
      "openai/toolInvocation/invoking": "Selecionando presentes…",		// Exibido durante a busca
      "openai/toolInvocation/invoked": "Encontrei opções de presentes",   // Exibido quando a busca termina
    },
  },
  async ({ input }) => {
    // ...
    return {
      content: [],
      structuredContent: { items: gifts },
    };
  }
);

Assim, em um único lugar você descreve:

  • a forma dos dados de entrada para o modelo (inputSchema);
  • como a ferramenta será exibida e se comportará no UI (_meta).

7. Design de schemas: o que pedir ao modelo e o que não pedir

Uma armadilha típica é tentar repassar todo o trabalho ao modelo. Por exemplo, você descreve em inputSchema o campo giftId e, no description, escreve: “UUID do presente no nosso banco de dados”. O modelo, claro, vai tentar gerar um UUID como "0f21b5f0-5a3a-4d1b-8f0b-9f1a6e3c1234", só que o problema é que esse presente provavelmente não existe no seu sistema.

Boa regra: não peça ao modelo para gerar identificadores técnicos e dados vinculados ao seu mundo interno.

Em vez disso, faça um fluxo em várias etapas:

  1. suggest_gifts retorna uma lista de presentes com id, title, price etc.;
  2. o UI/modelo permite que o usuário escolha uma das opções sugeridas;
  3. create_order recebe o giftId a partir do conjunto já existente.

Do ponto de vista dos schemas, isso significa que:

  • o inputSchema de ferramentas que “olham para fora” (para o usuário) descreve apenas o que a pessoa pode razoavelmente informar: parâmetros de busca, filtros, critérios;
  • o inputSchema de ferramentas que operam entidades internas se baseia em id já conhecidos, e não exige que o modelo os invente.

Para o seu app de presentes, isso significa que, em suggest_gifts, você não pede ao modelo para “inventar um SKU”, mas apenas os parâmetros da consulta. Os SKUs serão agregados no backend, e o UI os mostrará ao usuário.

Observação: SKU é um código único internacional do produto. Exemplo "GFT-CHC-500-BS".

8. Pequeno bloco prático: juntando tudo

Vamos reunir em um só lugar tudo o que falamos: schema Zod, geração de JSON Schema, registro da ferramenta com _meta e uso do schema na lógica de negócio. Montaremos um exemplo mínimo, porém conectado, para o app de presentes.

Primeiro, o schema Zod e o tipo:

import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";

const SuggestGiftsInputZod = z.object({
  relationship: z
    .enum(["friend", "partner", "sibling", "colleague", "parent"])
    .describe("Tipo de relacionamento com o destinatário do presente."),
  maxBudget: z
    .number()
    .min(0)
    .describe("Orçamento máximo na moeda do usuário."),
  interests: z
    .array(
      z
        .string()
        .min(1)
        .describe("Tag curta de interesse, por exemplo: videogames.")
    )
    .min(1)
    .describe("Lista de interesses do destinatário."),
});

type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;

const suggestGiftsInputSchemaJson = zodToJsonSchema(
  SuggestGiftsInputZod,
  "SuggestGiftsInput"
);

Depois — o registro da ferramenta com _meta para o UI:

server.registerTool(
  "suggest_gifts",
  {
    title: "Suggest gifts",
    description:
      "Use quando precisar selecionar ideias de presentes pelo orçamento, relacionamento e interesses.",
    inputSchema: suggestGiftsInputSchemaJson,
    _meta: {
      "openai/outputTemplate": "ui://widget/gifts.html",
      "openai/toolInvocation/invoking": "Selecionando presentes…",
      "openai/toolInvocation/invoked": "Encontrei opções de presentes",
      readOnlyHint: true,
    },
  },
  async ({ input }) => {
    const args = SuggestGiftsInputZod.parse(input) as SuggestGiftsInput;

    const gifts = await findGifts(args); // sua lógica de negócio

    return {
      content: [],
      structuredContent: {
        items: gifts,
      },
    };
  }
);

Em algum lugar próximo você terá a função de negócio tipada:

async function findGifts(input: SuggestGiftsInput) {
  // aqui você pode usar input.relationship, input.maxBudget, input.interests
  // e retornar um array de objetos do tipo Gift
  return [
    {
      id: "gift-1",
      title: "Jogo de tabuleiro baseado em videogames",
      price: 45,
      currency: "USD",
    },
  ];
}

No lado do widget, você então pegará window.openai.toolOutput.structuredContent.items e renderizará os cards, mas falaremos disso com mais detalhes em algumas aulas.

9. Erros comuns ao descrever ferramentas

Erro nº 1: descrições de campos muito genéricas ou sem sentido.
Se você escreve description: "Data" ou description: "Parâmetro de filtro", o modelo recebe praticamente zero informação útil. É como uma documentação do tipo “o método faz algo importante”. Use descrições que respondam “o que colocar aqui” e “em que formato”. Por exemplo: “Data em ISO 8601 no formato YYYY-MM-DD, ex.: "2025-02-14"” ou “Valor na moeda do usuário, exemplo: 49.99”.

Erro nº 2: ausência de enum onde ele é o mais indicado.
Frequentemente, desenvolvedores têm preguiça de transformar strings em enum e deixam type: "string". O resultado é o modelo inventando valores próprios, backend surpreso e UI quebrado. Se você tem um conjunto fixo de opções (relationship, tipos de status, modos de ordenação), quase sempre vale a pena usar enum e listar os valores possíveis. Isso aumenta muito a previsibilidade das tool-calls.

Erro nº 3: duas fontes de verdade para schema e tipos.
Clássico: no TypeScript você muda o campo maxBudget para priceMax, mas no JSON Schema esquece. O modelo continua mandando maxBudget, o código espera priceMax, e tudo quebra. Geralmente, esses erros só aparecem em produção. Por isso, é melhor desde o início usar Zod ou ferramenta similar que gere tanto o tipo quanto o JSON Schema a partir de uma única declaração.

Erro nº 4: pedir ao modelo que gere identificadores internos.
Campos como userId, giftId, orderId, se descritos como “UUID do usuário no nosso sistema”, serão preenchidos pelo modelo com valores inventados. Mesmo que você adicione um pattern para UUID, o modelo passará a gerar UUID “com cara de certos”, que não correspondem a nada. Esses campos devem ser preenchidos no backend, com base no contexto (autenticação, tool-call anterior), e não pedidos ao modelo.

Erro nº 5: schemas “divinos” gigantes para todos os casos.
Às vezes dá vontade de fazer uma única ferramenta do_everything com um objeto enorme, metade dos campos nullable, metade optional. O modelo se afoga nisso. É melhor dividir a funcionalidade em várias ferramentas com schemas mais estreitos e claros: uma para buscar presentes, outra para obter detalhes de um presente específico, e uma terceira para criar o pedido.

Erro nº 6: ignorar _meta e anotações.
Muitos desenvolvedores se limitam a name, description e inputSchema, deixando de lado os campos _meta como openai/outputTemplate e hints como destructiveHint. Como resultado, ferramentas que realizam ações perigosas “em silêncio” não são acompanhadas de avisos e confirmações no UI. Isso reduz a confiança do usuário e cria risco de operações inesperadas. Use anotações para marcar explicitamente ferramentas read‑only e perigosas, além de definir mensagens de status amigáveis.

Erro nº 7: ausência de validação de entrada no servidor.
Mesmo que o JSON Schema e o Zod pareçam descrever tudo, confiar apenas no modelo é arriscado. Às vezes o modelo pode gerar dados parcialmente válidos ou você mesmo altera o schema e esquece de restrições de negócio. Envolver o handler em try { parse } catch { ... } com um erro amigável dá ao modelo a chance de corrigir os argumentos e a você — a chance de não derrubar o serviço por causa de uma tool-call malformada.

Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION