feat(mcp): add write tools (Phase 2) (#690)

## 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` 
This commit is contained in:
Víctor Falcón 2026-07-17 17:25:03 +02:00 committed by GitHub
parent fb1adfc484
commit 5d7b655111
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1921 additions and 13 deletions

View File

@ -48,12 +48,17 @@ class McpTokenController extends Controller implements HasMiddleware
/**
* Create a new MCP token. The plaintext secret is flashed once; only its
* hash is stored, so it can never be shown again. Tokens are read-only for
* now (the `mcp:write` ability is introduced alongside the write tools).
* hash is stored, so it can never be shown again. A "read" token can only
* analyse data; a "read_write" token additionally carries `mcp:write`,
* unlocking the write tools.
*/
public function store(StoreMcpTokenRequest $request): RedirectResponse
{
$token = $request->user()->createToken($request->validated('name'), ['mcp:read']);
$abilities = $request->validated('scope') === 'read_write'
? ['mcp:read', 'mcp:write']
: ['mcp:read'];
$token = $request->user()->createToken($request->validated('name'), $abilities);
return to_route('mcp.index')->with('mcp_token', $token->plainTextToken);
}

View File

@ -24,6 +24,7 @@ class StoreMcpTokenRequest extends FormRequest
{
return [
'name' => ['required', 'string', 'max:255'],
'scope' => ['required', 'in:read,read_write'],
];
}
}

View File

