Свой MCP-сервер нужен, когда под вашу систему нет готового или готовый делает не то. По сути это тонкая обёртка над существующим API: вы описываете набор операций, которые агенту разрешено вызывать, и что каждая делает. Рабочий сервер с одним инструментом собирается за вечер — дальше всё упирается не в код, а в то, как этот инструмент описан.
Что понадобится
- Node.js 18+ и немного TypeScript. SDK есть и для Python, и для C#, но примеры ниже — на TypeScript: под него больше готовых серверов, с которых можно списать структуру.
- Доступ к системе, которую оборачиваем — ключ к API, строка подключения к базе, токен сервиса. Отдельный, с минимальными правами: сервер будет работать под ним постоянно.
- Агент, к которому подключаем — Claude Code, OpenClaw, Cursor или любой другой MCP-клиент.
- Полчаса на проверку. Не на написание — на то, чтобы задать агенту десяток живых вопросов и посмотреть, вызывает ли он нужный инструмент.
Если вы ещё не подключали ни одного готового сервера, начните с этого: как подключить MCP — полчаса работы, зато станет видно, как оно устроено изнутри.
1. Убедиться, что свой сервер действительно нужен
Три ситуации, где он оправдан:
- под вашу систему готового сервера нет — самописная учётная система, отраслевое ПО, внутренний сервис;
- готовый есть, но даёт слишком много: вам нужны три операции, а он открывает всю базу;
- нужна своя логика — проверки перед записью, ограничения, фильтрация выдачи.
Третий пункт встречается чаще остальных. Универсальный сервер к базе даёт агенту доступ ко всему, а вам нужно, чтобы он видел две таблицы и четыре поля.
А вот когда своего сервера делать не надо: если задача — объяснить агенту правила работы, а не дать доступ к данным. Правила — это скилл, он пишется за полчаса обычным текстом. Прежде чем писать код, сверьтесь со списком готовых серверов: за последний год их стало столько, что шанс найти нужный выше, чем кажется.
ПолезноЕсли система общается по OData или REST, готовый универсальный сервер часто закрывает задачу без единой строки кода — так делают с 1С, где хватает стандартного интерфейса OData.
2. Развернуть окружение
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"strict": true
},
"include": ["src/**/*"]
}
Структура, к которой всё равно придёте, когда инструментов станет больше трёх:
my-mcp-server/
├── src/
│ ├── index.ts # точка входа и регистрация
│ ├── tools/ # инструменты — что агент может вызвать
│ │ ├── orders.ts
│ │ └── customers.ts
│ └── resources/ # ресурсы — что агент может прочитать
├── package.json
└── tsconfig.json
3. Описать первый инструмент
Здесь основная работа, и здесь же основная ошибка. Агент выбирает инструмент по описанию, а не по названию функции. Плохое описание — и он либо не вызовет инструмент, когда нужно, либо вызовет не тот.
Что работает:
- Описывать задачу, а не реализацию. Не «выполняет SELECT по таблице orders», а «находит заказы клиента за период».
- Явно указывать ограничения — «возвращает не больше 50 записей», «только за последний год». Модель это читает и учитывает.
- Называть параметры человеческими словами.
date_fromпонятнее, чемp1, — и для модели тоже. - Один инструмент — одно действие. «Найти или создать» агент будет вызывать наугад.
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
const server = new Server(
{ name: "my-mcp-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "find_orders",
description:
"Находит заказы контрагента за период. Возвращает не больше 50 самых свежих записей: номер, дату, сумму и статус.",
inputSchema: {
type: "object",
properties: {
customer: { type: "string", description: "Название или ИНН контрагента" },
date_from: { type: "string", description: "Начало периода, YYYY-MM-DD" },
date_to: { type: "string", description: "Конец периода, YYYY-MM-DD" },
},
required: ["customer"],
},
},
],
}));
4. Реализовать вызов
Обработчик получает имя инструмента и аргументы, делает реальную работу и возвращает текст. Валидацию аргументов удобно повесить на Zod — модель ошибается в форматах чаще, чем человек.
import { z } from "zod";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const FindOrders = z.object({
customer: z.string().min(1).max(200),
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name !== "find_orders") {
throw new Error(`Unknown tool: ${request.params.name}`);
}
try {
const { customer, date_from, date_to } = FindOrders.parse(request.params.arguments);
// Параметризованный запрос: подстановка строк в SQL — прямой путь к инъекции
const { rows } = await pool.query(
`SELECT number, date, total, status
FROM orders
WHERE customer ILIKE $1
AND date BETWEEN COALESCE($2::date, date '2000-01-01')
AND COALESCE($3::date, CURRENT_DATE)
ORDER BY date DESC
LIMIT 50`,
[`%${customer}%`, date_from ?? null, date_to ?? null]
);
return {
content: [{ type: "text", text: JSON.stringify(rows, null, 2) }],
};
} catch (error) {
// Исключение наружу = пустой ответ у агента. Ошибку возвращаем текстом
return {
content: [{ type: "text", text: `Ошибка: ${(error as Error).message}` }],
isError: true,
};
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
Кроме инструментов протокол знает ресурсы — данные, которые агент читает, а не вызывает: справочник, конфиг, документ. Регистрируются они через ListResourcesRequestSchema и ReadResourceRequestSchema тем же способом. Отличие простое: инструмент — это действие, ресурс — это чтение.
5. Подключить к агенту
Конфиг зависит от клиента, но суть везде одна: чем запустить и с какими аргументами.
Claude Code — одной командой, подробнее про уровни конфигурации:
claude mcp add my-server -- npx tsx /path/to/my-mcp-server/src/index.ts
OpenClaw — в ~/.openclaw/openclaw.json:
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["/path/to/my-mcp-server/dist/index.js"]
}
}
}
На время разработки удобнее запускать исходник через npx tsx — не придётся пересобирать после каждой правки. Ключи и строки подключения кладите в переменные окружения, а не в конфиг: конфиг легко уезжает в репозиторий вместе с секретом.
6. Проверить в MCP Inspector
Самый быстрый способ увидеть, что сервер вообще жив, — официальный отладчик. Он поднимает веб-интерфейс, где инструменты вызываются руками:
npx @modelcontextprotocol/inspector node dist/index.js
Что смотреть: появился ли инструмент в списке, какие параметры он объявил, что возвращает на реальном вызове и сколько там текста.
Логи внутри сервера пишите только в stderr. stdout занят протоколом, и любая посторонняя строка там ломает обмен:
console.error("Debug:", value); // правильно
console.log("Debug:", value); // ломает протокол
Когда сервер отвечает в Inspector, переходите к настоящей проверке: задайте агенту десяток формулировок живым языком — «сколько мы отгрузили Ромашке в июне», «покажи последние заказы этого клиента» — и посмотрите, вызывает ли он инструмент и с какими параметрами. Если промахивается, правьте описание, а не код.
7. Опубликовать, если сервер нужен не только вам
Чтобы коллеги подключали сервер через npx, нужен bin в package.json:
{
"name": "@myorg/mcp-orders",
"version": "1.0.0",
"bin": { "mcp-orders": "./dist/index.js" }
}
npm run build && npm publish --access public
После этого подключение сводится к одной строке — npx -y @myorg/mcp-orders. Публичный сервер имеет смысл ещё и зарегистрировать в реестре MCP: так его найдут через каталоги.
ОсторожноОпубликованный сервер — это код, который другие запустят у себя с доступом к своим данным. Прежде чем выкладывать, перечитайте разбор рисков MCP: чужой сервер в конфиге агента — распространённый вектор атаки, и вы теперь по ту сторону.
Шесть ошибок, на которых спотыкаются все
1. Двадцать инструментов сразу. Три понятных работают лучше двадцати расплывчатых: агенту труднее выбрать, а вам — понять, почему он выбрал не то. Добавляйте следующий, только когда предыдущий вызывается предсказуемо.
2. Возврат сырых данных. Тысяча строк JSON забивает контекстное окно и стоит денег на каждом вызове. Отдавайте поля, нужные для ответа, и ставьте лимит.
3. Запрос без ограничения. Выборка без LIMIT однажды вернёт всю таблицу — обычно в самый неподходящий момент.
4. Права администратора «на время отладки». Они остаются навсегда. Если инструмент только читает — подключайтесь read-only пользователем.
5. Вывод в stdout. Одна забытая console.log — и клиент видит сломанный протокол вместо инструментов. Симптом узнаваемый: сервер «подключён», но список инструментов пуст.
6. Необработанное исключение. Упавший инструмент возвращает агенту пустоту, и тот начинает фантазировать. Оборачивайте в try/catch и возвращайте текст ошибки — модель умеет его прочитать и попробовать иначе.
Коротко о главном
- MCP-сервер — тонкая обёртка над тем, что у вас уже есть: API, база, внутренний сервис.
- Рабочий минимум: список инструментов, описание каждого, реализация вызова.
- Описание важнее кода. Агент выбирает инструмент по тексту описания — формулируйте задачу, а не реализацию, и указывайте ограничения явно.
- Начинайте с одного инструмента под одну задачу и проверяйте на живых формулировках.
- Отладка —
npx @modelcontextprotocol/inspector; логи только в stderr. - Прав давайте ровно столько, сколько нужно инструменту, — безопасность MCP начинается здесь.
Если своего писать не хочется — сначала посмотрите каталог готовых серверов и бесплатные варианты: половина типовых задач закрыта чужим кодом.