@kkelly-offical/kkcode 0.1.2 → 0.1.6

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.
Files changed (58) hide show
  1. package/README.md +120 -178
  2. package/package.json +46 -46
  3. package/src/agent/agent.mjs +41 -0
  4. package/src/agent/prompt/frontend-designer.txt +58 -0
  5. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -0
  6. package/src/agent/prompt/longagent-coding-agent.txt +37 -0
  7. package/src/agent/prompt/longagent-debugging-agent.txt +46 -0
  8. package/src/agent/prompt/longagent-preview-agent.txt +63 -0
  9. package/src/config/defaults.mjs +260 -195
  10. package/src/config/schema.mjs +71 -6
  11. package/src/core/constants.mjs +91 -46
  12. package/src/index.mjs +1 -1
  13. package/src/knowledge/frontend-aesthetics.txt +39 -0
  14. package/src/knowledge/loader.mjs +2 -1
  15. package/src/knowledge/tailwind.txt +12 -3
  16. package/src/mcp/client-http.mjs +141 -157
  17. package/src/mcp/client-sse.mjs +288 -286
  18. package/src/mcp/client-stdio.mjs +533 -451
  19. package/src/mcp/constants.mjs +2 -0
  20. package/src/mcp/registry.mjs +479 -394
  21. package/src/mcp/stdio-framing.mjs +133 -127
  22. package/src/mcp/tool-result.mjs +24 -0
  23. package/src/observability/index.mjs +42 -0
  24. package/src/observability/metrics.mjs +137 -0
  25. package/src/observability/tracer.mjs +137 -0
  26. package/src/orchestration/background-manager.mjs +372 -358
  27. package/src/orchestration/background-worker.mjs +305 -245
  28. package/src/orchestration/longagent-manager.mjs +171 -116
  29. package/src/orchestration/stage-scheduler.mjs +728 -489
  30. package/src/permission/exec-policy.mjs +9 -11
  31. package/src/provider/anthropic.mjs +1 -0
  32. package/src/provider/openai.mjs +340 -339
  33. package/src/provider/retry-policy.mjs +68 -68
  34. package/src/provider/router.mjs +241 -228
  35. package/src/provider/sse.mjs +104 -91
  36. package/src/repl.mjs +1 -1
  37. package/src/session/checkpoint.mjs +66 -3
  38. package/src/session/engine.mjs +227 -225
  39. package/src/session/longagent-4stage.mjs +460 -0
  40. package/src/session/longagent-hybrid.mjs +1081 -0
  41. package/src/session/longagent-plan.mjs +365 -329
  42. package/src/session/longagent-project-memory.mjs +53 -0
  43. package/src/session/longagent-scaffold.mjs +291 -100
  44. package/src/session/longagent-task-bus.mjs +54 -0
  45. package/src/session/longagent-utils.mjs +472 -0
  46. package/src/session/longagent.mjs +884 -1462
  47. package/src/session/project-context.mjs +30 -0
  48. package/src/session/store.mjs +510 -503
  49. package/src/session/task-validator.mjs +4 -3
  50. package/src/skill/builtin/design.mjs +76 -0
  51. package/src/skill/builtin/frontend.mjs +8 -0
  52. package/src/skill/registry.mjs +390 -336
  53. package/src/storage/ghost-commit-store.mjs +18 -8
  54. package/src/tool/executor.mjs +11 -0
  55. package/src/tool/git-auto.mjs +0 -19
  56. package/src/tool/registry.mjs +71 -37
  57. package/src/ui/activity-renderer.mjs +664 -410
  58. package/src/util/git.mjs +23 -0
