@keeperhub/wallet 0.1.11 → 0.1.13
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/README.md +21 -5
- package/bin/keeperhub-wallet-mcp.js +21 -0
- package/dist/cli.cjs +562 -165
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +576 -164
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +573 -202
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +71 -245
- package/dist/index.d.ts +71 -245
- package/dist/index.js +587 -201
- package/dist/index.js.map +1 -1
- package/dist/mcp-server.cjs +1305 -0
- package/dist/mcp-server.cjs.map +1 -0
- package/dist/mcp-server.d.cts +54 -0
- package/dist/mcp-server.d.ts +54 -0
- package/dist/mcp-server.js +1283 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/payment-signer-CyeRXcX2.d.cts +236 -0
- package/dist/payment-signer-CyeRXcX2.d.ts +236 -0
- package/package.json +57 -54
- package/skill/keeperhub-wallet.skill.md +16 -9
|
@@ -0,0 +1,1283 @@
|
|
|
1
|
+
// src/mcp-server.ts
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { dirname as dirname3, join as join3 } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
// src/balance.ts
|
|
10
|
+
import {
|
|
11
|
+
createPublicClient,
|
|
12
|
+
erc20Abi,
|
|
13
|
+
formatUnits,
|
|
14
|
+
http
|
|
15
|
+
} from "viem";
|
|
16
|
+
|
|
17
|
+
// src/chains.ts
|
|
18
|
+
import { defineChain } from "viem";
|
|
19
|
+
import { base } from "viem/chains";
|
|
20
|
+
var tempo = defineChain({
|
|
21
|
+
id: 4217,
|
|
22
|
+
name: "Tempo",
|
|
23
|
+
nativeCurrency: { decimals: 18, name: "Ether", symbol: "ETH" },
|
|
24
|
+
rpcUrls: {
|
|
25
|
+
default: {
|
|
26
|
+
http: [process.env.TEMPO_RPC_URL ?? "https://rpc.tempo.xyz"]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
blockExplorers: {
|
|
30
|
+
default: { name: "Tempo Explorer", url: "https://explorer.tempo.xyz" }
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
var BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
|
34
|
+
var TEMPO_USDC_E = "0x20c000000000000000000000b9537d11c60e8b50";
|
|
35
|
+
|
|
36
|
+
// src/balance.ts
|
|
37
|
+
var USDC_DECIMALS = 6;
|
|
38
|
+
async function checkBalance(wallet, opts = {}) {
|
|
39
|
+
const baseClient = opts.baseClient ?? createPublicClient({
|
|
40
|
+
chain: base,
|
|
41
|
+
transport: http()
|
|
42
|
+
});
|
|
43
|
+
const tempoClient = opts.tempoClient ?? createPublicClient({
|
|
44
|
+
chain: tempo,
|
|
45
|
+
transport: http()
|
|
46
|
+
});
|
|
47
|
+
const [baseRaw, tempoRaw] = await Promise.all([
|
|
48
|
+
baseClient.readContract({
|
|
49
|
+
address: BASE_USDC,
|
|
50
|
+
abi: erc20Abi,
|
|
51
|
+
functionName: "balanceOf",
|
|
52
|
+
args: [wallet.walletAddress]
|
|
53
|
+
}),
|
|
54
|
+
tempoClient.readContract({
|
|
55
|
+
address: TEMPO_USDC_E,
|
|
56
|
+
abi: erc20Abi,
|
|
57
|
+
functionName: "balanceOf",
|
|
58
|
+
args: [wallet.walletAddress]
|
|
59
|
+
})
|
|
60
|
+
]);
|
|
61
|
+
return {
|
|
62
|
+
base: {
|
|
63
|
+
chain: "base",
|
|
64
|
+
token: "USDC",
|
|
65
|
+
amount: formatUnits(baseRaw, USDC_DECIMALS),
|
|
66
|
+
address: wallet.walletAddress
|
|
67
|
+
},
|
|
68
|
+
tempo: {
|
|
69
|
+
chain: "tempo",
|
|
70
|
+
token: "USDC.e",
|
|
71
|
+
amount: formatUnits(tempoRaw, USDC_DECIMALS),
|
|
72
|
+
address: wallet.walletAddress
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/fund.ts
|
|
78
|
+
var EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
79
|
+
var COINBASE_HOST = "pay.coinbase.com";
|
|
80
|
+
var COINBASE_PATH = "/buy/select-asset";
|
|
81
|
+
function fund(walletAddress) {
|
|
82
|
+
if (!EVM_ADDRESS_RE.test(walletAddress)) {
|
|
83
|
+
throw new Error(`Invalid EVM wallet address: ${walletAddress}`);
|
|
84
|
+
}
|
|
85
|
+
const params = new URLSearchParams({
|
|
86
|
+
defaultNetwork: "base",
|
|
87
|
+
defaultAsset: "USDC",
|
|
88
|
+
addresses: JSON.stringify({ [walletAddress]: ["base"] }),
|
|
89
|
+
presetCryptoAmount: "5"
|
|
90
|
+
});
|
|
91
|
+
const coinbaseOnrampUrl = `https://${COINBASE_HOST}${COINBASE_PATH}?${params.toString()}`;
|
|
92
|
+
const disclaimer = "If the Coinbase page does not pre-fill, paste your address manually. For Tempo USDC.e, transfer from an exchange or another wallet to the address above -- Onramp does not support Tempo directly. Coinbase sessionToken URLs are the 2025+ canonical form; legacy query-param URLs may drop prefill on some accounts.";
|
|
93
|
+
return {
|
|
94
|
+
coinbaseOnrampUrl,
|
|
95
|
+
tempoAddress: walletAddress,
|
|
96
|
+
disclaimer
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/hmac.ts
|
|
101
|
+
import { createHash, createHmac } from "crypto";
|
|
102
|
+
function computeSignature(secret, method, path, subOrgId, body, timestamp) {
|
|
103
|
+
const bodyDigest = createHash("sha256").update(body).digest("hex");
|
|
104
|
+
const signingString = `${method}
|
|
105
|
+
${path}
|
|
106
|
+
${subOrgId}
|
|
107
|
+
${bodyDigest}
|
|
108
|
+
${timestamp}`;
|
|
109
|
+
return createHmac("sha256", secret).update(signingString).digest("hex");
|
|
110
|
+
}
|
|
111
|
+
function buildHmacHeaders(secret, method, path, subOrgId, body) {
|
|
112
|
+
const timestamp = String(Math.floor(Date.now() / 1e3));
|
|
113
|
+
const signature = computeSignature(
|
|
114
|
+
secret,
|
|
115
|
+
method,
|
|
116
|
+
path,
|
|
117
|
+
subOrgId,
|
|
118
|
+
body,
|
|
119
|
+
timestamp
|
|
120
|
+
);
|
|
121
|
+
return {
|
|
122
|
+
"X-KH-Sub-Org": subOrgId,
|
|
123
|
+
"X-KH-Timestamp": timestamp,
|
|
124
|
+
"X-KH-Signature": signature
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/mpp-detect.ts
|
|
129
|
+
var MPP_PREFIX = "Payment ";
|
|
130
|
+
function parseMppChallenge(response) {
|
|
131
|
+
const header = response.headers.get("WWW-Authenticate");
|
|
132
|
+
if (!header) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
if (!header.startsWith(MPP_PREFIX)) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const serialized = header.slice(MPP_PREFIX.length).trim();
|
|
139
|
+
if (serialized.length === 0) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return { serialized };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/payment-signer.ts
|
|
146
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
147
|
+
|
|
148
|
+
// src/types.ts
|
|
149
|
+
var KeeperHubError = class extends Error {
|
|
150
|
+
code;
|
|
151
|
+
constructor(code, message) {
|
|
152
|
+
super(message);
|
|
153
|
+
this.name = "KeeperHubError";
|
|
154
|
+
this.code = code;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
var WalletConfigMissingError = class extends Error {
|
|
158
|
+
constructor() {
|
|
159
|
+
super(
|
|
160
|
+
"Wallet config not found at ~/.keeperhub/wallet.json. Run `npx @keeperhub/wallet add` to provision."
|
|
161
|
+
);
|
|
162
|
+
this.name = "WalletConfigMissingError";
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
var WalletConfigCorruptError = class extends Error {
|
|
166
|
+
path;
|
|
167
|
+
constructor(path, reason) {
|
|
168
|
+
super(
|
|
169
|
+
`Wallet config at ${path} is unreadable: ${reason}. Repair the file by hand or delete it to re-provision a new wallet (this will abandon any funds held in the current wallet).`
|
|
170
|
+
);
|
|
171
|
+
this.name = "WalletConfigCorruptError";
|
|
172
|
+
this.path = path;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// src/client.ts
|
|
177
|
+
var TRAILING_SLASH = /\/$/;
|
|
178
|
+
function defaultCodeForStatus(status) {
|
|
179
|
+
if (status === 401) {
|
|
180
|
+
return "HMAC_INVALID";
|
|
181
|
+
}
|
|
182
|
+
if (status === 403) {
|
|
183
|
+
return "POLICY_BLOCKED";
|
|
184
|
+
}
|
|
185
|
+
if (status === 404) {
|
|
186
|
+
return "NOT_FOUND";
|
|
187
|
+
}
|
|
188
|
+
if (status === 502) {
|
|
189
|
+
return "TURNKEY_UPSTREAM";
|
|
190
|
+
}
|
|
191
|
+
return `HTTP_${status}`;
|
|
192
|
+
}
|
|
193
|
+
var KeeperHubClient = class {
|
|
194
|
+
baseUrl;
|
|
195
|
+
fetchImpl;
|
|
196
|
+
wallet;
|
|
197
|
+
constructor(wallet, opts = {}) {
|
|
198
|
+
this.wallet = wallet;
|
|
199
|
+
const envBase = process.env.KEEPERHUB_API_URL;
|
|
200
|
+
this.baseUrl = (opts.baseUrl ?? envBase ?? "https://app.keeperhub.com").replace(TRAILING_SLASH, "");
|
|
201
|
+
this.fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* HMAC-signed POST/GET to any /api/agentic-wallet/* route except
|
|
205
|
+
* /provision. Path MUST start with a leading slash. Body is
|
|
206
|
+
* JSON.stringify'd (or the empty string for GET).
|
|
207
|
+
*
|
|
208
|
+
* Error mapping: non-2xx/non-202 surface as `KeeperHubError(code,
|
|
209
|
+
* message)` where `code` is the server-supplied field or the default
|
|
210
|
+
* taxonomy (`HMAC_INVALID`, `POLICY_BLOCKED`, `NOT_FOUND`,
|
|
211
|
+
* `TURNKEY_UPSTREAM`, `HTTP_<status>`). 202 ask-tier surfaces as an
|
|
212
|
+
* AskTierResponse envelope.
|
|
213
|
+
*/
|
|
214
|
+
async request(method, path, body) {
|
|
215
|
+
const bodyStr = body === void 0 ? "" : JSON.stringify(body);
|
|
216
|
+
const hmacHeaders = buildHmacHeaders(
|
|
217
|
+
this.wallet.hmacSecret,
|
|
218
|
+
method,
|
|
219
|
+
path,
|
|
220
|
+
this.wallet.subOrgId,
|
|
221
|
+
bodyStr
|
|
222
|
+
);
|
|
223
|
+
const headers = method === "POST" ? { ...hmacHeaders, "content-type": "application/json" } : { ...hmacHeaders };
|
|
224
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
225
|
+
method,
|
|
226
|
+
headers,
|
|
227
|
+
body: method === "POST" ? bodyStr : void 0
|
|
228
|
+
});
|
|
229
|
+
if (response.status === 202) {
|
|
230
|
+
const data = await response.json();
|
|
231
|
+
return { _status: 202, approvalRequestId: data.approvalRequestId };
|
|
232
|
+
}
|
|
233
|
+
if (!response.ok) {
|
|
234
|
+
let code = "UNKNOWN";
|
|
235
|
+
let message = `HTTP ${response.status}`;
|
|
236
|
+
try {
|
|
237
|
+
const data = await response.json();
|
|
238
|
+
code = data.code ?? defaultCodeForStatus(response.status);
|
|
239
|
+
message = data.error ?? message;
|
|
240
|
+
} catch {
|
|
241
|
+
}
|
|
242
|
+
throw new KeeperHubError(code, message);
|
|
243
|
+
}
|
|
244
|
+
return await response.json();
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// src/storage.ts
|
|
249
|
+
import { randomBytes } from "crypto";
|
|
250
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
251
|
+
import { homedir } from "os";
|
|
252
|
+
import { dirname, join } from "path";
|
|
253
|
+
async function readWalletConfig() {
|
|
254
|
+
const walletPath = join(homedir(), ".keeperhub", "wallet.json");
|
|
255
|
+
let raw;
|
|
256
|
+
try {
|
|
257
|
+
raw = await readFile(walletPath, "utf-8");
|
|
258
|
+
} catch (err) {
|
|
259
|
+
if (err.code === "ENOENT") {
|
|
260
|
+
throw new WalletConfigMissingError();
|
|
261
|
+
}
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
let parsed;
|
|
265
|
+
try {
|
|
266
|
+
parsed = JSON.parse(raw);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
269
|
+
throw new WalletConfigCorruptError(walletPath, reason);
|
|
270
|
+
}
|
|
271
|
+
if (!(parsed.subOrgId && parsed.walletAddress && parsed.hmacSecret)) {
|
|
272
|
+
throw new WalletConfigCorruptError(walletPath, "missing required fields");
|
|
273
|
+
}
|
|
274
|
+
return parsed;
|
|
275
|
+
}
|
|
276
|
+
async function writeWalletConfig(config) {
|
|
277
|
+
const walletPath = join(homedir(), ".keeperhub", "wallet.json");
|
|
278
|
+
await mkdir(dirname(walletPath), { recursive: true, mode: 448 });
|
|
279
|
+
const suffix = randomBytes(8).toString("hex");
|
|
280
|
+
const tmpPath = `${walletPath}.${process.pid}.${suffix}.tmp`;
|
|
281
|
+
await writeFile(tmpPath, JSON.stringify(config, null, 2), { mode: 384 });
|
|
282
|
+
await chmod(tmpPath, 384);
|
|
283
|
+
await rename(tmpPath, walletPath);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// src/workflow-slug.ts
|
|
287
|
+
var KEEPERHUB_WORKFLOW_RE = /\/api\/mcp\/workflows\/([a-zA-Z0-9_-]+)\/call(?:\/?)(?:\?|$|#)/;
|
|
288
|
+
function extractKeeperHubWorkflowSlug(url) {
|
|
289
|
+
if (!url || url.length === 0) {
|
|
290
|
+
return { ok: false, reason: "EMPTY_URL" };
|
|
291
|
+
}
|
|
292
|
+
const match = KEEPERHUB_WORKFLOW_RE.exec(url);
|
|
293
|
+
if (!match || !match[1]) {
|
|
294
|
+
return { ok: false, reason: "URL_PATTERN_MISMATCH" };
|
|
295
|
+
}
|
|
296
|
+
return { ok: true, slug: match[1] };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/x402-detect.ts
|
|
300
|
+
function isX402Shape(value) {
|
|
301
|
+
if (typeof value !== "object" || value === null) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
const v = value;
|
|
305
|
+
if (v.x402Version !== 2) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
if (!Array.isArray(v.accepts) || v.accepts.length === 0) {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
const first = v.accepts[0];
|
|
312
|
+
if (first.scheme !== "exact") {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
async function parseX402Challenge(response) {
|
|
318
|
+
const headerB64 = response.headers.get("PAYMENT-REQUIRED");
|
|
319
|
+
if (headerB64) {
|
|
320
|
+
try {
|
|
321
|
+
const decoded = JSON.parse(
|
|
322
|
+
Buffer.from(headerB64, "base64").toString("utf-8")
|
|
323
|
+
);
|
|
324
|
+
if (isX402Shape(decoded)) {
|
|
325
|
+
return decoded;
|
|
326
|
+
}
|
|
327
|
+
} catch {
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const clone = response.clone();
|
|
332
|
+
const body = await clone.json();
|
|
333
|
+
if (isX402Shape(body)) {
|
|
334
|
+
return body;
|
|
335
|
+
}
|
|
336
|
+
} catch {
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/payment-signer.ts
|
|
342
|
+
var TEMPO_CHAIN_ID = 4217;
|
|
343
|
+
var DEFAULT_APPROVAL_POLL = { intervalMs: 2e3, maxAttempts: 150 };
|
|
344
|
+
var VALID_AFTER_PAST_SLACK_SECONDS = 60;
|
|
345
|
+
var NONCE_BYTES = 32;
|
|
346
|
+
async function sleep(ms) {
|
|
347
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
348
|
+
}
|
|
349
|
+
function selectProtocol(x402, mpp, hint) {
|
|
350
|
+
const h = hint ?? "auto";
|
|
351
|
+
if (h === "x402") {
|
|
352
|
+
if (!x402) {
|
|
353
|
+
throw new KeeperHubError(
|
|
354
|
+
"X402_NOT_OFFERED",
|
|
355
|
+
"x402 is not offered by this endpoint"
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return "x402";
|
|
359
|
+
}
|
|
360
|
+
if (h === "mpp") {
|
|
361
|
+
if (!mpp) {
|
|
362
|
+
throw new KeeperHubError(
|
|
363
|
+
"MPP_NOT_OFFERED",
|
|
364
|
+
"mpp is not offered by this endpoint"
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
return "mpp";
|
|
368
|
+
}
|
|
369
|
+
if (x402) return "x402";
|
|
370
|
+
if (mpp) return "mpp";
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
function createPaymentSigner(opts = {}) {
|
|
374
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
375
|
+
const walletLoader = opts.walletLoader ?? readWalletConfig;
|
|
376
|
+
const clientFactory = opts.clientFactory ?? ((wallet) => new KeeperHubClient(wallet, { fetch: fetchImpl }));
|
|
377
|
+
const pollCfg = opts.approval ?? DEFAULT_APPROVAL_POLL;
|
|
378
|
+
async function signOrPoll(client, body) {
|
|
379
|
+
const result = await client.request(
|
|
380
|
+
"POST",
|
|
381
|
+
"/api/agentic-wallet/sign",
|
|
382
|
+
body
|
|
383
|
+
);
|
|
384
|
+
if ("_status" in result && result._status === 202) {
|
|
385
|
+
const approvalRequestId = result.approvalRequestId;
|
|
386
|
+
for (let attempt = 0; attempt < pollCfg.maxAttempts; attempt++) {
|
|
387
|
+
await sleep(pollCfg.intervalMs);
|
|
388
|
+
const status = await client.request(
|
|
389
|
+
"GET",
|
|
390
|
+
`/api/agentic-wallet/approval-request/${approvalRequestId}`
|
|
391
|
+
);
|
|
392
|
+
if ("status" in status && status.status !== "pending") {
|
|
393
|
+
if (status.status === "rejected") {
|
|
394
|
+
throw new KeeperHubError(
|
|
395
|
+
"APPROVAL_REJECTED",
|
|
396
|
+
"User rejected the operation"
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
const retry = await client.request(
|
|
400
|
+
"POST",
|
|
401
|
+
"/api/agentic-wallet/sign",
|
|
402
|
+
body
|
|
403
|
+
);
|
|
404
|
+
if ("_status" in retry) {
|
|
405
|
+
throw new KeeperHubError(
|
|
406
|
+
"APPROVAL_LOOP",
|
|
407
|
+
"Sign returned 202 again after approval"
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
return retry.signature;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
throw new KeeperHubError(
|
|
414
|
+
"APPROVAL_TIMEOUT",
|
|
415
|
+
`No human response within ${pollCfg.intervalMs * pollCfg.maxAttempts}ms`
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
return result.signature;
|
|
419
|
+
}
|
|
420
|
+
async function payViaMpp(response, mpp, wallet, retry) {
|
|
421
|
+
const slug = extractKeeperHubWorkflowSlug(response.url);
|
|
422
|
+
if (!slug.ok) {
|
|
423
|
+
throw new KeeperHubError(
|
|
424
|
+
"UNSUPPORTED_RECIPIENT",
|
|
425
|
+
`This wallet only signs payments for KeeperHub workflows. The 402 came from a URL that does not match /api/mcp/workflows/<slug>/call (reason: ${slug.reason}). See KEEP-311 for generic x402 support.`
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
const client = clientFactory(wallet);
|
|
429
|
+
const signature = await signOrPoll(client, {
|
|
430
|
+
chain: "tempo",
|
|
431
|
+
workflowSlug: slug.slug,
|
|
432
|
+
paymentChallenge: {
|
|
433
|
+
kind: "mpp",
|
|
434
|
+
serialized: mpp.serialized,
|
|
435
|
+
chainId: TEMPO_CHAIN_ID
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
const headers = new Headers(retry?.headers);
|
|
439
|
+
headers.set("Authorization", `Payment ${signature}`);
|
|
440
|
+
return fetchImpl(response.url, {
|
|
441
|
+
method: retry?.method ?? "POST",
|
|
442
|
+
headers,
|
|
443
|
+
body: retry?.body ?? void 0
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
async function payViaX402(response, x402, wallet, retry) {
|
|
447
|
+
const accept = x402.accepts[0];
|
|
448
|
+
if (!accept) {
|
|
449
|
+
throw new KeeperHubError(
|
|
450
|
+
"X402_EMPTY_ACCEPTS",
|
|
451
|
+
"x402 challenge has no accepts entries"
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
const slug = extractKeeperHubWorkflowSlug(x402.resource.url || response.url);
|
|
455
|
+
if (!slug.ok) {
|
|
456
|
+
throw new KeeperHubError(
|
|
457
|
+
"UNSUPPORTED_RECIPIENT",
|
|
458
|
+
`This wallet only signs payments for KeeperHub workflows. The 402 came from a URL that does not match /api/mcp/workflows/<slug>/call (reason: ${slug.reason}). See KEEP-311 for generic x402 support.`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
462
|
+
const validAfter = now - VALID_AFTER_PAST_SLACK_SECONDS;
|
|
463
|
+
const validBefore = now + accept.maxTimeoutSeconds;
|
|
464
|
+
const nonce = `0x${randomBytes2(NONCE_BYTES).toString("hex")}`;
|
|
465
|
+
const client = clientFactory(wallet);
|
|
466
|
+
const signature = await signOrPoll(client, {
|
|
467
|
+
chain: "base",
|
|
468
|
+
workflowSlug: slug.slug,
|
|
469
|
+
paymentChallenge: {
|
|
470
|
+
kind: "x402",
|
|
471
|
+
payTo: accept.payTo,
|
|
472
|
+
amount: accept.amount,
|
|
473
|
+
validAfter,
|
|
474
|
+
validBefore,
|
|
475
|
+
nonce
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
const paymentSigPayload = {
|
|
479
|
+
x402Version: 2,
|
|
480
|
+
accepted: accept,
|
|
481
|
+
payload: {
|
|
482
|
+
signature,
|
|
483
|
+
authorization: {
|
|
484
|
+
from: wallet.walletAddress,
|
|
485
|
+
to: accept.payTo,
|
|
486
|
+
value: accept.amount,
|
|
487
|
+
validAfter: String(validAfter),
|
|
488
|
+
validBefore: String(validBefore),
|
|
489
|
+
nonce
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
const paymentSigHeader = Buffer.from(
|
|
494
|
+
JSON.stringify(paymentSigPayload)
|
|
495
|
+
).toString("base64");
|
|
496
|
+
const retryUrl = x402.resource.url || response.url;
|
|
497
|
+
const headers = new Headers(retry?.headers);
|
|
498
|
+
headers.set("PAYMENT-SIGNATURE", paymentSigHeader);
|
|
499
|
+
return fetchImpl(retryUrl, {
|
|
500
|
+
method: retry?.method ?? "POST",
|
|
501
|
+
headers,
|
|
502
|
+
body: retry?.body ?? void 0
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
async function pay(response, options) {
|
|
506
|
+
if (response.status !== 402) {
|
|
507
|
+
return response;
|
|
508
|
+
}
|
|
509
|
+
const x402 = await parseX402Challenge(response);
|
|
510
|
+
const mpp = parseMppChallenge(response);
|
|
511
|
+
if (!(x402 || mpp)) {
|
|
512
|
+
return response;
|
|
513
|
+
}
|
|
514
|
+
const wallet = await walletLoader();
|
|
515
|
+
const protocol = selectProtocol(x402, mpp, options?.paymentHint);
|
|
516
|
+
if (protocol === "x402") {
|
|
517
|
+
return payViaX402(response, x402, wallet, options);
|
|
518
|
+
}
|
|
519
|
+
if (protocol === "mpp") {
|
|
520
|
+
return payViaMpp(response, mpp, wallet, options);
|
|
521
|
+
}
|
|
522
|
+
return response;
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
pay,
|
|
526
|
+
async fetch(input, init) {
|
|
527
|
+
const first = await fetchImpl(input, init);
|
|
528
|
+
if (first.status !== 402) {
|
|
529
|
+
return first;
|
|
530
|
+
}
|
|
531
|
+
return pay(first, {
|
|
532
|
+
body: init?.body ?? void 0,
|
|
533
|
+
headers: init?.headers,
|
|
534
|
+
method: init?.method,
|
|
535
|
+
paymentHint: init?.paymentHint
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
var paymentSigner = createPaymentSigner();
|
|
541
|
+
|
|
542
|
+
// src/provision.ts
|
|
543
|
+
var TRAILING_SLASH2 = /\/$/;
|
|
544
|
+
var WALLET_ADDRESS_PATTERN = /^0x[a-fA-F0-9]{40}$/;
|
|
545
|
+
var ProvisionResponseInvalidError = class extends Error {
|
|
546
|
+
code = "PROVISION_RESPONSE_INVALID";
|
|
547
|
+
constructor(message) {
|
|
548
|
+
super(message);
|
|
549
|
+
this.name = "ProvisionResponseInvalidError";
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
var ProvisionHttpError = class extends Error {
|
|
553
|
+
code = "PROVISION_HTTP_ERROR";
|
|
554
|
+
status;
|
|
555
|
+
body;
|
|
556
|
+
constructor(status, body) {
|
|
557
|
+
super(`provision failed: HTTP ${status}: ${body}`);
|
|
558
|
+
this.name = "ProvisionHttpError";
|
|
559
|
+
this.status = status;
|
|
560
|
+
this.body = body;
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
function resolveBaseUrl(override) {
|
|
564
|
+
const candidate = override ?? process.env.KEEPERHUB_API_URL ?? "https://app.keeperhub.com";
|
|
565
|
+
return candidate.replace(TRAILING_SLASH2, "");
|
|
566
|
+
}
|
|
567
|
+
function isNonEmptyString(value) {
|
|
568
|
+
return typeof value === "string" && value.length > 0;
|
|
569
|
+
}
|
|
570
|
+
function validateProvisionResponse(data) {
|
|
571
|
+
if (typeof data !== "object" || data === null) {
|
|
572
|
+
throw new ProvisionResponseInvalidError(
|
|
573
|
+
"provision response is not an object"
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
const { subOrgId, walletAddress, hmacSecret } = data;
|
|
577
|
+
if (!(isNonEmptyString(subOrgId) && isNonEmptyString(walletAddress) && isNonEmptyString(hmacSecret))) {
|
|
578
|
+
throw new ProvisionResponseInvalidError(
|
|
579
|
+
"provision response missing subOrgId, walletAddress, or hmacSecret"
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
if (!WALLET_ADDRESS_PATTERN.test(walletAddress)) {
|
|
583
|
+
throw new ProvisionResponseInvalidError(
|
|
584
|
+
`provision response walletAddress is not a valid 0x-prefixed 40-hex address: ${walletAddress}`
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
subOrgId,
|
|
589
|
+
walletAddress,
|
|
590
|
+
hmacSecret
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
async function provisionWallet(options = {}) {
|
|
594
|
+
const baseUrl = resolveBaseUrl(options.baseUrl);
|
|
595
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
596
|
+
const response = await fetchImpl(`${baseUrl}/api/agentic-wallet/provision`, {
|
|
597
|
+
method: "POST",
|
|
598
|
+
headers: { "content-type": "application/json" },
|
|
599
|
+
body: "{}",
|
|
600
|
+
signal: AbortSignal.timeout(3e4)
|
|
601
|
+
});
|
|
602
|
+
if (!response.ok) {
|
|
603
|
+
const text = await response.text();
|
|
604
|
+
throw new ProvisionHttpError(response.status, text);
|
|
605
|
+
}
|
|
606
|
+
const raw = await response.json();
|
|
607
|
+
const data = validateProvisionResponse(raw);
|
|
608
|
+
await writeWalletConfig(data);
|
|
609
|
+
return data;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// src/safety-config.ts
|
|
613
|
+
import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
614
|
+
import { homedir as homedir2 } from "os";
|
|
615
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
616
|
+
var DEFAULT_SAFETY_CONFIG = {
|
|
617
|
+
auto_approve_max_usd: 5,
|
|
618
|
+
ask_threshold_usd: 50,
|
|
619
|
+
block_threshold_usd: 100,
|
|
620
|
+
allowlisted_contracts: [
|
|
621
|
+
"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
622
|
+
// Base USDC
|
|
623
|
+
"0x20c000000000000000000000b9537d11c60e8b50"
|
|
624
|
+
// Tempo USDC.e
|
|
625
|
+
]
|
|
626
|
+
};
|
|
627
|
+
function getSafetyPath() {
|
|
628
|
+
return join2(homedir2(), ".keeperhub", "safety.json");
|
|
629
|
+
}
|
|
630
|
+
async function loadSafetyConfig() {
|
|
631
|
+
const path = getSafetyPath();
|
|
632
|
+
let raw;
|
|
633
|
+
try {
|
|
634
|
+
raw = await readFile2(path, "utf-8");
|
|
635
|
+
} catch (err) {
|
|
636
|
+
if (err.code === "ENOENT") {
|
|
637
|
+
await mkdir2(dirname2(path), { recursive: true, mode: 448 });
|
|
638
|
+
await writeFile2(path, JSON.stringify(DEFAULT_SAFETY_CONFIG, null, 2), {
|
|
639
|
+
mode: 420
|
|
640
|
+
});
|
|
641
|
+
await chmod2(path, 420);
|
|
642
|
+
return DEFAULT_SAFETY_CONFIG;
|
|
643
|
+
}
|
|
644
|
+
throw err;
|
|
645
|
+
}
|
|
646
|
+
const parsed = JSON.parse(raw);
|
|
647
|
+
return validateAndMerge(parsed);
|
|
648
|
+
}
|
|
649
|
+
var THRESHOLD_KEYS = [
|
|
650
|
+
"auto_approve_max_usd",
|
|
651
|
+
"ask_threshold_usd",
|
|
652
|
+
"block_threshold_usd"
|
|
653
|
+
];
|
|
654
|
+
function validateAndMerge(partial) {
|
|
655
|
+
const merged = {
|
|
656
|
+
auto_approve_max_usd: partial.auto_approve_max_usd ?? DEFAULT_SAFETY_CONFIG.auto_approve_max_usd,
|
|
657
|
+
ask_threshold_usd: partial.ask_threshold_usd ?? DEFAULT_SAFETY_CONFIG.ask_threshold_usd,
|
|
658
|
+
block_threshold_usd: partial.block_threshold_usd ?? DEFAULT_SAFETY_CONFIG.block_threshold_usd,
|
|
659
|
+
allowlisted_contracts: partial.allowlisted_contracts ?? DEFAULT_SAFETY_CONFIG.allowlisted_contracts
|
|
660
|
+
};
|
|
661
|
+
for (const key of THRESHOLD_KEYS) {
|
|
662
|
+
const v = merged[key];
|
|
663
|
+
if (!(Number.isFinite(v) && v >= 0)) {
|
|
664
|
+
throw new Error(
|
|
665
|
+
`safety.json: ${key} must be a non-negative finite number; got ${String(v)}`
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (merged.ask_threshold_usd < merged.auto_approve_max_usd) {
|
|
670
|
+
throw new Error(
|
|
671
|
+
"safety.json: ask_threshold_usd must be >= auto_approve_max_usd"
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
if (merged.block_threshold_usd < merged.ask_threshold_usd) {
|
|
675
|
+
throw new Error(
|
|
676
|
+
"safety.json: block_threshold_usd must be >= ask_threshold_usd"
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
if (!Array.isArray(merged.allowlisted_contracts)) {
|
|
680
|
+
throw new Error("safety.json: allowlisted_contracts must be an array");
|
|
681
|
+
}
|
|
682
|
+
merged.allowlisted_contracts = merged.allowlisted_contracts.map(
|
|
683
|
+
(a) => a.toLowerCase()
|
|
684
|
+
);
|
|
685
|
+
return merged;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// src/mcp-server.ts
|
|
689
|
+
var BODY_TEXT_CAP_BYTES = 256 * 1024;
|
|
690
|
+
var USDC_DECIMALS2 = 1e6;
|
|
691
|
+
var HTTP_TIMEOUT_MS = 3e4;
|
|
692
|
+
var ACCEPT_CONTROL_CHARS_RE = (
|
|
693
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately matching control chars + Unicode separators + bidi-overrides to neutralise log-injection / hidden-text vectors before rendering upstream-supplied strings
|
|
694
|
+
/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200b-\u200f\u202a-\u202e]/g
|
|
695
|
+
);
|
|
696
|
+
var KEEPERHUB_BASE_URL_TRAILING = /\/$/;
|
|
697
|
+
function resolveKeeperhubBaseUrl() {
|
|
698
|
+
const candidate = process.env.KEEPERHUB_API_URL ?? "https://app.keeperhub.com";
|
|
699
|
+
return candidate.replace(KEEPERHUB_BASE_URL_TRAILING, "");
|
|
700
|
+
}
|
|
701
|
+
function readPackageVersion() {
|
|
702
|
+
try {
|
|
703
|
+
const here = dirname3(fileURLToPath(import.meta.url));
|
|
704
|
+
const pkgPath = join3(here, "..", "package.json");
|
|
705
|
+
const raw = readFileSync(pkgPath, "utf-8");
|
|
706
|
+
const parsed = JSON.parse(raw);
|
|
707
|
+
if (typeof parsed.version === "string" && parsed.version.length > 0) {
|
|
708
|
+
return parsed.version;
|
|
709
|
+
}
|
|
710
|
+
} catch {
|
|
711
|
+
}
|
|
712
|
+
return "0.0.0";
|
|
713
|
+
}
|
|
714
|
+
function sanitise(input) {
|
|
715
|
+
return input.replace(ACCEPT_CONTROL_CHARS_RE, "");
|
|
716
|
+
}
|
|
717
|
+
function logEvent(event, data) {
|
|
718
|
+
const entry = {
|
|
719
|
+
level: "info",
|
|
720
|
+
event,
|
|
721
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
722
|
+
...data
|
|
723
|
+
};
|
|
724
|
+
process.stderr.write(`${JSON.stringify(entry)}
|
|
725
|
+
`);
|
|
726
|
+
}
|
|
727
|
+
async function withToolLogging(toolName, fn) {
|
|
728
|
+
const startMs = Date.now();
|
|
729
|
+
logEvent("mcp.tool.called", { tool: toolName });
|
|
730
|
+
try {
|
|
731
|
+
const result = await fn();
|
|
732
|
+
logEvent("mcp.tool.completed", {
|
|
733
|
+
tool: toolName,
|
|
734
|
+
duration_ms: Date.now() - startMs,
|
|
735
|
+
success: true
|
|
736
|
+
});
|
|
737
|
+
return result;
|
|
738
|
+
} catch (error) {
|
|
739
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
740
|
+
logEvent("mcp.tool.error", {
|
|
741
|
+
tool: toolName,
|
|
742
|
+
duration_ms: Date.now() - startMs,
|
|
743
|
+
success: false,
|
|
744
|
+
error: message
|
|
745
|
+
});
|
|
746
|
+
throw error;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
function structuredError(payload) {
|
|
750
|
+
return {
|
|
751
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
752
|
+
isError: true
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
function structuredOk(payload) {
|
|
756
|
+
return {
|
|
757
|
+
content: [{ type: "text", text: JSON.stringify(payload) }]
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
function defaultDeps() {
|
|
761
|
+
return {
|
|
762
|
+
readWalletConfig,
|
|
763
|
+
provisionWallet: () => provisionWallet(),
|
|
764
|
+
loadSafetyConfig,
|
|
765
|
+
checkBalance: (wallet) => checkBalance(wallet),
|
|
766
|
+
paymentSigner,
|
|
767
|
+
fetchImpl: globalThis.fetch
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
var provisionInflight = null;
|
|
771
|
+
async function ensureWallet(deps) {
|
|
772
|
+
try {
|
|
773
|
+
const wallet = await deps.readWalletConfig();
|
|
774
|
+
return {
|
|
775
|
+
provisioned: false,
|
|
776
|
+
walletAddress: wallet.walletAddress,
|
|
777
|
+
subOrgId: wallet.subOrgId,
|
|
778
|
+
hmacSecret: wallet.hmacSecret
|
|
779
|
+
};
|
|
780
|
+
} catch (err) {
|
|
781
|
+
if (err instanceof WalletConfigCorruptError) {
|
|
782
|
+
throw err;
|
|
783
|
+
}
|
|
784
|
+
if (!(err instanceof WalletConfigMissingError)) {
|
|
785
|
+
throw err;
|
|
786
|
+
}
|
|
787
|
+
if (provisionInflight === null) {
|
|
788
|
+
provisionInflight = (async () => {
|
|
789
|
+
try {
|
|
790
|
+
const minted = await deps.provisionWallet();
|
|
791
|
+
logEvent("mcp.wallet.provisioned", {
|
|
792
|
+
walletAddress: minted.walletAddress
|
|
793
|
+
});
|
|
794
|
+
return minted;
|
|
795
|
+
} finally {
|
|
796
|
+
provisionInflight = null;
|
|
797
|
+
}
|
|
798
|
+
})();
|
|
799
|
+
}
|
|
800
|
+
const wallet = await provisionInflight;
|
|
801
|
+
return {
|
|
802
|
+
provisioned: true,
|
|
803
|
+
walletAddress: wallet.walletAddress,
|
|
804
|
+
subOrgId: wallet.subOrgId,
|
|
805
|
+
hmacSecret: wallet.hmacSecret
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
function resetProvisionInflightForTests() {
|
|
810
|
+
provisionInflight = null;
|
|
811
|
+
}
|
|
812
|
+
function microUsdcToUsd(microUsdc) {
|
|
813
|
+
return Number(microUsdc) / USDC_DECIMALS2;
|
|
814
|
+
}
|
|
815
|
+
function extractX402AmountMicro(x402) {
|
|
816
|
+
if (!x402) {
|
|
817
|
+
return null;
|
|
818
|
+
}
|
|
819
|
+
let min = null;
|
|
820
|
+
for (const accept of x402.accepts) {
|
|
821
|
+
if (!/^\d+$/.test(accept.amount)) {
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
const candidate = BigInt(accept.amount);
|
|
825
|
+
if (min === null || candidate < min) {
|
|
826
|
+
min = candidate;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return min;
|
|
830
|
+
}
|
|
831
|
+
function parseUsdcAmount(decimal) {
|
|
832
|
+
const match = /^(\d+)(?:\.(\d+))?$/.exec(decimal);
|
|
833
|
+
if (!match) {
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
const whole = match[1] ?? "0";
|
|
837
|
+
const fracRaw = match[2] ?? "";
|
|
838
|
+
const fracPadded = `${fracRaw}000000`.slice(0, 6);
|
|
839
|
+
try {
|
|
840
|
+
return BigInt(whole) * BigInt(USDC_DECIMALS2) + BigInt(fracPadded);
|
|
841
|
+
} catch {
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
function pickResponseFormat(requested, contentType) {
|
|
846
|
+
if (requested) {
|
|
847
|
+
return requested;
|
|
848
|
+
}
|
|
849
|
+
const ct = contentType.toLowerCase();
|
|
850
|
+
if (ct.startsWith("text/") || ct.includes("json") || ct.includes("xml") || ct.includes("yaml")) {
|
|
851
|
+
return "text";
|
|
852
|
+
}
|
|
853
|
+
return "base64";
|
|
854
|
+
}
|
|
855
|
+
var callWorkflowInputSchema = {
|
|
856
|
+
slug: z.string().min(1).describe("KeeperHub workflow slug"),
|
|
857
|
+
body: z.record(z.string(), z.unknown()).optional().describe("JSON body forwarded to the workflow's input schema"),
|
|
858
|
+
paymentHint: z.enum(["auto", "x402", "mpp"]).optional().describe(
|
|
859
|
+
"Payment protocol preference. 'auto' (default) prefers x402 when offered, MPP otherwise."
|
|
860
|
+
),
|
|
861
|
+
responseFormat: z.enum(["text", "base64", "json"]).optional().describe(
|
|
862
|
+
"How to render the response body. Defaults to 'text'. Non-text content-types force base64."
|
|
863
|
+
)
|
|
864
|
+
};
|
|
865
|
+
async function loadSafetyOrError(deps) {
|
|
866
|
+
try {
|
|
867
|
+
return { safety: await deps.loadSafetyConfig() };
|
|
868
|
+
} catch (err) {
|
|
869
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
870
|
+
return {
|
|
871
|
+
error: structuredError({
|
|
872
|
+
code: "SAFETY_CONFIG_INVALID",
|
|
873
|
+
message: sanitise(
|
|
874
|
+
`~/.keeperhub/safety.json is unreadable: ${message}. Repair the file by hand or delete it to fall back to defaults.`
|
|
875
|
+
)
|
|
876
|
+
})
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
function classifyFetchError(err) {
|
|
881
|
+
if (err instanceof Error) {
|
|
882
|
+
if (err.name === "AbortError" || err.name === "TimeoutError") {
|
|
883
|
+
return {
|
|
884
|
+
code: "UPSTREAM_TIMEOUT",
|
|
885
|
+
message: `Upstream request exceeded ${HTTP_TIMEOUT_MS}ms (${err.message}). Try again, or check https://status.keeperhub.com.`
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
if (err instanceof TypeError && err.message.includes("fetch failed")) {
|
|
889
|
+
const cause = typeof err.cause?.code === "string" ? err.cause.code : void 0;
|
|
890
|
+
return {
|
|
891
|
+
code: "UPSTREAM_UNREACHABLE",
|
|
892
|
+
message: `Could not reach KeeperHub upstream (${cause ?? err.message}). Check your network connectivity, then retry.`
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
function toolErrorEnvelope(err) {
|
|
899
|
+
if (err instanceof WalletConfigCorruptError) {
|
|
900
|
+
return structuredError({
|
|
901
|
+
code: "WALLET_CONFIG_CORRUPT",
|
|
902
|
+
message: sanitise(err.message),
|
|
903
|
+
path: err.path
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
if (err instanceof KeeperHubError) {
|
|
907
|
+
return structuredError({
|
|
908
|
+
code: err.code,
|
|
909
|
+
message: sanitise(err.message)
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
const fetchClassification = classifyFetchError(err);
|
|
913
|
+
if (fetchClassification) {
|
|
914
|
+
return structuredError({
|
|
915
|
+
code: fetchClassification.code,
|
|
916
|
+
message: sanitise(fetchClassification.message)
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
920
|
+
return structuredError({
|
|
921
|
+
code: "INTERNAL_ERROR",
|
|
922
|
+
message: sanitise(message)
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
async function handleCallWorkflow(args, deps) {
|
|
926
|
+
const safetyResult = await loadSafetyOrError(deps);
|
|
927
|
+
if ("error" in safetyResult) {
|
|
928
|
+
return safetyResult.error;
|
|
929
|
+
}
|
|
930
|
+
const { safety } = safetyResult;
|
|
931
|
+
let ensured;
|
|
932
|
+
try {
|
|
933
|
+
ensured = await ensureWallet(deps);
|
|
934
|
+
} catch (err) {
|
|
935
|
+
return toolErrorEnvelope(err);
|
|
936
|
+
}
|
|
937
|
+
const baseUrl = resolveKeeperhubBaseUrl();
|
|
938
|
+
const url = `${baseUrl}/api/mcp/workflows/${encodeURIComponent(args.slug)}/call`;
|
|
939
|
+
const bodyJson = JSON.stringify(args.body ?? {});
|
|
940
|
+
let probe;
|
|
941
|
+
try {
|
|
942
|
+
probe = await deps.fetchImpl(url, {
|
|
943
|
+
method: "POST",
|
|
944
|
+
headers: { "content-type": "application/json" },
|
|
945
|
+
body: bodyJson,
|
|
946
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
|
|
947
|
+
});
|
|
948
|
+
} catch (err) {
|
|
949
|
+
return toolErrorEnvelope(err);
|
|
950
|
+
}
|
|
951
|
+
if (probe.status === 402) {
|
|
952
|
+
const x402 = await parseX402Challenge(probe);
|
|
953
|
+
const mpp = parseMppChallenge(probe);
|
|
954
|
+
const amountMicro = extractX402AmountMicro(x402);
|
|
955
|
+
if (amountMicro !== null) {
|
|
956
|
+
const blockMicro = BigInt(
|
|
957
|
+
Math.round(safety.block_threshold_usd * USDC_DECIMALS2)
|
|
958
|
+
);
|
|
959
|
+
if (amountMicro > blockMicro) {
|
|
960
|
+
const attemptedUsd = microUsdcToUsd(amountMicro);
|
|
961
|
+
return structuredError({
|
|
962
|
+
code: "POLICY_BLOCKED",
|
|
963
|
+
message: sanitise(
|
|
964
|
+
`Payment of ${attemptedUsd} USD exceeds local safety cap of ${safety.block_threshold_usd} USD (block_threshold_usd in ~/.keeperhub/safety.json).`
|
|
965
|
+
),
|
|
966
|
+
threshold_usd: safety.block_threshold_usd,
|
|
967
|
+
attempted_usd: attemptedUsd,
|
|
968
|
+
...ensured.provisioned ? {
|
|
969
|
+
provisioned: true,
|
|
970
|
+
walletAddress: ensured.walletAddress,
|
|
971
|
+
fundingUrl: fund(ensured.walletAddress).coinbaseOnrampUrl
|
|
972
|
+
} : {}
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
const balanceSnap = await deps.checkBalance({
|
|
976
|
+
subOrgId: ensured.subOrgId,
|
|
977
|
+
walletAddress: ensured.walletAddress,
|
|
978
|
+
hmacSecret: ensured.hmacSecret
|
|
979
|
+
});
|
|
980
|
+
const baseBalance = parseUsdcAmount(balanceSnap.base.amount);
|
|
981
|
+
if (baseBalance !== null && baseBalance < amountMicro) {
|
|
982
|
+
const fundInfo = fund(ensured.walletAddress);
|
|
983
|
+
return structuredError({
|
|
984
|
+
code: "INSUFFICIENT_FUNDS",
|
|
985
|
+
message: sanitise(
|
|
986
|
+
`Wallet ${ensured.walletAddress} has ${balanceSnap.base.amount} Base USDC; payment requires ${microUsdcToUsd(amountMicro)} USD.`
|
|
987
|
+
),
|
|
988
|
+
needed_usd: microUsdcToUsd(amountMicro),
|
|
989
|
+
balance_usd: Number(balanceSnap.base.amount),
|
|
990
|
+
funding_url: fundInfo.coinbaseOnrampUrl,
|
|
991
|
+
walletAddress: ensured.walletAddress,
|
|
992
|
+
...ensured.provisioned ? { provisioned: true } : {}
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (!(x402 || mpp)) {
|
|
997
|
+
const text = await probe.text();
|
|
998
|
+
return structuredError({
|
|
999
|
+
code: "PAYMENT_REQUIRED_UNPARSEABLE",
|
|
1000
|
+
message: sanitise(
|
|
1001
|
+
`Upstream returned 402 with no parseable x402 or MPP challenge. Body: ${text.slice(0, 512)}`
|
|
1002
|
+
)
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
let final;
|
|
1007
|
+
try {
|
|
1008
|
+
final = await deps.paymentSigner.fetch(url, {
|
|
1009
|
+
method: "POST",
|
|
1010
|
+
headers: { "content-type": "application/json" },
|
|
1011
|
+
body: bodyJson,
|
|
1012
|
+
paymentHint: args.paymentHint ?? "auto",
|
|
1013
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
|
|
1014
|
+
});
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
const env = toolErrorEnvelope(err);
|
|
1017
|
+
if (ensured.provisioned && env.isError) {
|
|
1018
|
+
const parsed = JSON.parse(env.content[0]?.text ?? "{}");
|
|
1019
|
+
return structuredError({
|
|
1020
|
+
...parsed,
|
|
1021
|
+
provisioned: true,
|
|
1022
|
+
walletAddress: ensured.walletAddress,
|
|
1023
|
+
fundingUrl: fund(ensured.walletAddress).coinbaseOnrampUrl
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
return env;
|
|
1027
|
+
}
|
|
1028
|
+
const paid = probe.status === 402 && final.status !== 402;
|
|
1029
|
+
const protocolUsed = paid ? final.headers.get("x402-protocol") ?? "x402" : void 0;
|
|
1030
|
+
const HEADER_ALLOWLIST = /* @__PURE__ */ new Set([
|
|
1031
|
+
"content-type",
|
|
1032
|
+
"content-length",
|
|
1033
|
+
"x402-protocol",
|
|
1034
|
+
"x-execution-id",
|
|
1035
|
+
"execution-id",
|
|
1036
|
+
"x-ratelimit-limit",
|
|
1037
|
+
"x-ratelimit-remaining",
|
|
1038
|
+
"x-ratelimit-reset",
|
|
1039
|
+
"retry-after"
|
|
1040
|
+
]);
|
|
1041
|
+
const headersOut = {};
|
|
1042
|
+
for (const [k, v] of final.headers.entries()) {
|
|
1043
|
+
if (HEADER_ALLOWLIST.has(k.toLowerCase())) {
|
|
1044
|
+
headersOut[k] = v;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
const executionId = final.headers.get("x-execution-id") ?? final.headers.get("execution-id");
|
|
1048
|
+
const contentType = final.headers.get("content-type") ?? "";
|
|
1049
|
+
const responseFormat = pickResponseFormat(args.responseFormat, contentType);
|
|
1050
|
+
const buf = Buffer.from(await final.arrayBuffer());
|
|
1051
|
+
const truncated = buf.byteLength > BODY_TEXT_CAP_BYTES;
|
|
1052
|
+
const sliced = truncated ? buf.subarray(0, BODY_TEXT_CAP_BYTES) : buf;
|
|
1053
|
+
let bodyOut;
|
|
1054
|
+
if (responseFormat === "base64") {
|
|
1055
|
+
bodyOut = sliced.toString("base64");
|
|
1056
|
+
} else {
|
|
1057
|
+
bodyOut = sliced.toString("utf-8");
|
|
1058
|
+
if (responseFormat === "json") {
|
|
1059
|
+
try {
|
|
1060
|
+
const reparsed = JSON.parse(bodyOut);
|
|
1061
|
+
bodyOut = JSON.stringify(reparsed);
|
|
1062
|
+
} catch {
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
const result = {
|
|
1067
|
+
status: final.status,
|
|
1068
|
+
headers: headersOut,
|
|
1069
|
+
bodyText: bodyOut,
|
|
1070
|
+
paid,
|
|
1071
|
+
responseFormat
|
|
1072
|
+
};
|
|
1073
|
+
if (truncated) {
|
|
1074
|
+
result.bodyTruncated = true;
|
|
1075
|
+
}
|
|
1076
|
+
if (protocolUsed) {
|
|
1077
|
+
result.protocolUsed = protocolUsed;
|
|
1078
|
+
}
|
|
1079
|
+
if (executionId) {
|
|
1080
|
+
result.executionId = executionId;
|
|
1081
|
+
}
|
|
1082
|
+
if (ensured.provisioned) {
|
|
1083
|
+
result.provisioned = true;
|
|
1084
|
+
result.walletAddress = ensured.walletAddress;
|
|
1085
|
+
result.fundingUrl = fund(ensured.walletAddress).coinbaseOnrampUrl;
|
|
1086
|
+
}
|
|
1087
|
+
return structuredOk(result);
|
|
1088
|
+
}
|
|
1089
|
+
async function handleBalance(deps) {
|
|
1090
|
+
let ensured;
|
|
1091
|
+
try {
|
|
1092
|
+
ensured = await ensureWallet(deps);
|
|
1093
|
+
} catch (err) {
|
|
1094
|
+
return toolErrorEnvelope(err);
|
|
1095
|
+
}
|
|
1096
|
+
const snap = await deps.checkBalance({
|
|
1097
|
+
subOrgId: ensured.subOrgId,
|
|
1098
|
+
walletAddress: ensured.walletAddress,
|
|
1099
|
+
hmacSecret: ensured.hmacSecret
|
|
1100
|
+
});
|
|
1101
|
+
return structuredOk({
|
|
1102
|
+
base: { amount: snap.base.amount, address: snap.base.address },
|
|
1103
|
+
tempo: { amount: snap.tempo.amount, address: snap.tempo.address },
|
|
1104
|
+
...ensured.provisioned ? {
|
|
1105
|
+
provisioned: true,
|
|
1106
|
+
fundingUrl: fund(ensured.walletAddress).coinbaseOnrampUrl
|
|
1107
|
+
} : {}
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
async function handleInfo(deps) {
|
|
1111
|
+
let ensured;
|
|
1112
|
+
try {
|
|
1113
|
+
ensured = await ensureWallet(deps);
|
|
1114
|
+
} catch (err) {
|
|
1115
|
+
return toolErrorEnvelope(err);
|
|
1116
|
+
}
|
|
1117
|
+
return structuredOk({
|
|
1118
|
+
subOrgId: ensured.subOrgId,
|
|
1119
|
+
walletAddress: ensured.walletAddress,
|
|
1120
|
+
...ensured.provisioned ? {
|
|
1121
|
+
provisioned: true,
|
|
1122
|
+
fundingUrl: fund(ensured.walletAddress).coinbaseOnrampUrl
|
|
1123
|
+
} : {}
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
var submitFeedbackInputSchema = {
|
|
1127
|
+
executionId: z.string().min(1).describe(
|
|
1128
|
+
"Workflow execution id returned in the `executionId` field of a previous successful call_workflow response."
|
|
1129
|
+
),
|
|
1130
|
+
value: z.number().int().describe(
|
|
1131
|
+
"Rating value as a raw int128. With valueDecimals=0 this is a 1-5 star score; with valueDecimals=1 it is 0.1-step score. Server validates int128 range."
|
|
1132
|
+
),
|
|
1133
|
+
valueDecimals: z.number().int().min(0).max(18).describe(
|
|
1134
|
+
"Decimals for value. Use 0 for an integer 1-5 score, 1 for a 0.1-step 0-50 score, etc."
|
|
1135
|
+
),
|
|
1136
|
+
comment: z.string().max(2e3).optional().describe(
|
|
1137
|
+
"Optional plain-text comment included in the feedbackURI JSON."
|
|
1138
|
+
),
|
|
1139
|
+
agentChainId: z.number().int().optional().describe(
|
|
1140
|
+
"Chain id where the rated agent NFT lives. Defaults to 1 (Ethereum mainnet); only 1 is supported today."
|
|
1141
|
+
),
|
|
1142
|
+
agentId: z.string().optional().describe(
|
|
1143
|
+
"Rated agent NFT id (uint256, decimal string). Defaults to KeeperHub's own ERC-8004 agent (31875)."
|
|
1144
|
+
)
|
|
1145
|
+
};
|
|
1146
|
+
async function handleSubmitFeedback(args, deps) {
|
|
1147
|
+
let ensured;
|
|
1148
|
+
try {
|
|
1149
|
+
ensured = await ensureWallet(deps);
|
|
1150
|
+
} catch (err) {
|
|
1151
|
+
return toolErrorEnvelope(err);
|
|
1152
|
+
}
|
|
1153
|
+
const baseUrl = resolveKeeperhubBaseUrl();
|
|
1154
|
+
const path = "/api/agentic-wallet/feedback";
|
|
1155
|
+
const url = `${baseUrl}${path}`;
|
|
1156
|
+
const body = JSON.stringify({
|
|
1157
|
+
executionId: args.executionId,
|
|
1158
|
+
value: args.value,
|
|
1159
|
+
valueDecimals: args.valueDecimals,
|
|
1160
|
+
...args.comment !== void 0 ? { comment: args.comment } : {},
|
|
1161
|
+
...args.agentChainId !== void 0 ? { agentChainId: args.agentChainId } : {},
|
|
1162
|
+
...args.agentId !== void 0 ? { agentId: args.agentId } : {}
|
|
1163
|
+
});
|
|
1164
|
+
const hmacHeaders = buildHmacHeaders(
|
|
1165
|
+
ensured.hmacSecret,
|
|
1166
|
+
"POST",
|
|
1167
|
+
path,
|
|
1168
|
+
ensured.subOrgId,
|
|
1169
|
+
body
|
|
1170
|
+
);
|
|
1171
|
+
let response;
|
|
1172
|
+
try {
|
|
1173
|
+
response = await deps.fetchImpl(url, {
|
|
1174
|
+
method: "POST",
|
|
1175
|
+
headers: {
|
|
1176
|
+
"content-type": "application/json",
|
|
1177
|
+
...hmacHeaders
|
|
1178
|
+
},
|
|
1179
|
+
body,
|
|
1180
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
|
|
1181
|
+
});
|
|
1182
|
+
} catch (err) {
|
|
1183
|
+
return toolErrorEnvelope(err);
|
|
1184
|
+
}
|
|
1185
|
+
let parsedBody;
|
|
1186
|
+
try {
|
|
1187
|
+
parsedBody = await response.json();
|
|
1188
|
+
} catch {
|
|
1189
|
+
const fallback = await response.text();
|
|
1190
|
+
return structuredError({
|
|
1191
|
+
code: "FEEDBACK_UNPARSEABLE_RESPONSE",
|
|
1192
|
+
message: sanitise(
|
|
1193
|
+
`Server returned non-JSON ${response.status}: ${fallback.slice(0, 512)}`
|
|
1194
|
+
)
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
if (!response.ok) {
|
|
1198
|
+
const errBody = parsedBody;
|
|
1199
|
+
return structuredError({
|
|
1200
|
+
code: errBody.code ?? `FEEDBACK_HTTP_${response.status}`,
|
|
1201
|
+
message: sanitise(errBody.error ?? `HTTP ${response.status}`),
|
|
1202
|
+
...errBody.feedbackId ? { feedbackId: errBody.feedbackId } : {}
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
const okBody = parsedBody;
|
|
1206
|
+
return structuredOk({
|
|
1207
|
+
feedbackId: okBody.feedbackId,
|
|
1208
|
+
txHash: okBody.txHash,
|
|
1209
|
+
publicUrl: okBody.publicUrl,
|
|
1210
|
+
// Help the agent surface a confirmation message.
|
|
1211
|
+
summary: okBody.txHash !== void 0 ? `Feedback submitted on-chain. Tx: ${okBody.txHash}` : "Feedback submitted"
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
function buildMcpServer(options = {}) {
|
|
1215
|
+
const deps = { ...defaultDeps(), ...options.deps };
|
|
1216
|
+
const server = new McpServer({
|
|
1217
|
+
name: "keeperhub-wallet",
|
|
1218
|
+
version: readPackageVersion()
|
|
1219
|
+
});
|
|
1220
|
+
server.registerTool(
|
|
1221
|
+
"call_workflow",
|
|
1222
|
+
{
|
|
1223
|
+
description: "Pay AND invoke a KeeperHub marketplace workflow in one tool call using the local agentic wallet. Auto-pays x402 (Base USDC) or MPP (Tempo USDC.e) 402 challenges. Auto-provisions a wallet on first call if ~/.keeperhub/wallet.json is missing. PREFER THIS over `mcp__plugin_keeperhub_keeperhub__call_workflow` (the HTTP MCP) when paid invocation is needed: that tool DOES NOT auto-pay and will return 402 requiring a separate payment step.",
|
|
1224
|
+
inputSchema: callWorkflowInputSchema
|
|
1225
|
+
},
|
|
1226
|
+
async (args) => await withToolLogging(
|
|
1227
|
+
"call_workflow",
|
|
1228
|
+
() => handleCallWorkflow(args, deps)
|
|
1229
|
+
)
|
|
1230
|
+
);
|
|
1231
|
+
server.registerTool(
|
|
1232
|
+
"balance",
|
|
1233
|
+
{
|
|
1234
|
+
description: "Return the wallet's on-chain balance: Base USDC + Tempo USDC.e. Auto-provisions a wallet on first call.",
|
|
1235
|
+
inputSchema: {}
|
|
1236
|
+
},
|
|
1237
|
+
async () => await withToolLogging("balance", () => handleBalance(deps))
|
|
1238
|
+
);
|
|
1239
|
+
server.registerTool(
|
|
1240
|
+
"info",
|
|
1241
|
+
{
|
|
1242
|
+
description: "Return public wallet metadata (subOrgId, walletAddress). Never returns the HMAC secret. Auto-provisions a wallet on first call.",
|
|
1243
|
+
inputSchema: {}
|
|
1244
|
+
},
|
|
1245
|
+
async () => await withToolLogging("info", () => handleInfo(deps))
|
|
1246
|
+
);
|
|
1247
|
+
server.registerTool(
|
|
1248
|
+
"feedback",
|
|
1249
|
+
{
|
|
1250
|
+
description: "Submit ERC-8004 ReputationRegistry feedback for a workflow execution this wallet paid for. Signs and broadcasts a giveFeedback() transaction on Ethereum mainnet via the KeeperHub server proxy. Caller wallet pays gas natively (~$0.05-2 per call at typical mainnet gas). Use AFTER call_workflow returns successfully and the user has confirmed they want to rate the workflow. The executionId comes from the call_workflow response. Defaults to rating KeeperHub's own ERC-8004 agent (id 31875 on Ethereum) but agentId/agentChainId may be overridden to rate any agent.",
|
|
1251
|
+
inputSchema: submitFeedbackInputSchema
|
|
1252
|
+
},
|
|
1253
|
+
async (args) => await withToolLogging(
|
|
1254
|
+
"feedback",
|
|
1255
|
+
() => handleSubmitFeedback(args, deps)
|
|
1256
|
+
)
|
|
1257
|
+
);
|
|
1258
|
+
return server;
|
|
1259
|
+
}
|
|
1260
|
+
async function runMcpServer() {
|
|
1261
|
+
const server = buildMcpServer();
|
|
1262
|
+
const transport = new StdioServerTransport();
|
|
1263
|
+
await server.connect(transport);
|
|
1264
|
+
logEvent("mcp.server.started", {
|
|
1265
|
+
version: readPackageVersion(),
|
|
1266
|
+
pid: process.pid,
|
|
1267
|
+
baseUrl: resolveKeeperhubBaseUrl()
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
var __test__ = {
|
|
1271
|
+
handleCallWorkflow,
|
|
1272
|
+
handleBalance,
|
|
1273
|
+
handleInfo,
|
|
1274
|
+
defaultDeps,
|
|
1275
|
+
resetProvisionInflightForTests,
|
|
1276
|
+
BODY_TEXT_CAP_BYTES
|
|
1277
|
+
};
|
|
1278
|
+
export {
|
|
1279
|
+
__test__,
|
|
1280
|
+
buildMcpServer,
|
|
1281
|
+
runMcpServer
|
|
1282
|
+
};
|
|
1283
|
+
//# sourceMappingURL=mcp-server.js.map
|