← MCP in Practice: Building Model Context Protocol Servers
07 / 07 · 60 min

Capstone: an MCP Server for Your Product API

Design the tool surface around user tasks, wrap a real API with error handling, and ship the server against a production checklist.

Lesson content is currently available in Russian.

What you will learn

  • →Проектировать поверхность инструментов от задач пользователя, а не от endpoints API.
  • →Оборачивать реальный API: клиент с таймаутами, ошибки как isError-результаты, экономия контекста.
  • →Сдавать сервер по production-чеклисту: smoke-тесты, безопасность, README, релиз.

Проектирование поверхности инструментов

Главная ошибка капстоунов — сгенерировать по инструменту на каждый endpoint REST API. У API вашего продукта может быть 80 endpoints; сервер с 80 инструментами — плохой сервер. Каждое определение инструмента съедает контекст модели, а выбор из десятков похожих опций резко повышает процент неверных вызовов. REST проектировался для программистов, читающих документацию; поверхность MCP проектируется для модели, читающей описания на лету.

Метод такой. Выпишите 5–8 задач, которые пользователь реально будет формулировать ассистенту: «найди клиента и покажи его последние заказы», «оформи возврат», «что с выручкой за неделю». Каждой задаче — один инструмент с точным именем-глаголом: search_customers, get_customer_orders, create_refund, get_revenue_report. Один такой инструмент может внутри дёргать три endpoint вашего API — это нормально: агрегируйте на сервере, а не заставляйте модель делать четыре вызова и склеивать результаты в контексте.

Дальше распределите по примитивам: справочные данные без параметров действия (схема тарифов, статус системы) — это resources, а не инструменты; типовые сценарии («еженедельный разбор метрик») — prompts. Для каждого инструмента напишите описание по формуле: что делает, что возвращает, ограничения и хороший пример значения каждого аргумента через describe() у zod-полей. И заложите пагинацию сразу: limit с разумным дефолтом и жёстким максимумом — модель, получившая 10 000 строк, утонет вместе с контекстом сессии.

Реализация: обёртка API и ошибки как данные

Весь поход в ваш API сведите в один типизированный клиент с таймаутом и нормализацией ошибок:

const BASE_URL = process.env.PRODUCT_API_URL ?? 'https://api.acme.dev';

type ApiResult<T> = { ok: true; data: T } | { ok: false; error: string };

async function callApi<T>(path: string, init?: RequestInit): Promise<ApiResult<T>> {
  try {
    const res = await fetch(BASE_URL + path, {
      ...init,
      headers: {
        Authorization: 'Bearer ' + API_KEY,
        'Content-Type': 'application/json',
        ...init?.headers,
      },
      signal: AbortSignal.timeout(15_000),
    });
    if (!res.ok) {
      return { ok: false, error: 'HTTP ' + res.status + ': ' + (await res.text()).slice(0, 300) };
    }
    return { ok: true, data: (await res.json()) as T };
  } catch (error) {
    return { ok: false, error: error instanceof Error ? error.message : 'unknown error' };
  }
}

Хендлеры инструментов переводят неудачу в isError-результат с действенным текстом — модель должна понять, что делать дальше:

async ({ customerId }) => {
  const result = await callApi<Order[]>('/v1/customers/' + customerId + '/orders');
  if (!result.ok) {
    return {
      content: [{
        type: 'text',
        text: 'Не удалось получить заказы: ' + result.error +
          '. Проверь customerId через search_customers и повтори.',
      }],
      isError: true,
    };
  }
  const trimmed = result.data.slice(0, 25).map(pickOrderSummary);
  return { content: [{ type: 'text', text: JSON.stringify(trimmed) }] };
}

Обратите внимание на два приёма. Текст ошибки подсказывает следующий шаг — «проверь id через search_customers» — это резко повышает способность агента восстановиться. И результат прорежен функцией pickOrderSummary: в контекст уходят пять полей заказа, а не сорок. Экономия контекста — такая же часть контракта, как схема аргументов.

Наблюдаемость и последний рубеж защиты

Production-сервер обязан быть наблюдаемым и ограниченным в ущербе. Минимальный набор:

Структурные логи в stderr. Каждый вызов инструмента — одна строка: время, инструмент, длительность, статус, усечённые аргументы:

function logCall(tool: string, ms: number, ok: boolean, args: unknown) {
  console.error(JSON.stringify({
    ts: new Date().toISOString(),
    tool,
    ms,
    ok,
    args: JSON.stringify(args).slice(0, 200),
  }));
}

