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:
parent
afd09eab87
commit
14ab73dcfb
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
38
lang/es.json
38
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 <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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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']);
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
Loading…
Reference in New Issue