JavaRush /Курсы /ChatGPT Apps /Описание инструментов: JSON Schema, типизация, annotation...

Описание инструментов: JSON Schema, типизация, annotations

ChatGPT Apps
4 уровень , 1 лекция
Открыта

1. Инструмент как контракт: что именно мы описываем

Когда вы регистрируете инструмент в MCP‑сервере, вы описываете его с помощью небольшого объекта. Упрощенная структура для TypeScript‑SDK выглядит так:

server.registerTool(
  "suggest_gifts",
  {
    title: "Suggest gifts",
    description: "Подбирает подарки по профилю получателя.",
    inputSchema: {
      type: "object",
      // сюда сейчас и будем углубляться
    },
  },
  async ({ input }) => {
    // ваш код
  }
);

Модель не знает, что внутри обработчика async ({ input }) => { ... }. Для неё есть только три вещи:

  1. name/title — как называется инструмент.
  2. description — когда его уместно использовать.
  3. inputSchema — какие аргументы нужно передать и в каком формате.

Всё, что мы делаем в этой лекции, относится к пункту 3 (и немного к метаданным _meta/annotations, о которых поговорим позже).

Важно понимать: JSON Schema в контексте ChatGPT App — это не скучный валидатор, а часть промпта для модели. Модель действительно читает description у полей, понимает, что такое enum, замечает minItems, format и т.д.

То есть вы не просто защищаете backend от кривых данных, вы объясняете ИИ‑модели, как корректно позвать вашу функцию.

2. Базовый JSON Schema для инструмента suggest_gifts

Начнем с простого. Скажем, у нас есть такой сценарий:

Пользователь пишет:
«Подбери подарок для брата 25 лет, бюджет 50–70 долларов, любит видеоигры и настольные игры».

Инструмент suggest_gifts должен принять примерно такие аргументы:

  • возраст получателя;
  • тип отношений (брат, коллега, партнер и т.п.);
  • минимальный и максимальный бюджет;
  • список интересов.

Опишем это как JSON Schema «в лоб», без Zod, чистым объектом:

const suggestGiftsInputSchema = {
  type: "object",
  properties: {
    age: {
      type: "integer",
      minimum: 0,
      maximum: 120,
      description: "Возраст получателя подарка в годах.",
    },
    relationship: {
      type: "string",
      enum: ["friend", "partner", "sibling", "colleague", "parent"],
      description:
        "Тип отношений с получателем: friend, partner, sibling (брат/сестра), colleague, parent.",
    },
    minBudget: {
      type: "number",
      minimum: 0,
      description: "Минимальный бюджет в валюте пользователя.",
    },
    maxBudget: {
      type: "number",
      minimum: 0,
      description: "Максимальный бюджет в валюте пользователя.",
    },
    interests: {
      type: "array",
      items: {
        type: "string",
        description:
          "Краткое название интереса, например: videogames, boardgames, books.",
      },
      minItems: 1,
      description: "Список интересов получателя.",
    },
  },
  required: ["relationship", "maxBudget"],
};

Несколько важных моментов, которые здесь сразу стоит проговорить.

Во‑первых, description у полей. В обычном API вы бы могли их и не писать — фронтенд‑разработчик и так прочитает Swagger и поймет. Но здесь «клиент» — модель, которая пытается вывести смысл из имени и описания. Чем яснее вы скажете: «возраст в годах», «бюджет в валюте пользователя», «enum с фиксированными значениями», тем меньше странных аргументов вы увидите в рантайме.

Во‑вторых, enum — один из самых мощных инструментов управления моделью. Если вы позволите модели писать любую строку в relationship, вы получите «bro», «girlfriend», «bestie», «teammate» и что‑нибудь ещё более креативное. Если вы задаёте enum, модель с очень высокой вероятностью будет выбирать только из этих значений. Это прямое уменьшение количества «галлюцинаций» в аргументах.

В‑третьих, не обязательно всё делать required. Например, age может быть необязательным: если пользователь его не указал, модель не будет выдумывать «примерный возраст» из воздуха (если вы так сформулируете описание). Здесь как раз начинается искусство: баланс между гибкостью и строгостью.

Теперь используем эту схему в регистрации инструмента:

server.registerTool(
  "suggest_gifts",
  {
    title: "Suggest gifts",
    description:
      "Подбирает идеи подарков по бюджету, типу отношений и интересам получателя.",
    inputSchema: suggestGiftsInputSchema,
  },
  async ({ input }) => {
    // здесь input уже примерно соответствует схеме
    // ...
  }
);

Такой «ручной» объект хорошо подходит для быстрых экспериментов. Но по мере роста приложения он превращается в отдельный мир, который легко разъедется с вашими TypeScript‑типами. Чуть позже вернёмся к этой проблеме и посмотрим, как решить её с помощью Zod и генерации JSON Schema из типов.

3. JSON Schema как промпт: как писать description, чтобы модель не страдала

Формально JSON Schema — это про валидацию. А неформально, в мире LLM, — ещё и структурированный промпт. Несколько практических правил:

  1. Поле description должно отвечать на вопрос «что сюда положить и в каком формате».
    Формулировка вида «Дата» не помогает. Формулировка «ISO 8601 дата в формате YYYY-MM-DD, например "2025-02-14"» — помогает очень сильно.
  2. Если поле связано с деньгами — уточняйте единицы.
    Лучше явно написать «Сумма в валюте пользователя» или «Сумма в долларах США». В противном случае модель может честно написать 50 и вы будете гадать, это 50 йен или 50 евро.
  3. Строковые «категории» почти всегда лучше делать через enum.
    Если поле — строка с «категорией», лучше сделать enum и описать каждое значение в description инструмента. Например, для relationship можно в описании инструмента написать: «relationship: один из friend (друг), partner (романтический партнер), sibling (брат или сестра), colleague (коллега по работе), parent (родитель). Не выдумывай другие значения.»
  4. Для массивов полезно задавать minItems и объяснять, что это за список.
    Если поле — массив, полезно указать minItems и кратко объяснить, что именно это за список. Например, interests — это не «описание человека в прозе», а «набор коротких тэгов».

Всё это звучит немного занудно, но на практике разница между «есть описания» и «нет описаний» — это разница между стабильным приложением и вечной лотереей «что сегодня пришлет модель».

Insight

У MCP-инструментов есть жёсткие ограничения по размеру — и именно они чаще всего становятся причиной «мистических» падений, странных ошибок и того, что ассистент внезапно перестаёт видеть ваши tools.

Ключевое правило простое: инструмент должен помещаться в ~4 KB JSON целиком. Это не только текст description, но вся структура:

  • описание инструмента,
  • схема аргументов (inputSchema),
  • вложенные объекты и enum,
  • _meta и annotations.

Если ваш инструмент разрастается, платформа начинает вести себя непредсказуемо: появляются ошибки вроде "Tool description is too long", "Schema validation failed", "Manifest exceeds size limits", а иногда ChatGPT просто перестаёт загружать инструмент или «забывает» о его существовании.

Рекомендация: держите description в пределах 10002000 символов, а весь инструмент — в пределах «безопасных» ~4 KB. Если описание становится слишком длинным, это почти всегда признак того, что инструмент делает слишком много вещей сразу. Отдельные инструменты должны быть узкими и предельно чёткими — так модель надёжнее понимает их границы и реже ошибается во входных данных.

4. TypeScript и Zod: один источник правды вместо двух

Ручное написание JSON‑схемы — боль для разработчика на TypeScript. Приходится поддерживать два параллельных мира:

  • типы в TS‑коде;
  • JSON Schema для модели.

С ростом приложения они начинают расходиться. Сегодня вы поменяли поле в TypeScript‑типе, завтра забыли обновить схему — и через неделю ловите падение в проде.

Де‑факто стандартный подход в TS‑мире — использовать Zod и конвертацию Zod -> JSON Schema.

Устанавливаем зависимости (если ещё не):

npm install zod zod-to-json-schema

Опишем входную схему для suggest_gifts через 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("Возраст получателя подарка в годах."),
  relationship: z
    .enum(["friend", "partner", "sibling", "colleague", "parent"])
    .describe(
      "Тип отношений: friend (друг), partner (партнер), sibling (брат/сестра), colleague (коллега), parent (родитель)."
    ),
  minBudget: z
    .number()
    .min(0)
    .optional()
    .describe("Минимальный бюджет в валюте пользователя."),
  maxBudget: z
    .number()
    .min(0)
    .describe("Максимальный бюджет в валюте пользователя."),
  interests: z
    .array(
      z
        .string()
        .min(1)
        .describe(
          "Краткий тег интереса, например: videogames, boardgames, books."
        )
    )
    .min(1)
    .describe("Список интересов получателя."),
});

Теперь у вас есть:

  1. Runtime‑валидация: SuggestGiftsInputZod.parse(input);
  2. TypeScript‑тип: type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;
  3. JSON Schema для модели: zodToJsonSchema(SuggestGiftsInputZod).

Используем это при регистрации инструмента:

type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;

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

