Skip to content

Commit 5c56929

Browse files
authored
fix: mcp pay fetch (#69)
* fix: mcp pay fetch * resolve comments * resolve comments * set default timeout to 30s
1 parent 71ccdf7 commit 5c56929

File tree

6 files changed

+281
-27
lines changed

6 files changed

+281
-27
lines changed

packages/use-agently-sdk/a2a.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1+
import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
22
import { randomUUID } from "node:crypto";
33
import { extractAgentText, extractStreamEventText, sendA2AMessage, sendA2AMessageStream, getA2ACard } from "./a2a";
44
import { createA2AClient, createPaymentFetch, createDryRunFetch, DryRunPaymentRequired } from "./client";
@@ -13,6 +13,8 @@ import {
1313
} from "./testing";
1414
import { accounts } from "x402-fl/testcontainers";
1515

16+
setDefaultTimeout(30_000);
17+
1618
describe("extractAgentText", () => {
1719
test("returns fallback for null/undefined result", () => {
1820
expect(extractAgentText(null)).toStrictEqual("The agent processed your request but returned no response.");

packages/use-agently-sdk/mcp.test.ts

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { afterAll, beforeAll, describe, expect, test, spyOn } from "bun:test";
1+
import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test, spyOn } from "bun:test";
22
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4+
import { generatePrivateKey } from "viem/accounts";
45
import { listMcpTools, callMcpTool } from "./mcp";
56
import { createMcpPaymentClient, DryRunPaymentRequired, USER_AGENT } from "./client";
67
import { EvmPrivateKeyWallet } from "./wallets/evm-private-key";
@@ -15,6 +16,8 @@ import {
1516
import { accounts } from "x402-fl/testcontainers";
1617
import pkg from "./package.json" with { type: "json" };
1718

19+
setDefaultTimeout(30_000);
20+
1821
let fixture: X402FacilitatorLocal;
1922

2023
beforeAll(async () => {
@@ -36,7 +39,7 @@ async function createMcpClient(): Promise<Client> {
3639
return client;
3740
}
3841

39-
describe("mcp free (sdk)", () => {
42+
describe("mcp free", () => {
4043
test("calls echo tool and returns text content", async () => {
4144
const client = await createMcpClient();
4245
try {
@@ -54,7 +57,7 @@ describe("mcp free (sdk)", () => {
5457
});
5558
});
5659

57-
describe("mcp x402 payment (sdk)", () => {
60+
describe("mcp x402 payment", () => {
5861
test("paid tool call succeeds with funded wallet and debits sender exactly $0.001", async () => {
5962
const wallet = new EvmPrivateKeyWallet(TEST_PRIVATE_KEY, fixture.container.getRpcUrl());
6063
const client = await createMcpClient();
@@ -89,7 +92,7 @@ describe("mcp x402 payment (sdk)", () => {
8992
});
9093
});
9194

92-
describe("listMcpTools (high-level)", () => {
95+
describe("listMcpTools", () => {
9396
test("returns array including echo tool", async () => {
9497
const tools = await listMcpTools(mcpUrl());
9598
expect(Array.isArray(tools)).toBe(true);
@@ -109,7 +112,7 @@ describe("listMcpTools (high-level)", () => {
109112
});
110113
});
111114

112-
describe("callMcpTool (high-level)", () => {
115+
describe("callMcpTool", () => {
113116
test("free tool call succeeds in dry-run mode", async () => {
114117
const result = await callMcpTool(mcpUrl(), "echo", { message: "hello high-level" });
115118
const content = result.content as Array<{ type: string; text: string }>;
@@ -152,4 +155,91 @@ describe("callMcpTool (high-level)", () => {
152155
expect(err.message).toContain("USDC");
153156
}
154157
});
158+
159+
test("custom fetchImpl is used for paid tool call", async () => {
160+
const wallet = new EvmPrivateKeyWallet(TEST_PRIVATE_KEY, fixture.container.getRpcUrl());
161+
const spy = spyOn(globalThis, "fetch");
162+
try {
163+
// @ts-expect-error — Bun's typeof fetch includes preconnect namespace (oven-sh/bun#23741)
164+
const customFetch: typeof fetch = (input, init) => {
165+
const headers = new Headers(init?.headers);
166+
headers.set("X-Custom-Header", "pay-test");
167+
return globalThis.fetch(input, { ...init, headers });
168+
};
169+
170+
const result = await callMcpTool(
171+
mcpUrl(),
172+
"paid-echo-tool",
173+
{ message: "hello custom fetch pay" },
174+
{
175+
transaction: PayTransaction(wallet),
176+
fetchImpl: customFetch,
177+
},
178+
);
179+
const content = result.content as Array<{ type: string; text: string }>;
180+
expect(content[0].text).toStrictEqual("hello custom fetch pay");
181+
182+
// Verify the custom header was sent on at least one underlying fetch call
183+
const hasCustomHeader = spy.mock.calls.some((call) => {
184+
const headers = new Headers(call[1]?.headers);
185+
return headers.get("X-Custom-Header") === "pay-test";
186+
});
187+
expect(hasCustomHeader).toBe(true);
188+
} finally {
189+
spy.mockRestore();
190+
}
191+
});
192+
193+
test("custom fetchImpl is used for dry-run tool call", async () => {
194+
const spy = spyOn(globalThis, "fetch");
195+
try {
196+
// @ts-expect-error — Bun's typeof fetch includes preconnect namespace (oven-sh/bun#23741)
197+
const customFetch: typeof fetch = (input, init) => {
198+
const headers = new Headers(init?.headers);
199+
headers.set("X-Custom-Header", "dryrun-test");
200+
return globalThis.fetch(input, { ...init, headers });
201+
};
202+
203+
const result = await callMcpTool(
204+
mcpUrl(),
205+
"echo",
206+
{ message: "hello custom fetch dryrun" },
207+
{ fetchImpl: customFetch },
208+
);
209+
const content = result.content as Array<{ type: string; text: string }>;
210+
expect(content[0].text).toStrictEqual("hello custom fetch dryrun");
211+
212+
const hasCustomHeader = spy.mock.calls.some((call) => {
213+
const headers = new Headers(call[1]?.headers);
214+
return headers.get("X-Custom-Header") === "dryrun-test";
215+
});
216+
expect(hasCustomHeader).toBe(true);
217+
} finally {
218+
spy.mockRestore();
219+
}
220+
});
221+
222+
test("paid tool with unfunded wallet returns isError with insufficient_funds", async () => {
223+
const unfundedKey = generatePrivateKey();
224+
const unfundedWallet = new EvmPrivateKeyWallet(unfundedKey, fixture.container.getRpcUrl());
225+
226+
const result = await callMcpTool(
227+
mcpUrl(),
228+
"paid-echo-tool",
229+
{ message: "should fail — no funds" },
230+
{ transaction: PayTransaction(unfundedWallet) },
231+
);
232+
233+
expect(result.isError).toStrictEqual(true);
234+
235+
const content = result.content as Array<{ type: string; text?: string }>;
236+
expect(content.length).toBeGreaterThan(0);
237+
expect(content[0].type).toStrictEqual("text");
238+
239+
const parsed = JSON.parse(content[0].text!);
240+
expect(parsed.x402Version).toBeDefined();
241+
expect(parsed.accepts).toBeDefined();
242+
expect(Array.isArray(parsed.accepts)).toBe(true);
243+
expect(parsed.error).toContain("insufficient_funds");
244+
});
155245
});

packages/use-agently-sdk/mcp.ts

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
22
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
33
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
44
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5-
import { DryRunPaymentRequired, resolveFetchForTransaction, createMcpPaymentClient } from "./client.js";
6-
import { DryRunTransaction, type TransactionMode } from "./utils/transaction.js";
5+
import type { x402MCPClient, x402MCPToolCallResult } from "@x402/mcp";
6+
import { DryRunPaymentRequired, resolveFetchForTransaction, createMcpPaymentClient, clientFetch } from "./client.js";
7+
import { DryRunTransaction, PayTransaction, type TransactionMode } from "./utils/transaction.js";
8+
import type { Wallet } from "./wallets/wallet.js";
79
import pkg from "./package.json" with { type: "json" };
810

911
export interface McpCallOptions {
@@ -22,15 +24,30 @@ export function resolveMcpUrl(input: string): string {
2224
return url.toString();
2325
}
2426

27+
async function createMcpClient(
28+
mcpUrl: string,
29+
options: { clientInfo?: { name: string; version: string }; fetchImpl?: typeof fetch; wallet: Wallet },
30+
): Promise<x402MCPClient>;
2531
async function createMcpClient(
2632
mcpUrl: string,
2733
options?: { clientInfo?: { name: string; version: string }; fetchImpl?: typeof fetch },
28-
): Promise<Client> {
34+
): Promise<Client>;
35+
async function createMcpClient(
36+
mcpUrl: string,
37+
options?: { clientInfo?: { name: string; version: string }; fetchImpl?: typeof fetch; wallet?: Wallet },
38+
): Promise<Client | x402MCPClient> {
2939
const client = new Client(options?.clientInfo ?? { name: "@use-agently/sdk", version: pkg.version });
3040
const transport = new StreamableHTTPClientTransport(
3141
new URL(mcpUrl),
3242
options?.fetchImpl ? { fetch: options.fetchImpl } : undefined,
3343
);
44+
45+
if (options?.wallet) {
46+
const x402Client = createMcpPaymentClient(client, options.wallet);
47+
await x402Client.connect(transport);
48+
return x402Client;
49+
}
50+
3451
await client.connect(transport);
3552
return client;
3653
}
@@ -49,23 +66,47 @@ export async function listMcpTools(uri: string, options?: McpCallOptions): Promi
4966
}
5067

5168
/** Call a tool on an MCP server, with optional payment support. Defaults to dry-run mode. */
69+
export async function callMcpTool(uri: string, tool: string, args?: Record<string, unknown>): Promise<CallToolResult>;
70+
export async function callMcpTool(
71+
uri: string,
72+
tool: string,
73+
args: Record<string, unknown> | undefined,
74+
options: McpCallOptions & { transaction: PayTransaction },
75+
): Promise<x402MCPToolCallResult>;
76+
export async function callMcpTool(
77+
uri: string,
78+
tool: string,
79+
args: Record<string, unknown> | undefined,
80+
options: McpCallOptions & { transaction: DryRunTransaction },
81+
): Promise<CallToolResult>;
82+
export async function callMcpTool(
83+
uri: string,
84+
tool: string,
85+
args: Record<string, unknown> | undefined,
86+
options: McpCallOptions,
87+
): Promise<CallToolResult | x402MCPToolCallResult>;
5288
export async function callMcpTool(
5389
uri: string,
5490
tool: string,
5591
args?: Record<string, unknown>,
5692
options?: McpCallOptions,
57-
): Promise<CallToolResult> {
93+
): Promise<CallToolResult | x402MCPToolCallResult> {
5894
const mcpUrl = resolveMcpUrl(uri);
5995
const transaction = options?.transaction ?? DryRunTransaction;
6096
const resolvedFetch = resolveFetchForTransaction(transaction, options?.fetchImpl);
6197

6298
if (transaction.mode === "pay") {
63-
const client = await createMcpClient(mcpUrl, { clientInfo: options?.clientInfo, fetchImpl: resolvedFetch });
99+
// Use the caller's fetchImpl (e.g. User-Agent), but skip the payment-wrapped fetch —
100+
// x402MCPClient handles payment at the MCP protocol level.
101+
const x402Client = await createMcpClient(mcpUrl, {
102+
clientInfo: options?.clientInfo,
103+
fetchImpl: options?.fetchImpl ?? clientFetch,
104+
wallet: transaction.wallet,
105+
});
64106
try {
65-
const x402Client = createMcpPaymentClient(client, transaction.wallet);
66-
return (await x402Client.callTool(tool, args ?? {})) as unknown as CallToolResult;
107+
return await x402Client.callTool(tool, args ?? {});
67108
} finally {
68-
await client.close();
109+
await x402Client.close();
69110
}
70111
}
71112

@@ -78,7 +119,7 @@ export async function callMcpTool(
78119
if (content?.length > 0 && content[0].type === "text" && content[0].text) {
79120
try {
80121
const parsed = JSON.parse(content[0].text);
81-
if (parsed?.accepts) {
122+
if (Array.isArray(parsed?.accepts) && parsed.accepts.length > 0) {
82123
throw new DryRunPaymentRequired(parsed.accepts);
83124
}
84125
} catch (e) {

packages/use-agently/src/commands/a2a.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1+
import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
22
import { randomUUID } from "node:crypto";
33
import { EvmPrivateKeyWallet, DryRunPaymentRequired, createA2AClient } from "@use-agently/sdk";
44
import { accounts } from "x402-fl/testcontainers";
@@ -16,6 +16,8 @@ import {
1616
} from "../testing";
1717
import { extractAgentText } from "./a2a";
1818

19+
setDefaultTimeout(30_000);
20+
1921
mockConfigModule();
2022

2123
const { cli } = await import("../cli");

0 commit comments

Comments
 (0)