feat(mcp): add write tools behind the mcp:write ability

Add a WriteTool base (Pro gate + mcp:write ability check, #[IsDestructive])
and the write tools: create/update/delete/categorize/label transaction,
create balance, and full CRUD for categories, labels and automation rules.
Manual-only guardrails reuse the existing source enum and isConnected()
barriers; categorize and label work on any transaction. Adds a list_labels
read tool so label ids are discoverable, and re-enables read vs read_write
token scopes in the token controller/request.
This commit is contained in:
Víctor Falcón 2026-07-17 16:28:29 +02:00
parent fb1adfc484
commit be4f363e9e
23 changed files with 1519 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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