1. Загальна картина: шлях виклику інструмента через сервер
Перш ніж писати код, зафіксуймо архітектуру. Це допоможе вам не загубитися в деталях.
Якщо говорити термінами Apps SDK + MCP, усе виглядає так: у нас є MCP‑сервер (у нашому курсі це обробник маршруту (Route Handler) app/mcp/route.ts у Next.js). Він реєструє інструменти й ресурси та реалізує обробники для цих інструментів.
Високорівнева схема:
sequenceDiagram
participant User as Користувач
participant Chat as ChatGPT (модель)
participant App as ChatGPT App
participant MCP as MCP-сервер / backend
participant DB as Каталог/зовнішні API
User->>Chat: "Підбери подарунок..."
Chat->>App: вирішує викликати tool `suggest_gifts`
App->>MCP: JSON-RPC call_tool (ім’я + аргументи)
MCP->>MCP: Валідація, авторизація
MCP->>DB: Запит каталогу/фільтрація
DB-->>MCP: Список кандидатів
MCP-->>App: structuredContent + content + _meta
App-->>Chat: Передає результат моделі + у віджет
Chat-->>User: Пояснює вибір, показує віджет
Головна думка проста: сервер нічого не знає про «магію» моделі. Він бачить звичайний запит: імʼя інструмента + аргументи — і має повернути структуровану відповідь. Натомість модель узагалі не бачить вашого коду. Вона бачить лише:
- які є інструменти та їхні схеми;
- аргументи, які вона сама сформувала;
- JSON‑відповідь, яку ви повернули.
Тож наше завдання в цій лекції — акуратно реалізувати «середню частину»: MCP‑сервер і обробники інструментів.
Insight: ліміт mcp-tools
У MCP‑сервері кількість інструментів — така сама обмежувальна метрика, як памʼять або токени контексту. Формально ви можете зареєструвати десятки й навіть сотні tools, але платформа та модель працюють із ними нелінійно. Кожен новий інструмент додає «шуму» під час маршрутизації.
Практика підказує такі орієнтири:
- жорстка стеля для ChatGPT ≈ до 128 MCP-tools на сервер;
- робочий діапазон — до 50 інструментів. Далі якість помітно просідає: модель починає плутати схожі за описом tools, рідше згадує рідкісні й частіше обирає не те.
В Anthropic картина подібна: ліміт — близько максимум 100 tools. Водночас вони самі радять триматися в межах до 50.
2. Де в шаблоні Next.js + Apps SDK живе серверна логіка
У модулі 2 ми вже розгортали офіційний Next.js‑шаблон для ChatGPT App і побіжно оглянули його структуру. Тепер подивімося, де в ньому розташований MCP‑сервер і як він повʼязаний із віджетом.
Якщо ви використовуєте цей шаблон, MCP‑сервер зазвичай реалізують у файлі app/mcp/route.ts (App Router). Саме туди приходять JSON‑RPC виклики ChatGPT: tools/call, resources/list, handshake тощо.
Типова структура проєкту:
my-chatgpt-app/
├─ app/
│ ├─ mcp/
│ │ └─ route.ts # MCP-сервер + реєстрація інструментів
│ ├─ page.tsx # React-віджет (UI)
│ ├─ layout.tsx # Root layout, Bootstrap SDK
│ └─ globals.css # Глобальні стилі
│
├─ proxy.ts # CORS та інше
├─ next.config.ts
├─ package.json
├─ tsconfig.json
└─ .env
У route.ts ми:
- створюємо екземпляр MCP‑сервера (через @modelcontextprotocol/sdk);
- реєструємо інструменти (server.registerTool(...));
- визначаємо HTTP‑обробник, який приймає запити від ChatGPT і передає їх у MCP‑сервер.
Далі писатимемо код на TypeScript, спираючись на цю структуру.
3. Мінімальний MCP‑сервер і обробник інструмента
Почнімо з найпростішого: створімо сервер і додамо навчальний інструмент suggest_gifts, який поверне заглушку.
Припустімо, що MCP‑SDK у нас уже встановлений:
pnpm add @modelcontextprotocol/sdk
І створімо простий app/mcp/route.ts:
// app/mcp/route.ts
import { NextRequest } from "next/server";
import { McpServer } from "@modelcontextprotocol/sdk/server";
const server = new McpServer({ name: "giftgenius-mcp" });
// Реєстрація інструмента з мінімальною схемою
server.registerTool(
"suggest_gifts",
{
title: "Підбір подарунків",
description: "Підбирає подарунки за інтересами та бюджетом.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Короткий опис отримувача." },
},
required: ["query"],
},
},
async ({ input }) => {
// Тут буде бізнес-логіка
return {
content: [
{
type: "text",
text: `Заглушка: подарунки для "${input.query}".`,
},
],
structuredContent: {},
};
}
);
// HTTP-обробник Next.js
export async function POST(req: NextRequest) {
const body = await req.text(); // JSON-RPC рядок
const response = await server.handle(body);
return new Response(response, {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
Це вже робочий варіант: ChatGPT зможе викликати suggest_gifts, а сервер поверне текстову заглушку.
Важливо, що server.registerTool приймає:
- імʼя інструмента;
- метадані та JSON Schema входу;
- обробник (handler) — асинхронну функцію, у яку прилітають аргументи input.
Але поки що тут немає ні валідації, ні нормального structured output, ні авторизації. Саме цим і займемося далі.
4. Валідація вхідних даних і поділ шарів
Чому однієї JSON‑схеми замало
Так, платформа сама перевіряє базові речі за схемою: типи полів, обовʼязкові властивості тощо. Але:
- модель може передати логічно некоректні дані (наприклад, бюджет −100 або список інтересів із 1000 елементів);
- у вас є бізнес‑обмеження (максимальний бюджет, підтримувані валюти тощо);
- інколи ChatGPT або інший клієнт може поводитися дивно й надіслати щось зовсім неочікуване.
Тому всередині обробника (handler) усе одно потрібна додаткова валідація.
Розділімо код: обробник ↔ бізнес‑логіка
Щоб серверний код не перетворився на хаотичний клубок, зручно тримати бізнес‑логіку окремо. Наприклад, створімо app/mcp/gifts.ts:
// app/mcp/gifts.ts
export type SuggestGiftsInput = {
age?: number | null;
relationship: "friend" | "partner" | "colleague";
maxBudget: number;
interests: string[];
};
export type GiftItem = {
id: string;
title: string;
price: number;
currency: "USD";
score: number;
tags: string[];
shortDescription: string;
};
// Проста "база" подарунків
const CATALOG: GiftItem[] = [
{
id: "board-game-1",
title: "Настільна гра «Космічна стратегія»",
price: 39,
currency: "USD",
score: 0.93,
tags: ["board_games", "strategy", "2-4_players"],
shortDescription: "Чудовий подарунок для поціновувачів настолок.",
},
// ...
];
export function suggestGifts(input: SuggestGiftsInput): GiftItem[] {
if (input.maxBudget <= 0) {
throw new Error("Бюджет має бути додатним числом.");
}
const filtered = CATALOG.filter(
(item) => item.price <= input.maxBudget
);
// Спрощено: просто сортуємо за score і беремо топ-3
return filtered.sort((a, b) => b.score - a.score).slice(0, 3);
}
Тепер в обробнику MCP‑інструмента ми займаємося:
- розбором input;
- зіставленням із типом SuggestGiftsInput;
- безпечним викликом suggestGifts;
- пакуванням результату у формат, зрозумілий ChatGPT і нашому UI.
5. Реалізація обробника: від input до structuredContent
Перепишемо registerTool у route.ts, використовуючи нашу бізнес‑логіку:
// app/mcp/route.ts (фрагмент)
import { suggestGifts, SuggestGiftsInput } from "./gifts";
server.registerTool(
"suggest_gifts",
{
title: "Підбір подарунків",
description:
"Використовуйте, коли потрібно підібрати подарунки за інтересами, бюджетом і типом стосунків.",
inputSchema: {
type: "object",
properties: {
age: {
type: "integer",
minimum: 0,
maximum: 120,
description: "Вік отримувача, якщо відомо.",
},
relationship: {
type: "string",
enum: ["friend", "partner", "colleague"],
description: "Тип ваших стосунків з отримувачем.",
},
maxBudget: {
type: "number",
minimum: 1,
description: "Максимальний бюджет у доларах США.",
},
interests: {
type: "array",
items: { type: "string" },
description: "Інтереси отримувача (наприклад, board games, hiking).",
},
},
required: ["relationship", "maxBudget", "interests"],
},
},
async ({ input }) => {
// Базова логічна валідація
if (!Array.isArray(input.interests) || input.interests.length === 0) {
return {
isError: true,
content: [
{
type: "text",
text: "Потрібно вказати принаймні один інтерес отримувача.",
},
],
structuredContent: { errorCode: "NO_INTERESTS" },
};
}
const payload: SuggestGiftsInput = {
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
};
const items = suggestGifts(payload);
if (items.length === 0) {
return {
content: [
{
type: "text",
text:
"Я не знайшов відповідних подарунків у заданому бюджеті. Спробуйте збільшити бюджет або змінити інтереси.",
},
],
structuredContent: {
items: [],
emptyReason: "NO_MATCHES",
},
};
}
return {
content: [
{
type: "text",
text: `Знайшов ${items.length} відповідні варіанти подарунка.`,
},
],
structuredContent: {
items: items.map((item) => ({
id: item.id,
title: item.title,
price: item.price,
currency: item.currency,
shortDescription: item.shortDescription,
tags: item.tags,
})),
},
};
}
);
Тут є кілька важливих моментів.
По‑перше, ми явно перевіряємо, що interests — не порожній список. Навіть якщо JSON Schema формально дозволяє порожній масив, для нас такий запит усе одно не має сенсу. Краще одразу повернути зрозумілу помилку, ніж намагатися зібрати випадковий список.
По‑друге, ми повертаємо два набори даних:
- content — для моделі. Це коротке текстове резюме: «знайшов N варіантів». Модель використає його у своїй відповіді користувачеві.
- structuredContent — для моделі й для UI. Це структурований JSON зі списком подарунків, який наш віджет може показати картками.
Поширена помилка — поміщати в content увесь довжелезний JSON. Так робити не варто: модель витрачає на це токени й може почати плутатися. Краще тримати content коротким, а деталі класти в structuredContent.
6. Додаємо UI‑шаблон і _meta/openai/outputTemplate
На рівні Apps SDK сервер також підказує ChatGPT, який UI‑шаблон використати для візуалізації результату інструмента. Це робиться через ресурси й _meta["openai/outputTemplate"]. Сервер реєструє HTML‑ресурс із mimeType: "text/html+skybridge", а інструмент у відповіді посилається на нього.
У Next.js‑шаблоні це зазвичай сховано за зручним обгортанням, але у спрощеному вигляді все виглядає так:
// десь під час ініціалізації MCP-сервера
server.registerResource("ui://widget/gifts.html", {
name: "Gift suggestions widget",
mimeType: "text/html+skybridge",
// далі: спосіб віддати HTML (вбудований шаблон або файл)
});
А у відповіді інструмента:
return {
content: [{ type: "text", text: `Знайшов ${items.length} подарунків.` }],
structuredContent: { items: /* ... */ },
_meta: {
"openai/outputTemplate": "ui://widget/gifts.html",
},
};
Тоді ChatGPT не лише зрозуміє структуру результату, а й завантажить потрібний HTML/JS для віджета. А наш React‑компонент усередині iframe прочитає window.openai.toolOutput і відобразить список подарунків.
Детальніше про UI‑частину говоритимемо в лекціях про обробку ToolOutput → UI (у цьому ж модулі). А зараз зверніть увагу лише на звʼязок: обробник інструмента відповідає не тільки за бізнес‑дані, а й за те, до якого UI‑шаблону привʼязати результат. Тут ми дивимося на цей звʼязок очима MCP‑сервера: який шаблон вказати і що покласти в structuredContent.
Insight
Розробники ChatGPT задумували віджет як шаблон для відображення JSON. Саме тому й використовують назву outputTemplate. Початкова ідея така: ChatGPT викликає mcp‑tool, а mcp‑tool повертає JSON і, інколи, віджет. Якщо віджета немає, ChatGPT сам вирішує, як показати JSON.
А якщо віджет указано, то ChatGPT показує його, передає в нього JSON як toolOutput, і віджет має відобразити ці дані. Віджет — це шаблон для відображення JSON. Саме тому він кешується ще на стадії реєстрації застосунку у Store.
Ви можете використовувати віджет так, як вам зручно: у ньому можна викликати fetch(). Але якщо ви розумітимете початковий задум розробників ChatGPT, вам буде легше прийняти наявність певних обмежень і, ймовірно, майбутніх змін.
7. Авторизація і доступ в обробнику
Поки що ми робили вигляд, що у світі все — публічні дані. На практиці частина інструментів потребує авторизації: доступу до облікового запису користувача, його замовлень, платежів, документів тощо.
У термінах Apps SDK / MCP для інструмента можна задати securitySchemes. А далі, в обробнику, — перевіряти токени та контекст.
Найпростіший приклад:
server.registerTool(
"list_user_orders",
{
title: "Список замовлень користувача",
description: "Повертає останні замовлення авторизованого користувача.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
_meta: {
securitySchemes: [{ type: "oauth2", scopes: ["orders.read"] }],
}
},
async ({ auth }) => {
if (!auth?.accessToken) {
return {
isError: true,
content: [
{
type: "text",
text: "Потрібно увійти в обліковий запис, щоб переглянути замовлення.",
},
],
_meta: {
// Просимо ChatGPT запустити OAuth UI
"mcp/www_authenticate": [
'Bearer resource_metadata="https://your-mcp.example.com/.well-known/oauth-protected-resource", error="insufficient_scope", error_description="Авторизуйтеся, щоб продовжити."',
],
},
};
}
// Тут перевіряємо токен, issuer, audience, scope...
const orders = await fetchUserOrders(auth.accessToken);
return {
content: [
{
type: "text",
text: `Знайшов ${orders.length} останніх замовлень.`,
},
],
structuredContent: { orders },
};
}
);
Тут важливо зрозуміти таке:
- ChatGPT не «сам здогадується» про ваші перевірки. Він лише передає токени й контекст, а ви зобовʼязані коректно виконати авторизацію.
- Спеціальне поле _meta["mcp/www_authenticate"] каже платформі: «потрібно показати користувачеві UI для входу / оновлення токена». Без цього ChatGPT просто побачить помилку.
Про складнощі авторизації ми окремо говоритимемо в модулі 10. А зараз досить базової ідеї: перевіряйте токен в обробнику й не вірте моделі на слово.
8. Взаємодія із зовнішніми API та БД: шари й практики
Спокуса «зробити все в обробнику» дуже велика: розбір аргументів, запит у базу, фільтрація, перетворення в structuredContent, логування — і ще трохи роздумів. Усе в одній функції на 150 рядків. Це приблизно як писати весь застосунок у pages/index.tsx: можна, але болісно.
Набагато краще розділити шари:
// gifts-repository.ts
import type { GiftItem } from "./gifts";
export async function fetchGiftsFromApi(
maxBudget: number,
interests: string[]
): Promise<GiftItem[]> {
const resp = await fetch("https://example.com/api/gifts", {
method: "POST",
body: JSON.stringify({ maxBudget, interests }),
headers: { "Content-Type": "application/json" },
});
if (!resp.ok) {
throw new Error(`Gift API error: ${resp.status}`);
}
const data = (await resp.json()) as GiftItem[];
return data;
}
// gifts.ts (оновлений)
import { fetchGiftsFromApi } from "./gifts-repository";
export async function suggestGifts(input: SuggestGiftsInput): Promise<GiftItem[]> {
if (input.maxBudget <= 0) {
throw new Error("Бюджет має бути додатним числом.");
}
const items = await fetchGiftsFromApi(input.maxBudget, input.interests);
return items.sort((a, b) => b.score - a.score).slice(0, 3);
}
// route.ts (фрагмент обробника)
async ({ input }) => {
try {
const payload: SuggestGiftsInput = {
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
};
const items = await suggestGifts(payload);
// ...
} catch (err) {
console.error("suggest_gifts failed", err);
return {
isError: true,
content: [
{
type: "text",
text: "Сталася помилка під час підбору подарунків. Спробуйте ще раз пізніше.",
},
],
structuredContent: {
errorCode: "INTERNAL_ERROR",
},
};
}
}
Такий підхід дає кілька плюсів.
- Тестованість: можна писати unit‑тести на suggestGifts і fetchGiftsFromApi, не підіймаючи MCP‑сервер.
- Читабельність: обробник залишається тонким адаптером між протоколом (MCP) і вашою логікою.
- Повторне використання: якщо пізніше знадобиться той самий підбір подарунків в іншому місці (наприклад, в окремому REST‑API), не доведеться «виколупувати» логіку з MCP.
9. Логування і базова спостережуваність
Серверна реалізація інструментів — чудове місце, щоб одразу подбати про мінімальну спостережуваність. У продакшені ви захочете знати:
- які інструменти викликаються;
- з якими аргументами (без персональних даних (PII), звісно);
- скільки часу займає обробка;
- скільки помилок і яких саме.
Зараз ми розбираємося в устрої ChatGPT App, тож роботу з професійними засобами логування відкладемо на потім. Найпростіший «обгортковий» логер навколо обробників може виглядати так:
// simple-logger.ts
export function logToolInvocationStart(tool: string, args: unknown) {
console.log(
JSON.stringify({
level: "info",
event: "tool_invocation_started",
tool,
timestamp: new Date().toISOString(),
// Ніколи не логуємо PII у проді!
args,
})
);
}
export function logToolInvocationEnd(tool: string, ms: number, success: boolean) {
console.log(
JSON.stringify({
level: "info",
event: "tool_invocation_finished",
tool,
durationMs: ms,
success,
timestamp: new Date().toISOString(),
})
);
}
// route.ts (обгортка обробника)
import { logToolInvocationStart, logToolInvocationEnd } from "./simple-logger";
server.registerTool(
"suggest_gifts",
{ /* ...meta... */ },
async ({ input }) => {
const startedAt = Date.now();
logToolInvocationStart("suggest_gifts", {
relationship: input.relationship,
maxBudget: input.maxBudget,
interestsCount: Array.isArray(input.interests)
? input.interests.length
: 0,
});
try {
// ... основна логіка ...
const duration = Date.now() - startedAt;
logToolInvocationEnd("suggest_gifts", duration, true);
return result;
} catch (err) {
const duration = Date.now() - startedAt;
logToolInvocationEnd("suggest_gifts", duration, false);
throw err;
}
}
);
Згодом, у модулях про метрики, SLO і моніторинг, ви зможете на основі цих логів будувати графіки та алерти. Але звичку логувати краще виробляти вже зараз.
10. Як серверний результат потрапляє у віджет (і назад)
У розділі 6 ми вже привʼязали результат інструмента до UI‑шаблону через _meta["openai/outputTemplate"]. Тепер подивімося на той самий шлях з іншого боку. Розберімо, як structuredContent опиняється всередині React‑віджета і що з ним робити в UI.
Хоча ця лекція фокусується на сервері, важливо розуміти таке: ви проєктуєте не лише «API для моделі», а й «API для UI». Сервер повертає:
- structuredContent — дані, які бачить і модель, і віджет (через toolOutput);
- content — стислий опис результату для моделі;
- _meta — приватні для віджета поля: openai/outputTemplate, openai/widgetCSP, openai/widgetDomain тощо.
Всередині React‑віджета ви потім робите щось на кшталт:
// app/page.tsx (фрагмент)
type ToolOutput = {
items?: {
id: string;
title: string;
price: number;
currency: string;
shortDescription: string;
tags: string[];
}[];
emptyReason?: string;
};
declare global {
interface Window {
openai?: {
toolOutput?: ToolOutput;
};
}
}
export default function GiftWidget() {
const output = typeof window !== "undefined"
? window.openai?.toolOutput
: undefined;
if (!output) {
return <div>Очікую на результати підбору подарунків…</div>;
}
if (!output.items || output.items.length === 0) {
return <div>Немає відповідних подарунків. Спробуйте змінити умови.</div>;
}
return (
<ul>
{output.items.map((item) => (
<li key={item.id}>
<strong>{item.title}</strong> — {item.price} {item.currency}
</li>
))}
</ul>
);
}
Саме тому так важливо, щоб structuredContent мав стабільний контракт і був «дружнім» до UI: окремі поля, а не надмірну вкладеність на 10 рівнів.
Детально про цей шлях ми говоримо в окремій лекції модуля 4. А тут лише зафіксуймо: сервер і віджет спираються на одну й ту саму структуру structuredContent.
11. Обробка помилок на сервері: формат і стратегія
У розділах 8–9 ми вже трохи торкнулися помилок і логування всередині обробника. Тепер зберімо це в єдиний формат: як саме повертати помилки інструментів, щоб і модель, і UI могли з ними працювати.
Помилки в обробниках неминучі: десь упаде зовнішній API, десь прилетить поганий input, а десь ви просто помилитеся. Головне — не перетворювати їх на «500 Internal Server Error без пояснень» для моделі й користувача.
Хороша серверна реалізація інструмента:
- розрізняє помилки валідації користувача / моделі та внутрішні помилки;
- повертає явне поле isError і зрозумілий errorCode у structuredContent;
- дає людині в content дружнє повідомлення.
Приклад (припустимо, що метадані інструмента — title, description, inputSchema тощо — ми вже винесли у змінну meta, щоб не дублювати їх тут):
function makeErrorResult(message: string, code: string) {
return {
isError: true,
content: [
{
type: "text",
text: message,
},
],
structuredContent: {
errorCode: code,
},
};
}
server.registerTool(
"suggest_gifts",
meta,
async ({ input }) => {
try {
if (input.maxBudget > 10000) {
return makeErrorResult(
"Занадто великий бюджет. Уточніть запит (до 10000 USD).",
"BUDGET_TOO_HIGH"
);
}
const items = await suggestGifts({
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
});
if (!items.length) {
return {
content: [
{
type: "text",
text:
"Не знайшов подарунків на цей бюджет. Спробуйте змінити інтереси або збільшити бюджет.",
},
],
structuredContent: {
items: [],
emptyReason: "NO_MATCHES",
},
};
}
return {/* нормальний результат */};
} catch (err) {
console.error(err);
return makeErrorResult(
"Внутрішня помилка сервера під час підбору подарунків.",
"INTERNAL_ERROR"
);
}
}
);
Такий формат допомагає і моделі (вона може спробувати змінити аргументи), і UI (віджет може показувати специфічні повідомлення для різних errorCode).
Детально про стійкість, ідемпотентність і безпечний дизайн інструментів ми говоримо за пару лекцій. Але вже тут корисно звикнути до простого правила: краще явно повернути помилку, ніж мовчки зробити щось дивне.
Наприкінці лекції ми ще зберемо ці та інші моменти в список типових помилок під час серверної реалізації інструментів. Так вам буде зручно використовувати його як чек‑лист.
12. Короткий наскрізний приклад (end‑to‑end): від запиту до відповіді
Зберімо все, що зробили, в логічний ланцюжок на нашому застосунку GiftGenius.
- Користувач пише до ChatGPT:
«Підбери подарунок для друга, він любить настолки, бюджет до 50 доларів». - Модель, знаючи про інструмент suggest_gifts і його схему, вирішує викликати його та формує tool_call:
{ "tool": "suggest_gifts", "arguments": { "relationship": "friend", "maxBudget": 50, "interests": ["board games"], "age": null } } - Платформа надсилає цей JSON‑RPC на наш MCP‑сервер (POST /app/mcp). Next.js передає тіло в server.handle(...).
- Наш обробник suggest_gifts:
- валідує, що interests — не порожній;
- викликає suggestGifts(payload);
- отримує масив GiftItem[] (топ‑3 за score);
- пакує його в structuredContent.items і додає _meta["openai/outputTemplate"] = "ui://widget/gifts.html".
- ChatGPT отримує відповідь, кладе structuredContent у контекст, завантажує HTML‑ресурс віджета gifts.html і передає туди toolOutput.
- Наш React‑віджет читає window.openai.toolOutput.items і відображає список подарунків. А модель за content і structuredContent пише користувачеві текстове пояснення, чому ці подарунки підходять.
- Користувач натискає, наприклад, «Показати ще» у віджеті. Віджет викликає callTool через SDK → знову потрапляє в наш обробник, але вже з іншими аргументами (наприклад, зі збільшеним бюджетом).
Увесь цей ланцюжок тримається на тому, що серверна реалізація інструмента:
- приймає структурований input за узгодженою JSON Schema;
- акуратно валідує дані;
- викликає ізольовану бізнес‑логіку;
- повертає стабільний structured output;
- за потреби вказує UI‑шаблон і метадані.
13. Типові помилки при серверній реалізації інструментів
Помилка №1: «Усе в одному місці» — гігантський обробник.
Коли вся логіка і робота із зовнішніми API живе всередині server.registerTool(..., async () => { ... }), код швидко розростається й перетворюється на нечитабельний моноліт. За найменшої зміни «ламається» все одразу. Краще винести бізнес‑логіку в окремі функції/модулі й зробити обробник тонким адаптером.
Помилка №2: Сліпа віра JSON‑схемі.
Розробники часто думають: «Раз схема є — отже, вхід завжди валідний». Але модель може надіслати дивні значення, а зовнішні клієнти — тим паче. Не можна покладатися лише на типи й JSON Schema: потрібна логічна валідація (межі бюджетів, довжина масивів, дозволені значення тощо).
Помилка №3: Скидати все в content і ігнорувати structuredContent.
Іноді в content кладуть величезний JSON у рядку «про всяк випадок». Це робить підказки моделі шумними й дорогими за токенами, а UI страждає, бо йому доводиться декодувати рядок замість того, щоб отримати нормальну структуру. Набагато краще тримати content коротким, а деталі складати в structuredContent.
Помилка №4: Нестабільний формат structured output.
Сьогодні items — масив обʼєктів із полями id, title, price, а завтра ви раптом перейменували price на amount — і віджет падає. Або додали новий рівень вкладеності. Так робити можна, але потрібно або версіонувати контракт, або еволюціонувати схему малими кроками. Інакше UI та тести постійно ламатимуться.
Помилка №5: Відсутність осмисленої обробки помилок.
Кинути виняток і сподіватися, що платформа сама «якось обробить», — не найкраща стратегія. Модель побачить незрозумілий JSON‑RPC error, користувач — червону плашку, а ви втратите контекст проблеми. Набагато краще повертати явний isError, errorCode і зрозуміле людині повідомлення, а деталі логувати на сервері.
Помилка №6: Ігнорування авторизації та довіра моделі.
Іноді розробники думають: «Модель же розумна, вона не викликатиме цей інструмент, якщо користувач не авторизований». Насправді модель не знає про ваші ACL і ліміти. Вона бачить лише описи tools. Усі перевірки прав мають бути в серверному обробнику — незалежно від того, як інструмент описано.
Помилка №7: Логування всього підряд, включно з PII.
Дуже легко за звичкою залогувати весь input повністю. У випадку з ChatGPT App це може включати персональні дані (імена, e‑mail, адреси тощо), що порушує і політики OpenAI, і здоровий глузд. Краще логувати лише агреговану/знеособлену інформацію: тип стосунку, діапазон бюджету, кількість інтересів.
Помилка №8: Відсутність таймаутів і ретраїв під час роботи із зовнішніми API.
Якщо інструмент усередині обробника робить fetch до зовнішнього API без таймаутів і повторів, будь‑яка затримка цього API виглядатиме як «ChatGPT завис». Користувач подумає, що зламався весь застосунок. На боці сервера потрібно обмежувати час, обробляти таймаути й повертати осмислену помилку.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