@kkelly-offical/kkcode 0.1.7 → 0.2.1

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 (163) hide show
  1. package/LICENSE +674 -674
  2. package/README.md +452 -387
  3. package/package.json +50 -46
  4. package/src/agent/agent.mjs +228 -220
  5. package/src/agent/custom-agent-loader.mjs +6 -3
  6. package/src/agent/generator.mjs +2 -2
  7. package/src/agent/prompt/assistant.txt +12 -0
  8. package/src/agent/prompt/bug-hunter.txt +89 -89
  9. package/src/agent/prompt/frontend-designer.txt +58 -58
  10. package/src/agent/prompt/guide.txt +1 -1
  11. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -83
  12. package/src/agent/prompt/longagent-coding-agent.txt +37 -37
  13. package/src/agent/prompt/longagent-debugging-agent.txt +46 -46
  14. package/src/agent/prompt/longagent-preview-agent.txt +63 -63
  15. package/src/command/custom-commands.mjs +2 -2
  16. package/src/commands/agent.mjs +1 -1
  17. package/src/commands/background.mjs +145 -4
  18. package/src/commands/chat.mjs +117 -76
  19. package/src/commands/config.mjs +148 -1
  20. package/src/commands/doctor.mjs +30 -6
  21. package/src/commands/init.mjs +32 -6
  22. package/src/commands/longagent.mjs +117 -0
  23. package/src/commands/mcp.mjs +275 -43
  24. package/src/commands/permission.mjs +1 -1
  25. package/src/commands/session.mjs +195 -140
  26. package/src/commands/skill.mjs +63 -0
  27. package/src/commands/theme.mjs +1 -1
  28. package/src/config/defaults.mjs +280 -260
  29. package/src/config/import-config.mjs +1 -1
  30. package/src/config/load-config.mjs +61 -4
  31. package/src/config/schema.mjs +591 -574
  32. package/src/context.mjs +4 -1
  33. package/src/core/constants.mjs +97 -91
  34. package/src/core/types.mjs +1 -1
  35. package/src/github/api.mjs +78 -78
  36. package/src/github/auth.mjs +294 -286
  37. package/src/github/flow.mjs +298 -298
  38. package/src/github/workspace.mjs +225 -212
  39. package/src/index.mjs +84 -82
  40. package/src/knowledge/frontend-aesthetics.txt +38 -38
  41. package/src/mcp/client-http.mjs +139 -141
  42. package/src/mcp/client-sse.mjs +297 -288
  43. package/src/mcp/client-stdio.mjs +534 -533
  44. package/src/mcp/constants.mjs +2 -2
  45. package/src/mcp/registry.mjs +498 -479
  46. package/src/mcp/stdio-framing.mjs +135 -133
  47. package/src/mcp/tool-result.mjs +24 -24
  48. package/src/observability/edit-diagnostics.mjs +449 -0
  49. package/src/observability/index.mjs +42 -42
  50. package/src/observability/metrics.mjs +165 -137
  51. package/src/observability/tracer.mjs +137 -137
  52. package/src/onboarding.mjs +209 -0
  53. package/src/orchestration/background-manager.mjs +567 -372
  54. package/src/orchestration/background-worker.mjs +419 -305
  55. package/src/orchestration/interruption-reason.mjs +21 -0
  56. package/src/orchestration/longagent-manager.mjs +197 -171
  57. package/src/orchestration/stage-scheduler.mjs +733 -728
  58. package/src/orchestration/subagent-router.mjs +7 -1
  59. package/src/orchestration/task-scheduler.mjs +219 -7
  60. package/src/permission/engine.mjs +1 -1
  61. package/src/permission/exec-policy.mjs +370 -370
  62. package/src/permission/file-edit-policy.mjs +108 -0
  63. package/src/permission/prompt.mjs +1 -1
  64. package/src/permission/rules.mjs +116 -7
  65. package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
  66. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
  67. package/src/plugin/hook-bus.mjs +19 -5
  68. package/src/plugin/manifest-loader.mjs +222 -0
  69. package/src/provider/anthropic.mjs +396 -390
  70. package/src/provider/ollama.mjs +7 -1
  71. package/src/provider/openai.mjs +382 -340
  72. package/src/provider/retry-policy.mjs +74 -68
  73. package/src/provider/router.mjs +242 -241
  74. package/src/provider/sse.mjs +104 -104
  75. package/src/provider/wizard.mjs +556 -0
  76. package/src/repl/capability-facade.mjs +30 -0
  77. package/src/repl/command-surface.mjs +23 -0
  78. package/src/repl/controller-entry.mjs +40 -0
  79. package/src/repl/core-shell.mjs +208 -0
  80. package/src/repl/dialog-router.mjs +87 -0
  81. package/src/repl/input-engine.mjs +76 -0
  82. package/src/repl/keymap.mjs +7 -0
  83. package/src/repl/operator-surface.mjs +15 -0
  84. package/src/repl/permission-flow.mjs +49 -0
  85. package/src/repl/runtime-facade.mjs +36 -0
  86. package/src/repl/slash-router.mjs +62 -0
  87. package/src/repl/state-store.mjs +29 -0
  88. package/src/repl/turn-controller.mjs +58 -0
  89. package/src/repl/verification.mjs +23 -0
  90. package/src/repl.mjs +3368 -2981
  91. package/src/rules/load-rules.mjs +3 -3
  92. package/src/runtime.mjs +1 -1
  93. package/src/session/agent-transaction.mjs +86 -0
  94. package/src/session/checkpoint.mjs +302 -302
  95. package/src/session/compaction.mjs +298 -298
  96. package/src/session/engine.mjs +417 -232
  97. package/src/session/longagent-4stage.mjs +467 -460
  98. package/src/session/longagent-hybrid.mjs +1344 -1097
  99. package/src/session/longagent-plan.mjs +376 -365
  100. package/src/session/longagent-project-memory.mjs +53 -53
  101. package/src/session/longagent-scaffold.mjs +291 -291
  102. package/src/session/longagent-task-bus.mjs +138 -54
  103. package/src/session/longagent-utils.mjs +828 -472
  104. package/src/session/longagent.mjs +911 -900
  105. package/src/session/loop.mjs +1005 -930
  106. package/src/session/prompt/agent.txt +25 -25
  107. package/src/session/prompt/anthropic.txt +150 -150
  108. package/src/session/prompt/beast.txt +1 -1
  109. package/src/session/prompt/plan.txt +31 -31
  110. package/src/session/prompt/qwen.txt +46 -46
  111. package/src/session/recovery.mjs +21 -0
  112. package/src/session/rollback.mjs +196 -195
  113. package/src/session/routing-observability.mjs +72 -0
  114. package/src/session/runtime-state.mjs +47 -0
  115. package/src/session/store.mjs +523 -519
  116. package/src/session/system-prompt.mjs +308 -273
  117. package/src/session/task-validator.mjs +267 -267
  118. package/src/session/usability-gates.mjs +2 -2
  119. package/src/skill/builtin/commit.mjs +64 -64
  120. package/src/skill/builtin/design.mjs +76 -76
  121. package/src/skill/generator.mjs +18 -2
  122. package/src/skill/registry.mjs +642 -390
  123. package/src/storage/audit-store.mjs +18 -11
  124. package/src/storage/event-log.mjs +7 -1
  125. package/src/storage/ghost-commit-store.mjs +243 -245
  126. package/src/storage/paths.mjs +13 -0
  127. package/src/theme/default-theme.mjs +1 -1
  128. package/src/theme/markdown.mjs +4 -0
  129. package/src/theme/schema.mjs +1 -1
  130. package/src/theme/status-bar.mjs +162 -158
  131. package/src/tool/audit-wrapper.mjs +18 -2
  132. package/src/tool/edit-transaction.mjs +23 -0
  133. package/src/tool/executor.mjs +26 -1
  134. package/src/tool/file-read-state.mjs +65 -0
  135. package/src/tool/git-auto.mjs +526 -526
  136. package/src/tool/git-full-auto.mjs +487 -478
  137. package/src/tool/mutation-guard.mjs +54 -0
  138. package/src/tool/prompt/edit.txt +3 -3
  139. package/src/tool/prompt/multiedit.txt +1 -0
  140. package/src/tool/prompt/notebookedit.txt +2 -1
  141. package/src/tool/prompt/patch.txt +25 -24
  142. package/src/tool/prompt/read.txt +3 -3
  143. package/src/tool/prompt/sysinfo.txt +29 -0
  144. package/src/tool/prompt/task.txt +66 -4
  145. package/src/tool/prompt/write.txt +2 -2
  146. package/src/tool/question-prompt.mjs +99 -93
  147. package/src/tool/registry.mjs +1701 -1343
  148. package/src/tool/task-tool.mjs +14 -6
  149. package/src/ui/activity-renderer.mjs +667 -664
  150. package/src/ui/repl-background-panel.mjs +7 -0
  151. package/src/ui/repl-capability-panel.mjs +9 -0
  152. package/src/ui/repl-dashboard.mjs +54 -4
  153. package/src/ui/repl-help.mjs +110 -0
  154. package/src/ui/repl-operator-panel.mjs +12 -0
  155. package/src/ui/repl-route-feedback.mjs +35 -0
  156. package/src/ui/repl-status-view.mjs +76 -0
  157. package/src/ui/repl-task-panel.mjs +5 -0
  158. package/src/ui/repl-transcript-panel.mjs +56 -0
  159. package/src/ui/repl-turn-summary.mjs +135 -0
  160. package/src/usage/pricing.mjs +122 -121
  161. package/src/usage/usage-meter.mjs +1 -0
  162. package/src/util/git.mjs +562 -519
  163. package/src/util/template.mjs +6 -1
@@ -1,479 +1,498 @@
1
- import { createHttpMcpClient } from "./client-http.mjs"
2
- import { createStdioMcpClient } from "./client-stdio.mjs"
3
- import { createSseMcpClient } from "./client-sse.mjs"
4
- import { McpError } from "../core/errors.mjs"
5
- import { EventBus } from "../core/events.mjs"
6
- import { EVENT_TYPES } from "../core/constants.mjs"
7
- import { readFile } from "node:fs/promises"
8
- import { execFile } from "node:child_process"
9
- import { promisify } from "node:util"
10
- import { join } from "node:path"
11
- import { homedir } from "node:os"
12
-
13
- const state = {
14
- loaded: false,
15
- servers: new Map(),
16
- tools: new Map(),
17
- prompts: new Map(),
18
- health: new Map(),
19
- configured: new Map(),
20
- loadedAt: 0,
21
- lastSignature: "",
22
- initPromise: null,
23
- shuttingDown: false
24
- }
25
-
26
- function normalizeTool(serverName, tool) {
27
- const id = `mcp_${serverName}_${tool.name}`
28
- return {
29
- id,
30
- server: serverName,
31
- name: tool.name,
32
- description: tool.description || `${serverName}:${tool.name}`,
33
- inputSchema: tool.inputSchema || tool.input_schema || { type: "object", properties: {}, required: [] }
34
- }
35
- }
36
-
37
- function normalizePrompt(serverName, prompt) {
38
- const id = `mcp_${serverName}_${prompt.name}`
39
- return {
40
- id,
41
- server: serverName,
42
- name: prompt.name,
43
- description: prompt.description || `${serverName}:${prompt.name}`,
44
- arguments: prompt.arguments || []
45
- }
46
- }
47
-
48
- const execFileAsync = promisify(execFile)
49
- let context7InstallLock = null
50
- async function ensureGlobalPackage(pkg) {
51
- const name = pkg.replace(/@[^/]*$/, "")
52
- try {
53
- await execFileAsync("npm", ["list", "-g", name], { timeout: 10000 })
54
- } catch {
55
- await execFileAsync("npm", ["install", "-g", pkg], { timeout: 120000 })
56
- }
57
- }
58
-
59
- function resolveTransport(server = {}) {
60
- const transport = String(server.transport || server.type || "stdio").toLowerCase()
61
- if (transport === "http") return "http"
62
- if (transport === "sse" || transport === "streamable-http") return "sse"
63
- return "stdio"
64
- }
65
-
66
- function createClient(name, server) {
67
- const transport = resolveTransport(server)
68
- if (transport === "sse") return createSseMcpClient(name, server)
69
- if (transport === "http") return createHttpMcpClient(name, server)
70
- return createStdioMcpClient(name, server)
71
- }
72
-
73
- function setHealth(name, serverConfig = {}, patch = {}) {
74
- const prev = state.health.get(name) || {
75
- name,
76
- transport: resolveTransport(serverConfig),
77
- ok: false,
78
- reason: "not_checked",
79
- error: null,
80
- lastCheckedAt: 0
81
- }
82
- const next = {
83
- ...prev,
84
- ...patch,
85
- name,
86
- transport: patch.transport || prev.transport || resolveTransport(serverConfig),
87
- lastCheckedAt: Date.now()
88
- }
89
- state.health.set(name, next)
90
- return next
91
- }
92
-
93
- /**
94
- * Dynamic discovery: load MCP server configs from well-known project files.
95
- * Checks (in order, merged):
96
- * .mcp.json — Claude Code / VS Code convention
97
- * .mcp/config.json — directory-based convention
98
- * .kkcode/mcp.json — kkcode-specific
99
- * ~/.kkcode/mcp.json — global user-level
100
- */
101
- async function discoverProjectServers(cwd) {
102
- const candidates = [
103
- join(cwd, ".mcp.json"),
104
- join(cwd, ".mcp", "config.json"),
105
- join(cwd, ".kkcode", "mcp.json"),
106
- join(homedir(), ".kkcode", "mcp.json")
107
- ]
108
- const merged = {}
109
- for (const filePath of candidates) {
110
- try {
111
- const raw = await readFile(filePath, "utf-8")
112
- const parsed = JSON.parse(raw)
113
- const servers = parsed?.servers || parsed?.mcpServers || {}
114
- for (const [name, cfg] of Object.entries(servers)) {
115
- if (!merged[name]) merged[name] = cfg
116
- }
117
- } catch {
118
- // ignore missing/invalid files
119
- }
120
- }
121
- return merged
122
- }
123
-
124
- async function connectServer(name, server) {
125
- // Lazy install for context7 built-in server
126
- if (name === "context7" && server?.command === "context7-mcp") {
127
- if (!context7InstallLock) {
128
- context7InstallLock = ensureGlobalPackage("@upstash/context7-mcp@latest").catch(() => {
129
- context7InstallLock = null // Reset on failure to allow retry
130
- })
131
- }
132
- await context7InstallLock
133
- }
134
-
135
- const transport = resolveTransport(server)
136
- let client
137
- try {
138
- client = createClient(name, server)
139
- } catch (error) {
140
- const health = setHealth(name, server, {
141
- ok: false,
142
- reason: error.reason || "unknown",
143
- error: error.message,
144
- transport
145
- })
146
- await EventBus.emit({
147
- type: EVENT_TYPES.MCP_HEALTH,
148
- payload: { server: name, ...health }
149
- })
150
- return null
151
- }
152
-
153
- let health
154
- try {
155
- health = await client.health()
156
- } catch (error) {
157
- health = { ok: false, reason: error.reason || "unknown", error: error.message || String(error) }
158
- }
159
-
160
- const normalizedHealth = setHealth(name, server, {
161
- ok: Boolean(health?.ok),
162
- reason: health?.reason || (health?.ok ? "ok" : "unknown"),
163
- error: health?.error || null,
164
- phase: health?.phase || null,
165
- transport
166
- })
167
-
168
- await EventBus.emit({
169
- type: EVENT_TYPES.MCP_HEALTH,
170
- payload: { server: name, ...normalizedHealth }
171
- })
172
-
173
- if (!normalizedHealth.ok) return null
174
-
175
- state.servers.set(name, client)
176
-
177
- // Discover tools
178
- try {
179
- const tools = await client.listTools()
180
- for (const tool of tools) {
181
- const normalized = normalizeTool(name, tool)
182
- state.tools.set(normalized.id, normalized)
183
- }
184
- } catch (error) {
185
- setHealth(name, server, {
186
- ok: false,
187
- reason: error.reason || "unknown",
188
- error: `listTools failed: ${error.message}`
189
- })
190
- state.servers.delete(name)
191
- await EventBus.emit({
192
- type: EVENT_TYPES.MCP_HEALTH,
193
- payload: { server: name, ...state.health.get(name) }
194
- })
195
- return null
196
- }
197
-
198
- // Discover prompts (optional)
199
- if (typeof client.listPrompts === "function") {
200
- try {
201
- const prompts = await client.listPrompts()
202
- for (const prompt of prompts) {
203
- const normalized = normalizePrompt(name, prompt)
204
- state.prompts.set(normalized.id, normalized)
205
- }
206
- } catch {
207
- // optional capability
208
- }
209
- }
210
-
211
- return client
212
- }
213
-
214
- async function reinitialize(config, { force = false, cwd = null } = {}) {
215
- state.shuttingDown = false
216
- const ttlMs = Math.max(0, Number(config?.runtime?.mcp_refresh_ttl_ms || 60000))
217
- const effectiveCwd = cwd || process.cwd()
218
- const sig = JSON.stringify({
219
- mcp: config?.mcp || {},
220
- runtime: config?.runtime || {},
221
- cwd: effectiveCwd
222
- })
223
-
224
- const cacheValid = state.loaded && !force && state.lastSignature === sig && Date.now() - state.loadedAt <= ttlMs
225
- if (cacheValid) return
226
-
227
- for (const [, client] of state.servers) {
228
- if (typeof client.shutdown === "function") client.shutdown()
229
- }
230
- state.loaded = false
231
- state.servers.clear()
232
- state.tools.clear()
233
- state.prompts.clear()
234
- state.health.clear()
235
- state.configured.clear()
236
-
237
- // Built-in MCP servers (user config can override or disable with enabled: false)
238
- const builtinServers = {
239
- context7: {
240
- command: "context7-mcp",
241
- args: [],
242
- timeout_ms: 30000,
243
- framing: "newline"
244
- }
245
- }
246
- const configServers = config?.mcp?.servers || {}
247
- const discoveredServers = config?.mcp?.auto_discover !== false
248
- ? await discoverProjectServers(effectiveCwd)
249
- : {}
250
- const allServers = { ...builtinServers, ...discoveredServers, ...configServers }
251
-
252
- for (const [name, serverConfig] of Object.entries(allServers)) {
253
- state.configured.set(name, serverConfig)
254
- if (serverConfig?.enabled === false) {
255
- setHealth(name, serverConfig, {
256
- ok: false,
257
- reason: "disabled",
258
- error: null
259
- })
260
- } else {
261
- setHealth(name, serverConfig, {
262
- ok: false,
263
- reason: "not_checked",
264
- error: null
265
- })
266
- }
267
- }
268
-
269
- const entries = Object.entries(allServers).filter(([, serverConfig]) => serverConfig?.enabled !== false)
270
- await Promise.allSettled(entries.map(([name, serverConfig]) => connectServer(name, serverConfig)))
271
-
272
- state.loaded = true
273
- state.loadedAt = Date.now()
274
- state.lastSignature = sig
275
- }
276
-
277
- export const McpRegistry = {
278
- async initialize(config, { force = false, cwd = null } = {}) {
279
- if (state.initPromise) {
280
- await state.initPromise
281
- if (!force) return
282
- }
283
- state.initPromise = reinitialize(config, { force, cwd })
284
- try {
285
- await state.initPromise
286
- } finally {
287
- state.initPromise = null
288
- }
289
- },
290
-
291
- isReady() {
292
- return state.loaded
293
- },
294
-
295
- listServers() {
296
- return [...state.servers.keys()]
297
- },
298
-
299
- serverInfo(name) {
300
- const health = state.health.get(name)
301
- if (!health) return null
302
- return {
303
- name,
304
- transport: health.transport,
305
- lastHealth: health.ok ? "ok" : "fail",
306
- reason: health.reason || "unknown",
307
- lastError: health.error || null
308
- }
309
- },
310
-
311
- healthSnapshot() {
312
- return [...state.health.entries()]
313
- .map(([name, health]) => ({
314
- name,
315
- transport: health.transport || "stdio",
316
- ok: Boolean(health.ok),
317
- reason: health.reason || "unknown",
318
- error: health.error || null,
319
- phase: health.phase || null,
320
- configured: state.configured.has(name),
321
- enabled: state.configured.get(name)?.enabled !== false,
322
- lastCheckedAt: health.lastCheckedAt || 0
323
- }))
324
- .sort((a, b) => a.name.localeCompare(b.name))
325
- },
326
-
327
- listTools() {
328
- return [...state.tools.values()]
329
- },
330
-
331
- listPrompts() {
332
- return [...state.prompts.values()]
333
- },
334
-
335
- async getPrompt(promptId, args = {}) {
336
- const prompt = state.prompts.get(promptId)
337
- if (!prompt) throw new Error(`mcp prompt not found: ${promptId}`)
338
- const client = state.servers.get(prompt.server)
339
- if (!client || typeof client.getPrompt !== "function") {
340
- throw new Error(`mcp server "${prompt.server}" does not support prompts/get`)
341
- }
342
- return client.getPrompt(prompt.name, args)
343
- },
344
-
345
- async listResources(serverName) {
346
- const client = state.servers.get(serverName)
347
- if (!client) return []
348
- return client.listResources()
349
- },
350
-
351
- async listTemplates(serverName) {
352
- const client = state.servers.get(serverName)
353
- if (!client) return []
354
- return client.listTemplates()
355
- },
356
-
357
- async callTool(toolId, args = {}, signal = null) {
358
- if (state.shuttingDown) {
359
- throw new McpError("MCP registry is shutting down", { reason: "shutting_down" })
360
- }
361
- const tool = state.tools.get(toolId)
362
- if (!tool) throw new Error(`mcp tool not found: ${toolId}`)
363
- let client = state.servers.get(tool.server)
364
- if (!client) throw new Error(`mcp server not found: ${tool.server}`)
365
- const serverConfig = state.configured.get(tool.server)
366
- const serverTimeout = serverConfig?.timeout_ms
367
- let effectiveSignal = signal
368
- if (serverTimeout && !signal) {
369
- effectiveSignal = AbortSignal.timeout(serverTimeout)
370
- }
371
- try {
372
- return await client.callTool(tool.name, args, effectiveSignal)
373
- } catch (error) {
374
- if (error?.reason === "spawn_failed" || error?.reason === "server_crash") {
375
- setHealth(tool.server, serverConfig, {
376
- ok: false, reason: error.reason, error: error.message
377
- })
378
- try {
379
- await this.refreshServer(tool.server)
380
- client = state.servers.get(tool.server)
381
- if (client) return client.callTool(tool.name, args, effectiveSignal)
382
- } catch {}
383
- }
384
- throw error
385
- }
386
- },
387
-
388
- async refreshServer(name) {
389
- const serverConfig = state.configured.get(name)
390
- if (!serverConfig) throw new Error(`mcp server not configured: ${name}`)
391
- const existing = state.servers.get(name)
392
- if (existing && typeof existing.shutdown === "function") existing.shutdown()
393
- state.servers.delete(name)
394
- for (const [id, t] of state.tools) {
395
- if (t.server === name) state.tools.delete(id)
396
- }
397
- for (const [id, p] of state.prompts) {
398
- if (p.server === name) state.prompts.delete(id)
399
- }
400
- return connectServer(name, serverConfig)
401
- },
402
-
403
- async addServer(name, serverConfig) {
404
- if (state.servers.has(name)) {
405
- const existing = state.servers.get(name)
406
- if (typeof existing.shutdown === "function") existing.shutdown()
407
- state.servers.delete(name)
408
- for (const [id, t] of state.tools) {
409
- if (t.server === name) state.tools.delete(id)
410
- }
411
- for (const [id, p] of state.prompts) {
412
- if (p.server === name) state.prompts.delete(id)
413
- }
414
- }
415
- state.configured.set(name, serverConfig)
416
- return connectServer(name, serverConfig)
417
- },
418
-
419
- async healthCheck(serverName) {
420
- const client = state.servers.get(serverName)
421
- const serverConfig = state.configured.get(serverName)
422
- if (!client || !serverConfig) return { ok: false, reason: "not_found" }
423
- try {
424
- const result = await client.health()
425
- const patch = {
426
- ok: Boolean(result?.ok),
427
- reason: result?.reason || (result?.ok ? "ok" : "unknown"),
428
- error: result?.error || null
429
- }
430
- setHealth(serverName, serverConfig, patch)
431
- await EventBus.emit({ type: EVENT_TYPES.MCP_HEALTH, payload: { server: serverName, ...patch } })
432
- if (!result?.ok) {
433
- try { await this.refreshServer(serverName) } catch {}
434
- }
435
- return patch
436
- } catch (error) {
437
- const patch = { ok: false, reason: error.reason || "unknown", error: error.message }
438
- setHealth(serverName, serverConfig, patch)
439
- return patch
440
- }
441
- },
442
-
443
- async healthCheckAll() {
444
- const results = {}
445
- for (const name of state.configured.keys()) {
446
- if (state.configured.get(name)?.enabled === false) continue
447
- results[name] = await this.healthCheck(name)
448
- }
449
- return results
450
- },
451
-
452
- removeServer(name) {
453
- const client = state.servers.get(name)
454
- if (client && typeof client.shutdown === "function") client.shutdown()
455
- state.servers.delete(name)
456
- state.configured.delete(name)
457
- state.health.delete(name)
458
- for (const [id, t] of state.tools) {
459
- if (t.server === name) state.tools.delete(id)
460
- }
461
- for (const [id, p] of state.prompts) {
462
- if (p.server === name) state.prompts.delete(id)
463
- }
464
- },
465
-
466
- shutdown() {
467
- state.shuttingDown = true
468
- for (const [, client] of state.servers) {
469
- if (typeof client.shutdown === "function") client.shutdown()
470
- }
471
- state.servers.clear()
472
- state.tools.clear()
473
- state.prompts.clear()
474
- state.health.clear()
475
- state.configured.clear()
476
- state.loaded = false
477
- state.lastSignature = ""
478
- }
479
- }
1
+ import { createHttpMcpClient } from "./client-http.mjs"
2
+ import { createStdioMcpClient } from "./client-stdio.mjs"
3
+ import { createSseMcpClient } from "./client-sse.mjs"
4
+ import { McpError } from "../core/errors.mjs"
5
+ import { EventBus } from "../core/events.mjs"
6
+ import { EVENT_TYPES } from "../core/constants.mjs"
7
+ import { readFile } from "node:fs/promises"
8
+ import { join } from "node:path"
9
+ import { userRootDir } from "../storage/paths.mjs"
10
+
11
+ const state = {
12
+ loaded: false,
13
+ servers: new Map(),
14
+ tools: new Map(),
15
+ prompts: new Map(),
16
+ health: new Map(),
17
+ configured: new Map(),
18
+ loadedAt: 0,
19
+ lastSignature: "",
20
+ initPromise: null,
21
+ shuttingDown: false
22
+ }
23
+
24
+ function normalizeTool(serverName, tool) {
25
+ const id = `mcp_${serverName}_${tool.name}`
26
+ return {
27
+ id,
28
+ server: serverName,
29
+ name: tool.name,
30
+ description: tool.description || `${serverName}:${tool.name}`,
31
+ inputSchema: tool.inputSchema || tool.input_schema || { type: "object", properties: {}, required: [] }
32
+ }
33
+ }
34
+
35
+ function normalizePrompt(serverName, prompt) {
36
+ const id = `mcp_${serverName}_${prompt.name}`
37
+ return {
38
+ id,
39
+ server: serverName,
40
+ name: prompt.name,
41
+ description: prompt.description || `${serverName}:${prompt.name}`,
42
+ arguments: prompt.arguments || []
43
+ }
44
+ }
45
+
46
+ function resolveTransport(server = {}) {
47
+ const transport = String(server.transport || server.type || "stdio").toLowerCase()
48
+ if (transport === "http") return "http"
49
+ if (transport === "sse" || transport === "streamable-http") return "sse"
50
+ return "stdio"
51
+ }
52
+
53
+ function createClient(name, server) {
54
+ const transport = resolveTransport(server)
55
+ if (transport === "sse") return createSseMcpClient(name, server)
56
+ if (transport === "http") return createHttpMcpClient(name, server)
57
+ return createStdioMcpClient(name, server)
58
+ }
59
+
60
+ function setHealth(name, serverConfig = {}, patch = {}) {
61
+ const prev = state.health.get(name) || {
62
+ name,
63
+ transport: resolveTransport(serverConfig),
64
+ ok: false,
65
+ reason: "not_checked",
66
+ error: null,
67
+ lastCheckedAt: 0
68
+ }
69
+ const next = {
70
+ ...prev,
71
+ ...patch,
72
+ name,
73
+ transport: patch.transport || prev.transport || resolveTransport(serverConfig),
74
+ lastCheckedAt: Date.now()
75
+ }
76
+ state.health.set(name, next)
77
+ return next
78
+ }
79
+
80
+ /**
81
+ * Dynamic discovery: load MCP server configs from well-known project files.
82
+ * Checks (in order, merged):
83
+ * .mcp.json — Claude Code / VS Code convention
84
+ * .mcp/config.json — directory-based convention
85
+ * .kkcode/mcp.json — kkcode-specific
86
+ * <KKCODE_HOME>/mcp.json — global user-level
87
+ */
88
+ async function discoverProjectServers(cwd) {
89
+ const candidates = [
90
+ join(cwd, ".mcp.json"),
91
+ join(cwd, ".mcp", "config.json"),
92
+ join(cwd, ".kkcode", "mcp.json"),
93
+ join(userRootDir(), "mcp.json")
94
+ ]
95
+ const merged = {}
96
+ for (const filePath of candidates) {
97
+ try {
98
+ const raw = await readFile(filePath, "utf-8")
99
+ const parsed = JSON.parse(raw)
100
+ const servers = parsed?.servers || parsed?.mcpServers || {}
101
+ for (const [name, cfg] of Object.entries(servers)) {
102
+ if (!merged[name]) merged[name] = cfg
103
+ }
104
+ } catch {
105
+ // ignore missing/invalid files
106
+ }
107
+ }
108
+ return merged
109
+ }
110
+
111
+ async function connectServer(name, server) {
112
+ const transport = resolveTransport(server)
113
+ let client
114
+ try {
115
+ client = createClient(name, server)
116
+ } catch (error) {
117
+ const health = setHealth(name, server, {
118
+ ok: false,
119
+ reason: error.reason || "unknown",
120
+ error: error.message,
121
+ transport
122
+ })
123
+ await EventBus.emit({
124
+ type: EVENT_TYPES.MCP_HEALTH,
125
+ payload: { server: name, ...health }
126
+ })
127
+ return null
128
+ }
129
+
130
+ let health
131
+ try {
132
+ health = await client.health()
133
+ } catch (error) {
134
+ health = { ok: false, reason: error.reason || "unknown", error: error.message || String(error) }
135
+ }
136
+
137
+ const normalizedHealth = setHealth(name, server, {
138
+ ok: Boolean(health?.ok),
139
+ reason: health?.reason || (health?.ok ? "ok" : "unknown"),
140
+ error: health?.error || null,
141
+ phase: health?.phase || null,
142
+ transport
143
+ })
144
+
145
+ await EventBus.emit({
146
+ type: EVENT_TYPES.MCP_HEALTH,
147
+ payload: { server: name, ...normalizedHealth }
148
+ })
149
+
150
+ if (!normalizedHealth.ok) return null
151
+
152
+ state.servers.set(name, client)
153
+
154
+ // Discover tools
155
+ try {
156
+ const tools = await client.listTools()
157
+ for (const tool of tools) {
158
+ const normalized = normalizeTool(name, tool)
159
+ state.tools.set(normalized.id, normalized)
160
+ }
161
+ } catch (error) {
162
+ setHealth(name, server, {
163
+ ok: false,
164
+ reason: error.reason || "unknown",
165
+ error: `listTools failed: ${error.message}`
166
+ })
167
+ state.servers.delete(name)
168
+ await EventBus.emit({
169
+ type: EVENT_TYPES.MCP_HEALTH,
170
+ payload: { server: name, ...state.health.get(name) }
171
+ })
172
+ return null
173
+ }
174
+
175
+ // Discover prompts (optional)
176
+ if (typeof client.listPrompts === "function") {
177
+ try {
178
+ const prompts = await client.listPrompts()
179
+ for (const prompt of prompts) {
180
+ const normalized = normalizePrompt(name, prompt)
181
+ state.prompts.set(normalized.id, normalized)
182
+ }
183
+ } catch {
184
+ // optional capability
185
+ }
186
+ }
187
+
188
+ return client
189
+ }
190
+
191
+ async function reinitialize(config, { force = false, cwd = null } = {}) {
192
+ state.shuttingDown = false
193
+ const ttlMs = Math.max(0, Number(config?.runtime?.mcp_refresh_ttl_ms || 60000))
194
+ const effectiveCwd = cwd || process.cwd()
195
+ const sig = JSON.stringify({
196
+ mcp: config?.mcp || {},
197
+ runtime: config?.runtime || {},
198
+ cwd: effectiveCwd
199
+ })
200
+
201
+ const cacheValid = state.loaded && !force && state.lastSignature === sig && Date.now() - state.loadedAt <= ttlMs
202
+ if (cacheValid) return
203
+
204
+ for (const [, client] of state.servers) {
205
+ if (typeof client.shutdown === "function") {
206
+ try { await Promise.resolve(client.shutdown()) } catch { /* best-effort */ }
207
+ }
208
+ }
209
+ state.loaded = false
210
+ state.servers.clear()
211
+ state.tools.clear()
212
+ state.prompts.clear()
213
+ state.health.clear()
214
+ state.configured.clear()
215
+
216
+ // Built-in MCP servers (user config can override or disable with enabled: false)
217
+ const builtinServers = {
218
+ context7: {
219
+ command: "npx",
220
+ args: ["--yes", "@upstash/context7-mcp"],
221
+ timeout_ms: 30000,
222
+ startup_timeout_ms: 60000,
223
+ framing: "newline"
224
+ }
225
+ }
226
+ const configServers = config?.mcp?.servers || {}
227
+ const discoveredServers = config?.mcp?.auto_discover !== false
228
+ ? await discoverProjectServers(effectiveCwd)
229
+ : {}
230
+ const allServers = { ...builtinServers, ...discoveredServers, ...configServers }
231
+
232
+ // Merge global mcp.* defaults into each server config (server-level overrides global)
233
+ const mcpGlobalDefaults = {}
234
+ for (const gk of ["timeout_ms", "shutdown_timeout_ms", "max_sse_buffer_bytes", "max_reconnect_attempts", "circuit_reset_ms", "max_buffer_bytes"]) {
235
+ if (config?.mcp?.[gk] !== undefined) mcpGlobalDefaults[gk] = config.mcp[gk]
236
+ }
237
+
238
+ for (const [name, serverConfig] of Object.entries(allServers)) {
239
+ const effective = { ...mcpGlobalDefaults, ...serverConfig }
240
+ allServers[name] = effective
241
+ state.configured.set(name, effective)
242
+ if (serverConfig?.enabled === false) {
243
+ setHealth(name, serverConfig, {
244
+ ok: false,
245
+ reason: "disabled",
246
+ error: null
247
+ })
248
+ } else {
249
+ setHealth(name, serverConfig, {
250
+ ok: false,
251
+ reason: "not_checked",
252
+ error: null
253
+ })
254
+ }
255
+ }
256
+
257
+ const entries = Object.entries(allServers).filter(([, serverConfig]) => serverConfig?.enabled !== false)
258
+
259
+ // 内置服务器(如 context7)必须在启动前加载,失败时重试一次
260
+ const builtinEntries = entries.filter(([name]) => name in builtinServers)
261
+ const otherEntries = entries.filter(([name]) => !(name in builtinServers))
262
+
263
+ // 优先加载内置 MCP 服务器(串行 + 重试)
264
+ for (const [name, serverConfig] of builtinEntries) {
265
+ let result = await connectServer(name, serverConfig)
266
+ if (!result) {
267
+ // 重试一次
268
+ const health = state.health.get(name)
269
+ const errMsg = health?.error || "unknown error"
270
+ process.stderr.write(`[kkcode] MCP builtin "${name}" 首次连接失败 (${errMsg}),正在重试...\n`)
271
+ result = await connectServer(name, serverConfig)
272
+ if (!result) {
273
+ process.stderr.write(`[kkcode] MCP builtin "${name}" 重试仍失败,跳过。可使用 /status 查看详情。\n`)
274
+ }
275
+ }
276
+ }
277
+
278
+ // 并行加载其他 MCP 服务器
279
+ await Promise.allSettled(otherEntries.map(([name, serverConfig]) => connectServer(name, serverConfig)))
280
+
281
+ state.loaded = true
282
+ state.loadedAt = Date.now()
283
+ state.lastSignature = sig
284
+ }
285
+
286
+ export const McpRegistry = {
287
+ async initialize(config, { force = false, cwd = null } = {}) {
288
+ if (state.initPromise) {
289
+ await state.initPromise
290
+ if (!force) return
291
+ }
292
+ state.initPromise = reinitialize(config, { force, cwd })
293
+ try {
294
+ await state.initPromise
295
+ } finally {
296
+ state.initPromise = null
297
+ }
298
+ },
299
+
300
+ isReady() {
301
+ return state.loaded
302
+ },
303
+
304
+ listServers() {
305
+ return [...state.servers.keys()]
306
+ },
307
+
308
+ serverInfo(name) {
309
+ const health = state.health.get(name)
310
+ if (!health) return null
311
+ return {
312
+ name,
313
+ transport: health.transport,
314
+ lastHealth: health.ok ? "ok" : "fail",
315
+ reason: health.reason || "unknown",
316
+ lastError: health.error || null
317
+ }
318
+ },
319
+
320
+ healthSnapshot() {
321
+ return [...state.health.entries()]
322
+ .map(([name, health]) => ({
323
+ name,
324
+ transport: health.transport || "stdio",
325
+ ok: Boolean(health.ok),
326
+ reason: health.reason || "unknown",
327
+ error: health.error || null,
328
+ phase: health.phase || null,
329
+ configured: state.configured.has(name),
330
+ enabled: state.configured.get(name)?.enabled !== false,
331
+ lastCheckedAt: health.lastCheckedAt || 0
332
+ }))
333
+ .sort((a, b) => a.name.localeCompare(b.name))
334
+ },
335
+
336
+ listTools() {
337
+ return [...state.tools.values()]
338
+ },
339
+
340
+ listPrompts() {
341
+ return [...state.prompts.values()]
342
+ },
343
+
344
+ async getPrompt(promptId, args = {}) {
345
+ const prompt = state.prompts.get(promptId)
346
+ if (!prompt) throw new McpError(`mcp prompt not found: ${promptId}`, { reason: "not_found", prompt: promptId })
347
+ const client = state.servers.get(prompt.server)
348
+ if (!client || typeof client.getPrompt !== "function") {
349
+ throw new McpError(`mcp server "${prompt.server}" does not support prompts/get`, { reason: "not_supported", server: prompt.server })
350
+ }
351
+ try {
352
+ return await client.getPrompt(prompt.name, args)
353
+ } catch (error) {
354
+ if (error instanceof McpError) throw error
355
+ throw new McpError(`mcp prompt "${promptId}" failed: ${error?.message || error}`, {
356
+ reason: "bad_response", server: prompt.server, prompt: promptId
357
+ })
358
+ }
359
+ },
360
+
361
+ async listResources(serverName) {
362
+ const client = state.servers.get(serverName)
363
+ if (!client) return []
364
+ return client.listResources()
365
+ },
366
+
367
+ async listTemplates(serverName) {
368
+ const client = state.servers.get(serverName)
369
+ if (!client) return []
370
+ return client.listTemplates()
371
+ },
372
+
373
+ async callTool(toolId, args = {}, signal = null) {
374
+ if (state.shuttingDown) {
375
+ throw new McpError("MCP registry is shutting down", { reason: "shutting_down" })
376
+ }
377
+ const tool = state.tools.get(toolId)
378
+ if (!tool) throw new McpError(`mcp tool not found: ${toolId}`, { reason: "not_found", tool: toolId })
379
+ let client = state.servers.get(tool.server)
380
+ if (!client) throw new McpError(`mcp server not found: ${tool.server}`, { reason: "not_found", server: tool.server })
381
+ const serverConfig = state.configured.get(tool.server)
382
+ const serverTimeout = serverConfig?.timeout_ms
383
+ let effectiveSignal = signal
384
+ if (serverTimeout && !signal) {
385
+ effectiveSignal = AbortSignal.timeout(serverTimeout)
386
+ }
387
+ try {
388
+ return await client.callTool(tool.name, args, effectiveSignal)
389
+ } catch (error) {
390
+ if (error?.reason === "spawn_failed" || error?.reason === "server_crash") {
391
+ setHealth(tool.server, serverConfig, {
392
+ ok: false, reason: error.reason, error: error.message
393
+ })
394
+ try {
395
+ await this.refreshServer(tool.server)
396
+ client = state.servers.get(tool.server)
397
+ if (client) {
398
+ const retrySignal = serverTimeout ? AbortSignal.timeout(serverTimeout) : null
399
+ return client.callTool(tool.name, args, retrySignal)
400
+ }
401
+ } catch {}
402
+ }
403
+ throw error
404
+ }
405
+ },
406
+
407
+ async refreshServer(name) {
408
+ const serverConfig = state.configured.get(name)
409
+ if (!serverConfig) throw new Error(`mcp server not configured: ${name}`)
410
+ const existing = state.servers.get(name)
411
+ if (existing && typeof existing.shutdown === "function") existing.shutdown()
412
+ state.servers.delete(name)
413
+ for (const [id, t] of state.tools) {
414
+ if (t.server === name) state.tools.delete(id)
415
+ }
416
+ for (const [id, p] of state.prompts) {
417
+ if (p.server === name) state.prompts.delete(id)
418
+ }
419
+ return connectServer(name, serverConfig)
420
+ },
421
+
422
+ async addServer(name, serverConfig) {
423
+ if (state.servers.has(name)) {
424
+ const existing = state.servers.get(name)
425
+ if (typeof existing.shutdown === "function") existing.shutdown()
426
+ state.servers.delete(name)
427
+ for (const [id, t] of state.tools) {
428
+ if (t.server === name) state.tools.delete(id)
429
+ }
430
+ for (const [id, p] of state.prompts) {
431
+ if (p.server === name) state.prompts.delete(id)
432
+ }
433
+ }
434
+ state.configured.set(name, serverConfig)
435
+ return connectServer(name, serverConfig)
436
+ },
437
+
438
+ async healthCheck(serverName) {
439
+ const client = state.servers.get(serverName)
440
+ const serverConfig = state.configured.get(serverName)
441
+ if (!client || !serverConfig) return { ok: false, reason: "not_found" }
442
+ try {
443
+ const result = await client.health()
444
+ const patch = {
445
+ ok: Boolean(result?.ok),
446
+ reason: result?.reason || (result?.ok ? "ok" : "unknown"),
447
+ error: result?.error || null
448
+ }
449
+ setHealth(serverName, serverConfig, patch)
450
+ await EventBus.emit({ type: EVENT_TYPES.MCP_HEALTH, payload: { server: serverName, ...patch } })
451
+ if (!result?.ok) {
452
+ try { await this.refreshServer(serverName) } catch {}
453
+ }
454
+ return patch
455
+ } catch (error) {
456
+ const patch = { ok: false, reason: error.reason || "unknown", error: error.message }
457
+ setHealth(serverName, serverConfig, patch)
458
+ return patch
459
+ }
460
+ },
461
+
462
+ async healthCheckAll() {
463
+ const results = {}
464
+ for (const name of state.configured.keys()) {
465
+ if (state.configured.get(name)?.enabled === false) continue
466
+ results[name] = await this.healthCheck(name)
467
+ }
468
+ return results
469
+ },
470
+
471
+ removeServer(name) {
472
+ const client = state.servers.get(name)
473
+ if (client && typeof client.shutdown === "function") client.shutdown()
474
+ state.servers.delete(name)
475
+ state.configured.delete(name)
476
+ state.health.delete(name)
477
+ for (const [id, t] of state.tools) {
478
+ if (t.server === name) state.tools.delete(id)
479
+ }
480
+ for (const [id, p] of state.prompts) {
481
+ if (p.server === name) state.prompts.delete(id)
482
+ }
483
+ },
484
+
485
+ shutdown() {
486
+ state.shuttingDown = true
487
+ for (const [, client] of state.servers) {
488
+ if (typeof client.shutdown === "function") client.shutdown()
489
+ }
490
+ state.servers.clear()
491
+ state.tools.clear()
492
+ state.prompts.clear()
493
+ state.health.clear()
494
+ state.configured.clear()
495
+ state.loaded = false
496
+ state.lastSignature = ""
497
+ }
498
+ }