diff --git a/src/server/connection-routes.ts b/src/server/connection-routes.ts index fd0d550d..d15669de 100644 --- a/src/server/connection-routes.ts +++ b/src/server/connection-routes.ts @@ -132,6 +132,15 @@ export function connectionRoutes( const earlier = recovered(); if (earlier) return earlier; const { approval, exposed } = approvalFor(thread.id, body.approvalId); + // An approval runs its stored request once. The claim below binds one + // tool call id, so without this check the same approval could execute + // again for every fresh tool call id sent with it. + const claimed = connections.store.actionForApproval(approval.id); + if (claimed && claimed.toolCallId !== body.toolCallId) + return c.json( + { error: 'This approval request already ran its stored action.' }, + 409, + ); if ( !connections.store.claimAction( thread.id, diff --git a/src/server/connection-store.ts b/src/server/connection-store.ts index bc53c1b7..2fc3908d 100644 --- a/src/server/connection-store.ts +++ b/src/server/connection-store.ts @@ -153,6 +153,18 @@ export class ConnectionStore { : null, }; } + // Looks up what an approval was already claimed for, so one approval can + // never run its stored request twice under different tool call ids. + actionForApproval(approvalId: string) { + const row = this.db + .prepare( + 'SELECT threadId, toolCallId FROM mcp_actions WHERE approvalId=?', + ) + .get(approvalId); + return row + ? { threadId: String(row.threadId), toolCallId: String(row.toolCallId) } + : undefined; + } // Claim an approved action once. Returns false if it was already claimed, // so a double click or a retried request never executes twice. claimAction( diff --git a/tests/connections.test.ts b/tests/connections.test.ts index 80332791..f07cb0e2 100644 --- a/tests/connections.test.ts +++ b/tests/connections.test.ts @@ -486,3 +486,31 @@ it('returns a saved result only for the approval that produced it', async () => expect(sent).toHaveBeenCalledOnce(); expect(sent.mock.calls[0][0]).toMatchObject({ to: 'alice@example.com' }); }); + +it('runs an approved request once, even under a fresh toolCallId', async () => { + const { dot, connections, request, sent } = fixture(); + await connections.add(dot.id, { + name: 'Mail', + url: 'https://mail.example.com/mcp', + }); + const approvalId = await requestApproval( + connections, + dot.id, + 'mail__send_mail', + { to: 'a@example.com', body: 'Hi' }, + ); + const approve = (toolCallId: string) => + request('/conversations/thread/connection-actions', 'POST', { + toolCallId, + approvalId, + }); + expect(await (await approve('tc1')).json()).toEqual({ + isError: false, + text: 'Sent to a@example.com', + }); + // The stored request ran. The same approval must not run it a second + // time under a different tool call id. + const replay = await approve('tc2'); + expect(replay.status).toBe(409); + expect(sent).toHaveBeenCalledOnce(); +});