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

Expose a remote (streamable HTTP) MCP server so users can connect Whisper
Money to their own AI assistant (Claude web/desktop, Claude Code, ChatGPT)
to analyse their finances.

- Auth via Sanctum personal access tokens with MCP-only abilities
  (mcp:read / mcp:write); tokens are created, rotated and revoked from a new
  Settings -> MCP access page, with per-client connection instructions and a
  one-time secret reveal.
- Read tools: search_transactions, spending_by_category, get_cashflow,
  get_net_worth, list_accounts, list_categories, list_spaces. Transaction and
  listing tools are space-scoped (optional space arg, defaults to personal);
  the analytics tools reuse the existing user-scoped services/controllers.
- Pro-plan gating is enforced per request inside the tools, so a lapsed
  subscription stops working without the user revoking the token. Free users
  can still create tokens (marked PRO in the UI).

Write tools are intentionally out of scope for this PR (follow-up).
This commit is contained in:
Víctor Falcón 2026-07-17 15:17:40 +02:00
parent afd09eab87
commit 14ab73dcfb
25 changed files with 1588 additions and 77 deletions

View File

@ -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,
};
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\StoreMcpTokenRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Sanctum\PersonalAccessToken;
class McpTokenController extends Controller
{
/**
* 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.
*/
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<array{id: int|string, name: string, scope: string, created_at: ?string, last_used_at: ?string}>
*/
private function tokensFor(Request $request): array
{
return $request->user()->tokens()
->latest()
->get()
->map(fn (PersonalAccessToken $token): array => [
'id' => $token->id,
'name' => $token->name,
'scope' => in_array('mcp:write', $token->abilities ?? [], true) ? 'read_write' : 'read',
'created_at' => $token->created_at?->toIso8601String(),
'last_used_at' => $token->last_used_at?->toIso8601String(),
])
->all();
}
}

View File

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

View File

@ -0,0 +1,43 @@
<?php
namespace App\Mcp\Servers;
use App\Mcp\Tools\GetCashflow;
use App\Mcp\Tools\GetNetWorth;
use App\Mcp\Tools\ListAccounts;
use App\Mcp\Tools\ListCategories;
use App\Mcp\Tools\ListSpaces;
use App\Mcp\Tools\SearchTransactions;
use App\Mcp\Tools\SpendingByCategory;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
#[Name('Whisper Money')]
#[Version('1.0.0')]
#[Instructions(<<<'MARKDOWN'
Read-only access to the authenticated user's Whisper Money finance data, for
analysing spending, cashflow and net worth.
- All amounts are integers in minor units (cents). Divide by 100 for a display value.
- Data is organised into "spaces" (the personal space and any shared spaces).
Transaction and account tools accept an optional `space` id and default to the
personal space; call `list_spaces` to discover ids. The cashflow, net-worth and
spending tools cover the user's whole account.
- To find recurring charges (subscriptions), use `search_transactions` and group
the results by merchant and cadence yourself.
MARKDOWN)]
class WhisperMoneyServer extends Server
{
/** @var array<int, class-string> */
protected array $tools = [
SearchTransactions::class,
SpendingByCategory::class,
GetCashflow::class,
GetNetWorth::class,
ListAccounts::class,
ListCategories::class,
ListSpaces::class,
];
}

View File

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

View File

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

View File

@ -0,0 +1,50 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Account;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List the user\'s accounts in a space, including whether each is connected to a bank/provider (connected accounts are read-only).')]
class ListAccounts extends McpTool
{
/**
* @return array<string, JsonSchema>
*/
public function schema(JsonSchema $schema): array
{
return [
'space' => $schema->string()->description('Space id to query. Defaults to the personal space.'),
];
}
protected function respond(Request $request, User $user): Response
{
$space = $this->resolveSpace($request, $user);
$accounts = Account::query()
->forSpace($space)
->with('bank:id,name')
->orderBy('name')
->get()
->map(fn (Account $account): array => [
'id' => $account->id,
'name' => $account->name,
'type' => $account->type->value,
'currency' => $account->currency_code,
'bank' => $account->bank?->name,
'is_connected' => $account->isConnected(),
]);
return $this->json([
'space_id' => $space->id,
'accounts' => $accounts,
]);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Category;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List the user\'s categories in a space. Categories form a tree via parent_id; use the ids to filter search_transactions or spending_by_category.')]
class ListCategories extends McpTool
{
/**
* @return array<string, JsonSchema>
*/
public function schema(JsonSchema $schema): array
{
return [
'space' => $schema->string()->description('Space id to query. Defaults to the personal space.'),
];
}
protected function respond(Request $request, User $user): Response
{
$space = $this->resolveSpace($request, $user);
$categories = Category::query()
->forSpace($space)
->orderBy('name')
->get()
->map(fn (Category $category): array => [
'id' => $category->id,
'name' => $category->name,
'type' => $category->type->value,
'cashflow_direction' => $category->cashflow_direction->value,
'parent_id' => $category->parent_id,
]);
return $this->json([
'space_id' => $space->id,
'categories' => $categories,
]);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Space;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List the spaces the user can access (personal and shared). Pass a space id to the other tools\' `space` argument to query a specific one.')]
class ListSpaces extends McpTool
{
/**
* @return array<string, JsonSchema>
*/
public function schema(JsonSchema $schema): array
{
return [];
}
protected function respond(Request $request, User $user): Response
{
$spaces = $user->accessibleSpaces()
->map(fn (Space $space): array => [
'id' => $space->id,
'name' => $space->name,
'personal' => $space->personal,
'is_default' => $space->personal,
'is_current' => $space->id === $user->current_space_id,
'role' => $space->roleFor($user),
]);
return $this->json(['spaces' => $spaces]);
}
}

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