server.registerTool(
  "suggest_gifts",
  {
    title: "Suggest gifts",
    description:
      "Подбирает идеи подарков по бюджету, типу отношений и интересам получателя.",
    inputSchema: suggestGiftsInputSchemaJson,
  },
  async ({ input }) => {
    // здесь input уже можно дополнительно проверить Zod'ом:
    const args = SuggestGiftsInputZod.parse(input) as SuggestGiftsInput;

    // дальше работаем с типизированным args
  }
);

Такой подход как раз и даёт тот самый single source of truth — один источник правды: вы описываете схему один раз, а TypeScript‑тип и JSON Schema генерируются автоматически.

В реальном мире вы ещё добавите на это тесты, которые проверяют, что zodToJsonSchema выдает ожидаемую структуру, но это уже тема модуля про тестирование.

Insight: ChatGPT плохо работает с опциональными параметрами

Одна из самых болезненных практик в проде: как только вы начинаете активно использовать optional-поля в схемах инструментов, качество tool-calls заметно падает. Модель в теории «понимает», что такое необязательные параметры, но на практике чаще всего просто не присылает их вообще — даже когда по бизнес-логике они вам очень нужны.

Эту проблему красиво решили в Response API: там просто убрали optional поля — все параметры инструмента должны быть объявлены как required. Но проблема никуда не делась: идея «я помечу половину полей как необязательные, и модель сама будет решать, что заполнять» разбивается о реальность: обычно она просто ничего не присылает.

5. Где заканчивается «схема» и начинается «дизайн интерфейса»

До этого мы всё время говорили о inputSchema — то есть о том, какие аргументы модель должна сгенерировать для запуска инструмента. Но после вызова инструмента жизнь не заканчивается: результат ещё нужно отрисовать в UI.

Здесь полезно отделить два уровня:

  • Схема для инструмента описывает входные аргументы, которые должна сгенерировать модель. Это всегда JSON, который живет в пространстве MCP / tool‑call.
  • UI‑компонент (виджет) читает toolOutput.structuredContent и уже на его основе строит интерфейс. Формат structuredContent вы также проектируете сами, но это уже не JSON Schema для модели (хотя вы можете для себя формализовать и это).

Иногда разработчики пытаются одним JSON‑объектом убить двух зайцев — совместить и входы для модели, и формат данных для UI. Это редко заканчивается хорошо. Удобнее разделить:

  • inputSchema — про то, что нужно модели, чтобы запустить инструмент;
  • structuredContent — про то, что нужно UI, чтобы отрисовать результат.

Например, inputSchema для suggest_gifts не содержит никаких id подарков. А structuredContent наоборот — содержит список карточек с id, title, price, ссылкой на покупку и т.п.

6. Аннотации и _meta: как влиять на UX и безопасность

Помимо самой схемы параметров и структуры ответа есть ещё один слой — то, как платформа относится к инструменту и показывает его пользователю. За это отвечают метаданные и аннотации.

Помимо стандартных полей title, description, inputSchema, у инструмента могут быть дополнительные метаданные и annotations. В Apps SDK и MCP часть этих вещей живет в _meta (например, securitySchemes), ещё часть — в специальных полях вроде OpenAI‑specific hints вроде readOnlyHint и destructiveHint.

Здесь важно понимать: эти аннотации не меняют JSON Schema, но влияют на то, как ChatGPT показывает инструмент пользователю и как относится к его вызову.

Пример: readOnlyHint и destructiveHint

Допустим, у вас есть два инструмента:

  • list_gifts — просто получить список подарков (безопасно);
  • create_order — создать заказ (потенциально опасно: деньги, адрес, всё серьёзно).

Вы можете пометить их примерно так (псевдокод):

server.registerTool(
  "list_gifts",
  {
    title: "List gift suggestions",
    description: "Получает список доступных подарков по заданным фильтрам.",
    inputSchema: listGiftsInputSchema,
    _meta: {
      readOnlyHint: true,
    },
  },
  async ({ input }) => { /* ... */ }
);

server.registerTool(
  "create_order",
  {
    title: "Create gift order",
    description:
      "Создает заказ на конкретный подарок от имени пользователя. Используй только после явного подтверждения.",
    inputSchema: createOrderInputSchema,
    _meta: {
      destructiveHint: true,
    },
  },
  async ({ input }) => { /* ... */ }
);

Семантика здесь следующая. readOnlyHint сигнализирует ChatGPT, что инструмент ничего не изменяет и безопасен; модель и UI могут вызывать его свободнее. destructiveHint говорит, что инструмент делает необратимые или критичные действия, поэтому у пользователя чаще появятся подтверждения, и модель будет более осторожна.

