Skip to content

Potential settlement gating issue: paid MCP callback runs before settlement #43

@chenshj73

Description

@chenshj73

Hi, I noticed a possible payment-flow ordering issue while reviewing the current source.

In packages/js-sdk/src/handler/server/plugins/with-x402.ts, withX402 builds a payment requirement for the MCP tool resource, verifies the submitted payment, executes the wrapped callback, and only then settles.

The requirement is scoped to the tool resource and payee:

172:               reqs.push({
173:                 scheme: "exact" as const,
174:                 network,
175:                 maxAmountRequired,
176:                 payTo: normalizedPayTo,
177:                 asset: normalizedAsset,
178:                 maxTimeoutSeconds: 300,
179:                 resource: `mcp://${name}`,
180:                 mimeType: "application/json",
181:                 description,
182:                 extra,
183:               });

The submitted payment is decoded and verified first:

272:         // Decode & verify
273:         let decoded: PaymentPayload;
274:         try {
275:           decoded = decodeX402Payment(token);
276:           decoded.x402Version = x402Version;
277:         } catch {
278:           return paymentRequired("INVALID_PAYMENT");
279:         }
280:
281:         const selected = findMatchingPaymentRequirements(accepts, decoded);
286:         const vr = await verify(decoded, selected);
287:         if (!vr.isValid) {
288:           return paymentRequired(vr.invalidReason ?? "INVALID_PAYMENT", {
289:             payer: vr.payer,
290:           });
291:         }

But the paid tool callback runs before settlement:

293:         // Execute tool
294:         let result: CallToolResult;
295:         let failed = false;
296:         try {
297:           result = await cb(args, extra);
...
316:         // Settle only on success
317:         if (!failed) {
318:           try {
319:             const s = await settle(decoded, selected);
320:             if (s.success) {

So the current order appears to be:

payment header -> decode/select requirement -> verify -> cb(args, extra) -> settle

Why this may matter:

  • Verification is not always the same as completed settlement.
  • A wrapped MCP tool callback can perform expensive computation, external calls, or state-changing work.
  • If settle(decoded, selected) fails after cb(args, extra) has already run, the wrapper can return a payment error but cannot undo the paid work.

A safer design would complete settlement before invoking externally effective callbacks, or clearly document that pre-settlement callbacks must be side-effect-free. A regression test where verify succeeds and settle fails after a callback would make the intended behavior explicit.

I am reporting this as a potential issue rather than a confirmed exploit, since the final impact depends on the callbacks users register with withX402.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions