feat(mcp): allow write tools over OAuth connections

Reverse the read-only-over-OAuth restriction: OAuth connections (Claude
Desktop / ChatGPT, resolved via the api guard) now get read+write access,
gated by the user approving the connection on the consent screen. Sanctum
personal access tokens still need the mcp:write ability, and bank-connected
accounts/transactions stay read-only for every caller.

Update the consent screen and settings copy to state the connection can read
and make changes (bank-connected data excepted), the server instructions, and
flip the OAuth guardrail test to assert a write succeeds.
This commit is contained in:
Víctor Falcón 2026-07-17 18:56:48 +02:00
parent e226d362ec
commit 5fa2f69c40
6 changed files with 47 additions and 54 deletions

View File

@ -35,8 +35,8 @@ use Laravel\Mcp\Server\Tool;
#[Version('1.0.0')]
#[Instructions(<<<'MARKDOWN'
Access to the authenticated user's Whisper Money finance data, for analysing
spending, cashflow and net worth and, with a read & write token, for editing
that data.
spending, cashflow and net worth and, with write access, for editing that
data.
- 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).

View File

@ -17,8 +17,9 @@ use Laravel\Mcp\Response;
/**
* Base for every Whisper Money write tool. On top of the McpTool Pro-plan gate
* it requires the calling token to carry the `mcp:write` ability, so a
* read-only token can analyse data but never change it.
* it gates write access: OAuth connections (Claude Desktop / ChatGPT) get
* read+write, and Sanctum personal access tokens must carry the `mcp:write`
* ability, so a read-only PAT can analyse data but never change it.
*
* Each concrete write tool must additionally carry the #[IsDestructive]
* annotation. PHP attributes are not inherited, so the framework only reports
@ -28,17 +29,13 @@ abstract class WriteTool extends McpTool
{
protected function respond(Request $request, User $user): Response
{
// OAuth connections (Claude Desktop / ChatGPT) authenticate through the
// `api` (Passport) guard and are read-only in this iteration, whatever
// scope a client requests: writes need a Claude Code read & write
// personal access token. Clamping on the resolving guard keeps the
// guarantee server-side instead of trusting the client to request a
// narrow scope (Passport always honours the `*` wildcard scope).
if (Auth::getDefaultDriver() === 'api') {
return Response::error('OAuth connections are read-only. Use a Claude Code read & write token to make changes.');
}
if (! $user->tokenCan('mcp:write')) {
// Write access is granted to OAuth connections (Claude Desktop /
// ChatGPT, resolved via the `api` guard — the user approves the
// connection on the consent screen) and to Sanctum personal access
// tokens carrying the mcp:write ability. A read-only Sanctum token is
// rejected. Bank-connected data stays protected for both (see the
// writableAccount / transaction helpers below).
if (Auth::getDefaultDriver() !== 'api' && ! $user->tokenCan('mcp:write')) {
return Response::error('This token is read-only. Create a read & write token to make changes.');
}

View File

@ -2274,14 +2274,15 @@
"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",
"These apps sign in instead of using a token. Add a custom connector pointing at the URL below, then approve the connection on the Whisper Money screen that opens. No token needed.": "Estas apps inician sesión en lugar de usar un token. Añade un conector personalizado que apunte a la URL de abajo y luego aprueba la conexión en la pantalla de Whisper Money que se abre. No hace falta ningún token.",
"Connected apps can read and analyse your data, but cannot change it.": "Las apps conectadas pueden leer y analizar tus datos, pero no pueden modificarlos.",
"Connected apps can read, analyse and make changes to your data (bank-connected accounts stay read-only). You approve the connection on the Whisper Money screen.": "Las apps conectadas pueden leer, analizar y modificar tus datos (las cuentas conectadas a un banco siguen siendo de solo lectura). Tú apruebas la conexión en la pantalla de Whisper Money.",
"Connect to :app": "Conectar con :app",
"Connect :client to :app": "Conectar :client con :app",
":client is asking to read and analyse your finance data.": ":client solicita leer y analizar tus datos financieros.",
":client is asking to read and make changes to your finance data.": ":client solicita leer y modificar tus datos financieros.",
"Signed in as": "Sesión iniciada como",
"Sends you back to": "Te devuelve a",
"This connection can:": "Esta conexión puede:",
"Read and analyse your transactions, balances, categories and spending.": "Leer y analizar tus transacciones, saldos, categorías y gastos.",
"It cannot create, edit or delete anything. You can disconnect it at any time from the connected app.": "No puede crear, editar ni eliminar nada. Puedes desconectarla cuando quieras desde la app conectada.",
"Create, edit and delete transactions, categories, labels and automation rules.": "Crear, editar y eliminar transacciones, categorías, etiquetas y reglas de automatización.",
"Bank-connected accounts and their transactions stay read-only. You can disconnect it at any time from the connected app.": "Las cuentas conectadas a un banco y sus transacciones siguen siendo de solo lectura. Puedes desconectarla cuando quieras desde la app conectada.",
"Connecting…": "Conectando…"
}

View File

@ -407,35 +407,6 @@ export default function Mcp() {
</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 Code')}
</h3>
<p className="text-sm text-muted-foreground">
{__(
'Run this, using one of your tokens in place of <token>.',
)}
</p>
<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">
{__('Claude Desktop & ChatGPT')}
@ -461,10 +432,24 @@ export default function Mcp() {
</div>
<p className="pt-1 text-sm text-muted-foreground">
{__(
'Connected apps can read and analyse your data, but cannot change it.',
'Connected apps can read, analyse and make changes to your data (bank-connected accounts stay read-only). You approve the connection on the Whisper Money screen.',
)}
</p>
</div>
<div className="space-y-1">
<h3 className="text-sm font-medium">
{__('Claude Code')}
</h3>
<p className="text-sm text-muted-foreground">
{__(
'Run this, using one of your tokens in place of <token>.',
)}
</p>
<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>
</CardContent>
</Card>
</div>

View File

@ -64,7 +64,7 @@
</h3>
<p class="text-sm text-muted-foreground text-center">
{{ __(':client is asking to read and analyse your finance data.', ['client' => $client->name]) }}
{{ __(':client is asking to read and make changes to your finance data.', ['client' => $client->name]) }}
</p>
</div>
@ -93,10 +93,18 @@
{{ __('Read and analyse your transactions, balances, categories and spending.') }}
</span>
</li>
<li class="flex items-start gap-2">
<div class="rounded-full bg-primary/10 p-1 mt-0.5">
<div class="h-1.5 w-1.5 rounded-full bg-primary"></div>
</div>
<span class="text-sm text-muted-foreground">
{{ __('Create, edit and delete transactions, categories, labels and automation rules.') }}
</span>
</li>
</ul>
<p class="text-sm text-muted-foreground pt-1">
{{ __('It cannot create, edit or delete anything. You can disconnect it at any time from the connected app.') }}
{{ __('Bank-connected accounts and their transactions stay read-only. You can disconnect it at any time from the connected app.') }}
</p>
</div>
</div>

View File

@ -178,11 +178,11 @@ it('authenticates a full Authorization Code + PKCE flow and serves read tools',
/*
|--------------------------------------------------------------------------
| Read-only guardrail (decision #1: OAuth connections cannot write)
| Write access over OAuth (Claude Desktop / ChatGPT connections can write)
|--------------------------------------------------------------------------
*/
it('blocks write tools for an OAuth (read-only) connection and creates no row', function () {
it('lets an OAuth connection use write tools', function () {
$user = User::factory()->create();
$token = issueOAuthToken($user);
@ -197,7 +197,9 @@ it('blocks write tools for an OAuth (read-only) connection and creates no row',
],
])
->assertOk()
->assertSee('read-only', false);
->assertSee('Groceries', false);
expect(Label::query()->count())->toBe(0);
$label = Label::query()->where('user_id', $user->id)->first();
expect($label)->not->toBeNull();
expect($label->name)->toBe('Groceries');
});