@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,401 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class LmStudioRuntimeServices:
9
+ api_base: Callable[[dict[str, Any]], str | None]
10
+ current_model: Callable[[str, dict[str, Any]], str]
11
+ http_json: Callable[..., Any]
12
+ join_url: Callable[[str, str], str]
13
+ model_list_headers: Callable[[str, dict[str, Any]], dict[str, str]]
14
+ model_id_matches: Callable[[str, str], bool]
15
+ positive_int: Callable[[Any], int | None]
16
+ model_context: Callable[[dict[str, Any]], int | None]
17
+ log: Callable[[str, str], None]
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class LmStudioLifecyclePolicy:
22
+ recommended_preset: Callable[[str, dict[str, Any]], str]
23
+ required_context: Callable[[str, str], int]
24
+ model_context_hint: Callable[[str], int | None]
25
+ default_context: int
26
+ minimum_context: int
27
+
28
+
29
+ class LmStudioModelLifecycle:
30
+ def __init__(
31
+ self,
32
+ runtime: LmStudioRuntimeServices,
33
+ policy: LmStudioLifecyclePolicy,
34
+ post_json: Callable[..., Any],
35
+ ) -> None:
36
+ self.runtime = runtime
37
+ self.policy = policy
38
+ self.post_json = post_json
39
+
40
+ def v1_model_info(
41
+ self, config: dict[str, Any], timeout: float = 3.0
42
+ ) -> dict[str, Any] | None:
43
+ base = self.runtime.api_base(config)
44
+ current = self.runtime.current_model("lm-studio", config)
45
+ if not base or not current:
46
+ return None
47
+ data = self.runtime.http_json(
48
+ self.runtime.join_url(base, "/api/v1/models"),
49
+ headers=self.runtime.model_list_headers("lm-studio", config),
50
+ timeout=timeout,
51
+ )
52
+ items = data.get("models") if isinstance(data, dict) else None
53
+ if not isinstance(items, list):
54
+ return None
55
+ return next(
56
+ (
57
+ item
58
+ for item in items
59
+ if isinstance(item, dict)
60
+ and self.runtime.model_id_matches(str(item.get("key") or ""), current)
61
+ ),
62
+ None,
63
+ )
64
+
65
+ def loaded_instance_ids(
66
+ self, config: dict[str, Any], timeout: float = 3.0
67
+ ) -> list[str]:
68
+ try:
69
+ item = self.v1_model_info(config, timeout)
70
+ except Exception:
71
+ return []
72
+ instances = item.get("loaded_instances") if isinstance(item, dict) else None
73
+ if not isinstance(instances, list):
74
+ return []
75
+ return [
76
+ str(instance["id"])
77
+ for instance in instances
78
+ if isinstance(instance, dict) and instance.get("id")
79
+ ]
80
+
81
+ def target_context(
82
+ self, config: dict[str, Any], info: dict[str, Any] | None = None
83
+ ) -> int | None:
84
+ target = self.runtime.positive_int(config.get("context_window"))
85
+ if not target:
86
+ preset = self.policy.recommended_preset("lm-studio", config)
87
+ target = self.policy.required_context(preset, "lm-studio")
88
+ target = target or self.policy.default_context
89
+ max_length = self.runtime.positive_int(
90
+ (info or {}).get("max_model_len")
91
+ ) or self.policy.model_context_hint(str(config.get("current_model") or ""))
92
+ return min(target, max_length) if max_length else target
93
+
94
+ def load_timeout_seconds(self, config: dict[str, Any]) -> float:
95
+ configured = self.runtime.positive_int(config.get("request_timeout_ms"))
96
+ return max(60.0, min(600.0, configured / 1000.0)) if configured else 300.0
97
+
98
+ def load_model(
99
+ self,
100
+ config: dict[str, Any],
101
+ context_length: int,
102
+ timeout: float | None = None,
103
+ ) -> dict[str, Any]:
104
+ base = self.runtime.api_base(config)
105
+ model = self.runtime.current_model("lm-studio", config)
106
+ if not base or not model:
107
+ raise RuntimeError("LM Studio model or base URL is not configured")
108
+ return self.post_json(
109
+ self.runtime.join_url(base, "/api/v1/models/load"),
110
+ {
111
+ "model": model,
112
+ "context_length": context_length,
113
+ "flash_attention": True,
114
+ "echo_load_config": True,
115
+ },
116
+ headers=self.runtime.model_list_headers("lm-studio", config),
117
+ timeout=timeout or self.load_timeout_seconds(config),
118
+ )
119
+
120
+ def unload_loaded_instances(
121
+ self, config: dict[str, Any], timeout: float = 20.0
122
+ ) -> list[str]:
123
+ base = self.runtime.api_base(config)
124
+ if not base:
125
+ return []
126
+ unloaded: list[str] = []
127
+ for instance_id in self.loaded_instance_ids(config):
128
+ try:
129
+ self.post_json(
130
+ self.runtime.join_url(base, "/api/v1/models/unload"),
131
+ {"instance_id": instance_id},
132
+ headers=self.runtime.model_list_headers("lm-studio", config),
133
+ timeout=timeout,
134
+ )
135
+ unloaded.append(instance_id)
136
+ except Exception:
137
+ continue
138
+ return unloaded
139
+
140
+ def load_response_context(self, response: dict[str, Any], fallback: int) -> int:
141
+ load_config = response.get("load_config") if isinstance(response, dict) else None
142
+ if isinstance(load_config, dict):
143
+ value = self.runtime.positive_int(load_config.get("context_length"))
144
+ if value:
145
+ return value
146
+ return fallback
147
+
148
+ def ensure_loaded_context(
149
+ self, config: dict[str, Any], timeout: float = 3.0
150
+ ) -> list[str]:
151
+ info = discover_lm_studio_runtime(config, self.runtime, timeout=timeout)
152
+ target = self.target_context(config, info)
153
+ if not target:
154
+ return []
155
+ messages: list[str] = []
156
+ loaded = self.runtime.positive_int((info or {}).get("loaded_context_len"))
157
+ state = str((info or {}).get("state") or "")
158
+ max_length = self.runtime.positive_int((info or {}).get("max_model_len"))
159
+ if max_length:
160
+ messages.append(f"LM Studio model max context: {max_length:,} tokens.")
161
+ if loaded:
162
+ messages.append(f"LM Studio loaded context: {loaded:,} tokens.")
163
+ if loaded and state == "loaded" and loaded >= target:
164
+ config["native_compat"] = True
165
+ if not self.runtime.positive_int(config.get("context_window")):
166
+ config["context_window"] = min(loaded, target)
167
+ return messages
168
+ if max_length and max_length < self.policy.minimum_context:
169
+ config["native_compat"] = False
170
+ config["context_window"] = max_length
171
+ messages.append(
172
+ "LM Studio selected model cannot provide enough context for Claude Code "
173
+ f"({max_length:,} < {self.policy.minimum_context:,})."
174
+ )
175
+ return messages
176
+ action = "reloading" if loaded else "loading"
177
+ messages.append(
178
+ f"LM Studio auto-{action} selected model with {target:,} context tokens."
179
+ )
180
+ try:
181
+ response = self.load_model(config, target)
182
+ except Exception:
183
+ if not loaded:
184
+ raise
185
+ self.unload_loaded_instances(config)
186
+ response = self.load_model(config, target)
187
+ actual = self.load_response_context(response, target)
188
+ config["context_window"] = actual
189
+ if actual >= self.policy.minimum_context:
190
+ config["native_compat"] = True
191
+ messages.append(
192
+ f"LM Studio loaded selected model with {actual:,} context tokens."
193
+ )
194
+ else:
195
+ config["native_compat"] = False
196
+ current_output = self.runtime.positive_int(config.get("max_output_tokens")) or 4096
197
+ config["max_output_tokens"] = min(current_output, max(512, actual // 4))
198
+ messages.append(
199
+ "LM Studio loaded the model, but the applied context is still too small "
200
+ f"for Claude Code ({actual:,} < {self.policy.minimum_context:,})."
201
+ )
202
+ return messages
203
+
204
+ def context_guard(
205
+ self,
206
+ config: dict[str, Any],
207
+ *,
208
+ load: bool = False,
209
+ ) -> list[str]:
210
+ if load:
211
+ try:
212
+ return self.ensure_loaded_context(config, timeout=1.5)
213
+ except Exception as exc:
214
+ config["native_compat"] = False
215
+ return [
216
+ "LM Studio could not automatically load the selected model with the recommended context.",
217
+ f"LM Studio load error: {type(exc).__name__}: {exc}",
218
+ ]
219
+
220
+ info: dict[str, Any] | None = None
221
+ target = self.target_context(config, info)
222
+ loaded = self.runtime.positive_int((info or {}).get("loaded_context_len"))
223
+ state = str((info or {}).get("state") or "")
224
+ max_length = self.runtime.positive_int((info or {}).get("max_model_len"))
225
+ messages: list[str] = []
226
+ if max_length:
227
+ messages.append(f"LM Studio model max context: {max_length:,} tokens.")
228
+ if target:
229
+ messages.append(f"LM Studio target context: {target:,} tokens.")
230
+ if max_length and max_length < self.policy.minimum_context:
231
+ config["native_compat"] = False
232
+ config["context_window"] = max_length
233
+ messages.append(
234
+ "LM Studio selected model cannot provide enough context for Claude Code "
235
+ f"({max_length:,} < {self.policy.minimum_context:,})."
236
+ )
237
+ return messages
238
+ config["native_compat"] = True
239
+ if loaded:
240
+ messages.append(
241
+ f"LM Studio currently loaded context: {loaded:,} tokens."
242
+ )
243
+ if target and loaded < target:
244
+ messages.append(
245
+ "LM Studio will reload this model with the target context when you launch or test."
246
+ )
247
+ elif state and state != "loaded":
248
+ messages.append(
249
+ "LM Studio will load this model with the target context when you launch or test."
250
+ )
251
+ elif target:
252
+ messages.append(
253
+ "LM Studio will prepare this model with the target context when you launch or test."
254
+ )
255
+ return messages
256
+
257
+
258
+ @dataclass(frozen=True, slots=True)
259
+ class LmStudioLifecycleApi:
260
+ """Explicit public adapter for late-bound LM Studio lifecycle services."""
261
+
262
+ lifecycle_factory: Callable[[], LmStudioModelLifecycle]
263
+
264
+ def v1_model_info(self, pcfg: dict[str, Any], timeout: float = 3.0) -> dict[str, Any] | None:
265
+ return self.lifecycle_factory().v1_model_info(pcfg, timeout)
266
+
267
+ def loaded_instance_ids(self, pcfg: dict[str, Any], timeout: float = 3.0) -> list[str]:
268
+ return self.lifecycle_factory().loaded_instance_ids(pcfg, timeout)
269
+
270
+ def target_context(self, pcfg: dict[str, Any], info: dict[str, Any] | None = None) -> int | None:
271
+ return self.lifecycle_factory().target_context(pcfg, info)
272
+
273
+ def load_timeout_seconds(self, pcfg: dict[str, Any]) -> float:
274
+ return self.lifecycle_factory().load_timeout_seconds(pcfg)
275
+
276
+ def load_model(self, pcfg: dict[str, Any], context_length: int, timeout: float | None = None) -> dict[str, Any]:
277
+ return self.lifecycle_factory().load_model(pcfg, context_length, timeout)
278
+
279
+ def unload_loaded_instances(self, pcfg: dict[str, Any], timeout: float = 20.0) -> list[str]:
280
+ return self.lifecycle_factory().unload_loaded_instances(pcfg, timeout)
281
+
282
+ def load_response_context(self, response: dict[str, Any], fallback: int) -> int:
283
+ return self.lifecycle_factory().load_response_context(response, fallback)
284
+
285
+ def ensure_loaded_for_context(self, pcfg: dict[str, Any], timeout: float = 3.0) -> list[str]:
286
+ return self.lifecycle_factory().ensure_loaded_context(pcfg, timeout)
287
+
288
+ def apply_loaded_context_guard(self, pcfg: dict[str, Any], load: bool = False) -> list[str]:
289
+ return self.lifecycle_factory().context_guard(pcfg, load=load)
290
+
291
+
292
+ def discover_lm_studio_runtime(
293
+ provider_config: dict[str, Any],
294
+ services: LmStudioRuntimeServices,
295
+ *,
296
+ timeout: float = 3.0,
297
+ ) -> dict[str, Any] | None:
298
+ base = services.api_base(provider_config)
299
+ if not base:
300
+ return None
301
+ current = services.current_model("lm-studio", provider_config)
302
+ headers = services.model_list_headers("lm-studio", provider_config)
303
+ v0_url = services.join_url(base, "/api/v0/models")
304
+ try:
305
+ data = services.http_json(v0_url, headers=headers, timeout=timeout)
306
+ runtime = _runtime_from_v0(data, v0_url, current, services)
307
+ if runtime is not None:
308
+ return runtime
309
+ except Exception as exc:
310
+ services.log(
311
+ "WARN",
312
+ f"lm_studio_runtime_discovery_failed api=v0 error={type(exc).__name__}: {exc}",
313
+ )
314
+ v1_url = services.join_url(base, "/api/v1/models")
315
+ try:
316
+ data = services.http_json(v1_url, headers=headers, timeout=timeout)
317
+ return _runtime_from_v1(data, v1_url, current, services)
318
+ except Exception as exc:
319
+ services.log(
320
+ "WARN",
321
+ f"lm_studio_runtime_discovery_failed api=v1 error={type(exc).__name__}: {exc}",
322
+ )
323
+ return None
324
+
325
+
326
+ def _select_model(
327
+ items: Any,
328
+ current: str,
329
+ id_key: str,
330
+ matches: Callable[[str, str], bool],
331
+ ) -> dict[str, Any] | None:
332
+ if not isinstance(items, list):
333
+ return None
334
+ fallback: dict[str, Any] | None = None
335
+ for item in items:
336
+ if not isinstance(item, dict):
337
+ continue
338
+ if fallback is None:
339
+ fallback = item
340
+ if matches(str(item.get(id_key) or ""), current):
341
+ return item
342
+ return fallback if not current else None
343
+
344
+
345
+ def _runtime_from_v0(
346
+ data: Any,
347
+ models_url: str,
348
+ current: str,
349
+ services: LmStudioRuntimeServices,
350
+ ) -> dict[str, Any] | None:
351
+ items = data.get("data") if isinstance(data, dict) else None
352
+ selected = _select_model(items, current, "id", services.model_id_matches)
353
+ if selected is None:
354
+ return None
355
+ return {
356
+ "models_url": models_url,
357
+ "requested_model": current,
358
+ "runtime_model": str(selected.get("id") or ""),
359
+ "max_model_len": services.positive_int(selected.get("max_context_length"))
360
+ or services.model_context(selected),
361
+ "loaded_context_len": services.positive_int(selected.get("loaded_context_length")),
362
+ "state": selected.get("state"),
363
+ "capabilities": selected.get("capabilities"),
364
+ "type": selected.get("type"),
365
+ "root": selected.get("arch"),
366
+ }
367
+
368
+
369
+ def _runtime_from_v1(
370
+ data: Any,
371
+ models_url: str,
372
+ current: str,
373
+ services: LmStudioRuntimeServices,
374
+ ) -> dict[str, Any] | None:
375
+ items = data.get("models") if isinstance(data, dict) else None
376
+ selected = _select_model(items, current, "key", services.model_id_matches)
377
+ if selected is None:
378
+ return None
379
+ loaded_context = None
380
+ instance_ids: list[str] = []
381
+ instances = selected.get("loaded_instances")
382
+ if isinstance(instances, list) and instances:
383
+ for instance in instances:
384
+ if isinstance(instance, dict) and instance.get("id"):
385
+ instance_ids.append(str(instance["id"]))
386
+ config = instances[0].get("config") if isinstance(instances[0], dict) else None
387
+ if isinstance(config, dict):
388
+ loaded_context = services.positive_int(config.get("context_length"))
389
+ return {
390
+ "models_url": models_url,
391
+ "requested_model": current,
392
+ "runtime_model": str(selected.get("key") or ""),
393
+ "max_model_len": services.positive_int(selected.get("max_context_length"))
394
+ or services.model_context(selected),
395
+ "loaded_context_len": loaded_context,
396
+ "state": "loaded" if loaded_context else "not-loaded",
397
+ "instance_ids": instance_ids,
398
+ "capabilities": selected.get("capabilities"),
399
+ "type": selected.get("type"),
400
+ "root": selected.get("architecture"),
401
+ }
@@ -0,0 +1,144 @@
1
+ """Managed MCP configuration generation for web, Z.AI, and channel servers."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ import importlib.util
6
+ import os
7
+ from pathlib import Path
8
+ import sys
9
+ from typing import Any, Callable
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class ManagedMcpConfigPaths:
14
+ web_tools: Path
15
+ duckduckgo_compat: Path
16
+ zai: Path
17
+ channel: Path
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class ManagedMcpConfigPolicy:
22
+ router_base: str
23
+ zai_servers: tuple[tuple[str, str], ...]
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class ManagedMcpConfigPorts:
28
+ find_executable: Callable[[str], str | None]
29
+ save_json: Callable[[Path, dict[str, Any], str], None]
30
+ primary_api_key: Callable[[str, dict[str, Any]], str]
31
+ meaningful_key: Callable[[str], bool]
32
+ initialize_channel_cursor: Callable[[], Any]
33
+ log: Callable[[str, str], None]
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class ManagedMcpConfigService:
38
+ paths: ManagedMcpConfigPaths
39
+ policy: ManagedMcpConfigPolicy
40
+ ports: ManagedMcpConfigPorts
41
+
42
+ def write_web_tools(self, config: dict[str, Any]) -> Path:
43
+ web = config.get("web_search", {})
44
+ package = web.get("package") or "ddg-mcp-search"
45
+ npx = self.ports.find_executable("npx") or ("npx.cmd" if os.name == "nt" else "npx")
46
+ servers: dict[str, Any] = {"duckduckgo": {"command": npx, "args": ["-y", package]}}
47
+ if web.get("fetch_enabled", True):
48
+ command, arguments = self._fetch_command(web)
49
+ if command:
50
+ servers["web_fetch"] = {
51
+ "command": command,
52
+ "args": arguments,
53
+ "ciel_runtime_stdio": "jsonl",
54
+ }
55
+ else:
56
+ self.ports.log("WARN", "web_fetch_disabled_missing_runner install=uvx_or_uv")
57
+ self.ports.save_json(self.paths.web_tools, {"mcpServers": servers}, "web_tools_mcp_config")
58
+ return self.paths.web_tools
59
+
60
+ def write_duckduckgo_compat(self, config: dict[str, Any]) -> Path:
61
+ path = self.write_web_tools(config)
62
+ try:
63
+ self.paths.duckduckgo_compat.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
64
+ except (OSError, UnicodeError) as exc:
65
+ self.ports.log(
66
+ "WARN",
67
+ f"duckduckgo_mcp_compat_config_write_failed error={type(exc).__name__}: {exc}",
68
+ )
69
+ return path
70
+
71
+ def write_zai(self, provider: str, provider_config: dict[str, Any]) -> Path | None:
72
+ if provider != "zai" or not bool(provider_config.get("managed_mcp", True)):
73
+ return None
74
+ key = self.ports.primary_api_key(provider, provider_config)
75
+ if not self.ports.meaningful_key(key):
76
+ self.ports.log("WARN", "zai_mcp_config_skipped_missing_api_key")
77
+ return None
78
+ npx = self.ports.find_executable("npx") or ("npx.cmd" if os.name == "nt" else "npx")
79
+ servers: dict[str, Any] = {
80
+ "zai-mcp-server": {
81
+ "type": "stdio",
82
+ "command": npx,
83
+ "args": ["-y", "@z_ai/mcp-server@latest"],
84
+ "env": {"Z_AI_API_KEY": key, "Z_AI_MODE": "ZAI"},
85
+ }
86
+ }
87
+ for name, url in self.policy.zai_servers:
88
+ servers[name] = {
89
+ "type": "http",
90
+ "url": url,
91
+ "headers": {"Authorization": f"Bearer {key}"},
92
+ }
93
+ self.ports.save_json(self.paths.zai, {"mcpServers": servers}, "zai_mcp_config")
94
+ self.ports.log("INFO", f"zai_mcp_config_written servers={','.join(sorted(servers))}")
95
+ return self.paths.zai
96
+
97
+ def reset_zai_if_inactive(self, provider: str) -> None:
98
+ if provider == "zai":
99
+ return
100
+ try:
101
+ self.paths.zai.unlink()
102
+ self.ports.log("INFO", "zai_mcp_config_removed inactive_provider")
103
+ except FileNotFoundError:
104
+ return
105
+ except OSError as exc:
106
+ self.ports.log("WARN", f"zai_mcp_config_remove_failed error={type(exc).__name__}: {exc}")
107
+
108
+ def write_channel(self) -> Path:
109
+ data = {
110
+ "mcpServers": {
111
+ "ciel-runtime-router": {
112
+ "type": "sse",
113
+ "url": f"{self.policy.router_base}/ca/mcp/sse",
114
+ }
115
+ }
116
+ }
117
+ self.ports.save_json(self.paths.channel, data, "channel_mcp_config")
118
+ self.ports.initialize_channel_cursor()
119
+ return self.paths.channel
120
+
121
+ def _fetch_command(self, web: dict[str, Any]) -> tuple[str | None, list[Any]]:
122
+ arguments: list[Any] = [web.get("fetch_package") or "mcp-server-fetch"]
123
+ if web.get("fetch_user_agent"):
124
+ arguments.extend(["--user-agent", str(web["fetch_user_agent"])])
125
+ if web.get("fetch_ignore_robots_txt", False):
126
+ arguments.append("--ignore-robots-txt")
127
+ command = self.ports.find_executable("uvx")
128
+ if command:
129
+ return command, arguments
130
+ uv = self.ports.find_executable("uv")
131
+ if uv:
132
+ return uv, ["tool", "run", *arguments]
133
+ if importlib.util.find_spec("uv") is not None:
134
+ return sys.executable, ["-m", "uv", "tool", "run", *arguments]
135
+ pipx = self.ports.find_executable("pipx")
136
+ return (pipx, ["run", *arguments]) if pipx else (None, arguments)
137
+
138
+
139
+ __all__ = [
140
+ "ManagedMcpConfigPaths",
141
+ "ManagedMcpConfigPolicy",
142
+ "ManagedMcpConfigPorts",
143
+ "ManagedMcpConfigService",
144
+ ]
@@ -0,0 +1,151 @@
1
+ """Discovery of MCP servers restored from runtime-managed artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .mcp_inventory import McpInventoryService
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ManagedMcpDiscoveryPaths:
16
+ web_tools: Path
17
+ proxy: Path
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class ManagedMcpDiscoveryPorts:
22
+ read_generated: Callable[[Path, Path], dict[str, dict[str, Any]]]
23
+ load_json: Callable[[Path], Any]
24
+ log: Callable[[str, str], None]
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class ManagedMcpDiscoveryService:
29
+ paths: ManagedMcpDiscoveryPaths
30
+ ports: ManagedMcpDiscoveryPorts
31
+ native_channel_names: frozenset[str]
32
+
33
+ def discover(self, cwd: Path) -> dict[str, dict[str, Any]]:
34
+ servers = self.ports.read_generated(self.paths.web_tools, cwd)
35
+ if not self.paths.proxy.exists() or not self.paths.proxy.is_file():
36
+ return servers
37
+ try:
38
+ proxy_data = self.ports.load_json(self.paths.proxy)
39
+ except (OSError, UnicodeError, json.JSONDecodeError, TypeError) as exc:
40
+ self.ports.log(
41
+ "WARN",
42
+ f"managed_mcp_proxy_config_read_failed path={self.paths.proxy} "
43
+ f"error={type(exc).__name__}: {exc}",
44
+ )
45
+ return servers
46
+ proxy_servers = (
47
+ proxy_data.get("mcpServers")
48
+ if isinstance(proxy_data, dict)
49
+ else None
50
+ )
51
+ if not isinstance(proxy_servers, dict):
52
+ return servers
53
+ for raw_name, raw_entry in proxy_servers.items():
54
+ name = str(raw_name or "").strip()
55
+ if (
56
+ not name
57
+ or name.casefold() in self.native_channel_names
58
+ or not isinstance(raw_entry, dict)
59
+ ):
60
+ continue
61
+ restored = self._restore_proxy_entry(name, raw_entry)
62
+ if restored is None:
63
+ continue
64
+ restored_name, server = restored
65
+ if restored_name.casefold() not in self.native_channel_names:
66
+ servers.setdefault(restored_name, server)
67
+ return servers
68
+
69
+ def _restore_proxy_entry(
70
+ self, name: str, entry: dict[str, Any]
71
+ ) -> tuple[str, dict[str, Any]] | None:
72
+ args = entry.get("args")
73
+ if not isinstance(args, list):
74
+ return name, dict(entry)
75
+ arguments = [str(item) for item in args]
76
+ if "mcp-proxy" not in arguments or "--server-config" not in arguments:
77
+ return name, dict(entry)
78
+ try:
79
+ config_path = Path(
80
+ arguments[arguments.index("--server-config") + 1]
81
+ ).expanduser()
82
+ wrapped_name = (
83
+ arguments[arguments.index("--server-name") + 1].strip()
84
+ if "--server-name" in arguments
85
+ and arguments.index("--server-name") + 1 < len(arguments)
86
+ else name
87
+ )
88
+ wrapped_server = self.ports.load_json(config_path)
89
+ except (
90
+ OSError,
91
+ UnicodeError,
92
+ json.JSONDecodeError,
93
+ TypeError,
94
+ ValueError,
95
+ ) as exc:
96
+ self.ports.log(
97
+ "WARN",
98
+ f"managed_mcp_wrapped_config_read_failed server={name} "
99
+ f"error={type(exc).__name__}: {exc}",
100
+ )
101
+ return None
102
+ if not wrapped_name or not isinstance(wrapped_server, dict):
103
+ return None
104
+ restored = dict(wrapped_server)
105
+ restored.pop("ciel_runtime_disable_notification_stream", None)
106
+ return wrapped_name, restored
107
+
108
+
109
+ @dataclass(frozen=True, slots=True)
110
+ class NativeMcpConfigWriterPorts:
111
+ discover_user: Callable[..., dict[str, dict[str, Any]]]
112
+ discover_managed: Callable[[Path], dict[str, dict[str, Any]]]
113
+ save_json: Callable[[Path, dict[str, Any], str], None]
114
+ log: Callable[[str, str], None]
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class NativeMcpConfigWriter:
119
+ """Merge normalized user/managed MCP discovery into a native CLI artifact."""
120
+
121
+ path: Path
122
+ ports: NativeMcpConfigWriterPorts
123
+
124
+ def write(
125
+ self,
126
+ passthrough: list[str] | None = None,
127
+ cwd: Path | None = None,
128
+ home: Path | None = None,
129
+ ) -> Path | None:
130
+ working_directory = cwd or Path.cwd()
131
+ servers = self.ports.discover_user(
132
+ passthrough, working_directory, home
133
+ )
134
+ merged = McpInventoryService.merge(
135
+ servers, self.ports.discover_managed(working_directory)
136
+ )
137
+ for name in merged.duplicates:
138
+ self.ports.log(
139
+ "INFO", f"native_mcp_managed_duplicate_skipped server={name}"
140
+ )
141
+ servers = merged.servers
142
+ if not servers:
143
+ return None
144
+ self.ports.save_json(
145
+ self.path, {"mcpServers": servers}, "native_mcp_config"
146
+ )
147
+ self.ports.log(
148
+ "INFO",
149
+ f"native_mcp_config_written servers={','.join(sorted(servers))}",
150
+ )
151
+ return self.path