## Why
Second pass at the complexity report. After the trial-experiment code
(deleted by #762) and the analytics controller (#766), the worst
offenders were the MCP write tools:
- `UpdateTransaction::write` — cyclomatic complexity **20**
- `UpdateAutomationRule::write` — cyclomatic complexity **13**
Both for the same reason: a long wall of `if ($request->has('x')) {
$model->x = ...; }` blocks, one per optional field.
## What changed
Two shapes moved into `WriteTool`:
**`modelInSpace()`** — resolving a record in the space was written five
times (account, transaction, category, label, plus a rule resolver that
existed twice, once in `UpdateAutomationRule` and once in
`DeleteAutomationRule`), each a copy of the same query + null check +
message. The four public helpers are now one-liners over it, and
`ruleInSpace()` is shared instead of duplicated.
**`applyFields()`** — assigns only the fields the request actually
carries, which is the "only what you pass changes" contract these tools
document. Values are closures, so resolving a related model
(`accountInSpace`, `categoryInSpace` — either can throw a validation
error) still happens only when its field is present.
## Metrics
| | before | after |
|---|---|---|
| methods over complexity 10 | 35 | 33 |
| `UpdateTransaction::write` | 20 | 7 |
| `UpdateAutomationRule::write` | 13 | 6 |
| duplicated lines | 5.34% | 5.34% |
Duplication does not move, and it's worth being straight about why: what
jscpd still flags across `app/Mcp/Tools` is **file headers** — the `use`
block, the `#[Description]` attribute, the class line and the opening of
`schema()`. Nine near-identical lines per tool that no extraction can
remove. Deleting the duplicated `ruleInSpace` shortened
`DeleteAutomationRule` enough that its header became a clone pair with
another tool's, so the counter stayed flat while the real duplication
went away.
## Testing
`tests/Feature/Mcp` — 46 tests green, plus a new one. Nothing asserted
the resolver failure messages before, and this PR changes how they are
built (a shared template plus an optional hint), so the new test pins
both branches: the message that points at a listing tool (`Call
search_transactions to find ids.`) and the one that has no hint to
append.
## Why
The ChatGPT app directory rejects the submission with:
> Every MCP tool must set readOnlyHint, openWorldHint, destructiveHint
to true or false.
We only ever declared one hint per tool — `#[IsReadOnly]` on the reads,
`#[IsDestructive]` on the writes — so the other two were absent from
`tools/list` and the portal's scan flagged all 23 tools.
## What
- `McpTool::annotations()` now defaults all three hints, so every tool
reports `readOnlyHint`, `destructiveHint` and `openWorldHint`
explicitly. The attributes still override: `#[IsReadOnly]` on the eight
read tools, `#[IsDestructive]` on the four deletes.
- `openWorldHint` is always `false`: every tool reads or writes the
user's own account, never the open web.
- `destructiveHint` drops to `false` on the eleven
create/update/categorize/label tools. Marking them destructive was wrong
— the directory reserves it for irreversible operations — and it made
ChatGPT ask for confirmation on every write, including recategorizing a
transaction.
- Tool descriptions trimmed to the portal's 200-character cap (nine were
longer, `create_automation_rule` ran to 524). The cuts are facts the
server instructions already state — amounts in minor units,
whole-account scope. The JsonLogic variable list and example move to the
`rules_json` schema field, which the model still reads and the form does
not cap.
- `chatgpt-app-submission.json` is the submission-import file the portal
accepts, carrying the listing metadata, the per-tool hints with their
required justifications, and the positive/negative test cases.
## Testing
`tests/Unit/Mcp/ToolAnnotationsTest.php` pins both contracts: every tool
declares all three hints with `readOnlyHint`/`destructiveHint` matching
the expected tool lists, and no description exceeds 200 characters.
`tests/Feature/Mcp` still passes.
## Why
You could not add a transaction by hand to a bank-connected account.
There was no good reason for it: bank sync **only inserts** rows it has
not seen before (dedup runs on `dedup_fingerprint` /
`external_transaction_id`, both `null` on manual rows) and **never
deletes or updates**, so a hand-entered transaction survives every later
sync untouched.
The one thing that genuinely does not make sense is letting a user set a
**balance** on a connected account, because the next sync overwrites it.
That restriction stays.
## What was actually blocking it
Less than it looked. The HTTP endpoint already allowed it,
`ManualBalanceAdjuster` already skipped connected accounts, and the
transaction dialog already handled them (it forces `updateBalance:
false`). Only two surfaces blocked it:
- **MCP** — `WriteTool::writableAccount()` rejected every connected
account for *all* writes. Split into `accountInSpace()` (no connection
check — used by `create_transaction` / `update_transaction`) and
`balanceWritableAccount()` (still rejects connected — used by
`create_balance`).
- **The account detail page** — hid its "Add transaction" button for
connected accounts, even though the same dialog on the transactions page
already offered them in its account picker.
## Also in here
Two problems the change surfaced, both fixed:
- **`EnableBankingSyncer` linked-account watermark** took the newest
transaction of *any* source. With manual rows now able to land on a
connected account, one dated later than the bank's last posting would
shrink the fetch window and skip the bank history in between —
permanently, since the watermark only moves forward. On the QA data this
would have skipped **36 days**. Now restricted to bank-sourced rows.
- **`calculateHistoricalBalances`** derives history by walking back from
a bank-provided reference balance, summing transactions unfiltered.
Counting a hand-entered row subtracts money the bank never had. Now
walks bank-sourced rows only. (Safe before only because manual rows
could not reach a connected account.)
Plus the honesty/copy work:
- `ManualBalanceAdjuster` returns whether it shifted anything, so
`create` / `update` / `delete_transaction` all report `balance_updated`
instead of silently no-opping and letting the agent claim a balance
moved.
- The **OAuth consent screen** and the AI Connector settings page said
"bank-connected accounts stay read-only". That was a trust statement,
and it is no longer true — both now say bank-*synced transactions*
cannot be edited or deleted and connected balances stay untouched.
- `create_balance`'s description no longer contradicts the server
instructions shipped alongside it.
- The transaction dialog now *explains* why the "Update account balance"
checkbox is absent on a connected account instead of just hiding it (it
defaults to on and is localStorage-persisted, so it used to vanish
mid-form with no reason given).
## What stays blocked
- Balances on connected accounts — MCP `create_balance` rejects them,
the adjuster no-ops, the UI shows the explanation instead of the
checkbox.
- Editing or deleting bank/imported transactions — still gated on
`source === manually_created`, unchanged.
## Testing
- `create_transaction` on a connected account leaves its balances alone;
moving a transaction onto a connected account unwinds only the manual
side it came from.
- A manual row survives a sync and does not block the bank's own rows —
the invariant the whole change rests on, previously untested.
- A manual transaction does not move the linked-account sync window.
- The account page offers "Add transaction" on connected accounts, and
hides it on non-transactional ones.
- Full suite: 2047 passing, phpstan clean.
QA'd in the browser end to end (create from a connected account, edit it
afterwards, contrast with a manual account, switch accounts mid-form)
and over real MCP calls against the running server (`create_transaction`
returned `balance_updated: false` on connected / `true` on manual;
`create_balance` refused the connected account).
## Demo
https://github.com/user-attachments/assets/7a7f1cbb-8f68-402d-ba35-3288dd7ea77f
<!-- PLACEHOLDER: drag the video here -->
## MCP Phase 2 — write tools
> **Stacked on #689** (`mcp-functionality`). Base this PR on
`mcp-functionality`, not `main`, and merge it **after** #689.
Phase 1 shipped a read-only MCP server for Pro accounts. This adds the
**write** surface and re-enables the read/read-write token scope the UI
dropped in PR1.
### Write tools
A new `WriteTool` base extends `McpTool`: on top of the Pro-plan gate it
requires the calling token to carry `mcp:write`, returning a clear error
for read-only tokens. Each concrete tool is annotated `#[IsDestructive]`
(PHP attributes aren't inherited, so the annotation lives on each tool,
not the base — a docblock on `WriteTool` notes this).
- `create_transaction` — manual (non-connected) accounts only; forces
`source = manually_created`.
- `update_transaction` / `delete_transaction` — manually-created
transactions only; bank/imported ones stay locked.
- `categorize_transaction` — sets/clears the category on **any**
transaction (imported included), marking it `category_source = manual`.
- `label_transaction` — add/remove labels on **any** transaction.
- `create_balance` — balance snapshot on manual accounts only.
- `create_category` / `update_category` / `delete_category` — mirrors
the settings controller (parent/depth/cycle rules, cashflow derivation,
child strategies).
- `create_label` / `update_label` / `delete_label`.
- `create_automation_rule` / `update_automation_rule` /
`delete_automation_rule` — JsonLogic conditions + category/label
actions, at least one action required.
- `list_labels` — a small **read** tool added so label ids are
discoverable (label/automation tools are unusable without it).
### Guardrails
Write tools never touch bank-sourced data: the existing
`TransactionSource` enum and `Account::isConnected()` are the barriers,
reused not reinvented. There is no server-side write confirmation
(client-controlled, accepted decision) — hence `#[IsDestructive]`.
### Token scope
`StoreMcpTokenRequest` re-adds `scope` (`read` | `read_write`); the
controller grants `['mcp:read']` or `['mcp:read', 'mcp:write']`. The
settings page gets its scope selector back with honest copy (new strings
added to `lang/es.json`). The `/mcp` route stays gated on
`abilities:mcp:read` — any MCP token can connect and read; the per-tool
`mcp:write` check is what blocks writes.
### Tests
Happy path + guardrail failures for every write tool, the
read-only-token rejection (via a real read-only PAT so the `tokenCan`
gate runs exactly as over HTTP), cross-user isolation, the inherited Pro
gate, and read/read_write scope validation.
### Notes
- `AutomationRule::labels()` gained a generic return annotation (needed
for larastan level 5 on the new label mapping).
### Verification
- `vendor/bin/pint --test` ✅
- `vendor/bin/phpstan analyse` (larastan level 5) — 0 errors ✅
- `php artisan test tests/Feature/Mcp
tests/Feature/Settings/McpTokenTest.php
tests/Feature/LocalizationTest.php` ✅
- `prettier --check` / `eslint` on `settings/mcp.tsx` ✅