@agentwonderland/mcp 0.1.54 → 0.1.55

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.
@@ -1,11 +1,11 @@
1
1
  import { z } from "zod";
2
- import { apiGet, apiPost, apiPostWithPayment } from "../core/api-client.js";
2
+ import { apiGet, apiPost, apiPostWithApprovedLinkSpendRequest, apiPostWithPayment } from "../core/api-client.js";
3
3
  import { uploadLocalFiles } from "../core/file-upload.js";
4
4
  import { hasWalletConfigured, getConfiguredMethods, getWalletAddress } from "../core/payments.js";
5
- import { requiresSpendConfirmation } from "../core/config.js";
5
+ import { getLinkConfig, setPendingLinkSpendRequest, requiresSpendConfirmation } from "../core/config.js";
6
6
  import { canSpend, recordSpend, requiresPolicyConfirmation } from "../core/spend-policy.js";
7
7
  import { formatRunResult } from "../core/formatters.js";
8
- import { storeFeedbackToken } from "./_token-cache.js";
8
+ import { decodeMppNetworkId, ensureApprovedLinkSpendRequest, LinkApprovalRequiredError, } from "../core/link-cli.js";
9
9
  import { formatPaymentLabel, resolveConfirmationMethod, } from "./_payment-confirmation.js";
10
10
  const POLL_INTERVAL_MS = 3000;
11
11
  const POLL_MAX_MS = 300000;
@@ -19,6 +19,25 @@ function multiText(...blocks) {
19
19
  function money(value) {
20
20
  return `$${value.toFixed(value < 1 ? 4 : 2)}`;
21
21
  }
22
+ function cents(valueUsd) {
23
+ return String(Math.max(1, Math.ceil(valueUsd * 100)));
24
+ }
25
+ function nonEmptyLimits(limits) {
26
+ return limits && Object.keys(limits).length > 0 ? limits : undefined;
27
+ }
28
+ function appliedPlaybookLimits(playbook) {
29
+ return nonEmptyLimits(playbook.current_quote.applied_limits ?? playbook.applied_limits ?? playbook.default_limits);
30
+ }
31
+ function fanoutControls(playbook) {
32
+ return playbook.current_quote.fanout_controls ?? playbook.fanout_controls ?? [];
33
+ }
34
+ function buildPlaybookLinkApprovalContext(params) {
35
+ return [
36
+ `Approve up to ${money(params.budget)} for the Agent Wonderland "${params.playbook.name}" playbook.`,
37
+ `This playbook run ${params.runId} has ${params.stepCount} child-agent steps and will stop before exceeding the approved budget.`,
38
+ "Each child agent is still charged at its exact quoted price, unused budget is not spent, and failed child runs keep the existing Agent Wonderland refund behavior.",
39
+ ].join(" ");
40
+ }
22
41
  async function collectWalletAddresses(paymentMethod) {
23
42
  const addresses = new Set();
24
43
  const primary = await getWalletAddress(paymentMethod);
@@ -65,10 +84,12 @@ function formatPlaybookList(playbooks, query) {
65
84
  }
66
85
  const lines = playbooks.map((playbook) => {
67
86
  const support = playbook.support_status === "supported" ? "supported" : `catalog-only: ${playbook.support_note ?? "not launch executable"}`;
87
+ const limits = appliedPlaybookLimits(playbook);
88
+ const fanout = limits ? ` · default limits ${JSON.stringify(limits)}` : "";
68
89
  return [
69
90
  ` ${playbook.name} (${playbook.slug})`,
70
91
  ` ${playbook.outcome}`,
71
- ` ${playbook.current_quote.step_count} step${playbook.current_quote.step_count === 1 ? "" : "s"} · ${money(playbook.current_quote.estimated_cost_usd)} est. · ${support}`,
92
+ ` ${playbook.current_quote.step_count} step${playbook.current_quote.step_count === 1 ? "" : "s"} · ${money(playbook.current_quote.estimated_cost_usd)} est. · ${support}${fanout}`,
72
93
  ` Favorites: ${playbook.stats?.favorite_count ?? 0} · Rating: ${playbook.stats?.average_rating ? `${playbook.stats.average_rating}/5` : "unrated"} (${playbook.stats?.rating_count ?? 0})`,
73
94
  ` Inspect: get_playbook({ slug: "${playbook.slug}" })`,
74
95
  ].join("\n");
@@ -108,9 +129,21 @@ function formatPlaybook(playbook) {
108
129
  }
109
130
  if (playbook.budget_notes.length)
110
131
  lines.push("", "Budget notes:", ...playbook.budget_notes.map((note) => ` - ${note}`));
132
+ const limits = appliedPlaybookLimits(playbook);
133
+ const controls = fanoutControls(playbook);
134
+ if (controls.length) {
135
+ lines.push("", "Fanout controls:");
136
+ for (const control of controls) {
137
+ const current = limits?.[control.key] ?? control.default;
138
+ lines.push(` - ${control.key}: default ${control.default}, current ${current}, max ${control.max} — ${control.description}`);
139
+ }
140
+ if (limits)
141
+ lines.push(` Override by passing limits, for example limits: ${JSON.stringify(limits)}`);
142
+ }
111
143
  if (playbook.risks.length)
112
144
  lines.push("", "Risks:", ...playbook.risks.map((risk) => ` - ${risk}`));
113
- lines.push("", "Sample input:", JSON.stringify(playbook.sample_input, null, 2), "", `Run quote: run_playbook({ slug: "${playbook.slug}", input: <input>, budget: ${Math.max(1, Math.ceil(q.estimated_cost_usd + 1))} })`);
145
+ const runLimits = formatLimitsField(limits);
146
+ lines.push("", "Sample input:", JSON.stringify(playbook.sample_input, null, 2), "", `Run quote: run_playbook({ slug: "${playbook.slug}", input: <input>, budget: ${Math.max(1, Math.ceil(q.estimated_cost_usd + 1))}${runLimits ? `, ${runLimits}` : ""} })`);
114
147
  return lines.join("\n");
115
148
  }
116
149
  function formatLimitsField(limits) {
@@ -131,6 +164,18 @@ function formatRunConfirmationCommand(slug, method, budget, limits) {
131
164
  }
132
165
  function formatQuoteNotes(playbook) {
133
166
  const lines = [];
167
+ const controls = fanoutControls(playbook);
168
+ if (controls.length > 0) {
169
+ const limits = appliedPlaybookLimits(playbook) ?? {};
170
+ lines.push("", "Fanout controls:");
171
+ for (const control of controls) {
172
+ const current = limits[control.key] ?? control.default;
173
+ lines.push(` - ${control.key}: current ${current}, default ${control.default}, max ${control.max}`);
174
+ }
175
+ if (Object.keys(limits).length > 0) {
176
+ lines.push(` Override by editing limits in the confirmation call: ${JSON.stringify(limits)}`);
177
+ }
178
+ }
134
179
  if (playbook.budget_notes.length > 0) {
135
180
  lines.push("", "Budget and fanout notes:", ...playbook.budget_notes.map((note) => ` - ${note}`));
136
181
  }
@@ -223,6 +268,39 @@ function stepInput(baseInput, step, iteration) {
223
268
  if (step.agent_slug === "scan-contract-for-risks" && !("file" in input) && input.contract) {
224
269
  input.file = input.contract;
225
270
  }
271
+ if (step.agent_slug === "marketing-copy-council" && !("brief" in input)) {
272
+ input.brief = input.product ?? input.context ?? input.text;
273
+ }
274
+ if (step.agent_slug === "write-landing-page-copy") {
275
+ const previousOutputs = Array.isArray(input.previous_outputs) ? input.previous_outputs : [];
276
+ const councilOutput = previousOutputs.find((item) => {
277
+ const record = item && typeof item === "object" ? item : null;
278
+ return record?.step === "council" || record?.step === "marketing-copy-council";
279
+ }) ?? previousOutputs[previousOutputs.length - 1];
280
+ const context = {
281
+ brief: input.brief,
282
+ product_slug: input.product_slug,
283
+ council_output: councilOutput,
284
+ previous_outputs: previousOutputs,
285
+ };
286
+ input.product = input.product ?? input.product_name ?? input.product_slug ?? "Landing page";
287
+ input.context = input.context ?? JSON.stringify(context);
288
+ }
289
+ if (step.agent_slug === "publish-html-to-a-public-url") {
290
+ const previousOutputs = Array.isArray(input.previous_outputs) ? input.previous_outputs : [];
291
+ const writerOutput = previousOutputs[previousOutputs.length - 1];
292
+ const outputRecord = writerOutput && typeof writerOutput === "object"
293
+ ? writerOutput
294
+ : null;
295
+ const output = outputRecord?.output && typeof outputRecord.output === "object"
296
+ ? outputRecord.output
297
+ : outputRecord?.output;
298
+ input.html = input.html
299
+ ?? (output && typeof output === "object" ? output.html : undefined)
300
+ ?? (typeof output === "string" ? output : undefined)
301
+ ?? JSON.stringify(output ?? writerOutput ?? {});
302
+ input.slug = input.slug ?? input.product_slug;
303
+ }
226
304
  if (step.agent_slug === "place-search") {
227
305
  input.location = input.location ?? input.zip;
228
306
  }
@@ -499,7 +577,9 @@ export function registerPlaybookTools(server) {
499
577
  budget: effectiveBudget,
500
578
  limits: effectiveLimits,
501
579
  });
502
- playbook = quoted.playbook;
580
+ if (quoted?.playbook) {
581
+ playbook = quoted.playbook;
582
+ }
503
583
  }
504
584
  catch {
505
585
  // Quote analytics should not block the user-facing quote.
@@ -511,12 +591,13 @@ export function registerPlaybookTools(server) {
511
591
  if (playbook.current_quote.blocking_issues.length > 0) {
512
592
  return text(`Cannot run ${playbook.slug}; child-agent issues must be fixed first:\n${playbook.current_quote.blocking_issues.map((issue) => `- ${issue}`).join("\n")}`);
513
593
  }
594
+ const runLimits = appliedPlaybookLimits(playbook) ?? effectiveLimits;
514
595
  const estimatedCost = playbook.current_quote.estimated_cost_usd;
515
596
  const spendCheck = canSpend({ method: spendMethod, amountUsd: Math.min(effectiveBudget, estimatedCost) });
516
597
  if (!spendCheck.ok)
517
598
  return text(spendCheck.message);
518
599
  if (estimatedCost > effectiveBudget) {
519
- pendingPlaybookRuns.set(playbook.slug, { playbook, input: effectiveInput, budget: effectiveBudget, method, limits: effectiveLimits });
600
+ pendingPlaybookRuns.set(playbook.slug, { playbook, input: effectiveInput, budget: effectiveBudget, method, limits: runLimits });
520
601
  const cheaperAlternatives = await formatCheaperAlternatives(playbook.slug, effectiveBudget);
521
602
  return text([
522
603
  `${playbook.name} quote exceeds the budget.`,
@@ -528,11 +609,11 @@ export function registerPlaybookTools(server) {
528
609
  ...(cheaperAlternatives ? ["", cheaperAlternatives] : []),
529
610
  "",
530
611
  `Try a higher budget or reduced limits, then call:`,
531
- formatRunConfirmationCommand(playbook.slug, method, Math.ceil(estimatedCost + 1), effectiveLimits),
612
+ formatRunConfirmationCommand(playbook.slug, method, Math.ceil(estimatedCost + 1), runLimits),
532
613
  ].join("\n"));
533
614
  }
534
615
  if ((requiresSpendConfirmation() || requiresPolicyConfirmation(spendMethod, estimatedCost)) && !confirmed) {
535
- pendingPlaybookRuns.set(playbook.slug, { playbook, input: effectiveInput, budget: effectiveBudget, method, limits: effectiveLimits });
616
+ pendingPlaybookRuns.set(playbook.slug, { playbook, input: effectiveInput, budget: effectiveBudget, method, limits: runLimits });
536
617
  return text([
537
618
  `Ready to run ${playbook.name}`,
538
619
  "",
@@ -548,7 +629,7 @@ export function registerPlaybookTools(server) {
548
629
  ...formatQuoteNotes(playbook),
549
630
  "",
550
631
  "To proceed, call:",
551
- formatRunConfirmationCommand(playbook.slug, method, effectiveBudget, effectiveLimits),
632
+ formatRunConfirmationCommand(playbook.slug, method, effectiveBudget, runLimits),
552
633
  "",
553
634
  "To cancel, do nothing.",
554
635
  ].join("\n"));
@@ -594,7 +675,7 @@ export function registerPlaybookTools(server) {
594
675
  slug: playbook.slug,
595
676
  input: processedInput,
596
677
  budget: effectiveBudget,
597
- limits: effectiveLimits,
678
+ limits: runLimits,
598
679
  }),
599
680
  completedStepIds: new Set(),
600
681
  existingOutputs: [],
@@ -603,6 +684,14 @@ export function registerPlaybookTools(server) {
603
684
  await apiPost(`/playbook-runs/${runState.run_id}/status`, { status: "running" });
604
685
  let charged = runState.chargedUsd;
605
686
  const childOutputs = [...runState.existingOutputs];
687
+ let linkSpendRequestId;
688
+ let linkNetworkId;
689
+ const linkApprovalContext = buildPlaybookLinkApprovalContext({
690
+ playbook,
691
+ budget: effectiveBudget,
692
+ stepCount: playbook.current_quote.step_count,
693
+ runId: runState.run_id,
694
+ });
606
695
  for (const [iteration, step] of runState.steps.entries()) {
607
696
  if (runState.completedStepIds.has(step.id)) {
608
697
  continue;
@@ -650,24 +739,62 @@ export function registerPlaybookTools(server) {
650
739
  let schemaError;
651
740
  let usedPaidMethod = false;
652
741
  const executeChild = async (childInput) => {
742
+ const body = { input: childInput, playbook_context: playbookContext };
653
743
  try {
654
- return await apiPost(`/agents/${step.agent_id}/run`, { input: childInput, playbook_context: playbookContext }, { ensureConsumerPrincipal: true });
744
+ return await apiPost(`/agents/${step.agent_id}/run`, body, { ensureConsumerPrincipal: true });
655
745
  }
656
746
  catch (err) {
657
747
  const status = err?.status;
658
748
  if (status !== 402)
659
749
  throw err;
660
750
  usedPaidMethod = true;
661
- return await apiPostWithPayment(`/agents/${step.agent_id}/run`, { input: childInput, playbook_context: playbookContext }, method);
751
+ if ((method ?? spendMethod) === "link") {
752
+ const linkConfig = getLinkConfig();
753
+ if (!linkConfig) {
754
+ throw new Error('Payment method "link" is not configured. Run wallet_setup({ action: "add-link" }).');
755
+ }
756
+ const challenge = err?.headers?.get("www-authenticate");
757
+ if (!challenge && !linkNetworkId) {
758
+ throw new Error("Link payment challenge did not include a WWW-Authenticate header.");
759
+ }
760
+ linkNetworkId = linkNetworkId ?? await decodeMppNetworkId(challenge);
761
+ linkSpendRequestId = linkSpendRequestId ?? await ensureApprovedLinkSpendRequest({
762
+ amount: cents(effectiveBudget),
763
+ currency: "usd",
764
+ context: linkApprovalContext,
765
+ expiresAt: Math.floor(Date.now() / 1000) + 3600,
766
+ networkId: linkNetworkId,
767
+ paymentMethodId: linkConfig.paymentMethodId,
768
+ });
769
+ return await apiPostWithApprovedLinkSpendRequest(`/agents/${step.agent_id}/run`, body, linkSpendRequestId);
770
+ }
771
+ return await apiPostWithPayment(`/agents/${step.agent_id}/run`, body, method);
662
772
  }
663
773
  };
664
774
  try {
665
775
  result = await executeChild(payload);
666
776
  }
667
777
  catch (err) {
778
+ if (err instanceof LinkApprovalRequiredError) {
779
+ await recordStep(runState.run_id, step.id, {
780
+ status: "pending",
781
+ agent_id: step.agent_id,
782
+ provider_id: step.provider_id,
783
+ consumption_mode: "not_charged",
784
+ error_code: null,
785
+ failure_message: null,
786
+ });
787
+ await updateRun(runState.run_id, {
788
+ status: "paused",
789
+ error_code: "LINK_APPROVAL_REQUIRED",
790
+ failure_message: "Approve the Link spend request, then resume the playbook run.",
791
+ });
792
+ const receipt = await apiGet(`/playbook-runs/${runState.run_id}`);
793
+ return text(`${formatReceipt(receipt)}\n\n${err.message}`);
794
+ }
668
795
  const apiErr = err;
669
- const validationFailed = apiErr?.status === 400
670
- && /missing required field|validation failed|invalid input|schema/i.test(apiErr.message ?? "");
796
+ const validationFailed = (apiErr?.status === 400 || apiErr?.status === 422)
797
+ && /missing required field|validation failed|invalid input|schema|provide at least one/i.test(apiErr.message ?? "");
671
798
  if (validationFailed) {
672
799
  try {
673
800
  const agent = await apiGet(`/agents/${step.agent_id}`);
@@ -729,13 +856,9 @@ export function registerPlaybookTools(server) {
729
856
  return text(`${formatReceipt(receipt)}\n\nTried the live child schema but could not safely repair the input. Use get_agent({ agent_id: "${step.agent_id}" }) to inspect the child agent schema before retrying.`);
730
857
  }
731
858
  const jobId = result.job_id ?? "";
732
- const resultAgentId = result.agent_id ?? step.agent_id;
733
859
  const status = result.status;
734
860
  let output = result.output;
735
861
  let errorCode = result.error_code;
736
- if (result.feedback_token && jobId) {
737
- storeFeedbackToken(jobId, result.feedback_token, resultAgentId);
738
- }
739
862
  if (status === "processing" && jobId) {
740
863
  const pollResult = await pollJobUntilDone(jobId, method);
741
864
  output = pollResult.output;
@@ -834,10 +957,13 @@ export function registerPlaybookTools(server) {
834
957
  });
835
958
  const receipt = await apiGet(`/playbook-runs/${runState.run_id}`);
836
959
  pendingPlaybookRuns.delete(playbook.slug);
960
+ if ((method ?? spendMethod) === "link" && linkSpendRequestId) {
961
+ setPendingLinkSpendRequest(null);
962
+ }
837
963
  const runBlocks = childOutputs.length > 0
838
964
  ? childOutputs.map((item) => item.output ? formatRunResult({ status: "success", job_id: item.job_id, output: item.output }, { paymentMethod: method }) : "").filter(Boolean)
839
965
  : [];
840
- return multiText(uploadSummary ? `${uploadSummary}\n\n${formatReceipt(receipt)}` : formatReceipt(receipt), ...runBlocks, `IMPORTANT: After presenting the result above, you MUST ask the user: "Paid ${money(receipt.charged_usd)} across ${receipt.steps.filter((s) => s.status === "succeeded").length} steps. Was this playbook useful? I can save it with favorite_playbook({ slug: "${playbook.slug}" }) or record feedback with rate_playbook({ slug: "${playbook.slug}", run_id: "${receipt.run_id}", rating: <1-5>, useful: true, comment: "..." })."`);
966
+ return multiText(uploadSummary ? `${uploadSummary}\n\n${formatReceipt(receipt)}` : formatReceipt(receipt), ...runBlocks);
841
967
  });
842
968
  server.tool("favorite_playbook", "Save an Agent Wonderland Playbook for later. Favorites are associated with the configured consumer principal when available.", {
843
969
  playbook_id: z.string().optional(),
@@ -849,28 +975,6 @@ export function registerPlaybookTools(server) {
849
975
  const result = await apiPost(`/playbooks/${encodeURIComponent(id)}/favorite`, {}, { ensureConsumerPrincipal: true });
850
976
  return text(`Saved ${result.playbook_slug} to favorites.`);
851
977
  });
852
- server.tool("rate_playbook", "Record feedback on an Agent Wonderland Playbook after a run. Provide a rating, useful flag, comment, or any combination.", {
853
- playbook_id: z.string().optional(),
854
- slug: z.string().optional(),
855
- run_id: z.string().optional(),
856
- rating: z.number().int().min(1).max(5).optional(),
857
- useful: z.boolean().optional(),
858
- comment: z.string().max(2000).optional(),
859
- }, async ({ playbook_id, slug, run_id, rating, useful, comment }) => {
860
- const id = slug ?? playbook_id;
861
- if (!id)
862
- return text("Provide slug or playbook_id.");
863
- if (rating == null && useful == null && !comment?.trim()) {
864
- return text("Provide rating, useful, or comment.");
865
- }
866
- const result = await apiPost(`/playbooks/${encodeURIComponent(id)}/feedback`, {
867
- run_id,
868
- rating,
869
- useful,
870
- comment,
871
- }, { ensureConsumerPrincipal: true });
872
- return text(`Recorded feedback for ${result.playbook_slug}.`);
873
- });
874
978
  server.tool("get_playbook_run", "Inspect current or historical Agent Wonderland Playbook run state, including completed steps, charged/refunded amount, partial output, and resume command when paused.", {
875
979
  run_id: z.string(),
876
980
  }, async ({ run_id }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentwonderland/mcp",
3
- "version": "0.1.54",
3
+ "version": "0.1.55",
4
4
  "type": "module",
5
5
  "description": "MCP server for the Agent Wonderland AI agent marketplace",
6
6
  "bin": {
@@ -10,6 +10,7 @@ const {
10
10
  mockGetConsumerPrincipalForMethod,
11
11
  mockGetBaseRebatePrincipal,
12
12
  mockPaymentFetch,
13
+ mockPayMppWithApprovedLinkSpendRequest,
13
14
  } = vi.hoisted(() => ({
14
15
  mockGetApiUrl: vi.fn(),
15
16
  mockGetApiKey: vi.fn(),
@@ -19,6 +20,7 @@ const {
19
20
  mockGetConsumerPrincipalForMethod: vi.fn(),
20
21
  mockGetBaseRebatePrincipal: vi.fn(),
21
22
  mockPaymentFetch: vi.fn(),
23
+ mockPayMppWithApprovedLinkSpendRequest: vi.fn(),
22
24
  }));
23
25
 
24
26
  vi.mock("../config.js", () => ({
@@ -39,6 +41,10 @@ vi.mock("../principal.js", () => ({
39
41
  getBaseRebatePrincipal: (...args: unknown[]) => mockGetBaseRebatePrincipal(...args),
40
42
  }));
41
43
 
44
+ vi.mock("../link-cli.js", () => ({
45
+ payMppWithApprovedLinkSpendRequest: (...args: unknown[]) => mockPayMppWithApprovedLinkSpendRequest(...args),
46
+ }));
47
+
42
48
  describe("api-client headers", () => {
43
49
  beforeEach(() => {
44
50
  vi.clearAllMocks();
@@ -61,6 +67,7 @@ describe("api-client headers", () => {
61
67
  status: 200,
62
68
  headers: { "content-type": "application/json" },
63
69
  }));
70
+ mockPayMppWithApprovedLinkSpendRequest.mockResolvedValue({ ok: true });
64
71
  });
65
72
 
66
73
  it("includes the Base rebate principal alongside the method-specific consumer principal", async () => {
@@ -130,4 +137,30 @@ describe("api-client headers", () => {
130
137
  expect(calls[8]).toMatchObject({ url: "/playbook-runs/run-1", headers: { "X-AW-MCP-Tool": "run_playbook", "X-AW-MCP-Action": "poll" } });
131
138
  expect(calls[9]).toMatchObject({ url: "/playbook-runs/run-1/steps/ads", headers: { "X-AW-MCP-Tool": "run_playbook", "X-AW-MCP-Action": "execute" } });
132
139
  });
140
+
141
+ it("uses Link MPP pay with run-agent headers for an approved spend request", async () => {
142
+ const { apiPostWithApprovedLinkSpendRequest } = await import("../api-client.js");
143
+
144
+ await apiPostWithApprovedLinkSpendRequest(
145
+ "/agents/agent-1/run",
146
+ { input: { text: "hello" } },
147
+ "lsrq_test_123",
148
+ );
149
+
150
+ expect(mockPayMppWithApprovedLinkSpendRequest).toHaveBeenCalledWith(expect.objectContaining({
151
+ url: "https://api.agentwonderland.test/agents/agent-1/run",
152
+ method: "POST",
153
+ body: { input: { text: "hello" } },
154
+ spendRequestId: "lsrq_test_123",
155
+ headers: expect.objectContaining({
156
+ "Content-Type": "application/json",
157
+ Accept: "application/json",
158
+ "X-AW-Surface": "mcp",
159
+ "X-AW-MCP-Version": MCP_PACKAGE_VERSION,
160
+ "X-AW-MCP-Tool": "run_agent",
161
+ "X-AW-MCP-Action": "execute",
162
+ }),
163
+ }));
164
+ expect(mockEnsureConsumerPrincipalForMethod).toHaveBeenCalledWith("link");
165
+ });
133
166
  });
@@ -122,4 +122,114 @@ describe("Link CLI spend requests", () => {
122
122
  expect(execCalls[0]?.args).toEqual(expect.arrayContaining(["spend-request", "retrieve", "lsrq_test_123"]));
123
123
  expect(state.pendingWrites).toEqual([null]);
124
124
  });
125
+
126
+ it("reuses an approved spend request id for playbook MPP payments", async () => {
127
+ const expiresAt = Math.floor(Date.now() / 1000) + 300;
128
+ state.pending = {
129
+ id: "lsrq_test_123",
130
+ approvalUrl: "https://link.example/approve/lsrq_test_123",
131
+ amount: "500",
132
+ currency: "usd",
133
+ context: "Agent Wonderland playbook test context that is long enough for Link",
134
+ expiresAt,
135
+ networkId: "profile_test",
136
+ paymentMethodId: "csmrpd_test_123",
137
+ createdAt: new Date().toISOString(),
138
+ };
139
+ outputs.push({ id: "lsrq_test_123", status: "approved" });
140
+
141
+ const { ensureApprovedLinkSpendRequest } = await import("../link-cli.js");
142
+
143
+ await expect(ensureApprovedLinkSpendRequest({
144
+ amount: "500",
145
+ currency: "usd",
146
+ context: "Agent Wonderland playbook test context that is long enough for Link",
147
+ expiresAt,
148
+ networkId: "profile_test",
149
+ paymentMethodId: "csmrpd_test_123",
150
+ })).resolves.toBe("lsrq_test_123");
151
+
152
+ expect(execCalls).toHaveLength(1);
153
+ expect(execCalls[0]?.args).toEqual(expect.arrayContaining(["spend-request", "retrieve", "lsrq_test_123"]));
154
+ expect(state.pendingWrites).toEqual([]);
155
+ });
156
+
157
+ it("pauses when the reusable Link spend request is still pending approval", async () => {
158
+ const expiresAt = Math.floor(Date.now() / 1000) + 300;
159
+ state.pending = {
160
+ id: "lsrq_test_123",
161
+ approvalUrl: "https://link.example/approve/lsrq_test_123",
162
+ amount: "500",
163
+ currency: "usd",
164
+ context: "Agent Wonderland playbook test context that is long enough for Link",
165
+ expiresAt,
166
+ networkId: "profile_test",
167
+ paymentMethodId: "csmrpd_test_123",
168
+ createdAt: new Date().toISOString(),
169
+ };
170
+ outputs.push({ id: "lsrq_test_123", status: "pending_approval" });
171
+
172
+ const { ensureApprovedLinkSpendRequest, LinkApprovalRequiredError } = await import("../link-cli.js");
173
+
174
+ await expect(ensureApprovedLinkSpendRequest({
175
+ amount: "500",
176
+ currency: "usd",
177
+ context: "Agent Wonderland playbook test context that is long enough for Link",
178
+ expiresAt,
179
+ networkId: "profile_test",
180
+ paymentMethodId: "csmrpd_test_123",
181
+ })).rejects.toBeInstanceOf(LinkApprovalRequiredError);
182
+
183
+ expect(execCalls).toHaveLength(1);
184
+ expect(state.pendingWrites).toEqual([]);
185
+ });
186
+
187
+ it("runs Link MPP pay with an approved spend request and returns the response body", async () => {
188
+ outputs.push({
189
+ response: {
190
+ body: {
191
+ status: "success",
192
+ job_id: "job_123",
193
+ },
194
+ },
195
+ });
196
+
197
+ const { payMppWithApprovedLinkSpendRequest } = await import("../link-cli.js");
198
+
199
+ await expect(payMppWithApprovedLinkSpendRequest({
200
+ url: "https://api.agentwonderland.test/agents/agent-1/run",
201
+ method: "POST",
202
+ headers: {
203
+ Accept: "application/json",
204
+ "Content-Type": "application/json",
205
+ },
206
+ body: { input: { text: "hello" } },
207
+ spendRequestId: "lsrq_test_123",
208
+ })).resolves.toEqual({
209
+ status: "success",
210
+ job_id: "job_123",
211
+ });
212
+
213
+ expect(execCalls[0]?.args).toEqual(expect.arrayContaining([
214
+ "mpp",
215
+ "pay",
216
+ "https://api.agentwonderland.test/agents/agent-1/run",
217
+ "--spend-request-id",
218
+ "lsrq_test_123",
219
+ "--method",
220
+ "POST",
221
+ "--data",
222
+ JSON.stringify({ input: { text: "hello" } }),
223
+ ]));
224
+ expect(execCalls[0]?.args).toContain("Accept: application/json");
225
+ });
226
+
227
+ it("decodes the MPP network id from a payment challenge", async () => {
228
+ outputs.push({ accepts: [{ network_id: "profile_test" }] });
229
+
230
+ const { decodeMppNetworkId } = await import("../link-cli.js");
231
+
232
+ await expect(decodeMppNetworkId("Payment challenge")).resolves.toBe("profile_test");
233
+ expect(execCalls[0]?.args).toEqual(expect.arrayContaining(["mpp", "decode", "--challenge", "Payment challenge"]));
234
+ });
125
235
  });
@@ -8,6 +8,7 @@ import {
8
8
  getConsumerPrincipalForMethod,
9
9
  } from "./principal.js";
10
10
  import { MCP_PACKAGE_VERSION } from "./version.js";
11
+ import { payMppWithApprovedLinkSpendRequest } from "./link-cli.js";
11
12
 
12
13
  // ── Error class ────────────────────────────────────────────────────
13
14
 
@@ -16,6 +17,7 @@ export class ApiError extends Error {
16
17
  public readonly status: number,
17
18
  message: string,
18
19
  public readonly body?: unknown,
20
+ public readonly headers?: Headers,
19
21
  ) {
20
22
  super(message);
21
23
  this.name = "ApiError";
@@ -121,7 +123,7 @@ async function handleResponse<T>(response: Response): Promise<T> {
121
123
  : typeof body === "string"
122
124
  ? body
123
125
  : `Request failed with status ${response.status}`;
124
- throw new ApiError(response.status, message, body);
126
+ throw new ApiError(response.status, message, body, response.headers);
125
127
  }
126
128
 
127
129
  return body as T;
@@ -205,6 +207,28 @@ export async function apiPostWithPayment<T>(
205
207
  return attachResponseMetadata(result, response);
206
208
  }
207
209
 
210
+ export async function apiPostWithApprovedLinkSpendRequest<T>(
211
+ path: string,
212
+ body: unknown,
213
+ spendRequestId: string,
214
+ options?: RequestOptions,
215
+ ): Promise<T> {
216
+ const url = `${getApiUrl()}${path}`;
217
+ const headers = await buildHeaders(path, "POST", {
218
+ ensureConsumerPrincipal: true,
219
+ principalMethod: "link",
220
+ ...options,
221
+ });
222
+ const result = await payMppWithApprovedLinkSpendRequest({
223
+ url,
224
+ method: "POST",
225
+ headers,
226
+ body,
227
+ spendRequestId,
228
+ });
229
+ return result as T;
230
+ }
231
+
208
232
  export async function apiPut<T>(path: string, body: unknown, options?: RequestOptions): Promise<T> {
209
233
  const url = `${getApiUrl()}${path}`;
210
234
  const response = await fetch(url, {