@ -2,13 +2,29 @@
namespace App\Mcp\Servers;
use App\Mcp\Tools\CategorizeTransaction;
use App\Mcp\Tools\CreateAutomationRule;
use App\Mcp\Tools\CreateBalance;
use App\Mcp\Tools\CreateCategory;
use App\Mcp\Tools\CreateLabel;
use App\Mcp\Tools\CreateTransaction;
use App\Mcp\Tools\DeleteAutomationRule;
use App\Mcp\Tools\DeleteCategory;
use App\Mcp\Tools\DeleteLabel;
use App\Mcp\Tools\DeleteTransaction;
use App\Mcp\Tools\GetCashflow;
use App\Mcp\Tools\GetNetWorth;
use App\Mcp\Tools\LabelTransaction;
use App\Mcp\Tools\ListAccounts;
use App\Mcp\Tools\ListCategories;
use App\Mcp\Tools\ListLabels;
use App\Mcp\Tools\ListSpaces;
use App\Mcp\Tools\SearchTransactions;
use App\Mcp\Tools\SpendingByCategory;
use App\Mcp\Tools\UpdateAutomationRule;
use App\Mcp\Tools\UpdateCategory;
use App\Mcp\Tools\UpdateLabel;
use App\Mcp\Tools\UpdateTransaction;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
@ -18,27 +34,55 @@ use Laravel\Mcp\Server\Tool;
#[Name('Whisper Money')]
#[Version('1.0.0')]
#[Instructions(<<<'MARKDOWN'
Read-only access to the authenticated user's Whisper Money finance data, for
analysing spending, cashflow and net worth.
Access to the authenticated user's Whisper Money finance data, for analysing
spending, cashflow and net worth and, with a read & write token, for editing
that data.
- All amounts are integers in minor units (cents). Divide by 100 for a display value.
- Data is organised into "spaces" (the personal space and any shared spaces).
Transaction and account tools accept an optional `space` id and default to the
personal space; call `list_spaces` to discover ids. The cashflow, net-worth and
spending tools cover the user's whole account.
Transaction, account, category and label tools accept an optional `space` id and
default to the personal space; call `list_spaces` to discover ids. The cashflow,
net-worth and spending tools cover the user's whole account.
- To find recurring charges (subscriptions), use `search_transactions` and group
the results by merchant and cadence yourself.
Write tools (create_transaction, update_transaction, delete_transaction,
categorize_transaction, label_transaction, create_balance and full CRUD for
categories, labels and automation rules) require a read & write token; a
read-only token can analyse data but never change it. Bank-connected accounts
and bank/imported transactions are protected: you can only create, edit or
delete manual transactions and manual-account balances, but you can categorize
and label any transaction.
MARKDOWN)]
class WhisperMoneyServer extends Server
{
/** @var array<int, class-string<Tool>> */
protected array $tools = [
// Read
SearchTransactions::class,
SpendingByCategory::class,
GetCashflow::class,
GetNetWorth::class,
ListAccounts::class,
ListCategories::class,
ListLabels::class,
ListSpaces::class,
// Write
CreateTransaction::class,
UpdateTransaction::class,
DeleteTransaction::class,
CategorizeTransaction::class,
LabelTransaction::class,
CreateBalance::class,
CreateCategory::class,
UpdateCategory::class,
DeleteCategory::class,
CreateLabel::class,
UpdateLabel::class,
DeleteLabel::class,
CreateAutomationRule::class,
UpdateAutomationRule::class,
DeleteAutomationRule::class,
];
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\CategorySource;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Set (or clear) the category of any transaction, including bank/imported ones. Marks the category as manually assigned. Pass category_id: null to remove the category.')]
class CategorizeTransaction extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'transaction_id' => $schema->string()->description('Id of the transaction to categorize.')->required(),
'category_id' => $schema->string()->description('Category id to assign, or null to remove the category.')->required(),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
if (! $request->has('category_id')) {
return Response::error('Provide category_id to set the category (or null to remove it).');
}
$space = $this->resolveSpace($request, $user);
$transaction = $this->transactionInSpace($request, $space);
$categoryId = $request->filled('category_id')
? $this->categoryInSpace($request, $space)->id
: null;
$transaction->category_id = $categoryId;
$transaction->category_source = $categoryId === null ? null : CategorySource::Manual;
$transaction->ai_confidence = null;
$transaction->categorized_by_rule_id = null;
$transaction->save();
return $this->json(['transaction' => $this->presentTransaction($transaction->refresh())]);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Mcp\Tools\Concerns;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
trait DecodesRulesJson
{
/**
* Decode and validate a JsonLogic condition argument, accepting either a
* JSON object or a JSON-encoded string.
*
* @return array<string, mixed>
*/
protected function rulesJson(Request $request, string $key = 'rules_json'): array
{
$rulesJson = $request->get($key);
if (is_string($rulesJson)) {
$rulesJson = json_decode($rulesJson, true);
}
if (! is_array($rulesJson) || $rulesJson === []) {
throw ValidationException::withMessages([
$key => "{$key} must be a non-empty JsonLogic object.",
]);
}
return $rulesJson;
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Mcp\Tools\Concerns;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Models\Category;
use App\Models\Space;
use App\Services\CategoryTree;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
/**
* Category create/update helpers shared by the category write tools. Mirrors
* the web form's parent resolution (ownership, depth, cycle) and its
* type-driven cashflow derivation, without depending on `auth()` (which is not
* the active guard for MCP requests).
*/
trait ResolvesCategoryWrites
{
/**
* Resolve the requested parent category within the space, enforcing the
* same rules as the web form. Returns null when creating/keeping a root.
* $moving is the category being edited (null on create) so its own subtree
* depth and cycle constraints can be checked.
*/
protected function resolveParentCategory(Request $request, Space $space, ?Category $moving): ?Category
{
if (! $request->filled('parent_id')) {
return null;
}
$parentId = $request->string('parent_id')->toString();
$parent = Category::query()->forSpace($space)->whereKey($parentId)->first();
if ($parent === null) {
throw ValidationException::withMessages([
'parent_id' => "No category with id {$parentId} in space {$space->id}. Call list_categories to see valid ids.",
]);
}
$tree = new CategoryTree;
if ($moving !== null && $tree->wouldCreateCycle($moving, $parent->id)) {
throw ValidationException::withMessages([
'parent_id' => 'A category cannot be nested under itself or one of its children.',
]);
}
$subtreeDepth = $moving !== null ? $tree->subtreeDepth($moving) : 1;
if ($tree->depth($parent) + $subtreeDepth > Category::MAX_DEPTH) {
throw ValidationException::withMessages([
'parent_id' => 'Categories can only be nested up to '.Category::MAX_DEPTH.' levels deep.',
]);
}
return $parent;
}
/**
* Derive the cashflow direction the web form would compute: a child inherits
* its parent's direction; a root follows its type (savings/investment always
* count as an outflow, transfers keep the requested direction, everything
* else is hidden).
*/
protected function cashflowDirectionFor(CategoryType $type, ?Category $parent, ?string $requested): CategoryCashflowDirection
{
if ($parent !== null) {
return $parent->cashflow_direction;
}
return match ($type) {
CategoryType::Savings, CategoryType::Investment => CategoryCashflowDirection::Outflow,
CategoryType::Transfer => CategoryCashflowDirection::tryFrom((string) $requested) ?? CategoryCashflowDirection::Hidden,
default => CategoryCashflowDirection::Hidden,
};
}
/**
* Whether a sibling category (same parent) with the given name already
* exists in the space, optionally ignoring one category by id (for edits).
*/
protected function categoryNameTaken(Space $space, string $name, ?string $parentId, ?string $ignoreId = null): bool
{
return Category::query()
->forSpace($space)
->where('name', $name)
->where('parent_id', $parentId)
->when($ignoreId !== null, fn ($query) => $query->whereKeyNot($ignoreId))
->exists();
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\RuleOrigin;
use App\Mcp\Tools\Concerns\DecodesRulesJson;
use App\Models\AutomationRule;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description(<<<'TEXT'
Create an automation rule that auto-applies a category and/or labels to matching transactions. `rules_json` is a JsonLogic object evaluated against these lowercase variables: description, notes, creditor_name, debtor_name, account_name, bank_name, category, transaction_date (YYYY-MM-DD) and amount. Note: amount here is in MAJOR units (e.g. 12.50), not cents. Example: {"and":[{">":[{"var":"amount"},100]},{"in":["grocery",{"var":"description"}]}]}. At least one action (action_category_id or action_label_ids) is required.
TEXT)]
class CreateAutomationRule extends WriteTool
{
use DecodesRulesJson;
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'title' => $schema->string()->description('Human-readable rule name.')->required(),
'priority' => $schema->integer()->min(0)->description('Lower numbers are evaluated first.')->required(),
'rules_json' => $schema->object()->description('JsonLogic condition object.')->required(),
'action_category_id' => $schema->string()->description('Category id to assign to matching transactions.'),
'action_label_ids' => $schema->array()->items($schema->string())->description('Label ids to attach to matching transactions.'),
'action_note' => $schema->string()->description('Note to append to matching transactions.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'title' => ['required', 'string', 'max:255'],
'priority' => ['required', 'integer', 'min:0'],
'action_note' => ['sometimes', 'nullable', 'string'],
]);
$space = $this->resolveSpace($request, $user);
$rulesJson = $this->rulesJson($request);
$labels = $this->labelsInSpace($request, $space, 'action_label_ids');
$categoryId = $request->filled('action_category_id')
? $this->categoryInSpace($request, $space, 'action_category_id')->id
: null;
if ($categoryId === null && $labels->isEmpty()) {
throw ValidationException::withMessages([
'action_category_id' => 'At least one action is required: pass action_category_id and/or action_label_ids.',
]);
}
$rule = new AutomationRule([
'user_id' => $user->id,
'space_id' => $space->id,
'title' => $request->string('title')->toString(),
'priority' => $request->integer('priority'),
'origin' => RuleOrigin::User->value,
'rules_json' => $rulesJson,
'action_category_id' => $categoryId,
'action_note' => $request->filled('action_note') ? $request->string('action_note')->toString() : null,
]);
$rule->save();
if ($labels->isNotEmpty()) {
$rule->labels()->sync($labels->pluck('id')->all());
$rule->touch();
}
return $this->json(['automation_rule' => $this->presentAutomationRule($rule)]);
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Mcp\Tools;
use App\Models\AccountBalance;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Record an account balance snapshot on a non-connected (manual) account. Balance is an integer in minor units (cents). Replaces any existing snapshot for that date. Connected/bank accounts are read-only.')]
class CreateBalance extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'account_id' => $schema->string()->description('Id of a non-connected (manual) account.')->required(),
'balance' => $schema->integer()->description('Balance in minor units (cents).')->required(),
'balance_date' => $schema->string()->description('Snapshot date, YYYY-MM-DD. Defaults to today.'),
'invested_amount' => $schema->integer()->description('Optional invested amount in minor units (cents), for investment accounts.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'balance' => ['required', 'integer'],
'balance_date' => ['sometimes', 'date'],
'invested_amount' => ['sometimes', 'nullable', 'integer'],
]);
$space = $this->resolveSpace($request, $user);
$account = $this->writableAccount($request, $space);
$balanceDate = $request->filled('balance_date')
? $request->string('balance_date')->toString()
: now()->toDateString();
$balance = AccountBalance::updateOrCreate(
['account_id' => $account->id, 'balance_date' => $balanceDate],
[
'balance' => $request->integer('balance'),
...$request->filled('invested_amount')
? ['invested_amount' => $request->integer('invested_amount')]
: [],
],
);
return $this->json([
'balance' => [
'id' => $balance->id,
'account_id' => $balance->account_id,
'balance_date' => $balance->balance_date->toDateString(),
'balance' => $balance->balance,
'invested_amount' => $balance->invested_amount,
],
]);
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryColor;
use App\Enums\CategoryType;
use App\Mcp\Tools\Concerns\ResolvesCategoryWrites;
use App\Models\Category;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Create a category. A child (parent_id set) inherits its parent type and cashflow direction; a root follows its own type. Categories can be nested up to 3 levels deep.')]
class CreateCategory extends WriteTool
{
use ResolvesCategoryWrites;
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'name' => $schema->string()->description('Category name (unique among its siblings).')->required(),
'icon' => $schema->string()->description('Icon name (e.g. ShoppingBag, Home, Car).')->required(),
'color' => $schema->string()->enum(array_column(CategoryColor::cases(), 'value'))->description('Category color.')->required(),
'type' => $schema->string()->enum(array_column(CategoryType::cases(), 'value'))->description('Category type. Ignored for children (they inherit the parent type).')->required(),
'parent_id' => $schema->string()->description('Optional parent category id to nest under.'),
'cashflow_direction' => $schema->string()->enum(array_column(CategoryCashflowDirection::cases(), 'value'))->description('Only used for transfer-type roots; otherwise derived automatically.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'icon' => ['required', 'string'],
'color' => ['required', Rule::enum(CategoryColor::class)],
'type' => ['required', Rule::enum(CategoryType::class)],
'cashflow_direction' => ['sometimes', Rule::enum(CategoryCashflowDirection::class)],
]);
$space = $this->resolveSpace($request, $user);
$parent = $this->resolveParentCategory($request, $space, null);
$type = $parent !== null ? $parent->type : $request->enum('type', CategoryType::class);
$cashflow = $this->cashflowDirectionFor($type, $parent, $request->string('cashflow_direction')->toString());
$name = $request->string('name')->toString();
if ($this->categoryNameTaken($space, $name, $parent?->id)) {
throw ValidationException::withMessages([
'name' => 'A category with that name already exists at this level.',
]);
}
$category = new Category([
'user_id' => $user->id,
'space_id' => $space->id,
'name' => $name,
'icon' => $request->string('icon')->toString(),
'color' => $request->string('color')->toString(),
'type' => $type->value,
'cashflow_direction' => $cashflow->value,
'parent_id' => $parent?->id,
]);
$category->save();
return $this->json(['category' => $this->presentCategory($category)]);
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\LabelColor;
use App\Models\Label;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Create a label. Names are unique within the space.')]
class CreateLabel extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'name' => $schema->string()->description('Label name (unique within the space).')->required(),
'color' => $schema->string()->enum(array_column(LabelColor::cases(), 'value'))->description('Label color.')->required(),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'color' => ['required', Rule::enum(LabelColor::class)],
]);
$space = $this->resolveSpace($request, $user);
$name = $request->string('name')->toString();
if (Label::query()->forSpace($space)->where('name', $name)->exists()) {
throw ValidationException::withMessages([
'name' => 'A label with that name already exists.',
]);
}
$label = new Label([
'user_id' => $user->id,
'space_id' => $space->id,
'name' => $name,
'color' => $request->string('color')->toString(),
]);
$label->save();
return $this->json(['label' => $this->presentLabel($label)]);
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\CategorySource;
use App\Enums\TransactionSource;
use App\Models\Transaction;
use App\Models\User;
use App\Services\ManualBalanceAdjuster;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Create a manual transaction on a non-connected (manual) account. Amount is a signed integer in minor units (cents): negative for an expense, positive for income. Connected/bank accounts are read-only.')]
class CreateTransaction extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'account_id' => $schema->string()->description('Id of a non-connected (manual) account to add the transaction to.')->required(),
'description' => $schema->string()->description('Human-readable description.')->required(),
'amount' => $schema->integer()->description('Signed amount in minor units (cents). Negative = expense, positive = income.')->required(),
'transaction_date' => $schema->string()->description('Transaction date, YYYY-MM-DD.')->required(),
'currency_code' => $schema->string()->description('ISO 4217 currency code (3 letters). Defaults to the account currency.'),
'category_id' => $schema->string()->description('Optional category id to assign.'),
'creditor_name' => $schema->string()->description('Optional creditor (payee) name.'),
'debtor_name' => $schema->string()->description('Optional debtor (payer) name.'),
'notes' => $schema->string()->description('Optional free-text notes.'),
'label_ids' => $schema->array()->items($schema->string())->description('Optional label ids to attach.'),
'update_balance' => $schema->boolean()->description('When true, shift the manual account balance snapshots by this amount. Default false.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'account_id' => ['required', 'string'],
'description' => ['required', 'string'],
'amount' => ['required', 'integer'],
'transaction_date' => ['required', 'date'],
'currency_code' => ['sometimes', 'string', 'size:3'],
'notes' => ['sometimes', 'nullable', 'string'],
'creditor_name' => ['sometimes', 'nullable', 'string', 'max:255'],
'debtor_name' => ['sometimes', 'nullable', 'string', 'max:255'],
]);
$space = $this->resolveSpace($request, $user);
$account = $this->writableAccount($request, $space);
$labels = $this->labelsInSpace($request, $space, 'label_ids');
$categoryId = $request->filled('category_id')
? $this->categoryInSpace($request, $space)->id
: null;
$transaction = new Transaction([
'user_id' => $user->id,
'space_id' => $space->id,
'account_id' => $account->id,
'category_id' => $categoryId,
'category_source' => $categoryId === null ? null : CategorySource::Manual->value,
'description' => $request->string('description')->toString(),
'transaction_date' => $request->string('transaction_date')->toString(),
'amount' => $request->integer('amount'),
'currency_code' => $request->filled('currency_code')
? mb_strtoupper($request->string('currency_code')->toString())
: $account->currency_code,
'notes' => $request->filled('notes') ? $request->string('notes')->toString() : null,
'creditor_name' => $request->filled('creditor_name') ? $request->string('creditor_name')->toString() : null,
'debtor_name' => $request->filled('debtor_name') ? $request->string('debtor_name')->toString() : null,
'source' => TransactionSource::ManuallyCreated->value,
]);
$transaction->save();
if ($labels->isNotEmpty()) {
$transaction->labels()->sync($labels->pluck('id')->all());
}
if ($request->boolean('update_balance')) {
app(ManualBalanceAdjuster::class)->applyCreatedTransaction($transaction->load('account'));
}
return $this->json(['transaction' => $this->presentTransaction($transaction)]);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Mcp\Tools;
use App\Models\AutomationRule;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Delete an automation rule. Transactions it already categorized keep their category; the rule simply stops running on future transactions.')]
class DeleteAutomationRule extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'automation_rule_id' => $schema->string()->description('Id of the automation rule to delete.')->required(),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$space = $this->resolveSpace($request, $user);
$id = $request->string('automation_rule_id')->toString();
$rule = AutomationRule::query()->forSpace($space)->whereKey($id)->first();
if ($rule === null) {
throw ValidationException::withMessages([
'automation_rule_id' => "No automation rule with id {$id} in space {$space->id}.",
]);
}
$rule->delete();
return $this->json(['deleted' => true, 'id' => $rule->id]);
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\CategoryDeletionStrategy;
use App\Models\Category;
use App\Models\User;
use App\Services\CategoryTree;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Delete a category. The strategy decides what happens to its child categories: "reparent" (default) lifts them to the deleted category\'s parent, "promote" turns them into roots, "cascade" deletes the whole subtree and uncategorizes affected transactions.')]
class DeleteCategory extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'category_id' => $schema->string()->description('Id of the category to delete.')->required(),
'strategy' => $schema->string()->enum(array_column(CategoryDeletionStrategy::cases(), 'value'))->description('How to handle child categories. Defaults to "reparent".'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'strategy' => ['sometimes', Rule::enum(CategoryDeletionStrategy::class)],
]);
$space = $this->resolveSpace($request, $user);
$category = $this->categoryInSpace($request, $space);
$strategy = $request->enum('strategy', CategoryDeletionStrategy::class) ?? CategoryDeletionStrategy::Reparent;
$tree = new CategoryTree;
match ($strategy) {
CategoryDeletionStrategy::Cascade => $tree->deleteSubtree($category),
CategoryDeletionStrategy::Promote => $this->detachChildrenAndDelete($category, null),
CategoryDeletionStrategy::Reparent => $this->detachChildrenAndDelete($category, $category->parent_id),
};
return $this->json(['deleted' => true, 'id' => $category->id, 'strategy' => $strategy->value]);
}
/**
* Move the category's direct children to a new parent, then delete it.
*/
private function detachChildrenAndDelete(Category $category, ?string $newParentId): void
{
try {
$category->children()->update(['parent_id' => $newParentId]);
} catch (UniqueConstraintViolationException) {
throw ValidationException::withMessages([
'strategy' => 'A category with the same name already exists at the destination level. Rename it first.',
]);
}
$category->delete();
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Mcp\Tools;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Delete a label. It is removed from every transaction it was attached to.')]
class DeleteLabel extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'label_id' => $schema->string()->description('Id of the label to delete.')->required(),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$space = $this->resolveSpace($request, $user);
$label = $this->labelInSpace($request, $space);
$label->delete();
return $this->json(['deleted' => true, 'id' => $label->id]);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\TransactionSource;
use App\Models\User;
use App\Services\ManualBalanceAdjuster;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Delete a manually-created transaction. Only manual transactions can be deleted; bank/imported ones cannot.')]
class DeleteTransaction extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'transaction_id' => $schema->string()->description('Id of the manually-created transaction to delete.')->required(),
'update_balance' => $schema->boolean()->description('When true, reverse this transaction from the manual account balance snapshots. Default false.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$space = $this->resolveSpace($request, $user);
$transaction = $this->transactionInSpace($request, $space);
if ($transaction->source !== TransactionSource::ManuallyCreated) {
return Response::error('Only manually-created transactions can be deleted. This one came from a bank or import.');
}
if ($request->boolean('update_balance')) {
app(ManualBalanceAdjuster::class)->reverseDeletedTransaction($transaction);
}
$transaction->delete();
return $this->json(['deleted' => true, 'id' => $transaction->id]);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Mcp\Tools;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Add and/or remove labels on any transaction, including bank/imported ones. Pass add_label_ids and/or remove_label_ids.')]
class LabelTransaction extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'transaction_id' => $schema->string()->description('Id of the transaction to label.')->required(),
'add_label_ids' => $schema->array()->items($schema->string())->description('Label ids to attach.'),
'remove_label_ids' => $schema->array()->items($schema->string())->description('Label ids to detach.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$space = $this->resolveSpace($request, $user);
$transaction = $this->transactionInSpace($request, $space);
$add = $this->labelsInSpace($request, $space, 'add_label_ids');
$remove = $this->labelsInSpace($request, $space, 'remove_label_ids');
if ($add->isEmpty() && $remove->isEmpty()) {
return Response::error('Provide add_label_ids and/or remove_label_ids (arrays of label ids).');
}
if ($add->isNotEmpty()) {
$transaction->labels()->syncWithoutDetaching($add->pluck('id')->all());
}
if ($remove->isNotEmpty()) {
$transaction->labels()->detach($remove->pluck('id')->all());
}
return $this->json(['transaction' => $this->presentTransaction($transaction->refresh())]);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Label;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List the user\'s labels in a space. Use the ids with label_transaction or automation-rule label actions.')]
class ListLabels extends McpTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'space' => $schema->string()->description('Space id to query. Defaults to the personal space.'),
];
}
protected function respond(Request $request, User $user): Response
{
$space = $this->resolveSpace($request, $user);
$labels = Label::query()
->forSpace($space)
->orderBy('name')
->get()
->map(fn (Label $label): array => [
'id' => $label->id,
'name' => $label->name,
'color' => $label->color,
]);
return $this->json([
'space_id' => $space->id,
'labels' => $labels,
]);
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Tools\Concerns\DecodesRulesJson;
use App\Models\AutomationRule;
use App\Models\Space;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Edit an automation rule. Only the fields you pass are changed. The rule must always keep at least one action (a category or labels). See create_automation_rule for the rules_json format.')]
class UpdateAutomationRule extends WriteTool
{
use DecodesRulesJson;
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'automation_rule_id' => $schema->string()->description('Id of the automation rule to edit.')->required(),
'title' => $schema->string()->description('New rule name.'),
'priority' => $schema->integer()->min(0)->description('New priority (lower is evaluated first).'),
'rules_json' => $schema->object()->description('New JsonLogic condition object.'),
'action_category_id' => $schema->string()->description('New category id to assign, or null to clear.'),
'action_label_ids' => $schema->array()->items($schema->string())->description('Replacement set of label ids (replaces all existing labels).'),
'action_note' => $schema->string()->description('New note to append, or null to clear.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'title' => ['sometimes', 'string', 'max:255'],
'priority' => ['sometimes', 'integer', 'min:0'],
'action_note' => ['sometimes', 'nullable', 'string'],
]);
$space = $this->resolveSpace($request, $user);
$rule = $this->ruleInSpace($request, $space);
if ($request->has('title')) {
$rule->title = $request->string('title')->toString();
}
if ($request->has('priority')) {
$rule->priority = $request->integer('priority');
}
if ($request->has('rules_json')) {
$rule->rules_json = $this->rulesJson($request);
}
if ($request->has('action_category_id')) {
$rule->action_category_id = $request->filled('action_category_id')
? $this->categoryInSpace($request, $space, 'action_category_id')->id
: null;
}
if ($request->has('action_note')) {
$rule->action_note = $request->filled('action_note') ? $request->string('action_note')->toString() : null;
}
$newLabels = $request->has('action_label_ids')
? $this->labelsInSpace($request, $space, 'action_label_ids')
: null;
// The rule must keep at least one action. Compare against the labels it
// would have after this edit (the new set if provided, else current).
$labelCount = $newLabels !== null ? $newLabels->count() : $rule->labels()->count();
if ($rule->action_category_id === null && $labelCount === 0) {
throw ValidationException::withMessages([
'action_category_id' => 'An automation rule must keep at least one action: a category or labels.',
]);
}
$rule->save();
if ($newLabels !== null) {
$rule->labels()->sync($newLabels->pluck('id')->all());
}
$rule->touch();
return $this->json(['automation_rule' => $this->presentAutomationRule($rule->refresh())]);
}
private function ruleInSpace(Request $request, Space $space): AutomationRule
{
$id = $request->string('automation_rule_id')->toString();
$rule = AutomationRule::query()->forSpace($space)->whereKey($id)->first();
if ($rule === null) {
throw ValidationException::withMessages([
'automation_rule_id' => "No automation rule with id {$id} in space {$space->id}.",
]);
}
return $rule;
}
}

