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,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