@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.
- package/README.md +132 -0
- package/ciel-runtime-menu.py +56 -6
- package/ciel_runtime.py +9379 -35909
- package/ciel_runtime_support/advisor_client.py +193 -0
- package/ciel_runtime_support/advisor_policy.py +320 -0
- package/ciel_runtime_support/advisor_refinement.py +160 -0
- package/ciel_runtime_support/advisor_request_builder.py +261 -0
- package/ciel_runtime_support/agy_installer.py +169 -0
- package/ciel_runtime_support/agy_mcp_restore.py +182 -0
- package/ciel_runtime_support/anthropic_model_policy.py +186 -0
- package/ciel_runtime_support/anthropic_response_writer.py +255 -0
- package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
- package/ciel_runtime_support/api_key_cooldown.py +159 -0
- package/ciel_runtime_support/architecture.py +488 -1
- package/ciel_runtime_support/architecture_budget.py +42 -0
- package/ciel_runtime_support/channel_backlog.py +90 -0
- package/ciel_runtime_support/channel_cli.py +119 -0
- package/ciel_runtime_support/channel_compact_injection.py +82 -0
- package/ciel_runtime_support/channel_compact_poll.py +67 -0
- package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
- package/ciel_runtime_support/channel_config_service.py +281 -0
- package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
- package/ciel_runtime_support/channel_connection_registry.py +128 -0
- package/ciel_runtime_support/channel_connection_worker.py +284 -0
- package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
- package/ciel_runtime_support/channel_cursor_repository.py +89 -0
- package/ciel_runtime_support/channel_cursor_service.py +178 -0
- package/ciel_runtime_support/channel_event_identity.py +212 -0
- package/ciel_runtime_support/channel_event_projection.py +315 -0
- package/ciel_runtime_support/channel_inflight.py +127 -0
- package/ciel_runtime_support/channel_injection.py +115 -0
- package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
- package/ciel_runtime_support/channel_launch_policy.py +180 -0
- package/ciel_runtime_support/channel_llm_context.py +156 -0
- package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
- package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
- package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
- package/ciel_runtime_support/channel_mcp_tools.py +240 -0
- package/ciel_runtime_support/channel_mcp_transport.py +394 -0
- package/ciel_runtime_support/channel_message_dedupe.py +65 -0
- package/ciel_runtime_support/channel_message_policy.py +256 -0
- package/ciel_runtime_support/channel_message_prompt.py +305 -0
- package/ciel_runtime_support/channel_message_repository.py +234 -0
- package/ciel_runtime_support/channel_notification_projection.py +217 -0
- package/ciel_runtime_support/channel_panel.py +162 -0
- package/ciel_runtime_support/channel_pending_injection.py +209 -0
- package/ciel_runtime_support/channel_pending_poll.py +109 -0
- package/ciel_runtime_support/channel_probe_cache.py +433 -0
- package/ciel_runtime_support/channel_probe_report.py +101 -0
- package/ciel_runtime_support/channel_runtime_environment.py +181 -0
- package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
- package/ciel_runtime_support/channel_session_repository.py +90 -0
- package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
- package/ciel_runtime_support/channel_terminal_input.py +277 -0
- package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
- package/ciel_runtime_support/channel_tool_context.py +166 -0
- package/ciel_runtime_support/channel_transcript.py +414 -0
- package/ciel_runtime_support/channel_transcript_repository.py +96 -0
- package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
- package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
- package/ciel_runtime_support/chat_files.py +138 -0
- package/ciel_runtime_support/chat_http_controller.py +235 -0
- package/ciel_runtime_support/claude_environment.py +375 -0
- package/ciel_runtime_support/claude_router.py +247 -193
- package/ciel_runtime_support/cli_dispatch.py +792 -0
- package/ciel_runtime_support/cli_parser.py +165 -0
- package/ciel_runtime_support/cli_usage.py +100 -0
- package/ciel_runtime_support/codex_app_server.py +20 -5
- package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
- package/ciel_runtime_support/codex_cli.py +42 -6
- package/ciel_runtime_support/codex_config.py +323 -0
- package/ciel_runtime_support/codex_launch_configuration.py +240 -0
- package/ciel_runtime_support/codex_launch_policy.py +66 -0
- package/ciel_runtime_support/codex_mcp_integration.py +195 -0
- package/ciel_runtime_support/codex_mcp_restore.py +304 -0
- package/ciel_runtime_support/codex_model_catalog.py +133 -0
- package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
- package/ciel_runtime_support/codex_router.py +147 -1
- package/ciel_runtime_support/codex_session_repository.py +115 -0
- package/ciel_runtime_support/codex_session_selection.py +114 -0
- package/ciel_runtime_support/command_asset_installer.py +103 -0
- package/ciel_runtime_support/compatibility_probe.py +295 -0
- package/ciel_runtime_support/compatibility_protocol.py +251 -0
- package/ciel_runtime_support/compatibility_runtime.py +166 -0
- package/ciel_runtime_support/compatibility_test.py +370 -0
- package/ciel_runtime_support/config_migrations.py +307 -0
- package/ciel_runtime_support/config_repository.py +175 -0
- package/ciel_runtime_support/config_value_codec.py +64 -0
- package/ciel_runtime_support/configuration_cli.py +374 -0
- package/ciel_runtime_support/context_compaction.py +280 -0
- package/ciel_runtime_support/context_setup.py +208 -0
- package/ciel_runtime_support/context_summary_policy.py +392 -0
- package/ciel_runtime_support/credential_cli.py +104 -0
- package/ciel_runtime_support/credential_management.py +261 -0
- package/ciel_runtime_support/credentials.py +269 -0
- package/ciel_runtime_support/executable_discovery.py +141 -0
- package/ciel_runtime_support/github_copilot_oauth.py +335 -0
- package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
- package/ciel_runtime_support/header_forwarding.py +73 -0
- package/ciel_runtime_support/headless_config.py +221 -0
- package/ciel_runtime_support/http_response.py +129 -0
- package/ciel_runtime_support/install_diagnostics.py +149 -0
- package/ciel_runtime_support/kimi_identity.py +123 -0
- package/ciel_runtime_support/launch_diagnostics.py +204 -0
- package/ciel_runtime_support/launch_state.py +127 -0
- package/ciel_runtime_support/live_api_key_controller.py +58 -0
- package/ciel_runtime_support/llm_config_http.py +148 -0
- package/ciel_runtime_support/llm_option_config.py +259 -0
- package/ciel_runtime_support/llm_presentation_data.py +447 -0
- package/ciel_runtime_support/llm_presets.py +773 -0
- package/ciel_runtime_support/lm_studio_runtime.py +401 -0
- package/ciel_runtime_support/managed_mcp_config.py +144 -0
- package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
- package/ciel_runtime_support/managed_service_cleanup.py +89 -0
- package/ciel_runtime_support/mcp_config_reader.py +230 -0
- package/ciel_runtime_support/mcp_http_proxy.py +607 -0
- package/ciel_runtime_support/mcp_inventory.py +59 -0
- package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
- package/ciel_runtime_support/mcp_probe_codec.py +136 -0
- package/ciel_runtime_support/mcp_probe_transport.py +328 -0
- package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
- package/ciel_runtime_support/mcp_proxy_config.py +107 -0
- package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
- package/ciel_runtime_support/mcp_proxy_process.py +560 -0
- package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
- package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
- package/ciel_runtime_support/mcp_transport.py +146 -0
- package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
- package/ciel_runtime_support/model_catalog_projection.py +61 -0
- package/ciel_runtime_support/model_context_hints.py +109 -0
- package/ciel_runtime_support/model_panel.py +147 -0
- package/ciel_runtime_support/model_registry_repository.py +231 -0
- package/ciel_runtime_support/npm_runtime.py +191 -0
- package/ciel_runtime_support/ollama_catalog.py +462 -0
- package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
- package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
- package/ciel_runtime_support/ollama_context_sync.py +87 -0
- package/ciel_runtime_support/ollama_forwarding.py +449 -0
- package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
- package/ciel_runtime_support/openai_chat_router.py +64 -0
- package/ciel_runtime_support/openai_forwarding.py +194 -0
- package/ciel_runtime_support/openai_responses_router.py +291 -0
- package/ciel_runtime_support/openai_responses_stream.py +135 -0
- package/ciel_runtime_support/output_budget.py +89 -0
- package/ciel_runtime_support/package_lifecycle.py +217 -0
- package/ciel_runtime_support/plan_artifact_controller.py +104 -0
- package/ciel_runtime_support/prelaunch.py +959 -0
- package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
- package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
- package/ciel_runtime_support/prelaunch_terminal.py +764 -0
- package/ciel_runtime_support/process_control.py +708 -0
- package/ciel_runtime_support/prompt_compaction.py +322 -0
- package/ciel_runtime_support/prompt_injection.py +176 -0
- package/ciel_runtime_support/protocols/__init__.py +24 -0
- package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
- package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
- package/ciel_runtime_support/protocols/chat_projection.py +315 -0
- package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
- package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
- package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
- package/ciel_runtime_support/protocols/ollama_response.py +231 -0
- package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
- package/ciel_runtime_support/protocols/openai_responses.py +271 -0
- package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
- package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
- package/ciel_runtime_support/provider_adapters.py +160 -0
- package/ciel_runtime_support/provider_catalog_sources.py +316 -0
- package/ciel_runtime_support/provider_choice.py +201 -0
- package/ciel_runtime_support/provider_compatibility.py +165 -0
- package/ciel_runtime_support/provider_config_mutations.py +361 -0
- package/ciel_runtime_support/provider_configuration_service.py +162 -0
- package/ciel_runtime_support/provider_context.py +308 -0
- package/ciel_runtime_support/provider_contract_projection.py +73 -0
- package/ciel_runtime_support/provider_descriptor.py +82 -0
- package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
- package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
- package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
- package/ciel_runtime_support/provider_limits.py +457 -0
- package/ciel_runtime_support/provider_model_identity.py +139 -0
- package/ciel_runtime_support/provider_model_selection.py +431 -0
- package/ciel_runtime_support/provider_model_specs.py +142 -0
- package/ciel_runtime_support/provider_models.py +263 -0
- package/ciel_runtime_support/provider_network.py +176 -0
- package/ciel_runtime_support/provider_option_cli.py +238 -0
- package/ciel_runtime_support/provider_option_panel.py +275 -0
- package/ciel_runtime_support/provider_option_status.py +192 -0
- package/ciel_runtime_support/provider_policy.py +101 -0
- package/ciel_runtime_support/provider_query_policy.py +67 -0
- package/ciel_runtime_support/provider_readiness.py +112 -0
- package/ciel_runtime_support/provider_request_access.py +131 -0
- package/ciel_runtime_support/provider_request_builder.py +250 -0
- package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
- package/ciel_runtime_support/provider_runtime_info.py +113 -0
- package/ciel_runtime_support/provider_runtime_modes.py +150 -0
- package/ciel_runtime_support/provider_sampling_policy.py +46 -0
- package/ciel_runtime_support/provider_status.py +145 -0
- package/ciel_runtime_support/provider_timeout_policy.py +184 -0
- package/ciel_runtime_support/provider_tool_policy.py +145 -0
- package/ciel_runtime_support/providers/__init__.py +65 -0
- package/ciel_runtime_support/providers/anthropic.py +160 -0
- package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
- package/ciel_runtime_support/providers/base.py +232 -0
- package/ciel_runtime_support/providers/catalog.py +326 -0
- package/ciel_runtime_support/providers/cloud.py +194 -0
- package/ciel_runtime_support/providers/constants.py +54 -0
- package/ciel_runtime_support/providers/deepseek.py +115 -0
- package/ciel_runtime_support/providers/fireworks.py +158 -0
- package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
- package/ciel_runtime_support/providers/kimi.py +297 -0
- package/ciel_runtime_support/providers/lm_studio.py +76 -0
- package/ciel_runtime_support/providers/meta.py +257 -0
- package/ciel_runtime_support/providers/native.py +194 -0
- package/ciel_runtime_support/providers/nim.py +69 -0
- package/ciel_runtime_support/providers/nvidia.py +158 -0
- package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
- package/ciel_runtime_support/providers/ollama.py +181 -0
- package/ciel_runtime_support/providers/ollama_context.py +195 -0
- package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
- package/ciel_runtime_support/providers/opencode.py +220 -0
- package/ciel_runtime_support/providers/opencode_go.py +37 -0
- package/ciel_runtime_support/providers/openrouter.py +57 -0
- package/ciel_runtime_support/providers/vllm.py +65 -0
- package/ciel_runtime_support/providers/zai.py +119 -0
- package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
- package/ciel_runtime_support/rate_limit_policy.py +117 -0
- package/ciel_runtime_support/rate_limit_repository.py +154 -0
- package/ciel_runtime_support/registry.py +46 -0
- package/ciel_runtime_support/request_shortcuts.py +253 -0
- package/ciel_runtime_support/request_trace.py +323 -0
- package/ciel_runtime_support/response_collection.py +209 -0
- package/ciel_runtime_support/router_access.py +238 -0
- package/ciel_runtime_support/router_client_lifecycle.py +366 -0
- package/ciel_runtime_support/router_health_policy.py +101 -0
- package/ciel_runtime_support/router_http.py +513 -0
- package/ciel_runtime_support/router_process_lifecycle.py +401 -0
- package/ciel_runtime_support/router_rate_limit_service.py +285 -0
- package/ciel_runtime_support/router_server_runtime.py +103 -0
- package/ciel_runtime_support/router_shortcuts.py +201 -0
- package/ciel_runtime_support/routing_fallback.py +73 -0
- package/ciel_runtime_support/runtime_activity_repository.py +143 -0
- package/ciel_runtime_support/runtime_adapters.py +104 -0
- package/ciel_runtime_support/runtime_command_factory.py +73 -0
- package/ciel_runtime_support/runtime_compatibility.py +50 -0
- package/ciel_runtime_support/runtime_constants.py +178 -0
- package/ciel_runtime_support/runtime_launch.py +1602 -0
- package/ciel_runtime_support/runtime_llm_options.py +312 -0
- package/ciel_runtime_support/runtime_logging.py +161 -0
- package/ciel_runtime_support/runtime_paths.py +157 -0
- package/ciel_runtime_support/runtime_restart.py +84 -0
- package/ciel_runtime_support/runtime_upgrade.py +149 -0
- package/ciel_runtime_support/secure_json_repository.py +55 -0
- package/ciel_runtime_support/session_import.py +356 -0
- package/ciel_runtime_support/settings_repository.py +8 -0
- package/ciel_runtime_support/slash_command_assets.py +211 -0
- package/ciel_runtime_support/sse_stream.py +57 -0
- package/ciel_runtime_support/sse_trace.py +225 -0
- package/ciel_runtime_support/statusline_script.py +593 -0
- package/ciel_runtime_support/statusline_settings.py +53 -0
- package/ciel_runtime_support/stream_chunk_policy.py +18 -0
- package/ciel_runtime_support/streaming_anthropic.py +1955 -0
- package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
- package/ciel_runtime_support/terminal_platform_io.py +127 -0
- package/ciel_runtime_support/timeout_profile.py +196 -0
- package/ciel_runtime_support/tool_dialects.py +85 -0
- package/ciel_runtime_support/tool_exposure_policy.py +63 -0
- package/ciel_runtime_support/tool_guard_hooks.py +218 -0
- package/ciel_runtime_support/tool_request_projection.py +96 -0
- package/ciel_runtime_support/tool_schema.py +483 -0
- package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
- package/ciel_runtime_support/ui_text.py +266 -0
- package/ciel_runtime_support/upstream_error_policy.py +104 -0
- package/ciel_runtime_support/upstream_retry.py +419 -0
- package/ciel_runtime_support/upstream_stream_io.py +106 -0
- package/ciel_runtime_support/usage_events.py +96 -0
- package/ciel_runtime_support/visible_stream_filters.py +130 -0
- package/ciel_runtime_support/web_endpoints.py +447 -0
- package/ciel_runtime_support/web_ui.py +915 -0
- package/ciel_runtime_support/web_ui_controller.py +189 -0
- package/ciel_runtime_support/windows_console_input.py +137 -0
- package/ciel_runtime_support/windows_console_mode.py +112 -0
- package/docs/Architecture.md +54 -0
- package/docs/Configuration.md +17 -0
- package/docs/Module-Map.md +1093 -26
- package/docs/Providers.md +46 -1
- package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
- package/npm-bin/run-ciel-runtime.js +22 -2
- 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"]
|