View File

@ -0,0 +1,101 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryColor;
use App\Enums\CategoryType;
use App\Mcp\Tools\Concerns\ResolvesCategoryWrites;
use App\Models\Category;
use App\Models\Space;
use App\Models\User;
use App\Services\CategoryTree;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Edit a category. Only the fields you pass are changed. Moving it under a parent (or clearing parent_id to make it a root) re-derives its type/cashflow and cascades the type to its descendants.')]
class UpdateCategory extends WriteTool
{
use ResolvesCategoryWrites;
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'category_id' => $schema->string()->description('Id of the category to edit.')->required(),
'name' => $schema->string()->description('New category name.'),
'icon' => $schema->string()->description('New icon name.'),
'color' => $schema->string()->enum(array_column(CategoryColor::cases(), 'value'))->description('New category color.'),
'type' => $schema->string()->enum(array_column(CategoryType::cases(), 'value'))->description('New type (ignored while the category has a parent).'),
'parent_id' => $schema->string()->description('New parent id, or null to make it a root.'),
'cashflow_direction' => $schema->string()->enum(array_column(CategoryCashflowDirection::cases(), 'value'))->description('Only used for transfer-type roots; otherwise derived automatically.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'name' => ['sometimes', 'string', 'max:255'],
'icon' => ['sometimes', 'string'],
'color' => ['sometimes', Rule::enum(CategoryColor::class)],
'type' => ['sometimes', Rule::enum(CategoryType::class)],
'cashflow_direction' => ['sometimes', Rule::enum(CategoryCashflowDirection::class)],
]);
$space = $this->resolveSpace($request, $user);
$category = $this->categoryInSpace($request, $space);
$parent = $request->has('parent_id')
? $this->resolveParentCategory($request, $space, $category)
: $this->currentParent($category, $space);
$type = $parent !== null
? $parent->type
: ($request->has('type') ? $request->enum('type', CategoryType::class) : $category->type);
$requestedDirection = $request->filled('cashflow_direction')
? $request->string('cashflow_direction')->toString()
: $category->cashflow_direction->value;
$cashflow = $this->cashflowDirectionFor($type, $parent, $requestedDirection);
$name = $request->has('name') ? $request->string('name')->toString() : $category->name;
if ($this->categoryNameTaken($space, $name, $parent?->id, $category->id)) {
throw ValidationException::withMessages([
'name' => 'A category with that name already exists at this level.',
]);
}
$category->fill([
'name' => $name,
'icon' => $request->has('icon') ? $request->string('icon')->toString() : $category->icon,
'color' => $request->has('color') ? $request->string('color')->toString() : $category->color,
'type' => $type->value,
'cashflow_direction' => $cashflow->value,
'parent_id' => $parent?->id,
]);
$category->save();
(new CategoryTree)->syncDescendantTypes($category);
return $this->json(['category' => $this->presentCategory($category->refresh())]);
}
private function currentParent(Category $category, Space $space): ?Category
{
if ($category->parent_id === null) {
return null;
}
return Category::query()->forSpace($space)->whereKey($category->parent_id)->first();
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\LabelColor;
use App\Models\Label;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Edit a label. Only the fields you pass are changed.')]
class UpdateLabel extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'label_id' => $schema->string()->description('Id of the label to edit.')->required(),
'name' => $schema->string()->description('New label name (unique within the space).'),
'color' => $schema->string()->enum(array_column(LabelColor::cases(), 'value'))->description('New label color.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$request->validate([
'name' => ['sometimes', 'string', 'max:255'],
'color' => ['sometimes', Rule::enum(LabelColor::class)],
]);
$space = $this->resolveSpace($request, $user);
$label = $this->labelInSpace($request, $space);
if ($request->has('name')) {
$name = $request->string('name')->toString();
$exists = Label::query()
->forSpace($space)
->where('name', $name)
->whereKeyNot($label->id)
->exists();
if ($exists) {
throw ValidationException::withMessages([
'name' => 'A label with that name already exists.',
]);
}
$label->name = $name;
}
if ($request->has('color')) {
$label->color = $request->string('color')->toString();
}
$label->save();
return $this->json(['label' => $this->presentLabel($label)]);
}
}

