context-mode 1.0.168 → 2.0.0-next.5

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 (395) hide show
  1. package/.claude-plugin/marketplace.json +4 -22
  2. package/.claude-plugin/plugin.json +9 -24
  3. package/SHA256SUMS +31 -0
  4. package/claude-code/skills/ctx-doctor/SKILL.md +6 -0
  5. package/claude-code/skills/ctx-stats/SKILL.md +6 -0
  6. package/claude-code/skills/ctx-upgrade/SKILL.md +6 -0
  7. package/cli.mjs +6744 -0
  8. package/copilot/plugin.json +14 -0
  9. package/fixtures/hook-roundtrip.json +22 -0
  10. package/gemini-extension.json +14 -0
  11. package/hook.mjs +202 -0
  12. package/hooks/claude-code.json +50 -0
  13. package/hooks/codex.json +50 -0
  14. package/hooks/copilot-cli.json +33 -0
  15. package/hooks/hooks.json +16 -111
  16. package/hooks/kiro.json +69 -0
  17. package/hooks/qwen.json +50 -0
  18. package/hooks/vscode.json +40 -0
  19. package/hooks/zcode.json +53 -0
  20. package/opencode/plugin.js +22456 -0
  21. package/package.json +29 -114
  22. package/pi/index.js +50841 -0
  23. package/pi/skills/ctx-doctor/SKILL.md +6 -0
  24. package/pi/skills/ctx-stats/SKILL.md +6 -0
  25. package/pi/skills/ctx-upgrade/SKILL.md +6 -0
  26. package/qwen-extension.json +15 -0
  27. package/run.mjs +5633 -0
  28. package/search.mjs +5633 -0
  29. package/server.mjs +48198 -0
  30. package/skills/ctx-doctor/SKILL.md +3 -19
  31. package/skills/ctx-stats/SKILL.md +3 -23
  32. package/skills/ctx-upgrade/SKILL.md +3 -28
  33. package/.codex-plugin/hooks.json +0 -65
  34. package/.codex-plugin/mcp.json +0 -13
  35. package/.codex-plugin/plugin.json +0 -31
  36. package/.openclaw-plugin/index.ts +0 -12
  37. package/.openclaw-plugin/openclaw.plugin.json +0 -38
  38. package/.openclaw-plugin/package.json +0 -28
  39. package/LICENSE +0 -94
  40. package/README.md +0 -1619
  41. package/bin/statusline.mjs +0 -378
  42. package/build/adapters/antigravity/index.d.ts +0 -51
  43. package/build/adapters/antigravity/index.js +0 -169
  44. package/build/adapters/antigravity-cli/index.d.ts +0 -51
  45. package/build/adapters/antigravity-cli/index.js +0 -342
  46. package/build/adapters/base.d.ts +0 -92
  47. package/build/adapters/base.js +0 -135
  48. package/build/adapters/claude-code/config.d.ts +0 -8
  49. package/build/adapters/claude-code/config.js +0 -8
  50. package/build/adapters/claude-code/hooks.d.ts +0 -106
  51. package/build/adapters/claude-code/hooks.js +0 -183
  52. package/build/adapters/claude-code/index.d.ts +0 -80
  53. package/build/adapters/claude-code/index.js +0 -569
  54. package/build/adapters/claude-code-base.d.ts +0 -49
  55. package/build/adapters/claude-code-base.js +0 -113
  56. package/build/adapters/client-map.d.ts +0 -10
  57. package/build/adapters/client-map.js +0 -42
  58. package/build/adapters/codex/config.d.ts +0 -8
  59. package/build/adapters/codex/config.js +0 -8
  60. package/build/adapters/codex/hooks.d.ts +0 -55
  61. package/build/adapters/codex/hooks.js +0 -64
  62. package/build/adapters/codex/index.d.ts +0 -87
  63. package/build/adapters/codex/index.js +0 -992
  64. package/build/adapters/codex/paths.d.ts +0 -1
  65. package/build/adapters/codex/paths.js +0 -12
  66. package/build/adapters/codex/usage.d.ts +0 -107
  67. package/build/adapters/codex/usage.js +0 -227
  68. package/build/adapters/copilot-base.d.ts +0 -94
  69. package/build/adapters/copilot-base.js +0 -297
  70. package/build/adapters/copilot-cli/hooks.d.ts +0 -33
  71. package/build/adapters/copilot-cli/hooks.js +0 -64
  72. package/build/adapters/copilot-cli/index.d.ts +0 -48
  73. package/build/adapters/copilot-cli/index.js +0 -341
  74. package/build/adapters/cursor/config.d.ts +0 -4
  75. package/build/adapters/cursor/config.js +0 -4
  76. package/build/adapters/cursor/hooks.d.ts +0 -55
  77. package/build/adapters/cursor/hooks.js +0 -82
  78. package/build/adapters/cursor/index.d.ts +0 -64
  79. package/build/adapters/cursor/index.js +0 -517
  80. package/build/adapters/detect.d.ts +0 -136
  81. package/build/adapters/detect.js +0 -623
  82. package/build/adapters/gemini-cli/config.d.ts +0 -8
  83. package/build/adapters/gemini-cli/config.js +0 -8
  84. package/build/adapters/gemini-cli/hooks.d.ts +0 -83
  85. package/build/adapters/gemini-cli/hooks.js +0 -115
  86. package/build/adapters/gemini-cli/index.d.ts +0 -80
  87. package/build/adapters/gemini-cli/index.js +0 -483
  88. package/build/adapters/jetbrains-copilot/config.d.ts +0 -8
  89. package/build/adapters/jetbrains-copilot/config.js +0 -8
  90. package/build/adapters/jetbrains-copilot/hooks.d.ts +0 -59
  91. package/build/adapters/jetbrains-copilot/hooks.js +0 -87
  92. package/build/adapters/jetbrains-copilot/index.d.ts +0 -31
  93. package/build/adapters/jetbrains-copilot/index.js +0 -131
  94. package/build/adapters/kimi/config.d.ts +0 -8
  95. package/build/adapters/kimi/config.js +0 -8
  96. package/build/adapters/kimi/hooks.d.ts +0 -28
  97. package/build/adapters/kimi/hooks.js +0 -34
  98. package/build/adapters/kimi/index.d.ts +0 -66
  99. package/build/adapters/kimi/index.js +0 -537
  100. package/build/adapters/kimi/paths.d.ts +0 -21
  101. package/build/adapters/kimi/paths.js +0 -52
  102. package/build/adapters/kimi/usage.d.ts +0 -82
  103. package/build/adapters/kimi/usage.js +0 -217
  104. package/build/adapters/kiro/hooks.d.ts +0 -61
  105. package/build/adapters/kiro/hooks.js +0 -94
  106. package/build/adapters/kiro/index.d.ts +0 -61
  107. package/build/adapters/kiro/index.js +0 -301
  108. package/build/adapters/omp/index.d.ts +0 -65
  109. package/build/adapters/omp/index.js +0 -182
  110. package/build/adapters/omp/plugin.d.ts +0 -81
  111. package/build/adapters/omp/plugin.js +0 -331
  112. package/build/adapters/omp/usage.d.ts +0 -49
  113. package/build/adapters/omp/usage.js +0 -110
  114. package/build/adapters/openclaw/config.d.ts +0 -8
  115. package/build/adapters/openclaw/config.js +0 -8
  116. package/build/adapters/openclaw/hooks.d.ts +0 -50
  117. package/build/adapters/openclaw/hooks.js +0 -61
  118. package/build/adapters/openclaw/index.d.ts +0 -74
  119. package/build/adapters/openclaw/index.js +0 -439
  120. package/build/adapters/openclaw/mcp-tools.d.ts +0 -54
  121. package/build/adapters/openclaw/mcp-tools.js +0 -207
  122. package/build/adapters/openclaw/plugin.d.ts +0 -141
  123. package/build/adapters/openclaw/plugin.js +0 -818
  124. package/build/adapters/openclaw/session-db.d.ts +0 -55
  125. package/build/adapters/openclaw/session-db.js +0 -88
  126. package/build/adapters/openclaw/usage.d.ts +0 -34
  127. package/build/adapters/openclaw/usage.js +0 -52
  128. package/build/adapters/openclaw/workspace-router.d.ts +0 -29
  129. package/build/adapters/openclaw/workspace-router.js +0 -64
  130. package/build/adapters/opencode/config.d.ts +0 -8
  131. package/build/adapters/opencode/config.js +0 -8
  132. package/build/adapters/opencode/hooks.d.ts +0 -38
  133. package/build/adapters/opencode/hooks.js +0 -50
  134. package/build/adapters/opencode/index.d.ts +0 -67
  135. package/build/adapters/opencode/index.js +0 -463
  136. package/build/adapters/opencode/plugin.d.ts +0 -184
  137. package/build/adapters/opencode/plugin.js +0 -558
  138. package/build/adapters/opencode/zod3tov4.d.ts +0 -1
  139. package/build/adapters/opencode/zod3tov4.js +0 -132
  140. package/build/adapters/pi/extension.d.ts +0 -81
  141. package/build/adapters/pi/extension.js +0 -861
  142. package/build/adapters/pi/index.d.ts +0 -57
  143. package/build/adapters/pi/index.js +0 -173
  144. package/build/adapters/pi/mcp-bridge.d.ts +0 -268
  145. package/build/adapters/pi/mcp-bridge.js +0 -894
  146. package/build/adapters/qwen-code/hooks.d.ts +0 -26
  147. package/build/adapters/qwen-code/hooks.js +0 -29
  148. package/build/adapters/qwen-code/index.d.ts +0 -40
  149. package/build/adapters/qwen-code/index.js +0 -361
  150. package/build/adapters/qwen-code/usage.d.ts +0 -90
  151. package/build/adapters/qwen-code/usage.js +0 -222
  152. package/build/adapters/types.d.ts +0 -360
  153. package/build/adapters/types.js +0 -118
  154. package/build/adapters/vscode-copilot/config.d.ts +0 -8
  155. package/build/adapters/vscode-copilot/config.js +0 -8
  156. package/build/adapters/vscode-copilot/hooks.d.ts +0 -71
  157. package/build/adapters/vscode-copilot/hooks.js +0 -98
  158. package/build/adapters/vscode-copilot/index.d.ts +0 -32
  159. package/build/adapters/vscode-copilot/index.js +0 -222
  160. package/build/adapters/zed/index.d.ts +0 -40
  161. package/build/adapters/zed/index.js +0 -163
  162. package/build/cache-heal.d.ts +0 -48
  163. package/build/cache-heal.js +0 -150
  164. package/build/cli.d.ts +0 -34
  165. package/build/cli.js +0 -1827
  166. package/build/concurrency/runPool.d.ts +0 -36
  167. package/build/concurrency/runPool.js +0 -51
  168. package/build/db-base.d.ts +0 -179
  169. package/build/db-base.js +0 -569
  170. package/build/executor.d.ts +0 -72
  171. package/build/executor.js +0 -678
  172. package/build/exit-classify.d.ts +0 -19
  173. package/build/exit-classify.js +0 -12
  174. package/build/fetch-cache.d.ts +0 -13
  175. package/build/fetch-cache.js +0 -15
  176. package/build/lifecycle.d.ts +0 -124
  177. package/build/lifecycle.js +0 -278
  178. package/build/openclaw/mcp-tools.d.ts +0 -54
  179. package/build/openclaw/mcp-tools.js +0 -198
  180. package/build/openclaw/workspace-router.d.ts +0 -29
  181. package/build/openclaw/workspace-router.js +0 -64
  182. package/build/openclaw-plugin.d.ts +0 -130
  183. package/build/openclaw-plugin.js +0 -626
  184. package/build/opencode-plugin.d.ts +0 -122
  185. package/build/opencode-plugin.js +0 -375
  186. package/build/pi-extension.d.ts +0 -14
  187. package/build/pi-extension.js +0 -451
  188. package/build/routing-block.d.ts +0 -8
  189. package/build/routing-block.js +0 -86
  190. package/build/runPool.d.ts +0 -36
  191. package/build/runPool.js +0 -51
  192. package/build/runtime.d.ts +0 -71
  193. package/build/runtime.js +0 -659
  194. package/build/search/auto-memory.d.ts +0 -47
  195. package/build/search/auto-memory.js +0 -170
  196. package/build/search/ctx-search-schema.d.ts +0 -90
  197. package/build/search/ctx-search-schema.js +0 -135
  198. package/build/search/flood-guard.d.ts +0 -57
  199. package/build/search/flood-guard.js +0 -80
  200. package/build/search/unified.d.ts +0 -56
  201. package/build/search/unified.js +0 -104
  202. package/build/security.d.ts +0 -206
  203. package/build/security.js +0 -780
  204. package/build/server.d.ts +0 -184
  205. package/build/server.js +0 -4542
  206. package/build/session/analytics.d.ts +0 -745
  207. package/build/session/analytics.js +0 -2369
  208. package/build/session/db.d.ts +0 -468
  209. package/build/session/db.js +0 -1308
  210. package/build/session/error-classifier.d.ts +0 -87
  211. package/build/session/error-classifier.js +0 -303
  212. package/build/session/event-emit.d.ts +0 -48
  213. package/build/session/event-emit.js +0 -101
  214. package/build/session/extract.d.ts +0 -333
  215. package/build/session/extract.js +0 -2688
  216. package/build/session/model-prices.json +0 -429
  217. package/build/session/persist-tool-calls.d.ts +0 -54
  218. package/build/session/persist-tool-calls.js +0 -105
  219. package/build/session/pricing.d.ts +0 -64
  220. package/build/session/pricing.js +0 -151
  221. package/build/session/project-attribution.d.ts +0 -73
  222. package/build/session/project-attribution.js +0 -245
  223. package/build/session/purge.d.ts +0 -138
  224. package/build/session/purge.js +0 -240
  225. package/build/session/snapshot.d.ts +0 -46
  226. package/build/session/snapshot.js +0 -532
  227. package/build/store-directory.d.ts +0 -56
  228. package/build/store-directory.js +0 -254
  229. package/build/store.d.ts +0 -147
  230. package/build/store.js +0 -1714
  231. package/build/tool-naming.d.ts +0 -4
  232. package/build/tool-naming.js +0 -24
  233. package/build/truncate.d.ts +0 -57
  234. package/build/truncate.js +0 -142
  235. package/build/types.d.ts +0 -117
  236. package/build/types.js +0 -20
  237. package/build/util/claude-config.d.ts +0 -32
  238. package/build/util/claude-config.js +0 -84
  239. package/build/util/hook-config.d.ts +0 -27
  240. package/build/util/hook-config.js +0 -76
  241. package/build/util/jsonc.d.ts +0 -14
  242. package/build/util/jsonc.js +0 -104
  243. package/build/util/plugin-cache-integrity.d.ts +0 -51
  244. package/build/util/plugin-cache-integrity.js +0 -146
  245. package/build/util/project-dir.d.ts +0 -149
  246. package/build/util/project-dir.js +0 -340
  247. package/build/util/sibling-mcp.d.ts +0 -79
  248. package/build/util/sibling-mcp.js +0 -181
  249. package/cli.bundle.mjs +0 -1235
  250. package/configs/antigravity/GEMINI.md +0 -68
  251. package/configs/antigravity/mcp_config.json +0 -7
  252. package/configs/antigravity-cli/hooks/hooks.json +0 -37
  253. package/configs/antigravity-cli/hooks.json +0 -37
  254. package/configs/antigravity-cli/mcp_config.json +0 -10
  255. package/configs/antigravity-cli/plugin.json +0 -14
  256. package/configs/antigravity-cli/rules/context-mode.md +0 -77
  257. package/configs/antigravity-cli/skills/context-mode/SKILL.md +0 -77
  258. package/configs/claude-code/CLAUDE.md +0 -91
  259. package/configs/codex/AGENTS.md +0 -99
  260. package/configs/codex/config.toml +0 -8
  261. package/configs/codex/hooks.json +0 -47
  262. package/configs/copilot-cli/.github/plugin/plugin.json +0 -23
  263. package/configs/copilot-cli/.mcp.json +0 -12
  264. package/configs/copilot-cli/README.md +0 -47
  265. package/configs/copilot-cli/hooks.json +0 -41
  266. package/configs/copilot-cli/skills/context-mode/SKILL.md +0 -38
  267. package/configs/cursor/context-mode.mdc +0 -76
  268. package/configs/cursor/hooks.json +0 -21
  269. package/configs/cursor/mcp.json +0 -7
  270. package/configs/gemini-cli/GEMINI.md +0 -88
  271. package/configs/gemini-cli/mcp.json +0 -7
  272. package/configs/gemini-cli/settings.json +0 -60
  273. package/configs/jetbrains-copilot/copilot-instructions.md +0 -80
  274. package/configs/jetbrains-copilot/hooks.json +0 -16
  275. package/configs/jetbrains-copilot/mcp.json +0 -7
  276. package/configs/kilo/AGENTS.md +0 -84
  277. package/configs/kilo/kilo.json +0 -6
  278. package/configs/kimi/hooks.json +0 -54
  279. package/configs/kiro/KIRO.md +0 -84
  280. package/configs/kiro/agent.json +0 -18
  281. package/configs/kiro/mcp.json +0 -7
  282. package/configs/omp/SYSTEM.md +0 -85
  283. package/configs/omp/mcp.json +0 -7
  284. package/configs/openclaw/AGENTS.md +0 -87
  285. package/configs/openclaw/openclaw.json +0 -13
  286. package/configs/opencode/AGENTS.md +0 -84
  287. package/configs/opencode/opencode.json +0 -6
  288. package/configs/pi/AGENTS.md +0 -3
  289. package/configs/qwen-code/QWEN.md +0 -91
  290. package/configs/vscode-copilot/copilot-instructions.md +0 -80
  291. package/configs/vscode-copilot/hooks.json +0 -16
  292. package/configs/vscode-copilot/mcp.json +0 -7
  293. package/configs/zed/AGENTS.md +0 -68
  294. package/hooks/antigravity-cli/payload.mjs +0 -98
  295. package/hooks/antigravity-cli/posttooluse.mjs +0 -138
  296. package/hooks/antigravity-cli/pretooluse.mjs +0 -78
  297. package/hooks/antigravity-cli/stop.mjs +0 -58
  298. package/hooks/auto-injection.mjs +0 -102
  299. package/hooks/cache-heal-utils.mjs +0 -379
  300. package/hooks/codex/platform.mjs +0 -1
  301. package/hooks/codex/posttooluse.mjs +0 -66
  302. package/hooks/codex/precompact.mjs +0 -70
  303. package/hooks/codex/pretooluse.mjs +0 -42
  304. package/hooks/codex/sessionstart.mjs +0 -121
  305. package/hooks/codex/stop.mjs +0 -138
  306. package/hooks/codex/userpromptsubmit.mjs +0 -75
  307. package/hooks/copilot-cli/posttooluse.mjs +0 -79
  308. package/hooks/copilot-cli/precompact.mjs +0 -66
  309. package/hooks/copilot-cli/pretooluse.mjs +0 -41
  310. package/hooks/copilot-cli/sessionstart.mjs +0 -121
  311. package/hooks/copilot-cli/stop.mjs +0 -59
  312. package/hooks/copilot-cli/userpromptsubmit.mjs +0 -77
  313. package/hooks/core/codex-caps.mjs +0 -112
  314. package/hooks/core/formatters.mjs +0 -356
  315. package/hooks/core/mcp-ready.mjs +0 -109
  316. package/hooks/core/platform-detect.mjs +0 -49
  317. package/hooks/core/routing.mjs +0 -1017
  318. package/hooks/core/stdin.mjs +0 -90
  319. package/hooks/core/tool-naming.mjs +0 -57
  320. package/hooks/cursor/afteragentresponse.mjs +0 -74
  321. package/hooks/cursor/hooks.json +0 -31
  322. package/hooks/cursor/posttooluse.mjs +0 -72
  323. package/hooks/cursor/pretooluse.mjs +0 -26
  324. package/hooks/cursor/sessionstart.mjs +0 -97
  325. package/hooks/cursor/stop.mjs +0 -48
  326. package/hooks/ensure-deps.mjs +0 -254
  327. package/hooks/formatters/claude-code.mjs +0 -113
  328. package/hooks/formatters/cursor.mjs +0 -37
  329. package/hooks/formatters/gemini-cli.mjs +0 -55
  330. package/hooks/formatters/vscode-copilot.mjs +0 -55
  331. package/hooks/gemini-cli/aftermodel.mjs +0 -70
  332. package/hooks/gemini-cli/aftertool.mjs +0 -61
  333. package/hooks/gemini-cli/beforeagent.mjs +0 -99
  334. package/hooks/gemini-cli/beforetool.mjs +0 -27
  335. package/hooks/gemini-cli/precompress.mjs +0 -55
  336. package/hooks/gemini-cli/sessionstart.mjs +0 -137
  337. package/hooks/heal-partial-install.mjs +0 -712
  338. package/hooks/jetbrains-copilot/posttooluse.mjs +0 -61
  339. package/hooks/jetbrains-copilot/precompact.mjs +0 -55
  340. package/hooks/jetbrains-copilot/pretooluse.mjs +0 -27
  341. package/hooks/jetbrains-copilot/sessionstart.mjs +0 -124
  342. package/hooks/kimi/platform.mjs +0 -1
  343. package/hooks/kimi/posttooluse.mjs +0 -72
  344. package/hooks/kimi/precompact.mjs +0 -80
  345. package/hooks/kimi/pretooluse.mjs +0 -42
  346. package/hooks/kimi/sessionend.mjs +0 -61
  347. package/hooks/kimi/sessionstart.mjs +0 -113
  348. package/hooks/kimi/stop.mjs +0 -132
  349. package/hooks/kimi/userpromptsubmit.mjs +0 -90
  350. package/hooks/kiro/agentspawn.mjs +0 -97
  351. package/hooks/kiro/posttooluse.mjs +0 -51
  352. package/hooks/kiro/pretooluse.mjs +0 -64
  353. package/hooks/kiro/userpromptsubmit.mjs +0 -88
  354. package/hooks/normalize-hooks.mjs +0 -323
  355. package/hooks/platform-bridge.mjs +0 -340
  356. package/hooks/posttooluse.mjs +0 -187
  357. package/hooks/precompact.mjs +0 -100
  358. package/hooks/pretooluse.mjs +0 -227
  359. package/hooks/qwen-code/platform.mjs +0 -1
  360. package/hooks/qwen-code/stop.mjs +0 -168
  361. package/hooks/routing-block.mjs +0 -105
  362. package/hooks/run-hook.mjs +0 -95
  363. package/hooks/security.bundle.mjs +0 -2
  364. package/hooks/session-attribution.bundle.mjs +0 -1
  365. package/hooks/session-db.bundle.mjs +0 -144
  366. package/hooks/session-directive.mjs +0 -520
  367. package/hooks/session-extract.bundle.mjs +0 -3
  368. package/hooks/session-helpers.mjs +0 -428
  369. package/hooks/session-loaders.mjs +0 -448
  370. package/hooks/session-snapshot.bundle.mjs +0 -32
  371. package/hooks/sessionstart.mjs +0 -464
  372. package/hooks/stop.mjs +0 -82
  373. package/hooks/suppress-stderr.mjs +0 -25
  374. package/hooks/userpromptsubmit.mjs +0 -108
  375. package/hooks/vscode-copilot/posttooluse.mjs +0 -61
  376. package/hooks/vscode-copilot/precompact.mjs +0 -55
  377. package/hooks/vscode-copilot/pretooluse.mjs +0 -27
  378. package/hooks/vscode-copilot/sessionstart.mjs +0 -129
  379. package/openclaw.plugin.json +0 -38
  380. package/scripts/heal-better-sqlite3.mjs +0 -430
  381. package/scripts/heal-installed-plugins.mjs +0 -598
  382. package/scripts/plugin-cache-integrity.mjs +0 -248
  383. package/scripts/postinstall.mjs +0 -384
  384. package/server.bundle.mjs +0 -1161
  385. package/skills/.ignore +0 -7
  386. package/skills/context-mode/SKILL.md +0 -300
  387. package/skills/context-mode/references/anti-patterns.md +0 -283
  388. package/skills/context-mode/references/patterns-javascript.md +0 -298
  389. package/skills/context-mode/references/patterns-python.md +0 -304
  390. package/skills/context-mode/references/patterns-shell.md +0 -277
  391. package/skills/ctx-index/SKILL.md +0 -46
  392. package/skills/ctx-insight/SKILL.md +0 -24
  393. package/skills/ctx-purge/SKILL.md +0 -49
  394. package/skills/ctx-search/SKILL.md +0 -35
  395. package/start.mjs +0 -616
package/build/server.js DELETED
@@ -1,4542 +0,0 @@
1
- #!/usr/bin/env node
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { createRequire } from "node:module";
5
- import { existsSync, unlinkSync, readdirSync, readFileSync, writeFileSync, writeSync, renameSync, rmSync, mkdirSync, statSync, symlinkSync, lstatSync, realpathSync } from "node:fs";
6
- import { spawnSync } from "node:child_process";
7
- import { join, dirname, resolve, sep, isAbsolute } from "node:path";
8
- import { fileURLToPath } from "node:url";
9
- import { homedir, tmpdir, cpus } from "node:os";
10
- import { request as httpsRequest } from "node:https";
11
- import { AsyncLocalStorage } from "node:async_hooks";
12
- import { z } from "zod";
13
- import { PolyglotExecutor } from "./executor.js";
14
- import { runPool } from "./runPool.js";
15
- import { ContentStore, cleanupStaleDBs, cleanupStaleContentDBs } from "./store.js";
16
- import { composeFetchCacheKey } from "./fetch-cache.js";
17
- import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readToolDenyPatterns, readToolPermissionPatterns, evaluateFilePath, evaluateProjectContainment, } from "./security.js";
18
- import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime, } from "./runtime.js";
19
- import { classifyNonZeroExit } from "./exit-classify.js";
20
- import { startLifecycleGuard, noteMcpActivity, noteRequestStart, noteRequestEnd, attachMcpActivityTap } from "./lifecycle.js";
21
- import { charSafePrefix } from "./truncate.js";
22
- import { describeStorageDirectorySource, ensureWritableStorageDir, formatStorageDirectoryError, hashProjectDirCanonical, hashProjectDirLegacy, resolveContentStorePath, resolveContentStorageDir, resolveDefaultSessionDir, resolveSessionDbPath, resolveSessionStorageDir, resolveStatsStorageDir, SessionDB, StorageDirectoryError, } from "./session/db.js";
23
- import { purgeSession } from "./session/purge.js";
24
- import { emitCacheHitEvent, emitIndexWriteEvent, emitSandboxExecuteEvent, } from "./session/event-emit.js";
25
- import { persistToolCallCounter, restoreSessionStats } from "./session/persist-tool-calls.js";
26
- import { searchAllSources } from "./search/unified.js";
27
- import { buildCtxSearchInputSchema, CTX_SEARCH_SHARED_MODE, resolveProjectScope, } from "./search/ctx-search-schema.js";
28
- import { FloodGuard } from "./search/flood-guard.js";
29
- import { buildNodeCommand, isInProcessPluginPlatform } from "./adapters/types.js";
30
- import { detectPlatform, getSessionDirSegments } from "./adapters/detect.js";
31
- import { parseCodexContextModePluginRoot } from "./adapters/codex/index.js";
32
- import { getHookScriptPaths } from "./util/hook-config.js";
33
- import { stripJsonComments } from "./util/jsonc.js";
34
- import { resolveClaudeConfigDir } from "./util/claude-config.js";
35
- import { resolveProjectDir } from "./util/project-dir.js";
36
- import { loadDatabase } from "./db-base.js";
37
- import { AnalyticsEngine, formatReport, getConversationStats, getContentBytesAllSessions, getLifetimeStats, getMultiAdapterLifetimeStats, getRealBytesStats, pricePerToken } from "./session/analytics.js";
38
- const __pkg_dir = dirname(fileURLToPath(import.meta.url));
39
- const VERSION = (() => {
40
- for (const rel of ["../package.json", "./package.json"]) {
41
- const p = resolve(__pkg_dir, rel);
42
- if (existsSync(p)) {
43
- try {
44
- return JSON.parse(readFileSync(p, "utf8")).version;
45
- }
46
- catch { }
47
- }
48
- }
49
- return "unknown";
50
- })();
51
- function getPackageRoot() {
52
- return existsSync(resolve(__pkg_dir, "package.json")) ? __pkg_dir : dirname(__pkg_dir);
53
- }
54
- function resolveCodexRuntimePluginRoot(fallbackRoot) {
55
- try {
56
- const probe = process.platform === "win32"
57
- ? spawnSync("cmd.exe", ["/d", "/s", "/c", "codex plugin list"], {
58
- encoding: "utf-8",
59
- stdio: ["ignore", "pipe", "ignore"],
60
- timeout: 5000,
61
- })
62
- : spawnSync("codex", ["plugin", "list"], {
63
- encoding: "utf-8",
64
- stdio: ["ignore", "pipe", "ignore"],
65
- timeout: 5000,
66
- });
67
- if (probe.status !== 0)
68
- return fallbackRoot;
69
- const runtimeRoot = parseCodexContextModePluginRoot(String(probe.stdout));
70
- if (runtimeRoot && existsSync(resolve(runtimeRoot, ".codex-plugin", "hooks.json"))) {
71
- return runtimeRoot;
72
- }
73
- }
74
- catch {
75
- // Best effort only. Non-Codex hosts and older Codex builds may not expose
76
- // plugin list; keep the package-root fallback for those environments.
77
- }
78
- return fallbackRoot;
79
- }
80
- function getRuntimeAwarePackageRoot(platformId) {
81
- const packageRoot = getPackageRoot();
82
- return platformId === "codex"
83
- ? resolveCodexRuntimePluginRoot(packageRoot)
84
- : packageRoot;
85
- }
86
- // Prevent silent MCP server death from unhandled async errors.
87
- //
88
- // Guarded for plugin-native OpenCode/Kilo imports (#574): when server.js is
89
- // imported only to reuse the ctx_* tool registry, these handlers would become
90
- // process-wide OpenCode/Kilo host handlers. In Node, adding an
91
- // `uncaughtException` listener changes default crash behavior, so only the
92
- // standalone MCP process may install them.
93
- if (process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
94
- process.on("unhandledRejection", (err) => {
95
- process.stderr.write(`[context-mode] unhandledRejection: ${err}\n`);
96
- });
97
- process.on("uncaughtException", (err) => {
98
- try {
99
- writeSync(2, `[context-mode] uncaughtException: ${err?.message ?? err}\n`);
100
- }
101
- finally {
102
- process.exit(1);
103
- }
104
- });
105
- }
106
- const runtimes = detectRuntimes();
107
- const available = getAvailableLanguages(runtimes);
108
- export const server = new McpServer({
109
- name: "context-mode",
110
- version: VERSION,
111
- });
112
- export const REGISTERED_CTX_TOOLS = [];
113
- export function shouldSuppressMcpToolsForNativePluginHost(opts = {}) {
114
- const embedded = opts.embedded ?? process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS;
115
- if (embedded === "1")
116
- return false;
117
- const platform = opts.platform ?? detectPlatform().platform;
118
- if (platform !== "opencode" && platform !== "kilo")
119
- return false;
120
- const settings = opts.settings ?? readNativePluginHostSettings(platform);
121
- return settingsHasContextModePlugin(settings) && settingsHasLegacyContextModeMcp(settings);
122
- }
123
- function readNativePluginHostSettings(platform) {
124
- const base = platform === "kilo" ? "kilo" : "opencode";
125
- const paths = [
126
- resolve(`${base}.json`),
127
- resolve(`${base}.jsonc`),
128
- resolve(`.${base}`, `${base}.json`),
129
- resolve(`.${base}`, `${base}.jsonc`),
130
- join(homedir(), ".config", base, `${base}.json`),
131
- join(homedir(), ".config", base, `${base}.jsonc`),
132
- ];
133
- for (const p of paths) {
134
- try {
135
- if (!existsSync(p))
136
- continue;
137
- return JSON.parse(stripJsonComments(readFileSync(p, "utf8")));
138
- }
139
- catch { /* try next config path */ }
140
- }
141
- return null;
142
- }
143
- function settingsHasContextModePlugin(settings) {
144
- const plugins = settings?.plugin;
145
- return Array.isArray(plugins) && plugins.some((p) => typeof p === "string" && p.includes("context-mode"));
146
- }
147
- function settingsHasLegacyContextModeMcp(settings) {
148
- const mcp = settings?.mcp;
149
- return !!(mcp &&
150
- typeof mcp === "object" &&
151
- !Array.isArray(mcp) &&
152
- Object.prototype.hasOwnProperty.call(mcp, "context-mode"));
153
- }
154
- const suppressMcpToolsForNativePluginHost = shouldSuppressMcpToolsForNativePluginHost();
155
- /**
156
- * Issue #623 — surface why ctx_* tools/list is empty on suppressed legacy MCP
157
- * children. When a user upgrades OpenCode/Kilo from v1.0.136 → v1.0.137+ without
158
- * running `context-mode upgrade`, their opencode.json still has BOTH the legacy
159
- * mcp.context-mode block AND the plugin entry. The plugin path registers the
160
- * tools natively, but the legacy MCP child runs in parallel and used to expose
161
- * duplicate tools — v1.0.137 suppressed those duplicates. The suppression was
162
- * silent, leaving any MCP client that inspected the child via tools/list with
163
- * an empty list and no diagnostic. Emit one stderr line per process so an
164
- * operator running the child directly (or any non-plugin MCP host) sees the
165
- * exact reason and the `context-mode upgrade` fix.
166
- *
167
- * Exported for test (suppression-diagnostic regression guard).
168
- */
169
- let __suppressionDiagnosticEmitted = false;
170
- export function emitSuppressionDiagnostic(opts = {}) {
171
- if (__suppressionDiagnosticEmitted)
172
- return;
173
- __suppressionDiagnosticEmitted = true;
174
- const write = opts.write ?? ((c) => { process.stderr.write(c); });
175
- const platform = opts.platform ?? "opencode/kilo";
176
- write(`[context-mode] ctx_* tools/list intentionally empty on this MCP child: ` +
177
- `legacy mcp.context-mode block coexists with plugin: ["context-mode"] in ` +
178
- `${platform}.json — plugin-native tools are the supported path (#623). ` +
179
- `Run \`context-mode upgrade\` to remove the legacy block (preserves other ` +
180
- `MCP servers).\n`);
181
- }
182
- /** Test-only: reset the one-shot emission flag so suites can re-exercise. */
183
- export function __resetSuppressionDiagnosticForTests() {
184
- __suppressionDiagnosticEmitted = false;
185
- }
186
- /**
187
- * Issue #637 — register an explicit empty `tools/list` handler on the McpServer.
188
- *
189
- * Background: when `suppressMcpToolsForNativePluginHost` is true, every
190
- * `server.registerTool()` call is short-circuited (returns `undefined` above).
191
- * The MCP SDK only installs the SDK-default `tools/list` handler when at least
192
- * one `registerTool()` reaches `setToolRequestHandlers()` internally
193
- * (mcp.js:56-67). Suppressing every registration leaves `tools/list`
194
- * unregistered, and the framework's RPC layer answers it with
195
- * `-32601 "Method not found"`.
196
- *
197
- * The reporter of #637 (SquirrelRat) inspected the suppressed child via
198
- * `tools/list` and read the JSON-RPC error as "the plugin never registers any
199
- * ctx_* tools" — when in fact the plugin DOES register all 11 tools natively
200
- * (verified at `src/adapters/opencode/plugin.ts:469` and
201
- * `tests/opencode-plugin.test.ts:88`). The misleading -32601 is the seed of
202
- * the #637 perception.
203
- *
204
- * This helper installs an explicit handler that returns `{tools: []}` — a
205
- * spec-compliant empty list. Paired with the existing #623 stderr diagnostic,
206
- * an operator now sees:
207
- * - wire response: `{tools: []}` (matches expectation, no JSON-RPC error)
208
- * - stderr: `[context-mode] ctx_* tools/list intentionally empty… (#623)`
209
- *
210
- * Idempotent: throws inside SDK if called twice on the same server because
211
- * `assertCanSetRequestHandler` (mcp.js:60) rejects duplicate registrations;
212
- * we therefore install the SDK's default tool handlers FIRST (via a no-op
213
- * registerTool of a fake tool, immediately removed) only if needed. To keep
214
- * the public surface minimal, we just call `server.server.setRequestHandler`
215
- * directly — that is the same low-level call used for prompts/resources at
216
- * server.ts:259-261 and avoids the SDK guard entirely.
217
- *
218
- * Exported for test (#637 in-memory regression guard).
219
- */
220
- export function registerEmptyToolsListHandler(target = server) {
221
- target.server.registerCapabilities({ tools: { listChanged: false } });
222
- target.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] }));
223
- }
224
- const originalRegisterTool = server.registerTool.bind(server);
225
- server.registerTool = (...args) => {
226
- const [name, config, handler] = args;
227
- if (suppressMcpToolsForNativePluginHost) {
228
- emitSuppressionDiagnostic();
229
- return undefined;
230
- }
231
- const wrappedHandler = wrapToolHandler(name, handler);
232
- REGISTERED_CTX_TOOLS.push({ name, config, handler: wrappedHandler });
233
- args[2] = wrappedHandler;
234
- return originalRegisterTool(...args);
235
- };
236
- function wrapToolHandler(name, handler) {
237
- return async (toolArgs) => {
238
- // #854: mark a tool call in-flight so the bridge-child idle reaper never
239
- // shuts the server down mid-execution during a long ctx_execute/batch that
240
- // emits no further inbound messages. Symmetric end in finally (success+error).
241
- noteRequestStart();
242
- try {
243
- return await handler(toolArgs);
244
- }
245
- catch (err) {
246
- const result = storageErrorResult(err);
247
- if (result) {
248
- try {
249
- return trackResponse(name, result);
250
- }
251
- catch (trackErr) {
252
- if (trackErr instanceof StorageDirectoryError)
253
- return result;
254
- throw trackErr;
255
- }
256
- }
257
- throw err;
258
- }
259
- finally {
260
- noteRequestEnd();
261
- }
262
- };
263
- }
264
- // Issue #637 — when suppression is active, install the empty tools/list handler
265
- // once at module-init time so the suppressed MCP child responds with
266
- // `{tools: []}` instead of JSON-RPC `-32601 Method not found`. Pair with the
267
- // #623 stderr diagnostic that explains WHY the list is empty. Skipped for the
268
- // embedded plugin-import path because the embedded process is not the stdio
269
- // MCP child an operator would inspect — it lives inside the OpenCode/Kilo
270
- // host and never speaks JSON-RPC over stdio.
271
- if (suppressMcpToolsForNativePluginHost && process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
272
- registerEmptyToolsListHandler(server);
273
- }
274
- const projectDirOverride = new AsyncLocalStorage();
275
- export async function withProjectDirOverride(projectDir, fn) {
276
- const ctx = typeof projectDir === "string" ? { projectDir } : projectDir;
277
- return projectDirOverride.run(ctx, fn);
278
- }
279
- // Register empty prompts/resources handlers so MCP clients don't get -32601 (#168).
280
- // OpenCode calls listPrompts()/listResources() unconditionally — the error can poison
281
- // the SDK transport layer, causing subsequent listTools() calls to fail permanently.
282
- import { ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
283
- server.server.registerCapabilities({ prompts: { listChanged: false }, resources: { listChanged: false } });
284
- server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
285
- server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
286
- server.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] }));
287
- // ── Strict-client (Gemini function-calling) schema compatibility ──────────────
288
- // Gemini's function-calling API — used by Antigravity CLI (`agy`) and Gemini CLI
289
- // — rejects JSON Schema `const` and `additionalProperties`. A rejected parameter
290
- // schema makes the host SILENTLY DROP that tool from the model's function list,
291
- // so the agent never sees our ctx_* tools and falls back to hand-rolling the MCP
292
- // protocol through its Bash tool. Sanitize the EMITTED tools/list schema:
293
- // • `const: X` → `enum: [X]` — an identical single-value constraint
294
- // • drop `additionalProperties` — advisory only; every ctx_* handler parses
295
- // args with Zod (which strips unknown keys server-side), so removing it
296
- // changes no validation and no call behavior.
297
- // Both transforms are behavior-preserving for every other client (Claude Code,
298
- // Copilot, Cursor, …): `const` and a one-value `enum` are equivalent, and no
299
- // model sends undeclared properties. Only the wire schema changes — never
300
- // validation or how any tool is invoked.
301
- export function sanitizeSchemaForStrictClients(node) {
302
- if (Array.isArray(node))
303
- return node.map(sanitizeSchemaForStrictClients);
304
- if (node === null || typeof node !== "object")
305
- return node;
306
- const out = {};
307
- for (const [key, value] of Object.entries(node)) {
308
- if (key === "additionalProperties")
309
- continue;
310
- if (key === "const") {
311
- out.enum = [value];
312
- continue;
313
- }
314
- out[key] = sanitizeSchemaForStrictClients(value);
315
- }
316
- return out;
317
- }
318
- // Wrap the SDK-installed tools/list handler so its generated schemas pass through
319
- // the sanitizer above. Best-effort by design: if the MCP SDK's internals shift,
320
- // the original handler is left untouched (no regression — strict clients stay as
321
- // they were, every other client unaffected). Must run AFTER all registerTool()
322
- // calls so the SDK's default tools/list handler already exists.
323
- export function installStrictClientSchemaCompat(target = server) {
324
- try {
325
- const low = target.server;
326
- const original = low._requestHandlers?.get("tools/list");
327
- if (typeof original !== "function")
328
- return;
329
- target.server.setRequestHandler(ListToolsRequestSchema, async (req, extra) => {
330
- const result = (await original(req, extra));
331
- if (result && Array.isArray(result.tools)) {
332
- for (const tool of result.tools) {
333
- if (!tool || tool.inputSchema == null)
334
- continue;
335
- try {
336
- tool.inputSchema = sanitizeSchemaForStrictClients(tool.inputSchema);
337
- }
338
- catch {
339
- /* leave this tool's schema unchanged */
340
- }
341
- }
342
- }
343
- return result;
344
- });
345
- }
346
- catch {
347
- /* best-effort — never break tools/list */
348
- }
349
- }
350
- const executor = new PolyglotExecutor({
351
- runtimes,
352
- projectRoot: () => getProjectDir(),
353
- });
354
- // ─────────────────────────────────────────────────────────
355
- // FS read tracking preload for ctx_batch_execute
356
- // ─────────────────────────────────────────────────────────
357
- // NODE_OPTIONS is denied by the executor's #buildSafeEnv (security).
358
- // Instead, we inject it as an inline shell env prefix in each batch command.
359
- // This temp file is loaded via --require when batch commands spawn Node processes.
360
- const CM_FS_PRELOAD = join(tmpdir(), `cm-fs-preload-${process.pid}.js`);
361
- writeFileSync(CM_FS_PRELOAD, `(function(){var __cm_fs=0;process.on('exit',function(){if(__cm_fs>0)try{process.stderr.write('__CM_FS__:'+__cm_fs+'\\n')}catch(e){}});try{var f=require('fs');var ors=f.readFileSync;f.readFileSync=function(){var r=ors.apply(this,arguments);if(Buffer.isBuffer(r))__cm_fs+=r.length;else if(typeof r==='string')__cm_fs+=Buffer.byteLength(r);return r;};}catch(e){}})();\n`);
362
- // In the stdio MCP path, main() also removes this file during graceful
363
- // shutdown. Plugin-native OpenCode/Kilo imports skip main() (#574), so
364
- // register a top-level best-effort cleanup too to avoid leaking preload
365
- // snippets under /tmp when the host process exits.
366
- process.on("exit", () => { try {
367
- unlinkSync(CM_FS_PRELOAD);
368
- }
369
- catch { /* best effort */ } });
370
- // Lazy singleton — no DB overhead unless index/search is used
371
- let _store = null;
372
- /**
373
- * Build the FK-attribution object passed to every ContentStore.index*() call
374
- * in this process. CLAUDE_SESSION_ID is the only MCP-side handle we have on
375
- * the current session — eventId stays undefined because MCP tool invocations
376
- * are not paired with PostToolUse event rows at index time (the hook fires
377
- * AFTER the tool returns). Empty-string fallback inside #insertChunks keeps
378
- * legacy unattributed rows readable.
379
- */
380
- export function currentAttribution() {
381
- const override = projectDirOverride.getStore();
382
- if (override?.sessionId)
383
- return { sessionId: override.sessionId };
384
- // CLAUDE_SESSION_ID env var is NOT propagated to MCP servers (only to hooks).
385
- // Cross-adapter resolution: every adapter (15 of them) sets *_PROJECT_DIR env
386
- // and writes session_events via hooks. Read the most-recent session_id from
387
- // THIS project's session DB. Works for claude-code/cursor/gemini-cli/codex/
388
- // kiro/opencode/zed/kilo/openclaw/qwen-code/vscode-copilot/jetbrains-copilot/
389
- // omp/pi/antigravity — no adapter-specific transcript path required.
390
- const sessionId = process.env.CLAUDE_SESSION_ID ?? resolveSessionIdFromSessionDB();
391
- if (!sessionId)
392
- return undefined;
393
- return { sessionId };
394
- }
395
- let __cachedSessionId;
396
- /** v1.0.134 SLICE A: opts injection for testability. Production callers pass nothing. */
397
- export function resolveSessionIdFromSessionDB(opts) {
398
- // 2s cache — ctx_fetch_and_index can fire 5+ chunks/sec; DB open cost adds up.
399
- const now = Date.now();
400
- if (!opts?.bypassCache && __cachedSessionId && now - __cachedSessionId.checkedAt < 2000) {
401
- return __cachedSessionId.sid;
402
- }
403
- try {
404
- const projectDir = opts?.projectDir
405
- ?? process.env.CLAUDE_PROJECT_DIR
406
- ?? process.env.CONTEXT_MODE_PROJECT_DIR;
407
- if (!projectDir)
408
- return undefined;
409
- const sessionsDir = opts?.sessionsDir ?? getSessionDir();
410
- const dbPath = resolveSessionDbPath({ projectDir, sessionsDir });
411
- if (!existsSync(dbPath))
412
- return undefined;
413
- const Database = loadDatabase();
414
- const db = new Database(dbPath, { readonly: true, fileMustExist: true });
415
- try {
416
- const row = db.prepare("SELECT session_id FROM session_events ORDER BY created_at DESC LIMIT 1").get();
417
- const sid = row?.session_id;
418
- if (sid)
419
- __cachedSessionId = { sid, checkedAt: now };
420
- return sid;
421
- }
422
- finally {
423
- try {
424
- db.close();
425
- }
426
- catch { /* best-effort */ }
427
- }
428
- }
429
- catch {
430
- return undefined;
431
- }
432
- }
433
- /**
434
- * Auto-index session events files written by SessionStart hook.
435
- * Scans ~/.claude/context-mode/sessions/ for *-events.md files.
436
- * CLAUDE_PROJECT_DIR is NOT available to MCP servers — only to hooks —
437
- * so we glob-scan instead of computing a specific hash.
438
- * Files are consumed (deleted) after indexing to prevent double-indexing.
439
- * Called on every getStore() — readdirSync is sub-millisecond when no files match.
440
- */
441
- function maybeIndexSessionEvents(store) {
442
- try {
443
- const sessionsDir = getSessionDir();
444
- if (!existsSync(sessionsDir))
445
- return;
446
- const files = readdirSync(sessionsDir).filter(f => f.endsWith("-events.md"));
447
- for (const file of files) {
448
- const filePath = join(sessionsDir, file);
449
- try {
450
- store.index({ path: filePath, source: "session-events", attribution: currentAttribution() });
451
- unlinkSync(filePath);
452
- }
453
- catch { /* best-effort per file */ }
454
- }
455
- }
456
- catch { /* best-effort — session continuity never blocks tools */ }
457
- }
458
- // ── Platform-aware paths ──────────────────────────────────────────────────
459
- // The adapter (stored after MCP handshake) is the canonical source for
460
- // platform-specific paths. All session DB paths go through it — no
461
- // hardcoded configDir detection in tool handlers.
462
- let _detectedAdapter = null;
463
- /**
464
- * Resolve the Claude Code config root, honoring `CLAUDE_CONFIG_DIR` (incl.
465
- * leading `~`) before falling back to `~/.claude`. Mirrors
466
- * `hooks/session-helpers.mjs::resolveConfigDir` and
467
- * `ClaudeCodeAdapter.getConfigDir` so the pre-detection path agrees with
468
- * hooks/adapter on where Claude Code session data lives. See issue #453.
469
- *
470
- * Issue #460 round-3: delegates to the canonical util so empty/whitespace
471
- * env values fall back instead of poisoning downstream `join()` calls.
472
- */
473
- async function getDiagnosticAdapter() {
474
- if (_detectedAdapter)
475
- return _detectedAdapter;
476
- try {
477
- const { getAdapter } = await import("./adapters/detect.js");
478
- const signal = detectPlatform();
479
- return await getAdapter(signal.platform);
480
- }
481
- catch {
482
- return null;
483
- }
484
- }
485
- /**
486
- * Get the platform-specific sessions directory from the detected adapter.
487
- * Falls back to the detected platform config root before adapter detection.
488
- */
489
- function getDefaultSessionDir() {
490
- if (_detectedAdapter)
491
- return _detectedAdapter.getSessionDir();
492
- // Pre-detection path (race window before MCP `initialize` completes):
493
- // call detectPlatform() (sync, env-var-based) and look up segments via
494
- // getSessionDirSegments() (sync map, no adapter instantiation). This keeps
495
- // non-Claude platforms from spilling sessions into ~/.claude/. For Claude
496
- // Code/Codex (single-segment roots), reroute through their config-dir
497
- // contracts so the pre-detection window does not split-state with hooks.
498
- try {
499
- const signal = detectPlatform();
500
- const segments = getSessionDirSegments(signal.platform);
501
- if (segments) {
502
- return resolveDefaultSessionDir({
503
- configDir: join(...segments),
504
- configDirEnv: configDirEnvForSessionSegments(segments),
505
- });
506
- }
507
- }
508
- catch { /* fall through to claude fallback */ }
509
- return resolveDefaultSessionDir({ configDir: ".claude", configDirEnv: "CLAUDE_CONFIG_DIR" });
510
- }
511
- function configDirEnvForSessionSegments(segments) {
512
- if (segments.length === 1 && segments[0] === ".claude")
513
- return "CLAUDE_CONFIG_DIR";
514
- if (segments.length === 1 && segments[0] === ".codex")
515
- return "CODEX_HOME";
516
- return undefined;
517
- }
518
- function getSessionDir() {
519
- return ensureWritableStorageDir(resolveSessionStorageDir(getDefaultSessionDir));
520
- }
521
- /**
522
- * Project directory detection across supported platforms.
523
- *
524
- * Priority:
525
- * 1. Platform-specific env var (set by host IDE before MCP server spawn)
526
- * 2. CONTEXT_MODE_PROJECT_DIR (set by start.mjs for ALL platforms — universal)
527
- * 3. process.cwd() (last resort)
528
- *
529
- * CONTEXT_MODE_PROJECT_DIR guarantees correct projectDir even for platforms
530
- * that don't set their own env var (Cursor, OpenClaw, Codex, Kiro, Zed).
531
- */
532
- export function getProjectDir() {
533
- const override = projectDirOverride.getStore();
534
- if (override)
535
- return override.projectDir;
536
- // Delegated to the shared resolver so the env-var chain rejects plugin
537
- // install paths (set by a prior MCP boot's start.mjs after `/ctx-upgrade`)
538
- // and prefers the shell-set PWD before the chdir'd cwd. v1.0.115 adds
539
- // the Claude Code transcript heuristic — read `cwd` from the most-recently-
540
- // modified `~/.claude/projects/<encoded>/<session>.jsonl` to recover the
541
- // real project dir when MCP was launched from a non-project cwd (desktop-
542
- // app launch, /ctx-upgrade respawn). See src/util/project-dir.ts.
543
- //
544
- // Issue #521 (v1.0.119): the transcript heuristic ONLY applies on Claude
545
- // Code. Other platforms (Cursor, OpenCode, Codex, ...) either have no
546
- // transcript at that path or use a different schema without `cwd`. Worse,
547
- // a Cursor user who also runs Claude Code would pick up the most-recently-
548
- // modified Claude Code session's cwd — wrong project entirely. Gate the
549
- // path on detected platform so non-Claude hosts skip the heuristic and
550
- // fall through to PWD/cwd cleanly.
551
- //
552
- // The Claude heuristic must also be fresh. Hosts such as Pi can be
553
- // misdetected as Claude Code solely because ~/.claude exists; without a
554
- // freshness guard an old Claude transcript can globally hijack ctx shell cwd
555
- // after reboot. Active Claude sessions update their transcript as the user
556
- // interacts, so stale transcripts should fall through to PWD/cwd.
557
- //
558
- // Issue #545 (v1.0.124): pass strictPlatform for ALL adapters so the
559
- // env-var cascade is built ALGORITHMICALLY from the platform's own
560
- // workspace vars + universal escape hatch — foreign workspace vars (e.g.
561
- // CLAUDE_PROJECT_DIR leaked into Pi's MCP child env from the user's shell)
562
- // cannot win, regardless of cascade order. start.mjs intentionally does
563
- // NOT pass strictPlatform — host detection is unreliable at the entrypoint
564
- // and the legacy literal cascade is preserved there for semver safety.
565
- let transcriptsRoot;
566
- let strictPlatform;
567
- let codexHome;
568
- try {
569
- const detected = detectPlatform().platform;
570
- strictPlatform = detected;
571
- if (detected === "claude-code") {
572
- transcriptsRoot = join(homedir(), ".claude", "projects");
573
- }
574
- // Issue #45 — Codex publishes no workspace env var, so the resolver
575
- // reads `meta.cwd` from the most-recently-modified session.jsonl under
576
- // `${codexHome}/sessions/`. Wire codexHome at the call site so the
577
- // resolver can be exercised under test without process-level mutation.
578
- if (detected === "codex") {
579
- codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex");
580
- }
581
- }
582
- catch { /* detection failure — leave undefined, resolver uses legacy cascade */ }
583
- return resolveProjectDir({
584
- env: process.env,
585
- cwd: process.cwd(),
586
- pwd: process.env.PWD,
587
- transcriptsRoot,
588
- transcriptMaxAgeMs: 5 * 60 * 1000,
589
- strictPlatform,
590
- codexHome,
591
- });
592
- }
593
- /**
594
- * Resolve a possibly-relative path against the project directory (full env cascade),
595
- * not the MCP server's process.cwd(). MCP server is spawned by the host and its cwd
596
- * is unrelated to where the user is working.
597
- */
598
- function resolveProjectPath(filePath) {
599
- return isAbsolute(filePath) ? filePath : resolve(getProjectDir(), filePath);
600
- }
601
- /**
602
- * Resolve the per-project SessionDB path. Delegates to
603
- * {@link resolveSessionDbPath} so casing-only variants of the same
604
- * physical worktree on macOS / Windows hit ONE DB, not two — and any
605
- * pre-existing legacy raw-casing DB gets migrated in place on first
606
- * resolve. Linux is a no-op.
607
- */
608
- function getSessionDbPath() {
609
- return resolveSessionDbPath({
610
- projectDir: getProjectDir(),
611
- sessionsDir: getSessionDir(),
612
- });
613
- }
614
- /**
615
- * Compute a per-project, per-platform persistent path for the ContentStore.
616
- * Derives content dir from the adapter's session dir so each platform
617
- * has its own isolated FTS5 DB — no cross-platform data sharing.
618
- *
619
- * Layout: ~/<configDir>/context-mode/content/<hash>.db
620
- * e.g. ~/.claude/context-mode/content/87c28c41ddb64d38.db
621
- * ~/.cursor/context-mode/content/87c28c41ddb64d38.db
622
- */
623
- function getStorePath() {
624
- const dir = ensureWritableStorageDir(resolveContentStorageDir(getDefaultSessionDir));
625
- // Delegate to resolveContentStorePath: same case-fold + one-shot legacy
626
- // rename behavior as resolveSessionDbPath. On macOS / Windows, an
627
- // existing legacy raw-casing FTS5 db (with -wal/-shm sidecars) is
628
- // migrated in place on first call. On Linux it's a no-op.
629
- return resolveContentStorePath({ projectDir: getProjectDir(), contentDir: dir });
630
- }
631
- function getStore() {
632
- if (!_store) {
633
- // Content DB cleanup on fresh start is handled by SessionStart hook.
634
- // Server just opens whatever DB exists (or creates new if hook deleted it).
635
- const dbPath = getStorePath();
636
- _store = new ContentStore(dbPath);
637
- // Wire deny-policy hook: store re-checks the Read deny list before
638
- // re-reading any file_path during auto-refresh. Catches policy edits
639
- // made after a file was originally indexed. See #442 round-3.
640
- _store.setDenyChecker((filePath) => {
641
- try {
642
- const projectDir = getProjectDir();
643
- const denyGlobs = readToolDenyPatterns("Read", projectDir);
644
- const r = evaluateFilePath(filePath, denyGlobs, process.platform === "win32", projectDir);
645
- return r.denied;
646
- }
647
- catch {
648
- // Fail-closed for refresh: skip on error rather than re-read.
649
- return true;
650
- }
651
- });
652
- // One-time startup cleanup: remove stale content DBs (>14 days)
653
- try {
654
- const contentDir = dirname(getStorePath());
655
- cleanupStaleContentDBs(contentDir, 14);
656
- _store.cleanupStaleSources(14);
657
- // Also clean legacy shared dir from before platform isolation
658
- const legacyDir = join(homedir(), ".context-mode", "content");
659
- if (existsSync(legacyDir))
660
- cleanupStaleContentDBs(legacyDir, 0);
661
- }
662
- catch { /* best-effort */ }
663
- // Also clean old PID-based DBs from migration
664
- cleanupStaleDBs();
665
- }
666
- maybeIndexSessionEvents(_store);
667
- return _store;
668
- }
669
- // ─────────────────────────────────────────────────────────
670
- // Session stats — track context consumption per tool
671
- // ─────────────────────────────────────────────────────────
672
- const sessionStats = {
673
- calls: {},
674
- bytesReturned: {},
675
- bytesIndexed: 0,
676
- bytesSandboxed: 0, // network I/O consumed inside sandbox (never enters context)
677
- cacheHits: 0,
678
- cacheMisses: 0, // ctx_fetch_and_index calls that bypassed the TTL cache
679
- cacheBytesSaved: 0, // bytes avoided by TTL cache hits
680
- sessionStart: Date.now(),
681
- };
682
- function storageErrorResult(err) {
683
- if (!(err instanceof StorageDirectoryError))
684
- return null;
685
- return {
686
- content: [{ type: "text", text: formatStorageDirectoryError(err) }],
687
- isError: true,
688
- };
689
- }
690
- // ── Version outdated warning ──────────────────────────────────────────────
691
- // Non-blocking npm check at startup. trackResponse prepends warning
692
- // using a burst cadence: 3 warnings → 1h silent → 3 warnings → repeat.
693
- let _latestVersion = null;
694
- let _warningBurstCount = 0;
695
- let _lastBurstStart = 0;
696
- const VERSION_BURST_SIZE = 3;
697
- const VERSION_SILENT_MS = 60 * 60 * 1000; // 1 hour
698
- async function fetchLatestVersion() {
699
- return new Promise((res) => {
700
- const req = httpsRequest("https://registry.npmjs.org/context-mode/latest", { headers: { Connection: "close" } }, (resp) => {
701
- let raw = "";
702
- resp.on("data", (chunk) => { raw += chunk; });
703
- resp.on("end", () => {
704
- try {
705
- const data = JSON.parse(raw);
706
- res(data.version ?? "unknown");
707
- }
708
- catch {
709
- res("unknown");
710
- }
711
- });
712
- });
713
- req.on("error", () => res("unknown"));
714
- req.setTimeout(5000, () => { req.destroy(); res("unknown"); });
715
- req.end();
716
- });
717
- }
718
- function getUpgradeHint() {
719
- const name = _detectedAdapter?.name;
720
- if (name === "Claude Code")
721
- return "/ctx-upgrade";
722
- if (name === "OpenClaw")
723
- return "npm run install:openclaw";
724
- if (name === "Pi")
725
- return "npm run build";
726
- return "npm update -g context-mode";
727
- }
728
- function semverNewer(a, b) {
729
- const pa = a.split(".").map(Number);
730
- const pb = b.split(".").map(Number);
731
- for (let i = 0; i < 3; i++) {
732
- if ((pa[i] ?? 0) > (pb[i] ?? 0))
733
- return true;
734
- if ((pa[i] ?? 0) < (pb[i] ?? 0))
735
- return false;
736
- }
737
- return false;
738
- }
739
- function isOutdated() {
740
- if (!_latestVersion || _latestVersion === "unknown")
741
- return false;
742
- return semverNewer(_latestVersion, VERSION);
743
- }
744
- function shouldShowVersionWarning() {
745
- if (!isOutdated())
746
- return false;
747
- const now = Date.now();
748
- // Start of a new burst?
749
- if (_warningBurstCount >= VERSION_BURST_SIZE) {
750
- if (now - _lastBurstStart < VERSION_SILENT_MS)
751
- return false; // still silent
752
- _warningBurstCount = 0; // silence over, reset burst
753
- }
754
- if (_warningBurstCount === 0)
755
- _lastBurstStart = now;
756
- _warningBurstCount++;
757
- return true;
758
- }
759
- // ── Self-heal Layer 2: Mid-session registry heal (anthropics/claude-code#46915) ──
760
- // Runs once on first tool call. If Claude Code auto-updated the registry mid-session,
761
- // hooks break because CLAUDE_PLUGIN_ROOT points to a deleted directory. We create a
762
- // symlink from the broken path to our actual directory so hooks recover.
763
- let _cacheHealDone = false;
764
- function healCacheMidSession() {
765
- if (_cacheHealDone)
766
- return;
767
- _cacheHealDone = true;
768
- try {
769
- // Issue #460 round-3: honor $CLAUDE_CONFIG_DIR so users who relocate
770
- // their CC config root don't have plugin cache healing operate against
771
- // the wrong tree (and silently miss dangling-symlink cleanup).
772
- const claudeRoot = resolveClaudeConfigDir();
773
- const ipPath = resolve(claudeRoot, "plugins", "installed_plugins.json");
774
- if (!existsSync(ipPath))
775
- return;
776
- const ip = JSON.parse(readFileSync(ipPath, "utf-8"));
777
- const cacheRoot = resolve(claudeRoot, "plugins", "cache");
778
- // Issue #795: canonicalize cacheRoot so the traversal guard works when
779
- // ~/.claude is a symlink to another volume. path.resolve() does not
780
- // dereference symlinks, so installPath values stored as physical paths
781
- // (e.g. /Volumes/SSD/.../plugins/cache/...) would fail the startsWith
782
- // check against a symlink-path cacheRoot (/Users/me/.claude/...).
783
- // realpathSync follows the symlink chain to the canonical location.
784
- let cacheRootCanon;
785
- try {
786
- cacheRootCanon = realpathSync(cacheRoot);
787
- }
788
- catch {
789
- cacheRootCanon = cacheRoot;
790
- }
791
- // Plugin root: build/ for tsc, plugin root for bundle
792
- const pluginRoot = getPackageRoot();
793
- for (const [key, entries] of Object.entries((ip.plugins ?? {}))) {
794
- if (key !== "context-mode@context-mode")
795
- continue;
796
- for (const entry of entries) {
797
- const rp = entry.installPath;
798
- if (!rp || existsSync(rp))
799
- continue;
800
- // Path traversal guard (canonical comparison — see #795)
801
- if (!resolve(rp).startsWith(cacheRootCanon + sep))
802
- continue;
803
- // Remove dangling symlink
804
- try {
805
- if (lstatSync(rp).isSymbolicLink())
806
- unlinkSync(rp);
807
- }
808
- catch { }
809
- const parent = dirname(rp);
810
- if (!existsSync(parent))
811
- mkdirSync(parent, { recursive: true });
812
- if (existsSync(pluginRoot)) {
813
- symlinkSync(pluginRoot, rp, process.platform === "win32" ? "junction" : undefined);
814
- }
815
- }
816
- }
817
- }
818
- catch { /* best effort */ }
819
- }
820
- function trackResponse(toolName, response) {
821
- // #854: a response is activity too — refresh the bridge-child idle clock so a
822
- // chatty/streaming call keeps its server alive even between inbound frames.
823
- noteMcpActivity();
824
- // Mid-session cache heal — one-shot, first tool call
825
- healCacheMidSession();
826
- // Prepend version outdated warning if needed
827
- if (shouldShowVersionWarning() && response.content.length > 0) {
828
- const hint = getUpgradeHint();
829
- response.content[0].text =
830
- `⚠️ context-mode v${VERSION} outdated → v${_latestVersion} available. Upgrade: ${hint}\n\n` +
831
- response.content[0].text;
832
- }
833
- const bytes = response.content.reduce((sum, c) => sum + Buffer.byteLength(c.text), 0);
834
- sessionStats.calls[toolName] = (sessionStats.calls[toolName] || 0) + 1;
835
- sessionStats.bytesReturned[toolName] =
836
- (sessionStats.bytesReturned[toolName] || 0) + bytes;
837
- // Persist a sidecar JSON snapshot for the statusline — read at ~3-5 Hz by
838
- // bin/statusline.mjs (and any external dashboard) so they don't have to
839
- // open the SQLite database. Throttled inside persistStats() (500ms) so
840
- // it's safe to call on every response.
841
- persistStats();
842
- // Persist to SessionDB so counters survive process restart, --continue,
843
- // upgrade. Re-introduces the write path 4742160 added and b392c2f dropped.
844
- // setImmediate keeps this off the response hot path; the helper itself
845
- // is best-effort (never throws).
846
- setImmediate(() => persistToolCallCounter(getSessionDbPath(), toolName, bytes));
847
- // D2 Phase 5/7 — sandbox-execute event emission. Tracks the bytes the
848
- // user actually saw from sandboxed runs so getRealBytesStats() can
849
- // replace the conservative `events × 256` estimate. Best-effort and
850
- // off the hot path, same shape as persistToolCallCounter above.
851
- if (toolName === "ctx_execute"
852
- || toolName === "ctx_execute_file"
853
- || toolName === "ctx_batch_execute") {
854
- setImmediate(() => emitSandboxExecuteEvent({
855
- sessionDbPath: getSessionDbPath(),
856
- toolName,
857
- bytesReturned: bytes,
858
- }));
859
- }
860
- return response;
861
- }
862
- function trackIndexed(bytes, source = "unknown") {
863
- sessionStats.bytesIndexed += bytes;
864
- persistStats();
865
- // D2 Phase 5/7 — index-write event emission. `bytes_avoided` because
866
- // these are bytes that would have flooded context if the user had
867
- // Read'd the source instead of indexing.
868
- if (bytes > 0) {
869
- setImmediate(() => emitIndexWriteEvent({
870
- sessionDbPath: getSessionDbPath(),
871
- source,
872
- bytesAvoided: bytes,
873
- }));
874
- }
875
- }
876
- // ─────────────────────────────────────────────────────────
877
- // Stats persistence — written after every tool call so
878
- // external readers (status line scripts, dashboards, hooks)
879
- // can see real-time savings without spawning an MCP client.
880
- // ─────────────────────────────────────────────────────────
881
- const STATS_PERSIST_THROTTLE_MS = 500;
882
- // Schema version for the persisted stats payload (~/.claude/context-mode/sessions/stats-*.json).
883
- // Bump when a field is added/renamed/removed. Statusline reads `schemaVersion ?? 0` and warns when
884
- // it sees a future schema, so legacy bundles degrade gracefully on upgrade rather than silently
885
- // rendering missing fields (PR #401 architect review P1.3).
886
- // v2: added tokens_saved_lifetime + dollars_saved_lifetime.
887
- const STATS_SCHEMA_VERSION = 2;
888
- // pricePerToken() intentionally NOT defined here — single source in
889
- // src/session/analytics.ts re-exported above. (P1.1 — pricing constant dedup,
890
- // PR #401 architect + ops 2-vote convergence.)
891
- const LIFETIME_REFRESH_MS = 30_000;
892
- // Matches the conversion factor in src/session/analytics.ts renderBottomLine:
893
- // ~1KB per session event ÷ 4 bytes/token = 256 tokens/event.
894
- const TOKENS_PER_EVENT = 256;
895
- let _lastStatsPersist = 0;
896
- let _lifetimeCache;
897
- /**
898
- * Resolve the per-session stats file path.
899
- *
900
- * The session id mirrors the Claude Code adapter contract
901
- * (`pid-<parent pid>`), so a status line script can derive
902
- * the same id from `$PPID` without coupling to MCP.
903
- */
904
- // CLAUDE_SESSION_ID flows from the hosting process (Claude Code, pi, etc.)
905
- // straight into a path.join, and path.join collapses ".." into the result,
906
- // so a host env CLAUDE_SESSION_ID=../../evil writes "stats-evil.json" two
907
- // levels above statsDir. The env var is not under direct MCP-tool-caller
908
- // control, but in CI / multi-tenant contexts where the host env is partly
909
- // influenceable this is an arbitrary-write primitive within the MCP server
910
- // process's filesystem permissions. Constrain to a UUID-shaped charset
911
- // before splicing into the stats filename.
912
- const SESSION_ID_RE = /^[A-Za-z0-9._-]+$/;
913
- function sanitizeSessionId(raw) {
914
- return SESSION_ID_RE.test(raw) ? raw : `pid-${process.ppid}`;
915
- }
916
- function getStatsFilePath() {
917
- const raw = process.env.CLAUDE_SESSION_ID || `pid-${process.ppid}`;
918
- const sessionId = sanitizeSessionId(raw);
919
- const statsDir = ensureWritableStorageDir(resolveStatsStorageDir(getDefaultSessionDir));
920
- return join(statsDir, `stats-${sessionId}.json`);
921
- }
922
- function persistStats() {
923
- const now = Date.now();
924
- if (now - _lastStatsPersist < STATS_PERSIST_THROTTLE_MS)
925
- return;
926
- _lastStatsPersist = now;
927
- try {
928
- const totalReturned = Object.values(sessionStats.bytesReturned).reduce((a, b) => a + b, 0);
929
- const totalCalls = Object.values(sessionStats.calls).reduce((a, b) => a + b, 0);
930
- const keptOut = sessionStats.bytesIndexed +
931
- sessionStats.bytesSandboxed +
932
- sessionStats.cacheBytesSaved;
933
- const totalProcessed = keptOut + totalReturned;
934
- const reductionPct = totalProcessed > 0
935
- ? Math.round((1 - totalReturned / totalProcessed) * 100)
936
- : 0;
937
- const tokensSaved = Math.round(keptOut / 4);
938
- // Lifetime savings — cached separately because getLifetimeStats() scans
939
- // disk (per-project SessionDBs + auto-memory dirs) and is too expensive
940
- // for the 500ms persist throttle. Refresh every 30s; the statusline
941
- // doesn't need second-by-second lifetime accuracy.
942
- let lifetimeTokens = _lifetimeCache?.tokens ?? 0;
943
- if (!_lifetimeCache || now - _lifetimeCache.computedAt > LIFETIME_REFRESH_MS) {
944
- try {
945
- const life = getLifetimeStats({ sessionsDir: getSessionDir() });
946
- lifetimeTokens = (life?.totalEvents ?? 0) * TOKENS_PER_EVENT;
947
- _lifetimeCache = { tokens: lifetimeTokens, computedAt: now };
948
- }
949
- catch {
950
- // best-effort — keep stale cache or 0
951
- }
952
- }
953
- const payload = {
954
- schemaVersion: STATS_SCHEMA_VERSION,
955
- version: VERSION,
956
- updated_at: now,
957
- session_start: sessionStats.sessionStart,
958
- uptime_ms: now - sessionStats.sessionStart,
959
- total_calls: totalCalls,
960
- bytes_returned: totalReturned,
961
- bytes_indexed: sessionStats.bytesIndexed,
962
- bytes_sandboxed: sessionStats.bytesSandboxed,
963
- cache_hits: sessionStats.cacheHits,
964
- cache_bytes_saved: sessionStats.cacheBytesSaved,
965
- kept_out: keptOut,
966
- total_processed: totalProcessed,
967
- reduction_pct: reductionPct,
968
- tokens_saved: tokensSaved,
969
- // statusline-facing $ values — pre-computed at the current per-token
970
- // rate (dynamic when PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN is set by a
971
- // Pi host; Opus $15/1M otherwise). Resolved on every persist via
972
- // pricePerToken() so the env override picks up without an MCP restart.
973
- dollars_saved_session: +(tokensSaved * pricePerToken()).toFixed(2),
974
- tokens_saved_lifetime: lifetimeTokens,
975
- dollars_saved_lifetime: +(lifetimeTokens * pricePerToken()).toFixed(2),
976
- by_tool: Object.fromEntries(Object.keys({ ...sessionStats.calls, ...sessionStats.bytesReturned }).map((t) => [
977
- t,
978
- {
979
- calls: sessionStats.calls[t] || 0,
980
- bytes: sessionStats.bytesReturned[t] || 0,
981
- },
982
- ])),
983
- };
984
- const filePath = getStatsFilePath();
985
- const tmpPath = `${filePath}.tmp`;
986
- writeFileSync(tmpPath, JSON.stringify(payload));
987
- renameSync(tmpPath, filePath);
988
- }
989
- catch {
990
- // best-effort — never break tool calls because of stats persistence
991
- }
992
- }
993
- // ==============================================================================
994
- // Security: server-side deny firewall
995
- // ==============================================================================
996
- /**
997
- * Check a shell command against Bash deny patterns.
998
- * Returns an error ToolResult if denied, or null if allowed.
999
- */
1000
- function checkDenyPolicy(command, toolName) {
1001
- try {
1002
- const policies = readBashPolicies(process.env.CLAUDE_PROJECT_DIR);
1003
- const result = evaluateCommandDenyOnly(command, policies);
1004
- if (result.decision === "deny") {
1005
- return trackResponse(toolName, {
1006
- content: [{
1007
- type: "text",
1008
- text: `Command blocked by security policy: matches deny pattern ${result.matchedPattern}`,
1009
- }],
1010
- isError: true,
1011
- });
1012
- }
1013
- }
1014
- catch {
1015
- // Security check failed — allow through (fail-open for server,
1016
- // hooks are the primary enforcement layer)
1017
- }
1018
- return null;
1019
- }
1020
- /**
1021
- * Check non-shell code for shell-escape calls against deny patterns.
1022
- */
1023
- function checkNonShellDenyPolicy(code, language, toolName) {
1024
- try {
1025
- const commands = extractShellCommands(code, language);
1026
- if (commands.length === 0)
1027
- return null;
1028
- const policies = readBashPolicies(process.env.CLAUDE_PROJECT_DIR);
1029
- for (const cmd of commands) {
1030
- const result = evaluateCommandDenyOnly(cmd, policies);
1031
- if (result.decision === "deny") {
1032
- return trackResponse(toolName, {
1033
- content: [{
1034
- type: "text",
1035
- text: `Command blocked by security policy: embedded shell command "${cmd}" matches deny pattern ${result.matchedPattern}`,
1036
- }],
1037
- isError: true,
1038
- });
1039
- }
1040
- }
1041
- }
1042
- catch {
1043
- // Fail-open
1044
- }
1045
- return null;
1046
- }
1047
- /**
1048
- * Issue #852 — project-boundary containment for `ctx_execute_file`.
1049
- *
1050
- * The harness sandbox (Claude Code, etc.) cannot inspect MCP input params, so a
1051
- * user approving a `ctx_execute_file` call cannot see that its `path` escapes
1052
- * the workspace. This guard refuses a `path` that resolves outside the project
1053
- * root (absolute escape, `../` traversal, or symlink-out), restoring the
1054
- * boundary the host believes it is enforcing.
1055
- *
1056
- * Escape hatch — NO bespoke opt-out env. A deliberate out-of-project read is
1057
- * expressed in the SAME host config the user already maintains: a
1058
- * `permissions.allow` rule like `Read(/var/log/**)`. This reuses the exact
1059
- * mechanism Claude Code uses to whitelist a path outside its sandbox, so the
1060
- * grant lives in one place and stays meaningful instead of rotting into a
1061
- * context-mode-only env flag nobody sets.
1062
- *
1063
- * Fail-open on resolver failure (consistent with the other deny checks): if the
1064
- * project root cannot be resolved, containment evaluates as "inside" and the
1065
- * path is allowed through rather than spuriously blocking legitimate work.
1066
- */
1067
- function checkProjectBoundary(filePath, toolName) {
1068
- try {
1069
- const projectDir = getProjectDir();
1070
- const allowGlobs = readToolPermissionPatterns("Read", "allow", projectDir);
1071
- const verdict = evaluateProjectContainment(filePath, projectDir, allowGlobs);
1072
- if (verdict.allowed)
1073
- return null;
1074
- return trackResponse(toolName, {
1075
- content: [{
1076
- type: "text",
1077
- text: `File access blocked: "${filePath}" resolves outside the project root ` +
1078
- `(${projectDir}). context-mode confines ${toolName} to the workspace so it ` +
1079
- `cannot be used to bypass the host's sandbox/permission controls (issue #852). ` +
1080
- `To intentionally process a file outside the project, add a host allow rule, ` +
1081
- `e.g. "permissions": { "allow": ["Read(${filePath})"] } in your settings.`,
1082
- }],
1083
- isError: true,
1084
- });
1085
- }
1086
- catch {
1087
- // Fail-open — resolver failure must not block legitimate in-project work.
1088
- }
1089
- return null;
1090
- }
1091
- /**
1092
- * Check a file path against Read deny patterns.
1093
- * Returns an error ToolResult if denied, or null if allowed.
1094
- */
1095
- function checkFilePathDenyPolicy(filePath, toolName) {
1096
- try {
1097
- const projectDir = getProjectDir();
1098
- const denyGlobs = readToolDenyPatterns("Read", projectDir);
1099
- const result = evaluateFilePath(filePath, denyGlobs, process.platform === "win32", projectDir);
1100
- if (result.denied) {
1101
- return trackResponse(toolName, {
1102
- content: [{
1103
- type: "text",
1104
- text: `File access blocked by security policy: path matches Read deny pattern ${result.matchedPattern}`,
1105
- }],
1106
- isError: true,
1107
- });
1108
- }
1109
- }
1110
- catch {
1111
- // Fail-open
1112
- }
1113
- return null;
1114
- }
1115
- // Build description dynamically based on detected runtimes
1116
- const langList = available.join(", ");
1117
- const bunNote = hasBunRuntime()
1118
- ? " (Bun detected — JS/TS runs 3-5x faster)"
1119
- : "";
1120
- // ─────────────────────────────────────────────────────────
1121
- // Helper: smart snippet extraction — returns windows around
1122
- // matching query terms instead of dumb truncation
1123
- //
1124
- // When `highlighted` is provided (from FTS5 `highlight()` with
1125
- // STX/ETX markers), match positions are derived from the markers.
1126
- // This is the authoritative source — FTS5 uses the exact same
1127
- // tokenizer that produced the BM25 match, so stemmed variants
1128
- // like "configuration" matching query "configure" are found
1129
- // correctly. Falls back to indexOf on raw terms when highlighted
1130
- // is absent (non-FTS codepath).
1131
- // ─────────────────────────────────────────────────────────
1132
- const STX = "\x02";
1133
- const ETX = "\x03";
1134
- /**
1135
- * Parse FTS5 highlight markers to find match positions in the
1136
- * original (marker-free) text. Returns character offsets into the
1137
- * stripped content where each matched token begins.
1138
- */
1139
- export function positionsFromHighlight(highlighted) {
1140
- const positions = [];
1141
- let cleanOffset = 0;
1142
- let i = 0;
1143
- while (i < highlighted.length) {
1144
- if (highlighted[i] === STX) {
1145
- // Record position of this match in the clean text
1146
- positions.push(cleanOffset);
1147
- i++; // skip STX
1148
- // Advance through matched text until ETX
1149
- while (i < highlighted.length && highlighted[i] !== ETX) {
1150
- cleanOffset++;
1151
- i++;
1152
- }
1153
- if (i < highlighted.length)
1154
- i++; // skip ETX
1155
- }
1156
- else {
1157
- cleanOffset++;
1158
- i++;
1159
- }
1160
- }
1161
- return positions;
1162
- }
1163
- /** Strip STX/ETX markers to recover original content. */
1164
- function stripMarkers(highlighted) {
1165
- return highlighted.replaceAll(STX, "").replaceAll(ETX, "");
1166
- }
1167
- export function extractSnippet(content, query, maxLen = 1500, highlighted) {
1168
- if (content.length <= maxLen)
1169
- return content;
1170
- // Derive match positions from FTS5 highlight markers when available
1171
- const positions = [];
1172
- if (highlighted) {
1173
- for (const pos of positionsFromHighlight(highlighted)) {
1174
- positions.push(pos);
1175
- }
1176
- }
1177
- // Fallback: indexOf on raw query terms (non-FTS codepath)
1178
- if (positions.length === 0) {
1179
- const terms = query
1180
- .toLowerCase()
1181
- .split(/\s+/)
1182
- .filter((t) => t.length > 2);
1183
- const lower = content.toLowerCase();
1184
- for (const term of terms) {
1185
- let idx = lower.indexOf(term);
1186
- while (idx !== -1) {
1187
- positions.push(idx);
1188
- idx = lower.indexOf(term, idx + 1);
1189
- }
1190
- }
1191
- }
1192
- // No matches at all — return prefix
1193
- if (positions.length === 0) {
1194
- return content.slice(0, maxLen) + "\n…";
1195
- }
1196
- // Sort positions, merge overlapping windows
1197
- positions.sort((a, b) => a - b);
1198
- const WINDOW = 300;
1199
- const windows = [];
1200
- for (const pos of positions) {
1201
- const start = Math.max(0, pos - WINDOW);
1202
- const end = Math.min(content.length, pos + WINDOW);
1203
- if (windows.length > 0 && start <= windows[windows.length - 1][1]) {
1204
- windows[windows.length - 1][1] = end;
1205
- }
1206
- else {
1207
- windows.push([start, end]);
1208
- }
1209
- }
1210
- // Collect windows until maxLen
1211
- const parts = [];
1212
- let total = 0;
1213
- for (const [start, end] of windows) {
1214
- if (total >= maxLen)
1215
- break;
1216
- const part = content.slice(start, Math.min(end, start + (maxLen - total)));
1217
- parts.push((start > 0 ? "…" : "") + part + (end < content.length ? "…" : ""));
1218
- total += part.length;
1219
- }
1220
- return parts.join("\n\n");
1221
- }
1222
- export function formatBatchQueryResults(store, queries, source, maxOutput = 80 * 1024, scope = "batch") {
1223
- const sections = [];
1224
- let outputSize = 0;
1225
- // When scope is "global", searchWithFallback receives `undefined` for the
1226
- // source filter, which makes it query the entire persistent index instead
1227
- // of only the chunks just produced by this batch's commands. Default
1228
- // remains "batch" to preserve the historical behavior.
1229
- const searchSource = scope === "global" ? undefined : source;
1230
- for (const query of queries) {
1231
- if (outputSize > maxOutput) {
1232
- sections.push(`## ${query}\n(output cap reached — use ctx_search(queries: ["${query}"]) for details)\n`);
1233
- continue;
1234
- }
1235
- const results = store.searchWithFallback(query, 3, searchSource, undefined, "exact");
1236
- sections.push(`## ${query}`);
1237
- sections.push("");
1238
- if (results.length > 0) {
1239
- for (const result of results) {
1240
- const snippet = extractSnippet(result.content, query, 3000, result.highlighted);
1241
- sections.push(`### ${result.title}`);
1242
- sections.push(snippet);
1243
- sections.push("");
1244
- outputSize += snippet.length + result.title.length;
1245
- }
1246
- continue;
1247
- }
1248
- sections.push("No matching sections found.");
1249
- sections.push("");
1250
- }
1251
- if (scope === "global") {
1252
- sections.push(`\n> **Scope:** Queries searched the entire persistent index (query_scope: "global").`);
1253
- }
1254
- else {
1255
- sections.push(`\n> **Tip:** Results are scoped to this batch only. To search across all indexed sources, use \`ctx_search(queries: [...])\` or call ctx_batch_execute with \`query_scope: "global"\`.`);
1256
- }
1257
- return sections;
1258
- }
1259
- function quotePosixSingle(value) {
1260
- return `'${value.replace(/'/g, "'\\''")}'`;
1261
- }
1262
- function quotePowerShellSingle(value) {
1263
- return `'${value.replace(/'/g, "''")}'`;
1264
- }
1265
- export function buildBatchNodeOptionsPrefix(shellPath, preloadPath) {
1266
- const option = `--require ${preloadPath}`;
1267
- const shell = shellPath.toLowerCase();
1268
- const base = shell.split(/[\\/]/).pop() ?? shell;
1269
- if (shell.includes("powershell") || shell.includes("pwsh")) {
1270
- return `$env:NODE_OPTIONS=${quotePowerShellSingle(option)}; `;
1271
- }
1272
- if (base === "cmd" || base === "cmd.exe") {
1273
- return `set "NODE_OPTIONS=${option.replace(/"/g, '""')}" && `;
1274
- }
1275
- return `NODE_OPTIONS=${quotePosixSingle(option)} `;
1276
- }
1277
- /**
1278
- * Per-section budget for the echoed `$ <command>` line so a 50KB heredoc
1279
- * payload cannot dominate the response body. The full command always reaches
1280
- * the executor — only the echo is clipped (Issues #717 + #736).
1281
- */
1282
- const COMMAND_ECHO_MAX = 500;
1283
- function truncateCommandForEcho(command) {
1284
- const cleaned = command.replace(/\s+/g, " ").trim();
1285
- if (cleaned.length <= COMMAND_ECHO_MAX)
1286
- return cleaned;
1287
- return cleaned.slice(0, COMMAND_ECHO_MAX) + "…";
1288
- }
1289
- /**
1290
- * Default execution timeout (ms) applied ONLY under Antigravity CLI (`agy`).
1291
- * agy does not enforce an MCP RPC timeout, so a ctx_execute with a runaway or
1292
- * blocking script hangs forever — the host never kills it and the user must
1293
- * interrupt. Every other host enforces its own RPC timeout, so we keep the
1294
- * no-server-timer behavior there (Issue #406 — long builds need an unbounded
1295
- * run). A caller can still pass an explicit `timeout` to override on any host.
1296
- */
1297
- export const AGY_DEFAULT_EXEC_TIMEOUT_MS = 120_000;
1298
- export function resolveExecTimeout(timeout) {
1299
- if (timeout !== undefined)
1300
- return timeout;
1301
- // Only agy gets a default — every other host enforces its own RPC timeout, so
1302
- // keep the unbounded behavior there. Detected via the env the agy bundle pins
1303
- // (CONTEXT_MODE_PLATFORM=antigravity-cli). Tunable via CONTEXT_MODE_AGY_EXEC_TIMEOUT_MS.
1304
- if (detectPlatform().platform !== "antigravity-cli")
1305
- return undefined;
1306
- const override = Number(process.env.CONTEXT_MODE_AGY_EXEC_TIMEOUT_MS);
1307
- return Number.isFinite(override) && override > 0 ? override : AGY_DEFAULT_EXEC_TIMEOUT_MS;
1308
- }
1309
- /**
1310
- * Per-call budget for the source-code echo prepended by `ctx_execute` and
1311
- * `ctx_execute_file` (Issues #717 + #736). The full code always reaches the
1312
- * sandbox — only the echo is clipped so massive payloads don't dominate
1313
- * the response. Multi-line preserved (unlike command echo) so the user
1314
- * sees the actual program shape.
1315
- */
1316
- const CODE_ECHO_MAX = 2000;
1317
- function truncateCodeForEcho(code) {
1318
- if (code.length <= CODE_ECHO_MAX)
1319
- return code;
1320
- return code.slice(0, CODE_ECHO_MAX) + "\n… (truncated)";
1321
- }
1322
- /**
1323
- * Build the source-code preamble surfaced before tool stdout. Provenance
1324
- * survives in indexed chunks (FTS5 sees the fenced block) so later
1325
- * ctx_search hits remember what ran.
1326
- */
1327
- function buildExecuteEcho(language, code, path) {
1328
- const header = path ? `path=${path}\n` : "";
1329
- const fenced = `\`\`\`${language}\n${truncateCodeForEcho(code)}\n\`\`\``;
1330
- return `${header}${fenced}\n\n`;
1331
- }
1332
- function formatCommandOutput(label, command, raw, onFsBytes) {
1333
- let output = raw || "(no output)";
1334
- const fsMatches = output.matchAll(/__CM_FS__:(\d+)/g);
1335
- let cmdFsBytes = 0;
1336
- for (const m of fsMatches)
1337
- cmdFsBytes += parseInt(m[1]);
1338
- if (cmdFsBytes > 0) {
1339
- onFsBytes?.(cmdFsBytes);
1340
- output = output.replace(/__CM_FS__:\d+\n?/g, "");
1341
- }
1342
- // Echo the executed command below the section heading so per-chunk
1343
- // indexed content retains provenance for later ctx_search hits
1344
- // (Issues #717 + #736).
1345
- const echoed = truncateCommandForEcho(command);
1346
- return `# ${label}\n\n$ ${echoed}\n\n${output}\n`;
1347
- }
1348
- function combineExecOutput(result) {
1349
- const stdout = result.stdout || "";
1350
- const stderr = result.stderr || "";
1351
- if (!stderr)
1352
- return stdout;
1353
- if (!stdout)
1354
- return stderr;
1355
- return `${stdout}${stdout.endsWith("\n") ? "" : "\n"}${stderr}`;
1356
- }
1357
- /**
1358
- * Execute batch commands. concurrency=1 preserves the legacy serial path
1359
- * (shared timeout budget + cascading skip-on-timeout). concurrency>1 runs
1360
- * commands concurrently with at most N in flight; each command receives the
1361
- * full timeout, output is collated by input index, and per-command timeouts
1362
- * record `(timed out)` blocks without skipping siblings.
1363
- */
1364
- export async function runBatchCommands(commands, opts, executor) {
1365
- const { timeout, concurrency, nodeOptsPrefix, cwd, onFsBytes } = opts;
1366
- if (concurrency <= 1) {
1367
- // Serial path — shared timeout budget, cascading skip on timeout.
1368
- // When `timeout` is undefined, no shared budget is enforced; each
1369
- // command runs to completion (Issue #406).
1370
- const outputs = [];
1371
- const startTime = Date.now();
1372
- let timedOut = false;
1373
- for (let i = 0; i < commands.length; i++) {
1374
- const cmd = commands[i];
1375
- let perCmdTimeout;
1376
- if (timeout !== undefined) {
1377
- const elapsed = Date.now() - startTime;
1378
- const remaining = timeout - elapsed;
1379
- if (remaining <= 0) {
1380
- outputs.push(`# ${cmd.label}\n\n(skipped — batch timeout exceeded)\n`);
1381
- timedOut = true;
1382
- continue;
1383
- }
1384
- perCmdTimeout = remaining;
1385
- }
1386
- const result = await executor.execute({
1387
- language: "shell",
1388
- code: `${nodeOptsPrefix}${cmd.command}`,
1389
- timeout: perCmdTimeout,
1390
- cwd,
1391
- });
1392
- outputs.push(formatCommandOutput(cmd.label, cmd.command, combineExecOutput(result), onFsBytes));
1393
- if (result.timedOut) {
1394
- timedOut = true;
1395
- for (let j = i + 1; j < commands.length; j++) {
1396
- outputs.push(`# ${commands[j].label}\n\n(skipped — batch timeout exceeded)\n`);
1397
- }
1398
- break;
1399
- }
1400
- }
1401
- return { outputs, timedOut };
1402
- }
1403
- // Parallel path — delegated to the shared runPool primitive.
1404
- // Each job returns { output, timedOut }; runPool handles in-flight cap,
1405
- // throw isolation (Promise.allSettled semantics), and order preservation.
1406
- const jobs = commands.map((cmd) => ({
1407
- run: async () => {
1408
- const result = await executor.execute({
1409
- language: "shell",
1410
- code: `${nodeOptsPrefix}${cmd.command}`,
1411
- timeout,
1412
- cwd,
1413
- });
1414
- // Always route partial output through formatCommandOutput so __CM_FS__
1415
- // markers are stripped + counted, even when the command timed out.
1416
- const formatted = formatCommandOutput(cmd.label, cmd.command, combineExecOutput(result), onFsBytes);
1417
- const output = result.timedOut
1418
- ? formatted.replace(/\n$/, "") + `\n(timed out after ${timeout ?? "?"}ms)\n`
1419
- : formatted;
1420
- return { output, timedOut: !!result.timedOut };
1421
- },
1422
- }));
1423
- const { settled } = await runPool(jobs, { concurrency });
1424
- const outputs = new Array(commands.length);
1425
- let timedOut = false;
1426
- for (let i = 0; i < settled.length; i++) {
1427
- const r = settled[i];
1428
- if (r.status === "fulfilled") {
1429
- outputs[i] = r.value.output;
1430
- if (r.value.timedOut)
1431
- timedOut = true;
1432
- }
1433
- else {
1434
- // Isolated executor throw (spawn EAGAIN, ENOMEM, EMFILE, …) — siblings keep running.
1435
- const message = r.reason instanceof Error ? r.reason.message : String(r.reason);
1436
- outputs[i] = `# ${commands[i].label}\n\n(executor error: ${message})\n`;
1437
- }
1438
- }
1439
- return { outputs, timedOut };
1440
- }
1441
- // ─────────────────────────────────────────────────────────
1442
- // Tool: execute
1443
- // ─────────────────────────────────────────────────────────
1444
- server.registerTool("ctx_execute", {
1445
- // #852: surface code execution in the host approval prompt's title (the
1446
- // only server-controlled field the MCP permission UI renders besides args).
1447
- title: "Run code in a sandbox (executes the supplied code)",
1448
- // #846: runs arbitrary code in a sandbox with full network access.
1449
- annotations: {
1450
- readOnlyHint: false,
1451
- destructiveHint: true,
1452
- idempotentHint: false,
1453
- openWorldHint: true,
1454
- },
1455
- description: `Run code in a sandboxed subprocess.${bunNote} Languages: ${langList}.
1456
-
1457
- Think-in-Code — the core philosophy: the bytes your code processes never enter your conversation memory; only what you console.log() does. Reading a 700 KB log directly means 700 KB of your remaining reasoning capacity gets spent on raw bytes. Running code over that same log in this sandbox and printing a 3 KB summary leaves you with 697 KB of capacity for the actual work.
1458
-
1459
- Concrete shape — analyze 47 source files without reading any of them:
1460
- ctx_execute(language: "javascript", code: \`
1461
- const fs = require('fs');
1462
- const files = fs.readdirSync('src').filter(f => f.endsWith('.ts'));
1463
- files.forEach(f => {
1464
- const lines = fs.readFileSync('src/'+f,'utf8').split('\\\\n').length;
1465
- console.log(f + ': ' + lines + ' lines');
1466
- });
1467
- \`)
1468
- // 47 files analyzed, 15,314 LoC summarized — output ~3.6 KB instead of 47 Read() calls = ~700 KB.
1469
-
1470
- WHEN:
1471
- - You intend to derive an answer FROM data (filter, count, aggregate, parse, compare, transform) — do the derivation in code and print only the answer
1472
- - Output shape or size cannot be predicted before execution (recursive finds, repo-wide greps, list endpoints, query results, log scans)
1473
- - You would otherwise read raw output and then mentally compute — that compute belongs here, in code, where its inputs stay out of your conversation
1474
- - You need to keep a long-running process alive (dev server, watcher, daemon) — pass \`background: true\` to detach on timeout instead of killing the process
1475
- - The output may legitimately be large but you only want recall-by-topic later — pass an \`intent\` string; outputs over ~5KB are auto-indexed into the knowledge base and only the section titles + previews come back, retrievable via ctx_search
1476
-
1477
- WHEN NOT:
1478
- - Single observational command whose entire short output you intend to consume verbatim (whoami, pwd, git status on a clean tree) — Bash is simpler
1479
- - File mutations (Edit/Write) or navigation (cd/ls) — Bash is the right surface
1480
- - You already know the output is one short fixed line and you want to read it as-is
1481
-
1482
- RETURNS:
1483
- Only what your code prints. Wrap risky calls in try/catch — uncaught errors go to stderr and may leak more than intended. When \`intent\` is set and output exceeds the auto-index threshold, the response carries searchable section titles + previews instead of the raw stdout; use ctx_search(queries: [...]) to drill into specific sections.
1484
-
1485
- EXAMPLE: ctx_execute(language: "javascript", code: "const out = require('child_process').execSync('npm test', {encoding:'utf8', stdio:['ignore','pipe','pipe']}); console.log(out.split('\\\\n').filter(l => /(FAIL|✗|×|Error:|Tests +.*(failed|passed))/i.test(l)).slice(0, 60).join('\\\\n'))")
1486
- EXAMPLE: ctx_execute(language: "javascript", code: "const out = require('child_process').execSync('gh issue list --json number,title --limit 100', {encoding:'utf8'}); const hooks = JSON.parse(out).filter(i => /hook|routing/i.test(i.title)); console.log(\`\${hooks.length} hook-related issues\`)")`,
1487
- inputSchema: z.object({
1488
- language: z
1489
- .enum([
1490
- "javascript",
1491
- "typescript",
1492
- "python",
1493
- "shell",
1494
- "ruby",
1495
- "go",
1496
- "rust",
1497
- "php",
1498
- "perl",
1499
- "r",
1500
- "elixir",
1501
- "csharp",
1502
- ])
1503
- .describe("Runtime language"),
1504
- code: z
1505
- .string()
1506
- .describe("Source code to execute. Use console.log (JS/TS), print (Python/Ruby/Perl/R), echo (Shell), echo (PHP), fmt.Println (Go), IO.puts (Elixir), or Console.WriteLine (C#) to output a summary to context."),
1507
- timeout: z
1508
- .coerce.number()
1509
- .optional()
1510
- .describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs (which is the right layer for this policy). Pass an explicit value for long-running builds (Gradle/Maven/SBT)."),
1511
- // background: wrapped in coerceBoolean preprocessor so the literal
1512
- // strings "true"/"false" arriving from OpenCode's native plugin
1513
- // bridge (and several LLM providers' tool-call JSON) parse as the
1514
- // boolean the handler expects. z.coerce.boolean() is unsafe here —
1515
- // Boolean("false") is true. Fixes #627.
1516
- background: z
1517
- .preprocess(coerceBoolean, z.boolean())
1518
- .optional()
1519
- .default(false)
1520
- .describe("Keep process running after timeout (for servers/daemons). Returns partial output without killing the process. IMPORTANT: Do NOT add setTimeout/self-close timers in background scripts — the process must stay alive until the timeout detaches it. For server+fetch patterns, prefer putting both server and fetch in ONE ctx_execute call instead of using background."),
1521
- cwd: z
1522
- .string()
1523
- .optional()
1524
- .describe("Optional working directory for shell commands. Non-shell languages still execute from their sandbox temp directory."),
1525
- intent: z
1526
- .string()
1527
- .optional()
1528
- .describe("What you're looking for in the output. When provided and output is large (>5KB), " +
1529
- "indexes output into knowledge base and returns section titles + previews — not full content. " +
1530
- "Use ctx_search(queries: [...]) to retrieve specific sections. Example: 'failing tests', 'HTTP 500 errors'." +
1531
- "\n\nTIP: Use specific technical terms, not just concepts. Check 'Searchable terms' in the response for available vocabulary."),
1532
- }),
1533
- }, async ({ language, code, timeout, background, cwd, intent }) => {
1534
- // Security: deny-only firewall
1535
- if (language === "shell") {
1536
- const denied = checkDenyPolicy(code, "execute");
1537
- if (denied)
1538
- return denied;
1539
- }
1540
- else {
1541
- const denied = checkNonShellDenyPolicy(code, language, "execute");
1542
- if (denied)
1543
- return denied;
1544
- }
1545
- try {
1546
- // For JS/TS: wrap in async IIFE with fetch + http/https interceptors to track network bytes
1547
- let instrumentedCode = code;
1548
- if (language === "javascript" || language === "typescript") {
1549
- // Wrap user code in a closure that shadows CJS require with http/https interceptor.
1550
- // globalThis.require does NOT work because CJS require is module-scoped, not global.
1551
- // The closure approach (function(__cm_req){ var require=...; })(require) correctly
1552
- // shadows the CJS require for all code inside, including __cm_main().
1553
- instrumentedCode = `
1554
- // FS read instrumentation — count bytes read via fs.readFileSync/readFile
1555
- let __cm_fs=0;
1556
- process.on('exit',()=>{if(__cm_fs>0)try{process.stderr.write('__CM_FS__:'+__cm_fs+'\\n')}catch{}});
1557
- (function(){
1558
- try{
1559
- var f=typeof require!=='undefined'?require('fs'):null;
1560
- if(!f)return;
1561
- var ors=f.readFileSync;
1562
- f.readFileSync=function(){var r=ors.apply(this,arguments);if(Buffer.isBuffer(r))__cm_fs+=r.length;else if(typeof r==='string')__cm_fs+=Buffer.byteLength(r);return r;};
1563
- var orf=f.readFile;
1564
- if(orf)f.readFile=function(){var a=Array.from(arguments),cb=a.pop();orf.apply(this,a.concat([function(e,d){if(!e&&d){if(Buffer.isBuffer(d))__cm_fs+=d.length;else if(typeof d==='string')__cm_fs+=Buffer.byteLength(d);}cb(e,d);}]));};
1565
- }catch{}
1566
- })();
1567
- let __cm_net=0;
1568
- // Report network bytes on process exit — works with both promise and callback patterns.
1569
- // process.on('exit') fires after all I/O completes, unlike .finally() which fires
1570
- // when __cm_main() resolves (immediately for callback-based http.get without await).
1571
- process.on('exit',()=>{if(__cm_net>0)try{process.stderr.write('__CM_NET__:'+__cm_net+'\\n')}catch{}});
1572
- ;(function(__cm_req){
1573
- // Intercept globalThis.fetch
1574
- const __cm_f=globalThis.fetch;
1575
- globalThis.fetch=async(...a)=>{const r=await __cm_f(...a);
1576
- try{const cl=r.clone();const b=await cl.arrayBuffer();__cm_net+=b.byteLength}catch{}
1577
- return r};
1578
- // Shadow CJS require with http/https network tracking.
1579
- const __cm_hc=new Map();
1580
- const __cm_hm=new Set(['http','https','node:http','node:https']);
1581
- function __cm_wf(m,origFn){return function(...a){
1582
- const li=a.length-1;
1583
- if(li>=0&&typeof a[li]==='function'){const oc=a[li];a[li]=function(res){
1584
- res.on('data',function(c){__cm_net+=c.length});oc(res);};}
1585
- const req=origFn.apply(m,a);
1586
- const oOn=req.on.bind(req);
1587
- req.on=function(ev,cb,...r){
1588
- if(ev==='response'){return oOn(ev,function(res){
1589
- res.on('data',function(c){__cm_net+=c.length});cb(res);
1590
- },...r);}
1591
- return oOn(ev,cb,...r);
1592
- };
1593
- return req;
1594
- }}
1595
- var require=__cm_req?function(id){
1596
- const m=__cm_req(id);
1597
- if(!__cm_hm.has(id))return m;
1598
- const k=id.replace('node:','');
1599
- if(__cm_hc.has(k))return __cm_hc.get(k);
1600
- const w=Object.create(m);
1601
- if(typeof m.get==='function')w.get=__cm_wf(m,m.get);
1602
- if(typeof m.request==='function')w.request=__cm_wf(m,m.request);
1603
- __cm_hc.set(k,w);return w;
1604
- }:__cm_req;
1605
- if(__cm_req){if(__cm_req.resolve)require.resolve=__cm_req.resolve;
1606
- if(__cm_req.cache)require.cache=__cm_req.cache;}
1607
- async function __cm_main(){
1608
- ${code}
1609
- }
1610
- __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nsetInterval(()=>{},2147483647);' : ''}
1611
- })(typeof require!=='undefined'?require:null);`;
1612
- }
1613
- const effTimeout = resolveExecTimeout(timeout);
1614
- const result = await executor.execute({ language, code: instrumentedCode, timeout: effTimeout, background, cwd });
1615
- // Echo the executed source code before stdout so users can audit
1616
- // and tooling can block command patterns (Issues #717 + #736).
1617
- // Built from the user-supplied `code`, NOT the instrumented variant.
1618
- const echo = buildExecuteEcho(language, code);
1619
- // Parse sandbox network metrics from stderr
1620
- const netMatch = result.stderr?.match(/__CM_NET__:(\d+)/);
1621
- if (netMatch) {
1622
- sessionStats.bytesSandboxed += parseInt(netMatch[1]);
1623
- // Clean the metric line from stderr
1624
- result.stderr = result.stderr.replace(/\n?__CM_NET__:\d+\n?/g, "");
1625
- }
1626
- // Parse sandbox FS read metrics from stderr
1627
- const fsMatch = result.stderr?.match(/__CM_FS__:(\d+)/);
1628
- if (fsMatch) {
1629
- sessionStats.bytesSandboxed += parseInt(fsMatch[1]);
1630
- result.stderr = result.stderr.replace(/\n?__CM_FS__:\d+\n?/g, "");
1631
- }
1632
- if (result.timedOut) {
1633
- const partialOutput = result.stdout?.trim();
1634
- if (result.backgrounded && partialOutput) {
1635
- // Background mode: process is still running, return partial output as success
1636
- return trackResponse("ctx_execute", {
1637
- content: [
1638
- {
1639
- type: "text",
1640
- text: `${echo}${partialOutput}\n\n_(process backgrounded after ${effTimeout}ms — still running)_`,
1641
- },
1642
- ],
1643
- });
1644
- }
1645
- if (partialOutput) {
1646
- // Timeout with partial output — return as success with note
1647
- return trackResponse("ctx_execute", {
1648
- content: [
1649
- {
1650
- type: "text",
1651
- text: `${echo}${partialOutput}\n\n_(timed out after ${effTimeout}ms — partial output shown above)_`,
1652
- },
1653
- ],
1654
- });
1655
- }
1656
- return trackResponse("ctx_execute", {
1657
- content: [
1658
- {
1659
- type: "text",
1660
- text: `${echo}Execution timed out after ${effTimeout}ms\n\nstderr:\n${result.stderr}`,
1661
- },
1662
- ],
1663
- isError: true,
1664
- });
1665
- }
1666
- if (result.exitCode !== 0) {
1667
- const { isError, output } = classifyNonZeroExit({
1668
- language, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr,
1669
- });
1670
- if (intent && intent.trim().length > 0 && Buffer.byteLength(output) > INTENT_SEARCH_THRESHOLD) {
1671
- trackIndexed(Buffer.byteLength(output));
1672
- return trackResponse("ctx_execute", {
1673
- content: [
1674
- { type: "text", text: `${echo}${intentSearch(output, intent, isError ? `execute:${language}:error` : `execute:${language}`)}` },
1675
- ],
1676
- isError,
1677
- });
1678
- }
1679
- // Auto-index large error output into FTS5 — no data loss
1680
- if (Buffer.byteLength(output) > LARGE_OUTPUT_THRESHOLD) {
1681
- trackIndexed(Buffer.byteLength(output));
1682
- return trackResponse("ctx_execute", {
1683
- content: [
1684
- { type: "text", text: `${echo}${intentSearch(output, "errors failures exceptions", isError ? `execute:${language}:error` : `execute:${language}`)}` },
1685
- ],
1686
- isError,
1687
- });
1688
- }
1689
- return trackResponse("ctx_execute", {
1690
- content: [
1691
- { type: "text", text: `${echo}${output}` },
1692
- ],
1693
- isError,
1694
- });
1695
- }
1696
- const stdout = result.stdout || "(no output)";
1697
- // Intent-driven search: if intent provided and output is large enough
1698
- if (intent && intent.trim().length > 0 && Buffer.byteLength(stdout) > INTENT_SEARCH_THRESHOLD) {
1699
- trackIndexed(Buffer.byteLength(stdout));
1700
- return trackResponse("ctx_execute", {
1701
- content: [
1702
- { type: "text", text: `${echo}${intentSearch(stdout, intent, `execute:${language}`)}` },
1703
- ],
1704
- });
1705
- }
1706
- // Auto-index large stdout into FTS5 — return pointer, not raw content
1707
- if (Buffer.byteLength(stdout) > LARGE_OUTPUT_THRESHOLD) {
1708
- const indexed = indexStdout(stdout, `execute:${language}`);
1709
- // Prepend echo to the first text content so provenance still surfaces
1710
- const echoed = {
1711
- ...indexed,
1712
- content: indexed.content.map((c, i) => i === 0 && c.type === "text"
1713
- ? { ...c, text: `${echo}${c.text}` }
1714
- : c),
1715
- };
1716
- return trackResponse("ctx_execute", echoed);
1717
- }
1718
- return trackResponse("ctx_execute", {
1719
- content: [
1720
- { type: "text", text: `${echo}${stdout}` },
1721
- ],
1722
- });
1723
- }
1724
- catch (err) {
1725
- const message = err instanceof Error ? err.message : String(err);
1726
- return trackResponse("ctx_execute", {
1727
- content: [
1728
- { type: "text", text: `Runtime error: ${message}` },
1729
- ],
1730
- isError: true,
1731
- });
1732
- }
1733
- });
1734
- // ─────────────────────────────────────────────────────────
1735
- // Helper: index stdout into FTS5 knowledge base
1736
- // ─────────────────────────────────────────────────────────
1737
- function indexStdout(stdout, source) {
1738
- const store = getStore();
1739
- trackIndexed(Buffer.byteLength(stdout));
1740
- const indexed = store.index({ content: stdout, source, attribution: currentAttribution() });
1741
- return {
1742
- content: [
1743
- {
1744
- type: "text",
1745
- text: `Indexed ${indexed.totalChunks} sections (${indexed.codeChunks} with code) from: ${indexed.label}\nUse ctx_search(queries: ["..."]) to query this content. Use source: "${indexed.label}" to scope results.`,
1746
- },
1747
- ],
1748
- };
1749
- }
1750
- // ─────────────────────────────────────────────────────────
1751
- // Helper: intent-driven search on execution output
1752
- // ─────────────────────────────────────────────────────────
1753
- const INTENT_SEARCH_THRESHOLD = 5_000; // bytes — ~80-100 lines
1754
- const LARGE_OUTPUT_THRESHOLD = 102_400; // 100KB — auto-index into FTS5, return pointer
1755
- function intentSearch(stdout, intent, source, maxResults = 5) {
1756
- const totalLines = stdout.split("\n").length;
1757
- const totalBytes = Buffer.byteLength(stdout);
1758
- // Index into the PERSISTENT store so user can ctx_search() later
1759
- const persistent = getStore();
1760
- const indexed = persistent.indexPlainText(stdout, source, undefined, currentAttribution());
1761
- // Search the persistent store directly (porter → trigram → fuzzy)
1762
- let results = persistent.searchWithFallback(intent, maxResults, source);
1763
- // Extract distinctive terms as vocabulary hints for the LLM
1764
- const distinctiveTerms = persistent.getDistinctiveTerms(indexed.sourceId);
1765
- if (results.length === 0) {
1766
- const lines = [
1767
- `Indexed ${indexed.totalChunks} sections from "${source}" into knowledge base.`,
1768
- `No sections matched intent "${intent}" in ${totalLines}-line output (${(totalBytes / 1024).toFixed(1)}KB).`,
1769
- ];
1770
- if (distinctiveTerms.length > 0) {
1771
- lines.push("");
1772
- lines.push(`Searchable terms: ${distinctiveTerms.join(", ")}`);
1773
- }
1774
- lines.push("");
1775
- lines.push("Use ctx_search(queries: [...]) to explore the indexed content.");
1776
- return lines.join("\n");
1777
- }
1778
- // Return ONLY titles + first-line previews — not full content
1779
- const lines = [
1780
- `Indexed ${indexed.totalChunks} sections from "${source}" into knowledge base.`,
1781
- `${results.length} sections matched "${intent}" (${totalLines} lines, ${(totalBytes / 1024).toFixed(1)}KB):`,
1782
- "",
1783
- ];
1784
- for (const r of results) {
1785
- const preview = r.content.split("\n")[0].slice(0, 120);
1786
- lines.push(` - ${r.title}: ${preview}`);
1787
- }
1788
- if (distinctiveTerms.length > 0) {
1789
- lines.push("");
1790
- lines.push(`Searchable terms: ${distinctiveTerms.join(", ")}`);
1791
- }
1792
- lines.push("");
1793
- lines.push("Use ctx_search(queries: [...]) to retrieve full content of any section.");
1794
- return lines.join("\n");
1795
- }
1796
- // ─────────────────────────────────────────────────────────
1797
- // Tool: execute_file
1798
- // ─────────────────────────────────────────────────────────
1799
- server.registerTool("ctx_execute_file", {
1800
- // #852: the host's MCP approval prompt renders only the tool name/title +
1801
- // raw args — the title is the one server-controlled signal, so make it
1802
- // unambiguously announce code execution + file read for the reviewer.
1803
- title: "Run code over a file (executes code, reads the given path)",
1804
- // #846: runs arbitrary code over a file in a sandbox with full network access.
1805
- annotations: {
1806
- readOnlyHint: false,
1807
- destructiveHint: true,
1808
- idempotentHint: false,
1809
- openWorldHint: true,
1810
- },
1811
- description: `Read a file into a sandboxed FILE_CONTENT variable and run code over it. Only what you console.log() enters your conversation — the file bytes stay in the sandbox.
1812
-
1813
- Think-in-Code applied to file-level analysis: Reading the whole file means every byte enters your conversation memory and costs reasoning capacity for the rest of the session. Running code over it here lets you keep the raw bytes out and only the derived answer in. Same principle as ctx_execute, scoped to one named file via the FILE_CONTENT variable.
1814
-
1815
- WHEN:
1816
- - You want to KNOW SOMETHING ABOUT a file (line count, matches of a pattern, parsed structure, statistical aggregate) without needing to SEE all of it
1817
- - The file is structured (CSV, JSON, log, code) and a code-level derivation is cheaper than reading verbatim
1818
- - The file is large enough that reading the full content would burn meaningful conversation memory you need for the actual work
1819
- - The derivation may itself produce a large output you want recall-by-topic on later — pass an \`intent\` string; outputs over ~5KB are auto-indexed and only matching sections come back, retrievable via ctx_search
1820
-
1821
- WHEN NOT:
1822
- - You intend to EDIT the file — use Read so the subsequent Edit can match the exact text
1823
- - You only need one specific line and you know its offset — Read with offset/limit is the simplest path
1824
- - The file is small AND you will consume all of it for understanding/editing — Read directly
1825
-
1826
- RETURNS:
1827
- Only what your code prints. The FILE_CONTENT variable holds the raw bytes inside the sandbox; nothing else leaves. When \`intent\` is set and output exceeds the auto-index threshold, the response carries searchable section titles + previews instead of the raw stdout.
1828
-
1829
- EXAMPLE: ctx_execute_file(path: "huge.log", language: "javascript", code: "const errs = FILE_CONTENT.split('\\\\n').filter(l => /ERROR|FATAL/.test(l)); console.log(\`\${errs.length} error lines\`); console.log(errs.slice(-5).join('\\\\n'))")
1830
- EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const rows = FILE_CONTENT.split('\\\\n'); console.log(\`rows: \${rows.length - 1}, header: \${rows[0]}\`)")`,
1831
- inputSchema: z.object({
1832
- path: z
1833
- .string()
1834
- .describe("Absolute file path or relative to project root"),
1835
- language: z
1836
- .enum([
1837
- "javascript",
1838
- "typescript",
1839
- "python",
1840
- "shell",
1841
- "ruby",
1842
- "go",
1843
- "rust",
1844
- "php",
1845
- "perl",
1846
- "r",
1847
- "elixir",
1848
- "csharp",
1849
- ])
1850
- .describe("Runtime language"),
1851
- code: z
1852
- .string()
1853
- .describe("Code to process FILE_CONTENT (file_content in Elixir). Print summary via console.log/print/echo/IO.puts/Console.WriteLine."),
1854
- timeout: z
1855
- .coerce.number()
1856
- .optional()
1857
- .describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs."),
1858
- intent: z
1859
- .string()
1860
- .optional()
1861
- .describe("What you're looking for in the output. When provided and output is large (>5KB), " +
1862
- "returns only matching sections via BM25 search instead of truncated output."),
1863
- }),
1864
- }, async ({ path, language, code, timeout, intent }) => {
1865
- // Security (#852): confine the processed file to the project root so
1866
- // ctx_execute_file cannot be used to escape the host's sandbox/permission
1867
- // controls. Runs before the deny-glob check — boundary first, then policy.
1868
- const boundaryDenied = checkProjectBoundary(path, "ctx_execute_file");
1869
- if (boundaryDenied)
1870
- return boundaryDenied;
1871
- // Security: check file path against Read deny patterns
1872
- const pathDenied = checkFilePathDenyPolicy(path, "ctx_execute_file");
1873
- if (pathDenied)
1874
- return pathDenied;
1875
- // Security: check code parameter against Bash deny patterns
1876
- if (language === "shell") {
1877
- const codeDenied = checkDenyPolicy(code, "execute_file");
1878
- if (codeDenied)
1879
- return codeDenied;
1880
- }
1881
- else {
1882
- const codeDenied = checkNonShellDenyPolicy(code, language, "execute_file");
1883
- if (codeDenied)
1884
- return codeDenied;
1885
- }
1886
- try {
1887
- const effTimeout = resolveExecTimeout(timeout);
1888
- const result = await executor.executeFile({
1889
- path,
1890
- language,
1891
- code,
1892
- timeout: effTimeout,
1893
- });
1894
- // Echo path + executed source code before stdout for audit/debug
1895
- // (Issues #717 + #736).
1896
- const echo = buildExecuteEcho(language, code, path);
1897
- if (result.timedOut) {
1898
- return trackResponse("ctx_execute_file", {
1899
- content: [
1900
- {
1901
- type: "text",
1902
- text: `${echo}Timed out processing ${path} after ${effTimeout}ms`,
1903
- },
1904
- ],
1905
- isError: true,
1906
- });
1907
- }
1908
- if (result.exitCode !== 0) {
1909
- const { isError, output } = classifyNonZeroExit({
1910
- language, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr,
1911
- });
1912
- if (intent && intent.trim().length > 0 && Buffer.byteLength(output) > INTENT_SEARCH_THRESHOLD) {
1913
- trackIndexed(Buffer.byteLength(output));
1914
- return trackResponse("ctx_execute_file", {
1915
- content: [
1916
- { type: "text", text: `${echo}${intentSearch(output, intent, isError ? `file:${path}:error` : `file:${path}`)}` },
1917
- ],
1918
- isError,
1919
- });
1920
- }
1921
- // Auto-index large error output into FTS5 — no data loss
1922
- if (Buffer.byteLength(output) > LARGE_OUTPUT_THRESHOLD) {
1923
- trackIndexed(Buffer.byteLength(output));
1924
- return trackResponse("ctx_execute_file", {
1925
- content: [
1926
- { type: "text", text: `${echo}${intentSearch(output, "errors failures exceptions", isError ? `file:${path}:error` : `file:${path}`)}` },
1927
- ],
1928
- isError,
1929
- });
1930
- }
1931
- return trackResponse("ctx_execute_file", {
1932
- content: [
1933
- { type: "text", text: `${echo}${output}` },
1934
- ],
1935
- isError,
1936
- });
1937
- }
1938
- const stdout = result.stdout || "(no output)";
1939
- if (intent && intent.trim().length > 0 && Buffer.byteLength(stdout) > INTENT_SEARCH_THRESHOLD) {
1940
- trackIndexed(Buffer.byteLength(stdout));
1941
- return trackResponse("ctx_execute_file", {
1942
- content: [
1943
- { type: "text", text: `${echo}${intentSearch(stdout, intent, `file:${path}`)}` },
1944
- ],
1945
- });
1946
- }
1947
- // Auto-index large stdout into FTS5 — return pointer, not raw content
1948
- if (Buffer.byteLength(stdout) > LARGE_OUTPUT_THRESHOLD) {
1949
- const indexed = indexStdout(stdout, `file:${path}`);
1950
- const echoed = {
1951
- ...indexed,
1952
- content: indexed.content.map((c, i) => i === 0 && c.type === "text"
1953
- ? { ...c, text: `${echo}${c.text}` }
1954
- : c),
1955
- };
1956
- return trackResponse("ctx_execute_file", echoed);
1957
- }
1958
- return trackResponse("ctx_execute_file", {
1959
- content: [
1960
- { type: "text", text: `${echo}${stdout}` },
1961
- ],
1962
- });
1963
- }
1964
- catch (err) {
1965
- const message = err instanceof Error ? err.message : String(err);
1966
- return trackResponse("ctx_execute_file", {
1967
- content: [
1968
- { type: "text", text: `Runtime error: ${message}` },
1969
- ],
1970
- isError: true,
1971
- });
1972
- }
1973
- });
1974
- // ─────────────────────────────────────────────────────────
1975
- // Tool: index
1976
- // ─────────────────────────────────────────────────────────
1977
- server.registerTool("ctx_index", {
1978
- title: "Index Content",
1979
- // #846: writes content into the local FTS5 store (additive, not destructive;
1980
- // re-indexing the same content adds rows, so not idempotent). No network.
1981
- annotations: {
1982
- readOnlyHint: false,
1983
- destructiveHint: false,
1984
- idempotentHint: false,
1985
- openWorldHint: false,
1986
- },
1987
- description: `Store content in a searchable knowledge base (BM25 over FTS5). Splits markdown by headings, keeps code blocks intact, and persists the raw chunks. The full content stays in storage — retrieve any section on-demand via ctx_search; nothing is summarized or truncated.
1988
-
1989
- WHEN:
1990
- - Documentation from Context7, Skills, or MCP tools (API docs, framework guides, code examples)
1991
- - API references (endpoint details, parameter specs, response schemas)
1992
- - MCP tools/list output (exact tool signatures and descriptions)
1993
- - Skill prompts and instructions that are too large to keep verbatim in conversation
1994
- - README files, migration guides, changelog entries
1995
- - Any content with code examples you may need to reference precisely later
1996
-
1997
- WHEN NOT:
1998
- - Log files, test output, CSV, or build output — use ctx_execute_file, which processes in-sandbox without persisting bytes
1999
- - Single-use ephemeral content you will not query later — keep it inline if it fits, or ctx_execute_file it
2000
-
2001
- RETURNS:
2002
- Indexing metadata: chunk counts (total, code-bearing), source label, and the exact ctx_search call shape to query the indexed content. Raw content is NOT echoed back — it lives in storage, retrievable via ctx_search(source: "<label>"). When \`path\` is provided, a content hash is stored so ctx_search results auto-flag staleness on future calls.
2003
-
2004
- EXAMPLE: ctx_index(content: "# React useEffect\\n\\nThe Effect Hook lets you ...", source: "react-useeffect-docs")
2005
- EXAMPLE: ctx_index(path: "/path/to/large-spec.md", source: "openapi-v2-spec")`,
2006
- inputSchema: z.object({
2007
- content: z
2008
- .string()
2009
- .optional()
2010
- .describe("Raw text/markdown to index. Provide this OR path, not both."),
2011
- path: z
2012
- .string()
2013
- .optional()
2014
- .describe("File OR directory path to read and index (content never enters context). Provide this OR content. Directory paths trigger a bounded recursive walk (#687)."),
2015
- source: z
2016
- .string()
2017
- .optional()
2018
- .describe("Label for the indexed content (e.g., 'Context7: React useEffect', 'Skill: frontend-design')"),
2019
- include: z.array(z.string()).optional().describe("Directory-only: glob patterns to include (default: all matching extensions)."),
2020
- exclude: z.array(z.string()).optional().describe("Directory-only: glob patterns to exclude. Merged with defaults (node_modules, .git, dist, build, .next, coverage, .venv, __pycache__, .DS_Store)."),
2021
- maxDepth: z.number().int().min(0).optional().describe("Directory-only: max recursion depth from root (default: 5)."),
2022
- maxFiles: z.number().int().min(1).optional().describe("Directory-only: hard cap on files indexed (default: 200) — FTS5 blow-up guard."),
2023
- extensions: z.array(z.string()).optional().describe("Directory-only: allowed file extensions (default: .md .mdx .txt .json .yaml .yml .ts .tsx .js .jsx .py .rs .go .sh)."),
2024
- respectGitignore: z.boolean().optional().describe("Directory-only: apply nearest .gitignore (default: true)."),
2025
- followSymlinks: z.boolean().optional().describe("Directory-only: follow directory symlinks (default: false — cycle hazard + escape risk)."),
2026
- }),
2027
- }, async ({ content, path, source, include, exclude, maxDepth, maxFiles, extensions, respectGitignore, followSymlinks }) => {
2028
- if (!content && !path) {
2029
- return trackResponse("ctx_index", {
2030
- content: [
2031
- {
2032
- type: "text",
2033
- text: "Error: Either content or path must be provided",
2034
- },
2035
- ],
2036
- isError: true,
2037
- });
2038
- }
2039
- // Apply Read deny-policy to prevent indexing sensitive files into the
2040
- // FTS5 store, which would otherwise be queryable via ctx_search and
2041
- // exfiltrate content into the model's context (issue #442). Mirrors the
2042
- // check ctx_execute_file already performs.
2043
- if (path) {
2044
- const pathDenied = checkFilePathDenyPolicy(path, "ctx_index");
2045
- if (pathDenied)
2046
- return pathDenied;
2047
- }
2048
- try {
2049
- const resolvedPath = path ? resolveProjectPath(path) : undefined;
2050
- // Directory dispatch (#687, reported by @matiasduartee). When the
2051
- // resolved path is a directory, walk it bounded and re-enter `index()`
2052
- // per-file so the security gate at store.ts:845 (TOCTOU defense from
2053
- // #442 round-3) keeps running for every file.
2054
- //
2055
- // Root-level symlink defense: the deny-glob check above ran on the
2056
- // user-supplied `path`. If `path` is a symlink whose target lands in
2057
- // a sensitive directory (e.g. `/tmp/link -> /etc`), statSync would
2058
- // happily report directory and walkDirectoryDetailed would
2059
- // realpathSync internally, walking /etc with the user's deny globs
2060
- // bound to /tmp/link instead of the real target. Detect the symlink
2061
- // with lstatSync, follow it once, and re-apply the deny check
2062
- // against the realpath so the user's deny globs see the actual
2063
- // walk root.
2064
- if (resolvedPath && existsSync(resolvedPath)) {
2065
- const lst = lstatSync(resolvedPath);
2066
- if (lst.isSymbolicLink()) {
2067
- let realTarget;
2068
- try {
2069
- realTarget = realpathSync(resolvedPath);
2070
- }
2071
- catch {
2072
- return trackResponse("ctx_index", {
2073
- content: [{ type: "text", text: "Error: symlink target could not be resolved." }],
2074
- });
2075
- }
2076
- if (realTarget !== resolvedPath) {
2077
- const realDenied = checkFilePathDenyPolicy(realTarget, "ctx_index");
2078
- if (realDenied)
2079
- return realDenied;
2080
- }
2081
- }
2082
- }
2083
- if (resolvedPath && existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()) {
2084
- const store = getStore();
2085
- const projectDir = getProjectDir();
2086
- const denyGlobs = readToolDenyPatterns("Read", projectDir);
2087
- const isWin32 = process.platform === "win32";
2088
- const perFileDeny = (absPath) => {
2089
- try {
2090
- return evaluateFilePath(absPath, denyGlobs, isWin32, projectDir).denied;
2091
- }
2092
- catch {
2093
- return false; // fail-open consistent with checkFilePathDenyPolicy
2094
- }
2095
- };
2096
- const dirResult = store.indexDirectory({
2097
- path: resolvedPath,
2098
- source: source ?? resolvedPath,
2099
- attribution: currentAttribution(),
2100
- perFileDeny,
2101
- include,
2102
- exclude,
2103
- maxDepth,
2104
- maxFiles,
2105
- extensions,
2106
- respectGitignore,
2107
- followSymlinks,
2108
- });
2109
- const capNote = dirResult.capped
2110
- ? ` (cap reached — only first ${dirResult.filesIndexed} of ${dirResult.totalSeen}+ files; raise maxFiles to index more)`
2111
- : "";
2112
- const denyNote = dirResult.denied > 0
2113
- ? ` (${dirResult.denied} file${dirResult.denied === 1 ? "" : "s"} blocked by Read deny policy)`
2114
- : "";
2115
- const failNote = dirResult.failed > 0
2116
- ? ` (${dirResult.failed} file${dirResult.failed === 1 ? "" : "s"} failed to read)`
2117
- : "";
2118
- return trackResponse("ctx_index", {
2119
- content: [
2120
- {
2121
- type: "text",
2122
- text: `Indexed ${dirResult.filesIndexed} file${dirResult.filesIndexed === 1 ? "" : "s"} (${dirResult.totalChunks} sections) from directory: ${dirResult.label}${capNote}${denyNote}${failNote}\nUse ctx_search(queries: ["..."]) to query this content.`,
2123
- },
2124
- ],
2125
- });
2126
- }
2127
- // Track the raw bytes being indexed (content or file)
2128
- if (content)
2129
- trackIndexed(Buffer.byteLength(content));
2130
- else if (resolvedPath) {
2131
- try {
2132
- const fs = await import("fs");
2133
- trackIndexed(fs.readFileSync(resolvedPath).byteLength);
2134
- }
2135
- catch { /* ignore — file read errors handled by store */ }
2136
- }
2137
- const store = getStore();
2138
- const result = store.index({ content, path: resolvedPath, source: source ?? resolvedPath, attribution: currentAttribution() });
2139
- return trackResponse("ctx_index", {
2140
- content: [
2141
- {
2142
- type: "text",
2143
- text: `Indexed ${result.totalChunks} sections (${result.codeChunks} with code) from: ${result.label}\nUse ctx_search(queries: ["..."]) to query this content. Use source: "${result.label}" to scope results.`,
2144
- },
2145
- ],
2146
- });
2147
- }
2148
- catch (err) {
2149
- const message = err instanceof Error ? err.message : String(err);
2150
- return trackResponse("ctx_index", {
2151
- content: [
2152
- { type: "text", text: `Index error: ${message}` },
2153
- ],
2154
- isError: true,
2155
- });
2156
- }
2157
- });
2158
- // ─────────────────────────────────────────────────────────
2159
- // Tool: search — progressive throttling
2160
- // ─────────────────────────────────────────────────────────
2161
- // Track search calls per N-second window for progressive throttling.
2162
- // Defaults preserve the historical behavior (60s window, soft-cap at 3
2163
- // calls, hard-block at 8). All three thresholds are overridable via env
2164
- // vars so users can loosen or tighten the policy without forking. Invalid
2165
- // values (non-positive numbers, NaN) fall back to the default to avoid
2166
- // silently disabling the protection.
2167
- function readPositiveEnv(name, defaultValue) {
2168
- const raw = process.env[name];
2169
- if (!raw)
2170
- return defaultValue;
2171
- const parsed = Number(raw);
2172
- return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;
2173
- }
2174
- const SEARCH_WINDOW_MS = readPositiveEnv("CONTEXT_MODE_SEARCH_WINDOW_MS", 60_000);
2175
- const SEARCH_MAX_RESULTS_AFTER = readPositiveEnv("CONTEXT_MODE_SEARCH_MAX_RESULTS_AFTER", 3); // after N calls: 1 result per query
2176
- const SEARCH_BLOCK_AFTER = readPositiveEnv("CONTEXT_MODE_SEARCH_BLOCK_AFTER", 8); // after N calls: refuse, demand batching
2177
- // #769: progressive throttle bucketed PER agent-context, not machine-global.
2178
- // Concurrent subagents share ONE MCP server process; a single global counter
2179
- // summed their independent searches into one budget and hard-blocked
2180
- // legitimate parallel fan-out. The guard keys each actor's window separately
2181
- // so single-actor flood protection is preserved while fan-out is not starved.
2182
- const searchFloodGuard = new FloodGuard({
2183
- windowMs: SEARCH_WINDOW_MS,
2184
- softCapAfter: SEARCH_MAX_RESULTS_AFTER,
2185
- blockAfter: SEARCH_BLOCK_AFTER,
2186
- });
2187
- /**
2188
- * Per-agent flood-guard key. Each concurrent subagent in a Claude Code
2189
- * Task/Workflow fan-out runs under its own session id (written to SessionDB
2190
- * via hooks), so currentAttribution().sessionId is the per-agent discriminator
2191
- * already available MCP-side. Falls back to a single shared bucket when no
2192
- * identity is resolvable (preserves today's single-threaded behaviour).
2193
- */
2194
- function searchFloodGuardKey() {
2195
- try {
2196
- return currentAttribution()?.sessionId ?? "__default__";
2197
- }
2198
- catch {
2199
- return "__default__";
2200
- }
2201
- }
2202
- /**
2203
- * Defensive coercion: parse stringified JSON arrays, AND lift a bare
2204
- * non-empty string into a single-element array.
2205
- *
2206
- * Two shapes show up from the wild:
2207
- * 1. `"[\"a\",\"b\"]"` — Claude Code double-serialization bug
2208
- * (https://github.com/anthropics/claude-code/issues/34520).
2209
- * 2. `"single query"` — some LLM providers / OpenCode's native plugin
2210
- * bridge deliver a single string when the schema expects `string[]`
2211
- * (issue #627). v1.0.139 (#621) made the bridge run the Zod schema,
2212
- * so this now surfaces as `Expected array, received string`. The
2213
- * ergonomic recovery is to treat it as `["single query"]`.
2214
- *
2215
- * An empty string is intentionally NOT lifted — empty input should still
2216
- * fail Zod's `.min(1)` check rather than masquerade as `[""]`.
2217
- */
2218
- function coerceJsonArray(val) {
2219
- if (typeof val === "string") {
2220
- const trimmed = val.trim();
2221
- if (trimmed.length === 0)
2222
- return val; // let zod produce "non-empty" error
2223
- try {
2224
- const parsed = JSON.parse(val);
2225
- if (Array.isArray(parsed))
2226
- return parsed;
2227
- }
2228
- catch { /* fall through — not JSON, treat as bare-string lift */ }
2229
- // Bare-string lift (#627): single query delivered as a plain string.
2230
- return [val];
2231
- }
2232
- return val;
2233
- }
2234
- /**
2235
- * Defensive coercion: accept the string literals "true"/"false" as
2236
- * booleans. The OpenCode native plugin bridge (and several LLM providers'
2237
- * tool-call JSON) stringifies primitives — `background:"false"` instead
2238
- * of `background:false`, `confirm:"true"` instead of `confirm:true`.
2239
- *
2240
- * We deliberately do NOT use `z.coerce.boolean()` for boolean fields:
2241
- * `Boolean("false")` is `true`, so Zod's coerce path silently flips the
2242
- * meaning. This helper recognises only the documented literal forms and
2243
- * passes anything else through untouched so Zod surfaces the right error.
2244
- *
2245
- * Fixes #627.
2246
- */
2247
- function coerceBoolean(val) {
2248
- if (typeof val === "string") {
2249
- const t = val.trim().toLowerCase();
2250
- if (t === "true")
2251
- return true;
2252
- if (t === "false")
2253
- return false;
2254
- }
2255
- return val;
2256
- }
2257
- /**
2258
- * Coerce commands array: handles double-serialization AND the case where
2259
- * the model passes plain command strings instead of {label, command} objects.
2260
- */
2261
- function coerceCommandsArray(val) {
2262
- const arr = coerceJsonArray(val);
2263
- if (Array.isArray(arr)) {
2264
- return arr.map((item, i) => typeof item === "string" ? { label: `cmd_${i + 1}`, command: item } : item);
2265
- }
2266
- return arr;
2267
- }
2268
- server.registerTool("ctx_search", {
2269
- title: "Search Indexed Content",
2270
- // #846: read-only query over the local FTS5 store. No mutation, no network.
2271
- annotations: {
2272
- readOnlyHint: true,
2273
- destructiveHint: false,
2274
- idempotentHint: true,
2275
- openWorldHint: false,
2276
- },
2277
- description: `Search a unified knowledge base with a multi-strategy ranking pipeline. Two parallel matchers run on every query: a Porter-stemming matcher ("caching" finds "cached", "caches", "cach") and a trigram-substring matcher ("useEff" finds "useEffect"). Their ranked lists are merged via Reciprocal Rank Fusion, so a document that ranks well in both surfaces above one that wins only on a single strategy. Multi-term queries get an additional proximity-rerank pass that boosts passages where the query terms appear close together. Typos are corrected via Levenshtein distance and re-searched. Result snippets are window-extracted around the matched terms, not blindly truncated.
2278
-
2279
- The knowledge base is unified: queries reach indexed content you stored (ctx_index, ctx_fetch_and_index, ctx_batch_execute output) AND auto-captured session memory written by hooks (decisions, errors, blockers, plans, user prompts, rejected approaches, tool failures, compaction guides — 26 event categories). File-backed sources carry a content hash and auto-flag staleness when the source file changes.
2280
-
2281
- WHEN:
2282
- - You want to recall something that exists in storage (recently indexed content, prior session events, auto-memory) instead of re-reading raw sources
2283
- - You have multiple related questions about the same body of knowledge — batch every question into one call (the ranking pipeline runs per-query but the round-trip cost is paid once)
2284
- - You want to scope the query to one labelled source (pass \`source\` — partial match is fine)
2285
- - You want a chronological view across current session + prior sessions + persistent auto-memory (pass \`sort: "timeline"\` — the default \`relevance\` mode only ranks within the current session)
2286
- - You want to filter ranked results by content shape (pass \`contentType: "code"\` to surface implementation snippets or \`contentType: "prose"\` to surface explanations)
2287
-
2288
- WHEN NOT:
2289
- - The data you want to query has never been stored in the knowledge base AND no session memory has accumulated around it — capture first (run a gather-and-index call), then come back here to query
2290
- - You have one ad-hoc question against data that is not in the knowledge base — answer it inline by running code in the sandbox tool; one round-trip instead of capture-then-query
2291
-
2292
- RETURNS:
2293
- Per-query ranked sections with window-extracted snippets. Use 2-4 specific technical terms per query. Common session-memory source labels: \`decision\` (user corrections / preferences), \`error\` and \`error-resolution\` (past failures + their fixes), \`blocker\`, \`plan\`, \`user-prompt\`, \`rejected-approach\`, \`compaction\` (post-compact session guide). See ctx_stats for live category counts. Each response carries a throttle counter (call #N/M in the rolling time window); results taper toward the soft cap and calls block after the hard cap. Tune via CONTEXT_MODE_SEARCH_WINDOW_MS, CONTEXT_MODE_SEARCH_MAX_RESULTS_AFTER, CONTEXT_MODE_SEARCH_BLOCK_AFTER.
2294
-
2295
- EXAMPLE: ctx_search(queries: ["root cause", "proposed fix", "test coverage"], source: "issue-#683")
2296
- EXAMPLE: ctx_search(queries: ["what did we decide about caching"], source: "decision", sort: "timeline")
2297
- EXAMPLE: ctx_search(queries: ["useEffect cleanup pattern"], source: "react-docs", contentType: "code", limit: 5)
2298
- EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blockers"], sort: "timeline")`,
2299
- // Schema construction is centralised in `src/search/ctx-search-schema.ts`
2300
- // so the conditional `project` field (only registered when the host runs
2301
- // in shared-DB mode, `CONTEXT_MODE_PROJECT_DIR` set at module load) is a
2302
- // hard property of the tool surface — not a runtime hint. Fixes #737.
2303
- inputSchema: buildCtxSearchInputSchema(CTX_SEARCH_SHARED_MODE),
2304
- }, async (params) => {
2305
- try {
2306
- const store = getStore();
2307
- const sort = params.sort || "relevance";
2308
- // Guard: redirect when the index is empty — ctx_search is a follow-up
2309
- // tool that requires prior indexing. Skip for timeline mode (SessionDB/auto-memory may have data).
2310
- if (sort !== "timeline" && store.getStats().chunks === 0) {
2311
- return trackResponse("ctx_search", {
2312
- content: [{
2313
- type: "text",
2314
- text: "Knowledge base is empty — no content has been indexed yet.\n\n" +
2315
- "ctx_search is a follow-up tool that queries previously indexed content. " +
2316
- "To gather and index content first, use:\n" +
2317
- " • ctx_batch_execute(commands, queries) — run commands, auto-index output, and search in one call\n" +
2318
- " • ctx_fetch_and_index(url) — fetch a URL, index it, then search with ctx_search\n" +
2319
- " • ctx_index(content, source) — manually index text content\n\n" +
2320
- "After indexing, ctx_search becomes available for follow-up queries.",
2321
- }],
2322
- isError: true,
2323
- });
2324
- }
2325
- const raw = params;
2326
- // Normalize: accept both query (string) and queries (array)
2327
- const queryList = [];
2328
- if (Array.isArray(raw.queries) && raw.queries.length > 0) {
2329
- queryList.push(...raw.queries);
2330
- }
2331
- else if (typeof raw.query === "string" && raw.query.length > 0) {
2332
- queryList.push(raw.query);
2333
- }
2334
- if (queryList.length === 0) {
2335
- return trackResponse("ctx_search", {
2336
- content: [{ type: "text", text: "Error: provide query or queries." }],
2337
- isError: true,
2338
- });
2339
- }
2340
- const { limit = 3, source, contentType, project } = params;
2341
- // Resolve the per-project scope (#737). When shared-DB mode is off the
2342
- // resolver returns `undefined` and `project` is silently ignored — the
2343
- // per-project DB is naturally isolated by directory hash, so there is
2344
- // nothing for an in-process filter to do.
2345
- const projectScope = resolveProjectScope(project, CTX_SEARCH_SHARED_MODE, () => getProjectDir());
2346
- // Progressive throttling: track calls per agent-context window (#769).
2347
- const now = Date.now();
2348
- const flood = searchFloodGuard.record(searchFloodGuardKey(), now);
2349
- const searchCallCount = flood.count;
2350
- // After SEARCH_BLOCK_AFTER calls (for THIS agent): refuse
2351
- if (flood.blocked) {
2352
- return trackResponse("ctx_search", {
2353
- content: [{
2354
- type: "text",
2355
- text: `BLOCKED: ${searchCallCount} search calls in ${Math.round((now - flood.windowStart) / 1000)}s. ` +
2356
- "You're flooding context. STOP making individual search calls. " +
2357
- "Use ctx_batch_execute(commands, queries) for your next research step.",
2358
- }],
2359
- isError: true,
2360
- });
2361
- }
2362
- // Determine per-query result limit based on throttle level
2363
- const effectiveLimit = flood.softCapped
2364
- ? 1 // after soft cap: only 1 result per query
2365
- : Math.min(limit, 2); // normal: max 2
2366
- const MAX_TOTAL = 40 * 1024; // 40KB total cap
2367
- let totalSize = 0;
2368
- const sections = [];
2369
- // Open SessionDB once before the loop (Blocker 4: avoid open/close per query).
2370
- // Issue #737: also open in relevance mode when a string `projectScope`
2371
- // is in play — the 2-step IN-clause needs SessionDB to translate
2372
- // `project_dir` → allow-set of session ids for the ContentStore filter.
2373
- let timelineDB = null;
2374
- const needsSessionDB = sort === "timeline" || typeof projectScope === "string";
2375
- if (needsSessionDB) {
2376
- try {
2377
- const sessionsDir = getSessionDir();
2378
- const projectDir = getProjectDir();
2379
- const dbFile = resolveSessionDbPath({ projectDir, sessionsDir });
2380
- if (existsSync(dbFile)) {
2381
- timelineDB = new SessionDB({ dbPath: dbFile });
2382
- }
2383
- }
2384
- catch { /* SessionDB unavailable — search ContentStore + auto-memory only */ }
2385
- }
2386
- // Resolve the session-id allow-set once for the relevance-mode path —
2387
- // searchAllSources resolves its own copy for timeline mode. Empty set
2388
- // is preserved (means "no events for this project"), which surfaces
2389
- // only legacy `session_id=''` chunks via the post-filter.
2390
- let relevanceAllowSet;
2391
- if (typeof projectScope === "string" && timelineDB) {
2392
- try {
2393
- relevanceAllowSet = new Set(timelineDB.getSessionIdsForProject(projectScope));
2394
- }
2395
- catch { /* best-effort */ }
2396
- }
2397
- const configDir = _detectedAdapter?.getConfigDir() ?? resolveClaudeConfigDir();
2398
- try {
2399
- for (const q of queryList) {
2400
- if (totalSize > MAX_TOTAL) {
2401
- sections.push(`## ${q}\n(output cap reached)\n`);
2402
- continue;
2403
- }
2404
- let results;
2405
- if (sort === "timeline") {
2406
- results = searchAllSources({
2407
- query: q,
2408
- limit: effectiveLimit,
2409
- store,
2410
- sort,
2411
- source,
2412
- contentType,
2413
- sessionDB: timelineDB,
2414
- projectDir: getProjectDir(),
2415
- configDir,
2416
- adapter: _detectedAdapter ?? undefined,
2417
- projectScope,
2418
- });
2419
- }
2420
- else {
2421
- results = store.searchWithFallback(q, effectiveLimit, source, contentType, "like", relevanceAllowSet);
2422
- }
2423
- if (results.length === 0) {
2424
- sections.push(`## ${q}\nNo results found.`);
2425
- continue;
2426
- }
2427
- const formatted = results
2428
- .map((r, i) => {
2429
- const origin = r.origin || "current-session";
2430
- const ts = r.timestamp ? r.timestamp.slice(0, 16).replace("T", " ") : "";
2431
- const header = `--- [${origin}${ts ? " | " + ts : ""} | ${r.source}] ---`;
2432
- const heading = `### ${r.title}`;
2433
- const snippet = extractSnippet(r.content, q, 1500, r.highlighted);
2434
- return `${header}\n${heading}\n\n${snippet}`;
2435
- })
2436
- .join("\n\n");
2437
- sections.push(`## ${q}\n\n${formatted}`);
2438
- totalSize += formatted.length;
2439
- }
2440
- }
2441
- finally {
2442
- try {
2443
- timelineDB?.close();
2444
- }
2445
- catch { }
2446
- }
2447
- let output = sections.join("\n\n---\n\n");
2448
- // Report auto-refreshed stale sources
2449
- if (store.lastRefreshCount > 0) {
2450
- output = `> Auto-refreshed ${store.lastRefreshCount} stale source${store.lastRefreshCount > 1 ? "s" : ""} (file changed since indexing).\n\n` + output;
2451
- }
2452
- // Throttle counter — always surfaced so agents can pace themselves
2453
- // proactively instead of discovering the limit only after results are
2454
- // already truncated. Soft warning after SEARCH_MAX_RESULTS_AFTER calls;
2455
- // gentle informational line before that.
2456
- const throttleRemaining = Math.max(0, SEARCH_BLOCK_AFTER - searchCallCount);
2457
- const softCapRemaining = Math.max(0, SEARCH_MAX_RESULTS_AFTER - searchCallCount);
2458
- if (searchCallCount >= SEARCH_MAX_RESULTS_AFTER) {
2459
- output += `\n\n⚠ search call #${searchCallCount}/${SEARCH_BLOCK_AFTER} in this window. ` +
2460
- `Results limited to ${effectiveLimit}/query. ${throttleRemaining} call(s) remaining before block. ` +
2461
- `Batch queries: ctx_search(queries: ["q1","q2","q3"]) or use ctx_batch_execute.`;
2462
- }
2463
- else {
2464
- output += `\n\n> Throttle: call #${searchCallCount}/${SEARCH_BLOCK_AFTER} in this window. ` +
2465
- `${softCapRemaining} call(s) before soft cap. ` +
2466
- `Prefer ctx_search(queries: [...]) array form for multi-query workloads — it counts as a single call.`;
2467
- }
2468
- if (output.trim().length === 0) {
2469
- const sources = store.listSources();
2470
- const sourceList = sources.length > 0
2471
- ? `\nIndexed sources: ${sources.map((s) => `"${s.label}" (${s.chunkCount} sections)`).join(", ")}`
2472
- : "";
2473
- return trackResponse("ctx_search", {
2474
- content: [{ type: "text", text: `No results found.${sourceList}` }],
2475
- });
2476
- }
2477
- return trackResponse("ctx_search", {
2478
- content: [{ type: "text", text: output }],
2479
- });
2480
- }
2481
- catch (err) {
2482
- const message = err instanceof Error ? err.message : String(err);
2483
- return trackResponse("ctx_search", {
2484
- content: [{ type: "text", text: `Search error: ${message}` }],
2485
- isError: true,
2486
- });
2487
- }
2488
- });
2489
- // ─────────────────────────────────────────────────────────
2490
- // Turndown path resolution (external dep, like better-sqlite3)
2491
- // ─────────────────────────────────────────────────────────
2492
- let _turndownPath = null;
2493
- let _gfmPluginPath = null;
2494
- function resolveTurndownPath() {
2495
- if (!_turndownPath) {
2496
- const require = createRequire(import.meta.url);
2497
- _turndownPath = require.resolve("turndown");
2498
- }
2499
- return _turndownPath;
2500
- }
2501
- function resolveGfmPluginPath() {
2502
- if (!_gfmPluginPath) {
2503
- const require = createRequire(import.meta.url);
2504
- _gfmPluginPath = require.resolve("turndown-plugin-gfm");
2505
- }
2506
- return _gfmPluginPath;
2507
- }
2508
- // ─────────────────────────────────────────────────────────
2509
- // Tool: fetch_and_index
2510
- // ─────────────────────────────────────────────────────────
2511
- // Subprocess code that fetches a URL, detects Content-Type, and outputs a
2512
- // __CM_CT__:<type> marker on the first line so the handler can route to the
2513
- // appropriate indexing strategy. HTML is converted to markdown via Turndown.
2514
- export function buildFetchCode(url, outputPath) {
2515
- const turndownPath = JSON.stringify(resolveTurndownPath());
2516
- const gfmPath = JSON.stringify(resolveGfmPluginPath());
2517
- const escapedOutputPath = JSON.stringify(outputPath);
2518
- // Embed classifyIp into the subprocess so the connect-time DNS lookup is
2519
- // re-validated with the same policy as ssrfGuard. Without this, an attacker
2520
- // can serve a public IP for the parent's pre-flight ssrfGuard lookup and
2521
- // then a blocked IP (e.g. 169.254.169.254 IMDS) for the subprocess fetch's
2522
- // own lookup — classic DNS rebinding across the parent/child boundary.
2523
- //
2524
- // CRITICAL: bundlers (esbuild) rename top-level identifiers — `classifyIp`
2525
- // becomes e.g. `_h` in server.bundle.mjs. `classifyIp.toString()` returns
2526
- // the renamed source `function _h(t){...}`, but the embedded subprocess
2527
- // template references the literal name `classifyIp` (and the function's
2528
- // own internal recursion is also `_h(...)`). Result: the subprocess sees
2529
- // `function _h(t){...; return _h(...)}` injected, then references to
2530
- // `classifyIp` blow up with `ReferenceError: classifyIp is not defined`.
2531
- //
2532
- // Fix: emit `var <fnName> = <fn-expr>; var classifyIp = <fnName>;`. The
2533
- // named function expression preserves recursion under whatever name the
2534
- // bundler chose, and the alias re-exposes the canonical `classifyIp`
2535
- // identifier the rest of the embedded script depends on.
2536
- const classifyIpInner = classifyIp.toString();
2537
- const classifyIpFnName = classifyIp.name || "classifyIp";
2538
- const classifyIpSrc = classifyIpFnName === "classifyIp"
2539
- ? `var classifyIp = ${classifyIpInner};`
2540
- : `var ${classifyIpFnName} = ${classifyIpInner};\nvar classifyIp = ${classifyIpFnName};`;
2541
- const strictMode = process.env.CTX_FETCH_STRICT === "1";
2542
- return `
2543
- const TurndownService = require(${turndownPath});
2544
- const { gfm } = require(${gfmPath});
2545
- const fs = require('fs');
2546
- const dns = require('no' + 'de:dns');
2547
- const dnsPromises = require('no' + 'de:dns/promises');
2548
- const url = ${JSON.stringify(url)};
2549
- const outputPath = ${escapedOutputPath};
2550
-
2551
- // Strip proxy env vars from this subprocess only. A configured outbound
2552
- // proxy (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY) would route fetch through
2553
- // an arbitrary target — DNS resolution happens at the proxy and the
2554
- // in-subprocess DNS rebinding guard never sees the rebound IP. The
2555
- // sandbox fetch path has no legitimate need for an upstream proxy.
2556
- delete process.env.HTTP_PROXY;
2557
- delete process.env.HTTPS_PROXY;
2558
- delete process.env.ALL_PROXY;
2559
- delete process.env.http_proxy;
2560
- delete process.env.https_proxy;
2561
- delete process.env.all_proxy;
2562
- delete process.env.npm_config_proxy;
2563
- delete process.env.npm_config_https_proxy;
2564
-
2565
- ${classifyIpSrc}
2566
-
2567
- const STRICT = ${JSON.stringify(strictMode)};
2568
-
2569
- // SSRF rebinding defense: every dns.lookup call inside this subprocess
2570
- // (including the one undici performs to connect the fetch socket) is
2571
- // re-validated against the same policy ssrfGuard runs in the parent.
2572
- // Even if a hostname rebinds between the parent's pre-flight check and
2573
- // the subprocess's actual connect, the connect-time lookup re-classifies
2574
- // every returned record and aborts before TCP if any verdict is "block".
2575
- const _origLookup = dns.lookup;
2576
- dns.lookup = function patchedLookup(hostname, options, callback) {
2577
- if (typeof options === 'function') { callback = options; options = {}; }
2578
- if (typeof options === 'number') { options = { family: options }; }
2579
- const wantAll = options && options.all;
2580
- const opts = Object.assign({}, options || {}, { all: true, verbatim: true });
2581
- _origLookup(hostname, opts, function(err, records) {
2582
- if (err) return callback(err);
2583
- if (!Array.isArray(records)) {
2584
- records = [{ address: records, family: (options && options.family) || 4 }];
2585
- }
2586
- for (var i = 0; i < records.length; i++) {
2587
- var verdict = classifyIp(records[i].address);
2588
- if (verdict === 'block' || (STRICT && verdict === 'private')) {
2589
- return callback(new Error(
2590
- 'SSRF blocked at connect-time: ' + hostname +
2591
- ' resolves to ' + records[i].address +
2592
- ' (' + verdict + ')'
2593
- ));
2594
- }
2595
- }
2596
- if (wantAll) callback(null, records);
2597
- else callback(null, records[0].address, records[0].family);
2598
- });
2599
- };
2600
-
2601
- // dns/promises is a separate function reference. Patching dns.lookup does
2602
- // NOT affect dnsPromises.lookup. Today undici's connect path uses callback
2603
- // dns.lookup so default fetch is covered, but the invariant is fragile —
2604
- // any future undici switch (or user code calling dnsPromises.lookup
2605
- // directly) would bypass the guard. Patch both to keep the contract.
2606
- const _origPromisesLookup = dnsPromises.lookup;
2607
- dnsPromises.lookup = async function patchedPromisesLookup(hostname, options) {
2608
- const opts = Object.assign({}, options || {}, { all: true, verbatim: true });
2609
- const records = await _origPromisesLookup(hostname, opts);
2610
- const list = Array.isArray(records) ? records : [records];
2611
- for (var i = 0; i < list.length; i++) {
2612
- var verdict = classifyIp(list[i].address);
2613
- if (verdict === 'block' || (STRICT && verdict === 'private')) {
2614
- throw new Error(
2615
- 'SSRF blocked at connect-time: ' + hostname +
2616
- ' resolves to ' + list[i].address + ' (' + verdict + ')'
2617
- );
2618
- }
2619
- }
2620
- return options && options.all
2621
- ? list
2622
- : { address: list[0].address, family: list[0].family };
2623
- };
2624
-
2625
- // dns.resolve4 / dns.resolve6 use a different code path (no getaddrinfo,
2626
- // no /etc/hosts) than dns.lookup — they must be patched separately or the
2627
- // guard is trivially bypassed by any caller using dns.resolve* directly.
2628
- ['resolve4', 'resolve6'].forEach(function patchResolve(name) {
2629
- const _origResolve = dns[name];
2630
- dns[name] = function patchedResolve(hostname, options, cb) {
2631
- if (typeof options === 'function') { cb = options; options = undefined; }
2632
- _origResolve.call(dns, hostname, options || {}, function(err, addrs) {
2633
- if (err) return cb(err);
2634
- var withTtl = options && options.ttl;
2635
- for (var i = 0; i < addrs.length; i++) {
2636
- var ip = withTtl ? addrs[i].address : addrs[i];
2637
- var v = classifyIp(ip);
2638
- if (v === 'block' || (STRICT && v === 'private')) {
2639
- return cb(new Error(
2640
- 'SSRF blocked at connect-time: ' + hostname +
2641
- ' resolves to ' + ip + ' (' + v + ')'
2642
- ));
2643
- }
2644
- }
2645
- cb(null, addrs);
2646
- });
2647
- };
2648
- });
2649
-
2650
- // Generic dns.resolve is a polymorphic dispatcher (rrtype-driven). Internally
2651
- // Node delegates to dns.resolve4/dns.resolve6 for A/AAAA, but the patches
2652
- // above hook the *exported* references — Node's internal dispatcher holds
2653
- // captured originals and bypasses our patch. Patch the wrapper explicitly:
2654
- // classify A/AAAA records the same way; pass through CNAME/MX/TXT/SRV/etc.
2655
- const _origResolveGeneric = dns.resolve;
2656
- dns.resolve = function patchedResolveGeneric(hostname, rrtype, cb) {
2657
- if (typeof rrtype === 'function') { cb = rrtype; rrtype = 'A'; }
2658
- _origResolveGeneric.call(dns, hostname, rrtype, function(err, records) {
2659
- if (err) return cb(err);
2660
- if ((rrtype === 'A' || rrtype === 'AAAA') && Array.isArray(records)) {
2661
- for (var i = 0; i < records.length; i++) {
2662
- var ip = records[i];
2663
- var v = classifyIp(ip);
2664
- if (v === 'block' || (STRICT && v === 'private')) {
2665
- return cb(new Error(
2666
- 'SSRF blocked at connect-time: ' + hostname +
2667
- ' resolves to ' + ip + ' (' + v + ')'
2668
- ));
2669
- }
2670
- }
2671
- }
2672
- cb(null, records);
2673
- });
2674
- };
2675
-
2676
- function emit(ct, content) {
2677
- // Write content to file to bypass executor stdout truncation (100KB limit).
2678
- // Only the content-type marker goes to stdout.
2679
- fs.writeFileSync(outputPath, content);
2680
- console.log('__CM_CT__:' + ct);
2681
- }
2682
-
2683
- // Manual redirect handling: a 3xx Location header can rebind the subprocess
2684
- // fetch to an alternate host the parent's pre-flight ssrfGuard never saw.
2685
- // Even with the connect-time DNS patch, a redirect target that is a literal
2686
- // IP (e.g. http://169.254.169.254/) skips getaddrinfo entirely. Walk the
2687
- // chain manually so every hop runs through classifyIp before the next fetch.
2688
- const MAX_REDIRECTS = 5;
2689
- async function fetchWithManualRedirect(initialUrl) {
2690
- let currentUrl = initialUrl;
2691
- for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
2692
- const resp = await fetch(currentUrl, { redirect: 'manual' });
2693
- if (resp.status < 300 || resp.status >= 400) return resp;
2694
- const location = resp.headers.get('location') || resp.headers.get('Location');
2695
- if (!location) return resp;
2696
- if (redirectCount === MAX_REDIRECTS) {
2697
- throw new Error('SSRF blocked: redirect chain exceeded ' + MAX_REDIRECTS + ' hops');
2698
- }
2699
- let nextParsed;
2700
- try { nextParsed = new URL(location, currentUrl); } catch (e) {
2701
- throw new Error('SSRF blocked: invalid redirect Location: ' + location);
2702
- }
2703
- if (nextParsed.protocol !== 'http:' && nextParsed.protocol !== 'https:') {
2704
- throw new Error('SSRF blocked: redirect to non-http(s) scheme ' + nextParsed.protocol);
2705
- }
2706
- // If the redirect target is a literal IP, classify it directly — no DNS
2707
- // lookup will fire and the connect-time guard would never see it.
2708
- const hostname = nextParsed.hostname.replace(/^\[|\]$/g, '');
2709
- const isIpLiteral = /^[0-9.]+$/.test(hostname) || hostname.includes(':');
2710
- if (isIpLiteral) {
2711
- const verdict = classifyIp(hostname);
2712
- if (verdict === 'block' || (STRICT && verdict === 'private')) {
2713
- throw new Error('SSRF blocked: redirect to ' + hostname + ' (' + verdict + ')');
2714
- }
2715
- } else {
2716
- // Hostname target: resolve and classify every record. The patched
2717
- // dns.lookup also fires on the next fetch's connect, but checking
2718
- // here gives a clearer error and short-circuits before TCP setup.
2719
- const records = await dnsPromises.lookup(hostname, { all: true, verbatim: true });
2720
- for (const rec of records) {
2721
- const verdict = classifyIp(rec.address);
2722
- if (verdict === 'block' || (STRICT && verdict === 'private')) {
2723
- throw new Error(
2724
- 'SSRF blocked: redirect target ' + hostname +
2725
- ' resolves to ' + rec.address + ' (' + verdict + ')'
2726
- );
2727
- }
2728
- }
2729
- }
2730
- currentUrl = nextParsed.toString();
2731
- }
2732
- throw new Error('SSRF blocked: redirect chain exceeded ' + MAX_REDIRECTS + ' hops');
2733
- }
2734
-
2735
- // Subprocess response-body size cap. A malicious or unexpectedly large
2736
- // endpoint reachable through ctx_fetch_and_index would otherwise stream
2737
- // gigabytes into resp.text(), then into outputPath, then into the parent
2738
- // MCP server's heap via readFileSync. 50 MB is far above typical web
2739
- // page / API response sizes (~1-5 MB) but bounded enough to keep parent
2740
- // heap survivable. Cap both early via Content-Length and after the read.
2741
- const MAX_FETCH_BYTES = 50 * 1024 * 1024;
2742
- async function safeText(resp) {
2743
- const cl = parseInt(resp.headers.get('content-length') || '0', 10);
2744
- if (cl > MAX_FETCH_BYTES) {
2745
- throw new Error('Response too large: Content-Length ' + cl + ' exceeds ' + MAX_FETCH_BYTES);
2746
- }
2747
- const text = await resp.text();
2748
- if (text.length > MAX_FETCH_BYTES) {
2749
- throw new Error('Response too large: ' + text.length + ' bytes exceeds ' + MAX_FETCH_BYTES);
2750
- }
2751
- return text;
2752
- }
2753
-
2754
- async function main() {
2755
- const resp = await fetchWithManualRedirect(url);
2756
- if (!resp.ok) { console.error("HTTP " + resp.status); process.exit(1); }
2757
- const contentType = resp.headers.get('content-type') || '';
2758
-
2759
- // --- JSON responses ---
2760
- if (contentType.includes('application/json') || contentType.includes('+json')) {
2761
- const text = await safeText(resp);
2762
- try {
2763
- const pretty = JSON.stringify(JSON.parse(text), null, 2);
2764
- emit('json', pretty);
2765
- } catch {
2766
- emit('text', text);
2767
- }
2768
- return;
2769
- }
2770
-
2771
- // --- HTML responses (default for text/html, application/xhtml+xml) ---
2772
- if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
2773
- const html = await safeText(resp);
2774
- const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
2775
- td.use(gfm);
2776
- td.remove(['script', 'style', 'nav', 'header', 'footer', 'noscript']);
2777
- emit('html', td.turndown(html));
2778
- return;
2779
- }
2780
-
2781
- // --- Everything else: plain text, CSV, XML, etc. ---
2782
- const text = await safeText(resp);
2783
- emit('text', text);
2784
- }
2785
- main();
2786
- `;
2787
- }
2788
- // ─────────────────────────────────────────────────────────
2789
- // fetch_and_index helpers — split into parallel-safe fetch and serial-only index
2790
- // ─────────────────────────────────────────────────────────
2791
- const FETCH_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
2792
- const FETCH_PREVIEW_LIMIT = 3072;
2793
- function formatFetchTtl(ttlMs) {
2794
- if (ttlMs === 0)
2795
- return "0ms";
2796
- const day = 24 * 60 * 60 * 1000;
2797
- const hour = 60 * 60 * 1000;
2798
- const minute = 60 * 1000;
2799
- if (ttlMs % day === 0)
2800
- return `${ttlMs / day}d`;
2801
- if (ttlMs % hour === 0)
2802
- return `${ttlMs / hour}h`;
2803
- if (ttlMs % minute === 0)
2804
- return `${ttlMs / minute}m`;
2805
- return `${ttlMs}ms`;
2806
- }
2807
- /**
2808
- * Pure fetch step — TTL cache check + subprocess fetch. SAFE TO RUN IN PARALLEL.
2809
- * Performs zero SQLite writes (only reads source meta). Caller must funnel
2810
- * fetched results through `indexFetched` serially to avoid FTS5 WAL contention.
2811
- */
2812
- /**
2813
- * SSRF guard for ctx_fetch_and_index: validate URL scheme + resolve target IP +
2814
- * block link-local / IMDS / multicast / reserved IP ranges. Returns null if
2815
- * safe; returns a FetchOneResult fetch_error if blocked.
2816
- *
2817
- * Policy (PR #401 ops review, developer-friendly default):
2818
- *
2819
- * **HARD BLOCK** (no legitimate dev workflow):
2820
- * - file://, gopher://, javascript:, data: schemes (only http: and https:)
2821
- * - 169.254.0.0/16 link-local (INCLUDES 169.254.169.254 = AWS/GCP/Azure IMDS
2822
- * cloud credential endpoint — high-value target for indirect prompt injection)
2823
- * - IPv6 link-local fe80::/10
2824
- * - Multicast (224+ IPv4, ff00::/8 IPv6) and reserved (0.0.0.0/8) ranges
2825
- *
2826
- * **ALLOW by default** (legitimate developer use cases dominate):
2827
- * - localhost, 127.x.x.x, ::1 (local dev servers — Next.js, Vite, Postgres, …)
2828
- * - 10.x, 172.16-31.x, 192.168.x RFC1918 private (developer's internal network)
2829
- *
2830
- * **STRICT MODE** opt-in via env var: `CTX_FETCH_STRICT=1`
2831
- * - Blocks loopback + RFC1918 too
2832
- * - For hosted/CI environments where the runtime isn't the user's own machine
2833
- *
2834
- * DNS resolution is performed against the resolved IP (not just URL parse) so a
2835
- * hostname like `evil.com` pointing to 169.254.169.254 is rejected — defends
2836
- * against attacker-controlled DNS records and DNS rebinding.
2837
- */
2838
- async function ssrfGuard(rawUrl) {
2839
- let parsed;
2840
- try {
2841
- parsed = new URL(rawUrl);
2842
- }
2843
- catch {
2844
- return { kind: "fetch_error", url: rawUrl, error: "invalid URL", reason: "exit" };
2845
- }
2846
- // 1. Scheme allowlist — http and https only
2847
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
2848
- return {
2849
- kind: "fetch_error",
2850
- url: rawUrl,
2851
- error: `URL scheme "${parsed.protocol}" not allowed (only http: and https:)`,
2852
- reason: "exit",
2853
- };
2854
- }
2855
- const strict = process.env.CTX_FETCH_STRICT === "1";
2856
- // 2. DNS resolve + check IP ranges (hard-block + optional strict-mode block)
2857
- try {
2858
- const { lookup } = await import("node:dns/promises");
2859
- const records = await lookup(parsed.hostname, { all: true, verbatim: true });
2860
- for (const rec of records) {
2861
- const verdict = classifyIp(rec.address);
2862
- if (verdict === "block") {
2863
- return {
2864
- kind: "fetch_error",
2865
- url: rawUrl,
2866
- error: `URL "${parsed.hostname}" resolves to ${rec.address} — blocked (link-local / IMDS / multicast / reserved)`,
2867
- reason: "exit",
2868
- };
2869
- }
2870
- if (verdict === "private" && strict) {
2871
- return {
2872
- kind: "fetch_error",
2873
- url: rawUrl,
2874
- error: `URL "${parsed.hostname}" resolves to private IP ${rec.address} — blocked under CTX_FETCH_STRICT=1`,
2875
- reason: "exit",
2876
- };
2877
- }
2878
- }
2879
- }
2880
- catch (err) {
2881
- // libuv DNS error codes that typically indicate the resolver itself can't
2882
- // reach a nameserver — common when the MCP host process is running under
2883
- // a sandbox that blocks outbound network, OR a transient upstream DNS
2884
- // hiccup. Append an imperative retry hint so the agent does not capitulate
2885
- // to training data on the FIRST transient failure (PR #654 substitute —
2886
- // sibling-tool consistency with hooks/core/routing.mjs WebFetch wording).
2887
- const errCode = err?.code ?? "";
2888
- const isTransientDns = errCode === "ETIMEOUT" || errCode === "ETIMEDOUT" ||
2889
- errCode === "EAI_AGAIN" || errCode === "ENETUNREACH" || errCode === "EPERM";
2890
- const baseMsg = err instanceof Error ? err.message : String(err);
2891
- const hint = isTransientDns
2892
- ? " — transient DNS error; retry once before falling back. If it keeps failing, the MCP host may be running under a network sandbox; restart the host with network access enabled."
2893
- : "";
2894
- return {
2895
- kind: "fetch_error",
2896
- url: rawUrl,
2897
- error: `DNS lookup failed for "${parsed.hostname}": ${baseMsg}${hint}`,
2898
- reason: "exit",
2899
- };
2900
- }
2901
- return null; // safe to fetch
2902
- }
2903
- /**
2904
- * Classify an IP address.
2905
- * - "block": always blocked (link-local/IMDS/multicast/reserved/malformed)
2906
- * - "private": loopback or RFC1918 — allowed by default, blocked in strict mode
2907
- * - "public": safe to fetch
2908
- *
2909
- * Exported (via the function name) so SSRF tests can exercise the matcher directly.
2910
- */
2911
- export function classifyIp(rawIp) {
2912
- // RFC 6874 zone identifiers (`fe80::1%eth0`, URL-encoded `%25eth0`) must
2913
- // be stripped BEFORE any prefix/equality classification. Without the strip,
2914
- // a loopback `::1%eth0` no longer matches `lower === "::1"` and falls
2915
- // through to "public" — silently bypassing the SSRF guard. Strip first,
2916
- // classify second.
2917
- const pctIdx = rawIp.indexOf("%");
2918
- const ip = pctIdx === -1 ? rawIp : rawIp.slice(0, pctIdx);
2919
- const lower = ip.toLowerCase();
2920
- // IPv6 takes priority — check for `:` first so IPv4-mapped addresses
2921
- // (`::ffff:127.0.0.1`) don't get incorrectly routed through the IPv4 parser.
2922
- if (lower.includes(":")) {
2923
- // IPv4-mapped IPv6 (`::ffff:127.0.0.1`) — recurse through IPv4 classifier
2924
- const v4MappedMatch = lower.match(/^::ffff:([\d.]+)$/);
2925
- if (v4MappedMatch)
2926
- return classifyIp(v4MappedMatch[1]);
2927
- // Hard-block
2928
- if (lower === "::")
2929
- return "block"; // unspecified
2930
- if (lower.startsWith("fe8") || lower.startsWith("fe9") ||
2931
- lower.startsWith("fea") || lower.startsWith("feb"))
2932
- return "block"; // fe80::/10 link-local
2933
- if (lower.startsWith("ff"))
2934
- return "block"; // ff00::/8 multicast
2935
- // Private (loopback + ULA)
2936
- if (lower === "::1")
2937
- return "private";
2938
- if (lower.startsWith("fc") || lower.startsWith("fd"))
2939
- return "private"; // fc00::/7 ULA
2940
- return "public";
2941
- }
2942
- // IPv4 (or non-IP string — malformed = block)
2943
- if (!ip.includes("."))
2944
- return "block"; // not an IP at all
2945
- const parts = ip.split(".").map((p) => parseInt(p, 10));
2946
- if (parts.length !== 4 || parts.some((p) => isNaN(p) || p < 0 || p > 255))
2947
- return "block";
2948
- const [a, b] = parts;
2949
- // Hard-block (no legitimate use)
2950
- if (a === 169 && b === 254)
2951
- return "block"; // link-local incl. 169.254.169.254 (IMDS)
2952
- if (a === 0)
2953
- return "block"; // 0.0.0.0/8 (current network)
2954
- if (a >= 224)
2955
- return "block"; // 224.0.0.0+ multicast/reserved
2956
- // Private (loopback + RFC1918) — allow by default
2957
- if (a === 127)
2958
- return "private"; // 127.0.0.0/8 loopback
2959
- if (a === 10)
2960
- return "private"; // 10.0.0.0/8
2961
- if (a === 172 && b >= 16 && b <= 31)
2962
- return "private"; // 172.16.0.0/12
2963
- if (a === 192 && b === 168)
2964
- return "private"; // 192.168.0.0/16
2965
- return "public";
2966
- }
2967
- async function fetchOneUrl(url, source, force, ttl) {
2968
- // SSRF guard — reject file://, javascript:, loopback, RFC1918, IMDS, link-local
2969
- // BEFORE any cache lookup or subprocess spawn. Even cached entries shouldn't
2970
- // serve a previously-poisoned source label.
2971
- const ssrfBlock = await ssrfGuard(url);
2972
- if (ssrfBlock)
2973
- return ssrfBlock;
2974
- if (!force && ttl !== 0) {
2975
- const store = getStore();
2976
- // Cache key composes (source, url) so two distinct URLs sharing the same
2977
- // `source` label do not collide — they each get their own cache slot
2978
- // (commit 1f1243e regression test enforced).
2979
- const cacheKey = composeFetchCacheKey(source, url);
2980
- const meta = store.getSourceMeta(cacheKey);
2981
- if (meta) {
2982
- const indexedAt = new Date(meta.indexedAt + "Z"); // SQLite datetime is UTC without Z
2983
- const ageMs = Date.now() - indexedAt.getTime();
2984
- const cacheTtlMs = ttl ?? FETCH_TTL_MS;
2985
- if (ageMs < cacheTtlMs) {
2986
- const ageHours = Math.floor(ageMs / (60 * 60 * 1000));
2987
- const ageMin = Math.floor(ageMs / (60 * 1000));
2988
- const ageStr = ageHours > 0 ? `${ageHours}h ago` : ageMin > 0 ? `${ageMin}m ago` : "just now";
2989
- const estimatedBytes = meta.chunkCount * 1600; // ~1.6KB/chunk avg
2990
- return { kind: "cached", label: meta.label, chunkCount: meta.chunkCount, estimatedBytes, ageStr, ttlStr: formatFetchTtl(cacheTtlMs) };
2991
- }
2992
- // Stale — fall through to re-fetch silently
2993
- }
2994
- }
2995
- const outputPath = join(tmpdir(), `ctx-fetch-${Date.now()}-${Math.random().toString(36).slice(2)}.dat`);
2996
- try {
2997
- const fetchCode = buildFetchCode(url, outputPath);
2998
- const result = await executor.execute({
2999
- language: "javascript",
3000
- code: fetchCode,
3001
- timeout: 30_000,
3002
- });
3003
- if (result.exitCode !== 0) {
3004
- // Subprocess fetch failure — undici / fetch can surface EAI_AGAIN /
3005
- // ETIMEDOUT / ENETUNREACH in stderr when the resolver is overloaded
3006
- // or the network is briefly unavailable. Append the same retry hint
3007
- // ssrfGuard's pre-flight DNS path emits so the agent doesn't capitulate
3008
- // to training data on the first transient failure (PR #654 substitute —
3009
- // sibling-tool consistency with hooks/core/routing.mjs WebFetch wording).
3010
- const raw = result.stderr || result.stdout || "unknown error";
3011
- const isTransientDns = /\b(EAI_AGAIN|ETIMEDOUT|ETIMEOUT|ENETUNREACH|EPERM|getaddrinfo)\b/.test(raw);
3012
- const hint = isTransientDns
3013
- ? " — transient DNS error; retry once before falling back. If it keeps failing, the MCP host may be running under a network sandbox; restart the host with network access enabled."
3014
- : "";
3015
- return { kind: "fetch_error", url, error: `${raw}${hint}`, reason: "exit" };
3016
- }
3017
- const header = (result.stdout || "").trim();
3018
- let markdown;
3019
- try {
3020
- // Parent-side defense-in-depth on the subprocess output size. The
3021
- // embedded safeText() in buildFetchCode already caps before writing,
3022
- // but a torn write (subprocess killed mid-write, fs cache desync,
3023
- // etc.) could still leave an oversized file. Bail before slurping
3024
- // multiple gigabytes into the long-running MCP server's heap.
3025
- const MAX_FETCH_OUTPUT_BYTES = 50 * 1024 * 1024;
3026
- const fileSize = statSync(outputPath).size;
3027
- if (fileSize > MAX_FETCH_OUTPUT_BYTES) {
3028
- return { kind: "fetch_error", url, error: `subprocess output ${fileSize} bytes exceeds cap ${MAX_FETCH_OUTPUT_BYTES}`, reason: "read" };
3029
- }
3030
- markdown = readFileSync(outputPath, "utf-8").trim();
3031
- }
3032
- catch {
3033
- return { kind: "fetch_error", url, error: "could not read subprocess output", reason: "read" };
3034
- }
3035
- if (markdown.length === 0) {
3036
- return { kind: "fetch_error", url, error: "empty content", reason: "empty" };
3037
- }
3038
- return { kind: "fetched", url, source, markdown, header };
3039
- }
3040
- catch (err) {
3041
- return {
3042
- kind: "fetch_error",
3043
- url,
3044
- error: err instanceof Error ? err.message : String(err),
3045
- reason: "throw",
3046
- };
3047
- }
3048
- finally {
3049
- try {
3050
- rmSync(outputPath);
3051
- }
3052
- catch { /* already gone */ }
3053
- }
3054
- }
3055
- /**
3056
- * Serial-only indexing step — single FTS5 write per call. Caller loops over
3057
- * fetched results and calls this one-at-a-time to avoid SQLite WAL contention
3058
- * (PRD finding E).
3059
- */
3060
- function indexFetched(f) {
3061
- const store = getStore();
3062
- // Storage label composed via composeFetchCacheKey so two URLs sharing a
3063
- // `source` label do not overwrite each other (commit 1f1243e). ctx_search()
3064
- // still finds both via LIKE-mode source filter on the `source` substring.
3065
- const storageLabel = composeFetchCacheKey(f.source, f.url);
3066
- const attribution = currentAttribution();
3067
- let indexed;
3068
- if (f.header === "__CM_CT__:json") {
3069
- indexed = store.indexJSON(f.markdown, storageLabel, undefined, attribution);
3070
- }
3071
- else if (f.header === "__CM_CT__:text") {
3072
- indexed = store.indexPlainText(f.markdown, storageLabel, undefined, attribution);
3073
- }
3074
- else {
3075
- indexed = store.index({ content: f.markdown, source: storageLabel, attribution });
3076
- }
3077
- // Track AFTER the FTS5 write succeeds — failed indexes shouldn't inflate the counter.
3078
- trackIndexed(Buffer.byteLength(f.markdown));
3079
- const preview = f.markdown.length > FETCH_PREVIEW_LIMIT
3080
- ? charSafePrefix(f.markdown, FETCH_PREVIEW_LIMIT) + "\n\n…[truncated — use ctx_search() for full content]"
3081
- : f.markdown;
3082
- return {
3083
- label: indexed.label,
3084
- totalChunks: indexed.totalChunks,
3085
- totalBytes: Buffer.byteLength(f.markdown),
3086
- preview,
3087
- };
3088
- }
3089
- server.registerTool("ctx_fetch_and_index", {
3090
- title: "Fetch & Index URL(s)",
3091
- // #846: fetches external URLs (open world) and writes them into the store.
3092
- annotations: {
3093
- readOnlyHint: false,
3094
- destructiveHint: false,
3095
- idempotentHint: false,
3096
- openWorldHint: true,
3097
- },
3098
- description: `Fetches URL content, converts HTML to markdown (JSON is chunked by key paths, plain text indexed directly), persists it in a searchable knowledge base, and returns a small preview window per source. The raw page bytes never enter your conversation — they live in storage and you retrieve any section on-demand via ctx_search.
3099
-
3100
- Caching: every fetch is cached on disk and reused for repeat calls within the TTL window. The default TTL is 24 hours; override per-call with the \`ttl\` parameter (milliseconds, \`ttl: 0\` bypasses cache like \`force: true\`). Stored content older than 14 days is cleaned up on startup.
3101
-
3102
- WHEN:
3103
- - You need web content (docs, changelogs, API references, spec pages) and the raw page bytes should NOT enter your conversation
3104
- - Multi-URL research (library evaluation, migration scans, doc comparisons): pass the \`requests\` array and a \`concurrency\` value 2-8 for parallel I/O
3105
- - You want repeat lookups against the same URL to be cheap (TTL cache hits return only a hint, no re-fetch)
3106
- - You want a long-lived cache window (override \`ttl\` upward for stable specs) or a guaranteed-fresh fetch (\`ttl: 0\` or \`force: true\`)
3107
-
3108
- WHEN NOT:
3109
- - You already have the content locally — store it via the inline index tool
3110
- - The page is SPA-rendered (JavaScript-required to materialize content) — this is a plain HTTP fetch, no headless browser
3111
-
3112
- RETURNS:
3113
- Per-source preview windows extracted around indexable headings plus indexing metadata (chunk counts, source labels, cache state). Raw content is NOT echoed back — retrieve any section on-demand via ctx_search(source: "<label>"). Concurrency parallelizes the fetch phase up to your chosen value (capped by the host's logical CPU count); the FTS5 write phase always runs serially because SQLite is a single-writer store. Net latency = max(fetch latency across the pool) + sum(per-source index write time). Cache hits skip both phases and return a small freshness hint instead of re-fetching. Use 4-8 for stable I/O-bound batches; lower the value when the target host enforces a per-IP rate limit you cannot raise.
3114
-
3115
- EXAMPLE: ctx_fetch_and_index(
3116
- requests: [{url: "https://react.dev/...", source: "react"}, {url: "https://vuejs.org/...", source: "vue"}],
3117
- concurrency: 5
3118
- )`,
3119
- inputSchema: z.object({
3120
- url: z.string().optional().describe("Single URL to fetch and index (legacy single-shape)"),
3121
- source: z
3122
- .string()
3123
- .optional()
3124
- .describe("Label for the indexed content when using single `url` (e.g., 'React useEffect docs', 'Supabase Auth API'). For batch, put source in each requests entry."),
3125
- requests: z
3126
- .preprocess(coerceJsonArray, z.array(z.object({
3127
- url: z.string().describe("URL to fetch"),
3128
- source: z.string().optional().describe("Label for this URL's indexed content"),
3129
- })).min(1))
3130
- .optional()
3131
- .describe("Batch shape: array of {url, source?} entries. Use with concurrency>1 for parallel fetch. " +
3132
- "Each request indexed under its own source label. Output preserves input order."),
3133
- concurrency: z
3134
- .coerce.number()
3135
- .int()
3136
- .min(1)
3137
- .max(8)
3138
- .optional()
3139
- .default(1)
3140
- .describe("Max URLs to fetch in parallel (1-8, default: 1). " +
3141
- "Use 4-8 for I/O-bound multi-URL batches (library docs, changelogs, pricing pages). " +
3142
- "Capped by os.cpus().length on small machines (response notes when capped). " +
3143
- "Indexing is always serial regardless — only fetches race."),
3144
- force: z
3145
- .preprocess(coerceBoolean, z.boolean())
3146
- .optional()
3147
- .describe("Skip cache and re-fetch even if content was recently indexed"),
3148
- ttl: z
3149
- .coerce.number()
3150
- .int()
3151
- .min(0)
3152
- .optional()
3153
- .describe("Override the cache freshness window for this call, in milliseconds. " +
3154
- "`ttl: 0` bypasses the cache like `force: true`; omit to use the default 24h TTL."),
3155
- }),
3156
- }, async ({ url, source, requests, concurrency, force, ttl }) => {
3157
- // Normalize input: legacy {url} or new {requests: [...]}.
3158
- // requests wins when both are provided (explicit batch intent).
3159
- const batch = requests
3160
- ? requests
3161
- : url
3162
- ? [{ url, source }]
3163
- : [];
3164
- if (batch.length === 0) {
3165
- return trackResponse("ctx_fetch_and_index", {
3166
- content: [{
3167
- type: "text",
3168
- text: "ctx_fetch_and_index requires either `url` (single) or `requests: [{url, source?}, ...]` (batch).",
3169
- }],
3170
- isError: true,
3171
- });
3172
- }
3173
- const isLegacySingle = !requests && batch.length === 1;
3174
- const requestedConcurrency = concurrency ?? 1;
3175
- // Parallel fetch via shared runPool primitive. capByCpuCount only for batch
3176
- // — single-URL doesn't need the cap (only one job, executor is one subprocess).
3177
- const jobs = batch.map((req) => ({
3178
- run: () => fetchOneUrl(req.url, req.source, force, ttl),
3179
- }));
3180
- const { settled, effectiveConcurrency, capped } = await runPool(jobs, {
3181
- concurrency: requestedConcurrency,
3182
- capByCpuCount: !isLegacySingle && requestedConcurrency > 1,
3183
- });
3184
- const finalized = [];
3185
- for (let i = 0; i < settled.length; i++) {
3186
- const r = settled[i];
3187
- if (r.status === "rejected") {
3188
- const message = r.reason instanceof Error ? r.reason.message : String(r.reason);
3189
- finalized.push({ kind: "job_error", url: batch[i].url, error: message });
3190
- continue;
3191
- }
3192
- const v = r.value;
3193
- if (v.kind === "cached") {
3194
- sessionStats.cacheHits++;
3195
- sessionStats.cacheBytesSaved += v.estimatedBytes;
3196
- // D2 Phase 5/7 — cache-hit event emission. `bytes_avoided` is the
3197
- // size of the cached payload that would have re-entered context
3198
- // had the TTL window missed. Best-effort, off the hot path.
3199
- const cachedBytes = v.estimatedBytes;
3200
- const cachedLabel = v.label;
3201
- setImmediate(() => emitCacheHitEvent({
3202
- sessionDbPath: getSessionDbPath(),
3203
- source: cachedLabel,
3204
- bytesAvoided: cachedBytes,
3205
- }));
3206
- finalized.push({ kind: "cached", label: v.label, chunkCount: v.chunkCount, ageStr: v.ageStr, ttlStr: v.ttlStr });
3207
- }
3208
- else if (v.kind === "fetch_error") {
3209
- finalized.push({ kind: "fetch_error", url: v.url, error: v.error, reason: v.reason });
3210
- }
3211
- else {
3212
- // Serial FTS5 write here — no parallel store.index calls.
3213
- // Cache miss: the URL was not in the TTL window so we paid the
3214
- // network round-trip + re-indexed. Counted here so ctx_stats can
3215
- // report nominal cache_hit_rate alongside the existing hit metrics.
3216
- sessionStats.cacheMisses++;
3217
- finalized.push({ kind: "fetched", indexed: indexFetched(v) });
3218
- }
3219
- }
3220
- // Backward-compat single-URL response shape — preserve the EXACT original wording.
3221
- if (isLegacySingle) {
3222
- const r = finalized[0];
3223
- if (r.kind === "cached") {
3224
- return trackResponse("ctx_fetch_and_index", {
3225
- content: [{
3226
- type: "text",
3227
- text: `Cached: **${r.label}** — ${r.chunkCount} sections, indexed ${r.ageStr} (fresh, TTL: ${r.ttlStr}).\nTo refresh: call ctx_fetch_and_index again with \`force: true\`.\n\nYou MUST call ctx_search() to answer questions about this content — this cached response contains no content.\nUse: ctx_search(queries: [...], source: "${r.label}")`,
3228
- }],
3229
- });
3230
- }
3231
- if (r.kind === "fetched") {
3232
- const totalKB = (r.indexed.totalBytes / 1024).toFixed(1);
3233
- const text = [
3234
- `Fetched and indexed **${r.indexed.totalChunks} sections** (${totalKB}KB) from: ${r.indexed.label}`,
3235
- `Full content indexed in sandbox — use ctx_search(queries: [...], source: "${r.indexed.label}") for specific lookups.`,
3236
- "",
3237
- "---",
3238
- "",
3239
- r.indexed.preview,
3240
- ].join("\n");
3241
- return trackResponse("ctx_fetch_and_index", {
3242
- content: [{ type: "text", text }],
3243
- });
3244
- }
3245
- // fetch_error — preserve original error wording per reason
3246
- if (r.kind === "fetch_error") {
3247
- const text = r.reason === "empty" ? `Fetched ${r.url} but got empty content`
3248
- : r.reason === "read" ? `Fetched ${r.url} but could not read subprocess output`
3249
- : r.reason === "exit" ? `Failed to fetch ${r.url}: ${r.error}`
3250
- : /* throw */ `Fetch error: ${r.error}`;
3251
- return trackResponse("ctx_fetch_and_index", {
3252
- content: [{ type: "text", text }],
3253
- isError: true,
3254
- });
3255
- }
3256
- // job_error
3257
- return trackResponse("ctx_fetch_and_index", {
3258
- content: [{ type: "text", text: `Fetch error: ${r.error}` }],
3259
- isError: true,
3260
- });
3261
- }
3262
- // Batch response — aggregated summary; isError only when EVERY URL failed.
3263
- // Per-URL preview capped tightly so a 8-URL batch doesn't undo the
3264
- // context-savings the tool exists to deliver (PRD review finding G1).
3265
- const FETCH_BATCH_PREVIEW_LIMIT = 384; // ~3KB total for 8-URL batches
3266
- const lines = [];
3267
- let totalSections = 0;
3268
- let totalBytes = 0;
3269
- let cachedCount = 0;
3270
- let fetchedCount = 0;
3271
- let errorCount = 0;
3272
- const snippets = [];
3273
- for (const r of finalized) {
3274
- if (r.kind === "cached") {
3275
- cachedCount++;
3276
- lines.push(`- [cache] ${r.label} — ${r.chunkCount} sections (${r.ageStr}, TTL: ${r.ttlStr})`);
3277
- }
3278
- else if (r.kind === "fetched") {
3279
- fetchedCount++;
3280
- totalSections += r.indexed.totalChunks;
3281
- totalBytes += r.indexed.totalBytes;
3282
- const kb = (r.indexed.totalBytes / 1024).toFixed(1);
3283
- lines.push(`- [new] ${r.indexed.label} — ${r.indexed.totalChunks} sections (${kb}KB)`);
3284
- const snippet = r.indexed.preview.length > FETCH_BATCH_PREVIEW_LIMIT
3285
- ? r.indexed.preview.slice(0, FETCH_BATCH_PREVIEW_LIMIT).trimEnd() + "…"
3286
- : r.indexed.preview;
3287
- snippets.push(`### ${r.indexed.label}\n\n${snippet}`);
3288
- }
3289
- else {
3290
- errorCount++;
3291
- lines.push(`- [err] ${r.url}: ${r.error}`);
3292
- }
3293
- }
3294
- const totalKB = (totalBytes / 1024).toFixed(1);
3295
- const cappedNote = capped
3296
- ? ` cap=${effectiveConcurrency}/${cpus().length}cpu`
3297
- : "";
3298
- // Status line: counts + sections + size, with singular/plural agreement
3299
- // (count=1 → "1 error" not "1 errors") so the line stays grammatical.
3300
- const fmt = (n, sing, plur) => `${n} ${n === 1 ? sing : plur}`;
3301
- const headerLine = `fetched ${batch.length} c=${effectiveConcurrency}${cappedNote}. ` +
3302
- `ok=${fetchedCount} cache=${cachedCount} err=${errorCount}. ` +
3303
- `${fmt(totalSections, "section", "sections")} ${totalKB}KB.`;
3304
- const text = [
3305
- headerLine,
3306
- "",
3307
- ...lines,
3308
- "",
3309
- `ctx_search(queries: [...], source: "<label>") for full content.`,
3310
- ...(snippets.length > 0 ? ["", "---", "", ...snippets] : []),
3311
- ].join("\n");
3312
- return trackResponse("ctx_fetch_and_index", {
3313
- content: [{ type: "text", text }],
3314
- isError: errorCount === batch.length, // only mark error if every URL failed
3315
- });
3316
- });
3317
- // ─────────────────────────────────────────────────────────
3318
- // Tool: batch_execute
3319
- // ─────────────────────────────────────────────────────────
3320
- server.registerTool("ctx_batch_execute", {
3321
- title: "Batch Execute & Search",
3322
- // #846: runs arbitrary shell commands (with network) and indexes output.
3323
- annotations: {
3324
- readOnlyHint: false,
3325
- destructiveHint: true,
3326
- idempotentHint: false,
3327
- openWorldHint: true,
3328
- },
3329
- description: `Run multiple commands in ONE call. Every command's output is auto-indexed into the knowledge base; if you also pass \`queries\`, the matching sections come back in the same round trip so a follow-up search call is not needed.
3330
-
3331
- Concurrency parallelizes the FETCH phase (run-the-commands). The DERIVATION phase — turning raw output into an answer — still belongs in code: add a processing command that consumes the indexed output and prints only the answer, so the raw bytes never enter your conversation (Think-in-Code, same principle as the sandbox tool).
3332
-
3333
- WHEN:
3334
- - You have 3+ related commands you would otherwise run sequentially (multi-issue lookups, git log + git diff + git blame, multi-file reads, multi-region cloud queries)
3335
- - You want to gather AND query in one round trip — pass \`queries\` so the matching sections come back inline
3336
- - You want to parallelize I/O-bound work — pass \`concurrency\` 2-8 (network calls, gh CLI, cloud APIs, multi-repo git reads)
3337
- - The combined output is large enough that piping it through ctx_search later would itself be expensive — let auto-index + inline queries do both in one shot
3338
-
3339
- WHEN NOT:
3340
- - Single command with no follow-up query — run it in the sandbox tool directly
3341
- - CPU-bound or stateful commands — keep concurrency at 1 (npm test, build, lint, port-binding servers, lock-file holders, anything that races on the same resource)
3342
-
3343
- RETURNS:
3344
- Auto-indexed section list per command label, plus top matches per query (when \`queries\` is passed). Raw output is NOT echoed in full — only the matched windows. Concurrency>1 switches each command to its own per-command timeout (no shared budget); concurrency=1 preserves the legacy shared-budget cascading-skip-on-timeout path. Use 4-8 for I/O-bound batches; keep at 1 for CPU work or shared-state commands; lower the value when target hosts enforce per-IP rate limits.
3345
-
3346
- EXAMPLE: ctx_batch_execute(
3347
- commands: [
3348
- {label: "issue 1", command: "gh issue view 1"},
3349
- {label: "issue 2", command: "gh issue view 2"},
3350
- {label: "summarize", command: "echo done"}
3351
- ],
3352
- queries: ["root cause", "proposed fix"],
3353
- concurrency: 2
3354
- )`,
3355
- inputSchema: z.object({
3356
- commands: z.preprocess(coerceCommandsArray, z
3357
- .array(z.object({
3358
- label: z
3359
- .string()
3360
- .describe("Section header for this command's output (e.g., 'README', 'Package.json', 'Source Tree')"),
3361
- command: z
3362
- .string()
3363
- .describe("Shell command to execute"),
3364
- }))
3365
- .min(1)
3366
- .describe("Commands to execute as a batch. Output is labeled with the section header. " +
3367
- "Default order is sequential; pass concurrency>1 to run in parallel (output stays in input order).")),
3368
- queries: z.preprocess(coerceJsonArray, z
3369
- .array(z.string())
3370
- .min(1)
3371
- .describe("Search queries to extract information from indexed output. Use 5-8 comprehensive queries. " +
3372
- "Each returns top 5 matching sections with full content. " +
3373
- "This is your ONLY chance — put ALL your questions here. No follow-up calls needed.")),
3374
- timeout: z
3375
- .coerce.number()
3376
- .optional()
3377
- .describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs. With concurrency=1, the value (when set) is a shared budget across commands; with concurrency>1, it is applied per-command."),
3378
- concurrency: z
3379
- .coerce.number()
3380
- .int()
3381
- .min(1)
3382
- .max(8)
3383
- .optional()
3384
- .default(1)
3385
- .describe("Max commands to run in parallel (1-8, default: 1). " +
3386
- "Use 4-8 for I/O-bound batches (network, gh, curl, multi-repo git reads). " +
3387
- "Keep at 1 for CPU-bound (npm test, build, lint) or stateful commands (ports, locks). " +
3388
- ">1 switches to per-command timeouts (no shared budget) and " +
3389
- "individual `(timed out)` blocks instead of cascading skip."),
3390
- cwd: z
3391
- .string()
3392
- .optional()
3393
- .describe("Optional working directory for all shell commands in this batch."),
3394
- query_scope: z
3395
- .enum(["batch", "global"])
3396
- .optional()
3397
- .default("batch")
3398
- .describe("Scope for `queries` (default: `batch`). " +
3399
- "`batch` searches ONLY the chunks produced by this batch's commands " +
3400
- "— useful when you want answers about the just-fetched output. " +
3401
- "`global` searches the entire persistent index (same scope as ctx_search) " +
3402
- "— useful when you want the batch commands to enrich context and " +
3403
- "the queries to also surface related prior knowledge in one round trip."),
3404
- }),
3405
- }, async ({ commands, queries, timeout, concurrency, cwd, query_scope }) => {
3406
- // Security: check each command against deny patterns
3407
- for (const cmd of commands) {
3408
- const denied = checkDenyPolicy(cmd.command, "batch_execute");
3409
- if (denied)
3410
- return denied;
3411
- }
3412
- try {
3413
- // Inject NODE_OPTIONS for FS read tracking in spawned Node processes.
3414
- // The executor denies NODE_OPTIONS in its env (security), so we set it
3415
- // as an inline shell prefix. This only affects child `node` invocations.
3416
- const nodeOptsPrefix = buildBatchNodeOptionsPrefix(runtimes.shell, CM_FS_PRELOAD);
3417
- // Full stdout is preserved per-command and indexed into FTS5 (Issue #61, #197).
3418
- // Concurrency>1 switches to a worker pool with per-command timeouts.
3419
- const effTimeout = resolveExecTimeout(timeout);
3420
- const { outputs: perCommandOutputs, timedOut } = await runBatchCommands(commands, {
3421
- timeout: effTimeout,
3422
- concurrency,
3423
- nodeOptsPrefix,
3424
- cwd,
3425
- onFsBytes: (bytes) => { sessionStats.bytesSandboxed += bytes; },
3426
- }, executor);
3427
- const stdout = perCommandOutputs.join("\n");
3428
- const totalBytes = Buffer.byteLength(stdout);
3429
- const totalLines = stdout.split("\n").length;
3430
- if (timedOut && perCommandOutputs.length === 0) {
3431
- return trackResponse("ctx_batch_execute", {
3432
- content: [
3433
- {
3434
- type: "text",
3435
- text: `Batch timed out after ${effTimeout}ms. No output captured.`,
3436
- },
3437
- ],
3438
- isError: true,
3439
- });
3440
- }
3441
- // Track indexed bytes (raw data that stays in sandbox)
3442
- trackIndexed(totalBytes);
3443
- // Index into knowledge base — markdown heading chunking splits by # labels
3444
- const store = getStore();
3445
- const source = `batch:${commands
3446
- .map((c) => c.label)
3447
- .join(",")
3448
- .slice(0, 80)}`;
3449
- const indexed = store.index({ content: stdout, source, attribution: currentAttribution() });
3450
- // Commands inventory — list what the agent actually ran so the
3451
- // response itself documents intent, not just per-section echoes.
3452
- // Placed before "## Indexed Sections" so it scans top-down with
3453
- // the human asking "what just happened" (Issues #717 + #736).
3454
- const commandsInventory = ["## Commands", ""];
3455
- for (const c of commands) {
3456
- commandsInventory.push(`- ${c.label}: \`${truncateCommandForEcho(c.command)}\``);
3457
- }
3458
- // Build section inventory — direct query by source_id (no FTS5 MATCH needed)
3459
- const allSections = store.getChunksBySource(indexed.sourceId);
3460
- const inventory = ["## Indexed Sections", ""];
3461
- const sectionTitles = [];
3462
- for (const s of allSections) {
3463
- const bytes = Buffer.byteLength(s.content);
3464
- inventory.push(`- ${s.title} (${(bytes / 1024).toFixed(1)}KB)`);
3465
- sectionTitles.push(s.title);
3466
- }
3467
- // Run all search queries — default scope is batch-local (legacy behavior).
3468
- // When the caller passes query_scope: "global", searches reach the entire
3469
- // persistent index in the same round trip. Cross-source search remains
3470
- // available via explicit ctx_search() as well.
3471
- const queryResults = formatBatchQueryResults(store, queries, source, undefined, query_scope);
3472
- // Get searchable terms for edge cases where follow-up is needed
3473
- const distinctiveTerms = store.getDistinctiveTerms
3474
- ? store.getDistinctiveTerms(indexed.sourceId)
3475
- : [];
3476
- const output = [
3477
- `Executed ${commands.length} commands (${totalLines} lines, ${(totalBytes / 1024).toFixed(1)}KB). ` +
3478
- `Indexed ${indexed.totalChunks} sections. Searched ${queries.length} queries.`,
3479
- "",
3480
- ...commandsInventory,
3481
- "",
3482
- ...inventory,
3483
- "",
3484
- ...queryResults,
3485
- distinctiveTerms.length > 0
3486
- ? `\nSearchable terms for follow-up: ${distinctiveTerms.join(", ")}`
3487
- : "",
3488
- ].join("\n");
3489
- return trackResponse("ctx_batch_execute", {
3490
- content: [{ type: "text", text: output }],
3491
- });
3492
- }
3493
- catch (err) {
3494
- const message = err instanceof Error ? err.message : String(err);
3495
- return trackResponse("ctx_batch_execute", {
3496
- content: [
3497
- {
3498
- type: "text",
3499
- text: `Batch execution error: ${message}`,
3500
- },
3501
- ],
3502
- isError: true,
3503
- });
3504
- }
3505
- });
3506
- /**
3507
- * Pi byte accounting: patch lifetime.totalEvents from bytes_sandboxed
3508
- * in stats-*.json files instead of the default events × 256 heuristic.
3509
- * Only active for Pi adapter — other platforms use getLifetimeStats() as-is.
3510
- */
3511
- function patchPiLifetimeFromStatsFiles(lifetime, sessionsDir) {
3512
- if (!existsSync(sessionsDir))
3513
- return;
3514
- let sandboxedBytes = 0;
3515
- try {
3516
- for (const f of readdirSync(sessionsDir)) {
3517
- if (!f.startsWith("stats-") || !f.endsWith(".json"))
3518
- continue;
3519
- try {
3520
- const raw = JSON.parse(readFileSync(join(sessionsDir, f), "utf-8"));
3521
- sandboxedBytes += (raw?.bytes_sandboxed ?? 0) + (raw?.bytes_indexed ?? 0);
3522
- }
3523
- catch { /* corrupt file — skip */ }
3524
- }
3525
- }
3526
- catch { /* never block ctx_stats on stats file I/O */ }
3527
- if (sandboxedBytes > 0) {
3528
- const rescueTokens = (lifetime.rescueBytes ?? 0) / 4;
3529
- lifetime.totalEvents = Math.round((sandboxedBytes / 4 + rescueTokens) / 256);
3530
- }
3531
- }
3532
- // ─────────────────────────────────────────────────────────
3533
- // Tool: stats
3534
- // ─────────────────────────────────────────────────────────
3535
- /**
3536
- * Create a minimal in-memory DB adapter for when the session DB is unavailable.
3537
- * All queries return empty results so AnalyticsEngine.queryAll() still works.
3538
- */
3539
- function createMinimalDb() {
3540
- return {
3541
- prepare: () => ({
3542
- run: () => undefined,
3543
- get: (..._args) => ({ cnt: 0, compact_count: 0, minutes: null, rate: 0, avg: 0, outcome: "exploratory" }),
3544
- all: () => [],
3545
- }),
3546
- };
3547
- }
3548
- server.registerTool("ctx_stats", {
3549
- title: "Session Statistics",
3550
- // #846: read-only diagnostics. Was cancelled by Codex when unannotated.
3551
- annotations: {
3552
- readOnlyHint: true,
3553
- destructiveHint: false,
3554
- idempotentHint: true,
3555
- openWorldHint: false,
3556
- },
3557
- description: "Returns context consumption statistics for the current session. " +
3558
- "Shows total bytes returned to context, breakdown by tool, call counts, " +
3559
- "estimated token usage, and context savings ratio.",
3560
- inputSchema: z.object({}),
3561
- }, async () => {
3562
- // ONE call, ONE source — AnalyticsEngine.queryAll()
3563
- let text;
3564
- try {
3565
- const projectDir = getProjectDir();
3566
- // Canonical hash + migration-aware path. The downstream
3567
- // getConversationStats / getRealBytesStats reconstruct the DB
3568
- // filename from worktreeHash; pass the SAME canonical hash that
3569
- // resolveSessionDbPath used so they hit the same file.
3570
- const dbHash = hashProjectDirCanonical(projectDir);
3571
- const sessionDbPath = resolveSessionDbPath({
3572
- projectDir,
3573
- sessionsDir: getSessionDir(),
3574
- });
3575
- if (existsSync(sessionDbPath)) {
3576
- const Database = loadDatabase();
3577
- const sdb = new Database(sessionDbPath, { readonly: true });
3578
- try {
3579
- const engine = new AnalyticsEngine(sdb);
3580
- const report = engine.queryAll(sessionStats);
3581
- // MCP usage is read-only and cheap; only available when DB exists.
3582
- const mcpUsage = engine.getMcpToolUsage();
3583
- // Lifetime stats span every project's SessionDB + auto-memory dir
3584
- // (Bugs #3/#4); failures are absorbed inside getLifetimeStats so a
3585
- // corrupt sidecar can never break ctx_stats.
3586
- // B3b Slice 3.1: scope to active adapter via getSessionDir() so
3587
- // non-Claude platforms (Cursor, OpenCode, JetBrains, ...) read
3588
- // from THEIR sessions dir — not the hardcoded ~/.claude/ default.
3589
- // Mirrors the statusline contract at src/server.ts:540.
3590
- const lifetime = getLifetimeStats({ sessionsDir: getSessionDir() });
3591
- // B3b Slices 3.2-3.6: cross-adapter aggregation so the renderer
3592
- // can show "Where it came from" + the "across N AI tools"
3593
- // headline. Best-effort — failures absorbed so a corrupt
3594
- // sidecar in any adapter dir cannot break ctx_stats.
3595
- let multiAdapter;
3596
- try {
3597
- multiAdapter = getMultiAdapterLifetimeStats();
3598
- }
3599
- catch { /* never block ctx_stats */ }
3600
- // F1: wire conversation + realBytes opts so formatReport renders the
3601
- // narrative 5-section "kitap gibi" layout (timeline, ladder, receipt,
3602
- // example cost, auto-memory). Without these, formatReport falls back
3603
- // to the legacy active-session header. Best-effort — failures absorbed.
3604
- // Resolve session_id: prefer env (CLAUDE_SESSION_ID), else most-recent
3605
- // UUID session_id from session_events in this DB.
3606
- let conversation;
3607
- let realBytes;
3608
- try {
3609
- let sid = process.env.CLAUDE_SESSION_ID;
3610
- if (!sid) {
3611
- const row = sdb.prepare("SELECT session_id FROM session_events WHERE session_id LIKE '________-____-____-____-____________' ORDER BY created_at DESC LIMIT 1").get();
3612
- sid = row?.session_id;
3613
- }
3614
- if (sid) {
3615
- conversation = getConversationStats({ sessionId: sid, sessionsDir: getSessionDir(), worktreeHash: dbHash });
3616
- // v1.0.133 Slice 3: pass contentDbPath so getRealBytesStats can
3617
- // join chunks WHERE session_id = sid and fold the indexed
3618
- // content bytes into the per-conversation bar. Without this,
3619
- // Mert's session showed ~200B (event metadata only) even with
3620
- // 49 MB of indexed content sitting in the content DB.
3621
- // Render-time read-only — no DB mutation, no backfill.
3622
- const contentDbPath = getStorePath();
3623
- // v1.0.148 Bug E+F: a conversation typically spans many
3624
- // session_ids (resume cycles, /compact rebirths, PID
3625
- // sub-process sessions launched by ctx_execute). Scoping
3626
- // per-session loses sandbox-burst bytes_avoided that the
3627
- // PID-sessions own. Look up THIS session's project_dir
3628
- // from META and aggregate via META subquery so all
3629
- // sibling sessions in the same cwd attribute together.
3630
- // Fallback to sessionId scope if the META lookup fails
3631
- // (best-effort — the original metric is still defensible).
3632
- let convReal;
3633
- try {
3634
- const Database = loadDatabase();
3635
- const dbFiles = (await import("node:fs"))
3636
- .readdirSync(getSessionDir())
3637
- .filter((f) => f.endsWith(".db") && (!dbHash || f.startsWith(dbHash)));
3638
- let projectDirForSid;
3639
- for (const file of dbFiles) {
3640
- try {
3641
- const sdb = new Database((await import("node:path")).join(getSessionDir(), file), { readonly: true });
3642
- try {
3643
- const r = sdb
3644
- .prepare("SELECT project_dir FROM session_meta WHERE session_id = ?")
3645
- .get(sid);
3646
- if (r?.project_dir) {
3647
- projectDirForSid = r.project_dir;
3648
- break;
3649
- }
3650
- }
3651
- finally {
3652
- sdb.close();
3653
- }
3654
- }
3655
- catch { /* skip unreadable DB */ }
3656
- }
3657
- convReal = projectDirForSid
3658
- ? getRealBytesStats({ projectDir: projectDirForSid, sessionsDir: getSessionDir(), worktreeHash: dbHash, contentDbPath })
3659
- : getRealBytesStats({ sessionId: sid, sessionsDir: getSessionDir(), worktreeHash: dbHash, contentDbPath });
3660
- }
3661
- catch {
3662
- convReal = getRealBytesStats({ sessionId: sid, sessionsDir: getSessionDir(), worktreeHash: dbHash, contentDbPath });
3663
- }
3664
- const lifeRealBase = getRealBytesStats({ sessionsDir: getSessionDir() });
3665
- // v1.0.134 SLICE C: lifetime tier sums ALL chunks (no
3666
- // session_id filter). Without this fold, lifetime "kept out"
3667
- // only counts session_events.bytes_avoided and ignores the
3668
- // bulk of indexed payload across every prior conversation.
3669
- const lifeContentBytes = getContentBytesAllSessions(contentDbPath);
3670
- const lifeReal = {
3671
- ...lifeRealBase,
3672
- contentBytes: lifeRealBase.contentBytes + lifeContentBytes,
3673
- bytesAvoided: lifeRealBase.bytesAvoided + lifeContentBytes,
3674
- totalSavedTokens: Math.floor((lifeRealBase.eventDataBytes
3675
- + lifeRealBase.bytesAvoided
3676
- + lifeContentBytes
3677
- + lifeRealBase.snapshotBytes) / 4),
3678
- };
3679
- realBytes = { conversation: convReal, lifetime: lifeReal };
3680
- }
3681
- }
3682
- catch { /* never block ctx_stats */ }
3683
- // Pi byte accounting: patch lifetime from stats-*.json files
3684
- // (actual bytes_sandboxed, not events × 256 heuristic).
3685
- if (_detectedAdapter?.name === "Pi") {
3686
- patchPiLifetimeFromStatsFiles(lifetime, getSessionDir());
3687
- }
3688
- // v1.0.117: pass projectDir as cwd so the narrative renderer's
3689
- // "started in <path>" line matches the user's actual project.
3690
- // Snapshot the persistent store so the renderer can show
3691
- // total_chunks / last_indexed_at without callers having to query
3692
- // separately. Best-effort — getStore() is process-local and may
3693
- // be unavailable on cold paths; failures are absorbed.
3694
- let indexState;
3695
- try {
3696
- indexState = getStore().getIndexState();
3697
- }
3698
- catch { /* never block ctx_stats */ }
3699
- text = formatReport(report, VERSION, _latestVersion, { lifetime, mcpUsage, multiAdapter, conversation, realBytes, indexState, cwd: projectDir });
3700
- }
3701
- finally {
3702
- sdb.close();
3703
- }
3704
- }
3705
- else {
3706
- // No session DB — build a minimal report from runtime stats only.
3707
- // Lifetime still meaningful (other projects, auto-memory) so include it.
3708
- const engine = new AnalyticsEngine(createMinimalDb());
3709
- const report = engine.queryAll(sessionStats);
3710
- const lifetime = getLifetimeStats({ sessionsDir: getSessionDir() });
3711
- if (_detectedAdapter?.name === "Pi") {
3712
- patchPiLifetimeFromStatsFiles(lifetime, getSessionDir());
3713
- }
3714
- let multiAdapter;
3715
- try {
3716
- multiAdapter = getMultiAdapterLifetimeStats();
3717
- }
3718
- catch { /* never block ctx_stats */ }
3719
- let indexState;
3720
- try {
3721
- indexState = getStore().getIndexState();
3722
- }
3723
- catch { /* never block ctx_stats */ }
3724
- text = formatReport(report, VERSION, _latestVersion, { lifetime, multiAdapter, indexState });
3725
- }
3726
- }
3727
- catch {
3728
- // Session DB not available or incompatible — build minimal report from runtime stats
3729
- const engine = new AnalyticsEngine(createMinimalDb());
3730
- const report = engine.queryAll(sessionStats);
3731
- let lifetime;
3732
- try {
3733
- lifetime = getLifetimeStats({ sessionsDir: getSessionDir() });
3734
- }
3735
- catch { /* never block ctx_stats */ }
3736
- if (_detectedAdapter?.name === "Pi" && lifetime) {
3737
- patchPiLifetimeFromStatsFiles(lifetime, getSessionDir());
3738
- }
3739
- let multiAdapter;
3740
- try {
3741
- multiAdapter = getMultiAdapterLifetimeStats();
3742
- }
3743
- catch { /* never block ctx_stats */ }
3744
- text = formatReport(report, VERSION, _latestVersion, (lifetime || multiAdapter) ? { lifetime, multiAdapter } : undefined);
3745
- }
3746
- return trackResponse("ctx_stats", {
3747
- content: [{ type: "text", text }],
3748
- });
3749
- });
3750
- // ── ctx-doctor: diagnostics (server-side) ─────────────────────────────────
3751
- server.registerTool("ctx_doctor", {
3752
- title: "Run Diagnostics",
3753
- // #846: read-only diagnostics (runs an internal self-test, mutates nothing).
3754
- // Was cancelled by Codex when unannotated.
3755
- annotations: {
3756
- readOnlyHint: true,
3757
- destructiveHint: false,
3758
- idempotentHint: true,
3759
- openWorldHint: false,
3760
- },
3761
- description: "Diagnose context-mode installation. Runs all checks server-side and " +
3762
- "returns a plain-text status report with [OK]/[FAIL]/[WARN] prefixes " +
3763
- "(renderer-safe across MCP clients). No CLI execution needed.",
3764
- inputSchema: z.object({}),
3765
- }, async () => {
3766
- // Renderer-safe output (Mickey #3 — Z.ai GLM 4.7 ReferenceError):
3767
- // Z.ai's MCP renderer mounts a custom React component for GitHub-flavored
3768
- // markdown task-list syntax (`- [x]` / `- [ ]` / `- [-]`) that depends on
3769
- // a missing `client` context, throwing `ReferenceError: client is not
3770
- // defined`. We avoid both task-list syntax AND `## ` h2 headings to stay
3771
- // safe across all MCP renderers — using plain-text status prefixes
3772
- // (`[OK]` / `[FAIL]` / `[WARN]`) instead.
3773
- const lines = ["context-mode doctor", ""];
3774
- let currentPlatform;
3775
- try {
3776
- currentPlatform = detectPlatform(server.server.getClientVersion() ?? undefined).platform;
3777
- }
3778
- catch {
3779
- currentPlatform = detectPlatform().platform;
3780
- }
3781
- // __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root.
3782
- // Codex is special: when plugin-manager runtime root differs from the
3783
- // current package root, diagnose the root Codex will actually execute.
3784
- const pluginRoot = getRuntimeAwarePackageRoot(currentPlatform);
3785
- // Runtimes
3786
- const total = 11;
3787
- const pct = ((available.length / total) * 100).toFixed(0);
3788
- lines.push(`[OK] Runtimes: ${available.length}/${total} (${pct}%) — ${available.join(", ")}`);
3789
- // Performance
3790
- if (hasBunRuntime()) {
3791
- lines.push("[OK] Performance: FAST (Bun)");
3792
- }
3793
- else {
3794
- lines.push("[WARN] Performance: NORMAL — install Bun for 3-5x speed boost");
3795
- }
3796
- const sessionStorage = resolveSessionStorageDir(getDefaultSessionDir);
3797
- const contentStorage = resolveContentStorageDir(getDefaultSessionDir);
3798
- const statsStorage = resolveStatsStorageDir(getDefaultSessionDir);
3799
- lines.push(`[OK] Storage sessions: ${sessionStorage.path} (${describeStorageDirectorySource(sessionStorage)})`);
3800
- lines.push(`[OK] Storage content: ${contentStorage.path} (${describeStorageDirectorySource(contentStorage)})`);
3801
- lines.push(`[OK] Storage stats: ${statsStorage.path} (${describeStorageDirectorySource(statsStorage)})`);
3802
- // Server test — cleanup executor to prevent resource leaks (#247)
3803
- {
3804
- const testExecutor = new PolyglotExecutor({ runtimes });
3805
- try {
3806
- const result = await testExecutor.execute({ language: "javascript", code: 'console.log("ok");', timeout: 5000 });
3807
- if (result.exitCode === 0 && result.stdout.trim() === "ok") {
3808
- lines.push("[OK] Server test: PASS");
3809
- }
3810
- else {
3811
- const detail = result.stderr?.trim() ? ` (${result.stderr.trim().slice(0, 200)})` : "";
3812
- lines.push(`[FAIL] Server test: FAIL — exit ${result.exitCode}${detail}`);
3813
- }
3814
- }
3815
- catch (err) {
3816
- lines.push(`[FAIL] Server test: FAIL — ${err instanceof Error ? err.message : err}`);
3817
- }
3818
- finally {
3819
- testExecutor.cleanupBackgrounded();
3820
- }
3821
- }
3822
- // FTS5 / SQLite — close in finally to prevent GC segfault (#247)
3823
- {
3824
- let testDb;
3825
- try {
3826
- const Database = loadDatabase();
3827
- testDb = new Database(":memory:");
3828
- testDb.exec("CREATE VIRTUAL TABLE fts_test USING fts5(content)");
3829
- testDb.exec("INSERT INTO fts_test(content) VALUES ('hello world')");
3830
- const row = testDb.prepare("SELECT * FROM fts_test WHERE fts_test MATCH 'hello'").get();
3831
- if (row && row.content === "hello world") {
3832
- lines.push("[OK] FTS5 / SQLite: PASS — native module works");
3833
- }
3834
- else {
3835
- lines.push("[FAIL] FTS5 / SQLite: FAIL — unexpected result");
3836
- }
3837
- }
3838
- catch (err) {
3839
- lines.push(`[FAIL] FTS5 / SQLite: FAIL — ${err instanceof Error ? err.message : err}`);
3840
- }
3841
- finally {
3842
- try {
3843
- testDb?.close();
3844
- }
3845
- catch { /* best effort */ }
3846
- }
3847
- }
3848
- // Hooks
3849
- const diagnosticAdapter = await getDiagnosticAdapter();
3850
- if (diagnosticAdapter) {
3851
- for (const result of diagnosticAdapter.validateHooks(pluginRoot)) {
3852
- const prefix = result.status === "pass" ? "[OK]" : result.status === "warn" ? "[WARN]" : "[FAIL]";
3853
- const fix = result.fix ? ` — fix: ${result.fix}` : "";
3854
- lines.push(`${prefix} ${result.check}: ${result.message}${fix}`);
3855
- }
3856
- const hookScriptPaths = getHookScriptPaths(diagnosticAdapter, pluginRoot);
3857
- if (hookScriptPaths.length === 0) {
3858
- lines.push("[OK] Hook scripts: no direct .mjs script paths to verify");
3859
- }
3860
- for (const scriptPath of hookScriptPaths) {
3861
- const hookPath = resolve(pluginRoot, scriptPath);
3862
- if (existsSync(hookPath)) {
3863
- lines.push(`[OK] Hook script: PASS — ${hookPath}`);
3864
- }
3865
- else {
3866
- lines.push(`[FAIL] Hook script: FAIL — not found at ${hookPath}`);
3867
- }
3868
- }
3869
- }
3870
- else {
3871
- lines.push("[WARN] Hooks: adapter detection unavailable");
3872
- }
3873
- // Version
3874
- lines.push(`[OK] Version: v${VERSION}`);
3875
- return trackResponse("ctx_doctor", {
3876
- content: [{ type: "text", text: lines.join("\n") }],
3877
- });
3878
- });
3879
- // ── ctx-upgrade: upgrade meta-tool ─────────────────────────────────────────
3880
- server.registerTool("ctx_upgrade", {
3881
- title: "Upgrade Plugin",
3882
- // #846: an action tool (returns an upgrade command to run); not read-only,
3883
- // but non-destructive and idempotent. No direct network from the call.
3884
- annotations: {
3885
- readOnlyHint: false,
3886
- destructiveHint: false,
3887
- idempotentHint: true,
3888
- openWorldHint: false,
3889
- },
3890
- description: "Upgrade context-mode to the latest version. Returns a shell command to execute. " +
3891
- "You MUST run the returned command using your shell tool (Bash, shell_execute, " +
3892
- "run_in_terminal, etc.) and display the output as a checklist. " +
3893
- "Tell the user to restart their session after upgrade.",
3894
- inputSchema: z.object({}),
3895
- }, async () => {
3896
- // Issue #542 — thread MCP clientInfo into the spawned upgrade
3897
- // process. detectPlatform() runs IN-PROCESS here (no spawn boundary)
3898
- // so clientInfo from the MCP handshake is the highest-confidence
3899
- // signal available. We forward the resolved PlatformId as a
3900
- // --platform flag (cross-shell safe on POSIX, Git Bash, PowerShell,
3901
- // and cmd.exe — unlike env-var prefixes). If detection fails we
3902
- // skip the flag and let upgrade()'s own detectPlatform() fall back.
3903
- let platformFlag = "";
3904
- let nodeOpts = undefined;
3905
- let platformId;
3906
- try {
3907
- const clientInfo = server.server.getClientVersion();
3908
- const signal = detectPlatform(clientInfo ?? undefined);
3909
- platformId = signal.platform;
3910
- platformFlag = ` --platform ${signal.platform}`;
3911
- nodeOpts = isInProcessPluginPlatform(signal.platform) && runtimes.javascript
3912
- ? { platform: signal.platform, jsRuntime: runtimes.javascript }
3913
- : undefined;
3914
- }
3915
- catch {
3916
- try {
3917
- platformId = detectPlatform().platform;
3918
- }
3919
- catch { /* best effort — fall back to upgrade()'s own detect */ }
3920
- }
3921
- // __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root.
3922
- // Only Codex may replace it with the plugin-manager runtime root; other
3923
- // adapters can coexist with Codex on the same machine.
3924
- const pluginRoot = getRuntimeAwarePackageRoot(platformId);
3925
- const bundlePath = resolve(pluginRoot, "cli.bundle.mjs");
3926
- const fallbackPath = resolve(pluginRoot, "build", "cli.js");
3927
- // Insight pivoted to the hosted dashboard (context-mode.com/insight), so
3928
- // ctx_insight no longer builds a local cache. On upgrade, sweep the legacy
3929
- // insight-cache and stop any stale local dashboard left from old versions.
3930
- try {
3931
- const sessDir = getSessionDir();
3932
- const insightCacheDir = join(dirname(sessDir), "insight-cache");
3933
- if (existsSync(insightCacheDir)) {
3934
- // Kill any running insight server first via the shared helper —
3935
- // this is locale-independent on Windows (PR #469) and isolates per-pid
3936
- // failures. We ignore the structured result: cache cleanup is
3937
- // best-effort and must never block ctx_upgrade.
3938
- killProcessOnPort(4747);
3939
- rmSync(insightCacheDir, { recursive: true, force: true });
3940
- }
3941
- }
3942
- catch { /* best effort — don't block upgrade */ }
3943
- let cmd;
3944
- if (existsSync(bundlePath)) {
3945
- cmd = `${buildNodeCommand(bundlePath, nodeOpts)} upgrade${platformFlag}`;
3946
- }
3947
- else if (existsSync(fallbackPath)) {
3948
- cmd = `${buildNodeCommand(fallbackPath, nodeOpts)} upgrade${platformFlag}`;
3949
- }
3950
- else {
3951
- // Inline fallback: neither CLI file exists (e.g. marketplace installs).
3952
- // Generate a self-contained node -e script that performs the upgrade.
3953
- const repoUrl = "https://github.com/mksglu/context-mode.git";
3954
- // Write inline script to a temp .mjs file — avoids quote-escaping issues
3955
- // across cmd.exe, PowerShell, and bash (node -e '...' breaks on Windows).
3956
- const scriptLines = [
3957
- `import{execFileSync}from"node:child_process";`,
3958
- `import{cpSync,rmSync,existsSync,mkdtempSync,readFileSync,writeFileSync,lstatSync}from"node:fs";`,
3959
- `import{join,resolve,sep}from"node:path";`,
3960
- `import{tmpdir}from"node:os";`,
3961
- `const P=${JSON.stringify(pluginRoot)};`,
3962
- `const T=mkdtempSync(join(tmpdir(),"ctx-upgrade-"));`,
3963
- `try{`,
3964
- `console.log("- [x] Starting inline upgrade (no CLI found)");`,
3965
- `execFileSync("git",["clone","--depth","1","${repoUrl}",T],{stdio:"inherit"});`,
3966
- `console.log("- [x] Cloned latest source");`,
3967
- `execFileSync(process.platform==="win32"?"npm.cmd":"npm",["install"],{cwd:T,stdio:"inherit",shell:process.platform==="win32"});`,
3968
- `execFileSync(process.platform==="win32"?"npm.cmd":"npm",["run","build"],{cwd:T,stdio:"inherit",shell:process.platform==="win32"});`,
3969
- `console.log("- [x] Built from source");`,
3970
- `const pkg=JSON.parse(readFileSync(join(T,"package.json"),"utf8"));`,
3971
- `const items=[...(Array.isArray(pkg.files)?pkg.files:[]),"src","package.json"];`,
3972
- // Supply-chain containment on items[]. Mirror the cli.ts upgrade()
3973
- // guard: a compromised upstream package.json with files:["../etc"]
3974
- // would otherwise let path.join follow ".." out of pluginRoot.
3975
- // path.resolve normalizes "..", so the lexical startsWith catches
3976
- // both relative-".." traversal and absolute-path bypass. Plus a
3977
- // symlink filter so a committed symlink inside the clone can't
3978
- // plant itself in pluginRoot (cpSync default preserves source
3979
- // symlinks; a planted symlink in pluginRoot/src then redirects
3980
- // every subsequent load through to an attacker target).
3981
- `const PW=resolve(P)+sep;const TW=resolve(T)+sep;`,
3982
- `const noSymlink=(src)=>{try{return !lstatSync(src).isSymbolicLink()}catch{return false}};`,
3983
- `for(const item of items){const from=resolve(T,item);const to=resolve(P,item);if(!(to+sep).startsWith(PW))continue;if(!(from+sep).startsWith(TW))continue;if(!noSymlink(from))continue;if(existsSync(from)){rmSync(to,{recursive:true,force:true});cpSync(from,to,{recursive:true,force:true,filter:noSymlink});}}`,
3984
- // Issue #609: do NOT write .mcp.json into the cache dir. Claude Code reads
3985
- // .claude-plugin/plugin.json.mcpServers as the canonical MCP source — the
3986
- // per-version .mcp.json file is a stale-write vector. Same architectural
3987
- // fix as the cli.ts upgrade() path; both writers were the only producers.
3988
- `console.log("- [x] Copied package files");`,
3989
- `execFileSync(process.platform==="win32"?"npm.cmd":"npm",["install","--production"],{cwd:P,stdio:"inherit",shell:process.platform==="win32"});`,
3990
- `console.log("- [x] Installed production dependencies");`,
3991
- `console.log("## context-mode upgrade complete");`,
3992
- `}catch(e){`,
3993
- `console.error("- [ ] Upgrade failed:",e.message);`,
3994
- `process.exit(1);`,
3995
- `}finally{`,
3996
- `try{rmSync(T,{recursive:true,force:true})}catch{}`,
3997
- `}`,
3998
- ].join("\n");
3999
- // Server writes the temp script file — avoids shell quoting issues entirely
4000
- const tmpScript = resolve(pluginRoot, ".ctx-upgrade-inline.mjs");
4001
- const { writeFileSync: writeTmp } = await import("node:fs");
4002
- writeTmp(tmpScript, scriptLines);
4003
- cmd = buildNodeCommand(tmpScript, nodeOpts);
4004
- }
4005
- const text = [
4006
- "## ctx-upgrade",
4007
- "",
4008
- "Run this command using your shell execution tool:",
4009
- "",
4010
- "```",
4011
- cmd,
4012
- "```",
4013
- "",
4014
- "After the command completes, display results as a markdown checklist:",
4015
- "- `[x]` for success, `[ ]` for failure",
4016
- "- Example format:",
4017
- " ```",
4018
- " ## context-mode upgrade",
4019
- " - [x] Pulled latest from GitHub",
4020
- " - [x] Built and installed v0.9.24",
4021
- " - [x] npm global updated",
4022
- " - [x] Hooks configured",
4023
- " - [x] Doctor: all checks PASS",
4024
- " ```",
4025
- "- Tell the user to restart their session to pick up the new version.",
4026
- ].join("\n");
4027
- return trackResponse("ctx_upgrade", {
4028
- content: [{ type: "text", text }],
4029
- });
4030
- });
4031
- // ── ctx-purge: explicit knowledge base wipe ─────────────────────────────────
4032
- //
4033
- // Issue #520 — scoped purge.
4034
- // The schema is ADDITIVE: bare {confirm:true} preserves the legacy
4035
- // project-wide wipe verbatim (with a stderr deprecation warning so
4036
- // future callers migrate to explicit scope). When sessionId is given,
4037
- // only that session's rows + FTS5 chunks are removed; project-wide
4038
- // files (events.md, FTS5 store file, stats file) are preserved.
4039
- // Passing both sessionId AND scope:"project" is ambiguous (does the
4040
- // caller want a per-session wipe or a project-wide one?) and is
4041
- // rejected by an explicit check in the handler body — NOT a schema-level
4042
- // .refine(). MCP SDK's normalizeObjectSchema() reads `.shape` to project
4043
- // inputSchema → JSON Schema for tools/list; a ZodEffects (refine wrapper)
4044
- // has no `.shape`, so the SDK silently emits `properties: {}`, and Claude
4045
- // Code's strict-input-validation gate then rejects EVERY call to this
4046
- // tool with "input_schema does not support fields". Issue #563.
4047
- server.registerTool("ctx_purge", {
4048
- title: "Purge Knowledge Base",
4049
- // #846: permanently deletes indexed content — destructive. Purging an
4050
- // already-purged scope has no further effect (idempotent). No network.
4051
- annotations: {
4052
- readOnlyHint: false,
4053
- destructiveHint: true,
4054
- idempotentHint: true,
4055
- openWorldHint: false,
4056
- },
4057
- description: `DESTRUCTIVE: permanently delete indexed content. Cannot be undone. Requires confirm:true and exactly one scope.
4058
-
4059
- WHEN:
4060
- - User explicitly asks to clear a specific session ('purge this session', 'wipe this conversation')
4061
- - User explicitly asks to reset the whole project ('reset everything', 'wipe the knowledge base')
4062
-
4063
- WHEN NOT:
4064
- - User says 'reset', 'clear', or 'wipe' without naming a scope -> ask which scope before calling
4065
- - User wants to free memory or improve performance -> recommend ctx_stats first, do not purge
4066
-
4067
- SCOPES (pass exactly one):
4068
- - Per-session: ctx_purge(confirm: true, sessionId: "<uuid>") deletes that session's events (auto-captured decisions, errors, plans, user prompts, rejected approaches, etc.) and per-session FTS5 chunks; sibling sessions and stats file are preserved.
4069
- - Per-project: ctx_purge(confirm: true, scope: "project") wipes FTS5 knowledge base, every session DB row, events markdown, and resets the stats file. Use ctx_stats first to preview category counts before purging.
4070
-
4071
- CONTRACT:
4072
- - confirm:true is required; confirm:false returns 'purge cancelled'.
4073
- - sessionId and scope:'project' together return 'ambiguous - pick one'.
4074
- - scope:'session' without sessionId throws (sessionId required).
4075
- - Bare {confirm:true} is deprecated: maps to scope:'project' with a stderr warning; will hard-error in a future major.
4076
-
4077
- RETURNS:
4078
- A summary of removed rows + the resolved scope.
4079
-
4080
- EXAMPLE: ctx_purge(confirm: true, sessionId: "7c8a-1234-5678-9abc-def012345678")
4081
- EXAMPLE: ctx_purge(confirm: true, scope: "project")`,
4082
- // NOTE: schema MUST be a plain z.object — no .refine()/.transform()/
4083
- // .superRefine() wrapper. See block comment above & issue #563. The
4084
- // cross-field ambiguity check lives in the handler body below.
4085
- inputSchema: z.object({
4086
- // confirm: wrapped in coerceBoolean preprocessor — OpenCode's native
4087
- // plugin bridge can deliver `confirm:"true"` / `confirm:"false"` as
4088
- // string literals. Without this, v1.0.139's inputSchema.parse() path
4089
- // rejects valid intent as "Expected boolean, received string" (#627).
4090
- confirm: z.preprocess(coerceBoolean, z.boolean()).describe("MUST be true. Destructive operation; false returns 'purge cancelled'."),
4091
- sessionId: z.string().optional().describe("UUID of a single session. Pairs with confirm:true to wipe only that " +
4092
- "session's events + per-session FTS5 chunks. Sibling sessions and the " +
4093
- "stats file are preserved. MUST NOT be combined with scope:'project'."),
4094
- scope: z.enum(["session", "project"]).optional().describe("Explicit scope selector. 'session' REQUIRES sessionId. 'project' wipes " +
4095
- "the entire project (FTS5 + every session + stats). Omit only for the " +
4096
- "deprecated bare-{confirm:true} back-compat path."),
4097
- }),
4098
- }, async ({ confirm, sessionId, scope }) => {
4099
- // Cross-field ambiguity check — formerly a schema .refine(), moved
4100
- // into the handler so the inputSchema stays a plain ZodObject and
4101
- // the MCP SDK can serialize `.shape` into JSON Schema (issue #563).
4102
- // Same human-readable message as the original refine() preserved.
4103
- if (sessionId && scope === "project") {
4104
- return trackResponse("ctx_purge", {
4105
- content: [{
4106
- type: "text",
4107
- text: "Ambiguous purge: sessionId implies scope:'session', cannot combine with scope:'project'. " +
4108
- "Use scope:'project' WITHOUT sessionId for the legacy whole-project wipe.",
4109
- }],
4110
- isError: true,
4111
- });
4112
- }
4113
- if (!confirm) {
4114
- return trackResponse("ctx_purge", {
4115
- content: [{
4116
- type: "text",
4117
- text: "Purge cancelled. Pass confirm: true to proceed.",
4118
- }],
4119
- });
4120
- }
4121
- // Effective scope resolution:
4122
- // - explicit scope wins
4123
- // - else "session" iff sessionId is given
4124
- // - else "project" (back-compat — emit deprecation warning so
4125
- // callers migrate to the explicit form before a future major).
4126
- const effectiveScope = scope ?? (sessionId ? "session" : "project");
4127
- if (!scope && !sessionId) {
4128
- console.warn("[context-mode] ctx_purge: bare {confirm:true} is deprecated. " +
4129
- "Pass scope:'project' for the whole-project wipe, or scope:'session' + sessionId " +
4130
- "for a scoped wipe. See issue #520.");
4131
- }
4132
- // Close the persistent FTS5 content store handle BEFORE delegating to
4133
- // purgeSession so the store's lock is released on Windows. The handle
4134
- // is recreated lazily on the next getStore() call.
4135
- let storePathForPurge;
4136
- try {
4137
- storePathForPurge = getStorePath();
4138
- }
4139
- catch { /* best effort — store path may be unresolvable on fresh install */ }
4140
- if (_store) {
4141
- try {
4142
- _store.cleanup();
4143
- }
4144
- catch { /* best effort */ }
4145
- _store = null;
4146
- }
4147
- // FTS5 store: pass contentDir so purgeSession sweeps BOTH canonical
4148
- // and legacy raw-casing variants (dual-hash, mirrors session events).
4149
- // storePath is also passed for the rare case where the resolver picked
4150
- // an absolute path that differs from the dual-hash pair (e.g. caller
4151
- // pre-migrated). Both paths are de-duped during unlink.
4152
- const contentDir = storePathForPurge ? dirname(storePathForPurge) : undefined;
4153
- const { deleted } = purgeSession({
4154
- projectDir: getProjectDir(),
4155
- sessionsDir: getSessionDir(),
4156
- storePath: storePathForPurge,
4157
- contentDir,
4158
- legacyContentDir: join(homedir(), ".context-mode", "content"),
4159
- // hashProjectDirLegacy mirrors the deployed (≤ v1.0.111) raw-casing
4160
- // hash that named files under ~/.context-mode/content/. Using the
4161
- // legacy hash here is correct: that pre-pre-legacy directory was
4162
- // never migrated and still uses raw casing.
4163
- contentHash: hashProjectDirLegacy(getProjectDir()),
4164
- scope: effectiveScope,
4165
- sessionId,
4166
- });
4167
- // Stats are PROJECT-scoped (one stats file per project, summing all
4168
- // sessions). A scoped per-session purge MUST leave stats alone — they
4169
- // still belong to other sessions in the same project. Stats reset
4170
- // happens ONLY when scope === "project".
4171
- if (effectiveScope === "project") {
4172
- // Reset in-memory session stats
4173
- sessionStats.calls = {};
4174
- sessionStats.bytesReturned = {};
4175
- sessionStats.bytesIndexed = 0;
4176
- sessionStats.bytesSandboxed = 0;
4177
- sessionStats.cacheHits = 0;
4178
- sessionStats.cacheBytesSaved = 0;
4179
- sessionStats.sessionStart = Date.now();
4180
- deleted.push("session stats");
4181
- // Also drop the persisted stats file so external readers see a fresh state
4182
- try {
4183
- const statsFile = getStatsFilePath();
4184
- if (existsSync(statsFile))
4185
- unlinkSync(statsFile);
4186
- }
4187
- catch { /* best effort */ }
4188
- }
4189
- const message = effectiveScope === "session"
4190
- ? `Purged session ${sessionId}: ${deleted.length ? deleted.join(", ") : "no matching rows"}. ` +
4191
- `Other sessions and project-wide stats preserved.`
4192
- : `Purged: ${deleted.join(", ")}. All session data for this project has been permanently deleted.`;
4193
- return trackResponse("ctx_purge", {
4194
- content: [{
4195
- type: "text",
4196
- text: message,
4197
- }],
4198
- });
4199
- });
4200
- // Hard upper bound on every helper-internal spawnSync call. Caps tail-latency
4201
- // when an external binary hangs (xdg-open waiting for an X11 session, lsof
4202
- // stalling on /proc, taskkill blocking on an unresponsive process, etc.) so
4203
- // the MCP tool surfaces a diagnostic instead of blocking the agent loop.
4204
- // 5s is comfortably above the 99th-percentile completion of every command we
4205
- // invoke; anything past that is hung.
4206
- const HELPER_SPAWN_TIMEOUT_MS = 5000;
4207
- // Returns the argv attempts for opening `url` on `platform`, in fall-back order.
4208
- // Pure data — no I/O.
4209
- export function browserOpenArgv(url, platform) {
4210
- if (platform === "darwin")
4211
- return [{ cmd: "open", args: [url] }];
4212
- if (platform === "win32") {
4213
- // `start` is a cmd.exe builtin; the empty title arg ("") prevents the URL
4214
- // from being consumed as the window title.
4215
- return [{ cmd: "cmd", args: ["/c", "start", "", url] }];
4216
- }
4217
- // linux/bsd: try xdg-open, then sensible-browser (Debian/Ubuntu).
4218
- return [
4219
- { cmd: "xdg-open", args: [url] },
4220
- { cmd: "sensible-browser", args: [url] },
4221
- ];
4222
- }
4223
- // Opens a browser synchronously, waiting for each attempt to complete.
4224
- // Returns a structured result so callers can surface auto-open failures
4225
- // to the user instead of falsely reporting success.
4226
- export function openBrowserSync(url, platform = process.platform, runner = spawnSync) {
4227
- const attempts = browserOpenArgv(url, platform);
4228
- const errors = [];
4229
- for (const { cmd, args } of attempts) {
4230
- try {
4231
- const r = runner(cmd, args, { stdio: "ignore", timeout: HELPER_SPAWN_TIMEOUT_MS });
4232
- // Treat signal-kill (status === null) and any non-zero status as failure
4233
- // so the next fallback fires.
4234
- if (!r.error && r.status === 0)
4235
- return { ok: true, method: cmd };
4236
- const reason = r.error?.message ?? `status=${r.status === null ? "signaled" : r.status}`;
4237
- errors.push(`${cmd}: ${reason}`);
4238
- }
4239
- catch (e) {
4240
- errors.push(`${cmd}: ${e instanceof Error ? e.message : String(e)}`);
4241
- }
4242
- }
4243
- return { ok: false, method: "none", reason: errors.join("; ") };
4244
- }
4245
- // Kills any process listening on `port`. Returns a structured result so
4246
- // the caller can distinguish between (a) port was free, (b) kill succeeded,
4247
- // (c) kill failed (perms, missing binary, or per-pid failure mid-loop).
4248
- //
4249
- // On Windows the netstat parser is locale-independent: the STATE column
4250
- // ("LISTENING" / "ESTABLISHED" / ...) is translated on non-English Windows
4251
- // (Windows-FR shows "À l'écoute", Windows-DE "ABHÖREN", etc.), but the REMOTE
4252
- // ADDRESS column is not. A listening TCP socket always has remote
4253
- // "0.0.0.0:0" (IPv4) or "[::]:0" (IPv6); a connected one has a real
4254
- // addr:port. We therefore key off the remote column instead of the state
4255
- // string. This also rules out the pre-fix bug where matching only the local
4256
- // port number cross-matched a remote :port from an outbound connection and
4257
- // taskkill'd an unrelated process.
4258
- export function killProcessOnPort(port, platform = process.platform, runner = spawnSync) {
4259
- const result = { killedPids: [], attemptedPids: [], errors: [] };
4260
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
4261
- result.errors.push(`invalid port: ${port}`);
4262
- return result;
4263
- }
4264
- try {
4265
- if (platform === "win32") {
4266
- const r = runner("netstat", ["-ano"], {
4267
- encoding: "utf-8",
4268
- stdio: ["ignore", "pipe", "ignore"],
4269
- timeout: HELPER_SPAWN_TIMEOUT_MS,
4270
- });
4271
- if (r.error) {
4272
- result.errors.push(`netstat: ${r.error.message}`);
4273
- return result;
4274
- }
4275
- if (r.status !== 0 || typeof r.stdout !== "string")
4276
- return result;
4277
- const portSuffix = `:${port}`;
4278
- const pids = new Set();
4279
- for (const rawLine of r.stdout.split(/\r?\n/)) {
4280
- const line = rawLine.trim();
4281
- if (!line)
4282
- continue;
4283
- const tokens = line.split(/\s+/);
4284
- // netstat -ano LISTENING row (en-US): "TCP 0.0.0.0:4747 0.0.0.0:0 LISTENING 1234"
4285
- // The STATE column is locale-translated and may itself contain spaces
4286
- // (Windows-FR `À l'écoute` splits into two tokens), so we cannot index
4287
- // STATE by position. PID is always the trailing column; PROTO/LOCAL/
4288
- // REMOTE are the first three. We anchor on those + a remote-wildcard
4289
- // check that's locale-independent.
4290
- if (tokens.length < 5)
4291
- continue;
4292
- const proto = tokens[0];
4293
- const local = tokens[1];
4294
- const remote = tokens[2];
4295
- const pid = tokens[tokens.length - 1];
4296
- if (proto !== "TCP")
4297
- continue;
4298
- if (!local.endsWith(portSuffix))
4299
- continue;
4300
- // Listening sockets carry a wildcard remote; anything else is a
4301
- // connection (and matching it would kill an unrelated process).
4302
- if (remote !== "0.0.0.0:0" && remote !== "[::]:0")
4303
- continue;
4304
- if (!/^\d+$/.test(pid))
4305
- continue;
4306
- pids.add(pid);
4307
- }
4308
- for (const pid of pids) {
4309
- result.attemptedPids.push(pid);
4310
- try {
4311
- const k = runner("taskkill", ["/F", "/PID", pid], {
4312
- stdio: "ignore",
4313
- timeout: HELPER_SPAWN_TIMEOUT_MS,
4314
- });
4315
- if (k.error || k.status !== 0) {
4316
- result.errors.push(`taskkill ${pid}: ${k.error?.message ?? `status=${k.status}`}`);
4317
- }
4318
- else {
4319
- result.killedPids.push(pid);
4320
- }
4321
- }
4322
- catch (e) {
4323
- result.errors.push(`taskkill ${pid}: ${e instanceof Error ? e.message : String(e)}`);
4324
- }
4325
- }
4326
- }
4327
- else {
4328
- const r = runner("lsof", ["-ti", `:${port}`], {
4329
- encoding: "utf-8",
4330
- stdio: ["ignore", "pipe", "ignore"],
4331
- timeout: HELPER_SPAWN_TIMEOUT_MS,
4332
- });
4333
- if (r.error) {
4334
- // ENOENT (lsof not installed) is a real diagnostic; surface it.
4335
- result.errors.push(`lsof: ${r.error.message}`);
4336
- return result;
4337
- }
4338
- // lsof exits 1 with empty stdout when the port is free — not an error.
4339
- if (r.status !== 0 || typeof r.stdout !== "string")
4340
- return result;
4341
- const pids = r.stdout.split(/\r?\n/).filter(p => /^\d+$/.test(p));
4342
- for (const pid of pids) {
4343
- result.attemptedPids.push(pid);
4344
- try {
4345
- const k = runner("kill", [pid], {
4346
- stdio: "ignore",
4347
- timeout: HELPER_SPAWN_TIMEOUT_MS,
4348
- });
4349
- if (k.error || k.status !== 0) {
4350
- result.errors.push(`kill ${pid}: ${k.error?.message ?? `status=${k.status}`}`);
4351
- }
4352
- else {
4353
- result.killedPids.push(pid);
4354
- }
4355
- }
4356
- catch (e) {
4357
- result.errors.push(`kill ${pid}: ${e instanceof Error ? e.message : String(e)}`);
4358
- }
4359
- }
4360
- }
4361
- }
4362
- catch (e) {
4363
- result.errors.push(e instanceof Error ? e.message : String(e));
4364
- }
4365
- return result;
4366
- }
4367
- // ── ctx-insight: open the hosted Insight dashboard ───────────────────────────
4368
- // Insight pivoted from a locally-built dashboard to the hosted B2B product at
4369
- // context-mode.com/insight (the landing page is the single source of truth).
4370
- // The tool now simply opens that URL in the user default browser via the same
4371
- // cross-platform helper (openBrowserSync) used elsewhere.
4372
- const INSIGHT_URL = "https://context-mode.com/insight";
4373
- server.registerTool("ctx_insight", {
4374
- title: "Open Insight Dashboard",
4375
- // #846: opens a hosted dashboard URL in the browser — an external side
4376
- // effect (open world), not a read-only query; safe to repeat.
4377
- annotations: {
4378
- readOnlyHint: false,
4379
- destructiveHint: false,
4380
- idempotentHint: true,
4381
- openWorldHint: true,
4382
- },
4383
- description: "Opens the context-mode Insight dashboard (https://context-mode.com/insight) in your " +
4384
- "default browser — a dashboard launcher for the hosted analytics layer, not a Q&A engine. " +
4385
- "Insight surfaces per-engineer productive rate, retry waste, blocker detection, and " +
4386
- "role-narrowed views for CTO, EM, IC, CISO, FinOps, and DevOps. " +
4387
- "For natural-language queries over your indexed content, use ctx_search.",
4388
- inputSchema: z.object({}),
4389
- }, async () => {
4390
- const open = openBrowserSync(INSIGHT_URL);
4391
- const text = open.ok
4392
- ? `Opening Insight in your browser: ${INSIGHT_URL}`
4393
- : `Could not auto-open your browser (${open.reason}).\nOpen Insight manually: ${INSIGHT_URL}`;
4394
- return trackResponse("ctx_insight", {
4395
- content: [{ type: "text", text }],
4396
- });
4397
- });
4398
- // ─────────────────────────────────────────────────────────
4399
- // Server startup
4400
- // ─────────────────────────────────────────────────────────
4401
- async function main() {
4402
- // Clean up stale DB files from previous sessions
4403
- const cleaned = cleanupStaleDBs();
4404
- if (cleaned > 0) {
4405
- console.error(`Cleaned up ${cleaned} stale DB file(s) from previous sessions`);
4406
- }
4407
- // MCP readiness sentinel path (#230, #347)
4408
- // Uses process.pid (not ppid) — hooks use directory-scan to find any live sentinel.
4409
- // Hardcoded /tmp on Unix to avoid TMPDIR mismatch (#347).
4410
- const mcpSentinelDir = process.platform === "win32" ? tmpdir() : "/tmp";
4411
- const mcpSentinel = join(mcpSentinelDir, `context-mode-mcp-ready-${process.pid}`);
4412
- // #844: handle to the periodic sentinel refresh timer (started after connect).
4413
- let sentinelRefresh;
4414
- // Clean up own DB + backgrounded processes + preload script on shutdown
4415
- const shutdown = () => {
4416
- executor.cleanupBackgrounded();
4417
- if (_store)
4418
- _store.close(); // persist DB for --continue sessions
4419
- try {
4420
- unlinkSync(CM_FS_PRELOAD);
4421
- }
4422
- catch { /* best effort */ }
4423
- // Remove MCP readiness sentinel (#230)
4424
- try {
4425
- unlinkSync(mcpSentinel);
4426
- }
4427
- catch { /* best effort */ }
4428
- // #844: stop refreshing the sentinel mtime on shutdown.
4429
- if (sentinelRefresh)
4430
- clearInterval(sentinelRefresh);
4431
- };
4432
- const gracefulShutdown = async () => {
4433
- // Final stats flush — bypass throttle so the last 0-500ms of
4434
- // bytes_indexed / bytes_returned aren't silently lost on SIGTERM/SIGINT
4435
- // (PR #401 grill-me review B1: persistStats early-returns inside throttle
4436
- // window; gracefulShutdown previously did NOT bypass).
4437
- try {
4438
- _lastStatsPersist = 0;
4439
- persistStats();
4440
- }
4441
- catch { /* best effort — never block shutdown */ }
4442
- shutdown();
4443
- process.exit(0);
4444
- };
4445
- process.on("exit", shutdown);
4446
- process.on("SIGINT", () => { gracefulShutdown(); });
4447
- process.on("SIGTERM", () => { gracefulShutdown(); });
4448
- // Lifecycle guard: detect parent death + stdin close to prevent orphaned processes (#103)
4449
- startLifecycleGuard({ onShutdown: () => gracefulShutdown() });
4450
- const transport = new StdioServerTransport();
4451
- await server.connect(transport);
4452
- // #854: refresh the bridge-child idle clock on each inbound MCP message so an
4453
- // abandoned bridge child (CONTEXT_MODE_BRIDGE_DEPTH>0) self-terminates instead
4454
- // of accumulating under a long-lived Pi/omp parent. Best-effort; no stdin touch.
4455
- attachMcpActivityTap(transport);
4456
- // Write MCP readiness sentinel (#230)
4457
- try {
4458
- writeFileSync(mcpSentinel, String(process.pid));
4459
- }
4460
- catch { /* best effort */ }
4461
- // #844: refresh the sentinel mtime while the server is alive so readiness
4462
- // probes from a foreign PID namespace (shared /tmp) can trust a recent
4463
- // sentinel even when process.kill(pid, 0) cannot see this PID. The reader's
4464
- // freshness window is 90s (hooks/core/mcp-ready.mjs); refresh at 30s (3x).
4465
- // unref() so this timer never keeps the event loop alive on its own.
4466
- sentinelRefresh = setInterval(() => {
4467
- try {
4468
- writeFileSync(mcpSentinel, String(process.pid));
4469
- }
4470
- catch { /* best effort */ }
4471
- }, 30_000);
4472
- sentinelRefresh.unref();
4473
- // Detect platform adapter — stored for platform-aware session paths
4474
- try {
4475
- const { detectPlatform, getAdapter } = await import("./adapters/detect.js");
4476
- const clientInfo = server.server.getClientVersion();
4477
- const signal = detectPlatform(clientInfo ?? undefined);
4478
- _detectedAdapter = await getAdapter(signal.platform);
4479
- if (clientInfo) {
4480
- console.error(`MCP client: ${clientInfo.name} v${clientInfo.version} → ${signal.platform}`);
4481
- }
4482
- }
4483
- catch { /* best effort — _detectedAdapter stays null, falls back to .claude */ }
4484
- // Restore tool-call counters from SessionDB BEFORE the heartbeat fires
4485
- // so the very first persistStats() carries the prior PID's totals into
4486
- // the sidecar JSON the statusline reads. Otherwise `/ctx-upgrade` flashes
4487
- // `0 calls / $0.00` until the user makes another MCP tool call. Wrapped
4488
- // in try/catch — a stats-restore failure must never block server startup.
4489
- try {
4490
- const restored = restoreSessionStats(getSessionDbPath());
4491
- if (restored) {
4492
- for (const [tool, count] of Object.entries(restored.calls)) {
4493
- sessionStats.calls[tool] = count;
4494
- }
4495
- for (const [tool, bytes] of Object.entries(restored.bytesReturned)) {
4496
- sessionStats.bytesReturned[tool] = bytes;
4497
- }
4498
- // Anchor uptime_ms to the original session start so `/ctx-upgrade`
4499
- // doesn't reset the "session age" the statusline shows.
4500
- if (restored.sessionStart > 0) {
4501
- sessionStats.sessionStart = restored.sessionStart;
4502
- }
4503
- }
4504
- }
4505
- catch { /* best effort — never block startup on a stats restore failure */ }
4506
- // Non-blocking version check — result stored for trackResponse warnings.
4507
- // First fetch at startup, then refresh every hour so long-running sessions
4508
- // (some users keep the MCP server alive 24h+) catch new releases without a
4509
- // restart. `.unref()` lets the process exit normally on SIGTERM regardless
4510
- // of pending intervals.
4511
- fetchLatestVersion().then(v => { if (v !== "unknown")
4512
- _latestVersion = v; });
4513
- setInterval(() => {
4514
- fetchLatestVersion().then(v => { if (v !== "unknown")
4515
- _latestVersion = v; });
4516
- }, 60 * 60 * 1000).unref();
4517
- // Stats heartbeat — keep the statusline truthful while the user works in
4518
- // tools other than MCP (Bash/Read/Edit during long sessions or post-/compact
4519
- // pauses). Without this, stats.updated_at only advances on MCP tool calls,
4520
- // so bin/statusline.mjs falsely flips to "stale — restart to resume saving"
4521
- // even though the server is alive. Heartbeat refreshes updated_at every 60s;
4522
- // statusline staleness threshold is 30min (cliff is 30 missed ticks away).
4523
- setInterval(() => persistStats(), 60_000).unref();
4524
- if (process.stdin.isTTY) {
4525
- console.error(`Context Mode MCP server v${VERSION} running on stdio`);
4526
- console.error(`Detected runtimes:\n${getRuntimeSummary(runtimes)}`);
4527
- if (!hasBunRuntime()) {
4528
- console.error("\nPerformance tip: Install Bun for 3-5x faster JS/TS execution");
4529
- console.error(" curl -fsSL https://bun.sh/install | bash");
4530
- }
4531
- }
4532
- }
4533
- // Runs after every registerTool() above, so the SDK's default tools/list handler
4534
- // exists and can be wrapped. Makes ctx_* schemas safe for strict (Gemini
4535
- // function-calling) clients like Antigravity CLI (`agy`) / Gemini CLI.
4536
- installStrictClientSchemaCompat();
4537
- if (process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
4538
- main().catch((err) => {
4539
- console.error("Fatal:", err);
4540
- process.exit(1);
4541
- });
4542
- }