@agentwonderland/mcp 0.1.50 → 0.1.51

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.
@@ -21,6 +21,7 @@ vi.mock("../config.js", () => ({
21
21
  getApiUrl: () => "http://api.test",
22
22
  getCardConfig: () => currentCard,
23
23
  getLinkConfig: () => currentLink,
24
+ getLinkCooldown: () => null,
24
25
  getConfig: () => ({ defaultPaymentMethod: currentDefaultPaymentMethod }),
25
26
  getDefaultWallet: () => currentDefaultWallet,
26
27
  getWallets: () => currentWallets,
@@ -124,15 +125,35 @@ describe("payment method initialization", () => {
124
125
  networkId: "profile_test",
125
126
  });
126
127
  expect(mockCreateLinkSharedPaymentToken).toHaveBeenCalledWith(expect.objectContaining({
127
- amount: "2000",
128
+ amount: "25",
128
129
  currency: "usd",
129
130
  networkId: "profile_test",
130
131
  paymentMethodId: "csmrpd_link_123",
131
132
  }));
132
133
  const linkTokenRequest = mockCreateLinkSharedPaymentToken.mock.calls[0]?.[0];
133
- expect(linkTokenRequest?.context).toContain("up to USD 20.00");
134
+ expect(linkTokenRequest?.context).toContain("up to USD 0.25");
134
135
  expect(linkTokenRequest?.context).toContain("quoted at USD 0.25");
135
136
  });
137
+ it("uses an explicit Link approval override when configured", async () => {
138
+ process.env.AGENTWONDERLAND_LINK_APPROVAL_LIMIT_CENTS = "2000";
139
+ currentLink = {
140
+ paymentMethodId: "csmrpd_link_123",
141
+ label: "Visa ****4242",
142
+ };
143
+ currentDefaultPaymentMethod = "link";
144
+ const { getPaymentFetch } = await import("../payments.js");
145
+ await getPaymentFetch("link");
146
+ const stripeConfig = mockStripe.mock.calls[0]?.[0];
147
+ await stripeConfig.createToken({
148
+ amount: "25",
149
+ currency: "usd",
150
+ expiresAt: 1778290000,
151
+ networkId: "profile_test",
152
+ });
153
+ expect(mockCreateLinkSharedPaymentToken).toHaveBeenCalledWith(expect.objectContaining({
154
+ amount: "2000",
155
+ }));
156
+ });
136
157
  it("allows a low env override while never approving below the quoted amount", async () => {
137
158
  process.env.AGENTWONDERLAND_LINK_APPROVAL_LIMIT_CENTS = "10";
138
159
  currentLink = {
@@ -22,6 +22,22 @@ export interface PendingLinkSetup {
22
22
  phrase: string;
23
23
  createdAt: string;
24
24
  }
25
+ export interface PendingLinkSpendRequest {
26
+ id: string;
27
+ approvalUrl?: string;
28
+ amount: string;
29
+ currency: string;
30
+ context: string;
31
+ expiresAt: number;
32
+ networkId: string;
33
+ paymentMethodId: string;
34
+ createdAt: string;
35
+ }
36
+ export interface LinkCooldown {
37
+ reason: string;
38
+ createdAt: string;
39
+ blockedUntil: string;
40
+ }
25
41
  export interface SpendPolicy {
26
42
  maxPerTxUsd?: number;
27
43
  maxPerDayUsd?: number;
@@ -43,6 +59,8 @@ export interface Config {
43
59
  card: CardConfig | null;
44
60
  link: LinkConfig | null;
45
61
  pendingLinkSetup?: PendingLinkSetup | null;
62
+ pendingLinkSpendRequest?: PendingLinkSpendRequest | null;
63
+ linkCooldown?: LinkCooldown | null;
46
64
  pendingCardSetupToken?: string | null;
47
65
  favorites: string[];
48
66
  /** Require user confirmation before spending. Default: true. Set false for headless/automated use. */
@@ -103,6 +121,10 @@ export declare function removeWallet(id: string): void;
103
121
  export declare function getCardConfig(): CardConfig | null;
104
122
  export declare function getLinkConfig(): LinkConfig | null;
105
123
  export declare function getPendingLinkSetup(): PendingLinkSetup | null;
124
+ export declare function getPendingLinkSpendRequest(): PendingLinkSpendRequest | null;
125
+ export declare function setPendingLinkSpendRequest(pendingLinkSpendRequest: PendingLinkSpendRequest | null): void;
126
+ export declare function getLinkCooldown(): LinkCooldown | null;
127
+ export declare function setLinkCooldown(linkCooldown: LinkCooldown | null): void;
106
128
  /**
107
129
  * Save card configuration after setup.
108
130
  */
@@ -38,6 +38,8 @@ function migrateIfNeeded(raw) {
38
38
  card: raw.card ?? null,
39
39
  link: raw.link ?? null,
40
40
  pendingLinkSetup: raw.link ? null : (raw.pendingLinkSetup ?? null),
41
+ pendingLinkSpendRequest: raw.pendingLinkSpendRequest ?? null,
42
+ linkCooldown: raw.linkCooldown ?? null,
41
43
  pendingCardSetupToken: r.pendingCardSetupToken ?? null,
42
44
  favorites: r.favorites ?? [],
43
45
  confirmBeforeSpend: r.confirmBeforeSpend !== false,
@@ -113,6 +115,8 @@ function migrateIfNeeded(raw) {
113
115
  card,
114
116
  link: raw.link ?? null,
115
117
  pendingLinkSetup: raw.link ? null : (raw.pendingLinkSetup ?? null),
118
+ pendingLinkSpendRequest: raw.pendingLinkSpendRequest ?? null,
119
+ linkCooldown: raw.linkCooldown ?? null,
116
120
  pendingCardSetupToken: null,
117
121
  favorites: [],
118
122
  confirmBeforeSpend: true,
@@ -138,6 +142,8 @@ export function getConfig() {
138
142
  card: null,
139
143
  link: null,
140
144
  pendingLinkSetup: null,
145
+ pendingLinkSpendRequest: null,
146
+ linkCooldown: null,
141
147
  pendingCardSetupToken: null,
142
148
  favorites: [],
143
149
  confirmBeforeSpend: true,
@@ -301,6 +307,18 @@ export function getLinkConfig() {
301
307
  export function getPendingLinkSetup() {
302
308
  return getConfig().pendingLinkSetup ?? null;
303
309
  }
310
+ export function getPendingLinkSpendRequest() {
311
+ return getConfig().pendingLinkSpendRequest ?? null;
312
+ }
313
+ export function setPendingLinkSpendRequest(pendingLinkSpendRequest) {
314
+ saveConfig({ pendingLinkSpendRequest });
315
+ }
316
+ export function getLinkCooldown() {
317
+ return getConfig().linkCooldown ?? null;
318
+ }
319
+ export function setLinkCooldown(linkCooldown) {
320
+ saveConfig({ linkCooldown });
321
+ }
304
322
  /**
305
323
  * Save card configuration after setup.
306
324
  */
@@ -330,12 +348,14 @@ export function setLinkConfig(link) {
330
348
  link,
331
349
  defaultPaymentMethod: "link",
332
350
  pendingLinkSetup: null,
351
+ pendingLinkSpendRequest: null,
333
352
  });
334
353
  }
335
354
  else {
336
355
  saveConfig({
337
356
  link,
338
357
  pendingLinkSetup: null,
358
+ pendingLinkSpendRequest: null,
339
359
  defaultPaymentMethod: current.defaultPaymentMethod === "link"
340
360
  ? undefined
341
361
  : current.defaultPaymentMethod,
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { getPendingLinkSpendRequest, setLinkCooldown, setPendingLinkSpendRequest, } from "./config.js";
3
4
  const execFileAsync = promisify(execFile);
4
5
  const LINK_CLI_PACKAGE = "@stripe/link-cli";
5
6
  const LINK_CLI_TIMEOUT_MS = 10 * 60 * 1000;
@@ -92,6 +93,34 @@ function extractSpendRequestApproval(output) {
92
93
  }
93
94
  return null;
94
95
  }
96
+ function isMatchingPendingSpendRequest(pending, params) {
97
+ if (!pending)
98
+ return false;
99
+ if (pending.amount !== params.amount)
100
+ return false;
101
+ if (pending.currency.toLowerCase() !== params.currency.toLowerCase())
102
+ return false;
103
+ if (pending.context !== params.context)
104
+ return false;
105
+ if (pending.networkId !== params.networkId)
106
+ return false;
107
+ if (pending.paymentMethodId !== params.paymentMethodId)
108
+ return false;
109
+ if (pending.expiresAt <= Math.floor(Date.now() / 1000))
110
+ return false;
111
+ return true;
112
+ }
113
+ function isProjectedSpendCapError(message) {
114
+ return /projected daily spend|projected spend|exceeds limit/i.test(message);
115
+ }
116
+ function recordLinkCooldown(reason) {
117
+ const now = new Date();
118
+ setLinkCooldown({
119
+ reason,
120
+ createdAt: now.toISOString(),
121
+ blockedUntil: new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(),
122
+ });
123
+ }
95
124
  function normalizePaymentMethods(output) {
96
125
  const values = Array.isArray(output)
97
126
  ? output
@@ -181,6 +210,22 @@ export async function listLinkPaymentMethods() {
181
210
  return normalizePaymentMethods(output);
182
211
  }
183
212
  export async function createLinkSharedPaymentToken(params) {
213
+ const existing = getPendingLinkSpendRequest();
214
+ if (isMatchingPendingSpendRequest(existing, params)) {
215
+ try {
216
+ console.error(`Resuming pending Link approval: ${existing.id}`);
217
+ const spt = await retrieveSharedPaymentToken(existing.id, existing.approvalUrl);
218
+ setPendingLinkSpendRequest(null);
219
+ return spt;
220
+ }
221
+ catch (err) {
222
+ const message = err instanceof Error ? err.message : String(err);
223
+ if (!/denied|expired|not found|without a shared payment token|POLLING_TIMEOUT/i.test(message)) {
224
+ throw err;
225
+ }
226
+ setPendingLinkSpendRequest(null);
227
+ }
228
+ }
184
229
  const args = [
185
230
  "spend-request",
186
231
  "create",
@@ -207,6 +252,16 @@ export async function createLinkSharedPaymentToken(params) {
207
252
  }
208
253
  catch (err) {
209
254
  const message = err instanceof Error ? err.message : String(err);
255
+ if (isProjectedSpendCapError(message)) {
256
+ recordLinkCooldown(message);
257
+ throw new Error([
258
+ "Link is temporarily blocked by Stripe's projected-spend cap.",
259
+ "Reauthing Link or switching cards in the same Link account will not fix this.",
260
+ "Use USDC for now, wait for the rolling Link window to clear, or ask Stripe to raise/clear the merchant projected-spend cap.",
261
+ "",
262
+ message,
263
+ ].join("\n"));
264
+ }
210
265
  if (/invalid network_id|could not retrieve merchant information/i.test(message)) {
211
266
  throw new Error([
212
267
  message,
@@ -219,41 +274,58 @@ export async function createLinkSharedPaymentToken(params) {
219
274
  }
220
275
  const spt = extractSharedPaymentToken(output);
221
276
  if (spt) {
277
+ setPendingLinkSpendRequest(null);
222
278
  return spt;
223
279
  }
224
280
  const approval = extractSpendRequestApproval(output);
225
281
  if (approval?.id && approval.status === "pending_approval") {
282
+ setPendingLinkSpendRequest({
283
+ id: approval.id,
284
+ approvalUrl: approval.approvalUrl,
285
+ amount: params.amount,
286
+ currency: params.currency,
287
+ context: params.context,
288
+ expiresAt: params.expiresAt,
289
+ networkId: params.networkId,
290
+ paymentMethodId: params.paymentMethodId,
291
+ createdAt: new Date().toISOString(),
292
+ });
226
293
  if (approval.approvalUrl) {
227
294
  console.error(`Link approval required: ${approval.approvalUrl}`);
228
295
  }
229
- let retrieved = await runLinkCli([
296
+ const retrievedSpt = await retrieveSharedPaymentToken(approval.id, approval.approvalUrl);
297
+ setPendingLinkSpendRequest(null);
298
+ return retrievedSpt;
299
+ }
300
+ {
301
+ throw new Error("Link spend request completed without a shared payment token in the CLI response.");
302
+ }
303
+ }
304
+ async function retrieveSharedPaymentToken(spendRequestId, approvalUrl) {
305
+ let retrieved = await runLinkCli([
306
+ "spend-request",
307
+ "retrieve",
308
+ spendRequestId,
309
+ "--interval",
310
+ "2",
311
+ "--max-attempts",
312
+ "150",
313
+ ]);
314
+ let retrievedSpt = extractSharedPaymentToken(retrieved);
315
+ for (let attempt = 0; !retrievedSpt && attempt < 30; attempt += 1) {
316
+ await sleep(2_000);
317
+ retrieved = await runLinkCli([
230
318
  "spend-request",
231
319
  "retrieve",
232
- approval.id,
233
- "--interval",
234
- "2",
235
- "--max-attempts",
236
- "150",
320
+ spendRequestId,
237
321
  ]);
238
- let retrievedSpt = extractSharedPaymentToken(retrieved);
239
- for (let attempt = 0; !retrievedSpt && attempt < 30; attempt += 1) {
240
- await sleep(2_000);
241
- retrieved = await runLinkCli([
242
- "spend-request",
243
- "retrieve",
244
- approval.id,
245
- ]);
246
- retrievedSpt = extractSharedPaymentToken(retrieved);
247
- }
248
- if (retrievedSpt) {
249
- return retrievedSpt;
250
- }
251
- throw new Error([
252
- "Link spend request finished without a shared payment token.",
253
- approval.approvalUrl ? `Approval URL: ${approval.approvalUrl}` : undefined,
254
- ].filter(Boolean).join("\n"));
322
+ retrievedSpt = extractSharedPaymentToken(retrieved);
255
323
  }
256
- {
257
- throw new Error("Link spend request completed without a shared payment token in the CLI response.");
324
+ if (retrievedSpt) {
325
+ return retrievedSpt;
258
326
  }
327
+ throw new Error([
328
+ "Link spend request finished without a shared payment token.",
329
+ approvalUrl ? `Approval URL: ${approvalUrl}` : undefined,
330
+ ].filter(Boolean).join("\n"));
259
331
  }
@@ -9,7 +9,7 @@
9
9
  * Users can configure multiple wallets with different chains and select
10
10
  * which to use per-request via `--pay-with <wallet-id|chain|card>`.
11
11
  */
12
- import { getConfig, getWallets, getDefaultWallet, getCardConfig, getLinkConfig, resolveWalletAndChain, getApiUrl, } from "./config.js";
12
+ import { getConfig, getWallets, getDefaultWallet, getCardConfig, getLinkConfig, getLinkCooldown, resolveWalletAndChain, getApiUrl, } from "./config.js";
13
13
  // Feature flag: disable card payment for launch. Flip to true once Stripe
14
14
  // approves the live SPT issuer. Config/card state is still persisted so this
15
15
  // can be re-enabled without reconfiguring wallets.
@@ -26,7 +26,6 @@ const REGISTRY_METHOD_MAP = {
26
26
  card: "stripe_card",
27
27
  link: "stripe_card",
28
28
  };
29
- const DEFAULT_LINK_APPROVAL_LIMIT_CENTS = 2_000;
30
29
  const ACCEPTED_PAYMENT_ALIASES = {
31
30
  tempo: ["tempo_usdc", "tempo"],
32
31
  base: ["base_usdc", "base"],
@@ -164,10 +163,24 @@ function formatMinorCurrencyAmount(currency, amount) {
164
163
  function getLinkApprovalLimitAmount(actualAmount) {
165
164
  const actualAmountCents = Number(actualAmount);
166
165
  const configuredLimit = Number(process.env.AGENTWONDERLAND_LINK_APPROVAL_LIMIT_CENTS);
167
- const defaultLimit = Number.isFinite(configuredLimit) && configuredLimit > 0
168
- ? Math.floor(configuredLimit)
169
- : DEFAULT_LINK_APPROVAL_LIMIT_CENTS;
170
- return String(Math.max(actualAmountCents, defaultLimit));
166
+ const approvalLimit = Number.isFinite(configuredLimit) && configuredLimit > 0
167
+ ? Math.max(actualAmountCents, Math.floor(configuredLimit))
168
+ : actualAmountCents;
169
+ return String(approvalLimit);
170
+ }
171
+ function assertLinkNotCoolingDown() {
172
+ const cooldown = getLinkCooldown();
173
+ if (!cooldown)
174
+ return;
175
+ const blockedUntil = Date.parse(cooldown.blockedUntil);
176
+ if (!Number.isFinite(blockedUntil) || blockedUntil <= Date.now())
177
+ return;
178
+ throw new Error([
179
+ "Link is temporarily blocked by Stripe's projected-spend cap.",
180
+ "Reauthing Link or switching cards in the same Link account will not fix this.",
181
+ `Try again after ${cooldown.blockedUntil}, use USDC, or ask Stripe to raise/clear the merchant projected-spend cap.`,
182
+ `Last Link error: ${cooldown.reason}`,
183
+ ].join("\n"));
171
184
  }
172
185
  function buildLinkApprovalContext(params) {
173
186
  const amountText = formatMinorCurrencyAmount(params.currency, params.amount);
@@ -188,6 +201,7 @@ async function initLink() {
188
201
  methods: [stripe({
189
202
  paymentMethod: linkConfig.paymentMethodId,
190
203
  createToken: async (params) => {
204
+ assertLinkNotCoolingDown();
191
205
  const approvalAmount = getLinkApprovalLimitAmount(params.amount);
192
206
  console.error(`Requesting Link approval up to ${formatMinorCurrencyAmount(params.currency, approvalAmount)} ` +
193
207
  `for this ${formatMinorCurrencyAmount(params.currency, params.amount)} Agent Wonderland run.`);
@@ -1 +1 @@
1
- export declare const MCP_PACKAGE_VERSION = "0.1.50";
1
+ export declare const MCP_PACKAGE_VERSION = "0.1.51";
@@ -1 +1 @@
1
- export const MCP_PACKAGE_VERSION = "0.1.50";
1
+ export const MCP_PACKAGE_VERSION = "0.1.51";
@@ -5,6 +5,7 @@ const state = vi.hoisted(() => ({
5
5
  card: null,
6
6
  link: null,
7
7
  pendingLinkSetup: null,
8
+ linkCooldown: null,
8
9
  pendingCardSetupToken: null,
9
10
  spendPolicies: {},
10
11
  defaultPaymentMethod: undefined,
@@ -53,6 +54,7 @@ vi.mock("../../core/config.js", () => ({
53
54
  },
54
55
  getCardConfig: () => state.card,
55
56
  getLinkConfig: () => state.link,
57
+ getLinkCooldown: () => state.linkCooldown,
56
58
  getPendingLinkSetup: () => state.pendingLinkSetup,
57
59
  getPendingCardSetupToken: () => state.pendingCardSetupToken,
58
60
  getSpendPolicy: (walletId) => state.spendPolicies[walletId] ?? null,
@@ -130,6 +132,7 @@ function resetState() {
130
132
  state.card = null;
131
133
  state.link = null;
132
134
  state.pendingLinkSetup = null;
135
+ state.linkCooldown = null;
133
136
  state.pendingCardSetupToken = null;
134
137
  state.spendPolicies = {};
135
138
  state.defaultPaymentMethod = undefined;
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { getWallets, getCardConfig, getLinkConfig, getPendingLinkSetup, setPendingLinkSetup, setLinkConfig, setCardConfig, addWallet, getPendingCardSetupToken, getSpendPolicy, setDefaultPaymentMethod, setSpendPolicy, } from "../core/config.js";
2
+ import { getWallets, getCardConfig, getLinkConfig, getLinkCooldown, getPendingLinkSetup, setPendingLinkSetup, setLinkConfig, setCardConfig, addWallet, getPendingCardSetupToken, getSpendPolicy, setDefaultPaymentMethod, setSpendPolicy, } from "../core/config.js";
3
3
  import { getWalletAddress, isCardPaymentEnabled } from "../core/payments.js";
4
4
  import { fetchUsdcBalance } from "../core/balances.js";
5
5
  import { getSettings } from "../core/settings.js";
@@ -110,6 +110,12 @@ export function registerWalletTools(server) {
110
110
  lines.push(auth.authenticated
111
111
  ? " Link CLI: authenticated"
112
112
  : " Link CLI: not authenticated — run npx @stripe/link-cli auth login");
113
+ const cooldown = getLinkCooldown();
114
+ const blockedUntil = cooldown ? Date.parse(cooldown.blockedUntil) : NaN;
115
+ if (cooldown && Number.isFinite(blockedUntil) && blockedUntil > Date.now()) {
116
+ lines.push(` Link status: temporarily blocked by Stripe projected-spend cap until ${cooldown.blockedUntil}`);
117
+ lines.push(" Link note: reauthing or switching cards in the same Link account will not clear this cap.");
118
+ }
113
119
  }
114
120
  if (pendingCardSetupToken) {
115
121
  lines.push(" Card setup: pending confirmation");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentwonderland/mcp",
3
- "version": "0.1.50",
3
+ "version": "0.1.51",
4
4
  "type": "module",
5
5
  "description": "MCP server for the Agent Wonderland AI agent marketplace",
6
6
  "bin": {
@@ -26,6 +26,7 @@ vi.mock("../config.js", () => ({
26
26
  getApiUrl: () => "http://api.test",
27
27
  getCardConfig: () => currentCard,
28
28
  getLinkConfig: () => currentLink,
29
+ getLinkCooldown: () => null,
29
30
  getConfig: () => ({ defaultPaymentMethod: currentDefaultPaymentMethod }),
30
31
  getDefaultWallet: () => currentDefaultWallet,
31
32
  getWallets: () => currentWallets,
@@ -166,17 +167,50 @@ describe("payment method initialization", () => {
166
167
 
167
168
  expect(mockCreateLinkSharedPaymentToken).toHaveBeenCalledWith(
168
169
  expect.objectContaining({
169
- amount: "2000",
170
+ amount: "25",
170
171
  currency: "usd",
171
172
  networkId: "profile_test",
172
173
  paymentMethodId: "csmrpd_link_123",
173
174
  }),
174
175
  );
175
176
  const linkTokenRequest = mockCreateLinkSharedPaymentToken.mock.calls[0]?.[0] as { context: string } | undefined;
176
- expect(linkTokenRequest?.context).toContain("up to USD 20.00");
177
+ expect(linkTokenRequest?.context).toContain("up to USD 0.25");
177
178
  expect(linkTokenRequest?.context).toContain("quoted at USD 0.25");
178
179
  });
179
180
 
181
+ it("uses an explicit Link approval override when configured", async () => {
182
+ process.env.AGENTWONDERLAND_LINK_APPROVAL_LIMIT_CENTS = "2000";
183
+ currentLink = {
184
+ paymentMethodId: "csmrpd_link_123",
185
+ label: "Visa ****4242",
186
+ };
187
+ currentDefaultPaymentMethod = "link";
188
+
189
+ const { getPaymentFetch } = await import("../payments.js");
190
+ await getPaymentFetch("link");
191
+
192
+ const stripeConfig = mockStripe.mock.calls[0]?.[0] as {
193
+ createToken: (params: {
194
+ amount: string;
195
+ currency: string;
196
+ expiresAt: number;
197
+ networkId: string;
198
+ }) => Promise<string>;
199
+ };
200
+ await stripeConfig.createToken({
201
+ amount: "25",
202
+ currency: "usd",
203
+ expiresAt: 1778290000,
204
+ networkId: "profile_test",
205
+ });
206
+
207
+ expect(mockCreateLinkSharedPaymentToken).toHaveBeenCalledWith(
208
+ expect.objectContaining({
209
+ amount: "2000",
210
+ }),
211
+ );
212
+ });
213
+
180
214
  it("allows a low env override while never approving below the quoted amount", async () => {
181
215
  process.env.AGENTWONDERLAND_LINK_APPROVAL_LIMIT_CENTS = "10";
182
216
  currentLink = {
@@ -32,6 +32,24 @@ export interface PendingLinkSetup {
32
32
  createdAt: string;
33
33
  }
34
34
 
35
+ export interface PendingLinkSpendRequest {
36
+ id: string;
37
+ approvalUrl?: string;
38
+ amount: string;
39
+ currency: string;
40
+ context: string;
41
+ expiresAt: number;
42
+ networkId: string;
43
+ paymentMethodId: string;
44
+ createdAt: string;
45
+ }
46
+
47
+ export interface LinkCooldown {
48
+ reason: string;
49
+ createdAt: string;
50
+ blockedUntil: string;
51
+ }
52
+
35
53
  export interface SpendPolicy {
36
54
  maxPerTxUsd?: number;
37
55
  maxPerDayUsd?: number;
@@ -55,6 +73,8 @@ export interface Config {
55
73
  card: CardConfig | null;
56
74
  link: LinkConfig | null;
57
75
  pendingLinkSetup?: PendingLinkSetup | null;
76
+ pendingLinkSpendRequest?: PendingLinkSpendRequest | null;
77
+ linkCooldown?: LinkCooldown | null;
58
78
  pendingCardSetupToken?: string | null;
59
79
  favorites: string[];
60
80
  /** Require user confirmation before spending. Default: true. Set false for headless/automated use. */
@@ -113,6 +133,8 @@ interface LegacyConfig {
113
133
  card?: CardConfig | null;
114
134
  link?: LinkConfig | null;
115
135
  pendingLinkSetup?: PendingLinkSetup | null;
136
+ pendingLinkSpendRequest?: PendingLinkSpendRequest | null;
137
+ linkCooldown?: LinkCooldown | null;
116
138
  pendingCardSetupToken?: string | null;
117
139
  spendPolicies?: Record<string, SpendPolicy>;
118
140
  spendLedger?: SpendLedgerEntry[];
@@ -136,6 +158,8 @@ function migrateIfNeeded(raw: LegacyConfig): Config {
136
158
  card: raw.card ?? null,
137
159
  link: raw.link ?? null,
138
160
  pendingLinkSetup: raw.link ? null : (raw.pendingLinkSetup ?? null),
161
+ pendingLinkSpendRequest: raw.pendingLinkSpendRequest ?? null,
162
+ linkCooldown: raw.linkCooldown ?? null,
139
163
  pendingCardSetupToken: (r.pendingCardSetupToken as string | null | undefined) ?? null,
140
164
  favorites: r.favorites as string[] ?? [],
141
165
  confirmBeforeSpend: r.confirmBeforeSpend !== false,
@@ -214,6 +238,8 @@ function migrateIfNeeded(raw: LegacyConfig): Config {
214
238
  card,
215
239
  link: raw.link ?? null,
216
240
  pendingLinkSetup: raw.link ? null : (raw.pendingLinkSetup ?? null),
241
+ pendingLinkSpendRequest: raw.pendingLinkSpendRequest ?? null,
242
+ linkCooldown: raw.linkCooldown ?? null,
217
243
  pendingCardSetupToken: null,
218
244
  favorites: [],
219
245
  confirmBeforeSpend: true,
@@ -243,6 +269,8 @@ export function getConfig(): Config {
243
269
  card: null,
244
270
  link: null,
245
271
  pendingLinkSetup: null,
272
+ pendingLinkSpendRequest: null,
273
+ linkCooldown: null,
246
274
  pendingCardSetupToken: null,
247
275
  favorites: [],
248
276
  confirmBeforeSpend: true,
@@ -432,6 +460,22 @@ export function getPendingLinkSetup(): PendingLinkSetup | null {
432
460
  return getConfig().pendingLinkSetup ?? null;
433
461
  }
434
462
 
463
+ export function getPendingLinkSpendRequest(): PendingLinkSpendRequest | null {
464
+ return getConfig().pendingLinkSpendRequest ?? null;
465
+ }
466
+
467
+ export function setPendingLinkSpendRequest(pendingLinkSpendRequest: PendingLinkSpendRequest | null): void {
468
+ saveConfig({ pendingLinkSpendRequest });
469
+ }
470
+
471
+ export function getLinkCooldown(): LinkCooldown | null {
472
+ return getConfig().linkCooldown ?? null;
473
+ }
474
+
475
+ export function setLinkCooldown(linkCooldown: LinkCooldown | null): void {
476
+ saveConfig({ linkCooldown });
477
+ }
478
+
435
479
  /**
436
480
  * Save card configuration after setup.
437
481
  */
@@ -461,11 +505,13 @@ export function setLinkConfig(link: LinkConfig | null): void {
461
505
  link,
462
506
  defaultPaymentMethod: "link",
463
507
  pendingLinkSetup: null,
508
+ pendingLinkSpendRequest: null,
464
509
  });
465
510
  } else {
466
511
  saveConfig({
467
512
  link,
468
513
  pendingLinkSetup: null,
514
+ pendingLinkSpendRequest: null,
469
515
  defaultPaymentMethod: current.defaultPaymentMethod === "link"
470
516
  ? undefined
471
517
  : current.defaultPaymentMethod,
@@ -1,5 +1,11 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import {
4
+ getPendingLinkSpendRequest,
5
+ setLinkCooldown,
6
+ setPendingLinkSpendRequest,
7
+ type PendingLinkSpendRequest,
8
+ } from "./config.js";
3
9
 
4
10
  const execFileAsync = promisify(execFile);
5
11
  const LINK_CLI_PACKAGE = "@stripe/link-cli";
@@ -116,6 +122,40 @@ function extractSpendRequestApproval(output: unknown): { id: string; approvalUrl
116
122
  return null;
117
123
  }
118
124
 
125
+ function isMatchingPendingSpendRequest(
126
+ pending: PendingLinkSpendRequest | null,
127
+ params: {
128
+ amount: string;
129
+ currency: string;
130
+ context: string;
131
+ expiresAt: number;
132
+ networkId: string;
133
+ paymentMethodId: string;
134
+ },
135
+ ): pending is PendingLinkSpendRequest {
136
+ if (!pending) return false;
137
+ if (pending.amount !== params.amount) return false;
138
+ if (pending.currency.toLowerCase() !== params.currency.toLowerCase()) return false;
139
+ if (pending.context !== params.context) return false;
140
+ if (pending.networkId !== params.networkId) return false;
141
+ if (pending.paymentMethodId !== params.paymentMethodId) return false;
142
+ if (pending.expiresAt <= Math.floor(Date.now() / 1000)) return false;
143
+ return true;
144
+ }
145
+
146
+ function isProjectedSpendCapError(message: string): boolean {
147
+ return /projected daily spend|projected spend|exceeds limit/i.test(message);
148
+ }
149
+
150
+ function recordLinkCooldown(reason: string): void {
151
+ const now = new Date();
152
+ setLinkCooldown({
153
+ reason,
154
+ createdAt: now.toISOString(),
155
+ blockedUntil: new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(),
156
+ });
157
+ }
158
+
119
159
  function normalizePaymentMethods(output: unknown): LinkCliPaymentMethod[] {
120
160
  const values = Array.isArray(output)
121
161
  ? output
@@ -215,6 +255,22 @@ export async function createLinkSharedPaymentToken(params: {
215
255
  networkId: string;
216
256
  paymentMethodId: string;
217
257
  }): Promise<string> {
258
+ const existing = getPendingLinkSpendRequest();
259
+ if (isMatchingPendingSpendRequest(existing, params)) {
260
+ try {
261
+ console.error(`Resuming pending Link approval: ${existing.id}`);
262
+ const spt = await retrieveSharedPaymentToken(existing.id, existing.approvalUrl);
263
+ setPendingLinkSpendRequest(null);
264
+ return spt;
265
+ } catch (err) {
266
+ const message = err instanceof Error ? err.message : String(err);
267
+ if (!/denied|expired|not found|without a shared payment token|POLLING_TIMEOUT/i.test(message)) {
268
+ throw err;
269
+ }
270
+ setPendingLinkSpendRequest(null);
271
+ }
272
+ }
273
+
218
274
  const args = [
219
275
  "spend-request",
220
276
  "create",
@@ -242,6 +298,18 @@ export async function createLinkSharedPaymentToken(params: {
242
298
  output = await runLinkCli(args);
243
299
  } catch (err) {
244
300
  const message = err instanceof Error ? err.message : String(err);
301
+ if (isProjectedSpendCapError(message)) {
302
+ recordLinkCooldown(message);
303
+ throw new Error(
304
+ [
305
+ "Link is temporarily blocked by Stripe's projected-spend cap.",
306
+ "Reauthing Link or switching cards in the same Link account will not fix this.",
307
+ "Use USDC for now, wait for the rolling Link window to clear, or ask Stripe to raise/clear the merchant projected-spend cap.",
308
+ "",
309
+ message,
310
+ ].join("\n"),
311
+ );
312
+ }
245
313
  if (/invalid network_id|could not retrieve merchant information/i.test(message)) {
246
314
  throw new Error(
247
315
  [
@@ -256,45 +324,63 @@ export async function createLinkSharedPaymentToken(params: {
256
324
  }
257
325
  const spt = extractSharedPaymentToken(output);
258
326
  if (spt) {
327
+ setPendingLinkSpendRequest(null);
259
328
  return spt;
260
329
  }
261
330
 
262
331
  const approval = extractSpendRequestApproval(output);
263
332
  if (approval?.id && approval.status === "pending_approval") {
333
+ setPendingLinkSpendRequest({
334
+ id: approval.id,
335
+ approvalUrl: approval.approvalUrl,
336
+ amount: params.amount,
337
+ currency: params.currency,
338
+ context: params.context,
339
+ expiresAt: params.expiresAt,
340
+ networkId: params.networkId,
341
+ paymentMethodId: params.paymentMethodId,
342
+ createdAt: new Date().toISOString(),
343
+ });
264
344
  if (approval.approvalUrl) {
265
345
  console.error(`Link approval required: ${approval.approvalUrl}`);
266
346
  }
267
- let retrieved = await runLinkCli([
268
- "spend-request",
269
- "retrieve",
270
- approval.id,
271
- "--interval",
272
- "2",
273
- "--max-attempts",
274
- "150",
275
- ]);
276
- let retrievedSpt = extractSharedPaymentToken(retrieved);
277
- for (let attempt = 0; !retrievedSpt && attempt < 30; attempt += 1) {
278
- await sleep(2_000);
279
- retrieved = await runLinkCli([
280
- "spend-request",
281
- "retrieve",
282
- approval.id,
283
- ]);
284
- retrievedSpt = extractSharedPaymentToken(retrieved);
285
- }
286
- if (retrievedSpt) {
287
- return retrievedSpt;
288
- }
289
- throw new Error(
290
- [
291
- "Link spend request finished without a shared payment token.",
292
- approval.approvalUrl ? `Approval URL: ${approval.approvalUrl}` : undefined,
293
- ].filter(Boolean).join("\n"),
294
- );
347
+ const retrievedSpt = await retrieveSharedPaymentToken(approval.id, approval.approvalUrl);
348
+ setPendingLinkSpendRequest(null);
349
+ return retrievedSpt;
295
350
  }
296
351
 
297
352
  {
298
353
  throw new Error("Link spend request completed without a shared payment token in the CLI response.");
299
354
  }
300
355
  }
356
+
357
+ async function retrieveSharedPaymentToken(spendRequestId: string, approvalUrl?: string): Promise<string> {
358
+ let retrieved = await runLinkCli([
359
+ "spend-request",
360
+ "retrieve",
361
+ spendRequestId,
362
+ "--interval",
363
+ "2",
364
+ "--max-attempts",
365
+ "150",
366
+ ]);
367
+ let retrievedSpt = extractSharedPaymentToken(retrieved);
368
+ for (let attempt = 0; !retrievedSpt && attempt < 30; attempt += 1) {
369
+ await sleep(2_000);
370
+ retrieved = await runLinkCli([
371
+ "spend-request",
372
+ "retrieve",
373
+ spendRequestId,
374
+ ]);
375
+ retrievedSpt = extractSharedPaymentToken(retrieved);
376
+ }
377
+ if (retrievedSpt) {
378
+ return retrievedSpt;
379
+ }
380
+ throw new Error(
381
+ [
382
+ "Link spend request finished without a shared payment token.",
383
+ approvalUrl ? `Approval URL: ${approvalUrl}` : undefined,
384
+ ].filter(Boolean).join("\n"),
385
+ );
386
+ }
@@ -16,6 +16,7 @@ import {
16
16
  getDefaultWallet,
17
17
  getCardConfig,
18
18
  getLinkConfig,
19
+ getLinkCooldown,
19
20
  resolveWalletAndChain,
20
21
  getApiUrl,
21
22
  type WalletEntry,
@@ -41,8 +42,6 @@ const REGISTRY_METHOD_MAP: Record<string, string> = {
41
42
  link: "stripe_card",
42
43
  };
43
44
 
44
- const DEFAULT_LINK_APPROVAL_LIMIT_CENTS = 2_000;
45
-
46
45
  const ACCEPTED_PAYMENT_ALIASES: Record<string, string[]> = {
47
46
  tempo: ["tempo_usdc", "tempo"],
48
47
  base: ["base_usdc", "base"],
@@ -200,10 +199,25 @@ function formatMinorCurrencyAmount(currency: string, amount: string): string {
200
199
  function getLinkApprovalLimitAmount(actualAmount: string): string {
201
200
  const actualAmountCents = Number(actualAmount);
202
201
  const configuredLimit = Number(process.env.AGENTWONDERLAND_LINK_APPROVAL_LIMIT_CENTS);
203
- const defaultLimit = Number.isFinite(configuredLimit) && configuredLimit > 0
204
- ? Math.floor(configuredLimit)
205
- : DEFAULT_LINK_APPROVAL_LIMIT_CENTS;
206
- return String(Math.max(actualAmountCents, defaultLimit));
202
+ const approvalLimit = Number.isFinite(configuredLimit) && configuredLimit > 0
203
+ ? Math.max(actualAmountCents, Math.floor(configuredLimit))
204
+ : actualAmountCents;
205
+ return String(approvalLimit);
206
+ }
207
+
208
+ function assertLinkNotCoolingDown(): void {
209
+ const cooldown = getLinkCooldown();
210
+ if (!cooldown) return;
211
+ const blockedUntil = Date.parse(cooldown.blockedUntil);
212
+ if (!Number.isFinite(blockedUntil) || blockedUntil <= Date.now()) return;
213
+ throw new Error(
214
+ [
215
+ "Link is temporarily blocked by Stripe's projected-spend cap.",
216
+ "Reauthing Link or switching cards in the same Link account will not fix this.",
217
+ `Try again after ${cooldown.blockedUntil}, use USDC, or ask Stripe to raise/clear the merchant projected-spend cap.`,
218
+ `Last Link error: ${cooldown.reason}`,
219
+ ].join("\n"),
220
+ );
207
221
  }
208
222
 
209
223
  function buildLinkApprovalContext(params: {
@@ -239,6 +253,7 @@ async function initLink(): Promise<typeof fetch | null> {
239
253
  expiresAt: number;
240
254
  metadata?: Record<string, string>;
241
255
  }) => {
256
+ assertLinkNotCoolingDown();
242
257
  const approvalAmount = getLinkApprovalLimitAmount(params.amount);
243
258
  console.error(
244
259
  `Requesting Link approval up to ${formatMinorCurrencyAmount(params.currency, approvalAmount)} ` +
@@ -1 +1 @@
1
- export const MCP_PACKAGE_VERSION = "0.1.50";
1
+ export const MCP_PACKAGE_VERSION = "0.1.51";
@@ -29,6 +29,12 @@ type PendingLinkSetup = {
29
29
  createdAt: string;
30
30
  } | null;
31
31
 
32
+ type LinkCooldown = {
33
+ reason: string;
34
+ createdAt: string;
35
+ blockedUntil: string;
36
+ } | null;
37
+
32
38
  type WalletToolResult = {
33
39
  content: Array<{ type: "text"; text: string }>;
34
40
  };
@@ -39,6 +45,7 @@ const state = vi.hoisted(() => ({
39
45
  card: null as CardConfig,
40
46
  link: null as LinkConfig,
41
47
  pendingLinkSetup: null as PendingLinkSetup,
48
+ linkCooldown: null as LinkCooldown,
42
49
  pendingCardSetupToken: null as string | null,
43
50
  spendPolicies: {} as Record<string, unknown>,
44
51
  defaultPaymentMethod: undefined as string | undefined,
@@ -93,6 +100,7 @@ vi.mock("../../core/config.js", () => ({
93
100
  },
94
101
  getCardConfig: () => state.card,
95
102
  getLinkConfig: () => state.link,
103
+ getLinkCooldown: () => state.linkCooldown,
96
104
  getPendingLinkSetup: () => state.pendingLinkSetup,
97
105
  getPendingCardSetupToken: () => state.pendingCardSetupToken,
98
106
  getSpendPolicy: (walletId: string) => state.spendPolicies[walletId] ?? null,
@@ -176,6 +184,7 @@ function resetState(): void {
176
184
  state.card = null;
177
185
  state.link = null;
178
186
  state.pendingLinkSetup = null;
187
+ state.linkCooldown = null;
179
188
  state.pendingCardSetupToken = null;
180
189
  state.spendPolicies = {};
181
190
  state.defaultPaymentMethod = undefined;
@@ -4,6 +4,7 @@ import {
4
4
  getWallets,
5
5
  getCardConfig,
6
6
  getLinkConfig,
7
+ getLinkCooldown,
7
8
  getPendingLinkSetup,
8
9
  setPendingLinkSetup,
9
10
  setLinkConfig,
@@ -157,6 +158,12 @@ export function registerWalletTools(server: McpServer): void {
157
158
  lines.push(auth.authenticated
158
159
  ? " Link CLI: authenticated"
159
160
  : " Link CLI: not authenticated — run npx @stripe/link-cli auth login");
161
+ const cooldown = getLinkCooldown();
162
+ const blockedUntil = cooldown ? Date.parse(cooldown.blockedUntil) : NaN;
163
+ if (cooldown && Number.isFinite(blockedUntil) && blockedUntil > Date.now()) {
164
+ lines.push(` Link status: temporarily blocked by Stripe projected-spend cap until ${cooldown.blockedUntil}`);
165
+ lines.push(" Link note: reauthing or switching cards in the same Link account will not clear this cap.");
166
+ }
160
167
  }
161
168
 
162
169
  if (pendingCardSetupToken) {