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:
parent
fb1adfc484
commit
5d7b655111
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class StoreMcpTokenRequest extends FormRequest
|
|||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'scope' => ['required', 'in:read,read_write'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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())]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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())]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue