Files
codex-app-server/cloud/shared_home_mcp.py
host b977a9dee8 feat: sync shared-home MCP scripts on control-plane startup
租户 config 的 mcp_servers.* 指向共享 home 路径,但宿主共享
home 里原先没有 MCP 脚本,宿主 app-server 启动 MCP 即文件
不存在、握手断掉,面板 mcp-servers-list 三服务器 toolsError
全是 handshake failed;14:43 的手工拷入能修一次,但下次
MCP 脚本改动后面板又会跑旧版。

本批只在服务端 main() 启动时同步一次:清单唯一定义在
cloud/shared_home_mcp.py(7 文件:playwright_mcp/browser/
remote、websearch_mcp/websearch、imagegen_mcp/imagegen,含
同目录兄弟模块),解析优先用 host-config-host.json 的
codex_home,兜底用 tenants_root 父目录/codex-home;缺失
拷入、不同刷新(原子替换)、相同不动;源缺失/目标不可写
只记 warning,绝不阻塞启动。create_server() 不做同步,
避免单测写向生产共享路径。

被否决的落点:worker _tenant_dirs(只在下个任务派发时跑,
面板要等下一个任务才更新,且不修共享路径本身);deploy
host_deploy.sh(会在 shell 里复制第二份清单,且重启不
部署时不刷新);面板请求时同步(把读请求变成写请求,
失败会把面板读数变成 502 风险)。
2026-10-30 07:08:51 +00:00

140 lines
5.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""共享 codex-home 的 MCP 脚本自动补齐。
背景(2026-10-30 实测):租户 config 的 ``mcp_servers.*`` 指向
共享路径 ``<codex-home>/playwright_mcp.py`` 等,但宿主共享 home
里原先一个 MCP 脚本都没有——宿主 app-server 按该路径启动 MCP
时文件不存在、握手断掉,面板 ``mcp-servers-list`` 三服务器的
``toolsError`` 全是 ``handshake failed``。Docker 任务不受影响,
是因为租户 home 被挂载到容器内那个共享路径,容器内能解析。
本模块是这 7 个文件清单的唯一定义处:服务端启动同步、
测试、以及将来任何其它调用方都只从这里取清单,绝不在
server/worker/deploy 三处各写一份 7 文件列表(清单漂移就是
下一次“面板又跑旧版”的根源)。
语义与 playwright 播种一致,而不是 websearch/imagegen 的租户
播种语义:共享 home 里的脚本是系统文件,不是租户数据,租户
不会去改它,所以
- 缺失则拷入;
- 内容不同则刷新(原子替换)并记入 ``refreshed``;
- 内容相同则不动,绝不重写 mtime。
其它文件(config.toml、技能、租户数据)一律不动。
失败语义:本函数绝不抛异常阻塞调用方。源缺失、目标目录建
不出、单个文件不可写时只记入返回值(调用方记 warning),其
余文件照常同步。调用方(服务端启动)因此永远不会因为一次
补齐失败而起不来。
"""
import os
import tempfile
# 唯一清单:MCP 脚本与其同目录兄弟模块。只拷 *_mcp.py 不够,
# 因为 MCP 脚本 fallback 时从同目录 import 兄弟模块
# (playwright_mcp -> playwright_browser/playwright_remote,
# websearch_mcp -> websearch,imagegen_mcp -> imagegen)。
SHARED_HOME_MCP_FILES = (
"playwright_mcp.py",
"playwright_browser.py",
"playwright_remote.py",
"websearch_mcp.py",
"websearch.py",
"imagegen_mcp.py",
"imagegen.py",
)
def _cloud_dir():
"""部署代码的 cloud/ 目录:同步源,绝不是租户目录。"""
return os.path.dirname(os.path.abspath(__file__))
def _write_atomic(dst, data):
"""同目录临时文件 + os.replace 原子替换,避免并发启动的
另一控制面实例读到半写文件。"""
directory = os.path.dirname(dst) or "."
fd, tmp = tempfile.mkstemp(prefix=".mcp-sync-", dir=directory)
try:
with os.fdopen(fd, "wb") as fh:
fh.write(data)
os.replace(tmp, dst)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def sync_shared_home_mcp(shared_home, source_dir=None):
"""把部署版 MCP 脚本同步到共享 home。
参数:
- shared_home:目标目录(测试时传临时目录;生产由服务端
的解析函数给出,见 cloud/server.py 的
``_resolve_shared_home_mcp``)。
- source_dir:源目录,缺省为本模块所在的 cloud/ 目录。
返回 dict,键固定为 copied / refreshed / unchanged /
missing / error,调用方只看它、不捕获异常:
- copied:目标缺失、本次拷入的文件;
- refreshed:目标存在但内容不同、本次刷新的文件;
- unchanged:内容相同、本次未动的文件;
- missing:源目录里不存在的文件(跳过,不阻塞其它文件);
- error:None 或把缺失/写失败拼成的一行文本,调用方记
warning 即可,绝不因此阻塞启动/部署/派发。
"""
copied = []
refreshed = []
unchanged = []
missing = []
errors = []
if not shared_home:
return {"copied": copied, "refreshed": refreshed,
"unchanged": unchanged, "missing": missing,
"error": "no shared home resolved"}
source = os.path.abspath(source_dir) if source_dir else _cloud_dir()
try:
os.makedirs(shared_home, exist_ok=True)
except Exception as exc: # 目录建不出:只记错,不阻塞
return {"copied": copied, "refreshed": refreshed,
"unchanged": unchanged, "missing": missing,
"error": "cannot create shared home %s: %s"
% (shared_home, exc)}
for name in SHARED_HOME_MCP_FILES:
src = os.path.join(source, name)
dst = os.path.join(shared_home, name)
try:
with open(src, "rb") as fh:
data = fh.read()
except OSError:
missing.append(name)
continue
except Exception as exc:
errors.append("%s: cannot read source: %s" % (name, exc))
continue
try:
if os.path.isfile(dst):
with open(dst, "rb") as fh:
if fh.read() == data:
unchanged.append(name)
continue
_write_atomic(dst, data)
refreshed.append(name)
elif os.path.exists(dst):
# 目标是目录等非文件:不删、不覆盖,只记错。
errors.append("%s: destination is not a file" % name)
else:
_write_atomic(dst, data)
copied.append(name)
except Exception as exc:
errors.append("%s: %s" % (name, exc))
continue
parts = []
if missing:
parts.append("missing source files: %s" % ",".join(missing))
parts.extend(errors)
return {"copied": copied, "refreshed": refreshed,
"unchanged": unchanged, "missing": missing,
"error": "; ".join(parts) if parts else None}