feat(mcp): read-only MCP server for Pro accounts (#689)

## What & why

Adds a **read-only MCP server** so a paid ("Pro") user can connect
Whisper Money to their own AI assistant (Claude web/desktop, Claude
Code, ChatGPT) and analyse their own finances — spending, cashflow, net
worth, transactions.

This is **Phase 1 (PR1): read-only**. Write tools (create/edit/delete
transactions, categories, labels, rules, balances) are a deliberate
follow-up (PR2); the token plumbing already reserves an `mcp:write`
ability for them.

## How it works

- **Transport:** remote streamable HTTP server via `laravel/mcp`,
mounted at `/mcp` (`routes/ai.php`).
- **Auth:** Sanctum personal access tokens with **MCP-only abilities**
(`mcp:read`). The route is gated by `auth:sanctum` +
`abilities:mcp:read` + `throttle:60,1`, so a future public-API token
(different ability) can't reach it and vice versa.
- **Pro gating** is enforced **per request inside the tools**
(`User::canUseFeature(PlanFeature::McpAccess)`), so a lapsed
subscription stops working on its own without the user revoking the
token. Free users can still create tokens (marked **PRO** in the UI) but
every call returns a "paid plan required" error with an upgrade URL.
- **Consent:** connecting is the consent — a clearly-weighted
data-egress disclaimer + per-client connection instructions on the
settings page. No separate checkbox (by design).

## Tools (all read-only)

| Tool | Scope |
|------|-------|
| `search_transactions` | space-scoped (optional `space`, defaults to
personal) |
| `list_accounts`, `list_categories`, `list_spaces` | space-scoped |
| `spending_by_category`, `get_cashflow`, `get_net_worth` | user's whole
account (reuse existing analytics services/controllers) |

Recurring-charge detection is left to the agent over
`search_transactions` results (no dedicated tool).

## Settings → MCP access

New page to create / rotate / revoke tokens (name + one-time secret
reveal), with `last_used_at`, a PRO badge, the egress disclaimer, and
copy-paste connection instructions for Claude (web/desktop), Claude Code
and ChatGPT.

## Tests

- Tool behaviour + Pro gating + **cross-user / cross-space isolation**
(`tests/Feature/Mcp/McpToolsTest.php`).
- HTTP auth boundary: 401 without a token, 403 without `mcp:read`, 200
with it (`tests/Feature/Mcp/McpEndpointAuthTest.php`).
- Token CRUD + ownership + free-tier creation
(`tests/Feature/Settings/McpTokenTest.php`).

## Reviewed & adjusted

Ran technical + product reviews and applied the fixes: kept PR1 strictly
read-only (dropped a UI scope selector that promised non-existent
write), routed gating through the `PlanFeature` convention, put token
rotation behind a confirmation, removed a silent on-load clipboard copy,
weighted the egress disclaimer, and fixed a `list_spaces` N+1.

### Known, deliberate tradeoffs
- `get_cashflow` / `get_net_worth` / `spending_by_category` reuse the
existing **user-scoped** analytics controllers/services, so they cover
the whole account rather than a single space (documented in the server
instructions). Per-space analytics is a follow-up.
- Space tools scope by `space_id` gated by membership
(`accessibleSpaces`) — the intended shared-tenant model — rather than a
per-row `user_id` filter.

## Not runnable in this environment
Browser QA of the settings page wasn't run here (no local
`node_modules`); that surface relies on CI build/typecheck/lint and
follows existing settings-page conventions.

---

## Updates since opening