View File

@ -0,0 +1,114 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\CategorySource;
use App\Enums\TransactionSource;
use App\Models\User;
use App\Services\ManualBalanceAdjuster;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Carbon;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive]
#[Description('Edit a manually-created transaction. Only manual transactions can be edited; bank/imported ones keep their core fields locked (use categorize_transaction or label_transaction for those). Only the fields you pass are changed.')]
class UpdateTransaction extends WriteTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'transaction_id' => $schema->string()->description('Id of the manually-created transaction to edit.')->required(),
'description' => $schema->string()->description('New description.'),
'amount' => $schema->integer()->description('New signed amount in minor units (cents).'),
'transaction_date' => $schema->string()->description('New transaction date, YYYY-MM-DD.'),
'currency_code' => $schema->string()->description('New ISO 4217 currency code (3 letters).'),
'account_id' => $schema->string()->description('Move the transaction to another non-connected account.'),
'category_id' => $schema->string()->description('New category id, or null to clear the category.'),
'creditor_name' => $schema->string()->description('New creditor (payee) name.'),
'debtor_name' => $schema->string()->description('New debtor (payer) name.'),
'notes' => $schema->string()->description('New free-text notes.'),
'update_balance' => $schema->boolean()->description('When true and the amount/date/account changed, move the manual account balance snapshots accordingly. Default false.'),
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
];
}
protected function write(Request $request, User $user): Response
{
$space = $this->resolveSpace($request, $user);
$transaction = $this->transactionInSpace($request, $space);
if ($transaction->source !== TransactionSource::ManuallyCreated) {
return Response::error('Only manually-created transactions can be edited. This one came from a bank or import, so its core fields are locked. Use categorize_transaction or label_transaction instead.');
}
$request->validate([
'description' => ['sometimes', 'string'],
'amount' => ['sometimes', 'integer'],
'transaction_date' => ['sometimes', 'date'],
'currency_code' => ['sometimes', 'string', 'size:3'],
'notes' => ['sometimes', 'nullable', 'string'],
'creditor_name' => ['sometimes', 'nullable', 'string', 'max:255'],
'debtor_name' => ['sometimes', 'nullable', 'string', 'max:255'],
]);
// Snapshot the pre-edit account/date/amount so a manual balance can be
// moved off the old values if the edit changes them.
$originalSnapshot = clone $transaction;
if ($request->has('description')) {
$transaction->description = $request->string('description')->toString();
}
if ($request->has('amount')) {
$transaction->amount = $request->integer('amount');
}
if ($request->has('transaction_date')) {
$transaction->transaction_date = Carbon::parse($request->string('transaction_date')->toString());
}
if ($request->has('currency_code')) {
$transaction->currency_code = mb_strtoupper($request->string('currency_code')->toString());
}
if ($request->has('account_id')) {
$transaction->account_id = $this->writableAccount($request, $space)->id;
}
if ($request->has('notes')) {
$transaction->notes = $request->filled('notes') ? $request->string('notes')->toString() : null;
}
if ($request->has('creditor_name')) {
$transaction->creditor_name = $request->filled('creditor_name') ? $request->string('creditor_name')->toString() : null;
}
if ($request->has('debtor_name')) {
$transaction->debtor_name = $request->filled('debtor_name') ? $request->string('debtor_name')->toString() : null;
}
// A new category is always a manual assignment: reset any AI/rule
// provenance so the row is not later treated as machine-categorized.
// ponytail: unlike the web edit path this does not learn a correction
// rule — MCP writes stay predictable and side-effect free.
if ($request->has('category_id')) {
$newCategoryId = $request->filled('category_id') ? $this->categoryInSpace($request, $space)->id : null;
if ($newCategoryId !== $transaction->category_id) {
$transaction->category_id = $newCategoryId;
$transaction->category_source = $newCategoryId === null ? null : CategorySource::Manual;
$transaction->ai_confidence = null;
$transaction->categorized_by_rule_id = null;
}
}
$transaction->save();
if ($request->boolean('update_balance') && $transaction->wasChanged(['amount', 'transaction_date', 'account_id'])) {
$adjuster = app(ManualBalanceAdjuster::class);
$adjuster->reverseDeletedTransaction($originalSnapshot);
$adjuster->applyCreatedTransaction($transaction->load('account'));
}
return $this->json(['transaction' => $this->presentTransaction($transaction->refresh())]);
}
}

221
app/Mcp/Tools/WriteTool.php Normal file
View File

@ -0,0 +1,221 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Account;
use App\Models\AutomationRule;
use App\Models\Category;
use App\Models\Label;
use App\Models\Space;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
/**
* Base for every Whisper Money write tool. On top of the McpTool Pro-plan gate
* it requires the calling token to carry the `mcp:write` ability, so a
* read-only token can analyse data but never change it.
*
* Each concrete write tool must additionally carry the #[IsDestructive]
* annotation. PHP attributes are not inherited, so the framework only reports
* one declared directly on the served tool class it cannot live here.
*/
abstract class WriteTool extends McpTool
{
protected function respond(Request $request, User $user): Response
{
if (! $user->tokenCan('mcp:write')) {
return Response::error('This token is read-only. Create a read & write token to make changes.');
}
return $this->write($request, $user);
}
abstract protected function write(Request $request, User $user): Response;
/**
* Resolve an account the token may write to: it must live in the space and
* must not be connected to a bank (bank-sourced data is never touched).
*/
protected function writableAccount(Request $request, Space $space, string $key = 'account_id'): Account
{
$id = $request->string($key)->toString();
$account = Account::query()->forSpace($space)->whereKey($id)->first();
if ($account === null) {
throw ValidationException::withMessages([
$key => "No account with id {$id} in space {$space->id}. Call list_accounts to see valid ids.",
]);
}
if ($account->isConnected()) {
throw ValidationException::withMessages([
$key => 'That account is connected to a bank and is read-only. Only non-connected (manual) accounts can be written to.',
]);
}
return $account;
}
protected function transactionInSpace(Request $request, Space $space, string $key = 'transaction_id'): Transaction
{
$id = $request->string($key)->toString();
$transaction = Transaction::query()->forSpace($space)->whereKey($id)->first();
if ($transaction === null) {
throw ValidationException::withMessages([
$key => "No transaction with id {$id} in space {$space->id}. Call search_transactions to find ids.",
]);
}
return $transaction;
}
protected function categoryInSpace(Request $request, Space $space, string $key = 'category_id'): Category
{
$id = $request->string($key)->toString();
$category = Category::query()->forSpace($space)->whereKey($id)->first();
if ($category === null) {
throw ValidationException::withMessages([
$key => "No category with id {$id} in space {$space->id}. Call list_categories to see valid ids.",
]);
}
return $category;
}
protected function labelInSpace(Request $request, Space $space, string $key = 'label_id'): Label
{
$id = $request->string($key)->toString();
$label = Label::query()->forSpace($space)->whereKey($id)->first();
if ($label === null) {
throw ValidationException::withMessages([
$key => "No label with id {$id} in space {$space->id}. Call list_labels to see valid ids.",
]);
}
return $label;
}
/**
* Resolve every label id passed under $key, asserting each belongs to the
* space. Returns an empty collection when the argument is absent or empty.
*
* @return Collection<int, Label>
*/
protected function labelsInSpace(Request $request, Space $space, string $key): Collection
{
$ids = collect($request->get($key, []))
->map(fn (mixed $id): string => (string) $id)
->filter()
->unique()
->values();
if ($ids->isEmpty()) {
/** @var Collection<int, Label> $empty */
$empty = Label::query()->whereRaw('1 = 0')->get();
return $empty;
}
$labels = Label::query()->forSpace($space)->whereIn('id', $ids)->get();
if ($labels->count() !== $ids->count()) {
throw ValidationException::withMessages([
$key => "One or more label ids do not exist in space {$space->id}. Call list_labels to see valid ids.",
]);
}
return $labels;
}
/**
* The transaction shape returned by every transaction write tool, matching
* the fields search_transactions exposes so the agent sees a familiar row.
*
* @return array<string, mixed>
*/
protected function presentTransaction(Transaction $transaction): array
{
$transaction->loadMissing(['account:id,name', 'category:id,name', 'labels:id,name']);
return [
'id' => $transaction->id,
'date' => $transaction->transaction_date->toDateString(),
'description' => $transaction->description,
'amount' => $transaction->amount,
'currency' => $transaction->currency_code,
'category_id' => $transaction->category_id,
'category' => $transaction->category?->name,
'category_source' => $transaction->category_source?->value,
'account_id' => $transaction->account_id,
'account' => $transaction->account?->name,
'source' => $transaction->source->value,
'creditor_name' => $transaction->creditor_name,
'debtor_name' => $transaction->debtor_name,
'labels' => $transaction->labels
->map(fn (Label $label): array => ['id' => $label->id, 'name' => $label->name])
->values()
->all(),
];
}
/**
* @return array<string, mixed>
*/
protected function presentCategory(Category $category): array
{
return [
'id' => $category->id,
'name' => $category->name,
'icon' => $category->icon,
'color' => $category->color,
'type' => $category->type->value,
'cashflow_direction' => $category->cashflow_direction->value,
'parent_id' => $category->parent_id,
];
}
/**
* @return array<string, mixed>
*/
protected function presentLabel(Label $label): array
{
return [
'id' => $label->id,
'name' => $label->name,
'color' => $label->color,
];
}
/**
* @return array<string, mixed>
*/
protected function presentAutomationRule(AutomationRule $rule): array
{
$rule->loadMissing('labels:id,name');
return [
'id' => $rule->id,
'title' => $rule->title,
'priority' => $rule->priority,
'rules_json' => $rule->rules_json,
'action_category_id' => $rule->action_category_id,
'action_note' => $rule->action_note,
'origin' => $rule->origin->value,
'labels' => $rule->labels
->map(fn (Label $label): array => ['id' => $label->id, 'name' => $label->name])
->values()
->all(),
];
}
}

