Глава 8

Создайте первый MCP-server как узкий адаптер домена

Хороший server не начинается с LLM и вообще не начинается с протокола. Он берёт уже существующие детерминированные функции домена, описывает их схемами и возвращает проверяемый результат. Такой порядок держит бизнес-правила отдельно от протокольного слоя - и потому их можно тестировать и менять независимо.

Сначала - чистая доменная функция, которая ничего не знает про MCP. В нашем примере это проверка релиза по пяти правилам: CI, тесты, уязвимости, обратимость миграции, документированный откат.

TypeScript
export function listReleaseSnapshots(): readonly ReleaseSnapshot[] {
  return snapshots;
}

export function getReleaseSnapshot(service: string, version: string): ReleaseSnapshot | undefined {
  return snapshots.find(
    (snapshot) => normalize(snapshot.service) === normalize(service) && snapshot.version === version.trim()
  );
}

export function reviewRelease(service: string, version: string): ReleaseReview {
  const snapshot = getReleaseSnapshot(service, version);
  if (!snapshot) {
    throw new Error(`Релиз ${service}@${version} не найден`);
  }

  const checks: ReleaseCheck[] = [
    {
      id: "ci",
      title: "CI завершён успешно",
      passed: snapshot.ci === "passed",
      evidence: `ci=${snapshot.ci}; commit=${snapshot.commit}`
    },
    {
      id: "tests",
      title: "Все тесты прошли",
      passed: snapshot.testsFailed === 0,
      evidence: `passed=${snapshot.testsPassed}; failed=${snapshot.testsFailed}`
    },
    {
      id: "security",
      title: "Критических уязвимостей нет",
      passed: snapshot.criticalVulnerabilities === 0,
      evidence: `critical=${snapshot.criticalVulnerabilities}`
    },
    {
      id: "migration",
      title: "Миграция обратима",
      passed: snapshot.migrationReversible,
      evidence: `reversible=${snapshot.migrationReversible}`
    },
    {
      id: "rollback",
      title: "Откат документирован",
      passed: snapshot.rollbackDocumented,
      evidence: `documented=${snapshot.rollbackDocumented}`
    }
  ];

  const failed = checks.filter((check) => !check.passed);
  const decision = failed.length === 0 ? "ready" : "blocked";
  const summary = decision === "ready"
    ? `${snapshot.service}@${snapshot.version} готов к выпуску: пройдены все ${checks.length} проверок.`
    : `${snapshot.service}@${snapshot.version} заблокирован: ${failed.map((check) => check.title).join("; ")}.`;

  return { service: snapshot.service, version: snapshot.version, decision, checks, summary };
}

И только потом эта функция оборачивается в tool - с input/output schema и annotations, которые честно описывают её характер.

TypeScript
  server.registerTool(
    "review_release",
    {
      title: "Проверка готовности релиза",
      description: "Проверяет существующий снимок релиза по пяти детерминированным правилам.",
      inputSchema: z.object({
        service: z.string().min(2).max(64),
        version: z.string().regex(/^\d+\.\d+\.\d+$/u)
      }),
      outputSchema: reviewOutputSchema,
      annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false
      }
    },
    async ({ service, version }) => {
      try {
        const output = reviewRelease(service, version);
        return {
          content: [{ type: "text", text: output.summary }],
          structuredContent: output
        };
      } catch (error) {
        return {
          isError: true,
          content: [{ type: "text", text: error instanceof Error ? error.message : "Unknown release error" }]
        };
      }
    }
  );

Разворачивается такой сервер по одному и тому же механизму, и его стоит запомнить как рецепт.

  • Создайте McpServer с уникальными name/version и краткими instructions.
  • Опишите входную и выходную schema - это контракт, а не украшение.
  • В handler вызовите доменный сервис, а не пишите бизнес-правила внутри протокольного слоя.
  • Верните и человекочитаемый content, и машиночитаемый structuredContent.
  • Ожидаемую доменную ошибку преобразуйте в isError; неожиданные ошибки логируйте без секретов.

А проверить, безопасен ли инструмент по конструкции, можно в лаборатории.

Интерактивная лаборатория 7

Проверьте безопасность tool

Безопасная операция: узкая, типизированная, с подтверждением записи.

Два представления результата - не избыточность, а разделение потребителей.

Почему два представления результата. Текст удобен модели и UI. structuredContent удобен коду, тестам и последующей композиции. Если объявлен outputSchema, структура обязана ему соответствовать - иначе клиент вправе считать ответ невалидным.

И граница ответственности за ошибки, которую нельзя размывать.

Не выдавайте исключение БД за полезный ответ. Пользовательская ошибка, protocol error и infrastructure failure имеют разные retry-семантики. Отдайте клиенту безопасное сообщение, а полный stack сохраните только в защищённой телеметрии.

Сервер готов - осталось решить, как host будет его запускать. Для локального сценария это stdio, и у него своя дисциплина.

Ссылки