moysklad-analytics-mcp
README.md
# MoySklad Analytics
Interactive dashboard over the MoySklad JSON API 1.2 — sales & profit by product,
stock & restocking, customers & channels, and trends over time.
## Host note
`moysklad.uz` is a localized front-end for the same MoySklad platform — its login
button points at `online.moysklad.ru`, and **there is no `api.moysklad.uz`**
(the hostname does not resolve). Tokens issued through the `.uz` site work
against the standard API host:
```
https://api.moysklad.ru/api/remap/1.2
```
Two quirks worth knowing, both handled in `server/src/moysklad/moysklad.service.ts`:
- The API returns a bare nginx **415** to any client that does not advertise gzip.
- Account limits are **45 requests / 3 seconds** and **5 concurrent**; breaching
either returns 429 with a `X-RateLimit-Retry-After` header (milliseconds).
## Setup
```bash
pnpm setup # install server + web deps
cp .env.example .env # then put your token in it
echo 'MOYSKLAD_TOKEN=xxxxxxxx' > .env
```
Get a token in the MoySklad UI under **Настройки → Обмен данными → Токены
доступа к JSON API**, or:
```bash
curl -X POST https://api.moysklad.ru/api/remap/1.2/security/token \
-H "Authorization: Basic $(printf 'login:password' | base64)" \
--compressed
```
## Check the account first
```bash
pnpm probe
```
This validates the token and prints what the account actually holds: identity,
row counts per entity, stores, currency, document volumes, the date range of
real sales, and a reachability check across all 14 report endpoints with sample
row shapes. Run it before trusting any number on the dashboard.
**Permissions matter.** MoySklad hides cost price, purchase price and profit
from users who lack those rights. If the token belongs to a restricted user the
profit and margin columns come back empty — the probe shows this immediately,
and the dashboard labels such panels "нет прав" rather than "нет данных".
## Run
```bash
pnpm dev # API on :8787, dashboard on :5173
```
Open http://localhost:5173. The browser never sees the token — it talks only to
the Vite proxy, which forwards to the NestJS API.
```bash
pnpm build # production build of both
pnpm typecheck
```
## Layout
```
server/ NestJS 11 + TypeScript
src/moysklad/
moysklad.service.ts auth, gzip, pagination, rate limiter, retry
rate-limiter.ts 40 req/3s, 5 concurrent
moysklad.types.ts report row shapes
src/stats/
stats.service.ts report -> dashboard metrics, 5-minute TTL cache
stats.controller.ts GET /api/stats/*
period.dto.ts query validation
src/common/
api-exception.filter.ts upstream errors -> messages the UI can show
src/probe.ts account discovery
web/ Vite 8 + React 19 + HeroUI v3 + Tailwind v4 + Recharts
src/pages/ Overview, Products, Stock, Customers, Trends
src/components/ Panel, KpiCard, DataTable, charts, PeriodPicker
src/lib/ api client, formatters, types
```
## API
All endpoints take `?from=YYYY-MM-DD&to=YYYY-MM-DD` (default: last 30 days).
| Endpoint | Returns |
|---|---|
| `GET /api/stats/meta` | stores, organizations, default currency |
| `GET /api/stats/overview` | headline KPIs with period-over-period deltas |
| `GET /api/stats/products` | per-product revenue/profit/margin, loss makers, returns, Pareto |
| `GET /api/stats/stock` | stock value, days of cover, restock list, dead & overstocked |
| `GET /api/stats/customers` | top customers, repeat rate, concentration, channels, employees |
| `GET /api/stats/trends?interval=hour\|day\|month` | sales, orders and cash series |
| `POST /api/stats/cache/clear` | drop the TTL cache |
| `GET /api/assistant/status` | whether the assistant has an API key |
| `POST /api/assistant/ask` | SSE stream — `{question, history}` in, `text`/`tool`/`done`/`error` events out |
| `POST /api/mcp` | MCP (Streamable HTTP) for external AI clients — see below |
## Field semantics — verified against the live account
Four things the documentation does not make obvious, each confirmed against real
rows and each a wrong number if handled naively:
- **Money arrives in minor units** and is divided by 100. Confirmed: a bar priced
at `sellPrice: 9642.86` with `sellSum: 67500` for 7 units is $96.43 each, not
$9,642.
- **`margin` and `salesMargin` are different ratios, both fractions.**
`margin` = profit / cost (наценка) and `salesMargin` = profit / revenue
(маржа). The dashboard shows `salesMargin` as "Маржа" and `margin` as
"Наценка", each scaled by 100. Rendering the raw `margin` as a percentage
reports 0.6% where the truth is 60.7%.
- **`/report/stock/all` returns hrefs with a `?expand=supplier` suffix** while
the profit report returns bare ones. Joining them raw matches nothing and
silently classifies the entire catalogue as dead stock.
- **The group profit reports count documents, not units** — `salesCount`, not
`sellQuantity`, which does not exist on them.
- **The money series uses `credit`/`debit`**, not `income`/`outcome`.
Judgement calls that remain yours:
- **Average cheque** divides shipment revenue by shipment count (`/entity/demand`
in the window), not by customer-order count — the two differ, and mixing them
made Overview disagree with the Customers page.
- **Days of cover** uses velocity over the selected window only, so a short
window exaggerates urgency on seasonal items.
- **Dead stock** means zero sales *in the selected window*, not all-time.
## AI assistant
The **Ассистент** tab answers natural-language questions over the same data —
"what should we reorder", "how are treadmills selling", "who are our biggest
customers". Set `DEEPSEEK_API_KEY` ([get one here](https://platform.deepseek.com))
to enable it; without one the tab reports that it is off and nothing else changes.
It runs **DeepSeek** (`deepseek-v4-flash`, OpenAI-compatible API) with function
calling over seven read-only tools that wrap `StatsService`, so the assistant and
the dashboard panels read exactly the same numbers — there is no second
definition of "revenue" to drift. Answers stream to the browser over SSE.
The tools are the assistant's only access to the account: it cannot write to
MoySklad, and it cannot reach anything the dashboard cannot.
Four things worth knowing if you change it:
- **Why this model.** Benchmarked against the alternatives on the real account.
`deepseek-v4-flash` ($0.14 in / $0.28 out per 1M, $0.0028 on cache hits) scored
4/4 on tool selection at ~2s per round — cheapest and fastest of everything
tried. `deepseek-v4-pro` ($0.435/$0.87) was ~2x slower with no accuracy gain.
Local models were tested too and rejected: see the note below.
- **The balance is prepaid.** A depleted balance returns `402 Insufficient
Balance`, surfaced in the UI as a readable message rather than a stack trace.
- **Let the tools default the dates.** The prompt tells the model to omit
`from`/`to` unless the question names a period. Left to itself it computed
"last 30 days" as a 31-day window — figures correct for its window, but
silently disagreeing with the dashboard preset for the same phrase.
- **The catalogue is in Russian, and the model is not.** `search_products`
matches on any single token and, on a miss, returns the account's category
names so the model can retry with a real term.
- **The field semantics are in the prompt on purpose.** Margin vs markup, the
average-cheque basis, what "dead stock" counts — the same traps documented
above. Without them the model reports plausible, wrong numbers.
### Why not a local model
`qwen3:8b` and `qwen3:4b` were benchmarked on the production host via Ollama.
Tool selection was fine (4/4), but the box has no GPU: a single model round took
46–198s warm, so one answer (2–3 rounds) would take 3–5 minutes against ~2s for
DeepSeek. Worse, unconstrained inference took 7.7 of 8 cores and pushed
the production admin panel from 0.5s to 5.5s — the same host runs the API, admin,
bot, Postgres and MinIO. Capping it to 4 cores (`CPUQuota=400%`) protected
production but made inference slower still. Revisit only on a machine with a GPU.
- **The catalogue is in Russian, and the model is not.** Asked about
"treadmills", every model searched the English word and would have found
nothing. `search_products` therefore matches on any single token and, on a
miss, returns the account's category names so the model can retry with a real
term — and the prompt tells it to search in Russian.
- **The field semantics are in the prompt on purpose.** Margin vs markup, the
average-cheque basis, what "dead stock" counts — the same traps documented
above. Without them the model reports plausible, wrong numbers.
## MCP endpoint — the same data for external AI
`POST /api/mcp` speaks [MCP](https://modelcontextprotocol.io) over Streamable
HTTP (stateless, JSON responses), so any MCP client — Claude, Cursor, ChatGPT
connectors, another agent — can query the account directly.
The tool surface is the assistant's seven, plus seven business tools, plus raw:
- the **seven curated analytics tools** (`get_account_info`, `get_overview`,
`search_products`, `get_products`, `get_stock`, `get_customers`,
`get_trends`) — the same `buildTools(stats)` objects the in-dashboard
assistant uses, so an external model and the dashboard cannot disagree on
what "revenue" means;
- **seven business tools** (`server/src/mcp/business-tools.ts`), shaped by
probing what the account actually holds: `get_documents` (any of 16 document
types for a period — orders, shipments, cash, write-offs, transfers, ...),
`get_document` (one document with line items), `get_money` (balances per
org/account, cash vs bank flow, expenses by category — the cash desk is this
account's busiest surface), `get_purchases` (supplies + purchase orders by
supplier), `get_debts` (mutual settlements both directions),
`search_counterparties` (lookup + per-counterparty history), `get_turnover`
(opening/received/issued/closing per product). All money already in major
units;
- **`moysklad_api`** — raw read-only GET against any `entity/...` or
`report/...` path, for everything the curated tools don't cover
(counterparties, purchase documents, cash flow, single objects by id).
Structurally read-only: the underlying client only issues GET. Results are
capped at 100 rows / ~60k characters and `meta` objects are trimmed to
`href`+`type`. **Raw responses are in minor units (kopecks/tiyin)** — the
tool description tells the model to divide by 100; the curated tools already
return major units.
Enable it by setting `MCP_TOKEN` (min 24 chars, `openssl rand -hex 32`).
Without `MCP_TOKEN` the route simply stays behind Basic auth like everything
else. With it, clients get in two ways:
**1. Static bearer** — for Claude Code and scripts:
```bash
claude mcp add moysklad --transport http https://your-deployment.example.com/api/mcp \
--header "Authorization: Bearer <MCP_TOKEN>"
```
**2. OAuth login** — for the claude.ai and ChatGPT apps (including mobile),
whose connector UIs cannot send custom headers. Add a custom connector with
just the URL `https://your-deployment.example.com/api/mcp`; the app discovers the
OAuth endpoints, opens the login page, and the user signs in with the
**dashboard credentials** (`DASHBOARD_USER` / `DASHBOARD_PASSWORD`). In
claude.ai: Settings → Connectors → Add custom connector (connectors added on
the web are then available in the mobile app). In ChatGPT: Settings →
Connectors (Developer mode required for custom MCP).
The OAuth server is the full MCP-spec stack — RFC 9728 resource metadata,
RFC 8414 AS metadata, RFC 7591 dynamic client registration, authorization code
+ PKCE (S256, enforced), refresh tokens — implemented in
`server/src/oauth/`. Design choices worth knowing:
- **Stateless everything.** Codes, tokens and client registrations are
HMAC-signed blobs; there is no database, and a redeploy logs nobody out.
The signing key is `MCP_TOKEN` itself, so rotating that one value revokes
the static credential *and* every OAuth grant at once.
- **One human identity.** The login form checks the dashboard's Basic-auth
credentials — same person, same password, however they arrive.
- **Access tokens live 30 days, refresh 180.** The only server-side state is
the used-code set (single-use enforcement); losing it on restart is a
10-minute replay window, accepted.
- **Login is throttled** globally (20 failures/minute), not per-IP — behind
the proxy every client is one IP anyway.
`PUBLIC_URL` pins the origin used in OAuth metadata; unset, it is derived from
`Host`/`X-Forwarded-Proto`, which is correct behind Caddy.
## Authentication
The server enforces HTTP Basic auth and **refuses to start when
`NODE_ENV=production` without `DASHBOARD_USER` and `DASHBOARD_PASSWORD`**. Only
`/api/health` is exempt, and it reports nothing but liveness. `/api/mcp` has
its own Bearer token (above) and is exempt from Basic auth only when that token
is configured.
This is not optional hardening. The dashboard shows revenue, cost prices,
per-product margins and every customer with what they spend; on a public host an
unguarded URL is not obscure, because the certificate transparency log publishes
the hostname minutes after Caddy issues the certificate.
In development, omitting the variables runs without auth and logs a warning.
## Deployment
```bash
docker build -t moysklad-stats:latest . # API + built SPA in one image
```
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues