@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.
- package/claude_any.py +1087 -18
- 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.
|
|
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.
|
|
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", "
|
|
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
|
|
12911
|
-
|
|
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
|
|
12918
|
-
|
|
12919
|
-
|
|
12920
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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