← Блог / Skills & MCP

Написать свой MCP-сервер на TypeScript: тонкая обёртка над API

·9 мин

Готовые MCP-серверы — Postgres, GitHub, файловая система — покрывают типовые задачи. Но как только у вас есть собственный API (внутренний сервис, NestJS-бэкенд, сторонний REST без официального сервера), встаёт вопрос: как дать агенту доступ именно к вашим доменным операциям? Ответ — написать свой MCP-сервер на TypeScript. Это не сложная инфраструктура, а тонкая обёртка: несколько десятков строк кода, которые превращают ваши эндпоинты в инструменты для агента.

Зачем вообще свой сервер, если есть готовые

Возьмём конкретный сценарий. В сквозном проекте курса — CoffeeCRM на Next.js + NestJS — агент мог бы напрямую идти в базу через готовый Postgres MCP-сервер. Но это грубо: агент сам пишет SQL, видит все таблицы, ответы бывают огромными, бизнес-логика и валидация обходятся стороной.

Нам нужнее доменные действия: «дай активные позиции меню», «дай заказ #42 с составом». Это уже язык бизнеса, а не базы данных. Свой MCP-сервер — переводчик между агентом и вашим API: снаружи чистые инструменты, внутри — обычный fetch к существующим эндпоинтам.

Навык переносится на любой внутренний HTTP-сервис. Поменяйте тело клиентского модуля — каркас и принципы остаются теми же.

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

Три принципа чистого инструмента

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

1. Узкая зона ответственности

Один инструмент — одно понятное действие домена.

Плохо: coffeecrm_api(method, path, body) — универсальный «дёрни любой эндпоинт». По сути вы перекладываете выбор обратно на модель, которая не знает вашего API так, как знаете его вы.

Хорошо: list_products, get_order — каждый делает ровно одно осмысленное действие. Агент не гадает, а выбирает из конкретного словаря.

2. Говорящее имя и описание

Агент выбирает инструмент только по имени и описанию — он не читает исходники вашего сервера. Описание — не комментарий для людей, это интерфейс для модели.

Плохо: get_data / «Returns data». Агент не поймёт ни когда звать, ни что получит.

Хорошо: get_order / «Возвращает заказ CoffeeCRM по его id: статус, клиент, список позиций и сумма. Используй, когда нужны детали конкретного заказа.» — описание явно говорит когда и что.

Параметры тоже описывайте (через .describe() у Zod) — агент по ним поймёт, что подставлять, без дополнительных подсказок в промпте.

3. Компактный, предсказуемый результат

Агент платит контекстом за каждый ответ инструмента. Не вываливайте весь JSON на 200 полей.

Плохо: сырой дамп строки из БД со служебными полями, внутренними id и пустыми ключами.

Хорошо: «Заказ #42 — active, клиент Анна, 2 позиции (Latte ×1, Espresso ×2), итог 540 ₽.» Агент мгновенно понимает результат, контекст не раздувается, следующий шаг выбирается точнее.

Якорь для запоминания: одно действие · говорящее описание · компактный ответ.

Каркас сервера: структура проекта

Минимальная структура для сервера CoffeeCRM:

coffeecrm/mcp-server/
  package.json
  tsconfig.json
  src/
    index.ts        # каркас + регистрация инструментов
    coffeecrm.ts    # тонкий HTTP-клиент к API

Сервер намеренно разделён на два файла: index.ts — протокол MCP и описания инструментов, coffeecrm.ts — работа с API. Если завтра поменяется бэкенд, правите только клиентский модуль.

Перед написанием кода — обязательно сверяйтесь с актуальным MCP TypeScript SDK через Context7 (запрос по /modelcontextprotocol/typescript-sdk). Имена импортов и сигнатура registerTool между версиями SDK меняются; ориентироваться на примеры из статей — значит рисковать кодом, который не соберётся через полгода. Ниже — структура, сверенная с SDK на дату записи урока курса.

Скелет src/index.ts

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { coffeeApi } from "./coffeecrm.js";

const server = new McpServer({ name: "coffeecrm", version: "1.0.0" });

server.registerTool(
  "list_products",
  {
    title: "Список товаров CoffeeCRM",
    description:
      "Возвращает позиции меню CoffeeCRM. Используй, когда нужно узнать, " +
      "какие напитки есть и какие из них активны. " +
      "По умолчанию возвращает только активные.",
    inputSchema: z.object({
      onlyActive: z
        .boolean()
        .default(true)
        .describe("true — только активные; false — все"),
    }),
  },
  async ({ onlyActive }) => {
    const products = await coffeeApi.listProducts({ onlyActive });
    const lines = products.map(
      (p) => `#${p.id} ${p.name} — ${p.price}₽ ${p.active ? "(active)" : "(inactive)"}`,
    );
    return {
      content: [{ type: "text", text: lines.length
        ? `Позиции меню (${lines.length}):\n${lines.join("\n")}`
        : "Активных позиций нет." }],
    };
  },
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  // ВАЖНО: логи только в stderr — stdout занят протоколом MCP
  console.error("CoffeeCRM MCP server running on stdio");
}
main().catch((e) => { console.error("Fatal:", e); process.exit(1); });

Обратите внимание на console.error вместо console.log. Это не опечатка — это причина номер один, почему самописный сервер «падает или не виден»: любой вывод в stdout ломает бинарный протокол MCP. Все диагностические сообщения — только в stderr.

Тонкий клиент src/coffeecrm.ts

const BASE = process.env.COFFEECRM_API ?? "http://localhost:3000";

export const coffeeApi = {
  async listProducts({ onlyActive }: { onlyActive: boolean }) {
    const res = await fetch(`${BASE}/products?active=${onlyActive}`);
    if (!res.ok) throw new Error(`API ${res.status}`);
    return (await res.json()) as Array<{
      id: number; name: string; price: number; active: boolean;
    }>;
  },
  async getOrder(id: number) {
    const res = await fetch(`${BASE}/orders/${id}`);
    if (res.status === 404) return null;
    if (!res.ok) throw new Error(`API ${res.status}`);
    return (await res.json()) as {
      id: number; status: string; customerName: string; total: number;
      items: Array<{ productName: string; qty: number }>;
    };
  },
};

Сервер не знает ничего про SQL и схему базы — он зовёт наш бэкенд CoffeeCRM по HTTP. Вся бизнес-логика и валидация остаются в NestJS, где им и место. MCP-сервер — тонкий переводчик между агентом и API, не более.

Сборка и подключение к Claude Code

# В coffeecrm/mcp-server/
npm install
npm run build   # tsc -> dist/index.js

Подключаем в .mcp.json рядом с готовыми серверами (о них — в статье подключить MCP-серверы):

{
  "mcpServers": {
    "coffeecrm": {
      "type": "stdio",
      "command": "node",
      "args": ["./mcp-server/dist/index.js"],
      "env": { "COFFEECRM_API": "http://localhost:3000" }
    }
  }
}

Проверяем:

claude mcp list   # coffeecrm -> connected
/mcp              # у coffeecrm видны list_products и get_order

Если сервер не виден — чек-лист: путь в args верный, npm run build завершился без ошибок, нет console.log в stdout, бэкенд CoffeeCRM запущен.

Как агент выбирает инструмент сам

Ключевой момент демо: не называйте инструмент явно в промпте. Пишите на естественном языке — и смотрите, как агент сам сопоставляет задачу с описанием.

«Покажи активные позиции меню CoffeeCRM.» — агент выбирает list_products, не get_order, не SQL. Именно потому, что в описании написано «используй, когда нужно узнать, какие напитки есть».

«Дай детали заказа номер 42 — кто клиент и что заказал.» — агент выбирает get_order с id: 42 и возвращает компактную сводку.

Это и есть критерий качества: если агент сам выбирает нужный инструмент по естественному запросу — описания написаны правильно.

Аналогичная логика работает и со skills в Claude Code — там агент тоже выбирает по описанию, не по имени команды.

Что дальше

Три принципа чистого инструмента — узкая зона, говорящее описание, компактный ответ — это не правила конкретного фреймворка. Это подход к проектированию интерфейса между вашим доменом и языковой моделью. Напишите сервер поверх любого вашего HTTP-сервиса, замените тело coffeecrm.ts на вызовы вашего API — каркас и принципы останутся теми же.

Если хотите разобрать это на практике — от нулевого репозитория CoffeeCRM до рабочего агента с собственными инструментами — смотрите полный курс по Claude Code: модуль 3 целиком посвящён MCP, включая живое демо написания сервера с нуля.

Курс

Освойте Claude Code системно

6 модулей, реальный fullstack-проект до деплоя, свои skills, MCP и агенты.

Смотреть программу курса