Как создать пользовательский MCP-сервер на TypeScript, оборачивающий CLI
Пошаговое руководство по обёртыванию любого инструмента командной строки в виде сервера Model Context Protocol с использованием TypeScript SDK 1.29. Охватывает ловушку stdout, шаблоны child_process, распространение ошибок и полный рабочий git-сервер.
Самый быстрый способ дать ИИ-агенту доступ к инструменту командной строки — обернуть его как сервер Model Context Protocol (MCP). Агент вызывает типизированный инструмент, ваш сервер запускает CLI как подпроцесс, перехватывает вывод и возвращает его в виде структурированного ответа — без REST API, без привязок SDK, без вебхуков.
Это руководство строит такую обёртку с нуля, используя @modelcontextprotocol/sdk 1.29.0 и Node 18+. К концу у вас будет рабочий сервер git-mcp, предоставляющий git log и git diff как вызываемые инструменты, подключённый к Claude Desktop через stdio-транспорт. Каждая ловушка, которая ломает CLI-обёртки в продакшене, рассмотрена.
Почему “обернуть CLI” — правильный первый шаг
Большинство внутренних инструментов существует только как CLI: скрипты развёртывания, исполнители миграций баз данных, экспортёры журналов аудита, конвейеры обработки изображений. У них нет API, нет gRPC-поверхности, ничего, что агент мог бы вызвать напрямую. Обёртывание их в виде MCP-инструментов занимает 50-100 строк TypeScript и даёт обнаруживаемый, валидируемый по схеме интерфейс, которым может пользоваться любой MCP-совместимый клиент, включая Claude Code, Claude Desktop, Cursor и любой клиент, говорящий на спецификации MCP (2025-03-26).
Альтернатива — встраивать вызов CLI внутрь системного запроса или описания инструмента — хрупка. Аргументы калечатся, обработка ошибок исчезает, и агент не может отличить таймаут от плохого флага. Правильный MCP-сервер исправляет всё это.
Настройка проекта
Вам нужен Node.js 18 или новее. Создайте директорию проекта и установите зависимости:
mkdir git-mcp
cd git-mcp
npm init -y
npm install @modelcontextprotocol/sdk@1.29.0 zod@3
npm install -D @types/node typescript
Добавьте два поля в package.json и скрипт сборки. Поле "type": "module" указывает Node трактовать файлы .js как модули ES, что требуется SDK:
{
"type": "module",
"bin": {
"git-mcp": "./build/index.js"
},
"scripts": {
"build": "tsc && chmod +x build/index.js"
},
"files": ["build"]
}
Создайте tsconfig.json в корне проекта:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Создайте исходный файл:
mkdir src
touch src/index.ts
Ловушка stdout, которая убивает каждый stdio-сервер MCP
Прежде чем написать хоть одну строку бизнес-логики, выгравируйте это правило: никогда не вызывайте console.log() внутри stdio MCP-сервера.
Когда вы запускаете свой сервер под stdio-транспортом, MCP-клиент общается с ним через stdin/stdout сообщениями JSON-RPC. Любые байты, которые вы записываете в stdout вне протокола JSON-RPC, повреждают поток сообщений. Клиент увидит некорректный JSON, не сможет распарсить ответ и отключится — обычно с загадочной ошибкой “MCP server disconnected”, которая указывает в никуда рядом с вашей невинно выглядящей отладочной командой.
// @modelcontextprotocol/sdk 1.29.0, MCP spec 2025-03-26
// Bad -- corrupts the JSON-RPC stream
console.log("Running git log...");
// Good -- stderr is not part of the stdio transport
console.error("Running git log...");
Используйте console.error() для каждой диагностической строки. Она пишет в stderr, который MCP-клиент либо игнорирует, либо отображает отдельно. Это не пограничный случай — на этом спотыкается почти каждый автор MCP-серверов в первый раз.
Запуск CLI
Добавьте типизированный помощник, который порождает подпроцесс, собирает stdout и stderr и разрешается со структурированным результатом. Использование spawn вместо exec обходит ограничение буфера по умолчанию в 1 МБ, которое накладывает exec:
// src/index.ts
// @modelcontextprotocol/sdk 1.29.0, Node 18+
import { spawn } from "child_process";
interface CliResult {
stdout: string;
stderr: string;
exitCode: number;
}
function runCli(
command: string,
args: string[],
cwd?: string,
timeoutMs = 30_000
): Promise<CliResult> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
const errChunks: Buffer[] = [];
const child = spawn(command, args, {
cwd,
shell: false, // never pass shell: true with untrusted input
timeout: timeoutMs,
});
child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
child.stderr.on("data", (chunk: Buffer) => errChunks.push(chunk));
child.on("error", reject);
child.on("close", (code) => {
resolve({
stdout: Buffer.concat(chunks).toString("utf8"),
stderr: Buffer.concat(errChunks).toString("utf8"),
exitCode: code ?? 1,
});
});
});
}
Два момента стоят внимания:
shell: falseне опционален, если какая-либо часть аргументов приходит от LLM. Сshell: trueаргумент вроде--format=%H; rm -rf /становится shell-инъекцией. Всегда передавайте аргументы как массив и пустьspawnобрабатывает экранирование.- Таймаут распространяется через опцию
timeoutвchild_processNode, которая отправляетSIGTERMпосле крайнего срока. Добавьте резервныйSIGKILL, если CLI игнорируетSIGTERM.
Регистрация инструментов
Теперь подключите два инструмента git. Первый, git_log, возвращает последние N коммитов репозитория. Второй, git_diff, возвращает diff незакоммиченных изменений:
// src/index.ts (continued)
// @modelcontextprotocol/sdk 1.29.0
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "git-mcp",
version: "1.0.0",
});
server.registerTool(
"git_log",
{
description:
"Return the last N commits for a git repository. " +
"Includes hash, author, date, and subject line.",
inputSchema: {
repo: z.string().describe("Absolute path to the git repository root"),
count: z
.number()
.int()
.min(1)
.max(200)
.default(20)
.describe("Number of commits to return"),
},
},
async ({ repo, count }) => {
const result = await runCli(
"git",
["log", `--max-count=${count}`, "--pretty=format:%H|%an|%ad|%s", "--date=iso"],
repo
);
if (result.exitCode !== 0) {
return {
content: [
{
type: "text",
text: `git log failed (exit ${result.exitCode}):\n${result.stderr}`,
},
],
isError: true,
};
}
return {
content: [{ type: "text", text: result.stdout || "(no commits)" }],
};
}
);
server.registerTool(
"git_diff",
{
description:
"Return the unstaged diff for a git repository, or the diff for a specific file.",
inputSchema: {
repo: z.string().describe("Absolute path to the git repository root"),
file: z
.string()
.optional()
.describe("Optional relative path to a specific file"),
staged: z
.boolean()
.default(false)
.describe("If true, show staged (cached) diff instead of unstaged"),
},
},
async ({ repo, file, staged }) => {
const args = ["diff"];
if (staged) args.push("--cached");
if (file) args.push("--", file);
const result = await runCli("git", args, repo);
if (result.exitCode !== 0) {
return {
content: [
{
type: "text",
text: `git diff failed (exit ${result.exitCode}):\n${result.stderr}`,
},
],
isError: true,
};
}
return {
content: [
{ type: "text", text: result.stdout || "(no changes)" },
],
};
}
);
Несколько вещей, на которые стоит обратить внимание в обработчиках инструментов:
inputSchemaиспользует Zod-схемы напрямую. SDK конвертирует их в JSON Schema для валидации вызовов инструментов клиентом. Если вы передадите простой объект JSON Schema, вы потеряете семантику.default()и.optional().- Возвращайте
isError: trueвместе с содержимым, когда CLI завершается с ненулевым кодом. Это сообщает клиенту, что вызов провалился, не выбрасывая исключение, которое уронит сервер. - Сохраняйте параметр
repoкак абсолютный путь, который должен предоставить клиент. Не пытайтесь вычислить его изprocess.cwd()— рабочая директория сервера там, где её запустил MCP-клиент, что почти никогда не репозиторий пользователя.
Подключение транспорта и запуск сервера
Добавьте главную точку входа в конец src/index.ts:
// src/index.ts (continued)
// @modelcontextprotocol/sdk 1.29.0, stdio transport
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("git-mcp server running on stdio");
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});
Соберите и проверьте, что компилируется:
npm run build
Подключение к Claude Desktop
Откройте конфиг Claude Desktop. На macOS: ~/Library/Application Support/Claude/claude_desktop_config.json. На Windows: %AppData%\Claude\claude_desktop_config.json.
Добавьте свой сервер под mcpServers:
{
"mcpServers": {
"git-mcp": {
"command": "node",
"args": ["/absolute/path/to/git-mcp/build/index.js"]
}
}
}
Перезапустите Claude Desktop. Иконка молотка в панели инструментов должна появиться, показывая git_log и git_diff как доступные инструменты. Теперь вы можете спросить Claude: “Покажи мне последние 10 коммитов в /Users/me/projects/myrepo”, и он вызовет git_log напрямую.
Чтобы подключить к Claude Code, добавьте тот же блок в свои настройки MCP в Claude Code (.claude/settings.json под mcpServers), или выполните claude mcp add git-mcp -- node /path/to/build/index.js из терминала.
Ловушки в продакшен-обёртках CLI
Усечение большого вывода. Некоторые CLI выдают мегабайты вывода (git diff на большом рефакторинге, ps aux, полный SQL-дамп). Спецификация MCP не навязывает жёсткий лимит размера контента, но у клиентов есть практические лимиты. Добавьте защиту maxBytes в runCli и возвращайте уведомление об усечении:
const MAX_BYTES = 512_000; // 500 KB
// after collecting chunks:
const raw = Buffer.concat(chunks);
const text =
raw.byteLength > MAX_BYTES
? raw.slice(0, MAX_BYTES).toString("utf8") + "\n\n[output truncated]"
: raw.toString("utf8");
Поиск PATH в Windows. На Windows spawn("git", ...) с shell: false может не сработать, если git не в PATH, который наследует MCP-клиент. Либо используйте полный путь к исполняемому файлу, либо запускайте обёртку cmd.exe /c git ... (с правильной санитизацией аргументов). Альтернативно, разрешите путь к исполняемому файлу при старте, используя npm-пакет which, и закешируйте результат.
Таймаут на медленных операциях. git log на репозитории с 500 000 коммитов может занять несколько секунд. Настраивайте timeoutMs для каждого инструмента, а не используйте глобальное значение по умолчанию. Выставьте его как опциональный параметр, если размер репозитория пользователя непредсказуем.
Сообщения об ошибках из stderr. Многие CLI пишут ошибки использования в stderr с кодом выхода 0 (известная плохая привычка). Проверяйте result.stderr даже когда exitCode === 0, и выводите его в ответе инструмента вместе с содержимым stdout.
Нет shell-globbing. С shell: false глобы вроде *.ts в аргументе не раскрываются shell. Если ваш CLI ожидает раскрытия глобов, либо перечисляйте файлы сами (используя glob из npm), либо принимайте только явные пути в схеме инструмента.
Тестирование без клиента
Установите @modelcontextprotocol/inspector глобально, чтобы тестировать сервер интерактивно без настройки полного MCP-клиента:
npm install -g @modelcontextprotocol/inspector
npx @modelcontextprotocol/inspector node build/index.js
Inspector открывает UI в браузере, где вы можете перечислять инструменты, заполнять аргументы и вызывать их напрямую. Он также показывает сырые JSON-RPC сообщения, что делает диагностику проблемы повреждения stdout тривиальной — вы можете видеть мусорные байты, попадающие в поток, мгновенно.
Что выставить дальше
Два инструмента — это тонкий срез. Тот же шаблон масштабируется на любой CLI, на который опирается ваша команда:
- Выставьте
git blame,git showиgit grep, чтобы построить агента кодовой археологии. - Оберните
aws s3 lsиaws cloudformation describe-stacksдля агента, осведомлённого об инфраструктуре. - Выставьте
sqlite3 :memory: .schemaилиpsql \d tablename, чтобы агент мог исследовать схему базы данных перед написанием запросов. - Оберните пользовательский внутренний CLI для развёртывания, создания тикетов или экспорта журналов — вещи, которые жили только в shell-скриптах, потому что “никому не нужно было API для них.”
MCP-серверу всё равно, что делает CLI. Ему нужна только хорошо определённая входная схема (которую Zod даёт вам в 3 строках) и обработчик, который запускает бинарник и возвращает вывод.
Если ваша команда использует C# вместо TypeScript, тот же шаблон доступен через пакет NuGet ModelContextProtocol, который мы рассматривали при подключении MCP-серверов на .NET 10. Для более широкого взгляда на то, как MCP выглядит, когда IDE поставляет его напрямую, Azure MCP Server, поставляемый внутри Visual Studio 2022 17.14.30, — полезный реальный пример масштаба, на который нацелен этот протокол. И если вы строите автономных агентов, координирующих несколько инструментов, и нуждаетесь в фреймворке поверх сырого MCP, Microsoft Agent Framework 1.0 покрывает сторону C#. А для интеграции агентов на уровне IDE, agent skills в Visual Studio 2026 18.5 показывают, как Copilot автоматически обнаруживает определения skills из SKILL.md вашего репозитория.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.