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,1338 @@
1
+ from __future__ import annotations
2
+
3
+ import codecs
4
+ import queue
5
+ import threading
6
+ import time
7
+ from collections.abc import Iterator
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.parse import SplitResult, urlsplit, urlunsplit
12
+
13
+ import httpx
14
+
15
+ from .. import __version__
16
+ from .errors import (
17
+ McpAuthError,
18
+ McpProtocolError,
19
+ McpRemoteError,
20
+ McpTimeoutError,
21
+ McpTransportError,
22
+ )
23
+ from .jsonrpc import (
24
+ JsonRpcIdGenerator,
25
+ JsonRpcNotification,
26
+ JsonRpcProtocolError,
27
+ JsonRpcRequest,
28
+ JsonRpcResponse,
29
+ build_jsonrpc_notification,
30
+ build_jsonrpc_request,
31
+ build_jsonrpc_result_response,
32
+ encode_jsonrpc_message,
33
+ parse_jsonrpc_line,
34
+ )
35
+ from .models import ResolvedMcpServer
36
+ from .oauth import (
37
+ McpAuthorizationServerMetadata,
38
+ McpOAuthAuthRequiredError,
39
+ McpOAuthDiscoveryError,
40
+ McpOAuthError,
41
+ McpOAuthInsufficientScopeError,
42
+ McpOAuthReLoginRequired,
43
+ McpOAuthTokenExchangeError,
44
+ discover_authorization_server_metadata,
45
+ is_token_expired,
46
+ parse_www_authenticate_bearer_challenge,
47
+ refresh_access_token,
48
+ resolve_requested_scopes,
49
+ )
50
+ from .oauth_store import (
51
+ McpOAuthTokenRecord,
52
+ McpOAuthTokenStoreError,
53
+ delete_oauth_token_record,
54
+ load_oauth_token_record,
55
+ save_oauth_token_record,
56
+ )
57
+ from .server_requests import (
58
+ McpServerRequestContext,
59
+ McpServerRequestHandler,
60
+ McpServerRequestHandlerError,
61
+ McpUnsupportedServerRequestError,
62
+ )
63
+
64
+ _HTTP_ACCEPT_HEADER = "application/json, text/event-stream"
65
+ _USER_AGENT = f"alysis-code/{__version__}"
66
+ _SAFE_ERROR_BODY_CHAR_LIMIT = 1000
67
+ _OPEN_ENDED_SSE_FOLLOW_UP_QUIET_WINDOW_S = 0.1
68
+ _OPEN_ENDED_SSE_READER_JOIN_TIMEOUT_S = 1.0
69
+
70
+
71
+ class McpHttpTransportError(McpTransportError):
72
+ pass
73
+
74
+
75
+ class McpHttpTransportTimeoutError(McpHttpTransportError, McpTimeoutError):
76
+ pass
77
+
78
+
79
+ class McpHttpTransportProtocolError(McpHttpTransportError, McpProtocolError):
80
+ error_code = "mcp_http_transport_protocol_error"
81
+ retryable = False
82
+
83
+
84
+ class McpHttpTransportRemoteError(McpHttpTransportError, McpRemoteError):
85
+ error_code = "mcp_http_transport_remote_error"
86
+ retryable = False
87
+
88
+
89
+ class McpHttpTransportAuthRequiredError(McpHttpTransportError, McpAuthError):
90
+ error_code = "mcp_http_transport_auth_required"
91
+ retryable = False
92
+
93
+
94
+ class McpHttpTransportSessionExpiredError(McpHttpTransportError):
95
+ error_code = "mcp_http_transport_session_expired"
96
+ retryable = False
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class _SseEvent:
101
+ event: str | None
102
+ event_id: str | None
103
+ retry_ms: int | None
104
+ data: str
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class _SseReaderItem:
109
+ kind: str
110
+ activity_sequence: int
111
+ activity_monotonic: float
112
+ event: _SseEvent | None = None
113
+ error: BaseException | None = None
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class _SseReaderSnapshot:
118
+ activity_sequence: int
119
+ activity_monotonic: float
120
+ event_in_progress: bool
121
+
122
+
123
+ class _SseReaderState:
124
+ def __init__(self) -> None:
125
+ self._lock = threading.Lock()
126
+ self._latest_activity_sequence = 0
127
+ self._latest_activity_monotonic = 0.0
128
+ self._event_in_progress = False
129
+
130
+ def record_activity(self, *, event_in_progress: bool) -> tuple[int, float]:
131
+ with self._lock:
132
+ self._latest_activity_sequence += 1
133
+ self._latest_activity_monotonic = time.monotonic()
134
+ self._event_in_progress = event_in_progress
135
+ return self._latest_activity_sequence, self._latest_activity_monotonic
136
+
137
+ def set_event_in_progress(self, *, event_in_progress: bool) -> None:
138
+ with self._lock:
139
+ self._event_in_progress = event_in_progress
140
+
141
+ def snapshot(self) -> _SseReaderSnapshot:
142
+ with self._lock:
143
+ return _SseReaderSnapshot(
144
+ activity_sequence=self._latest_activity_sequence,
145
+ activity_monotonic=self._latest_activity_monotonic,
146
+ event_in_progress=self._event_in_progress,
147
+ )
148
+
149
+
150
+ class _SseEventParser:
151
+ def __init__(self) -> None:
152
+ self._event_name: str | None = None
153
+ self._event_id: str | None = None
154
+ self._retry_ms: int | None = None
155
+ self._data_lines: list[str] = []
156
+ self._saw_event_field = False
157
+
158
+ def event_in_progress(self, *, has_partial_line: bool) -> bool:
159
+ return (
160
+ has_partial_line
161
+ or self._saw_event_field
162
+ or self._event_name is not None
163
+ or self._event_id is not None
164
+ or self._retry_ms is not None
165
+ or bool(self._data_lines)
166
+ )
167
+
168
+ def _reset(self) -> None:
169
+ self._event_name = None
170
+ self._event_id = None
171
+ self._retry_ms = None
172
+ self._data_lines = []
173
+ self._saw_event_field = False
174
+
175
+ def _flush_event(self) -> _SseEvent | None:
176
+ if (
177
+ not self._saw_event_field
178
+ and self._event_name is None
179
+ and self._event_id is None
180
+ and self._retry_ms is None
181
+ and not self._data_lines
182
+ ):
183
+ return None
184
+ if (
185
+ not self._data_lines
186
+ and self._event_name is None
187
+ and self._event_id is None
188
+ and self._retry_ms is None
189
+ ):
190
+ self._reset()
191
+ return None
192
+ event = _SseEvent(
193
+ event=self._event_name,
194
+ event_id=self._event_id,
195
+ retry_ms=self._retry_ms,
196
+ data="\n".join(self._data_lines),
197
+ )
198
+ self._reset()
199
+ return event
200
+
201
+ def feed_line(self, line: str) -> _SseEvent | None:
202
+ if line == "":
203
+ return self._flush_event()
204
+ if line.startswith(":"):
205
+ return None
206
+ self._saw_event_field = True
207
+ field_name, sep, field_value = line.partition(":")
208
+ if not sep:
209
+ field_value = ""
210
+ elif field_value.startswith(" "):
211
+ field_value = field_value[1:]
212
+ if field_name == "data":
213
+ self._data_lines.append(field_value)
214
+ return None
215
+ if field_name == "event":
216
+ self._event_name = field_value
217
+ return None
218
+ if field_name == "id":
219
+ self._event_id = field_value
220
+ return None
221
+ if field_name == "retry" and field_value.isdigit():
222
+ self._retry_ms = int(field_value)
223
+ return None
224
+
225
+ def finish_eof(self) -> _SseEvent | None:
226
+ return self._flush_event()
227
+
228
+
229
+ def _extract_sse_line(text_buffer: str, *, eof: bool = False) -> tuple[str | None, str]:
230
+ for index, character in enumerate(text_buffer):
231
+ if character == "\n":
232
+ line = text_buffer[:index]
233
+ if line.endswith("\r"):
234
+ line = line[:-1]
235
+ return line, text_buffer[index + 1 :]
236
+ if character == "\r":
237
+ if index + 1 < len(text_buffer) and text_buffer[index + 1] == "\n":
238
+ return text_buffer[:index], text_buffer[index + 2 :]
239
+ if index + 1 == len(text_buffer) and not eof:
240
+ return None, text_buffer
241
+ return text_buffer[:index], text_buffer[index + 1 :]
242
+ return None, text_buffer
243
+
244
+
245
+ def _redacted_url(value: str) -> str:
246
+ split = urlsplit(value)
247
+ hostname = split.hostname or ""
248
+ if split.port is None:
249
+ netloc = hostname
250
+ else:
251
+ netloc = f"{hostname}:{split.port}"
252
+ redacted = SplitResult(
253
+ scheme=split.scheme,
254
+ netloc=netloc,
255
+ path=split.path or "/",
256
+ query="",
257
+ fragment="",
258
+ )
259
+ return urlunsplit(redacted)
260
+
261
+
262
+ def _normalized_content_type(raw_value: str | None) -> str:
263
+ value = str(raw_value or "").strip().lower()
264
+ if not value:
265
+ return ""
266
+ return value.split(";", 1)[0].strip()
267
+
268
+
269
+ def _response_body_snippet(response: httpx.Response) -> str:
270
+ try:
271
+ response.read()
272
+ except Exception:
273
+ return "<unable to read response body>"
274
+ try:
275
+ body = response.text
276
+ except Exception:
277
+ return "<unable to decode response body>"
278
+ if len(body) > _SAFE_ERROR_BODY_CHAR_LIMIT:
279
+ body = body[:_SAFE_ERROR_BODY_CHAR_LIMIT] + "...(truncated)"
280
+ return body
281
+
282
+
283
+ def _format_http_error(
284
+ *,
285
+ server: ResolvedMcpServer,
286
+ message: str,
287
+ ) -> str:
288
+ return f"MCP HTTP server '{server.id}' (url '{_redacted_url(server.url or '')}'): {message}"
289
+
290
+
291
+ def _iter_sse_events(response: httpx.Response) -> Iterator[_SseEvent]:
292
+ parser = _SseEventParser()
293
+ try:
294
+ raw_lines = response.iter_lines()
295
+ except Exception as exc: # noqa: BLE001
296
+ raise McpHttpTransportProtocolError("failed to read HTTP event stream") from exc
297
+
298
+ for raw_line in raw_lines:
299
+ if isinstance(raw_line, bytes):
300
+ line = raw_line.decode("utf-8")
301
+ else:
302
+ line = str(raw_line)
303
+ event = parser.feed_line(line)
304
+ if event is not None:
305
+ yield event
306
+ event = parser.finish_eof()
307
+ if event is not None:
308
+ yield event
309
+
310
+
311
+ class McpHttpTransport:
312
+ def __init__(
313
+ self,
314
+ *,
315
+ server: ResolvedMcpServer,
316
+ workspace_root: Path,
317
+ client: httpx.Client | None = None,
318
+ server_request_context: McpServerRequestContext | None = None,
319
+ server_request_handler: McpServerRequestHandler | None = None,
320
+ ) -> None:
321
+ if server.transport != "http":
322
+ raise McpHttpTransportError(f"MCP server '{server.id}' is not an HTTP transport.")
323
+ if server.url is None:
324
+ raise McpHttpTransportError(f"MCP server '{server.id}' is missing an HTTP URL.")
325
+ self.server = server
326
+ self.workspace_root = workspace_root.resolve()
327
+ self._server_request_context = server_request_context
328
+ self._server_request_handler = server_request_handler
329
+ self._id_generator = JsonRpcIdGenerator()
330
+ self._notifications: queue.Queue[JsonRpcNotification] = queue.Queue()
331
+ self._client = client or httpx.Client(follow_redirects=False)
332
+ self._owns_client = client is None
333
+ self._closed = False
334
+ self._session_id: str | None = None
335
+ self._negotiated_protocol_version: str | None = None
336
+ self._oauth_metadata: McpAuthorizationServerMetadata | None = None
337
+
338
+ @property
339
+ def closed(self) -> bool:
340
+ return self._closed
341
+
342
+ @property
343
+ def session_negotiated(self) -> bool:
344
+ return bool(self._session_id)
345
+
346
+ @property
347
+ def negotiated_protocol_version(self) -> str | None:
348
+ return self._negotiated_protocol_version
349
+
350
+ def stderr_tail(self) -> str:
351
+ return ""
352
+
353
+ def reset_session_state(self) -> None:
354
+ self._session_id = None
355
+ self._negotiated_protocol_version = None
356
+
357
+ def set_negotiated_protocol_version(self, protocol_version: str | None) -> None:
358
+ cleaned = str(protocol_version or "").strip()
359
+ self._negotiated_protocol_version = cleaned or None
360
+
361
+ def drain_notifications(self) -> tuple[JsonRpcNotification, ...]:
362
+ notifications: list[JsonRpcNotification] = []
363
+ while True:
364
+ try:
365
+ notifications.append(self._notifications.get_nowait())
366
+ except queue.Empty:
367
+ break
368
+ return tuple(notifications)
369
+
370
+ def _build_headers(self, *, access_token: str | None = None) -> dict[str, str]:
371
+ headers = dict(self.server.headers)
372
+ headers["Accept"] = _HTTP_ACCEPT_HEADER
373
+ headers["Content-Type"] = "application/json"
374
+ headers["User-Agent"] = _USER_AGENT
375
+ if access_token is not None:
376
+ headers["Authorization"] = f"Bearer {access_token}"
377
+ if self._session_id:
378
+ headers["MCP-Session-Id"] = self._session_id
379
+ if self._negotiated_protocol_version:
380
+ headers["MCP-Protocol-Version"] = self._negotiated_protocol_version
381
+ return headers
382
+
383
+ def _build_error(
384
+ self,
385
+ message: str,
386
+ *,
387
+ exc_type: type[McpHttpTransportError] = McpHttpTransportError,
388
+ ) -> McpHttpTransportError:
389
+ return exc_type(_format_http_error(server=self.server, message=message))
390
+
391
+ def _oauth_enabled(self) -> bool:
392
+ return self.server.oauth is not None
393
+
394
+ def _oauth_authorization_server_hint(self) -> str | None:
395
+ if self._oauth_metadata is not None:
396
+ return self._oauth_metadata.issuer or self._oauth_metadata.token_endpoint
397
+ if self.server.oauth is not None and self.server.oauth.authorization_server_url:
398
+ return self.server.oauth.authorization_server_url
399
+ return None
400
+
401
+ def _clear_stored_oauth_tokens(self) -> None:
402
+ try:
403
+ delete_oauth_token_record(self.server.id)
404
+ except McpOAuthTokenStoreError:
405
+ return
406
+
407
+ def _discover_oauth_metadata(
408
+ self,
409
+ *,
410
+ unauthorized_response: httpx.Response | None = None,
411
+ ) -> McpAuthorizationServerMetadata:
412
+ if self._oauth_metadata is not None:
413
+ return self._oauth_metadata
414
+ oauth_config = self.server.oauth
415
+ if oauth_config is None:
416
+ raise self._build_error("OAuth is not configured for this HTTP MCP server.")
417
+ metadata = discover_authorization_server_metadata(
418
+ server_id=self.server.id,
419
+ resource_server_url=self.server.url or "",
420
+ unauthorized_response=unauthorized_response,
421
+ authorization_server_url=oauth_config.authorization_server_url,
422
+ timeout_s=min(self.server.call_timeout_s, 10.0),
423
+ client=self._client,
424
+ )
425
+ self._oauth_metadata = metadata
426
+ return metadata
427
+
428
+ def _refresh_oauth_token_record(
429
+ self,
430
+ *,
431
+ current_record: McpOAuthTokenRecord,
432
+ unauthorized_response: httpx.Response | None = None,
433
+ ) -> McpOAuthTokenRecord:
434
+ metadata: McpAuthorizationServerMetadata | None = None
435
+ try:
436
+ metadata = self._discover_oauth_metadata(unauthorized_response=unauthorized_response)
437
+ oauth_config = self.server.oauth
438
+ if oauth_config is None:
439
+ raise McpOAuthReLoginRequired(
440
+ "OAuth configuration is missing for this HTTP MCP server.",
441
+ server_id=self.server.id,
442
+ authorization_server_url=self._oauth_authorization_server_hint(),
443
+ )
444
+ resolved_scopes = resolve_requested_scopes(
445
+ configured_scopes=oauth_config.scopes,
446
+ challenge_scope=None,
447
+ metadata_scopes_supported=None,
448
+ existing_granted_scopes=current_record.granted_scopes,
449
+ purpose="refresh",
450
+ )
451
+ refreshed = refresh_access_token(
452
+ server_id=self.server.id,
453
+ authorization_server_metadata=metadata,
454
+ resource_server_url=self.server.url or "",
455
+ client_id=oauth_config.client_id,
456
+ refresh_token=current_record.refresh_token or "",
457
+ requested_scopes=resolved_scopes,
458
+ existing_granted_scopes=current_record.granted_scopes,
459
+ timeout_s=min(self.server.call_timeout_s, 10.0),
460
+ client=self._client,
461
+ )
462
+ save_oauth_token_record(self.server.id, refreshed)
463
+ return refreshed
464
+ except (McpOAuthDiscoveryError, McpOAuthTokenExchangeError, McpOAuthTokenStoreError) as exc:
465
+ self._clear_stored_oauth_tokens()
466
+ raise McpOAuthReLoginRequired(
467
+ f"stored OAuth credentials could not be refreshed. Run 'alysis mcp auth login {self.server.id}' again.",
468
+ server_id=self.server.id,
469
+ authorization_server_url=(
470
+ metadata.token_endpoint
471
+ if metadata is not None
472
+ else self._oauth_authorization_server_hint()
473
+ ),
474
+ ) from exc
475
+
476
+ def _resolve_oauth_token_record(self, *, method: str) -> McpOAuthTokenRecord:
477
+ try:
478
+ record = load_oauth_token_record(self.server.id)
479
+ except McpOAuthTokenStoreError as exc:
480
+ raise McpOAuthReLoginRequired(
481
+ f"failed to read stored OAuth credentials before HTTP MCP '{method}'. Run 'alysis mcp auth login {self.server.id}' again.",
482
+ server_id=self.server.id,
483
+ authorization_server_url=self._oauth_authorization_server_hint(),
484
+ ) from exc
485
+ if record is None:
486
+ raise McpOAuthAuthRequiredError(
487
+ f"OAuth login is required before HTTP MCP '{method}'. Run 'alysis mcp auth login {self.server.id}'.",
488
+ server_id=self.server.id,
489
+ authorization_server_url=self._oauth_authorization_server_hint(),
490
+ )
491
+ if not is_token_expired(record):
492
+ return record
493
+ if not record.refresh_token:
494
+ self._clear_stored_oauth_tokens()
495
+ raise McpOAuthReLoginRequired(
496
+ f"stored OAuth credentials expired before HTTP MCP '{method}' and cannot be refreshed. Run 'alysis mcp auth login {self.server.id}' again.",
497
+ server_id=self.server.id,
498
+ authorization_server_url=self._oauth_authorization_server_hint(),
499
+ )
500
+ return self._refresh_oauth_token_record(current_record=record)
501
+
502
+ def _oauth_error_signal(self, response: httpx.Response) -> str | None:
503
+ params = parse_www_authenticate_bearer_challenge(response.headers.get("WWW-Authenticate"))
504
+ challenge_error = str(params.get("error") or "").strip()
505
+ if challenge_error:
506
+ return challenge_error
507
+ try:
508
+ payload = response.json()
509
+ except Exception:
510
+ return None
511
+ if isinstance(payload, dict):
512
+ body_error = payload.get("error")
513
+ if isinstance(body_error, str):
514
+ cleaned = body_error.strip()
515
+ if cleaned:
516
+ return cleaned
517
+ return None
518
+
519
+ def _oauth_scope_hint(self, response: httpx.Response) -> str:
520
+ params = parse_www_authenticate_bearer_challenge(response.headers.get("WWW-Authenticate"))
521
+ challenge_scope = str(params.get("scope") or "").strip()
522
+ resolved_scopes = resolve_requested_scopes(
523
+ configured_scopes=None,
524
+ challenge_scope=challenge_scope,
525
+ metadata_scopes_supported=None,
526
+ existing_granted_scopes=None,
527
+ purpose="login",
528
+ )
529
+ if not resolved_scopes:
530
+ return ""
531
+ return f" Server requested scopes: {', '.join(resolved_scopes)}."
532
+
533
+ def _handle_oauth_http_status(
534
+ self,
535
+ response: httpx.Response,
536
+ *,
537
+ method: str,
538
+ oauth_record: McpOAuthTokenRecord | None,
539
+ allow_request_replay: bool,
540
+ oauth_retry_attempted: bool,
541
+ ) -> bool:
542
+ if (
543
+ response.status_code == 403
544
+ and self._oauth_error_signal(response) == "insufficient_scope"
545
+ ):
546
+ raise McpOAuthInsufficientScopeError(
547
+ f"HTTP MCP '{method}' failed with insufficient_scope.",
548
+ server_id=self.server.id,
549
+ authorization_server_url=self._oauth_authorization_server_hint(),
550
+ )
551
+ if response.status_code != 401:
552
+ return False
553
+ scope_hint = self._oauth_scope_hint(response)
554
+ if oauth_record is None:
555
+ raise McpOAuthAuthRequiredError(
556
+ f"OAuth login is required before HTTP MCP '{method}'.{scope_hint} Run 'alysis mcp auth login {self.server.id}'.",
557
+ server_id=self.server.id,
558
+ authorization_server_url=self._oauth_authorization_server_hint(),
559
+ )
560
+ if not oauth_record.refresh_token:
561
+ self._clear_stored_oauth_tokens()
562
+ raise McpOAuthReLoginRequired(
563
+ f"HTTP MCP '{method}' returned 401 and stored OAuth credentials cannot be refreshed.{scope_hint} Run 'alysis mcp auth login {self.server.id}' again.",
564
+ server_id=self.server.id,
565
+ authorization_server_url=self._oauth_authorization_server_hint(),
566
+ )
567
+ if allow_request_replay:
568
+ if oauth_retry_attempted:
569
+ self._clear_stored_oauth_tokens()
570
+ raise McpOAuthReLoginRequired(
571
+ f"HTTP MCP '{method}' still returned 401 after one OAuth refresh and retry.{scope_hint} Run 'alysis mcp auth login {self.server.id}' again.",
572
+ server_id=self.server.id,
573
+ authorization_server_url=self._oauth_authorization_server_hint(),
574
+ )
575
+ self._refresh_oauth_token_record(
576
+ current_record=oauth_record,
577
+ unauthorized_response=response,
578
+ )
579
+ return True
580
+ self._refresh_oauth_token_record(
581
+ current_record=oauth_record,
582
+ unauthorized_response=response,
583
+ )
584
+ raise McpOAuthAuthRequiredError(
585
+ f"HTTP MCP '{method}' returned 401.{scope_hint} OAuth credentials were refreshed, but the active request was not automatically retried because it may be side-effectful. Retry the request manually.",
586
+ server_id=self.server.id,
587
+ authorization_server_url=self._oauth_authorization_server_hint(),
588
+ )
589
+
590
+ def _capture_session_headers(self, response: httpx.Response, *, method: str) -> None:
591
+ if method != "initialize":
592
+ return
593
+ session_id = str(response.headers.get("MCP-Session-Id") or "").strip()
594
+ if session_id:
595
+ self._session_id = session_id
596
+
597
+ def _raise_for_http_status(
598
+ self,
599
+ response: httpx.Response,
600
+ *,
601
+ method: str,
602
+ had_session_id: bool,
603
+ ) -> None:
604
+ status_code = response.status_code
605
+ if 200 <= status_code < 300:
606
+ return
607
+ body_snippet = _response_body_snippet(response).strip()
608
+ body_suffix = f": {body_snippet}" if body_snippet else ""
609
+ if status_code == 401:
610
+ raise self._build_error(
611
+ "server requires HTTP MCP authentication. Configure static headers for this "
612
+ "server, or use 'alysis mcp auth login <server_id>' for OAuth-enabled "
613
+ f"HTTP MCP servers{body_suffix}",
614
+ exc_type=McpHttpTransportAuthRequiredError,
615
+ )
616
+ if status_code == 404 and had_session_id:
617
+ raise self._build_error(
618
+ f"HTTP MCP session expired during '{method}'",
619
+ exc_type=McpHttpTransportSessionExpiredError,
620
+ )
621
+ if method == "initialize" and not had_session_id and status_code in {400, 404, 405}:
622
+ raise self._build_error(
623
+ "initialize POST was rejected by the HTTP MCP server. The server may require "
624
+ "legacy HTTP+SSE fallback behavior, which is intentionally out of scope in this "
625
+ f"slice{body_suffix}",
626
+ exc_type=McpHttpTransportProtocolError,
627
+ )
628
+ if status_code >= 500:
629
+ raise self._build_error(
630
+ f"HTTP MCP server returned {status_code}{body_suffix}",
631
+ exc_type=McpHttpTransportRemoteError,
632
+ )
633
+ raise self._build_error(
634
+ f"HTTP MCP request '{method}' failed with status {status_code}{body_suffix}",
635
+ exc_type=McpHttpTransportProtocolError,
636
+ )
637
+
638
+ def _process_jsonrpc_message(
639
+ self,
640
+ message: JsonRpcNotification | JsonRpcRequest | JsonRpcResponse,
641
+ *,
642
+ method: str,
643
+ expected_response_id: int | str | None,
644
+ response_received: bool,
645
+ server_request_timeout_s: float | None = None,
646
+ allow_server_requests: bool = False,
647
+ ) -> tuple[JsonRpcResponse | None, bool]:
648
+ if isinstance(message, JsonRpcNotification):
649
+ if response_received:
650
+ raise self._build_error(
651
+ "received an additional JSON-RPC message after the matching HTTP response "
652
+ f"for '{method}'",
653
+ exc_type=McpHttpTransportProtocolError,
654
+ )
655
+ self._notifications.put(message)
656
+ return None, response_received
657
+ if isinstance(message, JsonRpcRequest):
658
+ if not allow_server_requests:
659
+ if response_received:
660
+ raise self._build_error(
661
+ f"received unsupported server-initiated request '{message.method}' during "
662
+ f"HTTP MCP '{method}' follow-up",
663
+ exc_type=McpHttpTransportProtocolError,
664
+ )
665
+ raise self._build_error(
666
+ f"received unsupported server-initiated request '{message.method}' during "
667
+ f"HTTP MCP '{method}'",
668
+ exc_type=McpHttpTransportProtocolError,
669
+ )
670
+ self._handle_server_request_message(
671
+ message,
672
+ method=method,
673
+ timeout_s=server_request_timeout_s,
674
+ )
675
+ return None, response_received
676
+ if expected_response_id is None:
677
+ raise self._build_error(
678
+ f"received an unexpected JSON-RPC response while handling HTTP notification "
679
+ f"'{method}'",
680
+ exc_type=McpHttpTransportProtocolError,
681
+ )
682
+ if response_received:
683
+ raise self._build_error(
684
+ f"received multiple JSON-RPC responses for one HTTP MCP '{method}' request",
685
+ exc_type=McpHttpTransportProtocolError,
686
+ )
687
+ if message.id != expected_response_id:
688
+ raise self._build_error(
689
+ f"received JSON-RPC response id {message.id!r} while waiting for "
690
+ f"{expected_response_id!r} during HTTP MCP '{method}'",
691
+ exc_type=McpHttpTransportProtocolError,
692
+ )
693
+ return message, True
694
+
695
+ def _post_server_request_response(
696
+ self,
697
+ *,
698
+ payload: dict[str, Any],
699
+ request_method: str,
700
+ timeout_s: float,
701
+ ) -> None:
702
+ try:
703
+ self._send_jsonrpc_message(
704
+ payload=payload,
705
+ method=f"{request_method} response",
706
+ timeout_s=timeout_s,
707
+ expected_response_id=None,
708
+ )
709
+ except McpHttpTransportError as exc:
710
+ raise self._build_error(
711
+ f"failed to POST JSON-RPC response for server-initiated request '{request_method}'",
712
+ exc_type=type(exc),
713
+ ) from exc
714
+
715
+ def _handle_server_request_message(
716
+ self,
717
+ message: JsonRpcRequest,
718
+ *,
719
+ method: str,
720
+ timeout_s: float | None,
721
+ ) -> None:
722
+ if self._server_request_handler is None or self._server_request_context is None:
723
+ raise self._build_error(
724
+ f"received unsupported server-initiated request '{message.method}' during "
725
+ f"HTTP MCP '{method}'",
726
+ exc_type=McpHttpTransportProtocolError,
727
+ )
728
+ request_timeout_s = timeout_s or self.server.call_timeout_s
729
+ try:
730
+ result = self._server_request_handler.handle_request(
731
+ context=self._server_request_context,
732
+ method=message.method,
733
+ request_id=message.id,
734
+ params=message.params,
735
+ )
736
+ except McpUnsupportedServerRequestError:
737
+ raise self._build_error(
738
+ f"received unsupported server-initiated request '{message.method}' during "
739
+ f"HTTP MCP '{method}'",
740
+ exc_type=McpHttpTransportProtocolError,
741
+ ) from None
742
+ except McpServerRequestHandlerError as exc:
743
+ self._post_server_request_response(
744
+ payload={
745
+ "jsonrpc": "2.0",
746
+ "id": message.id,
747
+ "error": {
748
+ "code": exc.code,
749
+ "message": exc.message,
750
+ },
751
+ },
752
+ request_method=message.method,
753
+ timeout_s=request_timeout_s,
754
+ )
755
+ raise self._build_error(
756
+ f"failed to handle server-initiated request '{message.method}' during "
757
+ f"HTTP MCP '{method}'",
758
+ exc_type=McpHttpTransportProtocolError,
759
+ ) from exc
760
+ except Exception as exc:
761
+ self._post_server_request_response(
762
+ payload={
763
+ "jsonrpc": "2.0",
764
+ "id": message.id,
765
+ "error": {
766
+ "code": -32603,
767
+ "message": (
768
+ "Internal MCP host error while handling server-initiated request."
769
+ ),
770
+ },
771
+ },
772
+ request_method=message.method,
773
+ timeout_s=request_timeout_s,
774
+ )
775
+ raise self._build_error(
776
+ f"failed to handle server-initiated request '{message.method}' during "
777
+ f"HTTP MCP '{method}'",
778
+ exc_type=McpHttpTransportProtocolError,
779
+ ) from exc
780
+ self._post_server_request_response(
781
+ payload=build_jsonrpc_result_response(
782
+ request_id=message.id,
783
+ result=result,
784
+ ),
785
+ request_method=message.method,
786
+ timeout_s=request_timeout_s,
787
+ )
788
+
789
+ def _parse_single_jsonrpc_payload(
790
+ self,
791
+ raw_text: str,
792
+ *,
793
+ method: str,
794
+ ) -> JsonRpcNotification | JsonRpcRequest | JsonRpcResponse:
795
+ try:
796
+ messages = parse_jsonrpc_line(raw_text)
797
+ except JsonRpcProtocolError as exc:
798
+ raise self._build_error(
799
+ f"malformed JSON-RPC payload during HTTP MCP '{method}': {exc}",
800
+ exc_type=McpHttpTransportProtocolError,
801
+ ) from exc
802
+ if len(messages) != 1:
803
+ raise self._build_error(
804
+ f"JSON-RPC batch payloads are not supported for HTTP MCP '{method}'",
805
+ exc_type=McpHttpTransportProtocolError,
806
+ )
807
+ return messages[0]
808
+
809
+ def _handle_application_json(
810
+ self,
811
+ response: httpx.Response,
812
+ *,
813
+ method: str,
814
+ expected_response_id: int | str | None,
815
+ ) -> JsonRpcResponse | None:
816
+ try:
817
+ response.read()
818
+ raw_text = response.text
819
+ except Exception as exc: # noqa: BLE001
820
+ raise self._build_error(
821
+ f"failed to decode HTTP response body for '{method}'",
822
+ exc_type=McpHttpTransportProtocolError,
823
+ ) from exc
824
+ if not raw_text.strip():
825
+ if expected_response_id is None:
826
+ return None
827
+ raise self._build_error(
828
+ f"HTTP MCP '{method}' returned an empty JSON body",
829
+ exc_type=McpHttpTransportProtocolError,
830
+ )
831
+ message = self._parse_single_jsonrpc_payload(raw_text, method=method)
832
+ parsed_response, response_received = self._process_jsonrpc_message(
833
+ message,
834
+ method=method,
835
+ expected_response_id=expected_response_id,
836
+ response_received=False,
837
+ server_request_timeout_s=None,
838
+ allow_server_requests=False,
839
+ )
840
+ if expected_response_id is None:
841
+ return None
842
+ if not response_received or parsed_response is None:
843
+ raise self._build_error(
844
+ f"HTTP MCP '{method}' completed without the matching JSON-RPC response",
845
+ exc_type=McpHttpTransportProtocolError,
846
+ )
847
+ return parsed_response
848
+
849
+ def _raise_sse_reader_error(
850
+ self,
851
+ *,
852
+ method: str,
853
+ timeout_s: float,
854
+ error: BaseException,
855
+ ) -> None:
856
+ if isinstance(error, McpHttpTransportError):
857
+ raise error
858
+ if isinstance(error, httpx.TimeoutException):
859
+ raise self._build_error(
860
+ f"HTTP MCP '{method}' timed out after {timeout_s:.3f}s",
861
+ exc_type=McpHttpTransportTimeoutError,
862
+ ) from error
863
+ if isinstance(error, httpx.HTTPError):
864
+ raise self._build_error(
865
+ f"HTTP MCP '{method}' event stream failed: {error}",
866
+ exc_type=McpHttpTransportError,
867
+ ) from error
868
+ raise self._build_error(
869
+ f"failed to read HTTP event stream for '{method}': {error}",
870
+ exc_type=McpHttpTransportError,
871
+ ) from error
872
+
873
+ def _start_sse_reader(
874
+ self,
875
+ response: httpx.Response,
876
+ ) -> tuple[queue.Queue[_SseReaderItem], threading.Thread, _SseReaderState]:
877
+ reader_items: queue.Queue[_SseReaderItem] = queue.Queue()
878
+ reader_state = _SseReaderState()
879
+
880
+ def _reader() -> None:
881
+ parser = _SseEventParser()
882
+ decoder = codecs.getincrementaldecoder("utf-8")()
883
+ text_buffer = ""
884
+
885
+ def _queue_event(
886
+ *,
887
+ event: _SseEvent,
888
+ activity_sequence: int,
889
+ activity_monotonic: float,
890
+ ) -> None:
891
+ reader_items.put(
892
+ _SseReaderItem(
893
+ kind="event",
894
+ activity_sequence=activity_sequence,
895
+ activity_monotonic=activity_monotonic,
896
+ event=event,
897
+ )
898
+ )
899
+
900
+ try:
901
+ try:
902
+ body_chunks = response.iter_bytes()
903
+ except Exception as exc: # noqa: BLE001
904
+ raise McpHttpTransportProtocolError("failed to read HTTP event stream") from exc
905
+ for body_chunk in body_chunks:
906
+ if not body_chunk:
907
+ continue
908
+ activity_sequence, activity_monotonic = reader_state.record_activity(
909
+ event_in_progress=True
910
+ )
911
+ try:
912
+ # Keep HTTP content decoding enabled for POST-SSE so compressed
913
+ # event streams from servers or intermediaries still work.
914
+ text_buffer += decoder.decode(body_chunk)
915
+ except UnicodeDecodeError as exc:
916
+ raise McpHttpTransportProtocolError(
917
+ "failed to decode HTTP event stream as UTF-8"
918
+ ) from exc
919
+ while True:
920
+ line, text_buffer = _extract_sse_line(text_buffer)
921
+ if line is None:
922
+ break
923
+ event = parser.feed_line(line)
924
+ reader_state.set_event_in_progress(
925
+ event_in_progress=parser.event_in_progress(
926
+ has_partial_line=bool(text_buffer)
927
+ )
928
+ )
929
+ if event is not None:
930
+ _queue_event(
931
+ event=event,
932
+ activity_sequence=activity_sequence,
933
+ activity_monotonic=activity_monotonic,
934
+ )
935
+ reader_state.set_event_in_progress(
936
+ event_in_progress=parser.event_in_progress(
937
+ has_partial_line=bool(text_buffer)
938
+ )
939
+ )
940
+ try:
941
+ text_buffer += decoder.decode(b"", final=True)
942
+ except UnicodeDecodeError as exc:
943
+ raise McpHttpTransportProtocolError(
944
+ "failed to decode HTTP event stream as UTF-8"
945
+ ) from exc
946
+ eof_activity_sequence = 0
947
+ eof_activity_monotonic = 0.0
948
+ if text_buffer:
949
+ eof_activity_sequence, eof_activity_monotonic = reader_state.record_activity(
950
+ event_in_progress=True
951
+ )
952
+ while True:
953
+ line, text_buffer = _extract_sse_line(text_buffer, eof=True)
954
+ if line is None:
955
+ break
956
+ event = parser.feed_line(line)
957
+ if event is not None:
958
+ _queue_event(
959
+ event=event,
960
+ activity_sequence=eof_activity_sequence,
961
+ activity_monotonic=eof_activity_monotonic,
962
+ )
963
+ if text_buffer:
964
+ event = parser.feed_line(text_buffer)
965
+ text_buffer = ""
966
+ if event is not None:
967
+ _queue_event(
968
+ event=event,
969
+ activity_sequence=eof_activity_sequence,
970
+ activity_monotonic=eof_activity_monotonic,
971
+ )
972
+ final_event = parser.finish_eof()
973
+ if final_event is not None:
974
+ if eof_activity_sequence == 0:
975
+ eof_activity_sequence, eof_activity_monotonic = (
976
+ reader_state.record_activity(event_in_progress=False)
977
+ )
978
+ else:
979
+ reader_state.set_event_in_progress(event_in_progress=False)
980
+ _queue_event(
981
+ event=final_event,
982
+ activity_sequence=eof_activity_sequence,
983
+ activity_monotonic=eof_activity_monotonic,
984
+ )
985
+ except BaseException as exc: # noqa: BLE001
986
+ activity_sequence, activity_monotonic = reader_state.record_activity(
987
+ event_in_progress=False
988
+ )
989
+ reader_items.put(
990
+ _SseReaderItem(
991
+ kind="error",
992
+ activity_sequence=activity_sequence,
993
+ activity_monotonic=activity_monotonic,
994
+ error=exc,
995
+ )
996
+ )
997
+ return
998
+ activity_sequence, activity_monotonic = reader_state.record_activity(
999
+ event_in_progress=False
1000
+ )
1001
+ reader_items.put(
1002
+ _SseReaderItem(
1003
+ kind="closed",
1004
+ activity_sequence=activity_sequence,
1005
+ activity_monotonic=activity_monotonic,
1006
+ )
1007
+ )
1008
+
1009
+ reader_thread = threading.Thread(
1010
+ target=_reader,
1011
+ name=f"mcp-http-sse-{self.server.id}",
1012
+ daemon=True,
1013
+ )
1014
+ reader_thread.start()
1015
+ return reader_items, reader_thread, reader_state
1016
+
1017
+ def _handle_open_ended_follow_up_message(
1018
+ self,
1019
+ message: JsonRpcNotification | JsonRpcRequest | JsonRpcResponse,
1020
+ *,
1021
+ method: str,
1022
+ ) -> None:
1023
+ if isinstance(message, JsonRpcNotification):
1024
+ self._notifications.put(message)
1025
+ return
1026
+ if isinstance(message, JsonRpcRequest):
1027
+ raise self._build_error(
1028
+ f"received unsupported server-initiated request '{message.method}' during "
1029
+ f"HTTP MCP '{method}' follow-up",
1030
+ exc_type=McpHttpTransportProtocolError,
1031
+ )
1032
+ raise self._build_error(
1033
+ "received an additional JSON-RPC message after the matching HTTP response "
1034
+ f"for '{method}'",
1035
+ exc_type=McpHttpTransportProtocolError,
1036
+ )
1037
+
1038
+ def _handle_open_ended_request_event_stream(
1039
+ self,
1040
+ response: httpx.Response,
1041
+ *,
1042
+ method: str,
1043
+ expected_response_id: int | str,
1044
+ timeout_s: float,
1045
+ ) -> JsonRpcResponse:
1046
+ response_deadline = time.monotonic() + timeout_s
1047
+ last_reader_activity_monotonic: float | None = None
1048
+ last_observed_activity_sequence = 0
1049
+ reader_event_in_progress = False
1050
+ parsed_response: JsonRpcResponse | None = None
1051
+ reader_items, reader_thread, reader_state = self._start_sse_reader(response)
1052
+ try:
1053
+ while True:
1054
+ now = time.monotonic()
1055
+ if parsed_response is None:
1056
+ wait_timeout = response_deadline - now
1057
+ if wait_timeout <= 0:
1058
+ raise self._build_error(
1059
+ f"HTTP MCP '{method}' timed out after {timeout_s:.3f}s",
1060
+ exc_type=McpHttpTransportTimeoutError,
1061
+ )
1062
+ else:
1063
+ snapshot = reader_state.snapshot()
1064
+ if snapshot.activity_sequence > last_observed_activity_sequence:
1065
+ last_observed_activity_sequence = snapshot.activity_sequence
1066
+ last_reader_activity_monotonic = snapshot.activity_monotonic
1067
+ reader_event_in_progress = snapshot.event_in_progress
1068
+ assert last_reader_activity_monotonic is not None
1069
+ if now >= response_deadline:
1070
+ raise self._build_error(
1071
+ f"HTTP MCP '{method}' timed out after {timeout_s:.3f}s",
1072
+ exc_type=McpHttpTransportTimeoutError,
1073
+ )
1074
+ # Open-ended POST-SSE success is gated by raw reader progress, not only by
1075
+ # completed events. A partially read trailing SSE event keeps follow-up active
1076
+ # until that event either completes or the request deadline expires.
1077
+ quiet_deadline = (
1078
+ last_reader_activity_monotonic + _OPEN_ENDED_SSE_FOLLOW_UP_QUIET_WINDOW_S
1079
+ )
1080
+ if not reader_event_in_progress and quiet_deadline <= now:
1081
+ return parsed_response
1082
+ if reader_event_in_progress:
1083
+ wait_timeout = response_deadline - now
1084
+ else:
1085
+ wait_timeout = min(quiet_deadline - now, response_deadline - now)
1086
+ try:
1087
+ item = reader_items.get(timeout=max(wait_timeout, 0.0))
1088
+ except queue.Empty:
1089
+ snapshot = reader_state.snapshot()
1090
+ if snapshot.activity_sequence > last_observed_activity_sequence:
1091
+ last_observed_activity_sequence = snapshot.activity_sequence
1092
+ last_reader_activity_monotonic = snapshot.activity_monotonic
1093
+ reader_event_in_progress = snapshot.event_in_progress
1094
+ continue
1095
+ last_observed_activity_sequence = max(
1096
+ last_observed_activity_sequence,
1097
+ item.activity_sequence,
1098
+ )
1099
+ if item.kind == "closed":
1100
+ if parsed_response is None:
1101
+ raise self._build_error(
1102
+ "HTTP event stream ended before the matching JSON-RPC response "
1103
+ f"arrived for '{method}'",
1104
+ exc_type=McpHttpTransportProtocolError,
1105
+ )
1106
+ last_reader_activity_monotonic = max(
1107
+ last_reader_activity_monotonic or item.activity_monotonic,
1108
+ item.activity_monotonic,
1109
+ )
1110
+ return parsed_response
1111
+ if item.kind == "error":
1112
+ error = item.error or RuntimeError("unknown HTTP event-stream reader failure")
1113
+ self._raise_sse_reader_error(
1114
+ method=method,
1115
+ timeout_s=timeout_s,
1116
+ error=error,
1117
+ )
1118
+ event = item.event
1119
+ if event is None or not event.data.strip():
1120
+ continue
1121
+ last_reader_activity_monotonic = item.activity_monotonic
1122
+ message = self._parse_single_jsonrpc_payload(event.data, method=method)
1123
+ if parsed_response is None:
1124
+ parsed_message, response_received = self._process_jsonrpc_message(
1125
+ message,
1126
+ method=method,
1127
+ expected_response_id=expected_response_id,
1128
+ response_received=False,
1129
+ server_request_timeout_s=max(response_deadline - time.monotonic(), 0.001),
1130
+ allow_server_requests=True,
1131
+ )
1132
+ if parsed_message is None or not response_received:
1133
+ continue
1134
+ parsed_response = parsed_message
1135
+ continue
1136
+ self._handle_open_ended_follow_up_message(message, method=method)
1137
+ finally:
1138
+ response.close()
1139
+ reader_thread.join(timeout=_OPEN_ENDED_SSE_READER_JOIN_TIMEOUT_S)
1140
+
1141
+ def _handle_event_stream(
1142
+ self,
1143
+ response: httpx.Response,
1144
+ *,
1145
+ method: str,
1146
+ expected_response_id: int | str | None,
1147
+ timeout_s: float,
1148
+ ) -> JsonRpcResponse | None:
1149
+ has_content_length = response.headers.get("content-length") is not None
1150
+ if not has_content_length and expected_response_id is not None:
1151
+ return self._handle_open_ended_request_event_stream(
1152
+ response,
1153
+ method=method,
1154
+ expected_response_id=expected_response_id,
1155
+ timeout_s=timeout_s,
1156
+ )
1157
+ response_received = False
1158
+ parsed_response: JsonRpcResponse | None = None
1159
+ for event in _iter_sse_events(response):
1160
+ if not event.data.strip():
1161
+ continue
1162
+ message = self._parse_single_jsonrpc_payload(event.data, method=method)
1163
+ parsed_message, response_received = self._process_jsonrpc_message(
1164
+ message,
1165
+ method=method,
1166
+ expected_response_id=expected_response_id,
1167
+ response_received=response_received,
1168
+ server_request_timeout_s=timeout_s,
1169
+ allow_server_requests=expected_response_id is not None and not response_received,
1170
+ )
1171
+ if parsed_message is not None:
1172
+ parsed_response = parsed_message
1173
+ if expected_response_id is None:
1174
+ return None
1175
+ if parsed_response is None:
1176
+ raise self._build_error(
1177
+ f"HTTP event stream ended before the matching JSON-RPC response arrived for "
1178
+ f"'{method}'",
1179
+ exc_type=McpHttpTransportProtocolError,
1180
+ )
1181
+ return parsed_response
1182
+
1183
+ def _send_jsonrpc_message(
1184
+ self,
1185
+ *,
1186
+ payload: dict[str, Any],
1187
+ method: str,
1188
+ timeout_s: float,
1189
+ expected_response_id: int | str | None,
1190
+ ) -> JsonRpcResponse | None:
1191
+ if self._closed:
1192
+ raise self._build_error("transport is already closed.")
1193
+ request_body = encode_jsonrpc_message(payload)
1194
+ had_session_id = bool(self._session_id)
1195
+ allow_request_replay = method != "tools/call"
1196
+ oauth_retry_attempted = False
1197
+ try:
1198
+ while True:
1199
+ oauth_record: McpOAuthTokenRecord | None = None
1200
+ access_token: str | None = None
1201
+ if self._oauth_enabled():
1202
+ oauth_record = self._resolve_oauth_token_record(method=method)
1203
+ access_token = oauth_record.access_token
1204
+ request_headers = self._build_headers(access_token=access_token)
1205
+ with self._client.stream(
1206
+ "POST",
1207
+ self.server.url,
1208
+ headers=request_headers,
1209
+ content=request_body.encode("utf-8"),
1210
+ timeout=timeout_s,
1211
+ ) as response:
1212
+ if self._oauth_enabled() and self._handle_oauth_http_status(
1213
+ response,
1214
+ method=method,
1215
+ oauth_record=oauth_record,
1216
+ allow_request_replay=allow_request_replay,
1217
+ oauth_retry_attempted=oauth_retry_attempted,
1218
+ ):
1219
+ oauth_retry_attempted = True
1220
+ continue
1221
+ self._raise_for_http_status(
1222
+ response,
1223
+ method=method,
1224
+ had_session_id=had_session_id,
1225
+ )
1226
+ self._capture_session_headers(response, method=method)
1227
+ content_type = _normalized_content_type(response.headers.get("content-type"))
1228
+ if not content_type:
1229
+ if expected_response_id is None and response.status_code in {202, 204}:
1230
+ return None
1231
+ if expected_response_id is None and not response.read():
1232
+ return None
1233
+ raise self._build_error(
1234
+ f"HTTP MCP '{method}' returned no content type",
1235
+ exc_type=McpHttpTransportProtocolError,
1236
+ )
1237
+ if content_type == "application/json":
1238
+ return self._handle_application_json(
1239
+ response,
1240
+ method=method,
1241
+ expected_response_id=expected_response_id,
1242
+ )
1243
+ if content_type == "text/event-stream":
1244
+ return self._handle_event_stream(
1245
+ response,
1246
+ method=method,
1247
+ expected_response_id=expected_response_id,
1248
+ timeout_s=timeout_s,
1249
+ )
1250
+ raise self._build_error(
1251
+ f"HTTP MCP '{method}' returned unsupported content type {content_type!r}",
1252
+ exc_type=McpHttpTransportProtocolError,
1253
+ )
1254
+ except (McpHttpTransportError, McpOAuthError):
1255
+ raise
1256
+ except httpx.TimeoutException as exc:
1257
+ raise self._build_error(
1258
+ f"HTTP MCP '{method}' timed out after {timeout_s:.3f}s",
1259
+ exc_type=McpHttpTransportTimeoutError,
1260
+ ) from exc
1261
+ except httpx.HTTPError as exc:
1262
+ raise self._build_error(
1263
+ f"HTTP MCP '{method}' request failed: {exc}",
1264
+ exc_type=McpHttpTransportError,
1265
+ ) from exc
1266
+
1267
+ def request(
1268
+ self,
1269
+ *,
1270
+ method: str,
1271
+ params: Any | None,
1272
+ timeout_s: float,
1273
+ ) -> JsonRpcResponse:
1274
+ request_id = self._id_generator.next()
1275
+ response = self._send_jsonrpc_message(
1276
+ payload=build_jsonrpc_request(
1277
+ request_id=request_id,
1278
+ method=method,
1279
+ params=params,
1280
+ ),
1281
+ method=method,
1282
+ timeout_s=timeout_s,
1283
+ expected_response_id=request_id,
1284
+ )
1285
+ if response is None:
1286
+ raise self._build_error(
1287
+ f"HTTP MCP '{method}' completed without the matching JSON-RPC response",
1288
+ exc_type=McpHttpTransportProtocolError,
1289
+ )
1290
+ return response
1291
+
1292
+ def send_notification(
1293
+ self,
1294
+ *,
1295
+ method: str,
1296
+ params: Any | None,
1297
+ completion_timeout_s: float | None = None,
1298
+ ) -> None:
1299
+ timeout_s = completion_timeout_s or self.server.call_timeout_s
1300
+ self._send_jsonrpc_message(
1301
+ payload=build_jsonrpc_notification(
1302
+ method=method,
1303
+ params=params,
1304
+ ),
1305
+ method=method,
1306
+ timeout_s=timeout_s,
1307
+ expected_response_id=None,
1308
+ )
1309
+
1310
+ def close(self) -> None:
1311
+ if self._closed:
1312
+ return
1313
+ self._closed = True
1314
+ if self._session_id:
1315
+ try:
1316
+ delete_headers = dict(self.server.headers)
1317
+ delete_headers["User-Agent"] = _USER_AGENT
1318
+ if self._oauth_enabled():
1319
+ try:
1320
+ close_record = load_oauth_token_record(self.server.id)
1321
+ except McpOAuthTokenStoreError:
1322
+ close_record = None
1323
+ if close_record is not None:
1324
+ delete_headers["Authorization"] = f"Bearer {close_record.access_token}"
1325
+ delete_headers["MCP-Session-Id"] = self._session_id
1326
+ if self._negotiated_protocol_version:
1327
+ delete_headers["MCP-Protocol-Version"] = self._negotiated_protocol_version
1328
+ response = self._client.delete(
1329
+ self.server.url,
1330
+ headers=delete_headers,
1331
+ timeout=min(self.server.call_timeout_s, 2.0),
1332
+ )
1333
+ if response.status_code not in {200, 202, 204, 404, 405}:
1334
+ response.read()
1335
+ except Exception:
1336
+ pass
1337
+ if self._owns_client:
1338
+ self._client.close()