Форум программистов, компьютерный форум, киберфорум
run.dev
Войти
Регистрация
Восстановить пароль
Блоги Сообщество Поиск Заказать работу  

Что такое MCP сервер и как его создать. Часть 2

Запись от run.dev размещена 17.10.2025 в 21:22
Показов 5891 Комментарии 0

Нажмите на изображение для увеличения
Название: Что такое MCP сервер и как его создать 3.jpg
Просмотров: 565
Размер:	32.1 Кб
ID:	11312
Когда я впервые попытался подключить свой MCP сервер к Claude Desktop, думал что это будет как с любым другим API - указал эндпоинт, авторизовался, готово. Но нет. MCP требует конфигурирования на стороне клиента, и каждое приложение делает это по-своему. Причём документация местами отсутствует или устарела, приходилось разбираться методом проб и ошибок.

Что такое MCP сервер и как его создать. Часть 1

Клиентские приложения для MCP можно разделить на две категории. Первая - полноценные AI-ассистенты типа Claude Desktop или продукты от Anthropic. Они запускают серверы как дочерние процессы через stdio, управляют lifecycle, показывают доступные инструменты в интерфейсе. Вторая - кастомные клиенты, написанные разработчиками под конкретные нужды. Они могут использовать HTTP-транспорт, работать через proxy, добавлять свою логику авторизации.

Claude Desktop конфигурируется через JSON-файл в системной папке настроек. На Mac это ~/Library/Application Support/Claude/claude_desktop_config.json, на Windows - %APPDATA%/Claude/claude_desktop_config.json. Файл содержит объект mcpServers где ключ - имя сервера, значение - параметры запуска. Для stdio-сервера указываешь command и args - путь к исполняемому файлу и аргументы командной строки.

Я долго не мог понять почему мой сервер не запускается пока не включил логирование. Оказалось - Claude Desktop запускает процесс в своей рабочей директории, относительные пути не работают. Пришлось указывать абсолютный путь к Node.js и к скрипту сервера. Ещё один момент - переменные окружения. Если сервер использует env vars для конфигурации, их нужно явно прокидывать через параметр env в конфиге.
JSON
1
2
3
4
5
6
7
8
9
10
11
12
{
  "mcpServers": {
    "notes": {
      "command": "/usr/local/bin/node",
      "args": ["/Users/username/projects/notes-mcp/build/index.js"],
      "env": {
        "NOTES_DIR": "/Users/username/Documents/notes",
        "LOG_LEVEL": "debug"
      }
    }
  }
}
После изменения конфига нужно перезапускать Claude Desktop полностью - hot reload не работает. Я ставил алиас на kill процесса и быстрый перезапуск, иначе тестирование превращается в мучение. Каждая итерация - изменил код, собрал, убил Claude, запустил заново, проверил. Минута на цикл минимум.

HTTP-серверы подключаются иначе. Вместо command указываешь url и transport: "http". Но тут начинаются нюансы с CORS, аутентификацией, сертификатами если используешь HTTPS. Claude Desktop не показывает подробных ошибок соединения - просто "server failed to connect". Приходится смотреть логи сервера и дебажить через curl. Другие AI-клиенты реализуют MCP по-своему. Cursor и некоторые другие IDE имеют встроенную поддержку, но конфигурируются через свои settings.json. Есть проекты типа mcp-client - библиотеки для написания собственных клиентов на TypeScript или Python. Они дают больше контроля но требуют кода вместо конфигурационных файлов.

Тестирование интеграции - отдельная боль. Нельзя просто написать unit-тест и проверить что сервер работает. Нужно запустить реального клиента, выполнить действия через интерфейс, проверить результат. Я автоматизировал часть проверок через скрипты которые шлют JSON-RPC напрямую в stdin сервера, но это не покрывает реальные сценарии использования.

Версионирование capabilities становится критичным когда обновляешь сервер. Старые клиенты могут не поддерживать новые фичи, новые клиенты могут требовать capabilities которых нет у старых серверов. Протокол предусматривает согласование возможностей через handshake, но это не спасает от проблем совместимости если клиент некорректно обрабатывает отсутствие ожидаемых функций.

Подключение к Claude Desktop



Первое подключение моего MCP сервера к Claude Desktop заняло три часа вместо обещанных пяти минут. Официальная документация показывала радужную картинку - добавь пару строк в конфиг и всё заработает. На практике я встретил с десяток неочевидных проблем, каждая из которых требовала гугления и чтения issue на GitHub.

Начать нужно с поиска конфигурационного файла. На macOS он живёт по адресу ~/Library/Application Support/Claude/claude_desktop_config.json. Если файла нет - создайте вручную, Claude не сделает это за вас. На Windows путь будет %APPDATA%\Claude\claude_desktop_config.j son, на Linux обычно ~/.config/Claude/claude_desktop_config.json, хотя я встречал вариации в зависимости от дистрибутива.

Структура файла элементарная - корневой объект с ключом mcpServers, внутри которого описания серверов. Каждый сервер имеет уникальное имя-ключ, а значение содержит параметры запуска. Для stdio-транспорта обязательны два поля: command - полный путь к исполняемому файлу, и args - массив аргументов командной строки.
JSON
1
2
3
4
5
6
7
8
9
10
{
  "mcpServers": {
    "weather": {
      "command": "/usr/local/bin/node",
      "args": [
        "/Users/username/weather-mcp/build/index.js"
      ]
    }
  }
}
Первая ошибка которую я сделал - указал относительный путь к Node.js просто как "node". Claude Desktop не нашёл исполняемый файл потому что его PATH отличается от терминального. Выяснилось это не сразу - приложение молча не запускало сервер, никаких ошибок в интерфейсе. Пришлось искать логи в ~/Library/Logs/Claude/, где обнаружилась строка "command not found". Решение - указывать абсолютные пути везде, находить их через which node в терминале.

Рабочая директория процесса тоже оказалась сюрпризом. Сервер запускается не из той папки где лежит его код, а из домашней директории пользователя. Если в коде есть относительные пути к конфигам или ресурсам - они сломаются. Я хранил настройки в ./config/settings.json рядом со скриптом, и сервер их не находил. Переписал на абсолютные пути через __dirname или process.env.HOME.

Переменные окружения передаются через опциональное поле env. Это объект где ключи - имена переменных, значения - их значения. Если сервер использует DATABASE_URL или API_KEY, прописывайте их здесь. Переменные из вашего shell-окружения автоматически не наследуются, что логично с точки зрения безопасности но неудобно при разработке.
JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
  "mcpServers": {
    "notes": {
      "command": "/usr/local/bin/node",
      "args": [
        "/Users/username/notes-mcp/build/index.js"
      ],
      "env": {
        "NOTES_DIR": "/Users/username/Documents/notes",
        "DEBUG": "true"
      }
    }
  }
}
Перезапуск Claude Desktop после изменения конфига обязателен. Hot reload не работает, изменения не подхватываются на лету. Я закрывал приложение через Cmd+Q, но процессы иногда зависали в фоне. Пришлось добавить в aliases команду для жёсткого убийства: killall "Claude" && open -a Claude. Грубо но эффективно для быстрой итерации при разработке.

Отладка проблем подключения требует терпения. Claude Desktop не показывает детальных ошибок в интерфейсе - максимум невнятное "Failed to connect to server". Смотреть нужно в логи приложения и логи самого сервера если вы их настроили. На Mac логи Claude лежат в ~/Library/Logs/Claude/mcp*.log, там видно попытки запуска серверов, ошибки stdio, JSON-RPC сообщения.
Я добавил в свой сервер логирование в отдельный файл:
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
const logStream = fs.createWriteStream('/tmp/notes-mcp.log', { flags: 'a' });
 
function log(message: string, data?: any) {
  const entry = {
    timestamp: new Date().toISOString(),
    message,
    data
  };
  logStream.write(JSON.stringify(entry) + '\n');
}
 
// При инициализации
log('Server starting', { cwd: process.cwd(), env: process.env });
Это помогло обнаружить что сервер запускается но падает при попытке прочитать конфиг из несуществующей директории. После фикса путей всё заработало.

Тестирование нового сервера начинается с простого промпта в Claude Desktop: "Покажи доступные инструменты". Если сервер подключен правильно, модель выведет список зарегистрированных tools с описаниями. Дальше можно пробовать реальные запросы типа "Создай заметку с заголовком Test" и смотреть что происходит.

Windows добавляет свои грабли. Пути с обратными слешами нужно экранировать или использовать прямые. PowerShell и CMD имеют разную семантику кавычек в аргументах. Я видел конфиги где args содержали строки типа C:\Users\Name\project\script.js - работало, но выглядело ужасно. Проще перейти на forward slashes даже на Windows, Node.js их понимает.

Управление несколькими серверами одновременно - обычная практика. У меня в конфиге пять разных MCP серверов: для работы с заметками, для мониторинга серверов, для управления задачами, для поиска по документации, для интеграции с почтой. Claude Desktop запускает их все при старте, каждый живёт в своём процессе. Главное чтобы имена не пересекались и инструменты имели понятные уникальные названия. Проверка работоспособности после обновления кода требует полного цикла: изменить код, собрать (npm run build), убить Claude Desktop, запустить заново, проверить в чате. Автоматизация помогает - я написал bash-скрипт который делает всё одной командой. Экономит минуты на каждой итерации, а их бывает десятки в день.

Если существует такое число A, что после приведения его в порядок, получается B, то выведите любое такое число
У Миши развитое эстетическое чувство. Он считает, что не все числа одинаково порядочные. Когда ему...

Одни говорят что класс это объект, другие, что класс-это шаблон объекта, дак что такое объект?
Вот читаю и википедию, и на форумах, одни пишут, что класс, это шаблон по которому создается...

Как в select option добавить часть текста: value = 'часть текста'?
Здравствуйте, дорогие форумчане:). Нужна Ваша помощь:-|: Как мне вот тут:rtfm::...

Как взять часть ссылки средствами JS и вставить эту часть в другую ссылку?
Приветствую! Опишу очень коротко проблему: Открывается страница с таким адресом: ...


Работа с другими AI-ассистентами



Claude Desktop - не единственный клиент для MCP, хотя документация создаёт именно такое впечатление. Я потратил неделю на интеграцию с Cursor IDE и обнаружил что каждый клиент понимает протокол по-своему, добавляя собственные особенности и ограничения.

