Commit Graph

1 Commits

Author SHA1 Message Date
Víctor Falcón 9e493dc75b
feat(mcp): record MCP tool usage and report it with stats:mcp-usage (#760)
## What

We had no idea whether anyone actually uses the MCP server. This records
one row per tool call and adds a report to read it back: which tools get
used, by which users, and how much.

**Storage** — `mcp_tool_calls`: `user_id`, `tool`, `created_at`. Raw
rows rather than pre-aggregated counters, because the volume is a
handful of calls per Pro user per day and a `GROUP BY` then answers
whatever we want to ask later. No arguments and no financial content are
stored.

**Recording** — one `rescue()`d insert in `McpTool::handle()`, the base
class all 24 tools inherit with no overrides, so coverage is complete by
construction. It sits *after* `respond()` and skips error responses, so
the number means "calls that did something": a plan-gate rejection, a
read-only token rejected on a write, or a `ValidationException` for an
unreachable id is an attempt, not usage. `rescue()` keeps a failed
insert from ever breaking a working tool call while still reporting to
Sentry.

**Reading** — `php artisan stats:mcp-usage [--days=30] [--top=20]`:

```
MCP usage — last 30 days (since 2026-07-13)
  Calls: 15   Users: 2

By tool
+---------------------+-------+-------+-------+
| Tool                | Calls | %     | Users |
+---------------------+-------+-------+-------+
| search_transactions | 10    | 66.7% | 2     |
| get_cashflow        | 2     | 13.3% | 1     |
| get_net_worth       | 2     | 13.3% | 1     |
| create_transaction  | 1     | 6.7%  | 1     |
+---------------------+-------+-------+-------+

By user (top 20)
+--------------------------------------------+-------+-------+---------------------+
| User                                       | Calls | Tools | Last call           |
+--------------------------------------------+-------+-------+---------------------+
| ana@example.com                            | 12    | 4     | 2026-08-11 08:06:59 |
| 20260811080659_bruno@example.com (deleted)  | 3     | 1     | 2026-08-11 08:06:59 |
+--------------------------------------------+-------+-------+---------------------+

By day
+------------+-------+-------+
| Day        | Calls | Users |
+------------+-------+-------+
| 2026-08-08 | 2     | 1     |
| 2026-08-11 | 13    | 2     |
+------------+-------+-------+
```

The per-user table joins `users` instead of eager-loading the relation:
`user:delete` soft-deletes, so the FK cascade never fires and the
relation's `deleted_at is null` scope would silently blank out exactly
the users we most want to see — the ones who churned. They render with a
`(deleted)` marker.

## QA

Driven through the real `/mcp` HTTP endpoint with real Sanctum bearer
tokens, against MySQL:

- 5 successful calls by a read+write user → 5 rows, right user, right
tool names.
- `get_net_worth` with missing arguments and `search_transactions` on an
unreachable space → both rejected, neither recorded.
- A read-only token: `list_accounts` recorded; `create_label` rejected
with "This token is read-only" and not recorded.
- Report checked at `--days` 1 / 30 / 200, with `--top 1` truncation,
with a churned (`markAsDeleted()`) user, and with no data at all.

48 MCP tests green, `pint` and `phpstan` clean.

## Deliberately left out

- **No Discord post or schedule.** The other `stats:*` commands post
weekly; whether MCP usage is worth that noise is a product call, and
it's one `Schedule::command()` line whenever we want it.
- **No client column.** `Auth::getDefaultDriver()` would tell us OAuth
(Claude Desktop / ChatGPT) vs personal token (Claude Code) at the
insert. It's not recoverable after the fact, so it's worth knowing we
skipped it — but it wasn't asked for.
- **No pruning.** Noted in the migration; add it if the table ever gets
big.
- **No collector service.** The sibling report commands extract one
because they feed both the console and Discord. This has one consumer.
2026-08-11 10:27:32 +02:00