Вводные слова
Еще в 2008 году, посмотрев фильм "Железный человек", я понял, что хочу сделать себе такого же виртуального помощника, как у главного героя был Джарвис — искуственный интеллект, с которым Тони Старк общался в формате обычной речи, а тот понимал его команды и послушно исполнял.

Я даже помню, как на втором курсе физтеха в 2012 ночами в общаге я пытался приручить google speech recognizer для распознавания моих команд, а потом пытался написать свой парсер, извлекающий команды из этого текстового запроса. Единственный успех, которого я добился — мог ставить голосом музыку на паузу.
С тех пор прошло много времени, а LLM-модели сделали мою мечту более реальной и я решил разобраться как делать своих агентов на typescript. Оказалось, что "разобраться" не так-то просто, учитывая скудное наличие документации по этой теме. Хотя хайп вокруг этого 3 месяца назад был мощный.
У данной статьи есть видео-версия на YouTube: youtu.be/9oJIU6A5Z70
Архитектура решения
Для реализации задуманного мне нужно было разобраться с MCP. MCP — model-context-protocol. То есть это протокол, по которому LLM может общаться с внешними инструментами. И именно фраза "LLM может общаться" меня сильно сбивала с толку.

Как LLM "общается" с другими системами?
Я долго не мог понять как заставить саму LLM (например ollama) отправлять запросы в сторонние сервисы. Вроде запущенная LLM живет "сама в себе" и не может отправлять внешние запросы. Я достаточно долго находился в этом искажении и ломал голову, думая что придумали какие-то мудрые модели, умеющие ходить во внешний мир и никак не мог понять как такое запустить у себя.

Потом пришло понимание, что алгоритм работы устроен иначе:
Изначально разрабатываются возможные варианты действий, который могут быть выполнены автоматически. Например "добавить пользователя с обязательными полями Имя и Дата рождения";
Я пишу (или говорю) этой системе в свободной форме что-то, из чего можно извлечь задачу. Например "создай пользователя Васю";
Дальше система отправляет запрос в LLM и с каждым запросом отправляет еще массив возможных действий с обязательными параметрами вызова;
LLM по тексту понимает что (возможно) хочет пользователь, чтобы ему сделала система;
-
Дальше LLM смотрит все ли параметры есть в текущей переписке
Если всех параметров нет (в нашем случае я не сразу назвал возраст), то LLM отвечает просто текстом со словами вроде "Для создания пользователя мне нужен возраст";
После чего шаги 2-5 повторяются до тех пор, пока не будут собраны все параметры.
Когда LLM понимает, что в переписке содержатся все необходимые параметры, она в ответе вместе с текстом отдает еще в массиве инструментов команды, которые нужно выполнить системе.
В нашем случае будет команда "добавить пользователя" с параметрами "имя: Вася", "год рождения 1994 (если я на вопрос о возрасте скажу 1994)"Система послушно выполняет переданные команды и сохраняет результат выполнения команды в контекст диалога и дальше уже передает эту информацию обратно в LLM с просьбой прокомментировать действия;
LLM смотрит, что в истории переписки есть просьба создать пользователя, есть дополнительные выяснения, есть команда на выполнение и есть результат её выполнения. В ответ LLM пишет финальный текст, глядя на все это безобразие, из серии "Я создал пользователя Васю 1994 года, скажите мне спасибо";
Мы благодарим LLM за проделанную работу или остаемся невежливыми и не пишем ему даже спасибо, чтобы не тратить драгоценные токены.

