diff --git a/cloud/mcp_server.py b/cloud/mcp_server.py index dabd46c..c138dee 100644 --- a/cloud/mcp_server.py +++ b/cloud/mcp_server.py @@ -18,13 +18,19 @@ 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。 + cancel_task、get_task_cost、get_task_trace、get_task_files、 + 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 机制处理,不在这里另起一套)。 +kill_requests 机制处理,不在这里另起一套);merge_task 与 +retry_task 是写操作,调用方 key 必须有相应项目权限,服务端 +按状态机拒绝非法转换(例如未完成任务 merge 会 409),MCP +层不绕过;get_task_files 的 path 参数是 worktree 内相对 +路径,服务端 _safe_worktree_path 防逃逸,MCP 层不另做路径 +展开,绝不传绝对路径。 """ import json @@ -73,6 +79,15 @@ TOOLS = [ {"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_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"]), ] @@ -149,6 +164,29 @@ class MCPServer: 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_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", {})) return True, {"error": "unknown tool: %s" % name} @staticmethod diff --git a/cloud/tests/test_mcp_server.py b/cloud/tests/test_mcp_server.py index 2e8dc0a..69418c8 100644 --- a/cloud/tests/test_mcp_server.py +++ b/cloud/tests/test_mcp_server.py @@ -39,6 +39,16 @@ class FakeTransport: return 200, {"total_tokens": 10} if path == "/tasks/t1/trace": return 200, {"entries": []} + if path == "/tasks/t1/files": + return 200, {"files": [{"path": "a.py"}]} + if path == "/tasks/t1/files?path=a.py": + return 200, {"content": "print(1)"} + if path == "/tasks/t1/result-full": + return 200, {"result_text_full": "full-result"} + if path == "/tasks/t1/retry": + return 200, {"id": "t1", "status": "queued"} + if path == "/tasks/t1/merge": + return 200, {"id": "t1", "status": "merged"} if path == "/tasks/nope": return 404, {"error": "not found"} return 400, {"error": "bad request"} @@ -111,6 +121,28 @@ class MCPServerTestCase(unittest.TestCase): payload = json.loads(resp["result"]["content"][0]["text"]) self.assertEqual(payload["http_status"], 404) + def test_new_tools_translate_to_rest(self): + is_error, payload = self.server.call_tool( + "get_task_files", {"task_id": "t1"}) + self.assertFalse(is_error) + self.assertEqual(payload["files"][0]["path"], "a.py") + is_error, payload = self.server.call_tool( + "get_task_files", {"task_id": "t1", "path": "a.py"}) + self.assertFalse(is_error) + self.assertEqual(payload["content"], "print(1)") + is_error, payload = self.server.call_tool( + "get_task_result_full", {"task_id": "t1"}) + self.assertFalse(is_error) + self.assertEqual(payload["result_text_full"], "full-result") + is_error, payload = self.server.call_tool( + "retry_task", {"task_id": "t1"}) + self.assertFalse(is_error) + self.assertEqual(payload["status"], "queued") + is_error, payload = self.server.call_tool( + "merge_task", {"task_id": "t1"}) + self.assertFalse(is_error) + self.assertEqual(payload["status"], "merged") + def test_missing_arg_is_error_not_rest_call(self): before = len(self.transport.calls) is_error, payload = self.server.call_tool("list_tasks", {})