@agentwonderland/mcp 0.1.25 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/core/__tests__/api-client.test.d.ts +1 -0
  2. package/dist/core/__tests__/api-client.test.js +51 -0
  3. package/dist/core/__tests__/formatters.test.js +10 -0
  4. package/dist/core/__tests__/passes-api.test.d.ts +1 -0
  5. package/dist/core/__tests__/passes-api.test.js +27 -0
  6. package/dist/core/__tests__/payments.test.js +10 -6
  7. package/dist/core/__tests__/principal.test.js +41 -4
  8. package/dist/core/__tests__/solana-charge.test.d.ts +1 -0
  9. package/dist/core/__tests__/solana-charge.test.js +50 -0
  10. package/dist/core/api-client.d.ts +1 -0
  11. package/dist/core/api-client.js +8 -3
  12. package/dist/core/balances.d.ts +1 -0
  13. package/dist/core/balances.js +56 -0
  14. package/dist/core/base-charge.js +13 -6
  15. package/dist/core/formatters.d.ts +3 -2
  16. package/dist/core/formatters.js +7 -1
  17. package/dist/core/passes.d.ts +1 -1
  18. package/dist/core/passes.js +5 -2
  19. package/dist/core/payments.d.ts +1 -0
  20. package/dist/core/payments.js +20 -7
  21. package/dist/core/principal.d.ts +3 -0
  22. package/dist/core/principal.js +29 -1
  23. package/dist/core/settings.d.ts +20 -0
  24. package/dist/core/settings.js +19 -0
  25. package/dist/core/solana-charge.d.ts +5 -0
  26. package/dist/core/solana-charge.js +29 -7
  27. package/dist/core/tempo-charge.d.ts +7 -0
  28. package/dist/core/tempo-charge.js +84 -0
  29. package/dist/index.js +5 -4
  30. package/dist/tools/__tests__/jobs.test.d.ts +1 -0
  31. package/dist/tools/__tests__/jobs.test.js +71 -0
  32. package/dist/tools/__tests__/run.test.d.ts +1 -0
  33. package/dist/tools/__tests__/run.test.js +149 -0
  34. package/dist/tools/__tests__/solve.test.d.ts +1 -0
  35. package/dist/tools/__tests__/solve.test.js +158 -0
  36. package/dist/tools/__tests__/wallet.test.d.ts +1 -0
  37. package/dist/tools/__tests__/wallet.test.js +230 -0
  38. package/dist/tools/_payment-confirmation.js +1 -1
  39. package/dist/tools/jobs.js +8 -1
  40. package/dist/tools/passes.js +11 -6
  41. package/dist/tools/run.js +16 -12
  42. package/dist/tools/solve.js +22 -15
  43. package/dist/tools/wallet.js +32 -12
  44. package/package.json +2 -2
  45. package/src/core/__tests__/api-client.test.ts +78 -0
  46. package/src/core/__tests__/formatters.test.ts +12 -0
  47. package/src/core/__tests__/passes-api.test.ts +33 -0
  48. package/src/core/__tests__/payments.test.ts +17 -6
  49. package/src/core/__tests__/principal.test.ts +49 -4
  50. package/src/core/__tests__/solana-charge.test.ts +59 -0
  51. package/src/core/api-client.ts +16 -3
  52. package/src/core/balances.ts +63 -0
  53. package/src/core/base-charge.ts +13 -6
  54. package/src/core/formatters.ts +10 -3
  55. package/src/core/passes.ts +5 -2
  56. package/src/core/payments.ts +22 -7
  57. package/src/core/principal.ts +42 -1
  58. package/src/core/settings.ts +36 -0
  59. package/src/core/solana-charge.ts +43 -9
  60. package/src/core/tempo-charge.ts +104 -0
  61. package/src/index.ts +5 -4
  62. package/src/tools/__tests__/jobs.test.ts +89 -0
  63. package/src/tools/__tests__/run.test.ts +176 -0
  64. package/src/tools/__tests__/solve.test.ts +186 -0
  65. package/src/tools/__tests__/wallet.test.ts +289 -0
  66. package/src/tools/_payment-confirmation.ts +1 -1
  67. package/src/tools/jobs.ts +10 -1
  68. package/src/tools/passes.ts +11 -5
  69. package/src/tools/run.ts +19 -11
  70. package/src/tools/solve.ts +25 -14
  71. package/src/tools/wallet.ts +30 -14
