Содержание

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

Когда писать свой, а когда взять готовый

Ситуация Решение
Нужен GitHub / Postgres / Slack / Jira Готовый из реестра
Внутренний REST API без OpenAPI Свой
Локальная утилита (CLI, скрипт) с особым интерфейсом Свой
База данных с нестандартной авторизацией Свой (или fork готового)
Один-два простых вызова — можно скриптом Skill или Bash, не MCP

Свой MCP оправдан, когда инструмент будет переиспользоваться: команда, несколько проектов, регулярные задачи. Для разовой задачи проще написать обычный скрипт и звать его через Bash.

Что мы построим

Сервер docs-search — ищет по локальным .md файлам в папке. Два инструмента:

  • search_docs(query, limit) — полнотекстовый поиск, возвращает топ-N совпадений со сниппетами
  • read_doc(path) — читает конкретный файл

Реальный кейс: внутренние команды держат wiki в /docs/ репозитория, и Claude должен уметь по ней шерстить, не загружая всё в контекст.

Шаг 1. Скелет проекта

mkdir mcp-docs-search && cd mcp-docs-search
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init

В package.json добавь:

{
  "type": "module",
  "bin": {
    "mcp-docs-search": "./dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts"
  }
}

В tsconfig.json важные опции:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true
  }
}

Шаг 2. Минимальный сервер

// src/index.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

const server = new Server(
  { name: "docs-search", version: "0.1.0" },
  { capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "ping",
      description: "Проверка связи. Возвращает pong.",
      inputSchema: { type: "object", properties: {} },
    },
  ],
}));

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  if (req.params.name === "ping") {
    return { content: [{ type: "text", text: "pong" }] };
  }
  throw new Error(`Unknown tool: ${req.params.name}`);
});

const transport = new StdioServerTransport();
await server.connect(transport);

Запусти npm run dev — сервер ждёт сообщения на stdin. Для теста есть отдельный инструмент (см. шаг 5), пока продолжаем.

Шаг 3. Реальные инструменты

Заменим ping на полезные search_docs и read_doc. Для поиска возьмём простую реализацию через grep-style проход — для тысяч файлов хватит, под бóльшие объёмы потом подменишь на индекс.

// src/index.ts (полная версия)
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { readFile } from "node:fs/promises";
import { glob } from "node:fs/promises";
import { resolve, relative } from "node:path";

const DOCS_DIR = resolve(process.env.DOCS_DIR ?? "./docs");

const server = new Server(
  { name: "docs-search", version: "0.1.0" },
  { capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "search_docs",
      description:
        "Ищет по содержимому всех .md файлов в DOCS_DIR. Возвращает совпадения с фрагментом контекста.",
      inputSchema: {
        type: "object",
        properties: {
          query: { type: "string", description: "Поисковая фраза" },
          limit: { type: "number", default: 5, description: "Макс число результатов" },
        },
        required: ["query"],
      },
    },
    {
      name: "read_doc",
      description: "Читает файл из DOCS_DIR по относительному пути.",
      inputSchema: {
        type: "object",
        properties: {
          path: { type: "string", description: "Относительный путь к .md" },
        },
        required: ["path"],
      },
    },
  ],
}));

async function searchDocs(query: string, limit = 5) {
  const q = query.toLowerCase();
  const results: Array<{ path: string; snippet: string }> = [];

  for await (const file of glob("**/*.md", { cwd: DOCS_DIR })) {
    const full = resolve(DOCS_DIR, file);
    const text = await readFile(full, "utf8");
    const lower = text.toLowerCase();
    const idx = lower.indexOf(q);
    if (idx === -1) continue;

    const start = Math.max(0, idx - 80);
    const end = Math.min(text.length, idx + q.length + 80);
    results.push({
      path: relative(DOCS_DIR, full),
      snippet: text.slice(start, end).replace(/\s+/g, " ").trim(),
    });
    if (results.length >= limit) break;
  }
  return results;
}

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  const { name, arguments: args } = req.params;

  if (name === "search_docs") {
    const { query, limit = 5 } = args as { query: string; limit?: number };
    const results = await searchDocs(query, limit);
    return {
      content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
    };
  }

  if (name === "read_doc") {
    const { path } = args as { path: string };
    const full = resolve(DOCS_DIR, path);
    if (!full.startsWith(DOCS_DIR)) throw new Error("Path traversal blocked");
    const text = await readFile(full, "utf8");
    return { content: [{ type: "text", text }] };
  }

  throw new Error(`Unknown tool: ${name}`);
});

await server.connect(new StdioServerTransport());
⚠️

Проверка full.startsWith(DOCS_DIR) — критична. Без неё read_doc с path: "../../etc/passwd" прочитает что угодно. MCP-сервер запускается с правами твоего пользователя.

Шаг 4. Подключение к Claude Code

Скомпилируй: npm run build. Получишь dist/index.js. Зарегистрируй в .mcp.json проекта (как и любой сервер из обзора MCP, он станет доступен всей команде; базовая настройка Claude Code — здесь):

{
  "mcpServers": {
    "docs-search": {
      "command": "node",
      "args": ["./mcp-docs-search/dist/index.js"],
      "env": {
        "DOCS_DIR": "./docs"
      }
    }
  }
}

Перезапусти Claude Code — внутри сессии набери /mcp и убедись, что docs-search показывает 2 инструмента.

Теперь работают запросы вроде:

Найди в наших доках всё про деплой через staging и собери чеклист

Claude вызовет search_docs("деплой staging") → возьмёт топ-5 файлов → при необходимости дочитает целиком через read_doc.

Шаг 5. Отладка через MCP Inspector

Запускать Claude Code на каждый чих — медленно. Для итерации есть Inspector — UI, который показывает запросы/ответы сервера в реальном времени:

npx @modelcontextprotocol/inspector node ./dist/index.js

Откроется браузер. Слева список tools, справа — форма для запуска и raw JSON ответа. Видно полный фрейм MCP-протокола, ошибки, тайминги.

💡

Inspector — стандарт де-факто для разработки MCP. Перед коммитом прогоняй через него все tools с edge-кейсами: пустой query, несуществующий path, очень большой результат.

Шаг 6. Логирование

stdout зарезервирован под протокол — твои console.log сломают парсер на стороне Claude. Логи пиши в stderr или файл:

function log(msg: string, data?: unknown) {
  process.stderr.write(`[docs-search] ${msg} ${data ? JSON.stringify(data) : ""}\n`);
}

log("server started", { docsDir: DOCS_DIR });

В Claude Code логи MCP-серверов видны через claude mcp logs docs-search.

Шаг 7. Публикация для команды

Если сервер полезен не только тебе, опубликуй в npm:

# package.json
{
  "name": "@your-org/mcp-docs-search",
  "version": "0.1.0",
  "bin": { "mcp-docs-search": "./dist/index.js" }
}

npm publish --access=public

Теперь в .mcp.json твоей команды:

{
  "mcpServers": {
    "docs-search": {
      "command": "npx",
      "args": ["-y", "@your-org/mcp-docs-search"],
      "env": { "DOCS_DIR": "./docs" }
    }
  }
}

Никто ничего не клонирует — npx подтянет пакет при первом запуске.

Что ещё умеет MCP

Мы делали только tools, но протокол шире:

Capability Когда использовать
Tools Действия с побочным эффектом (поиск, write, API call)
Resources Read-only данные, которые модель может «прочитать» (файлы, БД-строки)
Prompts Готовые шаблоны промптов с параметрами
Sampling Сервер просит модель выполнить под-задачу

Resources хорошо ложатся на «список открытых тикетов» или «список миграций» — ты выдаёшь URI, Claude сам решает когда подгрузить. Prompts удобны для шаблонов вроде «code review по нашим правилам» — но это часто перекрывается Skills.

Итого

  • Свой MCP оправдан, когда инструмент будет переиспользоваться
  • Скелет на TypeScript: @modelcontextprotocol/sdk + StdioServerTransport + два хендлера
  • Безопасность: всегда валидируй пути и не доверяй входным аргументам
  • Логи — только в stderr, иначе сломаешь протокол
  • Отладка — через @modelcontextprotocol/inspector
  • Шаринг с командой — публикация в npm + .mcp.json через npx
  • Tools покрывают 80% кейсов; resources/prompts — когда нужен read-only доступ или шаблоны
Поделиться: