@oneciel-ai/ciel-runtime 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (287) hide show
  1. package/README.md +132 -0
  2. package/ciel-runtime-menu.py +56 -6
  3. package/ciel_runtime.py +9379 -35909
  4. package/ciel_runtime_support/advisor_client.py +193 -0
  5. package/ciel_runtime_support/advisor_policy.py +320 -0
  6. package/ciel_runtime_support/advisor_refinement.py +160 -0
  7. package/ciel_runtime_support/advisor_request_builder.py +261 -0
  8. package/ciel_runtime_support/agy_installer.py +169 -0
  9. package/ciel_runtime_support/agy_mcp_restore.py +182 -0
  10. package/ciel_runtime_support/anthropic_model_policy.py +186 -0
  11. package/ciel_runtime_support/anthropic_response_writer.py +255 -0
  12. package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
  13. package/ciel_runtime_support/api_key_cooldown.py +159 -0
  14. package/ciel_runtime_support/architecture.py +488 -1
  15. package/ciel_runtime_support/architecture_budget.py +42 -0
  16. package/ciel_runtime_support/channel_backlog.py +90 -0
  17. package/ciel_runtime_support/channel_cli.py +119 -0
  18. package/ciel_runtime_support/channel_compact_injection.py +82 -0
  19. package/ciel_runtime_support/channel_compact_poll.py +67 -0
  20. package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
  21. package/ciel_runtime_support/channel_config_service.py +281 -0
  22. package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
  23. package/ciel_runtime_support/channel_connection_registry.py +128 -0
  24. package/ciel_runtime_support/channel_connection_worker.py +284 -0
  25. package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
  26. package/ciel_runtime_support/channel_cursor_repository.py +89 -0
  27. package/ciel_runtime_support/channel_cursor_service.py +178 -0
  28. package/ciel_runtime_support/channel_event_identity.py +212 -0
  29. package/ciel_runtime_support/channel_event_projection.py +315 -0
  30. package/ciel_runtime_support/channel_inflight.py +127 -0
  31. package/ciel_runtime_support/channel_injection.py +115 -0
  32. package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
  33. package/ciel_runtime_support/channel_launch_policy.py +180 -0
  34. package/ciel_runtime_support/channel_llm_context.py +156 -0
  35. package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
  36. package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
  37. package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
  38. package/ciel_runtime_support/channel_mcp_tools.py +240 -0
  39. package/ciel_runtime_support/channel_mcp_transport.py +394 -0
  40. package/ciel_runtime_support/channel_message_dedupe.py +65 -0
  41. package/ciel_runtime_support/channel_message_policy.py +256 -0
  42. package/ciel_runtime_support/channel_message_prompt.py +305 -0
  43. package/ciel_runtime_support/channel_message_repository.py +234 -0
  44. package/ciel_runtime_support/channel_notification_projection.py +217 -0
  45. package/ciel_runtime_support/channel_panel.py +162 -0
  46. package/ciel_runtime_support/channel_pending_injection.py +209 -0
  47. package/ciel_runtime_support/channel_pending_poll.py +109 -0
  48. package/ciel_runtime_support/channel_probe_cache.py +433 -0
  49. package/ciel_runtime_support/channel_probe_report.py +101 -0
  50. package/ciel_runtime_support/channel_runtime_environment.py +181 -0
  51. package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
  52. package/ciel_runtime_support/channel_session_repository.py +90 -0
  53. package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
  54. package/ciel_runtime_support/channel_terminal_input.py +277 -0
  55. package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
  56. package/ciel_runtime_support/channel_tool_context.py +166 -0
  57. package/ciel_runtime_support/channel_transcript.py +414 -0
  58. package/ciel_runtime_support/channel_transcript_repository.py +96 -0
  59. package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
  60. package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
  61. package/ciel_runtime_support/chat_files.py +138 -0
  62. package/ciel_runtime_support/chat_http_controller.py +235 -0
  63. package/ciel_runtime_support/claude_environment.py +375 -0
  64. package/ciel_runtime_support/claude_router.py +247 -193
  65. package/ciel_runtime_support/cli_dispatch.py +792 -0
  66. package/ciel_runtime_support/cli_parser.py +165 -0
  67. package/ciel_runtime_support/cli_usage.py +100 -0
  68. package/ciel_runtime_support/codex_app_server.py +20 -5
  69. package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
  70. package/ciel_runtime_support/codex_cli.py +42 -6
  71. package/ciel_runtime_support/codex_config.py +323 -0
  72. package/ciel_runtime_support/codex_launch_configuration.py +240 -0
  73. package/ciel_runtime_support/codex_launch_policy.py +66 -0
  74. package/ciel_runtime_support/codex_mcp_integration.py +195 -0
  75. package/ciel_runtime_support/codex_mcp_restore.py +304 -0
  76. package/ciel_runtime_support/codex_model_catalog.py +133 -0
  77. package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
  78. package/ciel_runtime_support/codex_router.py +147 -1
  79. package/ciel_runtime_support/codex_session_repository.py +115 -0
  80. package/ciel_runtime_support/codex_session_selection.py +114 -0
  81. package/ciel_runtime_support/command_asset_installer.py +103 -0
  82. package/ciel_runtime_support/compatibility_probe.py +295 -0
  83. package/ciel_runtime_support/compatibility_protocol.py +251 -0
  84. package/ciel_runtime_support/compatibility_runtime.py +166 -0
  85. package/ciel_runtime_support/compatibility_test.py +370 -0
  86. package/ciel_runtime_support/config_migrations.py +307 -0
  87. package/ciel_runtime_support/config_repository.py +175 -0
  88. package/ciel_runtime_support/config_value_codec.py +64 -0
  89. package/ciel_runtime_support/configuration_cli.py +374 -0
  90. package/ciel_runtime_support/context_compaction.py +280 -0
  91. package/ciel_runtime_support/context_setup.py +208 -0
  92. package/ciel_runtime_support/context_summary_policy.py +392 -0
  93. package/ciel_runtime_support/credential_cli.py +104 -0
  94. package/ciel_runtime_support/credential_management.py +261 -0
  95. package/ciel_runtime_support/credentials.py +269 -0
  96. package/ciel_runtime_support/executable_discovery.py +141 -0
  97. package/ciel_runtime_support/github_copilot_oauth.py +335 -0
  98. package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
  99. package/ciel_runtime_support/header_forwarding.py +73 -0
  100. package/ciel_runtime_support/headless_config.py +221 -0
  101. package/ciel_runtime_support/http_response.py +129 -0
  102. package/ciel_runtime_support/install_diagnostics.py +149 -0
  103. package/ciel_runtime_support/kimi_identity.py +123 -0
  104. package/ciel_runtime_support/launch_diagnostics.py +204 -0
  105. package/ciel_runtime_support/launch_state.py +127 -0
  106. package/ciel_runtime_support/live_api_key_controller.py +58 -0
  107. package/ciel_runtime_support/llm_config_http.py +148 -0
  108. package/ciel_runtime_support/llm_option_config.py +259 -0
  109. package/ciel_runtime_support/llm_presentation_data.py +447 -0
  110. package/ciel_runtime_support/llm_presets.py +773 -0
  111. package/ciel_runtime_support/lm_studio_runtime.py +401 -0
  112. package/ciel_runtime_support/managed_mcp_config.py +144 -0
  113. package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
  114. package/ciel_runtime_support/managed_service_cleanup.py +89 -0
  115. package/ciel_runtime_support/mcp_config_reader.py +230 -0
  116. package/ciel_runtime_support/mcp_http_proxy.py +607 -0
  117. package/ciel_runtime_support/mcp_inventory.py +59 -0
  118. package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
  119. package/ciel_runtime_support/mcp_probe_codec.py +136 -0
  120. package/ciel_runtime_support/mcp_probe_transport.py +328 -0
  121. package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
  122. package/ciel_runtime_support/mcp_proxy_config.py +107 -0
  123. package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
  124. package/ciel_runtime_support/mcp_proxy_process.py +560 -0
  125. package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
  126. package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
  127. package/ciel_runtime_support/mcp_transport.py +146 -0
  128. package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
  129. package/ciel_runtime_support/model_catalog_projection.py +61 -0
  130. package/ciel_runtime_support/model_context_hints.py +109 -0
  131. package/ciel_runtime_support/model_panel.py +147 -0
  132. package/ciel_runtime_support/model_registry_repository.py +231 -0
  133. package/ciel_runtime_support/npm_runtime.py +191 -0
  134. package/ciel_runtime_support/ollama_catalog.py +462 -0
  135. package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
  136. package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
  137. package/ciel_runtime_support/ollama_context_sync.py +87 -0
  138. package/ciel_runtime_support/ollama_forwarding.py +449 -0
  139. package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
  140. package/ciel_runtime_support/openai_chat_router.py +64 -0
  141. package/ciel_runtime_support/openai_forwarding.py +194 -0
  142. package/ciel_runtime_support/openai_responses_router.py +291 -0
  143. package/ciel_runtime_support/openai_responses_stream.py +135 -0
  144. package/ciel_runtime_support/output_budget.py +89 -0
  145. package/ciel_runtime_support/package_lifecycle.py +217 -0
  146. package/ciel_runtime_support/plan_artifact_controller.py +104 -0
  147. package/ciel_runtime_support/prelaunch.py +959 -0
  148. package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
  149. package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
  150. package/ciel_runtime_support/prelaunch_terminal.py +764 -0
  151. package/ciel_runtime_support/process_control.py +708 -0
  152. package/ciel_runtime_support/prompt_compaction.py +322 -0
  153. package/ciel_runtime_support/prompt_injection.py +176 -0
  154. package/ciel_runtime_support/protocols/__init__.py +24 -0
  155. package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
  156. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
  157. package/ciel_runtime_support/protocols/chat_projection.py +315 -0
  158. package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
  159. package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
  160. package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
  161. package/ciel_runtime_support/protocols/ollama_response.py +231 -0
  162. package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
  163. package/ciel_runtime_support/protocols/openai_responses.py +271 -0
  164. package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
  165. package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
  166. package/ciel_runtime_support/provider_adapters.py +160 -0
  167. package/ciel_runtime_support/provider_catalog_sources.py +316 -0
  168. package/ciel_runtime_support/provider_choice.py +201 -0
  169. package/ciel_runtime_support/provider_compatibility.py +165 -0
  170. package/ciel_runtime_support/provider_config_mutations.py +361 -0
  171. package/ciel_runtime_support/provider_configuration_service.py +162 -0
  172. package/ciel_runtime_support/provider_context.py +308 -0
  173. package/ciel_runtime_support/provider_contract_projection.py +73 -0
  174. package/ciel_runtime_support/provider_descriptor.py +82 -0
  175. package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
  176. package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
  177. package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
  178. package/ciel_runtime_support/provider_limits.py +457 -0
  179. package/ciel_runtime_support/provider_model_identity.py +139 -0
  180. package/ciel_runtime_support/provider_model_selection.py +431 -0
  181. package/ciel_runtime_support/provider_model_specs.py +142 -0
  182. package/ciel_runtime_support/provider_models.py +263 -0
  183. package/ciel_runtime_support/provider_network.py +176 -0
  184. package/ciel_runtime_support/provider_option_cli.py +238 -0
  185. package/ciel_runtime_support/provider_option_panel.py +275 -0
  186. package/ciel_runtime_support/provider_option_status.py +192 -0
  187. package/ciel_runtime_support/provider_policy.py +101 -0
  188. package/ciel_runtime_support/provider_query_policy.py +67 -0
  189. package/ciel_runtime_support/provider_readiness.py +112 -0
  190. package/ciel_runtime_support/provider_request_access.py +131 -0
  191. package/ciel_runtime_support/provider_request_builder.py +250 -0
  192. package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
  193. package/ciel_runtime_support/provider_runtime_info.py +113 -0
  194. package/ciel_runtime_support/provider_runtime_modes.py +150 -0
  195. package/ciel_runtime_support/provider_sampling_policy.py +46 -0
  196. package/ciel_runtime_support/provider_status.py +145 -0
  197. package/ciel_runtime_support/provider_timeout_policy.py +184 -0
  198. package/ciel_runtime_support/provider_tool_policy.py +145 -0
  199. package/ciel_runtime_support/providers/__init__.py +65 -0
  200. package/ciel_runtime_support/providers/anthropic.py +160 -0
  201. package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
  202. package/ciel_runtime_support/providers/base.py +232 -0
  203. package/ciel_runtime_support/providers/catalog.py +326 -0
  204. package/ciel_runtime_support/providers/cloud.py +194 -0
  205. package/ciel_runtime_support/providers/constants.py +54 -0
  206. package/ciel_runtime_support/providers/deepseek.py +115 -0
  207. package/ciel_runtime_support/providers/fireworks.py +158 -0
  208. package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
  209. package/ciel_runtime_support/providers/kimi.py +297 -0
  210. package/ciel_runtime_support/providers/lm_studio.py +76 -0
  211. package/ciel_runtime_support/providers/meta.py +257 -0
  212. package/ciel_runtime_support/providers/native.py +194 -0
  213. package/ciel_runtime_support/providers/nim.py +69 -0
  214. package/ciel_runtime_support/providers/nvidia.py +158 -0
  215. package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
  216. package/ciel_runtime_support/providers/ollama.py +181 -0
  217. package/ciel_runtime_support/providers/ollama_context.py +195 -0
  218. package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
  219. package/ciel_runtime_support/providers/opencode.py +220 -0
  220. package/ciel_runtime_support/providers/opencode_go.py +37 -0
  221. package/ciel_runtime_support/providers/openrouter.py +57 -0
  222. package/ciel_runtime_support/providers/vllm.py +65 -0
  223. package/ciel_runtime_support/providers/zai.py +119 -0
  224. package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
  225. package/ciel_runtime_support/rate_limit_policy.py +117 -0
  226. package/ciel_runtime_support/rate_limit_repository.py +154 -0
  227. package/ciel_runtime_support/registry.py +46 -0
  228. package/ciel_runtime_support/request_shortcuts.py +253 -0
  229. package/ciel_runtime_support/request_trace.py +323 -0
  230. package/ciel_runtime_support/response_collection.py +209 -0
  231. package/ciel_runtime_support/router_access.py +238 -0
  232. package/ciel_runtime_support/router_client_lifecycle.py +366 -0
  233. package/ciel_runtime_support/router_health_policy.py +101 -0
  234. package/ciel_runtime_support/router_http.py +513 -0
  235. package/ciel_runtime_support/router_process_lifecycle.py +401 -0
  236. package/ciel_runtime_support/router_rate_limit_service.py +285 -0
  237. package/ciel_runtime_support/router_server_runtime.py +103 -0
  238. package/ciel_runtime_support/router_shortcuts.py +201 -0
  239. package/ciel_runtime_support/routing_fallback.py +73 -0
  240. package/ciel_runtime_support/runtime_activity_repository.py +143 -0
  241. package/ciel_runtime_support/runtime_adapters.py +104 -0
  242. package/ciel_runtime_support/runtime_command_factory.py +73 -0
  243. package/ciel_runtime_support/runtime_compatibility.py +50 -0
  244. package/ciel_runtime_support/runtime_constants.py +178 -0
  245. package/ciel_runtime_support/runtime_launch.py +1602 -0
  246. package/ciel_runtime_support/runtime_llm_options.py +312 -0
  247. package/ciel_runtime_support/runtime_logging.py +161 -0
  248. package/ciel_runtime_support/runtime_paths.py +157 -0
  249. package/ciel_runtime_support/runtime_restart.py +84 -0
  250. package/ciel_runtime_support/runtime_upgrade.py +149 -0
  251. package/ciel_runtime_support/secure_json_repository.py +55 -0
  252. package/ciel_runtime_support/session_import.py +356 -0
  253. package/ciel_runtime_support/settings_repository.py +8 -0
  254. package/ciel_runtime_support/slash_command_assets.py +211 -0
  255. package/ciel_runtime_support/sse_stream.py +57 -0
  256. package/ciel_runtime_support/sse_trace.py +225 -0
  257. package/ciel_runtime_support/statusline_script.py +593 -0
  258. package/ciel_runtime_support/statusline_settings.py +53 -0
  259. package/ciel_runtime_support/stream_chunk_policy.py +18 -0
  260. package/ciel_runtime_support/streaming_anthropic.py +1955 -0
  261. package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
  262. package/ciel_runtime_support/terminal_platform_io.py +127 -0
  263. package/ciel_runtime_support/timeout_profile.py +196 -0
  264. package/ciel_runtime_support/tool_dialects.py +85 -0
  265. package/ciel_runtime_support/tool_exposure_policy.py +63 -0
  266. package/ciel_runtime_support/tool_guard_hooks.py +218 -0
  267. package/ciel_runtime_support/tool_request_projection.py +96 -0
  268. package/ciel_runtime_support/tool_schema.py +483 -0
  269. package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
  270. package/ciel_runtime_support/ui_text.py +266 -0
  271. package/ciel_runtime_support/upstream_error_policy.py +104 -0
  272. package/ciel_runtime_support/upstream_retry.py +419 -0
  273. package/ciel_runtime_support/upstream_stream_io.py +106 -0
  274. package/ciel_runtime_support/usage_events.py +96 -0
  275. package/ciel_runtime_support/visible_stream_filters.py +130 -0
  276. package/ciel_runtime_support/web_endpoints.py +447 -0
  277. package/ciel_runtime_support/web_ui.py +915 -0
  278. package/ciel_runtime_support/web_ui_controller.py +189 -0
  279. package/ciel_runtime_support/windows_console_input.py +137 -0
  280. package/ciel_runtime_support/windows_console_mode.py +112 -0
  281. package/docs/Architecture.md +54 -0
  282. package/docs/Configuration.md +17 -0
  283. package/docs/Module-Map.md +1093 -26
  284. package/docs/Providers.md +46 -1
  285. package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
  286. package/npm-bin/run-ciel-runtime.js +22 -2
  287. package/package.json +9 -2
