Корпоративный AI-агентчасть 4 из 9
- 01Концепция: агент, который знает компанию
- 02Первый разговор с моделью
- 03Агентный цикл и инструменты
- 04MCP: подключаем внутренние системы
- 05Знания компании: поиск по документам
- 06Права доступа: SSO, OAuth и агент от имени сотрудника
- 07Контекст и деньги: кэширование, сжатие истории и лимиты
- 08Интерфейс: веб-чат и бот в Slack
- 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 для всего остального.
Что дальше
Агент уже умеет отвечать на вопросы по структурированным данным. Но большая часть знаний компании лежит не в таблицах, а в тексте: вики, регламенты, договоры, протоколы встреч. В следующей части научим агента искать по документам.