В вашем Gift‑приложении suggest_gifts явно read‑only, а вот инструменты оформления заказа, списания денег и изменения пользовательских данных лучше помечать как потенциально destructive.

openWorldHint и похожие поля

В некоторых случаях вы хотите подсказать модели, что инструмент работает в «открытом мире», то есть его результаты не исчерпывающие. Например, search_products никогда не вернет все товары, которые существуют в мире, а только релевантные.

Такие аннотации помогают модели не делать сильных выводов вроде «если товар не найден в search_products, значит он не существует». Это тонкий UX‑момент, но в продакшн‑приложениях разница хорошо заметна.

_meta вокруг отображения UI

Когда ваш инструмент возвращает результат, вы можете дополнительно указать в _meta настройки, влияющие на виджет. Например: какой HTML‑шаблон использовать как output‑template, нужны ли рамки, какую надпись показывать во время вызова и т.п.

Например, в официальном примере сервер отдельно регистрирует HTML виджета как MCP‑ресурс и затем ссылается на него через _meta["openai/outputTemplate"].

server.registerTool(
  "suggest_gifts",
  {
    title: "Suggest gifts",
    description: "Подбирает идеи подарков.",
    inputSchema: suggestGiftsInputSchemaJson,
    _meta: {
      "openai/outputTemplate": "ui://widget/gifts.html", // Это id MCP-ресурса: server.registerResource(...)
      "openai/toolInvocation/invoking": "Подбираю подарки…",		// Отображается в процессе поиска
      "openai/toolInvocation/invoked": "Нашёл варианты подарков",   // Отображается, если поиск закончен
    },
  },
  async ({ input }) => {
    // ...
    return {
      content: [],
      structuredContent: { items: gifts },
    };
  }
);

Таким образом, вы в одном месте описываете:

  • форму входных данных для модели (inputSchema);
  • то, как инструмент будет выглядеть и вести себя в UI (_meta).

7. Дизайн схем: что просить у модели, а что — нет

Одна из типичных ловушек — попытаться переложить всю работу на модель. Например, вы описываете в inputSchema поле giftId, и в description пишете: «UUID подарка из нашей базы данных». Модель, конечно, честно попробует сгенерировать UUID наподобие "0f21b5f0-5a3a-4d1b-8f0b-9f1a6e3c1234", только проблема в том, что такого подарка у вас, скорее всего, не существует.

Хорошее правило: не просить модель генерировать технические идентификаторы и данные, завязанные на ваш внутренний мир.

Вместо этого стоит сделать многошаговый сценарий:

  1. suggest_gifts возвращает список подарков с id, title, price и т.п.;
  2. UI/модель позволяют пользователю выбрать один из предложенных вариантов;
  3. create_order принимает giftId из уже существующего набора.

С точки зрения схем это значит, что:

  • inputSchema инструментов, которые смотрят «наружу» (на пользователя), описывают только то, что человек может разумно ввести: параметры поиска, фильтры, критерии;
  • inputSchema инструментов, которые оперируют внутренними сущностями, опираются на уже известные id, а не требуют от модели их сочинять.

Для вашего Gift‑приложения это означает, что в suggest_gifts вы не просите модель «придумать SKU код», а только параметры запроса. Сами SKU вы прикрутите на стороне backend’а, а UI покажет их пользователю.

Примечание: SKU — это международный уникальный код товара. Пример "GFT-CHC-500-BS".

8. Небольшой практический блок: собираем всё вместе

Давайте соберём в одном месте всё, о чём говорили выше: Zod‑схему, генерацию JSON Schema, регистрацию инструмента с _meta и использование схемы в бизнес‑логике. Соберем минимальный, но связанный пример для Gift‑приложения.

Сначала Zod‑схема и тип:

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

const SuggestGiftsInputZod = z.object({
  relationship: z
    .enum(["friend", "partner", "sibling", "colleague", "parent"])
    .describe("Тип отношений с получателем подарка."),
  maxBudget: z
    .number()
    .min(0)
    .describe("Максимальный бюджет в валюте пользователя."),
  interests: z
    .array(
      z
        .string()
        .min(1)
        .describe("Короткий тег интереса, например: videogames.")
    )
    .min(1)
    .describe("Список интересов получателя."),
});

type SuggestGiftsInput = z.infer<typeof SuggestGiftsInputZod>;

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

Дальше — регистрация инструмента с _meta для UI:

server.registerTool(
  "suggest_gifts",
  {
    title: "Suggest gifts",
    description:
      "Используй, когда нужно подобрать идеи подарков по бюджету, отношениям и интересам.",
    inputSchema: suggestGiftsInputSchemaJson,
    _meta: {
      "openai/outputTemplate": "ui://widget/gifts.html",
      "openai/toolInvocation/invoking": "Подбираю подарки…",
      "openai/toolInvocation/invoked": "Нашёл варианты подарков",
      readOnlyHint: true,
    },
  },
  async ({ input }) => {
    const args = SuggestGiftsInputZod.parse(input) as SuggestGiftsInput;

    const gifts = await findGifts(args); // ваша бизнес-логика

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

Где‑то рядом у вас будет типизированная бизнес‑функция:

async function findGifts(input: SuggestGiftsInput) {
  // здесь можно использовать input.relationship, input.maxBudget, input.interests
  // и вернуть массив объектов типа Gift
  return [
    {
      id: "gift-1",
      title: "Настольная игра по видеоиграм",
      price: 45,
      currency: "USD",
    },
  ];
}

На стороне виджета вы потом возьмете window.openai.toolOutput.structuredContent.items и отрисуете карточки, но об этом подробнее через пару лекций.

9. Типичные ошибки при описании инструментов

Ошибка №1: Слишком общие или бессмысленные описания полей.
Если вы пишете description: "Дата" или description: "Параметр фильтра", модель получает примерно ноль полезной информации. Это как документация вида «метод делает что‑то важное». Используйте описания, которые отвечают на вопрос «что сюда положить» и «в каком формате». Например: «ISO 8601 дата в формате YYYY-MM-DD, напр. "2025-02-14"» или «Сумма в валюте пользователя, пример: 49.99».

Ошибка №2: Отсутствие enum там, где он напрашивается.
Часто разработчики ленятся превращать строки в enum и оставляют type: "string". В результате модель придумывает свои значения, backend удивляется, UI ломается. Если у вас есть фиксированный набор вариантов (relationship, типы статусов, способы сортировки) — почти всегда имеет смысл сделать enum и перечислить возможные значения. Это сильно повышает предсказуемость tool‑calls.

Ошибка №3: Два источника правды для схемы и типов.
Классика: в TypeScript вы меняете поле maxBudget на priceMax, а в JSON Schema забываете. Модель продолжает слать maxBudget, код ожидает priceMax, всё падает. Часто такие ошибки обнаруживаются уже в проде. Поэтому лучше с самого начала использовать Zod или аналогичный инструмент, который генерирует и тип, и JSON Schema из одной декларации.

Ошибка №4: Просьба к модели генерировать внутренние идентификаторы.
Поля вроде userId, giftId, orderId, если вы описали их как «UUID пользователя в нашей системе», неизбежно будут заполняться моделью выдуманными значениями. Даже если вы добавите pattern для UUID, модель просто начнет генерировать «правильно выглядящие» UUID, которые ничему не соответствуют. Такие поля лучше заполнять на backend’е по контексту (аутентификация, предыдущий tool‑call), а не просить об этом модель.

Ошибка №5: Гигантские «божественные» схемы на все случаи жизни.
Иногда хочется сделать один инструмент do_everything с огромным объектом, половина полей nullable, половина optional. Модель в этом тонет. Лучше разбить функциональность на несколько инструментов с более узкими и понятными схемами: один отвечает за поиск подарков, другой — за получение деталей конкретного подарка, третий — за создание заказа.

Ошибка №6: Игнорирование _meta и annotations.
Многие разработчики ограничиваются name, description и inputSchema, упуская _meta поля вроде openai/outputTemplate и hints вроде destructiveHint. В итоге инструменты, которые «молча» делают опасные действия, не сопровождаются подсказками и подтверждениями в UI. Это ухудшает доверие пользователя и создает риск неожиданных операций. Используйте annotations, чтобы явно пометить read‑only и опасные инструменты, а также задать дружелюбные статусы выполнения.

Ошибка №7: Отсутствие валидации входа на сервере.
Даже если JSON Schema и Zod вроде бы всё описывают, полагаться только на модель — рискованно. Иногда модель может выдать частично валидные данные или вы сами измените схему и забудете про бизнес‑ограничения. Оборачивание обработчика в try { parse } catch { ... } с дружелюбной ошибкой даёт модели шанс скорректировать аргументы, а вам — шанс не уронить весь сервис из‑за одного неудачного tool‑call.

1
Задача
ChatGPT Apps, 4 уровень, 1 лекция
Недоступна
Ручной JSON Schema как “промпт”: normalize_recipient
Ручной JSON Schema как “промпт”: normalize_recipient
1
Задача
ChatGPT Apps, 4 уровень, 1 лекция
Недоступна
Zod-first + типизация: meeting_request_draft (single source of truth)
Zod-first + типизация: meeting_request_draft (single source of truth)
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