@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.
- package/dist/core/__tests__/api-client.test.js +25 -1
- package/dist/core/__tests__/link-cli.test.js +95 -0
- package/dist/core/api-client.d.ts +3 -1
- package/dist/core/api-client.js +21 -2
- package/dist/core/link-cli.d.ts +17 -0
- package/dist/core/link-cli.js +165 -0
- package/dist/core/version.d.ts +1 -1
- package/dist/core/version.js +1 -1
- package/dist/index.js +1 -1
- package/dist/tools/__tests__/playbooks.test.js +114 -34
- package/dist/tools/playbooks.js +146 -42
- package/package.json +1 -1
- package/src/core/__tests__/api-client.test.ts +33 -0
- package/src/core/__tests__/link-cli.test.ts +110 -0
- package/src/core/api-client.ts +25 -1
- package/src/core/link-cli.ts +189 -0
- package/src/core/version.ts +1 -1
- package/src/index.ts +1 -1
- package/src/tools/__tests__/playbooks.test.ts +125 -46
- package/src/tools/playbooks.ts +177 -56
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { MCP_PACKAGE_VERSION } from "../version.js";
|
|
3
|
-
const { mockGetApiUrl, mockGetApiKey, mockGetPaymentFetch, mockEnsureConsumerPrincipalForMethod, mockEnsureBaseRebatePrincipal, mockGetConsumerPrincipalForMethod, mockGetBaseRebatePrincipal, mockPaymentFetch, } = vi.hoisted(() => ({
|
|
3
|
+
const { mockGetApiUrl, mockGetApiKey, mockGetPaymentFetch, mockEnsureConsumerPrincipalForMethod, mockEnsureBaseRebatePrincipal, mockGetConsumerPrincipalForMethod, mockGetBaseRebatePrincipal, mockPaymentFetch, mockPayMppWithApprovedLinkSpendRequest, } = vi.hoisted(() => ({
|
|
4
4
|
mockGetApiUrl: vi.fn(),
|
|
5
5
|
mockGetApiKey: vi.fn(),
|
|
6
6
|
mockGetPaymentFetch: vi.fn(),
|
|
@@ -9,6 +9,7 @@ const { mockGetApiUrl, mockGetApiKey, mockGetPaymentFetch, mockEnsureConsumerPri
|
|
|
9
9
|
mockGetConsumerPrincipalForMethod: vi.fn(),
|
|
10
10
|
mockGetBaseRebatePrincipal: vi.fn(),
|
|
11
11
|
mockPaymentFetch: vi.fn(),
|
|
12
|
+
mockPayMppWithApprovedLinkSpendRequest: vi.fn(),
|
|
12
13
|
}));
|
|
13
14
|
vi.mock("../config.js", () => ({
|
|
14
15
|
getApiUrl: () => mockGetApiUrl(),
|
|
@@ -25,6 +26,9 @@ vi.mock("../principal.js", () => ({
|
|
|
25
26
|
getConsumerPrincipalForMethod: (...args) => mockGetConsumerPrincipalForMethod(...args),
|
|
26
27
|
getBaseRebatePrincipal: (...args) => mockGetBaseRebatePrincipal(...args),
|
|
27
28
|
}));
|
|
29
|
+
vi.mock("../link-cli.js", () => ({
|
|
30
|
+
payMppWithApprovedLinkSpendRequest: (...args) => mockPayMppWithApprovedLinkSpendRequest(...args),
|
|
31
|
+
}));
|
|
28
32
|
describe("api-client headers", () => {
|
|
29
33
|
beforeEach(() => {
|
|
30
34
|
vi.clearAllMocks();
|
|
@@ -39,6 +43,7 @@ describe("api-client headers", () => {
|
|
|
39
43
|
status: 200,
|
|
40
44
|
headers: { "content-type": "application/json" },
|
|
41
45
|
}));
|
|
46
|
+
mockPayMppWithApprovedLinkSpendRequest.mockResolvedValue({ ok: true });
|
|
42
47
|
});
|
|
43
48
|
it("includes the Base rebate principal alongside the method-specific consumer principal", async () => {
|
|
44
49
|
const { apiPostWithPayment } = await import("../api-client.js");
|
|
@@ -96,4 +101,23 @@ describe("api-client headers", () => {
|
|
|
96
101
|
expect(calls[8]).toMatchObject({ url: "/playbook-runs/run-1", headers: { "X-AW-MCP-Tool": "run_playbook", "X-AW-MCP-Action": "poll" } });
|
|
97
102
|
expect(calls[9]).toMatchObject({ url: "/playbook-runs/run-1/steps/ads", headers: { "X-AW-MCP-Tool": "run_playbook", "X-AW-MCP-Action": "execute" } });
|
|
98
103
|
});
|
|
104
|
+
it("uses Link MPP pay with run-agent headers for an approved spend request", async () => {
|
|
105
|
+
const { apiPostWithApprovedLinkSpendRequest } = await import("../api-client.js");
|
|
106
|
+
await apiPostWithApprovedLinkSpendRequest("/agents/agent-1/run", { input: { text: "hello" } }, "lsrq_test_123");
|
|
107
|
+
expect(mockPayMppWithApprovedLinkSpendRequest).toHaveBeenCalledWith(expect.objectContaining({
|
|
108
|
+
url: "https://api.agentwonderland.test/agents/agent-1/run",
|
|
109
|
+
method: "POST",
|
|
110
|
+
body: { input: { text: "hello" } },
|
|
111
|
+
spendRequestId: "lsrq_test_123",
|
|
112
|
+
headers: expect.objectContaining({
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
Accept: "application/json",
|
|
115
|
+
"X-AW-Surface": "mcp",
|
|
116
|
+
"X-AW-MCP-Version": MCP_PACKAGE_VERSION,
|
|
117
|
+
"X-AW-MCP-Tool": "run_agent",
|
|
118
|
+
"X-AW-MCP-Action": "execute",
|
|
119
|
+
}),
|
|
120
|
+
}));
|
|
121
|
+
expect(mockEnsureConsumerPrincipalForMethod).toHaveBeenCalledWith("link");
|
|
122
|
+
});
|
|
99
123
|
});
|
|
@@ -99,4 +99,99 @@ describe("Link CLI spend requests", () => {
|
|
|
99
99
|
expect(execCalls[0]?.args).toEqual(expect.arrayContaining(["spend-request", "retrieve", "lsrq_test_123"]));
|
|
100
100
|
expect(state.pendingWrites).toEqual([null]);
|
|
101
101
|
});
|
|
102
|
+
it("reuses an approved spend request id for playbook MPP payments", async () => {
|
|
103
|
+
const expiresAt = Math.floor(Date.now() / 1000) + 300;
|
|
104
|
+
state.pending = {
|
|
105
|
+
id: "lsrq_test_123",
|
|
106
|
+
approvalUrl: "https://link.example/approve/lsrq_test_123",
|
|
107
|
+
amount: "500",
|
|
108
|
+
currency: "usd",
|
|
109
|
+
context: "Agent Wonderland playbook test context that is long enough for Link",
|
|
110
|
+
expiresAt,
|
|
111
|
+
networkId: "profile_test",
|
|
112
|
+
paymentMethodId: "csmrpd_test_123",
|
|
113
|
+
createdAt: new Date().toISOString(),
|
|
114
|
+
};
|
|
115
|
+
outputs.push({ id: "lsrq_test_123", status: "approved" });
|
|
116
|
+
const { ensureApprovedLinkSpendRequest } = await import("../link-cli.js");
|
|
117
|
+
await expect(ensureApprovedLinkSpendRequest({
|
|
118
|
+
amount: "500",
|
|
119
|
+
currency: "usd",
|
|
120
|
+
context: "Agent Wonderland playbook test context that is long enough for Link",
|
|
121
|
+
expiresAt,
|
|
122
|
+
networkId: "profile_test",
|
|
123
|
+
paymentMethodId: "csmrpd_test_123",
|
|
124
|
+
})).resolves.toBe("lsrq_test_123");
|
|
125
|
+
expect(execCalls).toHaveLength(1);
|
|
126
|
+
expect(execCalls[0]?.args).toEqual(expect.arrayContaining(["spend-request", "retrieve", "lsrq_test_123"]));
|
|
127
|
+
expect(state.pendingWrites).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
it("pauses when the reusable Link spend request is still pending approval", async () => {
|
|
130
|
+
const expiresAt = Math.floor(Date.now() / 1000) + 300;
|
|
131
|
+
state.pending = {
|
|
132
|
+
id: "lsrq_test_123",
|
|
133
|
+
approvalUrl: "https://link.example/approve/lsrq_test_123",
|
|
134
|
+
amount: "500",
|
|
135
|
+
currency: "usd",
|
|
136
|
+
context: "Agent Wonderland playbook test context that is long enough for Link",
|
|
137
|
+
expiresAt,
|
|
138
|
+
networkId: "profile_test",
|
|
139
|
+
paymentMethodId: "csmrpd_test_123",
|
|
140
|
+
createdAt: new Date().toISOString(),
|
|
141
|
+
};
|
|
142
|
+
outputs.push({ id: "lsrq_test_123", status: "pending_approval" });
|
|
143
|
+
const { ensureApprovedLinkSpendRequest, LinkApprovalRequiredError } = await import("../link-cli.js");
|
|
144
|
+
await expect(ensureApprovedLinkSpendRequest({
|
|
145
|
+
amount: "500",
|
|
146
|
+
currency: "usd",
|
|
147
|
+
context: "Agent Wonderland playbook test context that is long enough for Link",
|
|
148
|
+
expiresAt,
|
|
149
|
+
networkId: "profile_test",
|
|
150
|
+
paymentMethodId: "csmrpd_test_123",
|
|
151
|
+
})).rejects.toBeInstanceOf(LinkApprovalRequiredError);
|
|
152
|
+
expect(execCalls).toHaveLength(1);
|
|
153
|
+
expect(state.pendingWrites).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
it("runs Link MPP pay with an approved spend request and returns the response body", async () => {
|
|
156
|
+
outputs.push({
|
|
157
|
+
response: {
|
|
158
|
+
body: {
|
|
159
|
+
status: "success",
|
|
160
|
+
job_id: "job_123",
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
const { payMppWithApprovedLinkSpendRequest } = await import("../link-cli.js");
|
|
165
|
+
await expect(payMppWithApprovedLinkSpendRequest({
|
|
166
|
+
url: "https://api.agentwonderland.test/agents/agent-1/run",
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: {
|
|
169
|
+
Accept: "application/json",
|
|
170
|
+
"Content-Type": "application/json",
|
|
171
|
+
},
|
|
172
|
+
body: { input: { text: "hello" } },
|
|
173
|
+
spendRequestId: "lsrq_test_123",
|
|
174
|
+
})).resolves.toEqual({
|
|
175
|
+
status: "success",
|
|
176
|
+
job_id: "job_123",
|
|
177
|
+
});
|
|
178
|
+
expect(execCalls[0]?.args).toEqual(expect.arrayContaining([
|
|
179
|
+
"mpp",
|
|
180
|
+
"pay",
|
|
181
|
+
"https://api.agentwonderland.test/agents/agent-1/run",
|
|
182
|
+
"--spend-request-id",
|
|
183
|
+
"lsrq_test_123",
|
|
184
|
+
"--method",
|
|
185
|
+
"POST",
|
|
186
|
+
"--data",
|
|
187
|
+
JSON.stringify({ input: { text: "hello" } }),
|
|
188
|
+
]));
|
|
189
|
+
expect(execCalls[0]?.args).toContain("Accept: application/json");
|
|
190
|
+
});
|
|
191
|
+
it("decodes the MPP network id from a payment challenge", async () => {
|
|
192
|
+
outputs.push({ accepts: [{ network_id: "profile_test" }] });
|
|
193
|
+
const { decodeMppNetworkId } = await import("../link-cli.js");
|
|
194
|
+
await expect(decodeMppNetworkId("Payment challenge")).resolves.toBe("profile_test");
|
|
195
|
+
expect(execCalls[0]?.args).toEqual(expect.arrayContaining(["mpp", "decode", "--challenge", "Payment challenge"]));
|
|
196
|
+
});
|
|
102
197
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export declare class ApiError extends Error {
|
|
2
2
|
readonly status: number;
|
|
3
3
|
readonly body?: unknown | undefined;
|
|
4
|
-
|
|
4
|
+
readonly headers?: Headers | undefined;
|
|
5
|
+
constructor(status: number, message: string, body?: unknown | undefined, headers?: Headers | undefined);
|
|
5
6
|
}
|
|
6
7
|
interface RequestOptions {
|
|
7
8
|
ensureConsumerPrincipal?: boolean;
|
|
@@ -16,5 +17,6 @@ export declare function apiPost<T>(path: string, body: unknown, options?: Reques
|
|
|
16
17
|
* Pass `payWith` to specify a method, or omit for auto-detection.
|
|
17
18
|
*/
|
|
18
19
|
export declare function apiPostWithPayment<T>(path: string, body: unknown, payWith?: string, options?: RequestOptions): Promise<T>;
|
|
20
|
+
export declare function apiPostWithApprovedLinkSpendRequest<T>(path: string, body: unknown, spendRequestId: string, options?: RequestOptions): Promise<T>;
|
|
19
21
|
export declare function apiPut<T>(path: string, body: unknown, options?: RequestOptions): Promise<T>;
|
|
20
22
|
export {};
|
package/dist/core/api-client.js
CHANGED
|
@@ -2,14 +2,17 @@ import { getApiUrl, getApiKey } from "./config.js";
|
|
|
2
2
|
import { getPaymentFetch } from "./payments.js";
|
|
3
3
|
import { getBaseRebatePrincipal, ensureBaseRebatePrincipal, ensureConsumerPrincipalForMethod, getConsumerPrincipalForMethod, } from "./principal.js";
|
|
4
4
|
import { MCP_PACKAGE_VERSION } from "./version.js";
|
|
5
|
+
import { payMppWithApprovedLinkSpendRequest } from "./link-cli.js";
|
|
5
6
|
// ── Error class ────────────────────────────────────────────────────
|
|
6
7
|
export class ApiError extends Error {
|
|
7
8
|
status;
|
|
8
9
|
body;
|
|
9
|
-
|
|
10
|
+
headers;
|
|
11
|
+
constructor(status, message, body, headers) {
|
|
10
12
|
super(message);
|
|
11
13
|
this.status = status;
|
|
12
14
|
this.body = body;
|
|
15
|
+
this.headers = headers;
|
|
13
16
|
this.name = "ApiError";
|
|
14
17
|
}
|
|
15
18
|
}
|
|
@@ -96,7 +99,7 @@ async function handleResponse(response) {
|
|
|
96
99
|
: typeof body === "string"
|
|
97
100
|
? body
|
|
98
101
|
: `Request failed with status ${response.status}`;
|
|
99
|
-
throw new ApiError(response.status, message, body);
|
|
102
|
+
throw new ApiError(response.status, message, body, response.headers);
|
|
100
103
|
}
|
|
101
104
|
return body;
|
|
102
105
|
}
|
|
@@ -163,6 +166,22 @@ export async function apiPostWithPayment(path, body, payWith, options) {
|
|
|
163
166
|
const result = await handleResponse(response);
|
|
164
167
|
return attachResponseMetadata(result, response);
|
|
165
168
|
}
|
|
169
|
+
export async function apiPostWithApprovedLinkSpendRequest(path, body, spendRequestId, options) {
|
|
170
|
+
const url = `${getApiUrl()}${path}`;
|
|
171
|
+
const headers = await buildHeaders(path, "POST", {
|
|
172
|
+
ensureConsumerPrincipal: true,
|
|
173
|
+
principalMethod: "link",
|
|
174
|
+
...options,
|
|
175
|
+
});
|
|
176
|
+
const result = await payMppWithApprovedLinkSpendRequest({
|
|
177
|
+
url,
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers,
|
|
180
|
+
body,
|
|
181
|
+
spendRequestId,
|
|
182
|
+
});
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
166
185
|
export async function apiPut(path, body, options) {
|
|
167
186
|
const url = `${getApiUrl()}${path}`;
|
|
168
187
|
const response = await fetch(url, {
|
package/dist/core/link-cli.d.ts
CHANGED
|
@@ -3,6 +3,14 @@ export declare class LinkApprovalRequiredError extends Error {
|
|
|
3
3
|
readonly approvalUrl?: string | undefined;
|
|
4
4
|
constructor(spendRequestId: string, approvalUrl?: string | undefined);
|
|
5
5
|
}
|
|
6
|
+
export interface ApprovedLinkSpendRequestParams {
|
|
7
|
+
amount: string;
|
|
8
|
+
currency: string;
|
|
9
|
+
context: string;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
networkId: string;
|
|
12
|
+
paymentMethodId: string;
|
|
13
|
+
}
|
|
6
14
|
export interface LinkCliAuthStatus {
|
|
7
15
|
authenticated: boolean;
|
|
8
16
|
credentialsPath?: string;
|
|
@@ -30,3 +38,12 @@ export declare function createLinkSharedPaymentToken(params: {
|
|
|
30
38
|
networkId: string;
|
|
31
39
|
paymentMethodId: string;
|
|
32
40
|
}): Promise<string>;
|
|
41
|
+
export declare function ensureApprovedLinkSpendRequest(params: ApprovedLinkSpendRequestParams): Promise<string>;
|
|
42
|
+
export declare function payMppWithApprovedLinkSpendRequest(params: {
|
|
43
|
+
url: string;
|
|
44
|
+
method: string;
|
|
45
|
+
headers: Record<string, string>;
|
|
46
|
+
body?: unknown;
|
|
47
|
+
spendRequestId: string;
|
|
48
|
+
}): Promise<unknown>;
|
|
49
|
+
export declare function decodeMppNetworkId(challenge: string): Promise<string>;
|
package/dist/core/link-cli.js
CHANGED
|
@@ -108,6 +108,16 @@ function extractSpendRequestApproval(output) {
|
|
|
108
108
|
}
|
|
109
109
|
return null;
|
|
110
110
|
}
|
|
111
|
+
function extractSpendRequestStatus(output) {
|
|
112
|
+
const record = Array.isArray(output) ? asRecord(output[0]) : asRecord(output);
|
|
113
|
+
if (!record)
|
|
114
|
+
return {};
|
|
115
|
+
return {
|
|
116
|
+
id: typeof record.id === "string" ? record.id : undefined,
|
|
117
|
+
approvalUrl: typeof record.approval_url === "string" ? record.approval_url : undefined,
|
|
118
|
+
status: typeof record.status === "string" ? record.status : undefined,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
111
121
|
function isMatchingPendingSpendRequest(pending, params) {
|
|
112
122
|
if (!pending)
|
|
113
123
|
return false;
|
|
@@ -128,6 +138,12 @@ function isMatchingPendingSpendRequest(pending, params) {
|
|
|
128
138
|
function isProjectedSpendCapError(message) {
|
|
129
139
|
return /projected daily spend|projected spend|exceeds limit/i.test(message);
|
|
130
140
|
}
|
|
141
|
+
function isApprovedSpendRequestStatus(status) {
|
|
142
|
+
return /approved|active|ready/i.test(status ?? "");
|
|
143
|
+
}
|
|
144
|
+
function isPendingSpendRequestStatus(status) {
|
|
145
|
+
return /pending/i.test(status ?? "");
|
|
146
|
+
}
|
|
131
147
|
function recordLinkCooldown(reason) {
|
|
132
148
|
const now = new Date();
|
|
133
149
|
setLinkCooldown({
|
|
@@ -317,6 +333,155 @@ export async function createLinkSharedPaymentToken(params) {
|
|
|
317
333
|
throw new Error("Link spend request completed without a shared payment token in the CLI response.");
|
|
318
334
|
}
|
|
319
335
|
}
|
|
336
|
+
export async function ensureApprovedLinkSpendRequest(params) {
|
|
337
|
+
const existing = getPendingLinkSpendRequest();
|
|
338
|
+
if (isMatchingPendingSpendRequest(existing, params)) {
|
|
339
|
+
const retrieved = await runLinkCli([
|
|
340
|
+
"spend-request",
|
|
341
|
+
"retrieve",
|
|
342
|
+
existing.id,
|
|
343
|
+
], 30_000);
|
|
344
|
+
const status = extractSpendRequestStatus(retrieved);
|
|
345
|
+
if (isApprovedSpendRequestStatus(status.status)) {
|
|
346
|
+
return existing.id;
|
|
347
|
+
}
|
|
348
|
+
if (isPendingSpendRequestStatus(status.status)) {
|
|
349
|
+
throw new LinkApprovalRequiredError(existing.id, existing.approvalUrl ?? status.approvalUrl);
|
|
350
|
+
}
|
|
351
|
+
setPendingLinkSpendRequest(null);
|
|
352
|
+
}
|
|
353
|
+
const args = [
|
|
354
|
+
"spend-request",
|
|
355
|
+
"create",
|
|
356
|
+
"--credential-type",
|
|
357
|
+
"shared_payment_token",
|
|
358
|
+
"--network-id",
|
|
359
|
+
params.networkId,
|
|
360
|
+
"--amount",
|
|
361
|
+
params.amount,
|
|
362
|
+
"--currency",
|
|
363
|
+
params.currency,
|
|
364
|
+
"--payment-method-id",
|
|
365
|
+
params.paymentMethodId,
|
|
366
|
+
"--context",
|
|
367
|
+
params.context,
|
|
368
|
+
"--request-approval",
|
|
369
|
+
];
|
|
370
|
+
if (process.env.AGENTWONDERLAND_LINK_TEST_MODE === "1") {
|
|
371
|
+
args.push("--test");
|
|
372
|
+
}
|
|
373
|
+
let output;
|
|
374
|
+
try {
|
|
375
|
+
output = await runLinkCli(args);
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
379
|
+
if (isProjectedSpendCapError(message)) {
|
|
380
|
+
recordLinkCooldown(message);
|
|
381
|
+
throw new Error([
|
|
382
|
+
"Link is temporarily blocked by Stripe's projected-spend cap.",
|
|
383
|
+
"Reauthing Link or switching cards in the same Link account will not fix this.",
|
|
384
|
+
"Use USDC for now, wait for the rolling Link window to clear, or ask Stripe to raise/clear the merchant projected-spend cap.",
|
|
385
|
+
"",
|
|
386
|
+
message,
|
|
387
|
+
].join("\n"));
|
|
388
|
+
}
|
|
389
|
+
throw err;
|
|
390
|
+
}
|
|
391
|
+
const status = extractSpendRequestStatus(output);
|
|
392
|
+
const id = status.id ?? extractSpendRequestApproval(output)?.id;
|
|
393
|
+
if (id && isApprovedSpendRequestStatus(status.status)) {
|
|
394
|
+
setPendingLinkSpendRequest({
|
|
395
|
+
id,
|
|
396
|
+
approvalUrl: status.approvalUrl,
|
|
397
|
+
amount: params.amount,
|
|
398
|
+
currency: params.currency,
|
|
399
|
+
context: params.context,
|
|
400
|
+
expiresAt: params.expiresAt,
|
|
401
|
+
networkId: params.networkId,
|
|
402
|
+
paymentMethodId: params.paymentMethodId,
|
|
403
|
+
createdAt: new Date().toISOString(),
|
|
404
|
+
});
|
|
405
|
+
return id;
|
|
406
|
+
}
|
|
407
|
+
const approval = extractSpendRequestApproval(output);
|
|
408
|
+
if (approval?.id && isPendingSpendRequestStatus(approval.status)) {
|
|
409
|
+
setPendingLinkSpendRequest({
|
|
410
|
+
id: approval.id,
|
|
411
|
+
approvalUrl: approval.approvalUrl,
|
|
412
|
+
amount: params.amount,
|
|
413
|
+
currency: params.currency,
|
|
414
|
+
context: params.context,
|
|
415
|
+
expiresAt: params.expiresAt,
|
|
416
|
+
networkId: params.networkId,
|
|
417
|
+
paymentMethodId: params.paymentMethodId,
|
|
418
|
+
createdAt: new Date().toISOString(),
|
|
419
|
+
});
|
|
420
|
+
if (approval.approvalUrl) {
|
|
421
|
+
console.error(`Link approval required: ${approval.approvalUrl}`);
|
|
422
|
+
}
|
|
423
|
+
throw new LinkApprovalRequiredError(approval.id, approval.approvalUrl);
|
|
424
|
+
}
|
|
425
|
+
throw new Error("Link spend request did not return an approved or pending approval state.");
|
|
426
|
+
}
|
|
427
|
+
function extractMppPayBody(output) {
|
|
428
|
+
if (typeof output === "string") {
|
|
429
|
+
try {
|
|
430
|
+
return JSON.parse(output);
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return output;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (Array.isArray(output)) {
|
|
437
|
+
return output.length === 1 ? extractMppPayBody(output[0]) : output;
|
|
438
|
+
}
|
|
439
|
+
const record = asRecord(output);
|
|
440
|
+
if (!record)
|
|
441
|
+
return output;
|
|
442
|
+
for (const key of ["body", "json", "data", "response_body"]) {
|
|
443
|
+
const value = record[key];
|
|
444
|
+
if (value !== undefined)
|
|
445
|
+
return extractMppPayBody(value);
|
|
446
|
+
}
|
|
447
|
+
const response = asRecord(record.response);
|
|
448
|
+
if (response)
|
|
449
|
+
return extractMppPayBody(response.body ?? response.json ?? response.data ?? response);
|
|
450
|
+
return output;
|
|
451
|
+
}
|
|
452
|
+
export async function payMppWithApprovedLinkSpendRequest(params) {
|
|
453
|
+
const args = [
|
|
454
|
+
"mpp",
|
|
455
|
+
"pay",
|
|
456
|
+
params.url,
|
|
457
|
+
"--spend-request-id",
|
|
458
|
+
params.spendRequestId,
|
|
459
|
+
"--method",
|
|
460
|
+
params.method,
|
|
461
|
+
];
|
|
462
|
+
for (const [name, value] of Object.entries(params.headers)) {
|
|
463
|
+
args.push("--header", `${name}: ${value}`);
|
|
464
|
+
}
|
|
465
|
+
if (params.body !== undefined) {
|
|
466
|
+
args.push("--data", typeof params.body === "string" ? params.body : JSON.stringify(params.body));
|
|
467
|
+
}
|
|
468
|
+
const output = await runLinkCli(args);
|
|
469
|
+
return extractMppPayBody(output);
|
|
470
|
+
}
|
|
471
|
+
export async function decodeMppNetworkId(challenge) {
|
|
472
|
+
const output = await runLinkCli(["mpp", "decode", "--challenge", challenge], 30_000);
|
|
473
|
+
const found = walk(output, (value, key) => {
|
|
474
|
+
if (typeof value !== "string")
|
|
475
|
+
return null;
|
|
476
|
+
if (key && /network[_-]?id/i.test(key))
|
|
477
|
+
return value;
|
|
478
|
+
return null;
|
|
479
|
+
});
|
|
480
|
+
if (!found) {
|
|
481
|
+
throw new Error("Could not decode Link MPP network_id from payment challenge.");
|
|
482
|
+
}
|
|
483
|
+
return found;
|
|
484
|
+
}
|
|
320
485
|
async function retrieveSharedPaymentToken(spendRequestId, approvalUrl) {
|
|
321
486
|
const retrieved = await runLinkCli([
|
|
322
487
|
"spend-request",
|
package/dist/core/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const MCP_PACKAGE_VERSION = "0.1.
|
|
1
|
+
export declare const MCP_PACKAGE_VERSION = "0.1.55";
|
package/dist/core/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const MCP_PACKAGE_VERSION = "0.1.
|
|
1
|
+
export const MCP_PACKAGE_VERSION = "0.1.55";
|
package/dist/index.js
CHANGED
|
@@ -45,7 +45,7 @@ export async function startMcpServer() {
|
|
|
45
45
|
" For multi-agent workflows, use search_playbooks() → get_playbook() → run_playbook().",
|
|
46
46
|
"2. If the agent returns status 'processing', poll with get_job(). Async runs resolve automatically.",
|
|
47
47
|
"3. After a successful agent run, rate_agent() and optionally tip_agent() if the result was useful.",
|
|
48
|
-
"
|
|
48
|
+
" Playbooks return one run-level receipt; do not ask users to rate or tip individual child-agent steps.",
|
|
49
49
|
"4. Use list_jobs() to recover state across sessions (it checks every configured wallet).",
|
|
50
50
|
"",
|
|
51
51
|
"PAYMENT:",
|
|
@@ -1,19 +1,35 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
const mockApiGet = vi.fn();
|
|
3
3
|
const mockApiPost = vi.fn();
|
|
4
|
+
const mockApiPostWithApprovedLinkSpendRequest = vi.fn();
|
|
4
5
|
const mockApiPostWithPayment = vi.fn();
|
|
5
6
|
const mockUploadLocalFiles = vi.fn();
|
|
6
7
|
const mockHasWalletConfigured = vi.fn();
|
|
7
8
|
const mockGetConfiguredMethods = vi.fn();
|
|
8
9
|
const mockGetWalletAddress = vi.fn();
|
|
10
|
+
const mockGetLinkConfig = vi.fn();
|
|
11
|
+
const mockSetPendingLinkSpendRequest = vi.fn();
|
|
9
12
|
const mockRequiresSpendConfirmation = vi.fn();
|
|
10
13
|
const mockCanSpend = vi.fn();
|
|
11
14
|
const mockRecordSpend = vi.fn();
|
|
12
15
|
const mockRequiresPolicyConfirmation = vi.fn();
|
|
13
16
|
const mockStoreFeedbackToken = vi.fn();
|
|
17
|
+
const mockDecodeMppNetworkId = vi.fn();
|
|
18
|
+
const mockEnsureApprovedLinkSpendRequest = vi.fn();
|
|
19
|
+
class MockLinkApprovalRequiredError extends Error {
|
|
20
|
+
approvalUrl;
|
|
21
|
+
spendRequestId;
|
|
22
|
+
constructor(approvalUrl, spendRequestId = "lsrq_test_123") {
|
|
23
|
+
super(`Link approval required: ${approvalUrl}`);
|
|
24
|
+
this.name = "LinkApprovalRequiredError";
|
|
25
|
+
this.approvalUrl = approvalUrl;
|
|
26
|
+
this.spendRequestId = spendRequestId;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
14
29
|
vi.mock("../../core/api-client.js", () => ({
|
|
15
30
|
apiGet: mockApiGet,
|
|
16
31
|
apiPost: mockApiPost,
|
|
32
|
+
apiPostWithApprovedLinkSpendRequest: mockApiPostWithApprovedLinkSpendRequest,
|
|
17
33
|
apiPostWithPayment: mockApiPostWithPayment,
|
|
18
34
|
}));
|
|
19
35
|
vi.mock("../../core/file-upload.js", () => ({
|
|
@@ -25,6 +41,8 @@ vi.mock("../../core/payments.js", () => ({
|
|
|
25
41
|
getWalletAddress: mockGetWalletAddress,
|
|
26
42
|
}));
|
|
27
43
|
vi.mock("../../core/config.js", () => ({
|
|
44
|
+
getLinkConfig: mockGetLinkConfig,
|
|
45
|
+
setPendingLinkSpendRequest: mockSetPendingLinkSpendRequest,
|
|
28
46
|
requiresSpendConfirmation: mockRequiresSpendConfirmation,
|
|
29
47
|
}));
|
|
30
48
|
vi.mock("../../core/spend-policy.js", () => ({
|
|
@@ -35,6 +53,11 @@ vi.mock("../../core/spend-policy.js", () => ({
|
|
|
35
53
|
vi.mock("../_token-cache.js", () => ({
|
|
36
54
|
storeFeedbackToken: mockStoreFeedbackToken,
|
|
37
55
|
}));
|
|
56
|
+
vi.mock("../../core/link-cli.js", () => ({
|
|
57
|
+
decodeMppNetworkId: mockDecodeMppNetworkId,
|
|
58
|
+
ensureApprovedLinkSpendRequest: mockEnsureApprovedLinkSpendRequest,
|
|
59
|
+
LinkApprovalRequiredError: MockLinkApprovalRequiredError,
|
|
60
|
+
}));
|
|
38
61
|
function flattenToolText(result) {
|
|
39
62
|
const content = result?.content ?? [];
|
|
40
63
|
return content
|
|
@@ -105,6 +128,11 @@ describe("playbook MCP tools", () => {
|
|
|
105
128
|
mockHasWalletConfigured.mockReturnValue(true);
|
|
106
129
|
mockGetConfiguredMethods.mockReturnValue(["card"]);
|
|
107
130
|
mockGetWalletAddress.mockResolvedValue("0xabc");
|
|
131
|
+
mockGetLinkConfig.mockReturnValue({
|
|
132
|
+
paymentMethodId: "csmrpd_test_123",
|
|
133
|
+
});
|
|
134
|
+
mockDecodeMppNetworkId.mockResolvedValue("profile_test");
|
|
135
|
+
mockEnsureApprovedLinkSpendRequest.mockResolvedValue("lsrq_test_123");
|
|
108
136
|
mockRequiresSpendConfirmation.mockReturnValue(true);
|
|
109
137
|
mockRequiresPolicyConfirmation.mockReturnValue(false);
|
|
110
138
|
mockCanSpend.mockReturnValue({ ok: true, message: "" });
|
|
@@ -229,41 +257,13 @@ describe("playbook MCP tools", () => {
|
|
|
229
257
|
expect(mockApiPost).toHaveBeenCalledWith("/playbooks/competitor-ads/favorite", {}, { ensureConsumerPrincipal: true });
|
|
230
258
|
expect(text).toContain("Saved competitor-ads to favorites");
|
|
231
259
|
});
|
|
232
|
-
it("
|
|
233
|
-
mockApiPost.mockResolvedValueOnce({
|
|
234
|
-
ok: true,
|
|
235
|
-
feedback_id: "feedback-row-1",
|
|
236
|
-
playbook_slug: "competitor-ads",
|
|
237
|
-
});
|
|
238
|
-
const { registerPlaybookTools } = await import("../playbooks.js");
|
|
239
|
-
const harness = makeServerHarness();
|
|
240
|
-
registerPlaybookTools(harness.server);
|
|
241
|
-
const result = await harness.handlers.get("rate_playbook")({
|
|
242
|
-
slug: "competitor-ads",
|
|
243
|
-
run_id: "22222222-2222-4222-8222-222222222222",
|
|
244
|
-
rating: 5,
|
|
245
|
-
useful: true,
|
|
246
|
-
comment: "Useful.",
|
|
247
|
-
});
|
|
248
|
-
const text = flattenToolText(result);
|
|
249
|
-
expect(mockApiPost).toHaveBeenCalledWith("/playbooks/competitor-ads/feedback", {
|
|
250
|
-
run_id: "22222222-2222-4222-8222-222222222222",
|
|
251
|
-
rating: 5,
|
|
252
|
-
useful: true,
|
|
253
|
-
comment: "Useful.",
|
|
254
|
-
}, { ensureConsumerPrincipal: true });
|
|
255
|
-
expect(text).toContain("Recorded feedback for competitor-ads");
|
|
256
|
-
});
|
|
257
|
-
it("validates favorite and feedback inputs before calling the gateway", async () => {
|
|
260
|
+
it("does not expose per-playbook feedback as an MCP tool", async () => {
|
|
258
261
|
const { registerPlaybookTools } = await import("../playbooks.js");
|
|
259
262
|
const harness = makeServerHarness();
|
|
260
263
|
registerPlaybookTools(harness.server);
|
|
261
264
|
const favoriteResult = await harness.handlers.get("favorite_playbook")({});
|
|
262
|
-
const emptyFeedbackResult = await harness.handlers.get("rate_playbook")({ slug: "competitor-ads" });
|
|
263
|
-
const missingIdFeedbackResult = await harness.handlers.get("rate_playbook")({ rating: 5 });
|
|
264
265
|
expect(flattenToolText(favoriteResult)).toContain("Provide slug or playbook_id.");
|
|
265
|
-
expect(
|
|
266
|
-
expect(flattenToolText(missingIdFeedbackResult)).toContain("Provide slug or playbook_id.");
|
|
266
|
+
expect(harness.handlers.has("rate_playbook")).toBe(false);
|
|
267
267
|
expect(mockApiPost).not.toHaveBeenCalled();
|
|
268
268
|
});
|
|
269
269
|
it("quotes before confirmed execution", async () => {
|
|
@@ -526,12 +526,92 @@ describe("playbook MCP tools", () => {
|
|
|
526
526
|
error_code: null,
|
|
527
527
|
failure_message: null,
|
|
528
528
|
}));
|
|
529
|
-
expect(mockStoreFeedbackToken).
|
|
529
|
+
expect(mockStoreFeedbackToken).not.toHaveBeenCalled();
|
|
530
530
|
expect(text).toContain("Playbook run 22222222-2222-4222-8222-222222222222");
|
|
531
531
|
expect(text).toContain("Charged: $1.25");
|
|
532
|
-
expect(text).toContain("Was this playbook useful?");
|
|
533
|
-
expect(text).toContain("favorite_playbook({ slug: \"competitor-ads\" })");
|
|
534
|
-
expect(text).toContain("rate_playbook
|
|
532
|
+
expect(text).not.toContain("Was this playbook useful?");
|
|
533
|
+
expect(text).not.toContain("favorite_playbook({ slug: \"competitor-ads\" })");
|
|
534
|
+
expect(text).not.toContain("rate_playbook");
|
|
535
|
+
});
|
|
536
|
+
it("uses one approved Link spend request across paid playbook child steps", async () => {
|
|
537
|
+
mockRequiresSpendConfirmation.mockReturnValue(false);
|
|
538
|
+
mockGetConfiguredMethods.mockReturnValue(["link"]);
|
|
539
|
+
mockApiGet
|
|
540
|
+
.mockResolvedValueOnce(playbook)
|
|
541
|
+
.mockResolvedValueOnce({
|
|
542
|
+
run_id: "22222222-2222-4222-8222-222222222222",
|
|
543
|
+
status: "completed",
|
|
544
|
+
playbook_id: playbook.id,
|
|
545
|
+
playbook_slug: playbook.slug,
|
|
546
|
+
playbook_version: 1,
|
|
547
|
+
budget_usd: 5,
|
|
548
|
+
quoted_cost_usd: 1.25,
|
|
549
|
+
charged_usd: 1.25,
|
|
550
|
+
refunded_usd: 0,
|
|
551
|
+
remaining_budget_usd: 3.75,
|
|
552
|
+
steps: [{
|
|
553
|
+
playbook_step_id: "ads",
|
|
554
|
+
step_index: 0,
|
|
555
|
+
node_type: "aw_agent",
|
|
556
|
+
agent_slug: "ad-strategy-intel",
|
|
557
|
+
agent_id: "11111111-1111-4111-8111-111111111111",
|
|
558
|
+
provider_id: null,
|
|
559
|
+
job_id: "33333333-3333-4333-8333-333333333333",
|
|
560
|
+
status: "succeeded",
|
|
561
|
+
quoted_cost_usd: 1.25,
|
|
562
|
+
charged_usd: 1.25,
|
|
563
|
+
refunded_usd: 0,
|
|
564
|
+
}],
|
|
565
|
+
});
|
|
566
|
+
mockApiPost.mockImplementation(async (path) => {
|
|
567
|
+
if (path === "/playbook-runs") {
|
|
568
|
+
return {
|
|
569
|
+
run_id: "22222222-2222-4222-8222-222222222222",
|
|
570
|
+
steps: playbook.current_quote.steps,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
|
|
574
|
+
throw {
|
|
575
|
+
status: 402,
|
|
576
|
+
message: "payment required",
|
|
577
|
+
headers: new Headers({ "www-authenticate": "Payment challenge" }),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
return { ok: true };
|
|
581
|
+
});
|
|
582
|
+
mockApiPostWithApprovedLinkSpendRequest.mockResolvedValueOnce({
|
|
583
|
+
status: "success",
|
|
584
|
+
job_id: "33333333-3333-4333-8333-333333333333",
|
|
585
|
+
agent_id: "11111111-1111-4111-8111-111111111111",
|
|
586
|
+
output: { rows: 3 },
|
|
587
|
+
cost: 1.25,
|
|
588
|
+
});
|
|
589
|
+
const { registerPlaybookTools } = await import("../playbooks.js");
|
|
590
|
+
const harness = makeServerHarness();
|
|
591
|
+
registerPlaybookTools(harness.server);
|
|
592
|
+
const result = await harness.handlers.get("run_playbook")({
|
|
593
|
+
slug: "competitor-ads",
|
|
594
|
+
input: { domain: "notion.so" },
|
|
595
|
+
budget: 5,
|
|
596
|
+
pay_with: "link",
|
|
597
|
+
confirmed: true,
|
|
598
|
+
});
|
|
599
|
+
const text = flattenToolText(result);
|
|
600
|
+
expect(mockDecodeMppNetworkId).toHaveBeenCalledWith("Payment challenge");
|
|
601
|
+
expect(mockEnsureApprovedLinkSpendRequest).toHaveBeenCalledWith(expect.objectContaining({
|
|
602
|
+
amount: "500",
|
|
603
|
+
currency: "usd",
|
|
604
|
+
networkId: "profile_test",
|
|
605
|
+
paymentMethodId: "csmrpd_test_123",
|
|
606
|
+
}));
|
|
607
|
+
expect(mockApiPostWithApprovedLinkSpendRequest).toHaveBeenCalledWith("/agents/11111111-1111-4111-8111-111111111111/run", expect.objectContaining({
|
|
608
|
+
input: expect.objectContaining({
|
|
609
|
+
startUrls: [{ url: "https://notion.so" }],
|
|
610
|
+
}),
|
|
611
|
+
}), "lsrq_test_123");
|
|
612
|
+
expect(mockApiPostWithPayment).not.toHaveBeenCalled();
|
|
613
|
+
expect(mockSetPendingLinkSpendRequest).toHaveBeenCalledWith(null);
|
|
614
|
+
expect(text).toContain("Playbook run 22222222-2222-4222-8222-222222222222");
|
|
535
615
|
});
|
|
536
616
|
it("uploads local file inputs before run creation and child execution", async () => {
|
|
537
617
|
mockRequiresSpendConfirmation.mockReturnValue(false);
|