mcp-castor 2026.3.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/README.md +487 -0
- package/bin/castor.js +706 -0
- package/index.js +206 -0
- package/package.json +97 -0
- package/skills/canary-test-staging/SKILL.md +24 -0
- package/skills/evo-mutation-rollback/SKILL.md +29 -0
- package/skills/hypothesis-generation/SKILL.md +26 -0
- package/skills/traceback-condensing/SKILL.md +26 -0
- package/src/castor_runner.js +469 -0
- package/src/config.js +1204 -0
- package/src/env.js +10 -0
- package/src/evo_engine.js +214 -0
- package/src/harness/core/events.js +75 -0
- package/src/harness/core/kernel.js +209 -0
- package/src/harness/evo/evaluator.js +156 -0
- package/src/harness/evo/evo_operator.js +550 -0
- package/src/harness/evo/lineage_dag.js +383 -0
- package/src/harness/evo/trace_repair.js +173 -0
- package/src/harness/evo/watchdog.js +72 -0
- package/src/harness/loop_detector.js +135 -0
- package/src/harness/runner.js +1216 -0
- package/src/harness/services/ast_service.js +1813 -0
- package/src/harness/services/event_logger.js +275 -0
- package/src/harness/services/mcp_bridge.js +408 -0
- package/src/harness/services/provider_vllm.js +728 -0
- package/src/harness/services/sandbox_fs.js +1238 -0
- package/src/harness/services/searxng_lifecycle.js +254 -0
- package/src/harness/services/shell_executor.js +264 -0
- package/src/harness/services/shell_validator.js +506 -0
- package/src/harness/services/web_service.js +828 -0
- package/src/platform.js +344 -0
- package/src/repetition_detector.js +139 -0
- package/src/semaphore.js +373 -0
- package/src/server_lifecycle.js +781 -0
- package/src/skills.js +400 -0
- package/src/state_pruner.js +392 -0
- package/src/task_registry.js +1357 -0
- package/src/telemetry.js +638 -0
- package/src/tools.js +997 -0
- package/src/wsl_bridge.js +629 -0
- package/src/wsl_env.js +171 -0
- package/stream_proxy.js +453 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-Only JSONL Session Event Logger (Castor Harness)
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - Transparent, zero-binary event ledger (replaces opaque SQLite sessions.db)
|
|
6
|
+
* - Atomic append-only writes for turns, tokens, tool invocations, and metrics
|
|
7
|
+
* - Session branching (forking state at turn N for parallel evolutionary candidates)
|
|
8
|
+
* - Castor plugin integration
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { QWEN_STATE_DIR } from "../../config.js";
|
|
14
|
+
|
|
15
|
+
export class EventLoggerService {
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this.sessionId = options.sessionId || `session_${Date.now()}`;
|
|
18
|
+
this.baseDir = options.baseDir || path.join(QWEN_STATE_DIR, "sessions");
|
|
19
|
+
this.sessionDir = path.join(this.baseDir, this.sessionId);
|
|
20
|
+
this.logFile = path.join(this.sessionDir, "events.jsonl");
|
|
21
|
+
this.metaFile = path.join(this.sessionDir, "metadata.json");
|
|
22
|
+
|
|
23
|
+
this._ensureDirectories();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_ensureDirectories() {
|
|
27
|
+
try {
|
|
28
|
+
fs.mkdirSync(this.sessionDir, { recursive: true });
|
|
29
|
+
} catch {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Appends an event to the session's JSONL stream.
|
|
34
|
+
* @param {object} event
|
|
35
|
+
*/
|
|
36
|
+
append(event) {
|
|
37
|
+
const entry = {
|
|
38
|
+
timestamp: new Date().toISOString(),
|
|
39
|
+
sessionId: this.sessionId,
|
|
40
|
+
...event,
|
|
41
|
+
};
|
|
42
|
+
const line = JSON.stringify(entry) + "\n";
|
|
43
|
+
try {
|
|
44
|
+
fs.appendFileSync(this.logFile, line, "utf8");
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error(`[EventLogger] Failed to write event:`, err.message);
|
|
47
|
+
}
|
|
48
|
+
return entry;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns true when the session's event stream already carries a terminal
|
|
53
|
+
* event (a `session_end` or `session_error`). Used by the orphan-marking
|
|
54
|
+
* path to guard against a DOUBLE terminal: a session that already ended
|
|
55
|
+
* (cleanly or by error) must not receive a second terminal event when its
|
|
56
|
+
* task is later reaped as orphaned.
|
|
57
|
+
*
|
|
58
|
+
* The scan is a single read of the existing file (no write) and tolerates
|
|
59
|
+
* malformed lines (they are skipped, matching readAll), so a partially
|
|
60
|
+
* written / corrupt trailing line can never throw into the caller.
|
|
61
|
+
*
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
hasTerminalEvent() {
|
|
65
|
+
if (!fs.existsSync(this.logFile)) return false;
|
|
66
|
+
let content;
|
|
67
|
+
try {
|
|
68
|
+
content = fs.readFileSync(this.logFile, "utf8");
|
|
69
|
+
} catch (err) {
|
|
70
|
+
// C1: ENOENT is legitimate; other I/O failures throw.
|
|
71
|
+
if (err.code === "ENOENT") return false;
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
for (const line of content.split("\n")) {
|
|
75
|
+
if (!line.trim()) continue;
|
|
76
|
+
let ev;
|
|
77
|
+
try {
|
|
78
|
+
ev = JSON.parse(line);
|
|
79
|
+
} catch {
|
|
80
|
+
continue; // malformed line: skip, never throw
|
|
81
|
+
}
|
|
82
|
+
if (ev && (ev.type === "session_end" || ev.type === "session_error")) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Reads all recorded events for the session.
|
|
91
|
+
* @returns {Array<object>}
|
|
92
|
+
*/
|
|
93
|
+
readAll() {
|
|
94
|
+
if (!fs.existsSync(this.logFile)) return [];
|
|
95
|
+
try {
|
|
96
|
+
const content = fs.readFileSync(this.logFile, "utf8");
|
|
97
|
+
return content
|
|
98
|
+
.split("\n")
|
|
99
|
+
.filter((l) => l.trim().length > 0)
|
|
100
|
+
.map((l) => {
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(l);
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
.filter(Boolean);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// C1: ENOENT is legitimate (file deleted between existsSync and read).
|
|
110
|
+
// Any other I/O failure is a real error — throw, don't mask.
|
|
111
|
+
if (err.code === "ENOENT") return [];
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Reconstructs the LLM conversation messages from the event stream.
|
|
118
|
+
* @returns {Array<object>}
|
|
119
|
+
*/
|
|
120
|
+
getConversationHistory() {
|
|
121
|
+
const events = this.readAll();
|
|
122
|
+
const messages = [];
|
|
123
|
+
|
|
124
|
+
for (const ev of events) {
|
|
125
|
+
if (ev.type === "user_message") {
|
|
126
|
+
messages.push({ role: "user", content: ev.content });
|
|
127
|
+
} else if (ev.type === "continuation_injected") {
|
|
128
|
+
// Faithful reconstruction of continuation directives injected mid-session
|
|
129
|
+
messages.push({
|
|
130
|
+
role: "user",
|
|
131
|
+
content: ev.content || "Your previous output was cut off by the token ceiling. Resume exactly where you stopped. Do not repeat already-emitted content.",
|
|
132
|
+
});
|
|
133
|
+
} else if (ev.type === "probe_budget_warning" && (ev.advisory || ev.content)) {
|
|
134
|
+
// Faithful reconstruction of in-band probe-budget advisories
|
|
135
|
+
messages.push({
|
|
136
|
+
role: "user",
|
|
137
|
+
content: ev.advisory || ev.content,
|
|
138
|
+
});
|
|
139
|
+
} else if (ev.type === "assistant_message") {
|
|
140
|
+
const msg = { role: "assistant", content: ev.content || "" };
|
|
141
|
+
if (ev.toolCalls && ev.toolCalls.length > 0) {
|
|
142
|
+
msg.tool_calls = ev.toolCalls;
|
|
143
|
+
}
|
|
144
|
+
messages.push(msg);
|
|
145
|
+
} else if (ev.type === "tool_result") {
|
|
146
|
+
messages.push({
|
|
147
|
+
role: "tool",
|
|
148
|
+
tool_call_id: ev.toolCallId,
|
|
149
|
+
content: typeof ev.result === "string" ? ev.result : JSON.stringify(ev.result ?? ev.error ?? ""),
|
|
150
|
+
});
|
|
151
|
+
} else if (ev.type === "tool_call_dropped") {
|
|
152
|
+
// Faithful reconstruction of dropped tool-call notices
|
|
153
|
+
messages.push({
|
|
154
|
+
role: "tool",
|
|
155
|
+
tool_call_id: ev.toolCallId,
|
|
156
|
+
content: ev.notice || `ToolExecutionError: Tool '${ev.name}' (id ${ev.toolCallId}) was dropped: its arguments were truncated mid-stream and could not be parsed as JSON (finish_reason: "length"). Please re-emit this tool call with complete, valid JSON arguments.`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return messages;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Forks/branches this session at a specific event index or turn count.
|
|
165
|
+
* Creates an independent session directory with historical events intact.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} newSessionId
|
|
168
|
+
* @param {number} [cutoffIndex]
|
|
169
|
+
* @returns {EventLoggerService}
|
|
170
|
+
*/
|
|
171
|
+
branch(newSessionId, cutoffIndex = null) {
|
|
172
|
+
const allEvents = this.readAll();
|
|
173
|
+
const sliced = cutoffIndex !== null ? allEvents.slice(0, cutoffIndex) : [...allEvents];
|
|
174
|
+
|
|
175
|
+
const childLogger = new EventLoggerService({
|
|
176
|
+
sessionId: newSessionId,
|
|
177
|
+
baseDir: this.baseDir,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
for (const ev of sliced) {
|
|
181
|
+
childLogger.append({ ...ev, parentSessionId: this.sessionId, branchedAt: new Date().toISOString() });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return childLogger;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Saves or updates metadata for this session.
|
|
189
|
+
* @param {object} meta
|
|
190
|
+
*/
|
|
191
|
+
saveMetadata(meta = {}) {
|
|
192
|
+
const data = {
|
|
193
|
+
sessionId: this.sessionId,
|
|
194
|
+
updatedAt: new Date().toISOString(),
|
|
195
|
+
...meta,
|
|
196
|
+
};
|
|
197
|
+
try {
|
|
198
|
+
fs.writeFileSync(this.metaFile, JSON.stringify(data, null, 2), "utf8");
|
|
199
|
+
} catch {}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Castor Plugin to mount EventLoggerService into Context.
|
|
205
|
+
*/
|
|
206
|
+
export function eventLoggerPlugin(ctx, options = {}) {
|
|
207
|
+
const logger = new EventLoggerService(options);
|
|
208
|
+
ctx.provide("logger", logger);
|
|
209
|
+
|
|
210
|
+
return () => {
|
|
211
|
+
logger.saveMetadata({ status: "closed" });
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Formats a concise summary of the last N events in a session for operator inspection.
|
|
217
|
+
* Works natively across Windows and WSL.
|
|
218
|
+
* @param {string} sessionId
|
|
219
|
+
* @param {number} [lastN=5]
|
|
220
|
+
* @returns {string}
|
|
221
|
+
*/
|
|
222
|
+
function inspectSession(sessionId, lastN = 5) {
|
|
223
|
+
const logger = new EventLoggerService({ sessionId });
|
|
224
|
+
const events = logger.readAll();
|
|
225
|
+
if (events.length === 0) {
|
|
226
|
+
return `[EventLogger] No events found for session '${sessionId}' in ${logger.logFile}`;
|
|
227
|
+
}
|
|
228
|
+
const recent = events.slice(-lastN);
|
|
229
|
+
const lines = [
|
|
230
|
+
`=== Session '${sessionId}' (showing last ${recent.length} of ${events.length} events) ===`,
|
|
231
|
+
];
|
|
232
|
+
for (let i = 0; i < recent.length; i++) {
|
|
233
|
+
const ev = recent[i];
|
|
234
|
+
const idx = events.length - recent.length + i + 1;
|
|
235
|
+
let detail = "";
|
|
236
|
+
if (ev.type === "tool_result") {
|
|
237
|
+
const resStr =
|
|
238
|
+
typeof ev.result === "string"
|
|
239
|
+
? ev.result
|
|
240
|
+
: JSON.stringify(ev.result ?? ev.error ?? "");
|
|
241
|
+
detail = `tool=${ev.toolName || ""} resultChars=${resStr.length} ${
|
|
242
|
+
ev.error ? `[ERROR: ${ev.error}]` : ""
|
|
243
|
+
}`;
|
|
244
|
+
} else if (ev.type === "assistant_message") {
|
|
245
|
+
const tc =
|
|
246
|
+
ev.toolCalls?.map((t) => t.name || t.function?.name).join(", ") || "none";
|
|
247
|
+
const preview = (ev.content || "").slice(0, 80).replace(/\r?\n/g, " ");
|
|
248
|
+
detail = `toolCalls=[${tc}] contentPreview="${preview}..."`;
|
|
249
|
+
} else if (ev.type === "user_message") {
|
|
250
|
+
const preview = (ev.content || "").slice(0, 80).replace(/\r?\n/g, " ");
|
|
251
|
+
detail = `contentPreview="${preview}..."`;
|
|
252
|
+
} else if (ev.type === "context_high_watermark" || ev.type === "context_depth_warning") {
|
|
253
|
+
detail = `promptTokens=${ev.promptTokens} threshold=${ev.threshold}`;
|
|
254
|
+
} else {
|
|
255
|
+
detail = JSON.stringify(ev);
|
|
256
|
+
}
|
|
257
|
+
lines.push(` [#${idx}] ${ev.timestamp} type=${ev.type} | ${detail}`);
|
|
258
|
+
}
|
|
259
|
+
return lines.join("\n");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (
|
|
263
|
+
process.argv[1] &&
|
|
264
|
+
(process.argv[1].endsWith("event_logger.js") ||
|
|
265
|
+
process.argv[1].endsWith("event_logger"))
|
|
266
|
+
) {
|
|
267
|
+
const targetSession = process.argv[2];
|
|
268
|
+
const count = parseInt(process.argv[3] || "5", 10);
|
|
269
|
+
if (!targetSession) {
|
|
270
|
+
console.log("Usage: node event_logger.js <sessionId> [eventCount]");
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
console.log(inspectSession(targetSession, count));
|
|
274
|
+
}
|
|
275
|
+
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic MCP Extension Bridge (Castor primary engine)
|
|
3
|
+
*
|
|
4
|
+
* Makes `extensions[]` first-class on the native engine by porting onto the
|
|
5
|
+
* official `@modelcontextprotocol/sdk` Client and StdioClientTransport.
|
|
6
|
+
*
|
|
7
|
+
* `extensions[]` on a dispatch (e.g. custom user tools, domain APIs) is honored
|
|
8
|
+
* directly by the native engine: each spec is normalized, spawned via
|
|
9
|
+
* StdioClientTransport, connected via SDK Client, and its tools are registered
|
|
10
|
+
* on the Castor Context.
|
|
11
|
+
*
|
|
12
|
+
* Features:
|
|
13
|
+
* - Normalizes each extension spec (string or { command, args, name }).
|
|
14
|
+
* - Spawns each extension via StdioClientTransport and buildSpawnProfile
|
|
15
|
+
* with Windows/WSL cross-platform path resolution. NEVER shell-interpolated.
|
|
16
|
+
* - Speaks full modern MCP specification through `@modelcontextprotocol/sdk`.
|
|
17
|
+
* - Registers each remote tool on Castor Context under `ext_<server>_<tool>`.
|
|
18
|
+
* - Teardown: dispose() cleanly closes the Client and enforces process-tree
|
|
19
|
+
* reaping (killProcessTreeSync) so zero child processes survive.
|
|
20
|
+
* - Bounded timeouts (initTimeoutMs, callTimeoutMs) and per-server tool caps.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createRequire } from "node:module";
|
|
24
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
25
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
26
|
+
import { IS_WINDOWS } from "../../config.js";
|
|
27
|
+
import { buildSpawnProfile, resolveCommandPath } from "../../platform.js";
|
|
28
|
+
import { killProcessTree, killProcessTreeSync } from "../../wsl_bridge.js";
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
let PKG_VERSION = "0.0.0";
|
|
32
|
+
try {
|
|
33
|
+
PKG_VERSION = require("../../../package.json").version || "0.0.0";
|
|
34
|
+
} catch {}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Extension-spec normalization
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Tokenize a command string into an argv array, respecting double-quoted
|
|
42
|
+
* segments. No shell is ever invoked - this is a pure string split.
|
|
43
|
+
* @param {string} s
|
|
44
|
+
* @returns {string[]}
|
|
45
|
+
*/
|
|
46
|
+
function tokenize(s) {
|
|
47
|
+
const out = [];
|
|
48
|
+
let cur = "";
|
|
49
|
+
let inQ = false;
|
|
50
|
+
for (const ch of s) {
|
|
51
|
+
if (ch === '"') {
|
|
52
|
+
inQ = !inQ;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (/\s/.test(ch) && !inQ) {
|
|
56
|
+
if (cur) {
|
|
57
|
+
out.push(cur);
|
|
58
|
+
cur = "";
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
cur += ch;
|
|
63
|
+
}
|
|
64
|
+
if (cur) out.push(cur);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Derive a human/server name from a command + args. Picks the last non-flag
|
|
70
|
+
* token (the package / entrypoint) and strips scope + path segments.
|
|
71
|
+
* @param {string} command
|
|
72
|
+
* @param {string[]} args
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
function deriveServerName(command, args) {
|
|
76
|
+
const candidates = (args || []).filter((a) => !a.startsWith("-"));
|
|
77
|
+
const pick = candidates.length ? candidates[candidates.length - 1] : command;
|
|
78
|
+
let name = String(pick).replace(/^@/, "").split("/").pop();
|
|
79
|
+
return name || "ext";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Sanitize an identifier to the safe charset [a-z0-9_].
|
|
84
|
+
* @param {string} s
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
function sanitizeName(s) {
|
|
88
|
+
let n = String(s)
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.replace(/[^a-z0-9_]+/g, "_")
|
|
91
|
+
.replace(/_+/g, "_")
|
|
92
|
+
.replace(/^_+|_+$/g, "");
|
|
93
|
+
return n || "ext";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Normalize a raw extension spec into { command, args, serverName }.
|
|
98
|
+
*
|
|
99
|
+
* Input contract:
|
|
100
|
+
* - a string like "npx -y @upstash/context7-mcp" or "uvx free-search-mcp"
|
|
101
|
+
* - a structured object { command, args, name? }
|
|
102
|
+
*
|
|
103
|
+
* When `targetInWsl` is set, Windows `.cmd`/`.exe` suffixes and aliases are
|
|
104
|
+
* normalized so the spec is interpreted cleanly inside WSL.
|
|
105
|
+
*
|
|
106
|
+
* @param {string|object} raw
|
|
107
|
+
* @param {{ targetInWsl?: boolean }} [opts]
|
|
108
|
+
* @returns {{ command: string, args: string[], serverName: string }}
|
|
109
|
+
*/
|
|
110
|
+
export function normalizeExtensionSpec(raw, { targetInWsl = false } = {}) {
|
|
111
|
+
if (raw && typeof raw === "object") {
|
|
112
|
+
const command = raw.command;
|
|
113
|
+
if (!command || typeof command !== "string") {
|
|
114
|
+
throw new Error("extension spec object must provide a string `command`");
|
|
115
|
+
}
|
|
116
|
+
const args = Array.isArray(raw.args) ? raw.args.map(String) : [];
|
|
117
|
+
const name = raw.name || deriveServerName(command, args);
|
|
118
|
+
return { command, args, serverName: sanitizeName(name) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof raw !== "string") {
|
|
122
|
+
throw new Error("extension spec must be a string or an object");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let s = raw;
|
|
126
|
+
if (targetInWsl) {
|
|
127
|
+
s = s.replace(/^npx\.cmd\b/, "npx").replace(/^uvx\.exe\b/, "uvx");
|
|
128
|
+
if (s.includes("context7@latest") && !s.includes("@upstash/context7-mcp")) {
|
|
129
|
+
s = s.replace("context7@latest", "@upstash/context7-mcp");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const tokens = tokenize(s);
|
|
134
|
+
if (tokens.length === 0) {
|
|
135
|
+
throw new Error("empty extension spec");
|
|
136
|
+
}
|
|
137
|
+
const command = tokens[0];
|
|
138
|
+
const args = tokens.slice(1);
|
|
139
|
+
return { command, args, serverName: sanitizeName(deriveServerName(command, args)) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Bridge
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A single spawned MCP server record + its SDK client state.
|
|
148
|
+
*/
|
|
149
|
+
function makeServerRecord(spec) {
|
|
150
|
+
return {
|
|
151
|
+
spec,
|
|
152
|
+
serverName: spec.serverName,
|
|
153
|
+
client: null,
|
|
154
|
+
transport: null,
|
|
155
|
+
pid: null,
|
|
156
|
+
stderr: "",
|
|
157
|
+
toolDisposers: [],
|
|
158
|
+
disposed: false,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Module-level registry of live bridges so that process-level shutdown
|
|
163
|
+
// (index.js cleanup) and task-cancel can reap every bridge child even when
|
|
164
|
+
// the owning runner's finally block has not yet run.
|
|
165
|
+
const LIVE_BRIDGES = new Set();
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Dispose every live bridge (kill all children + unregister tools). Called
|
|
169
|
+
* from the MCP server shutdown hook so no bridge child survives process exit.
|
|
170
|
+
*/
|
|
171
|
+
export function disposeAllBridges() {
|
|
172
|
+
for (const b of Array.from(LIVE_BRIDGES)) {
|
|
173
|
+
try {
|
|
174
|
+
b.dispose();
|
|
175
|
+
} catch {}
|
|
176
|
+
}
|
|
177
|
+
LIVE_BRIDGES.clear();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Retrieves the process IDs of all live bridge child processes across registered bridges.
|
|
182
|
+
* @returns {number[]} Array of child process IDs.
|
|
183
|
+
*/
|
|
184
|
+
export function getLiveBridgePids() {
|
|
185
|
+
const pids = [];
|
|
186
|
+
for (const b of Array.from(LIVE_BRIDGES)) {
|
|
187
|
+
for (const p of b.getPids()) pids.push(p);
|
|
188
|
+
}
|
|
189
|
+
return pids;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export class McpBridge {
|
|
193
|
+
/**
|
|
194
|
+
* @param {object} options
|
|
195
|
+
* @param {string} [options.cwd] working directory for the children
|
|
196
|
+
* @param {boolean} [options.targetInWsl] whether the target runs inside WSL
|
|
197
|
+
* @param {Array<string|object>} [options.extensions] raw extension specs
|
|
198
|
+
* @param {object} [options.timeouts]
|
|
199
|
+
* @param {number} [options.timeouts.initTimeoutMs] per-handshake-step timeout
|
|
200
|
+
* @param {number} [options.timeouts.callTimeoutMs] per tools/call timeout
|
|
201
|
+
* @param {number} [options.timeouts.maxToolsPerServer] tool cap per server
|
|
202
|
+
*/
|
|
203
|
+
constructor({ cwd, targetInWsl = false, extensions = [], timeouts = {} } = {}) {
|
|
204
|
+
this.cwd = cwd;
|
|
205
|
+
this.targetInWsl = targetInWsl;
|
|
206
|
+
this.extensions = Array.isArray(extensions) ? extensions : [];
|
|
207
|
+
this.initTimeoutMs = timeouts.initTimeoutMs ?? 60_000;
|
|
208
|
+
this.callTimeoutMs = timeouts.callTimeoutMs ?? 60_000;
|
|
209
|
+
this.maxToolsPerServer = timeouts.maxToolsPerServer ?? 64;
|
|
210
|
+
this.servers = [];
|
|
211
|
+
this.disposed = false;
|
|
212
|
+
this.started = false;
|
|
213
|
+
LIVE_BRIDGES.add(this);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* PIDs of all live bridge children (for tests / liveness assertions).
|
|
218
|
+
* @returns {number[]}
|
|
219
|
+
*/
|
|
220
|
+
getPids() {
|
|
221
|
+
return this.servers.map((s) => s.pid || s.transport?.pid).filter(Boolean);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Boot every extension, handshake via MCP SDK, and register its tools on the
|
|
226
|
+
* Castor Context. Never throws: a bad spec or a failed handshake is logged to
|
|
227
|
+
* stderr and skipped so it can never take down the dispatch.
|
|
228
|
+
* @param {import("../core/kernel.js").Context} ctx
|
|
229
|
+
*/
|
|
230
|
+
async start(ctx) {
|
|
231
|
+
if (this.started) return;
|
|
232
|
+
this.started = true;
|
|
233
|
+
for (const raw of this.extensions) {
|
|
234
|
+
let spec;
|
|
235
|
+
try {
|
|
236
|
+
spec = normalizeExtensionSpec(raw, { targetInWsl: this.targetInWsl });
|
|
237
|
+
} catch (err) {
|
|
238
|
+
process.stderr.write(
|
|
239
|
+
`[mcp-bridge] invalid extension spec ${JSON.stringify(raw)}: ${err.message}\n`
|
|
240
|
+
);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
await this._startOne(ctx, spec);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
process.stderr.write(
|
|
247
|
+
`[mcp-bridge] failed to start extension '${spec.serverName}': ${err.message}\n`
|
|
248
|
+
);
|
|
249
|
+
const srv = this.servers.find((s) => s.serverName === spec.serverName);
|
|
250
|
+
if (srv) this._killServer(srv);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Spawn one extension via SDK StdioClientTransport, run the MCP handshake,
|
|
257
|
+
* and register its tools on Context.
|
|
258
|
+
* @param {import("../core/kernel.js").Context} ctx
|
|
259
|
+
* @param {{ command: string, args: string[], serverName: string }} spec
|
|
260
|
+
*/
|
|
261
|
+
async _startOne(ctx, spec) {
|
|
262
|
+
const server = makeServerRecord(spec);
|
|
263
|
+
this.servers.push(server);
|
|
264
|
+
|
|
265
|
+
let command = spec.command;
|
|
266
|
+
if (!this.targetInWsl && IS_WINDOWS) {
|
|
267
|
+
command = resolveCommandPath(command) || command;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const profile = buildSpawnProfile({
|
|
271
|
+
command,
|
|
272
|
+
args: spec.args,
|
|
273
|
+
cwd: this.targetInWsl ? undefined : this.cwd,
|
|
274
|
+
mode: this.targetInWsl ? "wsl" : "windows",
|
|
275
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
276
|
+
useCd: false,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const transport = new StdioClientTransport({
|
|
280
|
+
command: profile.command,
|
|
281
|
+
args: profile.args,
|
|
282
|
+
env: profile.options.env,
|
|
283
|
+
cwd: profile.options.cwd,
|
|
284
|
+
stderr: "pipe",
|
|
285
|
+
});
|
|
286
|
+
server.transport = transport;
|
|
287
|
+
|
|
288
|
+
if (transport.stderr) {
|
|
289
|
+
transport.stderr.on("data", (chunk) => {
|
|
290
|
+
server.stderr += chunk.toString("utf8");
|
|
291
|
+
if (server.stderr.length > 20_000) server.stderr = server.stderr.slice(-20_000);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const client = new Client(
|
|
296
|
+
{ name: "mcp-castor-bridge", version: PKG_VERSION },
|
|
297
|
+
{ capabilities: {} }
|
|
298
|
+
);
|
|
299
|
+
server.client = client;
|
|
300
|
+
|
|
301
|
+
// Timeout-bounded initialize handshake
|
|
302
|
+
let initTimer;
|
|
303
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
304
|
+
initTimer = setTimeout(() => {
|
|
305
|
+
reject(new Error(`MCP 'initialize' timed out after ${this.initTimeoutMs}ms`));
|
|
306
|
+
}, this.initTimeoutMs);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
await Promise.race([client.connect(transport), timeoutPromise]);
|
|
311
|
+
} finally {
|
|
312
|
+
clearTimeout(initTimer);
|
|
313
|
+
server.pid = transport.pid;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Query tools/list with timeout
|
|
317
|
+
let listTimer;
|
|
318
|
+
const listTimeoutPromise = new Promise((_, reject) => {
|
|
319
|
+
listTimer = setTimeout(() => {
|
|
320
|
+
reject(new Error(`MCP 'tools/list' timed out after ${this.initTimeoutMs}ms`));
|
|
321
|
+
}, this.initTimeoutMs);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
let listResult;
|
|
325
|
+
try {
|
|
326
|
+
listResult = await Promise.race([client.listTools(), listTimeoutPromise]);
|
|
327
|
+
} finally {
|
|
328
|
+
clearTimeout(listTimer);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const tools = Array.isArray(listResult?.tools) ? listResult.tools : [];
|
|
332
|
+
const capped = tools.slice(0, this.maxToolsPerServer);
|
|
333
|
+
if (tools.length > capped.length) {
|
|
334
|
+
process.stderr.write(
|
|
335
|
+
`[mcp-bridge] ${spec.serverName}: ${tools.length} tools, capped to ${capped.length}\n`
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
for (const t of capped) {
|
|
340
|
+
const rawName = t.name || "tool";
|
|
341
|
+
const namespaced = `ext_${spec.serverName}_${sanitizeName(rawName)}`;
|
|
342
|
+
const dispose = ctx.registerTool(namespaced, {
|
|
343
|
+
description: `[ext:${spec.serverName}] ${t.description || ""}`.trim(),
|
|
344
|
+
parameters: t.inputSchema || { type: "object" },
|
|
345
|
+
execute: async (args) => {
|
|
346
|
+
return client.callTool(
|
|
347
|
+
{ name: rawName, arguments: args || {} },
|
|
348
|
+
undefined,
|
|
349
|
+
{ timeout: this.callTimeoutMs }
|
|
350
|
+
);
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
server.toolDisposers.push(dispose);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
process.stderr.write(
|
|
357
|
+
`[mcp-bridge] registered ${capped.length} tool(s) from '${spec.serverName}' (pid ${server.pid || transport.pid})\n`
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Kill a single bridge child via process-tree helpers and close SDK client.
|
|
363
|
+
* @param {object} server
|
|
364
|
+
*/
|
|
365
|
+
_killServer(server) {
|
|
366
|
+
if (server.disposed) return;
|
|
367
|
+
server.disposed = true;
|
|
368
|
+
const pid = server.pid || server.transport?.pid;
|
|
369
|
+
const child = server.transport?._process || (pid ? { pid } : null);
|
|
370
|
+
try {
|
|
371
|
+
server.client?.close();
|
|
372
|
+
} catch {}
|
|
373
|
+
try {
|
|
374
|
+
server.transport?.close();
|
|
375
|
+
} catch {}
|
|
376
|
+
if (child && pid) {
|
|
377
|
+
try {
|
|
378
|
+
killProcessTreeSync(child, null);
|
|
379
|
+
} catch {}
|
|
380
|
+
try {
|
|
381
|
+
killProcessTree(child, null).catch?.(() => {});
|
|
382
|
+
} catch {}
|
|
383
|
+
try {
|
|
384
|
+
child.kill?.("SIGKILL");
|
|
385
|
+
} catch {}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Teardown: cleanly close all SDK clients, kill every bridge child
|
|
391
|
+
* process-tree, and unregister all bridged tools. Idempotent.
|
|
392
|
+
*/
|
|
393
|
+
dispose() {
|
|
394
|
+
if (this.disposed) return;
|
|
395
|
+
this.disposed = true;
|
|
396
|
+
LIVE_BRIDGES.delete(this);
|
|
397
|
+
for (const server of this.servers) {
|
|
398
|
+
this._killServer(server);
|
|
399
|
+
for (const d of server.toolDisposers || []) {
|
|
400
|
+
try {
|
|
401
|
+
d();
|
|
402
|
+
} catch {}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
this.servers = [];
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|