View File

@ -69,6 +69,7 @@ class AutomationRule extends Model
return $this->belongsTo(Category::class, 'action_category_id');
}
/** @return BelongsToMany<Label, $this, AutomationRuleLabel, 'pivot'> */
public function labels(): BelongsToMany
{
return $this->belongsToMany(Label::class, 'automation_rule_labels')

View File

@ -2239,6 +2239,8 @@
"Scope": "Alcance",
"Read only": "Solo lectura",
"Read & write": "Lectura y escritura",
"Access": "Acceso",
"Read-only tokens can analyse your data. Read & write tokens can also create, edit and delete transactions, categories, labels and automation rules.": "Los tokens de solo lectura pueden analizar tus datos. Los tokens de lectura y escritura también pueden crear, editar y eliminar transacciones, categorías, etiquetas y reglas de automatización.",
"Create token": "Crear token",
"Your tokens": "Tus tokens",
"Rotate a token to replace a leaked secret, or revoke it to cut off access.": "Rota un token para reemplazar un secreto filtrado, o revócalo para cortar el acceso.",

View File

@ -29,6 +29,13 @@ import {
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useClipboard } from '@/hooks/use-clipboard';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
@ -67,7 +74,10 @@ export default function Mcp() {
>().props;
const [, copy] = useClipboard();
const form = useForm({ name: '' });
const form = useForm<{ name: string; scope: 'read' | 'read_write' }>({
name: '',
scope: 'read',
});
function createToken(event: React.FormEvent) {
event.preventDefault();
@ -168,7 +178,7 @@ export default function Mcp() {
<CardTitle>{__('Create a token')}</CardTitle>
<CardDescription>
{__(
'Tokens can read and analyse your data, but never change it.',
'Read-only tokens can analyse your data. Read & write tokens can also create, edit and delete transactions, categories, labels and automation rules.',
)}
</CardDescription>
</CardHeader>
@ -191,6 +201,42 @@ export default function Mcp() {
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="token-scope">
{__('Access')}
</Label>
{/* Radix Select renders a hidden native <select> next to the
trigger; wrap it so the parent's space-y never pushes that
hidden node below the trigger and breaks the row alignment. */}
<div className="w-full sm:w-48">
<Select
value={form.data.scope}
onValueChange={(value) =>
form.setData(
'scope',
value as
| 'read'
| 'read_write',
)
}
>
<SelectTrigger
id="token-scope"
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="read">
{__('Read only')}
</SelectItem>
<SelectItem value="read_write">
{__('Read & write')}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button
type="submit"
disabled={form.processing}

View File

@ -0,0 +1,328 @@
<?php
use App\Enums\CategorySource;
use App\Mcp\Servers\WhisperMoneyServer;
use App\Mcp\Tools\CategorizeTransaction;
use App\Mcp\Tools\CreateAutomationRule;
use App\Mcp\Tools\CreateBalance;
use App\Mcp\Tools\CreateCategory;
use App\Mcp\Tools\CreateLabel;
use App\Mcp\Tools\CreateTransaction;
use App\Mcp\Tools\DeleteAutomationRule;
use App\Mcp\Tools\DeleteCategory;
use App\Mcp\Tools\DeleteLabel;
use App\Mcp\Tools\DeleteTransaction;
use App\Mcp\Tools\LabelTransaction;
use App\Mcp\Tools\UpdateAutomationRule;
use App\Mcp\Tools\UpdateCategory;
use App\Mcp\Tools\UpdateLabel;
use App\Mcp\Tools\UpdateTransaction;
use App\Models\Account;
use App\Models\AutomationRule;
use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use Laravel\Mcp\Server\Testing\TestResponse;
/**
* Call a write tool as $user, giving them a real personal access token with the
* given abilities so the tool's tokenCan('mcp:write') gate is exercised exactly
* as it is over HTTP.
*
* @param array<string, mixed> $arguments
* @param list<string> $abilities
*/
function callWriteTool(User $user, string $tool, array $arguments = [], array $abilities = ['mcp:read', 'mcp:write']): TestResponse
{
$user->withAccessToken($user->createToken('mcp', $abilities)->accessToken);
return WhisperMoneyServer::actingAs($user)->tool($tool, $arguments);
}
it('creates a manual transaction and defaults the currency to the account', function () {
$user = User::factory()->create();
$account = Account::factory()->create(['user_id' => $user->id, 'currency_code' => 'EUR']);
callWriteTool($user, CreateTransaction::class, [
'account_id' => $account->id,
'description' => 'Blue Bottle Coffee',
'amount' => -450,
'transaction_date' => '2026-01-15',
])->assertOk()->assertSee('Blue Bottle Coffee');
$transaction = Transaction::query()->where('account_id', $account->id)->first();
expect($transaction)->not->toBeNull();
expect($transaction->description)->toBe('Blue Bottle Coffee');
expect($transaction->currency_code)->toBe('EUR');
expect($transaction->source->value)->toBe('manually_created');
});
it('refuses to create a transaction on a connected account', function () {
$user = User::factory()->create();
$account = Account::factory()->connected()->create(['user_id' => $user->id]);
callWriteTool($user, CreateTransaction::class, [
'account_id' => $account->id,
'description' => 'Nope',
'amount' => -100,
'transaction_date' => '2026-01-15',
])->assertHasErrors(['connected']);
expect(Transaction::query()->where('account_id', $account->id)->count())->toBe(0);
});
it('edits a manual transaction', function () {
$user = User::factory()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'description' => 'Old description',
]);
callWriteTool($user, UpdateTransaction::class, [
'transaction_id' => $transaction->id,
'description' => 'Fresh description',
])->assertOk()->assertSee('Fresh description');
expect($transaction->fresh()->description)->toBe('Fresh description');
});
it('refuses to edit an imported transaction', function () {
$user = User::factory()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->imported()->create([
'user_id' => $user->id,
'account_id' => $account->id,
]);
callWriteTool($user, UpdateTransaction::class, [
'transaction_id' => $transaction->id,
'description' => 'Hacked',
])->assertHasErrors(['manually-created']);
});
it('deletes a manual transaction', function () {
$user = User::factory()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
]);
callWriteTool($user, DeleteTransaction::class, [
'transaction_id' => $transaction->id,
])->assertOk();
expect(Transaction::query()->find($transaction->id))->toBeNull();
});
it('refuses to delete an imported transaction', function () {
$user = User::factory()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->imported()->create([
'user_id' => $user->id,
'account_id' => $account->id,
]);
callWriteTool($user, DeleteTransaction::class, [
'transaction_id' => $transaction->id,
])->assertHasErrors(['manually-created']);
expect(Transaction::query()->find($transaction->id))->not->toBeNull();
});
it('categorizes an imported transaction', function () {
$user = User::factory()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->imported()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'category_id' => null,
]);
$category = Category::factory()->create(['user_id' => $user->id, 'name' => 'Dining']);
callWriteTool($user, CategorizeTransaction::class, [
'transaction_id' => $transaction->id,
'category_id' => $category->id,
])->assertOk();
$transaction->refresh();
expect($transaction->category_id)->toBe($category->id);
expect($transaction->category_source)->toBe(CategorySource::Manual);
});
it('adds a label to an imported transaction', function () {
$user = User::factory()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->imported()->create([
'user_id' => $user->id,
'account_id' => $account->id,
]);
$label = Label::factory()->create(['user_id' => $user->id, 'name' => 'Reimbursable']);
callWriteTool($user, LabelTransaction::class, [
'transaction_id' => $transaction->id,
'add_label_ids' => [$label->id],
])->assertOk()->assertSee('Reimbursable');
expect($transaction->fresh()->labels->pluck('id')->all())->toContain($label->id);
});
it('records a balance on a manual account', function () {
$user = User::factory()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
callWriteTool($user, CreateBalance::class, [
'account_id' => $account->id,
'balance' => 250000,
'balance_date' => '2026-01-31',
])->assertOk();
expect($account->balances()->whereDate('balance_date', '2026-01-31')->value('balance'))->toBe(250000);
});
it('refuses to record a balance on a connected account', function () {
$user = User::factory()->create();
$account = Account::factory()->connected()->create(['user_id' => $user->id]);
callWriteTool($user, CreateBalance::class, [
'account_id' => $account->id,
'balance' => 250000,
])->assertHasErrors(['connected']);
expect($account->balances()->count())->toBe(0);
});
it('creates, updates and deletes a category', function () {
$user = User::factory()->create();
callWriteTool($user, CreateCategory::class, [
'name' => 'Travel',
'icon' => 'Plane',
'color' => 'blue',
'type' => 'expense',
])->assertOk()->assertSee('Travel');
$category = $user->categories()->where('name', 'Travel')->firstOrFail();
callWriteTool($user, UpdateCategory::class, [
'category_id' => $category->id,
'name' => 'Holidays',
])->assertOk()->assertSee('Holidays');
expect($category->fresh()->name)->toBe('Holidays');
callWriteTool($user, DeleteCategory::class, [
'category_id' => $category->id,
])->assertOk();
expect(Category::query()->find($category->id))->toBeNull();
});
it('creates, updates and deletes a label', function () {
$user = User::factory()->create();
callWriteTool($user, CreateLabel::class, [
'name' => 'Business',
'color' => 'green',
])->assertOk()->assertSee('Business');
$label = $user->labels()->where('name', 'Business')->firstOrFail();
callWriteTool($user, UpdateLabel::class, [
'label_id' => $label->id,
'name' => 'Work',
])->assertOk()->assertSee('Work');
expect($label->fresh()->name)->toBe('Work');
callWriteTool($user, DeleteLabel::class, [
'label_id' => $label->id,
])->assertOk();
expect(Label::query()->find($label->id))->toBeNull();
});
it('creates, updates and deletes an automation rule', function () {
$user = User::factory()->create();
$category = Category::factory()->create(['user_id' => $user->id, 'name' => 'Groceries']);
callWriteTool($user, CreateAutomationRule::class, [
'title' => 'Grocery rule',
'priority' => 0,
'rules_json' => ['in' => ['grocery', ['var' => 'description']]],
'action_category_id' => $category->id,
])->assertOk()->assertSee('Grocery rule');
$rule = $user->automationRules()->where('title', 'Grocery rule')->firstOrFail();
expect($rule->action_category_id)->toBe($category->id);
callWriteTool($user, UpdateAutomationRule::class, [
'automation_rule_id' => $rule->id,
'title' => 'Supermarket rule',
])->assertOk()->assertSee('Supermarket rule');
expect($rule->fresh()->title)->toBe('Supermarket rule');
callWriteTool($user, DeleteAutomationRule::class, [
'automation_rule_id' => $rule->id,
])->assertOk();
expect(AutomationRule::query()->find($rule->id))->toBeNull();
});
it('requires an automation rule to have at least one action', function () {
$user = User::factory()->create();
callWriteTool($user, CreateAutomationRule::class, [
'title' => 'No action',
'priority' => 0,
'rules_json' => ['==' => [1, 1]],
])->assertHasErrors(['action']);
expect($user->automationRules()->count())->toBe(0);
});
it('rejects a write tool called with a read-only token', function () {
$user = User::factory()->create();
callWriteTool($user, CreateLabel::class, [
'name' => 'Should not exist',
'color' => 'blue',
], ['mcp:read'])->assertHasErrors(['read-only']);
expect($user->labels()->count())->toBe(0);
});
it('still enforces the Pro-plan gate on write tools', function () {
config(['subscriptions.enabled' => true]);
$user = User::factory()->create();
callWriteTool($user, CreateLabel::class, [
'name' => 'Gated',
'color' => 'blue',
])->assertHasErrors(['Pro']);
expect($user->labels()->count())->toBe(0);
});
it('never lets a write tool touch another user\'s data', function () {
$user = User::factory()->create();
$other = User::factory()->create();
$otherAccount = Account::factory()->create(['user_id' => $other->id]);
$otherTransaction = Transaction::factory()->create([
'user_id' => $other->id,
'account_id' => $otherAccount->id,
]);
callWriteTool($user, DeleteTransaction::class, [
'transaction_id' => $otherTransaction->id,
])->assertHasErrors();
expect(Transaction::query()->find($otherTransaction->id))->not->toBeNull();
});

