feat(cloud): MCP 云端任务入口 + 上游 0.164 对齐配置
- cloud/mcp_server.py:stdio MCP 服务,把云端任务 REST 翻成 tools/list / tools/call(health、list_projects、list_tasks、 get_task、create_task、cancel_task、get_task_cost、 get_task_trace),底层只走已有 REST,不另开任务逻辑; 鉴权只认调用方自己的 CODEX_CLOUD_API_KEY(缺了启动失败, 绝不打印),服务端权限 403 原样透传;create_task 只传已知 字段,未知字段丢弃;REST 报错时 isError=true。 单测 test_mcp_server 9 项(假 transport,不联网、不碰真 key)。 - 上游对齐 #80 的可合部分:config.example.toml 加 [features] daemon_auto_start = false(防 managed daemon 绕过 版本 pin)+ docs/upstream-164-alignment.md 对齐表;换稳定版 二进制是另一次主机侧部署动作,本批不做、不谎称已升级。
This commit is contained in:
1 parent
b306b56517
commit
972203c38f
4 files changed
+454
No files matched your search
@@ -0,0 +1,231 @@
|
||||
"""云端任务管理的 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。
|
||||
create_task 只传 project_id + prompt + 可选 executor/model/
|
||||
effort/approval/priority,其余字段走服务端缺省;cancel_task
|
||||
只做取消,不做真 kill 之外的任何事(真 kill 由 worker 的
|
||||
kill_requests 机制处理,不在这里另起一套)。
|
||||
"""
|
||||
|
||||
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"]),
|
||||
]
|
||||
|
||||
|
||||
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"))
|
||||
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())
|
||||
@@ -0,0 +1,133 @@
|
||||
"""cloud mcp_server 单测:协议与 REST 翻译,不联网、不碰真实 key。
|
||||
|
||||
transport 用假函数注入,返回 (status, payload);测试里出现的
|
||||
key、URL 均为占位值。
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cloud.mcp_server import MCPServer, TOOLS
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.mcp_server import MCPServer, TOOLS
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, method, path, body=None):
|
||||
self.calls.append((method, path, body))
|
||||
if path == "/health":
|
||||
return 200, {"ok": True, "commit": "test"}
|
||||
if path == "/projects":
|
||||
return 200, {"projects": [{"id": "p1"}]}
|
||||
if path == "/tasks?project_id=p1":
|
||||
return 200, {"tasks": [{"id": "t1", "status": "completed"}]}
|
||||
if path == "/tasks/t1":
|
||||
return 200, {"id": "t1", "status": "completed"}
|
||||
if path == "/tasks" and method == "POST":
|
||||
return 201, {"id": "t2", "status": "queued"}
|
||||
if path == "/tasks/t1/cancel":
|
||||
return 200, {"id": "t1", "status": "interrupted"}
|
||||
if path == "/tasks/t1/cost":
|
||||
return 200, {"total_tokens": 10}
|
||||
if path == "/tasks/t1/trace":
|
||||
return 200, {"entries": []}
|
||||
if path == "/tasks/nope":
|
||||
return 404, {"error": "not found"}
|
||||
return 400, {"error": "bad request"}
|
||||
|
||||
|
||||
class MCPServerTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.transport = FakeTransport()
|
||||
self.server = MCPServer("https://example.invalid", "test-key",
|
||||
transport=self.transport)
|
||||
|
||||
def test_requires_api_key(self):
|
||||
with self.assertRaises(ValueError):
|
||||
MCPServer("https://example.invalid", "")
|
||||
|
||||
def test_initialize_and_tools_list(self):
|
||||
resp = self.server.handle({"jsonrpc": "2.0", "id": 1,
|
||||
"method": "initialize",
|
||||
"params": {"protocolVersion": "2024-11-05"}})
|
||||
self.assertEqual(resp["result"]["serverInfo"]["name"],
|
||||
"codex-cloud-mcp")
|
||||
resp = self.server.handle({"jsonrpc": "2.0", "id": 2,
|
||||
"method": "tools/list"})
|
||||
names = [t["name"] for t in resp["result"]["tools"]]
|
||||
self.assertEqual(names, [t["name"] for t in TOOLS])
|
||||
self.assertIn("create_task", names)
|
||||
self.assertIn("cancel_task", names)
|
||||
|
||||
def test_notification_returns_none(self):
|
||||
self.assertIsNone(self.server.handle(
|
||||
{"jsonrpc": "2.0", "method": "notifications/initialized"}))
|
||||
|
||||
def test_unknown_method_and_tool(self):
|
||||
resp = self.server.handle({"jsonrpc": "2.0", "id": 1,
|
||||
"method": "resources/list"})
|
||||
self.assertEqual(resp["error"]["code"], -32601)
|
||||
is_error, payload = self.server.call_tool("nope", {})
|
||||
self.assertTrue(is_error)
|
||||
self.assertIn("unknown tool", payload["error"])
|
||||
|
||||
def test_list_and_get_translate_to_rest(self):
|
||||
is_error, payload = self.server.call_tool(
|
||||
"list_tasks", {"project_id": "p1"})
|
||||
self.assertFalse(is_error)
|
||||
self.assertEqual(payload["tasks"][0]["id"], "t1")
|
||||
self.assertEqual(self.transport.calls[0][:2],
|
||||
("GET", "/tasks?project_id=p1"))
|
||||
is_error, payload = self.server.call_tool(
|
||||
"get_task", {"task_id": "t1"})
|
||||
self.assertFalse(is_error)
|
||||
self.assertEqual(payload["status"], "completed")
|
||||
|
||||
def test_create_only_passes_known_fields(self):
|
||||
is_error, payload = self.server.call_tool("create_task", {
|
||||
"project_id": "p1", "prompt": "do it", "model": "m1",
|
||||
"priority": 2, "injected": "drop-me"})
|
||||
self.assertFalse(is_error)
|
||||
self.assertEqual(payload["id"], "t2")
|
||||
method, path, body = self.transport.calls[-1]
|
||||
self.assertEqual((method, path), ("POST", "/tasks"))
|
||||
self.assertNotIn("injected", body)
|
||||
self.assertEqual(body["priority"], 2)
|
||||
|
||||
def test_rest_error_becomes_is_error(self):
|
||||
resp = self.server.handle({"jsonrpc": "2.0", "id": 9,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "get_task",
|
||||
"arguments": {"task_id": "nope"}}})
|
||||
self.assertTrue(resp["result"]["isError"])
|
||||
payload = json.loads(resp["result"]["content"][0]["text"])
|
||||
self.assertEqual(payload["http_status"], 404)
|
||||
|
||||
def test_missing_arg_is_error_not_rest_call(self):
|
||||
before = len(self.transport.calls)
|
||||
is_error, payload = self.server.call_tool("list_tasks", {})
|
||||
self.assertTrue(is_error)
|
||||
self.assertEqual(len(self.transport.calls), before)
|
||||
|
||||
def test_serve_loop_handles_bad_line(self):
|
||||
instream = io.StringIO(
|
||||
"not json\n"
|
||||
'{"jsonrpc": "2.0", "id": 1, "method": "ping"}\n')
|
||||
outstream = io.StringIO()
|
||||
self.server.serve(instream, outstream)
|
||||
lines = [json.loads(line) for line in outstream.getvalue().splitlines()]
|
||||
self.assertEqual(lines[0]["error"]["code"], -32700)
|
||||
self.assertEqual(lines[1]["id"], 1)
|
||||
self.assertEqual(lines[1]["result"], {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user