租户 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 风险)。
140 lines
5.5 KiB
Python
140 lines
5.5 KiB
Python
"""共享 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}
|