diff --git a/app/Features/Mcp.php b/app/Features/Mcp.php new file mode 100644 index 00000000..8b6e5f1e --- /dev/null +++ b/app/Features/Mcp.php @@ -0,0 +1,22 @@ +`. + * + * @api + */ +class Mcp +{ + /** + * Resolve the feature's initial value. + */ + public function resolve(?User $user): bool + { + return false; + } +} diff --git a/app/Http/Controllers/Settings/McpTokenController.php b/app/Http/Controllers/Settings/McpTokenController.php index 4ac5a4ac..d3ac3b11 100644 --- a/app/Http/Controllers/Settings/McpTokenController.php +++ b/app/Http/Controllers/Settings/McpTokenController.php @@ -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 + */ + 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. diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index a7991136..d39085cd 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -5,6 +5,7 @@ namespace App\Http\Middleware; use App\Enums\BankingConnectionStatus; use App\Enums\BankingProvider; use App\Features\CalculateBalancesOnImport; +use App\Features\Mcp; use App\Jobs\PurgeResidualEncryptionArtifactsJob; use App\Models\BankingConnection; use App\Services\CurrencyOptions; @@ -179,16 +180,19 @@ class HandleInertiaRequests extends Middleware return [ 'cashflow' => true, 'calculateBalancesOnImport' => false, + 'mcp' => false, ]; } $features = Feature::for($user)->values([ CalculateBalancesOnImport::class, + Mcp::class, ]); return [ 'cashflow' => true, 'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false, + 'mcp' => $features[Mcp::class] !== false, ]; } diff --git a/lang/es.json b/lang/es.json index 0d526109..d1026ecb 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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 .": "Ejecuta esto, usando uno de tus tokens en lugar de .", + "Claude Desktop & ChatGPT": "Claude Desktop y ChatGPT", + "Coming soon. These apps sign in with OAuth, which we are still building, so a token will not work with them yet. Use Claude Code for now.": "Muy pronto. Estas apps inician sesión con OAuth, que todavía estamos construyendo, así que de momento un token no funciona con ellas. Usa Claude Code por ahora." } diff --git a/resources/js/components/pro-badge.tsx b/resources/js/components/pro-badge.tsx new file mode 100644 index 00000000..6300e1d3 --- /dev/null +++ b/resources/js/components/pro-badge.tsx @@ -0,0 +1,20 @@ +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { __ } from '@/utils/i18n'; + +/** + * The "PRO" badge used to mark paid-plan features across settings. + */ +export function ProBadge({ className }: { className?: string }) { + return ( + + {__('PRO')} + + ); +} diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index 6661185c..da72e13d 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -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().props; + const { subscriptionsEnabled, auth, features } = + usePage().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 => diff --git a/resources/js/pages/settings/billing.tsx b/resources/js/pages/settings/billing.tsx index 5ca05e8c..f28b7685 100644 --- a/resources/js/pages/settings/billing.tsx +++ b/resources/js/pages/settings/billing.tsx @@ -1,6 +1,6 @@ import HeadingSmall from '@/components/heading-small'; +import { ProBadge } from '@/components/pro-badge'; import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import AppLayout from '@/layouts/app-layout'; @@ -427,12 +427,7 @@ function AiConsentSection({

{__('AI Categorization')}

- - PRO - +

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

-
+
- - {__('PRO')} - +
{!auth.hasProPlan && ( @@ -109,7 +108,7 @@ export default function Mcp() { {__( - '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.', )}{' '} )} - + + {__('Your data leaves Whisper Money')} {__( - 'The data you query is sent to whichever AI client you connect. Whisper Money cannot control what that client does with it. By connecting, you accept this. Revoke a token at any time to cut off access.', + '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.', )} @@ -141,7 +141,7 @@ export default function Mcp() {

{__( - "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.', )}

@@ -168,7 +168,7 @@ export default function Mcp() { {__('Create a token')} {__( - 'Tokens are read-only: they can analyse your data but never change it.', + 'Tokens can read and analyse your data, but never change it.', )} @@ -212,7 +212,7 @@ export default function Mcp() { {__('Your tokens')} {__( - '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.', )} @@ -271,7 +271,7 @@ export default function Mcp() { {__( - '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.', )} @@ -317,7 +317,7 @@ export default function Mcp() { {__( - '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.', )} @@ -356,7 +356,7 @@ export default function Mcp() { {__('How to connect')} {__( - '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.', )} @@ -378,19 +378,13 @@ export default function Mcp() {

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

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

-
- -
-

- {__('Claude Code')} -

{`claude mcp add --transport http whisper-money ${serverUrl} --header "Authorization: Bearer "`} @@ -398,11 +392,11 @@ export default function Mcp() {

- {__('ChatGPT')} + {__('Claude Desktop & ChatGPT')}

{__( - 'Enable developer mode, then Settings → Connectors → Create. Paste the URL above and add an "Authorization: Bearer " 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.', )}

diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 3ca7b705..607bb58e 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -42,6 +42,7 @@ export interface NavDivider { export interface Features { cashflow: boolean; calculateBalancesOnImport: boolean; + mcp: boolean; } export interface ExpiredBankingConnectionNotification { diff --git a/tests/Feature/Settings/McpTokenTest.php b/tests/Feature/Settings/McpTokenTest.php index 7cddb9b5..39b4212a 100644 --- a/tests/Feature/Settings/McpTokenTest.php +++ b/tests/Feature/Settings/McpTokenTest.php @@ -1,22 +1,41 @@ 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)