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:
parent
e226d362ec
commit
5fa2f69c40
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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…"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue