@oneciel-ai/claude-any 0.1.76 → 0.1.78

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 (2) hide show
  1. package/claude_any.py +1087 -18
  2. package/package.json +1 -1
package/claude_any.py CHANGED
@@ -24,7 +24,7 @@ import urllib.request
24
24
  from email.utils import parsedate_to_datetime
25
25
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
26
26
  from pathlib import Path
27
- from typing import Any, Callable
27
+ from typing import Any, Callable, Iterable
28
28
 
29
29
  from claude_any_support.observability import EventBus, render_events_html
30
30
  from claude_any_support.transcript_filter import (
@@ -57,6 +57,8 @@ PID_PATH = CONFIG_DIR / "router.pid"
57
57
  MODEL_LIST_CACHE_PATH = CONFIG_DIR / "model-list-cache.json"
58
58
  WEB_TOOLS_MCP_CONFIG = CONFIG_DIR / "web-tools-mcp.json"
59
59
  DUCKDUCKGO_MCP_CONFIG = CONFIG_DIR / "duckduckgo-mcp.json"
60
+ CHANNEL_MCP_CONFIG = CONFIG_DIR / "channel-mcp.json"
61
+ MCP_PROXY_CONFIG = CONFIG_DIR / "mcp-proxy.json"
60
62
  ROUTER_HOST = os.environ.get("CLAUDE_ANY_ROUTER_CLIENT_HOST", "127.0.0.1").strip() or "127.0.0.1"
61
63
  ROUTER_PORT = 8799
62
64
  ROUTER_BASE = f"http://{ROUTER_HOST}:{ROUTER_PORT}"
@@ -102,7 +104,7 @@ OFFICIAL_CHANNEL_PLUGINS = {
102
104
  "fakechat": "plugin:fakechat@claude-plugins-official",
103
105
  }
104
106
  APP_NAME = "Claude Any"
105
- VERSION = "0.1.76"
107
+ VERSION = "0.1.78"
106
108
  CREDITS = "Credits: One Ciel LLC"
107
109
 
108
110
  LOG_LEVELS = {"SILENT": 0, "ERROR": 1, "WARN": 2, "INFO": 3, "DEBUG": 4, "TRACE": 5}
@@ -120,6 +122,8 @@ _CHAT_CONDITION = threading.Condition()
120
122
  _CHAT_NEXT_ID: int | None = None
121
123
  _CHANNEL_SSE_LOCK = threading.Lock()
122
124
  _CHANNEL_SSE_CONNECTIONS: dict[str, dict[str, Any]] = {}
125
+ _CHANNEL_MCP_LOCK = threading.Lock()
126
+ _CHANNEL_MCP_SESSIONS: dict[str, dict[str, Any]] = {}
123
127
  EVENT_BUS = EventBus()
124
128
  ADVISOR_FEEDBACK_MARKER = "CLAUDE_ANY_ADVISOR_FEEDBACK"
125
129
  PLAN_GUARD_MARKER = "[claude-any-plan-guard]"
@@ -1048,6 +1052,7 @@ UI_TEXT = {
1048
1052
  "advisor_model": "Advisor Model",
1049
1053
  "test": "Test compatibility",
1050
1054
  "options": "LLM options",
1055
+ "log_level": "Log level",
1051
1056
  "presets": "LLM presets",
1052
1057
  "context_setup": "Context setup",
1053
1058
  "timeout_preset": "Timeout preset",
@@ -1068,6 +1073,7 @@ UI_TEXT = {
1068
1073
  "advisor_model": "Advisor Model",
1069
1074
  "test": "호환성 테스트",
1070
1075
  "options": "LLM 옵션",
1076
+ "log_level": "로그 레벨",
1071
1077
  "presets": "LLM 프리셋",
1072
1078
  "context_setup": "컨텍스트 설정",
1073
1079
  "timeout_preset": "타임아웃 프리셋",
@@ -1088,6 +1094,7 @@ UI_TEXT = {
1088
1094
  "advisor_model": "Advisor Model",
1089
1095
  "test": "互換性テスト",
1090
1096
  "options": "LLMオプション",
1097
+ "log_level": "ログレベル",
1091
1098
  "presets": "LLMプリセット",
1092
1099
  "context_setup": "コンテキスト設定",
1093
1100
  "timeout_preset": "timeout プリセット",
@@ -1108,6 +1115,7 @@ UI_TEXT = {
1108
1115
  "advisor_model": "Advisor Model",
1109
1116
  "test": "兼容性测试",
1110
1117
  "options": "LLM 选项",
1118
+ "log_level": "日志级别",
1111
1119
  "presets": "LLM 预设",
1112
1120
  "context_setup": "上下文设置",
1113
1121
  "timeout_preset": "Timeout 预设",
@@ -2247,6 +2255,74 @@ def current_log_level() -> int:
2247
2255
  return level
2248
2256
 
2249
2257
 
2258
+ def reset_log_level_cache() -> None:
2259
+ _LOG_LEVEL_CACHE.update({"value": None, "checked_at": 0.0, "file_mtime": 0.0})
2260
+
2261
+
2262
+ def log_level_name(value: int | None = None) -> str:
2263
+ if value is None:
2264
+ value = current_log_level()
2265
+ return str(LOG_LEVEL_NAMES.get(int(value), value))
2266
+
2267
+
2268
+ def log_level_source() -> str:
2269
+ if LOG_LEVEL_PATH.exists():
2270
+ return "file"
2271
+ if os.environ.get("CLAUDE_ANY_LOG_LEVEL", "").strip():
2272
+ return "env"
2273
+ return "default"
2274
+
2275
+
2276
+ def log_level_status() -> str:
2277
+ return f"{log_level_name()} ({log_level_source()})"
2278
+
2279
+
2280
+ def normalize_log_level(value: str) -> str | None:
2281
+ raw = str(value or "").strip()
2282
+ if not raw:
2283
+ raise ValueError("log level is empty")
2284
+ upper = raw.upper()
2285
+ aliases = {
2286
+ "OFF": "SILENT",
2287
+ "NONE": "SILENT",
2288
+ "QUIET": "SILENT",
2289
+ "WARNING": "WARN",
2290
+ "WARNINGS": "WARN",
2291
+ }
2292
+ upper = aliases.get(upper, upper)
2293
+ if upper in ("DEFAULT", "RESET", "UNSET", "AUTO"):
2294
+ return None
2295
+ if upper in LOG_LEVELS:
2296
+ return upper
2297
+ if upper.isdigit():
2298
+ numeric = max(0, min(5, int(upper)))
2299
+ return LOG_LEVEL_NAMES[numeric]
2300
+ raise ValueError(f"unknown log level: {value}")
2301
+
2302
+
2303
+ def set_log_level_config(value: str) -> list[str]:
2304
+ try:
2305
+ level = normalize_log_level(value)
2306
+ except ValueError as exc:
2307
+ known = ", ".join(LOG_LEVELS)
2308
+ return [f"{exc}. Known levels: {known}, DEFAULT."]
2309
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
2310
+ if level is None:
2311
+ try:
2312
+ LOG_LEVEL_PATH.unlink()
2313
+ except FileNotFoundError:
2314
+ pass
2315
+ reset_log_level_cache()
2316
+ return [f"Log level reset to {log_level_status()}."]
2317
+ LOG_LEVEL_PATH.write_text(level + "\n", encoding="utf-8")
2318
+ try:
2319
+ os.chmod(LOG_LEVEL_PATH, 0o600)
2320
+ except Exception:
2321
+ pass
2322
+ reset_log_level_cache()
2323
+ return [f"Log level set to {level}."]
2324
+
2325
+
2250
2326
  def router_log(level: str, message: str) -> None:
2251
2327
  """Append a line to router.log if the active level allows it.
2252
2328
  Rotates router.log when it exceeds ROUTER_LOG_MAX_BYTES."""
@@ -4677,6 +4753,134 @@ def stop_channel_sse_connection(name: str | None = None) -> dict[str, Any]:
4677
4753
  return {"stopped": stopped, "connections": channel_sse_status()}
4678
4754
 
4679
4755
 
4756
+ def _channel_mcp_session_id() -> str:
4757
+ return f"s{os.getpid()}-{time.time_ns()}"
4758
+
4759
+
4760
+ def _channel_mcp_notification(message: dict[str, Any]) -> dict[str, Any]:
4761
+ text = re.sub(r"\s+", " ", str(message.get("message") or "")).strip()
4762
+ channel = str(message.get("channel") or "default")
4763
+ sender = str(message.get("sender_id") or "channel")
4764
+ prefix = f"[{channel}] {sender}"
4765
+ content = f"{prefix}: {text}" if text else prefix
4766
+ meta = message.get("meta") if isinstance(message.get("meta"), dict) else {}
4767
+ merged_meta = {
4768
+ **meta,
4769
+ "claude_any_message_id": message.get("id"),
4770
+ "channel": channel,
4771
+ "sender_id": sender,
4772
+ "thread_id": message.get("thread_id"),
4773
+ "parent_id": message.get("parent_id"),
4774
+ }
4775
+ return {
4776
+ "jsonrpc": "2.0",
4777
+ "method": "notifications/claude/channel",
4778
+ "params": {
4779
+ "content": content,
4780
+ "meta": merged_meta,
4781
+ },
4782
+ }
4783
+
4784
+
4785
+ def _write_sse_event(handler: BaseHTTPRequestHandler, event: str, data: Any, event_id: int | None = None) -> None:
4786
+ if event_id is not None:
4787
+ handler.wfile.write(f"id: {event_id}\n".encode("utf-8"))
4788
+ handler.wfile.write(f"event: {event}\n".encode("utf-8"))
4789
+ payload = data if isinstance(data, str) else json.dumps(data, ensure_ascii=False, separators=(",", ":"))
4790
+ for line in payload.splitlines() or [""]:
4791
+ handler.wfile.write(f"data: {line}\n".encode("utf-8"))
4792
+ handler.wfile.write(b"\n")
4793
+ handler.wfile.flush()
4794
+
4795
+
4796
+ def handle_channel_mcp_get(handler: BaseHTTPRequestHandler, path: str) -> bool:
4797
+ if path == "/ca/mcp/health":
4798
+ write_json(handler, {"ok": True, "name": "claude-any-router", "sse": "/ca/mcp/sse"})
4799
+ return True
4800
+ if path != "/ca/mcp/sse":
4801
+ return False
4802
+ session = _channel_mcp_session_id()
4803
+ last_id = _chat_init_next_id() - 1
4804
+ with _CHANNEL_MCP_LOCK:
4805
+ _CHANNEL_MCP_SESSIONS[session] = {"created_at": time.time(), "last_id": last_id, "initialized": False}
4806
+ handler.send_response(200)
4807
+ handler.send_header("content-type", "text/event-stream")
4808
+ handler.send_header("cache-control", "no-cache")
4809
+ handler.send_header("connection", "close")
4810
+ handler.end_headers()
4811
+ _write_sse_event(handler, "endpoint", f"/ca/mcp/messages?session={urllib.parse.quote(session)}")
4812
+ try:
4813
+ while True:
4814
+ with _CHANNEL_MCP_LOCK:
4815
+ state = _CHANNEL_MCP_SESSIONS.get(session)
4816
+ if not state:
4817
+ return True
4818
+ last_id = int(state.get("last_id") or 0)
4819
+ messages = read_chat_messages(last_id, None, None, 100)
4820
+ if messages:
4821
+ for message in messages:
4822
+ last_id = max(last_id, int(message.get("id") or 0))
4823
+ _write_sse_event(handler, "message", _channel_mcp_notification(message), last_id)
4824
+ with _CHANNEL_MCP_LOCK:
4825
+ state = _CHANNEL_MCP_SESSIONS.get(session)
4826
+ if state:
4827
+ state["last_id"] = last_id
4828
+ continue
4829
+ handler.wfile.write(b": keepalive\n\n")
4830
+ handler.wfile.flush()
4831
+ with _CHAT_CONDITION:
4832
+ _CHAT_CONDITION.wait(timeout=15.0)
4833
+ except (BrokenPipeError, ConnectionError, ConnectionResetError):
4834
+ return True
4835
+ finally:
4836
+ with _CHANNEL_MCP_LOCK:
4837
+ _CHANNEL_MCP_SESSIONS.pop(session, None)
4838
+
4839
+
4840
+ def handle_channel_mcp_post(handler: BaseHTTPRequestHandler, path: str, body: dict[str, Any]) -> bool:
4841
+ if path != "/ca/mcp/messages":
4842
+ return False
4843
+ params = _query_params(handler)
4844
+ session = _first_param(params, "session")
4845
+ with _CHANNEL_MCP_LOCK:
4846
+ if session and session in _CHANNEL_MCP_SESSIONS:
4847
+ _CHANNEL_MCP_SESSIONS[session]["last_seen_at"] = time.time()
4848
+ method = str(body.get("method") or "")
4849
+ request_id = body.get("id")
4850
+ if method == "initialize":
4851
+ protocol = "2024-11-05"
4852
+ req_params = body.get("params") if isinstance(body.get("params"), dict) else {}
4853
+ if req_params.get("protocolVersion"):
4854
+ protocol = str(req_params["protocolVersion"])
4855
+ with _CHANNEL_MCP_LOCK:
4856
+ if session and session in _CHANNEL_MCP_SESSIONS:
4857
+ _CHANNEL_MCP_SESSIONS[session]["initialized"] = True
4858
+ write_json(
4859
+ handler,
4860
+ {
4861
+ "jsonrpc": "2.0",
4862
+ "id": request_id,
4863
+ "result": {
4864
+ "protocolVersion": protocol,
4865
+ "capabilities": {"tools": {"listChanged": False}},
4866
+ "serverInfo": {"name": "claude-any-router", "version": VERSION},
4867
+ },
4868
+ },
4869
+ )
4870
+ return True
4871
+ if method == "tools/list":
4872
+ write_json(handler, {"jsonrpc": "2.0", "id": request_id, "result": {"tools": []}})
4873
+ return True
4874
+ if method == "ping":
4875
+ write_json(handler, {"jsonrpc": "2.0", "id": request_id, "result": {}})
4876
+ return True
4877
+ if request_id is None:
4878
+ write_json(handler, {"ok": True})
4879
+ return True
4880
+ write_json(handler, {"jsonrpc": "2.0", "id": request_id, "result": {}})
4881
+ return True
4882
+
4883
+
4680
4884
  def _query_params(handler: BaseHTTPRequestHandler) -> dict[str, list[str]]:
4681
4885
  return urllib.parse.parse_qs(urllib.parse.urlparse(handler.path).query, keep_blank_values=True)
4682
4886
 
@@ -8112,6 +8316,21 @@ class RouterHandler(BaseHTTPRequestHandler):
8112
8316
  except Exception:
8113
8317
  pass
8114
8318
 
8319
+ def do_HEAD(self) -> None:
8320
+ cfg = load_config()
8321
+ if reject_external_router_request(self, cfg):
8322
+ return
8323
+ parsed = urllib.parse.urlparse(self.path)
8324
+ path = parsed.path
8325
+ if path in ("/", "/health", "/healthz"):
8326
+ self.send_response(200)
8327
+ self.send_header("content-type", "text/plain; charset=utf-8")
8328
+ self.end_headers()
8329
+ return
8330
+ self.send_response(404)
8331
+ self.send_header("content-type", "application/json")
8332
+ self.end_headers()
8333
+
8115
8334
  def do_GET(self) -> None:
8116
8335
  parsed = urllib.parse.urlparse(self.path)
8117
8336
  path = parsed.path
@@ -8123,6 +8342,8 @@ class RouterHandler(BaseHTTPRequestHandler):
8123
8342
  return
8124
8343
  if handle_llm_config_get(self, path):
8125
8344
  return
8345
+ if handle_channel_mcp_get(self, path):
8346
+ return
8126
8347
  if handle_chat_get(self, path) or handle_plan_get(self, path):
8127
8348
  return
8128
8349
  provider, pcfg = get_current_provider(cfg)
@@ -8153,6 +8374,8 @@ class RouterHandler(BaseHTTPRequestHandler):
8153
8374
  body = parse_json_body(raw)
8154
8375
  if handle_llm_config_post(self, path, body):
8155
8376
  return
8377
+ if handle_channel_mcp_post(self, path, body):
8378
+ return
8156
8379
  if handle_chat_post(self, path, body) or handle_plan_post(self, path, body):
8157
8380
  return
8158
8381
  provider, pcfg = get_current_provider(cfg)
@@ -8645,6 +8868,7 @@ def status_lines() -> list[str]:
8645
8868
  *([f"request_timeout_ms: {pcfg.get('request_timeout_ms', 'default')}"] if provider in ("vllm", "nvidia-hosted", "self-hosted-nim") else []),
8646
8869
  *([f"stream_idle_timeout_ms: {pcfg.get('stream_idle_timeout_ms', 'auto')}"] if provider in ("vllm", "nvidia-hosted", "self-hosted-nim") else []),
8647
8870
  f"claude_model: {current_upstream_model_id(provider, pcfg) if direct_native else current_alias(cfg)}",
8871
+ f"log_level: {log_level_status()}",
8648
8872
  f"channels: {channel_status_text(cfg)}",
8649
8873
  f"router: {'bypassed for native provider compatibility' if direct_native else (('up' if router_up() else 'down') + ' ' + ROUTER_BASE)}",
8650
8874
  f"config: {CONFIG_PATH}",
@@ -8655,6 +8879,20 @@ def cmd_status(_: argparse.Namespace) -> None:
8655
8879
  print("\n".join(status_lines()))
8656
8880
 
8657
8881
 
8882
+ def cmd_log_level(args: argparse.Namespace) -> None:
8883
+ value = getattr(args, "value", None)
8884
+ if not value:
8885
+ print(f"log_level: {log_level_status()}")
8886
+ for numeric in sorted(LOG_LEVEL_NAMES):
8887
+ name = LOG_LEVEL_NAMES[numeric]
8888
+ mark = "*" if name == log_level_name() else " "
8889
+ print(f" {mark} {name:<6} {numeric}")
8890
+ print(" DEFAULT reset to environment/default")
8891
+ return
8892
+ for line in set_log_level_config(str(value)):
8893
+ print(line)
8894
+
8895
+
8658
8896
  def cmd_language(args: argparse.Namespace) -> None:
8659
8897
  cfg = load_config()
8660
8898
  if not args.value:
@@ -8759,6 +8997,298 @@ def channel_specs(cfg: dict[str, Any] | None = None) -> list[str]:
8759
8997
  return channels
8760
8998
 
8761
8999
 
9000
+ def _dedupe_strings(values: Iterable[str]) -> list[str]:
9001
+ out: list[str] = []
9002
+ seen: set[str] = set()
9003
+ for value in values:
9004
+ text = str(value or "").strip()
9005
+ if not text or text in seen:
9006
+ continue
9007
+ seen.add(text)
9008
+ out.append(text)
9009
+ return out
9010
+
9011
+
9012
+ def _path_for_compare(path: Path | str) -> str:
9013
+ try:
9014
+ return str(Path(path).expanduser().resolve()).replace("\\", "/").rstrip("/").casefold()
9015
+ except Exception:
9016
+ return str(path).replace("\\", "/").rstrip("/").casefold()
9017
+
9018
+
9019
+ def _project_key_matches_cwd(project_key: str, cwd: Path) -> bool:
9020
+ key = str(project_key or "").strip()
9021
+ if not key:
9022
+ return False
9023
+ try:
9024
+ project_path = Path(key).expanduser()
9025
+ except Exception:
9026
+ return False
9027
+ if not project_path.is_absolute():
9028
+ return False
9029
+ project = _path_for_compare(project_path)
9030
+ current = _path_for_compare(cwd)
9031
+ return current == project or current.startswith(project + "/")
9032
+
9033
+
9034
+ def _mcp_server_names_from_mapping(mapping: Any) -> list[str]:
9035
+ if not isinstance(mapping, dict):
9036
+ return []
9037
+ names: list[str] = []
9038
+ for key in ("mcpServers", "servers"):
9039
+ servers = mapping.get(key)
9040
+ if isinstance(servers, dict):
9041
+ names.extend(str(name).strip() for name in servers if str(name).strip())
9042
+ return _dedupe_strings(names)
9043
+
9044
+
9045
+ def _mcp_servers_from_mapping(mapping: Any) -> list[tuple[str, dict[str, Any]]]:
9046
+ if not isinstance(mapping, dict):
9047
+ return []
9048
+ found: list[tuple[str, dict[str, Any]]] = []
9049
+ seen: set[str] = set()
9050
+ for key in ("mcpServers", "servers"):
9051
+ servers = mapping.get(key)
9052
+ if not isinstance(servers, dict):
9053
+ continue
9054
+ for raw_name, raw_server in servers.items():
9055
+ name = str(raw_name or "").strip()
9056
+ if not name or name in seen or not isinstance(raw_server, dict):
9057
+ continue
9058
+ seen.add(name)
9059
+ found.append((name, dict(raw_server)))
9060
+ return found
9061
+
9062
+
9063
+ def _read_mcp_server_names_from_json(path: Path, cwd: Path) -> list[str]:
9064
+ try:
9065
+ data = json.loads(path.read_text(encoding="utf-8"))
9066
+ except Exception:
9067
+ return []
9068
+ names = _mcp_server_names_from_mapping(data)
9069
+ if path.name == ".claude.json" and isinstance(data, dict):
9070
+ projects = data.get("projects")
9071
+ if isinstance(projects, dict):
9072
+ for project_key, project_data in projects.items():
9073
+ if _project_key_matches_cwd(str(project_key), cwd):
9074
+ names.extend(_mcp_server_names_from_mapping(project_data))
9075
+ return _dedupe_strings(names)
9076
+
9077
+
9078
+ def _read_mcp_servers_from_json(path: Path, cwd: Path) -> list[tuple[str, dict[str, Any]]]:
9079
+ try:
9080
+ data = json.loads(path.read_text(encoding="utf-8"))
9081
+ except Exception:
9082
+ return []
9083
+ servers = _mcp_servers_from_mapping(data)
9084
+ if path.name == ".claude.json" and isinstance(data, dict):
9085
+ projects = data.get("projects")
9086
+ if isinstance(projects, dict):
9087
+ for project_key, project_data in projects.items():
9088
+ if _project_key_matches_cwd(str(project_key), cwd):
9089
+ servers.extend(_mcp_servers_from_mapping(project_data))
9090
+ out: list[tuple[str, dict[str, Any]]] = []
9091
+ seen: set[str] = set()
9092
+ for name, server in servers:
9093
+ if name in seen:
9094
+ continue
9095
+ seen.add(name)
9096
+ out.append((name, server))
9097
+ return out
9098
+
9099
+
9100
+ def _mcp_server_is_stdio(server: dict[str, Any]) -> bool:
9101
+ if not isinstance(server, dict):
9102
+ return False
9103
+ server_type = str(server.get("type") or "").strip().lower()
9104
+ if server_type and server_type not in ("stdio", "command"):
9105
+ return False
9106
+ command = str(server.get("command") or "").strip()
9107
+ if not command:
9108
+ return False
9109
+ joined = " ".join([command, *[str(item) for item in server.get("args", []) if item is not None]])
9110
+ return "mcp-proxy" not in joined
9111
+
9112
+
9113
+ def _mcp_config_passthrough_values(passthrough: list[str]) -> list[str]:
9114
+ values: list[str] = []
9115
+ i = 0
9116
+ while i < len(passthrough):
9117
+ arg = passthrough[i]
9118
+ if arg == "--mcp-config":
9119
+ i += 1
9120
+ while i < len(passthrough) and not passthrough[i].startswith("-"):
9121
+ values.append(passthrough[i])
9122
+ i += 1
9123
+ continue
9124
+ if arg.startswith("--mcp-config="):
9125
+ value = arg.split("=", 1)[1].strip()
9126
+ if value:
9127
+ values.append(value)
9128
+ i += 1
9129
+ return values
9130
+
9131
+
9132
+ def strip_mcp_config_passthrough(passthrough: list[str]) -> list[str]:
9133
+ stripped: list[str] = []
9134
+ i = 0
9135
+ while i < len(passthrough):
9136
+ arg = passthrough[i]
9137
+ if arg == "--mcp-config":
9138
+ i += 1
9139
+ while i < len(passthrough) and not passthrough[i].startswith("-"):
9140
+ i += 1
9141
+ continue
9142
+ if arg.startswith("--mcp-config="):
9143
+ i += 1
9144
+ continue
9145
+ stripped.append(arg)
9146
+ i += 1
9147
+ return stripped
9148
+
9149
+
9150
+ def _safe_mcp_proxy_name(name: str) -> str:
9151
+ safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", name.strip())
9152
+ return safe[:80] or "server"
9153
+
9154
+
9155
+ def _mcp_config_paths_from_passthrough(passthrough: list[str]) -> list[Path]:
9156
+ return [Path(value).expanduser() for value in _mcp_config_passthrough_values(passthrough)]
9157
+
9158
+
9159
+ def claude_mcp_config_paths(passthrough: list[str] | None = None, cwd: Path | None = None, home: Path | None = None) -> list[Path]:
9160
+ cwd = cwd or Path.cwd()
9161
+ home = home or HOME
9162
+ paths: list[Path] = []
9163
+ paths.extend(_mcp_config_paths_from_passthrough(passthrough or []))
9164
+ current = cwd
9165
+ visited: set[str] = set()
9166
+ while True:
9167
+ key = _path_for_compare(current)
9168
+ if key in visited:
9169
+ break
9170
+ visited.add(key)
9171
+ paths.append(current / ".mcp.json")
9172
+ if current == current.parent:
9173
+ break
9174
+ current = current.parent
9175
+ paths.extend([
9176
+ home / ".mcp.json",
9177
+ home / ".claude" / "settings.json",
9178
+ home / ".claude.json",
9179
+ ])
9180
+ out: list[Path] = []
9181
+ seen: set[str] = set()
9182
+ for path in paths:
9183
+ key = _path_for_compare(path)
9184
+ if key in seen:
9185
+ continue
9186
+ seen.add(key)
9187
+ out.append(path)
9188
+ return out
9189
+
9190
+
9191
+ def auto_discovered_mcp_channel_specs(
9192
+ passthrough: list[str] | None = None,
9193
+ cwd: Path | None = None,
9194
+ home: Path | None = None,
9195
+ ) -> list[str]:
9196
+ cwd = cwd or Path.cwd()
9197
+ specs: list[str] = []
9198
+ for path in claude_mcp_config_paths(passthrough, cwd, home):
9199
+ if not path.exists() or not path.is_file():
9200
+ continue
9201
+ for name in _read_mcp_server_names_from_json(path, cwd):
9202
+ if re.search(r"\s", name):
9203
+ continue
9204
+ specs.append(f"server:{name}" if not is_channel_spec_tagged(name) else name)
9205
+ return _dedupe_strings(specs)
9206
+
9207
+
9208
+ def _mcp_sse_servers_from_mapping(mapping: Any) -> list[dict[str, Any]]:
9209
+ if not isinstance(mapping, dict):
9210
+ return []
9211
+ found: list[dict[str, Any]] = []
9212
+ for key in ("mcpServers", "servers"):
9213
+ servers = mapping.get(key)
9214
+ if not isinstance(servers, dict):
9215
+ continue
9216
+ for raw_name, raw_server in servers.items():
9217
+ name = str(raw_name or "").strip()
9218
+ if not name or not isinstance(raw_server, dict):
9219
+ continue
9220
+ url = str(raw_server.get("url") or raw_server.get("endpoint") or "").strip()
9221
+ if not url.startswith(("http://", "https://")):
9222
+ continue
9223
+ server_type = str(raw_server.get("type") or "").strip().lower()
9224
+ if server_type and server_type not in ("sse", "http", "streamable-http"):
9225
+ continue
9226
+ headers = raw_server.get("headers") if isinstance(raw_server.get("headers"), dict) else {}
9227
+ found.append(
9228
+ {
9229
+ "name": f"mcp-{name}",
9230
+ "url": url,
9231
+ "headers": {str(k): str(v) for k, v in headers.items() if str(k).strip()},
9232
+ "channel": name,
9233
+ "sender_id": name,
9234
+ "recipient": "all",
9235
+ "mcp": True,
9236
+ }
9237
+ )
9238
+ return found
9239
+
9240
+
9241
+ def _read_mcp_sse_servers_from_json(path: Path, cwd: Path) -> list[dict[str, Any]]:
9242
+ try:
9243
+ data = json.loads(path.read_text(encoding="utf-8"))
9244
+ except Exception:
9245
+ return []
9246
+ servers = _mcp_sse_servers_from_mapping(data)
9247
+ if path.name == ".claude.json" and isinstance(data, dict):
9248
+ projects = data.get("projects")
9249
+ if isinstance(projects, dict):
9250
+ for project_key, project_data in projects.items():
9251
+ if _project_key_matches_cwd(str(project_key), cwd):
9252
+ servers.extend(_mcp_sse_servers_from_mapping(project_data))
9253
+ out: list[dict[str, Any]] = []
9254
+ seen: set[str] = set()
9255
+ for server in servers:
9256
+ key = f"{server.get('name')}|{server.get('url')}"
9257
+ if key in seen:
9258
+ continue
9259
+ seen.add(key)
9260
+ out.append(server)
9261
+ return out
9262
+
9263
+
9264
+ def auto_start_sse_channels_from_mcp_configs(
9265
+ passthrough: list[str] | None = None,
9266
+ cwd: Path | None = None,
9267
+ home: Path | None = None,
9268
+ ) -> list[dict[str, Any]]:
9269
+ cwd = cwd or Path.cwd()
9270
+ started: list[dict[str, Any]] = []
9271
+ for path in claude_mcp_config_paths(passthrough, cwd, home):
9272
+ if not path.exists() or not path.is_file():
9273
+ continue
9274
+ for server in _read_mcp_sse_servers_from_json(path, cwd):
9275
+ try:
9276
+ status = start_channel_sse_connection(server)
9277
+ started.append(status)
9278
+ router_log("INFO", f"channel_sse_auto_started name={status.get('name')} url={status.get('url')}")
9279
+ except Exception as exc:
9280
+ router_log("WARN", f"channel_sse_auto_start_failed path={path} error={type(exc).__name__}: {exc}")
9281
+ return started
9282
+
9283
+
9284
+ def channel_specs_for_launch(cfg: dict[str, Any], passthrough: list[str], extra_specs: list[str] | None = None) -> list[str]:
9285
+ configured = [spec for spec in channel_specs(cfg) if is_channel_spec_tagged(spec)]
9286
+ specs = configured
9287
+ if extra_specs:
9288
+ specs = [*specs, *extra_specs]
9289
+ return _dedupe_strings(spec for spec in specs if is_channel_spec_tagged(spec))
9290
+
9291
+
8762
9292
  def is_channel_spec_tagged(spec: str) -> bool:
8763
9293
  return spec.startswith("plugin:") or spec.startswith("server:")
8764
9294
 
@@ -12046,7 +12576,7 @@ def main_menu_rows(cfg: dict[str, Any], provider: str, pcfg: dict[str, Any], lan
12046
12576
  f"4. {ui_text('model', lang)} [{compact_text(pcfg.get('current_model', 'unset'), 62)}]",
12047
12577
  f"5. {ui_text('advisor_model', lang)} [{compact_text(pcfg.get('advisor_model') or 'off', 62)}]",
12048
12578
  f"6. {ui_text('options', lang)} [{compact_text(llm_options_status(provider, pcfg), 62)}]",
12049
- f"7. Channels [{channel_status_text(cfg)}]",
12579
+ f"7. {ui_text('log_level', lang)} [{log_level_status()}]",
12050
12580
  f"8. {ui_text('test', lang)}",
12051
12581
  f"9. {ui_text('launch', lang)}",
12052
12582
  ui_text("quit", lang),
@@ -12076,6 +12606,30 @@ def language_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
12076
12606
  return rows, values
12077
12607
 
12078
12608
 
12609
+ def log_level_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
12610
+ rows: list[str] = []
12611
+ values: list[str] = []
12612
+ current = log_level_name()
12613
+ descriptions = {
12614
+ "SILENT": "no router log writes",
12615
+ "ERROR": "errors only",
12616
+ "WARN": "warnings and errors",
12617
+ "INFO": "normal diagnostics",
12618
+ "DEBUG": "verbose diagnostics",
12619
+ "TRACE": "request/response trace detail",
12620
+ }
12621
+ for numeric in sorted(LOG_LEVEL_NAMES):
12622
+ name = LOG_LEVEL_NAMES[numeric]
12623
+ mark = "*" if name == current else " "
12624
+ rows.append(f"{mark} {name:<6} {numeric} {descriptions.get(name, '')}")
12625
+ values.append(name)
12626
+ rows.append(f"Reset to default/env [{log_level_status()}]")
12627
+ values.append("DEFAULT")
12628
+ rows.append(ui_text("back", cfg.get("language", "en")))
12629
+ values.append("back")
12630
+ return rows, values
12631
+
12632
+
12079
12633
  def model_panel_rows(provider: str, pcfg: dict[str, Any]) -> tuple[list[str], list[str]]:
12080
12634
  values = unique_model_ids(provider, upstream_model_ids(provider, pcfg))
12081
12635
  rows: list[str] = []
@@ -12482,6 +13036,8 @@ def portable_prelaunch_menu() -> int:
12482
13036
  panel_rows, panel_values = ["Run compatibility test", "Back"], ["run", "back"]
12483
13037
  elif name == "options":
12484
13038
  panel_rows, panel_values = llm_option_panel_rows(provider, pcfg, cfg.get("language", "en"))
13039
+ elif name == "log-level":
13040
+ panel_rows, panel_values = log_level_panel_rows(cfg)
12485
13041
  elif name == "channels":
12486
13042
  panel_rows, panel_values = channel_panel_rows(cfg)
12487
13043
  elif name == "context":
@@ -12661,6 +13217,15 @@ def portable_prelaunch_menu() -> int:
12661
13217
  panel_rows, panel_values = ["Run compatibility test again", "Back"], ["run", "back"]
12662
13218
  refresh_checks()
12663
13219
  main_idx = 9 if "Compatibility: OK" in out else 4
13220
+ elif panel == "log-level":
13221
+ if value == "back":
13222
+ close_panel()
13223
+ elif value:
13224
+ messages = set_log_level_config(value)
13225
+ refresh_checks()
13226
+ cfg = load_config()
13227
+ panel_rows, panel_values = log_level_panel_rows(cfg)
13228
+ panel_idx = max(0, min(panel_idx, len(panel_rows) - 1))
12664
13229
  elif panel == "channels":
12665
13230
  if value == "back":
12666
13231
  close_panel()
@@ -12793,7 +13358,7 @@ def portable_prelaunch_menu() -> int:
12793
13358
  elif key in ("esc", "q"):
12794
13359
  return 10
12795
13360
  elif key == "enter":
12796
- actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "channels", "test", "launch", "quit"]
13361
+ actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "log-level", "test", "launch", "quit"]
12797
13362
  action = actions[main_idx]
12798
13363
  if action == "launch":
12799
13364
  blockers = launch_readiness_errors()
@@ -12907,17 +13472,16 @@ def normalize_channel_passthrough(passthrough: list[str]) -> list[str]:
12907
13472
  return normalized
12908
13473
 
12909
13474
 
12910
- def claude_channel_args(cfg: dict[str, Any], passthrough: list[str]) -> list[str]:
12911
- channels = [spec for spec in channel_specs(cfg) if is_channel_spec_tagged(spec)]
12912
- if not channels or has_passthrough_option(passthrough, "--channels", "--dangerously-load-development-channels"):
12913
- return []
12914
- return ["--dangerously-load-development-channels", *channels]
13475
+ def native_channel_passthrough_requested(passthrough: list[str]) -> bool:
13476
+ return has_passthrough_option(passthrough, "--channels", "--dangerously-load-development-channels")
12915
13477
 
12916
13478
 
12917
- def claude_channels_requested(cfg: dict[str, Any], passthrough: list[str]) -> bool:
12918
- if has_passthrough_option(passthrough, "--channels", "--dangerously-load-development-channels"):
12919
- return True
12920
- return any(is_channel_spec_tagged(spec) for spec in channel_specs(cfg))
13479
+ def claude_channel_args(cfg: dict[str, Any], passthrough: list[str], extra_specs: list[str] | None = None) -> list[str]:
13480
+ return []
13481
+
13482
+
13483
+ def claude_channels_requested(cfg: dict[str, Any], passthrough: list[str], extra_specs: list[str] | None = None) -> bool:
13484
+ return native_channel_passthrough_requested(passthrough)
12921
13485
 
12922
13486
 
12923
13487
  def write_web_tools_mcp_config(cfg: dict[str, Any]) -> Path:
@@ -12960,6 +13524,472 @@ def write_duckduckgo_mcp_config(cfg: dict[str, Any]) -> Path:
12960
13524
  return path
12961
13525
 
12962
13526
 
13527
+ def write_channel_mcp_config() -> Path:
13528
+ data = {
13529
+ "mcpServers": {
13530
+ "claude-any-router": {
13531
+ "type": "sse",
13532
+ "url": f"{ROUTER_BASE}/ca/mcp/sse",
13533
+ }
13534
+ }
13535
+ }
13536
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
13537
+ CHANNEL_MCP_CONFIG.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
13538
+ try:
13539
+ os.chmod(CHANNEL_MCP_CONFIG, 0o600)
13540
+ except Exception:
13541
+ pass
13542
+ return CHANNEL_MCP_CONFIG
13543
+
13544
+
13545
+ def write_mcp_proxy_config(
13546
+ passthrough: list[str],
13547
+ *,
13548
+ extra_config_paths: list[Path | str] | None = None,
13549
+ cwd: Path | None = None,
13550
+ home: Path | None = None,
13551
+ ) -> Path | None:
13552
+ cwd = cwd or Path.cwd()
13553
+ extra = [Path(item).expanduser() for item in (extra_config_paths or [])]
13554
+ paths = [*extra, *claude_mcp_config_paths(passthrough, cwd, home)]
13555
+ servers: dict[str, Any] = {}
13556
+ seen: set[str] = set()
13557
+ server_dir = CONFIG_DIR / "mcp-proxy-servers"
13558
+ for path in paths:
13559
+ if not path.exists() or not path.is_file():
13560
+ continue
13561
+ for name, server in _read_mcp_servers_from_json(path, cwd):
13562
+ if name in seen:
13563
+ continue
13564
+ seen.add(name)
13565
+ if _mcp_server_is_stdio(server):
13566
+ server_dir.mkdir(parents=True, exist_ok=True)
13567
+ server_path = server_dir / f"{_safe_mcp_proxy_name(name)}.json"
13568
+ server_path.write_text(json.dumps(server, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
13569
+ try:
13570
+ os.chmod(server_path, 0o600)
13571
+ except Exception:
13572
+ pass
13573
+ servers[name] = {
13574
+ "command": sys.executable,
13575
+ "args": [
13576
+ str(Path(__file__).resolve()),
13577
+ "mcp-proxy",
13578
+ "--server-name",
13579
+ name,
13580
+ "--server-config",
13581
+ str(server_path),
13582
+ ],
13583
+ }
13584
+ else:
13585
+ servers[name] = server
13586
+ if not servers:
13587
+ return None
13588
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
13589
+ MCP_PROXY_CONFIG.write_text(json.dumps({"mcpServers": servers}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
13590
+ try:
13591
+ os.chmod(MCP_PROXY_CONFIG, 0o600)
13592
+ except Exception:
13593
+ pass
13594
+ router_log("INFO", f"mcp_proxy_config_written servers={','.join(sorted(servers))}")
13595
+ return MCP_PROXY_CONFIG
13596
+
13597
+
13598
+ def should_use_channel_stdin_proxy(use_router_mode: bool, passthrough: list[str]) -> bool:
13599
+ return bool(use_router_mode and not native_channel_passthrough_requested(passthrough))
13600
+
13601
+
13602
+ def format_channel_wake_prompt(message: dict[str, Any]) -> str:
13603
+ channel = str(message.get("channel") or "default")
13604
+ sender = str(message.get("sender_id") or "channel")
13605
+ mid = str(message.get("id") or "")
13606
+ meta = message.get("meta") if isinstance(message.get("meta"), dict) else {}
13607
+ room = str(meta.get("room_id") or meta.get("room") or channel)
13608
+ thread = str(message.get("thread_id") or meta.get("thread_id") or "")
13609
+ body = re.sub(r"\s+", " ", str(message.get("message") or "")).strip()
13610
+ fields = [f"channel={channel}", f"room={room}", f"from={sender}"]
13611
+ if mid:
13612
+ fields.append(f"id={mid}")
13613
+ if thread:
13614
+ fields.append(f"thread={thread}")
13615
+ return (
13616
+ "[claude-any external channel message] "
13617
+ + " ".join(fields)
13618
+ + f" text={json.dumps(body, ensure_ascii=False)}. "
13619
+ + "If relevant to current work, respond or act now; otherwise keep working."
13620
+ )
13621
+
13622
+
13623
+ def _write_fd_all(fd: int, data: bytes) -> None:
13624
+ view = memoryview(data)
13625
+ while view:
13626
+ written = os.write(fd, view)
13627
+ view = view[written:]
13628
+
13629
+
13630
+ def _channel_wake_input_bytes(prompt: str) -> bytes:
13631
+ # Ctrl-U clears any stale line editor text before submitting the synthetic prompt.
13632
+ return b"\x15" + prompt.encode("utf-8", errors="replace") + b"\n"
13633
+
13634
+
13635
+ def _inject_pending_channel_messages(master_fd: int, last_id: int) -> int:
13636
+ for message in read_chat_messages(last_id, None, None, 100):
13637
+ try:
13638
+ last_id = max(last_id, int(message.get("id") or 0))
13639
+ except Exception:
13640
+ continue
13641
+ prompt = format_channel_wake_prompt(message)
13642
+ _write_fd_all(master_fd, _channel_wake_input_bytes(prompt))
13643
+ router_log("INFO", f"channel_stdin_proxy_injected message_id={message.get('id')} channel={message.get('channel')}")
13644
+ return last_id
13645
+
13646
+
13647
+ def _chat_messages_file_marker() -> tuple[float, int]:
13648
+ try:
13649
+ stat = CHAT_MESSAGES_PATH.stat()
13650
+ return (stat.st_mtime, stat.st_size)
13651
+ except Exception:
13652
+ return (0.0, 0)
13653
+
13654
+
13655
+ def subprocess_call_with_channel_wake_proxy(cmd: list[str], env: dict[str, str]) -> int:
13656
+ if os.name != "posix" or not sys.stdin.isatty() or not sys.stdout.isatty():
13657
+ router_log("INFO", "channel_stdin_proxy_unavailable; using direct subprocess call")
13658
+ return subprocess.call(cmd, env=env)
13659
+ import pty
13660
+ import select
13661
+ import termios
13662
+ import tty
13663
+
13664
+ master_fd, slave_fd = pty.openpty()
13665
+ proc = subprocess.Popen(cmd, stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, env=env, close_fds=True)
13666
+ os.close(slave_fd)
13667
+ stdin_fd = sys.stdin.fileno()
13668
+ stdout_fd = sys.stdout.fileno()
13669
+ old_attrs = termios.tcgetattr(stdin_fd)
13670
+ last_id = _chat_init_next_id() - 1
13671
+ last_channel_poll = 0.0
13672
+ last_channel_marker = _chat_messages_file_marker()
13673
+ try:
13674
+ tty.setraw(stdin_fd)
13675
+ while proc.poll() is None:
13676
+ try:
13677
+ readable, _, _ = select.select([stdin_fd, master_fd], [], [], 0.2)
13678
+ except OSError:
13679
+ break
13680
+ if stdin_fd in readable:
13681
+ data = os.read(stdin_fd, 4096)
13682
+ if data:
13683
+ _write_fd_all(master_fd, data)
13684
+ if master_fd in readable:
13685
+ try:
13686
+ data = os.read(master_fd, 4096)
13687
+ except OSError:
13688
+ break
13689
+ if data:
13690
+ _write_fd_all(stdout_fd, data)
13691
+ now = time.time()
13692
+ if now - last_channel_poll >= 0.5:
13693
+ last_channel_poll = now
13694
+ marker = _chat_messages_file_marker()
13695
+ if marker != last_channel_marker:
13696
+ last_channel_marker = marker
13697
+ last_id = _inject_pending_channel_messages(master_fd, last_id)
13698
+ while True:
13699
+ try:
13700
+ readable, _, _ = select.select([master_fd], [], [], 0)
13701
+ if master_fd not in readable:
13702
+ break
13703
+ data = os.read(master_fd, 4096)
13704
+ if not data:
13705
+ break
13706
+ _write_fd_all(stdout_fd, data)
13707
+ except OSError:
13708
+ break
13709
+ return proc.returncode if proc.returncode is not None else 0
13710
+ finally:
13711
+ try:
13712
+ termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_attrs)
13713
+ except Exception:
13714
+ pass
13715
+ try:
13716
+ os.close(master_fd)
13717
+ except Exception:
13718
+ pass
13719
+ if proc.poll() is None:
13720
+ try:
13721
+ proc.terminate()
13722
+ except Exception:
13723
+ pass
13724
+
13725
+
13726
+ def _mcp_proxy_notification_payload(server_name: str, message: dict[str, Any]) -> dict[str, Any] | None:
13727
+ method = str(message.get("method") or "").strip()
13728
+ if not method.startswith("notifications/"):
13729
+ return None
13730
+ params = message.get("params") if isinstance(message.get("params"), dict) else {}
13731
+ payload = params.get("payload") if isinstance(params.get("payload"), dict) else {}
13732
+ data = params.get("data") if isinstance(params.get("data"), dict) else {}
13733
+ event = params.get("event") if isinstance(params.get("event"), dict) else {}
13734
+ meta: dict[str, Any] = {
13735
+ "mcp_server": server_name,
13736
+ "mcp_method": method,
13737
+ }
13738
+ meta.update(_event_meta_from_sources(message, params, payload, data, event))
13739
+ content = (
13740
+ _event_payload_text(params)
13741
+ or _event_payload_text(payload)
13742
+ or _event_payload_text(data)
13743
+ or _event_payload_text(event)
13744
+ )
13745
+ if not content and params:
13746
+ content = json.dumps(params, ensure_ascii=False, separators=(",", ":"), default=str)
13747
+ if not content:
13748
+ return None
13749
+ channel = str(meta.get("channel") or meta.get("room_id") or meta.get("room") or server_name)
13750
+ return {
13751
+ "channel": channel,
13752
+ "sender_id": str(meta.get("sender_id") or meta.get("agent_id") or server_name),
13753
+ "recipients": meta.get("recipient_id") or "all",
13754
+ "thread_id": meta.get("thread_id"),
13755
+ "parent_id": meta.get("parent_id"),
13756
+ "kind": method.replace("notifications/claude/", "").replace("notifications/", "").replace("/", "."),
13757
+ "message": content,
13758
+ "meta": meta,
13759
+ }
13760
+
13761
+
13762
+ def _mcp_proxy_observe_json_message(server_name: str, payload: Any) -> None:
13763
+ if not isinstance(payload, dict):
13764
+ return
13765
+ chat_payload = _mcp_proxy_notification_payload(server_name, payload)
13766
+ if not chat_payload:
13767
+ return
13768
+ try:
13769
+ saved = append_chat_message(chat_payload)
13770
+ router_log(
13771
+ "INFO",
13772
+ f"mcp_proxy_notification server={server_name} method={payload.get('method')} message_id={saved.get('id')}",
13773
+ )
13774
+ except Exception as exc:
13775
+ router_log("WARN", f"mcp_proxy_notification_failed server={server_name} error={type(exc).__name__}: {exc}")
13776
+
13777
+
13778
+ def _mcp_proxy_observe_stdout_line(server_name: str, line: bytes) -> None:
13779
+ try:
13780
+ text = line.decode("utf-8", errors="replace").strip()
13781
+ if not text or not text.startswith("{"):
13782
+ return
13783
+ payload = json.loads(text)
13784
+ except Exception:
13785
+ return
13786
+ _mcp_proxy_observe_json_message(server_name, payload)
13787
+
13788
+
13789
+ def _mcp_proxy_header_end(buffer: bytes) -> tuple[int, int] | None:
13790
+ crlf = buffer.find(b"\r\n\r\n")
13791
+ lf = buffer.find(b"\n\n")
13792
+ candidates: list[tuple[int, int]] = []
13793
+ if crlf >= 0:
13794
+ candidates.append((crlf, 4))
13795
+ if lf >= 0:
13796
+ candidates.append((lf, 2))
13797
+ return min(candidates, key=lambda item: item[0]) if candidates else None
13798
+
13799
+
13800
+ def _mcp_proxy_frame_header(buffer: bytes) -> tuple[int, int, int] | None:
13801
+ header = _mcp_proxy_header_end(buffer)
13802
+ if not header:
13803
+ return None
13804
+ header_end, delimiter_len = header
13805
+ length = _mcp_proxy_content_length(buffer[:header_end])
13806
+ if length is None:
13807
+ return None
13808
+ return header_end, delimiter_len, length
13809
+
13810
+
13811
+ def _mcp_proxy_content_length(header_bytes: bytes) -> int | None:
13812
+ try:
13813
+ header_text = header_bytes.decode("ascii", errors="replace")
13814
+ except Exception:
13815
+ return None
13816
+ for line in re.split(r"\r?\n", header_text):
13817
+ name, sep, value = line.partition(":")
13818
+ if sep and name.strip().lower() == "content-length":
13819
+ try:
13820
+ length = int(value.strip())
13821
+ except Exception:
13822
+ return None
13823
+ return length if length >= 0 else None
13824
+ return None
13825
+
13826
+
13827
+ class _McpStdoutObserver:
13828
+ def __init__(self, server_name: str) -> None:
13829
+ self.server_name = server_name
13830
+ self.buffer = bytearray()
13831
+
13832
+ def feed(self, chunk: bytes) -> None:
13833
+ if not chunk:
13834
+ return
13835
+ self.buffer.extend(chunk)
13836
+ self._drain()
13837
+
13838
+ def _drop_until_candidate(self) -> bool:
13839
+ data = bytes(self.buffer)
13840
+ if not data:
13841
+ return False
13842
+ stripped = data.lstrip()
13843
+ if len(stripped) != len(data):
13844
+ del self.buffer[: len(data) - len(stripped)]
13845
+ data = stripped
13846
+ if _mcp_proxy_frame_header(data) or data.startswith(b"{"):
13847
+ return True
13848
+ lowered = data.lower()
13849
+ content_idx = lowered.find(b"content-length:")
13850
+ json_idx = data.find(b"{")
13851
+ candidates = [idx for idx in (content_idx, json_idx) if idx >= 0]
13852
+ newline_idx = data.find(b"\n")
13853
+ if candidates:
13854
+ keep_from = min(candidates)
13855
+ if newline_idx >= 0 and newline_idx < keep_from:
13856
+ del self.buffer[: newline_idx + 1]
13857
+ elif keep_from > 0:
13858
+ del self.buffer[:keep_from]
13859
+ return True
13860
+ if newline_idx >= 0:
13861
+ del self.buffer[: newline_idx + 1]
13862
+ return True
13863
+ if len(self.buffer) > 1024 * 1024:
13864
+ del self.buffer[:-4096]
13865
+ return False
13866
+
13867
+ def _drain(self) -> None:
13868
+ while self.buffer:
13869
+ if not self._drop_until_candidate():
13870
+ return
13871
+ data = bytes(self.buffer)
13872
+ frame = _mcp_proxy_frame_header(data)
13873
+ if frame:
13874
+ header_end, delimiter_len, length = frame
13875
+ body_start = header_end + delimiter_len
13876
+ body_end = body_start + length
13877
+ if len(data) < body_end:
13878
+ return
13879
+ body = data[body_start:body_end]
13880
+ del self.buffer[:body_end]
13881
+ try:
13882
+ payload = json.loads(body.decode("utf-8", errors="replace"))
13883
+ except Exception:
13884
+ continue
13885
+ _mcp_proxy_observe_json_message(self.server_name, payload)
13886
+ continue
13887
+ if data.startswith(b"{"):
13888
+ newline_idx = data.find(b"\n")
13889
+ if newline_idx < 0:
13890
+ return
13891
+ line = data[:newline_idx]
13892
+ del self.buffer[: newline_idx + 1]
13893
+ _mcp_proxy_observe_stdout_line(self.server_name, line)
13894
+ continue
13895
+ return
13896
+
13897
+
13898
+ def _mcp_proxy_forward_stdin(proc: subprocess.Popen[bytes]) -> None:
13899
+ try:
13900
+ while True:
13901
+ chunk = sys.stdin.buffer.read(65536)
13902
+ if not chunk:
13903
+ break
13904
+ if proc.stdin:
13905
+ proc.stdin.write(chunk)
13906
+ proc.stdin.flush()
13907
+ except Exception:
13908
+ pass
13909
+ finally:
13910
+ try:
13911
+ if proc.stdin:
13912
+ proc.stdin.close()
13913
+ except Exception:
13914
+ pass
13915
+
13916
+
13917
+ def _mcp_proxy_forward_stderr(proc: subprocess.Popen[bytes]) -> None:
13918
+ try:
13919
+ if not proc.stderr:
13920
+ return
13921
+ while True:
13922
+ chunk = proc.stderr.read(4096)
13923
+ if not chunk:
13924
+ break
13925
+ sys.stderr.buffer.write(chunk)
13926
+ sys.stderr.buffer.flush()
13927
+ except Exception:
13928
+ pass
13929
+
13930
+
13931
+ def run_mcp_stdio_proxy(server_name: str, server_config_path: Path) -> int:
13932
+ try:
13933
+ server = json.loads(server_config_path.read_text(encoding="utf-8"))
13934
+ except Exception as exc:
13935
+ print(f"claude-any mcp-proxy: cannot read server config: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
13936
+ return 2
13937
+ if not isinstance(server, dict) or not _mcp_server_is_stdio(server):
13938
+ print("claude-any mcp-proxy: server config is not a stdio MCP server", file=sys.stderr, flush=True)
13939
+ return 2
13940
+ command = str(server.get("command") or "").strip()
13941
+ args = [str(item) for item in server.get("args", [])] if isinstance(server.get("args"), list) else []
13942
+ env = os.environ.copy()
13943
+ raw_env = server.get("env")
13944
+ if isinstance(raw_env, dict):
13945
+ env.update({str(k): str(v) for k, v in raw_env.items() if str(k)})
13946
+ cwd_value = server.get("cwd") or server.get("workingDirectory")
13947
+ cwd = str(cwd_value) if cwd_value else None
13948
+ try:
13949
+ proc = subprocess.Popen(
13950
+ [command, *args],
13951
+ stdin=subprocess.PIPE,
13952
+ stdout=subprocess.PIPE,
13953
+ stderr=subprocess.PIPE,
13954
+ cwd=cwd,
13955
+ env=env,
13956
+ bufsize=0,
13957
+ )
13958
+ except Exception as exc:
13959
+ print(f"claude-any mcp-proxy: failed to start {command}: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
13960
+ return 127
13961
+ router_log("INFO", f"mcp_proxy_started server={server_name} command={command}")
13962
+ threading.Thread(target=_mcp_proxy_forward_stdin, args=(proc,), daemon=True, name=f"mcp-proxy-stdin-{server_name}").start()
13963
+ threading.Thread(target=_mcp_proxy_forward_stderr, args=(proc,), daemon=True, name=f"mcp-proxy-stderr-{server_name}").start()
13964
+ try:
13965
+ observer = _McpStdoutObserver(server_name)
13966
+ if proc.stdout:
13967
+ while True:
13968
+ chunk = proc.stdout.read(65536)
13969
+ if not chunk:
13970
+ break
13971
+ observer.feed(chunk)
13972
+ sys.stdout.buffer.write(chunk)
13973
+ sys.stdout.buffer.flush()
13974
+ rc = proc.wait()
13975
+ router_log("INFO", f"mcp_proxy_exited server={server_name} rc={rc}")
13976
+ return rc
13977
+ finally:
13978
+ if proc.poll() is None:
13979
+ try:
13980
+ proc.terminate()
13981
+ except Exception:
13982
+ pass
13983
+
13984
+
13985
+ def cmd_mcp_proxy(argv: list[str]) -> int:
13986
+ parser = argparse.ArgumentParser(prog="claude-any mcp-proxy")
13987
+ parser.add_argument("--server-name", required=True)
13988
+ parser.add_argument("--server-config", required=True)
13989
+ args = parser.parse_args(argv)
13990
+ return run_mcp_stdio_proxy(args.server_name, Path(args.server_config).expanduser())
13991
+
13992
+
12963
13993
  def run_claude_update_check(claude: str, enabled: bool = True) -> None:
12964
13994
  if not enabled:
12965
13995
  return
@@ -13264,14 +14294,16 @@ def launch_claude(
13264
14294
  use_native_anthropic = native_anthropic_enabled(provider)
13265
14295
  use_ollama_native = ollama_native_compat_enabled(provider, pcfg)
13266
14296
  use_provider_native = provider_native_compat_enabled(provider, pcfg)
14297
+ use_router_mode = not (use_native_anthropic or use_ollama_native or use_provider_native)
13267
14298
  cleanup_managed_services_for_provider(provider, pcfg, cfg, quiet=True)
13268
- if not (use_native_anthropic or use_ollama_native or use_provider_native):
14299
+ if use_router_mode:
13269
14300
  start_router_if_needed()
13270
14301
  env = os.environ.copy()
13271
14302
  env["PATH"] = str(HOME / ".local" / "bin") + os.pathsep + env.get("PATH", "")
13272
14303
  launch_env = env_vars(cfg)
13273
14304
  launch_passthrough = normalize_channel_passthrough(passthrough)
13274
14305
  if claude_channels_requested(cfg, launch_passthrough):
14306
+ env.pop("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", None)
13275
14307
  launch_env.pop("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", None)
13276
14308
  if use_native_anthropic:
13277
14309
  for key in (
@@ -13302,8 +14334,21 @@ def launch_claude(
13302
14334
  run_claude_update_check(claude, enabled=update_check)
13303
14335
  claude = find_executable("claude") or claude
13304
14336
  extra_args: list[str] = []
14337
+ mcp_config_paths: list[str] = []
13305
14338
  if should_attach_web_search(provider, cfg, web_search_override):
13306
- extra_args.extend(["--mcp-config", str(write_duckduckgo_mcp_config(cfg))])
14339
+ mcp_config_paths.append(str(write_duckduckgo_mcp_config(cfg)))
14340
+ claude_passthrough = list(launch_passthrough)
14341
+ if should_use_channel_stdin_proxy(use_router_mode, launch_passthrough):
14342
+ auto_start_sse_channels_from_mcp_configs(launch_passthrough)
14343
+ proxy_config = write_mcp_proxy_config(
14344
+ launch_passthrough,
14345
+ extra_config_paths=[Path(path) for path in mcp_config_paths],
14346
+ )
14347
+ if proxy_config:
14348
+ mcp_config_paths = [str(proxy_config)]
14349
+ claude_passthrough = strip_mcp_config_passthrough(launch_passthrough)
14350
+ if mcp_config_paths:
14351
+ extra_args.extend(["--mcp-config", *mcp_config_paths])
13307
14352
  if should_append_compat_prompt(provider, cfg) and not has_passthrough_option(launch_passthrough, "--system-prompt"):
13308
14353
  extra_args.extend(["--append-system-prompt", NON_ANTHROPIC_COMPAT_PROMPT])
13309
14354
  extra_args.extend(claude_channel_args(cfg, launch_passthrough))
@@ -13315,7 +14360,9 @@ def launch_claude(
13315
14360
  if model:
13316
14361
  cmd.extend(["--model", model])
13317
14362
  cmd.extend(extra_args)
13318
- cmd.extend(launch_passthrough)
14363
+ cmd.extend(claude_passthrough)
14364
+ if should_use_channel_stdin_proxy(use_router_mode, launch_passthrough):
14365
+ return subprocess_call_with_channel_wake_proxy(cmd, env)
13319
14366
  return subprocess.call(cmd, env=env)
13320
14367
 
13321
14368
 
@@ -13337,7 +14384,8 @@ Control plane, runs before Claude Code and does not require LLM connectivity:
13337
14384
  claude-any set-api-key PROVIDER KEY
13338
14385
  claude-any web-search [on|off] Auto-attach DuckDuckGo MCP for non-native providers
13339
14386
  claude-any web-fetch [on|off] Auto-attach fetch MCP for web page content
13340
- claude-any channels [cmd] Configure Claude Code --channels auto-injection
14387
+ claude-any log-level [LEVEL] Show or set router log level
14388
+ claude-any channels [cmd] Configure external channel specs
13341
14389
  claude-any ollama-native [on|off] Use Ollama's official Claude Code env path
13342
14390
  claude-any ollama-options [provider] [key=value ...]
13343
14391
  Set Ollama num_ctx/options/keep_alive/think
@@ -13372,12 +14420,13 @@ Headless setup flags, namespaced to avoid Claude CLI collisions:
13372
14420
  claude-any --ca-rate-limit-status on|off
13373
14421
  claude-any --ca-stream on|off
13374
14422
  claude-any --ca-stream-word-chunking on|off
14423
+ claude-any --ca-log-level LEVEL Set router log level: SILENT, ERROR, WARN, INFO, DEBUG, TRACE
13375
14424
  claude-any --ca-web-search Force DuckDuckGo MCP for this launch
13376
14425
  claude-any --ca-no-web-search Disable DuckDuckGo MCP for this launch
13377
14426
  claude-any --ca-web-fetch Enable fetch MCP
13378
14427
  claude-any --ca-no-web-fetch Disable fetch MCP
13379
14428
  claude-any --ca-channel SPEC Add an official/approved Claude Code channel
13380
- claude-any --ca-clear-channels Clear saved channel auto-injection specs
14429
+ claude-any --ca-clear-channels Clear saved channel specs
13381
14430
  claude-any --ca-no-self-update-check
13382
14431
  Skip Claude Any npm self-update check
13383
14432
  claude-any --ca-no-update-check Skip Claude Code update check for this launch
@@ -13505,6 +14554,8 @@ def apply_headless_env_config() -> tuple[bool, bool | None, bool | None, bool |
13505
14554
 
13506
14555
 
13507
14556
  def run_cli(argv: list[str]) -> int:
14557
+ if argv and argv[0] == "mcp-proxy":
14558
+ return cmd_mcp_proxy(argv[1:])
13508
14559
  if argv and argv[0] in ("help", "--help", "-h"):
13509
14560
  print(cli_usage())
13510
14561
  return 0
@@ -13560,6 +14611,9 @@ def run_cli(argv: list[str]) -> int:
13560
14611
  if head in ("web-fetch", "webfetch"):
13561
14612
  cmd_web_fetch(argparse.Namespace(value=rest[0] if rest else None))
13562
14613
  return 0
14614
+ if head in ("log-level", "loglevel", "logging"):
14615
+ cmd_log_level(argparse.Namespace(value=rest[0] if rest else None))
14616
+ return 0
13563
14617
  if head in ("channels", "channel"):
13564
14618
  cmd_channels(argparse.Namespace(values=rest))
13565
14619
  return 0
@@ -13881,6 +14935,18 @@ def run_cli(argv: list[str]) -> int:
13881
14935
  i += 1
13882
14936
  cmd_provider_options(argparse.Namespace(values=[f"stream_word_chunking={value}"]))
13883
14937
  skip_menu = True
14938
+ elif arg == "--ca-log-level" or arg.startswith("--ca-log-level="):
14939
+ value = arg.split("=", 1)[1] if "=" in arg else None
14940
+ if value is None:
14941
+ if i + 1 >= len(argv):
14942
+ raise SystemExit("Missing level for --ca-log-level")
14943
+ value = argv[i + 1]
14944
+ i += 2
14945
+ else:
14946
+ i += 1
14947
+ for line in set_log_level_config(value):
14948
+ print(line)
14949
+ skip_menu = True
13884
14950
  elif arg == "--ca-web-search":
13885
14951
  web_search_override = True
13886
14952
  skip_menu = True
@@ -14008,6 +15074,9 @@ def build_parser() -> argparse.ArgumentParser:
14008
15074
  wf = sub.add_parser("web-fetch")
14009
15075
  wf.add_argument("value", nargs="?")
14010
15076
  wf.set_defaults(func=cmd_web_fetch)
15077
+ ll = sub.add_parser("log-level")
15078
+ ll.add_argument("value", nargs="?")
15079
+ ll.set_defaults(func=cmd_log_level)
14011
15080
  ch = sub.add_parser("channels")
14012
15081
  ch.add_argument("values", nargs="*")
14013
15082
  ch.set_defaults(func=cmd_channels)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneciel-ai/claude-any",
3
- "version": "0.1.76",
3
+ "version": "0.1.78",
4
4
  "description": "Claude Code provider selector for Anthropic, Ollama, Ollama Cloud, vLLM, NVIDIA hosted, and self-hosted NIM.",
5
5
  "license": "MIT",
6
6
  "author": "One Ciel LLC",