Cursor поддерживает MCP нативно через свой конфигурационный файл. Находится он в .cursor/config.json внутри проекта или в глобальных настройках редактора. Структура похожа на Claude Desktop, но есть нюансы. Cursor запускает серверы в контексте проекта, поэтому относительные пути работают корректно - удобнее чем абсолютные. Другое отличие - серверы стартуют только когда открываешь проект, а не вместе с самим редактором.
JSON
1
2
3
4
5
6
7
8
9
10
11
{
  "mcp": {
    "servers": {
      "project-tools": {
        "command": "node",
        "args": ["./mcp-server/build/index.js"],
        "cwd": "${workspaceFolder}"
      }
    }
  }
}
Переменная ${workspaceFolder} раскрывается в путь к корню проекта - штука которой не хватает в Claude Desktop. Можно указывать пути относительно проекта и серверы работают независимо от того где физически лежит код. Continue.dev - еще один популярный AI-ассистент для VS Code с поддержкой MCP. Но их реализация отличается кардинально. Вместо stdio они используют HTTP-транспорт даже для локальных серверов. Запускают процесс сервера в фоне, подключаются к нему через localhost, держат соединение открытым. Мой stdio-сервер пришлось адаптировать, добавив HTTP-обёртку.

Собственные клиенты на основе MCP SDK дают максимальную гибкость. Я написал простой CLI-клиент для тестирования серверов локально, без GUI и лишних зависимостей. Запускаешь сервер через child_process, шлёшь JSON-RPC команды, получаешь ответы. Идеально для CI/CD пайплайнов и автоматического тестирования.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
 
const transport = new StdioClientTransport({
  command: 'node',
  args: ['./build/index.js']
});
 
const client = new Client({
  name: 'test-client',
  version: '1.0.0'
});
 
await client.connect(transport);
 
// Получаем список инструментов
const tools = await client.listTools();
console.log('Available tools:', tools);
 
// Вызываем инструмент
const result = await client.callTool('create_note', {
  title: 'Test',
  content: 'Content'
});
console.log('Result:', result);
Проблема совместимости всплывает когда разные клиенты интерпретируют спецификацию по-разному. Cursor ожидает что tools/list вернёт полный список сразу и кэширует его. Claude Desktop запрашивает список при каждой необходимости. Если сервер динамически меняет набор инструментов, Cursor может не увидеть изменения без перезапуска.

Я столкнулся с багом когда один клиент отправлял параметры инструмента как строку даже если схема требовала число. Сервер валидировал через Zod и падал с ошибкой валидации. Пришлось добавить принудительное преобразование типов перед валидацией - грязный хак но другого решения не нашел.

Версионирование capabilities становится критичным при работе с разными клиентами. Один поддерживает только базовые инструменты, другой умеет работать с промптами и ресурсами, третий добавил собственные расширения протокола. Сервер должен деградировать gracefully, предоставляя минимальную функциональность если клиент не поддерживает продвинутые фичи. Я проверяю capabilities клиента при инициализации и отключаю несовместимые возможности. Тестирование на разных клиентах обязательно если планируете публичный релиз сервера. У меня стоит набор автотестов которые гоняют сервер через разные клиенты и проверяют что базовая функциональность работает везде одинаково. Специфичные фичи тестируются отдельно для каждого клиента.

Отладка и мониторинг взаимодействия



Отладка MCP серверов - это отдельный круг ада для разработчиков. Первый раз когда мой сервер молча падал без объяснений, я потратил два часа на поиск проблемы. Console.log не работает - stdout занят протоколом, любой вывод туда ломает JSON-RPC. Debugger подключить нельзя - процесс запускается клиентом, не тобой. Остаётся только stderr и файлы, причём логирование нужно настраивать заранее, post-factum ничего не узнаешь.

Я сразу добавляю в каждый новый сервер модуль логирования который пишет в файл. Простая функция с timestamp'ом и уровнями debug/info/error спасает кучу времени. В разработке ставлю DEBUG, в продакшене только ERROR и выше. Формат JSON удобен для автоматического парсинга, plain text читается человеком проще.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// logger.ts
import fs from 'fs';
import path from 'path';
 
const LOG_FILE = process.env.MCP_LOG_FILE || '/tmp/mcp-server.log';
const LOG_LEVEL = process.env.LOG_LEVEL || 'info';
 
const levels = { debug: 0, info: 1, warn: 2, error: 3 };
 
export function log(level: keyof typeof levels, msg: string, meta?: any) {
  if (levels[level] < levels[LOG_LEVEL]) return;
  
  const entry = {
    ts: new Date().toISOString(),
    level,
    msg,
    ...meta && { meta }
  };
  
  // stderr для критичных ошибок
  if (level === 'error') {
    console.error(JSON.stringify(entry));
  }
  
  // файл для всего остального
  fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + '
');
}
 
// Отлавливаем необработанные исключения
process.on('uncaughtException', (error) => {
  log('error', 'Uncaught exception', { 
    message: error.message, 
    stack: error.stack 
  });
  process.exit(1);
});
Мониторинг JSON-RPC траффика помогает понять что именно клиент отправляет серверу. Я добавил middleware который логирует каждое входящее сообщение перед обработкой и каждый ответ перед отправкой. Видишь точный запрос с параметрами - проще понять почему упала валидация или вернулась ошибка.
Хитрый приём - перехватывать process.stdin и process.stdout через прокси-стримы. Читаешь из реального stdin, пишешь копию в лог, передаёшь дальше. То же самое с stdout в обратную сторону. Получается полная запись сессии которую можно replay для воспроизведения бага.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Transform } from 'stream';
 
const logStdin = new Transform({
  transform(chunk, encoding, callback) {
    log('debug', 'Received from client', { data: chunk.toString() });
    callback(null, chunk);
  }
});
 
const logStdout = new Transform({
  transform(chunk, encoding, callback) {
    log('debug', 'Sending to client', { data: chunk.toString() });
    callback(null, chunk);
  }
});
 
process.stdin.pipe(logStdin).pipe(actualStdin);
actualStdout.pipe(logStdout).pipe(process.stdout);
HTTP-серверы отлаживаются проще - можно юзать стандартные инструменты типа curl или Postman. Но SSE-стрим проверять сложнее, нужны специализированные клиенты или самописные скрипты. Я держу набор curl-команд для типичных сценариев - инициализация, вызов инструмента, чтение ресурса. Запускаю их перед коммитом чтобы убедится что ничего не сломал.
Метрики производительности собираю через простой wrapper вокруг обработчиков. Замеряю время выполнения каждого инструмента, считаю количество вызовов, отслеживаю ошибки. Раз в час сбрасываю статистику в файл, потом анализирую что тормозит и где узкие места.
Типичная проблема - race condition при конкурентных запросах. Клиент может отправить несколько вызовов инструментов одновременно, они начинают выполняться параллельно. Если инструменты модифицируют общее состояние без синхронизации, получается каша. Я добавил queue для критичных операций - только одна выполняется в момент времени, остальные ждут. Производительность упала но стабильность выросла, а это важнее.

Расширенные возможности и паттерны



Базовый CRUD-сервер работает, но это только начало. Когда я начал строить реальные интеграции, быстро понял что нужны более хитрые паттерны. Middleware для общей логики, композиция серверов для модульности, graceful degradation когда что-то падает. Дальше покажу приёмы которые сэкономили мне недели отладки.

Middleware-паттерн пришёл из Express.js но прекрасно работает и в MCP. Идея проста - обернуть обработчики инструментов в цепочку функций которые выполняются до и после основной логики. Логирование, валидация, проверка прав, кэширование - всё это middleware. Я написал общий wrapper который применяется ко всем инструментам автоматически:
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
type ToolHandler = (args: any) => Promise<any>;
type Middleware = (
  handler: ToolHandler,
  name: string,
  args: any
) => Promise<any>;
 
const middlewares: Middleware[] = [];
 
function use(middleware: Middleware) {
  middlewares.push(middleware);
}
 
// Логирование всех вызовов
use(async (handler, name, args) => {
  const start = Date.now();
  log('info', [INLINE]Calling ${name}[/INLINE], { args });
  
  try {
    const result = await handler(args);
    log('info', [INLINE]${name} completed[/INLINE], { 
      duration: Date.now() - start 
    });
    return result;
  } catch (error: any) {
    log('error', [INLINE]${name} failed[/INLINE], { 
      error: error.message,
      duration: Date.now() - start
    });
    throw error;
  }
});
 
// Применяем middleware при регистрации
function registerTool(name: string, handler: ToolHandler) {
  const wrapped = async (args: any) => {
    let fn = handler;
    
    // Оборачиваем в обратном порядке
    for (let i = middlewares.length - 1; i >= 0; i--) {
      const middleware = middlewares[i];
      const previousFn = fn;
      fn = (args) => middleware(previousFn, name, args);
    }
    
    return fn(args);
  };
  
  server.tool(name, /* ... */, wrapped);
}
Кэширование результатов - классический кейс для middleware. Если инструмент читает данные которые меняются редко, зачем дёргать БД или API каждый раз? Добавил простой in-memory кэш с TTL:
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const cache = new Map<string, { value: any; expires: number }>();
 
use(async (handler, name, args) => {
  // Только для read-операций
  if (!name.startsWith('get_') && !name.startsWith('list_')) {
    return handler(args);
  }
  
  const cacheKey = `${name}:${JSON.stringify(args)}`;
  const cached = cache.get(cacheKey);
  
  if (cached && cached.expires > Date.now()) {
    log('debug', 'Cache hit', { name, cacheKey });
    return cached.value;
  }
  
  const result = await handler(args);
  
  cache.set(cacheKey, {
    value: result,
    expires: Date.now() + 60000 // 1 минута
  });
  
  return result;
});
Композиция серверов позволяет разбить функциональность на модули. Вместо одного монолитного сервера делаешь несколько специализированных. Один для работы с файлами, другой для API, третий для БД. Claude Desktop запускает их все, каждый независим и изолирован. Я так разделил сервер мониторинга - metrics, logs и alerts живут отдельно, но работают вместе.
Rate limiting защищает от перегрузки когда модель спамит запросами. Простейшая реализация через счётчик вызовов за окно времени:
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const rateLimits = new Map<string, number[]>();
 