- **Behind a feature flag.** New `App\Features\Mcp` (default off) hides
the whole settings screen — the nav item and every `settings/mcp*` route
(404 when off). Pro-plan gating still happens per request. Enable it
with `php artisan feature:enable "App\Features\Mcp" <email|all|25%>`.
- **Renamed** the user-facing page from "MCP access" to **"AI
Connector"** (nav, title, breadcrumb) so non-technical users understand
it. Route names, files and the feature stay internal.
- **Shared `ProBadge`** component (amber), now used on both the AI
Connector and billing pages instead of an inline badge.
- **Softer data-egress notice** (amber shield icon instead of a red
alert) and plainer copy throughout.
- **Accurate connection instructions (important).** Verified against the
official docs: a personal access token works with **Claude Code** today.
**Claude Desktop** and **ChatGPT** custom connectors authenticate over
**OAuth** and do not accept a static token, so they're now marked
**"coming soon"**. OAuth is the real unlock for those clients and is the
recommended follow-up (it also maps to the deferred write-tools work).
Sources: [Claude custom
connectors](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp),
[ChatGPT developer
mode](https://developers.openai.com/api/docs/guides/developer-mode).
- UI reviewed in a real browser; layout/alignment checked across states
(empty, new-token reveal, token list).
This commit is contained in:
Víctor Falcón 2026-07-17 16:54:15 +02:00 committed by GitHub
parent 782ec2f2e9
commit fb1adfc484
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1725 additions and 86 deletions

View File

@ -6,6 +6,7 @@ enum PlanFeature: string
{ {
case ConnectedAccounts = 'connected_accounts'; case ConnectedAccounts = 'connected_accounts';
case AiSuggestions = 'ai_suggestions'; case AiSuggestions = 'ai_suggestions';
case McpAccess = 'mcp_access';
/** /**
* Whether access to this feature is gated behind a paid (Pro) plan. * Whether access to this feature is gated behind a paid (Pro) plan.
@ -13,7 +14,7 @@ enum PlanFeature: string
public function requiresProPlan(): bool public function requiresProPlan(): bool
{ {
return match ($this) { return match ($this) {
self::ConnectedAccounts, self::AiSuggestions => true, self::ConnectedAccounts, self::AiSuggestions, self::McpAccess => true,
}; };
} }
} }

22
app/Features/Mcp.php Normal file
View File

@ -0,0 +1,22 @@
<?php
namespace App\Features;
use App\Models\User;
/**
* Gates the MCP access settings screen while the feature is being rolled out.
* Toggle per user / everyone with `php artisan feature:enable App\\Features\\Mcp <target>`.
*
* @api
*/
class Mcp
{
/**
* Resolve the feature's initial value.
*/
public function resolve(?User $user): bool
{
return false;
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Features\Mcp;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\StoreMcpTokenRequest;
use Closure;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controllers\HasMiddleware;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Pennant\Feature;
use Laravel\Sanctum\PersonalAccessToken;
class McpTokenController extends Controller implements HasMiddleware
{
/**
* Hide the whole MCP settings surface behind the rollout feature flag.
*
* @return array<int, Closure>
*/
public static function middleware(): array
{
return [
function (Request $request, Closure $next): mixed {
abort_unless(Feature::active(Mcp::class), 404);
return $next($request);
},
];
}
/**
* Show the MCP access page: existing tokens, connection details and the
* one-time plaintext secret when a token was just created.
*/
public function index(Request $request): Response
{
return Inertia::render('settings/mcp', [
'tokens' => $this->tokensFor($request),
'serverUrl' => url('/mcp'),
'subscribeUrl' => route('subscribe'),
'newToken' => $request->session()->get('mcp_token'),
]);
}
/**
* 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).
*/
public function store(StoreMcpTokenRequest $request): RedirectResponse
{
$token = $request->user()->createToken($request->validated('name'), ['mcp:read']);
return to_route('mcp.index')->with('mcp_token', $token->plainTextToken);
}
/**
* Revoke (delete) a token the user owns.
*/
public function destroy(Request $request, PersonalAccessToken $token): RedirectResponse
{
$this->authorizeOwnership($request, $token);
$token->delete();
return to_route('mcp.index');
}
/**
* Rotate a token: revoke it and issue a fresh secret keeping the same name
* and scope, so a leaked token can be replaced without reconfiguring intent.
*/
public function rotate(Request $request, PersonalAccessToken $token): RedirectResponse
{
$this->authorizeOwnership($request, $token);
$fresh = $request->user()->createToken($token->name, $token->abilities);
$token->delete();
return to_route('mcp.index')->with('mcp_token', $fresh->plainTextToken);
}
/**
* Ensure the token belongs to the requesting user before mutating it.
*/
private function authorizeOwnership(Request $request, PersonalAccessToken $token): void
{
abort_unless(
$token->tokenable_id === $request->user()->getKey()
&& $token->tokenable_type === $request->user()->getMorphClass(),
403
);
}
/**
* @return list<array{id: int|string, name: string, scope: string, created_at: ?string, last_used_at: ?string}>
*/
private function tokensFor(Request $request): array
{
return $request->user()->tokens()
->latest()
->get()
->map(fn (PersonalAccessToken $token): array => [
'id' => $token->id,
'name' => $token->name,
'scope' => in_array('mcp:write', $token->abilities ?? [], true) ? 'read_write' : 'read',
'created_at' => $token->created_at?->toIso8601String(),
'last_used_at' => $token->last_used_at?->toIso8601String(),
])
->all();
}
}

View File

@ -5,6 +5,7 @@ namespace App\Http\Middleware;
use App\Enums\BankingConnectionStatus; use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider; use App\Enums\BankingProvider;
use App\Features\CalculateBalancesOnImport; use App\Features\CalculateBalancesOnImport;
use App\Features\Mcp;
use App\Jobs\PurgeResidualEncryptionArtifactsJob; use App\Jobs\PurgeResidualEncryptionArtifactsJob;
use App\Models\BankingConnection; use App\Models\BankingConnection;
use App\Services\CurrencyOptions; use App\Services\CurrencyOptions;
@ -179,16 +180,19 @@ class HandleInertiaRequests extends Middleware
return [ return [
'cashflow' => true, 'cashflow' => true,
'calculateBalancesOnImport' => false, 'calculateBalancesOnImport' => false,
'mcp' => false,
]; ];
} }
$features = Feature::for($user)->values([ $features = Feature::for($user)->values([
CalculateBalancesOnImport::class, CalculateBalancesOnImport::class,
Mcp::class,
]); ]);
return [ return [
'cashflow' => true, 'cashflow' => true,
'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false, 'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false,
'mcp' => $features[Mcp::class] !== false,
]; ];
} }

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Settings;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class StoreMcpTokenRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
];
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Mcp\Servers;
use App\Mcp\Tools\GetCashflow;
use App\Mcp\Tools\GetNetWorth;
use App\Mcp\Tools\ListAccounts;
use App\Mcp\Tools\ListCategories;
use App\Mcp\Tools\ListSpaces;
use App\Mcp\Tools\SearchTransactions;
use App\Mcp\Tools\SpendingByCategory;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
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.
- 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.
- To find recurring charges (subscriptions), use `search_transactions` and group
the results by merchant and cadence yourself.
MARKDOWN)]
class WhisperMoneyServer extends Server
{
/** @var array<int, class-string<Tool>> */
protected array $tools = [
SearchTransactions::class,
SpendingByCategory::class,
GetCashflow::class,
GetNetWorth::class,
ListAccounts::class,
ListCategories::class,
ListSpaces::class,
];
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Mcp\Tools;
use App\Http\Controllers\Api\CashflowAnalyticsController;
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('The full cashflow picture for a date range as JSON, mirroring the app\'s cashflow screen: income/expense/savings/investment summary (current vs previous), the income-vs-expense category flow (sankey), and the monthly trend. Amounts are in minor units (cents). Covers the user\'s whole account.')]
class GetCashflow extends McpTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'from' => $schema->string()->description('Start date, YYYY-MM-DD.')->required(),
'to' => $schema->string()->description('End date, YYYY-MM-DD.')->required(),
];
}
protected function respond(Request $request, User $user): Response
{
$request->validate([
'from' => ['required', 'date'],
'to' => ['required', 'date'],
]);
$controller = app(CashflowAnalyticsController::class);
$range = ['from' => $request->string('from')->toString(), 'to' => $request->string('to')->toString()];
return $this->json([
'summary' => $this->callController($controller, 'summary', $user, $range),
'sankey' => $this->callController($controller, 'sankey', $user, $range),
'trend' => $this->callController($controller, 'trend', $user, $range),
]);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Mcp\Tools;
use App\Http\Controllers\Api\DashboardAnalyticsController;
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('Net worth for a date range as JSON: the current total vs the previous period, plus the per-account balance evolution over time. Set granularity to "monthly" (default) or "daily". Amounts are in minor units (cents). Covers the user\'s whole account.')]
class GetNetWorth extends McpTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'from' => $schema->string()->description('Start date, YYYY-MM-DD.')->required(),
'to' => $schema->string()->description('End date, YYYY-MM-DD.')->required(),
'granularity' => $schema->string()->enum(['monthly', 'daily'])->description('Evolution granularity (default monthly).'),
];
}
protected function respond(Request $request, User $user): Response
{
$request->validate([
'from' => ['required', 'date'],
'to' => ['required', 'date'],
'granularity' => ['sometimes', 'in:monthly,daily'],
]);
$controller = app(DashboardAnalyticsController::class);
$range = ['from' => $request->string('from')->toString(), 'to' => $request->string('to')->toString()];
$daily = $request->string('granularity')->toString() === 'daily';
return $this->json([
'granularity' => $daily ? 'daily' : 'monthly',
'current' => $this->callController($controller, 'netWorth', $user, $range),
'evolution' => $this->callController(
$controller,
$daily ? 'netWorthDailyEvolution' : 'netWorthEvolution',
$user,
$range,
),
]);
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Account;
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 accounts in a space, including whether each is connected to a bank/provider (connected accounts are read-only).')]
class ListAccounts 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);
$accounts = Account::query()
->forSpace($space)
->with('bank:id,name')
->orderBy('name')
->get()
->map(fn (Account $account): array => [
'id' => $account->id,
'name' => $account->name,
'type' => $account->type->value,
'currency' => $account->currency_code,
'bank' => $account->bank?->name,
'is_connected' => $account->isConnected(),
]);
return $this->json([
'space_id' => $space->id,
'accounts' => $accounts,
]);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Category;
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 categories in a space. Categories form a tree via parent_id; use the ids to filter search_transactions or spending_by_category.')]
class ListCategories 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);
$categories = Category::query()
->forSpace($space)
->orderBy('name')
->get()
->map(fn (Category $category): array => [
'id' => $category->id,
'name' => $category->name,
'type' => $category->type->value,
'cashflow_direction' => $category->cashflow_direction->value,
'parent_id' => $category->parent_id,
]);
return $this->json([
'space_id' => $space->id,
'categories' => $categories,
]);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Space;
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 spaces the user can access (personal and shared). Pass a space id to the other tools\' `space` argument to query a specific one.')]
class ListSpaces extends McpTool
{
/**
* @return array<string, JsonSchema>
*/
public function schema(JsonSchema $schema): array
{
return [];
}
protected function respond(Request $request, User $user): Response
{
$spaces = $user->accessibleSpaces()
->map(fn (Space $space): array => [
'id' => $space->id,
'name' => $space->name,
'personal' => $space->personal,
'is_current' => $space->id === $user->current_space_id,
]);
return $this->json(['spaces' => $spaces]);
}
}

104
app/Mcp/Tools/McpTool.php Normal file
View File

@ -0,0 +1,104 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\PlanFeature;
use App\Models\Space;
use App\Models\User;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
/**
* Base for every Whisper Money read tool. Enforces the Pro-plan gate on each
* call (a lapsed subscription stops working without revoking the token) and
* provides the shared space-resolution and JSON-encoding helpers.
*/
abstract class McpTool extends Tool
{
/**
* Expose snake_case tool names (search_transactions, list_spaces, ) instead
* of the framework default kebab-case, matching the documented tool catalog.
*/
public function name(): string
{
return Str::snake(class_basename($this));
}
public function handle(Request $request): Response
{
$user = $request->user();
if (! $user instanceof User) {
return Response::error('Authentication required.');
}
if (! $user->canUseFeature(PlanFeature::McpAccess)) {
return Response::error(
'A paid (Pro) plan is required to use the Whisper Money MCP. Upgrade your account at '.route('subscribe')
);
}
return $this->respond($request, $user);
}
abstract protected function respond(Request $request, User $user): Response;
/**
* Encode structured data as a JSON text response the agent can parse.
*/
protected function json(mixed $data): Response
{
return Response::text((string) json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
/**
* Reuse an existing analytics controller by invoking one of its actions with
* a synthesized GET request bound to the MCP user, returning its JSON body.
* Keeps the (user-scoped) dashboard maths in exactly one place.
*
* ponytail: couples to the controllers returning a JsonResponse; acceptable
* while they're stable. Extract the orchestration into a shared service if a
* controller stops returning JSON or a third tool needs the same maths.
*
* @param array<string, mixed> $query
* @return array<array-key, mixed>
*/
protected function callController(object $controller, string $method, User $user, array $query): array
{
$httpRequest = \Illuminate\Http\Request::create('/', 'GET', $query);
$httpRequest->setUserResolver(fn (): User => $user);
return $controller->{$method}($httpRequest)->getData(true);
}
/**
* The space a tool operates on: the optional `space` argument (validated
* against the spaces the user can access) or the user's personal space.
*
* Scoping is by `space_id` only, gated by membership (`accessibleSpaces`): a
* space is a shared tenant, so a member is meant to see every row in it. The
* security boundary is the membership check here, not a per-row `user_id`
* filter.
*/
protected function resolveSpace(Request $request, User $user): Space
{
$spaceId = $request->string('space')->toString();
if ($spaceId === '') {
return $user->personalSpace ?? $user->activeSpace();
}
$space = $user->accessibleSpaces()->firstWhere('id', $spaceId);
if ($space === null) {
throw ValidationException::withMessages([
'space' => "You do not have access to a space with id {$spaceId}. Call list_spaces to see valid ids.",
]);
}
return $space;
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Transaction;
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('Search and filter the user\'s transactions by text, category, account, date range and amount. Amounts are integers in minor units (cents). Use this to analyse spending or to find recurring charges by grouping results by merchant.')]
class SearchTransactions extends McpTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'query' => $schema->string()->description('Free text matched against description, creditor and debtor names.'),
'account_id' => $schema->string()->description('Restrict to a single account id.'),
'category_id' => $schema->string()->description('Restrict to a single category id.'),
'from' => $schema->string()->description('Earliest transaction date, YYYY-MM-DD.'),
'to' => $schema->string()->description('Latest transaction date, YYYY-MM-DD.'),
'min_amount' => $schema->integer()->description('Minimum signed amount in minor units (cents).'),
'max_amount' => $schema->integer()->description('Maximum signed amount in minor units (cents).'),
'limit' => $schema->integer()->min(1)->max(200)->description('Max rows to return (default 50).'),
'space' => $schema->string()->description('Space id to query. Defaults to the personal space.'),
];
}
protected function respond(Request $request, User $user): Response
{
$request->validate([
'from' => ['sometimes', 'date'],
'to' => ['sometimes', 'date'],
'limit' => ['sometimes', 'integer', 'min:1', 'max:200'],
]);
$space = $this->resolveSpace($request, $user);
$transactions = Transaction::query()
->forSpace($space)
->with(['account:id,name', 'category:id,name,type'])
->when($request->string('query')->toString() !== '', function ($query) use ($request): void {
$term = '%'.$request->string('query')->toString().'%';
$query->where(function ($q) use ($term): void {
$q->where('description', 'like', $term)
->orWhere('creditor_name', 'like', $term)
->orWhere('debtor_name', 'like', $term);
});
})
->when($request->string('account_id')->toString() !== '', fn ($query) => $query->where('account_id', $request->string('account_id')->toString()))
->when($request->string('category_id')->toString() !== '', fn ($query) => $query->where('category_id', $request->string('category_id')->toString()))
->when($request->string('from')->toString() !== '', fn ($query) => $query->whereDate('transaction_date', '>=', $request->string('from')->toString()))
->when($request->string('to')->toString() !== '', fn ($query) => $query->whereDate('transaction_date', '<=', $request->string('to')->toString()))
->when($request->has('min_amount'), fn ($query) => $query->where('amount', '>=', $request->integer('min_amount')))
->when($request->has('max_amount'), fn ($query) => $query->where('amount', '<=', $request->integer('max_amount')))
->orderByDesc('transaction_date')
->limit($request->integer('limit', 50))
->get()
->map(fn (Transaction $transaction): array => [
'id' => $transaction->id,
'date' => $transaction->transaction_date->toDateString(),
'description' => $transaction->description,
'amount' => $transaction->amount,
'currency' => $transaction->currency_code,
'category' => $transaction->category?->name,
'category_id' => $transaction->category_id,
'account' => $transaction->account?->name,
'account_id' => $transaction->account_id,
'source' => $transaction->source->value,
'creditor_name' => $transaction->creditor_name,
'debtor_name' => $transaction->debtor_name,
]);
return $this->json([
'space_id' => $space->id,
'count' => $transactions->count(),
'transactions' => $transactions,
]);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Mcp\Tools;
use App\Models\User;
use App\Services\CategorySpendingService;
use Carbon\Carbon;
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('Expense spending rolled up by category for a date range. Without parent_category_id, root categories are returned; pass one to drill into its children. Amounts are in minor units (cents). Covers the user\'s whole account.')]
class SpendingByCategory extends McpTool
{
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'from' => $schema->string()->description('Start date, YYYY-MM-DD.')->required(),
'to' => $schema->string()->description('End date, YYYY-MM-DD.')->required(),
'parent_category_id' => $schema->string()->description('Drill into a parent category\'s children.'),
];
}
protected function respond(Request $request, User $user): Response
{
$request->validate([
'from' => ['required', 'date'],
'to' => ['required', 'date'],
]);
$spending = app(CategorySpendingService::class)->forPeriod(
$user->id,
Carbon::parse($request->string('from')->toString()),
Carbon::parse($request->string('to')->toString()),
$request->string('parent_category_id')->toString() ?: null,
);
return $this->json(['categories' => $spending->values()]);
}
}

View File

@ -26,6 +26,7 @@ use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Billable; use Laravel\Cashier\Billable;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Pennant\Concerns\HasFeatures; use Laravel\Pennant\Concerns\HasFeatures;
use Laravel\Sanctum\HasApiTokens;
/** /**
* @property ?Carbon $last_logged_in_at * @property ?Carbon $last_logged_in_at
@ -36,7 +37,7 @@ use Laravel\Pennant\Concerns\HasFeatures;
class User extends Authenticatable implements HasLocalePreference, MustVerifyEmail class User extends Authenticatable implements HasLocalePreference, MustVerifyEmail
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use Billable, HasFactory, HasFeatures, HasUuids, Notifiable, SoftDeletes, TwoFactorAuthenticatable; use Billable, HasApiTokens, HasFactory, HasFeatures, HasUuids, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.

View File

@ -16,6 +16,8 @@ use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Queue\MaxAttemptsExceededException; use Illuminate\Queue\MaxAttemptsExceededException;
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
use Sentry\Laravel\Integration; use Sentry\Laravel\Integration;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
@ -54,6 +56,8 @@ return Application::configure(basePath: dirname(__DIR__))
'subscribed' => EnsureUserIsSubscribed::class, 'subscribed' => EnsureUserIsSubscribed::class,
'onboarded' => EnsureOnboardingComplete::class, 'onboarded' => EnsureOnboardingComplete::class,
'block-demo' => BlockDemoAccountActions::class, 'block-demo' => BlockDemoAccountActions::class,
'abilities' => CheckAbilities::class,
'ability' => CheckForAnyAbility::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {

View File

@ -19,7 +19,9 @@
"laravel/cashier": "^16.1", "laravel/cashier": "^16.1",
"laravel/fortify": "^1.30", "laravel/fortify": "^1.30",
"laravel/framework": "^13.0", "laravel/framework": "^13.0",
"laravel/mcp": "^0.6.7",
"laravel/pennant": "^1.18", "laravel/pennant": "^1.18",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.9", "laravel/wayfinder": "^0.1.9",
"resend/resend-php": "^1.1", "resend/resend-php": "^1.1",

211
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "a740337bb85751f3ad1ead3774dd1e02", "content-hash": "196f117744cd19cbd40b8b0fb6d2cdc2",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@ -2135,6 +2135,79 @@
}, },
"time": "2026-03-18T17:10:25+00:00" "time": "2026-03-18T17:10:25+00:00"
}, },
{
"name": "laravel/mcp",
"version": "v0.6.7",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
"reference": "c3775e57b95d7eadb580d543689d9971ec8721f2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/mcp/zipball/c3775e57b95d7eadb580d543689d9971ec8721f2",
"reference": "c3775e57b95d7eadb580d543689d9971ec8721f2",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
"illuminate/container": "^11.45.3|^12.41.1|^13.0",
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
"illuminate/http": "^11.45.3|^12.41.1|^13.0",
"illuminate/json-schema": "^12.41.1|^13.0",
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
"illuminate/validation": "^11.45.3|^12.41.1|^13.0",
"php": "^8.2"
},
"require-dev": {
"laravel/pint": "^1.20",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"pestphp/pest": "^3.8.5|^4.3.2",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.2.4"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
},
"providers": [
"Laravel\\Mcp\\Server\\McpServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Mcp\\": "src/",
"Laravel\\Mcp\\Server\\": "src/Server/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Rapidly build MCP servers for your Laravel applications.",
"homepage": "https://github.com/laravel/mcp",
"keywords": [
"laravel",
"mcp"
],
"support": {
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
"time": "2026-04-15T08:30:42+00:00"
},
{ {
"name": "laravel/pennant", "name": "laravel/pennant",
"version": "v1.22.0", "version": "v1.22.0",
@ -2270,6 +2343,69 @@
}, },
"time": "2026-03-17T13:45:17+00:00" "time": "2026-03-17T13:45:17+00:00"
}, },
{
"name": "laravel/sanctum",
"version": "v4.3.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/sanctum.git",
"reference": "2a9bccc18e9907808e0018dd15fa643937886b1e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e",
"reference": "2a9bccc18e9907808e0018dd15fa643937886b1e",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^11.0|^12.0|^13.0",
"illuminate/contracts": "^11.0|^12.0|^13.0",
"illuminate/database": "^11.0|^12.0|^13.0",
"illuminate/support": "^11.0|^12.0|^13.0",
"php": "^8.2",
"symfony/console": "^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "^1.6",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sanctum\\SanctumServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Sanctum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
"keywords": [
"auth",
"laravel",
"sanctum"
],
"support": {
"issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum"
},
"time": "2026-04-30T11:46:25+00:00"
},
{ {
"name": "laravel/serializable-closure", "name": "laravel/serializable-closure",
"version": "v2.0.10", "version": "v2.0.10",
@ -10390,79 +10526,6 @@
}, },
"time": "2026-03-17T16:42:14+00:00" "time": "2026-03-17T16:42:14+00:00"
}, },
{
"name": "laravel/mcp",
"version": "v0.6.3",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
"reference": "8a2c97ec1184e16029080e3f6172a7ca73de4df9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/mcp/zipball/8a2c97ec1184e16029080e3f6172a7ca73de4df9",
"reference": "8a2c97ec1184e16029080e3f6172a7ca73de4df9",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
"illuminate/container": "^11.45.3|^12.41.1|^13.0",
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
"illuminate/http": "^11.45.3|^12.41.1|^13.0",
"illuminate/json-schema": "^12.41.1|^13.0",
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
"illuminate/validation": "^11.45.3|^12.41.1|^13.0",
"php": "^8.2"
},
"require-dev": {
"laravel/pint": "^1.20",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"pestphp/pest": "^3.8.5|^4.3.2",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.2.4"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
},
"providers": [
"Laravel\\Mcp\\Server\\McpServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Mcp\\": "src/",
"Laravel\\Mcp\\Server\\": "src/Server/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Rapidly build MCP servers for your Laravel applications.",
"homepage": "https://github.com/laravel/mcp",
"keywords": [
"laravel",
"mcp"
],
"support": {
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
"time": "2026-03-12T12:46:43+00:00"
},
{ {
"name": "laravel/pail", "name": "laravel/pail",
"version": "v1.2.6", "version": "v1.2.6",

87
config/sanctum.php Normal file
View File

@ -0,0 +1,87 @@
<?php
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => AuthenticateSession::class,
'encrypt_cookies' => EncryptCookies::class,
'validate_csrf_token' => ValidateCsrfToken::class,
],
];

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->uuidMorphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

View File

@ -2220,5 +2220,56 @@
"See your categorised transactions": "Ver tus transacciones categorizadas", "See your categorised transactions": "Ver tus transacciones categorizadas",
"Private by design, always": "Privado por diseño, siempre", "Private by design, always": "Privado por diseño, siempre",
"Turning on AI never changes who owns your data — you do. We only use it to help you understand your own finances, and you can revoke this consent at any time from your settings.": "Activar la IA nunca cambia quién es el dueño de tus datos: lo eres tú. Solo la usamos para ayudarte a entender tus propias finanzas, y puedes revocar este consentimiento cuando quieras desde tus ajustes.", "Turning on AI never changes who owns your data — you do. We only use it to help you understand your own finances, and you can revoke this consent at any time from your settings.": "Activar la IA nunca cambia quién es el dueño de tus datos: lo eres tú. Solo la usamos para ayudarte a entender tus propias finanzas, y puedes revocar este consentimiento cuando quieras desde tus ajustes.",
"If you have any questions, **just reply to this email**. We personally read every message.": "Si tienes cualquier duda, **responde a este correo**. Leemos personalmente cada mensaje." "If you have any questions, **just reply to this email**. We personally read every message.": "Si tienes cualquier duda, **responde a este correo**. Leemos personalmente cada mensaje.",
"Token copied to clipboard": "Token copiado al portapapeles",
"Copied to clipboard": "Copiado al portapapeles",
"MCP access": "Acceso MCP",
"Connect Whisper Money to your AI assistant (Claude, ChatGPT) to analyse your finances.": "Conecta Whisper Money con tu asistente de IA (Claude, ChatGPT) para analizar tus finanzas.",
"PRO": "PRO",
"This is a Pro feature": "Esta es una función Pro",
"You can create a token now, but MCP requests only work on a paid plan.": "Puedes crear un token ahora, pero las peticiones del MCP solo funcionan con un plan de pago.",
"Upgrade your account": "Mejora tu cuenta",
"Your data leaves Whisper Money": "Tus datos salen de Whisper Money",
"The data you query is sent to whichever AI client you connect. Whisper Money cannot control what that client does with it. By connecting, you accept this. Revoke a token at any time to cut off access.": "Los datos que consultes se envían al cliente de IA que conectes. Whisper Money no puede controlar qué hace ese cliente con ellos. Al conectarlo, lo aceptas. Revoca un token en cualquier momento para cortar el acceso.",
"Copy your new token now": "Copia tu nuevo token ahora",
"Copy": "Copiar",
"Create a token": "Crear un token",
"Read-only tokens can only analyse data. Read & write tokens can also create and edit data.": "Los tokens de solo lectura solo pueden analizar datos. Los tokens de lectura y escritura también pueden crear y editar datos.",
"e.g. Claude Desktop": "p. ej. Claude Desktop",
"Scope": "Alcance",
"Read only": "Solo lectura",
"Read & write": "Lectura y escritura",
"Create token": "Crear token",
"Your tokens": "Tus tokens",
"Rotate a token to replace a leaked secret, or revoke it to cut off access.": "Rota un token para reemplazar un secreto filtrado, o revócalo para cortar el acceso.",
"You have no tokens yet.": "Todavía no tienes ningún token.",
"Created": "Creado",
"Last used": "Último uso",
"Rotate": "Rotar",
"Revoke": "Revocar",
"Revoke this token?": "¿Revocar este token?",
"Any AI client using this token will immediately lose access. This cannot be undone.": "Cualquier cliente de IA que use este token perderá el acceso inmediatamente. Esto no se puede deshacer.",
"How to connect": "Cómo conectar",
"Your MCP server URL is below. Use it with a token you created above.": "La URL de tu servidor MCP está abajo. Úsala con un token que hayas creado arriba.",
"Claude (web & desktop)": "Claude (web y escritorio)",
"Settings → Connectors → Add custom connector. Paste the URL above, choose \"API key\" authentication and paste your token.": "Ajustes → Conectores → Añadir conector personalizado. Pega la URL de arriba, elige la autenticación por «clave de API» y pega tu token.",
"Claude Code": "Claude Code",
"ChatGPT": "ChatGPT",
"Enable developer mode, then Settings → Connectors → Create. Paste the URL above and add an \"Authorization: Bearer <token>\" header.": "Activa el modo desarrollador, luego Ajustes → Conectores → Crear. Pega la URL de arriba y añade una cabecera «Authorization: Bearer <token>».",
"This is the only time it is shown. Store it somewhere safe — you won't be able to see it again.": "Es la única vez que se muestra. Guárdalo en un lugar seguro: no podrás verlo de nuevo.",
"Tokens are read-only: they can analyse your data but never change it.": "Los tokens son de solo lectura: pueden analizar tus datos pero nunca modificarlos.",
"Rotate this token?": "¿Rotar este token?",
"Rotating replaces the secret. Any AI client using the current token will stop working until you reconnect it with the new one.": "Rotar reemplaza el secreto. Cualquier cliente de IA que use el token actual dejará de funcionar hasta que lo reconectes con el nuevo.",
"AI Connector": "Conector de IA",
"You can create a token now, but it only works on a paid plan.": "Puedes crear un token ahora, pero solo funciona con un plan de pago.",
"Anything you ask about is sent to the AI app you connect, and we cannot control what it does with your data. Connect one only if you are comfortable with that. You can revoke a token any time to cut off access.": "Todo lo que consultes se envía a la app de IA que conectes, y no podemos controlar qué hace con tus datos. Conéctala solo si te parece bien. Puedes revocar un token cuando quieras para cortar el acceso.",
"This is the only time you will see it, so copy it somewhere safe now.": "Es la única vez que lo verás, así que cópialo ahora en un lugar seguro.",
"Tokens can read and analyse your data, but never change it.": "Los tokens pueden leer y analizar tus datos, pero nunca modificarlos.",
"Rotate a token if it leaks, or revoke it to cut off access.": "Rota un token si se filtra, o revócalo para cortar el acceso.",
"Rotating gives you a new secret and cancels the old one. Anything using the current token stops working until you reconnect it with the new secret.": "Al rotar obtienes un secreto nuevo y se cancela el anterior. Lo que use el token actual dejará de funcionar hasta que lo reconectes con el nuevo secreto.",
"Anything using this token loses access right away, and you cannot undo it.": "Cualquier cosa que use este token pierde el acceso al instante, y no se puede deshacer.",
"Here is your connection URL. Use it with a token you created above.": "Esta es tu URL de conexión. Úsala con un token que hayas creado arriba.",
"Run this, using one of your tokens in place of <token>.": "Ejecuta esto, usando uno de tus tokens en lugar de <token>.",
"Claude Desktop & ChatGPT": "Claude Desktop y ChatGPT",
"Coming soon. These apps sign in with OAuth, which we are still building, so a token will not work with them yet. Use Claude Code for now.": "Muy pronto. Estas apps inician sesión con OAuth, que todavía estamos construyendo, así que de momento un token no funciona con ellas. Usa Claude Code por ahora."
} }

View File

@ -23,6 +23,12 @@ parameters:
path: app/Http/Controllers/* path: app/Http/Controllers/*
- identifier: public.method.unused - identifier: public.method.unused
path: app/Http/Controllers/**/* path: app/Http/Controllers/**/*
# MCP servers and tools are entry points invoked by laravel/mcp via
# reflection, not called from PHP code.
- identifier: public.method.unused
path: app/Mcp/*
- identifier: public.method.unused
path: app/Mcp/**/*
- identifier: public.method.unused - identifier: public.method.unused
path: app/Http/Middleware/* path: app/Http/Middleware/*
- identifier: public.property.unused - identifier: public.property.unused

View File

@ -0,0 +1,20 @@
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { __ } from '@/utils/i18n';
/**
* The "PRO" badge used to mark paid-plan features across settings.
*/
export function ProBadge({ className }: { className?: string }) {
return (
<Badge
variant="secondary"
className={cn(
'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
className,
)}
>
{__('PRO')}
</Badge>
);
}

