@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.d.cts
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wire protocol between the MCP server (`@muhaven/mcp` STDIO subprocess)
|
|
6
|
+
* and the long-running `muhaven-broker` daemon.
|
|
7
|
+
*
|
|
8
|
+
* Newline-delimited JSON over a Unix socket (POSIX) or named pipe
|
|
9
|
+
* (Windows). Each request is a single JSON object; each response is a
|
|
10
|
+
* single JSON object. No request pipelining, no streaming.
|
|
11
|
+
*
|
|
12
|
+
* **Protocol version 0.2.0** — bumped from 0.1.0 in Wave 4 P3 ADR-3
|
|
13
|
+
* to add the `store_jwt` / `get_jwt` / `clear_jwt` triple. The broker
|
|
14
|
+
* is now the single keeper of the device-flow JWT (per ADR-3 D1
|
|
15
|
+
* "polling, not loopback callback") in addition to the session-key
|
|
16
|
+
* private half.
|
|
17
|
+
*
|
|
18
|
+
* Threat-model invariants:
|
|
19
|
+
* - The broker NEVER reaches out to the network. It only:
|
|
20
|
+
* (a) signs hashes that the MCP server received from the backend,
|
|
21
|
+
* (b) stores / returns / clears a JWT that the MCP server
|
|
22
|
+
* received from the backend.
|
|
23
|
+
* Splitting network egress (MCP server) from signing + secret
|
|
24
|
+
* storage (broker) is the lethal-trifecta mitigation in
|
|
25
|
+
* `THREAT_MODEL_P0.md` §"Lethal-trifecta self-audit".
|
|
26
|
+
* - Requests are size-capped (`maxRequestBytes`) — a malformed peer
|
|
27
|
+
* cannot exhaust broker memory by sending an unbounded JSON blob.
|
|
28
|
+
*/
|
|
29
|
+
declare const BROKER_PROTOCOL_VERSION = "0.2.0";
|
|
30
|
+
interface BrokerHelloRequest {
|
|
31
|
+
readonly type: 'hello';
|
|
32
|
+
}
|
|
33
|
+
interface BrokerSignHashRequest {
|
|
34
|
+
readonly type: 'sign_hash';
|
|
35
|
+
/** 0x-prefixed 32-byte hex (e.g., a UserOp hash to be ECDSA-signed). */
|
|
36
|
+
readonly hash: `0x${string}`;
|
|
37
|
+
/** Free-form context for the audit log. NOT trusted as policy input. */
|
|
38
|
+
readonly intent?: {
|
|
39
|
+
readonly tool: string;
|
|
40
|
+
readonly summary?: string;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
interface BrokerStoreJwtRequest {
|
|
44
|
+
readonly type: 'store_jwt';
|
|
45
|
+
/** A scoped device-flow JWT (mcp.read.*, mcp.propose.*) per ADR-3 D2. */
|
|
46
|
+
readonly jwt: string;
|
|
47
|
+
/** Optional issuer-stated expiry (epoch seconds). Used for proactive
|
|
48
|
+
* re-login UX; not for trust decisions. */
|
|
49
|
+
readonly expiresAtSec?: number;
|
|
50
|
+
}
|
|
51
|
+
interface BrokerGetJwtRequest {
|
|
52
|
+
readonly type: 'get_jwt';
|
|
53
|
+
}
|
|
54
|
+
interface BrokerClearJwtRequest {
|
|
55
|
+
readonly type: 'clear_jwt';
|
|
56
|
+
}
|
|
57
|
+
interface BrokerHelloResponse {
|
|
58
|
+
readonly type: 'hello';
|
|
59
|
+
readonly version: string;
|
|
60
|
+
/** 0x-prefixed checksummed address derived from the session key. */
|
|
61
|
+
readonly sessionKeyAddress: `0x${string}`;
|
|
62
|
+
/** Whether a JWT is currently in the keystore. Useful for `doctor`. */
|
|
63
|
+
readonly hasJwt: boolean;
|
|
64
|
+
}
|
|
65
|
+
interface BrokerSignHashResponse {
|
|
66
|
+
readonly type: 'sign_hash';
|
|
67
|
+
/** 0x-prefixed 65-byte ECDSA signature (r || s || v). */
|
|
68
|
+
readonly signature: `0x${string}`;
|
|
69
|
+
readonly signerAddress: `0x${string}`;
|
|
70
|
+
}
|
|
71
|
+
interface BrokerStoreJwtResponse {
|
|
72
|
+
readonly type: 'store_jwt';
|
|
73
|
+
readonly stored: true;
|
|
74
|
+
}
|
|
75
|
+
interface BrokerGetJwtResponse {
|
|
76
|
+
readonly type: 'get_jwt';
|
|
77
|
+
/** Null when no JWT in keystore — caller must trigger device-flow. */
|
|
78
|
+
readonly jwt: string | null;
|
|
79
|
+
readonly expiresAtSec: number | null;
|
|
80
|
+
}
|
|
81
|
+
interface BrokerClearJwtResponse {
|
|
82
|
+
readonly type: 'clear_jwt';
|
|
83
|
+
readonly cleared: true;
|
|
84
|
+
}
|
|
85
|
+
interface BrokerErrorResponse {
|
|
86
|
+
readonly type: 'error';
|
|
87
|
+
readonly code: BrokerErrorCode;
|
|
88
|
+
readonly message: string;
|
|
89
|
+
}
|
|
90
|
+
type BrokerErrorCode = 'invalid_request' | 'payload_too_large' | 'unsupported_type' | 'internal' | 'forbidden' | 'keystore_unavailable';
|
|
91
|
+
type BrokerRequest = BrokerHelloRequest | BrokerSignHashRequest | BrokerStoreJwtRequest | BrokerGetJwtRequest | BrokerClearJwtRequest;
|
|
92
|
+
type BrokerResponse = BrokerHelloResponse | BrokerSignHashResponse | BrokerStoreJwtResponse | BrokerGetJwtResponse | BrokerClearJwtResponse | BrokerErrorResponse;
|
|
93
|
+
/**
|
|
94
|
+
* Parse a single-line request payload. Returns either the validated
|
|
95
|
+
* request or a structured error — the daemon converts errors to a
|
|
96
|
+
* `BrokerErrorResponse` without raising so a malformed peer cannot crash
|
|
97
|
+
* the daemon process.
|
|
98
|
+
*/
|
|
99
|
+
declare function parseBrokerRequest(line: string): BrokerRequest | BrokerErrorResponse;
|
|
100
|
+
declare function serializeResponse(res: BrokerResponse): string;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Tiny line-based IPC client used by the MCP server to talk to a
|
|
104
|
+
* running `muhaven-broker` daemon. Mirrors the protocol in
|
|
105
|
+
* `src/broker/protocol.ts`.
|
|
106
|
+
*
|
|
107
|
+
* One request per connection; response is a single line. Connection
|
|
108
|
+
* timeouts surface as `BrokerClientError` with a stable `code` string so
|
|
109
|
+
* the MCP tool layer can map to host-friendly error responses without
|
|
110
|
+
* inspecting message strings.
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
type BrokerClientErrorCode = 'connect_failed' | 'timeout' | 'protocol_error' | 'broker_error';
|
|
114
|
+
declare class BrokerClientError extends Error {
|
|
115
|
+
readonly code: BrokerClientErrorCode;
|
|
116
|
+
readonly cause?: unknown | undefined;
|
|
117
|
+
constructor(code: BrokerClientErrorCode, message: string, cause?: unknown | undefined);
|
|
118
|
+
}
|
|
119
|
+
interface BrokerClientOptions {
|
|
120
|
+
endpoint: string;
|
|
121
|
+
timeoutMs: number;
|
|
122
|
+
}
|
|
123
|
+
declare class BrokerClient {
|
|
124
|
+
private readonly options;
|
|
125
|
+
constructor(options: BrokerClientOptions);
|
|
126
|
+
hello(): Promise<BrokerHelloResponse>;
|
|
127
|
+
signHash(hash: `0x${string}`, intent?: {
|
|
128
|
+
tool: string;
|
|
129
|
+
summary?: string;
|
|
130
|
+
}): Promise<BrokerSignHashResponse>;
|
|
131
|
+
storeJwt(jwt: string, expiresAtSec?: number): Promise<BrokerStoreJwtResponse>;
|
|
132
|
+
getJwt(): Promise<BrokerGetJwtResponse>;
|
|
133
|
+
clearJwt(): Promise<void>;
|
|
134
|
+
private exchange;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* JWT source for the MCP server. Wraps `BrokerClient.getJwt()` with a
|
|
139
|
+
* brief in-process cache (default 30s; tunable via
|
|
140
|
+
* `MUHAVEN_JWT_CACHE_TTL_SEC`) so the per-tool-call hot path doesn't
|
|
141
|
+
* round-trip the broker socket on every invocation.
|
|
142
|
+
*
|
|
143
|
+
* The cache is invalidated on:
|
|
144
|
+
* - Explicit `invalidate()` (called on backend-side 401 responses).
|
|
145
|
+
* - JWT expiry (when the broker recorded an `expiresAtSec`).
|
|
146
|
+
*
|
|
147
|
+
* On a cache miss, the source asks the broker via IPC. If the broker
|
|
148
|
+
* returns `jwt: null`, the caller (tool handler / device-flow client)
|
|
149
|
+
* is responsible for kicking off the device-flow ceremony — this module
|
|
150
|
+
* never speaks HTTPS itself.
|
|
151
|
+
*/
|
|
152
|
+
|
|
153
|
+
declare class NoJwtAvailableError extends Error {
|
|
154
|
+
readonly code = "no_jwt";
|
|
155
|
+
constructor();
|
|
156
|
+
}
|
|
157
|
+
declare class JwtSource {
|
|
158
|
+
private readonly broker;
|
|
159
|
+
private readonly cacheTtlSec;
|
|
160
|
+
private readonly nowMs;
|
|
161
|
+
private cached;
|
|
162
|
+
constructor(broker: BrokerClient, cacheTtlSec: number, nowMs?: () => number);
|
|
163
|
+
/**
|
|
164
|
+
* Fetch the current JWT, throwing `NoJwtAvailableError` when the
|
|
165
|
+
* broker keystore is empty.
|
|
166
|
+
*/
|
|
167
|
+
get(): Promise<string>;
|
|
168
|
+
/** Drop the in-process cache. Call after a backend 401 to force refresh. */
|
|
169
|
+
invalidate(): void;
|
|
170
|
+
/** Returns the cached JWT iff it's fresh AND not past `expiresAtSec`. */
|
|
171
|
+
private cachedJwtIfValid;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Thin REST client for the MuHaven backend. The backend exposes JWT-
|
|
176
|
+
* authenticated endpoints under `/api/v1/...`; this client carries the
|
|
177
|
+
* caller's bearer token on every request and surfaces errors as
|
|
178
|
+
* `BackendError` with stable codes.
|
|
179
|
+
*
|
|
180
|
+
* **JWT acquisition**: per ADR-3, the JWT is fetched from a `JwtSource`
|
|
181
|
+
* (which is broker-mediated). On a 401 response the client invalidates
|
|
182
|
+
* the JWT cache once and retries; if the second 401 lands, surface
|
|
183
|
+
* `BackendError(unauthorized)` to the caller — that's the device-flow
|
|
184
|
+
* trigger.
|
|
185
|
+
*
|
|
186
|
+
* **URL guard**: every request URL is checked against the configured
|
|
187
|
+
* allowed-host allowlist. Defends against (Wave-5) prompt-injection
|
|
188
|
+
* coercing the LLM into a host-swap. Today the tool layer hard-codes
|
|
189
|
+
* paths so the guard is belt-and-suspenders, but defining it here means
|
|
190
|
+
* the guarantee survives later refactors.
|
|
191
|
+
*/
|
|
192
|
+
|
|
193
|
+
interface BackendClientOptions {
|
|
194
|
+
baseUrl: string;
|
|
195
|
+
jwtSource: JwtSource;
|
|
196
|
+
timeoutMs: number;
|
|
197
|
+
allowedHosts: readonly string[];
|
|
198
|
+
/** Inject a fetch impl for tests. */
|
|
199
|
+
fetchImpl?: typeof fetch;
|
|
200
|
+
}
|
|
201
|
+
type BackendErrorCode = 'unauthorized' | 'forbidden' | 'not_found' | 'gone' | 'rate_limited' | 'bad_request' | 'server_error' | 'network' | 'timeout' | 'invalid_response' | 'host_not_allowed';
|
|
202
|
+
declare class BackendError extends Error {
|
|
203
|
+
readonly code: BackendErrorCode;
|
|
204
|
+
readonly status?: number | undefined;
|
|
205
|
+
readonly body?: unknown | undefined;
|
|
206
|
+
constructor(code: BackendErrorCode, message: string, status?: number | undefined, body?: unknown | undefined);
|
|
207
|
+
}
|
|
208
|
+
declare class BackendClient {
|
|
209
|
+
private readonly options;
|
|
210
|
+
private readonly fetchImpl;
|
|
211
|
+
constructor(options: BackendClientOptions);
|
|
212
|
+
get<T>(path: string, query?: Record<string, string | number | undefined>): Promise<T>;
|
|
213
|
+
post<T>(path: string, body: unknown): Promise<T>;
|
|
214
|
+
/**
|
|
215
|
+
* Path-less variant for unauthenticated calls (e.g., device-code
|
|
216
|
+
* flow's `/auth/device/code` and `/auth/device/token`). Sends no
|
|
217
|
+
* Authorization header.
|
|
218
|
+
*/
|
|
219
|
+
postUnauth<T>(path: string, body: unknown): Promise<T>;
|
|
220
|
+
private buildUrl;
|
|
221
|
+
private exchangeWithRetry;
|
|
222
|
+
private exchange;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Tool descriptions are the **single source of truth** for what each MCP
|
|
227
|
+
* tool advertises to the host LLM. They are also hashed (SHA-256) at
|
|
228
|
+
* package build time and the hashes shipped in `tool-hashes.json`.
|
|
229
|
+
*
|
|
230
|
+
* The mcp-context-protector pattern (post-MCPoison, Apr 2026) compares
|
|
231
|
+
* the live description hash against the pinned hash on first use; a
|
|
232
|
+
* mismatch implies either a malicious downgrade attack or an
|
|
233
|
+
* out-of-band patch. Both warrant operator review before re-confirming.
|
|
234
|
+
*
|
|
235
|
+
* Naming convention is locked in `development/DEV_WAVE_4/TOOL_NAMESPACE.md`:
|
|
236
|
+
* muhaven.<group>.<verb> ^muhaven\.[a-z]+\.[a-z][a-z0-9_]*$
|
|
237
|
+
*
|
|
238
|
+
* Renaming a tool is a breaking change — bump the package major.
|
|
239
|
+
*/
|
|
240
|
+
interface ToolDescriptor {
|
|
241
|
+
/** Canonical name. MUST match the regex in TOOL_NAMESPACE.md. */
|
|
242
|
+
readonly name: string;
|
|
243
|
+
/** Group classification — drives the --read-only filter. P7 adds
|
|
244
|
+
* `issuer` for issuer-side state-mutating tools that require an
|
|
245
|
+
* approved issuer kernel (the use-case-side gate produces structured
|
|
246
|
+
* 403s for non-issuers; the group is for the read-only filter only).
|
|
247
|
+
*
|
|
248
|
+
* P11 adds `governance` for the FHE-encrypted voting ceremony +
|
|
249
|
+
* protection-coverage / KYC-attestation reads. The two read tools
|
|
250
|
+
* (`muhaven.governance.protection_coverage`,
|
|
251
|
+
* `muhaven.governance.kyc_attestation`) are also exposed under
|
|
252
|
+
* `read` so `--read-only` keeps them available; the two propose
|
|
253
|
+
* tools (`muhaven.governance.propose`, `muhaven.governance.cast_vote`)
|
|
254
|
+
* are filtered off in read-only mode. */
|
|
255
|
+
readonly group: 'read' | 'position' | 'policy' | 'issuer' | 'governance';
|
|
256
|
+
/** Human-readable description shown in the host UI. */
|
|
257
|
+
readonly description: string;
|
|
258
|
+
/** When true, the host SHOULD render a confirmation cue before invoking. */
|
|
259
|
+
readonly sensitive: boolean;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The 22 Wave 4 MCP tools across five groups:
|
|
263
|
+
* muhaven.read.* (7 — incl. P11 protection_coverage + kyc_attestation)
|
|
264
|
+
* muhaven.position.* (4)
|
|
265
|
+
* muhaven.policy.* (4)
|
|
266
|
+
* muhaven.issuer.* (5 — P7)
|
|
267
|
+
* muhaven.governance.* (2 — P11; cast_vote frontend runner deferred to Wave 5)
|
|
268
|
+
*
|
|
269
|
+
* `MUHAVEN_READ_ONLY=true` exposes only the 7 `muhaven.read.*` tools.
|
|
270
|
+
* P5's `muhaven.checkout.*` namespace was retired before Wave 4 close — the
|
|
271
|
+
* hosted checkout surface ships as a separate Vite SPA (apps/checkout-pay/),
|
|
272
|
+
* not as an MCP tool group.
|
|
273
|
+
*/
|
|
274
|
+
declare const TOOL_DESCRIPTORS: readonly ToolDescriptor[];
|
|
275
|
+
/**
|
|
276
|
+
* Hash a tool descriptor — the bytes that change when an attacker
|
|
277
|
+
* rewrites a tool's *advertised behaviour*. Excluding `group` keeps the
|
|
278
|
+
* hash stable across refactors that move a tool between groups; both
|
|
279
|
+
* `name` and `description` are included because either changing alters
|
|
280
|
+
* the LLM's interpretation of the tool.
|
|
281
|
+
*/
|
|
282
|
+
declare function hashToolDescriptor(d: ToolDescriptor): string;
|
|
283
|
+
interface ToolHashEntry {
|
|
284
|
+
readonly name: string;
|
|
285
|
+
readonly sha256: string;
|
|
286
|
+
}
|
|
287
|
+
declare function buildToolHashTable(): readonly ToolHashEntry[];
|
|
288
|
+
/** Compare a live descriptor against a pinned hash. Returns null on
|
|
289
|
+
* match; returns a structured mismatch payload on drift. */
|
|
290
|
+
declare function verifyDescriptorAgainstPin(descriptor: ToolDescriptor, pinnedSha256: string): {
|
|
291
|
+
liveSha256: string;
|
|
292
|
+
pinnedSha256: string;
|
|
293
|
+
} | null;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Shared AUTH_REQUIRED payload used by both server-level and handler-level
|
|
297
|
+
* unauthorized mappings.
|
|
298
|
+
*
|
|
299
|
+
* Why both layers? The handlers wrap every BackendClient call in a
|
|
300
|
+
* try/catch + `mapBackendError(...)` (so the structured tool result is
|
|
301
|
+
* uniform). That means a `BackendError(unauthorized)` is converted to a
|
|
302
|
+
* normal return value and never reaches `server.ts`'s catch block, where
|
|
303
|
+
* the original AUTH_REQUIRED branch lived. Surfacing it from BOTH layers:
|
|
304
|
+
* 1. handler layer → catches the common case (backend 401 after one
|
|
305
|
+
* refresh retry).
|
|
306
|
+
* 2. server layer → catches the JwtSource-throws-NoJwtAvailable case,
|
|
307
|
+
* which propagates THROUGH the handler (the handler never sees a
|
|
308
|
+
* BackendError because no HTTP request fires).
|
|
309
|
+
*
|
|
310
|
+
* Producing the same payload from both keeps the host LLM's parsing path
|
|
311
|
+
* stable (always look for `code: 'AUTH_REQUIRED'`).
|
|
312
|
+
*/
|
|
313
|
+
interface AuthRequiredPayload {
|
|
314
|
+
readonly ok: false;
|
|
315
|
+
readonly code: 'AUTH_REQUIRED';
|
|
316
|
+
readonly message: string;
|
|
317
|
+
readonly loginCommand: string;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Tool handlers — pure functions of `(input, deps)` returning a structured
|
|
322
|
+
* tool result. The MCP server transport layer (`src/server.ts`) wires
|
|
323
|
+
* these to the host LLM via `@modelcontextprotocol/sdk`.
|
|
324
|
+
*
|
|
325
|
+
* Design notes:
|
|
326
|
+
* - Handlers NEVER throw. They translate every error into a structured
|
|
327
|
+
* `{ ok: false, code, message }` payload so the host LLM can decide
|
|
328
|
+
* how to surface it without crashing the MCP server. This matches the
|
|
329
|
+
* MCPB error-presentation convention.
|
|
330
|
+
* - Position handlers DO NOT submit UserOps. They return an unsigned
|
|
331
|
+
* envelope plus a broker signature; the host (or the MuHaven
|
|
332
|
+
* dashboard via deep-link) is responsible for bundler submission.
|
|
333
|
+
* Splitting submission from signing is the lethal-trifecta defense.
|
|
334
|
+
* - Backend errors with status >= 500 are surfaced as `server_error`
|
|
335
|
+
* so the host can retry; client errors (4xx) bubble up as the
|
|
336
|
+
* discriminating code (`unauthorized`, `forbidden`, etc.). The host
|
|
337
|
+
* MUST NOT auto-retry 4xx.
|
|
338
|
+
*/
|
|
339
|
+
|
|
340
|
+
interface ToolDeps {
|
|
341
|
+
backend: BackendClient;
|
|
342
|
+
broker?: BrokerClient;
|
|
343
|
+
/** Surface this MCP server is configured for. Always 'mcp' here, but
|
|
344
|
+
* carried as a dep so the audit tool can filter to the local surface. */
|
|
345
|
+
surface: 'mcp';
|
|
346
|
+
}
|
|
347
|
+
type ToolResult<T> = {
|
|
348
|
+
ok: true;
|
|
349
|
+
data: T;
|
|
350
|
+
} | {
|
|
351
|
+
ok: false;
|
|
352
|
+
code: string;
|
|
353
|
+
message: string;
|
|
354
|
+
} | AuthRequiredPayload;
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Static registry binding each tool descriptor to its zod schema and
|
|
358
|
+
* handler. Keeping this in one file means a CI lint can audit the
|
|
359
|
+
* mapping in a single read — no risk of a tool being declared in
|
|
360
|
+
* `descriptions.ts` but accidentally not wired to a handler, or vice
|
|
361
|
+
* versa.
|
|
362
|
+
*
|
|
363
|
+
* The registry exposes a filtered view (`registryForReadOnly`) consumed
|
|
364
|
+
* by `src/server.ts` when `MUHAVEN_READ_ONLY=true`. The filter prunes
|
|
365
|
+
* the `position.*` and `policy.*` groups; only `read.*` tools remain
|
|
366
|
+
* advertised. Mirrors `github/github-mcp-server`'s `--read-only` flag.
|
|
367
|
+
*/
|
|
368
|
+
|
|
369
|
+
interface ToolEntry<TInput = unknown, TOutput = unknown> {
|
|
370
|
+
descriptor: ToolDescriptor;
|
|
371
|
+
schema: z.ZodTypeAny;
|
|
372
|
+
handler: (input: TInput, deps: ToolDeps) => Promise<ToolResult<TOutput>>;
|
|
373
|
+
}
|
|
374
|
+
declare function fullToolRegistry(): readonly ToolEntry[];
|
|
375
|
+
declare function registryForReadOnly(): readonly ToolEntry[];
|
|
376
|
+
declare function selectRegistry(readOnly: boolean): readonly ToolEntry[];
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* MCP STDIO server bridge — registers all tools from `tools/registry.ts`
|
|
380
|
+
* with `@modelcontextprotocol/sdk`'s STDIO transport, validates each
|
|
381
|
+
* tool input against its zod schema, and dispatches to the handler.
|
|
382
|
+
*
|
|
383
|
+
* Hardening invariants per `THREAT_MODEL_P0.md` + ADR-3:
|
|
384
|
+
* - Transport is STDIO **only** — never TCP. The MCP SDK's
|
|
385
|
+
* `StdioServerTransport` is the only one we mount.
|
|
386
|
+
* - Tool inputs are zod-validated server-side; the LLM cannot inject
|
|
387
|
+
* new fields (`additionalProperties: false`).
|
|
388
|
+
* - Tool descriptions are pinned at build time via `tool-hashes.json`
|
|
389
|
+
* and re-verified on startup. A drift exits with code 70 (matches
|
|
390
|
+
* the BSD `EX_CONFIG` convention).
|
|
391
|
+
* - On any backend `unauthorized` response after a single retry, the
|
|
392
|
+
* handler returns a structured `AUTH_REQUIRED` payload that
|
|
393
|
+
* instructs the user to run `muhaven-broker login`.
|
|
394
|
+
*/
|
|
395
|
+
|
|
396
|
+
interface BuildServerOptions {
|
|
397
|
+
registry: readonly ToolEntry[];
|
|
398
|
+
backend: BackendClient;
|
|
399
|
+
broker: BrokerClient | undefined;
|
|
400
|
+
}
|
|
401
|
+
declare function buildMcpServer(opts: BuildServerOptions): Server;
|
|
402
|
+
/**
|
|
403
|
+
* Boot options for `runMcpStdioCli`.
|
|
404
|
+
*
|
|
405
|
+
* `filterRegistry` is the OpenClaw-shaped extension point: callers can
|
|
406
|
+
* supply a function that receives the post-`--read-only` registry and
|
|
407
|
+
* returns a (possibly narrower) subset. The bundled OpenClaw skill uses
|
|
408
|
+
* this to ship a curated 11-tool subset out of the 22-tool upstream
|
|
409
|
+
* surface (ADR-C). The filter MUST be a pure function; any side effect
|
|
410
|
+
* (mutation of the input array, network call, etc.) is unsupported.
|
|
411
|
+
*
|
|
412
|
+
* Tool-description hash verification fires BEFORE the filter — drift in
|
|
413
|
+
* an upstream descriptor must abort startup even if the consumer would
|
|
414
|
+
* have filtered the affected tool out. Otherwise an attacker who patches
|
|
415
|
+
* a single descriptor could hide it from the verification gate by
|
|
416
|
+
* shipping a subset filter that excludes only that tool.
|
|
417
|
+
*/
|
|
418
|
+
interface RunMcpStdioCliOptions {
|
|
419
|
+
filterRegistry?: (registry: readonly ToolEntry[]) => readonly ToolEntry[];
|
|
420
|
+
}
|
|
421
|
+
/** Production STDIO entrypoint — wired through `bin/muhaven-mcp.cjs`. */
|
|
422
|
+
declare function runMcpStdioCli(opts?: RunMcpStdioCliOptions): Promise<void>;
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Runtime configuration sourced from env vars declared in `manifest.json`.
|
|
426
|
+
*
|
|
427
|
+
* MCPB hosts (Claude Desktop, Cursor, Claude Code) inject these via the
|
|
428
|
+
* STDIO subprocess environment. Per ADR-3 the **JWT itself is no longer
|
|
429
|
+
* an env var** — it is acquired via the device-flow ceremony and lives
|
|
430
|
+
* in the broker-managed keystore. The MCP server fetches it from the
|
|
431
|
+
* broker on each tool call (with a brief in-process cache).
|
|
432
|
+
*
|
|
433
|
+
* Validation is intentionally strict: we fail to start rather than
|
|
434
|
+
* launch with a half-configured signing path. The error messages name
|
|
435
|
+
* the env var so an MCPB user can fix their host config without reading
|
|
436
|
+
* code.
|
|
437
|
+
*/
|
|
438
|
+
interface McpRuntimeConfig {
|
|
439
|
+
/** MuHaven backend base URL (no trailing slash). e.g. https://api.muhaven.app */
|
|
440
|
+
backendBaseUrl: string;
|
|
441
|
+
/** Origin of the MuHaven dashboard (used in AUTH_REQUIRED messages). */
|
|
442
|
+
dashboardBaseUrl: string;
|
|
443
|
+
/** Path / endpoint of the muhaven-broker IPC. */
|
|
444
|
+
brokerEndpoint: string;
|
|
445
|
+
/** When true, the position.* and policy.* toolsets are not registered. */
|
|
446
|
+
readOnly: boolean;
|
|
447
|
+
/** Soft timeout (ms) for backend HTTP calls. Default 15s. */
|
|
448
|
+
requestTimeoutMs: number;
|
|
449
|
+
/** Soft timeout (ms) for broker IPC calls. Default 5s. */
|
|
450
|
+
brokerTimeoutMs: number;
|
|
451
|
+
/** Allowed backend hostnames for URL guard. Derived from baseUrl. */
|
|
452
|
+
allowedBackendHosts: readonly string[];
|
|
453
|
+
/** In-process JWT cache TTL in seconds. Default 30. */
|
|
454
|
+
jwtCacheTtlSec: number;
|
|
455
|
+
}
|
|
456
|
+
interface BrokerRuntimeConfig {
|
|
457
|
+
/** Endpoint to bind: socket path on POSIX, named pipe name on Windows. */
|
|
458
|
+
endpoint: string;
|
|
459
|
+
/** 0x-prefixed 32-byte private key. Sensitive — keychain-backed. */
|
|
460
|
+
sessionKeyHex: `0x${string}`;
|
|
461
|
+
/** Maximum payload bytes accepted from the IPC peer. */
|
|
462
|
+
maxRequestBytes: number;
|
|
463
|
+
/** Per-request hard timeout (ms). */
|
|
464
|
+
requestTimeoutMs: number;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Compute the default IPC endpoint for the broker. POSIX: a socket file
|
|
468
|
+
* inside the user's home dir. Windows: a per-user named pipe.
|
|
469
|
+
*
|
|
470
|
+
* Both endpoints are bound to the local user only — never exposed over
|
|
471
|
+
* TCP. The TCP transport ban is a hard invariant of the broker.
|
|
472
|
+
*/
|
|
473
|
+
declare function defaultBrokerEndpoint(): string;
|
|
474
|
+
declare function loadMcpConfig(env?: NodeJS.ProcessEnv): McpRuntimeConfig;
|
|
475
|
+
declare function loadBrokerConfig(env?: NodeJS.ProcessEnv): BrokerRuntimeConfig;
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* RFC 8628-flavored device authorization grant client.
|
|
479
|
+
*
|
|
480
|
+
* Per ADR-3 D1, the broker is zero-egress — so the device-flow HTTPS
|
|
481
|
+
* traffic happens here in the MCP package's process space (the
|
|
482
|
+
* `muhaven-broker login` CLI invokes this; once a JWT is acquired it
|
|
483
|
+
* is handed to the broker over IPC via `BrokerClient.storeJwt`).
|
|
484
|
+
*
|
|
485
|
+
* Three backend endpoints involved:
|
|
486
|
+
* POST /api/v1/auth/device/code — broker requests a code
|
|
487
|
+
* POST /api/v1/auth/device/authorize — dashboard authorizes (NOT called here)
|
|
488
|
+
* POST /api/v1/auth/device/token — broker polls for the JWT
|
|
489
|
+
*/
|
|
490
|
+
interface DeviceCodeIssued {
|
|
491
|
+
deviceCode: string;
|
|
492
|
+
userCode: string;
|
|
493
|
+
verificationUri: string;
|
|
494
|
+
verificationUriComplete: string;
|
|
495
|
+
expiresInSec: number;
|
|
496
|
+
pollIntervalSec: number;
|
|
497
|
+
}
|
|
498
|
+
interface DeviceFlowOptions {
|
|
499
|
+
backendBaseUrl: string;
|
|
500
|
+
dashboardBaseUrl: string;
|
|
501
|
+
/** Optional describer for the request, displayed on the /link page. */
|
|
502
|
+
requesterMetadata?: {
|
|
503
|
+
processName?: string;
|
|
504
|
+
hostname?: string;
|
|
505
|
+
os?: string;
|
|
506
|
+
};
|
|
507
|
+
/** Inject a fetch impl for tests. */
|
|
508
|
+
fetchImpl?: typeof fetch;
|
|
509
|
+
/** Inject a sleeper for tests. */
|
|
510
|
+
sleep?: (ms: number) => Promise<void>;
|
|
511
|
+
/** Inject a clock for tests. */
|
|
512
|
+
nowMs?: () => number;
|
|
513
|
+
}
|
|
514
|
+
type DeviceFlowEvent = {
|
|
515
|
+
type: 'code_issued';
|
|
516
|
+
code: DeviceCodeIssued;
|
|
517
|
+
} | {
|
|
518
|
+
type: 'polling';
|
|
519
|
+
attempt: number;
|
|
520
|
+
nextPollMs: number;
|
|
521
|
+
} | {
|
|
522
|
+
type: 'authorized';
|
|
523
|
+
jwt: string;
|
|
524
|
+
expiresAtSec: number | null;
|
|
525
|
+
scope: string[] | null;
|
|
526
|
+
} | {
|
|
527
|
+
type: 'denied';
|
|
528
|
+
reason?: string;
|
|
529
|
+
} | {
|
|
530
|
+
type: 'expired';
|
|
531
|
+
};
|
|
532
|
+
type DeviceFlowError = {
|
|
533
|
+
code: 'network';
|
|
534
|
+
cause: unknown;
|
|
535
|
+
} | {
|
|
536
|
+
code: 'rate_limited';
|
|
537
|
+
} | {
|
|
538
|
+
code: 'invalid_response';
|
|
539
|
+
status?: number;
|
|
540
|
+
body?: unknown;
|
|
541
|
+
} | {
|
|
542
|
+
code: 'denied';
|
|
543
|
+
reason?: string;
|
|
544
|
+
} | {
|
|
545
|
+
code: 'expired';
|
|
546
|
+
} | {
|
|
547
|
+
code: 'timeout';
|
|
548
|
+
};
|
|
549
|
+
declare class DeviceFlowAbortedError extends Error {
|
|
550
|
+
readonly detail: DeviceFlowError;
|
|
551
|
+
constructor(detail: DeviceFlowError);
|
|
552
|
+
}
|
|
553
|
+
declare class DeviceFlowClient {
|
|
554
|
+
private readonly options;
|
|
555
|
+
private readonly fetchImpl;
|
|
556
|
+
private readonly sleep;
|
|
557
|
+
private readonly nowMs;
|
|
558
|
+
constructor(options: DeviceFlowOptions);
|
|
559
|
+
/**
|
|
560
|
+
* Run the full ceremony: request a code, yield events for the caller
|
|
561
|
+
* to display the URL, then poll until authorized / denied / expired.
|
|
562
|
+
* Throws `DeviceFlowAbortedError` on terminal failure.
|
|
563
|
+
*/
|
|
564
|
+
run(opts?: {
|
|
565
|
+
overallTimeoutMs?: number;
|
|
566
|
+
}): AsyncGenerator<DeviceFlowEvent, {
|
|
567
|
+
jwt: string;
|
|
568
|
+
expiresAtSec: number | null;
|
|
569
|
+
scope: string[] | null;
|
|
570
|
+
}, void>;
|
|
571
|
+
private requestCode;
|
|
572
|
+
private pollOnce;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Thin wrapper around viem's privateKeyToAccount. Keeps the rest of the
|
|
577
|
+
* broker free of viem types so the IPC layer can be tested without
|
|
578
|
+
* pulling viem into the test-runtime resolution graph.
|
|
579
|
+
*
|
|
580
|
+
* The on-chain UserOp submission is the frontend / dashboard's job in
|
|
581
|
+
* Wave 4 (P6 wires it for the policy-engine cron path). The broker's
|
|
582
|
+
* single responsibility is to ECDSA-sign a hash — never to issue an
|
|
583
|
+
* RPC, never to construct an unsigned UserOp, never to read an Arb
|
|
584
|
+
* RPC endpoint. That isolation is the lethal-trifecta mitigation.
|
|
585
|
+
*/
|
|
586
|
+
interface ISigner {
|
|
587
|
+
readonly address: `0x${string}`;
|
|
588
|
+
signHash(hash: `0x${string}`): Promise<`0x${string}`>;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Cross-platform JWT keystore for the broker daemon.
|
|
593
|
+
*
|
|
594
|
+
* Two backends per ADR-3 D3:
|
|
595
|
+
* - **OS keychain** (default) via `@napi-rs/keyring` — Windows DPAPI /
|
|
596
|
+
* Credential Manager / macOS Security framework / Linux Secret
|
|
597
|
+
* Service via D-Bus.
|
|
598
|
+
* - **File** (opt-in via `MUHAVEN_KEYRING=file`) — JSON file at
|
|
599
|
+
* `~/.muhaven/jwt`, mode 0600, parent dir mode 0700. Required for
|
|
600
|
+
* WSL2 / devcontainer / SSH-remote where Secret Service is absent.
|
|
601
|
+
*
|
|
602
|
+
* The interface is intentionally tiny — the broker daemon never inspects
|
|
603
|
+
* a JWT, only stores / fetches / clears it.
|
|
604
|
+
*/
|
|
605
|
+
interface JwtRecord {
|
|
606
|
+
jwt: string;
|
|
607
|
+
expiresAtSec: number | null;
|
|
608
|
+
storedAtSec: number;
|
|
609
|
+
}
|
|
610
|
+
type KeystoreBackend = 'os' | 'file';
|
|
611
|
+
interface IKeystore {
|
|
612
|
+
readonly backend: KeystoreBackend;
|
|
613
|
+
readonly available: boolean;
|
|
614
|
+
set(record: JwtRecord): Promise<void>;
|
|
615
|
+
get(): Promise<JwtRecord | null>;
|
|
616
|
+
clear(): Promise<void>;
|
|
617
|
+
}
|
|
618
|
+
type KeystoreErrorCode = 'os_keystore_unavailable' | 'file_read_failed' | 'file_clear_failed' | 'malformed_record';
|
|
619
|
+
declare class KeystoreError extends Error {
|
|
620
|
+
readonly code: KeystoreErrorCode;
|
|
621
|
+
readonly cause?: unknown | undefined;
|
|
622
|
+
constructor(code: KeystoreErrorCode, message: string, cause?: unknown | undefined);
|
|
623
|
+
}
|
|
624
|
+
interface OpenKeystoreOptions {
|
|
625
|
+
/** When 'file', force the file backend regardless of OS support. */
|
|
626
|
+
preferred?: KeystoreBackend;
|
|
627
|
+
/** Override default file path (testing / Docker volumes). */
|
|
628
|
+
filePath?: string;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Pick a keystore backend. Order:
|
|
632
|
+
* 1. `MUHAVEN_KEYRING=file` env or `preferred='file'` → FileKeystore.
|
|
633
|
+
* 2. `@napi-rs/keyring` import succeeds + `getPassword()` doesn't throw → OsKeystore.
|
|
634
|
+
* 3. Fall back to FileKeystore (with a warning the caller can surface).
|
|
635
|
+
*
|
|
636
|
+
* Returns the keystore + whether a fallback was applied so the caller
|
|
637
|
+
* can print a doctor-style warning.
|
|
638
|
+
*/
|
|
639
|
+
declare function openKeystore(options?: OpenKeystoreOptions): Promise<{
|
|
640
|
+
keystore: IKeystore;
|
|
641
|
+
fallbackReason: string | null;
|
|
642
|
+
}>;
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* `muhaven-broker` daemon — the single-purpose process that holds the
|
|
646
|
+
* session-key private half AND the device-flow JWT, exposing only IPC
|
|
647
|
+
* primitives for `sign_hash` + `store_jwt` / `get_jwt` / `clear_jwt`.
|
|
648
|
+
*
|
|
649
|
+
* Design constraints (ranked):
|
|
650
|
+
* 1. Never speak TCP. Reachable only via local socket / named pipe.
|
|
651
|
+
* 2. Never reach out to the network. No fetch, no RPC, no bundler —
|
|
652
|
+
* even after ADR-3 the broker remains zero-egress; the *MCP server*
|
|
653
|
+
* speaks HTTPS to the backend and hands the JWT to the broker for
|
|
654
|
+
* storage via `store_jwt`.
|
|
655
|
+
* 3. Peer access is enforced by filesystem permissions on POSIX (parent
|
|
656
|
+
* dir 0700 / socket file 0600). Windows named pipe inherits the
|
|
657
|
+
* creating user's ACL by default.
|
|
658
|
+
* 4. Survive a malformed peer: each request size-capped, JSON parse
|
|
659
|
+
* failure → structured error response (not a crash), hung peer
|
|
660
|
+
* force-disconnected after `requestTimeoutMs`.
|
|
661
|
+
*
|
|
662
|
+
* See `development/DEV_WAVE_4/ADR_LOG.md` ADR-3 for the device-flow
|
|
663
|
+
* design that motivates the JWT verbs.
|
|
664
|
+
*/
|
|
665
|
+
|
|
666
|
+
interface BrokerDaemonOptions {
|
|
667
|
+
config: BrokerRuntimeConfig;
|
|
668
|
+
signer?: ISigner;
|
|
669
|
+
/** Inject a keystore for tests; default opens the configured backend. */
|
|
670
|
+
keystore?: IKeystore;
|
|
671
|
+
/** Override for the connection-handler logger; defaults to silent. */
|
|
672
|
+
logger?: (event: BrokerLogEvent) => void;
|
|
673
|
+
}
|
|
674
|
+
interface BrokerLogEvent {
|
|
675
|
+
level: 'info' | 'warn' | 'error';
|
|
676
|
+
msg: string;
|
|
677
|
+
meta?: Record<string, unknown>;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Pure-function request handler — given a parsed request, signer, and
|
|
681
|
+
* keystore, returns the response object. Easy to unit-test without
|
|
682
|
+
* spawning a socket.
|
|
683
|
+
*/
|
|
684
|
+
declare function handleBrokerRequest(req: BrokerRequest, signer: ISigner, keystore: IKeystore, nowSec?: () => number): Promise<BrokerResponse>;
|
|
685
|
+
declare class BrokerDaemon {
|
|
686
|
+
private readonly server;
|
|
687
|
+
private readonly signer;
|
|
688
|
+
private readonly log;
|
|
689
|
+
private readonly config;
|
|
690
|
+
private keystore;
|
|
691
|
+
constructor(options: BrokerDaemonOptions);
|
|
692
|
+
start(): Promise<string>;
|
|
693
|
+
stop(): Promise<void>;
|
|
694
|
+
private onConnection;
|
|
695
|
+
private runAndRespond;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export { BROKER_PROTOCOL_VERSION, BackendClient, BackendError, type BackendErrorCode, BrokerClient, BrokerClientError, type BrokerClientErrorCode, BrokerDaemon, type BrokerDaemonOptions, type BrokerRequest, type BrokerResponse, type BrokerRuntimeConfig, DeviceFlowAbortedError, DeviceFlowClient, type DeviceFlowEvent, type IKeystore, JwtSource, type KeystoreBackend, KeystoreError, type McpRuntimeConfig, NoJwtAvailableError, type RunMcpStdioCliOptions, TOOL_DESCRIPTORS, type ToolDescriptor, type ToolEntry, type ToolHashEntry, buildMcpServer, buildToolHashTable, defaultBrokerEndpoint, fullToolRegistry, handleBrokerRequest, hashToolDescriptor, loadBrokerConfig, loadMcpConfig, openKeystore, parseBrokerRequest, registryForReadOnly, runMcpStdioCli, selectRegistry, serializeResponse, verifyDescriptorAgainstPin };
|