From fb1adfc484f4ad3c9ff265fe417fb8d79831ed34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Fri, 17 Jul 2026 16:54:15 +0200 Subject: [PATCH] feat(mcp): read-only MCP server for Pro accounts (#689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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" `. - **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). --- app/Enums/PlanFeature.php | 3 +- app/Features/Mcp.php | 22 + .../Settings/McpTokenController.php | 116 +++++ app/Http/Middleware/HandleInertiaRequests.php | 4 + .../Settings/StoreMcpTokenRequest.php | 29 ++ app/Mcp/Servers/WhisperMoneyServer.php | 44 ++ app/Mcp/Tools/GetCashflow.php | 44 ++ app/Mcp/Tools/GetNetWorth.php | 52 +++ app/Mcp/Tools/ListAccounts.php | 50 +++ app/Mcp/Tools/ListCategories.php | 48 ++ app/Mcp/Tools/ListSpaces.php | 37 ++ app/Mcp/Tools/McpTool.php | 104 +++++ app/Mcp/Tools/SearchTransactions.php | 86 ++++ app/Mcp/Tools/SpendingByCategory.php | 46 ++ app/Models/User.php | 3 +- bootstrap/app.php | 4 + composer.json | 2 + composer.lock | 211 +++++---- config/sanctum.php | 87 ++++ ...42_create_personal_access_tokens_table.php | 33 ++ lang/es.json | 53 ++- phpstan.neon | 6 + resources/js/components/pro-badge.tsx | 20 + resources/js/layouts/settings/layout.tsx | 21 +- resources/js/pages/settings/billing.tsx | 9 +- resources/js/pages/settings/mcp.tsx | 409 ++++++++++++++++++ resources/js/types/index.d.ts | 1 + routes/ai.php | 19 + routes/settings.php | 12 + tests/Feature/InertiaSharedDataTest.php | 1 + tests/Feature/Mcp/McpEndpointAuthTest.php | 32 ++ tests/Feature/Mcp/McpToolsTest.php | 99 +++++ tests/Feature/Settings/McpTokenTest.php | 104 +++++ 33 files changed, 1725 insertions(+), 86 deletions(-) create mode 100644 app/Features/Mcp.php create mode 100644 app/Http/Controllers/Settings/McpTokenController.php create mode 100644 app/Http/Requests/Settings/StoreMcpTokenRequest.php create mode 100644 app/Mcp/Servers/WhisperMoneyServer.php create mode 100644 app/Mcp/Tools/GetCashflow.php create mode 100644 app/Mcp/Tools/GetNetWorth.php create mode 100644 app/Mcp/Tools/ListAccounts.php create mode 100644 app/Mcp/Tools/ListCategories.php create mode 100644 app/Mcp/Tools/ListSpaces.php create mode 100644 app/Mcp/Tools/McpTool.php create mode 100644 app/Mcp/Tools/SearchTransactions.php create mode 100644 app/Mcp/Tools/SpendingByCategory.php create mode 100644 config/sanctum.php create mode 100644 database/migrations/2026_07_17_125642_create_personal_access_tokens_table.php create mode 100644 resources/js/components/pro-badge.tsx create mode 100644 resources/js/pages/settings/mcp.tsx create mode 100644 routes/ai.php create mode 100644 tests/Feature/Mcp/McpEndpointAuthTest.php create mode 100644 tests/Feature/Mcp/McpToolsTest.php create mode 100644 tests/Feature/Settings/McpTokenTest.php diff --git a/app/Enums/PlanFeature.php b/app/Enums/PlanFeature.php index eff4d728..f595c2b5 100644 --- a/app/Enums/PlanFeature.php +++ b/app/Enums/PlanFeature.php @@ -6,6 +6,7 @@ enum PlanFeature: string { case ConnectedAccounts = 'connected_accounts'; case AiSuggestions = 'ai_suggestions'; + case McpAccess = 'mcp_access'; /** * Whether access to this feature is gated behind a paid (Pro) plan. @@ -13,7 +14,7 @@ enum PlanFeature: string public function requiresProPlan(): bool { return match ($this) { - self::ConnectedAccounts, self::AiSuggestions => true, + self::ConnectedAccounts, self::AiSuggestions, self::McpAccess => true, }; } } diff --git a/app/Features/Mcp.php b/app/Features/Mcp.php new file mode 100644 index 00000000..8b6e5f1e --- /dev/null +++ b/app/Features/Mcp.php @@ -0,0 +1,22 @@ +`. + * + * @api + */ +class Mcp +{ + /** + * Resolve the feature's initial value. + */ + public function resolve(?User $user): bool + { + return false; + } +} diff --git a/app/Http/Controllers/Settings/McpTokenController.php b/app/Http/Controllers/Settings/McpTokenController.php new file mode 100644 index 00000000..d3ac3b11 --- /dev/null +++ b/app/Http/Controllers/Settings/McpTokenController.php @@ -0,0 +1,116 @@ + + */ + 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 + */ + 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(); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index a7991136..d39085cd 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -5,6 +5,7 @@ namespace App\Http\Middleware; use App\Enums\BankingConnectionStatus; use App\Enums\BankingProvider; use App\Features\CalculateBalancesOnImport; +use App\Features\Mcp; use App\Jobs\PurgeResidualEncryptionArtifactsJob; use App\Models\BankingConnection; use App\Services\CurrencyOptions; @@ -179,16 +180,19 @@ class HandleInertiaRequests extends Middleware return [ 'cashflow' => true, 'calculateBalancesOnImport' => false, + 'mcp' => false, ]; } $features = Feature::for($user)->values([ CalculateBalancesOnImport::class, + Mcp::class, ]); return [ 'cashflow' => true, 'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false, + 'mcp' => $features[Mcp::class] !== false, ]; } diff --git a/app/Http/Requests/Settings/StoreMcpTokenRequest.php b/app/Http/Requests/Settings/StoreMcpTokenRequest.php new file mode 100644 index 00000000..42028ba6 --- /dev/null +++ b/app/Http/Requests/Settings/StoreMcpTokenRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Mcp/Servers/WhisperMoneyServer.php b/app/Mcp/Servers/WhisperMoneyServer.php new file mode 100644 index 00000000..42f0ae59 --- /dev/null +++ b/app/Mcp/Servers/WhisperMoneyServer.php @@ -0,0 +1,44 @@ +> */ + protected array $tools = [ + SearchTransactions::class, + SpendingByCategory::class, + GetCashflow::class, + GetNetWorth::class, + ListAccounts::class, + ListCategories::class, + ListSpaces::class, + ]; +} diff --git a/app/Mcp/Tools/GetCashflow.php b/app/Mcp/Tools/GetCashflow.php new file mode 100644 index 00000000..ac618762 --- /dev/null +++ b/app/Mcp/Tools/GetCashflow.php @@ -0,0 +1,44 @@ + + */ + 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), + ]); + } +} diff --git a/app/Mcp/Tools/GetNetWorth.php b/app/Mcp/Tools/GetNetWorth.php new file mode 100644 index 00000000..bb866ec3 --- /dev/null +++ b/app/Mcp/Tools/GetNetWorth.php @@ -0,0 +1,52 @@ + + */ + 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, + ), + ]); + } +} diff --git a/app/Mcp/Tools/ListAccounts.php b/app/Mcp/Tools/ListAccounts.php new file mode 100644 index 00000000..c8f155a1 --- /dev/null +++ b/app/Mcp/Tools/ListAccounts.php @@ -0,0 +1,50 @@ + + */ + 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, + ]); + } +} diff --git a/app/Mcp/Tools/ListCategories.php b/app/Mcp/Tools/ListCategories.php new file mode 100644 index 00000000..d26bfa68 --- /dev/null +++ b/app/Mcp/Tools/ListCategories.php @@ -0,0 +1,48 @@ + + */ + 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, + ]); + } +} diff --git a/app/Mcp/Tools/ListSpaces.php b/app/Mcp/Tools/ListSpaces.php new file mode 100644 index 00000000..ad30341b --- /dev/null +++ b/app/Mcp/Tools/ListSpaces.php @@ -0,0 +1,37 @@ + + */ + 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]); + } +} diff --git a/app/Mcp/Tools/McpTool.php b/app/Mcp/Tools/McpTool.php new file mode 100644 index 00000000..925cb8ef --- /dev/null +++ b/app/Mcp/Tools/McpTool.php @@ -0,0 +1,104 @@ +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 $query + * @return array + */ + 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; + } +} diff --git a/app/Mcp/Tools/SearchTransactions.php b/app/Mcp/Tools/SearchTransactions.php new file mode 100644 index 00000000..000f2c04 --- /dev/null +++ b/app/Mcp/Tools/SearchTransactions.php @@ -0,0 +1,86 @@ + + */ + 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, + ]); + } +} diff --git a/app/Mcp/Tools/SpendingByCategory.php b/app/Mcp/Tools/SpendingByCategory.php new file mode 100644 index 00000000..22da0ec3 --- /dev/null +++ b/app/Mcp/Tools/SpendingByCategory.php @@ -0,0 +1,46 @@ + + */ + 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()]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 27d4a66b..2b09ecc8 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -26,6 +26,7 @@ use Illuminate\Support\Facades\Log; use Laravel\Cashier\Billable; use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Pennant\Concerns\HasFeatures; +use Laravel\Sanctum\HasApiTokens; /** * @property ?Carbon $last_logged_in_at @@ -36,7 +37,7 @@ use Laravel\Pennant\Concerns\HasFeatures; class User extends Authenticatable implements HasLocalePreference, MustVerifyEmail { /** @use HasFactory */ - use Billable, HasFactory, HasFeatures, HasUuids, Notifiable, SoftDeletes, TwoFactorAuthenticatable; + use Billable, HasApiTokens, HasFactory, HasFeatures, HasUuids, Notifiable, SoftDeletes, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. diff --git a/bootstrap/app.php b/bootstrap/app.php index 9a402ed2..99b0b78e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -16,6 +16,8 @@ use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; use Illuminate\Http\Request; use Illuminate\Queue\MaxAttemptsExceededException; +use Laravel\Sanctum\Http\Middleware\CheckAbilities; +use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility; use Sentry\Laravel\Integration; return Application::configure(basePath: dirname(__DIR__)) @@ -54,6 +56,8 @@ return Application::configure(basePath: dirname(__DIR__)) 'subscribed' => EnsureUserIsSubscribed::class, 'onboarded' => EnsureOnboardingComplete::class, 'block-demo' => BlockDemoAccountActions::class, + 'abilities' => CheckAbilities::class, + 'ability' => CheckForAnyAbility::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/composer.json b/composer.json index e37588e4..75341194 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,9 @@ "laravel/cashier": "^16.1", "laravel/fortify": "^1.30", "laravel/framework": "^13.0", + "laravel/mcp": "^0.6.7", "laravel/pennant": "^1.18", + "laravel/sanctum": "^4.3", "laravel/tinker": "^3.0", "laravel/wayfinder": "^0.1.9", "resend/resend-php": "^1.1", diff --git a/composer.lock b/composer.lock index 2c31763d..c6fb4071 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a740337bb85751f3ad1ead3774dd1e02", + "content-hash": "196f117744cd19cbd40b8b0fb6d2cdc2", "packages": [ { "name": "aws/aws-crt-php", @@ -2135,6 +2135,79 @@ }, "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", "version": "v1.22.0", @@ -2270,6 +2343,69 @@ }, "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", "version": "v2.0.10", @@ -10390,79 +10526,6 @@ }, "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", "version": "v1.2.6", diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 00000000..cde73cff --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,87 @@ + 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, + ], + +]; diff --git a/database/migrations/2026_07_17_125642_create_personal_access_tokens_table.php b/database/migrations/2026_07_17_125642_create_personal_access_tokens_table.php new file mode 100644 index 00000000..62a379f4 --- /dev/null +++ b/database/migrations/2026_07_17_125642_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/lang/es.json b/lang/es.json index d3bd1699..e312c295 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2220,5 +2220,56 @@ "See your categorised transactions": "Ver tus transacciones categorizadas", "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.", - "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 \" header.": "Activa el modo desarrollador, luego Ajustes → Conectores → Crear. Pega la URL de arriba y añade una cabecera «Authorization: Bearer ».", + "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 .": "Ejecuta esto, usando uno de tus tokens en lugar de .", + "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." } diff --git a/phpstan.neon b/phpstan.neon index c745a4ee..b1fd3a12 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -23,6 +23,12 @@ parameters: path: app/Http/Controllers/* - identifier: public.method.unused 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 path: app/Http/Middleware/* - identifier: public.property.unused diff --git a/resources/js/components/pro-badge.tsx b/resources/js/components/pro-badge.tsx new file mode 100644 index 00000000..6300e1d3 --- /dev/null +++ b/resources/js/components/pro-badge.tsx @@ -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 ( + + {__('PRO')} + + ); +} diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index 26943f82..da72e13d 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -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 categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController'; 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 { Button } from '@/components/ui/button'; import { @@ -34,6 +35,7 @@ import { type PropsWithChildren } from 'react'; const getNavItems = ( subscriptionsEnabled: boolean, isDemoAccount: boolean, + mcpEnabled: boolean, ): (NavItem | NavSectionHeader | NavDivider)[] => [ { type: 'nav-item' as const, @@ -65,6 +67,16 @@ const getNavItems = ( href: labelsIndex(), icon: null, }, + ...(mcpEnabled + ? [ + { + type: 'nav-item' as const, + title: 'AI Connector', + href: mcpIndex(), + icon: null, + }, + ] + : []), { type: 'divider' }, { type: 'section-header', @@ -172,7 +184,8 @@ function renderMobileNavGroups( } export default function SettingsLayout({ children }: PropsWithChildren) { - const { subscriptionsEnabled, auth } = usePage().props; + const { subscriptionsEnabled, auth, features } = + usePage().props; const isDemoAccount = auth?.isDemoAccount ?? false; // 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 sidebarNavItems = getNavItems(subscriptionsEnabled, isDemoAccount); + const sidebarNavItems = getNavItems( + subscriptionsEnabled, + isDemoAccount, + features.mcp, + ); const activeNavItem = sidebarNavItems.find( (item): item is NavItem => diff --git a/resources/js/pages/settings/billing.tsx b/resources/js/pages/settings/billing.tsx index 5ca05e8c..f28b7685 100644 --- a/resources/js/pages/settings/billing.tsx +++ b/resources/js/pages/settings/billing.tsx @@ -1,6 +1,6 @@ import HeadingSmall from '@/components/heading-small'; +import { ProBadge } from '@/components/pro-badge'; import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import AppLayout from '@/layouts/app-layout'; @@ -427,12 +427,7 @@ function AiConsentSection({

{__('AI Categorization')}

- - PRO - +

{__( diff --git a/resources/js/pages/settings/mcp.tsx b/resources/js/pages/settings/mcp.tsx new file mode 100644 index 00000000..d959d42b --- /dev/null +++ b/resources/js/pages/settings/mcp.tsx @@ -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 ( + + + + +

+
+ + +
+ + {!auth.hasProPlan && ( + + + {__('This is a Pro feature')} + + + {__( + 'You can create a token now, but it only works on a paid plan.', + )}{' '} + + {__('Upgrade your account')} + + + + )} + + + + + {__('Your data leaves Whisper Money')} + + + {__( + '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.', + )} + + + + {newToken && ( + + + + {__('Copy your new token now')} + + +

+ {__( + 'This is the only time you will see it, so copy it somewhere safe now.', + )} +

+
+ + {newToken} + + +
+
+
+ )} + + {/* Create token */} + + + {__('Create a token')} + + {__( + 'Tokens can read and analyse your data, but never change it.', + )} + + + +
+
+ + + form.setData('name', e.target.value) + } + placeholder={__('e.g. Claude Desktop')} + required + /> +
+ +
+ {form.errors.name && ( +

+ {form.errors.name} +

+ )} +
+
+ + {/* Token list */} + + + {__('Your tokens')} + + {__( + 'Rotate a token if it leaks, or revoke it to cut off access.', + )} + + + + {tokens.length === 0 ? ( +

+ {__('You have no tokens yet.')} +

+ ) : ( +
    + {tokens.map((token) => ( +
  • +
    +
    + + {token.name} + + + {token.scope === + 'read_write' + ? __('Read & write') + : __('Read only')} + +
    +

    + {__('Created')}:{' '} + {formatDate( + token.created_at, + )}{' '} + · {__('Last used')}:{' '} + {formatDate( + token.last_used_at, + )} +

    +
    +
    + + + + + + + + {__( + 'Rotate this token?', + )} + + + {__( + '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.', + )} + + + + + {__('Cancel')} + + + router.post( + rotate( + token.id, + ).url, + {}, + { + preserveScroll: true, + }, + ) + } + > + {__('Rotate')} + + + + + + + + + + + + {__( + 'Revoke this token?', + )} + + + {__( + 'Anything using this token loses access right away, and you cannot undo it.', + )} + + + + + {__('Cancel')} + + + router.delete( + destroy( + token.id, + ).url, + { + preserveScroll: true, + }, + ) + } + > + {__('Revoke')} + + + + +
    +
  • + ))} +
+ )} +
+
+ + {/* Connection instructions */} + + + {__('How to connect')} + + {__( + 'Here is your connection URL. Use it with a token you created above.', + )} + + + +
+ + {serverUrl} + + +
+ +
+

+ {__('Claude Code')} +

+

+ {__( + 'Run this, using one of your tokens in place of .', + )} +

+ + {`claude mcp add --transport http whisper-money ${serverUrl} --header "Authorization: Bearer "`} + +
+ +
+

+ {__('Claude Desktop & 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.', + )} +

+
+
+
+
+ + + ); +} diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 3ca7b705..607bb58e 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -42,6 +42,7 @@ export interface NavDivider { export interface Features { cashflow: boolean; calculateBalancesOnImport: boolean; + mcp: boolean; } export interface ExpiredBankingConnectionNotification { diff --git a/routes/ai.php b/routes/ai.php new file mode 100644 index 00000000..a86b053a --- /dev/null +++ b/routes/ai.php @@ -0,0 +1,19 @@ +middleware(['auth:sanctum', 'abilities:mcp:read', 'throttle:60,1']); diff --git a/routes/settings.php b/routes/settings.php index 2071c630..18b67346 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Settings\BankController; use App\Http\Controllers\Settings\CategoryController; use App\Http\Controllers\Settings\ChartColorSchemeController; use App\Http\Controllers\Settings\LabelController; +use App\Http\Controllers\Settings\McpTokenController; use App\Http\Controllers\Settings\NetWorthChartLoanPreferenceController; use App\Http\Controllers\Settings\NetWorthChartRealEstatePreferenceController; 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::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::get('settings/automation-rules', [AutomationRuleController::class, 'index'])->name('automation-rules.index'); diff --git a/tests/Feature/InertiaSharedDataTest.php b/tests/Feature/InertiaSharedDataTest.php index f86c4ec1..02a7d293 100644 --- a/tests/Feature/InertiaSharedDataTest.php +++ b/tests/Feature/InertiaSharedDataTest.php @@ -99,6 +99,7 @@ test('shared feature flags do not include coinbase flag', function () { expect($props['features'])->toBe([ 'cashflow' => true, 'calculateBalancesOnImport' => false, + 'mcp' => false, ]); }); diff --git a/tests/Feature/Mcp/McpEndpointAuthTest.php b/tests/Feature/Mcp/McpEndpointAuthTest.php new file mode 100644 index 00000000..89986465 --- /dev/null +++ b/tests/Feature/Mcp/McpEndpointAuthTest.php @@ -0,0 +1,32 @@ + '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(); +}); diff --git a/tests/Feature/Mcp/McpToolsTest.php b/tests/Feature/Mcp/McpToolsTest.php new file mode 100644 index 00000000..795c1ce5 --- /dev/null +++ b/tests/Feature/Mcp/McpToolsTest.php @@ -0,0 +1,99 @@ + 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'); +}); diff --git a/tests/Feature/Settings/McpTokenTest.php b/tests/Feature/Settings/McpTokenTest.php new file mode 100644 index 00000000..39b4212a --- /dev/null +++ b/tests/Feature/Settings/McpTokenTest.php @@ -0,0 +1,104 @@ +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']); +});