@@ -1,451 +1,533 @@
1
- import { spawn } from "node:child_process"
2
- import { McpError } from "../core/errors.mjs"
3
- import { EventBus } from "../core/events.mjs"
4
- import { EVENT_TYPES } from "../core/constants.mjs"
5
- import { createStdioFramingDecoder, encodeRpcMessage } from "./stdio-framing.mjs"
6
-
7
- const VALID_FRAMING = new Set(["auto", "content-length", "newline"])
8
- const VALID_HEALTH_METHOD = new Set(["auto", "ping", "tools_list"])
9
-
10
- function normalizeFraming(value) {
11
- const framing = String(value || "auto").toLowerCase()
12
- return VALID_FRAMING.has(framing) ? framing : "auto"
13
- }
14
-
15
- function normalizeHealthMethod(value) {
16
- const method = String(value || "auto").toLowerCase()
17
- return VALID_HEALTH_METHOD.has(method) ? method : "auto"
18
- }
19
-
20
- function classifySpawnError(error) {
21
- const code = String(error?.code || "").toUpperCase()
22
- const msg = String(error?.message || error || "")
23
- if (code === "ENOENT" || code === "EACCES") return "spawn_failed"
24
- if (msg.includes("ENOENT") || msg.includes("EACCES") || msg.includes("spawn")) return "spawn_failed"
25
- return "unknown"
26
- }
27
-
28
- function normalizeToolResult(result, serverName, toolName) {
29
- if (result?.isError) {
30
- const text = Array.isArray(result.content)
31
- ? result.content.map((item) => item?.text || "").join("\n").trim()
32
- : ""
33
- throw new McpError(text || "mcp tool returned isError", {
34
- reason: "bad_response",
35
- server: serverName,
36
- action: `tools/call:${toolName}`,
37
- phase: "request"
38
- })
39
- }
40
-
41
- const content = Array.isArray(result?.content) ? result.content : null
42
- const contentText = content
43
- ? content.map((item) => (typeof item?.text === "string" ? item.text : "")).join("\n").trim()
44
- : ""
45
- const output =
46
- contentText ||
47
- (typeof result?.output === "string" ? result.output : "") ||
48
- (typeof result === "string" ? result : JSON.stringify(result))
49
-
50
- return content
51
- ? { output, raw: result, content }
52
- : { output, raw: result }
53
- }
54
-
55
- export function createStdioMcpClient(serverName, config = {}) {
56
- const command = config.command
57
- const cmdArgs = Array.isArray(config.args) ? config.args : []
58
- const envOverrides = config.env || {}
59
- const startupTimeoutMs = Math.max(100, Number(config.startup_timeout_ms || 5000))
60
- const requestTimeoutMs = Math.max(100, Number(config.request_timeout_ms || config.timeout_ms || 30000))
61
- const healthCheckMethod = normalizeHealthMethod(config.health_check_method)
62
- const configuredFraming = normalizeFraming(config.framing)
63
- const isWindows = process.platform === "win32"
64
- const explicitShell = config.shell === true || (config.shell !== false && isWindows)
65
-
66
- let executable
67
- let spawnArgs
68
- if (Array.isArray(command)) {
69
- executable = command[0]
70
- spawnArgs = command.slice(1)
71
- } else {
72
- executable = command
73
- spawnArgs = cmdArgs
74
- }
75
-
76
- if (!executable) {
77
- throw new McpError(`mcp server "${serverName}" missing command`, {
78
- reason: "spawn_failed",
79
- server: serverName,
80
- phase: "startup"
81
- })
82
- }
83
-
84
- let child = null
85
- let lifecycle = "closed"
86
- let nextId = 1
87
- let initialized = false
88
- let activeFraming = configuredFraming === "auto" ? "content-length" : configuredFraming
89
- let decoder = createStdioFramingDecoder({
90
- framing: configuredFraming === "auto" ? "auto" : activeFraming
91
- })
92
- let malformedSeen = false
93
- let malformedSnippet = ""
94
- let stderrLines = []
95
- let ignoreClose = false
96
-
97
- const pending = new Map()
98
-
99
- function resetRuntime() {
100
- decoder = createStdioFramingDecoder({
101
- framing: configuredFraming === "auto" ? "auto" : activeFraming
102
- })
103
- malformedSeen = false
104
- malformedSnippet = ""
105
- stderrLines = []
106
- }
107
-
108
- function appendStderr(chunk) {
109
- const text = String(chunk || "").trim()
110
- if (!text) return
111
- stderrLines.push(text)
112
- if (stderrLines.length > 8) stderrLines = stderrLines.slice(stderrLines.length - 8)
113
- }
114
-
115
- function rejectPending(reason, message, details = {}) {
116
- for (const [, entry] of pending) {
117
- clearTimeout(entry.timer)
118
- entry.reject(
119
- new McpError(message, {
120
- reason,
121
- server: serverName,
122
- action: entry.method,
123
- phase: entry.phase || details.phase || "request",
124
- stderrSnippet: stderrLines.join(" | ") || undefined,
125
- ...details
126
- })
127
- )
128
- }
129
- pending.clear()
130
- }
131
-
132
- function cleanupChild() {
133
- child = null
134
- initialized = false
135
- lifecycle = "closed"
136
- }
137
-
138
- async function startProcess() {
139
- if (child && lifecycle !== "closed") return
140
-
141
- resetRuntime()
142
- lifecycle = "starting"
143
- ignoreClose = false
144
-
145
- await new Promise((resolve, reject) => {
146
- let settled = false
147
- const proc = spawn(executable, spawnArgs, {
148
- stdio: ["pipe", "pipe", "pipe"],
149
- env: { ...process.env, ...envOverrides },
150
- windowsHide: true,
151
- shell: explicitShell
152
- })
153
- child = proc
154
-
155
- const timer = setTimeout(() => {
156
- if (settled) return
157
- settled = true
158
- try { proc.kill() } catch {}
159
- reject(
160
- new McpError(`mcp server "${serverName}" startup timeout after ${startupTimeoutMs}ms`, {
161
- reason: "timeout",
162
- server: serverName,
163
- phase: "startup"
164
- })
165
- )
166
- }, startupTimeoutMs)
167
-
168
- proc.once("spawn", () => {
169
- if (settled) return
170
- settled = true
171
- clearTimeout(timer)
172
- lifecycle = "running"
173
- resolve()
174
- })
175
-
176
- proc.once("error", (err) => {
177
- const reason = classifySpawnError(err)
178
- if (!settled) {
179
- settled = true
180
- clearTimeout(timer)
181
- reject(
182
- new McpError(`mcp server "${serverName}" process error: ${err.message}`, {
183
- reason,
184
- server: serverName,
185
- phase: "startup"
186
- })
187
- )
188
- } else {
189
- rejectPending(reason, `mcp server "${serverName}" process error: ${err.message}`, { phase: "request" })
190
- }
191
- })
192
-
193
- proc.stdout.on("data", (chunk) => {
194
- let payloads = []
195
- try {
196
- payloads = decoder.push(chunk)
197
- } catch (error) {
198
- malformedSeen = true
199
- malformedSnippet = String(error.message || "invalid framing").slice(0, 240)
200
- return
201
- }
202
-
203
- for (const payload of payloads) {
204
- let msg
205
- try {
206
- msg = JSON.parse(payload)
207
- } catch {
208
- malformedSeen = true
209
- malformedSnippet = String(payload || "").slice(0, 240)
210
- continue
211
- }
212
-
213
- if (msg?.id != null && pending.has(msg.id)) {
214
- const entry = pending.get(msg.id)
215
- pending.delete(msg.id)
216
- clearTimeout(entry.timer)
217
- if (msg.error) {
218
- entry.reject(
219
- new McpError(
220
- `mcp server "${serverName}" error: ${msg.error.message || JSON.stringify(msg.error)}`,
221
- {
222
- reason: "bad_response",
223
- server: serverName,
224
- action: entry.method,
225
- phase: entry.phase,
226
- code: msg.error.code,
227
- stderrSnippet: stderrLines.join(" | ") || undefined
228
- }
229
- )
230
- )
231
- } else {
232
- const elapsed = Date.now() - entry.startedAt
233
- EventBus.emit({
234
- type: EVENT_TYPES.MCP_REQUEST,
235
- payload: { server: serverName, action: entry.method, elapsed, transport: "stdio" }
236
- }).catch(() => {})
237
- entry.resolve(msg.result ?? {})
238
- }
239
- }
240
- }
241
- })
242
-
243
- proc.stderr.on("data", (chunk) => appendStderr(chunk))
244
-
245
- proc.on("close", (code, signal) => {
246
- if (ignoreClose) {
247
- cleanupChild()
248
- return
249
- }
250
- const reason = malformedSeen ? "bad_response" : "server_crash"
251
- const extra = malformedSeen && malformedSnippet
252
- ? `; malformed stdout: ${malformedSnippet}`
253
- : ""
254
- rejectPending(
255
- reason,
256
- `mcp server "${serverName}" process exited unexpectedly (code=${code ?? "null"}, signal=${signal || "null"})${extra}`,
257
- { phase: lifecycle === "starting" ? "startup" : "request" }
258
- )
259
- cleanupChild()
260
- })
261
- })
262
- }
263
-
264
- async function shutdownProcess() {
265
- if (!child) return
266
- ignoreClose = true
267
- try {
268
- child.kill()
269
- } catch {}
270
- rejectPending("unknown", `mcp server "${serverName}" shutdown`, { phase: "shutdown" })
271
- cleanupChild()
272
- }
273
-
274
- async function sendRequest(method, params = {}, { phase = "request", timeoutMs = requestTimeoutMs } = {}) {
275
- await startProcess()
276
- const id = nextId++
277
- const payload = { jsonrpc: "2.0", id, method, params }
278
-
279
- return new Promise((resolve, reject) => {
280
- const startedAt = Date.now()
281
- const timer = setTimeout(() => {
282
- pending.delete(id)
283
- reject(
284
- new McpError(`mcp server "${serverName}" timed out after ${timeoutMs}ms on "${method}"`, {
285
- reason: "timeout",
286
- server: serverName,
287
- action: method,
288
- phase
289
- })
290
- )
291
- }, timeoutMs)
292
-
293
- pending.set(id, { resolve, reject, timer, method, phase, startedAt })
294
- try {
295
- const wireFraming = configuredFraming === "auto" ? activeFraming : configuredFraming
296
- child.stdin.write(encodeRpcMessage(payload, wireFraming))
297
- } catch (error) {
298
- clearTimeout(timer)
299
- pending.delete(id)
300
- reject(
301
- new McpError(`mcp server "${serverName}" stdin write failed: ${error.message}`, {
302
- reason: "server_crash",
303
- server: serverName,
304
- action: method,
305
- phase
306
- })
307
- )
308
- }
309
- })
310
- }
311
-
312
- function sendNotification(method, params = {}) {
313
- if (!child || lifecycle === "closed") return
314
- const payload = { jsonrpc: "2.0", method, params }
315
- try {
316
- const wireFraming = configuredFraming === "auto" ? activeFraming : configuredFraming
317
- child.stdin.write(encodeRpcMessage(payload, wireFraming))
318
- } catch {
319
- // best effort
320
- }
321
- }
322
-
323
- async function initializeOnce() {
324
- if (initialized) return
325
- const initParams = {
326
- protocolVersion: "2024-11-05",
327
- capabilities: {},
328
- clientInfo: { name: "kkcode", version: "0.1.2" }
329
- }
330
-
331
- if (configuredFraming === "auto") {
332
- let lastError = null
333
- for (const candidate of ["content-length", "newline"]) {
334
- activeFraming = candidate
335
- await shutdownProcess()
336
- try {
337
- await sendRequest("initialize", initParams, { phase: "initialize" })
338
- sendNotification("notifications/initialized")
339
- initialized = true
340
- return
341
- } catch (error) {
342
- lastError = error
343
- }
344
- }
345
- throw lastError || new McpError(`mcp server "${serverName}" failed to initialize`, {
346
- reason: "unknown",
347
- server: serverName,
348
- phase: "initialize"
349
- })
350
- }
351
-
352
- await sendRequest("initialize", initParams, { phase: "initialize" })
353
- sendNotification("notifications/initialized")
354
- initialized = true
355
- }
356
-
357
- async function healthPingOrTools() {
358
- if (healthCheckMethod === "ping") {
359
- await sendRequest("ping", {}, { phase: "request" })
360
- return
361
- }
362
- if (healthCheckMethod === "tools_list") {
363
- await sendRequest("tools/list", {}, { phase: "request" })
364
- return
365
- }
366
-
367
- try {
368
- await sendRequest("ping", {}, { phase: "request" })
369
- } catch (error) {
370
- if (!["bad_response", "protocol_error", "unknown"].includes(error.reason)) throw error
371
- await sendRequest("tools/list", {}, { phase: "request" })
372
- }
373
- }
374
-
375
- return {
376
- serverName,
377
- transport: "stdio",
378
-
379
- async health() {
380
- try {
381
- await initializeOnce()
382
- await healthPingOrTools()
383
- return {
384
- ok: true,
385
- reason: "ok",
386
- framing: configuredFraming === "auto" ? activeFraming : configuredFraming
387
- }
388
- } catch (error) {
389
- return {
390
- ok: false,
391
- error: error.message,
392
- reason: error.reason || "unknown",
393
- phase: error.details?.phase || "unknown",
394
- framing: configuredFraming === "auto" ? activeFraming : configuredFraming
395
- }
396
- }
397
- },
398
-
399
- async listTools() {
400
- await initializeOnce()
401
- const out = await sendRequest("tools/list")
402
- return Array.isArray(out?.tools) ? out.tools : []
403
- },
404
-
405
- async listPrompts() {
406
- await initializeOnce()
407
- try {
408
- const out = await sendRequest("prompts/list")
409
- return Array.isArray(out?.prompts) ? out.prompts : []
410
- } catch {
411
- return []
412
- }
413
- },
414
-
415
- async getPrompt(name, args = {}) {
416
- await initializeOnce()
417
- return sendRequest("prompts/get", { name, arguments: args })
418
- },
419
-
420
- async listResources() {
421
- await initializeOnce()
422
- try {
423
- const out = await sendRequest("resources/list")
424
- return Array.isArray(out?.resources) ? out.resources : []
425
- } catch {
426
- return []
427
- }
428
- },
429
-
430
- async listTemplates() {
431
- await initializeOnce()
432
- try {
433
- const out = await sendRequest("resources/templates/list")
434
- return Array.isArray(out?.templates) ? out.templates : []
435
- } catch {
436
- return []
437
- }
438
- },
439
-
440
- async callTool(name, args = {}, signal = null) {
441
- await initializeOnce()
442
- const result = await sendRequest("tools/call", { name, arguments: args })
443
- return normalizeToolResult(result, serverName, name)
444
- },
445
-
446
- shutdown() {
447
- sendNotification("notifications/cancelled", { reason: "shutdown" })
448
- shutdownProcess().catch(() => {})
449
- }
450
- }
451
- }
1
+ import { spawn } from "node:child_process"
2
+ import { McpError } from "../core/errors.mjs"
3
+ import { EventBus } from "../core/events.mjs"
4
+ import { EVENT_TYPES } from "../core/constants.mjs"
5
+ import { createStdioFramingDecoder, encodeRpcMessage } from "./stdio-framing.mjs"
6
+ import { normalizeToolResult } from "./tool-result.mjs"
7
+ import { MCP_PROTOCOL_VERSION, MCP_CLIENT_INFO } from "./constants.mjs"
8
+
9
+ const VALID_FRAMING = new Set(["auto", "content-length", "newline"])
10
+ const VALID_HEALTH_METHOD = new Set(["auto", "ping", "tools_list"])
11
+
12
+ function normalizeFraming(value) {
13
+ const framing = String(value || "auto").toLowerCase()
14
+ return VALID_FRAMING.has(framing) ? framing : "auto"
15
+ }
16
+
17
+ function normalizeHealthMethod(value) {
18
+ const method = String(value || "auto").toLowerCase()
19
+ return VALID_HEALTH_METHOD.has(method) ? method : "auto"
20
+ }
21
+
22
+ function classifySpawnError(error) {
23
+ const code = String(error?.code || "").toUpperCase()
24
+ const msg = String(error?.message || error || "")
25
+ if (code === "ENOENT" || code === "EACCES") return "spawn_failed"
26
+ if (msg.includes("ENOENT") || msg.includes("EACCES") || msg.includes("spawn")) return "spawn_failed"
27
+ return "unknown"
28
+ }
29
+
30
+ export function createStdioMcpClient(serverName, config = {}) {
31
+ const command = config.command
32
+ const cmdArgs = Array.isArray(config.args) ? config.args : []
33
+ const envOverrides = config.env || {}
34
+ const startupTimeoutMs = Math.max(100, Number(config.startup_timeout_ms || 5000))
35
+ const requestTimeoutMs = Math.max(100, Number(config.request_timeout_ms || config.timeout_ms || 30000))
36
+ const healthCheckMethod = normalizeHealthMethod(config.health_check_method)
37
+ const configuredFraming = normalizeFraming(config.framing)
38
+ const isWindows = process.platform === "win32"
39
+ const explicitShell = config.shell === true || (config.shell !== false && isWindows)
40
+
41
+ let executable
42
+ let spawnArgs
43
+ if (Array.isArray(command)) {
44
+ executable = command[0]
45
+ spawnArgs = command.slice(1)
46
+ } else {
47
+ executable = command
48
+ spawnArgs = cmdArgs
49
+ }
50
+
51
+ if (!executable) {
52
+ throw new McpError(`mcp server "${serverName}" missing command`, {
53
+ reason: "spawn_failed",
54
+ server: serverName,
55
+ phase: "startup"
56
+ })
57
+ }
58
+
59
+ const maxReconnectAttempts = Number(config.max_reconnect_attempts ?? 5)
60
+ const circuitResetMs = Number(config.circuit_reset_ms ?? 60000)
61
+
62
+ let child = null
63
+ let lifecycle = "closed"
64
+ let nextId = 1
65
+ let initialized = false
66
+ let activeFraming = configuredFraming === "auto" ? "content-length" : configuredFraming
67
+ let decoder = createStdioFramingDecoder({
68
+ framing: configuredFraming === "auto" ? "auto" : activeFraming
69
+ })
70
+ let malformedSeen = false
71
+ let malformedSnippet = ""
72
+ let stderrLines = []
73
+ let stderrTotalBytes = 0
74
+ let ignoreClose = false
75
+ let reconnectAttempts = 0
76
+ let circuitState = "closed" // "closed" | "open" | "half_open"
77
+ let circuitOpenedAt = 0
78
+ let wasEverInitialized = false
79
+
80
+ const pending = new Map()
81
+
82
+ function resetRuntime() {
83
+ decoder = createStdioFramingDecoder({
84
+ framing: configuredFraming === "auto" ? "auto" : activeFraming
85
+ })
86
+ malformedSeen = false
87
+ malformedSnippet = ""
88
+ stderrLines = []
89
+ stderrTotalBytes = 0
90
+ }
91
+
92
+ function appendStderr(chunk) {
93
+ const text = String(chunk || "").trim()
94
+ if (!text) return
95
+ stderrTotalBytes += Buffer.byteLength(chunk)
96
+ stderrLines.push(text)
97
+ if (stderrLines.length > 32) stderrLines = stderrLines.slice(stderrLines.length - 32)
98
+ }
99
+
100
+ function rejectPending(reason, message, details = {}) {
101
+ for (const [, entry] of pending) {
102
+ clearTimeout(entry.timer)
103
+ entry.reject(
104
+ new McpError(message, {
105
+ reason,
106
+ server: serverName,
107
+ action: entry.method,
108
+ phase: entry.phase || details.phase || "request",
109
+ stderrSnippet: stderrLines.join(" | ") || undefined,
110
+ ...details
111
+ })
112
+ )
113
+ }
114
+ pending.clear()
115
+ }
116
+
117
+ function cleanupChild() {
118
+ child = null
119
+ initialized = false
120
+ lifecycle = "closed"
121
+ }
122
+
123
+ async function startProcess() {
124
+ if (child && lifecycle !== "closed") return
125
+
126
+ resetRuntime()
127
+ lifecycle = "starting"
128
+ ignoreClose = false
129
+
130
+ await new Promise((resolve, reject) => {
131
+ let settled = false
132
+ const proc = spawn(executable, spawnArgs, {
133
+ stdio: ["pipe", "pipe", "pipe"],
134
+ env: { ...process.env, ...envOverrides },
135
+ windowsHide: true,
136
+ shell: explicitShell
137
+ })
138
+ child = proc
139
+
140
+ const timer = setTimeout(() => {
141
+ if (settled) return
142
+ settled = true
143
+ try { proc.kill() } catch {}
144
+ reject(
145
+ new McpError(`mcp server "${serverName}" startup timeout after ${startupTimeoutMs}ms`, {
146
+ reason: "timeout",
147
+ server: serverName,
148
+ phase: "startup"
149
+ })
150
+ )
151
+ }, startupTimeoutMs)
152
+
153
+ proc.once("spawn", () => {
154
+ if (settled) return
155
+ settled = true
156
+ clearTimeout(timer)
157
+ lifecycle = "running"
158
+ resolve()
159
+ })
160
+
161
+ proc.once("error", (err) => {
162
+ const reason = classifySpawnError(err)
163
+ if (!settled) {
164
+ settled = true
165
+ clearTimeout(timer)
166
+ reject(
167
+ new McpError(`mcp server "${serverName}" process error: ${err.message}`, {
168
+ reason,
169
+ server: serverName,
170
+ phase: "startup"
171
+ })
172
+ )
173
+ } else {
174
+ rejectPending(reason, `mcp server "${serverName}" process error: ${err.message}`, { phase: "request" })
175
+ }
176
+ })
177
+
178
+ proc.stdout.on("data", (chunk) => {
179
+ let payloads = []
180
+ try {
181
+ payloads = decoder.push(chunk)
182
+ } catch (error) {
183
+ malformedSeen = true
184
+ malformedSnippet = String(error.message || "invalid framing").slice(0, 240)
185
+ return
186
+ }
187
+
188
+ for (const payload of payloads) {
189
+ let msg
190
+ try {
191
+ msg = JSON.parse(payload)
192
+ } catch {
193
+ malformedSeen = true
194
+ malformedSnippet = String(payload || "").slice(0, 240)
195
+ continue
196
+ }
197
+
198
+ if (msg?.id != null && pending.has(msg.id)) {
199
+ const entry = pending.get(msg.id)
200
+ pending.delete(msg.id)
201
+ clearTimeout(entry.timer)
202
+ if (msg.error) {
203
+ entry.reject(
204
+ new McpError(
205
+ `mcp server "${serverName}" error: ${msg.error.message || JSON.stringify(msg.error)}`,
206
+ {
207
+ reason: "bad_response",
208
+ server: serverName,
209
+ action: entry.method,
210
+ phase: entry.phase,
211
+ code: msg.error.code,
212
+ stderrSnippet: stderrLines.join(" | ") || undefined
213
+ }
214
+ )
215
+ )
216
+ } else {
217
+ const elapsed = Date.now() - entry.startedAt
218
+ EventBus.emit({
219
+ type: EVENT_TYPES.MCP_REQUEST,
220
+ payload: { server: serverName, action: entry.method, elapsed, transport: "stdio" }
221
+ }).catch(() => {})
222
+ entry.resolve(msg.result ?? {})
223
+ }
224
+ }
225
+ }
226
+ })
227
+
228
+ proc.stderr.on("data", (chunk) => appendStderr(chunk))
229
+
230
+ proc.on("close", (code, signal) => {
231
+ if (ignoreClose) {
232
+ cleanupChild()
233
+ return
234
+ }
235
+ const reason = malformedSeen ? "bad_response" : "server_crash"
236
+ const extra = malformedSeen && malformedSnippet
237
+ ? `; malformed stdout: ${malformedSnippet}`
238
+ : ""
239
+ rejectPending(
240
+ reason,
241
+ `mcp server "${serverName}" process exited unexpectedly (code=${code ?? "null"}, signal=${signal || "null"})${extra}`,
242
+ { phase: lifecycle === "starting" ? "startup" : "request" }
243
+ )
244
+ cleanupChild()
245
+ })
246
+ })
247
+ }
248
+
249
+ const shutdownTimeoutMs = Number(config.shutdown_timeout_ms || 5000)
250
+
251
+ async function shutdownProcess() {
252
+ if (!child) return
253
+ const proc = child
254
+ lifecycle = "stopping"
255
+ ignoreClose = true
256
+ try { proc.kill() } catch {}
257
+ rejectPending("unknown", `mcp server "${serverName}" shutdown`, { phase: "shutdown" })
258
+ await new Promise((resolve) => {
259
+ const killTimer = setTimeout(() => {
260
+ try { proc.kill("SIGKILL") } catch {}
261
+ resolve()
262
+ }, shutdownTimeoutMs)
263
+ proc.once("close", () => {
264
+ clearTimeout(killTimer)
265
+ resolve()
266
+ })
267
+ })
268
+ cleanupChild()
269
+ }
270
+
271
+ async function ensureAlive() {
272
+ // Circuit breaker: open state rejects immediately
273
+ if (circuitState === "open") {
274
+ if (Date.now() - circuitOpenedAt >= circuitResetMs) {
275
+ circuitState = "half_open"
276
+ } else {
277
+ throw new McpError(`mcp server "${serverName}" circuit breaker open`, {
278
+ reason: "server_crash", server: serverName, phase: "request"
279
+ })
280
+ }
281
+ }
282
+
283
+ // Only attempt lazy reconnect if we were previously initialized
284
+ if ((lifecycle === "closed" || lifecycle === "stopping") && wasEverInitialized) {
285
+ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000)
286
+ if (reconnectAttempts > 0) {
287
+ await new Promise((r) => setTimeout(r, delay))
288
+ }
289
+ try {
290
+ initialized = false
291
+ await startProcess()
292
+ await initializeOnce()
293
+ reconnectAttempts = 0
294
+ if (circuitState === "half_open") {
295
+ circuitState = "closed"
296
+ EventBus.emit({ type: EVENT_TYPES.MCP_CIRCUIT_CLOSE, payload: { server: serverName } }).catch(() => {})
297
+ }
298
+ EventBus.emit({ type: EVENT_TYPES.MCP_RECONNECT, payload: { server: serverName, success: true } }).catch(() => {})
299
+ } catch (error) {
300
+ reconnectAttempts++
301
+ if (circuitState === "half_open" || reconnectAttempts >= maxReconnectAttempts) {
302
+ circuitState = "open"
303
+ circuitOpenedAt = Date.now()
304
+ EventBus.emit({ type: EVENT_TYPES.MCP_CIRCUIT_OPEN, payload: { server: serverName, attempts: reconnectAttempts } }).catch(() => {})
305
+ }
306
+ EventBus.emit({ type: EVENT_TYPES.MCP_RECONNECT, payload: { server: serverName, success: false, attempt: reconnectAttempts } }).catch(() => {})
307
+ throw error
308
+ }
309
+ return
310
+ }
311
+
312
+ // Normal first-time startup
313
+ await startProcess()
314
+ }
315
+
316
+ async function sendRequest(method, params = {}, { phase = "request", timeoutMs = requestTimeoutMs, signal = null } = {}) {
317
+ if (signal?.aborted) {
318
+ throw new McpError(`mcp server "${serverName}" request cancelled`, {
319
+ reason: "timeout", server: serverName, action: method, phase
320
+ })
321
+ }
322
+ await ensureAlive()
323
+ if (nextId > Number.MAX_SAFE_INTEGER - 1) nextId = 1
324
+ const id = nextId++
325
+ const payload = { jsonrpc: "2.0", id, method, params }
326
+
327
+ return new Promise((resolve, reject) => {
328
+ const startedAt = Date.now()
329
+ let settled = false
330
+
331
+ function settle() {
332
+ if (settled) return false
333
+ settled = true
334
+ if (signal) signal.removeEventListener("abort", onAbort)
335
+ return true
336
+ }
337
+
338
+ function onAbort() {
339
+ if (!settle()) return
340
+ clearTimeout(timer)
341
+ pending.delete(id)
342
+ sendNotification("notifications/cancelled", { requestId: id, reason: "client_cancelled" })
343
+ reject(new McpError(`mcp server "${serverName}" request cancelled`, {
344
+ reason: "timeout", server: serverName, action: method, phase
345
+ }))
346
+ }
347
+
348
+ const timer = setTimeout(() => {
349
+ if (!settle()) return
350
+ pending.delete(id)
351
+ reject(
352
+ new McpError(`mcp server "${serverName}" timed out after ${timeoutMs}ms on "${method}"`, {
353
+ reason: "timeout",
354
+ server: serverName,
355
+ action: method,
356
+ phase
357
+ })
358
+ )
359
+ }, timeoutMs)
360
+
361
+ if (signal) signal.addEventListener("abort", onAbort, { once: true })
362
+
363
+ pending.set(id, {
364
+ resolve: (v) => { if (settle()) { clearTimeout(timer); resolve(v) } },
365
+ reject: (e) => { if (settle()) { clearTimeout(timer); reject(e) } },
366
+ timer, method, phase, startedAt
367
+ })
368
+ try {
369
+ const wireFraming = configuredFraming === "auto" ? activeFraming : configuredFraming
370
+ child.stdin.write(encodeRpcMessage(payload, wireFraming))
371
+ } catch (error) {
372
+ clearTimeout(timer)
373
+ pending.delete(id)
374
+ reject(
375
+ new McpError(`mcp server "${serverName}" stdin write failed: ${error.message}`, {
376
+ reason: "server_crash",
377
+ server: serverName,
378
+ action: method,
379
+ phase
380
+ })
381
+ )
382
+ }
383
+ })
384
+ }
385
+
386
+ function sendNotification(method, params = {}) {
387
+ if (!child || lifecycle === "closed") return
388
+ const payload = { jsonrpc: "2.0", method, params }
389
+ try {
390
+ const wireFraming = configuredFraming === "auto" ? activeFraming : configuredFraming
391
+ child.stdin.write(encodeRpcMessage(payload, wireFraming))
392
+ } catch {
393
+ // best effort
394
+ }
395
+ }
396
+
397
+ async function initializeOnce() {
398
+ if (initialized) return
399
+ const initParams = {
400
+ protocolVersion: MCP_PROTOCOL_VERSION,
401
+ capabilities: {},
402
+ clientInfo: MCP_CLIENT_INFO
403
+ }
404
+
405
+ if (configuredFraming === "auto") {
406
+ let lastError = null
407
+ let needRestart = false
408
+ for (const candidate of ["content-length", "newline"]) {
409
+ activeFraming = candidate
410
+ if (needRestart) {
411
+ await shutdownProcess()
412
+ }
413
+ try {
414
+ decoder.reset()
415
+ await sendRequest("initialize", initParams, { phase: "initialize" })
416
+ sendNotification("notifications/initialized")
417
+ initialized = true
418
+ wasEverInitialized = true
419
+ return
420
+ } catch (error) {
421
+ lastError = error
422
+ needRestart = true
423
+ }
424
+ }
425
+ throw lastError || new McpError(`mcp server "${serverName}" failed to initialize`, {
426
+ reason: "unknown",
427
+ server: serverName,
428
+ phase: "initialize"
429
+ })
430
+ }
431
+
432
+ await sendRequest("initialize", initParams, { phase: "initialize" })
433
+ sendNotification("notifications/initialized")
434
+ initialized = true
435
+ wasEverInitialized = true
436
+ }
437
+
438
+ async function healthPingOrTools() {
439
+ if (healthCheckMethod === "ping") {
440
+ await sendRequest("ping", {}, { phase: "request" })
441
+ return
442
+ }
443
+ if (healthCheckMethod === "tools_list") {
444
+ await sendRequest("tools/list", {}, { phase: "request" })
445
+ return
446
+ }
447
+
448
+ try {
449
+ await sendRequest("ping", {}, { phase: "request" })
450
+ } catch (error) {
451
+ if (!["bad_response", "protocol_error", "unknown"].includes(error.reason)) throw error
452
+ await sendRequest("tools/list", {}, { phase: "request" })
453
+ }
454
+ }
455
+
456
+ return {
457
+ serverName,
458
+ transport: "stdio",
459
+
460
+ async health() {
461
+ try {
462
+ await initializeOnce()
463
+ await healthPingOrTools()
464
+ return {
465
+ ok: true,
466
+ reason: "ok",
467
+ framing: configuredFraming === "auto" ? activeFraming : configuredFraming
468
+ }
469
+ } catch (error) {
470
+ return {
471
+ ok: false,
472
+ error: error.message,
473
+ reason: error.reason || "unknown",
474
+ phase: error.details?.phase || "unknown",
475
+ framing: configuredFraming === "auto" ? activeFraming : configuredFraming
476
+ }
477
+ }
478
+ },
479
+
480
+ async listTools() {
481
+ await initializeOnce()
482
+ const out = await sendRequest("tools/list")
483
+ return Array.isArray(out?.tools) ? out.tools : []
484
+ },
485
+
486
+ async listPrompts() {
487
+ await initializeOnce()
488
+ try {
489
+ const out = await sendRequest("prompts/list")
490
+ return Array.isArray(out?.prompts) ? out.prompts : []
491
+ } catch {
492
+ return []
493
+ }
494
+ },
495
+
496
+ async getPrompt(name, args = {}) {
497
+ await initializeOnce()
498
+ return sendRequest("prompts/get", { name, arguments: args })
499
+ },
500
+
501
+ async listResources() {
502
+ await initializeOnce()
503
+ try {
504
+ const out = await sendRequest("resources/list")
505
+ return Array.isArray(out?.resources) ? out.resources : []
506
+ } catch {
507
+ return []
508
+ }
509
+ },
510
+
511
+ async listTemplates() {
512
+ await initializeOnce()
513
+ try {
514
+ const out = await sendRequest("resources/templates/list")
515
+ return Array.isArray(out?.templates) ? out.templates : []
516
+ } catch {
517
+ return []
518
+ }
519
+ },
520
+
521
+ async callTool(name, args = {}, signal = null) {
522
+ await initializeOnce()
523
+ const result = await sendRequest("tools/call", { name, arguments: args }, { signal })
524
+ return normalizeToolResult(result, serverName, name)
525
+ },
526
+
527
+ shutdown() {
528
+ sendNotification("notifications/cancelled", { reason: "shutdown" })
529
+ shutdownProcess().catch(() => {})
530
+ pending.clear()
531
+ }
532
+ }
533
+ }