use(async (handler, name, args) => {
  const key = name;
  const now = Date.now();
  const window = 60000; // 1 минута
  const limit = 10; // максимум вызовов
  
  const calls = rateLimits.get(key) || [];
  const recentCalls = calls.filter(t => now - t < window);
  
  if (recentCalls.length >= limit) {
    throw new Error(`Rate limit exceeded for ${name}`);
  }
  
  recentCalls.push(now);
  rateLimits.set(key, recentCalls);
  
  return handler(args);
});
Длительные операции требуют отдельного подхода. Нельзя блокировать на минуты пока обрабатывается запрос - клиент может отвалиться по таймауту. Паттерн async job: возвращаешь jobId немедленно, операция выполняется в фоне, результат забирается отдельным запросом. Реализовал через Map с промисами:
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const jobs = new Map<string, Promise<any>>();
 
server.tool('start_long_task', /* ... */, async (args) => {
  const jobId = randomUUID();
  
  jobs.set(jobId, (async () => {
    // Долгая работа
    await processLargeFile(args.filename);
    return { status: 'completed' };
  })());
  
  return { jobId, status: 'started' };
});
 
server.tool('get_job_status', /* ... */, async ({ jobId }) => {
  const job = jobs.get(jobId);
  if (!job) return { status: 'not_found' };
  
  try {
    const result = await Promise.race([
      job,
      new Promise((_, reject) => 
        setTimeout(() => reject('pending'), 100)
      )
    ]);
    
    jobs.delete(jobId);
    return { status: 'completed', result };
  } catch {
    return { status: 'pending' };
  }
});
Circuit breaker спасает когда внешний сервис нестабилен. Если запросы к API падают несколько раз подряд, перестаём дёргать его на время - даём восстановиться. Состояния: closed (работает нормально), open (сломан, не пытаемся), half-open (проверяем восстановился ли).
Graceful degradation означает что сервер продолжает работать даже если часть функций недоступна. База данных упала - возвращаем кэшированные данные. API не отвечает - используем fallback-источник. Я добавил это в сервер новостей - если RSS фид недоступен, берём данные из локального бэкапа.

Работа с ресурсами и промптами



Ресурсы и промпты - это части MCP которые я долго игнорировал, считая второстепенными. Инструменты казались важнее - они выполняют действия, меняют данные, делают что-то полезное. Ресурсы просто отдают контент, промпты вообще выглядели как готовые текстовые шаблоны. Но когда разобрался, понял что недооценивал их возможности.

Ресурс в MCP - это именованный источник данных который сервер предоставляет клиенту. У каждого ресурса есть URI по которому его можно запросить, MIME-тип и содержимое. Клиент получает список доступных ресурсов, модель решает какие нужны для ответа на запрос, клиент читает их и передаёт модели как контекст. Звучит просто, но дьявол в деталях.

URI-схема полностью произвольная. Я использую префикс специфичный для домена - note:// для заметок, log:// для логов, doc:// для документации. После префикса идёт путь к конкретному ресурсу. Например note:///meeting-notes.md указывает на файл заметки, log:///2024/01/15/errors.log на лог-файл за конкретную дату.

Регистрация ресурсов делается через метод resource() у сервера. Первый параметр - URI-паттерн, второй - описание, третий - обработчик который возвращает содержимое. Паттерн может содержать параметры в фигурных скобках - doc:///{section}/{page} матчится на любые значения section и page.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
server.resource(
  "note:///{filename}",
  "Markdown заметка по имени файла",
  async (uri) => {
    // Извлекаем filename из URI
    const filename = uri.path.replace(/^\//, "");
    const filepath = path.join(NOTES_DIR, filename);
    
    try {
      const content = await fs.readFile(filepath, "utf-8");
      
      return {
        contents: [{
          uri: uri.toString(),
          mimeType: "text/markdown",
          text: content
        }]
      };
    } catch (error: any) {
      if (error.code === "ENOENT") {
        throw new Error(`Заметка не найдена: ${filename}`);
      }
      throw error;
    }
  }
);
Список доступных ресурсов возвращается через метод listResources(). Клиент вызывает его чтобы узнать что вообще есть на сервере. Модель видит описания ресурсов и сама решает нужно ли их читать для ответа на запрос пользователя.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
server.listResources(async () => {
  const files = await fs.readdir(NOTES_DIR);
  const mdFiles = files.filter(f => f.endsWith(".md"));
  
  return {
    resources: mdFiles.map(filename => ({
      uri: [INLINE]note:///${filename}[/INLINE],
      name: filename.replace(/\.md$/, ""),
      description: [INLINE]Заметка: ${filename}[/INLINE],
      mimeType: "text/markdown"
    }))
  };
});
Динамические ресурсы генерируются на лету при запросе. Вместо чтения файла можешь делать запрос к API, читать из базы, агрегировать данные. Я сделал ресурс для метрик мониторинга - при запросе metrics:///server/cpu сервер опрашивает систему и возвращает текущую загрузку процессора.

Бинарный контент передаётся через base64. Поле text заменяется на blob с закодированными данными. Я использую это для изображений в документации - модель может запросить картинку и получить её содержимое, хотя интерпретировать напрямую не может.

Промпты - это готовые шаблоны для взаимодействия с моделью. Сервер предлагает набор промптов, каждый с параметрами. Клиент подставляет значения и использует результат как часть контекста. Честно говоря использую их редко - большинство задач решается через инструменты и ресурсы. Но есть сценарии где промпты удобнее.

Регистрация промпта похожа на инструмент - имя, описание, схема аргументов и обработчик. Разница в том что промпт возвращает текст для модели, а не выполняет действие.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
server.prompt(
  "summarize_notes",
  "Создаёт промпт для саммаризации заметок по теме",
  {
    topic: z.string().describe("Тема для поиска заметок")
  },
  async ({ topic }) => {
    // Ищем релевантные заметки
    const files = await fs.readdir(NOTES_DIR);
    const relevant = [];
    
    for (const file of files) {
      const content = await fs.readFile(
        path.join(NOTES_DIR, file), 
        "utf-8"
      );
      
      if (content.toLowerCase().includes(topic.toLowerCase())) {
        relevant.push(content);
      }
    }
    
    const notesText = relevant.join("\n\n---\n\n");
    
    return {
      messages: [{
        role: "user",
        content: {
          type: "text",
          text: `Проанализируй следующие заметки по теме "${topic}" и составь краткое резюме:
 
${notesText}`
        }
      }]
    };
  }
);
Промпты полезны когда нужно сформировать сложный контекст из нескольких источников. Вместо того чтобы модель сама искала и читала ресурсы, промпт собирает всё в один запрос. Экономит токены и время.

Шаблонизация промптов через параметры даёт гибкость. Один промпт с разными аргументами генерирует разные контексты. У меня есть промпт для code review - принимает путь к файлу и тип проверки (security/performance/style), формирует соответствующий запрос.

Кэширование ресурсов критично для производительности. Если каждый раз читать файлы или дёргать API - будет медленно. Я добавил простой LRU-кэш который хранит последние N ресурсов в памяти. TTL настраивается отдельно для каждого типа ресурсов.

Подписка на изменения ресурсов реализуется через notifications. Когда файл меняется, сервер шлёт notifications/resources/list_changed и клиент может перезапросить список. FileSystemWatcher в Node.js отслеживает изменения автоматически, не нужно поллить директорию.

Ресурсы и промпты дополняют инструменты, давая модели больше контекста без явного вызова функций. Модель сама решает что читать и когда, а сервер предоставляет данные по запросу. Получается более естественное взаимодействие чем жёсткие API-вызовы.

Реализация инструментов и команд



Инструменты - это сердце MCP сервера, то ради чего всё затевается. Когда я писал первые серверы, делал инструменты интуитивно - что казалось нужным, то и добавлял. Через пару недель понял что половину функций модель не использует потому что описания невнятные, а другая половина работает криво из-за плохой валидации параметров. Пришлось переписывать с нуля, применяя паттерны которые сейчас покажу.

Хороший инструмент начинается с правильного имени. Не абстрактное execute_action или do_something, а конкретное create_note или send_email. Модель должна понимать что делает инструмент просто глядя на название. Я использую глагол в начале - create, read, update, delete, search, list. Дальше существительное - note, file, record. Получается self-documenting название которое не требует чтения описания. Описание инструмента пишется для модели, не для человека. Нужно объяснить когда использовать эту функцию, что она возвращает, какие есть ограничения. Я перестал писать общие фразы типа "работает с файлами" и начал быть конкретным: "создаёт новый markdown файл в указанной директории, возвращает полный путь или ошибку если файл уже существует".

Схема параметров через Zod даёт не только валидацию но и документацию. Каждому полю добавляю describe() с пояснением что туда передавать. Модель читает эти описания и формирует правильные аргументы.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
server.tool(
  "update_task_status",
  "Обновляет статус задачи в трекере. Используй когда пользователь хочет изменить состояние задачи.",
  {
    task_id: z.string()
      .regex(/^TASK-\d+$/)
      .describe("Идентификатор задачи в формате TASK-123"),
    status: z.enum(["todo", "in_progress", "review", "done"])
      .describe("Новый статус задачи"),
    comment: z.string()
      .optional()
      .describe("Опциональный комментарий к изменению")
  },
  async ({ task_id, status, comment }) => {
    // Проверяем существование задачи
    const task = await db.tasks.findOne({ id: task_id });
    if (!task) {
      throw new Error(`Задача ${task_id} не найдена`);
    }
    
    // Валидация переходов статуса
    const validTransitions = {
      todo: ["in_progress"],
      in_progress: ["review", "todo"],
      review: ["done", "in_progress"],
      done: []
    };
    
    if (!validTransitions[task.status]?.includes(status)) {
      throw new Error(
        [INLINE]Нельзя перейти из ${task.status} в ${status}[/INLINE]
      );
    }
    
    // Обновляем
    await db.tasks.updateOne(
      { id: task_id },
      { 
        $set: { status },
        $push: { 
          history: {
            timestamp: new Date(),
            from: task.status,
            to: status,
            comment
          }
        }
      }
    );
    
    return {
      content: [{
        type: "text",
        text: [INLINE]Задача ${task_id} переведена в статус ${status}[/INLINE]
      }]
    };
  }
);
Валидация параметров - это не только Zod-схема. Бизнес-логика требует дополнительных проверок которые схема не покрывает. Существование записи в БД, права доступа, корректность состояния - всё это проверяется в теле обработчика. Я возвращаю понятные ошибки вместо generic "validation failed", чтобы модель поняла что не так.

Идемпотентность инструментов экономит нервы при повторных вызовах. Если create_note с тем же названием вызвать дважды - первый раз создаст файл, второй вернёт ошибку "уже существует" или просто вернёт путь к существующему. Я добавил флаг overwrite для явного контроля поведения.

Композиция сложных операций из простых инструментов работает лучше чем один универсальный tool. Вместо manage_project с кучей параметров делаешь create_project, add_member, update_settings - модель сама выстраивает последовательность вызовов. Дебажить проще, ошибки локализуются, переиспользовать легче. Возвращаемые данные должны быть структурированными и полными. Не просто "успех" или "ошибка", а детальная информация что произошло. Я возвращаю объекты с полями status, message, data. Модель может использовать эти данные для формирования ответа пользователю или принятия решений о следующих действиях. Обработка длительных операций требует асинхронности без блокировки. Если инструмент обрабатывает большой файл или делает медленный API-запрос - возвращаешь job_id сразу, процесс идёт в фоне. Отдельный инструмент check_job_status проверяет прогресс. Модель может периодически опрашивать статус и информировать пользователя. Логирование внутри инструментов помогает отследить что пошло не так. Я логирую входные параметры, промежуточные состояния, результаты. При ошибке вижу полный контекст - когда вызвали, с какими аргументами, на каком шаге упало. Это особенно критично для production где воспроизвести баг сложно.

Тестирование инструментов делаю через mock-версии внешних зависимостей. База данных заменяется на in-memory объект, API-вызовы на фейковые ответы. Проверяю что инструмент корректно обрабатывает успешные сценарии, разные типы ошибок, edge cases типа пустых строк или огромных чисел. Автоматические тесты прогоняю перед каждым коммитом.

Управление состоянием и контекстом



Первая неделя работы с MCP научила меня жёсткому уроку - протокол stateless по дизайну. Каждый запрос самодостаточен, сервер не обязан помнить что было раньше. Это прекрасно для масштабирования и простоты, но ужасно когда нужно сохранить открытое соединение с базой или кэшировать результаты между вызовами. Я сделал сервер для работы с PostgreSQL который при каждом запросе открывал новое соединение - connection pool переполнялся за минуту под нагрузкой.

Состояние приходится хранить явно. Простейший вариант - Map в памяти процесса. Ключ - идентификатор сессии или клиента, значение - объект с данными. Работает для stdio где каждое соединение это отдельный процесс, всё живёт и умирает вместе. Но для HTTP-сервера обслуживающего много клиентов нужна явная изоляция по сессиям.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Простое хранилище состояния
interface SessionState {
  dbConnection?: any;
  cache: Map<string, any>;
  lastActivity: number;
}
 
const sessions = new Map<string, SessionState>();
 
function getSession(clientId: string): SessionState {
  if (!sessions.has(clientId)) {
    sessions.set(clientId, {
      cache: new Map(),
      lastActivity: Date.now()
    });
  }
  
  const session = sessions.get(clientId)!;
  session.lastActivity = Date.now();
  return session;
}
 
// Очистка неактивных сессий
setInterval(() => {
  const timeout = 30 * 60 * 1000; // 30 минут
  const now = Date.now();
  
  for (const [id, session] of sessions) {
    if (now - session.lastActivity > timeout) {
      // Закрываем ресурсы
      session.dbConnection?.close();
      sessions.delete(id);
    }
  }
}, 60000);
Идентификация клиента - отдельная задача. Stdio даёт изоляцию автоматом через процессы, но HTTP требует явного ID. Я генерирую случайный токен при первом запросе, возвращаю клиенту, он прикладывает его к каждому следующему вызову. JWT хорош когда нужны claims и подпись, простая строка справляется для базовых случаев.

Контекст для модели отличается от состояния сервера. Это информация которую модель должна помнить между сообщениями в рамках диалога. Пользователь спросил "покажи последние файлы", модель вызвала list_files и получила список. Дальше юзер говорит "открой первый" - модель должна помнить что было в том списке. MCP не даёт memory из коробки, это ответственность клиента или сервера.

Я реализовал контекст через structured messages. При вызове инструмента возвращаю не просто текст а объект с metadata. Клиент может сохранить эту мету и передать при следующем запросе если модель её запросит. Получается явный контекст управляемый приложением.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
server.tool("list_recent_files", /* ... */, async ({ count = 5 }) => {
  const files = await getRecentFiles(count);
  
  // Сохраняем список в сессии для следующих запросов
  const session = getSession(currentClientId);
  session.cache.set('last_file_list', files);
  
  return {
    content: [{
      type: "text",
      text: files.map((f, i) => `${i + 1}. ${f.name}`).join('\n')
    }],
    // Метаданные для клиента
    metadata: {
      files: files.map(f => ({ id: f.id, name: f.name }))
    }
  };
});
 
server.tool("open_file_by_index", /* ... */, async ({ index }) => {
  const session = getSession(currentClientId);
  const lastList = session.cache.get('last_file_list');
  
  if (!lastList) {
    throw new Error("Сначала получите список файлов");
  }
  
  const file = lastList[index - 1];
  if (!file) {
    throw new Error(`Файл с индексом ${index} не найден`);
  }
  
  const content = await fs.readFile(file.path, 'utf-8');
  return { content: [{ type: "text", text: content }] };
});
Персистентное состояние нужно когда сервер перезапускается но данные должны остаться. Redis, файловая система, SQLite - выбор зависит от объёма и требований. Я использую Redis для HTTP-серверов с множеством инстансов, файлы для локальных stdio-процессов где не нужна синхронизация между машинами.

Lifecycle управление критично для корректного освобождения ресурсов. При завершении сессии нужно закрыть БД-соединения, очистить кэш, завершить фоновые таски. Stdio-процессы умирают сами, но HTTP-серверы требуют явной логики cleanup при отключении клиента. Конкурентный доступ к состоянию создаёт race conditions если не синхронизировать. Два запроса параллельно модифицируют один cache entry - один затирает изменения другого. Async mutex или queue на критические секции решают проблему но добавляют сложность. Я стараюсь избегать shared mutable state где возможно, делая операции идемпотентными или используя copy-on-write.

Кэширование контекста и оптимизация памяти



Мой первый production MCP сервер сожрал всю память на машине за три часа работы. Начинал с 50MB, через час уже 500MB, к концу дня Node.js процесс весил 2.5GB и система начала свопить. Оказалось - я кэшировал абсолютно всё без ограничений и очистки. Каждый запрос добавлял данные в Map, ничего не удалялось. Классический memory leak который учат избегать на первом курсе, но я умудрился наступить на грабли в боевом коде.

Кэширование в MCP серверах балансирует между производительностью и потреблением памяти. Без кэша каждый запрос лезет в базу или файловую систему - медленно. С безлимитным кэшем процесс раздувается до гигабайтов - тоже плохо. Нужна стратегия которая держит горячие данные в памяти и выкидывает холодные.

LRU (Least Recently Used) - первое что приходит в голову. Фиксированный размер кэша, выкидываешь то что давно не использовалось. Проблема в том что size ограничивается количеством записей, а не объёмом памяти. Сто маленьких объектов и сто огромных - разница на порядки, но LRU видит просто "100 элементов". Я написал size-aware кэш который отслеживает примерный объём данных:
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class SizeAwareLRUCache<K, V> {
  private cache = new Map<K, { value: V; size: number; timestamp: number }>();
  private maxBytes: number;
  private currentBytes = 0;
 
  constructor(maxBytes: number) {
    this.maxBytes = maxBytes;
  }
 
  // Грубая оценка размера объекта в байтах
  private estimateSize(value: any): number {
    const json = JSON.stringify(value);
    return json.length * 2; // UTF-16, два байта на символ
  }
 
  set(key: K, value: V): void {
    const size = this.estimateSize(value);
    
    // Выкидываем старые записи пока не поместимся
    while (this.currentBytes + size > this.maxBytes && this.cache.size > 0) {
      const oldest = Array.from(this.cache.entries())
        .sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
      
      this.currentBytes -= oldest[1].size;
      this.cache.delete(oldest[0]);
    }
 
    this.cache.set(key, { value, size, timestamp: Date.now() });
    this.currentBytes += size;
  }
 
  get(key: K): V | undefined {
    const entry = this.cache.get(key);
    if (!entry) return undefined;
 
    // Обновляем timestamp при чтении
    entry.timestamp = Date.now();
    return entry.value;
  }
 
  clear(): void {
    this.cache.clear();
    this.currentBytes = 0;
  }
}
 
// Использование
const cache = new SizeAwareLRUCache<string, any>(50 * 1024 * 1024); // 50MB
Оценка размера через JSON.stringify неточная но быстрая. Для production можно использовать библиотеки типа object-sizeof которые обходят все поля рекурсивно, но это overhead на каждую вставку. Компромисс между точностью и скоростью зависит от задачи.

TTL (Time To Live) дополняет LRU стратегию. Данные протухают через время независимо от частоты использования. Я ставлю разные TTL для разных типов: конфиги живут минуты, списки файлов секунды, результаты тяжёлых вычислений часы.

Многоуровневый кэш разделяет горячие и холодные данные. L1 - маленький in-memory кэш для самого частого, L2 - Redis для теплых данных, L3 - файловая система или S3 для архива. Читаешь последовательно уровни, пишешь во все одновременно. Сложнее в реализации но гибче одноуровневой схемы.

Слабые ссылки (WeakMap в JS) автоматически очищают память когда объект больше не нужен. Но работают только для объектов как ключей, строки и числа не подходят. Я использую их для кэширования промежуточных результатов внутри одного запроса - после завершения обработки всё очищается само. Lazy loading откладывает загрузку тяжёлых данных до момента реального использования. Вместо того чтобы читать весь файл в память при старте, держишь только метаданные и читаешь содержимое по требованию. Экономит память когда половина ресурсов не нужна большую часть времени. Profiling показывает где реально течёт память. Chrome DevTools для Node.js умеет снимать heap snapshots - видишь что занимает место, какие объекты не освобождаются. Запускаю сервер под нагрузкой, беру несколько снапшотов через интервалы, сравниваю что растёт. Обычно виноваты event listeners которые забыли отписать или замыкания держащие большие объекты.

Мониторинг потребления памяти в production обязателен. process.memoryUsage() в Node.js показывает heapUsed и external память. Я логирую это каждую минуту и строю графики - видно когда начинается утечка задолго до краха системы. Alert при превышении порога даёт время разобраться до того как всё упадёт.

Подводные камни и решения реальных проблем



Документация MCP выглядит просто и понятно - запустил процесс, отправил JSON, получил ответ. Первые два сервера я написал за вечер и они даже работали. Но стоило запустить третий в production с реальной нагрузкой, как посыпалось всё. Зависания без причины, потерянные сообщения, случайные падения. Следующие две недели я провёл в дебаггере и логах, вылавливая баги которые никто не описывает в туториалах.

Буферизация stdio - первая ловушка куда попадают все. Операционная система не гарантирует что write() в stdout доставит данные атомарно. Большое сообщение разбивается на куски, приходит частями. Парсер на другой стороне получает половину JSON, пытается распарсить, падает с SyntaxError. У меня это проявилось при отправке больших ответов - результаты поиска с сотнями файлов. Первые несколько работали, потом начинались непредсказуемые ошибки.

Решение - буферизовать чтение до символа новой строки. Не парсишь что пришло сразу, а копишь в строку пока не увидишь \n. Звучит очевидно, но SDK от Anthropic это не делает автоматически, приходится оборачивать самому:
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import readline from 'readline';
 
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
  terminal: false
});
 
rl.on('line', (line) => {
  try {
    const message = JSON.parse(line);
    handleMessage(message);
  } catch (error) {
    log('error', 'Failed to parse message', { line, error });
  }
});
Дедлоки при синхронном чтении случаются чаще чем кажется. Клиент отправил запрос и ждёт ответ, читая из stdout. Сервер получил запрос, начал обработку, но внутри нужно вызвать другой инструмент. Отправляет вложенный запрос клиенту и... зависает. Оба читают и ждут ответа друг от друга. У меня так провалился сервер для работы с Git - один инструмент вызывал другой для проверки статуса репозитория перед коммитом.

Фикс - никогда не делать синхронных вызовов между сервером и клиентом внутри обработки запроса. Либо вся логика на стороне сервера, либо клиент сам выстраивает последовательность вызовов. Если нужна сложная оркестрация - возвращай промежуточный результат с указанием что делать дальше, пусть клиент решает.

Кодировки съедают время на отладку. Файл с кириллицей или эмодзи читается в UTF-8, пишется в stdout как Buffer, но где-то по пути превращается в Latin-1 и всё ломается. JSON.stringify не всегда корректно обрабатывает специальные символы если не указать явно encoding. Я потерял день выясняя почему русские заметки отображаются кракозябрами пока не добавил везде { encoding: 'utf8' }:
TypeScript
1
2
3
4
5
const content = await fs.readFile(filepath, { encoding: 'utf8' });
// Явная проверка что это валидный UTF-8
if (Buffer.from(content).toString('utf8') !== content) {
  throw new Error('Invalid UTF-8 encoding in file');
}
Race conditions проявляются при конкурентных запросах к shared state. HTTP-сервер получает два вызова одновременно, оба модифицируют одну и ту же запись в кэше. Результат непредсказуем - то работает, то нет. Stdio защищает естественной изоляцией процессов, HTTP требует явных локов. Я использую простой async mutex для критичных секций:
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class AsyncMutex {
  private locked = false;
  private queue: (() => void)[] = [];
 
  async acquire(): Promise<void> {
    return new Promise(resolve => {
      if (!this.locked) {
        this.locked = true;
        resolve();
      } else {
        this.queue.push(resolve);
      }
    });
  }
 
  release(): void {
    if (this.queue.length > 0) {
      const next = this.queue.shift()!;
      next();
    } else {
      this.locked = false;
    }
  }
}
 
const cacheMutex = new AsyncMutex();
 
async function updateCache(key: string, value: any) {
  await cacheMutex.acquire();
  try {
    cache.set(key, value);
  } finally {
    cacheMutex.release();
  }
}
Таймауты - вечная головная боль. Клиент ждёт ответ 30 секунд и отваливается, но сервер продолжает обрабатывать запрос минуты две. Ресурсы заняты, следующие запросы встают в очередь, всё тормозит. Claude Desktop имеет дефолтный таймаут который нигде не документирован - около 45 секунд. Если твой инструмент работает дольше - проблемы. Решаю через паттерн async job как писал выше, но это усложняет код.

Windows создаёт специфичные проблемы с путями и permissions. Backslashes в путях, case-insensitive файловая система, права доступа работают иначе чем в Unix. Я делаю кросс-платформенные пути через path.join() и path.resolve(), всегда нормализую перед использованием. Для критичных мест добавил явные проверки ОС:
TypeScript
1
2
3
4
5
6
const isWindows = process.platform === 'win32';
 
function normalizePath(p: string): string {
  const normalized = path.normalize(p);
  return isWindows ? normalized.toLowerCase() : normalized;
}
Orphan процессы остаются в памяти когда клиент падает аварийно. Stdio-серверы не получают сигнал о разрыве соединения, просто висят. Помогает watchdog - периодический пинг от клиента, если нет активности N секунд - сервер завершается сам. Грубо но работает.

Производительность и масштабирование



Нажмите на изображение для увеличения
Название: Что такое MCP сервер и как его создать 4.jpg
Просмотров: 133
Размер:	77.2 Кб
ID:	11313

Первый раз задумался о масштабировании когда мой MCP сервер начал обслуживать двадцать человек одновременно. До этого тестировал на себе - работало отлично. Но стоило подключить команду, начались тормоза. Запросы висели секундами, Claude Desktop жаловался на таймауты, пользователи матерились в чате. Оказалось - я запускал отдельный stdio-процесс на каждого клиента, каждый тянул свою копию в памяти, и железо просто не справлялось.

Stdio-транспорт имеет естественный предел масштабирования - количество процессов которые может поднять система. Linux комфортно живёт с сотнями процессов, но каждый жрёт базовые 50-100MB памяти плюс overhead на контекст. Сто клиентов - это 5-10GB только на процессы серверов, не считая самих приложений. На моём лаптопе с 16GB это критично, на сервере с 64GB терпимо, но не бесконечно.

HTTP-сервер масштабируется иначе - один процесс обслуживает всех. Node.js крутит event loop, обрабатывает запросы асинхронно, память растёт линейно с количеством активных соединений. Тысяча клиентов - это тысяча открытых SSE-стримов, каждый по паре килобайт буфера. Держится в пределах гигабайта если правильно настроить.

Горизонтальное масштабирование HTTP решается классически - ставишь nginx перед серверами, раскидываешь запросы round-robin или по нагрузке. Проблема в состоянии - если сессия привязана к конкретному инстансу, нельзя просто перекинуть клиента на другой. Я вынес state в Redis, теперь любой сервер видит данные любого клиента. Latency выросла на пару миллисекунд но масштабируемость появилась.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Sticky sessions через хэш client_id
upstream mcp_servers {
  ip_hash;  // или hash $http_x_client_id consistent;
  
  server 127.0.0.1:3001;
  server 127.0.0.1:3002;
  server 127.0.0.1:3003;
}
 
// В конфиге nginx
location /mcp {
  proxy_pass [url]http://mcp_servers;[/url]
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
}
Вертикальное масштабирование - добавление ресурсов одной машине - работает до определённого предела. Я апгрейдил сервер с 4 до 16 ядер, производительность выросла но не в четыре раза. Node.js однопоточный по природе, дополнительные ядра используются только если явно поднять кластер worker'ов. Cluster module помогает но добавляет сложности с shared state.

Connection pooling критичен когда сервер работает с базами или внешними API. Открывать новое соединение на каждый запрос - тратить десятки миллисекунд на TCP handshake и аутентификацию. Пул держит соединения открытыми, переиспользует между запросами. Я настроил pool size под реальную нагрузку - замерил сколько параллельных запросов бывает одновременно, добавил 20% запаса.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const pool = new Pool({
  host: 'localhost',
  database: 'mcp_data',
  max: 20, // максимум соединений
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});
 
// Запрос с автоматическим возвратом в пул
async function queryDB(sql: string, params: any[]) {
  const client = await pool.connect();
  try {
    const result = await client.query(sql, params);
    return result.rows;
  } finally {
    client.release(); // возвращаем в пул
  }
}
Bottleneck чаще всего оказывается не в MCP протоколе а в том что делает сервер. Парсинг больших JSON-файлов блокирует event loop на десятки миллисекунд. Синхронное чтение из файловой системы останавливает обработку других запросов. Криптография без crypto.subtle тоже CPU-intensive. Профилирование через perf на Linux показало что половина времени уходит на markdown-парсинг - вынес в worker thread, latency упала вдвое.

Метрики собираю не для красоты а для реальной диагностики. RPS, p95 latency, error rate, memory usage, CPU load - базовый набор. Дашборд в Grafana показывает что происходит в реальном времени. Когда latency взлетает - вижу коррелирует ли это с ростом RPS или проблема в конкретном инструменте. Алерты при превышении порогов дают время среагировать до полного краха.

Кэширование на уровне балансировщика экономит ресурсы серверов. Идемпотентные GET-запросы кэшируются nginx'ом или CDN'ом - сервер даже не получает повторные вызовы. Но для MCP это работает редко, большинство запросов уникальны или имеют side effects.

Обработка ошибок и восстановление



