@oneciel-ai/claude-any 0.1.76 → 0.1.77

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 +951 -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.77"
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
 
@@ -8123,6 +8327,8 @@ class RouterHandler(BaseHTTPRequestHandler):
8123
8327
  return
8124
8328
  if handle_llm_config_get(self, path):
8125
8329
  return
8330
+ if handle_channel_mcp_get(self, path):
8331
+ return
8126
8332
  if handle_chat_get(self, path) or handle_plan_get(self, path):
8127
8333
  return
8128
8334
  provider, pcfg = get_current_provider(cfg)
@@ -8153,6 +8359,8 @@ class RouterHandler(BaseHTTPRequestHandler):
8153
8359
  body = parse_json_body(raw)
8154
8360
  if handle_llm_config_post(self, path, body):
8155
8361
  return
8362
+ if handle_channel_mcp_post(self, path, body):
8363
+ return
8156
8364
  if handle_chat_post(self, path, body) or handle_plan_post(self, path, body):
8157
8365
  return
8158
8366
  provider, pcfg = get_current_provider(cfg)
@@ -8645,6 +8853,7 @@ def status_lines() -> list[str]:
8645
8853
  *([f"request_timeout_ms: {pcfg.get('request_timeout_ms', 'default')}"] if provider in ("vllm", "nvidia-hosted", "self-hosted-nim") else []),
8646
8854
  *([f"stream_idle_timeout_ms: {pcfg.get('stream_idle_timeout_ms', 'auto')}"] if provider in ("vllm", "nvidia-hosted", "self-hosted-nim") else []),
8647
8855
  f"claude_model: {current_upstream_model_id(provider, pcfg) if direct_native else current_alias(cfg)}",
8856
+ f"log_level: {log_level_status()}",
8648
8857
  f"channels: {channel_status_text(cfg)}",
8649
8858
  f"router: {'bypassed for native provider compatibility' if direct_native else (('up' if router_up() else 'down') + ' ' + ROUTER_BASE)}",
8650
8859
  f"config: {CONFIG_PATH}",
@@ -8655,6 +8864,20 @@ def cmd_status(_: argparse.Namespace) -> None:
8655
8864
  print("\n".join(status_lines()))
8656
8865
 
8657
8866
 
8867
+ def cmd_log_level(args: argparse.Namespace) -> None:
8868
+ value = getattr(args, "value", None)
8869
+ if not value:
8870
+ print(f"log_level: {log_level_status()}")
8871
+ for numeric in sorted(LOG_LEVEL_NAMES):
8872
+ name = LOG_LEVEL_NAMES[numeric]
8873
+ mark = "*" if name == log_level_name() else " "
8874
+ print(f" {mark} {name:<6} {numeric}")
8875
+ print(" DEFAULT reset to environment/default")
8876
+ return
8877
+ for line in set_log_level_config(str(value)):
8878
+ print(line)
8879
+
8880
+
8658
8881
  def cmd_language(args: argparse.Namespace) -> None:
8659
8882
  cfg = load_config()
8660
8883
  if not args.value:
@@ -8759,6 +8982,298 @@ def channel_specs(cfg: dict[str, Any] | None = None) -> list[str]:
8759
8982
  return channels
8760
8983
 
8761
8984
 
8985
+ def _dedupe_strings(values: Iterable[str]) -> list[str]:
8986
+ out: list[str] = []
8987
+ seen: set[str] = set()
8988
+ for value in values:
8989
+ text = str(value or "").strip()
8990
+ if not text or text in seen:
8991
+ continue
8992
+ seen.add(text)
8993
+ out.append(text)
8994
+ return out
8995
+
8996
+
8997
+ def _path_for_compare(path: Path | str) -> str:
8998
+ try:
8999
+ return str(Path(path).expanduser().resolve()).replace("\\", "/").rstrip("/").casefold()
9000
+ except Exception:
9001
+ return str(path).replace("\\", "/").rstrip("/").casefold()
9002
+
9003
+
9004
+ def _project_key_matches_cwd(project_key: str, cwd: Path) -> bool:
9005
+ key = str(project_key or "").strip()
9006
+ if not key:
9007
+ return False
9008
+ try:
9009
+ project_path = Path(key).expanduser()
9010
+ except Exception:
9011
+ return False
9012
+ if not project_path.is_absolute():
9013
+ return False
9014
+ project = _path_for_compare(project_path)
9015
+ current = _path_for_compare(cwd)
9016
+ return current == project or current.startswith(project + "/")
9017
+
9018
+
9019
+ def _mcp_server_names_from_mapping(mapping: Any) -> list[str]:
9020
+ if not isinstance(mapping, dict):
9021
+ return []
9022
+ names: list[str] = []
9023
+ for key in ("mcpServers", "servers"):
9024
+ servers = mapping.get(key)
9025
+ if isinstance(servers, dict):
9026
+ names.extend(str(name).strip() for name in servers if str(name).strip())
9027
+ return _dedupe_strings(names)
9028
+
9029
+
9030
+ def _mcp_servers_from_mapping(mapping: Any) -> list[tuple[str, dict[str, Any]]]:
9031
+ if not isinstance(mapping, dict):
9032
+ return []
9033
+ found: list[tuple[str, dict[str, Any]]] = []
9034
+ seen: set[str] = set()
9035
+ for key in ("mcpServers", "servers"):
9036
+ servers = mapping.get(key)
9037
+ if not isinstance(servers, dict):
9038
+ continue
9039
+ for raw_name, raw_server in servers.items():
9040
+ name = str(raw_name or "").strip()
9041
+ if not name or name in seen or not isinstance(raw_server, dict):
9042
+ continue
9043
+ seen.add(name)
9044
+ found.append((name, dict(raw_server)))
9045
+ return found
9046
+
9047
+
9048
+ def _read_mcp_server_names_from_json(path: Path, cwd: Path) -> list[str]:
9049
+ try:
9050
+ data = json.loads(path.read_text(encoding="utf-8"))
9051
+ except Exception:
9052
+ return []
9053
+ names = _mcp_server_names_from_mapping(data)
9054
+ if path.name == ".claude.json" and isinstance(data, dict):
9055
+ projects = data.get("projects")
9056
+ if isinstance(projects, dict):
9057
+ for project_key, project_data in projects.items():
9058
+ if _project_key_matches_cwd(str(project_key), cwd):
9059
+ names.extend(_mcp_server_names_from_mapping(project_data))
9060
+ return _dedupe_strings(names)
9061
+
9062
+
9063
+ def _read_mcp_servers_from_json(path: Path, cwd: Path) -> list[tuple[str, dict[str, Any]]]:
9064
+ try:
9065
+ data = json.loads(path.read_text(encoding="utf-8"))
9066
+ except Exception:
9067
+ return []
9068
+ servers = _mcp_servers_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
+ servers.extend(_mcp_servers_from_mapping(project_data))
9075
+ out: list[tuple[str, dict[str, Any]]] = []
9076
+ seen: set[str] = set()
9077
+ for name, server in servers:
9078
+ if name in seen:
9079
+ continue
9080
+ seen.add(name)
9081
+ out.append((name, server))
9082
+ return out
9083
+
9084
+
9085
+ def _mcp_server_is_stdio(server: dict[str, Any]) -> bool:
9086
+ if not isinstance(server, dict):
9087
+ return False
9088
+ server_type = str(server.get("type") or "").strip().lower()
9089
+ if server_type and server_type not in ("stdio", "command"):
9090
+ return False
9091
+ command = str(server.get("command") or "").strip()
9092
+ if not command:
9093
+ return False
9094
+ joined = " ".join([command, *[str(item) for item in server.get("args", []) if item is not None]])
9095
+ return "mcp-proxy" not in joined
9096
+
9097
+
9098
+ def _mcp_config_passthrough_values(passthrough: list[str]) -> list[str]:
9099
+ values: list[str] = []
9100
+ i = 0
9101
+ while i < len(passthrough):
9102
+ arg = passthrough[i]
9103
+ if arg == "--mcp-config":
9104
+ i += 1
9105
+ while i < len(passthrough) and not passthrough[i].startswith("-"):
9106
+ values.append(passthrough[i])
9107
+ i += 1
9108
+ continue
9109
+ if arg.startswith("--mcp-config="):
9110
+ value = arg.split("=", 1)[1].strip()
9111
+ if value:
9112
+ values.append(value)
9113
+ i += 1
9114
+ return values
9115
+
9116
+
9117
+ def strip_mcp_config_passthrough(passthrough: list[str]) -> list[str]:
9118
+ stripped: list[str] = []
9119
+ i = 0
9120
+ while i < len(passthrough):
9121
+ arg = passthrough[i]
9122
+ if arg == "--mcp-config":
9123
+ i += 1
9124
+ while i < len(passthrough) and not passthrough[i].startswith("-"):
9125
+ i += 1
9126
+ continue
9127
+ if arg.startswith("--mcp-config="):
9128
+ i += 1
9129
+ continue
9130
+ stripped.append(arg)
9131
+ i += 1
9132
+ return stripped
9133
+
9134
+
9135
+ def _safe_mcp_proxy_name(name: str) -> str:
9136
+ safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", name.strip())
9137
+ return safe[:80] or "server"
9138
+
9139
+
9140
+ def _mcp_config_paths_from_passthrough(passthrough: list[str]) -> list[Path]:
9141
+ return [Path(value).expanduser() for value in _mcp_config_passthrough_values(passthrough)]
9142
+
9143
+
9144
+ def claude_mcp_config_paths(passthrough: list[str] | None = None, cwd: Path | None = None, home: Path | None = None) -> list[Path]:
9145
+ cwd = cwd or Path.cwd()
9146
+ home = home or HOME
9147
+ paths: list[Path] = []
9148
+ paths.extend(_mcp_config_paths_from_passthrough(passthrough or []))
9149
+ current = cwd
9150
+ visited: set[str] = set()
9151
+ while True:
9152
+ key = _path_for_compare(current)
9153
+ if key in visited:
9154
+ break
9155
+ visited.add(key)
9156
+ paths.append(current / ".mcp.json")
9157
+ if current == current.parent:
9158
+ break
9159
+ current = current.parent
9160
+ paths.extend([
9161
+ home / ".mcp.json",
9162
+ home / ".claude" / "settings.json",
9163
+ home / ".claude.json",
9164
+ ])
9165
+ out: list[Path] = []
9166
+ seen: set[str] = set()
9167
+ for path in paths:
9168
+ key = _path_for_compare(path)
9169
+ if key in seen:
9170
+ continue
9171
+ seen.add(key)
9172
+ out.append(path)
9173
+ return out
9174
+
9175
+
9176
+ def auto_discovered_mcp_channel_specs(
9177
+ passthrough: list[str] | None = None,
9178
+ cwd: Path | None = None,
9179
+ home: Path | None = None,
9180
+ ) -> list[str]:
9181
+ cwd = cwd or Path.cwd()
9182
+ specs: list[str] = []
9183
+ for path in claude_mcp_config_paths(passthrough, cwd, home):
9184
+ if not path.exists() or not path.is_file():
9185
+ continue
9186
+ for name in _read_mcp_server_names_from_json(path, cwd):
9187
+ if re.search(r"\s", name):
9188
+ continue
9189
+ specs.append(f"server:{name}" if not is_channel_spec_tagged(name) else name)
9190
+ return _dedupe_strings(specs)
9191
+
9192
+
9193
+ def _mcp_sse_servers_from_mapping(mapping: Any) -> list[dict[str, Any]]:
9194
+ if not isinstance(mapping, dict):
9195
+ return []
9196
+ found: list[dict[str, Any]] = []
9197
+ for key in ("mcpServers", "servers"):
9198
+ servers = mapping.get(key)
9199
+ if not isinstance(servers, dict):
9200
+ continue
9201
+ for raw_name, raw_server in servers.items():
9202
+ name = str(raw_name or "").strip()
9203
+ if not name or not isinstance(raw_server, dict):
9204
+ continue
9205
+ url = str(raw_server.get("url") or raw_server.get("endpoint") or "").strip()
9206
+ if not url.startswith(("http://", "https://")):
9207
+ continue
9208
+ server_type = str(raw_server.get("type") or "").strip().lower()
9209
+ if server_type and server_type not in ("sse", "http", "streamable-http"):
9210
+ continue
9211
+ headers = raw_server.get("headers") if isinstance(raw_server.get("headers"), dict) else {}
9212
+ found.append(
9213
+ {
9214
+ "name": f"mcp-{name}",
9215
+ "url": url,
9216
+ "headers": {str(k): str(v) for k, v in headers.items() if str(k).strip()},
9217
+ "channel": name,
9218
+ "sender_id": name,
9219
+ "recipient": "all",
9220
+ "mcp": True,
9221
+ }
9222
+ )
9223
+ return found
9224
+
9225
+
9226
+ def _read_mcp_sse_servers_from_json(path: Path, cwd: Path) -> list[dict[str, Any]]:
9227
+ try:
9228
+ data = json.loads(path.read_text(encoding="utf-8"))
9229
+ except Exception:
9230
+ return []
9231
+ servers = _mcp_sse_servers_from_mapping(data)
9232
+ if path.name == ".claude.json" and isinstance(data, dict):
9233
+ projects = data.get("projects")
9234
+ if isinstance(projects, dict):
9235
+ for project_key, project_data in projects.items():
9236
+ if _project_key_matches_cwd(str(project_key), cwd):
9237
+ servers.extend(_mcp_sse_servers_from_mapping(project_data))
9238
+ out: list[dict[str, Any]] = []
9239
+ seen: set[str] = set()
9240
+ for server in servers:
9241
+ key = f"{server.get('name')}|{server.get('url')}"
9242
+ if key in seen:
9243
+ continue
9244
+ seen.add(key)
9245
+ out.append(server)
9246
+ return out
9247
+
9248
+
9249
+ def auto_start_sse_channels_from_mcp_configs(
9250
+ passthrough: list[str] | None = None,
9251
+ cwd: Path | None = None,
9252
+ home: Path | None = None,
9253
+ ) -> list[dict[str, Any]]:
9254
+ cwd = cwd or Path.cwd()
9255
+ started: list[dict[str, Any]] = []
9256
+ for path in claude_mcp_config_paths(passthrough, cwd, home):
9257
+ if not path.exists() or not path.is_file():
9258
+ continue
9259
+ for server in _read_mcp_sse_servers_from_json(path, cwd):
9260
+ try:
9261
+ status = start_channel_sse_connection(server)
9262
+ started.append(status)
9263
+ router_log("INFO", f"channel_sse_auto_started name={status.get('name')} url={status.get('url')}")
9264
+ except Exception as exc:
9265
+ router_log("WARN", f"channel_sse_auto_start_failed path={path} error={type(exc).__name__}: {exc}")
9266
+ return started
9267
+
9268
+
9269
+ def channel_specs_for_launch(cfg: dict[str, Any], passthrough: list[str], extra_specs: list[str] | None = None) -> list[str]:
9270
+ configured = [spec for spec in channel_specs(cfg) if is_channel_spec_tagged(spec)]
9271
+ specs = configured
9272
+ if extra_specs:
9273
+ specs = [*specs, *extra_specs]
9274
+ return _dedupe_strings(spec for spec in specs if is_channel_spec_tagged(spec))
9275
+
9276
+
8762
9277
  def is_channel_spec_tagged(spec: str) -> bool:
8763
9278
  return spec.startswith("plugin:") or spec.startswith("server:")
8764
9279
 
@@ -12046,7 +12561,7 @@ def main_menu_rows(cfg: dict[str, Any], provider: str, pcfg: dict[str, Any], lan
12046
12561
  f"4. {ui_text('model', lang)} [{compact_text(pcfg.get('current_model', 'unset'), 62)}]",
12047
12562
  f"5. {ui_text('advisor_model', lang)} [{compact_text(pcfg.get('advisor_model') or 'off', 62)}]",
12048
12563
  f"6. {ui_text('options', lang)} [{compact_text(llm_options_status(provider, pcfg), 62)}]",
12049
- f"7. Channels [{channel_status_text(cfg)}]",
12564
+ f"7. {ui_text('log_level', lang)} [{log_level_status()}]",
12050
12565
  f"8. {ui_text('test', lang)}",
12051
12566
  f"9. {ui_text('launch', lang)}",
12052
12567
  ui_text("quit", lang),
@@ -12076,6 +12591,30 @@ def language_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
12076
12591
  return rows, values
12077
12592
 
12078
12593
 
12594
+ def log_level_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
12595
+ rows: list[str] = []
12596
+ values: list[str] = []
12597
+ current = log_level_name()
12598
+ descriptions = {
12599
+ "SILENT": "no router log writes",
12600
+ "ERROR": "errors only",
12601
+ "WARN": "warnings and errors",
12602
+ "INFO": "normal diagnostics",
12603
+ "DEBUG": "verbose diagnostics",
12604
+ "TRACE": "request/response trace detail",
12605
+ }
12606
+ for numeric in sorted(LOG_LEVEL_NAMES):
12607
+ name = LOG_LEVEL_NAMES[numeric]
12608
+ mark = "*" if name == current else " "
12609
+ rows.append(f"{mark} {name:<6} {numeric} {descriptions.get(name, '')}")
12610
+ values.append(name)
12611
+ rows.append(f"Reset to default/env [{log_level_status()}]")
12612
+ values.append("DEFAULT")
12613
+ rows.append(ui_text("back", cfg.get("language", "en")))
12614
+ values.append("back")
12615
+ return rows, values
12616
+
12617
+
12079
12618
  def model_panel_rows(provider: str, pcfg: dict[str, Any]) -> tuple[list[str], list[str]]:
12080
12619
  values = unique_model_ids(provider, upstream_model_ids(provider, pcfg))
12081
12620
  rows: list[str] = []
@@ -12482,6 +13021,8 @@ def portable_prelaunch_menu() -> int:
12482
13021
  panel_rows, panel_values = ["Run compatibility test", "Back"], ["run", "back"]
12483
13022
  elif name == "options":
12484
13023
  panel_rows, panel_values = llm_option_panel_rows(provider, pcfg, cfg.get("language", "en"))
13024
+ elif name == "log-level":
13025
+ panel_rows, panel_values = log_level_panel_rows(cfg)
12485
13026
  elif name == "channels":
12486
13027
  panel_rows, panel_values = channel_panel_rows(cfg)
12487
13028
  elif name == "context":
@@ -12661,6 +13202,15 @@ def portable_prelaunch_menu() -> int:
12661
13202
  panel_rows, panel_values = ["Run compatibility test again", "Back"], ["run", "back"]
12662
13203
  refresh_checks()
12663
13204
  main_idx = 9 if "Compatibility: OK" in out else 4
13205
+ elif panel == "log-level":
13206
+ if value == "back":
13207
+ close_panel()
13208
+ elif value:
13209
+ messages = set_log_level_config(value)
13210
+ refresh_checks()
13211
+ cfg = load_config()
13212
+ panel_rows, panel_values = log_level_panel_rows(cfg)
13213
+ panel_idx = max(0, min(panel_idx, len(panel_rows) - 1))
12664
13214
  elif panel == "channels":
12665
13215
  if value == "back":
12666
13216
  close_panel()
@@ -12793,7 +13343,7 @@ def portable_prelaunch_menu() -> int:
12793
13343
  elif key in ("esc", "q"):
12794
13344
  return 10
12795
13345
  elif key == "enter":
12796
- actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "channels", "test", "launch", "quit"]
13346
+ actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "log-level", "test", "launch", "quit"]
12797
13347
  action = actions[main_idx]
