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:
parent
fb1adfc484
commit
be4f363e9e
|
|
@ -48,12 +48,17 @@ class McpTokenController extends Controller implements HasMiddleware
|
|||
|
||||
/**
|
||||
* Create a new MCP token. The plaintext secret is flashed once; only its
|
||||
* hash is stored, so it can never be shown again. Tokens are read-only for
|
||||
* now (the `mcp:write` ability is introduced alongside the write tools).
|
||||
* hash is stored, so it can never be shown again. A "read" token can only
|
||||
* analyse data; a "read_write" token additionally carries `mcp:write`,
|
||||
* unlocking the write tools.
|
||||
*/
|
||||
public function store(StoreMcpTokenRequest $request): RedirectResponse
|
||||
{
|
||||
$token = $request->user()->createToken($request->validated('name'), ['mcp:read']);
|
||||
$abilities = $request->validated('scope') === 'read_write'
|
||||
? ['mcp:read', 'mcp:write']
|
||||
: ['mcp:read'];
|
||||
|
||||
$token = $request->user()->createToken($request->validated('name'), $abilities);
|
||||
|
||||
return to_route('mcp.index')->with('mcp_token', $token->plainTextToken);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class StoreMcpTokenRequest extends FormRequest
|
|||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'scope' => ['required', 'in:read,read_write'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,29 @@
|
|||
|
||||
namespace App\Mcp\Servers;
|
||||
|
||||
use App\Mcp\Tools\CategorizeTransaction;
|
||||
use App\Mcp\Tools\CreateAutomationRule;
|
||||
use App\Mcp\Tools\CreateBalance;
|
||||
use App\Mcp\Tools\CreateCategory;
|
||||
use App\Mcp\Tools\CreateLabel;
|
||||
use App\Mcp\Tools\CreateTransaction;
|
||||
use App\Mcp\Tools\DeleteAutomationRule;
|
||||
use App\Mcp\Tools\DeleteCategory;
|
||||
use App\Mcp\Tools\DeleteLabel;
|
||||
use App\Mcp\Tools\DeleteTransaction;
|
||||
use App\Mcp\Tools\GetCashflow;
|
||||
use App\Mcp\Tools\GetNetWorth;
|
||||
use App\Mcp\Tools\LabelTransaction;
|
||||
use App\Mcp\Tools\ListAccounts;
|
||||
use App\Mcp\Tools\ListCategories;
|
||||
use App\Mcp\Tools\ListLabels;
|
||||
use App\Mcp\Tools\ListSpaces;
|
||||
use App\Mcp\Tools\SearchTransactions;
|
||||
use App\Mcp\Tools\SpendingByCategory;
|
||||
use App\Mcp\Tools\UpdateAutomationRule;
|
||||
use App\Mcp\Tools\UpdateCategory;
|
||||
use App\Mcp\Tools\UpdateLabel;
|
||||
use App\Mcp\Tools\UpdateTransaction;
|
||||
use Laravel\Mcp\Server;
|
||||
use Laravel\Mcp\Server\Attributes\Instructions;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
|
|
@ -18,27 +34,55 @@ use Laravel\Mcp\Server\Tool;
|
|||
#[Name('Whisper Money')]
|
||||
#[Version('1.0.0')]
|
||||
#[Instructions(<<<'MARKDOWN'
|
||||
Read-only access to the authenticated user's Whisper Money finance data, for
|
||||
analysing spending, cashflow and net worth.
|
||||
Access to the authenticated user's Whisper Money finance data, for analysing
|
||||
spending, cashflow and net worth — and, with a read & write token, for editing
|
||||
that data.
|
||||
|
||||
- All amounts are integers in minor units (cents). Divide by 100 for a display value.
|
||||
- Data is organised into "spaces" (the personal space and any shared spaces).
|
||||
Transaction and account tools accept an optional `space` id and default to the
|
||||
personal space; call `list_spaces` to discover ids. The cashflow, net-worth and
|
||||
spending tools cover the user's whole account.
|
||||
Transaction, account, category and label tools accept an optional `space` id and
|
||||
default to the personal space; call `list_spaces` to discover ids. The cashflow,
|
||||
net-worth and spending tools cover the user's whole account.
|
||||
- To find recurring charges (subscriptions), use `search_transactions` and group
|
||||
the results by merchant and cadence yourself.
|
||||
|
||||
Write tools (create_transaction, update_transaction, delete_transaction,
|
||||
categorize_transaction, label_transaction, create_balance and full CRUD for
|
||||
categories, labels and automation rules) require a read & write token; a
|
||||
read-only token can analyse data but never change it. Bank-connected accounts
|
||||
and bank/imported transactions are protected: you can only create, edit or
|
||||
delete manual transactions and manual-account balances, but you can categorize
|
||||
and label any transaction.
|
||||
MARKDOWN)]
|
||||
class WhisperMoneyServer extends Server
|
||||
{
|
||||
/** @var array<int, class-string<Tool>> */
|
||||
protected array $tools = [
|
||||
// Read
|
||||
SearchTransactions::class,
|
||||
SpendingByCategory::class,
|
||||
GetCashflow::class,
|
||||
GetNetWorth::class,
|
||||
ListAccounts::class,
|
||||
ListCategories::class,
|
||||
ListLabels::class,
|
||||
ListSpaces::class,
|
||||
|
||||
// Write
|
||||
CreateTransaction::class,
|
||||
UpdateTransaction::class,
|
||||
DeleteTransaction::class,
|
||||
CategorizeTransaction::class,
|
||||
LabelTransaction::class,
|
||||
CreateBalance::class,
|
||||
CreateCategory::class,
|
||||
UpdateCategory::class,
|
||||
DeleteCategory::class,
|
||||
CreateLabel::class,
|
||||
UpdateLabel::class,
|
||||
DeleteLabel::class,
|
||||
CreateAutomationRule::class,
|
||||
UpdateAutomationRule::class,
|
||||
DeleteAutomationRule::class,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\CategorySource;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Set (or clear) the category of any transaction, including bank/imported ones. Marks the category as manually assigned. Pass category_id: null to remove the category.')]
|
||||
class CategorizeTransaction extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'transaction_id' => $schema->string()->description('Id of the transaction to categorize.')->required(),
|
||||
'category_id' => $schema->string()->description('Category id to assign, or null to remove the category.')->required(),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
if (! $request->has('category_id')) {
|
||||
return Response::error('Provide category_id to set the category (or null to remove it).');
|
||||
}
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$transaction = $this->transactionInSpace($request, $space);
|
||||
|
||||
$categoryId = $request->filled('category_id')
|
||||
? $this->categoryInSpace($request, $space)->id
|
||||
: null;
|
||||
|
||||
$transaction->category_id = $categoryId;
|
||||
$transaction->category_source = $categoryId === null ? null : CategorySource::Manual;
|
||||
$transaction->ai_confidence = null;
|
||||
$transaction->categorized_by_rule_id = null;
|
||||
$transaction->save();
|
||||
|
||||
return $this->json(['transaction' => $this->presentTransaction($transaction->refresh())]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Concerns;
|
||||
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
|
||||
trait DecodesRulesJson
|
||||
{
|
||||
/**
|
||||
* Decode and validate a JsonLogic condition argument, accepting either a
|
||||
* JSON object or a JSON-encoded string.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function rulesJson(Request $request, string $key = 'rules_json'): array
|
||||
{
|
||||
$rulesJson = $request->get($key);
|
||||
|
||||
if (is_string($rulesJson)) {
|
||||
$rulesJson = json_decode($rulesJson, true);
|
||||
}
|
||||
|
||||
if (! is_array($rulesJson) || $rulesJson === []) {
|
||||
throw ValidationException::withMessages([
|
||||
$key => "{$key} must be a non-empty JsonLogic object.",
|
||||
]);
|
||||
}
|
||||
|
||||
return $rulesJson;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Concerns;
|
||||
|
||||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategoryType;
|
||||
use App\Models\Category;
|
||||
use App\Models\Space;
|
||||
use App\Services\CategoryTree;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
|
||||
/**
|
||||
* Category create/update helpers shared by the category write tools. Mirrors
|
||||
* the web form's parent resolution (ownership, depth, cycle) and its
|
||||
* type-driven cashflow derivation, without depending on `auth()` (which is not
|
||||
* the active guard for MCP requests).
|
||||
*/
|
||||
trait ResolvesCategoryWrites
|
||||
{
|
||||
/**
|
||||
* Resolve the requested parent category within the space, enforcing the
|
||||
* same rules as the web form. Returns null when creating/keeping a root.
|
||||
* $moving is the category being edited (null on create) so its own subtree
|
||||
* depth and cycle constraints can be checked.
|
||||
*/
|
||||
protected function resolveParentCategory(Request $request, Space $space, ?Category $moving): ?Category
|
||||
{
|
||||
if (! $request->filled('parent_id')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parentId = $request->string('parent_id')->toString();
|
||||
|
||||
$parent = Category::query()->forSpace($space)->whereKey($parentId)->first();
|
||||
|
||||
if ($parent === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'parent_id' => "No category with id {$parentId} in space {$space->id}. Call list_categories to see valid ids.",
|
||||
]);
|
||||
}
|
||||
|
||||
$tree = new CategoryTree;
|
||||
|
||||
if ($moving !== null && $tree->wouldCreateCycle($moving, $parent->id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'parent_id' => 'A category cannot be nested under itself or one of its children.',
|
||||
]);
|
||||
}
|
||||
|
||||
$subtreeDepth = $moving !== null ? $tree->subtreeDepth($moving) : 1;
|
||||
|
||||
if ($tree->depth($parent) + $subtreeDepth > Category::MAX_DEPTH) {
|
||||
throw ValidationException::withMessages([
|
||||
'parent_id' => 'Categories can only be nested up to '.Category::MAX_DEPTH.' levels deep.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the cashflow direction the web form would compute: a child inherits
|
||||
* its parent's direction; a root follows its type (savings/investment always
|
||||
* count as an outflow, transfers keep the requested direction, everything
|
||||
* else is hidden).
|
||||
*/
|
||||
protected function cashflowDirectionFor(CategoryType $type, ?Category $parent, ?string $requested): CategoryCashflowDirection
|
||||
{
|
||||
if ($parent !== null) {
|
||||
return $parent->cashflow_direction;
|
||||
}
|
||||
|
||||
return match ($type) {
|
||||
CategoryType::Savings, CategoryType::Investment => CategoryCashflowDirection::Outflow,
|
||||
CategoryType::Transfer => CategoryCashflowDirection::tryFrom((string) $requested) ?? CategoryCashflowDirection::Hidden,
|
||||
default => CategoryCashflowDirection::Hidden,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a sibling category (same parent) with the given name already
|
||||
* exists in the space, optionally ignoring one category by id (for edits).
|
||||
*/
|
||||
protected function categoryNameTaken(Space $space, string $name, ?string $parentId, ?string $ignoreId = null): bool
|
||||
{
|
||||
return Category::query()
|
||||
->forSpace($space)
|
||||
->where('name', $name)
|
||||
->where('parent_id', $parentId)
|
||||
->when($ignoreId !== null, fn ($query) => $query->whereKeyNot($ignoreId))
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\RuleOrigin;
|
||||
use App\Mcp\Tools\Concerns\DecodesRulesJson;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description(<<<'TEXT'
|
||||
Create an automation rule that auto-applies a category and/or labels to matching transactions. `rules_json` is a JsonLogic object evaluated against these lowercase variables: description, notes, creditor_name, debtor_name, account_name, bank_name, category, transaction_date (YYYY-MM-DD) and amount. Note: amount here is in MAJOR units (e.g. 12.50), not cents. Example: {"and":[{">":[{"var":"amount"},100]},{"in":["grocery",{"var":"description"}]}]}. At least one action (action_category_id or action_label_ids) is required.
|
||||
TEXT)]
|
||||
class CreateAutomationRule extends WriteTool
|
||||
{
|
||||
use DecodesRulesJson;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'title' => $schema->string()->description('Human-readable rule name.')->required(),
|
||||
'priority' => $schema->integer()->min(0)->description('Lower numbers are evaluated first.')->required(),
|
||||
'rules_json' => $schema->object()->description('JsonLogic condition object.')->required(),
|
||||
'action_category_id' => $schema->string()->description('Category id to assign to matching transactions.'),
|
||||
'action_label_ids' => $schema->array()->items($schema->string())->description('Label ids to attach to matching transactions.'),
|
||||
'action_note' => $schema->string()->description('Note to append to matching transactions.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'priority' => ['required', 'integer', 'min:0'],
|
||||
'action_note' => ['sometimes', 'nullable', 'string'],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
|
||||
$rulesJson = $this->rulesJson($request);
|
||||
$labels = $this->labelsInSpace($request, $space, 'action_label_ids');
|
||||
$categoryId = $request->filled('action_category_id')
|
||||
? $this->categoryInSpace($request, $space, 'action_category_id')->id
|
||||
: null;
|
||||
|
||||
if ($categoryId === null && $labels->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'action_category_id' => 'At least one action is required: pass action_category_id and/or action_label_ids.',
|
||||
]);
|
||||
}
|
||||
|
||||
$rule = new AutomationRule([
|
||||
'user_id' => $user->id,
|
||||
'space_id' => $space->id,
|
||||
'title' => $request->string('title')->toString(),
|
||||
'priority' => $request->integer('priority'),
|
||||
'origin' => RuleOrigin::User->value,
|
||||
'rules_json' => $rulesJson,
|
||||
'action_category_id' => $categoryId,
|
||||
'action_note' => $request->filled('action_note') ? $request->string('action_note')->toString() : null,
|
||||
]);
|
||||
$rule->save();
|
||||
|
||||
if ($labels->isNotEmpty()) {
|
||||
$rule->labels()->sync($labels->pluck('id')->all());
|
||||
$rule->touch();
|
||||
}
|
||||
|
||||
return $this->json(['automation_rule' => $this->presentAutomationRule($rule)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Models\AccountBalance;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Record an account balance snapshot on a non-connected (manual) account. Balance is an integer in minor units (cents). Replaces any existing snapshot for that date. Connected/bank accounts are read-only.')]
|
||||
class CreateBalance extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'account_id' => $schema->string()->description('Id of a non-connected (manual) account.')->required(),
|
||||
'balance' => $schema->integer()->description('Balance in minor units (cents).')->required(),
|
||||
'balance_date' => $schema->string()->description('Snapshot date, YYYY-MM-DD. Defaults to today.'),
|
||||
'invested_amount' => $schema->integer()->description('Optional invested amount in minor units (cents), for investment accounts.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'balance' => ['required', 'integer'],
|
||||
'balance_date' => ['sometimes', 'date'],
|
||||
'invested_amount' => ['sometimes', 'nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$account = $this->writableAccount($request, $space);
|
||||
|
||||
$balanceDate = $request->filled('balance_date')
|
||||
? $request->string('balance_date')->toString()
|
||||
: now()->toDateString();
|
||||
|
||||
$balance = AccountBalance::updateOrCreate(
|
||||
['account_id' => $account->id, 'balance_date' => $balanceDate],
|
||||
[
|
||||
'balance' => $request->integer('balance'),
|
||||
...$request->filled('invested_amount')
|
||||
? ['invested_amount' => $request->integer('invested_amount')]
|
||||
: [],
|
||||
],
|
||||
);
|
||||
|
||||
return $this->json([
|
||||
'balance' => [
|
||||
'id' => $balance->id,
|
||||
'account_id' => $balance->account_id,
|
||||
'balance_date' => $balance->balance_date->toDateString(),
|
||||
'balance' => $balance->balance,
|
||||
'invested_amount' => $balance->invested_amount,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategoryColor;
|
||||
use App\Enums\CategoryType;
|
||||
use App\Mcp\Tools\Concerns\ResolvesCategoryWrites;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Create a category. A child (parent_id set) inherits its parent type and cashflow direction; a root follows its own type. Categories can be nested up to 3 levels deep.')]
|
||||
class CreateCategory extends WriteTool
|
||||
{
|
||||
use ResolvesCategoryWrites;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'name' => $schema->string()->description('Category name (unique among its siblings).')->required(),
|
||||
'icon' => $schema->string()->description('Icon name (e.g. ShoppingBag, Home, Car).')->required(),
|
||||
'color' => $schema->string()->enum(array_column(CategoryColor::cases(), 'value'))->description('Category color.')->required(),
|
||||
'type' => $schema->string()->enum(array_column(CategoryType::cases(), 'value'))->description('Category type. Ignored for children (they inherit the parent type).')->required(),
|
||||
'parent_id' => $schema->string()->description('Optional parent category id to nest under.'),
|
||||
'cashflow_direction' => $schema->string()->enum(array_column(CategoryCashflowDirection::cases(), 'value'))->description('Only used for transfer-type roots; otherwise derived automatically.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'icon' => ['required', 'string'],
|
||||
'color' => ['required', Rule::enum(CategoryColor::class)],
|
||||
'type' => ['required', Rule::enum(CategoryType::class)],
|
||||
'cashflow_direction' => ['sometimes', Rule::enum(CategoryCashflowDirection::class)],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$parent = $this->resolveParentCategory($request, $space, null);
|
||||
|
||||
$type = $parent !== null ? $parent->type : $request->enum('type', CategoryType::class);
|
||||
$cashflow = $this->cashflowDirectionFor($type, $parent, $request->string('cashflow_direction')->toString());
|
||||
|
||||
$name = $request->string('name')->toString();
|
||||
|
||||
if ($this->categoryNameTaken($space, $name, $parent?->id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'name' => 'A category with that name already exists at this level.',
|
||||
]);
|
||||
}
|
||||
|
||||
$category = new Category([
|
||||
'user_id' => $user->id,
|
||||
'space_id' => $space->id,
|
||||
'name' => $name,
|
||||
'icon' => $request->string('icon')->toString(),
|
||||
'color' => $request->string('color')->toString(),
|
||||
'type' => $type->value,
|
||||
'cashflow_direction' => $cashflow->value,
|
||||
'parent_id' => $parent?->id,
|
||||
]);
|
||||
$category->save();
|
||||
|
||||
return $this->json(['category' => $this->presentCategory($category)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\LabelColor;
|
||||
use App\Models\Label;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Create a label. Names are unique within the space.')]
|
||||
class CreateLabel extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'name' => $schema->string()->description('Label name (unique within the space).')->required(),
|
||||
'color' => $schema->string()->enum(array_column(LabelColor::cases(), 'value'))->description('Label color.')->required(),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'color' => ['required', Rule::enum(LabelColor::class)],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$name = $request->string('name')->toString();
|
||||
|
||||
if (Label::query()->forSpace($space)->where('name', $name)->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'name' => 'A label with that name already exists.',
|
||||
]);
|
||||
}
|
||||
|
||||
$label = new Label([
|
||||
'user_id' => $user->id,
|
||||
'space_id' => $space->id,
|
||||
'name' => $name,
|
||||
'color' => $request->string('color')->toString(),
|
||||
]);
|
||||
$label->save();
|
||||
|
||||
return $this->json(['label' => $this->presentLabel($label)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\CategorySource;
|
||||
use App\Enums\TransactionSource;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Services\ManualBalanceAdjuster;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Create a manual transaction on a non-connected (manual) account. Amount is a signed integer in minor units (cents): negative for an expense, positive for income. Connected/bank accounts are read-only.')]
|
||||
class CreateTransaction extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'account_id' => $schema->string()->description('Id of a non-connected (manual) account to add the transaction to.')->required(),
|
||||
'description' => $schema->string()->description('Human-readable description.')->required(),
|
||||
'amount' => $schema->integer()->description('Signed amount in minor units (cents). Negative = expense, positive = income.')->required(),
|
||||
'transaction_date' => $schema->string()->description('Transaction date, YYYY-MM-DD.')->required(),
|
||||
'currency_code' => $schema->string()->description('ISO 4217 currency code (3 letters). Defaults to the account currency.'),
|
||||
'category_id' => $schema->string()->description('Optional category id to assign.'),
|
||||
'creditor_name' => $schema->string()->description('Optional creditor (payee) name.'),
|
||||
'debtor_name' => $schema->string()->description('Optional debtor (payer) name.'),
|
||||
'notes' => $schema->string()->description('Optional free-text notes.'),
|
||||
'label_ids' => $schema->array()->items($schema->string())->description('Optional label ids to attach.'),
|
||||
'update_balance' => $schema->boolean()->description('When true, shift the manual account balance snapshots by this amount. Default false.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'account_id' => ['required', 'string'],
|
||||
'description' => ['required', 'string'],
|
||||
'amount' => ['required', 'integer'],
|
||||
'transaction_date' => ['required', 'date'],
|
||||
'currency_code' => ['sometimes', 'string', 'size:3'],
|
||||
'notes' => ['sometimes', 'nullable', 'string'],
|
||||
'creditor_name' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'debtor_name' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$account = $this->writableAccount($request, $space);
|
||||
$labels = $this->labelsInSpace($request, $space, 'label_ids');
|
||||
|
||||
$categoryId = $request->filled('category_id')
|
||||
? $this->categoryInSpace($request, $space)->id
|
||||
: null;
|
||||
|
||||
$transaction = new Transaction([
|
||||
'user_id' => $user->id,
|
||||
'space_id' => $space->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $categoryId,
|
||||
'category_source' => $categoryId === null ? null : CategorySource::Manual->value,
|
||||
'description' => $request->string('description')->toString(),
|
||||
'transaction_date' => $request->string('transaction_date')->toString(),
|
||||
'amount' => $request->integer('amount'),
|
||||
'currency_code' => $request->filled('currency_code')
|
||||
? mb_strtoupper($request->string('currency_code')->toString())
|
||||
: $account->currency_code,
|
||||
'notes' => $request->filled('notes') ? $request->string('notes')->toString() : null,
|
||||
'creditor_name' => $request->filled('creditor_name') ? $request->string('creditor_name')->toString() : null,
|
||||
'debtor_name' => $request->filled('debtor_name') ? $request->string('debtor_name')->toString() : null,
|
||||
'source' => TransactionSource::ManuallyCreated->value,
|
||||
]);
|
||||
$transaction->save();
|
||||
|
||||
if ($labels->isNotEmpty()) {
|
||||
$transaction->labels()->sync($labels->pluck('id')->all());
|
||||
}
|
||||
|
||||
if ($request->boolean('update_balance')) {
|
||||
app(ManualBalanceAdjuster::class)->applyCreatedTransaction($transaction->load('account'));
|
||||
}
|
||||
|
||||
return $this->json(['transaction' => $this->presentTransaction($transaction)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Delete an automation rule. Transactions it already categorized keep their category; the rule simply stops running on future transactions.')]
|
||||
class DeleteAutomationRule extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'automation_rule_id' => $schema->string()->description('Id of the automation rule to delete.')->required(),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
|
||||
$id = $request->string('automation_rule_id')->toString();
|
||||
|
||||
$rule = AutomationRule::query()->forSpace($space)->whereKey($id)->first();
|
||||
|
||||
if ($rule === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'automation_rule_id' => "No automation rule with id {$id} in space {$space->id}.",
|
||||
]);
|
||||
}
|
||||
|
||||
$rule->delete();
|
||||
|
||||
return $this->json(['deleted' => true, 'id' => $rule->id]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\CategoryDeletionStrategy;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use App\Services\CategoryTree;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Delete a category. The strategy decides what happens to its child categories: "reparent" (default) lifts them to the deleted category\'s parent, "promote" turns them into roots, "cascade" deletes the whole subtree and uncategorizes affected transactions.')]
|
||||
class DeleteCategory extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'category_id' => $schema->string()->description('Id of the category to delete.')->required(),
|
||||
'strategy' => $schema->string()->enum(array_column(CategoryDeletionStrategy::cases(), 'value'))->description('How to handle child categories. Defaults to "reparent".'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'strategy' => ['sometimes', Rule::enum(CategoryDeletionStrategy::class)],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$category = $this->categoryInSpace($request, $space);
|
||||
|
||||
$strategy = $request->enum('strategy', CategoryDeletionStrategy::class) ?? CategoryDeletionStrategy::Reparent;
|
||||
$tree = new CategoryTree;
|
||||
|
||||
match ($strategy) {
|
||||
CategoryDeletionStrategy::Cascade => $tree->deleteSubtree($category),
|
||||
CategoryDeletionStrategy::Promote => $this->detachChildrenAndDelete($category, null),
|
||||
CategoryDeletionStrategy::Reparent => $this->detachChildrenAndDelete($category, $category->parent_id),
|
||||
};
|
||||
|
||||
return $this->json(['deleted' => true, 'id' => $category->id, 'strategy' => $strategy->value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the category's direct children to a new parent, then delete it.
|
||||
*/
|
||||
private function detachChildrenAndDelete(Category $category, ?string $newParentId): void
|
||||
{
|
||||
try {
|
||||
$category->children()->update(['parent_id' => $newParentId]);
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
throw ValidationException::withMessages([
|
||||
'strategy' => 'A category with the same name already exists at the destination level. Rename it first.',
|
||||
]);
|
||||
}
|
||||
|
||||
$category->delete();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Delete a label. It is removed from every transaction it was attached to.')]
|
||||
class DeleteLabel extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'label_id' => $schema->string()->description('Id of the label to delete.')->required(),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$label = $this->labelInSpace($request, $space);
|
||||
|
||||
$label->delete();
|
||||
|
||||
return $this->json(['deleted' => true, 'id' => $label->id]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\TransactionSource;
|
||||
use App\Models\User;
|
||||
use App\Services\ManualBalanceAdjuster;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Delete a manually-created transaction. Only manual transactions can be deleted; bank/imported ones cannot.')]
|
||||
class DeleteTransaction extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'transaction_id' => $schema->string()->description('Id of the manually-created transaction to delete.')->required(),
|
||||
'update_balance' => $schema->boolean()->description('When true, reverse this transaction from the manual account balance snapshots. Default false.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$transaction = $this->transactionInSpace($request, $space);
|
||||
|
||||
if ($transaction->source !== TransactionSource::ManuallyCreated) {
|
||||
return Response::error('Only manually-created transactions can be deleted. This one came from a bank or import.');
|
||||
}
|
||||
|
||||
if ($request->boolean('update_balance')) {
|
||||
app(ManualBalanceAdjuster::class)->reverseDeletedTransaction($transaction);
|
||||
}
|
||||
|
||||
$transaction->delete();
|
||||
|
||||
return $this->json(['deleted' => true, 'id' => $transaction->id]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Add and/or remove labels on any transaction, including bank/imported ones. Pass add_label_ids and/or remove_label_ids.')]
|
||||
class LabelTransaction extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'transaction_id' => $schema->string()->description('Id of the transaction to label.')->required(),
|
||||
'add_label_ids' => $schema->array()->items($schema->string())->description('Label ids to attach.'),
|
||||
'remove_label_ids' => $schema->array()->items($schema->string())->description('Label ids to detach.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$transaction = $this->transactionInSpace($request, $space);
|
||||
|
||||
$add = $this->labelsInSpace($request, $space, 'add_label_ids');
|
||||
$remove = $this->labelsInSpace($request, $space, 'remove_label_ids');
|
||||
|
||||
if ($add->isEmpty() && $remove->isEmpty()) {
|
||||
return Response::error('Provide add_label_ids and/or remove_label_ids (arrays of label ids).');
|
||||
}
|
||||
|
||||
if ($add->isNotEmpty()) {
|
||||
$transaction->labels()->syncWithoutDetaching($add->pluck('id')->all());
|
||||
}
|
||||
|
||||
if ($remove->isNotEmpty()) {
|
||||
$transaction->labels()->detach($remove->pluck('id')->all());
|
||||
}
|
||||
|
||||
return $this->json(['transaction' => $this->presentTransaction($transaction->refresh())]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('List the user\'s labels in a space. Use the ids with label_transaction or automation-rule label actions.')]
|
||||
class ListLabels extends McpTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'space' => $schema->string()->description('Space id to query. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function respond(Request $request, User $user): Response
|
||||
{
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
|
||||
$labels = Label::query()
|
||||
->forSpace($space)
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (Label $label): array => [
|
||||
'id' => $label->id,
|
||||
'name' => $label->name,
|
||||
'color' => $label->color,
|
||||
]);
|
||||
|
||||
return $this->json([
|
||||
'space_id' => $space->id,
|
||||
'labels' => $labels,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Mcp\Tools\Concerns\DecodesRulesJson;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Space;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Edit an automation rule. Only the fields you pass are changed. The rule must always keep at least one action (a category or labels). See create_automation_rule for the rules_json format.')]
|
||||
class UpdateAutomationRule extends WriteTool
|
||||
{
|
||||
use DecodesRulesJson;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'automation_rule_id' => $schema->string()->description('Id of the automation rule to edit.')->required(),
|
||||
'title' => $schema->string()->description('New rule name.'),
|
||||
'priority' => $schema->integer()->min(0)->description('New priority (lower is evaluated first).'),
|
||||
'rules_json' => $schema->object()->description('New JsonLogic condition object.'),
|
||||
'action_category_id' => $schema->string()->description('New category id to assign, or null to clear.'),
|
||||
'action_label_ids' => $schema->array()->items($schema->string())->description('Replacement set of label ids (replaces all existing labels).'),
|
||||
'action_note' => $schema->string()->description('New note to append, or null to clear.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'title' => ['sometimes', 'string', 'max:255'],
|
||||
'priority' => ['sometimes', 'integer', 'min:0'],
|
||||
'action_note' => ['sometimes', 'nullable', 'string'],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$rule = $this->ruleInSpace($request, $space);
|
||||
|
||||
if ($request->has('title')) {
|
||||
$rule->title = $request->string('title')->toString();
|
||||
}
|
||||
if ($request->has('priority')) {
|
||||
$rule->priority = $request->integer('priority');
|
||||
}
|
||||
if ($request->has('rules_json')) {
|
||||
$rule->rules_json = $this->rulesJson($request);
|
||||
}
|
||||
if ($request->has('action_category_id')) {
|
||||
$rule->action_category_id = $request->filled('action_category_id')
|
||||
? $this->categoryInSpace($request, $space, 'action_category_id')->id
|
||||
: null;
|
||||
}
|
||||
if ($request->has('action_note')) {
|
||||
$rule->action_note = $request->filled('action_note') ? $request->string('action_note')->toString() : null;
|
||||
}
|
||||
|
||||
$newLabels = $request->has('action_label_ids')
|
||||
? $this->labelsInSpace($request, $space, 'action_label_ids')
|
||||
: null;
|
||||
|
||||
// The rule must keep at least one action. Compare against the labels it
|
||||
// would have after this edit (the new set if provided, else current).
|
||||
$labelCount = $newLabels !== null ? $newLabels->count() : $rule->labels()->count();
|
||||
|
||||
if ($rule->action_category_id === null && $labelCount === 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'action_category_id' => 'An automation rule must keep at least one action: a category or labels.',
|
||||
]);
|
||||
}
|
||||
|
||||
$rule->save();
|
||||
|
||||
if ($newLabels !== null) {
|
||||
$rule->labels()->sync($newLabels->pluck('id')->all());
|
||||
}
|
||||
|
||||
$rule->touch();
|
||||
|
||||
return $this->json(['automation_rule' => $this->presentAutomationRule($rule->refresh())]);
|
||||
}
|
||||
|
||||
private function ruleInSpace(Request $request, Space $space): AutomationRule
|
||||
{
|
||||
$id = $request->string('automation_rule_id')->toString();
|
||||
|
||||
$rule = AutomationRule::query()->forSpace($space)->whereKey($id)->first();
|
||||
|
||||
if ($rule === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'automation_rule_id' => "No automation rule with id {$id} in space {$space->id}.",
|
||||
]);
|
||||
}
|
||||
|
||||
return $rule;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategoryColor;
|
||||
use App\Enums\CategoryType;
|
||||
use App\Mcp\Tools\Concerns\ResolvesCategoryWrites;
|
||||
use App\Models\Category;
|
||||
use App\Models\Space;
|
||||
use App\Models\User;
|
||||
use App\Services\CategoryTree;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Edit a category. Only the fields you pass are changed. Moving it under a parent (or clearing parent_id to make it a root) re-derives its type/cashflow and cascades the type to its descendants.')]
|
||||
class UpdateCategory extends WriteTool
|
||||
{
|
||||
use ResolvesCategoryWrites;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'category_id' => $schema->string()->description('Id of the category to edit.')->required(),
|
||||
'name' => $schema->string()->description('New category name.'),
|
||||
'icon' => $schema->string()->description('New icon name.'),
|
||||
'color' => $schema->string()->enum(array_column(CategoryColor::cases(), 'value'))->description('New category color.'),
|
||||
'type' => $schema->string()->enum(array_column(CategoryType::cases(), 'value'))->description('New type (ignored while the category has a parent).'),
|
||||
'parent_id' => $schema->string()->description('New parent id, or null to make it a root.'),
|
||||
'cashflow_direction' => $schema->string()->enum(array_column(CategoryCashflowDirection::cases(), 'value'))->description('Only used for transfer-type roots; otherwise derived automatically.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'icon' => ['sometimes', 'string'],
|
||||
'color' => ['sometimes', Rule::enum(CategoryColor::class)],
|
||||
'type' => ['sometimes', Rule::enum(CategoryType::class)],
|
||||
'cashflow_direction' => ['sometimes', Rule::enum(CategoryCashflowDirection::class)],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$category = $this->categoryInSpace($request, $space);
|
||||
|
||||
$parent = $request->has('parent_id')
|
||||
? $this->resolveParentCategory($request, $space, $category)
|
||||
: $this->currentParent($category, $space);
|
||||
|
||||
$type = $parent !== null
|
||||
? $parent->type
|
||||
: ($request->has('type') ? $request->enum('type', CategoryType::class) : $category->type);
|
||||
|
||||
$requestedDirection = $request->filled('cashflow_direction')
|
||||
? $request->string('cashflow_direction')->toString()
|
||||
: $category->cashflow_direction->value;
|
||||
$cashflow = $this->cashflowDirectionFor($type, $parent, $requestedDirection);
|
||||
|
||||
$name = $request->has('name') ? $request->string('name')->toString() : $category->name;
|
||||
|
||||
if ($this->categoryNameTaken($space, $name, $parent?->id, $category->id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'name' => 'A category with that name already exists at this level.',
|
||||
]);
|
||||
}
|
||||
|
||||
$category->fill([
|
||||
'name' => $name,
|
||||
'icon' => $request->has('icon') ? $request->string('icon')->toString() : $category->icon,
|
||||
'color' => $request->has('color') ? $request->string('color')->toString() : $category->color,
|
||||
'type' => $type->value,
|
||||
'cashflow_direction' => $cashflow->value,
|
||||
'parent_id' => $parent?->id,
|
||||
]);
|
||||
$category->save();
|
||||
|
||||
(new CategoryTree)->syncDescendantTypes($category);
|
||||
|
||||
return $this->json(['category' => $this->presentCategory($category->refresh())]);
|
||||
}
|
||||
|
||||
private function currentParent(Category $category, Space $space): ?Category
|
||||
{
|
||||
if ($category->parent_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Category::query()->forSpace($space)->whereKey($category->parent_id)->first();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\LabelColor;
|
||||
use App\Models\Label;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Edit a label. Only the fields you pass are changed.')]
|
||||
class UpdateLabel extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'label_id' => $schema->string()->description('Id of the label to edit.')->required(),
|
||||
'name' => $schema->string()->description('New label name (unique within the space).'),
|
||||
'color' => $schema->string()->enum(array_column(LabelColor::cases(), 'value'))->description('New label color.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'color' => ['sometimes', Rule::enum(LabelColor::class)],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$label = $this->labelInSpace($request, $space);
|
||||
|
||||
if ($request->has('name')) {
|
||||
$name = $request->string('name')->toString();
|
||||
|
||||
$exists = Label::query()
|
||||
->forSpace($space)
|
||||
->where('name', $name)
|
||||
->whereKeyNot($label->id)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
throw ValidationException::withMessages([
|
||||
'name' => 'A label with that name already exists.',
|
||||
]);
|
||||
}
|
||||
|
||||
$label->name = $name;
|
||||
}
|
||||
|
||||
if ($request->has('color')) {
|
||||
$label->color = $request->string('color')->toString();
|
||||
}
|
||||
|
||||
$label->save();
|
||||
|
||||
return $this->json(['label' => $this->presentLabel($label)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\CategorySource;
|
||||
use App\Enums\TransactionSource;
|
||||
use App\Models\User;
|
||||
use App\Services\ManualBalanceAdjuster;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Edit a manually-created transaction. Only manual transactions can be edited; bank/imported ones keep their core fields locked (use categorize_transaction or label_transaction for those). Only the fields you pass are changed.')]
|
||||
class UpdateTransaction extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'transaction_id' => $schema->string()->description('Id of the manually-created transaction to edit.')->required(),
|
||||
'description' => $schema->string()->description('New description.'),
|
||||
'amount' => $schema->integer()->description('New signed amount in minor units (cents).'),
|
||||
'transaction_date' => $schema->string()->description('New transaction date, YYYY-MM-DD.'),
|
||||
'currency_code' => $schema->string()->description('New ISO 4217 currency code (3 letters).'),
|
||||
'account_id' => $schema->string()->description('Move the transaction to another non-connected account.'),
|
||||
'category_id' => $schema->string()->description('New category id, or null to clear the category.'),
|
||||
'creditor_name' => $schema->string()->description('New creditor (payee) name.'),
|
||||
'debtor_name' => $schema->string()->description('New debtor (payer) name.'),
|
||||
'notes' => $schema->string()->description('New free-text notes.'),
|
||||
'update_balance' => $schema->boolean()->description('When true and the amount/date/account changed, move the manual account balance snapshots accordingly. Default false.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$transaction = $this->transactionInSpace($request, $space);
|
||||
|
||||
if ($transaction->source !== TransactionSource::ManuallyCreated) {
|
||||
return Response::error('Only manually-created transactions can be edited. This one came from a bank or import, so its core fields are locked. Use categorize_transaction or label_transaction instead.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'description' => ['sometimes', 'string'],
|
||||
'amount' => ['sometimes', 'integer'],
|
||||
'transaction_date' => ['sometimes', 'date'],
|
||||
'currency_code' => ['sometimes', 'string', 'size:3'],
|
||||
'notes' => ['sometimes', 'nullable', 'string'],
|
||||
'creditor_name' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'debtor_name' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
// Snapshot the pre-edit account/date/amount so a manual balance can be
|
||||
// moved off the old values if the edit changes them.
|
||||
$originalSnapshot = clone $transaction;
|
||||
|
||||
if ($request->has('description')) {
|
||||
$transaction->description = $request->string('description')->toString();
|
||||
}
|
||||
if ($request->has('amount')) {
|
||||
$transaction->amount = $request->integer('amount');
|
||||
}
|
||||
if ($request->has('transaction_date')) {
|
||||
$transaction->transaction_date = Carbon::parse($request->string('transaction_date')->toString());
|
||||
}
|
||||
if ($request->has('currency_code')) {
|
||||
$transaction->currency_code = mb_strtoupper($request->string('currency_code')->toString());
|
||||
}
|
||||
if ($request->has('account_id')) {
|
||||
$transaction->account_id = $this->writableAccount($request, $space)->id;
|
||||
}
|
||||
if ($request->has('notes')) {
|
||||
$transaction->notes = $request->filled('notes') ? $request->string('notes')->toString() : null;
|
||||
}
|
||||
if ($request->has('creditor_name')) {
|
||||
$transaction->creditor_name = $request->filled('creditor_name') ? $request->string('creditor_name')->toString() : null;
|
||||
}
|
||||
if ($request->has('debtor_name')) {
|
||||
$transaction->debtor_name = $request->filled('debtor_name') ? $request->string('debtor_name')->toString() : null;
|
||||
}
|
||||
|
||||
// A new category is always a manual assignment: reset any AI/rule
|
||||
// provenance so the row is not later treated as machine-categorized.
|
||||
// ponytail: unlike the web edit path this does not learn a correction
|
||||
// rule — MCP writes stay predictable and side-effect free.
|
||||
if ($request->has('category_id')) {
|
||||
$newCategoryId = $request->filled('category_id') ? $this->categoryInSpace($request, $space)->id : null;
|
||||
|
||||
if ($newCategoryId !== $transaction->category_id) {
|
||||
$transaction->category_id = $newCategoryId;
|
||||
$transaction->category_source = $newCategoryId === null ? null : CategorySource::Manual;
|
||||
$transaction->ai_confidence = null;
|
||||
$transaction->categorized_by_rule_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
$transaction->save();
|
||||
|
||||
if ($request->boolean('update_balance') && $transaction->wasChanged(['amount', 'transaction_date', 'account_id'])) {
|
||||
$adjuster = app(ManualBalanceAdjuster::class);
|
||||
$adjuster->reverseDeletedTransaction($originalSnapshot);
|
||||
$adjuster->applyCreatedTransaction($transaction->load('account'));
|
||||
}
|
||||
|
||||
return $this->json(['transaction' => $this->presentTransaction($transaction->refresh())]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Category;
|
||||
use App\Models\Label;
|
||||
use App\Models\Space;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
|
||||
/**
|
||||
* Base for every Whisper Money write tool. On top of the McpTool Pro-plan gate
|
||||
* it requires the calling token to carry the `mcp:write` ability, so a
|
||||
* read-only token can analyse data but never change it.
|
||||
*
|
||||
* Each concrete write tool must additionally carry the #[IsDestructive]
|
||||
* annotation. PHP attributes are not inherited, so the framework only reports
|
||||
* one declared directly on the served tool class — it cannot live here.
|
||||
*/
|
||||
abstract class WriteTool extends McpTool
|
||||
{
|
||||
protected function respond(Request $request, User $user): Response
|
||||
{
|
||||
if (! $user->tokenCan('mcp:write')) {
|
||||
return Response::error('This token is read-only. Create a read & write token to make changes.');
|
||||
}
|
||||
|
||||
return $this->write($request, $user);
|
||||
}
|
||||
|
||||
abstract protected function write(Request $request, User $user): Response;
|
||||
|
||||
/**
|
||||
* Resolve an account the token may write to: it must live in the space and
|
||||
* must not be connected to a bank (bank-sourced data is never touched).
|
||||
*/
|
||||
protected function writableAccount(Request $request, Space $space, string $key = 'account_id'): Account
|
||||
{
|
||||
$id = $request->string($key)->toString();
|
||||
|
||||
$account = Account::query()->forSpace($space)->whereKey($id)->first();
|
||||
|
||||
if ($account === null) {
|
||||
throw ValidationException::withMessages([
|
||||
$key => "No account with id {$id} in space {$space->id}. Call list_accounts to see valid ids.",
|
||||
]);
|
||||
}
|
||||
|
||||
if ($account->isConnected()) {
|
||||
throw ValidationException::withMessages([
|
||||
$key => 'That account is connected to a bank and is read-only. Only non-connected (manual) accounts can be written to.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
protected function transactionInSpace(Request $request, Space $space, string $key = 'transaction_id'): Transaction
|
||||
{
|
||||
$id = $request->string($key)->toString();
|
||||
|
||||
$transaction = Transaction::query()->forSpace($space)->whereKey($id)->first();
|
||||
|
||||
if ($transaction === null) {
|
||||
throw ValidationException::withMessages([
|
||||
$key => "No transaction with id {$id} in space {$space->id}. Call search_transactions to find ids.",
|
||||
]);
|
||||
}
|
||||
|
||||
return $transaction;
|
||||
}
|
||||
|
||||
protected function categoryInSpace(Request $request, Space $space, string $key = 'category_id'): Category
|
||||
{
|
||||
$id = $request->string($key)->toString();
|
||||
|
||||
$category = Category::query()->forSpace($space)->whereKey($id)->first();
|
||||
|
||||
if ($category === null) {
|
||||
throw ValidationException::withMessages([
|
||||
$key => "No category with id {$id} in space {$space->id}. Call list_categories to see valid ids.",
|
||||
]);
|
||||
}
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
protected function labelInSpace(Request $request, Space $space, string $key = 'label_id'): Label
|
||||
{
|
||||
$id = $request->string($key)->toString();
|
||||
|
||||
$label = Label::query()->forSpace($space)->whereKey($id)->first();
|
||||
|
||||
if ($label === null) {
|
||||
throw ValidationException::withMessages([
|
||||
$key => "No label with id {$id} in space {$space->id}. Call list_labels to see valid ids.",
|
||||
]);
|
||||
}
|
||||
|
||||
return $label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every label id passed under $key, asserting each belongs to the
|
||||
* space. Returns an empty collection when the argument is absent or empty.
|
||||
*
|
||||
* @return Collection<int, Label>
|
||||
*/
|
||||
protected function labelsInSpace(Request $request, Space $space, string $key): Collection
|
||||
{
|
||||
$ids = collect($request->get($key, []))
|
||||
->map(fn (mixed $id): string => (string) $id)
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($ids->isEmpty()) {
|
||||
/** @var Collection<int, Label> $empty */
|
||||
$empty = Label::query()->whereRaw('1 = 0')->get();
|
||||
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$labels = Label::query()->forSpace($space)->whereIn('id', $ids)->get();
|
||||
|
||||
if ($labels->count() !== $ids->count()) {
|
||||
throw ValidationException::withMessages([
|
||||
$key => "One or more label ids do not exist in space {$space->id}. Call list_labels to see valid ids.",
|
||||
]);
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* The transaction shape returned by every transaction write tool, matching
|
||||
* the fields search_transactions exposes so the agent sees a familiar row.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function presentTransaction(Transaction $transaction): array
|
||||
{
|
||||
$transaction->loadMissing(['account:id,name', 'category:id,name', 'labels:id,name']);
|
||||
|
||||
return [
|
||||
'id' => $transaction->id,
|
||||
'date' => $transaction->transaction_date->toDateString(),
|
||||
'description' => $transaction->description,
|
||||
'amount' => $transaction->amount,
|
||||
'currency' => $transaction->currency_code,
|
||||
'category_id' => $transaction->category_id,
|
||||
'category' => $transaction->category?->name,
|
||||
'category_source' => $transaction->category_source?->value,
|
||||
'account_id' => $transaction->account_id,
|
||||
'account' => $transaction->account?->name,
|
||||
'source' => $transaction->source->value,
|
||||
'creditor_name' => $transaction->creditor_name,
|
||||
'debtor_name' => $transaction->debtor_name,
|
||||
'labels' => $transaction->labels
|
||||
->map(fn (Label $label): array => ['id' => $label->id, 'name' => $label->name])
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function presentCategory(Category $category): array
|
||||
{
|
||||
return [
|
||||
'id' => $category->id,
|
||||
'name' => $category->name,
|
||||
'icon' => $category->icon,
|
||||
'color' => $category->color,
|
||||
'type' => $category->type->value,
|
||||
'cashflow_direction' => $category->cashflow_direction->value,
|
||||
'parent_id' => $category->parent_id,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function presentLabel(Label $label): array
|
||||
{
|
||||
return [
|
||||
'id' => $label->id,
|
||||
'name' => $label->name,
|
||||
'color' => $label->color,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function presentAutomationRule(AutomationRule $rule): array
|
||||
{
|
||||
$rule->loadMissing('labels:id,name');
|
||||
|
||||
return [
|
||||
'id' => $rule->id,
|
||||
'title' => $rule->title,
|
||||
'priority' => $rule->priority,
|
||||
'rules_json' => $rule->rules_json,
|
||||
'action_category_id' => $rule->action_category_id,
|
||||
'action_note' => $rule->action_note,
|
||||
'origin' => $rule->origin->value,
|
||||
'labels' => $rule->labels
|
||||
->map(fn (Label $label): array => ['id' => $label->id, 'name' => $label->name])
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -69,6 +69,7 @@ class AutomationRule extends Model
|
|||
return $this->belongsTo(Category::class, 'action_category_id');
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<Label, $this, AutomationRuleLabel, 'pivot'> */
|
||||
public function labels(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Label::class, 'automation_rule_labels')
|
||||
|
|
|
|||
Loading…
Reference in New Issue