refactor(mcp): address review feedback

- Keep PR1 strictly read-only: drop the scope selector, the read_write
  request rule and mcp:write granting from the UI/controller (the write scope
  returns with the write tools in PR2). Removes a token that could be minted
  over-privileged with a false "can edit data" UI promise.
- Gate via User::canUseFeature(PlanFeature::McpAccess) instead of hasProPlan()
  directly, matching the existing feature-gating convention.
- Confirm token rotation behind an AlertDialog (it breaks connected clients),
  matching revoke; drop the silent on-load clipboard auto-copy.
- Give the data-egress disclaimer a destructive variant for visual weight.
- Extract the duplicated token-ownership check; document the controller-reuse
  coupling and the shared-tenant space-scoping model; fix ListSpaces N+1 and a
  duplicate output key.
This commit is contained in:
Víctor Falcón 2026-07-17 15:32:10 +02:00
parent 14ab73dcfb
commit fcf5e6e46f
7 changed files with 90 additions and 104 deletions

View File

@ -28,15 +28,12 @@ class McpTokenController extends Controller
/**
* Create a new MCP token. The plaintext secret is flashed once; only its
* hash is stored, so it can never be shown again.
* hash is stored, so it can never be shown again. Tokens are read-only for
* now (the `mcp:write` ability is introduced alongside the write tools).
*/
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);
$token = $request->user()->createToken($request->validated('name'), ['mcp:read']);
return to_route('mcp.index')->with('mcp_token', $token->plainTextToken);
}
@ -46,11 +43,7 @@ class McpTokenController extends Controller
*/
public function destroy(Request $request, PersonalAccessToken $token): RedirectResponse
{
abort_unless(
$token->tokenable_id === $request->user()->getKey()
&& $token->tokenable_type === $request->user()->getMorphClass(),
403
);
$this->authorizeOwnership($request, $token);
$token->delete();
@ -63,11 +56,7 @@ class McpTokenController extends Controller
*/
public function rotate(Request $request, PersonalAccessToken $token): RedirectResponse
{
abort_unless(
$token->tokenable_id === $request->user()->getKey()
&& $token->tokenable_type === $request->user()->getMorphClass(),
403
);
$this->authorizeOwnership($request, $token);
$fresh = $request->user()->createToken($token->name, $token->abilities);
$token->delete();
@ -75,6 +64,18 @@ class McpTokenController extends Controller
return to_route('mcp.index')->with('mcp_token', $fresh->plainTextToken);
}
/**
* Ensure the token belongs to the requesting user before mutating it.
*/
private function authorizeOwnership(Request $request, PersonalAccessToken $token): void
{
abort_unless(
$token->tokenable_id === $request->user()->getKey()
&& $token->tokenable_type === $request->user()->getMorphClass(),
403
);
}
/**
* @return list<array{id: int|string, name: string, scope: string, created_at: ?string, last_used_at: ?string}>
*/

View File

@ -24,7 +24,6 @@ class StoreMcpTokenRequest extends FormRequest
{
return [
'name' => ['required', 'string', 'max:255'],
'scope' => ['required', 'in:read,read_write'],
];
}
}

View File

@ -29,9 +29,7 @@ class ListSpaces extends McpTool
'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]);

View File

