Skip to main content
Glama
OlegStanKoptev

fintablo-mcp

README.md
# fintablo-mcp

**Только для чтения** [MCP](https://modelcontextprotocol.io)-сервер поверх API [Fintablo](https://fintablo.ru). Задайте финансовый вопрос на естественном языке — сервер вычисляет ответ **в коде** на основе **актуальных** данных API: без ручных выгрузок и устаревших срезов.

Главный инструмент: **`get_nzp`** — незавершённое производство (НЗП) по сделке, восстановленное из примитивов API и сверенное с собственной цифрой Fintablo.

> **Только чтение по умолчанию.** В клиенте нет ни одного метода записи. Fintablo не получает от этого сервера никаких изменений — независимо от того, о чём попросили модель.

## Формула НЗП

Расходы привязываются к **этапам** сделки, а не к сделке целиком. **Открытые** этапы (без даты акта) → НЗП; этапы, **закрытые актом**, → списаны. Для каждого открытого этапа — четыре слоя:

```
НЗП = Σ по открытым этапам [ операции + обязательства + зарплата + зарплатные налоги ]
```

| Слой | Источник | Нюанс |
|---|---|---|
| операции | прямые списания по этапу (`/v1/transaction`) | сумма берётся из поля `value`, **за вычетом НДС по строке** (`nds` — это **процент**) |
| обязательства | все обязательства по этапу (`/v1/obligation`) | считаются **целиком**, даже если ещё не подписаны актом; поле `amount` уже **без НДС** |
| зарплата | сдельная зарплата по этапу (`/v1/deal-salary`) | сумма из поля `value` |
| зарплатные налоги | НДФЛ + взносы | **оценка** — пропорционально зарплате (см. ниже) |

Выручка и «списано» сходятся к **100 %**, итог НЗП — к **~98–99 %**, остаток приходится целиком на слой зарплатных налогов.

### Как расходы привязаны к этапам

В API сделка содержит массив `stages[]`, у каждого этапа свой `id`, `actDate` и `amount` (выручка без НДС). У самих расходов **нет** отдельного поля `stageId` — они привязываются через поле **`dealId`**.

- **Операции** (`/v1/transaction`): документация прямо говорит, что при прикреплении операции к этапу в `dealId` передаётся **идентификатор этапа**. Поэтому операции запрашиваются **по id этапа** (`?dealId=<stageId>`).
- **Обязательства и сдельная зарплата** (`/v1/obligation`, `/v1/deal-salary`): здесь документация описывает `dealId` только как «идентификатор сделки», **без оговорки про этап**. Значит, Fintablo может хранить там либо id сделки, либо id этапа. Чтобы результат был верным в любом случае, сервер запрашивает эти слои **по всем кандидатам сразу** — id сделки + id всех этапов — и **убирает дубликаты по id записи**. Каждая запись затем относится к этапу **по своему собственному `dealId`**: если он совпадает с этапом — к этому этапу; если это id сделки целиком («на уровне сделки») — к **первому открытому** этапу, чтобы попасть в НЗП ровно один раз.

Сделка без этапов считается как единый расчётный объект (по собственным `id`/`actDate`/`amount`).

## Известная неопределённость — как распознать неверный результат

⚠️ Документация Fintablo **гарантирует** привязку к этапу через `dealId` только для операций ДДС. Для **обязательств** и **сдельной зарплаты** поведение в спецификации не зафиксировано, и проверить его можно только на **живой сделке с этапами**. Сервер написан так, чтобы быть корректным при любом из вариантов (см. выше), но если цифра НЗП выглядит не так, как ожидается, в первую очередь проверьте именно эти два слоя.

**На что смотреть:**

- **Обязательства или зарплата «потерялись».** Если по многоэтапной сделке вы видите в Fintablo обязательства/сдельную зарплату, а в НЗП соответствующий слой нулевой или заметно меньше — сообщите об этом: вероятно, привязка `dealId` устроена иначе, чем предполагалось.
- **Обязательство «уровня сделки» попало не на тот этап.** Обязательство, привязанное к сделке целиком (а не к конкретному этапу), относится к **первому открытому** этапу. В разбивке по этапам (`perStage`) оно окажется на этом этапе — это намеренное упрощение. На **итоговую** сумму НЗП это не влияет (запись считается один раз), но распределение по этапам может выглядеть неожиданно.
- **Зарплата считается только сдельная.** Слой зарплаты берётся из `/v1/deal-salary` (**сдельная** зарплата, привязанная к сделке). Зарплата, начисленная через **Ведомость месяца** (`/v1/salary`), в расчёт НЗП **не попадает**. Если в производстве есть труд, который проводится не как сдельный, этот слой будет занижен.
- **Зарплатные налоги — всегда оценка.** См. раздел [«О слое зарплатных налогов»](#о-слое-зарплатных-налогов): это коэффициент, а не фактические суммы.

Все перечисленное — вопросы бизнес-логики, а не ошибки в коде. Окончательно закрыть их можно, сверив результат с эталонной цифрой Fintablo на реальной сделке с этапами (см. [Golden-тесты](#golden-тесты)).

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

| Инструмент | Назначение |
|---|---|
| `get_nzp` | НЗП по одной сделке по её коду (основной) |
| `list_partners` | справочник контрагентов |
| `list_partner_groups` | группы контрагентов |
| `list_categories` | статьи ОПиУ, с фильтром по `pnlType` |
| `fintablo_get` | ограниченный сырой GET для исследования (allowlist путей, лимит размера) |

## Установка

```bash
npm install
cp .env.example .env        # добавьте ваш FINTABLO_TOKEN
npm run dev                 # запуск на stdio (tsx)
npm run inspect             # открыть MCP Inspector
npm test                    # запустить тесты математики
```

Сборка и подключение в Claude Desktop:

```bash
npm run build
```

Затем добавьте запись из `claude_desktop_config.example.json` в ваш
`claude_desktop_config.json` (используйте абсолютный путь к `dist/index.js`).

## Контракт API (проверено)

Эндпоинты и имена полей в `src/fintablo/client.ts` сверены с OpenAPI-спецификацией FinTablo
(`https://my.fintablo.ru/api/FinTablo-v1-swagger.yaml`, она же отрисованная документация на
`https://my.fintablo.ru/api/docs`):

- **Базовый URL:** `https://api.fintablo.ru`, все ресурсы под `/v1`.
- **Авторизация:** заголовок `Authorization: Bearer <token>`. Лимит — 300 запросов в минуту (клиент делает retry/backoff на `429` и `5xx`).
- **Операции** живут на `/v1/transaction`: сумма — `value`, тип — `group` (`income`/`outcome`/`transfer`), НДС — `nds` (**процент**). Разбитые операции приходят отдельными строками (через `parentId`), а не вложенным массивом.
- **Пагинация** различается: `/transaction` — `page` + `pageSize` (до 1000), `/deal` — `page` (по 500), справочники (`/partner`, `/partner-group`, `/category`) не пагинируются.
- **Сделка** ищется по **`name`** (поля `code` в API нет; человекочитаемый код вроде `02-26/Б` — это имя сделки).
- **Обязательства** (`/v1/obligation`): `amount` уже **без НДС**, отдельное поле `nds` — это сумма НДС. Привязка `dealId` к этапу спецификацией **не гарантирована** — см. [«Известная неопределённость»](#известная-неопределённость--как-распознать-неверный-результат).
- **Сдельная зарплата** (`/v1/deal-salary`): сумма — `value`. Не путать с `/v1/salary` (ведомость месяца — другой формат). Привязка `dealId` к этапу так же **не гарантирована** (см. там же).

## О слое зарплатных налогов

Слой `payrollTax` — это **оценка**: общий коэффициент `PAYROLL_TAX_RATE` (НДФЛ + взносы), применённый к сумме зарплаты этапа. Реальные эффективные ставки различаются по сотрудникам, и привязать их к конкретному этапу из сырых данных нельзя.

При этом ведомость месяца `/v1/salary` **возвращает** фактические `tax` (НДФЛ) и `fee` (взносы) по сотруднику за месяц. Это месячные итоги, поэтому для разнесения по сделкам/этапам всё равно нужна пропорция — но их можно использовать, чтобы откалибровать `PAYROLL_TAX_RATE` по эталонным сделкам.

## Golden-тесты

`test/nzp.test.ts` содержит синтетические проверки плюс `TODO` на самую ценную работу:
записать реальные фикстуры `DealData` для **11-24/Г** и **02-26/Б**, затем проверять, что
`computeNzp(...)` совпадает с эталонными цифрами в пределах допуска. Эти две сделки —
ваша регрессионная сеть: любое будущее изменение API, ломающее математику, будет поймано.

Открытые вопросы, которые закроют именно эти фикстуры:

- наследуют ли строки-части разбитой операции `group`/`nds` родителя (сейчас по умолчанию `outcome`);
- значение `PAYROLL_TAX_RATE`, при котором итог сходится к ~98–99 %.

## Структура

```
src/
  index.ts              # MCP-сервер: stdio + регистрация инструментов
  fintablo/
    client.ts           # клиент API: авторизация, пагинация, retry/backoff, raw→domain
    types.ts            # доменные типы, от которых зависит математика
  domain/
    nzp.ts              # формула — ЧИСТАЯ, без I/O, покрыта тестами
    vat.ts              # вычет НДС по строке (по проценту)
test/
  nzp.test.ts           # тесты математики + каркас golden-тестов
```

## Лицензия

MIT

TDQS

B3/5.0

Scored across 5 tools

Disambiguation5/5

Each tool targets a distinct resource or operation: fintablo_get for raw data exploration, get_nzp for a specific calculation, and the three list_* tools for reference data. No two tools have overlapping purposes.

Naming Consistency3/5

Naming conventions are mixed: most tools use a verb_noun pattern (list_categories, list_partners, get_nzp), but fintablo_get uses a namespace prefix before the verb, breaking consistency. However, the patterns are still readable.

Tool Count5/5

With 5 tools, the set is well-scoped for a specialized financial data server. Each tool serves a clear purpose without being overwhelming or insufficient.

Completeness2/5

The tool surface lacks key operations for the domain, such as listing deals or transactions, creating/updating data, or other common calculations. This leaves significant gaps that could hinder an agent from fully accomplishing financial analysis tasks.