diff --git a/app/Http/Controllers/Settings/McpTokenController.php b/app/Http/Controllers/Settings/McpTokenController.php index 432d879a..4ac5a4ac 100644 --- a/app/Http/Controllers/Settings/McpTokenController.php +++ b/app/Http/Controllers/Settings/McpTokenController.php @@ -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 */ diff --git a/app/Http/Requests/Settings/StoreMcpTokenRequest.php b/app/Http/Requests/Settings/StoreMcpTokenRequest.php index 77def3a0..42028ba6 100644 --- a/app/Http/Requests/Settings/StoreMcpTokenRequest.php +++ b/app/Http/Requests/Settings/StoreMcpTokenRequest.php @@ -24,7 +24,6 @@ class StoreMcpTokenRequest extends FormRequest { return [ 'name' => ['required', 'string', 'max:255'], - 'scope' => ['required', 'in:read,read_write'], ]; } } diff --git a/app/Mcp/Tools/ListSpaces.php b/app/Mcp/Tools/ListSpaces.php index 07ba0faf..ad30341b 100644 --- a/app/Mcp/Tools/ListSpaces.php +++ b/app/Mcp/Tools/ListSpaces.php @@ -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]); diff --git a/app/Mcp/Tools/McpTool.php b/app/Mcp/Tools/McpTool.php index 4f530d7b..b2f5b67d 100644 --- a/app/Mcp/Tools/McpTool.php +++ b/app/Mcp/Tools/McpTool.php @@ -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 $query - * @return array + * @return array */ 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 { diff --git a/lang/es.json b/lang/es.json index bac18f18..0d526109 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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 \" header.": "Activa el modo desarrollador, luego Ajustes → Conectores → Crear. Pega la URL de arriba y añade una cabecera «Authorization: Bearer ».", - "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." } diff --git a/resources/js/pages/settings/mcp.tsx b/resources/js/pages/settings/mcp.tsx index c5d37b59..5413fa63 100644 --- a/resources/js/pages/settings/mcp.tsx +++ b/resources/js/pages/settings/mcp.tsx @@ -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().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() { )} - + {__('Your data leaves Whisper Money')} {__( @@ -181,7 +161,7 @@ export default function Mcp() { {__('Create a token')} {__( - '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.', )} @@ -204,32 +184,6 @@ export default function Mcp() { required /> -
- - -
+ + + + + + + + {__( + 'Rotate this token?', + )} + + + {__( + 'Rotating replaces the secret. Any AI client using the current token will stop working until you reconnect it with the new one.', + )} + + + + + {__('Cancel')} + + + router.post( + rotate( + token.id, + ).url, + {}, + { + preserveScroll: + true, + }, + ) + } + > + {__('Rotate')} + + + +