Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/server/connection-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/server/connection-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
28 changes: 28 additions & 0 deletions tests/connections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Loading