В момент, когда я понял, что LLM только помогает понять что от системы хотят у меня наступило прозрение и я приступил к очень срочной и быстрой реализации.
Так что такое MCP?
Теперь возвращаемся к MCP. Это не какая-то волшебная таблетка и даже не чего-то новое. Это просто способ описывать возможные варианты взаимодействия с системой через stdin или http(s).
Это ровно тот же самый Swagger (OpenAPI), который мы в галере активно используем для описания возможных методов взаимодействия с системой. У них даже спецификации похожи: название метода, параметры на вход и параметры на выход.
{
"name": "создать_пользователя",
"description": "Создает нового пользователя с указанным именем и годом рождения. Этот инструмент следует использовать, когда пользователь хочет зарегистрироваться, создать профиль или добавить себя в систему. Он не выполняет проверку уникальности и не возвращает дополнительных данных о пользователе.",
"input_schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Имя пользователя, например, Иван"
},
"birthYear": {
"type": "integer",
"description": "Год рождения пользователя, например, 1990"
}
},
"required": ["name", "birthYear"]
}
}
В примере выше есть поле description
и name
, которые передаются в LLM для понимания что именно может эта команда сделать, а LLM уже попытается понять, опираясь на это описания, эту ли команду от неё сейчас хотят заполучить.
Именно в MCP серверах подробное описание в документации уже не вызывает приступы лени, как при описании документации к API (лично у меня), которую будут читать другие люди. И чем понятнее я опишу тем лучше будет работать LLM.
Почему не использовать обычные функции? Зачем MCP?
Уже достаточно давно в разных облачных GPT-подобных системах с API есть механизм функций или инструментов (в документации называются functions
или tools
) — functions в OpenAI, Функции в GigaChat . Некоторым клиентам мы интегрировали на очень базовом уровне эти механики на базе нашего GigaChat и работают они прекрасно!.
У каждого AI-провайдера свой подход к реализации функций — по-разному называются поля и немного разная структура самого запроса, который необходимо отправить в сторону LLM.
Мир разработки всегда, со временем, приходит к стандартизации. OpenAPI (Swagger) тому пример — делали разные команды документации к API кто во что горазд. Потом еще каждый по своему это все документировал и была некая анархия. Придумали OpenAPI — ситуация изменилась и теперь есть надежный способ описывать свое API. Так и с "инструментами на базе нейросетей".
Придумали Model-Context-Protocol, через который договорились как именно будут общаться системы и понимать команды, которые предлагают выполнить AI-помощники. Как только появился стандарт резко, как грибы после дождя, начали появляться "коннекторы" для подключения ИИ в разные уже существующие системы.
Есть целый репозиторий, в котором содержатся разные MCP — от управления календарем до управления своим Telegram-аккаунтом и все это в формате "разговорного нейросетевого интерфейса". Мне этот репозиторий оказался очень полезен для понимания масштабности сего изобретения.
На чем? Как? И почему я буду писать свой MCP?
Для разработки mcp-сервера я буду использовать официальный SDK от разработчиков этого протокола: https://github.com/modelcontextprotocol/typescript-sdk. Поскольку моя галера живет и работает на typescript я решил, что писать и клиентскую и серверную часть я буду на нем.
Изначальная точка проекта
Прежде чем писать сам MCP-сервер и подключать надо сделать базовую архитектуру. Обычно в своих проектах и личных агентах я все делаю на nest-фреймворке, но такие вещи проще показывать на чистом typescript без фреймворков. Готовим проект с точками входа в виде telegram и cli.
Весь код и шаги дублируются в github-репозитории — по шагу на коммит.
Базовый TS проект.
Для начала инициализируем npm-проект
mkdir ts-llm-agent-example
cd ts-llm-agent-example
npm init -y
npm install typescript --save-dev
npx tsc --init
Это создаст файлы package.json и tsconfig.json, которое надо я сразу же корректирую для удобной работы.
// package.json
{
"name": "ts-llm-agent-example",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"dev": "ts-node src/index.ts",
"start": "node dist/index.js",
"dev:watch": "nodemon --exec ts-node src/index.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"nodemon": "^3.1.10",
"ts-node": "^10.9.2",
"typescript": "^5.8.3"
}
}
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
После чего я выполняю команду yarn
и командой yarn dev:watch
запускаю сервис с автоматическим обновлением и перезапуском после изменения кода.
Различные точки входа
Мой агент будет работать в двух форматах:
через консоль (cli) для удобства разработки;
через telegram для удобства использования.
Механика и там и там будет одинаковая — от меня идет текст агенту и агент мне отвечает так же текстом. Конечно потом можно его развивать, чтобы он понимал изображения и по ним со мной вел диалог, но пока такой необходимости нет.
Для того, чтобы был удобный механизм выбора точки входа на будущее, делаю следующим образом:
// src/entrypoint/interface.ts
export interface AiEntryPointInterface {
run(): Promise | void;
}
// src/entrypoint/telegram.ts
export class CliEntryPoint implements AiEntryPointInterface{
run() {
console.log("CLI mode started");
// Здесь будет логика CLI
}
}
// src/entrypoint/cli.ts
export class TelegramEntryPoint implements AiEntryPointInterface {
run() {
console.log("Telegram mode started");
// Здесь будет логика Telegram
}
}
// src/entrypoint/selector.ts
export function selectEntrypoint(): AiEntryPointInterface {
const args = process.argv.slice(2);
if (args.includes('--cli')) {
return new CliEntryPoint();
} else if (args.includes('--telegram')) {
return new TelegramEntryPoint();
} else {
throw new Error('Usage: node dist/index.js --cli | --telegram');
}
}
// src/index.ts
import { selectEntrypoint } from './entrypoint/selector';
selectEntrypoint().run();
Теперь я могу запускать своего агента следующими способами:
# Запуск в виде telegram бота
yarn dev:watch --telegram
# Запуск в CLI режиме
yarn dev:watch --cli
Заглушка процессора текстовых сообщений
Для того, чтобы можно было общаться с агентом через консоль нужно реализовать механизм передачи вопроса в ChatProcessor
, который дальше уже будет общаться с AI-системами и выполнять задачи.
// src/ai/chat-processor.ts
export class ChatProcessor {
constructor() {
// Пока ничего не инициализируем
}
async processMessage(sessionId: string, text: string): Promise<{
message: string;
tools: { name: string; arguments: Record }[];
}> {
// Возвращаем простой ответ-заглушку
return {
message: `Echo: ${text}`,
tools: [],
};
}
}
В возвращаемом объекте есть 2 поля: message
— сообщение, которое нужно написать пользователю. tools
— список вызванных инструментов за время обработки запросов и параметры каждого вызова.
Внутри каждого entrypoint добавляем процессор в конструктор
// src/entrypoint/cli.ts
export class CliEntryPoint implements AiEntryPointInterface{
constructor(
private readonly processor: ChatProcessor,
) {
}
И на уровне создания entrypoint добавляем созданный ChatProcessor
в зависимости:
// src/entrypoint/selector.ts
const processor = new ChatProcessor();
if (args.includes('--cli')) {
return new CliEntryPoint(processor);
// ...
Таким образом у нас все зависимости процессора в одном месте будут задаваться и он дальше будет передаваться в нужную реализацию (управление зависимостями на минималках).
Работа в cli-режиме
В рамках данной статьи я буду делать только реализацию CLI режима (через консоль). В консоли механизм будет крайне простой:
// src/entrypoint/cli.ts
// .....
async run() {
const SESSION_ID = 'cli-session';
console.log("CLI mode started");
// Здесь будет логика CLI
const rl = readline.createInterface({input, output});
while (true) {
const query = await rl.question('\n?️ Ваш запрос: ');
if (query.trim().toLowerCase() === 'exit') {
console.log('? До свидания!');
rl.close();
process.exit(0);
}
const start = Date.now();
console.log('? Думаю...');
const response = await this.processor.processMessage(SESSION_ID, query);
const end = Date.now();
const durationSec = ((end - start) / 1000).toFixed(2);
console.log(`\n? AI (${durationSec} сек):\n${response.message}`);
if (response.tools.length > 0) {
console.log(`?️ Использованные инструменты:`);
response.tools.forEach((tool, i) => {
console.log(` ${i + 1}. ${tool.name} ${JSON.stringify(tool.arguments)}`);
});
}
}
}
И это дает нам такой результат:
?️ Ваш запрос:hi
? Думаю...
? AI (0.00 сек):
Echo: hi
?️ Использованные инструменты:
1. awesome_tool {"hi":true}
А если будет использован какой-то внешний инструмент за время обработки запроса, то он будет выведен отдельным блоком "Использованные инструменты". Для локальной отладки от CLI интерфейса больше ничего не нужно.
Реализация MCP сервера
Давайте сделаем сервер, который будет сохранять пользователей в json-файл. Вместо него, конечно, может быть любая база данных или стороннее API, но для примера хватит и обычного JSON.
Что будет уметь наш сервер:
Добавлять новых пользователей в JSON файл с полями Имя, Год рождения;
Получать список всех пользователей;
Считать количество пользователей, старше определенного возраста;
Отправлять сообщение пользователю с обращением к нему по имени — само сообщение будет тоже писаться в JSON файл для проверки работоспособности.
Для начала ставим зависимости:
# зависимости для запуска MCP-сервера
yarn add @modelcontextprotocol/sdk zod
# инструмент для отладки MCP
yarn add @modelcontextprotocol/inspector
Создаем входную точку для mcp сервера и добавляем команду для запуска mcp-inspector
в package.json
:
{
scripts: {
"mcp": "node dist/mcp/index.js",
"mcp-inspect": "mcp-inspector yarn mcp"
}
}
MCP-сервер может работать в нескольких режимах:
STDIO — запускается команда
yarn mcp
и туда сразу передаются аргументы через ввод.SSE, Streamable HTTPS — запуск сервера, к которому можно подключаться из-вне Первый вариант самый безопасный, т.к. во внешний мир MCP смотреть не будет никуда. Обычно стоит начинать именно с него, чтобы не заботиться о вопросах безопасности, т.к. публикация MCP-сервера дает возможность вызывать любые доступные команды.
Учитывая, что некоторые MCP-сервера могут, например, управлять вашим аккаунтом в Google Calendar, стоит быть более параноидальным в плане безопасности. Когда мы делаем ИИ-агентов под заказ мы реализуем второй вариант, т.к. обычно MCP запущен в отдельной изолированной среде и туда доступа к STDIO нет. В рамках сегодняшней публикации я реализую только stdio
подход и его будет достаточно для большинства локальных сценариев.
// src/mcp/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { EmailService } from '../services/EmailService';
import { UserService } from '../services/UserService';
const emailService = new EmailService();
const userService = new UserService();
const server = new McpServer({
name: "demo-server",
version: "1.0.0"
});
server.registerTool("send_message",
{
title: "Отправка сообщения",
description: "Отправляет сообщение с переданным текстом. Поле текста обязательное",
inputSchema: {
text: z.string(),
},
},
async (req) => {
await emailService.saveEmail(req.text);
return {content: [{type: "text", text: `Сообщение отправлено`}],};
}
);
server.registerTool(
'create_user',
{
title: 'Создание пользователя',
description: 'Создает пользователя. Имя и год рождения обязательные поля. Если пользователь их не передал, то их нужно запросить отдельно. Самому ИИ придумывать нельзя',
inputSchema: {
name: z.string(),
birthYear: z.number(),
},
},
async (req) => {
await userService.addUser({
name: req.name,
birthYear: req.birthYear
});
return {
content: [
{
type: 'text',
text: `Пользователь ${req.name} успешно создан.`,
},
],
};
}
);
// ? Инструмент: users-listserver.registerTool(
'users_list',
{
title: 'Получение списка пользователей',
description: 'Возвращает список пользователей со всеми полями',
outputSchema: {
elements: z.array(
z.object({
id: z.number(),
birthYear: z.string(),
})
),
},
},
async () => {
let elements = await userService.getUsers();
return {
structuredContent: {
elements: elements,
},
content: [
{
type: 'text',
text: elements.map((u) => `${u.name} (${u.birthYear})`).join(', ') || 'Нет пользователей',
},
],
};
}
);
server.registerTool(
'user_count',
{
title: 'Получить количество пользователей старше переданного возраста',
description: 'Возвращает количество пользователей. Если этот инструмент вызывается,' +
' то он должен вернуть количество пользователей в системе и это число точно нужно передать пользователю.' +
'Если возраст не был передан, то подставить 0',
inputSchema: {
age: z.number().optional().default(0),
},
},
async (req) => {
const users = await userService.countUsersOlderThan(req.age);
return {
content: [{type: 'text', text: String(users)}],
};
}
);
// Получаем команду через stdio, выполняем её и отдаем ответ
const transport = new StdioServerTransport();
server.connect(transport);
В этом коде используются сервисы UserService и EmailService. Напишем их самым простым образом — информация будет складываться в json файлы в папке data
:
// src/services/EmailService.ts
import fs from 'fs/promises';
import path from 'path';
type EmailEntry = {
id: string;
text: string;
timestamp: string;
};
export class EmailService {
private filePath: string;
constructor() {
this.filePath = path.resolve(process.cwd(), 'data/emails.json');
}
private async loadEmails(): Promise {
try {
const data = await fs.readFile(this.filePath, 'utf-8');
return JSON.parse(data) as EmailEntry[];
} catch {
return [];
}
}
private async saveEmails(emails: EmailEntry[]): Promise {
await fs.writeFile(this.filePath, JSON.stringify(emails, null, 2), 'utf-8');
}
async saveEmail(text: string): Promise {
const emails = await this.loadEmails();
const newEntry: EmailEntry = {
id: crypto.randomUUID(),
text,
timestamp: new Date().toISOString()
};
emails.push(newEntry);
await this.saveEmails(emails);
}
}
// src/services/UserService.ts
import fs from 'fs/promises';
import path from 'path';
type User = {
name: string;
birthYear: number;
};
export class UserService {
private filePath: string;
constructor() {
this.filePath = path.resolve(process.cwd(), 'data/users.json');
}
private async loadUsers(): Promise {
try {
const data = await fs.readFile(this.filePath, 'utf-8');
return JSON.parse(data) as User[];
} catch {
return [];
}
}
private async saveUsers(users: User[]): Promise {
await fs.writeFile(this.filePath, JSON.stringify(users, null, 2), 'utf-8');
}
async addUser(user: User): Promise {
const users = await this.loadUsers();
users.push(user);
await this.saveUsers(users);
}
async getUsers(): Promise {
return this.loadUsers();
}
async countUsersOlderThan(age: number): Promise {
const users = await this.loadUsers();
const currentYear = new Date().getFullYear();
return users.filter(user => currentYear - user.birthYear > age).length;
}
}
После чего запускаем yarn build
, чтобы консольный режим mcp собрался и можно было его передавать в инспектор. После запускаем yarn mcp-inspect
, видим следующее в консоли:
⚙️ Proxy server listening on 127.0.0.1:6277
? Session token: 244d4198a13125d807ef6f202d6629e87b7cfd1705c19a32180019682df3ef23
Use this token to authenticate requests or set DANGEROUSLY_OMIT_AUTH=true to disable auth
? Open inspector with token pre-filled:
http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=244d4198a13125d807ef6f202d6629e87b7cfd1705c19a32180019682df3ef23
Переходим по ссылке и попадаем в инспектор

После запуска нажимаем Connect и подключаемся к нашему локальному MCP. Работает он аналогично Postman, где мы можем интерактивно вызывать разные команды и видеть результат их выполнения.
Убедился, что все методы работают верно и на этом разработка моего первого MCP-сервера Я понажимал отправку сообщения, создание пользователя и другие кнопки и убедился, что все работает верно.
Также посмотрел в json файлы, которые появились после выполнения:закончена. Теперь можно приступать в организации общения между LLM и MCP сервером:
# data/emails.json
[
{
"id": "49d09705-8273-4e3b-8203-6a1d3bf82834",
"text": "Привет! Это проверка!",
"timestamp": "2025-07-09T07:34:01.359Z"
}
]
# data/users.json
[
{
"name": "Антон",
"birthYear": 1994
}
]
Реализация интеграции с ИИ
Теперь осталось научить наш ChatProcessor
принимать текстовую команду от пользователя, передавать её в LLM (OpenAI, Ollama, GigaChat). Дальше получать команду на выполнение от LLM, выполнить её, отправить результат в LLM, чтобы та сообщила нам какая она молодец, что все создала и сделала.

В рамках данной публикации я буду описывать работу с OpenAI, как с первопроходцем в мире ИИ. В репозитории, со временем, появятся коннекторы к GigaChat и Ollama для желающих запускаться на других AI-системах.
Интерфейс коннектора выглядит так:
export interface ToolDescriptor {
name: string;
description: string;
inputSchema: Record;
}
export type SingleToolRequest = { id?: string; name: string; arguments: Record };
export interface ToolCallRequest {
message: string;
toolCalls?: SingleToolRequest[];
}
export interface ToolCallResult {
request: SingleToolRequest
content: string;
structuredContent: any;
}
export interface AIHelperInterface {
// Обработка запроса с возможными инструментами обработки
chatWithTools(sessionId: string, message: string, tools: ToolDescriptor[]): Promise;
// Сохранение результата вызова инструмента, чтобы передать в истории
storeToolResult(sessionId: string, result: ToolCallResult): Promise;
// Отправка обычного текстового запроса в ИИ. Можно использовать модель проще, т.к. надо просто красиво ответить
simpleChat(sessionId: string, message: string): Promise;
// Сброс сессии. Уместно для Telegram по ChatId
resetSession(sessionId: string): Promise;
}
Хранение истории переписки
В ИИ надо слать запрос со всей историей сообщений, чтобы ИИ мог понимать весь контекст для дальнейшего ответа. Для того, чтобы это реализовать в удобном формате я написал класс SessionStorage, в котором будет храниться массив сообщений и другая полезная информация на уровне каждого диалога пользователя.
Сделано это по причине несущественного различия структуры данных у каждого ИИ. Поэтому каждый коннектор будет сам определять что сохраняет в сессию.
// src/ai/connector/session-storage.ts
export class SessionStorage {
private sessions: Record = {};
constructor(private readonly initSession: () => T) {}
get(sessionId: string): T {
if (!this.sessions[sessionId]) {
this.sessions[sessionId] = this.initSession();
}
return this.sessions[sessionId];
}
set(sessionId: string, messages: T) {
this.sessions[sessionId] = messages;
}
reset(sessionId: string) {
delete this.sessions[sessionId];
}
has(sessionId: string): boolean {
return sessionId in this.sessions;
}
}
Идентификатором сессии может быть что угодно. В случае с CLI идентификатор создается при запуске и дальше не меняется. В случае с Telegram это будет telegramChatId
, по которому уже будет храниться вся переписка, пополняться результатами выполнения функций и поддерживать контекст беседы.
Отправка запросов к ИИ
Если в системе подразумевается несколько вариантов коммуникации с другими системами, я всегда иду через разработку интерфейса и потом уже его реализацию. Коннектор с ИИ у нас полностью инкапсулирует механику работу с сессией и механику отправки запросов.
Почему сессией управляет коннектор? Вопрос справедливый, но такое решение было принято по причине различия объекта
Message
, который надо в массиве слать с каждым запросом в ИИ. Поэтому решил, что лучше передать управление сессии внутри самого коннектора через внешний классSessionManager
Для отправки запросов с инструментами OpenAI используется function calling механизм. Для того, чтобы под наши задачи ИИ-Агента можно было использовать OpenAI достаточно реализовать описанный ранее интерфейс.
// src/ai/connector/openai.ts
import { AIHelperInterface, ToolCallRequest, ToolCallResult, ToolDescriptor } from './interface';
import { OpenAI } from 'openai';
import { SessionStorage } from './session-storage';
import { ChatCompletionMessageParam, ChatCompletionTool } from "openai/resources/chat/completions";
interface Session {
messages: ChatCompletionMessageParam[] | any;
toolResult: Record;
}
export class OpenAIHelper implements AIHelperInterface {
/*
Объявляем сессию и задаем колбек для создания массива сообщений с system prompt в первом элементе */
protected session: SessionStorage = new SessionStorage(() => ({
messages: this.systemPrompt
? [{
role: 'system',
content: [{
type: 'text',
text: this.systemPrompt,
}],
}]
: [],
toolResult: {},
}));
// Коннектор к OpenAI
private openai: OpenAI;
constructor(
apiKey: string,
private readonly models: { tools: string; talk: string },
private readonly systemPrompt: string,
) {
this.openai = new OpenAI({apiKey});
}
async chatWithTools(sessionId: string, message: string, tools: ToolDescriptor[]): Promise {
const session = this.session.get(sessionId);
// Преобразуем описание инструментов в формат OpenAI
const openaiTools: ChatCompletionTool[] = tools.map(tool => ({
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: tool.inputSchema,
},
}));
// Добавляем сообщение пользователя с запросом
session.messages.push({
role: 'user',
content: [{
type: 'text',
text: message,
}],
});
const response = await this.openai.chat.completions.create({
model: this.models.tools,
messages: session.messages,
tools: openaiTools,
tool_choice: 'auto',
});
const toolCalls = response.choices[0].message.tool_calls || [];
session.messages.push(response.choices[0].message);
return {
message: response.choices[0].message.content ?? '',
toolCalls: toolCalls.map(tc => ({
id: tc.id,
name: tc.function.name,
arguments: JSON.parse(tc.function.arguments || '{}'),
})),
};
}
async resetSession(sessionId: string): Promise {
this.session.reset(sessionId);
}
async simpleChat(sessionId: string, message: string): Promise {
const session = this.session.get(sessionId);
session.messages.push({
role: 'user',
content: [{
type: 'text',
text: message,
}],
});
const response = await this.openai.chat.completions.create({
model: this.models.talk,
messages: session.messages,
});
return response.choices[0].message.content ?? '';
}
storeToolResult(sessionId: string, result: ToolCallResult): Promise {
if (!result.request.id) {
console.warn(
'Tool call result does not have an id. This is likely a bug.',
result,
);
return;
}
this.session.get(sessionId).messages.push({
role: 'tool',
tool_call_id: result.request.id,
content: result.content,
});
if (result.structuredContent)
this.session.get(sessionId).toolResult[result.request.id] = result.structuredContent;
}
}
Теперь у нас есть методы для вызова ИИ с переданными из MCP инструментов и обычный текстовый вызов для интерпретации результата вызова.
Подключение коннектора в ChatProcessor
Для начала сделаем AIHelperProvider
, который будет возвращать нам экземпляр коннектора в ИИ:
// src/ai/connector/provider.ts
import { AIHelperInterface } from './interface';
import { OpenAIHelper } from './openai';
const systemPrompt = "тут пока промпта нет. О нем позже";
export class AIHelperProvider {
static getAiProvider(type: 'openai' | 'gigachat' | 'ollama'): AIHelperInterface {
switch (type) {
case "openai":
const openaiApiKey = process.env.OPENAI_API_KEY || '';
const tools = process.env.OPENAI_MODEL_TOOLS || 'gpt-4.1-mini';
const talk = process.env.OPENAI_MODEL_TALK || 'gpt-4.1-nano';
return new OpenAIHelper(openaiApiKey, {
tools,
talk
}, systemPrompt);
}
throw new Error(`AI provider ${type} not supported`);
}
}
Не забыть еще в index.ts
подключить dotenv
пакет чтобы переменные окружения подтягивались из .env
файла в корне проекта.
yarn add dotenv
// src/index.ts
import { selectEntrypoint } from './entrypoint/selector';
import 'dotenv/config'
selectEntrypoint().run();
# содержимое файла `.env`
OPENAI_API_KEY={OPENAI_API_KEY}
OPENAI_MODEL_TOOLS=gpt-4.1-mini
OPENAI_MODEL_TALK=gpt-4.1-nano
Значение OPENAI_API_KEY
нужно получать через https://platform.openai.com/. Модели я оставил те, которые для этих задач используются у меня, но вы можете экспериментировать с другими моделями.
В самом ChatProccessor
используем его, в рамках обучения, не до конца правильно — через захардкоженный вариант провайдера (в данном случае openai
).
// src/ai/chat-processor.ts
import { AIHelperProvider } from './connector/provider';
import { AIHelperInterface } from './connector/interface';
export class ChatProcessor {
ai: AIHelperInterface;
constructor() {
this.ai = AIHelperProvider.getAiProvider('openai');
}
async processMessage(sessionId: string, text: string): Promise<{
message: string;
tools: { name: string; arguments: Record }[];
}> {
// Пока просто передаем в текстовый режим для теста
const result = await this.ai.simpleChat(sessionId, text);
return {
message: result,
tools: [],
};
}
}
В итоге при запуске в cli
режиме получаем следующее:
CLI mode started
?️ Ваш запрос:Как дела?
Как дела?
? Думаю...
? AI (1.97 сек):
У меня всё хорошо, спасибо! Как у вас дела?
?️ Ваш запрос:
Реализация MCP клиента
Теперь нужно подключать наши наработки к MCP-серверу. Для этого внутри ChatProcessor
нужно добавить подключение к MCP-серверу, собрать доступные инструменты и прописать их в конфигурацию. Для начала устанавливаем зависимости:
yarn add @modelcontextprotocol/sdk
Дальше в ChatProcessor
делаем метод init
, в котором мы будем опрашивать MCP на предмет доступных инструментов и сохраним это в памяти:
// src/ai/chat-processor.ts
private tools: ToolDescriptor[] = [];
constructor() {
this.ai = AIHelperProvider.getAiProvider('openai');
this.mcp = new Client({name: 'mcp-client-cli', version: '1.0.0'});
this.transport = new StdioClientTransport({command: 'node dist/mcp/index.js'});
}
async init() {
this.mcp.connect(this.transport);
this.tools = (await this.mcp.listTools()).tools;
}
Теперь у нас есть инструменты внутри ChatProcessor
, которые нам нужно передать в коннектор. Переписываем метод processMessage
на отправку запроса с инструментами, обработку их и отправку ответа пользователю:
async processMessage(sessionId: string, text: string): Promise<{
message: string;
tools: { name: string; arguments: Record }[];
}> {
const toolsUsed: { name: string; arguments: Record }[] = [];
const finalOutput: string[] = [];
const response = await this.ai.chatWithTools(sessionId, text, this.tools);
if (response.toolCalls && response.toolCalls.length > 0) {
for (const call of response.toolCalls) {
// Сохраняем для статистики
toolsUsed.push(call);
const result = await this.mcp.callTool({
name: call.name,
arguments: call.arguments,
});
const arrayResult = result.content as any[];
const flattened = arrayResult
.map((item) => (item.type === 'text' ? item.text : item.resource?.data || ''))
.join('\n\n');
// Сохраняем результат для истории с LLM
await this.ai.storeToolResult(sessionId, {
request: call,
content: flattened,
structuredContent: result.structuredContent,
});
}
const reply = await this.ai.simpleChat(sessionId, 'Напиши мне ответ на основе результата выполнения функций, который можно было бы сразу отправить тому, кто запрашивал');
finalOutput.push(reply);
} else {
finalOutput.push(response.message);
}
return {
message: finalOutput.join('\n'),
tools: toolsUsed,
};
}
Теперь наша механика работает как нужно.
Тестовые запуски нашего агента
После чего запускаем нашего агента и видим следующее:
CLI mode started
?️ Ваш запрос:Отправь сообщение с текстом привет друг
Отправь сообщение с текстом привет друг
? Думаю...
? AI (1.90 сек):
Привет друг!
?️ Использованные инструменты:
1. send_message {"text":"привет друг"}
?️ Ваш запрос:
А внутри файла data/emails.json
добавилось наше сообщение:
{
"id": "af346432-7e3d-45bc-b631-1c2ba7508b5a",
"text": "привет друг",
"timestamp": "2025-07-09T17:00:04.273Z"
}
Вот еще пример общения с ботом и просьбой отправить сообщение, но без указания текста сразу:
CLI mode started
?️ Ваш запрос:Давай отправим сообщение
? Думаю...
? AI (1.87 сек):
Какой текст сообщения вы хотите отправить?
?️ Ваш запрос:Придумай текст сам, но пусть он будет веселый и задорный и поднимет настроение новому пользователю!
? Думаю...
? AI (4.14 сек):
Конечно! Вот подходящий ответ, который можно сразу отправить:
«Привет! Я подготовил для тебя веселое и задорное сообщение: «Приветствуем в нашей дружной компании! Пусть каждый день будет как праздник, а настроение — всегда на высоте! Улыбайся, радуйся и достигай новых вершин вместе с нами!» Надеюсь, оно поднимет тебе настроение! ?»
?️ Использованные инструменты:
1. send_message {"text":"Приветствуем в нашей дружной компании! Пусть каждый день будет как праздник, а настроение — всегда на высоте! Улыбайся, радуйся и достигай новых вершин вместе с нами!"}
Теперь давайте проверим как происходит создание пользователя в режиме свободной беседы с нашим агентом:
?️ Ваш запрос:Давай создадим пользоавтеля
? Думаю...
? AI (1.55 сек):
Для создания пользователя мне нужны его имя и год рождения. Пожалуйста, предоставьте эту информацию.
?️ Ваш запрос:Антон
? Думаю...
? AI (2.02 сек):
Спасибо! Теперь, пожалуйста, укажите год рождения Антона.
?️ Ваш запрос:2020
? Думаю...
? AI (2.89 сек):
Пользователь Антон успешно создан.
?️ Использованные инструменты:
1. create_user {"name":"Антон","birthYear":2020}
Как видите, он понял, что я опечатался в слове "пользователя" и это его не смутило. Если я передам всю нужную информацию сразу, то он сразу же создаст пользователя:
?️ Ваш запрос: Давай создадим пользователя Антона, 30 лет.
? Думаю...
? AI (1.99 сек):
Пользователь по имени Антон, 30 лет, успешно создан.
?️ Использованные инструменты:
1. create_user {"name":"Антон","birthYear":1993}
Тут он совершил попытку рассчитать какого года должен быть Антон, чтобы сейчас ему было 30 лет. Т.к. модель была сделана в 2023 году, он поставил 1993.
Если бы у нас в MCP был метод "get-current-date" и в методе создания пользователя добавил в промпт что-то из серии "если сказали возраст, то смотри в инструмент current-date", то LLM сказала бы сначала вызвать current-date, предложила бы пользователю подтвердить верно ли она поняла год и после этого уже вызовет инструмент создания пользователя с этим годом.
Почему бы не сделать сразу вызов нескольких команд за один цикл?
Можно было бы сделать и так, чтобы LLM давала команды на одновременный запуск нескольких команд и использовать результат их вывода. Выглядело бы это так:
LLM дает первый инструмент;
-
ChatProcessor
вызывает первый инструмент;
отправляет сразу в LLM результат выполнения;
LLM дает в ответ новый инструмент
ChatProcessor
, выполняет его и снова кидает в LLM
Пункты 2.1 - 2.4 повторяются пока LLM не перестанет слать инструменты на выполнение Я такой вариант не рассматриваю и вам не советую. Лучше чтобы LLM вызывала не более одного инструмента, т.к. есть риск, что она войдет в цикл и будет тратить токены в длинном цикле. Пока что искусственный интеллект все же стоит контролировать физическим:)
Работа в режиме Telegram-бота
Теперь осталось подключить отдельный entrypoint для Telegram-бота для удобного использования своих агентов. Открываем файл src/entrypoint/telegram.ts
и реализуем механизм передачи сообщений.
Также добавим, чтобы по команде /reset
сбрасывалась сессия, чтобы можно было начинать новую беседу. Для начала получаем себе токен для бота через @BotFather, и прописываем его в .env
:
TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
Добавляем telegraf
в зависимости
yarn add telegraf
Теперь в selectEntrypoint
добавляем проброс ChatProcessor
в запуск TelegramEntrypoint
// src/entrypoint/selector.ts
} else if (args.includes('--telegram')) {
return new TelegramEntryPoint(processor);
// ...
И сам обработчик телеги описываем следующим образом:
// src/entrypoint/telegram.ts
import { AiEntryPointInterface } from './interface';
import { Context, Telegraf } from 'telegraf';
import { ChatProcessor } from '../ai/chat-processor';
import { message } from 'telegraf/filters';
export class TelegramEntryPoint implements AiEntryPointInterface {
constructor(
private readonly processor: ChatProcessor,
) {
}
async run() {
const TELEGRAM_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
if (!TELEGRAM_TOKEN) {
console.error('❌ Укажите TELEGRAM_BOT_TOKEN в .env');
process.exit(1);
}
const bot = new Telegraf(TELEGRAM_TOKEN);
bot.start(this.helpReply);
bot.help(this.helpReply);
bot.command('reset', async (ctx) => {
await this.processor.resetSession(ctx.chat.id.toString());
await ctx.reply('? Сессия сброшена. Начните сначала.');
});
bot.on(message('text'), async (ctx) => {
const sessionId = ctx.chat.id.toString();
const query = ctx.message.text;
const start = Date.now();
const thinkResult = await ctx.reply('? Думаю...'); // Сообщение для индикации процесса. Потом его удалим
try {
const response = await this.processor.processMessage(sessionId, query);
const end = Date.now();
const durationSec = ((end - start) / 1000).toFixed(2);
await ctx.reply(`? Ответ (${durationSec} сек):\n${response.message}`);
await ctx.telegram.deleteMessage(ctx.chat.id, thinkResult.message_id);
if (response.tools.length > 0) {
const toolText = response.tools
.map((tool, i) => ` ${i + 1}. ${tool.name} ${JSON.stringify(tool.arguments)}`)
.join('\n');
// Для отладки отправляем использованные инструменты.
await ctx.reply(`?️ Использованные инструменты:\n${toolText}`);
}
} catch (err) {
console.error('⚠️ Ошибка в обработке:', err);
await ctx.reply('❌ Произошла ошибка при обработке запроса.');
}
});
await bot.telegram.setMyCommands([
{
command: '/reset',
description: 'Сбросить сессию'
}
]);
await bot.launch(() => {
console.log('? Telegram бот запущен');
});
}
private helpReply(ctx: Context) {
return ctx.reply('? Привет! Я помощник. Напиши свой запрос. Напиши /reset для сброса истории.');
}
}
После чего собираем yarn build
и запускаем нашего telegram-бота yarn start --telegram
. И общаемся с ним также, как общались с консолью.

Если мы с ним обсудили все, что хотели сейчас, то стоит сбросить сессию, чтобы не раздувать контекст при сообщениях:

Заключение
На этом все. Весь код лежит в открытом github-репозитории и каждый шаг, описанный в этой статье, сделан отдельным коммитом для удобства.
Буду рад вашей подписке на мой Telegram-канал, где я делюсь разными способами автоматизации и разными аспектами ведения IT-бизнеса.
Комментарии (3)
VitaminND
17.08.2025 09:02Я почему то думал, что достаточно подключить Ollama по http и передавать ему контекст (в моем случае это структуру базы данных, текущий объект - например - вьюху, и команду, которую наберет пользователь типа "подключи текстовую таблицу и вытащи наименование продукта").
Далее AI, просмотрев структуру, выдаст команду программе на подключение определенной таблицы и такой-то джоин.
Возможно, еще предполагалось передавать сообщение типа "ты можешь вернуть запрос на доп. информацию в таком то формате, а если готов ответ - тогда в таком то формате".
Я не предполагал, что нужно предварительно разобрать сценарии и параметры.
VitaminND
Спасибо!
Как раз собираюсь агента делать на ts для подключения к своему расширению VSCode через Ollama.
Но AI будет крутиться локально - потребители очень нервно дышат, если что либо уходит во внешний инет.
amorev Автор
локальный AI вообще мощь. Я только так и делаю сейчас. Все перевожу на ollama после покупки RTX 3090 запускаются прям хорошие модели (gps-oss:20b) и работают очень быстро