Мой первый production MCP сервер падал три раза в день без объяснений. Смотрю логи - пусто, смотрю stderr - пусто, только Claude Desktop молча отключается и всё. Выяснилось через неделю - я не обрабатывал исключения нигде, любая ошибка убивала процесс. База данных не ответила вовремя - упал. JSON не распарсился - упал. Файл не нашёлся - упал. Пришлось переписывать с нормальной обработкой ошибок на всех уровнях. Ошибки в MCP делятся на категории по источнику и способу обработки. Валидационные - когда клиент прислал кривые параметры, неправильный формат, отсутствующие поля. Их ловить легко, Zod делает за тебя. Инфраструктурные - БД недоступна, сеть упала, диск заполнен. Тут нужна retry-логика и fallback на альтернативные источники. Бизнес-логические - пользователь пытается удалить несуществующую запись или обновить заблокированный ресурс. Возвращаешь понятное сообщение что не так.

JSON-RPC определяет стандартные коды ошибок но их недостаточно. Код -32602 означает invalid params - понятно что параметры кривые, но какие именно? Я добавляю в поле data детали: какой параметр не прошёл валидацию, что ожидалось, что пришло. Модель видит это и может скорректировать запрос.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
server.tool("delete_note", /* ... */, async ({ filename }) => {
try {
  const filepath = path.join(NOTES_DIR, filename);
  await fs.access(filepath);
  await fs.unlink(filepath);
  
  return { content: [{ type: "text", text: [INLINE]Удалено: ${filename}[/INLINE] }] };
} catch (error: any) {
  // Разные типы ошибок обрабатываем по-разному
  if (error.code === 'ENOENT') {
    throw new Error(`Файл ${filename} не существует`);
  }
  
  if (error.code === 'EACCES') {
    throw new Error(`Нет прав на удаление ${filename}`);
  }
  
  // Неожиданная ошибка - логируем детально
  log('error', 'Delete failed', { filename, error: error.message, stack: error.stack });
  throw new Error(`Ошибка при удалении файла: ${error.message}`);
}
});
Retry с экспоненциальной задержкой спасает при временных сбоях внешних сервисов. Первая попытка упала - ждём секунду, пробуем снова. Вторая упала - ждём две секунды. Третья - четыре. После трёх-пяти попыток сдаёмся и возвращаем ошибку. Но делаю это только для идемпотентных операций чтения, писать дважды опасно.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
async function retryWithBackoff<T>(
fn: () => Promise<T>,
maxAttempts: number = 3
): Promise<T> {
let lastError: Error;
 
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
  try {
    return await fn();
  } catch (error: any) {
    lastError = error;
    
    // Не ретраим некоторые ошибки
    if (error.code === 'ENOENT' || error.message.includes('not found')) {
      throw error;
    }
    
    if (attempt < maxAttempts) {
      const delay = Math.pow(2, attempt - 1) * 1000;
      log('warn', `Attempt ${attempt} failed, retrying in ${delay}ms`, { error: error.message });
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}
 
throw lastError!;
}
 
// Использование
const data = await retryWithBackoff(() => 
fetchFromAPI('https://api.example.com/data')
);
Circuit breaker останавливает попытки когда сервис явно недоступен. Счётчик неудачных запросов достиг порога - переходим в open state, дальнейшие вызовы сразу возвращают ошибку без реальных попыток. Через timeout переходим в half-open, пробуем один запрос. Успех - закрываем breaker, неудача - обратно в open. Экономит ресурсы вместо бесконечных таймаутов.

Graceful degradation означает что сервер продолжает работать с ограниченной функциональностью когда что-то сломалось. База недоступна - отдаём данные из кэша с пометкой что они могут быть устаревшие. API не отвечает - используем локальные fallback-значения. Полная остановка только когда работать совсем невозможно. Я добавил health check эндпоинт который проверяет состояние зависимостей и возвращает статус сервера. Клиент периодически дёргает его и видит если что-то сломалось. Можно переключиться на резервный сервер пока основной чинится. В stdio это не работает, но для HTTP критично.

Логирование ошибок делаю на разных уровнях. Error для реальных проблем требующих внимания, warn для временных сбоев которые зарезолвились, info для контекста. Структурированный JSON-формат позволяет парсить логи автоматически и строить алерты по паттернам. Когда error rate превышает порог - получаю уведомление в Slack.

Версионирование и обратная совместимость



Версионирование в MCP кажется простым пока не столкнёшься с реальностью. Протокол обновился, добавили новые capabilities, а у половины пользователей старые клиенты которые новую функциональность не понимают. Мой сервер упал при первом же запросе от обновлённого Claude Desktop - он прислал поля которых я не ожидал, валидация сломалась, всё встало. Версия протокола указывается в initialize request как строка формата YYYY-MM-DD. Anthropic обновляет спецификацию, меняя дату. Клиент говорит какую версию поддерживает, сервер проверяет совместимость. Но реально версия это не гарантия - старые клиенты могут использовать новый протокол частично, новые могут игнорировать устаревшие фичи. Полагаться только на строку версии наивно.

Capabilities решают проблему гибче. При handshake стороны обмениваются списками возможностей которые поддерживают. Клиент может сказать "умею работать с resources и tools, промпты не поддерживаю". Сервер отвечает "у меня есть все три". Дальше общаются на пересечении - только то что понимают обе стороны. Я проверяю capabilities вручную и отключаю несовместимую функциональность:
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
server.onInitialize((params) => {
  const clientCaps = params.capabilities;
  
  // Проверяем нужные нам возможности
  const hasToolsSupport = clientCaps.tools !== undefined;
  const hasResourcesSupport = clientCaps.resources !== undefined;
  
  if (!hasToolsSupport) {
    log('warn', 'Client does not support tools, limited functionality');
    disableToolsFeatures();
  }
  
  // Возвращаем только то что клиент поймёт
  return {
    protocolVersion: params.protocolVersion,
    capabilities: {
      ...(hasToolsSupport && { tools: {} }),
      ...(hasResourcesSupport && { resources: {} })
    }
  };
});
Breaking changes избегаю добавлением новых полей вместо изменения существующих. Старое поле оставляю, помечаю deprecated, добавляю новое с правильным форматом. Клиенты мигрируют постепенно, никто не ломается одновременно. Через несколько месяцев когда все обновились, удаляю deprecated поле в следующей мажорной версии.

Семантическое версионирование самого сервера помогает коммуницировать изменения. Major - breaking changes в API инструментов. Minor - новые инструменты или параметры обратно совместимые. Patch - багфиксы без изменений интерфейса. Публикую changelog с каждым релизом, описывая что поменялось и как мигрировать.

Тестирование на разных версиях клиентов обязательно перед релизом. Я держу несколько версий Claude Desktop в Docker-контейнерах - последнюю стабильную, предыдущую, бета если есть. Прогоняю набор тестов на каждой, проверяю что ничего не сломалось. Звучит параноидально но спасло от публичного позора пару раз. Fallback механизмы активируются когда клиент не поддерживает новую фичу. Вместо ошибки возвращаю результат в старом формате или упрощённую версию функциональности. Модель может не получить все данные но хотя бы что-то работает. Graceful degradation важнее полного отказа. Первые три MCP сервера которые я написал, работали ровно до тех пор пока я не начал их реально использовать. В тестах всё было идеально, а в бою сыпались ошибки которых я не ожидал. Сейчас расскажу про грабли на которые наступают почти все новички, включая меня самого.

Логирование в stdout убивает протокол мгновенно. Это ошибка номер один. Написал console.log("Server started") для отладки - сервер сразу перестал отвечать. Всё потому что stdout занят JSON-RPC сообщениями, любой посторонний вывод туда ломает парсинг на другой стороне. Клиент получает строку "Server started" вместо валидного JSON и падает с ошибкой. Использовать нужно только stderr для логов или писать в файл. Я потерял час выясняя почему мой сервер "не запускается", а оказалось просто забыл убрать один console.log из прошлого коммита.

Не обработанные исключения роняют процесс бесшумно. Особенно коварно на Windows где нет нормальных логов по умолчанию. Инструмент упал с ошибкой - весь сервер завершился, Claude Desktop просто отключился без объяснений. Добавление глобальных обработчиков process.on('uncaughtException') и process.on('unhandledRejection') спасло кучу нервов:
TypeScript
1
2
3
4
5
6
7
8
9
10
11
process.on('uncaughtException', (error) => {
  // Логируем в stderr, не в stdout!
  console.error('Fatal error:', error);
  logToFile('uncaught_exception', error);
  process.exit(1);
});
 
process.on('unhandledRejection', (reason) => {
  console.error('Unhandled promise rejection:', reason);
  logToFile('unhandled_rejection', reason);
});
Относительные пути не работают. Сервер запускается не из своей папки а откуда-то из домашней директории клиента. Путь ./config.json не найдётся никогда. Использовать надо либо абсолютные пути через __dirname в Node.js, либо переменные окружения, либо передавать пути явно через конфиг клиента. У меня сервер молча стартовал но не находил файлы настроек - оказалось искал их не там.

Забыл закрыть соединение с базой. Открыл connection в начале обработки инструмента, получил данные, вернул результат. Соединение осталось висеть. Через десять вызовов connection pool переполнился, дальнейшие запросы зависают в ожидании свободного слота. Использовать try-finally или async context обязательно для гарантированного освобождения ресурсов.

Синхронные операции блокируют event loop. `fs.readFileSync()` вместо async версии останавливает обработку всех других запросов. Один клиент читает большой файл секунду - все остальные тупо ждут. В Node.js всё должно быть асинхронным, блокирующих вызовов избегать как чумы. Я поймал это только профайлером, когда увидел что сервер простаивает в чтении файла пока остальные запросы висят в очереди.

Валидация параметров только через схему недостаточна. Zod проверит что filename это строка, но не проверит что это не ../../etc/passwd. Path traversal и подобные атаки нужно отлавливать вручную. Я добавляю явные проверки на подозрительные паттерны после схемной валидации:
TypeScript
1
2
3
4
5
6
7
// Схема пропустит, но это опасно
const filename = args.filename; // "../../etc/passwd"
 
// Явная проверка безопасности
if (filename.includes('..') || path.isAbsolute(filename)) {
  throw new Error('Invalid filename: path traversal detected');
}
Не тестировал на реальных данных. В тестах использовал маленькие файлы по килобайту, а в продакшене прилетели логи на мегабайты. Парсинг JSON занял секунды, память взорвалась, всё упало. Тестировать нужно на данных реального объёма и сложности, иначе проблемы вылезут только на боевой нагрузке.

Большинство этих ошибок выявляются только при реальном использовании, юнит-тесты их не ловят. Поэтому я всегда гоняю новый сервер под нагрузкой в тестовом окружении перед деплоем, имитируя поведение реальных пользователей.

Стратегии тестирования MCP серверов



Тестирование MCP серверов - это не то же самое что тестирование REST API. Первые месяцы я писал обычные юнит-тесты как привык, проверял что функции возвращают правильные значения. В изоляции всё работало идеально, зелёные галочки по всему репозиторию. Запустил с реальным клиентом - половина сломалась. Проблема в том что MCP это не просто набор функций, это протокол взаимодействия между процессами, и тестировать нужно именно это взаимодействие.

Юнит-тесты полезны для бизнес-логики внутри инструментов. Проверяешь что парсинг файлов работает корректно, валидация отсекает кривые данные, вычисления дают правильный результат. Но они не ловят проблемы уровня протокола - некорректный JSON-RPC, неправильные типы сообщений, гонки при конкурентных запросах. Я держу coverage юнит-тестов выше 80% для критичной логики, но это лишь базовый уровень проверки.

Интеграционные тесты запускают реальный сервер и шлют ему JSON-RPC через stdio. Поднимаешь процесс, отправляешь initialize, ждёшь ответ, вызываешь инструменты. Проверяешь не только результат но и корректность протокольного обмена. Я написал тестовый клиент который имитирует поведение Claude Desktop:
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import { spawn } from 'child_process';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
 
describe('MCP Server Integration', () => {
  let serverProcess: any;
  let messageId = 0;
 
  beforeAll(() => {
    serverProcess = spawn('node', ['./build/index.js']);
  });
 
  afterAll(() => {
    serverProcess.kill();
  });
 
  async function sendRequest(method: string, params: any): Promise<any> {
    const id = ++messageId;
    const request = JSON.stringify({
      jsonrpc: '2.0',
      id,
      method,
      params
    }) + '
';
 
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => reject('Timeout'), 5000);
      
      serverProcess.stdout.once('data', (data: Buffer) => {
        clearTimeout(timeout);
        const lines = data.toString().split('
').filter(Boolean);
        const response = JSON.parse(lines[lines.length - 1]);
        
        if (response.error) reject(response.error);
        else resolve(response.result);
      });
 
      serverProcess.stdin.write(request);
    });
  }
 
  it('should initialize correctly', async () => {
    const result = await sendRequest('initialize', {
      protocolVersion: '2024-11-05',
      capabilities: {},
      clientInfo: { name: 'test', version: '1.0' }
    });
 
    expect(result.protocolVersion).toBe('2024-11-05');
    expect(result.capabilities).toBeDefined();
  });
 
  it('should list available tools', async () => {
    const tools = await sendRequest('tools/list', {});
    expect(Array.isArray(tools.tools)).toBe(true);
    expect(tools.tools.length).toBeGreaterThan(0);
  });
 
  it('should call tool with valid params', async () => {
    const result = await sendRequest('tools/call', {
      name: 'create_note',
      arguments: {
        title: 'Test Note',
        content: 'Test content'
      }
    });
 
    expect(result.content).toBeDefined();
    expect(result.content[0].text).toContain('создана');
  });
});
Нагрузочное тестирование показывает как сервер ведёт себя под реальной нагрузкой. Я запускаю несколько десятков параллельных клиентов, каждый шлёт запросы с разными интервалами. Смотрю на latency, throughput, использование памяти. Обычно выявляются утечки памяти, дедлоки при конкуренции, переполнение очередей. Простой скрипт на artillery или k6 справляется за пять минут настройки.

Тестирование ошибочных сценариев критично. Клиент присылает невалидный JSON - сервер должен вернуть корректную ошибку а не упасть. Параметры инструмента не проходят валидацию - понятное сообщение что не так. База данных недоступна - graceful fallback на кэш. Я специально ломаю зависимости в тестах чтобы проверить что сервер не рухнет полностью. Автоматизация через CI прогоняет все тесты при каждом пуше. GitHub Actions или GitLab CI поднимает окружение, запускает юнит и интеграционные тесты, проверяет покрытие, генерит отчёт. Если что-то красное - пулл-реквест не мержится. Это спасло от регрессий десятки раз, когда рефакторинг ломал существующую функциональность незаметно.

Ручное тестирование с реальным Claude Desktop остаётся финальной проверкой перед релизом. Автоматика не поймает UX проблемы - непонятные сообщения ошибок, медленный отклик, странное поведение в edge cases. Я трачу полчаса на реальную сессию с новой версией, пытаюсь сломать типичными действиями. Иногда нахожу баги которые никакой тест не выявил бы.

Демонстрационное приложение: полнофункциональный MCP сервер



Собрал всё что описал выше в один работающий сервер - систему управления проектами с полным набором функций. Не игрушечный пример а реальное решение которое можно использовать для отслеживания задач, управления документацией и мониторинга статуса проектов. Делал его для себя изначально, потом понял что отличная демонстрация всех возможностей MCP.

Архитектура построена на модульном принципе. Три основных домена - задачи, документы и уведомления. Каждый живёт в своём файле с собственными инструментами и ресурсами. Центральный сервер координирует работу модулей, управляет состоянием, обрабатывает ошибки. Получилось около 800 строк TypeScript кода который демонстрирует практически все паттерны из статьи.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// src/index.ts - главный файл сервера
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { taskModule } from "./modules/tasks.js";
import { docModule } from "./modules/docs.js";
import { notificationModule } from "./modules/notifications.js";
import { StateManager } from "./state/manager.js";
import { Logger } from "./utils/logger.js";
 
const logger = new Logger('project-mcp');
const state = new StateManager();
 
const server = new McpServer({
  name: "project-management-mcp",
  version: "1.0.0",
  capabilities: {
    tools: {},
    resources: {},
    prompts: {}
  }
});
 
// Регистрируем модули
taskModule.register(server, state, logger);
docModule.register(server, state, logger);
notificationModule.register(server, state, logger);
 
// Глобальная обработка ошибок
process.on('uncaughtException', (error) => {
  logger.error('Uncaught exception', { error: error.message, stack: error.stack });
  process.exit(1);
});
 
process.on('unhandledRejection', (reason) => {
  logger.error('Unhandled rejection', { reason });
});
 
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  
  logger.info('Project Management MCP Server started');
  
  // Периодическая очистка устаревшего состояния
  setInterval(() => {
    state.cleanup();
  }, 60000);
}
 
main().catch((error) => {
  logger.error('Fatal error', { error });
  process.exit(1);
});
Модуль задач показывает как правильно организовать инструменты с валидацией, кэшированием и обработкой ошибок. Статусы задач храню в enum, переходы между ними валидирую явно. История изменений записывается автоматически при каждом обновлении - полезно для аудита.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
// src/modules/tasks.ts
import { z } from "zod";
 
const TaskStatus = z.enum(['backlog', 'todo', 'in_progress', 'review', 'done']);
const TaskPriority = z.enum(['low', 'medium', 'high', 'critical']);
 
export const taskModule = {
  register(server: any, state: any, logger: any) {
    
    server.tool(
      "create_task",
      "Создаёт новую задачу в проекте с указанными параметрами",
      {
        title: z.string().min(3).max(200)
          .describe("Заголовок задачи"),
        description: z.string().optional()
          .describe("Подробное описание задачи"),
        priority: TaskPriority.default('medium')
          .describe("Приоритет: low, medium, high, critical"),
        assignee: z.string().optional()
          .describe("Кому назначена задача"),
        tags: z.array(z.string()).default([])
          .describe("Теги для категоризации")
      },
      async (args) => {
        logger.info('Creating task', { title: args.title });
        
        try {
          const taskId = `TASK-${Date.now()}`;
          const task = {
            id: taskId,
            status: 'backlog',
            createdAt: new Date().toISOString(),
            history: [],
            ...args
          };
          
          // Сохраняем в состояние с кэшированием
          await state.tasks.set(taskId, task);
          
          // Уведомление если назначен исполнитель
          if (args.assignee) {
            await state.notifications.add({
              type: 'task_assigned',
              taskId,
              assignee: args.assignee,
              timestamp: new Date().toISOString()
            });
          }
          
          return {
            content: [{
              type: "text",
              text: `Задача ${taskId} создана успешно
Статус: backlog
Приоритет: ${args.priority}`
            }]
          };
        } catch (error: any) {
          logger.error('Failed to create task', { error: error.message, args });
          throw new Error(`Ошибка создания задачи: ${error.message}`);
        }
      }
    );
 
    server.tool(
      "update_task_status",
      "Обновляет статус задачи с проверкой валидности перехода",
      {
        taskId: z.string()
          .regex(/^TASK-\d+$/)
          .describe("Идентификатор задачи"),
        status: TaskStatus
          .describe("Новый статус задачи"),
        comment: z.string().optional()
          .describe("Комментарий к изменению")
      },
      async (args) => {
        const task = await state.tasks.get(args.taskId);
        
        if (!task) {
          throw new Error(`Задача ${args.taskId} не найдена`);
        }
        
        // Валидация переходов статуса
        const validTransitions: Record<string, string[]> = {
          backlog: ['todo'],
          todo: ['in_progress', 'backlog'],
          in_progress: ['review', 'todo'],
          review: ['done', 'in_progress'],
          done: ['todo']  // реопен
        };
        
        const allowed = validTransitions[task.status] || [];
        if (!allowed.includes(args.status)) {
          throw new Error(
            [INLINE]Нельзя перевести задачу из ${task.status} в ${args.status}. [/INLINE] +
            `Доступны: ${allowed.join(', ')}`
          );
        }
        
        // Записываем в историю
        task.history.push({
          timestamp: new Date().toISOString(),
          from: task.status,
          to: args.status,
          comment: args.comment
        });
        
        task.status = args.status;
        task.updatedAt = new Date().toISOString();
        
        await state.tasks.set(args.taskId, task);
        
        logger.info('Task status updated', { 
          taskId: args.taskId, 
          from: task.history[task.history.length - 1].from,
          to: args.status 
        });
        
        return {
          content: [{
            type: "text",
            text: [INLINE]Статус задачи ${args.taskId} изменён: ${task.status} → ${args.status}[/INLINE]
          }]
        };
      }
    );
 
    // Ресурс для получения полной информации о задаче
    server.resource(
      "task:///{taskId}",
      "Детальная информация о задаче включая историю изменений",
      async (uri: any) => {
        const taskId = uri.path.replace(/^\//, '');
        const task = await state.tasks.get(taskId);
        
        if (!task) {
          throw new Error(`Задача ${taskId} не найдена`);
        }
        
        const content = `# ${task.title}
 
[B]ID:[/B] ${task.id}
[B]Статус:[/B] ${task.status}
[B]Приоритет:[/B] ${task.priority}
[B]Создана:[/B] ${task.createdAt}
${task.assignee ? `[B]Исполнитель:[/B] ${task.assignee}` : ''}
${task.tags.length > 0 ? `[B]Теги:[/B] ${task.tags.join(', ')}` : ''}
 
[H2]Описание[/H2]
 
${task.description || 'Нет описания'}
 
[H2]История изменений[/H2]
 
${task.history.map((h: any) => 
  [INLINE]- ${h.timestamp}: ${h.from} → ${h.to}${h.comment ? [/INLINE] (${h.comment})` : ''}`
).join('
')}`;
        
        return {
          contents: [{
            uri: uri.toString(),
            mimeType: "text/markdown",
            text: content
          }]
        };
      }
    );
  }
};
State manager инкапсулирует работу с данными и кэшированием. Использую size-aware LRU кэш из прошлой главы плюс периодическая очистка неактивных записей. Все операции логируются для отладки, критичные данные персистятся в файловую систему чтобы не терять при перезапуске сервера.
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// src/state/manager.ts
import fs from 'fs/promises';
import path from 'path';
 
