Commit Graph

3 Commits

Author SHA1 Message Date
Víctor Falcón b2ff1664e2
feat(mcp): budget tools — read, create, edit and delete (#779)
Budgets were the one part of the app a connected agent could not see. It
could
list transactions, categories and labels, but "am I still inside my food
budget
this month" had no answer, and setting a budget up meant leaving the
conversation. This adds the four tools that close that gap.

## Tools

| Tool | What it does |
| --- | --- |
| `list_budgets` | Every budget with the period in progress: allocated,
carried over, spent, remaining, plus the categories and labels it tracks
|
| `create_budget` | A limit per period over categories and/or labels, or
the single catch-all budget |
| `update_budget` | Rename, or change the limit |
| `delete_budget` | Remove a budget; the transactions it was watching
are untouched |

Budgets hang off the user rather than off a space — that is how the app
already
decides which budgets a transaction feeds — so these tools take no
`space`
argument and cover the whole account, like the cashflow and net-worth
ones.

`update_budget` deliberately only takes `name` and `allocated_amount`.
The web
edit dialog locks the period length, start day and rollover behind an
explicit
message ("budgets are calculated historically"), and for good reason:
changing
them afterwards lets the next generated period overlap the one in
progress, so
the same transaction gets counted twice. To change those, or the tracked
categories, the agent deletes the budget and creates it again.

## Shared service, and one web behaviour change

Creating a budget seeds two periods and dispatches the historical
backfill for
both; that logic now lives in `BudgetService`, called by
`BudgetController` and
by the tools, instead of being copied into the MCP layer.

**This changes one thing on the web:** editing a budget's amount now
applies to
the period in progress as well as future ones. The old filter was
`start_date >= today`, which skipped a period that had already started —
so on
any day but the first of the period, saving a new amount appeared to do
nothing.
The edit dialog already promised the new behaviour ("This will update
the
allocated amount for the current and future periods") and prefills the
current
period's amount, so the code was the side that was wrong. Covered by a
new test
in `BudgetTest`.

## Fix: a start day that could hang period generation

`period_start_day` means a day of the month for monthly budgets and a
day of the
week for weekly ones. A weekly budget carrying a value above 6 sent
`calculatePeriodDates()` into `while ($date->dayOfWeek !== $dayOfWeek)`,
walking
backwards forever looking for a day that cannot exist. The web form caps
the
input at 6, but the server-side rule never did, so the tools were the
first
surface that could reach it — and once such a row exists, the daily
`budgets:generate-periods` command hits it and stops generating periods
**for
every user**.

Two changes: the tools validate the range against `period_type`, and the
generator takes the value modulo 7 so no caller can spin. Verified
against a row
doctored to `weekly` + `15`: it now resolves to a normal week instead of
hanging.

## Reviews

Both review passes ran; the notable ones applied beyond the two above:

- `remaining_amount` no longer adds the carried-over amount. The budget
cards,
the spending chart and the limit emails all measure against the
allocated
amount alone, so the old formula meant an agent quoting a number no
screen
  shows. `carried_over_amount` is still reported as its own field.
- The period reports `processing_historical`, so an agent can tell
"nothing
spent" from "backfill still running" instead of trusting a prose caveat.
- The category/label hints say the ids must be ones the user owns —
`list_categories` is space-scoped and can return a co-member's, which a
budget
  cannot track.
- Request id normalisation moved onto `McpTool` as `requestedIds()`,
shared with
`labelsInSpace`, which no longer runs a query just to build an empty
result.

Deliberately not applied: the "at least one category or label" and "only
one
catch-all" rules stay duplicated between `StoreBudgetRequest` and
`create_budget`. Unifying them would change the web error key
`selection`, which
the create dialog renders, and the two audiences want different wording
— the
agent gets told what to send next.

## QA

Driven over the real `/mcp` endpoint with a Sanctum token, since there
is no UI
surface:

- `tools/list` advertises all four with the right annotations and
schemas
  (`delete_budget` destructive, `list_budgets` read-only).
- `create_budget` → the response comes back with `processing_historical:
true`;
after draining the queue, the €120 transaction already on the account is
  attached and `spent_amount` reads 12000, `remaining_amount` 38000.
- `update_budget` to 60000 → the August period moves to 60000, the
closed July
  period keeps 50000.
- `delete_budget` → gone from `list_budgets`, transaction still there.
- Refused as expected: a read-only token, a weekly budget with
`period_start_day`
  15, a budget tracking nothing, and an unknown budget id.
- `mcp_tool_calls` recorded only the four calls that did something, not
the
  rejections.

`chatgpt-app-submission.json` gains the four tools with their hint
justifications, plus a budget test case, so the submitted manifest keeps
matching
what the server serves (`ToolAnnotationsTest` enforces the count).
2026-08-11 14:28:53 +00:00
Víctor Falcón bd1ad633f8
feat(mcp): filter search_transactions by label (#724)
## What

Adds label filtering to the `search_transactions` MCP tool. It's the one
filter the web transactions view has that the MCP surface was missing.

- New optional `label_ids` array param. Returns transactions carrying
**any** of the given labels (OR-semantics), matching the web app's label
filter (`Transaction::scopeApplyFilters`).
- Combines with the other filters (account, category, date, amount) with
AND, consistent with how the tool already treats them — so `category_id`
+ `label_ids` means "in that category **and** carrying one of these
labels".
- Each returned transaction now also exposes its `labels` (`id`,
`name`), so the agent can see why a row matched, consistent with what
the write tools already return.

## Validation & consistency

- `label_ids` are validated against the space via `labelsInSpace`,
promoted from `WriteTool` to the shared `McpTool` base so read and write
tools use one implementation. An unknown or stale label id now returns
the same actionable *"call list_labels"* error the rest of the MCP
surface gives, instead of a silent empty result.
- The tool `#[Description]` now mentions `label`, so agents discover the
capability without inspecting each param.

## Tests

- `search_transactions` filters by label id (matching row in, unlabelled
row out).
- A foreign/unknown label id is rejected with an error.
- Full MCP read + write suites stay green (26 tests) — confirms moving
`labelsInSpace` to the base didn't regress the write tools.

Pint, Larastan and the affected Pest suites pass locally.
2026-07-22 15:13:03 +02:00
Víctor Falcón fb1adfc484
feat(mcp): read-only MCP server for Pro accounts (#689)
## What & why

Adds a **read-only MCP server** so a paid ("Pro") user can connect
Whisper Money to their own AI assistant (Claude web/desktop, Claude
Code, ChatGPT) and analyse their own finances — spending, cashflow, net
worth, transactions.

This is **Phase 1 (PR1): read-only**. Write tools (create/edit/delete
transactions, categories, labels, rules, balances) are a deliberate
follow-up (PR2); the token plumbing already reserves an `mcp:write`
ability for them.

## How it works

- **Transport:** remote streamable HTTP server via `laravel/mcp`,
mounted at `/mcp` (`routes/ai.php`).
- **Auth:** Sanctum personal access tokens with **MCP-only abilities**
(`mcp:read`). The route is gated by `auth:sanctum` +
`abilities:mcp:read` + `throttle:60,1`, so a future public-API token
(different ability) can't reach it and vice versa.
- **Pro gating** is enforced **per request inside the tools**
(`User::canUseFeature(PlanFeature::McpAccess)`), so a lapsed
subscription stops working on its own without the user revoking the
token. Free users can still create tokens (marked **PRO** in the UI) but
every call returns a "paid plan required" error with an upgrade URL.
- **Consent:** connecting is the consent — a clearly-weighted
data-egress disclaimer + per-client connection instructions on the
settings page. No separate checkbox (by design).

## Tools (all read-only)

| Tool | Scope |
|------|-------|
| `search_transactions` | space-scoped (optional `space`, defaults to
personal) |
| `list_accounts`, `list_categories`, `list_spaces` | space-scoped |
| `spending_by_category`, `get_cashflow`, `get_net_worth` | user's whole
account (reuse existing analytics services/controllers) |

Recurring-charge detection is left to the agent over
`search_transactions` results (no dedicated tool).

## Settings → MCP access

New page to create / rotate / revoke tokens (name + one-time secret
reveal), with `last_used_at`, a PRO badge, the egress disclaimer, and
copy-paste connection instructions for Claude (web/desktop), Claude Code
and ChatGPT.

## Tests

- Tool behaviour + Pro gating + **cross-user / cross-space isolation**
(`tests/Feature/Mcp/McpToolsTest.php`).
- HTTP auth boundary: 401 without a token, 403 without `mcp:read`, 200
with it (`tests/Feature/Mcp/McpEndpointAuthTest.php`).
- Token CRUD + ownership + free-tier creation
(`tests/Feature/Settings/McpTokenTest.php`).

## Reviewed & adjusted

Ran technical + product reviews and applied the fixes: kept PR1 strictly
read-only (dropped a UI scope selector that promised non-existent
write), routed gating through the `PlanFeature` convention, put token
rotation behind a confirmation, removed a silent on-load clipboard copy,
weighted the egress disclaimer, and fixed a `list_spaces` N+1.

### Known, deliberate tradeoffs
- `get_cashflow` / `get_net_worth` / `spending_by_category` reuse the
existing **user-scoped** analytics controllers/services, so they cover
the whole account rather than a single space (documented in the server
instructions). Per-space analytics is a follow-up.
- Space tools scope by `space_id` gated by membership
(`accessibleSpaces`) — the intended shared-tenant model — rather than a
per-row `user_id` filter.

## Not runnable in this environment
Browser QA of the settings page wasn't run here (no local
`node_modules`); that surface relies on CI build/typecheck/lint and
follows existing settings-page conventions.

---

## Updates since opening

- **Behind a feature flag.** New `App\Features\Mcp` (default off) hides
the whole settings screen — the nav item and every `settings/mcp*` route
(404 when off). Pro-plan gating still happens per request. Enable it
with `php artisan feature:enable "App\Features\Mcp" <email|all|25%>`.
- **Renamed** the user-facing page from "MCP access" to **"AI
Connector"** (nav, title, breadcrumb) so non-technical users understand
it. Route names, files and the feature stay internal.
- **Shared `ProBadge`** component (amber), now used on both the AI
Connector and billing pages instead of an inline badge.
- **Softer data-egress notice** (amber shield icon instead of a red
alert) and plainer copy throughout.
- **Accurate connection instructions (important).** Verified against the
official docs: a personal access token works with **Claude Code** today.
**Claude Desktop** and **ChatGPT** custom connectors authenticate over
**OAuth** and do not accept a static token, so they're now marked
**"coming soon"**. OAuth is the real unlock for those clients and is the
recommended follow-up (it also maps to the deferred write-tools work).
Sources: [Claude custom
connectors](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp),
[ChatGPT developer
mode](https://developers.openai.com/api/docs/guides/developer-mode).
- UI reviewed in a real browser; layout/alignment checked across states
(empty, new-token reveal, token list).
2026-07-17 16:54:15 +02:00