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

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

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 Apps — це не «нудний валідатор», а частина підказки (prompt) для моделі. Модель справді читає description у полях, розуміє, що таке enum, і зважає на minItems, format тощо.

Тобто ви не просто захищаєте бекенд від некоректних даних — ви пояснюєте ШІ‑моделі, як правильно викликати вашу функцію.

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 — це не «опис людини прозою», а «набір коротких тегів».

Звучить трохи занудно, але на практиці різниця між «є описи» та «немає описів» — це різниця між стабільним застосунком і вічною лотереєю «що цього разу надішле модель».

Нотатка

MCP‑інструменти мають жорсткі обмеження на розмір. Саме вони найчастіше спричиняють «містичні» збої, дивні помилки й ситуації, коли асистент раптово перестає бачити ваші інструменти (tools).

Ключове правило просте: інструмент має повністю вміщуватися приблизно у 4 KB JSON. Це не лише текст description, а вся структура:

  • опис інструмента,
  • схема аргументів (inputSchema),
  • вкладені обʼєкти й enum,
  • _meta та анотації.

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

Рекомендація: тримайте description у межах 1 0002 000 символів, а весь інструмент — у межах «безпечних» приблизно 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. Валідація під час виконання: 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 повертає очікувану структуру. Але це вже тема модуля про тестування.

Нотатка: ChatGPT погано працює з опційними параметрами

Одна з найболючіших практичних речей у робочому середовищі: щойно ви починаєте активно використовувати поля optional у схемах інструментів, якість викликів помітно падає. Модель у теорії «розуміє», що таке необовʼязкові параметри, але на практиці найчастіше просто не надсилає їх узагалі — навіть коли за бізнес‑логікою вони вам дуже потрібні.

Цю проблему красиво розвʼязали в 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, інструмент може мати додаткові метадані та анотації. В 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 говорить, що інструмент виконує незворотні або критичні дії, тож користувачеві частіше показуватимуть підтвердження, а модель буде обережнішою.

У вашому застосунку для подарунків 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, а не вимагають від моделі їх вигадувати.

Для вашого застосунку для подарунків це означає, що в suggest_gifts ви не просите модель «придумати SKU‑код», а лише параметри запиту. Самі SKU ви згенеруєте на боці бекенду, а UI покаже їх користувачеві.

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

8. Невеликий практичний блок: збираємо все разом

Зберіть в одному місці все, про що говорили вище: Zod‑схему, генерацію JSON Schema, реєстрацію інструмента з _meta і використання схеми в бізнес‑логіці. Спробуймо зібрати мінімальний, але цілісний приклад для застосунку для подарунків.

Спочатку — 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". У результаті модель вигадує свої значення, бекенд дивується, UI ламається. Якщо у вас є фіксований набір варіантів (relationship, типи статусів, способи сортування) — майже завжди має сенс зробити enum і перелічити можливі значення. Це помітно підвищує передбачуваність викликів інструментів.

Помилка №3: Два джерела правди для схеми й типів.
Класика: у TypeScript ви змінюєте поле maxBudget на priceMax, а в JSON Schema забуваєте. Модель продовжує слати maxBudget, код очікує priceMax — і все падає. Часто такі помилки виявляються вже в робочому середовищі. Тому краще з самого початку використовувати Zod або аналогічний інструмент, який генерує і тип, і JSON Schema з однієї декларації.

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

Помилка №5: Гігантські «божественні» схеми на всі випадки життя.
Іноді хочеться зробити один інструмент do_everything з величезним обʼєктом: половина полів nullable, половина optional. Модель у цьому тоне. Краще розбити функціональність на кілька інструментів із вужчими та зрозумілими схемами: один відповідає за пошук подарунків, другий — за отримання деталей конкретного подарунка, третій — за створення замовлення.

Помилка №6: Ігнорування _meta та анотацій.
Багато розробників обмежуються name, description і inputSchema, випускаючи з уваги _meta поля на кшталт openai/outputTemplate і підказки на кшталт destructiveHint. У підсумку інструменти, які «мовчки» роблять небезпечні дії, не супроводжуються підказками й підтвердженнями в UI. Це погіршує довіру користувача і створює ризик неочікуваних операцій. Використовуйте анотації, щоб явно позначити read‑only і небезпечні інструменти, а також задати дружні статуси виконання.

Помилка №7: Відсутність валідації входу на сервері.
Навіть якщо JSON Schema і Zod нібито все описують, покладатися лише на модель — ризиковано. Іноді модель може видати частково валідні дані, або ви зміните схему й забудете про бізнес‑обмеження. Огорнення обробника в try { parse } catch { ... } із дружньою помилкою дає моделі шанс скоригувати аргументи, а вам — не «покласти» весь сервіс через один невдалий tool‑call.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