mcp-softBV
This commit is contained in:
@@ -10,17 +10,18 @@ from starlette.routing import Mount
|
|||||||
|
|
||||||
from system_tools import create_system_mcp
|
from system_tools import create_system_mcp
|
||||||
from materialproject_mcp import create_materials_mcp
|
from materialproject_mcp import create_materials_mcp
|
||||||
|
from softBV import create_softbv_mcp
|
||||||
# 创建 MCP 实例
|
# 创建 MCP 实例
|
||||||
system_mcp = create_system_mcp()
|
system_mcp = create_system_mcp()
|
||||||
materials_mcp = create_materials_mcp()
|
materials_mcp = create_materials_mcp()
|
||||||
|
softbv_mcp = create_softbv_mcp()
|
||||||
# 在 Starlette 的 lifespan 中启动 MCP 的 session manager
|
# 在 Starlette 的 lifespan 中启动 MCP 的 session manager
|
||||||
@contextlib.asynccontextmanager
|
@contextlib.asynccontextmanager
|
||||||
async def lifespan(app: Starlette):
|
async def lifespan(app: Starlette):
|
||||||
async with contextlib.AsyncExitStack() as stack:
|
async with contextlib.AsyncExitStack() as stack:
|
||||||
await stack.enter_async_context(system_mcp.session_manager.run())
|
await stack.enter_async_context(system_mcp.session_manager.run())
|
||||||
await stack.enter_async_context(materials_mcp.session_manager.run())
|
await stack.enter_async_context(materials_mcp.session_manager.run())
|
||||||
|
await stack.enter_async_context(softbv_mcp.session_manager.run())
|
||||||
yield # 服务器运行期间
|
yield # 服务器运行期间
|
||||||
# 退出时自动清理
|
# 退出时自动清理
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ app = Starlette(
|
|||||||
routes=[
|
routes=[
|
||||||
Mount("/system", app=system_mcp.streamable_http_app()),
|
Mount("/system", app=system_mcp.streamable_http_app()),
|
||||||
Mount("/materials", app=materials_mcp.streamable_http_app()),
|
Mount("/materials", app=materials_mcp.streamable_http_app()),
|
||||||
|
Mount("/softBV", app=softbv_mcp.streamable_http_app()),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,5 +40,5 @@ app = Starlette(
|
|||||||
# 访问:
|
# 访问:
|
||||||
# http://localhost:8000/system
|
# http://localhost:8000/system
|
||||||
# http://localhost:8000/materials
|
# http://localhost:8000/materials
|
||||||
#
|
# http://localhost:8000/softBV
|
||||||
# 如果需要浏览器客户端访问(CORS 暴露 Mcp-Session-Id),请参考 README 中的 CORS 配置示例 [1]
|
# 如果需要浏览器客户端访问(CORS 暴露 Mcp-Session-Id),请参考 README 中的 CORS 配置示例 [1]
|
||||||
464
mcp/softBV.py
Normal file
464
mcp/softBV.py
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
# softbv_mcp.py
|
||||||
|
#
|
||||||
|
# 在远程服务器上激活 softBV 环境并执行计算(支持 --md 与 --gen-cube 两个专用工具)。
|
||||||
|
# - 生命周期:建立 SSH 连接并注入上下文
|
||||||
|
# - 激活环境:source /cluster/home/koko125/script/softBV.sh
|
||||||
|
# - 工作目录:/cluster/home/koko125/sandbox
|
||||||
|
# - 可执行文件:/cluster/home/koko125/tool/softBV-GUI_linux/bin/softBV.x
|
||||||
|
#
|
||||||
|
# 依赖:
|
||||||
|
# pip install "mcp[cli]" asyncssh pydantic
|
||||||
|
#
|
||||||
|
# 用法(Starlette 挂载示例见你现有 main.py,导入 create_softbv_mcp 即可):
|
||||||
|
# from softbv_mcp import create_softbv_mcp
|
||||||
|
# softbv_mcp = create_softbv_mcp()
|
||||||
|
# Mount("/softbv", app=softbv_mcp.streamable_http_app())
|
||||||
|
#
|
||||||
|
# 可通过环境变量覆盖连接信息:
|
||||||
|
# REMOTE_HOST, REMOTE_USER, PRIVATE_KEY_PATH, SOFTBV_PROFILE, SOFTBV_BIN, DEFAULT_WORKDIR
|
||||||
|
|
||||||
|
import os
|
||||||
|
import posixpath
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from socket import socket
|
||||||
|
from typing import Any
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import asyncssh
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP, Context
|
||||||
|
from mcp.server.session import ServerSession
|
||||||
|
|
||||||
|
from lifespan import REMOTE_HOST, REMOTE_USER, PRIVATE_KEY_PATH
|
||||||
|
|
||||||
|
def shell_quote(arg: str) -> str:
|
||||||
|
"""安全地把字符串作为单个 shell 参数(POSIX)。"""
|
||||||
|
return "'" + str(arg).replace("'", "'\"'\"'") + "'"
|
||||||
|
|
||||||
|
# 如果你已经定义了这些常量与路径,可保持复用;也可按需改为你自己的配置源
|
||||||
|
|
||||||
|
|
||||||
|
# 固定的 softBV 环境信息(可用环境变量覆盖)
|
||||||
|
SOFTBV_PROFILE = os.getenv("SOFTBV_PROFILE", "/cluster/home/koko125/script/softBV.sh")
|
||||||
|
SOFTBV_BIN = os.getenv("SOFTBV_BIN", "/cluster/home/koko125/tool/softBV-GUI_linux/bin/softBV.x")
|
||||||
|
DEFAULT_WORKDIR = os.getenv("DEFAULT_WORKDIR", "/cluster/home/koko125/sandbox")
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SoftBVContext:
|
||||||
|
ssh_connection: asyncssh.SSHClientConnection
|
||||||
|
workdir: str
|
||||||
|
profile: str
|
||||||
|
bin_path: str
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def softbv_lifespan(_server) -> AsyncIterator[SoftBVContext]:
|
||||||
|
"""
|
||||||
|
FastMCP 生命周期:建立 SSH 连接并注入 softBV 上下文。
|
||||||
|
- 不再做额外的 DNS 解析或自定义异步步骤,避免 socket.SOCK_STREAM 的环境覆盖问题
|
||||||
|
- 仅负责连接与清理;工具中通过 ctx.request_context.lifespan_context 访问该上下文 [1]
|
||||||
|
"""
|
||||||
|
# 允许用环境变量覆盖连接信息
|
||||||
|
host = os.getenv("REMOTE_HOST", REMOTE_HOST)
|
||||||
|
user = os.getenv("REMOTE_USER", REMOTE_USER)
|
||||||
|
key_path = os.getenv("PRIVATE_KEY_PATH", PRIVATE_KEY_PATH)
|
||||||
|
|
||||||
|
conn: asyncssh.SSHClientConnection | None = None
|
||||||
|
try:
|
||||||
|
conn = await asyncssh.connect(
|
||||||
|
host,
|
||||||
|
username=user,
|
||||||
|
client_keys=[key_path],
|
||||||
|
known_hosts=None, # 如需主机指纹校验,可移除此参数
|
||||||
|
connect_timeout=15, # 避免长时间挂起
|
||||||
|
)
|
||||||
|
yield SoftBVContext(
|
||||||
|
ssh_connection=conn,
|
||||||
|
workdir=DEFAULT_WORKDIR,
|
||||||
|
profile=SOFTBV_PROFILE,
|
||||||
|
bin_path=SOFTBV_BIN,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
await conn.wait_closed()
|
||||||
|
async def run_in_softbv_env(
|
||||||
|
conn: asyncssh.SSHClientConnection,
|
||||||
|
profile_path: str,
|
||||||
|
cmd: str,
|
||||||
|
cwd: str | None = None,
|
||||||
|
check: bool = True,
|
||||||
|
) -> asyncssh.SSHCompletedProcess:
|
||||||
|
"""
|
||||||
|
在远端 bash 会话中激活 softBV 环境并执行 cmd。
|
||||||
|
如提供 cwd,则先 cd 到该目录。
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
if cwd:
|
||||||
|
parts.append(f"cd {shell_quote(cwd)}")
|
||||||
|
parts.append(f"source {shell_quote(profile_path)}")
|
||||||
|
parts.append(cmd)
|
||||||
|
composite = "; ".join(parts)
|
||||||
|
full = f"bash -lc {shell_quote(composite)}"
|
||||||
|
return await conn.run(full, check=check)
|
||||||
|
|
||||||
|
# ===== MCP 服务器 =====
|
||||||
|
mcp = FastMCP(
|
||||||
|
name="softBV Tools",
|
||||||
|
instructions="在远程服务器上激活 softBV 环境并执行相关计算的工具集。",
|
||||||
|
lifespan=softbv_lifespan,
|
||||||
|
streamable_http_path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ===== 辅助:列目录用于识别新生成文件 =====
|
||||||
|
async def _listdir(conn: asyncssh.SSHClientConnection, path: str) -> list[str]:
|
||||||
|
async with conn.start_sftp_client() as sftp:
|
||||||
|
try:
|
||||||
|
return await sftp.listdir(path)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ===== 结构化输入模型:--md =====
|
||||||
|
class SoftBVMDArgs(BaseModel):
|
||||||
|
input_cif: str = Field(description="远程 CIF 文件路径(相对或绝对),作为 --md 的输入")
|
||||||
|
# 位置参数(按帮助中的顺序;None 表示不提供,让程序使用默认)
|
||||||
|
type: str | None = Field(default=None, description="conducting ion 类型(例如 'Li')")
|
||||||
|
os: int | None = Field(default=None, description="conducting ion 氧化态(整数)")
|
||||||
|
sf: float | None = Field(default=None, description="screening factor(非正值使用默认)")
|
||||||
|
temperature: float | None = Field(default=None, description="温度 K(非正值默认 300)")
|
||||||
|
t_end: float | None = Field(default=None, description="生产时间 ps(非正值默认 10.0)")
|
||||||
|
t_equil: float | None = Field(default=None, description="平衡时间 ps(非正值默认 2.0)")
|
||||||
|
dt: float | None = Field(default=None, description="时间步长 ps(非正值默认 0.001)")
|
||||||
|
t_log: float | None = Field(default=None, description="采样间隔 ps(非正值每 100 步)")
|
||||||
|
cwd: str | None = Field(default=None, description="远程工作目录(默认使用生命周期中的 workdir)")
|
||||||
|
|
||||||
|
def _build_md_cmd(bin_path: str, args: SoftBVMDArgs, workdir: str) -> str:
|
||||||
|
input_abs = args.input_cif
|
||||||
|
if not input_abs.startswith("/"):
|
||||||
|
input_abs = posixpath.normpath(posixpath.join(workdir, args.input_cif))
|
||||||
|
parts: list[str] = [shell_quote(bin_path), shell_quote("--md"), shell_quote(input_abs)]
|
||||||
|
for val in [args.type, args.os, args.sf, args.temperature, args.t_end, args.t_equil, args.dt, args.t_log]:
|
||||||
|
if val is not None:
|
||||||
|
parts.append(shell_quote(str(val)))
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
# ===== 结构化输入模型:--gen-cube =====
|
||||||
|
class SoftBVGenCubeArgs(BaseModel):
|
||||||
|
input_cif: str = Field(description="远程 CIF 文件路径(相对或绝对),作为 --gen-cube 的输入")
|
||||||
|
type: str | None = Field(default=None, description="conducting ion 类型(如 'Li')")
|
||||||
|
os: int | None = Field(default=None, description="conducting ion 氧化态(整数)")
|
||||||
|
sf: float | None = Field(default=None, description="screening factor(非正值使用默认)")
|
||||||
|
resolution: float | None = Field(default=None, description="体素分辨率(默认约 0.1)")
|
||||||
|
ignore_conducting_ion: bool = Field(default=False, description="flag:ignore_conducting_ion")
|
||||||
|
periodic: bool = Field(default=True, description="flag:periodic(默认 True)")
|
||||||
|
output_name: str | None = Field(default=None, description="输出文件名前缀(可选)")
|
||||||
|
cwd: str | None = Field(default=None, description="远程工作目录(默认使用生命周期中的 workdir)")
|
||||||
|
|
||||||
|
def _build_gen_cube_cmd(bin_path: str, args: SoftBVGenCubeArgs, workdir: str) -> str:
|
||||||
|
input_abs = args.input_cif
|
||||||
|
if not input_abs.startswith("/"):
|
||||||
|
input_abs = posixpath.normpath(posixpath.join(workdir, args.input_cif))
|
||||||
|
parts: list[str] = [shell_quote(bin_path), shell_quote("--gen-cube"), shell_quote(input_abs)]
|
||||||
|
for val in [args.type, args.os, args.sf, args.resolution]:
|
||||||
|
if val is not None:
|
||||||
|
parts.append(shell_quote(str(val)))
|
||||||
|
if args.ignore_conducting_ion:
|
||||||
|
parts.append(shell_quote("--flag:ignore_conducting_ion"))
|
||||||
|
if args.periodic:
|
||||||
|
parts.append(shell_quote("--flag:periodic"))
|
||||||
|
if args.output_name:
|
||||||
|
parts.append(shell_quote(args.output_name))
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
# ===== 工具:环境信息检查 =====
|
||||||
|
# 工具:环境信息检查(修复版,避免超时)
|
||||||
|
@mcp.tool()
|
||||||
|
async def softbv_info(ctx: Context[ServerSession, SoftBVContext]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
快速自检:
|
||||||
|
- SFTP 检查工作目录、激活脚本、二进制是否存在/可执行(无需运行 softBV.x)
|
||||||
|
- 激活环境后仅输出标记与当前工作目录,避免长输出或阻塞
|
||||||
|
"""
|
||||||
|
app = ctx.request_context.lifespan_context
|
||||||
|
conn = app.ssh_connection
|
||||||
|
|
||||||
|
# 1) 通过 SFTP 快速检查文件与目录状态(不会长时间阻塞)
|
||||||
|
def stat_path_safe(path: str) -> dict[str, Any]:
|
||||||
|
return {"exists": False, "is_exec": False, "size": None}
|
||||||
|
|
||||||
|
workdir_info = stat_path_safe(app.workdir)
|
||||||
|
profile_info = stat_path_safe(app.profile)
|
||||||
|
bin_info = stat_path_safe(app.bin_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with conn.start_sftp_client() as sftp:
|
||||||
|
# workdir
|
||||||
|
try:
|
||||||
|
attrs = await sftp.stat(app.workdir)
|
||||||
|
workdir_info["exists"] = True
|
||||||
|
workdir_info["size"] = int(attrs.size or 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# profile
|
||||||
|
try:
|
||||||
|
attrs = await sftp.stat(app.profile)
|
||||||
|
profile_info["exists"] = True
|
||||||
|
profile_info["size"] = int(attrs.size or 0)
|
||||||
|
perms = int(attrs.permissions or 0)
|
||||||
|
profile_info["is_exec"] = bool(perms & 0o111)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# bin
|
||||||
|
try:
|
||||||
|
attrs = await sftp.stat(app.bin_path)
|
||||||
|
bin_info["exists"] = True
|
||||||
|
bin_info["size"] = int(attrs.size or 0)
|
||||||
|
perms = int(attrs.permissions or 0)
|
||||||
|
bin_info["is_exec"] = bool(perms & 0o111)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
await ctx.warning(f"SFTP 检查失败: {e}")
|
||||||
|
|
||||||
|
# 2) 激活环境并做极简命令(避免 softBV.x --help 的长输出)
|
||||||
|
# 仅返回当前用户、PWD 与二进制可执行判断;不实际运行 softBV.x
|
||||||
|
cmd = "echo __SOFTBV_READY__ && echo $USER && pwd && (test -x " + shell_quote(app.bin_path) + " && echo __BIN_OK__ || echo __BIN_NOT_EXEC__)"
|
||||||
|
proc = await run_in_softbv_env(conn, app.profile, cmd=cmd, cwd=app.workdir, check=False)
|
||||||
|
|
||||||
|
# 解析输出行
|
||||||
|
lines = proc.stdout.splitlines() if proc.stdout else []
|
||||||
|
ready = "__SOFTBV_READY__" in lines
|
||||||
|
user = None
|
||||||
|
pwd = None
|
||||||
|
bin_ok = "__BIN_OK__" in lines
|
||||||
|
|
||||||
|
# 尝试定位 user/pwd(ready 之后的两行)
|
||||||
|
if ready:
|
||||||
|
idx = lines.index("__SOFTBV_READY__")
|
||||||
|
if len(lines) > idx + 1:
|
||||||
|
user = lines[idx + 1].strip()
|
||||||
|
if len(lines) > idx + 2:
|
||||||
|
pwd = lines[idx + 2].strip()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"host": os.getenv("REMOTE_HOST", REMOTE_HOST),
|
||||||
|
"user": os.getenv("REMOTE_USER", REMOTE_USER),
|
||||||
|
"workdir": app.workdir,
|
||||||
|
"profile": app.profile,
|
||||||
|
"bin_path": app.bin_path,
|
||||||
|
"sftp_check": {
|
||||||
|
"workdir": workdir_info,
|
||||||
|
"profile": profile_info,
|
||||||
|
"bin": bin_info,
|
||||||
|
},
|
||||||
|
"activate_ready": ready,
|
||||||
|
"pwd": pwd,
|
||||||
|
"bin_is_executable": bin_ok or bin_info["is_exec"],
|
||||||
|
"exit_status": proc.exit_status,
|
||||||
|
"stderr_head": "\n".join(proc.stderr.splitlines()[:10]) if proc.stderr else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 友好日志
|
||||||
|
if not ready:
|
||||||
|
await ctx.warning("softBV 环境未就绪(可能 source 脚本路径问题或权限不足)")
|
||||||
|
if not result["bin_is_executable"]:
|
||||||
|
await ctx.warning("softBV 二进制不可执行或不存在,请检查 bin_path 与权限(chmod +x)")
|
||||||
|
if proc.exit_status != 0 and not proc.stderr:
|
||||||
|
await ctx.debug("命令非零退出但无 stderr,可能是某些子测试返回非零导致")
|
||||||
|
|
||||||
|
return result
|
||||||
|
# ===== 工具:--md =====
|
||||||
|
@mcp.tool()
|
||||||
|
async def softbv_md(req: SoftBVMDArgs, ctx: Context[ServerSession, SoftBVContext]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
执行 softBV.x --md,返回结构化结果:
|
||||||
|
- cmd/cwd/exit_status/stdout/stderr
|
||||||
|
- new_files:执行后新增文件列表,便于定位输出
|
||||||
|
"""
|
||||||
|
app = ctx.request_context.lifespan_context
|
||||||
|
conn = app.ssh_connection
|
||||||
|
workdir = req.cwd or app.workdir
|
||||||
|
cmd = _build_md_cmd(app.bin_path, req, workdir)
|
||||||
|
|
||||||
|
await ctx.info(f"softBV --md 执行: {cmd} (cwd={workdir})")
|
||||||
|
pre_list = await _listdir(conn, workdir)
|
||||||
|
|
||||||
|
proc = await run_in_softbv_env(conn, app.profile, cmd=cmd, cwd=workdir, check=False)
|
||||||
|
|
||||||
|
post_list = await _listdir(conn, workdir)
|
||||||
|
new_files = sorted(set(post_list) - set(pre_list))
|
||||||
|
if proc.exit_status == 0:
|
||||||
|
await ctx.debug(f"--md 成功,新文件 {len(new_files)} 个")
|
||||||
|
else:
|
||||||
|
await ctx.warning(f"--md 非零退出: {proc.exit_status}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cmd": cmd,
|
||||||
|
"cwd": workdir,
|
||||||
|
"exit_status": proc.exit_status,
|
||||||
|
"stdout": proc.stdout,
|
||||||
|
"stderr": proc.stderr,
|
||||||
|
"new_files": new_files,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def run_in_softbv_env_stream(
|
||||||
|
conn: asyncssh.SSHClientConnection,
|
||||||
|
profile_path: str,
|
||||||
|
cmd: str,
|
||||||
|
cwd: str | None = None,
|
||||||
|
) -> asyncssh.SSHClientProcess:
|
||||||
|
parts = []
|
||||||
|
if cwd:
|
||||||
|
parts.append(f"cd {shell_quote(cwd)}")
|
||||||
|
parts.append(f"source {shell_quote(profile_path)} >/dev/null 2>&1 || true")
|
||||||
|
parts.append(cmd)
|
||||||
|
composite = "; ".join(parts)
|
||||||
|
full = f"bash -lc {shell_quote(composite)}"
|
||||||
|
# 不阻塞,返回进程句柄
|
||||||
|
proc = await conn.create_process(full)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
# 轮询目录,识别新生成文件
|
||||||
|
async def _listdir(conn: asyncssh.SSHClientConnection, path: str) -> list[str]:
|
||||||
|
async with conn.start_sftp_client() as sftp:
|
||||||
|
try:
|
||||||
|
return await sftp.listdir(path)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 轮询日志文件大小(作为心跳/粗略进度依据)
|
||||||
|
async def _stat_size(conn: asyncssh.SSHClientConnection, path: str) -> int | None:
|
||||||
|
async with conn.start_sftp_client() as sftp:
|
||||||
|
try:
|
||||||
|
attrs = await sftp.stat(path)
|
||||||
|
return int(attrs.size or 0)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 构造 gen-cube 命令(保持你之前的参数拼接逻辑)
|
||||||
|
def _build_gen_cube_cmd(bin_path: str, args: SoftBVGenCubeArgs, workdir: str, log_path: str | None = None) -> str:
|
||||||
|
input_abs = args.input_cif
|
||||||
|
if not input_abs.startswith("/"):
|
||||||
|
input_abs = posixpath.normpath(posixpath.join(workdir, args.input_cif))
|
||||||
|
parts: list[str] = [shell_quote(bin_path), shell_quote("--gen-cube"), shell_quote(input_abs)]
|
||||||
|
for val in [args.type, args.os, args.sf, args.resolution]:
|
||||||
|
if val is not None:
|
||||||
|
parts.append(shell_quote(str(val)))
|
||||||
|
if args.ignore_conducting_ion:
|
||||||
|
parts.append(shell_quote("--flag:ignore_conducting_ion"))
|
||||||
|
if args.periodic:
|
||||||
|
parts.append(shell_quote("--flag:periodic"))
|
||||||
|
if args.output_name:
|
||||||
|
parts.append(shell_quote(args.output_name))
|
||||||
|
cmd = " ".join(parts)
|
||||||
|
# 将输出重定向到日志,便于轮询
|
||||||
|
if log_path:
|
||||||
|
cmd = f"{cmd} > {shell_quote(log_path)} 2>&1"
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
# 修复版:长时运行的 softbv_gen_cube,带心跳与超时保护
|
||||||
|
@mcp.tool()
|
||||||
|
async def softbv_gen_cube(req: SoftBVGenCubeArgs, ctx: Context[ServerSession, SoftBVContext]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
执行 softBV.x --gen-cube,支持长任务心跳,避免在 <25min 内被客户端强制超时。
|
||||||
|
- 每隔 10s 上报一次进度(心跳),包含已用时/日志大小/新增文件数
|
||||||
|
- 结束后返回 stdout_head(来自日志文件片段)、stderr_head(如有)、exit_status、新增文件
|
||||||
|
"""
|
||||||
|
app = ctx.request_context.lifespan_context
|
||||||
|
conn = app.ssh_connection
|
||||||
|
workdir = req.cwd or app.workdir
|
||||||
|
|
||||||
|
# 预先记录目录内容,用于结束后差集
|
||||||
|
before = await _listdir(conn, workdir)
|
||||||
|
|
||||||
|
# 远端日志文件路径(按时间戳命名)
|
||||||
|
import time
|
||||||
|
log_name = f"softbv_gencube_{int(time.time())}.log"
|
||||||
|
log_path = posixpath.join(workdir, log_name)
|
||||||
|
|
||||||
|
# 启动长任务,不阻塞当前协程
|
||||||
|
cmd = _build_gen_cube_cmd(app.bin_path, req, workdir, log_path=log_path)
|
||||||
|
await ctx.info(f"启动 --gen-cube: {cmd}")
|
||||||
|
proc = await run_in_softbv_env_stream(conn, app.profile, cmd=cmd, cwd=workdir)
|
||||||
|
|
||||||
|
# 心跳循环:直到进程退出
|
||||||
|
start_ts = time.time()
|
||||||
|
heartbeat_sec = 10 # 每 10 秒发送一次心跳
|
||||||
|
max_guard_min = 60 # 保险上限(服务端不主动终止;如客户端有限制可调大)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# 进程是否已退出
|
||||||
|
if proc.exit_status is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 采集状态:已用时、日志大小、新增文件数
|
||||||
|
elapsed = time.time() - start_ts
|
||||||
|
log_size = await _stat_size(conn, log_path)
|
||||||
|
now_files = await _listdir(conn, workdir)
|
||||||
|
new_files_count = len(set(now_files) - set(before))
|
||||||
|
|
||||||
|
# 这里无法获知真实百分比,使用“心跳/已用时提示”
|
||||||
|
await ctx.report_progress(
|
||||||
|
progress=min(elapsed / (25 * 60), 0.99), # 以 25min 为目标上限做近似刻度
|
||||||
|
total=1.0,
|
||||||
|
message=f"gen-cube 运行中: 已用时 {int(elapsed)}s, 日志 {log_size or 0}B, 新文件 {new_files_count}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 避免客户端超时:持续心跳即可。[1]
|
||||||
|
await asyncio.sleep(heartbeat_sec)
|
||||||
|
|
||||||
|
# 简易守护:超过 max_guard_min 仍未结束也不强制中断(由远端决定)
|
||||||
|
if elapsed > max_guard_min * 60:
|
||||||
|
await ctx.warning("任务已超过守护上限时间,仍在运行(未强制中断)。如需更长时间,请增大上限。")
|
||||||
|
# 不 break,继续等待,交由远端任务完成
|
||||||
|
finally:
|
||||||
|
# 等待进程真正结束(如果已结束,这里是快速返回)
|
||||||
|
await proc.wait()
|
||||||
|
|
||||||
|
# 结束后采集结果
|
||||||
|
exit_status = proc.exit_status
|
||||||
|
after = await _listdir(conn, workdir)
|
||||||
|
new_files = sorted(set(after) - set(before))
|
||||||
|
|
||||||
|
# 读取日志片段(头/尾),帮助定位输出
|
||||||
|
async with conn.start_sftp_client() as sftp:
|
||||||
|
head = ""
|
||||||
|
tail = ""
|
||||||
|
try:
|
||||||
|
async with sftp.open(log_path, "rb") as f:
|
||||||
|
content = await f.read()
|
||||||
|
text = content.decode("utf-8", errors="replace")
|
||||||
|
lines = text.splitlines()
|
||||||
|
head = "\n".join(lines[:40])
|
||||||
|
tail = "\n".join(lines[-40:])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 输出结构化结果
|
||||||
|
result = {
|
||||||
|
"cmd": cmd,
|
||||||
|
"cwd": workdir,
|
||||||
|
"exit_status": exit_status,
|
||||||
|
"log_file": log_path,
|
||||||
|
"stdout_head": head, # 代替一次性 stdout,避免大输出
|
||||||
|
"stderr_head": "", # 统一日志到文件,stderr_head 可留空
|
||||||
|
"new_files": new_files,
|
||||||
|
"elapsed_sec": int(time.time() - start_ts),
|
||||||
|
}
|
||||||
|
|
||||||
|
if exit_status == 0:
|
||||||
|
await ctx.info(f"gen-cube 完成,用时 {result['elapsed_sec']}s,新文件 {len(new_files)} 个")
|
||||||
|
else:
|
||||||
|
await ctx.warning(f"gen-cube 退出码 {exit_status},请查看日志 {log_path}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
def create_softbv_mcp() -> FastMCP:
|
||||||
|
"""供外部(Starlette)导入的工厂函数。"""
|
||||||
|
return mcp
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run(transport="streamable-http")
|
||||||
Reference in New Issue
Block a user