View File

@ -2,6 +2,7 @@ import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/
import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController'; import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController'; import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController';
import { index as labelsIndex } from '@/actions/App/Http/Controllers/Settings/LabelController'; import { index as labelsIndex } from '@/actions/App/Http/Controllers/Settings/LabelController';
import { index as mcpIndex } from '@/actions/App/Http/Controllers/Settings/McpTokenController';
import Heading from '@/components/heading'; import Heading from '@/components/heading';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@ -34,6 +35,7 @@ import { type PropsWithChildren } from 'react';
const getNavItems = ( const getNavItems = (
subscriptionsEnabled: boolean, subscriptionsEnabled: boolean,
isDemoAccount: boolean, isDemoAccount: boolean,
mcpEnabled: boolean,
): (NavItem | NavSectionHeader | NavDivider)[] => [ ): (NavItem | NavSectionHeader | NavDivider)[] => [
{ {
type: 'nav-item' as const, type: 'nav-item' as const,
@ -65,6 +67,16 @@ const getNavItems = (
href: labelsIndex(), href: labelsIndex(),
icon: null, icon: null,
}, },
...(mcpEnabled
? [
{
type: 'nav-item' as const,
title: 'AI Connector',
href: mcpIndex(),
icon: null,
},
]
: []),
{ type: 'divider' }, { type: 'divider' },
{ {
type: 'section-header', type: 'section-header',
@ -172,7 +184,8 @@ function renderMobileNavGroups(
} }
export default function SettingsLayout({ children }: PropsWithChildren) { export default function SettingsLayout({ children }: PropsWithChildren) {
const { subscriptionsEnabled, auth } = usePage<SharedData>().props; const { subscriptionsEnabled, auth, features } =
usePage<SharedData>().props;
const isDemoAccount = auth?.isDemoAccount ?? false; const isDemoAccount = auth?.isDemoAccount ?? false;
// When server-side rendering, we only render the layout on the client... // When server-side rendering, we only render the layout on the client...
@ -181,7 +194,11 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
} }
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
const sidebarNavItems = getNavItems(subscriptionsEnabled, isDemoAccount); const sidebarNavItems = getNavItems(
subscriptionsEnabled,
isDemoAccount,
features.mcp,
);
const activeNavItem = sidebarNavItems.find( const activeNavItem = sidebarNavItems.find(
(item): item is NavItem => (item): item is NavItem =>

View File

@ -1,6 +1,6 @@
import HeadingSmall from '@/components/heading-small'; import HeadingSmall from '@/components/heading-small';
import { ProBadge } from '@/components/pro-badge';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import AppLayout from '@/layouts/app-layout'; import AppLayout from '@/layouts/app-layout';
@ -427,12 +427,7 @@ function AiConsentSection({
<h3 className="text-base font-medium"> <h3 className="text-base font-medium">
{__('AI Categorization')} {__('AI Categorization')}
</h3> </h3>
<Badge <ProBadge />
variant="secondary"
className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
PRO
</Badge>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{__( {__(

View File

@ -0,0 +1,409 @@
import {
destroy,
index as mcpIndex,
rotate,
store,
} from '@/actions/App/Http/Controllers/Settings/McpTokenController';
import HeadingSmall from '@/components/heading-small';
import { ProBadge } from '@/components/pro-badge';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useClipboard } from '@/hooks/use-clipboard';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { type BreadcrumbItem, type SharedData } from '@/types';
import { __ } from '@/utils/i18n';
import { Head, Link, router, useForm, usePage } from '@inertiajs/react';
import { Copy, KeyRound, RefreshCw, ShieldAlert, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
interface TokenRow {
id: number;
name: string;
scope: 'read' | 'read_write';
created_at: string | null;
last_used_at: string | null;
}
interface McpPageProps {
tokens: TokenRow[];
serverUrl: string;
subscribeUrl: string;
newToken: string | null;
}
const breadcrumbs: BreadcrumbItem[] = [
{ title: 'AI Connector', href: mcpIndex().url },
];
function formatDate(value: string | null): string {
return value ? new Date(value).toLocaleString() : __('Never');
}
export default function Mcp() {
const { tokens, serverUrl, subscribeUrl, newToken, auth } = usePage<
SharedData & McpPageProps
>().props;
const [, copy] = useClipboard();
const form = useForm({ name: '' });
function createToken(event: React.FormEvent) {
event.preventDefault();
form.post(store().url, {
preserveScroll: true,
onSuccess: () => form.setData('name', ''),
});
}
function copyValue(value: string) {
copy(value).then((ok) => {
if (ok) {
toast.success(__('Copied to clipboard'));
}
});
}
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title={__('AI Connector')} />
<SettingsLayout>
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<HeadingSmall
title={__('AI Connector')}
description={__(
'Connect Whisper Money to your AI assistant (Claude, ChatGPT) to analyse your finances.',
)}
/>
<ProBadge className="mt-1 shrink-0" />
</div>
{!auth.hasProPlan && (
<Alert>
<AlertTitle>
{__('This is a Pro feature')}
</AlertTitle>
<AlertDescription>
{__(
'You can create a token now, but it only works on a paid plan.',
)}{' '}
<Link
href={subscribeUrl}
className="font-medium underline"
>
{__('Upgrade your account')}
</Link>
</AlertDescription>
</Alert>
)}
<Alert>
<ShieldAlert className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<AlertTitle>
{__('Your data leaves Whisper Money')}
</AlertTitle>
<AlertDescription>
{__(
'Anything you ask about is sent to the AI app you connect, and we cannot control what it does with your data. Connect one only if you are comfortable with that. You can revoke a token any time to cut off access.',
)}
</AlertDescription>
</Alert>
{newToken && (
<Alert>
<KeyRound className="h-4 w-4" />
<AlertTitle>
{__('Copy your new token now')}
</AlertTitle>
<AlertDescription>
<p className="mb-2">
{__(
'This is the only time you will see it, so copy it somewhere safe now.',
)}
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded-md bg-muted px-3 py-2 text-sm">
{newToken}
</code>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copyValue(newToken)}
aria-label={__('Copy')}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</AlertDescription>
</Alert>
)}
{/* Create token */}
<Card>
<CardHeader>
<CardTitle>{__('Create a token')}</CardTitle>
<CardDescription>
{__(
'Tokens can read and analyse your data, but never change it.',
)}
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={createToken}
className="flex flex-col gap-4 sm:flex-row sm:items-end"
>
<div className="flex-1 space-y-2">
<Label htmlFor="token-name">
{__('Name')}
</Label>
<Input
id="token-name"
value={form.data.name}
onChange={(e) =>
form.setData('name', e.target.value)
}
placeholder={__('e.g. Claude Desktop')}
required
/>
</div>
<Button
type="submit"
disabled={form.processing}
>
{__('Create token')}
</Button>
</form>
{form.errors.name && (
<p className="mt-2 text-sm text-destructive">
{form.errors.name}
</p>
)}
</CardContent>
</Card>
{/* Token list */}
<Card>
<CardHeader>
<CardTitle>{__('Your tokens')}</CardTitle>
<CardDescription>
{__(
'Rotate a token if it leaks, or revoke it to cut off access.',
)}
</CardDescription>
</CardHeader>
<CardContent>
{tokens.length === 0 ? (
<p className="text-sm text-muted-foreground">
{__('You have no tokens yet.')}
</p>
) : (
<ul className="divide-y">
{tokens.map((token) => (
<li
key={token.id}
className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium">
{token.name}
</span>
<Badge variant="outline">
{token.scope ===
'read_write'
? __('Read & write')
: __('Read only')}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{__('Created')}:{' '}
{formatDate(
token.created_at,
)}{' '}
· {__('Last used')}:{' '}
{formatDate(
token.last_used_at,
)}
</p>
</div>
<div className="flex items-center gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
>
<RefreshCw className="h-4 w-4" />
{__('Rotate')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{__(
'Rotate this token?',
)}
</AlertDialogTitle>
<AlertDialogDescription>
{__(
'Rotating gives you a new secret and cancels the old one. Anything using the current token stops working until you reconnect it with the new secret.',
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{__('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
router.post(
rotate(
token.id,
).url,
{},
{
preserveScroll: true,
},
)
}
>
{__('Rotate')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive"
>
<Trash2 className="h-4 w-4" />
{__('Revoke')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{__(
'Revoke this token?',
)}
</AlertDialogTitle>
<AlertDialogDescription>
{__(
'Anything using this token loses access right away, and you cannot undo it.',
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{__('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
router.delete(
destroy(
token.id,
).url,
{
preserveScroll: true,
},
)
}
>
{__('Revoke')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{/* Connection instructions */}
<Card>
<CardHeader>
<CardTitle>{__('How to connect')}</CardTitle>
<CardDescription>
{__(
'Here is your connection URL. Use it with a token you created above.',
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded-md bg-muted px-3 py-2 text-sm">
{serverUrl}
</code>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copyValue(serverUrl)}
aria-label={__('Copy')}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<div className="space-y-1">
<h3 className="text-sm font-medium">
{__('Claude Code')}
</h3>
<p className="text-sm text-muted-foreground">
{__(
'Run this, using one of your tokens in place of <token>.',
)}
</p>
<code className="block overflow-x-auto rounded-md bg-muted px-3 py-2 text-sm">
{`claude mcp add --transport http whisper-money ${serverUrl} --header "Authorization: Bearer <token>"`}
</code>
</div>
<div className="space-y-1">
<h3 className="text-sm font-medium">
{__('Claude Desktop & ChatGPT')}
</h3>
<p className="text-sm text-muted-foreground">
{__(
'Coming soon. These apps sign in with OAuth, which we are still building, so a token will not work with them yet. Use Claude Code for now.',
)}
</p>
</div>
</CardContent>
</Card>
</div>
</SettingsLayout>
</AppLayout>
);
}

View File

@ -42,6 +42,7 @@ export interface NavDivider {
export interface Features { export interface Features {
cashflow: boolean; cashflow: boolean;
calculateBalancesOnImport: boolean; calculateBalancesOnImport: boolean;
mcp: boolean;
} }
export interface ExpiredBankingConnectionNotification { export interface ExpiredBankingConnectionNotification {

19
routes/ai.php Normal file
View File

@ -0,0 +1,19 @@
<?php
use App\Mcp\Servers\WhisperMoneyServer;
use Laravel\Mcp\Facades\Mcp;
/*
|--------------------------------------------------------------------------
| MCP Routes
|--------------------------------------------------------------------------
|
| Remote (streamable HTTP) MCP server. Authenticated with Sanctum personal
| access tokens carrying an `mcp:read` ability, throttled per user. The
| Pro-plan gate is enforced inside each tool so a lapsed subscription stops
| working without the user having to revoke their token.
|
*/
Mcp::web('/mcp', WhisperMoneyServer::class)
->middleware(['auth:sanctum', 'abilities:mcp:read', 'throttle:60,1']);

View File

@ -8,6 +8,7 @@ use App\Http\Controllers\Settings\BankController;
use App\Http\Controllers\Settings\CategoryController; use App\Http\Controllers\Settings\CategoryController;
use App\Http\Controllers\Settings\ChartColorSchemeController; use App\Http\Controllers\Settings\ChartColorSchemeController;
use App\Http\Controllers\Settings\LabelController; use App\Http\Controllers\Settings\LabelController;
use App\Http\Controllers\Settings\McpTokenController;
use App\Http\Controllers\Settings\NetWorthChartLoanPreferenceController; use App\Http\Controllers\Settings\NetWorthChartLoanPreferenceController;
use App\Http\Controllers\Settings\NetWorthChartRealEstatePreferenceController; use App\Http\Controllers\Settings\NetWorthChartRealEstatePreferenceController;
use App\Http\Controllers\Settings\NotificationPreferenceController; use App\Http\Controllers\Settings\NotificationPreferenceController;
@ -58,6 +59,17 @@ Route::middleware('auth')->group(function () {
Route::patch('settings/labels/{label}', [LabelController::class, 'update'])->name('labels.update'); Route::patch('settings/labels/{label}', [LabelController::class, 'update'])->name('labels.update');
Route::delete('settings/labels/{label}', [LabelController::class, 'destroy'])->name('labels.destroy'); Route::delete('settings/labels/{label}', [LabelController::class, 'destroy'])->name('labels.destroy');
Route::get('settings/mcp', [McpTokenController::class, 'index'])->name('mcp.index');
Route::post('settings/mcp/tokens', [McpTokenController::class, 'store'])
->middleware('block-demo')
->name('mcp.tokens.store');
Route::post('settings/mcp/tokens/{token}/rotate', [McpTokenController::class, 'rotate'])
->middleware('block-demo')
->name('mcp.tokens.rotate');
Route::delete('settings/mcp/tokens/{token}', [McpTokenController::class, 'destroy'])
->middleware('block-demo')
->name('mcp.tokens.destroy');
Route::redirect('settings/budgets', '/budgets')->name('budgets.settings'); Route::redirect('settings/budgets', '/budgets')->name('budgets.settings');
Route::get('settings/automation-rules', [AutomationRuleController::class, 'index'])->name('automation-rules.index'); Route::get('settings/automation-rules', [AutomationRuleController::class, 'index'])->name('automation-rules.index');

View File

@ -99,6 +99,7 @@ test('shared feature flags do not include coinbase flag', function () {
expect($props['features'])->toBe([ expect($props['features'])->toBe([
'cashflow' => true, 'cashflow' => true,
'calculateBalancesOnImport' => false, 'calculateBalancesOnImport' => false,
'mcp' => false,
]); ]);
}); });

View File

@ -0,0 +1,32 @@
<?php
use App\Models\User;
use function Pest\Laravel\postJson;
use function Pest\Laravel\withHeaders;
$rpc = ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list'];
it('rejects the MCP endpoint without a token', function () use ($rpc) {
postJson('/mcp', $rpc)->assertUnauthorized();
});
it('rejects a token without the mcp:read ability', function () use ($rpc) {
$user = User::factory()->create();
$plain = $user->createToken('not-mcp', ['other'])->plainTextToken;
withHeaders(['Authorization' => "Bearer {$plain}"])
->postJson('/mcp', $rpc)
->assertForbidden();
});
it('accepts a token carrying the mcp:read ability', function () use ($rpc) {
$user = User::factory()->create();
$plain = $user->createToken('mcp', ['mcp:read'])->plainTextToken;
// Auth + ability middleware pass, so the request reaches the MCP transport
// (which answers the JSON-RPC envelope) rather than being rejected.
withHeaders(['Authorization' => "Bearer {$plain}"])
->postJson('/mcp', $rpc)
->assertOk();
});

View File

@ -0,0 +1,99 @@
<?php
use App\Mcp\Servers\WhisperMoneyServer;
use App\Mcp\Tools\ListAccounts;
use App\Mcp\Tools\ListCategories;
use App\Mcp\Tools\ListSpaces;
use App\Mcp\Tools\SearchTransactions;
use App\Models\Account;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
it('blocks read tools when subscriptions are enabled and the user has no paid plan', function () {
config(['subscriptions.enabled' => true]);
$user = User::factory()->create();
WhisperMoneyServer::actingAs($user)
->tool(ListSpaces::class)
->assertHasErrors()
->assertSee('Pro');
});
it('allows read tools for a user on a paid plan', function () {
// subscriptions disabled => everyone is treated as Pro (hasProPlan()).
$user = User::factory()->create();
WhisperMoneyServer::actingAs($user)
->tool(ListSpaces::class)
->assertOk()
->assertSee('Personal');
});
it('searches transactions scoped to the user\'s space', function () {
$user = User::factory()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'description' => 'Blue Bottle Coffee',
]);
WhisperMoneyServer::actingAs($user)
->tool(SearchTransactions::class, ['query' => 'Blue Bottle'])
->assertOk()
->assertSee('Blue Bottle Coffee');
});
it('never exposes another user\'s transactions', function () {
$user = User::factory()->create();
$userAccount = Account::factory()->create(['user_id' => $user->id]);
Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $userAccount->id,
'description' => 'My Own Groceries',
]);
$other = User::factory()->create();
$otherAccount = Account::factory()->create(['user_id' => $other->id]);
Transaction::factory()->create([
'user_id' => $other->id,
'account_id' => $otherAccount->id,
'description' => 'Secret Steakhouse',
]);
WhisperMoneyServer::actingAs($user)
->tool(SearchTransactions::class, [])
->assertOk()
->assertSee('My Own Groceries')
->assertDontSee('Secret Steakhouse');
});
it('rejects a space id the user cannot access', function () {
$user = User::factory()->create();
$other = User::factory()->create();
WhisperMoneyServer::actingAs($user)
->tool(ListAccounts::class, ['space' => $other->personalSpace->id])
->assertHasErrors();
});
it('lists the user\'s accounts for the space', function () {
$user = User::factory()->create();
Account::factory()->create(['user_id' => $user->id, 'name' => 'Everyday Checking']);
WhisperMoneyServer::actingAs($user)
->tool(ListAccounts::class, [])
->assertOk()
->assertSee('Everyday Checking');
});
it('lists the user\'s categories for the space', function () {
$user = User::factory()->create();
Category::factory()->create(['user_id' => $user->id, 'name' => 'Groceries']);
WhisperMoneyServer::actingAs($user)
->tool(ListCategories::class, [])
->assertOk()
->assertSee('Groceries');
});

View File

@ -0,0 +1,104 @@
<?php
use App\Features\Mcp;
use App\Models\User;
use Laravel\Pennant\Feature;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\get;
/**
* A user with the MCP rollout feature flag enabled.
*/
function mcpUser(): User
{
$user = User::factory()->create();
Feature::for($user)->activate(Mcp::class);
return $user;
}
it('requires authentication to view the MCP page', function () {
get(route('mcp.index'))->assertRedirect();
});
it('hides the MCP settings page when the feature flag is off', function () {
actingAs(User::factory()->create())
->get(route('mcp.index'))
->assertNotFound();
});
it('renders the MCP settings page', function () {
actingAs(mcpUser())
->get(route('mcp.index'))
->assertOk();
});
it('creates a read-only token and flashes the secret once', function () {
$user = mcpUser();
actingAs($user)
->post(route('mcp.tokens.store'), ['name' => 'Claude Desktop'])
->assertRedirect(route('mcp.index'))
->assertSessionHas('mcp_token');
$token = $user->tokens()->first();
expect($token->name)->toBe('Claude Desktop');
expect($token->abilities)->toBe(['mcp:read']);
});
it('requires a token name', function () {
actingAs(mcpUser())
->post(route('mcp.tokens.store'), ['name' => ''])
->assertSessionHasErrors('name');
});
it('lets a free account create a token (gating happens at request time)', function () {
config(['subscriptions.enabled' => true]);
$user = mcpUser();
actingAs($user)
->post(route('mcp.tokens.store'), ['name' => 'Free'])
->assertSessionHas('mcp_token');
expect($user->tokens()->count())->toBe(1);
});
it('revokes a token the user owns', function () {
$user = mcpUser();
$token = $user->createToken('X', ['mcp:read'])->accessToken;
actingAs($user)
->delete(route('mcp.tokens.destroy', $token->id))
->assertRedirect(route('mcp.index'));
expect($user->tokens()->count())->toBe(0);
});
it('cannot revoke another user\'s token', function () {
$user = mcpUser();
$other = User::factory()->create();
$token = $other->createToken('X', ['mcp:read'])->accessToken;
actingAs($user)
->delete(route('mcp.tokens.destroy', $token->id))
->assertForbidden();
expect($other->tokens()->count())->toBe(1);
});
it('rotates a token, replacing the secret but keeping the scope', function () {
$user = mcpUser();
$token = $user->createToken('X', ['mcp:read'])->accessToken;
actingAs($user)
->post(route('mcp.tokens.rotate', $token->id))
->assertSessionHas('mcp_token');
$tokens = $user->tokens()->get();
expect($tokens)->toHaveCount(1);
expect($tokens->first()->id)->not->toBe($token->id);
expect($tokens->first()->abilities)->toBe(['mcp:read']);
});