@muhaven/mcp 0.1.0
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/CHANGELOG.md +123 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/muhaven-broker.cjs +11 -0
- package/bin/muhaven-mcp.cjs +11 -0
- package/dist/broker.cjs +1117 -0
- package/dist/broker.d.cts +16 -0
- package/dist/broker.d.ts +16 -0
- package/dist/broker.js +1112 -0
- package/dist/index.cjs +1972 -0
- package/dist/index.d.cts +698 -0
- package/dist/index.d.ts +698 -0
- package/dist/index.js +1942 -0
- package/manifest.json +98 -0
- package/package.json +104 -0
- package/tool-hashes.json +93 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1942 @@
|
|
|
1
|
+
import { unlink, readFile, mkdir, chmod, writeFile, stat } from 'fs/promises';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
+
import { z, ZodError } from 'zod';
|
|
8
|
+
import { platform, homedir } from 'os';
|
|
9
|
+
import { connect, createServer } from 'net';
|
|
10
|
+
import { createHash } from 'crypto';
|
|
11
|
+
import { keccak256, toBytes } from 'viem';
|
|
12
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
13
|
+
|
|
14
|
+
// src/server.ts
|
|
15
|
+
var DEFAULT_BACKEND_URL = "https://api.muhaven.app";
|
|
16
|
+
var DEFAULT_DASHBOARD_URL = "https://muhaven.app";
|
|
17
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 15e3;
|
|
18
|
+
var DEFAULT_BROKER_TIMEOUT_MS = 5e3;
|
|
19
|
+
var DEFAULT_BROKER_MAX_BYTES = 64 * 1024;
|
|
20
|
+
var DEFAULT_JWT_CACHE_TTL_SEC = 30;
|
|
21
|
+
function defaultBrokerEndpoint() {
|
|
22
|
+
if (platform() === "win32") {
|
|
23
|
+
const user = process.env.USERNAME ?? "default";
|
|
24
|
+
const sanitized = user.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
25
|
+
return `\\\\.\\pipe\\muhaven-broker-${sanitized}`;
|
|
26
|
+
}
|
|
27
|
+
return join(homedir(), ".muhaven", "broker.sock");
|
|
28
|
+
}
|
|
29
|
+
function readEnv(name, env) {
|
|
30
|
+
const value = env[name];
|
|
31
|
+
if (value === void 0 || value === "") return void 0;
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
function readEnvBool(name, defaultValue, env) {
|
|
35
|
+
const raw = readEnv(name, env);
|
|
36
|
+
if (raw === void 0) return defaultValue;
|
|
37
|
+
return /^(1|true|yes|on)$/i.test(raw);
|
|
38
|
+
}
|
|
39
|
+
function readEnvInt(name, defaultValue, env) {
|
|
40
|
+
const raw = readEnv(name, env);
|
|
41
|
+
if (raw === void 0) return defaultValue;
|
|
42
|
+
const parsed = Number.parseInt(raw, 10);
|
|
43
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
44
|
+
throw new Error(`${name} must be a positive integer (got "${raw}")`);
|
|
45
|
+
}
|
|
46
|
+
return parsed;
|
|
47
|
+
}
|
|
48
|
+
function deriveAllowedHosts(baseUrl) {
|
|
49
|
+
try {
|
|
50
|
+
const u = new URL(baseUrl);
|
|
51
|
+
return [u.host];
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error(`MUHAVEN_BACKEND_URL is not a valid URL (got "${baseUrl}")`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function trimTrailingSlash(s) {
|
|
57
|
+
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
58
|
+
}
|
|
59
|
+
function loadMcpConfig(env = process.env) {
|
|
60
|
+
const backendBaseUrl = trimTrailingSlash(env.MUHAVEN_BACKEND_URL ?? DEFAULT_BACKEND_URL);
|
|
61
|
+
const dashboardBaseUrl = trimTrailingSlash(env.MUHAVEN_DASHBOARD_URL ?? DEFAULT_DASHBOARD_URL);
|
|
62
|
+
const brokerEndpoint = env.MUHAVEN_BROKER_ENDPOINT ?? defaultBrokerEndpoint();
|
|
63
|
+
const readOnly = readEnvBool("MUHAVEN_READ_ONLY", false, env);
|
|
64
|
+
const requestTimeoutMs = readEnvInt("MUHAVEN_REQUEST_TIMEOUT_MS", DEFAULT_REQUEST_TIMEOUT_MS, env);
|
|
65
|
+
const brokerTimeoutMs = readEnvInt("MUHAVEN_BROKER_TIMEOUT_MS", DEFAULT_BROKER_TIMEOUT_MS, env);
|
|
66
|
+
const jwtCacheTtlSec = readEnvInt("MUHAVEN_JWT_CACHE_TTL_SEC", DEFAULT_JWT_CACHE_TTL_SEC, env);
|
|
67
|
+
return {
|
|
68
|
+
backendBaseUrl,
|
|
69
|
+
dashboardBaseUrl,
|
|
70
|
+
brokerEndpoint,
|
|
71
|
+
readOnly,
|
|
72
|
+
requestTimeoutMs,
|
|
73
|
+
brokerTimeoutMs,
|
|
74
|
+
allowedBackendHosts: deriveAllowedHosts(backendBaseUrl),
|
|
75
|
+
jwtCacheTtlSec
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
var PRIVKEY_HEX_RE = /^0x[0-9a-fA-F]{64}$/;
|
|
79
|
+
function loadBrokerConfig(env = process.env) {
|
|
80
|
+
const sessionKeyHex = env.MUHAVEN_BROKER_SESSION_KEY;
|
|
81
|
+
if (!sessionKeyHex) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
"MUHAVEN_BROKER_SESSION_KEY is required (0x-prefixed 32-byte hex). Mint a session key via the dashboard policy-template install flow."
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (!PRIVKEY_HEX_RE.test(sessionKeyHex)) {
|
|
87
|
+
throw new Error("MUHAVEN_BROKER_SESSION_KEY must be a 0x-prefixed 32-byte hex string");
|
|
88
|
+
}
|
|
89
|
+
const endpoint = env.MUHAVEN_BROKER_ENDPOINT ?? defaultBrokerEndpoint();
|
|
90
|
+
const maxRequestBytes = readEnvInt("MUHAVEN_BROKER_MAX_BYTES", DEFAULT_BROKER_MAX_BYTES, env);
|
|
91
|
+
const requestTimeoutMs = readEnvInt("MUHAVEN_BROKER_TIMEOUT_MS", DEFAULT_BROKER_TIMEOUT_MS, env);
|
|
92
|
+
return {
|
|
93
|
+
endpoint,
|
|
94
|
+
sessionKeyHex,
|
|
95
|
+
maxRequestBytes,
|
|
96
|
+
requestTimeoutMs
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
var BrokerClientError = class extends Error {
|
|
100
|
+
constructor(code, message, cause) {
|
|
101
|
+
super(message);
|
|
102
|
+
this.code = code;
|
|
103
|
+
this.cause = cause;
|
|
104
|
+
this.name = "BrokerClientError";
|
|
105
|
+
}
|
|
106
|
+
code;
|
|
107
|
+
cause;
|
|
108
|
+
};
|
|
109
|
+
var BrokerClient = class {
|
|
110
|
+
constructor(options) {
|
|
111
|
+
this.options = options;
|
|
112
|
+
}
|
|
113
|
+
options;
|
|
114
|
+
async hello() {
|
|
115
|
+
const res = await this.exchange({ type: "hello" });
|
|
116
|
+
if (res.type !== "hello") {
|
|
117
|
+
throw new BrokerClientError("protocol_error", `expected hello response, got ${res.type}`);
|
|
118
|
+
}
|
|
119
|
+
return res;
|
|
120
|
+
}
|
|
121
|
+
async signHash(hash, intent) {
|
|
122
|
+
const res = await this.exchange({
|
|
123
|
+
type: "sign_hash",
|
|
124
|
+
hash,
|
|
125
|
+
...intent ? { intent } : {}
|
|
126
|
+
});
|
|
127
|
+
if (res.type !== "sign_hash") {
|
|
128
|
+
throw new BrokerClientError(
|
|
129
|
+
"protocol_error",
|
|
130
|
+
`expected sign_hash response, got ${res.type}`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return res;
|
|
134
|
+
}
|
|
135
|
+
async storeJwt(jwt, expiresAtSec) {
|
|
136
|
+
const res = await this.exchange({
|
|
137
|
+
type: "store_jwt",
|
|
138
|
+
jwt,
|
|
139
|
+
...expiresAtSec === void 0 ? {} : { expiresAtSec }
|
|
140
|
+
});
|
|
141
|
+
if (res.type !== "store_jwt") {
|
|
142
|
+
throw new BrokerClientError(
|
|
143
|
+
"protocol_error",
|
|
144
|
+
`expected store_jwt response, got ${res.type}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return res;
|
|
148
|
+
}
|
|
149
|
+
async getJwt() {
|
|
150
|
+
const res = await this.exchange({ type: "get_jwt" });
|
|
151
|
+
if (res.type !== "get_jwt") {
|
|
152
|
+
throw new BrokerClientError(
|
|
153
|
+
"protocol_error",
|
|
154
|
+
`expected get_jwt response, got ${res.type}`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return res;
|
|
158
|
+
}
|
|
159
|
+
async clearJwt() {
|
|
160
|
+
const res = await this.exchange({ type: "clear_jwt" });
|
|
161
|
+
if (res.type !== "clear_jwt") {
|
|
162
|
+
throw new BrokerClientError(
|
|
163
|
+
"protocol_error",
|
|
164
|
+
`expected clear_jwt response, got ${res.type}`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
exchange(request) {
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
let socket;
|
|
171
|
+
let buffer = "";
|
|
172
|
+
let settled = false;
|
|
173
|
+
const settleErr = (err2) => {
|
|
174
|
+
if (settled) return;
|
|
175
|
+
settled = true;
|
|
176
|
+
socket?.destroy();
|
|
177
|
+
reject(err2);
|
|
178
|
+
};
|
|
179
|
+
const settleOk = (res) => {
|
|
180
|
+
if (settled) return;
|
|
181
|
+
settled = true;
|
|
182
|
+
socket?.destroy();
|
|
183
|
+
resolve(res);
|
|
184
|
+
};
|
|
185
|
+
const timer = setTimeout(() => {
|
|
186
|
+
settleErr(new BrokerClientError("timeout", "broker IPC timeout"));
|
|
187
|
+
}, this.options.timeoutMs);
|
|
188
|
+
try {
|
|
189
|
+
socket = connect(this.options.endpoint);
|
|
190
|
+
} catch (err2) {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
settleErr(new BrokerClientError("connect_failed", "cannot connect to broker", err2));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
socket.once("connect", () => {
|
|
196
|
+
socket.write(JSON.stringify(request) + "\n");
|
|
197
|
+
});
|
|
198
|
+
socket.on("data", (chunk) => {
|
|
199
|
+
buffer += chunk.toString("utf8");
|
|
200
|
+
const newlineIdx = buffer.indexOf("\n");
|
|
201
|
+
if (newlineIdx < 0) return;
|
|
202
|
+
const line = buffer.slice(0, newlineIdx);
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
try {
|
|
205
|
+
const parsed = JSON.parse(line);
|
|
206
|
+
if (parsed.type === "error") {
|
|
207
|
+
settleErr(
|
|
208
|
+
new BrokerClientError("broker_error", `${parsed.code}: ${parsed.message}`)
|
|
209
|
+
);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
settleOk(parsed);
|
|
213
|
+
} catch (err2) {
|
|
214
|
+
settleErr(new BrokerClientError("protocol_error", "invalid JSON from broker", err2));
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
socket.on("error", (err2) => {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
settleErr(new BrokerClientError("connect_failed", err2.message, err2));
|
|
220
|
+
});
|
|
221
|
+
socket.on("close", () => {
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
if (!settled) {
|
|
224
|
+
settleErr(new BrokerClientError("protocol_error", "broker closed without response"));
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// src/auth/jwt-source.ts
|
|
232
|
+
var NoJwtAvailableError = class extends Error {
|
|
233
|
+
code = "no_jwt";
|
|
234
|
+
constructor() {
|
|
235
|
+
super(
|
|
236
|
+
"No JWT in broker keystore \u2014 run `muhaven-broker login` to authenticate via the device-code flow."
|
|
237
|
+
);
|
|
238
|
+
this.name = "NoJwtAvailableError";
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
var JwtSource = class {
|
|
242
|
+
constructor(broker, cacheTtlSec, nowMs = () => Date.now()) {
|
|
243
|
+
this.broker = broker;
|
|
244
|
+
this.cacheTtlSec = cacheTtlSec;
|
|
245
|
+
this.nowMs = nowMs;
|
|
246
|
+
}
|
|
247
|
+
broker;
|
|
248
|
+
cacheTtlSec;
|
|
249
|
+
nowMs;
|
|
250
|
+
cached = null;
|
|
251
|
+
/**
|
|
252
|
+
* Fetch the current JWT, throwing `NoJwtAvailableError` when the
|
|
253
|
+
* broker keystore is empty.
|
|
254
|
+
*/
|
|
255
|
+
async get() {
|
|
256
|
+
const cached = this.cachedJwtIfValid();
|
|
257
|
+
if (cached !== null) return cached;
|
|
258
|
+
let res;
|
|
259
|
+
try {
|
|
260
|
+
res = await this.broker.getJwt();
|
|
261
|
+
} catch (err2) {
|
|
262
|
+
if (err2 instanceof BrokerClientError) throw err2;
|
|
263
|
+
throw err2;
|
|
264
|
+
}
|
|
265
|
+
if (!res.jwt) {
|
|
266
|
+
this.cached = null;
|
|
267
|
+
throw new NoJwtAvailableError();
|
|
268
|
+
}
|
|
269
|
+
this.cached = {
|
|
270
|
+
jwt: res.jwt,
|
|
271
|
+
expiresAtSec: res.expiresAtSec,
|
|
272
|
+
cachedAtMs: this.nowMs()
|
|
273
|
+
};
|
|
274
|
+
return res.jwt;
|
|
275
|
+
}
|
|
276
|
+
/** Drop the in-process cache. Call after a backend 401 to force refresh. */
|
|
277
|
+
invalidate() {
|
|
278
|
+
this.cached = null;
|
|
279
|
+
}
|
|
280
|
+
/** Returns the cached JWT iff it's fresh AND not past `expiresAtSec`. */
|
|
281
|
+
cachedJwtIfValid() {
|
|
282
|
+
if (!this.cached) return null;
|
|
283
|
+
const ageMs = this.nowMs() - this.cached.cachedAtMs;
|
|
284
|
+
if (ageMs > this.cacheTtlSec * 1e3) return null;
|
|
285
|
+
if (this.cached.expiresAtSec !== null) {
|
|
286
|
+
const nowSec = Math.floor(this.nowMs() / 1e3);
|
|
287
|
+
if (this.cached.expiresAtSec - nowSec < 30) return null;
|
|
288
|
+
}
|
|
289
|
+
return this.cached.jwt;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// src/clients/backend-client.ts
|
|
294
|
+
var BackendError = class extends Error {
|
|
295
|
+
constructor(code, message, status, body) {
|
|
296
|
+
super(message);
|
|
297
|
+
this.code = code;
|
|
298
|
+
this.status = status;
|
|
299
|
+
this.body = body;
|
|
300
|
+
this.name = "BackendError";
|
|
301
|
+
}
|
|
302
|
+
code;
|
|
303
|
+
status;
|
|
304
|
+
body;
|
|
305
|
+
};
|
|
306
|
+
var BackendClient = class {
|
|
307
|
+
constructor(options) {
|
|
308
|
+
this.options = options;
|
|
309
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
310
|
+
}
|
|
311
|
+
options;
|
|
312
|
+
fetchImpl;
|
|
313
|
+
async get(path, query) {
|
|
314
|
+
const url = this.buildUrl(path, query);
|
|
315
|
+
return this.exchangeWithRetry("GET", url);
|
|
316
|
+
}
|
|
317
|
+
async post(path, body) {
|
|
318
|
+
const url = this.buildUrl(path);
|
|
319
|
+
return this.exchangeWithRetry("POST", url, body);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Path-less variant for unauthenticated calls (e.g., device-code
|
|
323
|
+
* flow's `/auth/device/code` and `/auth/device/token`). Sends no
|
|
324
|
+
* Authorization header.
|
|
325
|
+
*/
|
|
326
|
+
async postUnauth(path, body) {
|
|
327
|
+
const url = this.buildUrl(path);
|
|
328
|
+
return this.exchange(
|
|
329
|
+
"POST",
|
|
330
|
+
url,
|
|
331
|
+
body,
|
|
332
|
+
/* withAuth */
|
|
333
|
+
false
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
buildUrl(path, query) {
|
|
337
|
+
if (!path.startsWith("/")) {
|
|
338
|
+
throw new BackendError("bad_request", `path must start with "/": ${path}`);
|
|
339
|
+
}
|
|
340
|
+
const url = new URL(this.options.baseUrl + path);
|
|
341
|
+
if (!this.options.allowedHosts.includes(url.host)) {
|
|
342
|
+
throw new BackendError(
|
|
343
|
+
"host_not_allowed",
|
|
344
|
+
`request host ${url.host} not in allowedHosts`
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
if (query) {
|
|
348
|
+
for (const [k, v] of Object.entries(query)) {
|
|
349
|
+
if (v !== void 0 && v !== null && v !== "") url.searchParams.set(k, String(v));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return url;
|
|
353
|
+
}
|
|
354
|
+
async exchangeWithRetry(method, url, body) {
|
|
355
|
+
try {
|
|
356
|
+
return await this.exchange(method, url, body, true);
|
|
357
|
+
} catch (err2) {
|
|
358
|
+
if (err2 instanceof BackendError && err2.code === "unauthorized") {
|
|
359
|
+
this.options.jwtSource.invalidate();
|
|
360
|
+
return this.exchange(method, url, body, true);
|
|
361
|
+
}
|
|
362
|
+
throw err2;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
async exchange(method, url, body, withAuth) {
|
|
366
|
+
const headers = {
|
|
367
|
+
accept: "application/json",
|
|
368
|
+
...body !== void 0 ? { "content-type": "application/json" } : {}
|
|
369
|
+
};
|
|
370
|
+
if (withAuth) {
|
|
371
|
+
const jwt = await this.options.jwtSource.get();
|
|
372
|
+
headers.authorization = `Bearer ${jwt}`;
|
|
373
|
+
}
|
|
374
|
+
const ctrl = new AbortController();
|
|
375
|
+
const timer = setTimeout(() => ctrl.abort(), this.options.timeoutMs);
|
|
376
|
+
let res;
|
|
377
|
+
try {
|
|
378
|
+
res = await this.fetchImpl(url, {
|
|
379
|
+
method,
|
|
380
|
+
headers,
|
|
381
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
382
|
+
signal: ctrl.signal
|
|
383
|
+
});
|
|
384
|
+
} catch (err2) {
|
|
385
|
+
clearTimeout(timer);
|
|
386
|
+
if (err2.name === "AbortError") {
|
|
387
|
+
throw new BackendError("timeout", `${method} ${url.pathname} timed out`);
|
|
388
|
+
}
|
|
389
|
+
throw new BackendError("network", `${method} ${url.pathname} network error`, void 0, err2);
|
|
390
|
+
} finally {
|
|
391
|
+
clearTimeout(timer);
|
|
392
|
+
}
|
|
393
|
+
const status = res.status;
|
|
394
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
395
|
+
let payload = void 0;
|
|
396
|
+
if (contentType.includes("application/json")) {
|
|
397
|
+
try {
|
|
398
|
+
payload = await res.json();
|
|
399
|
+
} catch {
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
try {
|
|
403
|
+
payload = await res.text();
|
|
404
|
+
} catch {
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (status >= 200 && status < 300) {
|
|
408
|
+
return payload;
|
|
409
|
+
}
|
|
410
|
+
throw new BackendError(
|
|
411
|
+
mapStatus(status),
|
|
412
|
+
`${method} ${url.pathname} \u2192 ${status}`,
|
|
413
|
+
status,
|
|
414
|
+
payload
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
function mapStatus(status) {
|
|
419
|
+
if (status === 401) return "unauthorized";
|
|
420
|
+
if (status === 403) return "forbidden";
|
|
421
|
+
if (status === 404) return "not_found";
|
|
422
|
+
if (status === 410) return "gone";
|
|
423
|
+
if (status === 429) return "rate_limited";
|
|
424
|
+
if (status >= 400 && status < 500) return "bad_request";
|
|
425
|
+
if (status >= 500) return "server_error";
|
|
426
|
+
return "invalid_response";
|
|
427
|
+
}
|
|
428
|
+
var TOOL_DESCRIPTORS = [
|
|
429
|
+
{
|
|
430
|
+
name: "muhaven.read.portfolio",
|
|
431
|
+
group: "read",
|
|
432
|
+
description: "Return the authenticated investor's encrypted-balance portfolio summary. Output exposes only public aggregates (token list, ebool isOverexposed / isUnderYield handles); decrypted balances are NEVER included in the response. The LLM should call muhaven.read.portfolio for fact-checks about the user's state, not estimate from chat history.",
|
|
433
|
+
sensitive: false
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
name: "muhaven.read.yields",
|
|
437
|
+
group: "read",
|
|
438
|
+
description: 'Return per-token yield history (cleartext aggregates only \u2014 distribution dates, total funded amounts, epoch numbers). Use to answer "what was my yield last epoch?" with authoritative data, not from cached LLM context.',
|
|
439
|
+
sensitive: false
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
name: "muhaven.read.distribution",
|
|
443
|
+
group: "read",
|
|
444
|
+
description: "Return distribution status for a (token, epoch). Inputs: token address (Hex), epoch (uint). Output: { state, totalFunded, escrowsCreated, escrowsFunded } \u2014 all cleartext aggregates.",
|
|
445
|
+
sensitive: false
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
name: "muhaven.read.tokens",
|
|
449
|
+
group: "read",
|
|
450
|
+
description: "List the RWA tokens the authenticated user holds (token addresses + symbols + decimals). Balances are NOT included; use muhaven.read.portfolio for the encrypted-balance aggregates.",
|
|
451
|
+
sensitive: false
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
name: "muhaven.read.audit",
|
|
455
|
+
group: "read",
|
|
456
|
+
description: `Return the authenticated user's tiered-autonomy audit log entries. Cursor-paginated. Useful for forensic review ("why was I paused?") and grant-reviewer demos. Read-only \u2014 never exposes other users' data.`,
|
|
457
|
+
sensitive: false
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
name: "muhaven.position.buy",
|
|
461
|
+
group: "position",
|
|
462
|
+
description: "PROPOSE a Subscription buy. Returns an unsigned UserOp envelope plus a broker-signed session-key signature. The host MUST present the unsigned envelope to the user for passkey confirmation before submission to the bundler \u2014 this tool NEVER auto-submits. Fails when the user is in Advisory or Paused tier.",
|
|
463
|
+
sensitive: true
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
name: "muhaven.position.sell",
|
|
467
|
+
group: "position",
|
|
468
|
+
description: "PROPOSE a redemption-queue sell. Same envelope-plus-signature pattern as muhaven.position.buy. Requires the user to be in Confirm-per-action or Policy-bound tier on the MCP surface.",
|
|
469
|
+
sensitive: true
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
name: "muhaven.position.claim",
|
|
473
|
+
group: "position",
|
|
474
|
+
description: "PROPOSE a yield claim from RedemptionQueue / YieldSnapshot for a given token. Returns an unsigned UserOp + broker signature. Idempotent \u2014 proposing twice produces the same intent hash.",
|
|
475
|
+
sensitive: true
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
name: "muhaven.position.rebalance",
|
|
479
|
+
group: "position",
|
|
480
|
+
description: "PROPOSE a multi-leg atomic rebalance bundling buy + sell legs into a single UserOp. Each leg is constrained by the user's installed @zerodev/permissions CallPolicy.",
|
|
481
|
+
sensitive: true
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
name: "muhaven.policy.set_tier",
|
|
485
|
+
group: "policy",
|
|
486
|
+
description: "REQUEST or COMMIT a tiered-autonomy transition (Advisory \u2194 Confirm-per-action \u2194 Policy-bound) on the MCP surface. Two-step: first call returns a single-use confirmation token; the user passkey-signs in the dashboard; the host re-invokes with the token to commit. Step-down transitions skip the token. NEVER allows Advisory \u2192 Policy-bound in a single call (ADR-0).",
|
|
487
|
+
sensitive: true
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
name: "muhaven.policy.pause",
|
|
491
|
+
group: "policy",
|
|
492
|
+
description: "Activate the /pause kill-switch. Backend marks the surface Paused immediately; the on-chain @zerodev/permissions uninstallPlugin UserOp envelope is returned for the user to submit via passkey. Cascade-mode (no surface arg) pauses all four Wave 4 surfaces atomically.",
|
|
493
|
+
sensitive: true
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
name: "muhaven.policy.audit_export",
|
|
497
|
+
group: "policy",
|
|
498
|
+
description: "Stream the authenticated user's full audit log to a single JSON document. Convenience wrapper over muhaven.read.audit that drains the cursor \u2014 useful for compliance handoffs.",
|
|
499
|
+
sensitive: false
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
name: "muhaven.policy.session_key_status",
|
|
503
|
+
group: "policy",
|
|
504
|
+
description: "Return the current ZeroDev session-key validator state for the MCP surface: tier, validator address, valid-until timestamp, recent action count. Pure read; never modifies state.",
|
|
505
|
+
sensitive: false
|
|
506
|
+
},
|
|
507
|
+
// ── Wave 4 P7 — issuer-side tools (ADR-8) ──────────────────────────
|
|
508
|
+
// Same use-case backing as HavenBot's `muhaven_propose_*` tools; the
|
|
509
|
+
// dotted MCP names follow the namespace rule established in P3
|
|
510
|
+
// (TOOL_NAMESPACE.md §"`@muhaven/mcp` namespaces"). Every issuer tool
|
|
511
|
+
// is sensitive=true (host MUST render confirmation cue); they require
|
|
512
|
+
// an approved issuer kernel — the backend returns a structured 403
|
|
513
|
+
// when the JWT subject isn't issuer-roled.
|
|
514
|
+
{
|
|
515
|
+
name: "muhaven.issuer.distribute_yield",
|
|
516
|
+
group: "issuer",
|
|
517
|
+
description: "PROPOSE a yield distribution for a registered RWA token. Wraps the @muhaven/sdk distributeYield pipeline (startDistribution \u2192 batchCreate \u2192 fundEscrows). Returns an ActionDescriptor + confirmation token. Issuer-only \u2014 the use-case rejects non-issuer kernels with NOT_APPROVED_ISSUER.",
|
|
518
|
+
sensitive: true
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
name: "muhaven.issuer.kyc_add",
|
|
522
|
+
group: "issuer",
|
|
523
|
+
description: "PROPOSE adding an investor to the ERC-3643 whitelist for a registered token. kycTier=1 is retail KYC; kycTier=2 is accredited (which also requires tier 1). Returns an ActionDescriptor + confirmation token. Issuer-only.",
|
|
524
|
+
sensitive: true
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
name: "muhaven.issuer.kyc_remove",
|
|
528
|
+
group: "issuer",
|
|
529
|
+
description: "PROPOSE removing an investor from the ERC-3643 whitelist for a registered token. Tier-2 accredited status is auto-cleared by the contract. The on-chain T-5 KYC-revocation cascade across investor surfaces wires up in Wave 5 once the indexer subscribes. Issuer-only.",
|
|
530
|
+
sensitive: true
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
name: "muhaven.issuer.unpause_token",
|
|
534
|
+
group: "issuer",
|
|
535
|
+
description: "PROPOSE the F2 wizard step 6 closure: oracle.setNAV(token, initialNav) + tokenRegistry.setPaused(token, false). Both signed by the applicant kernel. Idempotent \u2014 refuses if the token is already active. Issuer-only.",
|
|
536
|
+
sensitive: true
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
name: "muhaven.issuer.audit_query",
|
|
540
|
+
group: "issuer",
|
|
541
|
+
description: "Return the calling issuer's tiered-autonomy audit log entries (cursor-paginated). Useful for compliance review of past distributions, KYC additions, and unpause events. Wave 4 = issuer-self only; Wave 5 adds permit-gated cross-user access for compliance officers.",
|
|
542
|
+
sensitive: false
|
|
543
|
+
},
|
|
544
|
+
// ── Wave 4 P11 — governance / protection / KYC tools ──────────────
|
|
545
|
+
// Reads land in `read` so `--read-only` keeps them available.
|
|
546
|
+
// State-mutating governance ceremony lives in the new `governance`
|
|
547
|
+
// group, filtered off by `--read-only`.
|
|
548
|
+
{
|
|
549
|
+
name: "muhaven.read.protection_coverage",
|
|
550
|
+
group: "read",
|
|
551
|
+
description: 'Read-only inspection of DefaultProtection coverage for an RWA token. Returns the public reserveRateBps, status, issuer address, and a human-readable explanation. Encrypted reserve balances are NEVER decrypted server-side; only public aggregates surface. Returns "not_deployed" when the P11.A contract is not yet on-chain.',
|
|
552
|
+
sensitive: false
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
name: "muhaven.read.kyc_attestation",
|
|
556
|
+
group: "read",
|
|
557
|
+
description: 'Read-only informational tool that explains MuHaven cross-chain KYC attestations + returns the registry config (default validity period, attestation signer, jurisdiction hash). Use to answer "how does cross-chain KYC work?" with authoritative data. Returns "not_deployed" when the P11.C registry is not yet on-chain.',
|
|
558
|
+
sensitive: false
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
name: "muhaven.governance.propose",
|
|
562
|
+
group: "governance",
|
|
563
|
+
description: "PROPOSE opening a governance proposal on the EncryptedGovernance contract. Wave 4 supports proposalType=0 (TRIGGER_PROTECTION) only; type=1 reserved for Wave 5. Returns an ActionDescriptor + confirmation token; the user signs through the dashboard ConfirmModal. Tier-gated. Refuses with P11_NOT_DEPLOYED when the contract is not yet on-chain.",
|
|
564
|
+
sensitive: true
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
name: "muhaven.governance.cast_vote",
|
|
568
|
+
group: "governance",
|
|
569
|
+
description: "PROPOSE submitting an FHE-encrypted ballot on an EncryptedGovernance proposal. The cleartext yes/no is rendered for the user in ConfirmModal; the SDK encrypts to InEuint128 client-side BEFORE the on-chain write so the agent surface NEVER sees the encrypted handle. The audit log records that a vote was cast but does NOT record which way (privacy invariant).",
|
|
570
|
+
sensitive: true
|
|
571
|
+
}
|
|
572
|
+
];
|
|
573
|
+
var _seenNames = /* @__PURE__ */ new Set();
|
|
574
|
+
for (const t of TOOL_DESCRIPTORS) {
|
|
575
|
+
if (_seenNames.has(t.name)) {
|
|
576
|
+
throw new Error(`Duplicate tool descriptor name: ${t.name}`);
|
|
577
|
+
}
|
|
578
|
+
_seenNames.add(t.name);
|
|
579
|
+
}
|
|
580
|
+
var TOOL_NAME_RE = /^muhaven\.[a-z]+\.[a-z][a-z0-9_]*$/;
|
|
581
|
+
for (const t of TOOL_DESCRIPTORS) {
|
|
582
|
+
if (!TOOL_NAME_RE.test(t.name)) {
|
|
583
|
+
throw new Error(
|
|
584
|
+
`Tool name "${t.name}" violates muhaven.<group>.<verb> regex (TOOL_NAMESPACE.md rule)`
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function hashToolDescriptor(d) {
|
|
589
|
+
const canonical = JSON.stringify({
|
|
590
|
+
name: d.name,
|
|
591
|
+
description: d.description,
|
|
592
|
+
sensitive: d.sensitive
|
|
593
|
+
});
|
|
594
|
+
return createHash("sha256").update(canonical, "utf-8").digest("hex");
|
|
595
|
+
}
|
|
596
|
+
function buildToolHashTable() {
|
|
597
|
+
return TOOL_DESCRIPTORS.map((d) => ({ name: d.name, sha256: hashToolDescriptor(d) }));
|
|
598
|
+
}
|
|
599
|
+
function verifyDescriptorAgainstPin(descriptor, pinnedSha256) {
|
|
600
|
+
const live = hashToolDescriptor(descriptor);
|
|
601
|
+
if (live === pinnedSha256) return null;
|
|
602
|
+
return { liveSha256: live, pinnedSha256 };
|
|
603
|
+
}
|
|
604
|
+
var HEX_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
605
|
+
var addressSchema = z.string().regex(HEX_ADDRESS_RE, "must be a 0x-prefixed 20-byte hex address");
|
|
606
|
+
var tierSchema = z.enum(["advisory", "confirm-per-action", "policy-bound", "paused"]);
|
|
607
|
+
var surfaceSchema = z.enum(["havenbot", "mcp", "openclaw", "checkout"]);
|
|
608
|
+
z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]);
|
|
609
|
+
var auditEventTypeSchema = z.enum([
|
|
610
|
+
"tier_changed",
|
|
611
|
+
"paused",
|
|
612
|
+
"resumed",
|
|
613
|
+
"cron_tick",
|
|
614
|
+
"confirm_token_issued",
|
|
615
|
+
"confirm_token_consumed",
|
|
616
|
+
"permit_granted",
|
|
617
|
+
"permit_revoked",
|
|
618
|
+
"validator_installed",
|
|
619
|
+
"validator_uninstalled",
|
|
620
|
+
"kyc_revocation_received",
|
|
621
|
+
"risk_questionnaire_complete"
|
|
622
|
+
]);
|
|
623
|
+
var ReadPortfolioInputSchema = z.object({}).strict();
|
|
624
|
+
var ReadYieldsInputSchema = z.object({
|
|
625
|
+
token: addressSchema.optional(),
|
|
626
|
+
limit: z.number().int().min(1).max(50).optional()
|
|
627
|
+
}).strict();
|
|
628
|
+
var ReadDistributionInputSchema = z.object({
|
|
629
|
+
token: addressSchema,
|
|
630
|
+
epoch: z.number().int().min(0)
|
|
631
|
+
}).strict();
|
|
632
|
+
var ReadTokensInputSchema = z.object({}).strict();
|
|
633
|
+
var ReadAuditInputSchema = z.object({
|
|
634
|
+
surface: surfaceSchema.optional(),
|
|
635
|
+
eventTypes: z.array(auditEventTypeSchema).max(20).optional(),
|
|
636
|
+
since: z.string().datetime().optional(),
|
|
637
|
+
until: z.string().datetime().optional(),
|
|
638
|
+
cursor: z.string().min(1).max(512).optional(),
|
|
639
|
+
limit: z.number().int().min(1).max(200).optional()
|
|
640
|
+
}).strict();
|
|
641
|
+
var PositionBuyInputSchema = z.object({
|
|
642
|
+
token: addressSchema,
|
|
643
|
+
/** Investor candidate spend, denominated in USDC base units (uint64). */
|
|
644
|
+
amountUsdc6: z.string().regex(/^\d+$/, "must be a base-10 integer string")
|
|
645
|
+
}).strict();
|
|
646
|
+
var PositionSellInputSchema = z.object({
|
|
647
|
+
token: addressSchema,
|
|
648
|
+
/** Encrypted-balance share count to redeem, denominated in fhERC-20 base units. */
|
|
649
|
+
amountShares: z.string().regex(/^\d+$/, "must be a base-10 integer string")
|
|
650
|
+
}).strict();
|
|
651
|
+
var PositionClaimInputSchema = z.object({
|
|
652
|
+
token: addressSchema,
|
|
653
|
+
/** When set, claim only the named escrow id; else claim-all. */
|
|
654
|
+
escrowId: z.string().regex(/^\d+$/).optional()
|
|
655
|
+
}).strict();
|
|
656
|
+
var PositionRebalanceInputSchema = z.object({
|
|
657
|
+
legs: z.array(
|
|
658
|
+
z.object({
|
|
659
|
+
token: addressSchema,
|
|
660
|
+
side: z.enum(["buy", "sell"]),
|
|
661
|
+
amount: z.string().regex(/^\d+$/)
|
|
662
|
+
}).strict()
|
|
663
|
+
).min(2).max(8)
|
|
664
|
+
}).strict();
|
|
665
|
+
var PolicySetTierInputSchema = z.object({
|
|
666
|
+
targetTier: tierSchema,
|
|
667
|
+
/** Returned by an earlier `request` call. Omit for step-down or first-call. */
|
|
668
|
+
confirmationToken: z.string().min(8).max(128).optional()
|
|
669
|
+
}).strict();
|
|
670
|
+
var PolicyPauseInputSchema = z.object({
|
|
671
|
+
/** Omit to cascade a pause across all four surfaces (panic button). */
|
|
672
|
+
surface: surfaceSchema.optional()
|
|
673
|
+
}).strict();
|
|
674
|
+
var PolicyAuditExportInputSchema = z.object({
|
|
675
|
+
surface: surfaceSchema.optional(),
|
|
676
|
+
since: z.string().datetime().optional(),
|
|
677
|
+
until: z.string().datetime().optional(),
|
|
678
|
+
/** Hard cap on rows to export, defends against runaway loops. */
|
|
679
|
+
maxRows: z.number().int().min(1).max(5e3).default(1e3)
|
|
680
|
+
}).strict();
|
|
681
|
+
var PolicySessionKeyStatusInputSchema = z.object({}).strict();
|
|
682
|
+
var IssuerDistributeYieldInputSchema = z.object({
|
|
683
|
+
tokenAddress: addressSchema,
|
|
684
|
+
/** Cleartext mhUSDC base units — encrypted SDK-side before submit. */
|
|
685
|
+
totalYieldUsd6: z.string().regex(/^[1-9]\d*$/, "must be a positive integer string"),
|
|
686
|
+
label: z.string().min(1).max(200).optional()
|
|
687
|
+
}).strict();
|
|
688
|
+
var IssuerKycAddInputSchema = z.object({
|
|
689
|
+
tokenAddress: addressSchema,
|
|
690
|
+
investorAddress: addressSchema,
|
|
691
|
+
kycTier: z.union([z.literal(1), z.literal(2)]).default(1)
|
|
692
|
+
}).strict();
|
|
693
|
+
var IssuerKycRemoveInputSchema = z.object({
|
|
694
|
+
tokenAddress: addressSchema,
|
|
695
|
+
investorAddress: addressSchema
|
|
696
|
+
}).strict();
|
|
697
|
+
var IssuerUnpauseTokenInputSchema = z.object({
|
|
698
|
+
tokenAddress: addressSchema,
|
|
699
|
+
/** Initial NAV in mhUSDC base units (6 decimals). 1_000_000 = $1.00. */
|
|
700
|
+
initialNavUsd6: z.string().regex(/^[1-9]\d*$/, "must be a positive integer string")
|
|
701
|
+
}).strict();
|
|
702
|
+
var IssuerAuditQueryInputSchema = z.object({
|
|
703
|
+
surface: surfaceSchema.optional(),
|
|
704
|
+
eventTypes: z.array(auditEventTypeSchema).max(20).optional(),
|
|
705
|
+
since: z.string().datetime().optional(),
|
|
706
|
+
until: z.string().datetime().optional(),
|
|
707
|
+
cursor: z.string().min(1).max(512).optional(),
|
|
708
|
+
limit: z.number().int().min(1).max(200).optional()
|
|
709
|
+
}).strict();
|
|
710
|
+
var ReadProtectionCoverageInputSchema = z.object({
|
|
711
|
+
tokenAddress: addressSchema
|
|
712
|
+
}).strict();
|
|
713
|
+
var ReadKycAttestationInputSchema = z.object({
|
|
714
|
+
investorAddress: addressSchema.optional()
|
|
715
|
+
}).strict();
|
|
716
|
+
var GovernanceProposeInputSchema = z.object({
|
|
717
|
+
tokenAddress: addressSchema,
|
|
718
|
+
/** 0 = TRIGGER_PROTECTION (Wave 4 only); 1 reserved Wave 5. */
|
|
719
|
+
proposalType: z.union([z.literal(0), z.literal(1)])
|
|
720
|
+
}).strict();
|
|
721
|
+
var GovernanceCastVoteInputSchema = z.object({
|
|
722
|
+
proposalId: z.string().regex(/^[1-9]\d*$/, "must be a positive integer string"),
|
|
723
|
+
voteYes: z.boolean()
|
|
724
|
+
}).strict();
|
|
725
|
+
|
|
726
|
+
// src/tools/auth-required.ts
|
|
727
|
+
function authRequiredPayload() {
|
|
728
|
+
return {
|
|
729
|
+
ok: false,
|
|
730
|
+
code: "AUTH_REQUIRED",
|
|
731
|
+
message: "No JWT in broker keystore. Run `muhaven-broker login` to authenticate via the device-code ceremony, then retry this tool.",
|
|
732
|
+
loginCommand: "muhaven-broker login"
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// src/tools/handlers.ts
|
|
737
|
+
function ok(data) {
|
|
738
|
+
return { ok: true, data };
|
|
739
|
+
}
|
|
740
|
+
function err(code, message) {
|
|
741
|
+
return { ok: false, code, message };
|
|
742
|
+
}
|
|
743
|
+
function mapBackendError(e) {
|
|
744
|
+
if (e instanceof BackendError) {
|
|
745
|
+
if (e.code === "unauthorized") return authRequiredPayload();
|
|
746
|
+
return err(`backend.${e.code}`, e.message);
|
|
747
|
+
}
|
|
748
|
+
if (e instanceof Error) return err("backend.network", e.message);
|
|
749
|
+
return err("backend.network", "unknown backend error");
|
|
750
|
+
}
|
|
751
|
+
function mapBrokerError(e) {
|
|
752
|
+
if (e instanceof BrokerClientError) return err(`broker.${e.code}`, e.message);
|
|
753
|
+
if (e instanceof Error) return err("broker.network", e.message);
|
|
754
|
+
return err("broker.network", "unknown broker error");
|
|
755
|
+
}
|
|
756
|
+
async function readPortfolio(_input, deps) {
|
|
757
|
+
try {
|
|
758
|
+
const data = await deps.backend.get("/api/v1/portfolio");
|
|
759
|
+
return ok(data);
|
|
760
|
+
} catch (e) {
|
|
761
|
+
return mapBackendError(e);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
async function readYields(input, deps) {
|
|
765
|
+
try {
|
|
766
|
+
const data = await deps.backend.get("/api/v1/yields", {
|
|
767
|
+
token: input.token,
|
|
768
|
+
limit: input.limit
|
|
769
|
+
});
|
|
770
|
+
return ok(data);
|
|
771
|
+
} catch (e) {
|
|
772
|
+
return mapBackendError(e);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
async function readDistribution(input, deps) {
|
|
776
|
+
try {
|
|
777
|
+
const data = await deps.backend.get("/api/v1/distributions", {
|
|
778
|
+
token: input.token,
|
|
779
|
+
epoch: input.epoch
|
|
780
|
+
});
|
|
781
|
+
return ok(data);
|
|
782
|
+
} catch (e) {
|
|
783
|
+
return mapBackendError(e);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
async function readTokens(_input, deps) {
|
|
787
|
+
try {
|
|
788
|
+
const data = await deps.backend.get("/api/v1/tokens");
|
|
789
|
+
return ok(data);
|
|
790
|
+
} catch (e) {
|
|
791
|
+
return mapBackendError(e);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
async function readAudit(input, deps) {
|
|
795
|
+
try {
|
|
796
|
+
const data = await deps.backend.get("/api/v1/agent/policy/audit", {
|
|
797
|
+
surface: input.surface,
|
|
798
|
+
eventTypes: input.eventTypes?.join(","),
|
|
799
|
+
since: input.since,
|
|
800
|
+
until: input.until,
|
|
801
|
+
cursor: input.cursor,
|
|
802
|
+
limit: input.limit
|
|
803
|
+
});
|
|
804
|
+
return ok(data);
|
|
805
|
+
} catch (e) {
|
|
806
|
+
return mapBackendError(e);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
var PLACEHOLDER_INTENT_DOMAIN = "muhaven.placeholder.intent.v0:";
|
|
810
|
+
function computeIntentHash(intent) {
|
|
811
|
+
const canonical = JSON.stringify(sortKeys(intent));
|
|
812
|
+
return keccak256(toBytes(PLACEHOLDER_INTENT_DOMAIN + canonical));
|
|
813
|
+
}
|
|
814
|
+
function sortKeys(value) {
|
|
815
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
816
|
+
if (value && typeof value === "object") {
|
|
817
|
+
const obj = value;
|
|
818
|
+
const sorted = {};
|
|
819
|
+
for (const k of Object.keys(obj).sort()) sorted[k] = sortKeys(obj[k]);
|
|
820
|
+
return sorted;
|
|
821
|
+
}
|
|
822
|
+
return value;
|
|
823
|
+
}
|
|
824
|
+
async function signEnvelope(intent, toolName, summary, deps) {
|
|
825
|
+
const intentHash = computeIntentHash(intent);
|
|
826
|
+
if (!deps.broker) {
|
|
827
|
+
return err(
|
|
828
|
+
"broker.unavailable",
|
|
829
|
+
"position tools require a running muhaven-broker daemon \u2014 see README \xA7Broker setup"
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
try {
|
|
833
|
+
const sig = await deps.broker.signHash(intentHash, { tool: toolName, summary });
|
|
834
|
+
return ok({
|
|
835
|
+
intentHash,
|
|
836
|
+
unsignedUserOp: {
|
|
837
|
+
target: "see backend",
|
|
838
|
+
data: "see backend",
|
|
839
|
+
note: "P3 returns a placeholder envelope; P6 wires the canonical UserOp shape."
|
|
840
|
+
},
|
|
841
|
+
brokerSignature: sig.signature,
|
|
842
|
+
signerAddress: sig.signerAddress
|
|
843
|
+
});
|
|
844
|
+
} catch (e) {
|
|
845
|
+
return mapBrokerError(e);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
async function positionBuy(input, deps) {
|
|
849
|
+
return signEnvelope(
|
|
850
|
+
{ kind: "buy", token: input.token, amountUsdc6: input.amountUsdc6 },
|
|
851
|
+
"muhaven.position.buy",
|
|
852
|
+
`buy ${input.amountUsdc6} USDC of ${input.token}`,
|
|
853
|
+
deps
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
async function positionSell(input, deps) {
|
|
857
|
+
return signEnvelope(
|
|
858
|
+
{ kind: "sell", token: input.token, amountShares: input.amountShares },
|
|
859
|
+
"muhaven.position.sell",
|
|
860
|
+
`sell ${input.amountShares} shares of ${input.token}`,
|
|
861
|
+
deps
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
async function positionClaim(input, deps) {
|
|
865
|
+
return signEnvelope(
|
|
866
|
+
{ kind: "claim", token: input.token, escrowId: input.escrowId ?? null },
|
|
867
|
+
"muhaven.position.claim",
|
|
868
|
+
`claim ${input.token}${input.escrowId ? ` escrow#${input.escrowId}` : " (all)"}`,
|
|
869
|
+
deps
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
async function positionRebalance(input, deps) {
|
|
873
|
+
return signEnvelope(
|
|
874
|
+
{ kind: "rebalance", legs: input.legs },
|
|
875
|
+
"muhaven.position.rebalance",
|
|
876
|
+
`rebalance ${input.legs.length} legs`,
|
|
877
|
+
deps
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
async function policySetTier(input, deps) {
|
|
881
|
+
try {
|
|
882
|
+
const data = await deps.backend.post("/api/v1/agent/policy/transition", {
|
|
883
|
+
surface: deps.surface,
|
|
884
|
+
targetTier: input.targetTier,
|
|
885
|
+
...input.confirmationToken ? { confirmationToken: input.confirmationToken } : {}
|
|
886
|
+
});
|
|
887
|
+
return ok(data);
|
|
888
|
+
} catch (e) {
|
|
889
|
+
return mapBackendError(e);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
async function policyPause(input, deps) {
|
|
893
|
+
try {
|
|
894
|
+
const data = await deps.backend.post("/api/v1/agent/policy/pause", {
|
|
895
|
+
...input.surface ? { surface: input.surface } : {}
|
|
896
|
+
});
|
|
897
|
+
return ok({
|
|
898
|
+
backend: data,
|
|
899
|
+
onChain: {
|
|
900
|
+
action: "uninstallPlugin",
|
|
901
|
+
note: "Submit via dashboard passkey or follow-up muhaven.position.* invocation."
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
} catch (e) {
|
|
905
|
+
return mapBackendError(e);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
async function policyAuditExport(input, deps) {
|
|
909
|
+
const items = [];
|
|
910
|
+
let cursor = void 0;
|
|
911
|
+
let pages = 0;
|
|
912
|
+
const PAGE_LIMIT = 200;
|
|
913
|
+
const MAX_PAGES = Math.ceil(input.maxRows / PAGE_LIMIT) + 1;
|
|
914
|
+
try {
|
|
915
|
+
while (items.length < input.maxRows && pages < MAX_PAGES) {
|
|
916
|
+
const page = await deps.backend.get("/api/v1/agent/policy/audit", {
|
|
917
|
+
surface: input.surface,
|
|
918
|
+
since: input.since,
|
|
919
|
+
until: input.until,
|
|
920
|
+
cursor,
|
|
921
|
+
limit: PAGE_LIMIT
|
|
922
|
+
});
|
|
923
|
+
const got = Array.isArray(page.items) ? page.items : [];
|
|
924
|
+
for (const row of got) {
|
|
925
|
+
if (items.length >= input.maxRows) break;
|
|
926
|
+
items.push(row);
|
|
927
|
+
}
|
|
928
|
+
pages++;
|
|
929
|
+
if (!page.cursor || got.length === 0) break;
|
|
930
|
+
cursor = page.cursor;
|
|
931
|
+
}
|
|
932
|
+
return ok({ items, total: items.length, truncated: items.length === input.maxRows });
|
|
933
|
+
} catch (e) {
|
|
934
|
+
return mapBackendError(e);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
async function policySessionKeyStatus(_input, deps) {
|
|
938
|
+
try {
|
|
939
|
+
const data = await deps.backend.get("/api/v1/agent/policy/state", { surface: deps.surface });
|
|
940
|
+
return ok(data);
|
|
941
|
+
} catch (e) {
|
|
942
|
+
return mapBackendError(e);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
async function issuerDistributeYield(input, deps) {
|
|
946
|
+
try {
|
|
947
|
+
const data = await deps.backend.post("/api/v1/agent/tools/propose_distribute_yield", {
|
|
948
|
+
tokenAddress: input.tokenAddress,
|
|
949
|
+
totalYieldUsd6: input.totalYieldUsd6,
|
|
950
|
+
...input.label ? { label: input.label } : {}
|
|
951
|
+
});
|
|
952
|
+
return ok(data);
|
|
953
|
+
} catch (e) {
|
|
954
|
+
return mapBackendError(e);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
async function issuerKycAdd(input, deps) {
|
|
958
|
+
try {
|
|
959
|
+
const data = await deps.backend.post("/api/v1/agent/tools/propose_kyc_add", {
|
|
960
|
+
tokenAddress: input.tokenAddress,
|
|
961
|
+
investorAddress: input.investorAddress,
|
|
962
|
+
kycTier: input.kycTier
|
|
963
|
+
});
|
|
964
|
+
return ok(data);
|
|
965
|
+
} catch (e) {
|
|
966
|
+
return mapBackendError(e);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
async function issuerKycRemove(input, deps) {
|
|
970
|
+
try {
|
|
971
|
+
const data = await deps.backend.post("/api/v1/agent/tools/propose_kyc_remove", {
|
|
972
|
+
tokenAddress: input.tokenAddress,
|
|
973
|
+
investorAddress: input.investorAddress
|
|
974
|
+
});
|
|
975
|
+
return ok(data);
|
|
976
|
+
} catch (e) {
|
|
977
|
+
return mapBackendError(e);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
async function issuerUnpauseToken(input, deps) {
|
|
981
|
+
try {
|
|
982
|
+
const data = await deps.backend.post("/api/v1/agent/tools/propose_unpause_token", {
|
|
983
|
+
tokenAddress: input.tokenAddress,
|
|
984
|
+
initialNavUsd6: input.initialNavUsd6
|
|
985
|
+
});
|
|
986
|
+
return ok(data);
|
|
987
|
+
} catch (e) {
|
|
988
|
+
return mapBackendError(e);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
async function issuerAuditQuery(input, deps) {
|
|
992
|
+
try {
|
|
993
|
+
const data = await deps.backend.get("/api/v1/agent/tools/audit_query", {
|
|
994
|
+
surface: input.surface,
|
|
995
|
+
eventTypes: input.eventTypes?.join(","),
|
|
996
|
+
since: input.since,
|
|
997
|
+
until: input.until,
|
|
998
|
+
cursor: input.cursor,
|
|
999
|
+
limit: input.limit
|
|
1000
|
+
});
|
|
1001
|
+
return ok(data);
|
|
1002
|
+
} catch (e) {
|
|
1003
|
+
return mapBackendError(e);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
async function readProtectionCoverage(input, deps) {
|
|
1007
|
+
try {
|
|
1008
|
+
const data = await deps.backend.post("/api/v1/agent/tools/check_protection_coverage", {
|
|
1009
|
+
tokenAddress: input.tokenAddress
|
|
1010
|
+
});
|
|
1011
|
+
return ok(data);
|
|
1012
|
+
} catch (e) {
|
|
1013
|
+
return mapBackendError(e);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
async function readKycAttestation(input, deps) {
|
|
1017
|
+
try {
|
|
1018
|
+
const data = await deps.backend.post("/api/v1/agent/tools/explain_kyc_attestation", {
|
|
1019
|
+
...input.investorAddress ? { investorAddress: input.investorAddress } : {}
|
|
1020
|
+
});
|
|
1021
|
+
return ok(data);
|
|
1022
|
+
} catch (e) {
|
|
1023
|
+
return mapBackendError(e);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
async function governancePropose(input, deps) {
|
|
1027
|
+
try {
|
|
1028
|
+
const data = await deps.backend.post("/api/v1/agent/tools/propose_governance_vote", {
|
|
1029
|
+
tokenAddress: input.tokenAddress,
|
|
1030
|
+
proposalType: input.proposalType
|
|
1031
|
+
});
|
|
1032
|
+
return ok(data);
|
|
1033
|
+
} catch (e) {
|
|
1034
|
+
return mapBackendError(e);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
async function governanceCastVote(input, deps) {
|
|
1038
|
+
try {
|
|
1039
|
+
const data = await deps.backend.post("/api/v1/agent/tools/cast_encrypted_vote", {
|
|
1040
|
+
proposalId: input.proposalId,
|
|
1041
|
+
voteYes: input.voteYes
|
|
1042
|
+
});
|
|
1043
|
+
return ok(data);
|
|
1044
|
+
} catch (e) {
|
|
1045
|
+
return mapBackendError(e);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// src/tools/registry.ts
|
|
1050
|
+
var HANDLERS = {
|
|
1051
|
+
"muhaven.read.portfolio": {
|
|
1052
|
+
schema: ReadPortfolioInputSchema,
|
|
1053
|
+
handler: readPortfolio
|
|
1054
|
+
},
|
|
1055
|
+
"muhaven.read.yields": {
|
|
1056
|
+
schema: ReadYieldsInputSchema,
|
|
1057
|
+
handler: readYields
|
|
1058
|
+
},
|
|
1059
|
+
"muhaven.read.distribution": {
|
|
1060
|
+
schema: ReadDistributionInputSchema,
|
|
1061
|
+
handler: readDistribution
|
|
1062
|
+
},
|
|
1063
|
+
"muhaven.read.tokens": {
|
|
1064
|
+
schema: ReadTokensInputSchema,
|
|
1065
|
+
handler: readTokens
|
|
1066
|
+
},
|
|
1067
|
+
"muhaven.read.audit": {
|
|
1068
|
+
schema: ReadAuditInputSchema,
|
|
1069
|
+
handler: readAudit
|
|
1070
|
+
},
|
|
1071
|
+
"muhaven.position.buy": {
|
|
1072
|
+
schema: PositionBuyInputSchema,
|
|
1073
|
+
handler: positionBuy
|
|
1074
|
+
},
|
|
1075
|
+
"muhaven.position.sell": {
|
|
1076
|
+
schema: PositionSellInputSchema,
|
|
1077
|
+
handler: positionSell
|
|
1078
|
+
},
|
|
1079
|
+
"muhaven.position.claim": {
|
|
1080
|
+
schema: PositionClaimInputSchema,
|
|
1081
|
+
handler: positionClaim
|
|
1082
|
+
},
|
|
1083
|
+
"muhaven.position.rebalance": {
|
|
1084
|
+
schema: PositionRebalanceInputSchema,
|
|
1085
|
+
handler: positionRebalance
|
|
1086
|
+
},
|
|
1087
|
+
"muhaven.policy.set_tier": {
|
|
1088
|
+
schema: PolicySetTierInputSchema,
|
|
1089
|
+
handler: policySetTier
|
|
1090
|
+
},
|
|
1091
|
+
"muhaven.policy.pause": {
|
|
1092
|
+
schema: PolicyPauseInputSchema,
|
|
1093
|
+
handler: policyPause
|
|
1094
|
+
},
|
|
1095
|
+
"muhaven.policy.audit_export": {
|
|
1096
|
+
schema: PolicyAuditExportInputSchema,
|
|
1097
|
+
handler: policyAuditExport
|
|
1098
|
+
},
|
|
1099
|
+
"muhaven.policy.session_key_status": {
|
|
1100
|
+
schema: PolicySessionKeyStatusInputSchema,
|
|
1101
|
+
handler: policySessionKeyStatus
|
|
1102
|
+
},
|
|
1103
|
+
// ── Wave 4 P7 — issuer group ────────────────────────────────────
|
|
1104
|
+
"muhaven.issuer.distribute_yield": {
|
|
1105
|
+
schema: IssuerDistributeYieldInputSchema,
|
|
1106
|
+
handler: issuerDistributeYield
|
|
1107
|
+
},
|
|
1108
|
+
"muhaven.issuer.kyc_add": {
|
|
1109
|
+
schema: IssuerKycAddInputSchema,
|
|
1110
|
+
handler: issuerKycAdd
|
|
1111
|
+
},
|
|
1112
|
+
"muhaven.issuer.kyc_remove": {
|
|
1113
|
+
schema: IssuerKycRemoveInputSchema,
|
|
1114
|
+
handler: issuerKycRemove
|
|
1115
|
+
},
|
|
1116
|
+
"muhaven.issuer.unpause_token": {
|
|
1117
|
+
schema: IssuerUnpauseTokenInputSchema,
|
|
1118
|
+
handler: issuerUnpauseToken
|
|
1119
|
+
},
|
|
1120
|
+
"muhaven.issuer.audit_query": {
|
|
1121
|
+
schema: IssuerAuditQueryInputSchema,
|
|
1122
|
+
handler: issuerAuditQuery
|
|
1123
|
+
},
|
|
1124
|
+
// ── Wave 4 P11 — governance / protection / KYC group ──────────────
|
|
1125
|
+
"muhaven.read.protection_coverage": {
|
|
1126
|
+
schema: ReadProtectionCoverageInputSchema,
|
|
1127
|
+
handler: readProtectionCoverage
|
|
1128
|
+
},
|
|
1129
|
+
"muhaven.read.kyc_attestation": {
|
|
1130
|
+
schema: ReadKycAttestationInputSchema,
|
|
1131
|
+
handler: readKycAttestation
|
|
1132
|
+
},
|
|
1133
|
+
"muhaven.governance.propose": {
|
|
1134
|
+
schema: GovernanceProposeInputSchema,
|
|
1135
|
+
handler: governancePropose
|
|
1136
|
+
},
|
|
1137
|
+
"muhaven.governance.cast_vote": {
|
|
1138
|
+
schema: GovernanceCastVoteInputSchema,
|
|
1139
|
+
handler: governanceCastVote
|
|
1140
|
+
}
|
|
1141
|
+
};
|
|
1142
|
+
var fullRegistry = TOOL_DESCRIPTORS.map((descriptor) => {
|
|
1143
|
+
const entry = HANDLERS[descriptor.name];
|
|
1144
|
+
if (!entry) {
|
|
1145
|
+
throw new Error(`Tool descriptor "${descriptor.name}" has no handler wired in registry.ts`);
|
|
1146
|
+
}
|
|
1147
|
+
return { descriptor, schema: entry.schema, handler: entry.handler };
|
|
1148
|
+
});
|
|
1149
|
+
var wiredNames = new Set(fullRegistry.map((e) => e.descriptor.name));
|
|
1150
|
+
for (const name of Object.keys(HANDLERS)) {
|
|
1151
|
+
if (!wiredNames.has(name)) {
|
|
1152
|
+
throw new Error(`Handler "${name}" has no descriptor in TOOL_DESCRIPTORS`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
function fullToolRegistry() {
|
|
1156
|
+
return fullRegistry;
|
|
1157
|
+
}
|
|
1158
|
+
function registryForReadOnly() {
|
|
1159
|
+
return fullRegistry.filter((e) => e.descriptor.group === "read");
|
|
1160
|
+
}
|
|
1161
|
+
function selectRegistry(readOnly) {
|
|
1162
|
+
return readOnly ? registryForReadOnly() : fullRegistry;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// src/server.ts
|
|
1166
|
+
var SERVER_NAME = "@muhaven/mcp";
|
|
1167
|
+
var SERVER_VERSION = "0.1.0";
|
|
1168
|
+
function toJsonInputSchema(schema) {
|
|
1169
|
+
return {
|
|
1170
|
+
type: "object",
|
|
1171
|
+
additionalProperties: false
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
async function loadPinnedToolHashes() {
|
|
1175
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1176
|
+
const candidates = [
|
|
1177
|
+
join(here, "..", "tool-hashes.json"),
|
|
1178
|
+
join(here, "tool-hashes.json")
|
|
1179
|
+
];
|
|
1180
|
+
for (const path of candidates) {
|
|
1181
|
+
try {
|
|
1182
|
+
const raw = await readFile(path, "utf8");
|
|
1183
|
+
const parsed = JSON.parse(raw);
|
|
1184
|
+
if (Array.isArray(parsed?.tools)) return parsed.tools;
|
|
1185
|
+
} catch {
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
function verifyToolHashes(pinned) {
|
|
1191
|
+
if (!pinned) return { ok: true, drift: [] };
|
|
1192
|
+
const pinnedMap = new Map(pinned.map((p) => [p.name, p.sha256]));
|
|
1193
|
+
const drift = [];
|
|
1194
|
+
for (const t of TOOL_DESCRIPTORS) {
|
|
1195
|
+
const live = hashToolDescriptor(t);
|
|
1196
|
+
const pin = pinnedMap.get(t.name);
|
|
1197
|
+
if (!pin || pin !== live) {
|
|
1198
|
+
drift.push({ name: t.name, live, pinned: pin ?? "<missing>" });
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
return { ok: drift.length === 0, drift };
|
|
1202
|
+
}
|
|
1203
|
+
function buildMcpServer(opts) {
|
|
1204
|
+
const server = new Server(
|
|
1205
|
+
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
1206
|
+
{ capabilities: { tools: {} } }
|
|
1207
|
+
);
|
|
1208
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
1209
|
+
return {
|
|
1210
|
+
tools: opts.registry.map((entry) => ({
|
|
1211
|
+
name: entry.descriptor.name,
|
|
1212
|
+
description: entry.descriptor.description,
|
|
1213
|
+
inputSchema: toJsonInputSchema(entry.schema)
|
|
1214
|
+
}))
|
|
1215
|
+
};
|
|
1216
|
+
});
|
|
1217
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
1218
|
+
const name = req.params.name;
|
|
1219
|
+
const entry = opts.registry.find((e) => e.descriptor.name === name);
|
|
1220
|
+
if (!entry) {
|
|
1221
|
+
return toolJsonResponse({ ok: false, code: "unknown_tool", message: `unknown tool: ${name}` });
|
|
1222
|
+
}
|
|
1223
|
+
let parsed;
|
|
1224
|
+
try {
|
|
1225
|
+
parsed = entry.schema.parse(req.params.arguments ?? {});
|
|
1226
|
+
} catch (err2) {
|
|
1227
|
+
if (err2 instanceof ZodError) {
|
|
1228
|
+
return toolJsonResponse({
|
|
1229
|
+
ok: false,
|
|
1230
|
+
code: "invalid_input",
|
|
1231
|
+
message: "tool input failed schema validation",
|
|
1232
|
+
issues: err2.issues
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
throw err2;
|
|
1236
|
+
}
|
|
1237
|
+
try {
|
|
1238
|
+
const result = await entry.handler(parsed, {
|
|
1239
|
+
backend: opts.backend,
|
|
1240
|
+
broker: opts.broker,
|
|
1241
|
+
surface: "mcp"
|
|
1242
|
+
});
|
|
1243
|
+
return toolJsonResponse(result);
|
|
1244
|
+
} catch (err2) {
|
|
1245
|
+
if (err2 instanceof NoJwtAvailableError) {
|
|
1246
|
+
return toolJsonResponse(authRequiredPayload());
|
|
1247
|
+
}
|
|
1248
|
+
if (err2 instanceof BackendError && err2.code === "unauthorized") {
|
|
1249
|
+
return toolJsonResponse(authRequiredPayload());
|
|
1250
|
+
}
|
|
1251
|
+
if (err2 instanceof BackendError) {
|
|
1252
|
+
return toolJsonResponse({ ok: false, code: `backend.${err2.code}`, message: err2.message });
|
|
1253
|
+
}
|
|
1254
|
+
if (err2 instanceof BrokerClientError) {
|
|
1255
|
+
return toolJsonResponse({ ok: false, code: `broker.${err2.code}`, message: err2.message });
|
|
1256
|
+
}
|
|
1257
|
+
return toolJsonResponse({
|
|
1258
|
+
ok: false,
|
|
1259
|
+
code: "internal",
|
|
1260
|
+
message: err2 instanceof Error ? err2.message : String(err2)
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
return server;
|
|
1265
|
+
}
|
|
1266
|
+
function toolJsonResponse(payload) {
|
|
1267
|
+
return {
|
|
1268
|
+
content: [{ type: "text", text: JSON.stringify(payload) }]
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
async function runMcpStdioCli(opts = {}) {
|
|
1272
|
+
const config = loadMcpConfig();
|
|
1273
|
+
const pinned = await loadPinnedToolHashes();
|
|
1274
|
+
const verify = verifyToolHashes(pinned);
|
|
1275
|
+
if (!verify.ok) {
|
|
1276
|
+
process.stderr.write(
|
|
1277
|
+
"tool-description hash drift detected \u2014 refusing to start. drift:\n" + JSON.stringify(verify.drift, null, 2) + "\n"
|
|
1278
|
+
);
|
|
1279
|
+
process.exit(70);
|
|
1280
|
+
}
|
|
1281
|
+
const broker = new BrokerClient({
|
|
1282
|
+
endpoint: config.brokerEndpoint,
|
|
1283
|
+
timeoutMs: config.brokerTimeoutMs
|
|
1284
|
+
});
|
|
1285
|
+
const jwtSource = new JwtSource(broker, config.jwtCacheTtlSec);
|
|
1286
|
+
const backend = new BackendClient({
|
|
1287
|
+
baseUrl: config.backendBaseUrl,
|
|
1288
|
+
jwtSource,
|
|
1289
|
+
timeoutMs: config.requestTimeoutMs,
|
|
1290
|
+
allowedHosts: config.allowedBackendHosts
|
|
1291
|
+
});
|
|
1292
|
+
const baseRegistry = selectRegistry(config.readOnly);
|
|
1293
|
+
const registry = opts.filterRegistry ? opts.filterRegistry(baseRegistry) : baseRegistry;
|
|
1294
|
+
if (registry.length === 0) {
|
|
1295
|
+
process.stderr.write(
|
|
1296
|
+
"[muhaven-mcp] tool registry is empty after filtering \u2014 refusing to start.\n"
|
|
1297
|
+
);
|
|
1298
|
+
process.exit(70);
|
|
1299
|
+
}
|
|
1300
|
+
const server = buildMcpServer({
|
|
1301
|
+
registry,
|
|
1302
|
+
backend,
|
|
1303
|
+
broker: config.readOnly ? void 0 : broker
|
|
1304
|
+
});
|
|
1305
|
+
const transport = new StdioServerTransport();
|
|
1306
|
+
await server.connect(transport);
|
|
1307
|
+
await new Promise((resolve) => {
|
|
1308
|
+
process.stdin.once("end", resolve);
|
|
1309
|
+
process.stdin.once("close", resolve);
|
|
1310
|
+
process.once("SIGINT", resolve);
|
|
1311
|
+
process.once("SIGTERM", resolve);
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// src/auth/device-flow.ts
|
|
1316
|
+
var DEFAULT_POLL_INTERVAL_MS = 2e3;
|
|
1317
|
+
var DEFAULT_OVERALL_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
1318
|
+
var DeviceFlowAbortedError = class extends Error {
|
|
1319
|
+
constructor(detail) {
|
|
1320
|
+
super(`device flow aborted: ${detail.code}`);
|
|
1321
|
+
this.detail = detail;
|
|
1322
|
+
this.name = "DeviceFlowAbortedError";
|
|
1323
|
+
}
|
|
1324
|
+
detail;
|
|
1325
|
+
};
|
|
1326
|
+
var DeviceFlowClient = class {
|
|
1327
|
+
constructor(options) {
|
|
1328
|
+
this.options = options;
|
|
1329
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
1330
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1331
|
+
this.nowMs = options.nowMs ?? (() => Date.now());
|
|
1332
|
+
}
|
|
1333
|
+
options;
|
|
1334
|
+
fetchImpl;
|
|
1335
|
+
sleep;
|
|
1336
|
+
nowMs;
|
|
1337
|
+
/**
|
|
1338
|
+
* Run the full ceremony: request a code, yield events for the caller
|
|
1339
|
+
* to display the URL, then poll until authorized / denied / expired.
|
|
1340
|
+
* Throws `DeviceFlowAbortedError` on terminal failure.
|
|
1341
|
+
*/
|
|
1342
|
+
async *run(opts) {
|
|
1343
|
+
const overallTimeout = opts?.overallTimeoutMs ?? DEFAULT_OVERALL_TIMEOUT_MS;
|
|
1344
|
+
const code = await this.requestCode();
|
|
1345
|
+
yield { type: "code_issued", code };
|
|
1346
|
+
const startedAt = this.nowMs();
|
|
1347
|
+
const pollMs = Math.max(1, code.pollIntervalSec) * 1e3;
|
|
1348
|
+
let attempt = 0;
|
|
1349
|
+
while (this.nowMs() - startedAt < overallTimeout) {
|
|
1350
|
+
attempt += 1;
|
|
1351
|
+
yield { type: "polling", attempt, nextPollMs: pollMs };
|
|
1352
|
+
await this.sleep(pollMs);
|
|
1353
|
+
const res = await this.pollOnce(code.deviceCode);
|
|
1354
|
+
switch (res.state) {
|
|
1355
|
+
case "pending":
|
|
1356
|
+
continue;
|
|
1357
|
+
case "authorized":
|
|
1358
|
+
if (!res.jwt) {
|
|
1359
|
+
throw new DeviceFlowAbortedError({
|
|
1360
|
+
code: "invalid_response",
|
|
1361
|
+
body: { reason: "authorized state missing jwt" }
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
yield {
|
|
1365
|
+
type: "authorized",
|
|
1366
|
+
jwt: res.jwt,
|
|
1367
|
+
expiresAtSec: res.expiresAtSec ?? null,
|
|
1368
|
+
scope: res.scope ?? null
|
|
1369
|
+
};
|
|
1370
|
+
return {
|
|
1371
|
+
jwt: res.jwt,
|
|
1372
|
+
expiresAtSec: res.expiresAtSec ?? null,
|
|
1373
|
+
scope: res.scope ?? null
|
|
1374
|
+
};
|
|
1375
|
+
case "denied":
|
|
1376
|
+
yield { type: "denied", reason: res.reason };
|
|
1377
|
+
throw new DeviceFlowAbortedError({ code: "denied", reason: res.reason });
|
|
1378
|
+
case "expired":
|
|
1379
|
+
yield { type: "expired" };
|
|
1380
|
+
throw new DeviceFlowAbortedError({ code: "expired" });
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
throw new DeviceFlowAbortedError({ code: "timeout" });
|
|
1384
|
+
}
|
|
1385
|
+
async requestCode() {
|
|
1386
|
+
const url = new URL("/api/v1/auth/device/code", this.options.backendBaseUrl);
|
|
1387
|
+
const requesterMetadata = {
|
|
1388
|
+
processName: this.options.requesterMetadata?.processName ?? "muhaven-broker",
|
|
1389
|
+
hostname: this.options.requesterMetadata?.hostname ?? "",
|
|
1390
|
+
os: this.options.requesterMetadata?.os ?? ""
|
|
1391
|
+
};
|
|
1392
|
+
let res;
|
|
1393
|
+
try {
|
|
1394
|
+
res = await this.fetchImpl(url, {
|
|
1395
|
+
method: "POST",
|
|
1396
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
1397
|
+
body: JSON.stringify({ requesterMetadata })
|
|
1398
|
+
});
|
|
1399
|
+
} catch (err2) {
|
|
1400
|
+
throw new DeviceFlowAbortedError({ code: "network", cause: err2 });
|
|
1401
|
+
}
|
|
1402
|
+
if (res.status === 429) {
|
|
1403
|
+
throw new DeviceFlowAbortedError({ code: "rate_limited" });
|
|
1404
|
+
}
|
|
1405
|
+
if (res.status >= 400) {
|
|
1406
|
+
const body2 = await safeJson(res);
|
|
1407
|
+
throw new DeviceFlowAbortedError({ code: "invalid_response", status: res.status, body: body2 });
|
|
1408
|
+
}
|
|
1409
|
+
const body = await safeJson(res);
|
|
1410
|
+
if (!body || !body.deviceCode || !body.userCode) {
|
|
1411
|
+
throw new DeviceFlowAbortedError({ code: "invalid_response", status: res.status, body });
|
|
1412
|
+
}
|
|
1413
|
+
const verificationUri = `${trim(this.options.dashboardBaseUrl)}/link`;
|
|
1414
|
+
const verificationUriComplete = `${verificationUri}?code=${encodeURIComponent(body.userCode)}`;
|
|
1415
|
+
return {
|
|
1416
|
+
deviceCode: body.deviceCode,
|
|
1417
|
+
userCode: body.userCode,
|
|
1418
|
+
verificationUri,
|
|
1419
|
+
verificationUriComplete,
|
|
1420
|
+
expiresInSec: body.expiresInSec ?? 300,
|
|
1421
|
+
pollIntervalSec: body.pollIntervalSec ?? Math.floor(DEFAULT_POLL_INTERVAL_MS / 1e3)
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
async pollOnce(deviceCode) {
|
|
1425
|
+
const url = new URL("/api/v1/auth/device/token", this.options.backendBaseUrl);
|
|
1426
|
+
let res;
|
|
1427
|
+
try {
|
|
1428
|
+
res = await this.fetchImpl(url, {
|
|
1429
|
+
method: "POST",
|
|
1430
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
1431
|
+
body: JSON.stringify({ deviceCode })
|
|
1432
|
+
});
|
|
1433
|
+
} catch (err2) {
|
|
1434
|
+
throw new DeviceFlowAbortedError({ code: "network", cause: err2 });
|
|
1435
|
+
}
|
|
1436
|
+
if (res.status === 429) {
|
|
1437
|
+
return { state: "pending" };
|
|
1438
|
+
}
|
|
1439
|
+
const body = await safeJson(res);
|
|
1440
|
+
if (!body || typeof body.state !== "string") {
|
|
1441
|
+
throw new DeviceFlowAbortedError({ code: "invalid_response", status: res.status, body });
|
|
1442
|
+
}
|
|
1443
|
+
return body;
|
|
1444
|
+
}
|
|
1445
|
+
};
|
|
1446
|
+
function trim(s) {
|
|
1447
|
+
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
1448
|
+
}
|
|
1449
|
+
async function safeJson(res) {
|
|
1450
|
+
try {
|
|
1451
|
+
return await res.json();
|
|
1452
|
+
} catch {
|
|
1453
|
+
return null;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// src/broker/protocol.ts
|
|
1458
|
+
var BROKER_PROTOCOL_VERSION = "0.2.0";
|
|
1459
|
+
var HASH_HEX_RE = /^0x[0-9a-fA-F]{64}$/;
|
|
1460
|
+
var JWT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
1461
|
+
function isHashHex(value) {
|
|
1462
|
+
return typeof value === "string" && HASH_HEX_RE.test(value);
|
|
1463
|
+
}
|
|
1464
|
+
function isJwtShape(value) {
|
|
1465
|
+
return typeof value === "string" && value.length <= 8192 && JWT_RE.test(value);
|
|
1466
|
+
}
|
|
1467
|
+
function parseBrokerRequest(line) {
|
|
1468
|
+
let parsed;
|
|
1469
|
+
try {
|
|
1470
|
+
parsed = JSON.parse(line);
|
|
1471
|
+
} catch {
|
|
1472
|
+
return { type: "error", code: "invalid_request", message: "request is not valid JSON" };
|
|
1473
|
+
}
|
|
1474
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
1475
|
+
return { type: "error", code: "invalid_request", message: "request must be a JSON object" };
|
|
1476
|
+
}
|
|
1477
|
+
const obj = parsed;
|
|
1478
|
+
switch (obj.type) {
|
|
1479
|
+
case "hello":
|
|
1480
|
+
return { type: "hello" };
|
|
1481
|
+
case "sign_hash": {
|
|
1482
|
+
const hash = obj.hash;
|
|
1483
|
+
if (!isHashHex(hash)) {
|
|
1484
|
+
return {
|
|
1485
|
+
type: "error",
|
|
1486
|
+
code: "invalid_request",
|
|
1487
|
+
message: "sign_hash.hash must be a 0x-prefixed 32-byte hex string"
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
const intent = obj.intent;
|
|
1491
|
+
const intentValid = intent === void 0 || typeof intent === "object" && intent !== null && typeof intent.tool === "string";
|
|
1492
|
+
if (!intentValid) {
|
|
1493
|
+
return {
|
|
1494
|
+
type: "error",
|
|
1495
|
+
code: "invalid_request",
|
|
1496
|
+
message: "sign_hash.intent.tool must be a string when provided"
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
return {
|
|
1500
|
+
type: "sign_hash",
|
|
1501
|
+
hash,
|
|
1502
|
+
...intent === void 0 ? {} : { intent }
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
case "store_jwt": {
|
|
1506
|
+
const jwt = obj.jwt;
|
|
1507
|
+
if (!isJwtShape(jwt)) {
|
|
1508
|
+
return {
|
|
1509
|
+
type: "error",
|
|
1510
|
+
code: "invalid_request",
|
|
1511
|
+
message: "store_jwt.jwt must be a JWT-shaped string \u22648192 chars"
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
const expiresAtSec = obj.expiresAtSec;
|
|
1515
|
+
const expiresValid = expiresAtSec === void 0 || typeof expiresAtSec === "number" && Number.isFinite(expiresAtSec) && expiresAtSec > 0;
|
|
1516
|
+
if (!expiresValid) {
|
|
1517
|
+
return {
|
|
1518
|
+
type: "error",
|
|
1519
|
+
code: "invalid_request",
|
|
1520
|
+
message: "store_jwt.expiresAtSec must be a positive number when provided"
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
return {
|
|
1524
|
+
type: "store_jwt",
|
|
1525
|
+
jwt,
|
|
1526
|
+
...expiresAtSec === void 0 ? {} : { expiresAtSec }
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
case "get_jwt":
|
|
1530
|
+
return { type: "get_jwt" };
|
|
1531
|
+
case "clear_jwt":
|
|
1532
|
+
return { type: "clear_jwt" };
|
|
1533
|
+
default:
|
|
1534
|
+
return {
|
|
1535
|
+
type: "error",
|
|
1536
|
+
code: "unsupported_type",
|
|
1537
|
+
message: `unsupported request type: ${String(obj.type)}`
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
function serializeResponse(res) {
|
|
1542
|
+
return JSON.stringify(res) + "\n";
|
|
1543
|
+
}
|
|
1544
|
+
var ViemSigner = class {
|
|
1545
|
+
account;
|
|
1546
|
+
constructor(privateKey) {
|
|
1547
|
+
this.account = privateKeyToAccount(privateKey);
|
|
1548
|
+
}
|
|
1549
|
+
get address() {
|
|
1550
|
+
return this.account.address;
|
|
1551
|
+
}
|
|
1552
|
+
async signHash(hash) {
|
|
1553
|
+
return this.account.sign({ hash });
|
|
1554
|
+
}
|
|
1555
|
+
};
|
|
1556
|
+
var KEYRING_SERVICE = "muhaven.mcp";
|
|
1557
|
+
var KEYRING_ACCOUNT = "jwt";
|
|
1558
|
+
async function loadKeyringModule() {
|
|
1559
|
+
try {
|
|
1560
|
+
const moduleName = "@napi-rs/keyring";
|
|
1561
|
+
return await import(moduleName);
|
|
1562
|
+
} catch {
|
|
1563
|
+
return null;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
var OsKeystore = class {
|
|
1567
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1568
|
+
constructor(entry) {
|
|
1569
|
+
this.entry = entry;
|
|
1570
|
+
}
|
|
1571
|
+
entry;
|
|
1572
|
+
backend = "os";
|
|
1573
|
+
available = true;
|
|
1574
|
+
async set(record) {
|
|
1575
|
+
try {
|
|
1576
|
+
this.entry.setPassword(JSON.stringify(record));
|
|
1577
|
+
} catch (err2) {
|
|
1578
|
+
this.available = false;
|
|
1579
|
+
throw new KeystoreError(
|
|
1580
|
+
"os_keystore_unavailable",
|
|
1581
|
+
`OS keychain rejected write \u2014 ${asMessage(err2)}. Try MUHAVEN_KEYRING=file or run \`muhaven-broker doctor\`.`,
|
|
1582
|
+
err2
|
|
1583
|
+
);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
async get() {
|
|
1587
|
+
try {
|
|
1588
|
+
const raw = this.entry.getPassword();
|
|
1589
|
+
if (!raw) return null;
|
|
1590
|
+
return parseRecord(raw);
|
|
1591
|
+
} catch (err2) {
|
|
1592
|
+
this.available = false;
|
|
1593
|
+
throw new KeystoreError(
|
|
1594
|
+
"os_keystore_unavailable",
|
|
1595
|
+
`OS keychain read failed \u2014 ${asMessage(err2)}. Try MUHAVEN_KEYRING=file or run \`muhaven-broker doctor\`.`,
|
|
1596
|
+
err2
|
|
1597
|
+
);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
async clear() {
|
|
1601
|
+
try {
|
|
1602
|
+
this.entry.deletePassword();
|
|
1603
|
+
} catch (err2) {
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
};
|
|
1607
|
+
var FileKeystore = class {
|
|
1608
|
+
constructor(path) {
|
|
1609
|
+
this.path = path;
|
|
1610
|
+
}
|
|
1611
|
+
path;
|
|
1612
|
+
backend = "file";
|
|
1613
|
+
available = true;
|
|
1614
|
+
static defaultPath() {
|
|
1615
|
+
return join(homedir(), ".muhaven", "jwt");
|
|
1616
|
+
}
|
|
1617
|
+
async set(record) {
|
|
1618
|
+
const parent = dirname(this.path);
|
|
1619
|
+
await mkdir(parent, { recursive: true, mode: 448 });
|
|
1620
|
+
await chmod(parent, 448).catch(() => void 0);
|
|
1621
|
+
await writeFile(this.path, JSON.stringify(record), { mode: 384 });
|
|
1622
|
+
await chmod(this.path, 384).catch(() => void 0);
|
|
1623
|
+
}
|
|
1624
|
+
async get() {
|
|
1625
|
+
try {
|
|
1626
|
+
const raw = await readFile(this.path, "utf8");
|
|
1627
|
+
return parseRecord(raw);
|
|
1628
|
+
} catch (err2) {
|
|
1629
|
+
if (err2.code === "ENOENT") return null;
|
|
1630
|
+
throw new KeystoreError("file_read_failed", asMessage(err2), err2);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
async clear() {
|
|
1634
|
+
try {
|
|
1635
|
+
await unlink(this.path);
|
|
1636
|
+
} catch (err2) {
|
|
1637
|
+
if (err2.code === "ENOENT") return;
|
|
1638
|
+
throw new KeystoreError("file_clear_failed", asMessage(err2), err2);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
};
|
|
1642
|
+
var KeystoreError = class extends Error {
|
|
1643
|
+
constructor(code, message, cause) {
|
|
1644
|
+
super(message);
|
|
1645
|
+
this.code = code;
|
|
1646
|
+
this.cause = cause;
|
|
1647
|
+
this.name = "KeystoreError";
|
|
1648
|
+
}
|
|
1649
|
+
code;
|
|
1650
|
+
cause;
|
|
1651
|
+
};
|
|
1652
|
+
function parseRecord(raw) {
|
|
1653
|
+
try {
|
|
1654
|
+
const parsed = JSON.parse(raw);
|
|
1655
|
+
if (!parsed || typeof parsed.jwt !== "string") return null;
|
|
1656
|
+
return {
|
|
1657
|
+
jwt: parsed.jwt,
|
|
1658
|
+
expiresAtSec: typeof parsed.expiresAtSec === "number" ? parsed.expiresAtSec : null,
|
|
1659
|
+
storedAtSec: typeof parsed.storedAtSec === "number" ? parsed.storedAtSec : 0
|
|
1660
|
+
};
|
|
1661
|
+
} catch {
|
|
1662
|
+
throw new KeystoreError("malformed_record", "keystore record is not valid JSON");
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
function asMessage(err2) {
|
|
1666
|
+
return err2 instanceof Error ? err2.message : String(err2);
|
|
1667
|
+
}
|
|
1668
|
+
async function openKeystore(options = {}) {
|
|
1669
|
+
const envPref = process.env.MUHAVEN_KEYRING?.toLowerCase();
|
|
1670
|
+
const wantFile = options.preferred === "file" || envPref === "file";
|
|
1671
|
+
const filePath = options.filePath ?? FileKeystore.defaultPath();
|
|
1672
|
+
if (wantFile) {
|
|
1673
|
+
return { keystore: new FileKeystore(filePath), fallbackReason: null };
|
|
1674
|
+
}
|
|
1675
|
+
const mod = await loadKeyringModule();
|
|
1676
|
+
if (!mod) {
|
|
1677
|
+
return {
|
|
1678
|
+
keystore: new FileKeystore(filePath),
|
|
1679
|
+
fallbackReason: "@napi-rs/keyring not installed for this platform"
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
const Entry = mod.Entry;
|
|
1683
|
+
if (!Entry) {
|
|
1684
|
+
return {
|
|
1685
|
+
keystore: new FileKeystore(filePath),
|
|
1686
|
+
fallbackReason: "@napi-rs/keyring loaded but Entry constructor missing"
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
let entry;
|
|
1690
|
+
try {
|
|
1691
|
+
entry = new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT);
|
|
1692
|
+
const raw = entry.getPassword();
|
|
1693
|
+
if (raw && typeof raw === "string") {
|
|
1694
|
+
const parsed = parseRecord(raw);
|
|
1695
|
+
if (parsed === null) {
|
|
1696
|
+
return {
|
|
1697
|
+
keystore: new FileKeystore(filePath),
|
|
1698
|
+
fallbackReason: "OS keychain held a record that did not contain a recognizable JWT \u2014 falling back to file. Run `muhaven-broker logout` if you want to clean it up."
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
} catch (err2) {
|
|
1703
|
+
const isMalformed = err2 instanceof KeystoreError && err2.code === "malformed_record";
|
|
1704
|
+
return {
|
|
1705
|
+
keystore: new FileKeystore(filePath),
|
|
1706
|
+
fallbackReason: isMalformed ? `OS keychain held a malformed JWT record \u2014 falling back to file. ${asMessage(err2)}` : `OS keychain probe failed: ${asMessage(err2)}`
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
return { keystore: new OsKeystore(entry), fallbackReason: null };
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// src/broker/daemon.ts
|
|
1713
|
+
var noopLogger = (_e) => {
|
|
1714
|
+
};
|
|
1715
|
+
async function handleBrokerRequest(req, signer, keystore, nowSec = () => Math.floor(Date.now() / 1e3)) {
|
|
1716
|
+
switch (req.type) {
|
|
1717
|
+
case "hello": {
|
|
1718
|
+
let hasJwt = false;
|
|
1719
|
+
try {
|
|
1720
|
+
const record = await keystore.get();
|
|
1721
|
+
hasJwt = record !== null;
|
|
1722
|
+
} catch {
|
|
1723
|
+
hasJwt = false;
|
|
1724
|
+
}
|
|
1725
|
+
return {
|
|
1726
|
+
type: "hello",
|
|
1727
|
+
version: BROKER_PROTOCOL_VERSION,
|
|
1728
|
+
sessionKeyAddress: signer.address,
|
|
1729
|
+
hasJwt
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
case "sign_hash": {
|
|
1733
|
+
const signature = await signer.signHash(req.hash);
|
|
1734
|
+
return { type: "sign_hash", signature, signerAddress: signer.address };
|
|
1735
|
+
}
|
|
1736
|
+
case "store_jwt": {
|
|
1737
|
+
try {
|
|
1738
|
+
await keystore.set({
|
|
1739
|
+
jwt: req.jwt,
|
|
1740
|
+
expiresAtSec: req.expiresAtSec ?? null,
|
|
1741
|
+
storedAtSec: nowSec()
|
|
1742
|
+
});
|
|
1743
|
+
return { type: "store_jwt", stored: true };
|
|
1744
|
+
} catch (err2) {
|
|
1745
|
+
return errorResponse(
|
|
1746
|
+
"keystore_unavailable",
|
|
1747
|
+
err2 instanceof Error ? err2.message : "keystore write failed"
|
|
1748
|
+
);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
case "get_jwt": {
|
|
1752
|
+
try {
|
|
1753
|
+
const record = await keystore.get();
|
|
1754
|
+
return {
|
|
1755
|
+
type: "get_jwt",
|
|
1756
|
+
jwt: record?.jwt ?? null,
|
|
1757
|
+
expiresAtSec: record?.expiresAtSec ?? null
|
|
1758
|
+
};
|
|
1759
|
+
} catch (err2) {
|
|
1760
|
+
return errorResponse(
|
|
1761
|
+
"keystore_unavailable",
|
|
1762
|
+
err2 instanceof Error ? err2.message : "keystore read failed"
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
case "clear_jwt": {
|
|
1767
|
+
try {
|
|
1768
|
+
await keystore.clear();
|
|
1769
|
+
return { type: "clear_jwt", cleared: true };
|
|
1770
|
+
} catch (err2) {
|
|
1771
|
+
return errorResponse(
|
|
1772
|
+
"keystore_unavailable",
|
|
1773
|
+
err2 instanceof Error ? err2.message : "keystore clear failed"
|
|
1774
|
+
);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
function errorResponse(code, message) {
|
|
1780
|
+
return { type: "error", code, message };
|
|
1781
|
+
}
|
|
1782
|
+
async function prepareEndpoint(endpoint) {
|
|
1783
|
+
if (platform() === "win32") return;
|
|
1784
|
+
const parent = dirname(endpoint);
|
|
1785
|
+
await mkdir(parent, { recursive: true, mode: 448 });
|
|
1786
|
+
await chmod(parent, 448);
|
|
1787
|
+
try {
|
|
1788
|
+
const s = await stat(endpoint);
|
|
1789
|
+
if (s.isSocket() || s.isFIFO()) {
|
|
1790
|
+
await unlink(endpoint);
|
|
1791
|
+
}
|
|
1792
|
+
} catch (err2) {
|
|
1793
|
+
if (err2.code !== "ENOENT") throw err2;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
async function applySocketPermissions(endpoint) {
|
|
1797
|
+
if (platform() === "win32") return;
|
|
1798
|
+
await chmod(endpoint, 384);
|
|
1799
|
+
}
|
|
1800
|
+
var BrokerDaemon = class {
|
|
1801
|
+
server;
|
|
1802
|
+
signer;
|
|
1803
|
+
log;
|
|
1804
|
+
config;
|
|
1805
|
+
keystore;
|
|
1806
|
+
constructor(options) {
|
|
1807
|
+
this.config = options.config;
|
|
1808
|
+
this.signer = options.signer ?? new ViemSigner(options.config.sessionKeyHex);
|
|
1809
|
+
this.keystore = options.keystore ?? null;
|
|
1810
|
+
this.log = options.logger ?? noopLogger;
|
|
1811
|
+
this.server = createServer((socket) => this.onConnection(socket));
|
|
1812
|
+
}
|
|
1813
|
+
async start() {
|
|
1814
|
+
if (!this.keystore) {
|
|
1815
|
+
const { keystore, fallbackReason } = await openKeystore();
|
|
1816
|
+
this.keystore = keystore;
|
|
1817
|
+
if (fallbackReason) {
|
|
1818
|
+
this.log({
|
|
1819
|
+
level: "warn",
|
|
1820
|
+
msg: "OS keychain unavailable \u2014 falling back to file-backed keystore",
|
|
1821
|
+
meta: { reason: fallbackReason }
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
await prepareEndpoint(this.config.endpoint);
|
|
1826
|
+
await new Promise((resolve, reject) => {
|
|
1827
|
+
const onError = (err2) => {
|
|
1828
|
+
this.server.off("listening", onListening);
|
|
1829
|
+
reject(err2);
|
|
1830
|
+
};
|
|
1831
|
+
const onListening = () => {
|
|
1832
|
+
this.server.off("error", onError);
|
|
1833
|
+
resolve();
|
|
1834
|
+
};
|
|
1835
|
+
this.server.once("error", onError);
|
|
1836
|
+
this.server.once("listening", onListening);
|
|
1837
|
+
this.server.listen(this.config.endpoint);
|
|
1838
|
+
});
|
|
1839
|
+
await applySocketPermissions(this.config.endpoint);
|
|
1840
|
+
this.log({
|
|
1841
|
+
level: "info",
|
|
1842
|
+
msg: "broker daemon listening",
|
|
1843
|
+
meta: {
|
|
1844
|
+
endpoint: this.config.endpoint,
|
|
1845
|
+
signer: this.signer.address,
|
|
1846
|
+
keystore: this.keystore.backend,
|
|
1847
|
+
version: BROKER_PROTOCOL_VERSION
|
|
1848
|
+
}
|
|
1849
|
+
});
|
|
1850
|
+
return this.config.endpoint;
|
|
1851
|
+
}
|
|
1852
|
+
async stop() {
|
|
1853
|
+
await new Promise((resolve, reject) => {
|
|
1854
|
+
this.server.close((err2) => err2 ? reject(err2) : resolve());
|
|
1855
|
+
});
|
|
1856
|
+
if (platform() !== "win32") {
|
|
1857
|
+
try {
|
|
1858
|
+
await unlink(this.config.endpoint);
|
|
1859
|
+
} catch (err2) {
|
|
1860
|
+
if (err2.code !== "ENOENT") {
|
|
1861
|
+
this.log({ level: "warn", msg: "failed to unlink socket on stop", meta: { err: err2 } });
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
this.log({ level: "info", msg: "broker daemon stopped" });
|
|
1866
|
+
}
|
|
1867
|
+
onConnection(socket) {
|
|
1868
|
+
let buffer = "";
|
|
1869
|
+
let bytesReceived = 0;
|
|
1870
|
+
const timeout = setTimeout(() => {
|
|
1871
|
+
this.log({ level: "warn", msg: "connection timeout \u2014 closing socket" });
|
|
1872
|
+
socket.destroy();
|
|
1873
|
+
}, this.config.requestTimeoutMs);
|
|
1874
|
+
const cleanup = () => {
|
|
1875
|
+
clearTimeout(timeout);
|
|
1876
|
+
socket.removeAllListeners("data");
|
|
1877
|
+
socket.removeAllListeners("end");
|
|
1878
|
+
socket.removeAllListeners("error");
|
|
1879
|
+
};
|
|
1880
|
+
socket.on("data", (chunk) => {
|
|
1881
|
+
bytesReceived += chunk.length;
|
|
1882
|
+
if (bytesReceived > this.config.maxRequestBytes) {
|
|
1883
|
+
const res = errorResponse("payload_too_large", "request exceeded maxRequestBytes");
|
|
1884
|
+
socket.end(serializeResponse(res));
|
|
1885
|
+
cleanup();
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
buffer += chunk.toString("utf8");
|
|
1889
|
+
const newlineIdx = buffer.indexOf("\n");
|
|
1890
|
+
if (newlineIdx < 0) return;
|
|
1891
|
+
const trailing = buffer.slice(newlineIdx + 1);
|
|
1892
|
+
if (trailing.length > 0) {
|
|
1893
|
+
const res = errorResponse(
|
|
1894
|
+
"invalid_request",
|
|
1895
|
+
"broker is single-shot \u2014 extra bytes after first newline"
|
|
1896
|
+
);
|
|
1897
|
+
socket.end(serializeResponse(res));
|
|
1898
|
+
cleanup();
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
const line = buffer.slice(0, newlineIdx);
|
|
1902
|
+
const parsed = parseBrokerRequest(line);
|
|
1903
|
+
void this.runAndRespond(parsed, socket).finally(() => {
|
|
1904
|
+
cleanup();
|
|
1905
|
+
});
|
|
1906
|
+
});
|
|
1907
|
+
socket.on("error", (err2) => {
|
|
1908
|
+
this.log({ level: "warn", msg: "socket error", meta: { err: err2.message } });
|
|
1909
|
+
cleanup();
|
|
1910
|
+
});
|
|
1911
|
+
socket.on("end", () => {
|
|
1912
|
+
cleanup();
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
async runAndRespond(parsed, socket) {
|
|
1916
|
+
if ("type" in parsed && parsed.type === "error") {
|
|
1917
|
+
socket.end(serializeResponse(parsed));
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
if (!this.keystore) {
|
|
1921
|
+
socket.end(
|
|
1922
|
+
serializeResponse(errorResponse("internal", "keystore not initialized"))
|
|
1923
|
+
);
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
try {
|
|
1927
|
+
const res = await handleBrokerRequest(parsed, this.signer, this.keystore);
|
|
1928
|
+
socket.end(serializeResponse(res));
|
|
1929
|
+
} catch (err2) {
|
|
1930
|
+
this.log({
|
|
1931
|
+
level: "error",
|
|
1932
|
+
msg: "handler failed",
|
|
1933
|
+
meta: { err: err2 instanceof Error ? err2.message : String(err2) }
|
|
1934
|
+
});
|
|
1935
|
+
socket.end(
|
|
1936
|
+
serializeResponse(errorResponse("internal", "broker handler failed; check broker logs"))
|
|
1937
|
+
);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
};
|
|
1941
|
+
|
|
1942
|
+
export { BROKER_PROTOCOL_VERSION, BackendClient, BackendError, BrokerClient, BrokerClientError, BrokerDaemon, DeviceFlowAbortedError, DeviceFlowClient, JwtSource, KeystoreError, NoJwtAvailableError, TOOL_DESCRIPTORS, buildMcpServer, buildToolHashTable, defaultBrokerEndpoint, fullToolRegistry, handleBrokerRequest, hashToolDescriptor, loadBrokerConfig, loadMcpConfig, openKeystore, parseBrokerRequest, registryForReadOnly, runMcpStdioCli, selectRegistry, serializeResponse, verifyDescriptorAgainstPin };
|