@ -0,0 +1,94 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Space;
use App\Models\User;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
/**
* Base for every Whisper Money read tool. Enforces the Pro-plan gate on each
* call (a lapsed subscription stops working without revoking the token) and
* provides the shared space-resolution and JSON-encoding helpers.
*/
abstract class McpTool extends Tool
{
/**
* Expose snake_case tool names (search_transactions, list_spaces, ) instead
* of the framework default kebab-case, matching the documented tool catalog.
*/
public function name(): string
{
return Str::snake(class_basename($this));
}
public function handle(Request $request): Response
{
$user = $request->user();
if ($user === 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<string, mixed> $query
* @return array<string, mixed>
*/
protected function callController(object $controller, string $method, User $user, array $query): array
{
$httpRequest = \Illuminate\Http\Request::create('/', 'GET', $query);
$httpRequest->setUserResolver(fn (): User => $user);
return $controller->{$method}($httpRequest)->getData(true);
}
/**
* The space a tool operates on: the optional `space` argument (validated
* against the spaces the user can access) or the user's personal space.
*/
protected function resolveSpace(Request $request, User $user): Space
{
$spaceId = $request->string('space')->toString();
if ($spaceId === '') {
return $user->personalSpace ?? $user->activeSpace();
}
$space = $user->accessibleSpaces()->firstWhere('id', $spaceId);
if ($space === null) {
throw ValidationException::withMessages([
'space' => "You do not have access to a space with id {$spaceId}. Call list_spaces to see valid ids.",
]);
}
return $space;
}
}

View File

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

View File

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

View File

@ -26,6 +26,7 @@ use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Billable;
use Laravel\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<UserFactory> */
use Billable, HasFactory, HasFeatures, HasUuids, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
use Billable, HasApiTokens, HasFactory, HasFeatures, HasUuids, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
/**
* The attributes that are mass assignable.

View File

@ -16,6 +16,8 @@ use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
use Illuminate\Http\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 {

View File

@ -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",

211
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"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",

87
config/sanctum.php Normal file
View File

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

View File

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

View File

@ -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 <token>\" header.": "Activa el modo desarrollador, luego Ajustes → Conectores → Crear. Pega la URL de arriba y añade una cabecera «Authorization: Bearer <token>».",
"This is the only time it is shown. Store it somewhere safe — you won't be able to see it again.": "Es la única vez que se muestra. Guárdalo en un lugar seguro: no podrás verlo de nuevo."
}

View File

@ -2,6 +2,7 @@ import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/
import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
import { index as 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',

View File

@ -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<SharedData & McpPageProps>().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 (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title={__('MCP access')} />
<SettingsLayout>
<div className="space-y-6">
<div className="flex items-center gap-2">
<HeadingSmall
title={__('MCP access')}
description={__(
'Connect Whisper Money to your AI assistant (Claude, ChatGPT) to analyse your finances.',
)}
/>
<Badge variant="secondary" className="tracking-widest">
{__('PRO')}
</Badge>
</div>
{!auth.hasProPlan && (
<Alert>
<AlertTitle>{__('This is a Pro feature')}</AlertTitle>
<AlertDescription>
{__(
'You can create a token now, but MCP requests only work on a paid plan.',
)}{' '}
<Link
href={subscribeUrl}
className="font-medium underline"
>
{__('Upgrade your account')}
</Link>
</AlertDescription>
</Alert>
)}
<Alert>
<AlertTitle>{__('Your data leaves Whisper Money')}</AlertTitle>
<AlertDescription>
{__(
'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.',
)}
</AlertDescription>
</Alert>
{newToken && (
<Alert>
<KeyRound className="h-4 w-4" />
<AlertTitle>{__('Copy your new token now')}</AlertTitle>
<AlertDescription>
<p className="mb-2">
{__(
"This is the only time it is shown. Store it somewhere safe — you won't be able to see it again.",
)}
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded-md bg-muted px-3 py-2 text-sm">
{newToken}
</code>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copyValue(newToken)}
aria-label={__('Copy')}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</AlertDescription>
</Alert>
)}
{/* Create token */}
<Card>
<CardHeader>
<CardTitle>{__('Create a token')}</CardTitle>
<CardDescription>
{__(
'Read-only tokens can only analyse data. Read & write tokens can also create and edit data.',
)}
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={createToken}
className="flex flex-col gap-4 sm:flex-row sm:items-end"
>
<div className="flex-1 space-y-2">
<Label htmlFor="token-name">
{__('Name')}
</Label>
<Input
id="token-name"
value={form.data.name}
onChange={(e) =>
form.setData('name', e.target.value)
}
placeholder={__('e.g. Claude Desktop')}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="token-scope">
{__('Scope')}
</Label>
<Select
value={form.data.scope}
onValueChange={(value) =>
form.setData('scope', value)
}
>
<SelectTrigger
id="token-scope"
className="w-full sm:w-56"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="read">
{__('Read only')}
</SelectItem>
<SelectItem value="read_write">
{__('Read & write')}
</SelectItem>
</SelectContent>
</Select>
</div>
<Button
type="submit"
disabled={form.processing}
>
{__('Create token')}
</Button>
</form>
{form.errors.name && (
<p className="mt-2 text-sm text-destructive">
{form.errors.name}
</p>
)}
</CardContent>
</Card>
{/* Token list */}
<Card>
<CardHeader>
<CardTitle>{__('Your tokens')}</CardTitle>
<CardDescription>
{__(
'Rotate a token to replace a leaked secret, or revoke it to cut off access.',
)}
</CardDescription>
</CardHeader>
<CardContent>
{tokens.length === 0 ? (
<p className="text-sm text-muted-foreground">
{__('You have no tokens yet.')}
</p>
) : (
<ul className="divide-y">
{tokens.map((token) => (
<li
key={token.id}
className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium">
{token.name}
</span>
<Badge variant="outline">
{token.scope ===
'read_write'
? __('Read & write')
: __('Read only')}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{__('Created')}:{' '}
{formatDate(
token.created_at,
)}{' '}
· {__('Last used')}:{' '}
{formatDate(
token.last_used_at,
)}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
router.post(
rotate(token.id).url,
{},
{
preserveScroll:
true,
},
)
}
>
<RefreshCw className="h-4 w-4" />
{__('Rotate')}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive"
>
<Trash2 className="h-4 w-4" />
{__('Revoke')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{__(
'Revoke this token?',
)}
</AlertDialogTitle>
<AlertDialogDescription>
{__(
'Any AI client using this token will immediately lose access. This cannot be undone.',
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{__('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
router.delete(
destroy(
token.id,
).url,
{
preserveScroll:
true,
},
)
}
>
{__('Revoke')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{/* Connection instructions */}
<Card>
<CardHeader>
<CardTitle>{__('How to connect')}</CardTitle>
<CardDescription>
{__(
'Your MCP server URL is below. Use it with a token you created above.',
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded-md bg-muted px-3 py-2 text-sm">
{serverUrl}
</code>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copyValue(serverUrl)}
aria-label={__('Copy')}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<div className="space-y-1">
<h3 className="text-sm font-medium">
{__('Claude (web & desktop)')}
</h3>
<p className="text-sm text-muted-foreground">
{__(
'Settings → Connectors → Add custom connector. Paste the URL above, choose "API key" authentication and paste your token.',
)}
</p>
</div>
<div className="space-y-1">
<h3 className="text-sm font-medium">
{__('Claude Code')}
</h3>
<code className="block overflow-x-auto rounded-md bg-muted px-3 py-2 text-sm">
{`claude mcp add --transport http whisper-money ${serverUrl} --header "Authorization: Bearer <token>"`}
</code>
</div>
<div className="space-y-1">
<h3 className="text-sm font-medium">
{__('ChatGPT')}
</h3>
<p className="text-sm text-muted-foreground">
{__(
'Enable developer mode, then Settings → Connectors → Create. Paste the URL above and add an "Authorization: Bearer <token>" header.',
)}
</p>
</div>
</CardContent>
</Card>
</div>
</SettingsLayout>
</AppLayout>
);
}

19
routes/ai.php Normal file
View File

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

View File

@ -8,6 +8,7 @@ use App\Http\Controllers\Settings\BankController;
use App\Http\Controllers\Settings\CategoryController;
use App\Http\Controllers\Settings\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');

View File

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

View File

@ -0,0 +1,93 @@
<?php
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\get;
it('requires authentication to view the MCP page', function () {
get(route('mcp.index'))->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']);
});