基底:#251 头 174b631(堆叠于 #242 feat-ci-expand 之上), 基线 origin/main aebc6238;本轨不改 #251 已定语义、不动 workflow 门槛、不删检查、不加 skip。 lint(ruff E,F@120):109 -> 10 - F401 43 -> 0:删未用导入;try/except 双绑定的名字出现 “打地鼠”(删 except 分支后 try 分支又被标出),两分支 均已核对正文无引用后清掉;MCP 三模块的 Error 名不删, 改走 importlib 属性绑定(见下),两路径绑定都保留。 - E702 40 -> 0:全在 host.py main() 的 argparse 段,纯分行, tokenize 按分号 token 拆(含 2377 行多行语句只断首个分号); 一次 5 空格缩进失误已回滚重做并以 ast + 全量 host 测试验证。 - E501 13 -> 0:host.py 5 项随 E702 分行连带清掉;mcp_server 3 项把描述拆成相邻字面量(AST 比对 24 条工具描述与原值 完全一致,注意 ruff 按东亚宽度计长,CJK 算 2);test_host 5 项断开 git commit 参数表。 - E402 8 -> 10:生产侧 2 项清掉(db.py _re、github_app _base64 移到顶部,之前无引用、无重名);剩余 10 项全是 tests/ 的 sys.path 前置 import(6 项旧 + 4 个新测试文件 沿用同一既定模式),移上去会破坏单文件直跑,有意保留。 - F841 4 -> 0:只去绑定、不删调用(run_task、DB 入队、 tarfile 上下文、git 提交),各有注释与原测试/新测试覆盖; host dispatch_from_branch 新增真实 git 仓库回归测试。 - F811 1 -> 0:server 本地 _row_get 与 peer_messages.row_get 逐行相同,删本地定义,全部调用点解析到同一 import; 新增 test_server_row_get(同一对象 + 三路行为)。 mypy(生产范围):24 -> 0,零新增 type: ignore - no-redef 9 -> 0:MCP 三模块 try/except 两次 from-import 改 importlib.import_module + 属性绑定(cloud 优先、flat copy 回落、内层 ImportError 同样回落,语义与原来一致; flat-copy 子进程测试证实回落后 __module__ 为顶层名); server _row_get 随 F811 消除。 - arg-type 10 -> 0:harness2_tasks shared 改 TypedDict 逐键精确类型;CodexProbeAdapter 补 no-op validate_tree (_adapter_validate 本就跳过缺失方法,行为一致)。 - var-annotated 3 -> 0:sub2api _SHARED_ADMIN/_login_slots、 verify-multiuser RESULTS 加精确注解。 - assignment/misc 2 -> 0:marker_tools ZoneInfo 先声明 Any 再 try import(草稿先验 mypy 通过)。 bandit:总数 286 不变,High 仍 4 项,全在 host.py,本轨不改 (属第 3 项白名单轨范围),只做 triage: - 1319 run_verify:无插值,整串验测命令,管道/&& 须 shell 语义(新增 test_host_run_verify 断言管道与 && 真实生效)。 - 1497 notify:模板来自算子配置,三处插值全部 shlex.quote, 证据为既有 test_notify_failure_quotes_shell_metacharacters (; touch 与 $(touch ) 不执行)。 - 2099/2103 goal verify:无插值,任务配置验测串,同 run_verify。 - 无新增 High;pip-audit 0 漏洞。 覆盖率(同口径三套件 coverage 计量):75.96% -> 77.87% (22392 行/缺 5385 -> 22437 行/缺 4963,显示 76% -> 78%) - client.py 0% -> 90%(pipe 假子进程 + main 分支) - run_parallel.py 0% -> 97%、migrate 包装 0% -> 95% - playwright_mcp 37% -> 62%、imagegen_mcp 47% -> 79%、 websearch_mcp 51% -> 86%、deploy.py 56% -> 77% - verify-multiuser 0% -> 27%(仅 check/req helper;main() 是主机侧真实多用户实弹验证,按门禁不建生产任务、 不以 mock 冒充,仍为缺口) - host.py 55% -> 57%(文件 2020 -> 2060 行系 E702 分行 的计量口径变化,非代码膨胀) - 未到 85%:门槛保持 75% 不动,不删低覆盖模块出统计口径; 缺口主力仍是 server.py / worker.py / host.py 大文件, 下一阶段须针对性深测,非本轨快赢可及。 验证:三套件 1576 -> 1629 项全过(tests 127、cloud/tests 1449 skipped=4、deploy/tests 53);ruff E9 通过;mypy 0; bandit High 4(同上);pip-audit 0 漏洞;试跑产物已删, 提交后工作树干净。
383 lines
18 KiB
Python
383 lines
18 KiB
Python
"""云端任务管理的 MCP 入口:把 REST API 翻成 MCP tools。
|
||
|
||
方向说明(别跟任务内 MCP 搞混):项目内 agent 用的 MCP 是
|
||
`[mcp_servers.*]`(给 agent 接外部工具);本文件是反方向——
|
||
让外部 MCP 客户端(编辑器、agent)用 tools/list / tools/call
|
||
管云端任务,底层一律走已有的 REST API,不另开一套任务逻辑。
|
||
|
||
协议:stdio 上每行一个 JSON-RPC 2.0 请求,支持 initialize、
|
||
ping、tools/list、tools/call;notifications/* 不回包(按 MCP
|
||
惯例)。tools/call 的结果一律是 content=[{type: text,
|
||
text: <REST 响应 JSON>}],REST 报错时 isError=true,文本里
|
||
含 HTTP 状态与服务端 error,不伪造成功。
|
||
|
||
鉴权:只认调用方自己的 X-API-Key,从环境变量
|
||
CODEX_CLOUD_API_KEY 读(缺了就启动失败,绝不读文件、
|
||
绝不打印 key);BASE 从 CODEX_CLOUD_BASE_URL 读,缺省
|
||
https://api.diante.me。MCP 服务本身不存任何凭证、不建
|
||
用户、不绕过服务端权限——服务端按 key 归属做 viewer/
|
||
contributor 等权限校验,403 原样透传。
|
||
|
||
工具(不包管理端):
|
||
health、list_projects、list_tasks、get_task、create_task、
|
||
cancel_task、get_task_cost、get_task_trace、get_task_files、
|
||
get_task_tree、
|
||
get_task_result_full、retry_task、merge_task。
|
||
create_task 只传 project_id + prompt + 可选 executor/model/
|
||
effort/approval/priority,其余字段走服务端缺省;cancel_task
|
||
只做取消,不做真 kill 之外的任何事(真 kill 由 worker 的
|
||
kill_requests 机制处理,不在这里另起一套);merge_task 与
|
||
retry_task 是写操作,调用方 key 必须有相应项目权限,服务端
|
||
按状态机拒绝非法转换(例如未完成任务 merge 会 409),MCP
|
||
层不绕过;get_task_files 的 path 参数是 worktree 内相对
|
||
路径,服务端 _safe_worktree_path 防逃逸,MCP 层不另做路径
|
||
展开,绝不传绝对路径。
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
|
||
__all__ = ["MCPServer", "main"]
|
||
|
||
|
||
def _tool(name, description, properties, required):
|
||
return {
|
||
"name": name,
|
||
"description": description,
|
||
"inputSchema": {
|
||
"type": "object",
|
||
"properties": properties,
|
||
"required": required,
|
||
},
|
||
}
|
||
|
||
|
||
TOOLS = [
|
||
_tool("health", "云端控制面健康检查(不含任务数据)。",
|
||
{}, []),
|
||
_tool("list_projects", "列出当前 key 可见的项目。",
|
||
{}, []),
|
||
_tool("list_tasks", "列出某项目的任务(需 project_id)。",
|
||
{"project_id": {"type": "string"}}, ["project_id"]),
|
||
_tool("get_task", "取单个任务详情(状态、结果、成本字段等)。",
|
||
{"task_id": {"type": "string"}}, ["task_id"]),
|
||
_tool("create_task", "在某项目建一个任务(需 project_id 与 prompt)。",
|
||
{"project_id": {"type": "string"},
|
||
"prompt": {"type": "string"},
|
||
"executor": {"type": "string"},
|
||
"model": {"type": "string"},
|
||
"effort": {"type": "string"},
|
||
"approval": {"type": "string"},
|
||
"priority": {"type": "integer"}},
|
||
["project_id", "prompt"]),
|
||
_tool("cancel_task", "取消一个任务(标 interrupted,容器真杀由 worker 处理)。",
|
||
{"task_id": {"type": "string"}}, ["task_id"]),
|
||
_tool("get_task_cost", "取单个任务的成本明细。",
|
||
{"task_id": {"type": "string"}}, ["task_id"]),
|
||
_tool("get_task_trace", "取单个任务的链路条目(摘要,完整结果在任务详情)。",
|
||
{"task_id": {"type": "string"}}, ["task_id"]),
|
||
_tool("get_task_files", "列/读任务 worktree 里的文件;不传 path 列顶层,传相对路径读文件或列目录。",
|
||
{"task_id": {"type": "string"},
|
||
"path": {"type": "string"}}, ["task_id"]),
|
||
_tool("get_task_tree", "取任务 worktree 的结构化目录树(只含名、类型、大小,"
|
||
"不读文件内容);不传 path 列 worktree 根,"
|
||
"传相对路径列该子目录;max_depth 1..5 缺省 3,max_entries 1..500 缺省 200,截断时 truncated=True。",
|
||
{"task_id": {"type": "string"},
|
||
"path": {"type": "string"},
|
||
"max_depth": {"type": "integer"},
|
||
"max_entries": {"type": "integer"}}, ["task_id"]),
|
||
_tool("get_task_result_full", "取单个任务的完整结果文本(result_text 只是摘要)。",
|
||
{"task_id": {"type": "string"}}, ["task_id"]),
|
||
_tool("retry_task", "重试一个失败/中断的任务(服务端状态机校验)。",
|
||
{"task_id": {"type": "string"}}, ["task_id"]),
|
||
_tool("merge_task", "合并一个已完成任务的改动(服务端状态机校验,未完成会拒绝)。",
|
||
{"task_id": {"type": "string"}}, ["task_id"]),
|
||
_tool("pause_task", "暂停一个进行中任务(服务端状态机校验)。",
|
||
{"task_id": {"type": "string"}}, ["task_id"]),
|
||
_tool("goal_resume_task", "断点续跑一个 goal 任务(服务端状态机校验)。",
|
||
{"task_id": {"type": "string"}}, ["task_id"]),
|
||
_tool("create_followup", "给进行中任务发后续消息;"
|
||
"mode=queued 排队进队尾不打断本轮,direct_send 立即送达且本轮结束后优先开下一轮。",
|
||
{"task_id": {"type": "string"},
|
||
"body": {"type": "string"},
|
||
"mode": {"type": "string"}}, ["task_id", "body"]),
|
||
_tool("get_task_agents", "取单个任务的 agent 分工条目。",
|
||
{"task_id": {"type": "string"}}, ["task_id"]),
|
||
_tool("list_workers", "列出 worker 聚合状态(普通用户只见在线/离线数,管理员见完整清单)。",
|
||
{}, []),
|
||
_tool("get_metrics", "取控制面可观测性聚合:任务计数、成功率、平均耗时、成本合计(只统计调用者是成员的项目)。",
|
||
{}, []),
|
||
_tool("list_project_members", "列出某项目的成员。",
|
||
{"project_id": {"type": "string"}}, ["project_id"]),
|
||
_tool("get_models", "取模型一览(价目与默认模型,服务端口径)。",
|
||
{}, []),
|
||
_tool("list_project_memories", "列出某项目的项目级记忆(此前任务沉淀的经验/约定,"
|
||
"新任务会自动注入;top_k 可选,默认 20)。",
|
||
{"project_id": {"type": "string"},
|
||
"top_k": {"type": "integer"}}, ["project_id"]),
|
||
_tool("create_project_memory", "给某项目记一条项目级记忆(kind 与 content 必填)。",
|
||
{"project_id": {"type": "string"},
|
||
"kind": {"type": "string"},
|
||
"content": {"type": "string"}}, ["project_id", "kind", "content"]),
|
||
_tool("supersede_project_memory", "废弃某项目的一条记忆,可同时给替代内容(不传则只废弃)。",
|
||
{"project_id": {"type": "string"},
|
||
"memory_id": {"type": "string"},
|
||
"replacement_content": {"type": "string"}}, ["project_id", "memory_id"]),
|
||
]
|
||
|
||
|
||
class MCPServer:
|
||
"""无状态的 REST→MCP 翻译层;transport 可注入,单测用假 transport。"""
|
||
|
||
def __init__(self, base_url, api_key, transport=None):
|
||
if not api_key:
|
||
raise ValueError("CODEX_CLOUD_API_KEY 未设置")
|
||
self.base_url = base_url.rstrip("/")
|
||
self.api_key = api_key
|
||
self.transport = transport or self._http
|
||
|
||
def _http(self, method, path, body=None):
|
||
url = self.base_url + path
|
||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||
req = urllib.request.Request(url, data=data, method=method)
|
||
req.add_header("X-API-Key", self.api_key)
|
||
if data is not None:
|
||
req.add_header("Content-Type", "application/json")
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||
raw = resp.read().decode("utf-8") or "{}"
|
||
return resp.status, json.loads(raw)
|
||
except urllib.error.HTTPError as exc:
|
||
raw = exc.read().decode("utf-8") or "{}"
|
||
try:
|
||
payload = json.loads(raw)
|
||
except ValueError:
|
||
payload = {"raw": raw[:500]}
|
||
return exc.code, payload
|
||
except Exception as exc: # noqa: BLE001 - 网络层一律转错误结果
|
||
return 0, {"error": str(exc)[:500]}
|
||
|
||
def call_tool(self, name, args):
|
||
"""返回 (is_error, payload_dict)。未知工具、缺参一律 is_error。"""
|
||
args = args or {}
|
||
if name == "health":
|
||
return self._wrap(*self.transport("GET", "/health"))
|
||
if name == "list_projects":
|
||
return self._wrap(*self.transport("GET", "/projects"))
|
||
if name == "list_tasks":
|
||
if not args.get("project_id"):
|
||
return True, {"error": "project_id required"}
|
||
q = urllib.parse.urlencode({"project_id": args["project_id"]})
|
||
return self._wrap(*self.transport("GET", "/tasks?" + q))
|
||
if name == "get_task":
|
||
if not args.get("task_id"):
|
||
return True, {"error": "task_id required"}
|
||
return self._wrap(*self.transport(
|
||
"GET", "/tasks/" + args["task_id"]))
|
||
if name == "create_task":
|
||
if not args.get("project_id") or not args.get("prompt"):
|
||
return True, {"error": "project_id and prompt required"}
|
||
body = {"project_id": args["project_id"],
|
||
"prompt": args["prompt"]}
|
||
for key in ("executor", "model", "effort", "approval",
|
||
"priority"):
|
||
if args.get(key) is not None:
|
||
body[key] = args[key]
|
||
return self._wrap(*self.transport("POST", "/tasks", body))
|
||
if name == "cancel_task":
|
||
if not args.get("task_id"):
|
||
return True, {"error": "task_id required"}
|
||
return self._wrap(*self.transport(
|
||
"POST", "/tasks/" + args["task_id"] + "/cancel", {}))
|
||
if name == "get_task_cost":
|
||
if not args.get("task_id"):
|
||
return True, {"error": "task_id required"}
|
||
return self._wrap(*self.transport(
|
||
"GET", "/tasks/" + args["task_id"] + "/cost"))
|
||
if name == "get_task_trace":
|
||
if not args.get("task_id"):
|
||
return True, {"error": "task_id required"}
|
||
return self._wrap(*self.transport(
|
||
"GET", "/tasks/" + args["task_id"] + "/trace"))
|
||
if name == "get_task_files":
|
||
if not args.get("task_id"):
|
||
return True, {"error": "task_id required"}
|
||
path = "/tasks/" + args["task_id"] + "/files"
|
||
if args.get("path"):
|
||
path += "?" + urllib.parse.urlencode(
|
||
{"path": args["path"]})
|
||
return self._wrap(*self.transport("GET", path))
|
||
if name == "get_task_tree":
|
||
if not args.get("task_id"):
|
||
return True, {"error": "task_id required"}
|
||
query = {}
|
||
if args.get("path"):
|
||
query["path"] = args["path"]
|
||
if args.get("max_depth") is not None:
|
||
query["max_depth"] = args["max_depth"]
|
||
if args.get("max_entries") is not None:
|
||
query["max_entries"] = args["max_entries"]
|
||
path = "/tasks/" + args["task_id"] + "/tree"
|
||
if query:
|
||
path += "?" + urllib.parse.urlencode(query)
|
||
return self._wrap(*self.transport("GET", path))
|
||
if name == "get_task_result_full":
|
||
if not args.get("task_id"):
|
||
return True, {"error": "task_id required"}
|
||
return self._wrap(*self.transport(
|
||
"GET", "/tasks/" + args["task_id"] + "/result-full"))
|
||
if name == "retry_task":
|
||
if not args.get("task_id"):
|
||
return True, {"error": "task_id required"}
|
||
return self._wrap(*self.transport(
|
||
"POST", "/tasks/" + args["task_id"] + "/retry", {}))
|
||
if name == "merge_task":
|
||
if not args.get("task_id"):
|
||
return True, {"error": "task_id required"}
|
||
return self._wrap(*self.transport(
|
||
"POST", "/tasks/" + args["task_id"] + "/merge", {}))
|
||
if name == "pause_task":
|
||
if not args.get("task_id"):
|
||
return True, {"error": "task_id required"}
|
||
return self._wrap(*self.transport(
|
||
"POST", "/tasks/" + args["task_id"] + "/pause", {}))
|
||
if name == "goal_resume_task":
|
||
if not args.get("task_id"):
|
||
return True, {"error": "task_id required"}
|
||
return self._wrap(*self.transport(
|
||
"POST", "/tasks/" + args["task_id"] + "/goal-resume", {}))
|
||
if name == "create_followup":
|
||
if not args.get("task_id") or not args.get("body"):
|
||
return True, {"error": "task_id and body required"}
|
||
body = {"body": args["body"]}
|
||
if args.get("mode") is not None:
|
||
body["mode"] = args["mode"]
|
||
return self._wrap(*self.transport(
|
||
"POST", "/tasks/" + args["task_id"] + "/followups", body))
|
||
if name == "get_task_agents":
|
||
if not args.get("task_id"):
|
||
return True, {"error": "task_id required"}
|
||
return self._wrap(*self.transport(
|
||
"GET", "/tasks/" + args["task_id"] + "/agents"))
|
||
if name == "list_workers":
|
||
return self._wrap(*self.transport("GET", "/workers"))
|
||
if name == "get_metrics":
|
||
return self._wrap(*self.transport("GET", "/metrics"))
|
||
if name == "list_project_members":
|
||
if not args.get("project_id"):
|
||
return True, {"error": "project_id required"}
|
||
return self._wrap(*self.transport(
|
||
"GET", "/projects/" + args["project_id"] + "/members"))
|
||
if name == "get_models":
|
||
return self._wrap(*self.transport("GET", "/models"))
|
||
if name == "list_project_memories":
|
||
if not args.get("project_id"):
|
||
return True, {"error": "project_id required"}
|
||
path = "/projects/" + args["project_id"] + "/identity-memories"
|
||
if args.get("top_k") is not None:
|
||
path += "?" + urllib.parse.urlencode(
|
||
{"top_k": args["top_k"]})
|
||
return self._wrap(*self.transport("GET", path))
|
||
if name == "create_project_memory":
|
||
if not args.get("project_id") or not args.get("kind") \
|
||
or not args.get("content"):
|
||
return True, {"error": "project_id, kind and content required"}
|
||
return self._wrap(*self.transport(
|
||
"POST", "/projects/" + args["project_id"]
|
||
+ "/identity-memories",
|
||
{"kind": args["kind"], "content": args["content"]}))
|
||
if name == "supersede_project_memory":
|
||
if not args.get("project_id") or not args.get("memory_id"):
|
||
return True, {"error": "project_id and memory_id required"}
|
||
body = {}
|
||
if args.get("replacement_content") is not None:
|
||
body["replacement_content"] = args["replacement_content"]
|
||
return self._wrap(*self.transport(
|
||
"POST", "/projects/" + args["project_id"]
|
||
+ "/identity-memories/" + args["memory_id"] + "/supersede",
|
||
body))
|
||
return True, {"error": "unknown tool: %s" % name}
|
||
|
||
@staticmethod
|
||
def _wrap(status, payload):
|
||
if status == 0 or status >= 400:
|
||
return True, {"http_status": status, "error": payload}
|
||
return False, payload
|
||
|
||
def handle(self, request):
|
||
"""处理一个 JSON-RPC 请求,返回响应 dict 或 None(notification)。"""
|
||
if not isinstance(request, dict):
|
||
return {"jsonrpc": "2.0", "id": None,
|
||
"error": {"code": -32600, "message": "invalid request"}}
|
||
method = request.get("method", "")
|
||
req_id = request.get("id")
|
||
if method.startswith("notifications/"):
|
||
return None
|
||
if method == "initialize":
|
||
return {"jsonrpc": "2.0", "id": req_id, "result": {
|
||
"protocolVersion": request.get("params", {}).get(
|
||
"protocolVersion", "2024-11-05"),
|
||
"capabilities": {"tools": {}},
|
||
"serverInfo": {"name": "codex-cloud-mcp",
|
||
"version": "0.1.0"}}}
|
||
if method == "ping":
|
||
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
|
||
if method == "tools/list":
|
||
return {"jsonrpc": "2.0", "id": req_id,
|
||
"result": {"tools": TOOLS}}
|
||
if method == "tools/call":
|
||
params = request.get("params", {}) or {}
|
||
is_error, payload = self.call_tool(
|
||
params.get("name"), params.get("arguments") or {})
|
||
return {"jsonrpc": "2.0", "id": req_id, "result": {
|
||
"content": [{"type": "text",
|
||
"text": json.dumps(payload,
|
||
ensure_ascii=False)}],
|
||
"isError": is_error}}
|
||
return {"jsonrpc": "2.0", "id": req_id,
|
||
"error": {"code": -32601,
|
||
"message": "method not found: %s" % method}}
|
||
|
||
def serve(self, instream, outstream):
|
||
"""stdio 主循环:每行一个请求,EOF 结束;坏行回 -32700 不炸循环。"""
|
||
for line in instream:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
request = json.loads(line)
|
||
except ValueError:
|
||
outstream.write(json.dumps(
|
||
{"jsonrpc": "2.0", "id": None,
|
||
"error": {"code": -32700,
|
||
"message": "parse error"}}) + "\n")
|
||
outstream.flush()
|
||
continue
|
||
response = self.handle(request)
|
||
if response is not None:
|
||
outstream.write(json.dumps(response,
|
||
ensure_ascii=False) + "\n")
|
||
outstream.flush()
|
||
|
||
|
||
def main(argv=None):
|
||
argv = list(sys.argv[1:] if argv is None else argv)
|
||
base_url = os.environ.get("CODEX_CLOUD_BASE_URL",
|
||
"https://api.diante.me")
|
||
api_key = os.environ.get("CODEX_CLOUD_API_KEY", "")
|
||
try:
|
||
server = MCPServer(base_url, api_key)
|
||
except ValueError as exc:
|
||
sys.stderr.write("mcp_server: %s\n" % exc)
|
||
return 2
|
||
server.serve(sys.stdin, sys.stdout)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|