diff --git a/app/Http/Controllers/Settings/McpTokenController.php b/app/Http/Controllers/Settings/McpTokenController.php index d3ac3b11..642632a1 100644 --- a/app/Http/Controllers/Settings/McpTokenController.php +++ b/app/Http/Controllers/Settings/McpTokenController.php @@ -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); } diff --git a/app/Http/Requests/Settings/StoreMcpTokenRequest.php b/app/Http/Requests/Settings/StoreMcpTokenRequest.php index 42028ba6..77def3a0 100644 --- a/app/Http/Requests/Settings/StoreMcpTokenRequest.php +++ b/app/Http/Requests/Settings/StoreMcpTokenRequest.php @@ -24,6 +24,7 @@ class StoreMcpTokenRequest extends FormRequest { return [ 'name' => ['required', 'string', 'max:255'], + 'scope' => ['required', 'in:read,read_write'], ]; } } diff --git a/app/Mcp/Servers/WhisperMoneyServer.php b/app/Mcp/Servers/WhisperMoneyServer.php index 42f0ae59..44f038bb 100644 --- a/app/Mcp/Servers/WhisperMoneyServer.php +++ b/app/Mcp/Servers/WhisperMoneyServer.php @@ -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> */ 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, ]; } diff --git a/app/Mcp/Tools/CategorizeTransaction.php b/app/Mcp/Tools/CategorizeTransaction.php new file mode 100644 index 00000000..854ce22c --- /dev/null +++ b/app/Mcp/Tools/CategorizeTransaction.php @@ -0,0 +1,50 @@ + + */ + 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())]); + } +} diff --git a/app/Mcp/Tools/Concerns/DecodesRulesJson.php b/app/Mcp/Tools/Concerns/DecodesRulesJson.php new file mode 100644 index 00000000..55f64b72 --- /dev/null +++ b/app/Mcp/Tools/Concerns/DecodesRulesJson.php @@ -0,0 +1,32 @@ + + */ + 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; + } +} diff --git a/app/Mcp/Tools/Concerns/ResolvesCategoryWrites.php b/app/Mcp/Tools/Concerns/ResolvesCategoryWrites.php new file mode 100644 index 00000000..68233f22 --- /dev/null +++ b/app/Mcp/Tools/Concerns/ResolvesCategoryWrites.php @@ -0,0 +1,94 @@ +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(); + } +} diff --git a/app/Mcp/Tools/CreateAutomationRule.php b/app/Mcp/Tools/CreateAutomationRule.php new file mode 100644 index 00000000..b591acff --- /dev/null +++ b/app/Mcp/Tools/CreateAutomationRule.php @@ -0,0 +1,81 @@ +":[{"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 + */ + 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)]); + } +} diff --git a/app/Mcp/Tools/CreateBalance.php b/app/Mcp/Tools/CreateBalance.php new file mode 100644 index 00000000..2e2ae5d3 --- /dev/null +++ b/app/Mcp/Tools/CreateBalance.php @@ -0,0 +1,66 @@ + + */ + 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, + ], + ]); + } +} diff --git a/app/Mcp/Tools/CreateCategory.php b/app/Mcp/Tools/CreateCategory.php new file mode 100644 index 00000000..e39990be --- /dev/null +++ b/app/Mcp/Tools/CreateCategory.php @@ -0,0 +1,79 @@ + + */ + 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)]); + } +} diff --git a/app/Mcp/Tools/CreateLabel.php b/app/Mcp/Tools/CreateLabel.php new file mode 100644 index 00000000..a3910477 --- /dev/null +++ b/app/Mcp/Tools/CreateLabel.php @@ -0,0 +1,58 @@ + + */ + 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)]); + } +} diff --git a/app/Mcp/Tools/CreateTransaction.php b/app/Mcp/Tools/CreateTransaction.php new file mode 100644 index 00000000..ba27b1ba --- /dev/null +++ b/app/Mcp/Tools/CreateTransaction.php @@ -0,0 +1,91 @@ + + */ + 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)]); + } +} diff --git a/app/Mcp/Tools/DeleteAutomationRule.php b/app/Mcp/Tools/DeleteAutomationRule.php new file mode 100644 index 00000000..bf033c98 --- /dev/null +++ b/app/Mcp/Tools/DeleteAutomationRule.php @@ -0,0 +1,47 @@ + + */ + 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]); + } +} diff --git a/app/Mcp/Tools/DeleteCategory.php b/app/Mcp/Tools/DeleteCategory.php new file mode 100644 index 00000000..c82bb755 --- /dev/null +++ b/app/Mcp/Tools/DeleteCategory.php @@ -0,0 +1,70 @@ + + */ + 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(); + } +} diff --git a/app/Mcp/Tools/DeleteLabel.php b/app/Mcp/Tools/DeleteLabel.php new file mode 100644 index 00000000..0ad47fd2 --- /dev/null +++ b/app/Mcp/Tools/DeleteLabel.php @@ -0,0 +1,36 @@ + + */ + 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]); + } +} diff --git a/app/Mcp/Tools/DeleteTransaction.php b/app/Mcp/Tools/DeleteTransaction.php new file mode 100644 index 00000000..124665c5 --- /dev/null +++ b/app/Mcp/Tools/DeleteTransaction.php @@ -0,0 +1,47 @@ + + */ + 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]); + } +} diff --git a/app/Mcp/Tools/LabelTransaction.php b/app/Mcp/Tools/LabelTransaction.php new file mode 100644 index 00000000..3000a839 --- /dev/null +++ b/app/Mcp/Tools/LabelTransaction.php @@ -0,0 +1,51 @@ + + */ + 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())]); + } +} diff --git a/app/Mcp/Tools/ListLabels.php b/app/Mcp/Tools/ListLabels.php new file mode 100644 index 00000000..40cfc41a --- /dev/null +++ b/app/Mcp/Tools/ListLabels.php @@ -0,0 +1,46 @@ + + */ + 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, + ]); + } +} diff --git a/app/Mcp/Tools/UpdateAutomationRule.php b/app/Mcp/Tools/UpdateAutomationRule.php new file mode 100644 index 00000000..7248b9f6 --- /dev/null +++ b/app/Mcp/Tools/UpdateAutomationRule.php @@ -0,0 +1,107 @@ + + */ + 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; + } +} diff --git a/app/Mcp/Tools/UpdateCategory.php b/app/Mcp/Tools/UpdateCategory.php new file mode 100644 index 00000000..179768c2 --- /dev/null +++ b/app/Mcp/Tools/UpdateCategory.php @@ -0,0 +1,101 @@ + + */ + 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(); + } +} diff --git a/app/Mcp/Tools/UpdateLabel.php b/app/Mcp/Tools/UpdateLabel.php new file mode 100644 index 00000000..bf157207 --- /dev/null +++ b/app/Mcp/Tools/UpdateLabel.php @@ -0,0 +1,69 @@ + + */ + 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)]); + } +} diff --git a/app/Mcp/Tools/UpdateTransaction.php b/app/Mcp/Tools/UpdateTransaction.php new file mode 100644 index 00000000..250aa34d --- /dev/null +++ b/app/Mcp/Tools/UpdateTransaction.php @@ -0,0 +1,114 @@ + + */ + 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())]); + } +} diff --git a/app/Mcp/Tools/WriteTool.php b/app/Mcp/Tools/WriteTool.php new file mode 100644 index 00000000..ab7b5057 --- /dev/null +++ b/app/Mcp/Tools/WriteTool.php @@ -0,0 +1,221 @@ +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 + */ + 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 $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 + */ + 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 + */ + 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 + */ + protected function presentLabel(Label $label): array + { + return [ + 'id' => $label->id, + 'name' => $label->name, + 'color' => $label->color, + ]; + } + + /** + * @return array + */ + 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(), + ]; + } +} diff --git a/app/Models/AutomationRule.php b/app/Models/AutomationRule.php index 08153311..74cdcb7b 100644 --- a/app/Models/AutomationRule.php +++ b/app/Models/AutomationRule.php @@ -69,6 +69,7 @@ class AutomationRule extends Model return $this->belongsTo(Category::class, 'action_category_id'); } + /** @return BelongsToMany */ public function labels(): BelongsToMany { return $this->belongsToMany(Label::class, 'automation_rule_labels') diff --git a/lang/es.json b/lang/es.json index e312c295..a61252f1 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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.", diff --git a/resources/js/pages/settings/mcp.tsx b/resources/js/pages/settings/mcp.tsx index d959d42b..fe7f786e 100644 --- a/resources/js/pages/settings/mcp.tsx +++ b/resources/js/pages/settings/mcp.tsx @@ -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() { {__('Create a token')} {__( - '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.', )} @@ -191,6 +201,42 @@ export default function Mcp() { required /> +
+ + {/* Radix Select renders a hidden native + form.setData( + 'scope', + value as + | 'read' + | 'read_write', + ) + } + > + + + + + + {__('Read only')} + + + {__('Read & write')} + + + +
+