From 17ea23bf74175e5a28c3522370ba37927e629553 Mon Sep 17 00:00:00 2001 From: estelledc Date: Sat, 4 Jul 2026 10:06:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=A3=E7=90=86=E6=94=AF=E6=8C=81=20?= =?UTF-8?q?--upstream-proxy=EF=BC=8C=E4=BF=AE=E5=A4=8D=20MCP=20=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E6=95=B0=E6=8D=AE=E5=BA=93=E5=9B=BD=E5=86=85=E7=9B=B4?= =?UTF-8?q?=E8=BF=9E=E4=B8=8D=E5=8F=AF=E8=BE=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:Streamable HTTP MCP 服务器(PubMed/ChEMBL/clinical-trials/biorxiv) 在国内网络环境下直连不可达。CSswitch 代理的 CONNECT 隧道直连目标, 不经过用户的翻墙代理,导致连接超时/失败。 修改: - csswitch_proxy.py:新增 --upstream-proxy 参数(host:port) 非 Anthropic 域的 CONNECT 隧道经上游代理建立,而非直连 - config.rs:新增 upstream_proxy 配置字段(持久化) - lib.rs:ensure_proxy() 传递 --upstream-proxy 给代理进程 - launch-virtual-sandbox.sh:支持 --upstream-proxy 参数, 同时设 http_proxy 指向上游代理(MCP HTTP 请求出墙) 用法: 代理: python3 csswitch_proxy.py --upstream-proxy 127.0.0.1:7890 沙箱: launch-virtual-sandbox.sh --upstream-proxy http://127.0.0.1:7890 Closes #11 --- desktop/src-tauri/src/config.rs | 6 +++ desktop/src-tauri/src/lib.rs | 12 ++++-- proxy/csswitch_proxy.py | 68 +++++++++++++++++++++++-------- scripts/launch-virtual-sandbox.sh | 21 +++++++--- 4 files changed, 83 insertions(+), 24 deletions(-) diff --git a/desktop/src-tauri/src/config.rs b/desktop/src-tauri/src/config.rs index d71f108..6bf27c4 100644 --- a/desktop/src-tauri/src/config.rs +++ b/desktop/src-tauri/src/config.rs @@ -55,6 +55,11 @@ pub struct Config { /// 官方模式下 CSSwitch 只负责把你交回真实客户端,绝不碰/托管你的官方登录。 #[serde(default = "default_mode")] pub mode: String, + /// CONNECT 隧道上游代理(host:port,如 "127.0.0.1:7890")。非空时非 Anthropic 域的 + /// HTTPS CONNECT 隧道经此代理而非直连目标,解决 MCP 外部数据库(PubMed/ChEMBL 等) + /// 在国内直连不可达的问题(#11)。 + #[serde(default)] + pub upstream_proxy: String, #[serde(default)] pub providers: BTreeMap, } @@ -67,6 +72,7 @@ impl Default for Config { sandbox_port: default_sandbox_port(), secret: String::new(), mode: default_mode(), + upstream_proxy: String::new(), providers: BTreeMap::new(), } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d397ddd..c4c2093 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -267,14 +267,20 @@ fn ensure_proxy( let logf = open_log("proxy.log").map_err(|e| format!("建日志失败:{e}"))?; let logf2 = logf.try_clone().map_err(|e| e.to_string())?; - let child = Command::new(&py) - .arg(&script) + let mut cmd = Command::new(&py); + cmd.arg(&script) .arg("--provider") .arg(&provider) .arg("--port") .arg(port.to_string()) .arg("--auth-token") - .arg(&secret) + .arg(&secret); + // 上游代理(修 #11):非空时传给代理的 --upstream-proxy, + // 使非 Anthropic 域的 CONNECT 隧道经此代理出墙(MCP 外部数据库连通)。 + if !cfg.upstream_proxy.is_empty() { + cmd.arg("--upstream-proxy").arg(&cfg.upstream_proxy); + } + let child = cmd // key 经环境变量注入,绝不作为命令行参数(避免 ps 泄露)。 .env(key_env(&provider), &key) .stdout(Stdio::from(logf)) diff --git a/proxy/csswitch_proxy.py b/proxy/csswitch_proxy.py index 60780dd..3a243e1 100644 --- a/proxy/csswitch_proxy.py +++ b/proxy/csswitch_proxy.py @@ -93,6 +93,7 @@ LOG = None PROV_NAME = None # 运行时设定;模块被 import 做测试时也要有定义,避免 handler NameError AUTH_SECRET = None # 未设则不启用鉴权(保持旧行为) +UPSTREAM_PROXY = None # CONNECT 隧道上游代理(如 Clash 7890),解决 MCP 外部数据库在国内直连不通 _DATE_SUFFIX = re.compile(r"-\d{8}$") # ---------- #3: targeted fast-fail(沙箱「Switching organization」卡死修复) ---------- @@ -418,19 +419,13 @@ def do_POST(self): self._handle_openai(areq) # ---- HTTP CONNECT 隧道:Anthropic 域名 fast-fail、其余透传(修 #3) ---- + # 当指定了 --upstream-proxy 时,非 Anthropic 域的 CONNECT 隧道不再直连目标, + # 而是通过上游代理(如 Clash/FlClash 的 7890)建立隧道,解决国内环境 MCP 外部 + # 数据库(PubMed/ChEMBL 等)直连不可达的问题(修 #11)。 def do_CONNECT(self): - # operon 用 https_proxy 走到这里;self.path 形如 "host:port"。 - # 【为何不走 _auth_ok】CONNECT 把目标放在请求行、没有可嵌 path-secret 的位置, - # operon 的 https_proxy 也带不上 secret。此处不鉴权的实际风险面很小: - # - 只监听回环(127.0.0.1),本机进程本就能自行外连,隧道不给它任何新能力; - # - 隧道是裸 TCP 转发,不注入上游 key、不经推理端点(那两条仍受 secret 保护)。 - # 即 path-secret 真正守护的边界(第三方 key + 推理端点)未被削弱。 - # 进一步收紧可让 launch 把 secret 放进 https_proxy 的 userinfo 再校验 - # Proxy-Authorization,但需先实测 operon 是否会带该头(否则误伤透传),留待整链联调。 target = self.path host = target.rsplit(":", 1)[0].strip("[]").lower() if _is_blocked_host(host): - # 401(未登录)而非 403(禁止):让 operon 判 logged-out 秒过,而非当组织问题反复重试。 log(f"CONNECT {target} -> 401 未登录(Anthropic 域名 fast-fail)") self._connect_reply(401) return @@ -439,19 +434,45 @@ def do_CONNECT(self): except (ValueError, IndexError): self._connect_reply(400) return - try: - upstream = socket.create_connection((host, port), timeout=10) - except Exception as e: - log(f"CONNECT {target} -> 502 上游连不上: {e}") - self._connect_reply(502) - return + if UPSTREAM_PROXY: + # 经上游代理建立隧道(修 #11:MCP 外部数据库连通) + try: + uph, upp = UPSTREAM_PROXY + upstream = socket.create_connection((uph, upp), timeout=10) + connect_req = f"CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n" + upstream.sendall(connect_req.encode()) + # 读上游代理的 CONNECT 响应 + resp = b"" + while b"\r\n\r\n" not in resp: + chunk = upstream.recv(4096) + if not chunk: + upstream.close() + raise ConnectionError("上游代理 CONNECT 响应不完整") + resp += chunk + status_line = resp.split(b"\r\n")[0].decode("utf-8", "replace") + if " 200 " not in status_line: + upstream.close() + log(f"CONNECT {target} -> 上游代理拒绝: {status_line.strip()}") + self._connect_reply(502) + return + log(f"CONNECT {target} -> 经上游代理 {uph}:{upp} 建立隧道") + except Exception as e: + log(f"CONNECT {target} -> 上游代理连不上 ({uph}:{upp}): {e}") + self._connect_reply(502) + return + else: + try: + upstream = socket.create_connection((host, port), timeout=10) + except Exception as e: + log(f"CONNECT {target} -> 502 直连不上: {e}") + self._connect_reply(502) + return self.send_response(200, "Connection Established") self.end_headers() try: self.wfile.flush() except Exception: pass - log(f"CONNECT {target} -> 隧道建立,透传") try: self._tunnel(self.connection, upstream) finally: @@ -644,6 +665,9 @@ def _replay_as_sse(self, aresp): ap.add_argument("--env-file", default=None) ap.add_argument("--log", default=None) ap.add_argument("--auth-token", default=None) + ap.add_argument("--upstream-proxy", default=None, + help="CONNECT 隧道上游代理 host:port(如 127.0.0.1:7890)," + "用于 MCP 外部数据库出墙(PubMed/ChEMBL 等)") args = ap.parse_args() PROV_NAME = args.provider PROV = PROVIDERS[PROV_NAME] @@ -654,6 +678,18 @@ def _replay_as_sse(self, aresp): if _up: PROV = dict(PROV) PROV["url"] = _up + if args.upstream_proxy: + hp = args.upstream_proxy.rsplit(":", 1) + if len(hp) == 2: + try: + UPSTREAM_PROXY = (hp[0], int(hp[1])) + except ValueError: + print(f"[csswitch] --upstream-proxy 端口非法:{hp[1]}", file=sys.stderr) + sys.exit(1) + else: + print(f"[csswitch] --upstream-proxy 格式应为 host:port,收到:{args.upstream_proxy}", + file=sys.stderr) + sys.exit(1) if not KEY: print(f"找不到 {PROV['key_env']}。用环境变量或 --env-file <路径> 提供。", file=sys.stderr) sys.exit(1) diff --git a/scripts/launch-virtual-sandbox.sh b/scripts/launch-virtual-sandbox.sh index 44c054f..9d02b77 100755 --- a/scripts/launch-virtual-sandbox.sh +++ b/scripts/launch-virtual-sandbox.sh @@ -13,8 +13,10 @@ # - encryption.key 的 keychain 镜像账号按【路径哈希】派生,沙箱与真实天然隔离 # # 用法: -# 先起代理: DEEPSEEK_API_KEY=... python3 proxy/csswitch_proxy.py --provider deepseek --port 18991 -# 再起沙箱: scripts/launch-virtual-sandbox.sh [--port 8990] [--proxy-url http://127.0.0.1:18991] +# 先起代理: DEEPSEEK_API_KEY=... python3 proxy/csswitch_proxy.py --provider deepseek --port 18991 \ +# [--upstream-proxy 127.0.0.1:7890] +# 再起沙箱: scripts/launch-virtual-sandbox.sh [--port 8990] [--proxy-url http://127.0.0.1:18991] \ +# [--upstream-proxy http://127.0.0.1:7890] set -euo pipefail PROJ="${0:A:h:h}" @@ -24,6 +26,7 @@ REAL_DIR="$HOME/.claude-science" BIN="/Applications/Claude Science.app/Contents/Resources/bin/claude-science" PORT=8990 PROXY_URL="http://127.0.0.1:18991" +UPSTREAM_PROXY="" # 上游代理(如 Clash 7890),修 #11:MCP 外部数据库连通 EMAIL="virtual@localhost.invalid" DRY_RUN=0 SKIP_FORGE=0 # app 调用时置 1:OAuth 由 app 进程内 Rust 伪造,本脚本不再调 node @@ -32,6 +35,7 @@ while [[ $# -gt 0 ]]; do case "$1" in --port) PORT="$2"; shift 2;; --proxy-url) PROXY_URL="$2"; shift 2;; + --upstream-proxy) UPSTREAM_PROXY="$2"; shift 2;; --email) EMAIL="$2"; shift 2;; --dry-run) DRY_RUN=1; shift;; --skip-oauth-forge) SKIP_FORGE=1; shift;; @@ -102,18 +106,25 @@ echo " 账号 = $EMAIL (本地假账号,不用真实凭证)" # 在到不了 claude.ai 的网络上会挂住重试 → UI 卡在 "Switching organization"。 # 做法:**只设 https_proxy**(那条卡死的 profile 请求是 HTTPS → 走 CONNECT,代理的 # do_CONNECT 对上述域名立即 403,operon 秒判 logged-out 秒过;其余 HTTPS 隧道透传)。 -# 【刻意不设 http_proxy】:代理未实现普通 HTTP 转发(GET http://host/…),若设了 http_proxy -# 普通 HTTP 的 MCP/下载/包源会撞代理拿 404(修 P2);不设则普通 HTTP 直连或走用户自己的 -# http_proxy,且无 Anthropic 域名走普通 HTTP,故不影响 fast-fail。 # no_proxy 让本地推理仍直连 127.0.0.1(operon 认【小写】 https_proxy/no_proxy)。 +# 【修 #11】:若指定了 --upstream-proxy,则同时设 http_proxy 指向上游代理, +# 解决 MCP 外部数据库(PubMed/ChEMBL 等)的 HTTP 请求国内直连不可达问题。 +# HTTPS 的 MCP 请求仍走 CSswitch 代理的 CONNECT 隧道(配合代理的 --upstream-proxy 参数)。 _PROXY_HOSTPORT="$(printf '%s' "$PROXY_URL" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://([^/]+).*#\1#')" _FASTFAIL_PROXY="http://$_PROXY_HOSTPORT" _NO_PROXY="127.0.0.1,localhost,::1" echo " 外联防卡 = Anthropic HTTPS fast-fail(经 $_FASTFAIL_PROXY,no_proxy=$_NO_PROXY)" +if [[ -n "$UPSTREAM_PROXY" ]]; then + echo " 上游代理 = $UPSTREAM_PROXY(MCP 外部数据库 / 包源走此通道)" + _HTTP_PROXY="$UPSTREAM_PROXY" +else + _HTTP_PROXY="" +fi echo HOME="$SANDBOX_HOME" \ ANTHROPIC_BASE_URL="$PROXY_URL" \ +http_proxy="${_HTTP_PROXY:-}" HTTP_PROXY="${_HTTP_PROXY:-}" \ https_proxy="$_FASTFAIL_PROXY" HTTPS_PROXY="$_FASTFAIL_PROXY" \ no_proxy="$_NO_PROXY" NO_PROXY="$_NO_PROXY" \ "$BIN" serve \