12798
13348
  if action == "launch":
12799
13349
  blockers = launch_readiness_errors()
@@ -12907,17 +13457,16 @@ def normalize_channel_passthrough(passthrough: list[str]) -> list[str]:
12907
13457
  return normalized
12908
13458
 
12909
13459
 
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]
13460
+ def native_channel_passthrough_requested(passthrough: list[str]) -> bool:
13461
+ return has_passthrough_option(passthrough, "--channels", "--dangerously-load-development-channels")
12915
13462
 
12916
13463
 
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))
13464
+ def claude_channel_args(cfg: dict[str, Any], passthrough: list[str], extra_specs: list[str] | None = None) -> list[str]:
13465
+ return []
13466
+
13467
+
13468
+ def claude_channels_requested(cfg: dict[str, Any], passthrough: list[str], extra_specs: list[str] | None = None) -> bool:
13469
+ return native_channel_passthrough_requested(passthrough)
12921
13470
 
12922
13471
 
12923
13472
  def write_web_tools_mcp_config(cfg: dict[str, Any]) -> Path:
@@ -12960,6 +13509,351 @@ def write_duckduckgo_mcp_config(cfg: dict[str, Any]) -> Path:
12960
13509
  return path
12961
13510
 
12962
13511
 
13512
+ def write_channel_mcp_config() -> Path:
13513
+ data = {
13514
+ "mcpServers": {
13515
+ "claude-any-router": {
13516
+ "type": "sse",
13517
+ "url": f"{ROUTER_BASE}/ca/mcp/sse",
13518
+ }
13519
+ }
13520
+ }
13521
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
13522
+ CHANNEL_MCP_CONFIG.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
13523
+ try:
13524
+ os.chmod(CHANNEL_MCP_CONFIG, 0o600)
13525
+ except Exception:
13526
+ pass
13527
+ return CHANNEL_MCP_CONFIG
13528
+
13529
+
13530
+ def write_mcp_proxy_config(
13531
+ passthrough: list[str],
13532
+ *,
13533
+ extra_config_paths: list[Path | str] | None = None,
13534
+ cwd: Path | None = None,
13535
+ home: Path | None = None,
13536
+ ) -> Path | None:
13537
+ cwd = cwd or Path.cwd()
13538
+ extra = [Path(item).expanduser() for item in (extra_config_paths or [])]
13539
+ paths = [*extra, *claude_mcp_config_paths(passthrough, cwd, home)]
13540
+ servers: dict[str, Any] = {}
13541
+ seen: set[str] = set()
13542
+ server_dir = CONFIG_DIR / "mcp-proxy-servers"
13543
+ for path in paths:
13544
+ if not path.exists() or not path.is_file():
13545
+ continue
13546
+ for name, server in _read_mcp_servers_from_json(path, cwd):
13547
+ if name in seen:
13548
+ continue
13549
+ seen.add(name)
13550
+ if _mcp_server_is_stdio(server):
13551
+ server_dir.mkdir(parents=True, exist_ok=True)
13552
+ server_path = server_dir / f"{_safe_mcp_proxy_name(name)}.json"
13553
+ server_path.write_text(json.dumps(server, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
13554
+ try:
13555
+ os.chmod(server_path, 0o600)
13556
+ except Exception:
13557
+ pass
13558
+ servers[name] = {
13559
+ "command": sys.executable,
13560
+ "args": [
13561
+ str(Path(__file__).resolve()),
13562
+ "mcp-proxy",
13563
+ "--server-name",
13564
+ name,
13565
+ "--server-config",
13566
+ str(server_path),
13567
+ ],
13568
+ }
13569
+ else:
13570
+ servers[name] = server
13571
+ if not servers:
13572
+ return None
13573
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
13574
+ MCP_PROXY_CONFIG.write_text(json.dumps({"mcpServers": servers}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
13575
+ try:
13576
+ os.chmod(MCP_PROXY_CONFIG, 0o600)
13577
+ except Exception:
13578
+ pass
13579
+ router_log("INFO", f"mcp_proxy_config_written servers={','.join(sorted(servers))}")
13580
+ return MCP_PROXY_CONFIG
13581
+
13582
+
13583
+ def should_use_channel_stdin_proxy(use_router_mode: bool, passthrough: list[str]) -> bool:
13584
+ return bool(use_router_mode and not native_channel_passthrough_requested(passthrough))
13585
+
13586
+
13587
+ def format_channel_wake_prompt(message: dict[str, Any]) -> str:
13588
+ channel = str(message.get("channel") or "default")
13589
+ sender = str(message.get("sender_id") or "channel")
13590
+ mid = str(message.get("id") or "")
13591
+ meta = message.get("meta") if isinstance(message.get("meta"), dict) else {}
13592
+ room = str(meta.get("room_id") or meta.get("room") or channel)
13593
+ thread = str(message.get("thread_id") or meta.get("thread_id") or "")
13594
+ body = re.sub(r"\s+", " ", str(message.get("message") or "")).strip()
13595
+ fields = [f"channel={channel}", f"room={room}", f"from={sender}"]
13596
+ if mid:
13597
+ fields.append(f"id={mid}")
13598
+ if thread:
13599
+ fields.append(f"thread={thread}")
13600
+ return (
13601
+ "[claude-any external channel message] "
13602
+ + " ".join(fields)
13603
+ + f" text={json.dumps(body, ensure_ascii=False)}. "
13604
+ + "If relevant to current work, respond or act now; otherwise keep working."
13605
+ )
13606
+
13607
+
13608
+ def _write_fd_all(fd: int, data: bytes) -> None:
13609
+ view = memoryview(data)
13610
+ while view:
13611
+ written = os.write(fd, view)
13612
+ view = view[written:]
13613
+
13614
+
13615
+ def _channel_wake_input_bytes(prompt: str) -> bytes:
13616
+ # Ctrl-U clears any stale line editor text before submitting the synthetic prompt.
13617
+ return b"\x15" + prompt.encode("utf-8", errors="replace") + b"\n"
13618
+
13619
+
13620
+ def _inject_pending_channel_messages(master_fd: int, last_id: int) -> int:
13621
+ for message in read_chat_messages(last_id, None, None, 100):
13622
+ try:
13623
+ last_id = max(last_id, int(message.get("id") or 0))
13624
+ except Exception:
13625
+ continue
13626
+ prompt = format_channel_wake_prompt(message)
13627
+ _write_fd_all(master_fd, _channel_wake_input_bytes(prompt))
13628
+ router_log("INFO", f"channel_stdin_proxy_injected message_id={message.get('id')} channel={message.get('channel')}")
13629
+ return last_id
13630
+
13631
+
13632
+ def _chat_messages_file_marker() -> tuple[float, int]:
13633
+ try:
13634
+ stat = CHAT_MESSAGES_PATH.stat()
13635
+ return (stat.st_mtime, stat.st_size)
13636
+ except Exception:
13637
+ return (0.0, 0)
13638
+
13639
+
13640
+ def subprocess_call_with_channel_wake_proxy(cmd: list[str], env: dict[str, str]) -> int:
13641
+ if os.name != "posix" or not sys.stdin.isatty() or not sys.stdout.isatty():
13642
+ router_log("INFO", "channel_stdin_proxy_unavailable; using direct subprocess call")
13643
+ return subprocess.call(cmd, env=env)
13644
+ import pty
13645
+ import select
13646
+ import termios
13647
+ import tty
13648
+
13649
+ master_fd, slave_fd = pty.openpty()
13650
+ proc = subprocess.Popen(cmd, stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, env=env, close_fds=True)
13651
+ os.close(slave_fd)
13652
+ stdin_fd = sys.stdin.fileno()
13653
+ stdout_fd = sys.stdout.fileno()
13654
+ old_attrs = termios.tcgetattr(stdin_fd)
13655
+ last_id = _chat_init_next_id() - 1
13656
+ last_channel_poll = 0.0
13657
+ last_channel_marker = _chat_messages_file_marker()
13658
+ try:
13659
+ tty.setraw(stdin_fd)
13660
+ while proc.poll() is None:
13661
+ try:
13662
+ readable, _, _ = select.select([stdin_fd, master_fd], [], [], 0.2)
13663
+ except OSError:
13664
+ break
13665
+ if stdin_fd in readable:
13666
+ data = os.read(stdin_fd, 4096)
13667
+ if data:
13668
+ _write_fd_all(master_fd, data)
13669
+ if master_fd in readable:
13670
+ try:
13671
+ data = os.read(master_fd, 4096)
13672
+ except OSError:
13673
+ break
13674
+ if data:
13675
+ _write_fd_all(stdout_fd, data)
13676
+ now = time.time()
13677
+ if now - last_channel_poll >= 0.5:
13678
+ last_channel_poll = now
13679
+ marker = _chat_messages_file_marker()
13680
+ if marker != last_channel_marker:
13681
+ last_channel_marker = marker
13682
+ last_id = _inject_pending_channel_messages(master_fd, last_id)
13683
+ while True:
13684
+ try:
13685
+ readable, _, _ = select.select([master_fd], [], [], 0)
13686
+ if master_fd not in readable:
13687
+ break
13688
+ data = os.read(master_fd, 4096)
13689
+ if not data:
13690
+ break
13691
+ _write_fd_all(stdout_fd, data)
13692
+ except OSError:
13693
+ break
13694
+ return proc.returncode if proc.returncode is not None else 0
13695
+ finally:
13696
+ try:
13697
+ termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_attrs)
13698
+ except Exception:
13699
+ pass
13700
+ try:
13701
+ os.close(master_fd)
13702
+ except Exception:
13703
+ pass
13704
+ if proc.poll() is None:
13705
+ try:
13706
+ proc.terminate()
13707
+ except Exception:
13708
+ pass
13709
+
13710
+
13711
+ def _mcp_proxy_notification_payload(server_name: str, message: dict[str, Any]) -> dict[str, Any] | None:
13712
+ method = str(message.get("method") or "").strip()
13713
+ if not method.startswith("notifications/"):
13714
+ return None
13715
+ params = message.get("params") if isinstance(message.get("params"), dict) else {}
13716
+ payload = params.get("payload") if isinstance(params.get("payload"), dict) else {}
13717
+ data = params.get("data") if isinstance(params.get("data"), dict) else {}
13718
+ event = params.get("event") if isinstance(params.get("event"), dict) else {}
13719
+ meta: dict[str, Any] = {
13720
+ "mcp_server": server_name,
13721
+ "mcp_method": method,
13722
+ }
13723
+ meta.update(_event_meta_from_sources(message, params, payload, data, event))
13724
+ content = (
13725
+ _event_payload_text(params)
13726
+ or _event_payload_text(payload)
13727
+ or _event_payload_text(data)
13728
+ or _event_payload_text(event)
13729
+ )
13730
+ if not content and params:
13731
+ content = json.dumps(params, ensure_ascii=False, separators=(",", ":"), default=str)
13732
+ if not content:
13733
+ return None
13734
+ channel = str(meta.get("channel") or meta.get("room_id") or meta.get("room") or server_name)
13735
+ return {
13736
+ "channel": channel,
13737
+ "sender_id": str(meta.get("sender_id") or meta.get("agent_id") or server_name),
13738
+ "recipients": meta.get("recipient_id") or "all",
13739
+ "thread_id": meta.get("thread_id"),
13740
+ "parent_id": meta.get("parent_id"),
13741
+ "kind": method.replace("notifications/claude/", "").replace("notifications/", "").replace("/", "."),
13742
+ "message": content,
13743
+ "meta": meta,
13744
+ }
13745
+
13746
+
13747
+ def _mcp_proxy_observe_stdout_line(server_name: str, line: bytes) -> None:
13748
+ try:
13749
+ text = line.decode("utf-8", errors="replace").strip()
13750
+ if not text or not text.startswith("{"):
13751
+ return
13752
+ payload = json.loads(text)
13753
+ except Exception:
13754
+ return
13755
+ if not isinstance(payload, dict):
13756
+ return
13757
+ chat_payload = _mcp_proxy_notification_payload(server_name, payload)
13758
+ if not chat_payload:
13759
+ return
13760
+ try:
13761
+ saved = append_chat_message(chat_payload)
13762
+ router_log(
13763
+ "INFO",
13764
+ f"mcp_proxy_notification server={server_name} method={payload.get('method')} message_id={saved.get('id')}",
13765
+ )
13766
+ except Exception as exc:
13767
+ router_log("WARN", f"mcp_proxy_notification_failed server={server_name} error={type(exc).__name__}: {exc}")
13768
+
13769
+
13770
+ def _mcp_proxy_forward_stdin(proc: subprocess.Popen[bytes]) -> None:
13771
+ try:
13772
+ while True:
13773
+ chunk = sys.stdin.buffer.read(65536)
13774
+ if not chunk:
13775
+ break
13776
+ if proc.stdin:
13777
+ proc.stdin.write(chunk)
13778
+ proc.stdin.flush()
13779
+ except Exception:
13780
+ pass
13781
+ finally:
13782
+ try:
13783
+ if proc.stdin:
13784
+ proc.stdin.close()
13785
+ except Exception:
13786
+ pass
13787
+
13788
+
13789
+ def _mcp_proxy_forward_stderr(proc: subprocess.Popen[bytes]) -> None:
13790
+ try:
13791
+ if not proc.stderr:
13792
+ return
13793
+ while True:
13794
+ chunk = proc.stderr.read(4096)
13795
+ if not chunk:
13796
+ break
13797
+ sys.stderr.buffer.write(chunk)
13798
+ sys.stderr.buffer.flush()
13799
+ except Exception:
13800
+ pass
13801
+
13802
+
13803
+ def run_mcp_stdio_proxy(server_name: str, server_config_path: Path) -> int:
13804
+ try:
13805
+ server = json.loads(server_config_path.read_text(encoding="utf-8"))
13806
+ except Exception as exc:
13807
+ print(f"claude-any mcp-proxy: cannot read server config: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
13808
+ return 2
13809
+ if not isinstance(server, dict) or not _mcp_server_is_stdio(server):
13810
+ print("claude-any mcp-proxy: server config is not a stdio MCP server", file=sys.stderr, flush=True)
13811
+ return 2
13812
+ command = str(server.get("command") or "").strip()
13813
+ args = [str(item) for item in server.get("args", [])] if isinstance(server.get("args"), list) else []
13814
+ env = os.environ.copy()
13815
+ raw_env = server.get("env")
13816
+ if isinstance(raw_env, dict):
13817
+ env.update({str(k): str(v) for k, v in raw_env.items() if str(k)})
13818
+ cwd_value = server.get("cwd") or server.get("workingDirectory")
13819
+ cwd = str(cwd_value) if cwd_value else None
13820
+ try:
13821
+ proc = subprocess.Popen(
13822
+ [command, *args],
13823
+ stdin=subprocess.PIPE,
13824
+ stdout=subprocess.PIPE,
13825
+ stderr=subprocess.PIPE,
13826
+ cwd=cwd,
13827
+ env=env,
13828
+ )
13829
+ except Exception as exc:
13830
+ print(f"claude-any mcp-proxy: failed to start {command}: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
13831
+ return 127
13832
+ threading.Thread(target=_mcp_proxy_forward_stdin, args=(proc,), daemon=True, name=f"mcp-proxy-stdin-{server_name}").start()
13833
+ threading.Thread(target=_mcp_proxy_forward_stderr, args=(proc,), daemon=True, name=f"mcp-proxy-stderr-{server_name}").start()
13834
+ try:
13835
+ if proc.stdout:
13836
+ for line in iter(proc.stdout.readline, b""):
13837
+ _mcp_proxy_observe_stdout_line(server_name, line)
13838
+ sys.stdout.buffer.write(line)
13839
+ sys.stdout.buffer.flush()
13840
+ return proc.wait()
13841
+ finally:
13842
+ if proc.poll() is None:
13843
+ try:
13844
+ proc.terminate()
13845
+ except Exception:
13846
+ pass
13847
+
13848
+
13849
+ def cmd_mcp_proxy(argv: list[str]) -> int:
13850
+ parser = argparse.ArgumentParser(prog="claude-any mcp-proxy")
13851
+ parser.add_argument("--server-name", required=True)
13852
+ parser.add_argument("--server-config", required=True)
13853
+ args = parser.parse_args(argv)
13854
+ return run_mcp_stdio_proxy(args.server_name, Path(args.server_config).expanduser())
13855
+
13856
+
12963
13857
  def run_claude_update_check(claude: str, enabled: bool = True) -> None:
12964
13858
  if not enabled:
12965
13859
  return
@@ -13264,14 +14158,16 @@ def launch_claude(
13264
14158
  use_native_anthropic = native_anthropic_enabled(provider)
13265
14159
  use_ollama_native = ollama_native_compat_enabled(provider, pcfg)
13266
14160
  use_provider_native = provider_native_compat_enabled(provider, pcfg)
14161
+ use_router_mode = not (use_native_anthropic or use_ollama_native or use_provider_native)
13267
14162
  cleanup_managed_services_for_provider(provider, pcfg, cfg, quiet=True)
13268
- if not (use_native_anthropic or use_ollama_native or use_provider_native):
14163
+ if use_router_mode:
13269
14164
  start_router_if_needed()
13270
14165
  env = os.environ.copy()
13271
14166
  env["PATH"] = str(HOME / ".local" / "bin") + os.pathsep + env.get("PATH", "")
13272
14167
  launch_env = env_vars(cfg)
13273
14168
  launch_passthrough = normalize_channel_passthrough(passthrough)
13274
14169
  if claude_channels_requested(cfg, launch_passthrough):
14170
+ env.pop("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", None)
13275
14171
  launch_env.pop("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", None)
13276
14172
  if use_native_anthropic:
13277
14173
  for key in (
@@ -13302,8 +14198,21 @@ def launch_claude(
13302
14198
  run_claude_update_check(claude, enabled=update_check)
13303
14199
  claude = find_executable("claude") or claude
13304
14200
  extra_args: list[str] = []
14201
+ mcp_config_paths: list[str] = []
13305
14202
  if should_attach_web_search(provider, cfg, web_search_override):
13306
- extra_args.extend(["--mcp-config", str(write_duckduckgo_mcp_config(cfg))])
14203
+ mcp_config_paths.append(str(write_duckduckgo_mcp_config(cfg)))
14204
+ claude_passthrough = list(launch_passthrough)
14205
+ if should_use_channel_stdin_proxy(use_router_mode, launch_passthrough):
14206
+ auto_start_sse_channels_from_mcp_configs(launch_passthrough)
14207
+ proxy_config = write_mcp_proxy_config(
14208
+ launch_passthrough,
14209
+ extra_config_paths=[Path(path) for path in mcp_config_paths],
14210
+ )
14211
+ if proxy_config:
14212
+ mcp_config_paths = [str(proxy_config)]
14213
+ claude_passthrough = strip_mcp_config_passthrough(launch_passthrough)
14214
+ if mcp_config_paths:
14215
+ extra_args.extend(["--mcp-config", *mcp_config_paths])
13307
14216
  if should_append_compat_prompt(provider, cfg) and not has_passthrough_option(launch_passthrough, "--system-prompt"):
13308
14217
  extra_args.extend(["--append-system-prompt", NON_ANTHROPIC_COMPAT_PROMPT])
13309
14218
  extra_args.extend(claude_channel_args(cfg, launch_passthrough))
@@ -13315,7 +14224,9 @@ def launch_claude(
13315
14224
  if model:
13316
14225
  cmd.extend(["--model", model])
13317
14226
  cmd.extend(extra_args)
13318
- cmd.extend(launch_passthrough)
14227
+ cmd.extend(claude_passthrough)
14228
+ if should_use_channel_stdin_proxy(use_router_mode, launch_passthrough):
14229
+ return subprocess_call_with_channel_wake_proxy(cmd, env)
13319
14230
  return subprocess.call(cmd, env=env)
13320
14231
 
13321
14232
 
@@ -13337,7 +14248,8 @@ Control plane, runs before Claude Code and does not require LLM connectivity:
13337
14248
  claude-any set-api-key PROVIDER KEY
13338
14249
  claude-any web-search [on|off] Auto-attach DuckDuckGo MCP for non-native providers
13339
14250
  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
14251
+ claude-any log-level [LEVEL] Show or set router log level
14252
+ claude-any channels [cmd] Configure external channel specs
13341
14253
  claude-any ollama-native [on|off] Use Ollama's official Claude Code env path
13342
14254
  claude-any ollama-options [provider] [key=value ...]
13343
14255
  Set Ollama num_ctx/options/keep_alive/think
@@ -13372,12 +14284,13 @@ Headless setup flags, namespaced to avoid Claude CLI collisions:
13372
14284
  claude-any --ca-rate-limit-status on|off
13373
14285
  claude-any --ca-stream on|off
13374
14286
  claude-any --ca-stream-word-chunking on|off
14287
+ claude-any --ca-log-level LEVEL Set router log level: SILENT, ERROR, WARN, INFO, DEBUG, TRACE
13375
14288
  claude-any --ca-web-search Force DuckDuckGo MCP for this launch
13376
14289
  claude-any --ca-no-web-search Disable DuckDuckGo MCP for this launch
13377
14290
  claude-any --ca-web-fetch Enable fetch MCP
13378
14291
  claude-any --ca-no-web-fetch Disable fetch MCP
13379
14292
  claude-any --ca-channel SPEC Add an official/approved Claude Code channel
13380
- claude-any --ca-clear-channels Clear saved channel auto-injection specs
14293
+ claude-any --ca-clear-channels Clear saved channel specs
13381
14294
  claude-any --ca-no-self-update-check
13382
14295
  Skip Claude Any npm self-update check
13383
14296
  claude-any --ca-no-update-check Skip Claude Code update check for this launch
@@ -13505,6 +14418,8 @@ def apply_headless_env_config() -> tuple[bool, bool | None, bool | None, bool |
13505
14418
 
13506
14419
 
13507
14420
  def run_cli(argv: list[str]) -> int:
14421
+ if argv and argv[0] == "mcp-proxy":
14422
+ return cmd_mcp_proxy(argv[1:])
13508
14423
  if argv and argv[0] in ("help", "--help", "-h"):
13509
14424
  print(cli_usage())
13510
14425
  return 0
@@ -13560,6 +14475,9 @@ def run_cli(argv: list[str]) -> int:
13560
14475
  if head in ("web-fetch", "webfetch"):
13561
14476
  cmd_web_fetch(argparse.Namespace(value=rest[0] if rest else None))
13562
14477
  return 0
14478
+ if head in ("log-level", "loglevel", "logging"):
14479
+ cmd_log_level(argparse.Namespace(value=rest[0] if rest else None))
14480
+ return 0
13563
14481
  if head in ("channels", "channel"):
13564
14482
  cmd_channels(argparse.Namespace(values=rest))
13565
14483
  return 0
@@ -13881,6 +14799,18 @@ def run_cli(argv: list[str]) -> int:
13881
14799
  i += 1
13882
14800
  cmd_provider_options(argparse.Namespace(values=[f"stream_word_chunking={value}"]))
13883
14801
  skip_menu = True
14802
+ elif arg == "--ca-log-level" or arg.startswith("--ca-log-level="):
14803
+ value = arg.split("=", 1)[1] if "=" in arg else None
14804
+ if value is None:
14805
+ if i + 1 >= len(argv):
14806
+ raise SystemExit("Missing level for --ca-log-level")
14807
+ value = argv[i + 1]
14808
+ i += 2
14809
+ else:
14810
+ i += 1
14811
+ for line in set_log_level_config(value):
14812
+ print(line)
14813
+ skip_menu = True
13884
14814
  elif arg == "--ca-web-search":
13885
14815
  web_search_override = True
13886
14816
  skip_menu = True
@@ -14008,6 +14938,9 @@ def build_parser() -> argparse.ArgumentParser:
14008
14938
  wf = sub.add_parser("web-fetch")
14009
14939
  wf.add_argument("value", nargs="?")
14010
14940
  wf.set_defaults(func=cmd_web_fetch)
14941
+ ll = sub.add_parser("log-level")
14942
+ ll.add_argument("value", nargs="?")
14943
+ ll.set_defaults(func=cmd_log_level)
14011
14944
  ch = sub.add_parser("channels")
14012
14945
  ch.add_argument("values", nargs="*")
14013
14946
  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.77",
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",