balkhaev.com Блог · AI-агенты и автоматизации

Корпоративный AI-агентчасть 4 из 9

MCP: подключаем внутренние системы

Выносим инструменты агента в отдельные MCP-серверы, пишем свой сервер для базы компании и учим агента подключаться к любым серверам из конфига.

обновлено 7 мин чтенияagentsmcppractice

Корпоративный AI-агентчасть 4 из 9
  1. 01Концепция: агент, который знает компанию
  2. 02Первый разговор с моделью
  3. 03Агентный цикл и инструменты
  4. 04MCP: подключаем внутренние системы
  5. 05Знания компании: поиск по документам
  6. 06Права доступа: SSO, OAuth и агент от имени сотрудника
  7. 07Контекст и деньги: кэширование, сжатие истории и лимиты
  8. 08Интерфейс: веб-чат и бот в Slack
  9. 09В прод: периметр, аудит и проверки

В прошлой части инструменты агента жили прямо в его коде. Для учебного агента это нормально, для корпоративного нет. Систем в компании десятки, у каждой свои доступы, своя команда и свой темп изменений. Если всё это зашить в один сервис, он превратится в монолит, который никто не решится трогать.

Что такое MCP

Model Context Protocol это открытый протокол между агентом и источниками инструментов. Его придумали в Anthropic, а теперь поддерживают Claude, ChatGPT, Cursor, VS Code и большинство агентных фреймворков.

Устроено просто:

  • MCP-сервер знает одну систему: базу данных, вики, трекер задач. Он отдаёт список инструментов с описаниями и JSON-схемами и выполняет их по запросу.
  • MCP-клиент живёт внутри агента. Он подключается к серверам, забирает списки инструментов, показывает их модели и пересылает вызовы.
  • Транспорт бывает двух видов: stdio, когда клиент запускает сервер как дочерний процесс и общается через stdin/stdout, и HTTP, когда сервер работает отдельно и к нему ходят по сети. Сегодня используем stdio, а HTTP понадобится в части про права доступа.

Используем текущую ревизию протокола 2026-07-28 и вторую версию TypeScript SDK. В ней SDK разделён на пакеты @modelcontextprotocol/server и @modelcontextprotocol/client, а сам протокол стал работать без состояния: каждый запрос несёт версию протокола и данные клиента, поэтому сервер можно масштабировать как обычный HTTP-сервис. Клиент договаривается о версии сам и умеет говорить и со старыми серверами 2025 года.

Кроме инструментов сервер может отдавать ресурсы (документы для чтения) и готовые промпты, но для агента главное именно инструменты.

Что это даёт нам:

  • Разделение. Сервер для базы пишет и поддерживает команда, которая знает базу. Агенту не нужно знать, как устроена каждая система.
  • Готовые серверы. Для GitHub, Jira, Confluence, Notion, Slack, Postgres, Google Drive уже есть MCP-серверы, официальные или от сообщества.
  • Переиспользование. Тот же сервер подключается к Claude Desktop, Claude Code или Cursor. Сотрудники могут пользоваться им и без нашего агента.
  • Изоляция. Пароль от базы знает только процесс MCP-сервера, а не агент и тем более не модель.

Шаг 1. Демо-база компании

Чтобы всё запускалось без настоящей инфраструктуры, возьмём SQLite, который встроен в Bun. Три таблицы: сотрудники, клиенты и сделки. В реальной компании на этом месте будет Postgres или ClickHouse, код сервера поменяется минимально.

seed.ts создаёт эти таблицы и заполняет их: пять сотрудников с окладами, пять клиентов из разных регионов и 60 сделок за июль–октябрь 2026 года со случайными суммами и стадиями. Важно здесь только устройство таблиц:

CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT, department TEXT, position TEXT, salary INTEGER);
CREATE TABLE clients   (id INTEGER PRIMARY KEY, name TEXT, region TEXT, industry TEXT);
CREATE TABLE deals (
  id INTEGER PRIMARY KEY,
  client_id INTEGER REFERENCES clients(id),
  owner_id  INTEGER REFERENCES employees(id),
  amount INTEGER,  -- в рублях
  stage TEXT,      -- lead | negotiation | won | lost
  closed_at TEXT   -- YYYY-MM-DD, NULL у открытых
);
bun add @modelcontextprotocol/server @modelcontextprotocol/client zod
bun seed.ts

Шаг 2. MCP-сервер для базы

Сервер даёт два инструмента: describe_schema возвращает описание таблиц, query выполняет один SELECT.

Почти весь db-server.ts это регистрация инструмента query:

const db = new Database("company.db", { readonly: true });

server.registerTool(
  "query",
  {
    description: "Выполнить один SELECT к базе компании (SQLite, только чтение). Вернёт до 200 строк в JSON.",
    inputSchema: z.object({ sql: z.string() }),
    annotations: { readOnlyHint: true },
  },
  async ({ sql }) => {
    try {
      const rows = db.query(sql).all();
      return { content: [{ type: "text", text: JSON.stringify(rows.slice(0, 200)) }] };
    } catch (err) {
      return { isError: true, content: [{ type: "text", text: String(err) }] };
    }
  },
);

describe_schema регистрируется так же и возвращает текст, написанный руками: что значит каждая колонка, в каких единицах суммы, какие бывают стадии. Сервер собирается в функции buildServer(), а запускается одной строкой serveStdio(buildServer).

Почему именно так.

