Skip to main content
Glama
README.md
# ☕ MCP Coffee — учебный проект по Model Context Protocol

[![CI](https://github.com/Ira-Ivaa/mcp-coffee/actions/workflows/ci.yml/badge.svg)](https://github.com/Ira-Ivaa/mcp-coffee/actions/workflows/ci.yml)

Telegram-бот кофейни, который принимает заказы в свободной форме («два капучино
и латте»), записывает их в Excel и сообщает время готовности. Главная цель
проекта — **разобраться, как устроен MCP изнутри**: и сервер, и клиент написаны
вручную, tool use реализован **ручной петлёй** поверх OpenAI, без готовых
агентских врапперов.

## Что демонстрирует проект

- **MCP-сервер на TypeScript** — три инструмента, транспорт Streamable HTTP,
  сессии по заголовку `mcp-session-id`.
- **MCP-клиент** — подключение, `tools/list`, `tools/call`.
- **Ручная петля tool use** — модель сама извлекает структуру заказа из текста;
  мы перехватываем вызовы инструментов, исполняем их через MCP и возвращаем
  результат в диалог, пока модель не ответит текстом.
- **Разделение слоёв** — хранение (Excel) отделено от бизнес-логики
  (инструменты) и от «мозга» (бот).

## Архитектура

Система состоит из трёх независимых частей:

| Часть | Файл | Ответственность |
|------|------|-----------------|
| **MCP-сервер** | `src/index.ts` | 3 инструмента + Express/Streamable HTTP на `:3000/mcp`. Единственный, кто трогает Excel. |
| **Слой хранения** | `src/excel.ts` | Чтение `menu.xlsx`, запись/чтение `orders.xlsx`. Не знает бизнес-правил. |
| **MCP-клиент** | `src/mcpClient.ts` | `connect` / `listTools` / `callTool`. |
| **Бот** | `src/bot.ts` | Telegram + OpenAI + ручная петля tool use. |

Ключевой принцип: **сервер не знает про OpenAI, а OpenAI не знает про Excel**.
Бот посередине — переводчик между ними.

## Поток данных

```
Пользователь в Telegram: "два капучино"
        │
        ▼
   [bot.ts] получает текст
        │
        ▼
   [bot.ts] → OpenAI: сообщение + список инструментов (function calling)
        │
        ▼
   OpenAI решает вызвать create_order({items:[{drink:"капучино",qty:2}]})
        │
        ▼
   [bot.ts] перехватывает вызов          ← РУЧНАЯ ПЕТЛЯ
        │
        ▼
   [mcpClient] → [index.ts] tools/call create_order(...)
        │
        ▼
   [index.ts] читает menu.xlsx (время), пишет строку в orders.xlsx
        │
        ▼
   результат {id, totalMinutes} → обратно в OpenAI
        │
        ▼
   OpenAI формулирует ответ: "Заказ №7 принят, готовность ~8 мин"
        │
        ▼
   [bot.ts] → Telegram
```

## Инструменты MCP

| Инструмент | Аргументы | Возвращает |
|-----------|-----------|------------|
| `get_menu` | — | список напитков `{drink, price, minutes, composition}` |
| `search_menu` | `query` | напитки, у которых ключевое слово есть в составе/названии |
| `create_order` | `items: [{drink, qty}]` | `{id, totalMinutes}` |
| `get_order_status` | `orderId` | `{status, minutesLeft, items}` |

Описания инструментов и схемы аргументов — на английском: это **контракт для
модели**, по которому она выбирает инструмент и заполняет аргументы.

## Технологии

`TypeScript` · `@modelcontextprotocol/sdk` · `Express` · `OpenAI SDK` ·
`Telegraf` · `ExcelJS` · `zod` · `Node.js 24`

## Запуск

```bash
npm install
cp .env.example .env   # впиши OPENAI_API_KEY и TELEGRAM_BOT_TOKEN
```

`.env`:

```
OPENAI_API_KEY=sk-...
TELEGRAM_BOT_TOKEN=123456:ABC-...
OPENAI_MODEL=gpt-4o-mini
MCP_URL=http://localhost:3000/mcp
```

Два терминала:

```bash
npm run dev       # 1-й: MCP-сервер на :3000
npm run dev:bot   # 2-й: Telegram-бот
```

Дальше пиши боту: «два капучино и латте» → получишь номер и время;
«когда готов заказ 3?» → остаток минут до готовности.

### Команды

| Команда | Что делает |
|---------|------------|
| `npm run dev` | MCP-сервер (tsx watch) |
| `npm run dev:bot` | Telegram-бот (tsx watch) |
| `npm run build` | сборка в `dist/` |
| `npm start` | запуск собранного сервера |
| `npm test` | юнит-тесты (встроенный `node:test`) |

## Структура

```
src/
├── index.ts          точка входа: MCP-сервер (инструменты + HTTP-транспорт)
├── bot.ts            точка входа: Telegram + OpenAI + ручная петля tool use
├── config.ts         настройки в одном месте (модель, порт, лимиты)
├── domain/           ЧИСТАЯ логика без I/O (тестируется без запуска сервера)
│   ├── search.ts       поиск по составу
│   ├── orders.ts       расчёт заказа: проверка меню, время
│   └── history.ts      обрезка окна контекста
├── infra/            адаптеры к внешнему миру
│   ├── excel.ts        слой хранения (ExcelJS)
│   └── mcpClient.ts    MCP-клиент (с авто-переподключением)
└── tests/            юнит-тесты к чистой логике
data/
└── menu.xlsx         меню (напиток | цена | время_приготовления_мин | состав)
                      orders.xlsx создаётся при первом заказе
```

Архитектура: **точки входа** наверху, **чистое ядро** (`domain/`) отделено от
**адаптеров к внешнему миру** (`infra/`). Благодаря этому логику можно
тестировать, не запуская сервер и бота.

## Принятые решения и почему

Этот раздел — самое важное в проекте: он показывает *понимание*, а не только код.

- **Ручная петля tool use вместо готового агента.** Цель — увидеть механику:
  как модель возвращает `tool_calls`, как результат с ролью `tool` и
  `tool_call_id` привязывается к конкретному вызову, как цикл повторяется.
  Это понимание дороже, чем «подключил библиотеку».

- **Поиск по составу — это retrieval, а не исправление опечаток.** Нормализацию
  текста («КАПУЧино», «капуччино») делает сама LLM — дублировать её на сервере
  бессмысленно. А вот состав напитков лежит в Excel, и в контексте модели его
  нет: по названию «Розовая волна» не угадать, что там арбузный сироп. Поэтому
  `search_menu` оправдан: LLM извлекает ключевое слово («арбуз»), а сервер
  находит напитки по данным, которых у модели нет. Это тот же паттерн, что и
  поиск по большому каталогу, который не помещается в окно контекста.

- **Расчёт времени — в инструменте, не в слое хранения.** Чтобы посчитать
  общее время, нужно сопоставить заказ с меню — это та же операция, что и
  валидация «существует ли напиток». Логично держать в одном месте. `excel.ts`
  остаётся тупым хранилищем: поменяем формулу — слой хранения не трогаем.

- **Общее время = сумма `время × количество`.** Сознательное упрощение для
  модели «один бариста по очереди». Легко заменить на максимум (параллельная
  готовка) — правка в одном месте, в обработчике `create_order`.

- **Описания инструментов на английском, общение — на русском.** Описания и
  `.describe()` — контракт для модели (выбор инструмента, заполнение
  аргументов): на английском надёжнее для function calling и экономнее по
  токенам. Тексты ошибок — на русском: они возвращаются модели как данные, и
  она пересказывает их пользователю.

- **Язык ответа задан в системном промпте.** `gpt-4o-mini` иногда вставляет
  иноязычные слова. Явное требование «отвечай только по-русски» в системном
  промпте убирает большинство таких сбоев; при необходимости можно дополнительно
  понизить `temperature`.

- **Сессии MCP по `mcp-session-id`.** Один разговор клиента с сервером = одна
  сессия со своим транспортом; так несколько клиентов не мешают друг другу.

- **Автопереподключение к MCP.** Сессии живут в памяти сервера: после его
  рестарта `mcp-session-id` бота протухает, и сервер отвечает 400. Чтобы бот не
  падал на каждом сообщении до перезапуска, `McpClient` при ошибке запроса один
  раз переподключается (новая сессия) и повторяет вызов.

- **Управление окном контекста — обрезка по границам ходов.** История растёт с
  каждым сообщением и упёрлась бы в лимит токенов. Обрезаем до `MAX_TURNS`
  последних ходов, но не как попало: у tool-use есть инвариант — `assistant` с
  `tool_calls` обязан сопровождаться `tool`-ответами с теми же `tool_call_id`.
  Поэтому окно всегда начинается с сообщения `user` и системное сообщение
  сохраняется — иначе «осиротевший» `tool` дал бы ошибку 400.

## Известные ограничения

- История диалога хранится **в памяти процесса** — перезапуск бота её обнуляет.
- Обрезка контекста — **по числу ходов** (`MAX_TURNS`), а не по токенам. Точнее
  было бы считать токены (`tiktoken`), но для учебного проекта порога по ходам
  достаточно.
- Юнит-тесты на чистую логику гоняются в CI (GitHub Actions) на каждый push.