@keeperhub/wallet 0.1.11 → 0.1.12

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.
@@ -0,0 +1,1184 @@
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/mpp-detect.ts
101
+ var MPP_PREFIX = "Payment ";
102
+ function parseMppChallenge(response) {
103
+ const header = response.headers.get("WWW-Authenticate");
104
+ if (!header) {
105
+ return null;
106
+ }
107
+ if (!header.startsWith(MPP_PREFIX)) {
108
+ return null;
109
+ }
110
+ const serialized = header.slice(MPP_PREFIX.length).trim();
111
+ if (serialized.length === 0) {
112
+ return null;
113
+ }
114
+ return { serialized };
115
+ }
116
+
117
+ // src/payment-signer.ts
118
+ import { randomBytes as randomBytes2 } from "crypto";
119
+
120
+ // src/hmac.ts
121
+ import { createHash, createHmac } from "crypto";
122
+ function computeSignature(secret, method, path, subOrgId, body, timestamp) {
123
+ const bodyDigest = createHash("sha256").update(body).digest("hex");
124
+ const signingString = `${method}
125
+ ${path}
126
+ ${subOrgId}
127
+ ${bodyDigest}
128
+ ${timestamp}`;
129
+ return createHmac("sha256", secret).update(signingString).digest("hex");
130
+ }
131
+ function buildHmacHeaders(secret, method, path, subOrgId, body) {
132
+ const timestamp = String(Math.floor(Date.now() / 1e3));
133
+ const signature = computeSignature(
134
+ secret,
135
+ method,
136
+ path,
137
+ subOrgId,
138
+ body,
139
+ timestamp
140
+ );
141
+ return {
142
+ "X-KH-Sub-Org": subOrgId,
143
+ "X-KH-Timestamp": timestamp,
144
+ "X-KH-Signature": signature
145
+ };
146
+ }
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
+ function buildMcpServer(options = {}) {
1127
+ const deps = { ...defaultDeps(), ...options.deps };
1128
+ const server = new McpServer({
1129
+ name: "keeperhub-wallet",
1130
+ version: readPackageVersion()
1131
+ });
1132
+ server.registerTool(
1133
+ "call_workflow",
1134
+ {
1135
+ 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.",
1136
+ inputSchema: callWorkflowInputSchema
1137
+ },
1138
+ async (args) => await withToolLogging(
1139
+ "call_workflow",
1140
+ () => handleCallWorkflow(args, deps)
1141
+ )
1142
+ );
1143
+ server.registerTool(
1144
+ "balance",
1145
+ {
1146
+ description: "Return the wallet's on-chain balance: Base USDC + Tempo USDC.e. Auto-provisions a wallet on first call.",
1147
+ inputSchema: {}
1148
+ },
1149
+ async () => await withToolLogging("balance", () => handleBalance(deps))
1150
+ );
1151
+ server.registerTool(
1152
+ "info",
1153
+ {
1154
+ description: "Return public wallet metadata (subOrgId, walletAddress). Never returns the HMAC secret. Auto-provisions a wallet on first call.",
1155
+ inputSchema: {}
1156
+ },
1157
+ async () => await withToolLogging("info", () => handleInfo(deps))
1158
+ );
1159
+ return server;
1160
+ }
1161
+ async function runMcpServer() {
1162
+ const server = buildMcpServer();
1163
+ const transport = new StdioServerTransport();
1164
+ await server.connect(transport);
1165
+ logEvent("mcp.server.started", {
1166
+ version: readPackageVersion(),
1167
+ pid: process.pid,
1168
+ baseUrl: resolveKeeperhubBaseUrl()
1169
+ });
1170
+ }
1171
+ var __test__ = {
1172
+ handleCallWorkflow,
1173
+ handleBalance,
1174
+ handleInfo,
1175
+ defaultDeps,
1176
+ resetProvisionInflightForTests,
1177
+ BODY_TEXT_CAP_BYTES
1178
+ };
1179
+ export {
1180
+ __test__,
1181
+ buildMcpServer,
1182
+ runMcpServer
1183
+ };
1184
+ //# sourceMappingURL=mcp-server.js.map