Никогда не логируйте секреты и полные тела ответов. Этот лог — ваш единственный источник правды при разборе инцидента «агент удалил не тот заказ».

Аннотации и подтверждения. Все мутирующие инструменты — с destructiveHint или idempotentHint по фактическому поведению. Клиент построит на них правильный UX подтверждений.

Пределы ущерба на сервере. Максимум записей на выборку, максимум мутаций в минуту, запрет массовых операций одним вызовом. Модель ошибается иначе, чем человек: она может искренне решить, что «почистить тестовые данные» означает пройтись delete-циклом по проду. Сервер — последний рубеж, где эта ошибка останавливается.

Режим READ_ONLY. Флаг окружения, при котором мутирующие инструменты не регистрируются вовсе. Это лучший ответ на вопрос службы безопасности «а что оно может натворить?» — в read-only-режиме ответ доказуемо «ничего».

Если сервер удалённый — добавьте проверку audience токена и скоупов из урока 4 на каждый запрос, а не только на initialize.

Definition of Done

Капстоун сдаётся не «когда работает», а когда закрыт чеклист. Пройдите его честно — это и есть отличие учебного скрипта от продукта:

Функциональность

  • Минимум три инструмента, один ресурс и один промпт, спроектированные от задач пользователя.
  • Каждый инструмент: zod-схема с describe() у полей, пагинация с дефолтом и максимумом, прореженные результаты.
  • Ошибки API возвращаются как isError-результаты с подсказкой следующего шага.

Безопасность

  • Секрет — только из env, fail-fast при отсутствии, ни разу не встречается в результатах и логах.
  • Аннотации на всех инструментах соответствуют фактическому поведению; есть режим READ_ONLY.
  • Таблица угроз из урока 4 обновлена под финальную поверхность инструментов.

Проверка

  • Smoke-скрипт на Inspector CLI: tools/list и по одному вызову каждого инструмента — зелёный в CI.
  • Полный ручной круг в Inspector: валидные и невалидные аргументы, чтение ресурса, предпросмотр промпта.
  • Сервер подключён и проверен в двух реальных клиентах.

Дистрибуция

  • npm-пакет: shebang, bin, files, prepublishOnly; состав проверен через npm pack --dry-run.
  • README: что делает сервер, таблица инструментов, блоки конфигурации Claude / Cursor / VS Code, переменные окружения.
  • Версия 1.0.0 с зафиксированным контрактом: что будет major-изменением, описано в README.

Финальный тест — попросите коллегу подключить сервер только по README, не задавая вам вопросов. Если получилось — вы построили настоящий MCP-сервер. Это и есть результат курса.

Practice

Капстоун: сервер для API вашего продукта

Постройте production-ready MCP-сервер для API вашего продукта (или публичного API, если своего нет): 5–8 задач пользователя → 3+ инструментов, 1+ ресурс, 1 промпт; клиент API с таймаутами и isError-ошибками; структурные логи; README и npm-пакет. Прогоните полный Definition of Done из последней секции.

Deliverable

Репозиторий с сервером, зелёный smoke-скрипт в CI, скриншоты работы в двух клиентах, README с конфигурациями и закрытый чеклист DoD с отметками по каждому пункту.

Checkpoint

Защитите свой дизайн: почему вы выбрали именно эти инструменты, а не сгенерировали по одному на каждый endpoint API? Покажите, как один из инструментов агрегирует несколько вызовов API.

  • ✓ Дизайн обоснован от задач пользователя, а не от структуры REST API.
  • ✓ Объяснена экономия контекста: меньше инструментов, прореженные результаты, пагинация с лимитами.
  • ✓ Показан инструмент-агрегатор и объяснено, почему склейка данных на сервере надёжнее цепочки вызовов моделью.

Check yourself

Почему не стоит генерировать по инструменту на каждый endpoint REST API?

01Почему не стоит генерировать по инструменту на каждый endpoint REST API?

Как правильно сообщить модели об ошибке внешнего API внутри инструмента?

02Как правильно сообщить модели об ошибке внешнего API внутри инструмента?

Зачем прореживать результаты инструмента (pickOrderSummary, limit)?

03Зачем прореживать результаты инструмента (pickOrderSummary, limit)?

Почему лимиты ущерба (rate limit мутаций, максимум записей) должны жить на сервере, а не полагаться на благоразумие модели?

04Почему лимиты ущерба (rate limit мутаций, максимум записей) должны жить на сервере, а не полагаться на благоразумие модели?

Sources

Capstone: an MCP Server for Your Product API · MCP in Practice: Building Model Context Protocol Servers · VORCL