mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
fix(mcp): retain changes during callback verification
Bound temporary eval tunnel setup retries before provider calls and record their outcomes. Keep production callback validation unchanged. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
fed6d7bab8
commit
93056b8ede
5 files changed
+47
-11
No files matched your search
@@ -334,6 +334,7 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
|
||||
let status = 204;
|
||||
let cloudAllowed = true;
|
||||
let goodChallenge = true;
|
||||
let duringVerification: (() => Promise<void>) | undefined;
|
||||
const fetcher: EventFetch = async (url, init) => {
|
||||
if (cloudOrigin && url === cloudOrigin + "/mcp/paperclip") return cloudAllowed
|
||||
? Response.json({ result: { structuredContent: { user: { id: f.actor.userId }, companyId: f.company.id, connectionId: principal.grant.id } } })
|
||||
@@ -344,7 +345,7 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
|
||||
const expected = "v1," + createHmac("sha256", Buffer.from(secret.slice(6), "base64")).update(`${headers.get("webhook-id")}.${headers.get("webhook-timestamp")}.${String(init.body)}`).digest("base64");
|
||||
// A rotation test may use another secret; initial deliveries must be independently verifiable.
|
||||
if (!String(headers.get("webhook-signature")).includes(expected) && body.type !== "verification") throw new Error("Invalid signature");
|
||||
if (body.type === "verification") return Response.json({ challenge: goodChallenge ? body.challenge : "wrong" });
|
||||
if (body.type === "verification") { await duringVerification?.(); duringVerification = undefined; return Response.json({ challenge: goodChallenge ? body.challenge : "wrong" }); }
|
||||
return new Response(null, { status });
|
||||
};
|
||||
const dispatch = async (p: typeof principal, _method: string, path: string) => {
|
||||
@@ -361,7 +362,7 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
|
||||
return row!;
|
||||
};
|
||||
return { ...f, principal, task: task!, service, input, received, secret, activity, dispatch, options,
|
||||
advance(ms: number) { clock += ms; }, setStatus(value: number) { status = value; }, denyCloud() { cloudAllowed = false; }, badChallenge() { goodChallenge = false; }, now: () => clock };
|
||||
advance(ms: number) { clock += ms; }, setStatus(value: number) { status = value; }, denyCloud() { cloudAllowed = false; }, duringVerification(fn: () => Promise<void>) { duringVerification = fn; }, badChallenge() { goodChallenge = false; }, now: () => clock };
|
||||
}
|
||||
|
||||
it("discovers MCP 2.0 events, validates metadata/headers, and preserves legacy tools", async () => {
|
||||
@@ -446,6 +447,15 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
|
||||
}
|
||||
});
|
||||
|
||||
it("retains changes occurring while callback ownership is being verified", async () => {
|
||||
const f = await eventFixture();
|
||||
f.duringVerification(async () => { await f.activity(); });
|
||||
await f.service.subscribe(f.principal, f.input);
|
||||
await f.service.tick();
|
||||
expect(f.received.filter(r => r.body.eventId)).toHaveLength(1);
|
||||
await f.service.unsubscribe(f.principal, f.input);
|
||||
});
|
||||
|
||||
it("bounds lifetimes and retries without claiming unsupported replay", async () => {
|
||||
const f = await eventFixture();
|
||||
const short = await f.service.subscribe(f.principal, { ...f.input, ttlMs: 1 });
|
||||
|
||||
@@ -75,6 +75,7 @@ export function createPublicMcpEvents(db: Db, oauth: PublicMcpOAuth, api: ApiDis
|
||||
}
|
||||
|
||||
async function subscribe(principal: McpPrincipal, raw: unknown, cloud?: CloudEventAuthority) {
|
||||
const requestedAt = new Date(now());
|
||||
const input = subscribeSchema.parse(raw);
|
||||
validate(input);
|
||||
if (!input.delivery.secret) throw new McpEventError(-32602, "A webhook signing secret is required.");
|
||||
@@ -109,7 +110,7 @@ export function createPublicMcpEvents(db: Db, oauth: PublicMcpOAuth, api: ApiDis
|
||||
if (existing && !active) await tx.delete(deliveries).where(eq(deliveries.subscriptionId, id));
|
||||
const value = { companyId: principal.grant.companyId, grantId: principal.grant.id, name: input.name, taskId: input.arguments.taskId,
|
||||
arguments: input.arguments, deliveryMaterial: await encrypt(destination), expiresAt, stoppedAt: null,
|
||||
verifiedAt: verify ? new Date(now()) : existing!.verifiedAt, startsAt: active ? existing.startsAt : new Date(now()), scannedAt: new Date(now()) };
|
||||
verifiedAt: verify ? new Date(now()) : existing!.verifiedAt, startsAt: active ? existing.startsAt : requestedAt, scannedAt: new Date(now()) };
|
||||
await tx.insert(subscriptions).values({ id, ...value }).onConflictDoUpdate({ target: subscriptions.id, set: value });
|
||||
await logActivity(tx as unknown as Db, { companyId: principal.grant.companyId, actorType: "user", actorId: principal.grant.userId,
|
||||
action: "mcp.event_subscribed", entityType: "mcp_subscription", entityId: id, details: { name: input.name, taskId: input.arguments.taskId, expiresAt: expiresAt.toISOString() } });
|
||||
|
||||
Reference in new issue
Block a user