mcp-coffee
by Ira-Ivaa
README.md
# ☕ MCP Coffee — учебный проект по Model Context Protocol
[](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.
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues