rird 2.1.231 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (381) hide show
  1. package/AGENTS.md +86 -0
  2. package/COMPLETED_TEST_SUITE.txt +280 -0
  3. package/Dockerfile +18 -0
  4. package/README.md +397 -6
  5. package/RIRD_ERROR_HANDLING_SUMMARY.md +307 -0
  6. package/TESTING.md +512 -0
  7. package/TEST_IMPLEMENTATION_REPORT.md +463 -0
  8. package/TEST_SUITE.md +307 -0
  9. package/TEST_SUMMARY.txt +380 -0
  10. package/bin/rird-perf.js +37 -0
  11. package/bin/rird.js +43 -8
  12. package/bunfig.toml +4 -0
  13. package/create-wrapper.ps1 +51 -0
  14. package/docs/ARCHITECTURE.md +768 -0
  15. package/docs/CLI_REFERENCE.md +681 -0
  16. package/docs/DOCUMENTATION_MANIFEST.md +392 -0
  17. package/docs/INDEX.md +295 -0
  18. package/docs/PRODUCTION_SETUP.md +633 -0
  19. package/docs/TROUBLESHOOTING.md +914 -0
  20. package/facebook_ads_library.png +0 -0
  21. package/nul +0 -0
  22. package/nul`nif +0 -0
  23. package/package.json +104 -15
  24. package/parsers-config.ts +239 -0
  25. package/rird-1.0.199.tgz +0 -0
  26. package/rird-1.0.205.tgz +0 -0
  27. package/script/build-windows.ts +56 -0
  28. package/script/build.ts +165 -0
  29. package/{postinstall.mjs → script/postinstall.mjs} +47 -68
  30. package/script/publish-registries.ts +187 -0
  31. package/script/publish.ts +85 -0
  32. package/script/schema.ts +47 -0
  33. package/src/acp/README.md +164 -0
  34. package/src/acp/agent.ts +1063 -0
  35. package/src/acp/session.ts +101 -0
  36. package/src/acp/types.ts +22 -0
  37. package/src/agent/agent.ts +367 -0
  38. package/src/agent/generate.txt +75 -0
  39. package/src/agent/prompt/compaction.txt +12 -0
  40. package/src/agent/prompt/explore.txt +18 -0
  41. package/src/agent/prompt/summary.txt +10 -0
  42. package/src/agent/prompt/title.txt +36 -0
  43. package/src/auth/index.ts +70 -0
  44. package/src/bun/index.ts +114 -0
  45. package/src/bus/bus-event.ts +43 -0
  46. package/src/bus/global.ts +10 -0
  47. package/src/bus/index.ts +105 -0
  48. package/src/cli/bootstrap.ts +17 -0
  49. package/src/cli/cmd/acp.ts +104 -0
  50. package/src/cli/cmd/activate.ts +50 -0
  51. package/src/cli/cmd/agent.ts +256 -0
  52. package/src/cli/cmd/auth.ts +412 -0
  53. package/src/cli/cmd/cmd.ts +7 -0
  54. package/src/cli/cmd/debug/config.ts +15 -0
  55. package/src/cli/cmd/debug/file.ts +91 -0
  56. package/src/cli/cmd/debug/index.ts +43 -0
  57. package/src/cli/cmd/debug/lsp.ts +48 -0
  58. package/src/cli/cmd/debug/ripgrep.ts +83 -0
  59. package/src/cli/cmd/debug/scrap.ts +15 -0
  60. package/src/cli/cmd/debug/skill.ts +15 -0
  61. package/src/cli/cmd/debug/snapshot.ts +48 -0
  62. package/src/cli/cmd/export.ts +88 -0
  63. package/src/cli/cmd/generate.ts +38 -0
  64. package/src/cli/cmd/github.ts +1400 -0
  65. package/src/cli/cmd/import.ts +98 -0
  66. package/src/cli/cmd/mcp.ts +654 -0
  67. package/src/cli/cmd/models.ts +68 -0
  68. package/src/cli/cmd/pr.ts +112 -0
  69. package/src/cli/cmd/run.ts +434 -0
  70. package/src/cli/cmd/serve.ts +31 -0
  71. package/src/cli/cmd/session.ts +106 -0
  72. package/src/cli/cmd/stats.ts +298 -0
  73. package/src/cli/cmd/tui/app.tsx +694 -0
  74. package/src/cli/cmd/tui/attach.ts +30 -0
  75. package/src/cli/cmd/tui/component/border.tsx +21 -0
  76. package/src/cli/cmd/tui/component/dialog-agent.tsx +31 -0
  77. package/src/cli/cmd/tui/component/dialog-command.tsx +124 -0
  78. package/src/cli/cmd/tui/component/dialog-mcp.tsx +86 -0
  79. package/src/cli/cmd/tui/component/dialog-model.tsx +236 -0
  80. package/src/cli/cmd/tui/component/dialog-provider.tsx +240 -0
  81. package/src/cli/cmd/tui/component/dialog-session-list.tsx +102 -0
  82. package/src/cli/cmd/tui/component/dialog-session-rename.tsx +31 -0
  83. package/src/cli/cmd/tui/component/dialog-stash.tsx +86 -0
  84. package/src/cli/cmd/tui/component/dialog-status.tsx +162 -0
  85. package/src/cli/cmd/tui/component/dialog-tag.tsx +44 -0
  86. package/src/cli/cmd/tui/component/dialog-theme-list.tsx +50 -0
  87. package/src/cli/cmd/tui/component/did-you-know.tsx +85 -0
  88. package/src/cli/cmd/tui/component/logo.tsx +48 -0
  89. package/src/cli/cmd/tui/component/prompt/autocomplete.tsx +574 -0
  90. package/src/cli/cmd/tui/component/prompt/history.tsx +108 -0
  91. package/src/cli/cmd/tui/component/prompt/index.tsx +1087 -0
  92. package/src/cli/cmd/tui/component/prompt/stash.tsx +101 -0
  93. package/src/cli/cmd/tui/component/tips.ts +27 -0
  94. package/src/cli/cmd/tui/component/todo-item.tsx +32 -0
  95. package/src/cli/cmd/tui/context/args.tsx +14 -0
  96. package/src/cli/cmd/tui/context/directory.ts +13 -0
  97. package/src/cli/cmd/tui/context/exit.tsx +23 -0
  98. package/src/cli/cmd/tui/context/helper.tsx +25 -0
  99. package/src/cli/cmd/tui/context/keybind.tsx +101 -0
  100. package/src/cli/cmd/tui/context/kv.tsx +49 -0
  101. package/src/cli/cmd/tui/context/local.tsx +345 -0
  102. package/src/cli/cmd/tui/context/prompt.tsx +18 -0
  103. package/src/cli/cmd/tui/context/route.tsx +46 -0
  104. package/src/cli/cmd/tui/context/sdk.tsx +74 -0
  105. package/src/cli/cmd/tui/context/sync.tsx +372 -0
  106. package/src/cli/cmd/tui/context/theme/aura.json +69 -0
  107. package/src/cli/cmd/tui/context/theme/ayu.json +80 -0
  108. package/src/cli/cmd/tui/context/theme/catppuccin-frappe.json +233 -0
  109. package/src/cli/cmd/tui/context/theme/catppuccin-macchiato.json +233 -0
  110. package/src/cli/cmd/tui/context/theme/catppuccin.json +112 -0
  111. package/src/cli/cmd/tui/context/theme/cobalt2.json +228 -0
  112. package/src/cli/cmd/tui/context/theme/cursor.json +249 -0
  113. package/src/cli/cmd/tui/context/theme/dracula.json +219 -0
  114. package/src/cli/cmd/tui/context/theme/everforest.json +241 -0
  115. package/src/cli/cmd/tui/context/theme/flexoki.json +237 -0
  116. package/src/cli/cmd/tui/context/theme/github.json +233 -0
  117. package/src/cli/cmd/tui/context/theme/gruvbox.json +95 -0
  118. package/src/cli/cmd/tui/context/theme/kanagawa.json +77 -0
  119. package/src/cli/cmd/tui/context/theme/lucent-orng.json +227 -0
  120. package/src/cli/cmd/tui/context/theme/material.json +235 -0
  121. package/src/cli/cmd/tui/context/theme/matrix.json +77 -0
  122. package/src/cli/cmd/tui/context/theme/mercury.json +252 -0
  123. package/src/cli/cmd/tui/context/theme/monokai.json +221 -0
  124. package/src/cli/cmd/tui/context/theme/nightowl.json +221 -0
  125. package/src/cli/cmd/tui/context/theme/nord.json +223 -0
  126. package/src/cli/cmd/tui/context/theme/one-dark.json +84 -0
  127. package/src/cli/cmd/tui/context/theme/orng.json +245 -0
  128. package/src/cli/cmd/tui/context/theme/palenight.json +222 -0
  129. package/src/cli/cmd/tui/context/theme/rird.json +245 -0
  130. package/src/cli/cmd/tui/context/theme/rosepine.json +234 -0
  131. package/src/cli/cmd/tui/context/theme/solarized.json +223 -0
  132. package/src/cli/cmd/tui/context/theme/synthwave84.json +226 -0
  133. package/src/cli/cmd/tui/context/theme/tokyonight.json +243 -0
  134. package/src/cli/cmd/tui/context/theme/vercel.json +245 -0
  135. package/src/cli/cmd/tui/context/theme/vesper.json +218 -0
  136. package/src/cli/cmd/tui/context/theme/zenburn.json +223 -0
  137. package/src/cli/cmd/tui/context/theme.tsx +1109 -0
  138. package/src/cli/cmd/tui/event.ts +40 -0
  139. package/src/cli/cmd/tui/hooks/use-safe-terminal-dimensions.ts +12 -0
  140. package/src/cli/cmd/tui/routes/home.tsx +138 -0
  141. package/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx +64 -0
  142. package/src/cli/cmd/tui/routes/session/dialog-message.tsx +109 -0
  143. package/src/cli/cmd/tui/routes/session/dialog-subagent.tsx +26 -0
  144. package/src/cli/cmd/tui/routes/session/dialog-timeline.tsx +47 -0
  145. package/src/cli/cmd/tui/routes/session/footer.tsx +88 -0
  146. package/src/cli/cmd/tui/routes/session/header.tsx +125 -0
  147. package/src/cli/cmd/tui/routes/session/index.tsx +1876 -0
  148. package/src/cli/cmd/tui/routes/session/sidebar.tsx +320 -0
  149. package/src/cli/cmd/tui/spawn.ts +60 -0
  150. package/src/cli/cmd/tui/thread.ts +142 -0
  151. package/src/cli/cmd/tui/ui/dialog-alert.tsx +57 -0
  152. package/src/cli/cmd/tui/ui/dialog-confirm.tsx +83 -0
  153. package/src/cli/cmd/tui/ui/dialog-help.tsx +38 -0
  154. package/src/cli/cmd/tui/ui/dialog-prompt.tsx +77 -0
  155. package/src/cli/cmd/tui/ui/dialog-select.tsx +333 -0
  156. package/src/cli/cmd/tui/ui/dialog.tsx +171 -0
  157. package/src/cli/cmd/tui/ui/spinner.ts +368 -0
  158. package/src/cli/cmd/tui/ui/toast.tsx +100 -0
  159. package/src/cli/cmd/tui/util/clipboard.ts +127 -0
  160. package/src/cli/cmd/tui/util/editor.ts +32 -0
  161. package/src/cli/cmd/tui/util/terminal.ts +146 -0
  162. package/src/cli/cmd/tui/worker.ts +63 -0
  163. package/src/cli/cmd/uninstall.ts +344 -0
  164. package/src/cli/cmd/upgrade.ts +127 -0
  165. package/src/cli/cmd/web.ts +84 -0
  166. package/src/cli/error.ts +69 -0
  167. package/src/cli/ui.ts +101 -0
  168. package/src/cli/upgrade.ts +28 -0
  169. package/src/command/index.ts +80 -0
  170. package/src/command/template/initialize.txt +10 -0
  171. package/src/command/template/review.txt +97 -0
  172. package/src/config/config.ts +994 -0
  173. package/src/config/markdown.ts +41 -0
  174. package/src/env/index.ts +26 -0
  175. package/src/file/ignore.ts +83 -0
  176. package/src/file/index.ts +328 -0
  177. package/src/file/ripgrep.ts +393 -0
  178. package/src/file/time.ts +64 -0
  179. package/src/file/watcher.ts +103 -0
  180. package/src/flag/flag.ts +84 -0
  181. package/src/format/formatter.ts +315 -0
  182. package/src/format/index.ts +137 -0
  183. package/src/global/index.ts +101 -0
  184. package/src/id/id.ts +73 -0
  185. package/src/ide/index.ts +76 -0
  186. package/src/index.ts +297 -0
  187. package/src/index.ts.backup +271 -0
  188. package/src/installation/index.ts +258 -0
  189. package/src/lib/IMPLEMENTATION_NOTES.md +345 -0
  190. package/src/lib/error-handler.ts +225 -0
  191. package/src/lib/error-testing-guide.md +258 -0
  192. package/src/lib/errors.ts +285 -0
  193. package/src/lib/performance.ts +70 -0
  194. package/src/lib/telemetry.ts +282 -0
  195. package/src/lsp/client.ts +229 -0
  196. package/src/lsp/index.ts +485 -0
  197. package/src/lsp/language.ts +116 -0
  198. package/src/lsp/server.ts +1895 -0
  199. package/src/mcp/auth.ts +135 -0
  200. package/src/mcp/index.ts +1117 -0
  201. package/src/mcp/intent-analyzer.ts +376 -0
  202. package/src/mcp/oauth-callback.ts +200 -0
  203. package/src/mcp/oauth-provider.ts +154 -0
  204. package/src/patch/index.ts +632 -0
  205. package/src/permission/index.ts +199 -0
  206. package/src/plugin/index.ts +91 -0
  207. package/src/project/bootstrap.ts +33 -0
  208. package/src/project/instance.ts +78 -0
  209. package/src/project/project.ts +236 -0
  210. package/src/project/state.ts +65 -0
  211. package/src/project/vcs.ts +76 -0
  212. package/src/provider/auth.ts +143 -0
  213. package/src/provider/models-macro.ts +55 -0
  214. package/src/provider/models.ts +161 -0
  215. package/src/provider/provider.ts +1109 -0
  216. package/src/provider/sdk/openai-compatible/src/README.md +5 -0
  217. package/src/provider/sdk/openai-compatible/src/index.ts +2 -0
  218. package/src/provider/sdk/openai-compatible/src/openai-compatible-provider.ts +100 -0
  219. package/src/provider/sdk/openai-compatible/src/responses/convert-to-openai-responses-input.ts +303 -0
  220. package/src/provider/sdk/openai-compatible/src/responses/map-openai-responses-finish-reason.ts +22 -0
  221. package/src/provider/sdk/openai-compatible/src/responses/openai-config.ts +18 -0
  222. package/src/provider/sdk/openai-compatible/src/responses/openai-error.ts +22 -0
  223. package/src/provider/sdk/openai-compatible/src/responses/openai-responses-api-types.ts +207 -0
  224. package/src/provider/sdk/openai-compatible/src/responses/openai-responses-language-model.ts +1713 -0
  225. package/src/provider/sdk/openai-compatible/src/responses/openai-responses-prepare-tools.ts +177 -0
  226. package/src/provider/sdk/openai-compatible/src/responses/openai-responses-settings.ts +1 -0
  227. package/src/provider/sdk/openai-compatible/src/responses/tool/code-interpreter.ts +88 -0
  228. package/src/provider/sdk/openai-compatible/src/responses/tool/file-search.ts +128 -0
  229. package/src/provider/sdk/openai-compatible/src/responses/tool/image-generation.ts +115 -0
  230. package/src/provider/sdk/openai-compatible/src/responses/tool/local-shell.ts +65 -0
  231. package/src/provider/sdk/openai-compatible/src/responses/tool/web-search-preview.ts +104 -0
  232. package/src/provider/sdk/openai-compatible/src/responses/tool/web-search.ts +103 -0
  233. package/src/provider/transform.ts +455 -0
  234. package/src/pty/index.ts +231 -0
  235. package/src/security/guardrails.test.ts +341 -0
  236. package/src/security/guardrails.ts +570 -0
  237. package/src/security/index.ts +19 -0
  238. package/src/server/error.ts +36 -0
  239. package/src/server/project.ts +79 -0
  240. package/src/server/server.ts +2641 -0
  241. package/src/server/tui.ts +71 -0
  242. package/src/session/compaction.ts +228 -0
  243. package/src/session/index.ts +464 -0
  244. package/src/session/llm.ts +201 -0
  245. package/src/session/message-v2.ts +695 -0
  246. package/src/session/message.ts +189 -0
  247. package/src/session/processor.ts +409 -0
  248. package/src/session/prompt/act-switch.txt +5 -0
  249. package/src/session/prompt/anthropic-20250930.txt +166 -0
  250. package/src/session/prompt/anthropic.txt +63 -0
  251. package/src/session/prompt/anthropic_spoof.txt +1 -0
  252. package/src/session/prompt/beast.txt +76 -0
  253. package/src/session/prompt/codex.txt +304 -0
  254. package/src/session/prompt/copilot-gpt-5.txt +137 -0
  255. package/src/session/prompt/gemini.txt +62 -0
  256. package/src/session/prompt/max-steps.txt +16 -0
  257. package/src/session/prompt/plan-reminder-anthropic.txt +35 -0
  258. package/src/session/prompt/plan.txt +24 -0
  259. package/src/session/prompt/polaris.txt +88 -0
  260. package/src/session/prompt/qwen.txt +59 -0
  261. package/src/session/prompt.ts +1552 -0
  262. package/src/session/retry.ts +86 -0
  263. package/src/session/revert.ts +108 -0
  264. package/src/session/sensitive-filter.test.ts +327 -0
  265. package/src/session/sensitive-filter.ts +466 -0
  266. package/src/session/status.ts +76 -0
  267. package/src/session/summary.ts +209 -0
  268. package/src/session/system.ts +122 -0
  269. package/src/session/todo.ts +37 -0
  270. package/src/share/share-next.ts +222 -0
  271. package/src/share/share.ts +87 -0
  272. package/src/shell/shell.ts +67 -0
  273. package/src/skill/index.ts +1 -0
  274. package/src/skill/skill.ts +83 -0
  275. package/src/snapshot/index.ts +197 -0
  276. package/src/storage/storage.ts +226 -0
  277. package/src/tests/agent.test.ts +308 -0
  278. package/src/tests/build-guards.test.ts +267 -0
  279. package/src/tests/config.test.ts +664 -0
  280. package/src/tests/tool-registry.test.ts +589 -0
  281. package/src/tool/bash.ts +314 -0
  282. package/src/tool/bash.txt +158 -0
  283. package/src/tool/batch.ts +175 -0
  284. package/src/tool/batch.txt +24 -0
  285. package/src/tool/codesearch.ts +184 -0
  286. package/src/tool/codesearch.txt +12 -0
  287. package/src/tool/edit.ts +675 -0
  288. package/src/tool/edit.txt +10 -0
  289. package/src/tool/glob.ts +65 -0
  290. package/src/tool/glob.txt +6 -0
  291. package/src/tool/grep.ts +121 -0
  292. package/src/tool/grep.txt +8 -0
  293. package/src/tool/invalid.ts +17 -0
  294. package/src/tool/ls.ts +110 -0
  295. package/src/tool/ls.txt +1 -0
  296. package/src/tool/lsp-diagnostics.ts +26 -0
  297. package/src/tool/lsp-diagnostics.txt +1 -0
  298. package/src/tool/lsp-hover.ts +31 -0
  299. package/src/tool/lsp-hover.txt +1 -0
  300. package/src/tool/lsp.ts +87 -0
  301. package/src/tool/lsp.txt +19 -0
  302. package/src/tool/multiedit.ts +46 -0
  303. package/src/tool/multiedit.txt +41 -0
  304. package/src/tool/patch.ts +233 -0
  305. package/src/tool/patch.txt +1 -0
  306. package/src/tool/read.ts +219 -0
  307. package/src/tool/read.txt +12 -0
  308. package/src/tool/registry.ts +162 -0
  309. package/src/tool/skill.ts +100 -0
  310. package/src/tool/task.ts +136 -0
  311. package/src/tool/task.txt +51 -0
  312. package/src/tool/todo.ts +39 -0
  313. package/src/tool/todoread.txt +14 -0
  314. package/src/tool/todowrite.txt +167 -0
  315. package/src/tool/tool.ts +71 -0
  316. package/src/tool/webfetch.ts +198 -0
  317. package/src/tool/webfetch.txt +13 -0
  318. package/src/tool/websearch.ts +268 -0
  319. package/src/tool/websearch.txt +13 -0
  320. package/src/tool/write.ts +110 -0
  321. package/src/tool/write.txt +8 -0
  322. package/src/util/archive.ts +16 -0
  323. package/src/util/color.ts +19 -0
  324. package/src/util/context.ts +25 -0
  325. package/src/util/defer.ts +12 -0
  326. package/src/util/eventloop.ts +20 -0
  327. package/src/util/filesystem.ts +83 -0
  328. package/src/util/fn.ts +11 -0
  329. package/src/util/iife.ts +3 -0
  330. package/src/util/keybind.ts +102 -0
  331. package/src/util/lazy.ts +11 -0
  332. package/src/util/license.ts +362 -0
  333. package/src/util/locale.ts +81 -0
  334. package/src/util/lock.ts +98 -0
  335. package/src/util/log.ts +180 -0
  336. package/src/util/queue.ts +32 -0
  337. package/src/util/rpc.ts +42 -0
  338. package/src/util/scrap.ts +10 -0
  339. package/src/util/signal.ts +12 -0
  340. package/src/util/timeout.ts +14 -0
  341. package/src/util/token.ts +7 -0
  342. package/src/util/wildcard.ts +54 -0
  343. package/sst-env.d.ts +9 -0
  344. package/test/agent/agent.test.ts +146 -0
  345. package/test/bun.test.ts +53 -0
  346. package/test/cli/cmd/acp.test.ts +144 -0
  347. package/test/cli/cmd/run.test.ts +250 -0
  348. package/test/cli/github-remote.test.ts +80 -0
  349. package/test/config/agent-color.test.ts +66 -0
  350. package/test/config/config.test.ts +536 -0
  351. package/test/config/markdown.test.ts +89 -0
  352. package/test/file/ignore.test.ts +10 -0
  353. package/test/fixture/fixture.ts +37 -0
  354. package/test/fixture/lsp/fake-lsp-server.js +77 -0
  355. package/test/helpers.ts +172 -0
  356. package/test/ide/ide.test.ts +82 -0
  357. package/test/installation/installation.test.ts +143 -0
  358. package/test/keybind.test.ts +421 -0
  359. package/test/lsp/client.test.ts +95 -0
  360. package/test/mcp/headers.test.ts +153 -0
  361. package/test/patch/patch.test.ts +348 -0
  362. package/test/preload.ts +57 -0
  363. package/test/project/project.test.ts +74 -0
  364. package/test/provider/provider.test.ts +74 -0
  365. package/test/provider/transform.test.ts +411 -0
  366. package/test/session/retry.test.ts +111 -0
  367. package/test/session/session.test.ts +71 -0
  368. package/test/skill/skill.test.ts +131 -0
  369. package/test/snapshot/snapshot.test.ts +940 -0
  370. package/test/tool/__snapshots__/tool.test.ts.snap +9 -0
  371. package/test/tool/bash.test.ts +434 -0
  372. package/test/tool/grep.test.ts +108 -0
  373. package/test/tool/patch.test.ts +259 -0
  374. package/test/tool/read.test.ts +42 -0
  375. package/test/util/iife.test.ts +36 -0
  376. package/test/util/lazy.test.ts +50 -0
  377. package/test/util/license.test.ts +235 -0
  378. package/test/util/timeout.test.ts +21 -0
  379. package/test/util/wildcard.test.ts +55 -0
  380. package/tsconfig.json +16 -0
  381. package/update-versions.ps1 +65 -0
@@ -0,0 +1,1117 @@
1
+ import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai"
2
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js"
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
4
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
5
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
6
+ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
7
+ import { type Tool as MCPToolDef, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
8
+ import { Config } from "../config/config"
9
+ import { Log } from "../util/log"
10
+ import { NamedError } from "@opencode-ai/util/error"
11
+ import z from "zod"
12
+ import { Instance } from "../project/instance"
13
+ import { Installation } from "../installation"
14
+ import { withTimeout } from "@/util/timeout"
15
+ import { McpOAuthProvider } from "./oauth-provider"
16
+ import { McpOAuthCallback } from "./oauth-callback"
17
+ import { McpAuth } from "./auth"
18
+ import { BusEvent } from "../bus/bus-event"
19
+ import { Bus } from "@/bus"
20
+ import { TuiEvent } from "@/cli/cmd/tui/event"
21
+ import open from "open"
22
+ import { validateTask, logBlockedAttempt } from "../security"
23
+ import { analyzeIntent, isSimpleTask } from "./intent-analyzer"
24
+ import { Session } from "../session"
25
+
26
+ export namespace MCP {
27
+ const log = Log.create({ service: "mcp" })
28
+
29
+ export function init() {
30
+ Bus.subscribe(Session.Event.Deleted, (evt) => {
31
+ resetSectionTracking(evt.properties.info.id)
32
+ })
33
+ }
34
+
35
+ export const ToolsChanged = BusEvent.define(
36
+ "mcp.tools.changed",
37
+ z.object({
38
+ server: z.string(),
39
+ }),
40
+ )
41
+
42
+ export const Failed = NamedError.create(
43
+ "MCPFailed",
44
+ z.object({
45
+ name: z.string(),
46
+ }),
47
+ )
48
+
49
+ type MCPClient = Client
50
+
51
+ export const Status = z
52
+ .discriminatedUnion("status", [
53
+ z
54
+ .object({
55
+ status: z.literal("connected"),
56
+ })
57
+ .meta({
58
+ ref: "MCPStatusConnected",
59
+ }),
60
+ z
61
+ .object({
62
+ status: z.literal("disabled"),
63
+ })
64
+ .meta({
65
+ ref: "MCPStatusDisabled",
66
+ }),
67
+ z
68
+ .object({
69
+ status: z.literal("failed"),
70
+ error: z.string(),
71
+ })
72
+ .meta({
73
+ ref: "MCPStatusFailed",
74
+ }),
75
+ z
76
+ .object({
77
+ status: z.literal("needs_auth"),
78
+ })
79
+ .meta({
80
+ ref: "MCPStatusNeedsAuth",
81
+ }),
82
+ z
83
+ .object({
84
+ status: z.literal("needs_client_registration"),
85
+ error: z.string(),
86
+ })
87
+ .meta({
88
+ ref: "MCPStatusNeedsClientRegistration",
89
+ }),
90
+ ])
91
+ .meta({
92
+ ref: "MCPStatus",
93
+ })
94
+ export type Status = z.infer<typeof Status>
95
+
96
+ // Register notification handlers for MCP client
97
+ function registerNotificationHandlers(client: MCPClient, serverName: string) {
98
+ client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
99
+ log.info("tools list changed notification received", { server: serverName })
100
+ Bus.publish(ToolsChanged, { server: serverName })
101
+ })
102
+ }
103
+
104
+ // Convert MCP tool definition to AI SDK Tool type
105
+ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Tool {
106
+ const inputSchema = mcpTool.inputSchema
107
+
108
+ // Spread first, then override type to ensure it's always "object"
109
+ const schema: JSONSchema7 = {
110
+ ...(inputSchema as JSONSchema7),
111
+ type: "object",
112
+ properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"],
113
+ additionalProperties: false,
114
+ }
115
+
116
+ return dynamicTool({
117
+ description: mcpTool.description ?? "",
118
+ inputSchema: jsonSchema(schema),
119
+ execute: async (args: unknown) => {
120
+ // Security guardrail: validate MCP tool arguments
121
+ const argsRecord = args as Record<string, unknown>
122
+ const taskCheck = validateTask({
123
+ description: mcpTool.description,
124
+ url: typeof argsRecord.url === "string" ? argsRecord.url : undefined,
125
+ command: typeof argsRecord.command === "string" ? argsRecord.command : undefined,
126
+ })
127
+ if (taskCheck.blocked) {
128
+ logBlockedAttempt(taskCheck, {
129
+ task: `MCP tool: ${mcpTool.name}`,
130
+ url: typeof argsRecord.url === "string" ? argsRecord.url : undefined,
131
+ command: typeof argsRecord.command === "string" ? argsRecord.command : undefined,
132
+ timestamp: new Date(),
133
+ })
134
+ throw new Error(`MCP tool blocked by security guardrails: ${taskCheck.reason}`)
135
+ }
136
+
137
+ return client.callTool({
138
+ name: mcpTool.name,
139
+ arguments: argsRecord,
140
+ })
141
+ },
142
+ })
143
+ }
144
+
145
+ // Store transports for OAuth servers to allow finishing auth
146
+ type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
147
+ const pendingOAuthTransports = new Map<string, TransportWithAuth>()
148
+
149
+ const state = Instance.state(
150
+ async () => {
151
+ const cfg = await Config.get()
152
+ const config = cfg.mcp ?? {}
153
+ const clients: Record<string, MCPClient> = {}
154
+ const status: Record<string, Status> = {}
155
+
156
+ await Promise.all(
157
+ Object.entries(config).map(async ([key, mcp]) => {
158
+ // If disabled by config, mark as disabled without trying to connect
159
+ if (mcp.enabled === false) {
160
+ status[key] = { status: "disabled" }
161
+ return
162
+ }
163
+
164
+ const result = await create(key, mcp).catch(() => undefined)
165
+ if (!result) return
166
+
167
+ status[key] = result.status
168
+
169
+ if (result.mcpClient) {
170
+ clients[key] = result.mcpClient
171
+ }
172
+ }),
173
+ )
174
+ return {
175
+ status,
176
+ clients,
177
+ }
178
+ },
179
+ async (state) => {
180
+ await Promise.all(
181
+ Object.values(state.clients).map((client) =>
182
+ client.close().catch((error) => {
183
+ log.error("Failed to close MCP client", {
184
+ error,
185
+ })
186
+ }),
187
+ ),
188
+ )
189
+ pendingOAuthTransports.clear()
190
+ },
191
+ )
192
+
193
+ export async function add(name: string, mcp: Config.Mcp) {
194
+ const s = await state()
195
+ const result = await create(name, mcp)
196
+ if (!result) {
197
+ const status = {
198
+ status: "failed" as const,
199
+ error: "unknown error",
200
+ }
201
+ s.status[name] = status
202
+ return {
203
+ status,
204
+ }
205
+ }
206
+ if (!result.mcpClient) {
207
+ s.status[name] = result.status
208
+ return {
209
+ status: s.status,
210
+ }
211
+ }
212
+ s.clients[name] = result.mcpClient
213
+ s.status[name] = result.status
214
+
215
+ return {
216
+ status: s.status,
217
+ }
218
+ }
219
+
220
+ async function create(key: string, mcp: Config.Mcp) {
221
+ if (mcp.enabled === false) {
222
+ log.info("mcp server disabled", { key })
223
+ return {
224
+ mcpClient: undefined,
225
+ status: { status: "disabled" as const },
226
+ }
227
+ }
228
+ log.info("found", { key, type: mcp.type })
229
+ let mcpClient: MCPClient | undefined
230
+ let status: Status | undefined = undefined
231
+
232
+ if (mcp.type === "remote") {
233
+ const url = new URL(mcp.url)
234
+ const oauthDisabled = mcp.oauth === false
235
+ const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
236
+ let authProvider: McpOAuthProvider | undefined
237
+
238
+ if (!oauthDisabled) {
239
+ authProvider = new McpOAuthProvider(
240
+ key,
241
+ mcp.url,
242
+ {
243
+ clientId: oauthConfig?.clientId,
244
+ clientSecret: oauthConfig?.clientSecret,
245
+ scope: oauthConfig?.scope,
246
+ },
247
+ {
248
+ onRedirect: async (url) => {
249
+ log.info("oauth redirect requested", { key, url: url.toString() })
250
+ },
251
+ },
252
+ )
253
+ }
254
+
255
+ const transports: Array<{ name: string; transport: TransportWithAuth }> = [
256
+ {
257
+ name: "StreamableHTTP",
258
+ transport: new StreamableHTTPClientTransport(url, {
259
+ authProvider,
260
+ requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
261
+ }),
262
+ },
263
+ {
264
+ name: "SSE",
265
+ transport: new SSEClientTransport(url, {
266
+ authProvider,
267
+ requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
268
+ }),
269
+ },
270
+ ]
271
+
272
+ let lastError: Error | undefined
273
+ for (const { name, transport } of transports) {
274
+ try {
275
+ const client = new Client({
276
+ name: "rird",
277
+ version: Installation.VERSION,
278
+ })
279
+ await client.connect(transport)
280
+ registerNotificationHandlers(client, key)
281
+ mcpClient = client
282
+ log.info("connected", { key, transport: name })
283
+ status = { status: "connected" }
284
+ break
285
+ } catch (error) {
286
+ lastError = error instanceof Error ? error : new Error(String(error))
287
+
288
+ // Handle OAuth-specific errors
289
+ if (error instanceof UnauthorizedError) {
290
+ log.info("mcp server requires authentication", { key, transport: name })
291
+
292
+ // Check if this is a "needs registration" error
293
+ if (lastError.message.includes("registration") || lastError.message.includes("client_id")) {
294
+ status = {
295
+ status: "needs_client_registration" as const,
296
+ error: "Server does not support dynamic client registration. Please provide clientId in config.",
297
+ }
298
+ // Show toast for needs_client_registration
299
+ Bus.publish(TuiEvent.ToastShow, {
300
+ title: "MCP Authentication Required",
301
+ message: `Server "${key}" requires a pre-registered client ID. Add clientId to your config.`,
302
+ variant: "warning",
303
+ duration: 8000,
304
+ }).catch((e) => log.debug("failed to show toast", { error: e }))
305
+ } else {
306
+ // Store transport for later finishAuth call
307
+ pendingOAuthTransports.set(key, transport)
308
+ status = { status: "needs_auth" as const }
309
+ // Show toast for needs_auth
310
+ Bus.publish(TuiEvent.ToastShow, {
311
+ title: "MCP Authentication Required",
312
+ message: `Server "${key}" requires authentication. Run: rird mcp auth ${key}`,
313
+ variant: "warning",
314
+ duration: 8000,
315
+ }).catch((e) => log.debug("failed to show toast", { error: e }))
316
+ }
317
+ break
318
+ }
319
+
320
+ log.debug("transport connection failed", {
321
+ key,
322
+ transport: name,
323
+ url: mcp.url,
324
+ error: lastError.message,
325
+ })
326
+ status = {
327
+ status: "failed" as const,
328
+ error: lastError.message,
329
+ }
330
+ }
331
+ }
332
+ }
333
+
334
+ if (mcp.type === "local") {
335
+ const [cmd, ...args] = mcp.command
336
+
337
+ // Check if script file exists for python/node commands
338
+ const interpreters = ["python", "python3", "node", "bun", "npx"]
339
+ if (interpreters.includes(cmd) && args.length > 0) {
340
+ const scriptPath = args[0]
341
+ // Skip check for npx packages (e.g., "npx -y mcp-desktop-automation")
342
+ if (!scriptPath.startsWith("-") && !scriptPath.startsWith("@")) {
343
+ try {
344
+ const fs = await import("fs/promises")
345
+ await fs.access(scriptPath)
346
+ } catch {
347
+ log.error("MCP script not found", { key, scriptPath })
348
+ status = {
349
+ status: "failed" as const,
350
+ error: `Script not found: ${scriptPath}. Run postinstall or check your config.`,
351
+ }
352
+ return { mcpClient: undefined, status }
353
+ }
354
+ }
355
+ }
356
+
357
+ const transport = new StdioClientTransport({
358
+ stderr: "ignore",
359
+ command: cmd,
360
+ args,
361
+ env: {
362
+ ...process.env,
363
+ ...(cmd === "rird" ? { BUN_BE_BUN: "1" } : {}),
364
+ ...mcp.environment,
365
+ },
366
+ })
367
+
368
+ try {
369
+ const client = new Client({
370
+ name: "rird",
371
+ version: Installation.VERSION,
372
+ })
373
+ await client.connect(transport)
374
+ registerNotificationHandlers(client, key)
375
+ mcpClient = client
376
+ status = {
377
+ status: "connected",
378
+ }
379
+ } catch (error) {
380
+ log.error("local mcp startup failed", {
381
+ key,
382
+ command: mcp.command,
383
+ error: error instanceof Error ? error.message : String(error),
384
+ })
385
+ status = {
386
+ status: "failed" as const,
387
+ error: error instanceof Error ? error.message : String(error),
388
+ }
389
+ }
390
+ }
391
+
392
+ if (!status) {
393
+ status = {
394
+ status: "failed" as const,
395
+ error: "Unknown error",
396
+ }
397
+ }
398
+
399
+ if (!mcpClient) {
400
+ return {
401
+ mcpClient: undefined,
402
+ status,
403
+ }
404
+ }
405
+
406
+ const result = await withTimeout(mcpClient.listTools(), mcp.timeout ?? 5000).catch((err) => {
407
+ log.error("failed to get tools from client", { key, error: err })
408
+ return undefined
409
+ })
410
+ if (!result) {
411
+ await mcpClient.close().catch((error) => {
412
+ log.error("Failed to close MCP client", {
413
+ error,
414
+ })
415
+ })
416
+ status = {
417
+ status: "failed",
418
+ error: "Failed to get tools",
419
+ }
420
+ return {
421
+ mcpClient: undefined,
422
+ status: {
423
+ status: "failed" as const,
424
+ error: "Failed to get tools",
425
+ },
426
+ }
427
+ }
428
+
429
+ log.info("create() successfully created client", { key, toolCount: result.tools.length })
430
+ return {
431
+ mcpClient,
432
+ status,
433
+ }
434
+ }
435
+
436
+ export async function status() {
437
+ const s = await state()
438
+ const cfg = await Config.get()
439
+ const config = cfg.mcp ?? {}
440
+ const result: Record<string, Status> = {}
441
+
442
+ // Include all MCPs from config, not just connected ones
443
+ for (const key of Object.keys(config)) {
444
+ result[key] = s.status[key] ?? { status: "disabled" }
445
+ }
446
+
447
+ return result
448
+ }
449
+
450
+ export async function clients() {
451
+ return state().then((state) => state.clients)
452
+ }
453
+
454
+ export async function connect(name: string) {
455
+ const cfg = await Config.get()
456
+ const config = cfg.mcp ?? {}
457
+ const mcp = config[name]
458
+ if (!mcp) {
459
+ log.error("MCP config not found", { name })
460
+ return
461
+ }
462
+
463
+ const result = await create(name, { ...mcp, enabled: true })
464
+
465
+ if (!result) {
466
+ const s = await state()
467
+ s.status[name] = {
468
+ status: "failed",
469
+ error: "Unknown error during connection",
470
+ }
471
+ return
472
+ }
473
+
474
+ const s = await state()
475
+ s.status[name] = result.status
476
+ if (result.mcpClient) {
477
+ s.clients[name] = result.mcpClient
478
+ }
479
+ }
480
+
481
+ export async function disconnect(name: string) {
482
+ const s = await state()
483
+ const client = s.clients[name]
484
+ if (client) {
485
+ await client.close().catch((error) => {
486
+ log.error("Failed to close MCP client", { name, error })
487
+ })
488
+ delete s.clients[name]
489
+ }
490
+ s.status[name] = { status: "disabled" }
491
+ }
492
+
493
+ export async function tools() {
494
+ const result: Record<string, Tool> = {}
495
+ const s = await state()
496
+ const clientsSnapshot = await clients()
497
+
498
+ const BROWSER_MCP_NAMES = new Set(["chrome-browser", "stealth-browser"])
499
+
500
+ for (const [clientName, client] of Object.entries(clientsSnapshot)) {
501
+ // Only include tools from connected MCPs (skip disabled ones)
502
+ if (s.status[clientName]?.status !== "connected") {
503
+ continue
504
+ }
505
+
506
+ const toolsResult = await client.listTools().catch((e) => {
507
+ log.error("failed to get tools", { clientName, error: e.message })
508
+ const failedStatus = {
509
+ status: "failed" as const,
510
+ error: e instanceof Error ? e.message : String(e),
511
+ }
512
+ s.status[clientName] = failedStatus
513
+ delete s.clients[clientName]
514
+ return undefined
515
+ })
516
+ if (!toolsResult) {
517
+ continue
518
+ }
519
+ for (const mcpTool of toolsResult.tools) {
520
+ const sanitizedClientName = (BROWSER_MCP_NAMES.has(clientName) ? "chrome" : clientName).replace(
521
+ /[^a-zA-Z0-9_-]/g,
522
+ "_",
523
+ )
524
+ const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
525
+ result[sanitizedClientName + "_" + sanitizedToolName] = convertMcpTool(mcpTool, client)
526
+ }
527
+ }
528
+ return result
529
+ }
530
+
531
+ /**
532
+ * Start OAuth authentication flow for an MCP server.
533
+ * Returns the authorization URL that should be opened in a browser.
534
+ */
535
+ export async function startAuth(mcpName: string): Promise<{ authorizationUrl: string }> {
536
+ const cfg = await Config.get()
537
+ const mcpConfig = cfg.mcp?.[mcpName]
538
+
539
+ if (!mcpConfig) {
540
+ throw new Error(`MCP server not found: ${mcpName}`)
541
+ }
542
+
543
+ if (mcpConfig.type !== "remote") {
544
+ throw new Error(`MCP server ${mcpName} is not a remote server`)
545
+ }
546
+
547
+ if (mcpConfig.oauth === false) {
548
+ throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
549
+ }
550
+
551
+ // Start the callback server
552
+ await McpOAuthCallback.ensureRunning()
553
+
554
+ // Generate and store a cryptographically secure state parameter BEFORE creating the provider
555
+ // The SDK will call provider.state() to read this value
556
+ const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
557
+ .map((b) => b.toString(16).padStart(2, "0"))
558
+ .join("")
559
+ await McpAuth.updateOAuthState(mcpName, oauthState)
560
+
561
+ // Create a new auth provider for this flow
562
+ // OAuth config is optional - if not provided, we'll use auto-discovery
563
+ const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
564
+ let capturedUrl: URL | undefined
565
+ const authProvider = new McpOAuthProvider(
566
+ mcpName,
567
+ mcpConfig.url,
568
+ {
569
+ clientId: oauthConfig?.clientId,
570
+ clientSecret: oauthConfig?.clientSecret,
571
+ scope: oauthConfig?.scope,
572
+ },
573
+ {
574
+ onRedirect: async (url) => {
575
+ capturedUrl = url
576
+ },
577
+ },
578
+ )
579
+
580
+ // Create transport with auth provider
581
+ const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), {
582
+ authProvider,
583
+ })
584
+
585
+ // Try to connect - this will trigger the OAuth flow
586
+ try {
587
+ const client = new Client({
588
+ name: "rird",
589
+ version: Installation.VERSION,
590
+ })
591
+ await client.connect(transport)
592
+ // If we get here, we're already authenticated
593
+ return { authorizationUrl: "" }
594
+ } catch (error) {
595
+ if (error instanceof UnauthorizedError && capturedUrl) {
596
+ // Store transport for finishAuth
597
+ pendingOAuthTransports.set(mcpName, transport)
598
+ return { authorizationUrl: capturedUrl.toString() }
599
+ }
600
+ throw error
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Complete OAuth authentication after user authorizes in browser.
606
+ * Opens the browser and waits for callback.
607
+ */
608
+ export async function authenticate(mcpName: string): Promise<Status> {
609
+ const { authorizationUrl } = await startAuth(mcpName)
610
+
611
+ if (!authorizationUrl) {
612
+ // Already authenticated
613
+ const s = await state()
614
+ return s.status[mcpName] ?? { status: "connected" }
615
+ }
616
+
617
+ // Get the state that was already generated and stored in startAuth()
618
+ const oauthState = await McpAuth.getOAuthState(mcpName)
619
+ if (!oauthState) {
620
+ throw new Error("OAuth state not found - this should not happen")
621
+ }
622
+
623
+ // The SDK has already added the state parameter to the authorization URL
624
+ // We just need to open the browser
625
+ log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
626
+ await open(authorizationUrl)
627
+
628
+ // Wait for callback using the OAuth state parameter
629
+ const code = await McpOAuthCallback.waitForCallback(oauthState)
630
+
631
+ // Validate and clear the state
632
+ const storedState = await McpAuth.getOAuthState(mcpName)
633
+ if (storedState !== oauthState) {
634
+ await McpAuth.clearOAuthState(mcpName)
635
+ throw new Error("OAuth state mismatch - potential CSRF attack")
636
+ }
637
+
638
+ await McpAuth.clearOAuthState(mcpName)
639
+
640
+ // Finish auth
641
+ return finishAuth(mcpName, code)
642
+ }
643
+
644
+ /**
645
+ * Complete OAuth authentication with the authorization code.
646
+ */
647
+ export async function finishAuth(mcpName: string, authorizationCode: string): Promise<Status> {
648
+ const transport = pendingOAuthTransports.get(mcpName)
649
+
650
+ if (!transport) {
651
+ throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
652
+ }
653
+
654
+ try {
655
+ // Call finishAuth on the transport
656
+ await transport.finishAuth(authorizationCode)
657
+
658
+ // Clear the code verifier after successful auth
659
+ await McpAuth.clearCodeVerifier(mcpName)
660
+
661
+ // Now try to reconnect
662
+ const cfg = await Config.get()
663
+ const mcpConfig = cfg.mcp?.[mcpName]
664
+
665
+ if (!mcpConfig) {
666
+ throw new Error(`MCP server not found: ${mcpName}`)
667
+ }
668
+
669
+ // Re-add the MCP server to establish connection
670
+ pendingOAuthTransports.delete(mcpName)
671
+ const result = await add(mcpName, mcpConfig)
672
+
673
+ const statusRecord = result.status as Record<string, Status>
674
+ return statusRecord[mcpName] ?? { status: "failed", error: "Unknown error after auth" }
675
+ } catch (error) {
676
+ log.error("failed to finish oauth", { mcpName, error })
677
+ return {
678
+ status: "failed",
679
+ error: error instanceof Error ? error.message : String(error),
680
+ }
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Remove OAuth credentials for an MCP server.
686
+ */
687
+ export async function removeAuth(mcpName: string): Promise<void> {
688
+ await McpAuth.remove(mcpName)
689
+ McpOAuthCallback.cancelPending(mcpName)
690
+ pendingOAuthTransports.delete(mcpName)
691
+ await McpAuth.clearOAuthState(mcpName)
692
+ log.info("removed oauth credentials", { mcpName })
693
+ }
694
+
695
+ /**
696
+ * Check if an MCP server supports OAuth (remote servers support OAuth by default unless explicitly disabled).
697
+ */
698
+ export async function supportsOAuth(mcpName: string): Promise<boolean> {
699
+ const cfg = await Config.get()
700
+ const mcpConfig = cfg.mcp?.[mcpName]
701
+ return mcpConfig?.type === "remote" && mcpConfig.oauth !== false
702
+ }
703
+
704
+ /**
705
+ * Check if an MCP server has stored OAuth tokens.
706
+ */
707
+ export async function hasStoredTokens(mcpName: string): Promise<boolean> {
708
+ const entry = await McpAuth.get(mcpName)
709
+ return !!entry?.tokens
710
+ }
711
+
712
+ export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
713
+
714
+ /**
715
+ * Get the authentication status for an MCP server.
716
+ */
717
+ export async function getAuthStatus(mcpName: string): Promise<AuthStatus> {
718
+ const hasTokens = await hasStoredTokens(mcpName)
719
+ if (!hasTokens) return "not_authenticated"
720
+ const expired = await McpAuth.isTokenExpired(mcpName)
721
+ return expired ? "expired" : "authenticated"
722
+ }
723
+
724
+ // =============================================================================
725
+ // SECTION LOADING: Dynamic tool section management for browser MCP
726
+ // =============================================================================
727
+
728
+ /** Browser MCP server names (config keys) */
729
+ const BROWSER_MCP_NAMES = ["chrome-browser", "stealth-browser"] as const
730
+
731
+ /** Name of the stealth browser MCP for tool notification events */
732
+ const STEALTH_BROWSER_MCP_NAME = "stealth-browser"
733
+
734
+ function resolveBrowserMcpName(status: Record<string, Status>): string | undefined {
735
+ for (const name of BROWSER_MCP_NAMES) {
736
+ if (status[name]?.status === "connected") return name
737
+ }
738
+ return undefined
739
+ }
740
+
741
+ // Track which MCP sections are loaded per-session to avoid race conditions
742
+ // Key: sessionID, Value: Set of section names loaded for that session
743
+ const sessionSections: Map<string, Set<string>> = new Map()
744
+
745
+ // Track global loaded sections (MCP server state is shared across sessions)
746
+ let globalLoadedSections: Set<string> = new Set()
747
+
748
+ /** Get or create section tracking for a session */
749
+ function getSessionSections(sessionID?: string): Set<string> {
750
+ if (!sessionID) return globalLoadedSections
751
+ if (!sessionSections.has(sessionID)) {
752
+ sessionSections.set(sessionID, new Set())
753
+ }
754
+ return sessionSections.get(sessionID)!
755
+ }
756
+
757
+ /** Clean up section tracking for a session */
758
+ function clearSessionSections(sessionID: string): void {
759
+ sessionSections.delete(sessionID)
760
+ }
761
+
762
+ /**
763
+ * Get the browser MCP client if connected.
764
+ * Returns undefined if not connected or not configured.
765
+ */
766
+ async function getBrowserClient(): Promise<Client | undefined> {
767
+ const s = await state()
768
+ const name = resolveBrowserMcpName(s.status)
769
+ if (!name) return undefined
770
+ const clientStatus = s.status[name]
771
+
772
+ if (!clientStatus || clientStatus.status !== "connected") {
773
+ log.debug("browser MCP not connected", {
774
+ status: clientStatus?.status ?? "not_configured",
775
+ })
776
+ return undefined
777
+ }
778
+
779
+ return s.clients[name]
780
+ }
781
+
782
+ /**
783
+ * Load tool sections dynamically via browser MCP meta-tools.
784
+ * Calls load_tool_section for each section to enable those tools.
785
+ *
786
+ * @param sections - Array of section names to load
787
+ * @param sessionID - Optional session ID for per-session tracking
788
+ * @returns Promise resolving to results of each load operation
789
+ *
790
+ * Valid sections:
791
+ * - browser-management: Core browser lifecycle (spawn, close, status)
792
+ * - element-interaction: Clicking, typing, scrolling
793
+ * - element-extraction: Scraping element data
794
+ * - file-extraction: File downloads, screenshots
795
+ * - network-debugging: Network requests, responses
796
+ * - cdp-functions: Chrome DevTools Protocol access
797
+ * - cookies-storage: Cookie and storage management
798
+ * - tabs: Multi-tab workflows
799
+ * - debugging: Console logs, errors
800
+ * - dynamic-hooks: Request interception
801
+ * - progressive-cloning: Page state cloning
802
+ */
803
+ export async function loadSections(sections: string[], sessionID?: string): Promise<{ section: string; success: boolean; error?: string }[]> {
804
+ const client = await getBrowserClient()
805
+ const results: { section: string; success: boolean; error?: string }[] = []
806
+ const sessionTracking = getSessionSections(sessionID)
807
+
808
+ if (!client) {
809
+ log.warn("cannot load sections - browser MCP not connected")
810
+ // Return failure for all requested sections
811
+ for (const section of sections) {
812
+ results.push({ section, success: false, error: "browser MCP not connected" })
813
+ }
814
+ return results
815
+ }
816
+
817
+ // Load each section via MCP meta-tool
818
+ for (const section of sections) {
819
+ try {
820
+ const result = await client.callTool({
821
+ name: "load_tool_section",
822
+ arguments: { section },
823
+ })
824
+
825
+ // Parse the response
826
+ const content = result.content
827
+ let success = true
828
+ let errorMsg: string | undefined
829
+
830
+ if (Array.isArray(content) && content.length > 0) {
831
+ const firstContent = content[0]
832
+ if (firstContent && typeof firstContent === "object" && "text" in firstContent) {
833
+ const text = firstContent.text as string
834
+ try {
835
+ const parsed = JSON.parse(text)
836
+ if (parsed.error) {
837
+ success = false
838
+ errorMsg = parsed.error
839
+ } else {
840
+ log.info("loaded tool section via MCP", {
841
+ section,
842
+ sessionID,
843
+ toolsLoaded: parsed.tools_loaded ?? parsed.tool_count ?? "unknown",
844
+ })
845
+ }
846
+ } catch {
847
+ // Non-JSON response, assume success
848
+ }
849
+ }
850
+ }
851
+
852
+ if (success) {
853
+ sessionTracking.add(section)
854
+ globalLoadedSections.add(section)
855
+ }
856
+ results.push({ section, success, error: errorMsg })
857
+ } catch (error) {
858
+ const errorMsg = error instanceof Error ? error.message : String(error)
859
+ log.error("error loading section via MCP", { section, sessionID, error: errorMsg })
860
+ results.push({ section, success: false, error: errorMsg })
861
+ }
862
+ }
863
+
864
+ // Notify that tools have changed so UI/tool list can refresh
865
+ const s = await state()
866
+ const browserName = resolveBrowserMcpName(s.status) ?? "chrome-browser"
867
+ Bus.publish(ToolsChanged, { server: browserName }).catch((e) =>
868
+ log.debug("failed to publish tools changed event", { error: e })
869
+ )
870
+
871
+ return results
872
+ }
873
+
874
+ /**
875
+ * Unload tool sections to save context tokens.
876
+ *
877
+ * When sessionID is provided: Only clears local tracking for that session (does NOT call MCP).
878
+ * This prevents one session's cancel from affecting other sessions.
879
+ *
880
+ * When sessionID is NOT provided: Actually calls unload_tool_section on MCP server.
881
+ *
882
+ * @param sections - Array of section names to unload (if empty, unloads all loaded sections)
883
+ * @param sessionID - Optional session ID - if provided, only clears local tracking
884
+ * @returns Promise resolving to results of each unload operation
885
+ */
886
+ export async function unloadSections(sections?: string[], sessionID?: string): Promise<{ section: string; success: boolean; error?: string }[]> {
887
+ const results: { section: string; success: boolean; error?: string }[] = []
888
+
889
+ // If sessionID is provided, just clear that session's tracking (don't actually unload from MCP)
890
+ // This prevents one session's cancel from affecting other sessions' loaded sections
891
+ if (sessionID) {
892
+ const sessionTracking = sessionSections.get(sessionID)
893
+ if (sessionTracking) {
894
+ const sectionsToRemove = sections ?? Array.from(sessionTracking)
895
+ for (const section of sectionsToRemove) {
896
+ sessionTracking.delete(section)
897
+ results.push({ section, success: true })
898
+ }
899
+ // Clean up if session has no more sections
900
+ if (sessionTracking.size === 0) {
901
+ clearSessionSections(sessionID)
902
+ }
903
+ log.debug("cleared session section tracking", { sessionID, cleared: sectionsToRemove })
904
+ }
905
+ return results
906
+ }
907
+
908
+ // No sessionID - actually unload from MCP server
909
+ const client = await getBrowserClient()
910
+
911
+ // If no sections specified, unload all currently loaded sections
912
+ const sectionsToUnload = sections ?? Array.from(globalLoadedSections)
913
+
914
+ if (sectionsToUnload.length === 0) {
915
+ log.debug("no sections to unload")
916
+ return results
917
+ }
918
+
919
+ if (!client) {
920
+ log.warn("cannot unload sections - browser MCP not connected")
921
+ // Still clear local tracking
922
+ for (const section of sectionsToUnload) {
923
+ globalLoadedSections.delete(section)
924
+ results.push({ section, success: false, error: "browser MCP not connected" })
925
+ }
926
+ return results
927
+ }
928
+
929
+ // Unload each section via MCP meta-tool
930
+ for (const section of sectionsToUnload) {
931
+ try {
932
+ const result = await client.callTool({
933
+ name: "unload_tool_section",
934
+ arguments: { section },
935
+ })
936
+
937
+ // Parse the response
938
+ const content = result.content
939
+ let success = true
940
+ let errorMsg: string | undefined
941
+
942
+ if (Array.isArray(content) && content.length > 0) {
943
+ const firstContent = content[0]
944
+ if (firstContent && typeof firstContent === "object" && "text" in firstContent) {
945
+ const text = firstContent.text as string
946
+ try {
947
+ const parsed = JSON.parse(text)
948
+ if (parsed.error) {
949
+ success = false
950
+ errorMsg = parsed.error
951
+ } else {
952
+ log.info("unloaded tool section via MCP", {
953
+ section,
954
+ toolsDisabled: parsed.tools_disabled?.length ?? parsed.tool_count ?? "unknown",
955
+ })
956
+ }
957
+ } catch {
958
+ // Non-JSON response, assume success
959
+ }
960
+ }
961
+ }
962
+
963
+ if (success) {
964
+ globalLoadedSections.delete(section)
965
+ }
966
+ results.push({ section, success, error: errorMsg })
967
+ } catch (error) {
968
+ const errorMsg = error instanceof Error ? error.message : String(error)
969
+ log.error("error unloading section via MCP", { section, error: errorMsg })
970
+ // Still remove from local tracking
971
+ globalLoadedSections.delete(section)
972
+ results.push({ section, success: false, error: errorMsg })
973
+ }
974
+ }
975
+
976
+ // Notify that tools have changed so UI/tool list can refresh
977
+ Bus.publish(ToolsChanged, { server: STEALTH_BROWSER_MCP_NAME }).catch((e) =>
978
+ log.debug("failed to publish tools changed event", { error: e })
979
+ )
980
+
981
+ return results
982
+ }
983
+
984
+ /**
985
+ * Analyze user prompt and prepare/load relevant MCP tool sections.
986
+ * This enables automatic section loading based on user intent.
987
+ *
988
+ * @param userPrompt - The user's message/prompt text
989
+ * @param sessionID - Optional session ID for per-session tracking
990
+ * @returns Promise resolving to the sections that were loaded
991
+ */
992
+ export async function prepareToolsForPrompt(userPrompt: string, sessionID?: string): Promise<string[]> {
993
+ if (!userPrompt || typeof userPrompt !== "string") return []
994
+
995
+ // Check if browser MCP is even connected
996
+ const client = await getBrowserClient()
997
+ if (!client) {
998
+ log.debug("skipping tool preparation - browser MCP not connected")
999
+ return []
1000
+ }
1001
+
1002
+ // Check if this is a simple task that doesn't need extra tools
1003
+ if (isSimpleTask(userPrompt)) {
1004
+ log.debug("simple task detected - using default tools only", { promptLength: userPrompt.length, sessionID })
1005
+ return []
1006
+ }
1007
+
1008
+ // Use the intent analyzer to determine which sections to load
1009
+ const neededSections = analyzeIntent(userPrompt)
1010
+
1011
+ // Filter to only sections not already loaded (check both global and session tracking)
1012
+ const sessionTracking = getSessionSections(sessionID)
1013
+ const newSections = neededSections.filter(
1014
+ (section) => !globalLoadedSections.has(section) && !sessionTracking.has(section)
1015
+ )
1016
+
1017
+ if (newSections.length === 0) {
1018
+ log.debug("all needed sections already loaded", {
1019
+ needed: neededSections,
1020
+ loaded: Array.from(globalLoadedSections),
1021
+ sessionID,
1022
+ })
1023
+ return neededSections
1024
+ }
1025
+
1026
+ // Load the new sections via MCP meta-tools
1027
+ log.info("preparing MCP sections for prompt", {
1028
+ newSections,
1029
+ alreadyLoaded: Array.from(globalLoadedSections),
1030
+ sessionID,
1031
+ })
1032
+
1033
+ const results = await loadSections(newSections, sessionID)
1034
+
1035
+ // Log summary
1036
+ const successful = results.filter((r) => r.success).map((r) => r.section)
1037
+ const failed = results.filter((r) => !r.success).map((r) => r.section)
1038
+
1039
+ if (failed.length > 0) {
1040
+ log.warn("some sections failed to load", { successful, failed, sessionID })
1041
+ } else {
1042
+ log.info("prepared MCP sections for prompt", {
1043
+ sectionsLoaded: successful,
1044
+ totalLoaded: Array.from(globalLoadedSections),
1045
+ sessionID,
1046
+ })
1047
+ }
1048
+
1049
+ return neededSections
1050
+ }
1051
+
1052
+ /**
1053
+ * Get currently loaded sections (global MCP server state).
1054
+ * @param sessionID - Optional session ID to get session-specific tracking
1055
+ */
1056
+ export function getLoadedSections(sessionID?: string): string[] {
1057
+ if (sessionID) {
1058
+ const sessionTracking = sessionSections.get(sessionID)
1059
+ return sessionTracking ? Array.from(sessionTracking) : []
1060
+ }
1061
+ return Array.from(globalLoadedSections)
1062
+ }
1063
+
1064
+ /**
1065
+ * Query the MCP server for its list of loaded sections.
1066
+ * Returns undefined if browser MCP is not connected.
1067
+ */
1068
+ export async function queryLoadedSections(): Promise<string[] | undefined> {
1069
+ const client = await getBrowserClient()
1070
+ if (!client) {
1071
+ return undefined
1072
+ }
1073
+
1074
+ try {
1075
+ const result = await client.callTool({
1076
+ name: "list_tool_sections",
1077
+ arguments: {},
1078
+ })
1079
+
1080
+ const content = result.content
1081
+ if (Array.isArray(content) && content.length > 0) {
1082
+ const firstContent = content[0]
1083
+ if (firstContent && typeof firstContent === "object" && "text" in firstContent) {
1084
+ const text = firstContent.text as string
1085
+ const parsed = JSON.parse(text)
1086
+ // Extract loaded sections from the response
1087
+ if (parsed.sections) {
1088
+ return parsed.sections
1089
+ .filter((s: { status: string }) => s.status === "loaded" || s.status === "always_loaded")
1090
+ .map((s: { name: string }) => s.name)
1091
+ }
1092
+ }
1093
+ }
1094
+ return []
1095
+ } catch (error) {
1096
+ log.error("failed to query loaded sections", {
1097
+ error: error instanceof Error ? error.message : String(error),
1098
+ })
1099
+ return undefined
1100
+ }
1101
+ }
1102
+
1103
+ /**
1104
+ * Reset local section tracking (useful for session reset).
1105
+ * @param sessionID - Optional session ID to reset only that session's tracking
1106
+ */
1107
+ export function resetSectionTracking(sessionID?: string): void {
1108
+ if (sessionID) {
1109
+ clearSessionSections(sessionID)
1110
+ log.debug("reset section tracking for session", { sessionID })
1111
+ } else {
1112
+ globalLoadedSections.clear()
1113
+ sessionSections.clear()
1114
+ log.debug("reset all section tracking")
1115
+ }
1116
+ }
1117
+ }