JavaRush /Курсы /ChatGPT Apps /Серверная реализация инструментов: от вызова до ответа

Серверная реализация инструментов: от вызова до ответа

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

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‑сервер и обработчики tools.

Insight: mcp-tools limit

В 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 мы:

  1. создаём экземпляр MCP‑сервера (через @modelcontextprotocol/sdk);
  2. регистрируем инструменты (server.registerTool(...));
  3. определяем 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 элементов);
  • у вас есть бизнес‑ограничения (max бюджет, поддерживаемые валюты и т.п.);
  • иногда ChatGPT или другой клиент может повести себя странно и прислать что‑то совсем неожиданное.

Поэтому внутри handler’а всё равно нужна дополнительная валидация.

Разделим код: 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);
}

Теперь в handler’е MCP‑инструмента мы занимаемся:

  • разбором input;
  • маппингом к типу SuggestGiftsInput;
  • безопасным вызовом suggestGifts;
  • упаковкой результата в формат, понятный ChatGPT и нашему UI.

5. Реализация handler’а: от 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 (в этом же модуле), поэтому сейчас обращаем внимание только на связь: handler инструмента отвечает не только за бизнес‑данные, но и за то, к какому UI‑шаблону привязать результат. Здесь мы смотрим на эту связку именно глазами MCP‑сервера: какой шаблон указать и что положить в structuredContent.

Insight

Создатели ChatGPT задумывали виджет как шаблон для отображения JSON. Поэтому и используют название outputTemplate для него. Все дело в том, что изначальная задумка - такая: ChatGPT вызвает mcp-tool, а mcp-tool возращает JSON и иногда возвращает виджет. Если виджета не было, то ChatGPT сам решает как ему отобразить JSON.

А если виджет указан, то ChatGPT показывает виджет, передает в виджет JSON как toolOutput и виджет должен отобразить JSON. Виджет - это шаблон для отбражения JSON. Именно поэтому он кешируется еще на стадии регистрации приложения в Store.

Вы можете использовать виджет как вам удобно: в нем можно вызывать fetch(). Но если вы будете понимать изначальную задумку разработчиков ChatGPT, вам будет легче принять наличие некоторых ограничений и, вероятно, будущих изменений.

7. Авторизация и доступ в handler’е

Пока мы притворялись, что всё в мире — публичные данные. На практике часть инструментов требует авторизации: доступ к аккаунту пользователя, его заказам, платежам, документам и т.п.

В терминологии Apps SDK / MCP у инструмента можно задать securitySchemes и дальше, в handler’е, проверять токены и контекст.

Простейший пример:

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, поэтому сейчас хватит базовой концепции: проверяем токен в handler’е, не верим на слово модели.

8. Взаимодействие с внешними API и БД: слои и практики

Соблазн «сделать всё в handler’е» очень велик: парсинг аргументов, запрос в базу, фильтрация, маппинг в 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 (фрагмент handler'а)
  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‑сервер.
  • Читаемость: handler остаётся тонким адаптером между протоколом (MCP) и вашей логикой.
  • Переиспользование: если позже понадобится тот же подбор подарков в другом месте (например, в отдельном REST‑API), не придётся «выпиливать» логику из MCP.

9. Логирование и базовая наблюдаемость

Серверная реализация инструментов — отличное место, чтобы сразу позаботиться о минимальной наблюдаемости. В продакшене вы захотите знать:

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

Сейчас мы разбираемся в устройстве ChatGPT App, поэтому работу с профессиональными логгерами отложим на потом. Простейший обёрточный логгер вокруг handler’ов может выглядеть так:

// 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 (обёртка handler'а)
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: отдельные поля, не nested‑ад в 10 уровней.

Подробно про этот путь мы говорим в отдельной лекции модуля 4, здесь же фиксируем: сервер и виджет опираются на одну и ту же структуру structuredContent.

11. Обработка ошибок на сервере: формат и стратегия

В разделах 8–9 мы уже чуть‑чуть коснулись ошибок и логирования внутри handler’а. Теперь соберём это в единый формат: как именно возвращать ошибки инструментов, чтобы и модель, и UI могли с ними работать.

Ошибки в handler’ах неизбежны: где‑то упадёт внешний 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.

  1. Пользователь пишет в ChatGPT:
    «Подбери подарок для друга, он любит настолки, бюджет до 50 долларов».
  2. Модель, зная про инструмент suggest_gifts и его схему, решает вызвать его и формирует tool_call:
    {
      "tool": "suggest_gifts",
      "arguments": {
        "relationship": "friend",
        "maxBudget": 50,
        "interests": ["board games"],
        "age": null
      }
    }
    
  3. Платформа отправляет этот JSON‑RPC на наш MCP‑сервер (POST /app/mcp), Next.js передаёт тело в server.handle(...).
  4. Наш handler suggest_gifts:
    • валидирует, что interests не пустой;
    • вызывает suggestGifts(payload);
    • получает массив GiftItem[] (топ‑3 по score);
    • упаковывает его в structuredContent.items и добавляет _meta["openai/outputTemplate"] = "ui://widget/gifts.html".
  5. ChatGPT получает ответ, кладёт structuredContent в контекст, подгружает HTML‑ресурс виджета gifts.html, передаёт туда toolOutput.
  6. Наш React‑виджет читает window.openai.toolOutput.items и отрисовывает список подарков; модель по content и structuredContent пишет пользователю текстовое объяснение, почему эти подарки подходят.
  7. Пользователь нажимает, например, «Показать ещё» в виджете — виджет вызывает callTool через SDK → снова попадает в наш handler, но уже с другими аргументами (например, увеличенный бюджет).

Вся эта цепочка держится на том, что серверная реализация инструмента:

  • принимает структурированный input по согласованной JSON Schema;
  • аккуратно валидирует данные;
  • вызывает изолированную бизнес‑логику;
  • возвращает стабильный structured output;
  • по необходимости указывает UI‑шаблон и метаданные.

13. Типичные ошибки при серверной реализации инструментов

Ошибка №1: «Всё в одном месте» — гигантский handler.
Когда вся логика и работа с внешними API живёт внутри server.registerTool(..., async () => { ... }), код быстро разрастается и превращается в нечитаемый монолит. При малейшем изменении ломается всё сразу. Лучше вынести бизнес‑логику в отдельные функции/модули и делать handler тонким адаптером.

Ошибка №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. Все проверки прав должны быть в серверном handler’е, независимо от того, как инструмент описан.

Ошибка №7: Логирование всего подряд, включая PII.
Очень легко по привычке залогировать весь input целиком. В случае с ChatGPT App это может включать PII (имена, e‑mail, адреса и т.п.), что нарушает и политику OpenAI, и здравый смысл. Лучше логировать только агрегированную/обезличенную информацию: тип отношения, диапазон бюджета, количество интересов.

Ошибка №8: Отсутствие таймаутов и ретраев при работе с внешними API.
Если инструмент внутри handler’а делает fetch к внешнему API без таймаутов и повторов, любая задержка этого API будет выглядеть как «ChatGPT завис». Пользователь подумает, что сломалось всё приложение. На стороне сервера нужно ставить ограничение по времени, обрабатывать таймауты и возвращать осмысленную ошибку.

1
Задача
ChatGPT Apps, 4 уровень, 2 лекция
Недоступна
Echo-профиль (минимальный MCP tool)
Echo-профиль (минимальный MCP tool)
1
Задача
ChatGPT Apps, 4 уровень, 2 лекция
Недоступна
Split bill (тонкий handler + отдельная бизнес-логика + ошибки)
Split bill (тонкий handler + отдельная бизнес-логика + ошибки)
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