@@ -9,6 +9,7 @@ import { Connection, Keypair, PublicKey, Transaction, sendAndConfirmTransaction
9
9
  import {
10
10
  ASSOCIATED_TOKEN_PROGRAM_ID,
11
11
  TOKEN_PROGRAM_ID,
12
+ createAssociatedTokenAccountInstruction,
12
13
  createTransferCheckedInstruction,
13
14
  getAssociatedTokenAddressSync,
14
15
  } from "@solana/spl-token";
@@ -75,24 +76,40 @@ interface SolanaChargeClientConfig {
75
76
  rpcUrl?: string;
76
77
  }
77
78
 
78
- async function resolveRecipientTokenAccount(
79
+ export async function resolveRecipientTokenAccount(
79
80
  connection: Connection,
80
81
  mint: PublicKey,
81
82
  recipient: PublicKey,
82
- ): Promise<PublicKey> {
83
+ ): Promise<{ tokenAccount: PublicKey; needsCreateAssociatedAccount: boolean }> {
83
84
  const accountInfo = await connection.getParsedAccountInfo(recipient, "confirmed");
84
85
  const owner = accountInfo.value?.owner;
85
86
  if (owner && owner.toBase58() === TOKEN_PROGRAM_ID.toBase58()) {
86
- return recipient;
87
+ return {
88
+ tokenAccount: recipient,
89
+ needsCreateAssociatedAccount: false,
90
+ };
87
91
  }
88
92
 
89
- return getAssociatedTokenAddressSync(
93
+ const associatedTokenAccount = getAssociatedTokenAddressSync(
90
94
  mint,
91
95
  recipient,
92
96
  false,
93
97
  TOKEN_PROGRAM_ID,
94
98
  ASSOCIATED_TOKEN_PROGRAM_ID,
95
99
  );
100
+ const associatedAccountInfo = await connection.getParsedAccountInfo(associatedTokenAccount, "confirmed");
101
+ const associatedOwner = associatedAccountInfo.value?.owner;
102
+ if (associatedOwner && associatedOwner.toBase58() === TOKEN_PROGRAM_ID.toBase58()) {
103
+ return {
104
+ tokenAccount: associatedTokenAccount,
105
+ needsCreateAssociatedAccount: false,
106
+ };
107
+ }
108
+
109
+ return {
110
+ tokenAccount: associatedTokenAccount,
111
+ needsCreateAssociatedAccount: true,
112
+ };
96
113
  }
97
114
 
98
115
  export function solanaChargeClient(config: SolanaChargeClientConfig) {
@@ -114,27 +131,44 @@ export function solanaChargeClient(config: SolanaChargeClientConfig) {
114
131
  TOKEN_PROGRAM_ID,
115
132
  ASSOCIATED_TOKEN_PROGRAM_ID,
116
133
  );
117
- const destinationTokenAccount = await resolveRecipientTokenAccount(
134
+ const destination = await resolveRecipientTokenAccount(
118
135
  connection,
119
136
  mint,
120
137
  recipient,
121
138
  );
139
+ const instructions = [];
140
+
141
+ if (destination.needsCreateAssociatedAccount) {
142
+ instructions.push(
143
+ createAssociatedTokenAccountInstruction(
144
+ owner,
145
+ destination.tokenAccount,
146
+ recipient,
147
+ mint,
148
+ TOKEN_PROGRAM_ID,
149
+ ASSOCIATED_TOKEN_PROGRAM_ID,
150
+ ),
151
+ );
152
+ }
122
153
 
123
- const instruction = createTransferCheckedInstruction(
154
+ instructions.push(createTransferCheckedInstruction(
124
155
  sourceTokenAccount,
125
156
  mint,
126
- destinationTokenAccount,
157
+ destination.tokenAccount,
127
158
  owner,
128
159
  amount,
129
160
  decimals,
130
- );
161
+ ));
131
162
 
132
163
  const { blockhash } = await connection.getLatestBlockhash("confirmed");
133
164
 
134
165
  const transaction = new Transaction({
135
166
  feePayer: owner,
136
167
  recentBlockhash: blockhash,
137
- }).add(instruction);
168
+ });
169
+ for (const instruction of instructions) {
170
+ transaction.add(instruction);
171
+ }
138
172
 
139
173
  const signature = await sendAndConfirmTransaction(connection, transaction, [keypair], {
140
174
  commitment: "confirmed",
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Custom MPP payment method: Tempo EVM USDC charge (client-side).
3
+ *
4
+ * Sends a standard ERC-20 transfer on Tempo and returns the tx hash as the
5
+ * payment credential.
6
+ */
7
+ import { Method, Credential, z } from "mppx";
8
+ import type { LocalAccount } from "viem/accounts";
9
+ import { toAtomicAmount } from "./amount-utils.js";
10
+
11
+ const TEMPO_USDC = "0x20c000000000000000000000b9537d11c60e8b50" as const;
12
+ const PATH_USD = "0x20c0000000000000000000000000000000000000" as const;
13
+ const TEMPO_MAINNET_CHAIN_ID = 4217;
14
+ const TEMPO_TESTNET_CHAIN_ID = 42431;
15
+
16
+ const ERC20_ABI = [
17
+ {
18
+ type: "function",
19
+ name: "transfer",
20
+ inputs: [
21
+ { name: "to", type: "address" },
22
+ { name: "value", type: "uint256" },
23
+ ],
24
+ outputs: [{ name: "", type: "bool" }],
25
+ stateMutability: "nonpayable",
26
+ },
27
+ ] as const;
28
+
29
+ const tempoChargeMethod = Method.from({
30
+ name: "tempo" as const,
31
+ intent: "charge" as const,
32
+ schema: {
33
+ credential: {
34
+ payload: z.object({
35
+ hash: z.string(),
36
+ type: z.literal("hash"),
37
+ }),
38
+ },
39
+ request: z.pipe(
40
+ z.object({
41
+ amount: z.string(),
42
+ currency: z.string(),
43
+ recipient: z.string(),
44
+ chainId: z.optional(z.number()),
45
+ decimals: z.optional(z.number()),
46
+ }),
47
+ z.transform((v: any) => ({
48
+ ...v,
49
+ methodDetails: {
50
+ chainId: v.chainId ?? TEMPO_MAINNET_CHAIN_ID,
51
+ },
52
+ })),
53
+ ),
54
+ },
55
+ });
56
+
57
+ interface TempoChargeClientConfig {
58
+ account: LocalAccount;
59
+ rpcUrl?: string;
60
+ }
61
+
62
+ export function tempoChargeClient(config: TempoChargeClientConfig) {
63
+ return Method.toClient(tempoChargeMethod as any, {
64
+ async createCredential({ challenge }: any) {
65
+ const { request } = challenge;
66
+ const amount = toAtomicAmount(request.amount, request.decimals ?? 6);
67
+ const recipient = request.recipient as `0x${string}`;
68
+ const currency = (request.currency ?? TEMPO_USDC) as `0x${string}`;
69
+ const chainId = request.methodDetails?.chainId ?? request.chainId ?? TEMPO_MAINNET_CHAIN_ID;
70
+
71
+ const { createWalletClient, createPublicClient, http } = await import("viem");
72
+ const { tempo, tempoModerato } = await import("viem/chains");
73
+
74
+ const chain = chainId === TEMPO_TESTNET_CHAIN_ID ? tempoModerato : tempo;
75
+ const rpcUrl = config.rpcUrl ?? chain.rpcUrls.default.http[0];
76
+
77
+ const walletClient = createWalletClient({
78
+ account: config.account,
79
+ chain,
80
+ transport: http(rpcUrl),
81
+ });
82
+
83
+ const publicClient = createPublicClient({
84
+ chain,
85
+ transport: http(rpcUrl),
86
+ });
87
+
88
+ const hash = await walletClient.writeContract({
89
+ address: currency,
90
+ abi: ERC20_ABI,
91
+ functionName: "transfer",
92
+ args: [recipient, amount],
93
+ });
94
+
95
+ await publicClient.waitForTransactionReceipt({ hash });
96
+
97
+ return Credential.serialize({
98
+ challenge,
99
+ payload: { hash, type: "hash" as const },
100
+ source: `did:pkh:eip155:${chainId}:${config.account.address}`,
101
+ });
102
+ },
103
+ });
104
+ }
package/src/index.ts CHANGED
@@ -47,11 +47,12 @@ export async function startMcpServer(): Promise<void> {
47
47
  "5. Ask user to rate or tip after a successful run",
48
48
  "",
49
49
  "PAYMENT:",
50
- "- Credit/debit card is the default and easiest way to pay — open the setup page to connect, no funding needed.",
51
- "- Crypto wallets (Tempo USDC, Base USDC, Solana USDC) are available for advanced users.",
50
+ "- Crypto wallets (Tempo USDC, Base USDC, Solana USDC) are the core supported rails.",
51
+ "- Saved cards can also be used, but card-backed MPP depends on Stripe Shared Payment Token availability in the current environment.",
52
52
  "- Card and crypto are SEPARATE payment methods. Card charges a credit card; crypto sends USDC on-chain.",
53
- "- Card is set as the default payment method when configured.",
54
- "- run_agent() and solve() require pay_with explicitly.",
53
+ "- When a card is configured, it becomes the default payment method. Use wallet_status to confirm whether `Card MPP: ready` before relying on it.",
54
+ "- run_agent() and solve() auto-detect the default compatible payment method when pay_with is omitted.",
55
+ "- Include pay_with explicitly when you need a specific rail or want deterministic control over the payment method used.",
55
56
  "- Use wallet_status to see the exact payment methods the user has configured before calling a paid tool.",
56
57
  "- Use open_observability_dashboard() to open a secure web usage dashboard for runs/spend/rebates.",
57
58
  "- Payment is automatic once configured. Users are never charged for failed runs.",
@@ -0,0 +1,89 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mockApiGet = vi.fn();
4
+ const mockIsAuthenticated = vi.fn();
5
+ const mockHasWalletConfigured = vi.fn();
6
+ const mockGetWalletAddress = vi.fn();
7
+ const mockFormatRunResult = vi.fn();
8
+
9
+ vi.mock("../../core/api-client.js", () => ({
10
+ apiGet: mockApiGet,
11
+ }));
12
+
13
+ vi.mock("../../core/config.js", () => ({
14
+ isAuthenticated: mockIsAuthenticated,
15
+ }));
16
+
17
+ vi.mock("../../core/payments.js", () => ({
18
+ hasWalletConfigured: mockHasWalletConfigured,
19
+ getWalletAddress: mockGetWalletAddress,
20
+ }));
21
+
22
+ vi.mock("../../core/formatters.js", () => ({
23
+ formatRunResult: mockFormatRunResult,
24
+ }));
25
+
26
+ function flattenToolText(result: unknown): string {
27
+ const content = (result as { content?: Array<{ type?: string; text?: string }> })?.content ?? [];
28
+ return content
29
+ .filter((item) => item?.type === "text")
30
+ .map((item) => item.text ?? "")
31
+ .join("\n\n");
32
+ }
33
+
34
+ function makeServerHarness() {
35
+ const handlers = new Map<string, (args: Record<string, unknown>) => Promise<unknown>>();
36
+ return {
37
+ handlers,
38
+ server: {
39
+ tool(name: string, _description: string, _schema: unknown, handler: (args: Record<string, unknown>) => Promise<unknown>) {
40
+ handlers.set(name, handler);
41
+ },
42
+ },
43
+ };
44
+ }
45
+
46
+ describe("job MCP tools", () => {
47
+ beforeEach(() => {
48
+ vi.resetModules();
49
+ vi.clearAllMocks();
50
+ mockIsAuthenticated.mockReturnValue(false);
51
+ mockHasWalletConfigured.mockReturnValue(true);
52
+ mockGetWalletAddress.mockResolvedValue("0xabc123");
53
+ mockFormatRunResult.mockReturnValue("formatted job result");
54
+ });
55
+
56
+ it("appends the wallet address when looking up a job for an unauthenticated consumer", async () => {
57
+ mockApiGet.mockResolvedValueOnce({ status: "completed", job_id: "job-123" });
58
+
59
+ const { registerJobTools } = await import("../jobs.js");
60
+ const harness = makeServerHarness();
61
+ registerJobTools(harness.server as never);
62
+
63
+ const getJob = harness.handlers.get("get_job");
64
+ expect(getJob).toBeDefined();
65
+
66
+ const result = await getJob!({ job_id: "job-123" });
67
+ const text = flattenToolText(result);
68
+
69
+ expect(mockApiGet).toHaveBeenCalledWith("/jobs/job-123?wallet=0xabc123");
70
+ expect(text).toContain("formatted job result");
71
+ });
72
+
73
+ it("returns a processing message while the async job is still running", async () => {
74
+ mockApiGet.mockResolvedValueOnce({ status: "processing" });
75
+
76
+ const { registerJobTools } = await import("../jobs.js");
77
+ const harness = makeServerHarness();
78
+ registerJobTools(harness.server as never);
79
+
80
+ const getJob = harness.handlers.get("get_job");
81
+ expect(getJob).toBeDefined();
82
+
83
+ const result = await getJob!({ job_id: "job-456" });
84
+ const text = flattenToolText(result);
85
+
86
+ expect(mockApiGet).toHaveBeenCalledWith("/jobs/job-456?wallet=0xabc123");
87
+ expect(text).toContain("Job job-456 is still processing");
88
+ });
89
+ });
@@ -0,0 +1,176 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mockApiGet = vi.fn();
4
+ const mockApiPost = vi.fn();
5
+ const mockApiPostWithPayment = vi.fn();
6
+ const mockGetCompatiblePaymentMethods = vi.fn();
7
+ const mockHasWalletConfigured = vi.fn();
8
+ const mockGetConfiguredMethods = vi.fn();
9
+ const mockGetWalletAddress = vi.fn();
10
+ const mockNormalizePaymentMethod = vi.fn();
11
+ const mockRequiresSpendConfirmation = vi.fn();
12
+ const mockGetDefaultTipAmount = vi.fn();
13
+ const mockCanSpend = vi.fn();
14
+ const mockRecordSpend = vi.fn();
15
+ const mockRequiresPolicyConfirmation = vi.fn();
16
+ const mockUploadLocalFiles = vi.fn();
17
+ const mockStoreFeedbackToken = vi.fn();
18
+ const mockGetActiveCreditPack = vi.fn();
19
+ const mockGetCreditPackInventory = vi.fn();
20
+ const mockGetCreditPackProgram = vi.fn();
21
+ const mockGetOrCreatePendingCardSetup = vi.fn();
22
+ const mockFormatCardSetupBlocks = vi.fn();
23
+
24
+ vi.mock("../../core/api-client.js", () => ({
25
+ apiGet: mockApiGet,
26
+ apiPost: mockApiPost,
27
+ apiPostWithPayment: mockApiPostWithPayment,
28
+ }));
29
+
30
+ vi.mock("../../core/card-setup.js", () => ({
31
+ getOrCreatePendingCardSetup: mockGetOrCreatePendingCardSetup,
32
+ formatCardSetupBlocks: mockFormatCardSetupBlocks,
33
+ }));
34
+
35
+ vi.mock("../../core/payments.js", () => ({
36
+ getCompatiblePaymentMethods: mockGetCompatiblePaymentMethods,
37
+ getConfiguredMethods: mockGetConfiguredMethods,
38
+ hasWalletConfigured: mockHasWalletConfigured,
39
+ getWalletAddress: mockGetWalletAddress,
40
+ normalizePaymentMethod: mockNormalizePaymentMethod,
41
+ }));
42
+
43
+ vi.mock("../../core/config.js", () => ({
44
+ requiresSpendConfirmation: mockRequiresSpendConfirmation,
45
+ getDefaultTipAmount: mockGetDefaultTipAmount,
46
+ }));
47
+
48
+ vi.mock("../../core/spend-policy.js", () => ({
49
+ canSpend: mockCanSpend,
50
+ recordSpend: mockRecordSpend,
51
+ requiresPolicyConfirmation: mockRequiresPolicyConfirmation,
52
+ }));
53
+
54
+ vi.mock("../../core/file-upload.js", () => ({
55
+ uploadLocalFiles: mockUploadLocalFiles,
56
+ }));
57
+
58
+ vi.mock("../_token-cache.js", () => ({
59
+ storeFeedbackToken: mockStoreFeedbackToken,
60
+ }));
61
+
62
+ vi.mock("../../core/passes.js", () => ({
63
+ formatCreditPackOffer: vi.fn(),
64
+ getActiveCreditPack: mockGetActiveCreditPack,
65
+ getCreditPackInventory: mockGetCreditPackInventory,
66
+ getCreditPackProgram: mockGetCreditPackProgram,
67
+ }));
68
+
69
+ function flattenToolText(result: unknown): string {
70
+ const content = (result as { content?: Array<{ type?: string; text?: string }> })?.content ?? [];
71
+ return content
72
+ .filter((item) => item?.type === "text")
73
+ .map((item) => item.text ?? "")
74
+ .join("\n\n");
75
+ }
76
+
77
+ function makeServerHarness() {
78
+ const handlers = new Map<string, (args: Record<string, unknown>) => Promise<unknown>>();
79
+ return {
80
+ handlers,
81
+ server: {
82
+ tool(name: string, _description: string, _schema: unknown, handler: (args: Record<string, unknown>) => Promise<unknown>) {
83
+ handlers.set(name, handler);
84
+ },
85
+ },
86
+ };
87
+ }
88
+
89
+ const selectedAgent = {
90
+ id: "agent-1",
91
+ name: "MockFixedPrice",
92
+ pricePerRunUsd: "0.01",
93
+ tags: ["translation"],
94
+ mcpSchema: null,
95
+ };
96
+
97
+ describe("run_agent MCP tool", () => {
98
+ beforeEach(() => {
99
+ vi.resetModules();
100
+ vi.clearAllMocks();
101
+
102
+ mockHasWalletConfigured.mockReturnValue(true);
103
+ mockGetConfiguredMethods.mockReturnValue(["card"]);
104
+ mockGetCompatiblePaymentMethods.mockReturnValue(["card"]);
105
+ mockNormalizePaymentMethod.mockImplementation((method: string) => method);
106
+ mockRequiresSpendConfirmation.mockReturnValue(true);
107
+ mockGetDefaultTipAmount.mockReturnValue(0);
108
+ mockCanSpend.mockReturnValue({ ok: true, message: "" });
109
+ mockRequiresPolicyConfirmation.mockReturnValue(false);
110
+ mockUploadLocalFiles.mockResolvedValue({ input: { text: "hello", target_language: "es" }, uploads: [] });
111
+ mockGetActiveCreditPack.mockReturnValue(null);
112
+ mockGetCreditPackInventory.mockResolvedValue([]);
113
+ mockGetCreditPackProgram.mockReturnValue(null);
114
+ mockGetOrCreatePendingCardSetup.mockResolvedValue({ url: "https://example.com/card-setup" });
115
+ mockFormatCardSetupBlocks.mockReturnValue(["card setup"]);
116
+ mockGetWalletAddress.mockResolvedValue("0xabc");
117
+ mockApiGet.mockResolvedValue(selectedAgent);
118
+ });
119
+
120
+ it("quotes explicit agent execution before running", async () => {
121
+ const { registerRunTools } = await import("../run.js");
122
+ const harness = makeServerHarness();
123
+ registerRunTools(harness.server as never);
124
+
125
+ const runAgent = harness.handlers.get("run_agent");
126
+ expect(runAgent).toBeDefined();
127
+
128
+ const result = await runAgent!({
129
+ agent_id: selectedAgent.id,
130
+ input: { text: "hello", target_language: "es" },
131
+ pay_with: "card",
132
+ });
133
+ const text = flattenToolText(result);
134
+
135
+ expect(text).toContain("Ready to run MockFixedPrice");
136
+ expect(text).toContain('run_agent({ agent_id: "agent-1", input: <same>, pay_with: "card", confirmed: true })');
137
+ expect(mockApiPostWithPayment).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it("runs the explicitly selected agent when confirmed", async () => {
141
+ mockApiPostWithPayment.mockResolvedValueOnce({
142
+ status: "success",
143
+ job_id: "job-1",
144
+ agent_id: selectedAgent.id,
145
+ agent_name: selectedAgent.name,
146
+ output: { translated_text: "hola" },
147
+ cost: 0.01,
148
+ });
149
+
150
+ const { registerRunTools } = await import("../run.js");
151
+ const harness = makeServerHarness();
152
+ registerRunTools(harness.server as never);
153
+
154
+ const runAgent = harness.handlers.get("run_agent");
155
+ expect(runAgent).toBeDefined();
156
+
157
+ const result = await runAgent!({
158
+ agent_id: selectedAgent.id,
159
+ input: { text: "hello", target_language: "es" },
160
+ pay_with: "card",
161
+ confirmed: true,
162
+ });
163
+ const text = flattenToolText(result);
164
+
165
+ expect(mockApiPostWithPayment).toHaveBeenCalledWith(
166
+ "/agents/agent-1/run",
167
+ { input: { text: "hello", target_language: "es" } },
168
+ "card",
169
+ );
170
+ expect(mockRecordSpend).toHaveBeenCalledWith("card", 0.01);
171
+ expect(text).toContain("translated_text");
172
+ expect(text).toContain("✓ MockFixedPrice");
173
+ expect(text).toContain("Paid: $0.01 via card");
174
+ expect(text).toContain("Job ID: job-1");
175
+ });
176
+ });
@@ -0,0 +1,186 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mockApiGet = vi.fn();
4
+ const mockApiPost = vi.fn();
5
+ const mockApiPostWithPayment = vi.fn();
6
+ const mockGetCompatiblePaymentMethods = vi.fn();
7
+ const mockHasWalletConfigured = vi.fn();
8
+ const mockGetConfiguredMethods = vi.fn();
9
+ const mockGetAcceptedPaymentMethods = vi.fn();
10
+ const mockGetWalletAddress = vi.fn();
11
+ const mockNormalizePaymentMethod = vi.fn();
12
+ const mockToRegistryPaymentMethod = vi.fn();
13
+ const mockRequiresSpendConfirmation = vi.fn();
14
+ const mockGetDefaultTipAmount = vi.fn();
15
+ const mockCanSpend = vi.fn();
16
+ const mockRecordSpend = vi.fn();
17
+ const mockRequiresPolicyConfirmation = vi.fn();
18
+ const mockUploadLocalFiles = vi.fn();
19
+ const mockStoreFeedbackToken = vi.fn();
20
+ const mockGetActiveCreditPack = vi.fn();
21
+ const mockGetCreditPackInventory = vi.fn();
22
+ const mockGetCreditPackProgram = vi.fn();
23
+ const mockGetOrCreatePendingCardSetup = vi.fn();
24
+ const mockFormatCardSetupBlocks = vi.fn();
25
+
26
+ vi.mock("../../core/api-client.js", () => ({
27
+ apiGet: mockApiGet,
28
+ apiPost: mockApiPost,
29
+ apiPostWithPayment: mockApiPostWithPayment,
30
+ }));
31
+
32
+ vi.mock("../../core/card-setup.js", () => ({
33
+ getOrCreatePendingCardSetup: mockGetOrCreatePendingCardSetup,
34
+ formatCardSetupBlocks: mockFormatCardSetupBlocks,
35
+ }));
36
+
37
+ vi.mock("../../core/payments.js", () => ({
38
+ getCompatiblePaymentMethods: mockGetCompatiblePaymentMethods,
39
+ hasWalletConfigured: mockHasWalletConfigured,
40
+ getConfiguredMethods: mockGetConfiguredMethods,
41
+ getAcceptedPaymentMethods: mockGetAcceptedPaymentMethods,
42
+ getWalletAddress: mockGetWalletAddress,
43
+ normalizePaymentMethod: mockNormalizePaymentMethod,
44
+ toRegistryPaymentMethod: mockToRegistryPaymentMethod,
45
+ }));
46
+
47
+ vi.mock("../../core/config.js", () => ({
48
+ requiresSpendConfirmation: mockRequiresSpendConfirmation,
49
+ getDefaultTipAmount: mockGetDefaultTipAmount,
50
+ }));
51
+
52
+ vi.mock("../../core/spend-policy.js", () => ({
53
+ canSpend: mockCanSpend,
54
+ recordSpend: mockRecordSpend,
55
+ requiresPolicyConfirmation: mockRequiresPolicyConfirmation,
56
+ }));
57
+
58
+ vi.mock("../../core/file-upload.js", () => ({
59
+ uploadLocalFiles: mockUploadLocalFiles,
60
+ }));
61
+
62
+ vi.mock("../_token-cache.js", () => ({
63
+ storeFeedbackToken: mockStoreFeedbackToken,
64
+ }));
65
+
66
+ vi.mock("../../core/passes.js", () => ({
67
+ getActiveCreditPack: mockGetActiveCreditPack,
68
+ getCreditPackInventory: mockGetCreditPackInventory,
69
+ getCreditPackProgram: mockGetCreditPackProgram,
70
+ }));
71
+
72
+ function flattenToolText(result: unknown): string {
73
+ const content = (result as { content?: Array<{ type?: string; text?: string }> })?.content ?? [];
74
+ return content
75
+ .filter((item) => item?.type === "text")
76
+ .map((item) => item.text ?? "")
77
+ .join("\n\n");
78
+ }
79
+
80
+ function makeServerHarness() {
81
+ const handlers = new Map<string, (args: Record<string, unknown>) => Promise<unknown>>();
82
+ return {
83
+ handlers,
84
+ server: {
85
+ tool(name: string, _description: string, _schema: unknown, handler: (args: Record<string, unknown>) => Promise<unknown>) {
86
+ handlers.set(name, handler);
87
+ },
88
+ },
89
+ };
90
+ }
91
+
92
+ const selectedAgent = {
93
+ id: "agent-1",
94
+ name: "MockFixedPrice",
95
+ pricePerRunUsd: "0.01",
96
+ tags: ["translation"],
97
+ mcpSchema: null,
98
+ };
99
+
100
+ describe("solve MCP tool", () => {
101
+ beforeEach(() => {
102
+ vi.resetModules();
103
+ vi.clearAllMocks();
104
+
105
+ mockHasWalletConfigured.mockReturnValue(true);
106
+ mockGetConfiguredMethods.mockReturnValue(["card"]);
107
+ mockGetAcceptedPaymentMethods.mockReturnValue(["card"]);
108
+ mockGetCompatiblePaymentMethods.mockReturnValue(["card"]);
109
+ mockNormalizePaymentMethod.mockImplementation((method: string) => method);
110
+ mockToRegistryPaymentMethod.mockImplementation((method: string) => method);
111
+ mockRequiresSpendConfirmation.mockReturnValue(true);
112
+ mockGetDefaultTipAmount.mockReturnValue(0);
113
+ mockCanSpend.mockReturnValue({ ok: true, message: "" });
114
+ mockRequiresPolicyConfirmation.mockReturnValue(false);
115
+ mockUploadLocalFiles.mockResolvedValue({ input: { text: "hello", target_language: "es" }, uploads: [] });
116
+ mockGetActiveCreditPack.mockReturnValue(null);
117
+ mockGetCreditPackInventory.mockResolvedValue([]);
118
+ mockGetCreditPackProgram.mockReturnValue(null);
119
+ mockGetOrCreatePendingCardSetup.mockResolvedValue({ url: "https://example.com/card-setup" });
120
+ mockFormatCardSetupBlocks.mockReturnValue(["card setup"]);
121
+ mockGetWalletAddress.mockResolvedValue("0xabc");
122
+ });
123
+
124
+ it("falls back to a quoted confirmation flow when /solve returns a 402 challenge", async () => {
125
+ mockApiPost.mockRejectedValueOnce(Object.assign(new Error("Payment Required"), { status: 402 }));
126
+ mockApiGet.mockResolvedValueOnce([selectedAgent]);
127
+
128
+ const { registerSolveTools } = await import("../solve.js");
129
+ const harness = makeServerHarness();
130
+ registerSolveTools(harness.server as never);
131
+
132
+ const solve = harness.handlers.get("solve");
133
+ expect(solve).toBeDefined();
134
+
135
+ const result = await solve!({
136
+ intent: "MockFixedPrice",
137
+ input: { text: "hello", target_language: "es" },
138
+ budget: 0.05,
139
+ pay_with: "card",
140
+ });
141
+ const text = flattenToolText(result);
142
+
143
+ expect(text).toContain("Best match: MockFixedPrice");
144
+ expect(text).toContain('solve({ intent: "MockFixedPrice"');
145
+ expect(text).toContain('pay_with: "card"');
146
+ expect(mockApiPostWithPayment).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it("falls back to paid execution when confirmed after a 402 challenge", async () => {
150
+ mockApiPost.mockRejectedValueOnce(Object.assign(new Error("Payment Required"), { status: 402 }));
151
+ mockApiGet.mockResolvedValueOnce([selectedAgent]);
152
+ mockApiPostWithPayment.mockResolvedValueOnce({
153
+ status: "success",
154
+ job_id: "job-1",
155
+ agent_id: selectedAgent.id,
156
+ agent_name: selectedAgent.name,
157
+ output: { translated_text: "hola" },
158
+ cost: 0.01,
159
+ });
160
+
161
+ const { registerSolveTools } = await import("../solve.js");
162
+ const harness = makeServerHarness();
163
+ registerSolveTools(harness.server as never);
164
+
165
+ const solve = harness.handlers.get("solve");
166
+ expect(solve).toBeDefined();
167
+
168
+ const result = await solve!({
169
+ intent: "MockFixedPrice",
170
+ input: { text: "hello", target_language: "es" },
171
+ budget: 0.05,
172
+ pay_with: "card",
173
+ confirmed: true,
174
+ });
175
+ const text = flattenToolText(result);
176
+
177
+ expect(mockApiPostWithPayment).toHaveBeenCalledWith(
178
+ "/agents/agent-1/run",
179
+ { input: { text: "hello", target_language: "es" } },
180
+ "card",
181
+ );
182
+ expect(text).toContain("Running MockFixedPrice — best match");
183
+ expect(text).toContain("Job ID: job-1");
184
+ expect(text).toContain("Paid: $0.01 via card");
185
+ });
186
+ });