@ -2,6 +2,7 @@
namespace App\Mcp\Tools;
use App\Enums\PlanFeature;
use App\Models\Space;
use App\Models\User;
use Illuminate\Support\Str;
@ -34,7 +35,7 @@ abstract class McpTool extends Tool
return Response::error('Authentication required.');
}
if (! $user->hasProPlan()) {
if (! $user->canUseFeature(PlanFeature::McpAccess)) {
return Response::error(
'A paid (Pro) plan is required to use the Whisper Money MCP. Upgrade your account at '.route('subscribe')
);
@ -58,8 +59,12 @@ abstract class McpTool extends Tool
* a synthesized GET request bound to the MCP user, returning its JSON body.
* Keeps the (user-scoped) dashboard maths in exactly one place.
*
* ponytail: couples to the controllers returning a JsonResponse; acceptable
* while they're stable. Extract the orchestration into a shared service if a
* controller stops returning JSON or a third tool needs the same maths.
*
* @param array<string, mixed> $query
* @return array<string, mixed>
* @return array<array-key, mixed>
*/
protected function callController(object $controller, string $method, User $user, array $query): array
{
@ -72,6 +77,11 @@ abstract class McpTool extends Tool
/**
* The space a tool operates on: the optional `space` argument (validated
* against the spaces the user can access) or the user's personal space.
*
* Scoping is by `space_id` only, gated by membership (`accessibleSpaces`): a
* space is a shared tenant, so a member is meant to see every row in it. The
* security boundary is the membership check here, not a per-row `user_id`
* filter.
*/
protected function resolveSpace(Request $request, User $user): Space
{

View File

@ -2255,5 +2255,8 @@
"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."
"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."
}

View File

@ -28,13 +28,6 @@ import {
} 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';
@ -42,7 +35,6 @@ 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 {
@ -73,19 +65,7 @@ export default function Mcp() {
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]);
const form = useForm({ name: '' });
function createToken(event: React.FormEvent) {
event.preventDefault();
@ -138,7 +118,7 @@ export default function Mcp() {
</Alert>
)}
<Alert>
<Alert variant="destructive">
<AlertTitle>{__('Your data leaves Whisper Money')}</AlertTitle>
<AlertDescription>
{__(
@ -181,7 +161,7 @@ export default function Mcp() {
<CardTitle>{__('Create a token')}</CardTitle>
<CardDescription>
{__(
'Read-only tokens can only analyse data. Read & write tokens can also create and edit data.',
'Tokens are read-only: they can analyse your data but never change it.',
)}
</CardDescription>
</CardHeader>
@ -204,32 +184,6 @@ export default function Mcp() {
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}
@ -291,23 +245,52 @@ export default function Mcp() {
</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"
>
<RefreshCw className="h-4 w-4" />
{__('Rotate')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{__(
'Rotate this token?',
)}
</AlertDialogTitle>
<AlertDialogDescription>
{__(
'Rotating replaces the secret. Any AI client using the current token will stop working until you reconnect it with the new one.',
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{__('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
router.post(
rotate(
token.id,
).url,
{},
{
preserveScroll:
true,
},
)
}
>
{__('Rotate')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button

View File

@ -15,11 +15,11 @@ it('renders the MCP settings page', function () {
->assertOk();
});
it('creates a read-only token by default and flashes the secret once', function () {
it('creates a read-only token and flashes the secret once', function () {
$user = User::factory()->create();
actingAs($user)
->post(route('mcp.tokens.store'), ['name' => 'Claude Desktop', 'scope' => 'read'])
->post(route('mcp.tokens.store'), ['name' => 'Claude Desktop'])
->assertRedirect(route('mcp.index'))
->assertSessionHas('mcp_token');
@ -29,18 +29,10 @@ it('creates a read-only token by default and flashes the secret once', function
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 () {
it('requires a token name', function () {
actingAs(User::factory()->create())
->post(route('mcp.tokens.store'), ['name' => 'X', 'scope' => 'admin'])
->assertSessionHasErrors('scope');
->post(route('mcp.tokens.store'), ['name' => ''])
->assertSessionHasErrors('name');
});
it('lets a free account create a token (gating happens at request time)', function () {
@ -48,7 +40,7 @@ it('lets a free account create a token (gating happens at request time)', functi
$user = User::factory()->create();
actingAs($user)
->post(route('mcp.tokens.store'), ['name' => 'Free', 'scope' => 'read'])
->post(route('mcp.tokens.store'), ['name' => 'Free'])
->assertSessionHas('mcp_token');
expect($user->tokens()->count())->toBe(1);
@ -79,7 +71,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();
$token = $user->createToken('X', ['mcp:read', 'mcp:write'])->accessToken;
$token = $user->createToken('X', ['mcp:read'])->accessToken;
actingAs($user)
->post(route('mcp.tokens.rotate', $token->id))
@ -89,5 +81,5 @@ it('rotates a token, replacing the secret but keeping the scope', function () {
expect($tokens)->toHaveCount(1);
expect($tokens->first()->id)->not->toBe($token->id);
expect($tokens->first()->abilities)->toBe(['mcp:read', 'mcp:write']);
expect($tokens->first()->abilities)->toBe(['mcp:read']);
});