@agentwonderland/mcp 0.1.24 → 0.1.25

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 (49) hide show
  1. package/dist/core/__tests__/amount-utils.test.d.ts +1 -0
  2. package/dist/core/__tests__/amount-utils.test.js +11 -0
  3. package/dist/core/__tests__/payments.test.js +55 -6
  4. package/dist/core/__tests__/spend-policy.test.d.ts +1 -0
  5. package/dist/core/__tests__/spend-policy.test.js +40 -0
  6. package/dist/core/amount-utils.d.ts +1 -0
  7. package/dist/core/amount-utils.js +4 -0
  8. package/dist/core/base-charge.js +3 -2
  9. package/dist/core/config.d.ts +19 -0
  10. package/dist/core/config.js +22 -0
  11. package/dist/core/formatters.d.ts +2 -3
  12. package/dist/core/formatters.js +5 -7
  13. package/dist/core/payments.js +14 -4
  14. package/dist/core/solana-charge.js +2 -1
  15. package/dist/core/spend-policy.d.ts +12 -0
  16. package/dist/core/spend-policy.js +53 -0
  17. package/dist/core/types.d.ts +1 -2
  18. package/dist/index.js +5 -2
  19. package/dist/prompts/index.js +4 -2
  20. package/dist/resources/agents.js +1 -1
  21. package/dist/tools/agent-info.js +2 -2
  22. package/dist/tools/favorites.js +1 -1
  23. package/dist/tools/observability.d.ts +2 -0
  24. package/dist/tools/observability.js +20 -0
  25. package/dist/tools/run.js +40 -28
  26. package/dist/tools/solve.js +45 -39
  27. package/dist/tools/wallet.js +26 -10
  28. package/package.json +1 -1
  29. package/src/core/__tests__/amount-utils.test.ts +13 -0
  30. package/src/core/__tests__/payments.test.ts +68 -6
  31. package/src/core/__tests__/spend-policy.test.ts +58 -0
  32. package/src/core/amount-utils.ts +5 -0
  33. package/src/core/base-charge.ts +3 -2
  34. package/src/core/config.ts +45 -0
  35. package/src/core/formatters.ts +6 -8
  36. package/src/core/payments.ts +17 -4
  37. package/src/core/solana-charge.ts +2 -1
  38. package/src/core/spend-policy.ts +69 -0
  39. package/src/core/types.ts +1 -2
  40. package/src/index.ts +5 -2
  41. package/src/prompts/index.ts +4 -2
  42. package/src/resources/agents.ts +1 -1
  43. package/src/tools/agent-info.ts +2 -2
  44. package/src/tools/favorites.ts +1 -4
  45. package/src/tools/observability.ts +43 -0
  46. package/src/tools/passes.ts +1 -7
  47. package/src/tools/run.ts +44 -43
  48. package/src/tools/solve.ts +50 -55
  49. package/src/tools/wallet.ts +30 -10
package/dist/tools/run.js CHANGED
@@ -5,9 +5,10 @@ import { formatCreditPackOffer, getActiveCreditPack, getCreditPackInventory, get
5
5
  import { getCompatiblePaymentMethods, getConfiguredMethods, hasWalletConfigured, getWalletAddress, normalizePaymentMethod, } from "../core/payments.js";
6
6
  import { requiresSpendConfirmation, getDefaultTipAmount } from "../core/config.js";
7
7
  import { formatRunResult } from "../core/formatters.js";
8
+ import { canSpend, recordSpend, requiresPolicyConfirmation } from "../core/spend-policy.js";
8
9
  import { storeFeedbackToken } from "./_token-cache.js";
9
10
  import { getOrCreatePendingCardSetup, formatCardSetupBlocks } from "../core/card-setup.js";
10
- import { formatPaymentChoicePrompt, formatPaymentLabel, formatRunConfirmationCommand, resolveConfirmationMethod, } from "./_payment-confirmation.js";
11
+ import { formatPaymentLabel, formatRunConfirmationCommand, resolveConfirmationMethod, } from "./_payment-confirmation.js";
11
12
  const POLL_INTERVAL_MS = 3000;
12
13
  const POLL_MAX_MS = 120000;
13
14
  async function pollJobUntilDone(jobId) {
@@ -60,7 +61,7 @@ export function registerRunTools(server) {
60
61
  server.tool("run_agent", "Run an AI agent from the marketplace. Pays automatically via configured wallet. Returns the agent's output, cost, and job ID for tracking. If spending confirmation is enabled, first call returns a price quote — call again with confirmed: true to execute. Local file paths in the input (e.g. /Users/.../photo.jpg) are automatically uploaded to temporary storage and replaced with download URLs before execution.", {
61
62
  agent_id: z.string().describe("Agent ID (UUID, slug, or name)"),
62
63
  input: z.record(z.unknown()).describe("Input payload for the agent"),
63
- pay_with: z.string().optional().describe("Payment method — wallet ID, chain name (tempo, base, etc.), or 'card'. Auto-detected if omitted."),
64
+ pay_with: z.string().trim().min(1).describe("Required payment method — wallet ID, chain name (tempo, base, etc.), or 'card'. Use wallet_status to see configured options."),
64
65
  confirmed: z.boolean().optional().describe("Set to true to confirm spending after seeing the price quote."),
65
66
  }, async ({ agent_id, input, pay_with, confirmed }) => {
66
67
  if (!hasWalletConfigured()) {
@@ -81,37 +82,36 @@ export function registerRunTools(server) {
81
82
  catch {
82
83
  return text(`Agent "${agent_id}" not found. Use search_agents to find available agents.`);
83
84
  }
84
- const price = parseFloat(agent.pricePer1kTokens ?? "0.01");
85
+ const price = parseFloat(agent.pricePerRunUsd ?? "0.01");
85
86
  const agentName = agent.name ?? agent_id;
86
87
  const creditPackInventory = await getCreditPackInventory(agent.id);
87
88
  const activeCreditPack = getActiveCreditPack(creditPackInventory);
88
- const configuredMethods = getConfiguredMethods();
89
- const compatibleMethods = getCompatiblePaymentMethods(agent, configuredMethods);
89
+ const compatibleMethods = getCompatiblePaymentMethods(agent, getConfiguredMethods());
90
90
  const pending = pendingRuns.get(agent.id);
91
- const requestedMethod = pay_with ?? pending?.method;
92
- const normalizedRequestedMethod = requestedMethod ? normalizePaymentMethod(requestedMethod) : null;
93
- if (!activeCreditPack && requestedMethod && !normalizedRequestedMethod) {
91
+ const requestedMethod = pay_with;
92
+ const normalizedRequestedMethod = normalizePaymentMethod(requestedMethod);
93
+ if (!normalizedRequestedMethod) {
94
94
  return text(`Payment method "${requestedMethod}" is not configured.\n\n` +
95
95
  "Use wallet_status to review your current payment methods.");
96
96
  }
97
- if (!activeCreditPack && normalizedRequestedMethod && !compatibleMethods.includes(normalizedRequestedMethod)) {
97
+ if (!compatibleMethods.includes(normalizedRequestedMethod)) {
98
98
  return text(`This agent cannot be paid with "${requestedMethod}".\n\n` +
99
99
  `Available payment methods for this agent: ${compatibleMethods.join(", ") || "none"}.\n` +
100
100
  "Use get_agent to inspect the agent details or choose another payment method.");
101
101
  }
102
- if (!activeCreditPack && !requestedMethod && compatibleMethods.length === 0) {
103
- return text(`No compatible payment methods are configured for ${agentName}.\n\n` +
104
- `Your configured methods: ${configuredMethods.join(", ") || "none"}\n` +
105
- `Agent accepts: ${agent.payment?.accepted_payments?.join(", ") || "unknown"}\n` +
106
- "Use wallet_status to review your current setup.");
107
- }
108
- if (!activeCreditPack && !requestedMethod && compatibleMethods.length > 1) {
109
- return text(formatPaymentChoicePrompt(agentName, compatibleMethods, compatibleMethods.map((method) => formatRunConfirmationCommand(agent.id, method).replace(", confirmed: true", ""))));
102
+ const method = resolveConfirmationMethod(pay_with, pending?.method, compatibleMethods);
103
+ const spendCheckMethod = method ?? normalizedRequestedMethod;
104
+ if (!activeCreditPack) {
105
+ const spendCheck = canSpend({
106
+ method: spendCheckMethod,
107
+ amountUsd: price,
108
+ });
109
+ if (!spendCheck.ok) {
110
+ return text(spendCheck.message);
111
+ }
110
112
  }
111
- const method = activeCreditPack
112
- ? undefined
113
- : resolveConfirmationMethod(pay_with, pending?.method, compatibleMethods);
114
- if (!activeCreditPack && requiresSpendConfirmation() && !confirmed) {
113
+ const needsPolicyConfirmation = !activeCreditPack && requiresPolicyConfirmation(spendCheckMethod, price);
114
+ if (!activeCreditPack && (requiresSpendConfirmation() || needsPolicyConfirmation) && !confirmed) {
115
115
  pendingRuns.set(agent.id, {
116
116
  agent: { id: agent.id, name: agentName, price },
117
117
  input,
@@ -146,17 +146,29 @@ export function registerRunTools(server) {
146
146
  return text(`Error: ${msg}`);
147
147
  }
148
148
  let result;
149
+ let usedPaidMethod = !activeCreditPack;
149
150
  try {
150
- result = activeCreditPack
151
- ? await apiPost(`/agents/${agent.id}/run`, { input: processedInput }, { ensureConsumerPrincipal: true })
152
- : await apiPostWithPayment(`/agents/${agent.id}/run`, { input: processedInput }, method);
151
+ if (activeCreditPack) {
152
+ try {
153
+ result = await apiPost(`/agents/${agent.id}/run`, { input: processedInput }, { ensureConsumerPrincipal: true });
154
+ }
155
+ catch (packErr) {
156
+ const packApiErr = packErr;
157
+ if (packApiErr?.status !== 402)
158
+ throw packErr;
159
+ result = await apiPostWithPayment(`/agents/${agent.id}/run`, { input: processedInput }, method);
160
+ usedPaidMethod = true;
161
+ }
162
+ }
163
+ else {
164
+ result = await apiPostWithPayment(`/agents/${agent.id}/run`, { input: processedInput }, method);
165
+ }
166
+ if (usedPaidMethod) {
167
+ recordSpend(spendCheckMethod, price);
168
+ }
153
169
  }
154
170
  catch (err) {
155
171
  const apiErr = err;
156
- if (activeCreditPack && apiErr?.status === 402) {
157
- return text(`Your available credit packs for ${agentName} could not cover this run.\n\n` +
158
- "Use list_agent_credit_packs to inspect your balances, or retry with a payment method.");
159
- }
160
172
  if (apiErr?.status === 402) {
161
173
  const allMethods = getConfiguredMethods();
162
174
  const methodName = method ?? allMethods[0] ?? "auto";
@@ -4,9 +4,10 @@ import { getOrCreatePendingCardSetup, formatCardSetupBlocks } from "../core/card
4
4
  import { getCompatiblePaymentMethods, hasWalletConfigured, getConfiguredMethods, getAcceptedPaymentMethods, getWalletAddress, normalizePaymentMethod, toRegistryPaymentMethod, } from "../core/payments.js";
5
5
  import { requiresSpendConfirmation, getDefaultTipAmount } from "../core/config.js";
6
6
  import { agentList, formatRunResult } from "../core/formatters.js";
7
+ import { canSpend, recordSpend, requiresPolicyConfirmation } from "../core/spend-policy.js";
7
8
  import { uploadLocalFiles } from "../core/file-upload.js";
8
9
  import { storeFeedbackToken } from "./_token-cache.js";
9
- import { formatPaymentChoicePrompt, formatPaymentLabel, formatSolveConfirmationCommand, makeSolvePendingKey, resolveConfirmationMethod, } from "./_payment-confirmation.js";
10
+ import { formatPaymentLabel, formatSolveConfirmationCommand, makeSolvePendingKey, resolveConfirmationMethod, } from "./_payment-confirmation.js";
10
11
  import { getActiveCreditPack, getCreditPackInventory, getCreditPackProgram } from "../core/passes.js";
11
12
  const POLL_INTERVAL_MS = 3000;
12
13
  const POLL_MAX_MS = 120000;
@@ -103,8 +104,9 @@ export function registerSolveTools(server) {
103
104
  .describe("Maximum budget in USD"),
104
105
  pay_with: z
105
106
  .string()
106
- .optional()
107
- .describe("Payment method — wallet ID, chain name (tempo, base, etc.), or 'card'. Auto-detected if omitted."),
107
+ .trim()
108
+ .min(1)
109
+ .describe("Required payment method — wallet ID, chain name (tempo, base, etc.), or 'card'. Use wallet_status to see configured options."),
108
110
  confirmed: z
109
111
  .boolean()
110
112
  .optional()
@@ -124,9 +126,9 @@ export function registerSolveTools(server) {
124
126
  const pendingKey = makeSolvePendingKey(intent, input, budget);
125
127
  const pending = pendingSolves.get(pendingKey);
126
128
  const configuredMethods = getConfiguredMethods();
127
- const requestedMethod = pay_with ?? pending?.method;
128
- const normalizedRequestedMethod = requestedMethod ? normalizePaymentMethod(requestedMethod) : null;
129
- if (requestedMethod && !normalizedRequestedMethod) {
129
+ const requestedMethod = pay_with;
130
+ const normalizedRequestedMethod = normalizePaymentMethod(requestedMethod);
131
+ if (!normalizedRequestedMethod) {
130
132
  return text(`Payment method "${requestedMethod}" is not configured.\n\n` +
131
133
  "Use wallet_status to review your current payment methods.");
132
134
  }
@@ -175,41 +177,24 @@ export function registerSolveTools(server) {
175
177
  return text(`No agents found matching "${intent}".`);
176
178
  }
177
179
  const discovery = agentList(agents, intent);
178
- const inputTokens = Math.ceil(JSON.stringify(input).length / 4);
179
180
  const affordable = agents.filter((agent) => {
180
- const price = parseFloat(agent.pricePer1kTokens ?? "0.01");
181
- const cost = agent.pricingModel === "fixed" ? price : (inputTokens / 1000) * price;
182
- return cost <= budget;
181
+ const price = parseFloat(agent.pricePerRunUsd ?? "0.01");
182
+ return price <= budget;
183
183
  });
184
184
  const selected = affordable[0] ?? agents[0];
185
185
  const activeCreditPack = await getCreditPackInventory(selected.id).then(getActiveCreditPack);
186
186
  const compatibleMethods = getCompatiblePaymentMethods(selected, configuredMethods);
187
- if (!activeCreditPack && normalizedRequestedMethod && !compatibleMethods.includes(normalizedRequestedMethod)) {
187
+ if (!compatibleMethods.includes(normalizedRequestedMethod)) {
188
188
  return text(`The best matching agent cannot be paid with "${requestedMethod}".\n\n` +
189
189
  `Available payment methods for ${selected.name}: ${compatibleMethods.join(", ") || "none"}.\n` +
190
190
  "Choose another payment method or refine your search.");
191
191
  }
192
- if (!activeCreditPack && !requestedMethod && compatibleMethods.length === 0) {
193
- return text(`No compatible payment methods are configured for ${selected.name}.\n\n` +
194
- `Your configured methods: ${configuredMethods.join(", ") || "none"}\n` +
195
- `Agent accepts: ${selected.payment?.accepted_payments?.join(", ") || "unknown"}\n` +
196
- "Use wallet_status to review your current setup.");
197
- }
198
- if (!activeCreditPack && !requestedMethod && compatibleMethods.length > 1) {
199
- return text([
200
- discovery,
201
- "",
202
- formatPaymentChoicePrompt(selected.name ?? selected.id, compatibleMethods, compatibleMethods.map((method) => formatSolveConfirmationCommand(intent, budget, method).replace(", confirmed: true", ""))),
203
- ].join("\n"));
204
- }
205
- const method = activeCreditPack
206
- ? undefined
207
- : resolveConfirmationMethod(pay_with, pending?.method, compatibleMethods);
208
- const selectedPrice = parseFloat(selected.pricePer1kTokens ?? "0.01");
209
- const estimatedCost = selected.pricingModel === "fixed"
210
- ? selectedPrice
211
- : (inputTokens / 1000) * selectedPrice;
212
- if (!activeCreditPack && requiresSpendConfirmation() && !confirmed) {
192
+ const method = resolveConfirmationMethod(pay_with, pending?.method, compatibleMethods);
193
+ const spendCheckMethod = method ?? normalizedRequestedMethod;
194
+ const selectedPrice = parseFloat(selected.pricePerRunUsd ?? "0.01");
195
+ const estimatedCost = selectedPrice;
196
+ const needsPolicyConfirmation = !activeCreditPack && requiresPolicyConfirmation(spendCheckMethod, estimatedCost);
197
+ if (!activeCreditPack && (requiresSpendConfirmation() || needsPolicyConfirmation) && !confirmed) {
213
198
  pendingSolves.set(pendingKey, { method });
214
199
  return text([
215
200
  discovery,
@@ -229,17 +214,38 @@ export function registerSolveTools(server) {
229
214
  ].join("\n"));
230
215
  }
231
216
  let result;
217
+ if (!activeCreditPack) {
218
+ const spendCheck = canSpend({
219
+ method: spendCheckMethod,
220
+ amountUsd: estimatedCost,
221
+ });
222
+ if (!spendCheck.ok) {
223
+ return text(spendCheck.message);
224
+ }
225
+ }
232
226
  try {
233
- result = activeCreditPack
234
- ? await apiPost(`/agents/${selected.id}/run`, { input: processedInput }, { ensureConsumerPrincipal: true })
235
- : await apiPostWithPayment(`/agents/${selected.id}/run`, { input: processedInput }, method);
227
+ let usedPaidMethod = !activeCreditPack;
228
+ if (activeCreditPack) {
229
+ try {
230
+ result = await apiPost(`/agents/${selected.id}/run`, { input: processedInput }, { ensureConsumerPrincipal: true });
231
+ }
232
+ catch (packErr) {
233
+ const packApiErr = packErr;
234
+ if (packApiErr?.status !== 402)
235
+ throw packErr;
236
+ result = await apiPostWithPayment(`/agents/${selected.id}/run`, { input: processedInput }, method);
237
+ usedPaidMethod = true;
238
+ }
239
+ }
240
+ else {
241
+ result = await apiPostWithPayment(`/agents/${selected.id}/run`, { input: processedInput }, method);
242
+ }
243
+ if (usedPaidMethod) {
244
+ recordSpend(spendCheckMethod, estimatedCost);
245
+ }
236
246
  }
237
247
  catch (err) {
238
248
  const apiErr = err;
239
- if (activeCreditPack && apiErr?.status === 402) {
240
- return text(`Your available credit packs for ${selected.name} could not cover this run.\n\n` +
241
- "Use list_agent_credit_packs to inspect your balances, or retry with a payment method.");
242
- }
243
249
  if (apiErr?.status === 402) {
244
250
  return text([
245
251
  "Payment failed — your wallet may not have enough funds or the selected method was rejected.",
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { getWallets, getCardConfig, setCardConfig, addWallet, getPendingCardSetupToken, } from "../core/config.js";
2
+ import { getWallets, getCardConfig, setCardConfig, addWallet, getPendingCardSetupToken, getSpendPolicy, setSpendPolicy, } from "../core/config.js";
3
3
  import { getWalletAddress } from "../core/payments.js";
4
4
  import { getOrCreatePendingCardSetup, formatCardSetupBlocks, getCardCapabilities, pollCardSetup, } from "../core/card-setup.js";
5
5
  import { isOwsAvailable, createOwsWallet, importKeyToOws, listOwsWallets, listOwsWalletsByChain, } from "../core/ows-adapter.js";
@@ -269,26 +269,42 @@ export function registerWalletTools(server) {
269
269
  .positive()
270
270
  .optional()
271
271
  .describe("Maximum USD per day across all transactions"),
272
- }, async ({ wallet_id, max_per_tx, max_per_day }) => {
272
+ require_confirmation_above: z
273
+ .number()
274
+ .positive()
275
+ .optional()
276
+ .describe("Require manual confirmation above this USD amount even when auto-spend is enabled"),
277
+ }, async ({ wallet_id, max_per_tx, max_per_day, require_confirmation_above }) => {
273
278
  const wallets = getWallets();
274
279
  const wallet = wallets.find((w) => w.id === wallet_id);
275
280
  if (!wallet) {
276
281
  const available = wallets.map((w) => w.id).join(", ") || "none";
277
282
  return text(`Wallet "${wallet_id}" not found. Available wallets: ${available}`);
278
283
  }
279
- if (max_per_tx == null && max_per_day == null) {
280
- return text("No policy changes specified. Provide max_per_tx and/or max_per_day.");
284
+ if (max_per_tx == null &&
285
+ max_per_day == null &&
286
+ require_confirmation_above == null) {
287
+ return text("No policy changes specified. Provide at least one policy field.");
281
288
  }
289
+ const existing = getSpendPolicy(wallet_id) ?? {};
290
+ const nextPolicy = {
291
+ ...existing,
292
+ ...(max_per_tx != null ? { maxPerTxUsd: max_per_tx } : {}),
293
+ ...(max_per_day != null ? { maxPerDayUsd: max_per_day } : {}),
294
+ ...(require_confirmation_above != null ? { requireConfirmationAboveUsd: require_confirmation_above } : {}),
295
+ };
296
+ setSpendPolicy(wallet_id, nextPolicy);
282
297
  // Build policy summary
283
298
  const policies = [];
284
- if (max_per_tx != null) {
285
- policies.push(`Max per transaction: $${max_per_tx.toFixed(2)}`);
299
+ if (nextPolicy.maxPerTxUsd != null) {
300
+ policies.push(`Max per transaction: $${nextPolicy.maxPerTxUsd.toFixed(2)}`);
301
+ }
302
+ if (nextPolicy.maxPerDayUsd != null) {
303
+ policies.push(`Max per day: $${nextPolicy.maxPerDayUsd.toFixed(2)}`);
286
304
  }
287
- if (max_per_day != null) {
288
- policies.push(`Max per day: $${max_per_day.toFixed(2)}`);
305
+ if (nextPolicy.requireConfirmationAboveUsd != null) {
306
+ policies.push(`Require confirmation above: $${nextPolicy.requireConfirmationAboveUsd.toFixed(2)}`);
289
307
  }
290
- // Note: actual persistence depends on the config module supporting policy fields.
291
- // For now, we report what would be set. The core/config module will handle storage.
292
308
  return text([
293
309
  `Spending policy set for wallet "${wallet_id}":`,
294
310
  ...policies.map((p) => ` ${p}`),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentwonderland/mcp",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "description": "MCP server for the Agent Wonderland AI agent marketplace",
6
6
  "bin": {
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { toAtomicAmount } from "../amount-utils.js";
3
+
4
+ describe("toAtomicAmount", () => {
5
+ it("converts decimal USDC challenge amounts into atomic units", () => {
6
+ expect(toAtomicAmount("0.020000")).toBe(20000n);
7
+ expect(toAtomicAmount("1.000000")).toBe(1000000n);
8
+ });
9
+
10
+ it("supports different decimal precisions", () => {
11
+ expect(toAtomicAmount("0.50", 2)).toBe(50n);
12
+ });
13
+ });
@@ -1,5 +1,7 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
+ const TEST_KEY = "1111111111111111111111111111111111111111111111111111111111111111";
4
+
3
5
  let currentCard = {
4
6
  consumerToken: "consumer_one",
5
7
  paymentMethodId: "pm_one",
@@ -7,17 +9,23 @@ let currentCard = {
7
9
  brand: "visa",
8
10
  };
9
11
 
10
- const createdFetches = [vi.fn(), vi.fn(), vi.fn()];
12
+ let currentDefaultWallet: any;
13
+ let currentWallets: any[] = [];
14
+ let currentResolvedMethod: { wallet: any; chain: string } | null = null;
15
+
16
+ const createdFetches = [vi.fn(), vi.fn(), vi.fn(), vi.fn()];
11
17
  const mockMppxCreate = vi.fn();
12
18
  const mockStripe = vi.fn((opts: unknown) => opts);
19
+ const mockTempo = vi.fn((..._args: unknown[]) => "tempo_method");
20
+ const mockBaseChargeClient = vi.fn((..._args: unknown[]) => "base_method");
13
21
 
14
22
  vi.mock("../config.js", () => ({
15
23
  getApiUrl: () => "http://api.test",
16
24
  getCardConfig: () => currentCard,
17
25
  getConfig: () => ({ defaultPaymentMethod: "card" }),
18
- getDefaultWallet: () => undefined,
19
- getWallets: () => [],
20
- resolveWalletAndChain: () => null,
26
+ getDefaultWallet: () => currentDefaultWallet,
27
+ getWallets: () => currentWallets,
28
+ resolveWalletAndChain: () => currentResolvedMethod,
21
29
  }));
22
30
 
23
31
  vi.mock("mppx/client", () => ({
@@ -25,21 +33,31 @@ vi.mock("mppx/client", () => ({
25
33
  create: (config: unknown) => mockMppxCreate(config),
26
34
  },
27
35
  stripe: (config: unknown) => mockStripe(config),
36
+ tempo: (config: unknown) => mockTempo(config),
37
+ }));
38
+
39
+ vi.mock("../base-charge.js", () => ({
40
+ baseChargeClient: (config: unknown) => mockBaseChargeClient(config),
28
41
  }));
29
42
 
30
- describe("card payment fetch cache", () => {
43
+ describe("payment method initialization", () => {
31
44
  beforeEach(() => {
32
45
  vi.clearAllMocks();
46
+ vi.resetModules();
33
47
  currentCard = {
34
48
  consumerToken: "consumer_one",
35
49
  paymentMethodId: "pm_one",
36
50
  last4: "1111",
37
51
  brand: "visa",
38
52
  };
53
+ currentDefaultWallet = undefined;
54
+ currentWallets = [];
55
+ currentResolvedMethod = null;
39
56
  mockMppxCreate
40
57
  .mockReturnValueOnce({ fetch: createdFetches[0] })
41
58
  .mockReturnValueOnce({ fetch: createdFetches[1] })
42
- .mockReturnValueOnce({ fetch: createdFetches[2] });
59
+ .mockReturnValueOnce({ fetch: createdFetches[2] })
60
+ .mockReturnValueOnce({ fetch: createdFetches[3] });
43
61
  });
44
62
 
45
63
  it("rebuilds the cached card fetch when the card config changes", async () => {
@@ -57,4 +75,48 @@ describe("card payment fetch cache", () => {
57
75
  expect(firstFetch).not.toBe(secondFetch);
58
76
  expect(mockMppxCreate).toHaveBeenCalledTimes(2);
59
77
  });
78
+
79
+ it("initializes only the Base method when base is requested", async () => {
80
+ const wallet = {
81
+ id: "aw-main",
82
+ keyType: "raw",
83
+ key: TEST_KEY,
84
+ chains: ["tempo", "base"],
85
+ defaultChain: "base",
86
+ };
87
+ currentDefaultWallet = wallet;
88
+ currentWallets = [wallet];
89
+ currentResolvedMethod = { wallet, chain: "base" };
90
+
91
+ const { getPaymentFetch } = await import("../payments.js");
92
+ await getPaymentFetch("base");
93
+
94
+ expect(mockBaseChargeClient).toHaveBeenCalledTimes(1);
95
+ expect(mockTempo).not.toHaveBeenCalled();
96
+ expect(mockMppxCreate).toHaveBeenCalledWith(
97
+ expect.objectContaining({ methods: ["base_method"] }),
98
+ );
99
+ });
100
+
101
+ it("initializes only the Tempo method when tempo is requested", async () => {
102
+ const wallet = {
103
+ id: "aw-main",
104
+ keyType: "raw",
105
+ key: TEST_KEY,
106
+ chains: ["tempo", "base"],
107
+ defaultChain: "tempo",
108
+ };
109
+ currentDefaultWallet = wallet;
110
+ currentWallets = [wallet];
111
+ currentResolvedMethod = { wallet, chain: "tempo" };
112
+
113
+ const { getPaymentFetch } = await import("../payments.js");
114
+ await getPaymentFetch("tempo");
115
+
116
+ expect(mockTempo).toHaveBeenCalledTimes(1);
117
+ expect(mockBaseChargeClient).not.toHaveBeenCalled();
118
+ expect(mockMppxCreate).toHaveBeenCalledWith(
119
+ expect.objectContaining({ methods: ["tempo_method"] }),
120
+ );
121
+ });
60
122
  });
@@ -0,0 +1,58 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const state = {
4
+ policies: {} as Record<string, {
5
+ maxPerTxUsd?: number;
6
+ maxPerDayUsd?: number;
7
+ requireConfirmationAboveUsd?: number;
8
+ }>,
9
+ ledger: [] as Array<{ method: string; amountUsd: number; day: string; timestamp: string }>,
10
+ };
11
+
12
+ vi.mock("../config.js", () => ({
13
+ getSpendPolicy: (method: string) => state.policies[method] ?? null,
14
+ getSpendLedger: () => state.ledger,
15
+ saveSpendLedger: (entries: Array<{ method: string; amountUsd: number; day: string; timestamp: string }>) => {
16
+ state.ledger = entries;
17
+ },
18
+ }));
19
+
20
+ import {
21
+ canSpend,
22
+ getDailySpend,
23
+ recordSpend,
24
+ requiresPolicyConfirmation,
25
+ } from "../spend-policy.js";
26
+
27
+ describe("spend-policy", () => {
28
+ beforeEach(() => {
29
+ state.policies = {};
30
+ state.ledger = [];
31
+ });
32
+
33
+ it("allows spend when no policy is set", () => {
34
+ expect(canSpend({ method: "wallet-1", amountUsd: 5 })).toEqual({ ok: true });
35
+ });
36
+
37
+ it("blocks spends above max_per_tx", () => {
38
+ state.policies["wallet-1"] = { maxPerTxUsd: 2 };
39
+ const result = canSpend({ method: "wallet-1", amountUsd: 3 });
40
+ expect(result.ok).toBe(false);
41
+ });
42
+
43
+ it("tracks daily spend totals per method", () => {
44
+ const now = new Date("2026-04-08T12:00:00.000Z");
45
+ recordSpend("wallet-1", 1.25, now);
46
+ recordSpend("wallet-1", 0.75, now);
47
+ recordSpend("wallet-2", 10, now);
48
+
49
+ expect(getDailySpend("wallet-1", now)).toBeCloseTo(2);
50
+ expect(getDailySpend("wallet-2", now)).toBeCloseTo(10);
51
+ });
52
+
53
+ it("requires explicit confirmation above configured threshold", () => {
54
+ state.policies["wallet-1"] = { requireConfirmationAboveUsd: 2 };
55
+ expect(requiresPolicyConfirmation("wallet-1", 1.99)).toBe(false);
56
+ expect(requiresPolicyConfirmation("wallet-1", 2)).toBe(true);
57
+ });
58
+ });
@@ -0,0 +1,5 @@
1
+ import { parseUnits } from "viem";
2
+
3
+ export function toAtomicAmount(amount: string, decimals = 6): bigint {
4
+ return parseUnits(amount, decimals);
5
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { Method, Credential, z } from "mppx";
8
8
  import type { LocalAccount } from "viem/accounts";
9
+ import { toAtomicAmount } from "./amount-utils.js";
9
10
 
10
11
  // Base USDC (Circle native)
11
12
  const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const;
@@ -63,12 +64,12 @@ export function baseChargeClient(config: BaseChargeClientConfig) {
63
64
  return Method.toClient(baseChargeMethod as any, {
64
65
  async createCredential({ challenge }: any) {
65
66
  const { request } = challenge;
66
- const amount = BigInt(request.amount);
67
+ const amount = toAtomicAmount(request.amount, request.decimals ?? 6);
67
68
  const recipient = request.recipient as `0x${string}`;
68
69
  const currency = (request.currency ?? BASE_USDC) as `0x${string}`;
69
70
 
70
71
  // Dynamic imports to keep the module lightweight
71
- const { createWalletClient, createPublicClient, http, encodeFunctionData } = await import("viem");
72
+ const { createWalletClient, createPublicClient, http } = await import("viem");
72
73
  const { base } = await import("viem/chains");
73
74
 
74
75
  const rpcUrl = config.rpcUrl ?? "https://mainnet.base.org";
@@ -21,6 +21,19 @@ export interface CardConfig {
21
21
  brand: string;
22
22
  }
23
23
 
24
+ export interface SpendPolicy {
25
+ maxPerTxUsd?: number;
26
+ maxPerDayUsd?: number;
27
+ requireConfirmationAboveUsd?: number;
28
+ }
29
+
30
+ export interface SpendLedgerEntry {
31
+ method: string;
32
+ amountUsd: number;
33
+ day: string;
34
+ timestamp: string;
35
+ }
36
+
24
37
  export interface Config {
25
38
  apiUrl: string;
26
39
  apiKey: string | null;
@@ -35,6 +48,10 @@ export interface Config {
35
48
  confirmBeforeSpend: boolean;
36
49
  /** Auto-tip amount in USD for successful runs. Default: 0 (no auto-tip). */
37
50
  defaultTipAmount: number;
51
+ /** Optional per-method spend policies enforced client-side by MCP tools. */
52
+ spendPolicies?: Record<string, SpendPolicy>;
53
+ /** Daily spend ledger used to enforce max-per-day limits. */
54
+ spendLedger?: SpendLedgerEntry[];
38
55
  }
39
56
 
40
57
  /** All supported chain identifiers. */
@@ -82,6 +99,8 @@ interface LegacyConfig {
82
99
  defaultWallet?: string | null;
83
100
  card?: CardConfig | null;
84
101
  pendingCardSetupToken?: string | null;
102
+ spendPolicies?: Record<string, SpendPolicy>;
103
+ spendLedger?: SpendLedgerEntry[];
85
104
  }
86
105
 
87
106
  /**
@@ -104,6 +123,8 @@ function migrateIfNeeded(raw: LegacyConfig): Config {
104
123
  favorites: r.favorites as string[] ?? [],
105
124
  confirmBeforeSpend: r.confirmBeforeSpend !== false,
106
125
  defaultTipAmount: typeof r.defaultTipAmount === "number" ? r.defaultTipAmount : 0,
126
+ spendPolicies: r.spendPolicies as Record<string, SpendPolicy> | undefined,
127
+ spendLedger: r.spendLedger as SpendLedgerEntry[] | undefined,
107
128
  };
108
129
  }
109
130
 
@@ -178,6 +199,8 @@ function migrateIfNeeded(raw: LegacyConfig): Config {
178
199
  favorites: [],
179
200
  confirmBeforeSpend: true,
180
201
  defaultTipAmount: 0,
202
+ spendPolicies: {},
203
+ spendLedger: [],
181
204
  };
182
205
 
183
206
  // Write migrated config (only if there was something to migrate)
@@ -203,6 +226,8 @@ export function getConfig(): Config {
203
226
  favorites: [],
204
227
  confirmBeforeSpend: true,
205
228
  defaultTipAmount: 0,
229
+ spendPolicies: {},
230
+ spendLedger: [],
206
231
  };
207
232
 
208
233
  if (!existsSync(CONFIG_FILE)) {
@@ -249,6 +274,26 @@ export function getDefaultTipAmount(): number {
249
274
  return getConfig().defaultTipAmount;
250
275
  }
251
276
 
277
+ export function getSpendPolicy(method: string): SpendPolicy | null {
278
+ const policies = getConfig().spendPolicies ?? {};
279
+ return policies[method] ?? null;
280
+ }
281
+
282
+ export function setSpendPolicy(method: string, policy: SpendPolicy): void {
283
+ const config = getConfig();
284
+ const policies = config.spendPolicies ?? {};
285
+ policies[method] = policy;
286
+ saveConfig({ spendPolicies: policies });
287
+ }
288
+
289
+ export function getSpendLedger(): SpendLedgerEntry[] {
290
+ return getConfig().spendLedger ?? [];
291
+ }
292
+
293
+ export function saveSpendLedger(entries: SpendLedgerEntry[]): void {
294
+ saveConfig({ spendLedger: entries });
295
+ }
296
+
252
297
  // ── Wallet helpers ─────────────────────────────────────────────────
253
298
 
254
299
  /**