export class StateManager {
  private tasks = new Map<string, any>();
  private docs = new Map<string, any>();
  private notifications: any[] = [];
  private cache: any;
  private stateDir = process.env.STATE_DIR || './.mcp-state';
  
  constructor() {
    this.cache = new SizeAwareLRUCache(50 * 1024 * 1024); // 50MB
    this.loadState();
  }
  
  async loadState() {
    try {
      await fs.mkdir(this.stateDir, { recursive: true });
      
      const tasksFile = path.join(this.stateDir, 'tasks.json');
      if (await fs.access(tasksFile).then(() => true).catch(() => false)) {
        const data = await fs.readFile(tasksFile, 'utf-8');
        const tasks = JSON.parse(data);
        Object.entries(tasks).forEach(([id, task]) => {
          this.tasks.set(id, task);
        });
      }
    } catch (error) {
      console.error('Failed to load state:', error);
    }
  }
  
  async saveState() {
    try {
      const tasksFile = path.join(this.stateDir, 'tasks.json');
      const tasksObj = Object.fromEntries(this.tasks.entries());
      await fs.writeFile(tasksFile, JSON.stringify(tasksObj, null, 2));
    } catch (error) {
      console.error('Failed to save state:', error);
    }
  }
  
  get tasks() {
    return {
      get: async (id: string) => {
        // Проверяем кэш сначала
        const cached = this.cache.get(`task:${id}`);
        if (cached) return cached;
        
        const task = this.tasks.get(id);
        if (task) {
          this.cache.set(`task:${id}`, task);
        }
        return task;
      },
      
      set: async (id: string, task: any) => {
        this.tasks.set(id, task);
        this.cache.set(`task:${id}`, task);
        await this.saveState();
      },
      
      list: async (filter?: any) => {
        const allTasks = Array.from(this.tasks.values());
        if (!filter) return allTasks;
        
        return allTasks.filter(task => {
          if (filter.status && task.status !== filter.status) return false;
          if (filter.assignee && task.assignee !== filter.assignee) return false;
          if (filter.priority && task.priority !== filter.priority) return false;
          return true;
        });
      }
    };
  }
  
  cleanup() {
    // Очищаем уведомления старше суток
    const dayAgo = Date.now() - 24 * 60 * 60 * 1000;
    this.notifications = this.notifications.filter(n => 
      new Date(n.timestamp).getTime() > dayAgo
    );
  }
}
Приложение работает как единая система. Создали задачу - она сохранилась в state и закэшировалась. Обновили статус - история записалась автоматически, уведомление ушло если нужно. Запросили ресурс - получили полную информацию в читаемом markdown формате. Всё логируется, все ошибки обрабатываются, память контролируется через LRU кэш.

Запускается стандартно - собрал через tsc, добавил в конфиг Claude Desktop, готово. За неделю использования нашёл пару багов которые в тестах не вылезли, но в целом работает стабильно. Обрабатывает десятки запросов в день без утечек памяти или падений. Это реальный рабочий инструмент, не просто демонстрация возможностей протокола.

Модуль документации показывает работу с файлами и полнотекстовым поиском. Храню документы в markdown, индексирую содержимое для быстрого поиска, версионирую изменения. Поиск реализовал через простой inverted index - собираю все слова из документов, для каждого слова храню список файлов где оно встречается. Работает быстрее чем grep по всей директории на каждый запрос.
[TS]
// src/modules/docs.ts
export const docModule = {
register(server: any, state: any, logger: any) {

server.tool(
"search_docs",
"Полнотекстовый поиск по документации проекта",
{
query: z.string().min(2)
.describe("Поисковый запрос"),
limit: z.number().min(1).max(50).default(10)
.describe("Максимум результатов")
},
async (args) => {
logger.info('Searching docs', { query: args.query });

// Ищем в индексе
const results = await state.docs.search(args.query, args.limit);

if (results.length === 0) {
return {
content: [{
type: "text",
text: Документы по запросу "${args.query}" не найдены
}]
};
}

const formatted = results.map((r: any) =>
`${r.title} (релевантность: ${Math.round(r.score * 100)}%)
${r.excerpt}...

Как задеплоить проект на гитхаб, если у меня есть фронтенд часть на реакте и бэкенд часть на nodeJS?
по сути проект в котором две паки одна фронтенд, другае бэкенд.. может дадите линки на видео...

Что такое router-view и как его переделать на nuxt ?
Дали задачку перенести сайт с vue на nuxt Теперь подробнее. Есть у нас сайт написанный на vueJS...

Что такое метод super и как его использовать?
если возможно, с примерами

“Сжать” список, переместив все ненулевые элементы в левую часть списка, не меняя их порядок, а все нули - в правую часть
Дан список целых чисел. Требуется “сжать” его, переместив все ненулевые элементы в левую часть...

В PyCharm'e при рандоной перемещке часть букв - на одной строке, часть - на другой
Здравствуйте, помогите, пожалуйста. Не могу понять, почему в строке &quot;угадай слово&quot;(буквы намеренно...

Создать сервер, запустить сервер и файл index.html
здравствуйте, на локальном ПК файл index.html находится: patch_server = r'i:/re/index.html' далее...

Найдите ближайшее большее число m такое, что сумма его цифр была строго больше суммы цифр числа n
Решите задачу одним циклом for, допускается применение условных операторов. Задано пятизначное...

Массив чисел, где есть такое число, что его сумма цифр равна сумме чисел после него
Напишите функцию task4, которая принимает массив чисел, где есть такое число, что сумма чисел в...

Что такое stretch и с чем его едят?
Всем привет, изучаю PyQt5. Всю голову сломал уже. Что делает метод addStretch и setStretch в...

Найти наименьшее натуральное число Q такое, что произведение его цифр равно заданному числу N
Требуется найти наименьшее натуральное число Q такое, что произведение его цифр равно заданному...

Возможно ли такое? Что бы на сайте был не только on-line калькулятор, но и что бы и формировались печатные формы.
Здрасте! Подскажите пожалуйста, возможно ли такое? Что бы на сайте был не только on-line...

Django: Что это такое вообще? Что я пропустил в изучении Python?
Какой язык используется в фигурных скобках? Это разве python? Если кто знает то дайте мне ссылку...

Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 0
Комментарии
 
Новые блоги и статьи
20. Мат мед. Абсентеизм как отдельный тип простоя
anaschu 29.05.2026
Апдейт модели: исправленные баги, абсентеизм и новые механизмы Продолжаю развивать ранее описанную модель рабочего коллектива на AnyLogic. За последние несколько дней был проведён серьёзный. . .
19. здоровье, усталость и психотип работника влияют на производительность предприятия, и наоборот, производительность на здоровье, усталось и психотип
anaschu 28.05.2026
Дискретно-событийная модель рабочего коллектива на AnyLogic: здоровье, выгорание, психотипы и микростимуляция Привет, коллеги. Хочу поделиться итогами нескольких недель работы над симуляционной. . .
"Прокси" для последовательного порта
Eddy_Em 28.05.2026
Эту штуку написал я достаточно давно. Но сейчас вот понадобилось настроить датчик грозы, но при этом не отключать его от "метеодемона". Соответственно, надо запустить этот "прокси": метеодемон будет. . .
Рефакторинг программы уравнивания.
Massaraksh7 26.05.2026
Пример по предыдущей записи в блоге. Но, надо заметить, что, во-первых, там оптимизация не только математики, но и работы с базой данных, и с графами, а во-вторых, это ещё не всё.
Использование TThread в Lazarus для математических вычислений.
Massaraksh7 25.05.2026
Производя рефакторинг своих программ на предмет ускорения их работы, обратил внимание на такой аспект, как сокращение времени матвычислений. Дело в том, что приходится работать с большими матрицами. . .
Модель здравосохранения 18. Чем здоровее работник, тем быстрее выгорает
anaschu 24.05.2026
Имитационная модель корпоративного здравоохранения: что показывает математика Сегодня в модели рабочего коллектива на AnyLogic появились три новые механики — выгорание через накопленную усталость,. . .
Модель здравосохранения 17. Планы на выгорание
anaschu 23.05.2026
Вот конкретная схема реализации: В классе Работник добавить: накопленнаяУсталость — растёт каждый час работы, снижается в перерывы и болезни коэффициентПрезентеизма — снижает продуктивность. . .
Изменение цветов в палитре gif файла aka фавикона
russiannick 23.05.2026
Изменение цветов в палитре gif файла, юзаемого как фавиконка в составе html-файла, помещенная в base64, средствами нативного Java Script, навеянное сном в майский день. Для работы необходим браузер,. . .
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2026, CyberForum.ru