Skip to content

Commit 91b2def

Browse files
committed
fix(mcp): isolate MPP challenge amounts per concurrent call
Replace shared mutable `lastChallengeAmount` with AsyncLocalStorage so concurrent tool calls through the MCP proxy each track their own payment amount without races.
1 parent fa912f1 commit 91b2def

3 files changed

Lines changed: 40 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.11.5] - 2026-04-07
6+
7+
### Fixed
8+
9+
- Fix race condition in MPP MCP proxy: concurrent tool calls no longer overwrite each other's payment amounts (replaced shared mutable variable with AsyncLocalStorage per-call context)
10+
511
## [0.11.4] - 2026-04-03
612

713
### Added
@@ -64,6 +70,7 @@ All notable changes to this project will be documented in this file.
6470
- Strip `outputSchema` from proxied MCP tool definitions
6571
- Downgrade @modelcontextprotocol/sdk to 1.27.1 for compatibility
6672

73+
[0.11.5]: https://github.com/cascade-protocol/x402-proxy/compare/v0.11.4...v0.11.5
6774
[0.11.4]: https://github.com/cascade-protocol/x402-proxy/compare/v0.11.3...v0.11.4
6875
[0.11.3]: https://github.com/cascade-protocol/x402-proxy/compare/v0.11.2...v0.11.3
6976
[0.11.2]: https://github.com/cascade-protocol/x402-proxy/compare/v0.11.1...v0.11.2

packages/x402-proxy/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "x402-proxy",
33
"private": true,
4-
"version": "0.11.4",
4+
"version": "0.11.5",
55
"description": "curl for x402 and MPP paid APIs with MCP proxy support. Auto-pays HTTP 402 on Base, Solana, and Tempo.",
66
"type": "module",
77
"sideEffects": false,

packages/x402-proxy/src/commands/mcp.ts

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
declare const __VERSION__: string;
22

3+
import { AsyncLocalStorage } from "node:async_hooks";
34
import { buildCommand, type CommandContext } from "@stricli/core";
45
import { TEMPO_NETWORK } from "../handler.js";
56
import { appendHistory, displayNetwork, formatAmount, type TxRecord } from "../history.js";
@@ -455,15 +456,17 @@ Wallet is auto-generated on first run. No env vars needed.`,
455456
const account = privateKeyToAccount(wallet.evmKey as `0x${string}`);
456457
const maxDeposit = config?.mppSessionBudget ?? "1";
457458

458-
// Wrap tempo methods to capture payment amounts from challenges
459-
let lastChallengeAmount: number | undefined;
459+
// Per-call async context to capture payment amounts without races.
460+
// Each concurrent callTool gets its own store via AsyncLocalStorage.
461+
const challengeAmountStore = new AsyncLocalStorage<{ amount?: number }>();
460462
const tempoMethods = tempo({ account, maxDeposit });
461463
const wrappedMethods = tempoMethods.map((m) => ({
462464
...m,
463465
createCredential: async (params: { challenge: { request: Record<string, unknown> } }) => {
464466
const req = params.challenge.request as { amount?: string; decimals?: number };
465-
if (req.amount) {
466-
lastChallengeAmount = Number(req.amount) / 10 ** (req.decimals ?? 6);
467+
const store = challengeAmountStore.getStore();
468+
if (req.amount && store) {
469+
store.amount = Number(req.amount) / 10 ** (req.decimals ?? 6);
467470
}
468471
return (m.createCredential as (p: unknown) => Promise<string>)(params);
469472
},
@@ -515,31 +518,32 @@ Wallet is auto-generated on first run. No env vars needed.`,
515518

516519
localServer.setRequestHandler(CallToolRequestSchema, async (request) => {
517520
const { name, arguments: args } = request.params;
518-
const result = await mppClient.callTool({ name, arguments: args ?? {} });
519-
520-
// Record MPP payment if receipt present
521-
if (result.receipt) {
522-
const record: TxRecord = {
523-
t: Date.now(),
524-
ok: true,
525-
kind: "mpp_payment",
526-
net: TEMPO_NETWORK,
527-
from: wallet.evmAddress ?? "unknown",
528-
tx: result.receipt.reference,
529-
amount: lastChallengeAmount,
530-
token: "USDC",
531-
label: `mcp:${name}`,
532-
};
533-
appendHistory(getHistoryPath(), record);
534-
const amountStr =
535-
lastChallengeAmount !== undefined ? formatAmount(lastChallengeAmount, "USDC") : "";
536-
warn(
537-
` MPP payment for tool "${name}" (Tempo)${amountStr ? ` \u00b7 ${amountStr}` : ""}`,
538-
);
539-
lastChallengeAmount = undefined;
540-
}
521+
const store = { amount: undefined as number | undefined };
522+
return challengeAmountStore.run(store, async () => {
523+
const result = await mppClient.callTool({ name, arguments: args ?? {} });
524+
525+
// Record MPP payment if receipt present
526+
if (result.receipt) {
527+
const record: TxRecord = {
528+
t: Date.now(),
529+
ok: true,
530+
kind: "mpp_payment",
531+
net: TEMPO_NETWORK,
532+
from: wallet.evmAddress ?? "unknown",
533+
tx: result.receipt.reference,
534+
amount: store.amount,
535+
token: "USDC",
536+
label: `mcp:${name}`,
537+
};
538+
appendHistory(getHistoryPath(), record);
539+
const amountStr = store.amount !== undefined ? formatAmount(store.amount, "USDC") : "";
540+
warn(
541+
` MPP payment for tool "${name}" (Tempo)${amountStr ? ` \u00b7 ${amountStr}` : ""}`,
542+
);
543+
}
541544

542-
return normalizeCallToolResult(result as CallToolResultLike);
545+
return normalizeCallToolResult(result as CallToolResultLike);
546+
});
543547
});
544548

545549
if (remoteResources.length > 0) {

0 commit comments

Comments
 (0)