diff --git a/app/Mcp/Servers/WhisperMoneyServer.php b/app/Mcp/Servers/WhisperMoneyServer.php index 44f038bb..27f594ab 100644 --- a/app/Mcp/Servers/WhisperMoneyServer.php +++ b/app/Mcp/Servers/WhisperMoneyServer.php @@ -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). diff --git a/app/Mcp/Tools/WriteTool.php b/app/Mcp/Tools/WriteTool.php index c0da9b8d..b9bddee6 100644 --- a/app/Mcp/Tools/WriteTool.php +++ b/app/Mcp/Tools/WriteTool.php @@ -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.'); } diff --git a/lang/es.json b/lang/es.json index 361691e7..63c2215f 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2274,14 +2274,15 @@ "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", "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…" } diff --git a/resources/js/pages/settings/mcp.tsx b/resources/js/pages/settings/mcp.tsx index 95ec7dfe..7b91dfa7 100644 --- a/resources/js/pages/settings/mcp.tsx +++ b/resources/js/pages/settings/mcp.tsx @@ -407,35 +407,6 @@ export default function Mcp() { -
- - {serverUrl} - - -
- -
-

- {__('Claude Code')} -

-

- {__( - 'Run this, using one of your tokens in place of .', - )} -

- - {`claude mcp add --transport http whisper-money ${serverUrl} --header "Authorization: Bearer "`} - -
-

{__('Claude Desktop & ChatGPT')} @@ -461,10 +432,24 @@ export default function Mcp() {

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

+ +
+

+ {__('Claude Code')} +

+

+ {__( + 'Run this, using one of your tokens in place of .', + )} +

+ + {`claude mcp add --transport http whisper-money ${serverUrl} --header "Authorization: Bearer "`} + +
diff --git a/resources/views/mcp/authorize.blade.php b/resources/views/mcp/authorize.blade.php index 3f3e1141..fa8f6cfe 100644 --- a/resources/views/mcp/authorize.blade.php +++ b/resources/views/mcp/authorize.blade.php @@ -64,7 +64,7 @@

- {{ __(':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]) }}

@@ -93,10 +93,18 @@ {{ __('Read and analyse your transactions, balances, categories and spending.') }} +
  • +
    +
    +
    + + {{ __('Create, edit and delete transactions, categories, labels and automation rules.') }} + +
  • - {{ __('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.') }}

    diff --git a/tests/Feature/Mcp/McpOAuthTest.php b/tests/Feature/Mcp/McpOAuthTest.php index ea37b19b..4d632dc7 100644 --- a/tests/Feature/Mcp/McpOAuthTest.php +++ b/tests/Feature/Mcp/McpOAuthTest.php @@ -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'); });