feat(mcp): gate the AI Connector behind a feature flag; polish UI and copy

- Add App\Features\Mcp (default off) and hide the settings screen (nav item +
  all mcp.* routes) behind it. Pro-plan gating stays request-time.
- Rename the user-facing page from 'MCP access' to 'AI Connector' so non-technical
  users understand it.
- Extract a shared ProBadge component (amber) used on the AI Connector and
  billing pages.
- Soften the data-egress notice (amber icon instead of a red alert) and rewrite
  the copy in plainer language.
- Correct the connection instructions after checking the vendor docs: Claude Code
  works with a token today; Claude Desktop and ChatGPT require OAuth (marked
  'coming soon'), since their custom connectors do not accept a static token.
This commit is contained in:
Víctor Falcón 2026-07-17 16:40:25 +02:00
parent bf40ecb708
commit 5eafc52994
10 changed files with 148 additions and 51 deletions

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

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

View File

@ -2,16 +2,36 @@
namespace App\Http\Controllers\Settings;
use App\Features\Mcp;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\StoreMcpTokenRequest;
use Closure;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controllers\HasMiddleware;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Pennant\Feature;
use Laravel\Sanctum\PersonalAccessToken;
class McpTokenController extends Controller
class McpTokenController extends Controller implements HasMiddleware
{
/**
* Hide the whole MCP settings surface behind the rollout feature flag.
*
* @return array<int, Closure>
*/
public static function middleware(): array
{
return [
function (Request $request, Closure $next): mixed {
abort_unless(Feature::active(Mcp::class), 404);
return $next($request);
},
];
}
/**
* Show the MCP access page: existing tokens, connection details and the
* one-time plaintext secret when a token was just created.

View File

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

View File

@ -2258,5 +2258,17 @@
"This is the only time it is shown. Store it somewhere safe — you won't be able to see it again.": "Es la única vez que se muestra. Guárdalo en un lugar seguro: no podrás verlo de nuevo.",
"Tokens are read-only: they can analyse your data but never change it.": "Los tokens son de solo lectura: pueden analizar tus datos pero nunca modificarlos.",
"Rotate this token?": "¿Rotar este token?",
"Rotating replaces the secret. Any AI client using the current token will stop working until you reconnect it with the new one.": "Rotar reemplaza el secreto. Cualquier cliente de IA que use el token actual dejará de funcionar hasta que lo reconectes con el nuevo."
"Rotating replaces the secret. Any AI client using the current token will stop working until you reconnect it with the new one.": "Rotar reemplaza el secreto. Cualquier cliente de IA que use el token actual dejará de funcionar hasta que lo reconectes con el nuevo.",
"AI Connector": "Conector de IA",
"You can create a token now, but it only works on a paid plan.": "Puedes crear un token ahora, pero solo funciona con un plan de pago.",
"Anything you ask about is sent to the AI app you connect, and we cannot control what it does with your data. Connect one only if you are comfortable with that. You can revoke a token any time to cut off access.": "Todo lo que consultes se envía a la app de IA que conectes, y no podemos controlar qué hace con tus datos. Conéctala solo si te parece bien. Puedes revocar un token cuando quieras para cortar el acceso.",
"This is the only time you will see it, so copy it somewhere safe now.": "Es la única vez que lo verás, así que cópialo ahora en un lugar seguro.",
"Tokens can read and analyse your data, but never change it.": "Los tokens pueden leer y analizar tus datos, pero nunca modificarlos.",
"Rotate a token if it leaks, or revoke it to cut off access.": "Rota un token si se filtra, o revócalo para cortar el acceso.",
"Rotating gives you a new secret and cancels the old one. Anything using the current token stops working until you reconnect it with the new secret.": "Al rotar obtienes un secreto nuevo y se cancela el anterior. Lo que use el token actual dejará de funcionar hasta que lo reconectes con el nuevo secreto.",
"Anything using this token loses access right away, and you cannot undo it.": "Cualquier cosa que use este token pierde el acceso al instante, y no se puede deshacer.",
"Here is your connection URL. Use it with a token you created above.": "Esta es tu URL de conexión. Úsala con un token que hayas creado arriba.",
"Run this, using one of your tokens in place of <token>.": "Ejecuta esto, usando uno de tus tokens en lugar de <token>.",
"Claude Desktop & ChatGPT": "Claude Desktop y ChatGPT",
"Coming soon. These apps sign in with OAuth, which we are still building, so a token will not work with them yet. Use Claude Code for now.": "Muy pronto. Estas apps inician sesión con OAuth, que todavía estamos construyendo, así que de momento un token no funciona con ellas. Usa Claude Code por ahora."
}

View File

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

View File

@ -35,6 +35,7 @@ import { type PropsWithChildren } from 'react';
const getNavItems = (
subscriptionsEnabled: boolean,
isDemoAccount: boolean,
mcpEnabled: boolean,
): (NavItem | NavSectionHeader | NavDivider)[] => [
{
type: 'nav-item' as const,
@ -66,12 +67,16 @@ const getNavItems = (
href: labelsIndex(),
icon: null,
},
{
type: 'nav-item' as const,
title: 'MCP access',
href: mcpIndex(),
icon: null,
},
...(mcpEnabled
? [
{
type: 'nav-item' as const,
title: 'AI Connector',
href: mcpIndex(),
icon: null,
},
]
: []),
{ type: 'divider' },
{
type: 'section-header',
@ -179,7 +184,8 @@ function renderMobileNavGroups(
}
export default function SettingsLayout({ children }: PropsWithChildren) {
const { subscriptionsEnabled, auth } = usePage<SharedData>().props;
const { subscriptionsEnabled, auth, features } =
usePage<SharedData>().props;
const isDemoAccount = auth?.isDemoAccount ?? false;
// When server-side rendering, we only render the layout on the client...
@ -188,7 +194,11 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
}
const currentPath = window.location.pathname;
const sidebarNavItems = getNavItems(subscriptionsEnabled, isDemoAccount);
const sidebarNavItems = getNavItems(
subscriptionsEnabled,
isDemoAccount,
features.mcp,
);
const activeNavItem = sidebarNavItems.find(
(item): item is NavItem =>

View File

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

View File

@ -5,6 +5,7 @@ import {
store,
} from '@/actions/App/Http/Controllers/Settings/McpTokenController';
import HeadingSmall from '@/components/heading-small';
import { ProBadge } from '@/components/pro-badge';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
AlertDialog,
@ -34,7 +35,7 @@ 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 { Copy, KeyRound, RefreshCw, ShieldAlert, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
interface TokenRow {
@ -53,7 +54,7 @@ interface McpPageProps {
}
const breadcrumbs: BreadcrumbItem[] = [
{ title: 'MCP access', href: mcpIndex().url },
{ title: 'AI Connector', href: mcpIndex().url },
];
function formatDate(value: string | null): string {
@ -86,20 +87,18 @@ export default function Mcp() {
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title={__('MCP access')} />
<Head title={__('AI Connector')} />
<SettingsLayout>
<div className="space-y-6">
<div className="flex items-center gap-2">
<div className="flex items-start justify-between gap-4">
<HeadingSmall
title={__('MCP access')}
title={__('AI Connector')}
description={__(
'Connect Whisper Money to your AI assistant (Claude, ChatGPT) to analyse your finances.',
)}
/>
<Badge variant="secondary" className="tracking-widest">
{__('PRO')}
</Badge>
<ProBadge className="mt-1 shrink-0" />
</div>
{!auth.hasProPlan && (
@ -109,7 +108,7 @@ export default function Mcp() {
</AlertTitle>
<AlertDescription>
{__(
'You can create a token now, but MCP requests only work on a paid plan.',
'You can create a token now, but it only works on a paid plan.',
)}{' '}
<Link
href={subscribeUrl}
@ -121,13 +120,14 @@ export default function Mcp() {
</Alert>
)}
<Alert variant="destructive">
<Alert>
<ShieldAlert className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<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.',
'Anything you ask about is sent to the AI app you connect, and we cannot control what it does with your data. Connect one only if you are comfortable with that. You can revoke a token any time to cut off access.',
)}
</AlertDescription>
</Alert>
@ -141,7 +141,7 @@ export default function Mcp() {
<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.",
'This is the only time you will see it, so copy it somewhere safe now.',
)}
</p>
<div className="flex items-center gap-2">
@ -168,7 +168,7 @@ export default function Mcp() {
<CardTitle>{__('Create a token')}</CardTitle>
<CardDescription>
{__(
'Tokens are read-only: they can analyse your data but never change it.',
'Tokens can read and analyse your data, but never change it.',
)}
</CardDescription>
</CardHeader>
@ -212,7 +212,7 @@ export default function Mcp() {
<CardTitle>{__('Your tokens')}</CardTitle>
<CardDescription>
{__(
'Rotate a token to replace a leaked secret, or revoke it to cut off access.',
'Rotate a token if it leaks, or revoke it to cut off access.',
)}
</CardDescription>
</CardHeader>
@ -271,7 +271,7 @@ export default function Mcp() {
</AlertDialogTitle>
<AlertDialogDescription>
{__(
'Rotating replaces the secret. Any AI client using the current token will stop working until you reconnect it with the new one.',
'Rotating gives you a new secret and cancels the old one. Anything using the current token stops working until you reconnect it with the new secret.',
)}
</AlertDialogDescription>
</AlertDialogHeader>
@ -317,7 +317,7 @@ export default function Mcp() {
</AlertDialogTitle>
<AlertDialogDescription>
{__(
'Any AI client using this token will immediately lose access. This cannot be undone.',
'Anything using this token loses access right away, and you cannot undo it.',
)}
</AlertDialogDescription>
</AlertDialogHeader>
@ -356,7 +356,7 @@ export default function Mcp() {
<CardTitle>{__('How to connect')}</CardTitle>
<CardDescription>
{__(
'Your MCP server URL is below. Use it with a token you created above.',
'Here is your connection URL. Use it with a token you created above.',
)}
</CardDescription>
</CardHeader>
@ -378,19 +378,13 @@ export default function Mcp() {
<div className="space-y-1">
<h3 className="text-sm font-medium">
{__('Claude (web & desktop)')}
{__('Claude Code')}
</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.',
'Run this, using one of your tokens in place of <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>
@ -398,11 +392,11 @@ export default function Mcp() {
<div className="space-y-1">
<h3 className="text-sm font-medium">
{__('ChatGPT')}
{__('Claude Desktop & 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.',
'Coming soon. These apps sign in with OAuth, which we are still building, so a token will not work with them yet. Use Claude Code for now.',
)}
</p>
</div>

View File

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

View File

@ -1,22 +1,41 @@
<?php
use App\Features\Mcp;
use App\Models\User;
use Laravel\Pennant\Feature;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\get;
/**
* A user with the MCP rollout feature flag enabled.
*/
function mcpUser(): User
{
$user = User::factory()->create();
Feature::for($user)->activate(Mcp::class);
return $user;
}
it('requires authentication to view the MCP page', function () {
get(route('mcp.index'))->assertRedirect();
});
it('renders the MCP settings page', function () {
it('hides the MCP settings page when the feature flag is off', function () {
actingAs(User::factory()->create())
->get(route('mcp.index'))
->assertNotFound();
});
it('renders the MCP settings page', function () {
actingAs(mcpUser())
->get(route('mcp.index'))
->assertOk();
});
it('creates a read-only token and flashes the secret once', function () {
$user = User::factory()->create();
$user = mcpUser();
actingAs($user)
->post(route('mcp.tokens.store'), ['name' => 'Claude Desktop'])
@ -30,14 +49,14 @@ it('creates a read-only token and flashes the secret once', function () {
});
it('requires a token name', function () {
actingAs(User::factory()->create())
actingAs(mcpUser())
->post(route('mcp.tokens.store'), ['name' => ''])
->assertSessionHasErrors('name');
});
it('lets a free account create a token (gating happens at request time)', function () {
config(['subscriptions.enabled' => true]);
$user = User::factory()->create();
$user = mcpUser();
actingAs($user)
->post(route('mcp.tokens.store'), ['name' => 'Free'])
@ -47,7 +66,7 @@ it('lets a free account create a token (gating happens at request time)', functi
});
it('revokes a token the user owns', function () {
$user = User::factory()->create();
$user = mcpUser();
$token = $user->createToken('X', ['mcp:read'])->accessToken;
actingAs($user)
@ -58,7 +77,7 @@ it('revokes a token the user owns', function () {
});
it('cannot revoke another user\'s token', function () {
$user = User::factory()->create();
$user = mcpUser();
$other = User::factory()->create();
$token = $other->createToken('X', ['mcp:read'])->accessToken;
@ -70,7 +89,7 @@ it('cannot revoke another user\'s token', function () {
});
it('rotates a token, replacing the secret but keeping the scope', function () {
$user = User::factory()->create();
$user = mcpUser();
$token = $user->createToken('X', ['mcp:read'])->accessToken;
actingAs($user)