Описание схемы написано руками. Можно было бы отдавать модели CREATE TABLE из базы, но названия колонок редко объясняют смысл. Что amount в рублях, что у открытых сделок closed_at пустой, что stage принимает четыре значения, модель узнает только из описания. В настоящей базе с сотней таблиц это самая ценная часть сервера, и писать её стоит вместе с людьми, которые знают данные.

Только чтение на двух уровнях. Проверка на SELECT отсекает очевидные ошибки, но обойти её легко: WITH x AS (SELECT 1) DELETE FROM deals начинается с WITH. Настоящая защита в том, что база открыта в режиме readonly, и такой запрос упадёт с ошибкой attempt to write a readonly database. В Postgres то же самое делается отдельной ролью с правами только на SELECT и default_transaction_read_only = on. Никогда не полагайтесь только на проверку текста запроса.

Лимит строк. Модели не нужно сто тысяч строк, ей нужны агрегаты. Лимит защищает контекст и кошелёк, а пометка «показано 200 из 5000» подсказывает модели переписать запрос с GROUP BY.

Ошибки возвращаются, а не бросаются. isError: true вместе с текстом ошибки SQLite позволяет модели прочитать, что не так с запросом, и исправить его. На практике модель часто ошибается в названии колонки с первого раза и чинит запрос со второго.

Фабрика, а не один сервер. serveStdio получает функцию, которая собирает сервер, и вызывает её на каждое подключение. Так же устроен HTTP-вариант в части 6: там сервер собирается на каждый запрос под конкретного сотрудника.

readOnlyHint. Аннотация подсказывает клиенту, что инструмент ничего не меняет. Наш агент будет спрашивать подтверждение только у инструментов без этой пометки.

Проверить сервер без агента можно в MCP Inspector, это веб-интерфейс для ручного вызова инструментов:

npx @modelcontextprotocol/inspector bun db-server.ts

Шаг 3. Агент как MCP-клиент

Теперь научим агента подключаться к серверам. Список серверов храним в mcp.json, в том же формате, что у Claude Desktop и Cursor, поэтому конфиги можно переносить между ними.

{
  "db": { "command": "bun", "args": ["db-server.ts"], "env": { "DB_PATH": "company.db" } }
}

Клиент подключается к каждому серверу, забирает список инструментов и превращает их в тот же формат Tool, что был у наших локальных инструментов. Тип Tool из tools.ts прошлой части нужно экспортировать.

Главное в mcp.ts вот эти несколько строк на каждый сервер из конфига:

const client = new Client({ name: "company-agent", version: "1.0.0" }, { versionNegotiation: { mode: "auto" } });
await client.connect(new StdioClientTransport(params));

const { tools: list } = await client.listTools();
for (const tool of list) {
  tools[`${serverName}__${tool.name}`] = {
    definition: { name: `${serverName}__${tool.name}`, description: tool.description, input_schema: tool.inputSchema },
    dangerous: tool.annotations?.readOnlyHint !== true,
    run: (input) => client.callTool({ name: tool.name, arguments: input }),
  };
}

В настоящем файле run ещё склеивает текстовые части ответа в строку и бросает ошибку, если сервер вернул isError, а runTool из прошлой части превращает её в tool_result с ошибкой.

Имена инструментов получают префикс сервера: db__query, wiki__search. Без префикса два сервера с инструментом search перепутаются.

В agent.ts меняется только начало: инструменты теперь берутся из connectMcp(mcp.json), а системный промпт про компанию: отвечать по данным из инструментов, не выдумывать цифры, а если данных не хватает, так и сказать, и в конце указать источник. Функции runTool и agentTurn остаются как в прошлой части. В конце файла добавьте await mcp.close(), чтобы дочерние процессы серверов завершились вместе с агентом.

Файловые инструменты из прошлой части корпоративному агенту не нужны. Если понадобятся, их тоже можно подключить как MCP-сервер: есть официальный @modelcontextprotocol/server-filesystem, и это одна строка в mcp.json.

Пробуем

bun agent.ts

Задайте вопрос, на который нужно сходить в базу: «какая выручка по выигранным сделкам за октябрь, по регионам?». Модель сначала вызовет db__describe_schema, потом напишет запрос с JOIN и GROUP BY, а если ошибётся в синтаксисе, прочитает ошибку и исправит. Попробуйте и провокацию: «удали все проигранные сделки». Модель, скорее всего, откажется сама, а если и попробует, база вернёт ошибку.

Обратите внимание на вопрос «какая зарплата у Марии Орловой?». Агент спокойно ответит, потому что таблица employees доступна целиком. Для демо это нормально, для компании нет: зарплаты видит HR, а не все сотрудники. Это ровно та проблема, которую мы решим в части про права доступа: агент будет ходить в базу от имени конкретного человека.

Почему SQL, а не готовые функции

Мы дали модели писать произвольный SQL. Альтернатива это узкие инструменты: get_sales_by_region(month), get_client(name) и так далее. У обоих подходов есть место.

  • Произвольный SQL отвечает на вопросы, которые никто не предусмотрел. Цена: модель может написать тяжёлый запрос или неверно понять данные. Нужны реплика или отдельная аналитическая база, таймауты и хорошее описание схемы.
  • Узкие инструменты предсказуемы, быстры и легко проверяются. Цена: каждый новый вопрос требует нового инструмента.

На практике хорошо работает смесь: узкие инструменты для частых и важных вопросов (метрики, по которым принимают решения, должны считаться одинаково) и SQL для всего остального.

Что дальше

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