alysis-code 0.13.0__py3-none-any.whl

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 (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,2308 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import hashlib
5
+ import json
6
+ import math
7
+ import re
8
+ import threading
9
+ import unicodedata
10
+ from dataclasses import dataclass, field, replace
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from ..runtime_kind import RuntimeKind, normalize_runtime_kind
15
+ from ..tools.registry import iter_builtin_tool_metadata
16
+ from .client import (
17
+ McpHttpClient,
18
+ McpListedResource,
19
+ McpListedTool,
20
+ McpStdioClient,
21
+ )
22
+ from .config import load_resolved_mcp_config, project_mcp_config_path, user_mcp_config_path
23
+ from .errors import McpConfigError as ConfigError
24
+ from .forge_scope import ForgeTaskMcpScope
25
+ from .models import ResolvedMcpConfig, ResolvedMcpServer
26
+ from .prompts import McpListedPrompt, prompts_mode_enabled
27
+ from .resources import resources_mode_enabled
28
+ from .untrusted_content import (
29
+ MCP_UNTRUSTED_TEXT_CHAR_LIMIT,
30
+ _looks_like_binary_blob,
31
+ build_host_owned_mcp_tool_description,
32
+ build_untrusted_mcp_text_block,
33
+ reduce_model_facing_tool_schema,
34
+ )
35
+
36
+ _LIVE_TOOL_RUNTIMES = frozenset(
37
+ {
38
+ RuntimeKind.INTERACTIVE_CHAT,
39
+ RuntimeKind.ONE_SHOT,
40
+ RuntimeKind.FORGE_EXEC,
41
+ }
42
+ )
43
+ _MAX_EXPOSED_MCP_TOOLS = 64
44
+ _MAX_SINGLE_TOOL_SCHEMA_BYTES = 32_000
45
+ _MAX_TOTAL_SCHEMA_BYTES = 120_000
46
+ _TOOL_ALIAS_MAX_LEN = 64
47
+ _MAX_STRUCTURED_CONTENT_BYTES = 24_000
48
+ _MAX_STRUCTURED_STRING_CHARS = 2_000
49
+ _MAX_STRUCTURED_CONTENT_DEPTH = 12
50
+ _MAX_LISTED_MCP_RESOURCES_TOTAL = 256
51
+ _MAX_LISTED_MCP_RESOURCES_PER_SERVER = 128
52
+ _DEFAULT_MCP_RESOURCE_LIST_LIMIT = 20
53
+ _MAX_MCP_RESOURCE_LIST_LIMIT = 50
54
+ _MAX_LISTED_MCP_PROMPTS_TOTAL = 256
55
+ _MAX_LISTED_MCP_PROMPTS_PER_SERVER = 128
56
+ _DEFAULT_MCP_PROMPT_LIST_LIMIT = 20
57
+ _MAX_MCP_PROMPT_LIST_LIMIT = 50
58
+ _ALIAS_CHARS_RE = re.compile(r"[^a-z0-9]+")
59
+ _McpLiveClient = McpStdioClient | McpHttpClient
60
+ _RESOURCE_TOOL_ALIASES = frozenset({"mcp_resources_list", "mcp_resource_read"})
61
+
62
+
63
+ class _McpClientLease:
64
+ """Stable, session-owned indirection for one live MCP connection.
65
+
66
+ Tool definitions keep this lease for the lifetime of the agent session. A
67
+ lifecycle reconnect can therefore replace the underlying transport without
68
+ leaving model-facing tool callables pointed at a closed client. Disabling a
69
+ lease is fail-closed: every subsequent operation raises before any transport
70
+ call is made.
71
+ """
72
+
73
+ def __init__(self, *, server_id: str, client: _McpLiveClient) -> None:
74
+ self.server_id = server_id
75
+ self._client: _McpLiveClient | None = client
76
+ self._enabled = True
77
+ self._generation = 1
78
+ self._lock = threading.RLock()
79
+
80
+ def _require_client(self) -> _McpLiveClient:
81
+ if not self._enabled:
82
+ raise RuntimeError(f"MCP server '{self.server_id}' is disabled for this session.")
83
+ client = self._client
84
+ if client is None or client.closed:
85
+ raise RuntimeError(
86
+ f"MCP server '{self.server_id}' is disconnected for this session. "
87
+ "Reconnect it before retrying the operation."
88
+ )
89
+ return client
90
+
91
+ @property
92
+ def enabled(self) -> bool:
93
+ with self._lock:
94
+ return self._enabled
95
+
96
+ @property
97
+ def generation(self) -> int:
98
+ with self._lock:
99
+ return self._generation
100
+
101
+ @property
102
+ def closed(self) -> bool:
103
+ with self._lock:
104
+ client = self._client
105
+ return client is None or client.closed
106
+
107
+ @property
108
+ def connected(self) -> bool:
109
+ with self._lock:
110
+ if not self._enabled:
111
+ return False
112
+ client = self._client
113
+ if client is None or client.closed:
114
+ return False
115
+ transport = client.transport
116
+ process = getattr(transport, "process", None)
117
+ if process is not None and process.poll() is not None:
118
+ return False
119
+ return True
120
+
121
+ @property
122
+ def transport(self) -> Any:
123
+ """Compatibility-only access used by low-level transport diagnostics/tests."""
124
+ with self._lock:
125
+ return self._require_client().transport
126
+
127
+ @property
128
+ def tools_list_changed(self) -> bool:
129
+ with self._lock:
130
+ return self._require_client().tools_list_changed
131
+
132
+ @property
133
+ def resources_list_changed(self) -> bool:
134
+ with self._lock:
135
+ return self._require_client().resources_list_changed
136
+
137
+ @property
138
+ def prompts_list_changed(self) -> bool:
139
+ with self._lock:
140
+ return self._require_client().prompts_list_changed
141
+
142
+ @property
143
+ def session_negotiated(self) -> bool:
144
+ with self._lock:
145
+ return self._require_client().session_negotiated
146
+
147
+ @property
148
+ def roots_capability_enabled(self) -> bool:
149
+ with self._lock:
150
+ return self._require_client().roots_capability_enabled
151
+
152
+ @property
153
+ def supports_tools(self) -> bool:
154
+ with self._lock:
155
+ return self._require_client().supports_tools
156
+
157
+ @property
158
+ def supports_resources(self) -> bool:
159
+ with self._lock:
160
+ return self._require_client().supports_resources
161
+
162
+ @property
163
+ def supports_prompts(self) -> bool:
164
+ with self._lock:
165
+ return self._require_client().supports_prompts
166
+
167
+ def ensure_initialized(self) -> None:
168
+ with self._lock:
169
+ self._require_client().ensure_initialized()
170
+
171
+ def observe_notifications(self) -> None:
172
+ with self._lock:
173
+ self._require_client().observe_notifications()
174
+
175
+ def list_tools(self) -> tuple[McpListedTool, ...]:
176
+ with self._lock:
177
+ return self._require_client().list_tools()
178
+
179
+ def list_resources(self) -> tuple[McpListedResource, ...]:
180
+ with self._lock:
181
+ return self._require_client().list_resources()
182
+
183
+ def list_prompts(self) -> tuple[McpListedPrompt, ...]:
184
+ with self._lock:
185
+ return self._require_client().list_prompts()
186
+
187
+ def read_resource(
188
+ self,
189
+ *,
190
+ resource_uri: str,
191
+ allowed_uris: Any = None,
192
+ ) -> Any:
193
+ with self._lock:
194
+ return self._require_client().read_resource(
195
+ resource_uri=resource_uri,
196
+ allowed_uris=allowed_uris,
197
+ )
198
+
199
+ def get_prompt(self, *, name: str, arguments: dict[str, str] | None = None) -> Any:
200
+ with self._lock:
201
+ return self._require_client().get_prompt(name=name, arguments=arguments)
202
+
203
+ def call_tool(self, *, tool_name: str, arguments: dict[str, Any]) -> Any:
204
+ with self._lock:
205
+ return self._require_client().call_tool(tool_name=tool_name, arguments=arguments)
206
+
207
+ def disable(self) -> bool:
208
+ with self._lock:
209
+ changed = self._enabled or self._client is not None
210
+ self._enabled = False
211
+ client = self._client
212
+ self._client = None
213
+ if client is not None:
214
+ client.close()
215
+ return changed
216
+
217
+ def replace_client(self, client: _McpLiveClient) -> None:
218
+ with self._lock:
219
+ old_client = self._client
220
+ self._client = client
221
+ self._enabled = True
222
+ self._generation += 1
223
+ if old_client is not None and old_client is not client:
224
+ old_client.close()
225
+
226
+ def close(self) -> None:
227
+ self.disable()
228
+
229
+
230
+ _McpSessionClient = _McpLiveClient | _McpClientLease
231
+
232
+
233
+ def _sanitize_alias_part(value: str, *, fallback: str) -> str:
234
+ lowered = str(value or "").strip().lower()
235
+ ascii_value = unicodedata.normalize("NFKD", lowered).encode("ascii", "ignore").decode("ascii")
236
+ sanitized = _ALIAS_CHARS_RE.sub("_", ascii_value).strip("_")
237
+ sanitized = re.sub(r"_+", "_", sanitized)
238
+ return sanitized or fallback
239
+
240
+
241
+ def _normalize_tool_name_key(value: str) -> str:
242
+ return str(value).casefold()
243
+
244
+
245
+ def _mcp_runtime_enabled(runtime_kind: RuntimeKind) -> bool:
246
+ return runtime_kind in _LIVE_TOOL_RUNTIMES
247
+
248
+
249
+ def _server_namespace(server: ResolvedMcpServer) -> str:
250
+ if server.tool_prefix:
251
+ return _sanitize_alias_part(server.tool_prefix, fallback="server")
252
+ return _sanitize_alias_part(server.id, fallback="server")
253
+
254
+
255
+ def _tool_base_alias(server: ResolvedMcpServer, tool_name: str) -> str:
256
+ return f"mcp__{_server_namespace(server)}__{_sanitize_alias_part(tool_name, fallback='tool')}"
257
+
258
+
259
+ def _hashed_alias(base_alias: str, *, unique_key: str) -> str:
260
+ digest = hashlib.sha1(unique_key.encode("utf-8")).hexdigest()[:10]
261
+ suffix = f"__{digest}"
262
+ prefix_len = max(1, _TOOL_ALIAS_MAX_LEN - len(suffix))
263
+ prefix = base_alias[:prefix_len].rstrip("_")
264
+ if not prefix:
265
+ prefix = "mcp"
266
+ return f"{prefix}{suffix}"
267
+
268
+
269
+ def _stable_json_size(value: object) -> int:
270
+ return len(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")))
271
+
272
+
273
+ def _omitted_structured_value(reason: str, *, detail: str | None = None) -> dict[str, Any]:
274
+ payload: dict[str, Any] = {
275
+ "omitted": True,
276
+ "reason": reason,
277
+ }
278
+ if detail:
279
+ payload["summary"] = detail
280
+ return payload
281
+
282
+
283
+ def _sanitize_structured_content_value(value: Any, *, depth: int = 0) -> Any:
284
+ if value is None or isinstance(value, (bool, int)):
285
+ return value
286
+ if isinstance(value, float):
287
+ if math.isfinite(value):
288
+ return value
289
+ return _omitted_structured_value("non_finite_number", detail="non-finite number omitted")
290
+ if isinstance(value, str):
291
+ if _looks_like_binary_blob(value):
292
+ return _omitted_structured_value(
293
+ "binary_like_string",
294
+ detail=f"binary-like string omitted ({len(value)} chars)",
295
+ )
296
+ if len(value) > _MAX_STRUCTURED_STRING_CHARS:
297
+ return _omitted_structured_value(
298
+ "string_too_large",
299
+ detail=f"long string omitted ({len(value)} chars)",
300
+ )
301
+ return value
302
+ if depth >= _MAX_STRUCTURED_CONTENT_DEPTH:
303
+ return _omitted_structured_value(
304
+ "max_depth_exceeded",
305
+ detail="nested structured content omitted",
306
+ )
307
+ if isinstance(value, list):
308
+ return [_sanitize_structured_content_value(item, depth=depth + 1) for item in value]
309
+ if isinstance(value, dict):
310
+ sanitized: dict[str, Any] = {}
311
+ for key, item in value.items():
312
+ sanitized[str(key)] = _sanitize_structured_content_value(item, depth=depth + 1)
313
+ return sanitized
314
+ return _omitted_structured_value(
315
+ "unsupported_type",
316
+ detail=f"{type(value).__name__} omitted",
317
+ )
318
+
319
+
320
+ def _normalize_prompt_structured_content(value: Any) -> Any:
321
+ sanitized = _sanitize_structured_content_value(copy.deepcopy(value))
322
+ try:
323
+ content_bytes = _stable_json_size(sanitized)
324
+ except TypeError:
325
+ return _omitted_structured_value(
326
+ "non_serializable",
327
+ detail="non-serializable structured content omitted",
328
+ )
329
+ if content_bytes > _MAX_STRUCTURED_CONTENT_BYTES:
330
+ return _omitted_structured_value(
331
+ "structured_content_too_large",
332
+ detail=f"structured content omitted ({content_bytes} bytes)",
333
+ )
334
+ return sanitized
335
+
336
+
337
+ def _validate_function_tool_schema(
338
+ *,
339
+ server: ResolvedMcpServer,
340
+ tool: McpListedTool,
341
+ ) -> dict[str, Any]:
342
+ raw_schema = copy.deepcopy(tool.input_schema)
343
+ if raw_schema.get("type") != "object":
344
+ raise ConfigError(
345
+ f"MCP server '{server.id}' tool '{tool.name}' has incompatible inputSchema: "
346
+ "root type must be 'object'."
347
+ )
348
+ properties = raw_schema.get("properties", {})
349
+ if not isinstance(properties, dict):
350
+ raise ConfigError(
351
+ f"MCP server '{server.id}' tool '{tool.name}' has incompatible inputSchema: "
352
+ "'properties' must be an object."
353
+ )
354
+ required = raw_schema.get("required", [])
355
+ if not isinstance(required, list) or any(not isinstance(item, str) for item in required):
356
+ raise ConfigError(
357
+ f"MCP server '{server.id}' tool '{tool.name}' has incompatible inputSchema: "
358
+ "'required' must be an array of strings."
359
+ )
360
+ schema = reduce_model_facing_tool_schema(raw_schema)
361
+ try:
362
+ schema_bytes = _stable_json_size(schema)
363
+ except TypeError as exc:
364
+ raise ConfigError(
365
+ f"MCP server '{server.id}' tool '{tool.name}' has non-serializable inputSchema."
366
+ ) from exc
367
+ if schema_bytes > _MAX_SINGLE_TOOL_SCHEMA_BYTES:
368
+ raise ConfigError(
369
+ f"MCP server '{server.id}' tool '{tool.name}' exposes an inputSchema that is too "
370
+ f"large ({schema_bytes} bytes). Narrow exposure with allowed_tools."
371
+ )
372
+ return schema
373
+
374
+
375
+ def _wrap_tool_result_text(
376
+ *,
377
+ server_id: str,
378
+ tool_name: str,
379
+ text: str,
380
+ mime_type: str | None = None,
381
+ ) -> str:
382
+ original_char_count = len(text)
383
+ truncated = False
384
+ wrapped_text = text
385
+ if _looks_like_binary_blob(text):
386
+ wrapped_text = "(omitted: binary-like text)"
387
+ elif len(text) > MCP_UNTRUSTED_TEXT_CHAR_LIMIT:
388
+ wrapped_text = text[:MCP_UNTRUSTED_TEXT_CHAR_LIMIT]
389
+ truncated = True
390
+ return build_untrusted_mcp_text_block(
391
+ source_type="tool_result",
392
+ server_id=server_id,
393
+ source_name=tool_name,
394
+ text=wrapped_text,
395
+ mime_type=mime_type,
396
+ original_char_count=original_char_count,
397
+ truncated=truncated,
398
+ )
399
+
400
+
401
+ @dataclass(frozen=True)
402
+ class McpToolBinding:
403
+ server_id: str
404
+ tool_name: str
405
+ tool_alias: str
406
+ description: str
407
+ parameters: dict[str, Any]
408
+ client: _McpSessionClient = field(repr=False)
409
+ session_mode: str | None = None
410
+
411
+ def bind_session_mode(self, session_mode: str | None) -> McpToolBinding:
412
+ normalized = str(session_mode or "").strip().lower() or None
413
+ return replace(self, session_mode=normalized)
414
+
415
+ def run(self, arguments: dict[str, Any]) -> dict[str, Any]:
416
+ if self.session_mode == "readonly":
417
+ raise RuntimeError(
418
+ f"Blocked in readonly mode: MCP tool '{self.tool_alias}' is not available."
419
+ )
420
+ result = self.client.call_tool(
421
+ tool_name=self.tool_name,
422
+ arguments=arguments,
423
+ )
424
+ content_items: list[dict[str, Any]] = []
425
+ for item in result.content:
426
+ payload_item = copy.deepcopy(item)
427
+ item_text = payload_item.get("text")
428
+ mime_type = payload_item.get("mime_type")
429
+ if isinstance(item_text, str):
430
+ payload_item["text"] = _wrap_tool_result_text(
431
+ server_id=self.server_id,
432
+ tool_name=self.tool_name,
433
+ text=item_text,
434
+ mime_type=mime_type if isinstance(mime_type, str) else None,
435
+ )
436
+ content_items.append(payload_item)
437
+ payload: dict[str, Any] = {
438
+ "server_id": self.server_id,
439
+ "tool_alias": self.tool_alias,
440
+ "tool_name": self.tool_name,
441
+ "is_error": result.is_error,
442
+ "content": content_items,
443
+ "content_summary": result.content_summary,
444
+ }
445
+ if result.structured_content is not None:
446
+ payload["structured_content"] = _normalize_prompt_structured_content(
447
+ result.structured_content
448
+ )
449
+ if result.extracted_text:
450
+ payload["text"] = _wrap_tool_result_text(
451
+ server_id=self.server_id,
452
+ tool_name=self.tool_name,
453
+ text=result.extracted_text,
454
+ )
455
+ return payload
456
+
457
+
458
+ @dataclass(frozen=True)
459
+ class McpHostToolBinding:
460
+ tool_name: str
461
+ tool_alias: str
462
+ description: str
463
+ parameters: dict[str, Any]
464
+ run_handler: Any = field(repr=False)
465
+ session_mode: str | None = None
466
+
467
+ def bind_session_mode(self, session_mode: str | None) -> McpHostToolBinding:
468
+ normalized = str(session_mode or "").strip().lower() or None
469
+ return replace(self, session_mode=normalized)
470
+
471
+ def run(self, arguments: dict[str, Any]) -> dict[str, Any]:
472
+ if self.session_mode == "readonly":
473
+ raise RuntimeError(
474
+ f"Blocked in readonly mode: MCP tool '{self.tool_alias}' is not available."
475
+ )
476
+ return self.run_handler(arguments)
477
+
478
+
479
+ @dataclass(frozen=True)
480
+ class _CollectedServerCatalog:
481
+ server: ResolvedMcpServer
482
+ client: _McpSessionClient
483
+ raw_tools: tuple[McpListedTool, ...]
484
+ filtered_tools: tuple[McpListedTool, ...]
485
+ raw_tool_names: tuple[str, ...]
486
+ listed_resources: tuple[McpListedResource, ...]
487
+ resources_snapshot_loaded: bool
488
+ tools_list_changed: bool
489
+ resources_list_changed: bool
490
+ roots_capability_enabled: bool
491
+ resources_capability_advertised: bool
492
+ session_negotiated: bool
493
+
494
+
495
+ @dataclass(frozen=True)
496
+ class _FlatToolRecord:
497
+ server: ResolvedMcpServer
498
+ client: _McpSessionClient
499
+ tool: McpListedTool
500
+ parameters: dict[str, Any]
501
+
502
+ @property
503
+ def unique_key(self) -> str:
504
+ return f"{self.server.id}\x00{self.tool.name}"
505
+
506
+ @property
507
+ def base_alias(self) -> str:
508
+ return _tool_base_alias(self.server, self.tool.name)
509
+
510
+
511
+ @dataclass(frozen=True)
512
+ class _SnapshottedResourceRecord:
513
+ server: ResolvedMcpServer
514
+ client: _McpSessionClient
515
+ resource: McpListedResource
516
+
517
+ @property
518
+ def lookup_key(self) -> tuple[str, str]:
519
+ return (self.server.id, self.resource.uri)
520
+
521
+
522
+ @dataclass(frozen=True)
523
+ class _SnapshottedPromptRecord:
524
+ server: ResolvedMcpServer
525
+ client: _McpSessionClient
526
+ prompt: McpListedPrompt
527
+
528
+ @property
529
+ def lookup_key(self) -> tuple[str, str]:
530
+ return (self.server.id, self.prompt.name)
531
+
532
+
533
+ _McpManagerToolBinding = McpToolBinding | McpHostToolBinding
534
+
535
+
536
+ class McpManager:
537
+ def __init__(
538
+ self,
539
+ *,
540
+ resolved_config: ResolvedMcpConfig,
541
+ workspace_root: Path,
542
+ runtime_kind: RuntimeKind | str,
543
+ session_id: str | None = None,
544
+ ) -> None:
545
+ self.resolved_config = resolved_config
546
+ self.workspace_root = workspace_root.resolve()
547
+ self.runtime_kind = normalize_runtime_kind(runtime_kind)
548
+ self.session_id = str(session_id or "").strip() or None
549
+ self._resolved_servers = tuple(resolved_config.servers)
550
+ self._active_servers = resolved_config.active_servers_for(self.runtime_kind)
551
+ self._resolved_servers_by_id = {server.id: server for server in self._resolved_servers}
552
+ self._active_servers_by_id = {server.id: server for server in self._active_servers}
553
+ self._live_runtime_enabled = _mcp_runtime_enabled(self.runtime_kind)
554
+ self._closed = False
555
+ self._lifecycle_lock = threading.RLock()
556
+ self._disabled_server_ids: set[str] = set()
557
+ self._client_leases_by_server_id: dict[str, list[_McpClientLease]] = {}
558
+ self._snapshot_loaded = False
559
+ self._snapshot_error: BaseException | None = None
560
+ self._tool_bindings: tuple[_McpManagerToolBinding, ...] = ()
561
+ self._resource_records: tuple[_SnapshottedResourceRecord, ...] = ()
562
+ self._resource_lookup: dict[tuple[str, str], _SnapshottedResourceRecord] = {}
563
+ self._resource_snapshotted_server_ids: set[str] = set()
564
+ self._prompt_enabled_servers = tuple(
565
+ server
566
+ for server in self._active_servers
567
+ if prompts_mode_enabled(
568
+ prompts_mode=server.prompts_mode,
569
+ runtime_kind=self.runtime_kind,
570
+ )
571
+ )
572
+ self._prompt_enabled_servers_by_id = {
573
+ server.id: server for server in self._prompt_enabled_servers
574
+ }
575
+ self._prompt_snapshot_loaded = not self._prompt_enabled_servers
576
+ self._prompt_loaded_server_ids: set[str] = set()
577
+ self._prompt_server_errors: dict[str, BaseException] = {}
578
+ self._prompt_records: tuple[_SnapshottedPromptRecord, ...] = ()
579
+ self._prompt_records_by_server_id: dict[str, tuple[_SnapshottedPromptRecord, ...]] = {}
580
+ self._prompt_lookup: dict[tuple[str, str], _SnapshottedPromptRecord] = {}
581
+ self._tool_stale_server_ids: set[str] = set()
582
+ self._resource_stale_server_ids: set[str] = set()
583
+ self._prompt_stale_server_ids: set[str] = set()
584
+ self._clients_by_server_id: dict[str, _McpSessionClient] = {}
585
+ self._prompt_clients_by_server_id: dict[str, _McpSessionClient] = {}
586
+ self._prompt_server_catalogs_by_id = {
587
+ server.id: self._initial_prompt_server_catalog(server)
588
+ for server in self._prompt_enabled_servers
589
+ }
590
+ self._catalog_snapshot: dict[str, Any] = {
591
+ "catalog_initialized": False,
592
+ "live_tool_runtime_enabled": self._live_runtime_enabled,
593
+ "active_server_ids": [server.id for server in self._active_servers],
594
+ "server_catalogs": [],
595
+ "exposed_tool_aliases": [],
596
+ "exposed_tool_names": [],
597
+ "exposed_tool_count": 0,
598
+ "snapshotted_resource_count": 0,
599
+ "resource_tool_names": [],
600
+ "resource_tool_count": 0,
601
+ "prompt_enabled_server_ids": [server.id for server in self._prompt_enabled_servers],
602
+ "prompt_snapshotted_server_ids": [],
603
+ "prompt_snapshot_complete": self._prompt_snapshot_loaded,
604
+ "prompt_snapshot_partial": False,
605
+ "tool_stale_server_ids": [],
606
+ "resource_stale_server_ids": [],
607
+ "prompt_stale_server_ids": [],
608
+ "prompt_server_catalogs": [],
609
+ "snapshotted_prompt_count": 0,
610
+ "manual_prompt_surface_enabled": False,
611
+ }
612
+ self._sync_prompt_catalog_snapshot_metadata()
613
+
614
+ @property
615
+ def resolved_servers(self) -> tuple[ResolvedMcpServer, ...]:
616
+ return self._resolved_servers
617
+
618
+ @property
619
+ def active_servers(self) -> tuple[ResolvedMcpServer, ...]:
620
+ return self._active_servers
621
+
622
+ @property
623
+ def tool_bindings(self) -> tuple[_McpManagerToolBinding, ...]:
624
+ self._ensure_tool_snapshot()
625
+ return self._tool_bindings
626
+
627
+ @property
628
+ def closed(self) -> bool:
629
+ return self._closed
630
+
631
+ def startup_metadata(self) -> dict[str, Any]:
632
+ return {
633
+ "config_present": self.resolved_config.has_any_config,
634
+ "user_config_present": self.resolved_config.user_config_present,
635
+ "project_config_present": self.resolved_config.project_config_present,
636
+ "resolved_server_count": len(self._resolved_servers),
637
+ "resolved_server_ids": [server.id for server in self._resolved_servers],
638
+ "active_server_count": len(self._active_servers),
639
+ "active_server_ids": [server.id for server in self._active_servers],
640
+ "live_tool_runtime_enabled": self._live_runtime_enabled,
641
+ }
642
+
643
+ def _register_client_lease(
644
+ self,
645
+ *,
646
+ server_id: str,
647
+ client: _McpLiveClient,
648
+ ) -> _McpClientLease:
649
+ if self._client_leases_by_server_id.get(server_id):
650
+ raise RuntimeError(
651
+ f"MCP server '{server_id}' already has a session-owned client lease."
652
+ )
653
+ lease = _McpClientLease(server_id=server_id, client=client)
654
+ self._client_leases_by_server_id[server_id] = [lease]
655
+ return lease
656
+
657
+ def _server_lifecycle_target(self, server_id: str) -> ResolvedMcpServer:
658
+ normalized_server_id = str(server_id or "").strip()
659
+ if not normalized_server_id:
660
+ raise RuntimeError("server_id must be a non-empty string.")
661
+ if self._closed:
662
+ raise RuntimeError("MCP manager is closed.")
663
+ server = self._active_servers_by_id.get(normalized_server_id)
664
+ if server is not None:
665
+ if server.trust != "explicit":
666
+ raise RuntimeError(
667
+ f"MCP server '{normalized_server_id}' is not explicitly trusted."
668
+ )
669
+ if not self._live_runtime_enabled:
670
+ raise RuntimeError(
671
+ f"MCP server lifecycle is unavailable for runtime '{self.runtime_kind.value}'."
672
+ )
673
+ return server
674
+ if normalized_server_id in self._resolved_servers_by_id:
675
+ raise RuntimeError(
676
+ f"MCP server '{normalized_server_id}' is not active for runtime "
677
+ f"'{self.runtime_kind.value}'."
678
+ )
679
+ raise RuntimeError(f"Unknown MCP server '{normalized_server_id}'.")
680
+
681
+ def server_lifecycle_status(self, *, server_id: str) -> dict[str, Any]:
682
+ with self._lifecycle_lock:
683
+ server = self._server_lifecycle_target(server_id)
684
+ leases = tuple(self._client_leases_by_server_id.get(server.id, ()))
685
+ enabled = server.id not in self._disabled_server_ids
686
+ connected = bool(enabled and any(lease.connected for lease in leases))
687
+ if not enabled:
688
+ connection_state = "disabled"
689
+ elif connected:
690
+ connection_state = "connected"
691
+ elif leases:
692
+ connection_state = "disconnected"
693
+ else:
694
+ connection_state = "not_materialized"
695
+ server_bindings = [
696
+ binding
697
+ for binding in self._tool_bindings
698
+ if isinstance(binding, McpToolBinding) and binding.server_id == server.id
699
+ ]
700
+ resource_count = sum(
701
+ 1 for record in self._resource_records if record.server.id == server.id
702
+ )
703
+ prompt_count = len(self._prompt_records_by_server_id.get(server.id, ()))
704
+ return {
705
+ "session_id": self.session_id,
706
+ "server_id": server.id,
707
+ "transport": server.transport,
708
+ "enabled": enabled,
709
+ "connection_state": connection_state,
710
+ "connected": connected,
711
+ "generation": max((lease.generation for lease in leases), default=0),
712
+ "catalog_initialized": self._snapshot_loaded,
713
+ "exposed_tool_count": len(server_bindings),
714
+ "snapshotted_resource_count": resource_count,
715
+ "prompt_snapshot_loaded": server.id in self._prompt_loaded_server_ids,
716
+ "snapshotted_prompt_count": prompt_count,
717
+ "secret_values_included": False,
718
+ }
719
+
720
+ def disable_server(self, *, server_id: str) -> dict[str, Any]:
721
+ with self._lifecycle_lock:
722
+ server = self._server_lifecycle_target(server_id)
723
+ already_disabled = server.id in self._disabled_server_ids
724
+ self._disabled_server_ids.add(server.id)
725
+ changed = not already_disabled
726
+ for lease in self._client_leases_by_server_id.get(server.id, ()):
727
+ changed = lease.disable() or changed
728
+ return {
729
+ **self.server_lifecycle_status(server_id=server.id),
730
+ "changed": changed,
731
+ "action": "disable",
732
+ }
733
+
734
+ def _replacement_tool_signature(
735
+ self,
736
+ *,
737
+ server: ResolvedMcpServer,
738
+ client: _McpLiveClient,
739
+ ) -> list[tuple[str, str, dict[str, Any]]]:
740
+ if not client.supports_tools:
741
+ if server.allowed_tools or server.denied_tools:
742
+ raise RuntimeError(
743
+ f"MCP server '{server.id}' no longer advertises its configured tools."
744
+ )
745
+ return []
746
+ raw_tools = client.list_tools()
747
+ filtered_tools, _raw_names = self._filter_server_tools(
748
+ server=server,
749
+ raw_tools=raw_tools,
750
+ )
751
+ return [
752
+ (
753
+ tool.name,
754
+ build_host_owned_mcp_tool_description(
755
+ server_id=server.id,
756
+ tool_name=tool.name,
757
+ server_description=tool.description,
758
+ ),
759
+ _validate_function_tool_schema(server=server, tool=tool),
760
+ )
761
+ for tool in filtered_tools
762
+ ]
763
+
764
+ def _validate_replacement_client(
765
+ self,
766
+ *,
767
+ server: ResolvedMcpServer,
768
+ client: _McpLiveClient,
769
+ validate_prompts: bool = True,
770
+ ) -> None:
771
+ client.ensure_initialized()
772
+ if self._snapshot_loaded:
773
+ expected_tools = [
774
+ (binding.tool_name, binding.description, binding.parameters)
775
+ for binding in self._tool_bindings
776
+ if isinstance(binding, McpToolBinding) and binding.server_id == server.id
777
+ ]
778
+ replacement_tools = self._replacement_tool_signature(server=server, client=client)
779
+ if replacement_tools != expected_tools:
780
+ raise RuntimeError(
781
+ f"MCP server '{server.id}' tool catalog changed. Recreate the IDE session "
782
+ "before using the new catalog."
783
+ )
784
+ if server.id in self._resource_snapshotted_server_ids:
785
+ replacement_resources = (
786
+ list(client.list_resources()) if client.supports_resources else []
787
+ )
788
+ expected_resources = [
789
+ record.resource
790
+ for record in self._resource_records
791
+ if record.server.id == server.id
792
+ ]
793
+ if replacement_resources != expected_resources:
794
+ raise RuntimeError(
795
+ f"MCP server '{server.id}' resource catalog changed. Recreate the IDE "
796
+ "session before using the new catalog."
797
+ )
798
+ if validate_prompts and server.id in self._prompt_loaded_server_ids:
799
+ replacement_prompts = list(client.list_prompts()) if client.supports_prompts else []
800
+ expected_prompts = [
801
+ record.prompt for record in self._prompt_records_by_server_id.get(server.id, ())
802
+ ]
803
+ if replacement_prompts != expected_prompts:
804
+ raise RuntimeError(
805
+ f"MCP server '{server.id}' prompt catalog changed. Recreate the IDE session "
806
+ "before using the new catalog."
807
+ )
808
+ # A list_changed notification that races any comparison means the
809
+ # replacement was already stale before it could become session-owned.
810
+ # Never swap such a connection under frozen model-facing bindings.
811
+ client.observe_notifications()
812
+ changed_surfaces: list[str] = []
813
+ if self._snapshot_loaded and client.tools_list_changed:
814
+ changed_surfaces.append("tool")
815
+ if server.id in self._resource_snapshotted_server_ids and client.resources_list_changed:
816
+ changed_surfaces.append("resource")
817
+ if (
818
+ validate_prompts
819
+ and server.id in self._prompt_loaded_server_ids
820
+ and client.prompts_list_changed
821
+ ):
822
+ changed_surfaces.append("prompt")
823
+ if changed_surfaces:
824
+ surfaces = ", ".join(changed_surfaces)
825
+ raise RuntimeError(
826
+ f"MCP server '{server.id}' reported a {surfaces} catalog change while "
827
+ "reconnecting. Recreate the IDE session before using the new catalog."
828
+ )
829
+
830
+ def _connect_server(self, *, server_id: str, action: str) -> dict[str, Any]:
831
+ with self._lifecycle_lock:
832
+ server = self._server_lifecycle_target(server_id)
833
+ if action == "enable" and server.id not in self._disabled_server_ids:
834
+ status = self.server_lifecycle_status(server_id=server.id)
835
+ if status["connected"]:
836
+ return {**status, "changed": False, "action": action}
837
+ client = self._build_live_client(server)
838
+ try:
839
+ self._validate_replacement_client(server=server, client=client)
840
+ leases = self._client_leases_by_server_id.get(server.id, [])
841
+ if leases:
842
+ leases[0].replace_client(client)
843
+ for redundant_lease in leases[1:]:
844
+ redundant_lease.disable()
845
+ self._client_leases_by_server_id[server.id] = [leases[0]]
846
+ live_client: _McpSessionClient = leases[0]
847
+ else:
848
+ live_client = self._register_client_lease(
849
+ server_id=server.id,
850
+ client=client,
851
+ )
852
+ if server.id in self._clients_by_server_id:
853
+ self._clients_by_server_id[server.id] = live_client
854
+ elif server.id in self._prompt_clients_by_server_id:
855
+ self._prompt_clients_by_server_id[server.id] = live_client
856
+ else:
857
+ # Keep a configured-but-empty server connected to this live manager so
858
+ # status and later prompt materialization operate on the owned connection.
859
+ self._clients_by_server_id[server.id] = live_client
860
+ if server.id in self._prompt_clients_by_server_id:
861
+ self._prompt_clients_by_server_id[server.id] = live_client
862
+ self._disabled_server_ids.discard(server.id)
863
+ # The replacement has just been compared with every frozen
864
+ # surface above. Clear stale state inherited from the closed
865
+ # transport so status reflects the validated connection.
866
+ self._tool_stale_server_ids.discard(server.id)
867
+ self._resource_stale_server_ids.discard(server.id)
868
+ self._prompt_stale_server_ids.discard(server.id)
869
+ self._sync_stale_catalog_snapshot_metadata()
870
+ except Exception:
871
+ client.close()
872
+ raise
873
+ return {
874
+ **self.server_lifecycle_status(server_id=server.id),
875
+ "changed": True,
876
+ "action": action,
877
+ }
878
+
879
+ def enable_server(self, *, server_id: str) -> dict[str, Any]:
880
+ return self._connect_server(server_id=server_id, action="enable")
881
+
882
+ def restart_server(self, *, server_id: str) -> dict[str, Any]:
883
+ return self._connect_server(server_id=server_id, action="restart")
884
+
885
+ def _refresh_stale_state_from_clients(self) -> None:
886
+ for server_id, client in self._clients_by_server_id.items():
887
+ if isinstance(client, _McpClientLease) and not client.enabled:
888
+ continue
889
+ client.observe_notifications()
890
+ if client.tools_list_changed:
891
+ self._tool_stale_server_ids.add(server_id)
892
+ if client.resources_list_changed and server_id in self._resource_snapshotted_server_ids:
893
+ self._resource_stale_server_ids.add(server_id)
894
+ for server_id, client in self._prompt_clients_by_server_id.items():
895
+ if isinstance(client, _McpClientLease) and not client.enabled:
896
+ continue
897
+ client.observe_notifications()
898
+ if client.prompts_list_changed and server_id in self._prompt_loaded_server_ids:
899
+ self._prompt_stale_server_ids.add(server_id)
900
+
901
+ def _sync_stale_catalog_snapshot_metadata(self) -> None:
902
+ self._catalog_snapshot["tool_stale_server_ids"] = sorted(self._tool_stale_server_ids)
903
+ self._catalog_snapshot["resource_stale_server_ids"] = sorted(
904
+ self._resource_stale_server_ids
905
+ )
906
+ self._catalog_snapshot["prompt_stale_server_ids"] = sorted(self._prompt_stale_server_ids)
907
+
908
+ def catalog_snapshot_metadata(self) -> dict[str, Any]:
909
+ self._refresh_stale_state_from_clients()
910
+ self._sync_stale_catalog_snapshot_metadata()
911
+ snapshot = copy.deepcopy(self._catalog_snapshot)
912
+ server_catalogs = snapshot.get("server_catalogs")
913
+ if isinstance(server_catalogs, list):
914
+ for entry in server_catalogs:
915
+ if not isinstance(entry, dict):
916
+ continue
917
+ server_id = str(entry.get("server_id") or "").strip()
918
+ client = self._clients_by_server_id.get(server_id)
919
+ if client is not None and not (
920
+ isinstance(client, _McpClientLease) and not client.enabled
921
+ ):
922
+ entry["tools_list_changed"] = client.tools_list_changed
923
+ entry["resources_list_changed"] = client.resources_list_changed
924
+ entry["tools_snapshot_stale"] = server_id in self._tool_stale_server_ids
925
+ entry["resources_snapshot_stale"] = server_id in self._resource_stale_server_ids
926
+ if entry.get("transport") == "http":
927
+ entry["session_negotiated"] = client.session_negotiated
928
+ prompt_server_catalogs = snapshot.get("prompt_server_catalogs")
929
+ if isinstance(prompt_server_catalogs, list):
930
+ for entry in prompt_server_catalogs:
931
+ if not isinstance(entry, dict):
932
+ continue
933
+ server_id = str(entry.get("server_id") or "").strip()
934
+ client = self._prompt_clients_by_server_id.get(server_id)
935
+ if server_id in self._prompt_stale_server_ids:
936
+ entry["prompt_snapshot_stale"] = True
937
+ else:
938
+ entry.pop("prompt_snapshot_stale", None)
939
+ if client is not None and not (
940
+ isinstance(client, _McpClientLease) and not client.enabled
941
+ ):
942
+ if client.prompts_list_changed:
943
+ entry["prompts_list_changed"] = True
944
+ else:
945
+ entry.pop("prompts_list_changed", None)
946
+ if entry.get("transport") == "http":
947
+ entry["session_negotiated"] = client.session_negotiated
948
+ return snapshot
949
+
950
+ def execution_context_summary(self) -> dict[str, Any]:
951
+ self._ensure_tool_snapshot()
952
+ snapshot = self.catalog_snapshot_metadata()
953
+ servers: list[dict[str, Any]] = []
954
+ for raw_entry in snapshot.get("server_catalogs") or []:
955
+ if not isinstance(raw_entry, dict):
956
+ continue
957
+ server_id = str(raw_entry.get("server_id") or "").strip()
958
+ if not server_id:
959
+ continue
960
+ tool_names = [
961
+ str(name).strip()
962
+ for name in raw_entry.get("exposed_tool_names") or []
963
+ if str(name).strip()
964
+ ]
965
+ servers.append(
966
+ {
967
+ "server_id": server_id,
968
+ "tool_names": tool_names,
969
+ "resources_available": bool(raw_entry.get("snapshotted_resource_count", 0)),
970
+ }
971
+ )
972
+ return {
973
+ "active_server_ids": [server.id for server in self._active_servers],
974
+ "servers": servers,
975
+ }
976
+
977
+ def scope_for_forge_task(
978
+ self,
979
+ *,
980
+ task_scope: ForgeTaskMcpScope | None,
981
+ ) -> ForgeTaskScopedMcpManager:
982
+ if task_scope is None or task_scope.is_empty:
983
+ return ForgeTaskScopedMcpManager.without_live_bootstrap(
984
+ resolved_config=self.resolved_config,
985
+ workspace_root=self.workspace_root,
986
+ runtime_kind=self.runtime_kind,
987
+ task_scope=task_scope,
988
+ close_delegate=self,
989
+ )
990
+ return ForgeTaskScopedMcpManager(base_manager=self, task_scope=task_scope)
991
+
992
+ def _resource_list_tool_parameters(self) -> dict[str, Any]:
993
+ return {
994
+ "type": "object",
995
+ "properties": {
996
+ "server_id": {
997
+ "type": "string",
998
+ "description": "Optional exact MCP server id filter.",
999
+ },
1000
+ "query": {
1001
+ "type": "string",
1002
+ "description": "Optional case-insensitive filter over resource uri, name, or description.",
1003
+ },
1004
+ "limit": {
1005
+ "type": "integer",
1006
+ "minimum": 1,
1007
+ "maximum": _MAX_MCP_RESOURCE_LIST_LIMIT,
1008
+ "description": (
1009
+ f"Maximum resources to return. Defaults to {_DEFAULT_MCP_RESOURCE_LIST_LIMIT}."
1010
+ ),
1011
+ },
1012
+ },
1013
+ "required": [],
1014
+ "additionalProperties": False,
1015
+ }
1016
+
1017
+ def _resource_read_tool_parameters(self) -> dict[str, Any]:
1018
+ return {
1019
+ "type": "object",
1020
+ "properties": {
1021
+ "server_id": {
1022
+ "type": "string",
1023
+ "description": "Exact MCP server id from mcp_resources_list.",
1024
+ },
1025
+ "uri": {
1026
+ "type": "string",
1027
+ "description": "Exact snapshotted resource URI from mcp_resources_list.",
1028
+ },
1029
+ },
1030
+ "required": ["server_id", "uri"],
1031
+ "additionalProperties": False,
1032
+ }
1033
+
1034
+ def _normalize_resource_list_limit(self, value: Any) -> int:
1035
+ if value is None:
1036
+ return _DEFAULT_MCP_RESOURCE_LIST_LIMIT
1037
+ if isinstance(value, bool) or not isinstance(value, int):
1038
+ raise RuntimeError("mcp_resources_list limit must be an integer.")
1039
+ if value < 1 or value > _MAX_MCP_RESOURCE_LIST_LIMIT:
1040
+ raise RuntimeError(
1041
+ f"mcp_resources_list limit must be between 1 and {_MAX_MCP_RESOURCE_LIST_LIMIT}."
1042
+ )
1043
+ return value
1044
+
1045
+ def _normalize_required_argument(self, arguments: dict[str, Any], *, field_name: str) -> str:
1046
+ value = arguments.get(field_name)
1047
+ if not isinstance(value, str) or not value.strip():
1048
+ raise RuntimeError(f"{field_name} must be a non-empty string.")
1049
+ return value.strip()
1050
+
1051
+ def _list_snapshotted_resources(self, arguments: dict[str, Any]) -> dict[str, Any]:
1052
+ server_id_value = arguments.get("server_id")
1053
+ if server_id_value is None:
1054
+ server_id = None
1055
+ elif isinstance(server_id_value, str) and server_id_value.strip():
1056
+ server_id = server_id_value.strip()
1057
+ else:
1058
+ raise RuntimeError("server_id must be a non-empty string when present.")
1059
+ query_value = arguments.get("query")
1060
+ if query_value is None:
1061
+ query = None
1062
+ elif isinstance(query_value, str):
1063
+ query = query_value.strip().casefold() or None
1064
+ else:
1065
+ raise RuntimeError("query must be a string when present.")
1066
+ limit = self._normalize_resource_list_limit(arguments.get("limit"))
1067
+ matching_resources: list[dict[str, Any]] = []
1068
+ for record in self._resource_records:
1069
+ if server_id is not None and record.server.id != server_id:
1070
+ continue
1071
+ if query is not None:
1072
+ haystack = " ".join(
1073
+ part
1074
+ for part in (
1075
+ record.server.id,
1076
+ record.resource.uri,
1077
+ record.resource.name,
1078
+ record.resource.description or "",
1079
+ )
1080
+ if part
1081
+ ).casefold()
1082
+ if query not in haystack:
1083
+ continue
1084
+ matching_resources.append(record.resource.as_tool_payload(server_id=record.server.id))
1085
+ returned_resources = matching_resources[:limit]
1086
+ return {
1087
+ "resources": returned_resources,
1088
+ "returned_count": len(returned_resources),
1089
+ "matching_count": len(matching_resources),
1090
+ "total_snapshot_count": len(self._resource_records),
1091
+ }
1092
+
1093
+ def _read_snapshotted_resource(self, arguments: dict[str, Any]) -> dict[str, Any]:
1094
+ server_id = self._normalize_required_argument(arguments, field_name="server_id")
1095
+ uri = self._normalize_required_argument(arguments, field_name="uri")
1096
+ record = self._resource_lookup.get((server_id, uri))
1097
+ if record is None:
1098
+ raise RuntimeError(
1099
+ "Blocked: MCP resource reads are limited to the frozen session snapshot; "
1100
+ f"no snapshotted resource matches server '{server_id}' and uri '{uri}'."
1101
+ )
1102
+ result = record.client.read_resource(resource_uri=uri, allowed_uris=frozenset({uri}))
1103
+ payload: dict[str, Any] = {
1104
+ "server_id": record.server.id,
1105
+ "uri": record.resource.uri,
1106
+ "name": record.resource.name,
1107
+ "content_summary": result.content_summary,
1108
+ "contents": [
1109
+ item.as_tool_payload(server_id=record.server.id, resource_uri=record.resource.uri)
1110
+ for item in result.contents
1111
+ ],
1112
+ }
1113
+ mime_type = result.mime_type or record.resource.mime_type
1114
+ if mime_type:
1115
+ payload["mime_type"] = mime_type
1116
+ if record.resource.description:
1117
+ payload["description"] = record.resource.description
1118
+ if record.resource.size is not None:
1119
+ payload["size"] = record.resource.size
1120
+ if any(item.text is not None for item in result.contents):
1121
+ payload["text"] = build_untrusted_mcp_text_block(
1122
+ source_type="resource_read",
1123
+ server_id=record.server.id,
1124
+ source_name=record.resource.uri,
1125
+ text=result.text,
1126
+ mime_type=mime_type,
1127
+ )
1128
+ return payload
1129
+
1130
+ def _build_resource_tool_bindings(self) -> tuple[McpHostToolBinding, ...]:
1131
+ if not self._resource_records:
1132
+ return ()
1133
+ return (
1134
+ McpHostToolBinding(
1135
+ tool_name="mcp_resources_list",
1136
+ tool_alias="mcp_resources_list",
1137
+ description=(
1138
+ "List read-only MCP resources from the frozen session snapshot across active "
1139
+ "servers."
1140
+ ),
1141
+ parameters=self._resource_list_tool_parameters(),
1142
+ run_handler=self._list_snapshotted_resources,
1143
+ ),
1144
+ McpHostToolBinding(
1145
+ tool_name="mcp_resource_read",
1146
+ tool_alias="mcp_resource_read",
1147
+ description=(
1148
+ "Read one frozen-snapshot MCP resource by server_id and uri. Arbitrary "
1149
+ "unsnapshotted URI reads are blocked."
1150
+ ),
1151
+ parameters=self._resource_read_tool_parameters(),
1152
+ run_handler=self._read_snapshotted_resource,
1153
+ ),
1154
+ )
1155
+
1156
+ def _normalize_prompt_list_limit(self, value: Any) -> int:
1157
+ if value is None:
1158
+ return _DEFAULT_MCP_PROMPT_LIST_LIMIT
1159
+ if isinstance(value, bool) or not isinstance(value, int):
1160
+ raise RuntimeError("prompt list limit must be an integer.")
1161
+ if value < 1 or value > _MAX_MCP_PROMPT_LIST_LIMIT:
1162
+ raise RuntimeError(
1163
+ f"prompt list limit must be between 1 and {_MAX_MCP_PROMPT_LIST_LIMIT}."
1164
+ )
1165
+ return value
1166
+
1167
+ def _close_prompt_clients(self) -> None:
1168
+ clients = list(self._prompt_clients_by_server_id.values())
1169
+ self._prompt_clients_by_server_id = {}
1170
+ for client in clients:
1171
+ if client in self._clients_by_server_id.values():
1172
+ continue
1173
+ client.close()
1174
+
1175
+ def _initial_prompt_server_catalog(self, server: ResolvedMcpServer) -> dict[str, Any]:
1176
+ return {
1177
+ "server_id": server.id,
1178
+ "transport": server.transport,
1179
+ "prompts_mode": server.prompts_mode,
1180
+ "prompt_snapshot_loaded": False,
1181
+ "prompt_snapshot_failed": False,
1182
+ "snapshotted_prompt_count": 0,
1183
+ }
1184
+
1185
+ def _sync_prompt_catalog_snapshot_metadata(self) -> None:
1186
+ prompt_enabled_server_ids = [server.id for server in self._prompt_enabled_servers]
1187
+ prompt_snapshotted_server_ids = [
1188
+ server.id
1189
+ for server in self._prompt_enabled_servers
1190
+ if server.id in self._prompt_loaded_server_ids
1191
+ ]
1192
+ prompt_snapshot_complete = len(prompt_snapshotted_server_ids) == len(
1193
+ prompt_enabled_server_ids
1194
+ )
1195
+ prompt_server_catalogs = [
1196
+ copy.deepcopy(self._prompt_server_catalogs_by_id[server.id])
1197
+ for server in self._prompt_enabled_servers
1198
+ ]
1199
+ self._catalog_snapshot["prompt_enabled_server_ids"] = prompt_enabled_server_ids
1200
+ self._catalog_snapshot["prompt_snapshotted_server_ids"] = prompt_snapshotted_server_ids
1201
+ self._catalog_snapshot["prompt_snapshot_complete"] = prompt_snapshot_complete
1202
+ self._catalog_snapshot["prompt_snapshot_partial"] = bool(
1203
+ prompt_snapshotted_server_ids
1204
+ ) and (not prompt_snapshot_complete)
1205
+ self._catalog_snapshot["prompt_server_catalogs"] = prompt_server_catalogs
1206
+ snapshotted_prompt_count = sum(
1207
+ len(records) for records in self._prompt_records_by_server_id.values()
1208
+ )
1209
+ self._catalog_snapshot["snapshotted_prompt_count"] = snapshotted_prompt_count
1210
+ self._catalog_snapshot["manual_prompt_surface_enabled"] = snapshotted_prompt_count > 0
1211
+ self._sync_stale_catalog_snapshot_metadata()
1212
+
1213
+ def _iter_ordered_prompt_records(
1214
+ self,
1215
+ *,
1216
+ server_id: str | None = None,
1217
+ ) -> tuple[_SnapshottedPromptRecord, ...]:
1218
+ if server_id is not None:
1219
+ return self._prompt_records_by_server_id.get(server_id, ())
1220
+ ordered_records: list[_SnapshottedPromptRecord] = []
1221
+ for server in self._prompt_enabled_servers:
1222
+ ordered_records.extend(self._prompt_records_by_server_id.get(server.id, ()))
1223
+ return tuple(ordered_records)
1224
+
1225
+ def _prompt_target_server(self, *, server_id: str) -> ResolvedMcpServer:
1226
+ normalized_server_id = str(server_id or "").strip()
1227
+ if not normalized_server_id:
1228
+ raise RuntimeError("server_id must be a non-empty string.")
1229
+ prompt_server = self._prompt_enabled_servers_by_id.get(normalized_server_id)
1230
+ if prompt_server is not None:
1231
+ return prompt_server
1232
+ active_server = self._active_servers_by_id.get(normalized_server_id)
1233
+ if active_server is not None:
1234
+ raise RuntimeError(
1235
+ f"MCP server '{normalized_server_id}' does not have prompts enabled for runtime "
1236
+ f"'{self.runtime_kind.value}'."
1237
+ )
1238
+ if normalized_server_id in self._resolved_servers_by_id:
1239
+ raise RuntimeError(
1240
+ f"MCP server '{normalized_server_id}' is not active for runtime "
1241
+ f"'{self.runtime_kind.value}'."
1242
+ )
1243
+ raise RuntimeError(f"Unknown MCP server '{normalized_server_id}'.")
1244
+
1245
+ def _raise_prompt_server_error(self, *, server_id: str) -> None:
1246
+ error = self._prompt_server_errors.get(server_id)
1247
+ if error is None:
1248
+ return
1249
+ if isinstance(error, Exception):
1250
+ raise error
1251
+ raise RuntimeError(str(error))
1252
+
1253
+ def _drop_prompt_server_snapshot(self, server_id: str) -> None:
1254
+ old_records = self._prompt_records_by_server_id.pop(server_id, ())
1255
+ if old_records:
1256
+ old_lookup_keys = {record.lookup_key for record in old_records}
1257
+ self._prompt_records = tuple(
1258
+ record
1259
+ for record in self._prompt_records
1260
+ if record.lookup_key not in old_lookup_keys
1261
+ )
1262
+ for lookup_key in old_lookup_keys:
1263
+ self._prompt_lookup.pop(lookup_key, None)
1264
+ old_client = self._prompt_clients_by_server_id.pop(server_id, None)
1265
+ if old_client is not None and old_client not in self._clients_by_server_id.values():
1266
+ old_client.close()
1267
+ self._prompt_loaded_server_ids.discard(server_id)
1268
+ self._prompt_server_errors.pop(server_id, None)
1269
+
1270
+ def _ensure_prompt_server_snapshot(
1271
+ self,
1272
+ server: ResolvedMcpServer,
1273
+ *,
1274
+ refresh: bool = False,
1275
+ ) -> None:
1276
+ if self._closed:
1277
+ raise RuntimeError("MCP manager is closed.")
1278
+ if server.id in self._disabled_server_ids:
1279
+ raise RuntimeError(f"MCP server '{server.id}' is disabled for this session.")
1280
+ if refresh:
1281
+ self._prompt_server_errors.pop(server.id, None)
1282
+ if server.id in self._prompt_loaded_server_ids and not refresh:
1283
+ return
1284
+ if server.id in self._prompt_server_errors:
1285
+ self._raise_prompt_server_error(server_id=server.id)
1286
+ replacing_loaded_snapshot = refresh and server.id in self._prompt_loaded_server_ids
1287
+ replaced_prompt_count = (
1288
+ len(self._prompt_records_by_server_id.get(server.id, ()))
1289
+ if replacing_loaded_snapshot
1290
+ else 0
1291
+ )
1292
+ if server.trust != "explicit":
1293
+ error = ConfigError(
1294
+ f"MCP server '{server.id}' uses unsupported trust mode '{server.trust}'. "
1295
+ "Live MCP prompt access currently requires trust='explicit'."
1296
+ )
1297
+ self._prompt_server_errors[server.id] = error
1298
+ self._prompt_server_catalogs_by_id[server.id]["prompt_snapshot_failed"] = True
1299
+ self._sync_prompt_catalog_snapshot_metadata()
1300
+ raise error
1301
+ existing_client = self._clients_by_server_id.get(server.id)
1302
+ raw_client: _McpLiveClient | None = None
1303
+ client: _McpSessionClient
1304
+ if existing_client is not None and not refresh:
1305
+ client = existing_client
1306
+ else:
1307
+ raw_client = self._build_live_client(server)
1308
+ client = raw_client
1309
+ try:
1310
+ client.ensure_initialized()
1311
+ listed_prompts: tuple[McpListedPrompt, ...] = ()
1312
+ if client.supports_prompts:
1313
+ listed_prompts = client.list_prompts()
1314
+ prompt_count = len(listed_prompts)
1315
+ if prompt_count > _MAX_LISTED_MCP_PROMPTS_PER_SERVER:
1316
+ raise ConfigError(
1317
+ "MCP prompt exposure is too large for server "
1318
+ f"'{server.id}' ({prompt_count} prompts). Narrow server-side prompt "
1319
+ "exposure or disable prompts_mode."
1320
+ )
1321
+ total_prompts = len(self._prompt_records) - replaced_prompt_count + prompt_count
1322
+ if total_prompts > _MAX_LISTED_MCP_PROMPTS_TOTAL:
1323
+ raise ConfigError(
1324
+ "MCP prompt exposure is too large for one session "
1325
+ f"({total_prompts} prompts). Narrow server-side prompt exposure or "
1326
+ "disable prompts_mode."
1327
+ )
1328
+ if raw_client is not None and isinstance(existing_client, _McpClientLease):
1329
+ self._validate_replacement_client(
1330
+ server=server,
1331
+ client=raw_client,
1332
+ validate_prompts=False,
1333
+ )
1334
+ existing_client.replace_client(raw_client)
1335
+ client = existing_client
1336
+ raw_client = None
1337
+ elif raw_client is not None:
1338
+ client = self._register_client_lease(server_id=server.id, client=raw_client)
1339
+ raw_client = None
1340
+ self._clients_by_server_id[server.id] = client
1341
+ new_records = tuple(
1342
+ _SnapshottedPromptRecord(
1343
+ server=server,
1344
+ client=client,
1345
+ prompt=prompt,
1346
+ )
1347
+ for prompt in listed_prompts
1348
+ )
1349
+ if refresh:
1350
+ self._drop_prompt_server_snapshot(server.id)
1351
+ self._prompt_records_by_server_id[server.id] = new_records
1352
+ if new_records:
1353
+ self._prompt_records = self._prompt_records + new_records
1354
+ for record in new_records:
1355
+ self._prompt_lookup[record.lookup_key] = record
1356
+ self._prompt_clients_by_server_id[server.id] = client
1357
+ self._prompt_loaded_server_ids.add(server.id)
1358
+ self._prompt_snapshot_loaded = len(self._prompt_loaded_server_ids) == len(
1359
+ self._prompt_enabled_servers
1360
+ )
1361
+ catalog_entry = self._prompt_server_catalogs_by_id[server.id]
1362
+ catalog_entry["prompt_snapshot_loaded"] = True
1363
+ catalog_entry["prompt_snapshot_failed"] = False
1364
+ if client.prompts_list_changed:
1365
+ catalog_entry["prompts_list_changed"] = True
1366
+ self._prompt_stale_server_ids.add(server.id)
1367
+ else:
1368
+ catalog_entry.pop("prompts_list_changed", None)
1369
+ self._prompt_stale_server_ids.discard(server.id)
1370
+ if server.id in self._prompt_stale_server_ids:
1371
+ catalog_entry["prompt_snapshot_stale"] = True
1372
+ else:
1373
+ catalog_entry.pop("prompt_snapshot_stale", None)
1374
+ catalog_entry["prompts_capability_advertised"] = client.supports_prompts
1375
+ catalog_entry["snapshotted_prompt_count"] = prompt_count
1376
+ self._sync_prompt_catalog_snapshot_metadata()
1377
+ except Exception as exc:
1378
+ if raw_client is not None:
1379
+ raw_client.close()
1380
+ self._prompt_server_errors[server.id] = exc
1381
+ catalog_entry = self._prompt_server_catalogs_by_id[server.id]
1382
+ if not replacing_loaded_snapshot:
1383
+ catalog_entry["prompt_snapshot_failed"] = True
1384
+ catalog_entry["prompt_snapshot_loaded"] = False
1385
+ catalog_entry.pop("prompts_list_changed", None)
1386
+ catalog_entry.pop("prompts_capability_advertised", None)
1387
+ catalog_entry["snapshotted_prompt_count"] = 0
1388
+ self._prompt_snapshot_loaded = False
1389
+ if server.id in self._prompt_stale_server_ids:
1390
+ catalog_entry["prompt_snapshot_stale"] = True
1391
+ else:
1392
+ catalog_entry.pop("prompt_snapshot_stale", None)
1393
+ self._sync_prompt_catalog_snapshot_metadata()
1394
+ raise
1395
+
1396
+ def _ensure_prompt_snapshot(
1397
+ self,
1398
+ *,
1399
+ server_id: str | None = None,
1400
+ refresh: bool = False,
1401
+ ) -> None:
1402
+ if server_id is None:
1403
+ if self._prompt_snapshot_loaded and not refresh:
1404
+ return
1405
+ for server in self._prompt_enabled_servers:
1406
+ self._ensure_prompt_server_snapshot(server, refresh=refresh)
1407
+ self._prompt_snapshot_loaded = True
1408
+ self._sync_prompt_catalog_snapshot_metadata()
1409
+ return
1410
+ self._ensure_prompt_server_snapshot(
1411
+ self._prompt_target_server(server_id=server_id),
1412
+ refresh=refresh,
1413
+ )
1414
+
1415
+ def list_prompts(
1416
+ self,
1417
+ *,
1418
+ server_id: str | None = None,
1419
+ query: str | None = None,
1420
+ limit: int = _DEFAULT_MCP_PROMPT_LIST_LIMIT,
1421
+ refresh: bool = False,
1422
+ ) -> dict[str, Any]:
1423
+ if server_id is None:
1424
+ normalized_server_id = None
1425
+ else:
1426
+ normalized_server_id = str(server_id).strip()
1427
+ if not normalized_server_id:
1428
+ raise RuntimeError("server_id must be a non-empty string when present.")
1429
+ self._ensure_prompt_snapshot(server_id=normalized_server_id, refresh=refresh)
1430
+ self._refresh_stale_state_from_clients()
1431
+ self._sync_stale_catalog_snapshot_metadata()
1432
+ normalized_query = str(query or "").strip().casefold() or None
1433
+ normalized_limit = self._normalize_prompt_list_limit(limit)
1434
+ matching_prompts: list[dict[str, Any]] = []
1435
+ for record in self._iter_ordered_prompt_records(server_id=normalized_server_id):
1436
+ if normalized_query is not None:
1437
+ haystack = " ".join(
1438
+ part
1439
+ for part in (
1440
+ record.server.id,
1441
+ record.prompt.name,
1442
+ record.prompt.title or "",
1443
+ record.prompt.description or "",
1444
+ " ".join(argument.name for argument in record.prompt.arguments),
1445
+ )
1446
+ if part
1447
+ ).casefold()
1448
+ if normalized_query not in haystack:
1449
+ continue
1450
+ matching_prompts.append(record.prompt.as_payload(server_id=record.server.id))
1451
+ returned_prompts = matching_prompts[:normalized_limit]
1452
+ payload: dict[str, Any] = {
1453
+ "prompts": returned_prompts,
1454
+ "returned_count": len(returned_prompts),
1455
+ "matching_count": len(matching_prompts),
1456
+ "total_snapshot_count": len(self._prompt_records),
1457
+ }
1458
+ if refresh:
1459
+ payload["refresh_performed"] = True
1460
+ if self._prompt_stale_server_ids:
1461
+ payload["stale_server_ids"] = sorted(self._prompt_stale_server_ids)
1462
+ payload["snapshot_complete"] = bool(
1463
+ self._catalog_snapshot.get("prompt_snapshot_complete")
1464
+ )
1465
+ payload["snapshot_partial"] = bool(
1466
+ self._catalog_snapshot.get("prompt_snapshot_partial")
1467
+ )
1468
+ return payload
1469
+
1470
+ def get_prompt(
1471
+ self,
1472
+ *,
1473
+ server_id: str,
1474
+ prompt_name: str,
1475
+ arguments: dict[str, str] | None = None,
1476
+ refresh: bool = False,
1477
+ ) -> dict[str, Any]:
1478
+ normalized_server_id = str(server_id or "").strip()
1479
+ normalized_prompt_name = str(prompt_name or "").strip()
1480
+ if not normalized_prompt_name:
1481
+ raise RuntimeError("prompt_name must be a non-empty string.")
1482
+ if arguments is not None and not isinstance(arguments, dict):
1483
+ raise RuntimeError("arguments must be an object mapping strings to strings.")
1484
+ self._ensure_prompt_snapshot(server_id=normalized_server_id, refresh=refresh)
1485
+ self._refresh_stale_state_from_clients()
1486
+ self._sync_stale_catalog_snapshot_metadata()
1487
+ record = self._prompt_lookup.get((normalized_server_id, normalized_prompt_name))
1488
+ if record is None:
1489
+ raise RuntimeError(
1490
+ "Blocked: MCP prompt fetches are limited to the frozen session snapshot; "
1491
+ f"no snapshotted prompt matches server '{normalized_server_id}' and prompt "
1492
+ f"{normalized_prompt_name!r}."
1493
+ )
1494
+ result = record.client.get_prompt(name=record.prompt.name, arguments=arguments)
1495
+ payload: dict[str, Any] = {
1496
+ "server_id": record.server.id,
1497
+ "name": record.prompt.name,
1498
+ "arguments": [argument.as_payload() for argument in record.prompt.arguments],
1499
+ "message_count": len(result.messages),
1500
+ "content_summary": result.content_summary,
1501
+ "messages": [
1502
+ message.as_payload(server_id=record.server.id, prompt_name=record.prompt.name)
1503
+ for message in result.messages
1504
+ ],
1505
+ }
1506
+ if refresh:
1507
+ payload["refresh_performed"] = True
1508
+ if normalized_server_id in self._prompt_stale_server_ids:
1509
+ payload["snapshot_stale"] = True
1510
+ if record.prompt.title:
1511
+ payload["title"] = record.prompt.title
1512
+ description = result.description or record.prompt.description
1513
+ if description:
1514
+ payload["description"] = description
1515
+ if arguments:
1516
+ payload["applied_arguments"] = {
1517
+ str(key): str(value) for key, value in arguments.items()
1518
+ }
1519
+ if result.text:
1520
+ payload["text"] = build_untrusted_mcp_text_block(
1521
+ source_type="prompt_get",
1522
+ server_id=record.server.id,
1523
+ source_name=record.prompt.name,
1524
+ text=result.text,
1525
+ )
1526
+ return payload
1527
+
1528
+ def _ensure_tool_snapshot(self) -> None:
1529
+ if self._snapshot_error is not None:
1530
+ if isinstance(self._snapshot_error, Exception):
1531
+ raise self._snapshot_error
1532
+ raise RuntimeError(str(self._snapshot_error))
1533
+ if self._snapshot_loaded:
1534
+ return
1535
+ if self._closed:
1536
+ raise RuntimeError("MCP manager is closed.")
1537
+ self._snapshot_loaded = True
1538
+ if not self._live_runtime_enabled:
1539
+ self._catalog_snapshot["catalog_initialized"] = True
1540
+ return
1541
+ collected_catalogs: tuple[_CollectedServerCatalog, ...] = ()
1542
+ try:
1543
+ collected_catalogs = self._collect_server_catalogs()
1544
+ leased_catalogs: list[_CollectedServerCatalog] = []
1545
+ for catalog in collected_catalogs:
1546
+ if isinstance(catalog.client, _McpClientLease):
1547
+ leased_catalogs.append(catalog)
1548
+ continue
1549
+ lease = self._register_client_lease(
1550
+ server_id=catalog.server.id,
1551
+ client=catalog.client,
1552
+ )
1553
+ if catalog.server.id in self._disabled_server_ids:
1554
+ # A disable issued before first tool materialization must
1555
+ # remain fail-closed after the frozen catalog is built.
1556
+ lease.disable()
1557
+ leased_catalogs.append(replace(catalog, client=lease))
1558
+ collected_catalogs = tuple(leased_catalogs)
1559
+ flat_records: list[_FlatToolRecord] = []
1560
+ resource_records: list[_SnapshottedResourceRecord] = []
1561
+ total_schema_bytes = 0
1562
+ total_resources = 0
1563
+ for catalog in collected_catalogs:
1564
+ for tool in catalog.filtered_tools:
1565
+ parameters = _validate_function_tool_schema(server=catalog.server, tool=tool)
1566
+ total_schema_bytes += _stable_json_size(parameters)
1567
+ flat_records.append(
1568
+ _FlatToolRecord(
1569
+ server=catalog.server,
1570
+ client=catalog.client,
1571
+ tool=tool,
1572
+ parameters=parameters,
1573
+ )
1574
+ )
1575
+ resource_count = len(catalog.listed_resources)
1576
+ if resource_count > _MAX_LISTED_MCP_RESOURCES_PER_SERVER:
1577
+ raise ConfigError(
1578
+ "MCP resource exposure is too large for server "
1579
+ f"'{catalog.server.id}' ({resource_count} resources). Narrow server-side "
1580
+ "resource exposure or disable resources_mode."
1581
+ )
1582
+ total_resources += resource_count
1583
+ for resource in catalog.listed_resources:
1584
+ resource_records.append(
1585
+ _SnapshottedResourceRecord(
1586
+ server=catalog.server,
1587
+ client=catalog.client,
1588
+ resource=resource,
1589
+ )
1590
+ )
1591
+ if len(flat_records) > _MAX_EXPOSED_MCP_TOOLS:
1592
+ raise ConfigError(
1593
+ "MCP tool exposure is too large for one session "
1594
+ f"({len(flat_records)} tools). Narrow exposure with allowed_tools."
1595
+ )
1596
+ if total_schema_bytes > _MAX_TOTAL_SCHEMA_BYTES:
1597
+ raise ConfigError(
1598
+ "MCP tool exposure schema footprint is too large for one session "
1599
+ f"({total_schema_bytes} bytes). Narrow exposure with allowed_tools."
1600
+ )
1601
+ if total_resources > _MAX_LISTED_MCP_RESOURCES_TOTAL:
1602
+ raise ConfigError(
1603
+ "MCP resource exposure is too large for one session "
1604
+ f"({total_resources} resources). Narrow server-side resource exposure or "
1605
+ "disable resources_mode."
1606
+ )
1607
+ alias_map = self._assign_aliases(flat_records)
1608
+ bindings: list[_McpManagerToolBinding] = []
1609
+ server_catalogs: list[dict[str, Any]] = []
1610
+ clients_with_bindings: dict[str, _McpSessionClient] = {}
1611
+ for catalog in collected_catalogs:
1612
+ exposed_aliases: list[str] = []
1613
+ exposed_tool_names: list[str] = []
1614
+ for record in flat_records:
1615
+ if record.server.id != catalog.server.id:
1616
+ continue
1617
+ tool_alias = alias_map[record.unique_key]
1618
+ exposed_aliases.append(tool_alias)
1619
+ exposed_tool_names.append(record.tool.name)
1620
+ clients_with_bindings[catalog.server.id] = catalog.client
1621
+ bindings.append(
1622
+ McpToolBinding(
1623
+ server_id=catalog.server.id,
1624
+ tool_name=record.tool.name,
1625
+ tool_alias=tool_alias,
1626
+ description=build_host_owned_mcp_tool_description(
1627
+ server_id=catalog.server.id,
1628
+ tool_name=record.tool.name,
1629
+ server_description=record.tool.description,
1630
+ ),
1631
+ parameters=copy.deepcopy(record.parameters),
1632
+ client=catalog.client,
1633
+ )
1634
+ )
1635
+ server_catalogs.append(
1636
+ {
1637
+ "server_id": catalog.server.id,
1638
+ "transport": catalog.server.transport,
1639
+ "roots_mode": catalog.server.roots_mode,
1640
+ "roots_capability_enabled": catalog.roots_capability_enabled,
1641
+ "resources_mode": catalog.server.resources_mode,
1642
+ "resources_capability_advertised": (
1643
+ catalog.resources_capability_advertised
1644
+ ),
1645
+ "resources_snapshot_loaded": catalog.resources_snapshot_loaded,
1646
+ "snapshotted_resource_count": len(catalog.listed_resources),
1647
+ "resources_list_changed": catalog.resources_list_changed,
1648
+ "resources_snapshot_stale": (
1649
+ catalog.resources_list_changed and catalog.resources_snapshot_loaded
1650
+ ),
1651
+ "raw_tool_names": list(catalog.raw_tool_names),
1652
+ "raw_tool_count": len(catalog.raw_tool_names),
1653
+ "exposed_tool_names": exposed_tool_names,
1654
+ "exposed_tool_aliases": exposed_aliases,
1655
+ "exposed_tool_count": len(exposed_tool_names),
1656
+ "tools_list_changed": catalog.tools_list_changed,
1657
+ "tools_snapshot_stale": catalog.tools_list_changed,
1658
+ **(
1659
+ {"session_negotiated": catalog.session_negotiated}
1660
+ if catalog.server.transport == "http"
1661
+ else {}
1662
+ ),
1663
+ }
1664
+ )
1665
+ # Keep one initialized, manager-owned connection for every active server.
1666
+ # This makes lifecycle status honest even when a server currently exposes
1667
+ # no tools/resources, and lets later prompt access share the same owner.
1668
+ clients_with_bindings[catalog.server.id] = catalog.client
1669
+ self._resource_records = tuple(resource_records)
1670
+ self._resource_lookup = {record.lookup_key: record for record in self._resource_records}
1671
+ self._resource_snapshotted_server_ids = {
1672
+ catalog.server.id
1673
+ for catalog in collected_catalogs
1674
+ if catalog.resources_snapshot_loaded
1675
+ }
1676
+ resource_tool_bindings = self._build_resource_tool_bindings()
1677
+ bindings.extend(resource_tool_bindings)
1678
+ self._tool_bindings = tuple(bindings)
1679
+ self._clients_by_server_id = clients_with_bindings
1680
+ prompt_server_catalogs = copy.deepcopy(
1681
+ self._catalog_snapshot.get("prompt_server_catalogs") or []
1682
+ )
1683
+ prompt_enabled_server_ids = list(
1684
+ self._catalog_snapshot.get("prompt_enabled_server_ids") or []
1685
+ )
1686
+ prompt_snapshotted_server_ids = list(
1687
+ self._catalog_snapshot.get("prompt_snapshotted_server_ids") or []
1688
+ )
1689
+ prompt_snapshot_complete = bool(self._catalog_snapshot.get("prompt_snapshot_complete"))
1690
+ prompt_snapshot_partial = bool(self._catalog_snapshot.get("prompt_snapshot_partial"))
1691
+ snapshotted_prompt_count = int(
1692
+ self._catalog_snapshot.get("snapshotted_prompt_count") or 0
1693
+ )
1694
+ manual_prompt_surface_enabled = bool(
1695
+ self._catalog_snapshot.get("manual_prompt_surface_enabled")
1696
+ )
1697
+ self._catalog_snapshot = {
1698
+ "catalog_initialized": True,
1699
+ "live_tool_runtime_enabled": self._live_runtime_enabled,
1700
+ "active_server_ids": [server.id for server in self._active_servers],
1701
+ "active_server_count": len(self._active_servers),
1702
+ "server_catalogs": server_catalogs,
1703
+ "exposed_tool_aliases": [binding.tool_alias for binding in self._tool_bindings],
1704
+ "exposed_tool_names": [binding.tool_name for binding in self._tool_bindings],
1705
+ "exposed_tool_count": len(self._tool_bindings),
1706
+ "snapshotted_resource_count": len(self._resource_records),
1707
+ "resource_tool_names": [binding.tool_alias for binding in resource_tool_bindings],
1708
+ "resource_tool_count": len(resource_tool_bindings),
1709
+ "prompt_enabled_server_ids": prompt_enabled_server_ids,
1710
+ "prompt_snapshotted_server_ids": prompt_snapshotted_server_ids,
1711
+ "prompt_snapshot_complete": prompt_snapshot_complete,
1712
+ "prompt_snapshot_partial": prompt_snapshot_partial,
1713
+ "tool_stale_server_ids": sorted(self._tool_stale_server_ids),
1714
+ "resource_stale_server_ids": sorted(self._resource_stale_server_ids),
1715
+ "prompt_stale_server_ids": sorted(self._prompt_stale_server_ids),
1716
+ "prompt_server_catalogs": prompt_server_catalogs,
1717
+ "snapshotted_prompt_count": snapshotted_prompt_count,
1718
+ "manual_prompt_surface_enabled": manual_prompt_surface_enabled,
1719
+ }
1720
+ for catalog in collected_catalogs:
1721
+ if catalog.tools_list_changed:
1722
+ self._tool_stale_server_ids.add(catalog.server.id)
1723
+ if catalog.resources_list_changed and catalog.resources_snapshot_loaded:
1724
+ self._resource_stale_server_ids.add(catalog.server.id)
1725
+ self._sync_stale_catalog_snapshot_metadata()
1726
+ except Exception as exc:
1727
+ self._snapshot_error = exc
1728
+ for catalog in collected_catalogs:
1729
+ catalog.client.close()
1730
+ self.close()
1731
+ raise
1732
+
1733
+ def _collect_server_catalogs(self) -> tuple[_CollectedServerCatalog, ...]:
1734
+ catalogs: list[_CollectedServerCatalog] = []
1735
+ try:
1736
+ for server in self._active_servers:
1737
+ if server.trust != "explicit":
1738
+ raise ConfigError(
1739
+ f"MCP server '{server.id}' uses unsupported trust mode '{server.trust}'. "
1740
+ "Live MCP tool exposure currently requires trust='explicit'."
1741
+ )
1742
+ leases = self._client_leases_by_server_id.get(server.id, [])
1743
+ existing_lease = leases[0] if leases else None
1744
+ raw_client: _McpLiveClient | None = None
1745
+ if (
1746
+ existing_lease is not None
1747
+ and existing_lease.enabled
1748
+ and not existing_lease.closed
1749
+ ):
1750
+ client: _McpSessionClient = existing_lease
1751
+ else:
1752
+ raw_client = self._build_live_client(server)
1753
+ client = raw_client
1754
+ try:
1755
+ client.ensure_initialized()
1756
+ raw_tools: tuple[McpListedTool, ...] = ()
1757
+ filtered_tools: tuple[McpListedTool, ...] = ()
1758
+ raw_tool_names: tuple[str, ...] = ()
1759
+ if client.supports_tools:
1760
+ raw_tools = client.list_tools()
1761
+ filtered_tools, raw_tool_names = self._filter_server_tools(
1762
+ server=server,
1763
+ raw_tools=raw_tools,
1764
+ )
1765
+ elif server.allowed_tools or server.denied_tools:
1766
+ raise ConfigError(
1767
+ f"MCP server '{server.id}' does not advertise tools capability, but "
1768
+ "tool allow/deny policy is configured."
1769
+ )
1770
+ listed_resources: tuple[McpListedResource, ...] = ()
1771
+ resources_snapshot_loaded = resources_mode_enabled(
1772
+ resources_mode=server.resources_mode,
1773
+ runtime_kind=self.runtime_kind,
1774
+ )
1775
+ if resources_snapshot_loaded:
1776
+ listed_resources = client.list_resources()
1777
+ catalog_client = client
1778
+ if raw_client is not None and existing_lease is not None:
1779
+ if existing_lease.enabled:
1780
+ # Recover an enabled-but-disconnected lease without
1781
+ # invalidating objects that already reference it.
1782
+ existing_lease.replace_client(raw_client)
1783
+ else:
1784
+ # A pre-snapshot disable remains disabled. The
1785
+ # temporary connection existed only to freeze the
1786
+ # catalog and must not become reachable afterward.
1787
+ raw_client.close()
1788
+ raw_client = None
1789
+ catalog_client = existing_lease
1790
+ catalogs.append(
1791
+ _CollectedServerCatalog(
1792
+ server=server,
1793
+ client=catalog_client,
1794
+ raw_tools=raw_tools,
1795
+ filtered_tools=filtered_tools,
1796
+ raw_tool_names=raw_tool_names,
1797
+ listed_resources=listed_resources,
1798
+ resources_snapshot_loaded=resources_snapshot_loaded,
1799
+ tools_list_changed=client.tools_list_changed,
1800
+ resources_list_changed=client.resources_list_changed,
1801
+ roots_capability_enabled=client.roots_capability_enabled,
1802
+ resources_capability_advertised=client.supports_resources,
1803
+ session_negotiated=client.session_negotiated,
1804
+ )
1805
+ )
1806
+ except Exception:
1807
+ if raw_client is not None:
1808
+ raw_client.close()
1809
+ elif existing_lease is None:
1810
+ client.close()
1811
+ raise
1812
+ except Exception:
1813
+ for catalog in catalogs:
1814
+ catalog.client.close()
1815
+ raise
1816
+ return tuple(catalogs)
1817
+
1818
+ def _build_live_client(self, server: ResolvedMcpServer) -> _McpLiveClient:
1819
+ if server.transport == "stdio":
1820
+ return McpStdioClient(
1821
+ server=server,
1822
+ workspace_root=self.workspace_root,
1823
+ runtime_kind=self.runtime_kind,
1824
+ )
1825
+ if server.transport == "http":
1826
+ return McpHttpClient(
1827
+ server=server,
1828
+ workspace_root=self.workspace_root,
1829
+ runtime_kind=self.runtime_kind,
1830
+ )
1831
+ raise ConfigError(
1832
+ f"MCP server '{server.id}' uses unsupported transport '{server.transport}'."
1833
+ )
1834
+
1835
+ def _filter_server_tools(
1836
+ self,
1837
+ *,
1838
+ server: ResolvedMcpServer,
1839
+ raw_tools: tuple[McpListedTool, ...],
1840
+ ) -> tuple[tuple[McpListedTool, ...], tuple[str, ...]]:
1841
+ raw_names = tuple(tool.name for tool in raw_tools)
1842
+ name_map: dict[str, McpListedTool] = {}
1843
+ for tool in raw_tools:
1844
+ name_key = _normalize_tool_name_key(tool.name)
1845
+ if name_key in name_map:
1846
+ raise ConfigError(
1847
+ f"MCP server '{server.id}' reported duplicate tool names that only differ by "
1848
+ f"case: '{name_map[name_key].name}' and '{tool.name}'."
1849
+ )
1850
+ name_map[name_key] = tool
1851
+
1852
+ if server.allowed_tools:
1853
+ missing_allowed = [
1854
+ name
1855
+ for name in server.allowed_tools
1856
+ if _normalize_tool_name_key(name) not in name_map
1857
+ ]
1858
+ if missing_allowed:
1859
+ missing = ", ".join(missing_allowed)
1860
+ raise ConfigError(
1861
+ f"MCP server '{server.id}' did not report configured allowed_tools: {missing}"
1862
+ )
1863
+ if server.denied_tools:
1864
+ missing_denied = [
1865
+ name
1866
+ for name in server.denied_tools
1867
+ if _normalize_tool_name_key(name) not in name_map
1868
+ ]
1869
+ if missing_denied:
1870
+ missing = ", ".join(missing_denied)
1871
+ raise ConfigError(
1872
+ f"MCP server '{server.id}' did not report configured denied_tools: {missing}"
1873
+ )
1874
+
1875
+ allowed_keys = {_normalize_tool_name_key(name) for name in server.allowed_tools}
1876
+ denied_keys = {_normalize_tool_name_key(name) for name in server.denied_tools}
1877
+ filtered: list[McpListedTool] = []
1878
+ for tool in raw_tools:
1879
+ tool_key = _normalize_tool_name_key(tool.name)
1880
+ if allowed_keys and tool_key not in allowed_keys:
1881
+ continue
1882
+ if tool_key in denied_keys:
1883
+ continue
1884
+ filtered.append(tool)
1885
+ return tuple(filtered), raw_names
1886
+
1887
+ def _assign_aliases(
1888
+ self,
1889
+ records: list[_FlatToolRecord],
1890
+ ) -> dict[str, str]:
1891
+ reserved_names = {metadata.name for metadata in iter_builtin_tool_metadata()}
1892
+ groups: dict[str, list[_FlatToolRecord]] = {}
1893
+ for record in records:
1894
+ groups.setdefault(record.base_alias, []).append(record)
1895
+
1896
+ assigned: dict[str, str] = {}
1897
+ used_aliases: set[str] = set(reserved_names)
1898
+ for record in records:
1899
+ base_alias = record.base_alias
1900
+ group = groups[base_alias]
1901
+ alias = base_alias
1902
+ needs_hash = (
1903
+ len(base_alias) > _TOOL_ALIAS_MAX_LEN or len(group) > 1 or alias in used_aliases
1904
+ )
1905
+ if needs_hash:
1906
+ alias = _hashed_alias(base_alias, unique_key=record.unique_key)
1907
+ if alias in used_aliases:
1908
+ alias = _hashed_alias(
1909
+ f"{base_alias}__{len(used_aliases)}",
1910
+ unique_key=record.unique_key,
1911
+ )
1912
+ if alias in used_aliases:
1913
+ raise ConfigError(
1914
+ f"Failed to assign a collision-free MCP tool alias for "
1915
+ f"server '{record.server.id}' tool '{record.tool.name}'."
1916
+ )
1917
+ used_aliases.add(alias)
1918
+ assigned[record.unique_key] = alias
1919
+ return assigned
1920
+
1921
+ def close(self) -> None:
1922
+ with self._lifecycle_lock:
1923
+ if self._closed:
1924
+ return
1925
+ self._closed = True
1926
+ clients = list(self._clients_by_server_id.values())
1927
+ clients.extend(
1928
+ client
1929
+ for client in self._prompt_clients_by_server_id.values()
1930
+ if client not in clients
1931
+ )
1932
+ # Defensive cleanup: leases are the authoritative owners. Include
1933
+ # every lease even if a partially failed catalog build never placed
1934
+ # it in either lookup map.
1935
+ for leases in self._client_leases_by_server_id.values():
1936
+ clients.extend(client for client in leases if client not in clients)
1937
+ self._clients_by_server_id = {}
1938
+ self._prompt_clients_by_server_id = {}
1939
+ self._client_leases_by_server_id = {}
1940
+ self._disabled_server_ids = set(self._active_servers_by_id)
1941
+ for client in clients:
1942
+ client.close()
1943
+
1944
+
1945
+ class ForgeTaskScopedMcpManager:
1946
+ def __init__(
1947
+ self,
1948
+ *,
1949
+ base_manager: McpManager | None,
1950
+ task_scope: ForgeTaskMcpScope | None,
1951
+ resolved_config: ResolvedMcpConfig | None = None,
1952
+ workspace_root: Path | None = None,
1953
+ runtime_kind: RuntimeKind | str | None = None,
1954
+ close_delegate: McpManager | None = None,
1955
+ ) -> None:
1956
+ self._base_manager = base_manager
1957
+ self._close_delegate = close_delegate or base_manager
1958
+ self._task_scope = task_scope
1959
+ if base_manager is not None:
1960
+ self._resolved_config = base_manager.resolved_config
1961
+ self._resolved_servers = base_manager.resolved_servers
1962
+ self._workspace_root = base_manager.workspace_root
1963
+ self._runtime_kind = base_manager.runtime_kind
1964
+ else:
1965
+ if resolved_config is None or workspace_root is None or runtime_kind is None:
1966
+ raise RuntimeError(
1967
+ "ForgeTaskScopedMcpManager requires resolved_config, workspace_root, "
1968
+ "and runtime_kind when no live base_manager is provided."
1969
+ )
1970
+ self._resolved_config = resolved_config
1971
+ self._resolved_servers = tuple(resolved_config.servers)
1972
+ self._workspace_root = workspace_root.resolve()
1973
+ self._runtime_kind = normalize_runtime_kind(runtime_kind)
1974
+ self._filtered_bindings: tuple[_McpManagerToolBinding, ...] | None = None
1975
+ self._scope_metadata: dict[str, Any] | None = None
1976
+ self._closed = False
1977
+
1978
+ @classmethod
1979
+ def without_live_bootstrap(
1980
+ cls,
1981
+ *,
1982
+ resolved_config: ResolvedMcpConfig,
1983
+ workspace_root: Path,
1984
+ runtime_kind: RuntimeKind | str,
1985
+ task_scope: ForgeTaskMcpScope | None,
1986
+ close_delegate: McpManager | None = None,
1987
+ ) -> ForgeTaskScopedMcpManager:
1988
+ return cls(
1989
+ base_manager=None,
1990
+ task_scope=task_scope,
1991
+ resolved_config=resolved_config,
1992
+ workspace_root=workspace_root,
1993
+ runtime_kind=runtime_kind,
1994
+ close_delegate=close_delegate,
1995
+ )
1996
+
1997
+ @property
1998
+ def resolved_config(self) -> ResolvedMcpConfig:
1999
+ return self._resolved_config
2000
+
2001
+ @property
2002
+ def resolved_servers(self) -> tuple[ResolvedMcpServer, ...]:
2003
+ return self._resolved_servers
2004
+
2005
+ @property
2006
+ def active_servers(self) -> tuple[ResolvedMcpServer, ...]:
2007
+ if self._base_manager is None:
2008
+ return ()
2009
+ return self._base_manager.active_servers
2010
+
2011
+ @property
2012
+ def workspace_root(self) -> Path:
2013
+ return self._workspace_root
2014
+
2015
+ @property
2016
+ def runtime_kind(self) -> RuntimeKind:
2017
+ return self._runtime_kind
2018
+
2019
+ @property
2020
+ def closed(self) -> bool:
2021
+ if self._close_delegate is not None:
2022
+ return self._close_delegate.closed
2023
+ return self._closed
2024
+
2025
+ @property
2026
+ def tool_bindings(self) -> tuple[_McpManagerToolBinding, ...]:
2027
+ self._ensure_filtered_bindings()
2028
+ assert self._filtered_bindings is not None
2029
+ return self._filtered_bindings
2030
+
2031
+ def startup_metadata(self) -> dict[str, Any]:
2032
+ if self._base_manager is not None:
2033
+ metadata = copy.deepcopy(self._base_manager.startup_metadata())
2034
+ else:
2035
+ metadata = {
2036
+ "config_present": self._resolved_config.has_any_config,
2037
+ "user_config_present": self._resolved_config.user_config_present,
2038
+ "project_config_present": self._resolved_config.project_config_present,
2039
+ "resolved_server_count": len(self._resolved_servers),
2040
+ "resolved_server_ids": [server.id for server in self._resolved_servers],
2041
+ "active_server_count": 0,
2042
+ "active_server_ids": [],
2043
+ "live_tool_runtime_enabled": False,
2044
+ }
2045
+ metadata.update(self._scope_metadata_payload())
2046
+ return metadata
2047
+
2048
+ def catalog_snapshot_metadata(self) -> dict[str, Any]:
2049
+ if self._base_manager is not None:
2050
+ snapshot = self._base_manager.catalog_snapshot_metadata()
2051
+ else:
2052
+ snapshot = {
2053
+ "catalog_initialized": True,
2054
+ "live_tool_runtime_enabled": False,
2055
+ "active_server_ids": [],
2056
+ "active_server_count": 0,
2057
+ "server_catalogs": [],
2058
+ "exposed_tool_aliases": [],
2059
+ "exposed_tool_names": [],
2060
+ "exposed_tool_count": 0,
2061
+ "snapshotted_resource_count": 0,
2062
+ "resource_tool_names": [],
2063
+ "resource_tool_count": 0,
2064
+ }
2065
+ snapshot["forge_task_mcp_scope"] = self._scope_metadata_payload()
2066
+ self._apply_filtered_tool_snapshot_metadata(snapshot)
2067
+ return snapshot
2068
+
2069
+ def execution_context_summary(self) -> dict[str, Any]:
2070
+ self._ensure_filtered_bindings()
2071
+ summary = {
2072
+ "active_server_ids": [],
2073
+ "servers": [],
2074
+ "task_scope": self._task_scope_payload(),
2075
+ }
2076
+ if self._base_manager is None:
2077
+ return summary
2078
+ assert self._filtered_bindings is not None
2079
+ filtered_tool_names: dict[str, list[str]] = {}
2080
+ for binding in self._filtered_bindings:
2081
+ if not isinstance(binding, McpToolBinding):
2082
+ continue
2083
+ filtered_tool_names.setdefault(binding.server_id, []).append(binding.tool_name)
2084
+ base_summary = self._base_manager.execution_context_summary()
2085
+ filtered_servers: list[dict[str, Any]] = []
2086
+ allow_resources = bool(self._task_scope and self._task_scope.allow_resources)
2087
+ for raw_entry in base_summary.get("servers") or []:
2088
+ if not isinstance(raw_entry, dict):
2089
+ continue
2090
+ server_id = str(raw_entry.get("server_id") or "").strip()
2091
+ if not server_id:
2092
+ continue
2093
+ tool_names = list(filtered_tool_names.get(server_id) or [])
2094
+ resources_available = bool(allow_resources and raw_entry.get("resources_available"))
2095
+ if not tool_names and not resources_available:
2096
+ continue
2097
+ filtered_servers.append(
2098
+ {
2099
+ "server_id": server_id,
2100
+ "tool_names": tool_names,
2101
+ "resources_available": resources_available,
2102
+ }
2103
+ )
2104
+ summary["active_server_ids"] = [entry["server_id"] for entry in filtered_servers]
2105
+ summary["servers"] = filtered_servers
2106
+ return summary
2107
+
2108
+ def close(self) -> None:
2109
+ if self._close_delegate is not None:
2110
+ self._close_delegate.close()
2111
+ return
2112
+ self._closed = True
2113
+
2114
+ def _scope_metadata_payload(self) -> dict[str, Any]:
2115
+ self._ensure_filtered_bindings()
2116
+ assert self._scope_metadata is not None
2117
+ return copy.deepcopy(self._scope_metadata)
2118
+
2119
+ def _task_scope_payload(self) -> dict[str, Any]:
2120
+ return {
2121
+ "present": self._task_scope is not None,
2122
+ "allow_resources": bool(self._task_scope and self._task_scope.allow_resources),
2123
+ "allowed_tools": [
2124
+ {
2125
+ "server_id": item.server_id,
2126
+ "tool_name": item.tool_name,
2127
+ }
2128
+ for item in (self._task_scope.allowed_tools if self._task_scope is not None else ())
2129
+ ],
2130
+ }
2131
+
2132
+ def _ensure_filtered_bindings(self) -> None:
2133
+ if self._filtered_bindings is not None:
2134
+ return
2135
+ if self._base_manager is None:
2136
+ allow_resources = bool(self._task_scope and self._task_scope.allow_resources)
2137
+ allowed_live_tool_count = (
2138
+ len(self._task_scope.allowed_tools) if self._task_scope is not None else 0
2139
+ )
2140
+ self._filtered_bindings = ()
2141
+ self._scope_metadata = {
2142
+ "forge_task_mcp_scope_present": self._task_scope is not None,
2143
+ "forge_task_resources_allowed": allow_resources,
2144
+ "forge_task_allowed_live_tool_count": allowed_live_tool_count,
2145
+ "forge_task_filtered_live_tool_count": 0,
2146
+ "forge_task_filtered_resource_tool_count": 0,
2147
+ "forge_task_filtered_tool_count": 0,
2148
+ "forge_task_live_bootstrap_skipped": True,
2149
+ }
2150
+ return
2151
+ base_bindings = self._base_manager.tool_bindings
2152
+ allowed_live_pairs = {
2153
+ (item.server_id, item.tool_name)
2154
+ for item in (self._task_scope.allowed_tools if self._task_scope is not None else ())
2155
+ }
2156
+ available_live_pairs = {
2157
+ (binding.server_id, binding.tool_name): binding
2158
+ for binding in base_bindings
2159
+ if isinstance(binding, McpToolBinding)
2160
+ }
2161
+ unknown_allowed_pairs = [
2162
+ f"{server_id}/{tool_name}"
2163
+ for server_id, tool_name in allowed_live_pairs
2164
+ if (server_id, tool_name) not in available_live_pairs
2165
+ ]
2166
+ if unknown_allowed_pairs:
2167
+ unknown_label = ", ".join(sorted(unknown_allowed_pairs))
2168
+ raise ConfigError(
2169
+ "Forge task mcp_scope references MCP tools that are not present in the frozen "
2170
+ f"session catalog: {unknown_label}"
2171
+ )
2172
+
2173
+ allow_resources = bool(self._task_scope and self._task_scope.allow_resources)
2174
+ filtered_bindings: list[_McpManagerToolBinding] = []
2175
+ filtered_live_tool_count = 0
2176
+ filtered_resource_tool_count = 0
2177
+ for binding in base_bindings:
2178
+ if isinstance(binding, McpToolBinding):
2179
+ if (binding.server_id, binding.tool_name) not in allowed_live_pairs:
2180
+ continue
2181
+ filtered_live_tool_count += 1
2182
+ filtered_bindings.append(binding)
2183
+ continue
2184
+ if binding.tool_alias in _RESOURCE_TOOL_ALIASES and allow_resources:
2185
+ filtered_resource_tool_count += 1
2186
+ filtered_bindings.append(binding)
2187
+
2188
+ self._filtered_bindings = tuple(filtered_bindings)
2189
+ self._scope_metadata = {
2190
+ "forge_task_mcp_scope_present": self._task_scope is not None,
2191
+ "forge_task_resources_allowed": allow_resources,
2192
+ "forge_task_allowed_live_tool_count": len(allowed_live_pairs),
2193
+ "forge_task_filtered_live_tool_count": filtered_live_tool_count,
2194
+ "forge_task_filtered_resource_tool_count": filtered_resource_tool_count,
2195
+ "forge_task_filtered_tool_count": len(filtered_bindings),
2196
+ "forge_task_live_bootstrap_skipped": False,
2197
+ }
2198
+
2199
+ def _apply_filtered_tool_snapshot_metadata(self, snapshot: dict[str, Any]) -> None:
2200
+ self._ensure_filtered_bindings()
2201
+ filtered_bindings = tuple(self._filtered_bindings or ())
2202
+ snapshot["exposed_tool_aliases"] = [binding.tool_alias for binding in filtered_bindings]
2203
+ snapshot["exposed_tool_names"] = [binding.tool_name for binding in filtered_bindings]
2204
+ snapshot["exposed_tool_count"] = len(filtered_bindings)
2205
+ resource_aliases = [
2206
+ binding.tool_alias
2207
+ for binding in filtered_bindings
2208
+ if isinstance(binding, McpHostToolBinding)
2209
+ and binding.tool_alias in _RESOURCE_TOOL_ALIASES
2210
+ ]
2211
+ snapshot["resource_tool_names"] = resource_aliases
2212
+ snapshot["resource_tool_count"] = len(resource_aliases)
2213
+
2214
+ live_bindings_by_server_id: dict[str, list[McpToolBinding]] = {}
2215
+ for binding in filtered_bindings:
2216
+ if isinstance(binding, McpToolBinding):
2217
+ live_bindings_by_server_id.setdefault(binding.server_id, []).append(binding)
2218
+
2219
+ server_catalogs = snapshot.get("server_catalogs")
2220
+ if not isinstance(server_catalogs, list):
2221
+ return
2222
+ for entry in server_catalogs:
2223
+ if not isinstance(entry, dict):
2224
+ continue
2225
+ server_id = str(entry.get("server_id") or "").strip()
2226
+ live_bindings = live_bindings_by_server_id.get(server_id) or []
2227
+ entry["exposed_tool_aliases"] = [binding.tool_alias for binding in live_bindings]
2228
+ entry["exposed_tool_names"] = [binding.tool_name for binding in live_bindings]
2229
+ entry["exposed_tool_count"] = len(live_bindings)
2230
+
2231
+
2232
+ def create_mcp_manager(
2233
+ *,
2234
+ workspace_root: Path,
2235
+ runtime_kind: RuntimeKind | str,
2236
+ session_id: str | None = None,
2237
+ ) -> McpManager:
2238
+ resolved_kind = normalize_runtime_kind(runtime_kind)
2239
+ resolved_config = load_resolved_mcp_config(
2240
+ workspace_root=workspace_root,
2241
+ )
2242
+ return McpManager(
2243
+ resolved_config=resolved_config,
2244
+ workspace_root=workspace_root,
2245
+ runtime_kind=resolved_kind,
2246
+ session_id=session_id,
2247
+ )
2248
+
2249
+
2250
+ def create_forge_task_scoped_mcp_manager(
2251
+ *,
2252
+ workspace_root: Path,
2253
+ session_id: str | None = None,
2254
+ task_scope: ForgeTaskMcpScope | None,
2255
+ ) -> ForgeTaskScopedMcpManager:
2256
+ resolved_kind = RuntimeKind.FORGE_EXEC
2257
+ if task_scope is None or task_scope.is_empty:
2258
+ resolved_workspace_root = workspace_root.resolve()
2259
+ return ForgeTaskScopedMcpManager.without_live_bootstrap(
2260
+ resolved_config=ResolvedMcpConfig(
2261
+ workspace_root=resolved_workspace_root,
2262
+ user_config_path=user_mcp_config_path(),
2263
+ project_config_path=project_mcp_config_path(resolved_workspace_root),
2264
+ user_config_present=False,
2265
+ project_config_present=False,
2266
+ servers=(),
2267
+ ),
2268
+ workspace_root=resolved_workspace_root,
2269
+ runtime_kind=resolved_kind,
2270
+ task_scope=task_scope,
2271
+ )
2272
+ resolved_config = load_resolved_mcp_config(
2273
+ workspace_root=workspace_root,
2274
+ )
2275
+ manager = McpManager(
2276
+ resolved_config=resolved_config,
2277
+ workspace_root=workspace_root,
2278
+ runtime_kind=resolved_kind,
2279
+ session_id=session_id,
2280
+ )
2281
+ return manager.scope_for_forge_task(task_scope=task_scope)
2282
+
2283
+
2284
+ def build_mcp_execution_context_summary(
2285
+ *,
2286
+ workspace_root: Path,
2287
+ runtime_kind: RuntimeKind | str,
2288
+ ) -> dict[str, Any] | None:
2289
+ try:
2290
+ resolved_kind = normalize_runtime_kind(runtime_kind)
2291
+ resolved_config = load_resolved_mcp_config(workspace_root=workspace_root)
2292
+ active_servers = resolved_config.active_servers_for(resolved_kind)
2293
+ return {
2294
+ "active_server_ids": [server.id for server in active_servers],
2295
+ "servers": [
2296
+ {
2297
+ "server_id": server.id,
2298
+ "tool_names": [name for name in server.allowed_tools if str(name).strip()],
2299
+ "resources_available": resources_mode_enabled(
2300
+ server.resources_mode,
2301
+ runtime_kind=resolved_kind,
2302
+ ),
2303
+ }
2304
+ for server in active_servers
2305
+ ],
2306
+ }
2307
+ except Exception:
2308
+ return None