@@ -0,0 +1,607 @@
1
+ """Streamable HTTP MCP proxy application service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import sys
10
+ import threading
11
+ import time
12
+ from typing import Any, Callable
13
+ import urllib.error
14
+ import urllib.request
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class McpHttpProxyCodec:
19
+ compact_tool_result_response: Callable[..., Any]
20
+ drain_input_messages: Callable[..., Any]
21
+ error_response: Callable[..., Any]
22
+ notification_payload: Callable[..., Any]
23
+ notification_wait_response: Callable[..., Any]
24
+ observe_json_message: Callable[..., Any]
25
+ tool_call_arguments: Callable[..., Any]
26
+ tool_call_name: Callable[..., Any]
27
+ tool_is_notification_wait: Callable[..., Any]
28
+ wait_timeout_seconds: Callable[..., Any]
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class McpHttpProxyTransport:
33
+ http_error_body_text: Callable[..., Any]
34
+ session_not_found: Callable[..., Any]
35
+ stream_read_timeout_error: Callable[..., Any]
36
+ streamable_headers: Callable[..., Any]
37
+ streamable_http_request: Callable[..., Any]
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class McpHttpProxyRuntime:
42
+ default_protocol_version: str
43
+ disable_notification_stream: Callable[..., Any]
44
+ is_streamable_http: Callable[..., Any]
45
+ json_safe_metadata: Callable[..., Any]
46
+ log: Callable[..., Any]
47
+ parse_bool: Callable[..., Any]
48
+ server_runtime_headers: Callable[..., Any]
49
+ write_json_response: Callable[..., Any]
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class McpHttpProxyServices:
54
+ codec: McpHttpProxyCodec
55
+ transport: McpHttpProxyTransport
56
+ runtime: McpHttpProxyRuntime
57
+
58
+
59
+ def run_mcp_streamable_http_proxy(
60
+ server_name: str,
61
+ server_config_path: Path,
62
+ *,
63
+ services: McpHttpProxyServices,
64
+ ) -> int:
65
+ codec = services.codec
66
+ transport = services.transport
67
+ runtime = services.runtime
68
+ MCP_STREAMABLE_HTTP_PROTOCOL_VERSION = runtime.default_protocol_version
69
+ _http_error_body_text = transport.http_error_body_text
70
+ _json_safe_metadata = runtime.json_safe_metadata
71
+ _mcp_proxy_compact_tool_result_response = codec.compact_tool_result_response
72
+ _mcp_proxy_drain_input_messages = codec.drain_input_messages
73
+ _mcp_proxy_error_response = codec.error_response
74
+ _mcp_proxy_notification_payload = codec.notification_payload
75
+ _mcp_proxy_notification_wait_response = codec.notification_wait_response
76
+ _mcp_proxy_observe_json_message = codec.observe_json_message
77
+ _mcp_proxy_streamable_http_request = transport.streamable_http_request
78
+ _mcp_proxy_tool_call_arguments = codec.tool_call_arguments
79
+ _mcp_proxy_tool_call_name = codec.tool_call_name
80
+ _mcp_proxy_tool_is_notification_wait = codec.tool_is_notification_wait
81
+ _mcp_proxy_wait_timeout_seconds = codec.wait_timeout_seconds
82
+ _mcp_proxy_write_json_response = runtime.write_json_response
83
+ _mcp_server_disable_proxy_notification_stream = runtime.disable_notification_stream
84
+ _mcp_server_is_streamable_http = runtime.is_streamable_http
85
+ _mcp_stream_read_timeout_error = transport.stream_read_timeout_error
86
+ _mcp_streamable_headers = transport.streamable_headers
87
+ _streamable_http_session_not_found = transport.session_not_found
88
+ mcp_server_runtime_headers = runtime.server_runtime_headers
89
+ parse_bool = runtime.parse_bool
90
+ router_log = runtime.log
91
+ try:
92
+ server = json.loads(server_config_path.read_text(encoding="utf-8"))
93
+ except Exception as exc:
94
+ router_log("ERROR", f"mcp_http_proxy_config_read_failed server={server_name} error={type(exc).__name__}: {exc}")
95
+ print(f"ciel-runtime mcp-proxy: cannot read server config: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
96
+ return 2
97
+ if not isinstance(server, dict) or not _mcp_server_is_streamable_http(server):
98
+ router_log("ERROR", f"mcp_http_proxy_invalid_config server={server_name}")
99
+ print("ciel-runtime mcp-proxy: server config is not a Streamable HTTP MCP server", file=sys.stderr, flush=True)
100
+ return 2
101
+ endpoint = str(server.get("url") or server.get("endpoint") or "").strip()
102
+ headers = mcp_server_runtime_headers(server)
103
+ protocol_version = str(server.get("mcp_protocol_version") or server.get("protocolVersion") or server.get("protocol_version") or MCP_STREAMABLE_HTTP_PROTOCOL_VERSION)
104
+ timeout = max(5.0, min(120.0, float(server.get("mcp_timeout_seconds") or server.get("timeout") or 20.0)))
105
+ requires_session = parse_bool(server.get("streamable_requires_session", server.get("require_session", server.get("mcp_session_required", True))), True)
106
+ notification_stream_enabled = not _mcp_server_disable_proxy_notification_stream(server)
107
+ session_id: str | None = None
108
+ initialize_payload: dict[str, Any] | None = None
109
+ initialized_payload: dict[str, Any] | None = None
110
+ session_lock = threading.Lock()
111
+ stream_stop = threading.Event()
112
+ stream_message_observed = threading.Event()
113
+ # Single-owner notification-stream lifecycle. ONE manager thread owns the
114
+ # backend session and the notification GET stream. It initializes the
115
+ # session, streams notifications, and on session loss re-initializes IN THE
116
+ # SAME thread -- so a second stream can never exist (no leak) and the stream
117
+ # never stays dead while Claude Code is idle (no zombie). The stdin loop
118
+ # NEVER initializes a session or opens a stream; when a tool call needs a
119
+ # session it asks the manager via session_cond and waits briefly.
120
+ session_cond = threading.Condition(session_lock)
121
+ session_requested = False
122
+ stream_reopen_requested = False
123
+ initialize_result: dict[str, Any] | None = None
124
+ manager_thread: threading.Thread | None = None
125
+ pending_notifications: list[dict[str, Any]] = []
126
+ pending_wait_count = 0
127
+ initialized_wait_seconds = max(
128
+ 0.0,
129
+ min(5.0, float(server.get("initialized_wait_seconds") or server.get("mcp_initialized_wait_seconds") or 1.0)),
130
+ )
131
+ shutdown_drain_seconds = max(
132
+ 0.0,
133
+ min(
134
+ 2.0,
135
+ float(server.get("shutdown_notification_drain_seconds") or 0.5),
136
+ ),
137
+ )
138
+ notification_condition = threading.Condition()
139
+ router_log("INFO", f"mcp_http_proxy_started server={server_name} endpoint={endpoint}")
140
+
141
+ def queue_proxy_notification(payload: dict[str, Any], saved: dict[str, Any] | None) -> None:
142
+ queued = _json_safe_metadata(payload)
143
+ if saved and saved.get("id") is not None:
144
+ queued["ciel_runtime_message_id"] = saved.get("id")
145
+ with notification_condition:
146
+ pending_notifications.append(queued)
147
+ if len(pending_notifications) > 200:
148
+ del pending_notifications[:-200]
149
+ notification_condition.notify_all()
150
+
151
+ def has_pending_notification_wait() -> bool:
152
+ with notification_condition:
153
+ return pending_wait_count > 0
154
+
155
+ def wait_for_proxy_notifications(payload: dict[str, Any]) -> dict[str, Any]:
156
+ nonlocal pending_wait_count
157
+ request_id = payload.get("id")
158
+ timeout_seconds = _mcp_proxy_wait_timeout_seconds(_mcp_proxy_tool_call_arguments(payload))
159
+ deadline = time.time() + timeout_seconds
160
+ with notification_condition:
161
+ pending_wait_count += 1
162
+ try:
163
+ while not pending_notifications:
164
+ remaining = deadline - time.time()
165
+ if remaining <= 0:
166
+ break
167
+ notification_condition.wait(timeout=min(1.0, remaining))
168
+ notifications = list(pending_notifications)
169
+ pending_notifications.clear()
170
+ finally:
171
+ pending_wait_count -= 1
172
+ timed_out = not notifications
173
+ router_log(
174
+ "INFO",
175
+ f"mcp_http_proxy_wait_resolved server={server_name} request_id={request_id} count={len(notifications)} timed_out={timed_out}",
176
+ )
177
+ return _mcp_proxy_notification_wait_response(request_id, server_name, notifications, timed_out=timed_out)
178
+
179
+ def emit_streamable_sse_message(data_lines: list[str]) -> None:
180
+ if not data_lines:
181
+ return
182
+ data_text = "\n".join(data_lines).strip()
183
+ if not data_text:
184
+ return
185
+ stream_message_observed.set()
186
+ try:
187
+ payload = json.loads(data_text)
188
+ except Exception as exc:
189
+ router_log("WARN", f"mcp_http_proxy_stream_json_failed server={server_name} error={type(exc).__name__}")
190
+ return
191
+ if not isinstance(payload, dict):
192
+ return
193
+ if _mcp_proxy_notification_payload(server_name, payload):
194
+ saved = _mcp_proxy_observe_json_message(server_name, payload, schedule_direct=False)
195
+ if saved:
196
+ queue_proxy_notification(payload, saved)
197
+ pending_wait = has_pending_notification_wait()
198
+ router_log(
199
+ "INFO",
200
+ f"mcp_http_proxy_notification_queued server={server_name} message_id={saved.get('id')} pending_wait={pending_wait}",
201
+ )
202
+ return
203
+ _mcp_proxy_observe_json_message(server_name, payload)
204
+ _mcp_proxy_write_json_response(payload)
205
+
206
+ def current_session_id() -> str | None:
207
+ with session_lock:
208
+ return session_id
209
+
210
+ def request_session_and_wait(timeout_s: float) -> str | None:
211
+ """Ask the manager for a session and wait briefly. Never inits here.
212
+
213
+ The stdin loop calls this when a tool call needs a session but none is
214
+ active. It only signals the manager (which solely owns session creation)
215
+ and waits; it never POSTs initialize or opens a stream itself, so two
216
+ owners can never exist.
217
+ """
218
+ nonlocal session_requested
219
+ with session_cond:
220
+ if session_id is not None:
221
+ return session_id
222
+ session_requested = True
223
+ session_cond.notify_all()
224
+ deadline = time.time() + timeout_s
225
+ while session_id is None and not stream_stop.is_set():
226
+ remaining = deadline - time.time()
227
+ if remaining <= 0:
228
+ break
229
+ session_cond.wait(timeout=min(1.0, remaining))
230
+ return session_id
231
+
232
+ def manager_initialize_locked() -> str | None:
233
+ """Re-establish the backend session. Returns the new session id or None.
234
+
235
+ Called ONLY by the manager thread, so session_id has a single writer.
236
+ Publishes the initialize result (for the stdin initialize reply) and the
237
+ new session id under session_cond, waking any waiter.
238
+ """
239
+ nonlocal session_id, initialize_result
240
+ init_payload = initialize_payload
241
+ if not init_payload:
242
+ return None
243
+ try:
244
+ result, returned_session = _mcp_proxy_streamable_http_request(
245
+ endpoint, headers, init_payload, timeout, protocol_version, None,
246
+ )
247
+ except Exception as exc:
248
+ router_log("WARN", f"mcp_http_proxy_session_init_failed server={server_name} error={type(exc).__name__}: {exc}")
249
+ return None
250
+ if isinstance(result, dict):
251
+ _mcp_proxy_observe_json_message(server_name, result)
252
+ with session_cond:
253
+ session_id = returned_session or session_id
254
+ if isinstance(result, dict):
255
+ initialize_result = result
256
+ new_session = session_id
257
+ session_cond.notify_all()
258
+ if new_session and initialized_payload:
259
+ try:
260
+ _mcp_proxy_streamable_http_request(
261
+ endpoint, headers, initialized_payload, timeout, protocol_version, new_session,
262
+ )
263
+ except Exception as exc:
264
+ router_log(
265
+ "WARN",
266
+ f"mcp_http_proxy_initialized_notification_failed server={server_name} "
267
+ f"error={type(exc).__name__}: {exc}",
268
+ )
269
+ if new_session:
270
+ router_log("INFO", f"mcp_http_proxy_session_initialized server={server_name} session={new_session}")
271
+ return new_session
272
+
273
+ def stream_manager() -> None:
274
+ """Single owner of the backend session and notification GET stream.
275
+
276
+ One thread for the whole proxy lifetime. It (re)initializes the session
277
+ when missing or requested, then runs the GET event-stream; on any
278
+ session loss / reconnect it loops back and re-initializes here, so there
279
+ is never a second stream worker and the stream never stays dead while
280
+ Claude Code is idle.
281
+ """
282
+ nonlocal session_requested, session_id, stream_reopen_requested
283
+ last_event_id: str | None = None
284
+ retry_seconds = max(1.0, min(60.0, float(server.get("retry_seconds") or 5.0)))
285
+ read_timeout = max(
286
+ 5.0,
287
+ min(
288
+ 3600.0,
289
+ float(
290
+ server.get("notification_read_timeout_seconds")
291
+ or server.get("stream_read_timeout_seconds")
292
+ or server.get("read_timeout_seconds")
293
+ or server.get("stream_timeout")
294
+ or 60.0
295
+ ),
296
+ ),
297
+ )
298
+ pre_initialized_read_timeout = max(
299
+ 0.2,
300
+ min(5.0, float(server.get("pre_initialized_read_timeout_seconds") or 0.5)),
301
+ )
302
+ while not stream_stop.is_set():
303
+ with session_cond:
304
+ current_session = session_id
305
+ requested = session_requested
306
+ reopen_requested = stream_reopen_requested
307
+ if reopen_requested:
308
+ stream_reopen_requested = False
309
+ # (Re)initialize when we have no session, or a tool call requested one.
310
+ if current_session is None or requested:
311
+ with session_cond:
312
+ session_requested = False
313
+ current_session = manager_initialize_locked()
314
+ if current_session is None:
315
+ # Init failed; back off and retry. Wake early on shutdown.
316
+ with session_cond:
317
+ if not stream_stop.is_set():
318
+ session_cond.wait(timeout=retry_seconds)
319
+ continue
320
+ last_event_id = None
321
+ if not notification_stream_enabled:
322
+ # No stream to own; just idle until shutdown or a session request.
323
+ with session_cond:
324
+ if not stream_stop.is_set() and not session_requested:
325
+ session_cond.wait(timeout=read_timeout)
326
+ continue
327
+ if initialized_payload is None and initialized_wait_seconds > 0:
328
+ # MCP clients send notifications/initialized immediately after
329
+ # initialize. Opening the Streamable HTTP GET before that point
330
+ # can leave some stateful servers with a live but unsubscribed
331
+ # notification stream. Wait briefly for the standard handshake;
332
+ # if an older/nonstandard client never sends it, keep the old
333
+ # behavior after the grace window.
334
+ with session_cond:
335
+ if initialized_payload is None and not stream_stop.is_set():
336
+ session_cond.wait(timeout=initialized_wait_seconds)
337
+ if session_requested:
338
+ continue
339
+ # A reopen requested while no GET is active is already
340
+ # satisfied by the stream we are about to open. Carrying
341
+ # the flag into the read loop would close this new stream
342
+ # immediately and race its server-side handler with another.
343
+ stream_reopen_requested = False
344
+ worker_session = current_session
345
+ event_name = "message"
346
+ data_lines: list[str] = []
347
+ reopen_after_close = False
348
+ try:
349
+ request_headers = _mcp_streamable_headers(
350
+ headers, protocol_version, worker_session, accept="text/event-stream",
351
+ )
352
+ if last_event_id:
353
+ request_headers["Last-Event-ID"] = last_event_id
354
+ req = urllib.request.Request(endpoint, headers=request_headers, method="GET")
355
+ with session_cond:
356
+ stream_read_timeout = read_timeout if initialized_payload is not None else min(read_timeout, pre_initialized_read_timeout)
357
+ with urllib.request.urlopen(req, timeout=stream_read_timeout) as response:
358
+ router_log("INFO", f"mcp_http_proxy_stream_connected server={server_name} session={worker_session} last_event_id={last_event_id or '-'}")
359
+ while not stream_stop.is_set():
360
+ with session_cond:
361
+ if session_requested or stream_reopen_requested or session_id != worker_session:
362
+ reopen_after_close = True
363
+ break
364
+ raw = response.readline()
365
+ if raw == b"":
366
+ raise ConnectionError("Streamable HTTP MCP notification stream ended")
367
+ line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
368
+ if not line:
369
+ emit_streamable_sse_message(data_lines)
370
+ data_lines = []
371
+ event_name = "message"
372
+ continue
373
+ if line.startswith(":"):
374
+ continue
375
+ field, _, value = line.partition(":")
376
+ if value.startswith(" "):
377
+ value = value[1:]
378
+ if field == "event":
379
+ event_name = value or "message"
380
+ elif field == "data":
381
+ data_lines.append(value)
382
+ elif field == "id":
383
+ last_event_id = value
384
+ elif field == "retry":
385
+ try:
386
+ retry_seconds = max(1.0, min(60.0, int(value) / 1000.0))
387
+ except (TypeError, ValueError) as exc:
388
+ router_log(
389
+ "WARN",
390
+ f"mcp_http_proxy_invalid_retry server={server_name} value={value!r} "
391
+ f"error={type(exc).__name__}: {exc}",
392
+ )
393
+ if reopen_after_close:
394
+ # Closing the client response does not guarantee the server
395
+ # handler has released its GET immediately. Observe the
396
+ # configured reconnect interval before opening the successor
397
+ # stream so the single owner is also single-owner server-side.
398
+ stream_stop.wait(timeout=min(retry_seconds, 0.25))
399
+ continue
400
+ except urllib.error.HTTPError as exc:
401
+ body_text = _http_error_body_text(exc)
402
+ if _streamable_http_session_not_found(exc, body_text):
403
+ with session_cond:
404
+ if session_id == worker_session:
405
+ session_id = None
406
+ last_event_id = None
407
+ router_log("WARN", f"mcp_http_proxy_stream_session_lost server={server_name} error=HTTPError:{exc.code}:{exc.reason}")
408
+ continue # re-init at loop top
409
+ router_log("WARN", f"mcp_http_proxy_stream_reconnect server={server_name} event={event_name} error=HTTPError:{exc.code}:{exc.reason}")
410
+ except Exception as exc:
411
+ if _mcp_stream_read_timeout_error(exc):
412
+ with session_cond:
413
+ initialized_seen = initialized_payload is not None
414
+ session_cond.notify_all()
415
+ router_log(
416
+ "WARN",
417
+ f"mcp_http_proxy_stream_timeout_reconnect server={server_name} event={event_name} "
418
+ f"initialized={initialized_seen} session={worker_session or '-'} "
419
+ f"last_event_id={last_event_id or '-'} error={type(exc).__name__}: {exc}",
420
+ )
421
+ # urllib can time out and close its client response while
422
+ # the backend GET handler is still unwinding. Respect the
423
+ # reconnect interval before replacing it to preserve the
424
+ # single-owner invariant on both sides of the socket.
425
+ stream_stop.wait(
426
+ timeout=min(retry_seconds, pre_initialized_read_timeout)
427
+ )
428
+ continue
429
+ router_log("WARN", f"mcp_http_proxy_stream_reconnect server={server_name} event={event_name} error={type(exc).__name__}: {exc}")
430
+ # Reconnect backoff. A plain reset retries the same session; a stream
431
+ # that ended because the backend dropped the session will fail the
432
+ # GET with 404 next round and re-init above.
433
+ with session_cond:
434
+ if not stream_stop.is_set():
435
+ session_cond.wait(timeout=retry_seconds)
436
+
437
+ def ensure_manager_started() -> None:
438
+ nonlocal manager_thread
439
+ if manager_thread is not None:
440
+ return
441
+ manager_thread = threading.Thread(
442
+ target=stream_manager, daemon=True, name=f"ca-mcp-http-stream-{server_name}",
443
+ )
444
+ manager_thread.start()
445
+
446
+ buffer = bytearray()
447
+ try:
448
+ stdin_fd = sys.stdin.fileno()
449
+ while True:
450
+ chunk = os.read(stdin_fd, 65536)
451
+ if not chunk:
452
+ break
453
+ buffer.extend(chunk)
454
+ for payload in _mcp_proxy_drain_input_messages(buffer):
455
+ request_id = payload.get("id")
456
+ method = str(payload.get("method") or "")
457
+ if method == "notifications/initialized":
458
+ # Cache it (the manager re-sends it after each re-init) and,
459
+ # if a session is already up, forward it now so the initial
460
+ # handshake completes in order.
461
+ with session_cond:
462
+ initialized_payload = payload
463
+ active = session_id
464
+ if active:
465
+ try:
466
+ _mcp_proxy_streamable_http_request(endpoint, headers, payload, timeout, protocol_version, active)
467
+ router_log("INFO", f"mcp_http_proxy_initialized_forwarded server={server_name} session={active}")
468
+ except urllib.error.HTTPError as exc:
469
+ body_text = _http_error_body_text(exc)
470
+ with session_cond:
471
+ if _streamable_http_session_not_found(exc, body_text) and session_id == active:
472
+ session_id = None
473
+ session_requested = True
474
+ router_log(
475
+ "WARN",
476
+ f"mcp_http_proxy_initialized_forward_failed server={server_name} error=HTTPError:{exc.code}:{exc.reason}",
477
+ )
478
+ except Exception as exc:
479
+ router_log("WARN", f"mcp_http_proxy_initialized_forward_failed server={server_name} error={type(exc).__name__}: {exc}")
480
+ with session_cond:
481
+ if active and session_id == active:
482
+ stream_reopen_requested = True
483
+ session_cond.notify_all()
484
+ continue
485
+ if method == "initialize":
486
+ # Hand the initialize off to the single session owner: cache
487
+ # the payload, wake the manager to (re)initialize, and reply
488
+ # with the result it produces. stdin never POSTs initialize
489
+ # itself, so session_id has exactly one writer (the manager).
490
+ with session_cond:
491
+ initialize_payload = payload
492
+ initialize_result = None
493
+ session_requested = True
494
+ session_cond.notify_all()
495
+ ensure_manager_started()
496
+ with session_cond:
497
+ deadline = time.time() + timeout
498
+ while initialize_result is None and not stream_stop.is_set():
499
+ remaining = deadline - time.time()
500
+ if remaining <= 0:
501
+ break
502
+ session_cond.wait(timeout=min(1.0, remaining))
503
+ resp = initialize_result
504
+ if payload.get("id") is not None:
505
+ if isinstance(resp, dict):
506
+ reply = dict(resp)
507
+ reply["id"] = request_id
508
+ _mcp_proxy_write_json_response(reply)
509
+ else:
510
+ _mcp_proxy_write_json_response(_mcp_proxy_error_response(request_id, "initialize timeout"))
511
+ continue
512
+ try:
513
+ if _mcp_proxy_tool_is_notification_wait(_mcp_proxy_tool_call_name(payload)):
514
+ _mcp_proxy_write_json_response(wait_for_proxy_notifications(payload))
515
+ continue
516
+ active_session = current_session_id()
517
+ if requires_session and not active_session:
518
+ active_session = request_session_and_wait(timeout)
519
+ if not active_session:
520
+ raise RuntimeError("Streamable HTTP MCP session is not initialized")
521
+ result, _returned = _mcp_proxy_streamable_http_request(
522
+ endpoint, headers, payload, timeout, protocol_version, active_session,
523
+ )
524
+ tool_name = _mcp_proxy_tool_call_name(payload)
525
+ if isinstance(result, dict):
526
+ _mcp_proxy_observe_json_message(server_name, result)
527
+ result = _mcp_proxy_compact_tool_result_response(server_name, tool_name, result)
528
+ if payload.get("id") is not None:
529
+ if isinstance(result, dict):
530
+ _mcp_proxy_write_json_response(result)
531
+ else:
532
+ _mcp_proxy_write_json_response(
533
+ {"jsonrpc": "2.0", "id": request_id, "result": result if result is not None else {}}
534
+ )
535
+ except urllib.error.HTTPError as exc:
536
+ body_text = _http_error_body_text(exc)
537
+ if _streamable_http_session_not_found(exc, body_text) and initialize_payload:
538
+ # Backend dropped the session mid tool-call. Ask the
539
+ # manager to re-init (single owner) and retry once on the
540
+ # session it provides. The stream resumes on that same
541
+ # re-init -- no second owner.
542
+ router_log("WARN", f"mcp_http_proxy_session_lost server={server_name} method={method} error=HTTPError:{exc.code}:{exc.reason}")
543
+ with session_cond:
544
+ if session_id is not None:
545
+ session_id = None
546
+ session_requested = True
547
+ session_cond.notify_all()
548
+ active_session = request_session_and_wait(timeout)
549
+ if active_session:
550
+ try:
551
+ result, _r = _mcp_proxy_streamable_http_request(
552
+ endpoint, headers, payload, timeout, protocol_version, active_session,
553
+ )
554
+ tool_name = _mcp_proxy_tool_call_name(payload)
555
+ if isinstance(result, dict):
556
+ _mcp_proxy_observe_json_message(server_name, result)
557
+ result = _mcp_proxy_compact_tool_result_response(server_name, tool_name, result)
558
+ if payload.get("id") is not None:
559
+ _mcp_proxy_write_json_response(result if isinstance(result, dict) else {"jsonrpc": "2.0", "id": request_id, "result": result if result is not None else {}})
560
+ continue
561
+ except Exception as retry_exc:
562
+ _mcp_proxy_write_json_response(_mcp_proxy_error_response(request_id, f"{type(retry_exc).__name__}: {retry_exc}"))
563
+ continue
564
+ _mcp_proxy_write_json_response(_mcp_proxy_error_response(request_id, "Streamable HTTP MCP session is not initialized"))
565
+ continue
566
+ _mcp_proxy_write_json_response(_mcp_proxy_error_response(request_id, f"HTTPError:{exc.code}: {exc.reason}; {body_text}".strip()))
567
+ except Exception as exc:
568
+ _mcp_proxy_write_json_response(_mcp_proxy_error_response(request_id, f"{type(exc).__name__}: {exc}"))
569
+ for payload in _mcp_proxy_drain_input_messages(buffer, final=True):
570
+ request_id = payload.get("id")
571
+ try:
572
+ if _mcp_proxy_tool_is_notification_wait(_mcp_proxy_tool_call_name(payload)):
573
+ _mcp_proxy_write_json_response(wait_for_proxy_notifications(payload))
574
+ continue
575
+ result, _r = _mcp_proxy_streamable_http_request(endpoint, headers, payload, timeout, protocol_version, current_session_id())
576
+ tool_name = _mcp_proxy_tool_call_name(payload)
577
+ if isinstance(result, dict):
578
+ result = _mcp_proxy_compact_tool_result_response(server_name, tool_name, result)
579
+ if payload.get("id") is not None:
580
+ _mcp_proxy_write_json_response(result if isinstance(result, dict) else {"jsonrpc": "2.0", "id": request_id, "result": result if result is not None else {}})
581
+ except Exception as exc:
582
+ _mcp_proxy_write_json_response(_mcp_proxy_error_response(request_id, f"{type(exc).__name__}: {exc}"))
583
+ router_log("INFO", f"mcp_http_proxy_exited server={server_name}")
584
+ return 0
585
+ except Exception as exc:
586
+ router_log("ERROR", f"mcp_http_proxy_failed server={server_name} error={type(exc).__name__}: {exc}")
587
+ print(f"ciel-runtime mcp-proxy: Streamable HTTP bridge failed: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
588
+ return 1
589
+ finally:
590
+ # stdin EOF commonly follows immediately after the initialized
591
+ # handshake in short-lived clients and tests. Give the single stream
592
+ # owner a bounded chance to consume an already-arriving SSE frame
593
+ # before signalling shutdown; otherwise the daemon can exit between
594
+ # GET connect and its first readline, losing that notification.
595
+ if (
596
+ initialized_payload is not None
597
+ and manager_thread is not None
598
+ and manager_thread.is_alive()
599
+ and not stream_message_observed.is_set()
600
+ and shutdown_drain_seconds > 0
601
+ ):
602
+ stream_message_observed.wait(timeout=shutdown_drain_seconds)
603
+ stream_stop.set()
604
+ with session_cond:
605
+ session_cond.notify_all()
606
+ if manager_thread is not None:
607
+ manager_thread.join(timeout=2.0)
@@ -0,0 +1,59 @@
1
+ """Conflict-safe merging for MCP inventories owned by different CLI adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class McpInventoryMerge:
11
+ servers: dict[str, dict[str, Any]]
12
+ added: tuple[str, ...]
13
+ duplicates: tuple[str, ...]
14
+
15
+
16
+ class McpInventoryService:
17
+ """Merge server maps while preserving the target CLI's native definitions."""
18
+
19
+ @staticmethod
20
+ def names(*inventories: dict[str, Any]) -> set[str]:
21
+ return {
22
+ name.casefold()
23
+ for inventory in inventories
24
+ for raw_name in inventory
25
+ if (name := str(raw_name or "").strip())
26
+ }
27
+
28
+ @classmethod
29
+ def merge(
30
+ cls,
31
+ primary: dict[str, dict[str, Any]],
32
+ recovered: dict[str, dict[str, Any]],
33
+ *,
34
+ reserved: set[str] | None = None,
35
+ ) -> McpInventoryMerge:
36
+ merged = dict(primary)
37
+ occupied = cls.names(primary)
38
+ occupied.update(
39
+ str(name).strip().casefold()
40
+ for name in (reserved or ())
41
+ if str(name).strip()
42
+ )
43
+ added: list[str] = []
44
+ duplicates: list[str] = []
45
+ for raw_name, server in recovered.items():
46
+ name = str(raw_name or "").strip()
47
+ if not name or not isinstance(server, dict):
48
+ continue
49
+ identity = name.casefold()
50
+ if identity in occupied:
51
+ duplicates.append(name)
52
+ continue
53
+ merged[name] = dict(server)
54
+ occupied.add(identity)
55
+ added.append(name)
56
+ return McpInventoryMerge(merged, tuple(added), tuple(duplicates))
57
+
58
+
59
+ __all__ = ["McpInventoryMerge", "McpInventoryService"]