View File

@ -38,7 +38,7 @@ it('creates a read-only token and flashes the secret once', function () {
$user = mcpUser();
actingAs($user)
->post(route('mcp.tokens.store'), ['name' => 'Claude Desktop'])
->post(route('mcp.tokens.store'), ['name' => 'Claude Desktop', 'scope' => 'read'])
->assertRedirect(route('mcp.index'))
->assertSessionHas('mcp_token');
@ -48,18 +48,39 @@ it('creates a read-only token and flashes the secret once', function () {
expect($token->abilities)->toBe(['mcp:read']);
});
it('creates a read & write token carrying the mcp:write ability', function () {
$user = mcpUser();
actingAs($user)
->post(route('mcp.tokens.store'), ['name' => 'Claude Code', 'scope' => 'read_write'])
->assertRedirect(route('mcp.index'))
->assertSessionHas('mcp_token');
expect($user->tokens()->first()->abilities)->toBe(['mcp:read', 'mcp:write']);
});
it('requires a token name', function () {
actingAs(mcpUser())
->post(route('mcp.tokens.store'), ['name' => ''])
->post(route('mcp.tokens.store'), ['name' => '', 'scope' => 'read'])
->assertSessionHasErrors('name');
});
it('requires a valid scope', function () {
actingAs(mcpUser())
->post(route('mcp.tokens.store'), ['name' => 'Bad', 'scope' => 'admin'])
->assertSessionHasErrors('scope');
actingAs(mcpUser())
->post(route('mcp.tokens.store'), ['name' => 'Missing'])
->assertSessionHasErrors('scope');
});
it('lets a free account create a token (gating happens at request time)', function () {
config(['subscriptions.enabled' => true]);
$user = mcpUser();
actingAs($user)
->post(route('mcp.tokens.store'), ['name' => 'Free'])
->post(route('mcp.tokens.store'), ['name' => 'Free', 'scope' => 'read'])
->assertSessionHas('mcp_token');
expect($user->tokens()->count())->toBe(1);