feat(mcp): 云端 MCP 工具从 8 个扩到 12 个
#159 只包了读 + 建 + 取消,日常闭环还缺:读产物文件、 读完整结果、重试、合并。新增 get_task_files(path 为 worktree 内相对路径,服务端 _safe_worktree_path 防逃逸, MCP 层不展开绝对路径)、get_task_result_full(依赖 #162 的 /result-full 端点,未部署前该工具回 404 isError,不炸)、 retry_task、merge_task(写操作,服务端状态机校验,非法 转换 409 原样透传,MCP 层不绕过)。 单测 test_mcp_server 10 项(含新增翻译 1 项)。
This commit is contained in:
1 parent
9a11ef27b8
commit
d5a602f7d3
2 files changed
+73
-3
No files matched your search
+41
-3
@@ -18,13 +18,19 @@ https://api.diante.me。MCP 服务本身不存任何凭证、不建
|
|||||||
用户、不绕过服务端权限——服务端按 key 归属做 viewer/
|
用户、不绕过服务端权限——服务端按 key 归属做 viewer/
|
||||||
contributor 等权限校验,403 原样透传。
|
contributor 等权限校验,403 原样透传。
|
||||||
|
|
||||||
工具(只包最常用的七个,不包管理端):
|
工具(不包管理端):
|
||||||
health、list_projects、list_tasks、get_task、create_task、
|
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/
|
create_task 只传 project_id + prompt + 可选 executor/model/
|
||||||
effort/approval/priority,其余字段走服务端缺省;cancel_task
|
effort/approval/priority,其余字段走服务端缺省;cancel_task
|
||||||
只做取消,不做真 kill 之外的任何事(真 kill 由 worker 的
|
只做取消,不做真 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
|
import json
|
||||||
@@ -73,6 +79,15 @@ TOOLS = [
|
|||||||
{"task_id": {"type": "string"}}, ["task_id"]),
|
{"task_id": {"type": "string"}}, ["task_id"]),
|
||||||
_tool("get_task_trace", "取单个任务的链路条目(摘要,完整结果在任务详情)。",
|
_tool("get_task_trace", "取单个任务的链路条目(摘要,完整结果在任务详情)。",
|
||||||
{"task_id": {"type": "string"}}, ["task_id"]),
|
{"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 True, {"error": "task_id required"}
|
||||||
return self._wrap(*self.transport(
|
return self._wrap(*self.transport(
|
||||||
"GET", "/tasks/" + args["task_id"] + "/trace"))
|
"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}
|
return True, {"error": "unknown tool: %s" % name}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ class FakeTransport:
|
|||||||
return 200, {"total_tokens": 10}
|
return 200, {"total_tokens": 10}
|
||||||
if path == "/tasks/t1/trace":
|
if path == "/tasks/t1/trace":
|
||||||
return 200, {"entries": []}
|
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":
|
if path == "/tasks/nope":
|
||||||
return 404, {"error": "not found"}
|
return 404, {"error": "not found"}
|
||||||
return 400, {"error": "bad request"}
|
return 400, {"error": "bad request"}
|
||||||
@@ -111,6 +121,28 @@ class MCPServerTestCase(unittest.TestCase):
|
|||||||
payload = json.loads(resp["result"]["content"][0]["text"])
|
payload = json.loads(resp["result"]["content"][0]["text"])
|
||||||
self.assertEqual(payload["http_status"], 404)
|
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):
|
def test_missing_arg_is_error_not_rest_call(self):
|
||||||
before = len(self.transport.calls)
|
before = len(self.transport.calls)
|
||||||
is_error, payload = self.server.call_tool("list_tasks", {})
|
is_error, payload = self.server.call_tool("list_tasks", {})
|
||||||
|
|||||||
Reference in new issue
Block a user