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/Http/Controllers/Settings/McpTokenController.php b/app/Http/Controllers/Settings/McpTokenController.php new file mode 100644 index 00000000..432d879a --- /dev/null +++ b/app/Http/Controllers/Settings/McpTokenController.php @@ -0,0 +1,95 @@ + $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. + */ + public function store(StoreMcpTokenRequest $request): RedirectResponse + { + $abilities = $request->validated('scope') === 'read_write' + ? ['mcp:read', 'mcp:write'] + : ['mcp:read']; + + $token = $request->user()->createToken($request->validated('name'), $abilities); + + return to_route('mcp.index')->with('mcp_token', $token->plainTextToken); + } + + /** + * Revoke (delete) a token the user owns. + */ + public function destroy(Request $request, PersonalAccessToken $token): RedirectResponse + { + abort_unless( + $token->tokenable_id === $request->user()->getKey() + && $token->tokenable_type === $request->user()->getMorphClass(), + 403 + ); + + $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 + { + abort_unless( + $token->tokenable_id === $request->user()->getKey() + && $token->tokenable_type === $request->user()->getMorphClass(), + 403 + ); + + $fresh = $request->user()->createToken($token->name, $token->abilities); + $token->delete(); + + return to_route('mcp.index')->with('mcp_token', $fresh->plainTextToken); + } + + /** + * @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/Requests/Settings/StoreMcpTokenRequest.php b/app/Http/Requests/Settings/StoreMcpTokenRequest.php new file mode 100644 index 00000000..77def3a0 --- /dev/null +++ b/app/Http/Requests/Settings/StoreMcpTokenRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'scope' => ['required', 'in:read,read_write'], + ]; + } +} diff --git a/app/Mcp/Servers/WhisperMoneyServer.php b/app/Mcp/Servers/WhisperMoneyServer.php new file mode 100644 index 00000000..df711723 --- /dev/null +++ b/app/Mcp/Servers/WhisperMoneyServer.php @@ -0,0 +1,43 @@ + */ + 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..7e4c13a1 --- /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..4de93b9e --- /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..eafe1e2a --- /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..0850506d --- /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..07ba0faf --- /dev/null +++ b/app/Mcp/Tools/ListSpaces.php @@ -0,0 +1,39 @@ + + */ + 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_default' => $space->personal, + 'is_current' => $space->id === $user->current_space_id, + 'role' => $space->roleFor($user), + ]); + + 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..4f530d7b --- /dev/null +++ b/app/Mcp/Tools/McpTool.php @@ -0,0 +1,94 @@ +user(); + + if ($user === null) { + return Response::error('Authentication required.'); + } + + if (! $user->hasProPlan()) { + 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. + * + * @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. + */ + 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..6d3e0b22 --- /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..344d64cc --- /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 95482af8..bac18f18 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2219,5 +2219,41 @@ "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." } diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index 26943f82..6661185c 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 { @@ -65,6 +66,12 @@ const getNavItems = ( href: labelsIndex(), icon: null, }, + { + type: 'nav-item' as const, + title: 'MCP access', + href: mcpIndex(), + icon: null, + }, { type: 'divider' }, { type: 'section-header', diff --git a/resources/js/pages/settings/mcp.tsx b/resources/js/pages/settings/mcp.tsx new file mode 100644 index 00000000..c5d37b59 --- /dev/null +++ b/resources/js/pages/settings/mcp.tsx @@ -0,0 +1,427 @@ +import { + destroy, + index as mcpIndex, + rotate, + store, +} from '@/actions/App/Http/Controllers/Settings/McpTokenController'; +import HeadingSmall from '@/components/heading-small'; +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useClipboard } from '@/hooks/use-clipboard'; +import AppLayout from '@/layouts/app-layout'; +import SettingsLayout from '@/layouts/settings/layout'; +import { type BreadcrumbItem, type SharedData } from '@/types'; +import { __ } from '@/utils/i18n'; +import { Head, Link, router, useForm, usePage } from '@inertiajs/react'; +import { Copy, KeyRound, RefreshCw, Trash2 } from 'lucide-react'; +import { useEffect } from 'react'; +import { toast } from 'sonner'; + +interface TokenRow { + id: number | string; + 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: 'MCP access', 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().props; + const [, copy] = useClipboard(); + + const form = useForm({ name: '', scope: 'read' }); + + useEffect(() => { + if (newToken) { + copy(newToken).then((ok) => { + if (ok) { + toast.success(__('Token copied to clipboard')); + } + }); + } + // Only react to a freshly created token. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [newToken]); + + 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 ( + + + + +
+
+ + + {__('PRO')} + +
+ + {!auth.hasProPlan && ( + + {__('This is a Pro feature')} + + {__( + 'You can create a token now, but MCP requests only work on a paid plan.', + )}{' '} + + {__('Upgrade your account')} + + + + )} + + + {__('Your data leaves 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.', + )} + + + + {newToken && ( + + + {__('Copy your new token now')} + +

+ {__( + "This is the only time it is shown. Store it somewhere safe — you won't be able to see it again.", + )} +

+
+ + {newToken} + + +
+
+
+ )} + + {/* Create token */} + + + {__('Create a token')} + + {__( + 'Read-only tokens can only analyse data. Read & write tokens can also create and edit data.', + )} + + + +
+
+ + + 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 to replace a leaked secret, 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, + )} +

    +
    +
    + + + + + + + + + {__( + 'Revoke this token?', + )} + + + {__( + 'Any AI client using this token will immediately lose access. This cannot be undone.', + )} + + + + + {__('Cancel')} + + + router.delete( + destroy( + token.id, + ).url, + { + preserveScroll: + true, + }, + ) + } + > + {__('Revoke')} + + + + +
    +
  • + ))} +
+ )} +
+
+ + {/* Connection instructions */} + + + {__('How to connect')} + + {__( + 'Your MCP server URL is below. Use it with a token you created above.', + )} + + + +
+ + {serverUrl} + + +
+ +
+

+ {__('Claude (web & desktop)')} +

+

+ {__( + 'Settings → Connectors → Add custom connector. Paste the URL above, choose "API key" authentication and paste your token.', + )} +

+
+ +
+

+ {__('Claude Code')} +

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

+ {__('ChatGPT')} +

+

+ {__( + 'Enable developer mode, then Settings → Connectors → Create. Paste the URL above and add an "Authorization: Bearer " header.', + )} +

+
+
+
+
+
+
+ ); +} 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/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..6bc9c042 --- /dev/null +++ b/tests/Feature/Settings/McpTokenTest.php @@ -0,0 +1,93 @@ +assertRedirect(); +}); + +it('renders the MCP settings page', function () { + actingAs(User::factory()->create()) + ->get(route('mcp.index')) + ->assertOk(); +}); + +it('creates a read-only token by default and flashes the secret once', function () { + $user = User::factory()->create(); + + actingAs($user) + ->post(route('mcp.tokens.store'), ['name' => 'Claude Desktop', 'scope' => 'read']) + ->assertRedirect(route('mcp.index')) + ->assertSessionHas('mcp_token'); + + $token = $user->tokens()->first(); + + expect($token->name)->toBe('Claude Desktop'); + expect($token->abilities)->toBe(['mcp:read']); +}); + +it('creates a read-write token when requested', function () { + $user = User::factory()->create(); + + actingAs($user)->post(route('mcp.tokens.store'), ['name' => 'CC', 'scope' => 'read_write']); + + expect($user->tokens()->first()->abilities)->toBe(['mcp:read', 'mcp:write']); +}); + +it('validates the token scope', function () { + actingAs(User::factory()->create()) + ->post(route('mcp.tokens.store'), ['name' => 'X', 'scope' => 'admin']) + ->assertSessionHasErrors('scope'); +}); + +it('lets a free account create a token (gating happens at request time)', function () { + config(['subscriptions.enabled' => true]); + $user = User::factory()->create(); + + actingAs($user) + ->post(route('mcp.tokens.store'), ['name' => 'Free', 'scope' => 'read']) + ->assertSessionHas('mcp_token'); + + expect($user->tokens()->count())->toBe(1); +}); + +it('revokes a token the user owns', function () { + $user = User::factory()->create(); + $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 = User::factory()->create(); + $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 = User::factory()->create(); + $token = $user->createToken('X', ['mcp:read', 'mcp:write'])->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', 'mcp:write']); +});