@agentwonderland/mcp 0.1.51 → 0.1.53
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 +6 -2
- package/dist/core/__tests__/link-cli.test.d.ts +1 -0
- package/dist/core/__tests__/link-cli.test.js +102 -0
- package/dist/core/__tests__/principal.test.js +25 -0
- package/dist/core/api-client.js +4 -2
- package/dist/core/link-cli.d.ts +5 -0
- package/dist/core/link-cli.js +25 -25
- package/dist/core/principal.d.ts +1 -0
- package/dist/core/principal.js +49 -1
- package/dist/index.js +3 -0
- package/dist/tools/__tests__/rebates.test.d.ts +1 -0
- package/dist/tools/__tests__/rebates.test.js +72 -0
- package/dist/tools/__tests__/run.test.js +26 -0
- package/dist/tools/__tests__/wallet.test.js +1 -0
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +1 -0
- package/dist/tools/observability.d.ts +2 -0
- package/dist/tools/observability.js +20 -0
- package/dist/tools/rebates.d.ts +2 -0
- package/dist/tools/rebates.js +51 -0
- package/dist/tools/run.js +6 -0
- package/dist/tools/solve.js +8 -1
- package/dist/tools/wallet.js +4 -4
- package/package.json +1 -1
- package/src/core/__tests__/api-client.test.ts +8 -1
- package/src/core/__tests__/link-cli.test.ts +125 -0
- package/src/core/__tests__/principal.test.ts +31 -0
- package/src/core/api-client.ts +4 -2
- package/src/core/link-cli.ts +25 -27
- package/src/core/principal.ts +54 -1
- package/src/index.ts +3 -0
- package/src/tools/__tests__/rebates.test.ts +91 -0
- package/src/tools/__tests__/run.test.ts +33 -0
- package/src/tools/__tests__/wallet.test.ts +1 -0
- package/src/tools/index.ts +1 -0
- package/src/tools/rebates.ts +102 -0
- package/src/tools/run.ts +7 -0
- package/src/tools/solve.ts +8 -1
- package/src/tools/wallet.ts +4 -4
package/dist/tools/wallet.js
CHANGED
|
@@ -6,7 +6,7 @@ import { getSettings } from "../core/settings.js";
|
|
|
6
6
|
import { getOrCreatePendingCardSetup, formatCardSetupBlocks, getCardCapabilities, pollCardSetup, } from "../core/card-setup.js";
|
|
7
7
|
import { getLinkCliAuthStatus, listLinkPaymentMethods, openLinkPaymentMethodAdd, startLinkCliLogin, } from "../core/link-cli.js";
|
|
8
8
|
import { isOwsAvailable, createOwsWallet, importKeyToOws, listOwsWallets, listOwsWalletsByChain, installOws, platformSupportsOws, } from "../core/ows-adapter.js";
|
|
9
|
-
import { ensureConsumerPrincipal, getConsumerPrincipal } from "../core/principal.js";
|
|
9
|
+
import { ensureConsumerPrincipal, ensureConsumerPrincipalForMethod, getConsumerPrincipal } from "../core/principal.js";
|
|
10
10
|
import { MCP_PACKAGE_VERSION } from "../core/version.js";
|
|
11
11
|
function text(t) {
|
|
12
12
|
return { content: [{ type: "text", text: t }] };
|
|
@@ -221,7 +221,7 @@ export function registerWalletTools(server) {
|
|
|
221
221
|
paymentMethodId: selected.id,
|
|
222
222
|
label: name ?? selected.label,
|
|
223
223
|
});
|
|
224
|
-
const principal = await
|
|
224
|
+
const principal = await ensureConsumerPrincipalForMethod("link");
|
|
225
225
|
return text([
|
|
226
226
|
"Link payment method connected.",
|
|
227
227
|
` Payment method: ${selected.label ?? selected.id}${selected.label ? ` (${selected.id})` : ""}`,
|
|
@@ -254,7 +254,7 @@ export function registerWalletTools(server) {
|
|
|
254
254
|
paymentMethodId: method.id,
|
|
255
255
|
label: method.label,
|
|
256
256
|
});
|
|
257
|
-
const principal = await
|
|
257
|
+
const principal = await ensureConsumerPrincipalForMethod("link");
|
|
258
258
|
return text([
|
|
259
259
|
"Link payment method connected.",
|
|
260
260
|
` Payment method: ${method.id}${method.label ? ` (${method.label})` : ""}`,
|
|
@@ -293,7 +293,7 @@ export function registerWalletTools(server) {
|
|
|
293
293
|
if (pendingToken) {
|
|
294
294
|
const result = await pollCardSetup(pendingToken, 250);
|
|
295
295
|
if (result) {
|
|
296
|
-
const principal = await
|
|
296
|
+
const principal = await ensureConsumerPrincipalForMethod("card");
|
|
297
297
|
return text(`Connected! ${result.brand} ****${result.last4} is ready for payments.\n\n` +
|
|
298
298
|
`Consumer principal: ${principal}`);
|
|
299
299
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { MCP_PACKAGE_VERSION } from "../version.js";
|
|
2
3
|
|
|
3
4
|
const {
|
|
4
5
|
mockGetApiUrl,
|
|
5
6
|
mockGetApiKey,
|
|
6
7
|
mockGetPaymentFetch,
|
|
7
8
|
mockEnsureConsumerPrincipalForMethod,
|
|
9
|
+
mockEnsureBaseRebatePrincipal,
|
|
8
10
|
mockGetConsumerPrincipalForMethod,
|
|
9
11
|
mockGetBaseRebatePrincipal,
|
|
10
12
|
mockPaymentFetch,
|
|
@@ -13,6 +15,7 @@ const {
|
|
|
13
15
|
mockGetApiKey: vi.fn(),
|
|
14
16
|
mockGetPaymentFetch: vi.fn(),
|
|
15
17
|
mockEnsureConsumerPrincipalForMethod: vi.fn(),
|
|
18
|
+
mockEnsureBaseRebatePrincipal: vi.fn(),
|
|
16
19
|
mockGetConsumerPrincipalForMethod: vi.fn(),
|
|
17
20
|
mockGetBaseRebatePrincipal: vi.fn(),
|
|
18
21
|
mockPaymentFetch: vi.fn(),
|
|
@@ -29,6 +32,7 @@ vi.mock("../payments.js", () => ({
|
|
|
29
32
|
|
|
30
33
|
vi.mock("../principal.js", () => ({
|
|
31
34
|
ensureConsumerPrincipal: vi.fn(),
|
|
35
|
+
ensureBaseRebatePrincipal: (...args: unknown[]) => mockEnsureBaseRebatePrincipal(...args),
|
|
32
36
|
ensureConsumerPrincipalForMethod: (...args: unknown[]) => mockEnsureConsumerPrincipalForMethod(...args),
|
|
33
37
|
getConsumerPrincipal: vi.fn(),
|
|
34
38
|
getConsumerPrincipalForMethod: (...args: unknown[]) => mockGetConsumerPrincipalForMethod(...args),
|
|
@@ -50,6 +54,9 @@ describe("api-client headers", () => {
|
|
|
50
54
|
mockGetBaseRebatePrincipal.mockResolvedValue(
|
|
51
55
|
"did:pkh:eip155:8453:0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
52
56
|
);
|
|
57
|
+
mockEnsureBaseRebatePrincipal.mockResolvedValue(
|
|
58
|
+
"did:pkh:eip155:8453:0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
59
|
+
);
|
|
53
60
|
mockPaymentFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
|
|
54
61
|
status: 200,
|
|
55
62
|
headers: { "content-type": "application/json" },
|
|
@@ -72,7 +79,7 @@ describe("api-client headers", () => {
|
|
|
72
79
|
"X-AW-Rebate-Principal":
|
|
73
80
|
"did:pkh:eip155:8453:0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
74
81
|
"X-AW-Surface": "mcp",
|
|
75
|
-
"X-AW-MCP-Version":
|
|
82
|
+
"X-AW-MCP-Version": MCP_PACKAGE_VERSION,
|
|
76
83
|
"X-AW-MCP-Tool": "run_agent",
|
|
77
84
|
"X-AW-MCP-Action": "execute",
|
|
78
85
|
}),
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { PendingLinkSpendRequest } from "../config.js";
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
execCalls,
|
|
6
|
+
outputs,
|
|
7
|
+
state,
|
|
8
|
+
mockExecFile,
|
|
9
|
+
} = vi.hoisted(() => {
|
|
10
|
+
const execCalls: Array<{ file: string; args: string[] }> = [];
|
|
11
|
+
const outputs: unknown[] = [];
|
|
12
|
+
const state: { pending: PendingLinkSpendRequest | null; pendingWrites: Array<PendingLinkSpendRequest | null> } = {
|
|
13
|
+
pending: null,
|
|
14
|
+
pendingWrites: [],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type MockExecFile = ((file: string, args: string[], options: unknown, callback: (err: Error | null, stdout?: string, stderr?: string) => void) => void) & {
|
|
18
|
+
[key: symbol]: unknown;
|
|
19
|
+
};
|
|
20
|
+
const mockExecFile = vi.fn((file: string, args: string[], _options: unknown, callback: (err: Error | null, stdout?: string, stderr?: string) => void) => {
|
|
21
|
+
execCalls.push({ file, args });
|
|
22
|
+
const next = outputs.shift();
|
|
23
|
+
if (next instanceof Error) {
|
|
24
|
+
callback(next);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
callback(null, JSON.stringify(next ?? null), "");
|
|
28
|
+
}) as unknown as MockExecFile;
|
|
29
|
+
mockExecFile[Symbol.for("nodejs.util.promisify.custom")] = async (file: string, args: string[]) => {
|
|
30
|
+
execCalls.push({ file, args });
|
|
31
|
+
const next = outputs.shift();
|
|
32
|
+
if (next instanceof Error) {
|
|
33
|
+
throw next;
|
|
34
|
+
}
|
|
35
|
+
return { stdout: JSON.stringify(next ?? null), stderr: "" };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return { execCalls, outputs, state, mockExecFile };
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
vi.mock("node:child_process", () => ({
|
|
42
|
+
execFile: mockExecFile,
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
vi.mock("../config.js", () => ({
|
|
46
|
+
getPendingLinkSpendRequest: () => state.pending,
|
|
47
|
+
setPendingLinkSpendRequest: (pending: PendingLinkSpendRequest | null) => {
|
|
48
|
+
state.pending = pending;
|
|
49
|
+
state.pendingWrites.push(pending);
|
|
50
|
+
},
|
|
51
|
+
setLinkCooldown: vi.fn(),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
describe("Link CLI spend requests", () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.clearAllMocks();
|
|
57
|
+
execCalls.length = 0;
|
|
58
|
+
outputs.length = 0;
|
|
59
|
+
state.pending = null;
|
|
60
|
+
state.pendingWrites = [];
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns an approval-required error immediately instead of blocking on Link approval", async () => {
|
|
64
|
+
outputs.push([
|
|
65
|
+
{
|
|
66
|
+
id: "lsrq_test_123",
|
|
67
|
+
approval_url: "https://link.example/approve/lsrq_test_123",
|
|
68
|
+
status: "pending_approval",
|
|
69
|
+
},
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
const { createLinkSharedPaymentToken, LinkApprovalRequiredError } = await import("../link-cli.js");
|
|
73
|
+
|
|
74
|
+
await expect(createLinkSharedPaymentToken({
|
|
75
|
+
amount: "10",
|
|
76
|
+
currency: "usd",
|
|
77
|
+
context: "Agent Wonderland test",
|
|
78
|
+
expiresAt: Math.floor(Date.now() / 1000) + 300,
|
|
79
|
+
networkId: "profile_test",
|
|
80
|
+
paymentMethodId: "csmrpd_test_123",
|
|
81
|
+
})).rejects.toBeInstanceOf(LinkApprovalRequiredError);
|
|
82
|
+
|
|
83
|
+
expect(execCalls).toHaveLength(1);
|
|
84
|
+
expect(execCalls[0]?.args).toContain("create");
|
|
85
|
+
expect(state.pendingWrites[0]).toMatchObject({
|
|
86
|
+
id: "lsrq_test_123",
|
|
87
|
+
approvalUrl: "https://link.example/approve/lsrq_test_123",
|
|
88
|
+
amount: "10",
|
|
89
|
+
currency: "usd",
|
|
90
|
+
networkId: "profile_test",
|
|
91
|
+
paymentMethodId: "csmrpd_test_123",
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("resumes a stored Link spend request and returns the approved shared payment token", async () => {
|
|
96
|
+
const expiresAt = Math.floor(Date.now() / 1000) + 300;
|
|
97
|
+
state.pending = {
|
|
98
|
+
id: "lsrq_test_123",
|
|
99
|
+
approvalUrl: "https://link.example/approve/lsrq_test_123",
|
|
100
|
+
amount: "10",
|
|
101
|
+
currency: "usd",
|
|
102
|
+
context: "Agent Wonderland test",
|
|
103
|
+
expiresAt,
|
|
104
|
+
networkId: "profile_test",
|
|
105
|
+
paymentMethodId: "csmrpd_test_123",
|
|
106
|
+
createdAt: new Date().toISOString(),
|
|
107
|
+
};
|
|
108
|
+
outputs.push({ shared_payment_token: "spt_test_123" });
|
|
109
|
+
|
|
110
|
+
const { createLinkSharedPaymentToken } = await import("../link-cli.js");
|
|
111
|
+
|
|
112
|
+
await expect(createLinkSharedPaymentToken({
|
|
113
|
+
amount: "10",
|
|
114
|
+
currency: "usd",
|
|
115
|
+
context: "Agent Wonderland test",
|
|
116
|
+
expiresAt,
|
|
117
|
+
networkId: "profile_test",
|
|
118
|
+
paymentMethodId: "csmrpd_test_123",
|
|
119
|
+
})).resolves.toBe("spt_test_123");
|
|
120
|
+
|
|
121
|
+
expect(execCalls).toHaveLength(1);
|
|
122
|
+
expect(execCalls[0]?.args).toEqual(expect.arrayContaining(["spend-request", "retrieve", "lsrq_test_123"]));
|
|
123
|
+
expect(state.pendingWrites).toEqual([null]);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -137,6 +137,22 @@ describe("consumer principal helpers", () => {
|
|
|
137
137
|
expect(state.addedWallets).toHaveLength(1);
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
+
it("creates an EVM identity for Link payments when only a Solana wallet exists", async () => {
|
|
141
|
+
state.wallets = [
|
|
142
|
+
{ id: "sol-wallet", keyType: "ows", owsWalletId: "ows-sol", chains: ["solana"], defaultChain: "solana" },
|
|
143
|
+
];
|
|
144
|
+
state.addresses = {
|
|
145
|
+
"sol-wallet": "42W2HfLfveSm1T5et9WTLp2CZ2QXdF2EYCUvyJ2gPpxF",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const { ensureConsumerPrincipalForMethod } = await import("../principal.js");
|
|
149
|
+
const principal = await ensureConsumerPrincipalForMethod("link");
|
|
150
|
+
|
|
151
|
+
expect(principal).toMatch(/^did:pkh:eip155:8453:0x[a-f0-9]{40}$/);
|
|
152
|
+
expect(state.addedWallets).toHaveLength(1);
|
|
153
|
+
expect(state.addedWallets[0]?.chains).toEqual(["tempo", "base"]);
|
|
154
|
+
});
|
|
155
|
+
|
|
140
156
|
it("returns the Base rebate principal when an EVM wallet is available", async () => {
|
|
141
157
|
state.wallets = [
|
|
142
158
|
{ id: "aw-main", keyType: "ows", owsWalletId: "ows-main", chains: ["tempo", "base", "solana"], defaultChain: "tempo" },
|
|
@@ -152,4 +168,19 @@ describe("consumer principal helpers", () => {
|
|
|
152
168
|
|
|
153
169
|
expect(principal).toBe("did:pkh:eip155:8453:0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
|
|
154
170
|
});
|
|
171
|
+
|
|
172
|
+
it("ensures a Base rebate principal even when the default wallet is Solana-only", async () => {
|
|
173
|
+
state.wallets = [
|
|
174
|
+
{ id: "sol-wallet", keyType: "ows", owsWalletId: "ows-sol", chains: ["solana"], defaultChain: "solana" },
|
|
175
|
+
];
|
|
176
|
+
state.addresses = {
|
|
177
|
+
"sol-wallet": "42W2HfLfveSm1T5et9WTLp2CZ2QXdF2EYCUvyJ2gPpxF",
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const { ensureBaseRebatePrincipal } = await import("../principal.js");
|
|
181
|
+
const principal = await ensureBaseRebatePrincipal();
|
|
182
|
+
|
|
183
|
+
expect(principal).toMatch(/^did:pkh:eip155:8453:0x[a-f0-9]{40}$/);
|
|
184
|
+
expect(state.addedWallets).toHaveLength(1);
|
|
185
|
+
});
|
|
155
186
|
});
|
package/src/core/api-client.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { getApiUrl, getApiKey } from "./config.js";
|
|
|
2
2
|
import { getPaymentFetch } from "./payments.js";
|
|
3
3
|
import {
|
|
4
4
|
getBaseRebatePrincipal,
|
|
5
|
-
|
|
5
|
+
ensureBaseRebatePrincipal,
|
|
6
6
|
ensureConsumerPrincipalForMethod,
|
|
7
7
|
getConsumerPrincipal,
|
|
8
8
|
getConsumerPrincipalForMethod,
|
|
@@ -70,7 +70,9 @@ async function buildHeaders(path: string, method: string, options?: RequestOptio
|
|
|
70
70
|
headers["X-AW-Consumer-Principal"] = principal;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
const rebatePrincipal =
|
|
73
|
+
const rebatePrincipal = options?.ensureConsumerPrincipal
|
|
74
|
+
? await ensureBaseRebatePrincipal()
|
|
75
|
+
: await getBaseRebatePrincipal();
|
|
74
76
|
if (rebatePrincipal) {
|
|
75
77
|
headers["X-AW-Rebate-Principal"] = rebatePrincipal;
|
|
76
78
|
}
|
package/src/core/link-cli.ts
CHANGED
|
@@ -11,8 +11,23 @@ const execFileAsync = promisify(execFile);
|
|
|
11
11
|
const LINK_CLI_PACKAGE = "@stripe/link-cli";
|
|
12
12
|
const LINK_CLI_TIMEOUT_MS = 10 * 60 * 1000;
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
export class LinkApprovalRequiredError extends Error {
|
|
15
|
+
constructor(
|
|
16
|
+
public readonly spendRequestId: string,
|
|
17
|
+
public readonly approvalUrl?: string,
|
|
18
|
+
) {
|
|
19
|
+
super(formatLinkApprovalRequiredMessage(spendRequestId, approvalUrl));
|
|
20
|
+
this.name = "LinkApprovalRequiredError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatLinkApprovalRequiredMessage(spendRequestId: string, approvalUrl?: string): string {
|
|
25
|
+
return [
|
|
26
|
+
"Link approval required.",
|
|
27
|
+
"The agent has not run yet and no charge has been captured.",
|
|
28
|
+
approvalUrl ? `Approve this spend request in Link: ${approvalUrl}` : `Spend request: ${spendRequestId}`,
|
|
29
|
+
"After approving, rerun the same tool call with confirmed: true.",
|
|
30
|
+
].join("\n");
|
|
16
31
|
}
|
|
17
32
|
|
|
18
33
|
export interface LinkCliAuthStatus {
|
|
@@ -264,6 +279,9 @@ export async function createLinkSharedPaymentToken(params: {
|
|
|
264
279
|
return spt;
|
|
265
280
|
} catch (err) {
|
|
266
281
|
const message = err instanceof Error ? err.message : String(err);
|
|
282
|
+
if (err instanceof LinkApprovalRequiredError) {
|
|
283
|
+
throw err;
|
|
284
|
+
}
|
|
267
285
|
if (!/denied|expired|not found|without a shared payment token|POLLING_TIMEOUT/i.test(message)) {
|
|
268
286
|
throw err;
|
|
269
287
|
}
|
|
@@ -344,9 +362,7 @@ export async function createLinkSharedPaymentToken(params: {
|
|
|
344
362
|
if (approval.approvalUrl) {
|
|
345
363
|
console.error(`Link approval required: ${approval.approvalUrl}`);
|
|
346
364
|
}
|
|
347
|
-
|
|
348
|
-
setPendingLinkSpendRequest(null);
|
|
349
|
-
return retrievedSpt;
|
|
365
|
+
throw new LinkApprovalRequiredError(approval.id, approval.approvalUrl);
|
|
350
366
|
}
|
|
351
367
|
|
|
352
368
|
{
|
|
@@ -355,32 +371,14 @@ export async function createLinkSharedPaymentToken(params: {
|
|
|
355
371
|
}
|
|
356
372
|
|
|
357
373
|
async function retrieveSharedPaymentToken(spendRequestId: string, approvalUrl?: string): Promise<string> {
|
|
358
|
-
|
|
374
|
+
const retrieved = await runLinkCli([
|
|
359
375
|
"spend-request",
|
|
360
376
|
"retrieve",
|
|
361
377
|
spendRequestId,
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
}
|
|
378
|
+
], 30_000);
|
|
379
|
+
const retrievedSpt = extractSharedPaymentToken(retrieved);
|
|
377
380
|
if (retrievedSpt) {
|
|
378
381
|
return retrievedSpt;
|
|
379
382
|
}
|
|
380
|
-
throw new
|
|
381
|
-
[
|
|
382
|
-
"Link spend request finished without a shared payment token.",
|
|
383
|
-
approvalUrl ? `Approval URL: ${approvalUrl}` : undefined,
|
|
384
|
-
].filter(Boolean).join("\n"),
|
|
385
|
-
);
|
|
383
|
+
throw new LinkApprovalRequiredError(spendRequestId, approvalUrl);
|
|
386
384
|
}
|
package/src/core/principal.ts
CHANGED
|
@@ -80,6 +80,43 @@ async function createFallbackIdentityWallet(): Promise<WalletEntry> {
|
|
|
80
80
|
return entry;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
async function ensureEvmIdentityWallet(): Promise<WalletEntry> {
|
|
84
|
+
const existing = preferredWallets().find((wallet) => walletSupportsEvm(wallet));
|
|
85
|
+
if (existing) return existing;
|
|
86
|
+
|
|
87
|
+
if (await isOwsAvailable()) {
|
|
88
|
+
const existingOwsWallets = await listOwsWallets();
|
|
89
|
+
const linked = existingOwsWallets[0];
|
|
90
|
+
if (linked) {
|
|
91
|
+
const entry: WalletEntry = {
|
|
92
|
+
id: linked.name,
|
|
93
|
+
keyType: "ows",
|
|
94
|
+
owsWalletId: linked.id,
|
|
95
|
+
chains: ["tempo", "base"],
|
|
96
|
+
defaultChain: "base",
|
|
97
|
+
label: linked.name,
|
|
98
|
+
};
|
|
99
|
+
addWallet(entry);
|
|
100
|
+
return entry;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const walletName = `aw-identity-${Date.now()}`;
|
|
104
|
+
const created = await createOwsWallet(walletName, "evm");
|
|
105
|
+
const entry: WalletEntry = {
|
|
106
|
+
id: walletName,
|
|
107
|
+
keyType: "ows",
|
|
108
|
+
owsWalletId: created.walletId,
|
|
109
|
+
chains: ["tempo", "base"],
|
|
110
|
+
defaultChain: "base",
|
|
111
|
+
label: AUTO_IDENTITY_LABEL,
|
|
112
|
+
};
|
|
113
|
+
addWallet(entry);
|
|
114
|
+
return entry;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return createFallbackIdentityWallet();
|
|
118
|
+
}
|
|
119
|
+
|
|
83
120
|
async function ensureIdentityWallet(): Promise<WalletEntry> {
|
|
84
121
|
const wallets = preferredWallets();
|
|
85
122
|
const existing = wallets.find((wallet) => walletSupportsEvm(wallet) || walletSupportsSolana(wallet));
|
|
@@ -131,9 +168,21 @@ export async function getBaseRebatePrincipal(): Promise<string | null> {
|
|
|
131
168
|
return (await principalForChain("base")) ?? principalForChain("tempo");
|
|
132
169
|
}
|
|
133
170
|
|
|
171
|
+
export async function ensureBaseRebatePrincipal(): Promise<string> {
|
|
172
|
+
const existing = await getBaseRebatePrincipal();
|
|
173
|
+
if (existing) return existing;
|
|
174
|
+
|
|
175
|
+
const wallet = await ensureEvmIdentityWallet();
|
|
176
|
+
const principal = await walletPrincipal(wallet);
|
|
177
|
+
if (!principal || !principal.startsWith(`did:pkh:eip155:${BASE_CHAIN_ID}:`)) {
|
|
178
|
+
throw new Error("Could not derive a Base rebate principal from the configured identity wallet.");
|
|
179
|
+
}
|
|
180
|
+
return principal;
|
|
181
|
+
}
|
|
182
|
+
|
|
134
183
|
export async function getConsumerPrincipalForMethod(method?: string): Promise<string | null> {
|
|
135
184
|
if (!method || method === "card" || method === "link") {
|
|
136
|
-
return
|
|
185
|
+
return getBaseRebatePrincipal();
|
|
137
186
|
}
|
|
138
187
|
|
|
139
188
|
const resolved = resolveWalletAndChain(method);
|
|
@@ -158,6 +207,10 @@ export async function ensureConsumerPrincipalForMethod(method?: string): Promise
|
|
|
158
207
|
const existing = await getConsumerPrincipalForMethod(method);
|
|
159
208
|
if (existing) return existing;
|
|
160
209
|
|
|
210
|
+
if (!method || method === "card" || method === "link") {
|
|
211
|
+
return ensureBaseRebatePrincipal();
|
|
212
|
+
}
|
|
213
|
+
|
|
161
214
|
if (method && method !== "card" && method !== "link") {
|
|
162
215
|
throw new Error(
|
|
163
216
|
`Could not derive a consumer principal for payment method "${method}". ` +
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { registerWalletTools } from "./tools/wallet.js";
|
|
|
14
14
|
import { registerFavoriteTools } from "./tools/favorites.js";
|
|
15
15
|
import { registerTipTools } from "./tools/tip.js";
|
|
16
16
|
import { registerPassTools } from "./tools/passes.js";
|
|
17
|
+
import { registerRebateTools } from "./tools/rebates.js";
|
|
17
18
|
import { registerUploadTools } from "./tools/upload.js";
|
|
18
19
|
import { registerProbeTools } from "./tools/probe.js";
|
|
19
20
|
import { registerProviderTools } from "./tools/providers.js";
|
|
@@ -71,6 +72,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
71
72
|
"",
|
|
72
73
|
"WALLET HYGIENE:",
|
|
73
74
|
"- wallet_status shows per-chain USDC balance and the active network (mainnet vs testnet).",
|
|
75
|
+
"- rebate_status shows accrued, pending, paid, and blocked consumer rebates plus recent payout transactions.",
|
|
74
76
|
"- To set up payments: wallet_setup({ action: \"start\" }). Link card/bank is recommended for most users.",
|
|
75
77
|
"- To create or import a crypto wallet directly: wallet_setup({ action: \"create\" }) or { action: \"import\", key }.",
|
|
76
78
|
"- NEVER delete or rotate keys programmatically. Direct users to edit ~/.agentwonderland/config.json or ~/.ows/ manually.",
|
|
@@ -89,6 +91,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
89
91
|
registerFavoriteTools(server);
|
|
90
92
|
registerTipTools(server);
|
|
91
93
|
registerPassTools(server);
|
|
94
|
+
registerRebateTools(server);
|
|
92
95
|
registerUploadTools(server);
|
|
93
96
|
registerProbeTools(server);
|
|
94
97
|
registerProviderTools(server);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
type ToolResult = {
|
|
5
|
+
content: Array<{ type: "text"; text: string }>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const { mockApiGet } = vi.hoisted(() => ({
|
|
9
|
+
mockApiGet: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("../../core/api-client.js", () => ({
|
|
13
|
+
apiGet: (...args: unknown[]) => mockApiGet(...args),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
async function getRebateStatusTool(): Promise<(args: Record<string, unknown>) => Promise<ToolResult>> {
|
|
17
|
+
const tools = new Map<string, (args: Record<string, unknown>) => Promise<ToolResult>>();
|
|
18
|
+
const server = {
|
|
19
|
+
tool: (
|
|
20
|
+
name: string,
|
|
21
|
+
_description: string,
|
|
22
|
+
_schema: unknown,
|
|
23
|
+
handler: (args: Record<string, unknown>) => Promise<ToolResult>,
|
|
24
|
+
) => {
|
|
25
|
+
tools.set(name, handler);
|
|
26
|
+
},
|
|
27
|
+
} as unknown as McpServer;
|
|
28
|
+
|
|
29
|
+
const { registerRebateTools } = await import("../rebates.js");
|
|
30
|
+
registerRebateTools(server);
|
|
31
|
+
|
|
32
|
+
const tool = tools.get("rebate_status");
|
|
33
|
+
if (!tool) throw new Error("rebate_status tool was not registered");
|
|
34
|
+
return tool;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function flattenText(result: ToolResult): string {
|
|
38
|
+
return result.content.map((item) => item.text).join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("rebate_status tool", () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.resetModules();
|
|
44
|
+
mockApiGet.mockReset();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("formats rebate balances and recent payouts", async () => {
|
|
48
|
+
mockApiGet.mockResolvedValueOnce({
|
|
49
|
+
consumer_principal: "did:pkh:eip155:8453:0xabc",
|
|
50
|
+
principal_type: "evm",
|
|
51
|
+
principal_value: "0xabc",
|
|
52
|
+
payout_address: "0xabc",
|
|
53
|
+
payout_chain: "base",
|
|
54
|
+
auto_payout_threshold_usd: "5.000000",
|
|
55
|
+
totals: {
|
|
56
|
+
lifetime_earned_usd: "2.500000",
|
|
57
|
+
pending_usd: "1.250000",
|
|
58
|
+
paid_usd: "1.000000",
|
|
59
|
+
blocked_usd: "0.250000",
|
|
60
|
+
total_count: 5,
|
|
61
|
+
pending_count: 2,
|
|
62
|
+
paid_count: 2,
|
|
63
|
+
blocked_count: 1,
|
|
64
|
+
},
|
|
65
|
+
by_source: [
|
|
66
|
+
{ source_type: "job_execution", status: "earned", count: 2, rebate_usd: "1.250000" },
|
|
67
|
+
],
|
|
68
|
+
recent_payouts: [
|
|
69
|
+
{
|
|
70
|
+
id: "payout-1",
|
|
71
|
+
status: "completed",
|
|
72
|
+
amount_usd: "1.000000",
|
|
73
|
+
earning_count: 2,
|
|
74
|
+
tx_hash: "0xhash",
|
|
75
|
+
processed_at: "2026-05-09T12:00:00.000Z",
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const tool = await getRebateStatusTool();
|
|
81
|
+
const result = await tool({});
|
|
82
|
+
const text = flattenText(result);
|
|
83
|
+
|
|
84
|
+
expect(mockApiGet).toHaveBeenCalledWith("/rebates/status", { ensureConsumerPrincipal: true });
|
|
85
|
+
expect(text).toContain("Lifetime earned: $2.5000 across 5 rebate(s)");
|
|
86
|
+
expect(text).toContain("Pending: $1.2500 across 2 rebate(s)");
|
|
87
|
+
expect(text).toContain("Auto payout: $3.7500 until the $5.0000 threshold");
|
|
88
|
+
expect(text).toContain("job execution / earned: $1.2500 (2)");
|
|
89
|
+
expect(text).toContain("completed: $1.0000 (2) tx=0xhash");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -173,4 +173,37 @@ describe("run_agent MCP tool", () => {
|
|
|
173
173
|
expect(text).toContain("Paid: $0.01 via card");
|
|
174
174
|
expect(text).toContain("Job ID: job-1");
|
|
175
175
|
});
|
|
176
|
+
|
|
177
|
+
it("returns Link approval instructions without wrapping them as an execution error", async () => {
|
|
178
|
+
mockGetConfiguredMethods.mockReturnValue(["link"]);
|
|
179
|
+
mockGetCompatiblePaymentMethods.mockReturnValue(["link"]);
|
|
180
|
+
mockApiPostWithPayment.mockRejectedValueOnce(
|
|
181
|
+
new Error([
|
|
182
|
+
"Link approval required.",
|
|
183
|
+
"The agent has not run yet and no charge has been captured.",
|
|
184
|
+
"Approve this spend request in Link: https://link.example/approve/lsrq_test",
|
|
185
|
+
"After approving, rerun the same tool call with confirmed: true.",
|
|
186
|
+
].join("\n")),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const { registerRunTools } = await import("../run.js");
|
|
190
|
+
const harness = makeServerHarness();
|
|
191
|
+
registerRunTools(harness.server as never);
|
|
192
|
+
|
|
193
|
+
const runAgent = harness.handlers.get("run_agent");
|
|
194
|
+
expect(runAgent).toBeDefined();
|
|
195
|
+
|
|
196
|
+
const result = await runAgent!({
|
|
197
|
+
agent_id: selectedAgent.id,
|
|
198
|
+
input: { text: "hello", target_language: "es" },
|
|
199
|
+
pay_with: "link",
|
|
200
|
+
confirmed: true,
|
|
201
|
+
});
|
|
202
|
+
const text = flattenToolText(result);
|
|
203
|
+
|
|
204
|
+
expect(text).toContain("Link approval required.");
|
|
205
|
+
expect(text).toContain("https://link.example/approve/lsrq_test");
|
|
206
|
+
expect(text).not.toContain("Error: Link approval required.");
|
|
207
|
+
expect(mockRecordSpend).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
176
209
|
});
|
|
@@ -175,6 +175,7 @@ vi.mock("../../core/ows-adapter.js", () => ({
|
|
|
175
175
|
|
|
176
176
|
vi.mock("../../core/principal.js", () => ({
|
|
177
177
|
ensureConsumerPrincipal: async () => state.consumerPrincipal,
|
|
178
|
+
ensureConsumerPrincipalForMethod: async () => state.consumerPrincipal,
|
|
178
179
|
getConsumerPrincipal: async () => state.consumerPrincipal,
|
|
179
180
|
}));
|
|
180
181
|
|
package/src/tools/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export { registerWalletTools } from "./wallet.js";
|
|
|
8
8
|
export { registerFavoriteTools } from "./favorites.js";
|
|
9
9
|
export { registerTipTools } from "./tip.js";
|
|
10
10
|
export { registerPassTools } from "./passes.js";
|
|
11
|
+
export { registerRebateTools } from "./rebates.js";
|
|
11
12
|
export { registerUploadTools } from "./upload.js";
|
|
12
13
|
export { registerProbeTools } from "./probe.js";
|
|
13
14
|
export { registerProviderTools } from "./providers.js";
|