@sage-protocol/openclaw-sage 0.1.8 → 0.1.10

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.
@@ -1,469 +0,0 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import { resolve } from "node:path";
4
- import { readFileSync, existsSync } from "node:fs";
5
- import { spawnSync } from "node:child_process";
6
-
7
- import { McpBridge } from "./mcp-bridge.js";
8
- import plugin from "./index.js";
9
- import { __test } from "./index.js";
10
-
11
- function candidateSageDebugBinDirs(): string[] {
12
- const here = resolve(new URL("..", import.meta.url).pathname);
13
- const candidates = [
14
- // Monorepo layout: packages/openclaw-sage and packages/sage
15
- resolve(here, "..", "sage", "target", "debug"),
16
- // Legacy layout fallback
17
- resolve(here, "..", "target", "debug"),
18
- ];
19
- return [...new Set(candidates.filter((dir) => existsSync(dir)))];
20
- }
21
-
22
- function resolveSageBinaryForTests(): string {
23
- const override = process.env.SAGE_BIN_TEST || process.env.SAGE_BIN;
24
- if (override && override.trim()) return override.trim();
25
-
26
- const exe = process.platform === "win32" ? "sage.exe" : "sage";
27
- for (const dir of candidateSageDebugBinDirs()) {
28
- const candidate = resolve(dir, exe);
29
- if (existsSync(candidate)) return candidate;
30
- }
31
- // Fallback to PATH
32
- return "sage";
33
- }
34
-
35
- function canExecuteSage(bin: string): boolean {
36
- const probe = spawnSync(bin, ["--version"], { stdio: "ignore" });
37
- return probe.status === 0;
38
- }
39
-
40
- function isRepoDebugSage(bin: string): boolean {
41
- // The repo build resolves to an explicit target/debug path; fallback is plain "sage" on PATH.
42
- return bin.includes("/target/debug/") || bin.includes("\\target\\debug\\");
43
- }
44
-
45
- function addSageDebugBinToPath() {
46
- // Ensure the `sage` binary used by the plugin resolves to this repo's build first.
47
- const dirs = candidateSageDebugBinDirs();
48
- if (!dirs.length) return { binDir: undefined };
49
- const sep = process.platform === "win32" ? ";" : ":";
50
- process.env.PATH = `${dirs.join(sep)}${sep}${process.env.PATH ?? ""}`;
51
- return { binDir: dirs[0] };
52
- }
53
-
54
- // ── P0: Version consistency ──────────────────────────────────────────
55
-
56
- test("PKG_VERSION matches package.json version", () => {
57
- const pkgPath = resolve(new URL("..", import.meta.url).pathname, "package.json");
58
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
59
- assert.equal(__test.PKG_VERSION, pkg.version, "PKG_VERSION should match package.json");
60
- });
61
-
62
- test("plugin.version matches package.json version", () => {
63
- const pkgPath = resolve(new URL("..", import.meta.url).pathname, "package.json");
64
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
65
- assert.equal(plugin.version, pkg.version, "plugin.version should match package.json");
66
- });
67
-
68
- // ── P1: Schema conversion ────────────────────────────────────────────
69
-
70
- test("mcpSchemaToTypebox handles string properties", () => {
71
- const schema = __test.mcpSchemaToTypebox({
72
- type: "object",
73
- properties: {
74
- name: { type: "string", description: "A name" },
75
- },
76
- required: ["name"],
77
- }) as any;
78
- assert.ok(schema);
79
- assert.equal(schema.type, "object");
80
- assert.ok(schema.properties.name, "should have name property");
81
- });
82
-
83
- test("mcpSchemaToTypebox handles enum properties", () => {
84
- const schema = __test.mcpSchemaToTypebox({
85
- type: "object",
86
- properties: {
87
- vote: { type: "string", enum: ["for", "against", "abstain"], description: "Vote direction" },
88
- },
89
- required: ["vote"],
90
- }) as any;
91
- assert.ok(schema);
92
- const voteField = schema.properties.vote;
93
- assert.ok(voteField, "should have vote property");
94
- // Union of literals produces anyOf
95
- assert.ok(
96
- voteField.anyOf || voteField.const || voteField.enum,
97
- "enum should produce union of literals or single literal",
98
- );
99
- });
100
-
101
- test("mcpSchemaToTypebox handles typed arrays", () => {
102
- const schema = __test.mcpSchemaToTypebox({
103
- type: "object",
104
- properties: {
105
- tags: { type: "array", items: { type: "string" }, description: "Tags list" },
106
- },
107
- }) as any;
108
- assert.ok(schema);
109
- const tagsField = schema.properties.tags;
110
- assert.ok(tagsField, "should have tags property");
111
- });
112
-
113
- test("mcpSchemaToTypebox handles nested objects", () => {
114
- const schema = __test.mcpSchemaToTypebox({
115
- type: "object",
116
- properties: {
117
- config: {
118
- type: "object",
119
- properties: {
120
- timeout: { type: "number", description: "Timeout in ms" },
121
- retry: { type: "boolean" },
122
- },
123
- required: ["timeout"],
124
- },
125
- },
126
- }) as any;
127
- assert.ok(schema);
128
- const configField = schema.properties.config;
129
- assert.ok(configField, "should have config property");
130
- assert.ok(configField.properties?.timeout, "nested object should have timeout");
131
- });
132
-
133
- test("mcpSchemaToTypebox handles empty/missing schema gracefully", () => {
134
- assert.ok(__test.mcpSchemaToTypebox(undefined));
135
- assert.ok(__test.mcpSchemaToTypebox({}));
136
- assert.ok(__test.mcpSchemaToTypebox({ type: "object" }));
137
- });
138
-
139
- test("jsonSchemaToTypebox handles single enum value as literal", () => {
140
- const result = __test.jsonSchemaToTypebox({ type: "string", enum: ["only_value"] });
141
- assert.ok(result);
142
- assert.equal(result.const, "only_value");
143
- });
144
-
145
- // ── P2: Error enrichment ─────────────────────────────────────────────
146
-
147
- test("enrichErrorMessage adds wallet hint for wallet errors", () => {
148
- const err = new Error("No wallet connected");
149
- const enriched = __test.enrichErrorMessage(err, "list_proposals");
150
- assert.ok(enriched.includes("sage wallet connect"), "should suggest wallet connect");
151
- });
152
-
153
- test("enrichErrorMessage adds auth hint for auth errors", () => {
154
- const err = new Error("401 Unauthorized: token expired");
155
- const enriched = __test.enrichErrorMessage(err, "ipfs_upload");
156
- assert.ok(enriched.includes("sage config ipfs setup"), "should suggest config ipfs setup");
157
- });
158
-
159
- test("enrichErrorMessage adds network hint for RPC errors", () => {
160
- const err = new Error("ECONNREFUSED 127.0.0.1:8545");
161
- const enriched = __test.enrichErrorMessage(err, "list_subdaos");
162
- assert.ok(enriched.includes("SAGE_PROFILE"), "should mention SAGE_PROFILE");
163
- });
164
-
165
- test("enrichErrorMessage adds bridge hint for bridge errors", () => {
166
- const err = new Error("MCP bridge not running");
167
- const enriched = __test.enrichErrorMessage(err, "search_prompts");
168
- assert.ok(enriched.includes("sage mcp start"), "should suggest mcp start");
169
- });
170
-
171
- test("enrichErrorMessage adds credits hint for balance errors", () => {
172
- const err = new Error("Insufficient IPFS balance");
173
- const enriched = __test.enrichErrorMessage(err, "ipfs_pin");
174
- assert.ok(enriched.includes("sage config ipfs faucet"), "should suggest config ipfs faucet");
175
- });
176
-
177
- test("enrichErrorMessage passes through unknown errors", () => {
178
- const err = new Error("Something unexpected");
179
- const enriched = __test.enrichErrorMessage(err, "unknown_tool");
180
- assert.equal(enriched, "Something unexpected");
181
- });
182
-
183
- // ── P2: SAGE_CONTEXT completeness ────────────────────────────────────
184
-
185
- test("SAGE_CONTEXT includes all major tool categories", () => {
186
- const ctx = __test.SAGE_CONTEXT;
187
- assert.ok(ctx.includes("Sage (Code Mode)"), "should include Code Mode header");
188
- assert.ok(ctx.includes("sage_search"), "should mention sage_search");
189
- assert.ok(ctx.includes("sage_execute"), "should mention sage_execute");
190
- assert.ok(ctx.includes("sage_status"), "should mention sage_status");
191
- });
192
-
193
- // ── Existing tests (integration — require sage binary) ───────────────
194
-
195
- test("McpBridge can initialize, list tools, and call a native tool", async (t) => {
196
- const sageBin = resolveSageBinaryForTests();
197
- if (!canExecuteSage(sageBin)) {
198
- t.skip(`sage binary not available for integration test: ${sageBin}`);
199
- return;
200
- }
201
- if (!isRepoDebugSage(sageBin)) {
202
- t.skip(`expected repo debug sage binary; got: ${sageBin}`);
203
- return;
204
- }
205
- const bridge = new McpBridge(sageBin, ["mcp", "start"]);
206
- try {
207
- await bridge.start();
208
- assert.ok(bridge.isReady(), "bridge should be ready after start");
209
- const tools = await bridge.listTools();
210
- assert.ok(Array.isArray(tools));
211
- assert.ok(
212
- tools.some((t) => t.name === "sage_search"),
213
- "expected sage_search tool to exist",
214
- );
215
- assert.ok(
216
- tools.some((t) => t.name === "sage_execute"),
217
- "expected sage_execute tool to exist",
218
- );
219
- assert.ok(
220
- tools.some((t) => t.name === "hub_list_servers"),
221
- "expected direct hub tool to exist alongside Code Mode tools",
222
- );
223
-
224
- const result = await bridge.callTool("sage_search", {
225
- domain: "meta",
226
- action: "get_project_context",
227
- params: {},
228
- });
229
- assert.ok(result && typeof result === "object");
230
- } finally {
231
- await bridge.stop().catch(() => {});
232
- assert.ok(!bridge.isReady(), "bridge should not be ready after stop");
233
- }
234
- });
235
-
236
- test("OpenClaw plugin registers MCP tools via sage mcp start", async () => {
237
- addSageDebugBinToPath();
238
-
239
- const registeredTools: string[] = [];
240
- const services: Array<{ id: string; start: Function; stop?: Function }> = [];
241
-
242
- const api = {
243
- id: "t",
244
- name: "t",
245
- logger: {
246
- info: (_: string) => {},
247
- warn: (_: string) => {},
248
- error: (_: string) => {},
249
- },
250
- registerTool: (tool: any) => {
251
- if (tool?.name) registeredTools.push(tool.name);
252
- },
253
- registerService: (svc: any) => {
254
- services.push(svc);
255
- },
256
- on: (_hook: string, _handler: any) => {},
257
- };
258
-
259
- plugin.register(api);
260
- const svc = services.find((s) => s.id === "sage-mcp-bridge");
261
- assert.ok(svc, "expected sage-mcp-bridge service to be registered");
262
-
263
- await svc!.start({
264
- config: {},
265
- stateDir: "/tmp",
266
- logger: api.logger,
267
- });
268
-
269
- assert.ok(registeredTools.includes("sage_search"), "expected sage_search to be registered");
270
- assert.ok(registeredTools.includes("sage_execute"), "expected sage_execute to be registered");
271
-
272
- // sage_status meta-tool should be registered
273
- assert.ok(
274
- registeredTools.includes("sage_status"),
275
- "expected sage_status meta-tool to be registered",
276
- );
277
-
278
- if (svc!.stop) {
279
- await svc!.stop({
280
- config: {},
281
- stateDir: "/tmp",
282
- logger: api.logger,
283
- });
284
- }
285
- });
286
-
287
- test("OpenClaw plugin registers before_agent_start hook and returns prependContext", async () => {
288
- const hooks: Record<string, any> = {};
289
-
290
- const api = {
291
- id: "t",
292
- name: "t",
293
- pluginConfig: {},
294
- logger: {
295
- info: (_: string) => {},
296
- warn: (_: string) => {},
297
- error: (_: string) => {},
298
- },
299
- registerTool: (_tool: any) => {},
300
- registerService: (_svc: any) => {},
301
- on: (hook: string, handler: any) => {
302
- hooks[hook] = handler;
303
- },
304
- };
305
-
306
- plugin.register(api as any);
307
- assert.ok(typeof hooks.before_agent_start === "function", "expected before_agent_start hook");
308
- assert.ok(
309
- typeof hooks.after_agent_response === "function" || typeof hooks.agent_end === "function",
310
- "expected a response capture hook (after_agent_response or agent_end)",
311
- );
312
-
313
- const result = await hooks.before_agent_start({ prompt: "build an mcp server" });
314
- assert.ok(result && typeof result === "object");
315
- assert.ok(
316
- typeof result.prependContext === "string" && result.prependContext.includes("Sage (Code Mode)"),
317
- "expected prependContext with Sage Code Mode context",
318
- );
319
- });
320
-
321
- test("formatSkillSuggestions formats stable markdown", () => {
322
- const out = __test.formatSkillSuggestions(
323
- [
324
- {
325
- key: "bug-bounty",
326
- name: "Bug Bounty",
327
- description: "Recon, scanning, API testing",
328
- source: "installed",
329
- mcpServers: ["zap"],
330
- },
331
- { key: "", name: "skip" },
332
- ],
333
- 3,
334
- );
335
-
336
- assert.ok(out.includes("## Suggested Skills"));
337
- assert.ok(out.includes("`sage_execute`"));
338
- assert.ok(out.includes('"domain": "skills"'));
339
- assert.ok(out.includes('"action": "use"'));
340
- assert.ok(out.includes('"key": "bug-bounty"'));
341
- assert.ok(out.includes("requires: zap"));
342
- });
343
-
344
- test("OpenClaw injectionGuard blocks dangerous execute payload (optional e2e)", async () => {
345
- if (process.env.SAGE_E2E_OPENCLAW !== "1") {
346
- return;
347
- }
348
-
349
- addSageDebugBinToPath();
350
-
351
- const tools = new Map<string, any>();
352
- const services: Array<{ id: string; start: Function; stop?: Function }> = [];
353
-
354
- const api = {
355
- id: "t",
356
- name: "t",
357
- pluginConfig: {
358
- injectionGuardEnabled: true,
359
- injectionGuardMode: "block",
360
- injectionGuardScanAgentPrompt: false,
361
- injectionGuardScanGetPrompt: false,
362
- },
363
- logger: {
364
- info: (_: string) => {},
365
- warn: (_: string) => {},
366
- error: (_: string) => {},
367
- },
368
- registerTool: (tool: any) => {
369
- if (tool?.name) tools.set(tool.name, tool);
370
- },
371
- registerService: (svc: any) => {
372
- services.push(svc);
373
- },
374
- on: (_hook: string, _handler: any) => {},
375
- };
376
-
377
- plugin.register(api as any);
378
- const svc = services.find((s) => s.id === "sage-mcp-bridge");
379
- assert.ok(svc, "expected sage-mcp-bridge service to be registered");
380
-
381
- await svc!.start({ config: {}, stateDir: "/tmp", logger: api.logger });
382
- try {
383
- const executeTool = tools.get("sage_execute");
384
- assert.ok(executeTool?.execute, "expected sage_execute tool");
385
-
386
- const result = await executeTool.execute("call-1", {
387
- domain: "skills",
388
- action: "use",
389
- params: { key: "rm -rf /" },
390
- });
391
-
392
- const text =
393
- result?.content
394
- ?.filter((c: any) => c.type === "text")
395
- .map((c: any) => c.text)
396
- .join("\n") ?? "";
397
-
398
- const blocked = /Blocked by injection guard/i.test(text);
399
- if (!blocked) {
400
- assert.ok(text.length > 0, "expected a normal tool response when not blocked");
401
- }
402
- } finally {
403
- if (svc!.stop) {
404
- await svc!.stop({ config: {}, stateDir: "/tmp", logger: api.logger });
405
- }
406
- }
407
- });
408
-
409
- test("OpenClaw injectionGuard warn mode does not hard-block execution (optional e2e)", async () => {
410
- if (process.env.SAGE_E2E_OPENCLAW !== "1") {
411
- return;
412
- }
413
-
414
- addSageDebugBinToPath();
415
-
416
- const tools = new Map<string, any>();
417
- const services: Array<{ id: string; start: Function; stop?: Function }> = [];
418
-
419
- const api = {
420
- id: "t",
421
- name: "t",
422
- pluginConfig: {
423
- injectionGuardEnabled: true,
424
- injectionGuardMode: "warn",
425
- injectionGuardScanAgentPrompt: false,
426
- injectionGuardScanGetPrompt: false,
427
- },
428
- logger: {
429
- info: (_: string) => {},
430
- warn: (_: string) => {},
431
- error: (_: string) => {},
432
- },
433
- registerTool: (tool: any) => {
434
- if (tool?.name) tools.set(tool.name, tool);
435
- },
436
- registerService: (svc: any) => {
437
- services.push(svc);
438
- },
439
- on: (_hook: string, _handler: any) => {},
440
- };
441
-
442
- plugin.register(api as any);
443
- const svc = services.find((s) => s.id === "sage-mcp-bridge");
444
- assert.ok(svc, "expected sage-mcp-bridge service to be registered");
445
-
446
- await svc!.start({ config: {}, stateDir: "/tmp", logger: api.logger });
447
- try {
448
- const executeTool = tools.get("sage_execute");
449
- assert.ok(executeTool?.execute, "expected sage_execute tool");
450
-
451
- const result = await executeTool.execute("call-2", {
452
- domain: "skills",
453
- action: "use",
454
- params: { key: "rm -rf /" },
455
- });
456
-
457
- const text =
458
- result?.content
459
- ?.filter((c: any) => c.type === "text")
460
- .map((c: any) => c.text)
461
- .join("\n") ?? "";
462
-
463
- assert.ok(!/Blocked by injection guard/i.test(text));
464
- } finally {
465
- if (svc!.stop) {
466
- await svc!.stop({ config: {}, stateDir: "/tmp", logger: api.logger });
467
- }
468
- }
469
- });
package/src/mcp-bridge.ts DELETED
@@ -1,230 +0,0 @@
1
- import { spawn, type ChildProcess } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
- import { EventEmitter } from "node:events";
4
- import { createInterface, type Interface as ReadlineInterface } from "node:readline";
5
-
6
- /** MCP tool definition returned by tools/list */
7
- export type McpToolDef = {
8
- name: string;
9
- description?: string;
10
- inputSchema?: Record<string, unknown>;
11
- /** Tool category (from Sage MCP registry) */
12
- annotations?: Record<string, unknown>;
13
- };
14
-
15
- /** JSON-RPC request/response types */
16
- type JsonRpcRequest = {
17
- jsonrpc: "2.0";
18
- id: string | number;
19
- method: string;
20
- params?: Record<string, unknown>;
21
- };
22
-
23
- type JsonRpcResponse = {
24
- jsonrpc: "2.0";
25
- id: string | number;
26
- result?: unknown;
27
- error?: { code: number; message: string; data?: unknown };
28
- };
29
-
30
- const MAX_RETRIES = 3;
31
- const RESTART_DELAY_MS = 1000;
32
-
33
- /**
34
- * Lightweight MCP stdio client.
35
- *
36
- * Spawns a child process that speaks JSON-RPC over stdin/stdout (MCP stdio transport).
37
- * Provides methods to list tools and call them.
38
- */
39
- export class McpBridge extends EventEmitter {
40
- private proc: ChildProcess | null = null;
41
- private rl: ReadlineInterface | null = null;
42
- private pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
43
- private ready = false;
44
- private retries = 0;
45
- private stopped = false;
46
-
47
- private clientVersion: string;
48
-
49
- constructor(
50
- private command: string,
51
- private args: string[],
52
- private env?: Record<string, string>,
53
- opts?: { clientVersion?: string },
54
- ) {
55
- super();
56
- this.clientVersion = opts?.clientVersion ?? "0.0.0";
57
- }
58
-
59
- /** Whether the bridge is connected and ready for requests */
60
- isReady(): boolean {
61
- return this.ready && this.proc !== null && !this.stopped;
62
- }
63
-
64
- async start(): Promise<void> {
65
- this.stopped = false;
66
- await this.spawn();
67
- await this.initialize();
68
- }
69
-
70
- async stop(): Promise<void> {
71
- this.stopped = true;
72
- this.ready = false;
73
- this.rejectAll("Bridge stopped");
74
- if (this.rl) {
75
- this.rl.close();
76
- this.rl = null;
77
- }
78
- if (this.proc) {
79
- this.proc.kill("SIGTERM");
80
- this.proc = null;
81
- }
82
- }
83
-
84
- async listTools(): Promise<McpToolDef[]> {
85
- const result = (await this.request("tools/list", {})) as { tools?: McpToolDef[] };
86
- return result?.tools ?? [];
87
- }
88
-
89
- async callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
90
- const result = (await this.request("tools/call", { name, arguments: args })) as {
91
- content?: Array<{ type: string; text?: string }>;
92
- isError?: boolean;
93
- };
94
-
95
- if (result?.isError) {
96
- const text = result.content?.map((c) => c.text ?? "").join("\n") ?? "MCP tool error";
97
- throw new Error(text);
98
- }
99
-
100
- return result;
101
- }
102
-
103
- // ── private ──────────────────────────────────────────────────────────
104
-
105
- private spawn(): Promise<void> {
106
- return new Promise((resolve, reject) => {
107
- const proc = spawn(this.command, this.args, {
108
- stdio: ["pipe", "pipe", "pipe"],
109
- env: { ...process.env, ...this.env },
110
- });
111
-
112
- proc.on("error", (err) => {
113
- if (!this.stopped) {
114
- this.handleCrash(err);
115
- }
116
- });
117
-
118
- proc.on("exit", (code) => {
119
- if (!this.stopped && code !== 0) {
120
- this.handleCrash(new Error(`MCP process exited with code ${code}`));
121
- }
122
- });
123
-
124
- if (!proc.stdout || !proc.stdin) {
125
- reject(new Error("Failed to open stdio pipes for MCP process"));
126
- return;
127
- }
128
-
129
- this.proc = proc;
130
-
131
- this.rl = createInterface({ input: proc.stdout });
132
- this.rl.on("line", (line) => this.handleLine(line));
133
-
134
- if (proc.stderr) {
135
- const errRl = createInterface({ input: proc.stderr });
136
- errRl.on("line", (line) => this.emit("log", line));
137
- }
138
-
139
- resolve();
140
- });
141
- }
142
-
143
- private async initialize(): Promise<void> {
144
- const result = (await this.request("initialize", {
145
- protocolVersion: "2024-11-05",
146
- capabilities: {},
147
- clientInfo: { name: "openclaw-sage-plugin", version: this.clientVersion },
148
- })) as { serverInfo?: { name?: string } };
149
-
150
- this.notify("notifications/initialized", {});
151
- this.ready = true;
152
- this.retries = 0;
153
- this.emit("ready", result?.serverInfo);
154
- }
155
-
156
- private request(method: string, params: Record<string, unknown>): Promise<unknown> {
157
- return new Promise((resolve, reject) => {
158
- if (!this.proc?.stdin?.writable) {
159
- reject(new Error("MCP process not running"));
160
- return;
161
- }
162
-
163
- const id = randomUUID();
164
- const req: JsonRpcRequest = { jsonrpc: "2.0", id, method, params };
165
-
166
- this.pending.set(id, { resolve, reject });
167
- this.proc.stdin.write(JSON.stringify(req) + "\n");
168
- });
169
- }
170
-
171
- private notify(method: string, params: Record<string, unknown>): void {
172
- if (!this.proc?.stdin?.writable) return;
173
- const msg = { jsonrpc: "2.0", method, params };
174
- this.proc.stdin.write(JSON.stringify(msg) + "\n");
175
- }
176
-
177
- private handleLine(line: string): void {
178
- let msg: JsonRpcResponse;
179
- try {
180
- msg = JSON.parse(line);
181
- } catch {
182
- return;
183
- }
184
-
185
- if (!msg.id) return;
186
-
187
- const id = String(msg.id);
188
- const pending = this.pending.get(id);
189
- if (!pending) return;
190
-
191
- this.pending.delete(id);
192
-
193
- if (msg.error) {
194
- pending.reject(new Error(`MCP error ${msg.error.code}: ${msg.error.message}`));
195
- } else {
196
- pending.resolve(msg.result);
197
- }
198
- }
199
-
200
- private async handleCrash(err: Error): Promise<void> {
201
- this.ready = false;
202
- this.rejectAll(`MCP process crashed: ${err.message}`);
203
-
204
- if (this.retries >= MAX_RETRIES) {
205
- this.emit("error", new Error(`MCP bridge failed after ${MAX_RETRIES} retries: ${err.message}`));
206
- return;
207
- }
208
-
209
- this.retries++;
210
- this.emit("log", `MCP process crashed, retry ${this.retries}/${MAX_RETRIES}...`);
211
-
212
- await new Promise((r) => setTimeout(r, RESTART_DELAY_MS));
213
-
214
- if (!this.stopped) {
215
- try {
216
- await this.spawn();
217
- await this.initialize();
218
- } catch (retryErr) {
219
- this.handleCrash(retryErr instanceof Error ? retryErr : new Error(String(retryErr)));
220
- }
221
- }
222
- }
223
-
224
- private rejectAll(reason: string): void {
225
- for (const [, { reject }] of this.pending) {
226
- reject(new Error(reason));
227
- }
228
- this.pending.clear();
229
- }
230
- }