feat: mcp-py 接入工具总线 sundynix.tools.py.* (与 Go 同契约)

第 5b 层 Python 算法工具挂上 NATS,core NATS request-reply + 队列组,
与 Go 侧 ServeTool 字节级同契约({tool,args,task_id}/{ok,content,error})。

- mcp_gateway: nats-py 连接(无限重连) + queue subscribe(mcp-py-workers) + 按工具名路由
  工具 echo / run_code(Docker桩) / parse_document(MinerU桩) / secure_sandbox(gVisor桩)
- Makefile: 新增 mcp-py-setup(venv + pip install -e .),mcp-py 缺 venv 自动 setup
  e2e 补上 TestToolCallRoundTrip
- 验证: live 对 NATS 容器跑通 4 类调用(含未知工具错误);3 个 Go e2e PASS

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-10 12:58:38 +08:00
parent e5fa0ae36c
commit a67604f4b7
2 changed files with 104 additions and 12 deletions
+9 -5
View File
@@ -1,4 +1,4 @@
.PHONY: infra infra-down devnats demo e2e gateway dispatcher mcp-go mcp-py desktop tidy .PHONY: infra infra-down devnats demo e2e gateway dispatcher mcp-go mcp-py mcp-py-setup desktop tidy
infra: ## 启动基础设施 (NATS / Postgres / Redis / Milvus / Neo4j) infra: ## 启动基础设施 (NATS / Postgres / Redis / Milvus / Neo4j)
docker compose up -d docker compose up -d
@@ -12,8 +12,8 @@ devnats: ## 启动内嵌 JetStream NATS(无 Docker 本地联调)
demo: ## 一键演示 Gateway→NATS→Dispatcher 任务流(无需 Docker demo: ## 一键演示 Gateway→NATS→Dispatcher 任务流(无需 Docker
bash scripts/demo.sh bash scripts/demo.sh
e2e: ## 跑共享 bus 的端到端测试(内嵌 NATS) e2e: ## 跑共享 bus 的端到端测试(内嵌 NATS):任务流 / 工具调用 / Token 流
cd sundynix-shared && go test ./bus/ -run 'TestTaskRoundTrip|TestTokenStreamRoundTrip' -v cd sundynix-shared && go test ./bus/ -run 'TestTaskRoundTrip|TestToolCallRoundTrip|TestTokenStreamRoundTrip' -v
gateway: gateway:
cd sundynix-gateway && go run ./cmd/server cd sundynix-gateway && go run ./cmd/server
@@ -24,8 +24,12 @@ dispatcher:
mcp-go: mcp-go:
cd sundynix-mcp-go && go run ./cmd/server cd sundynix-mcp-go && go run ./cmd/server
mcp-py: mcp-py-setup: ## 创建 venv 并安装 mcp-py 依赖(首次运行 mcp-py 前执行)
cd sundynix-mcp-py && python -m sundynix_mcp_py.main cd sundynix-mcp-py && python3 -m venv .venv && .venv/bin/pip install -q -e .
mcp-py: ## 运行 Python 算法型 MCP 工具服务(缺 venv 则自动 setup
cd sundynix-mcp-py && [ -x .venv/bin/python ] || $(MAKE) mcp-py-setup
cd sundynix-mcp-py && .venv/bin/python -m sundynix_mcp_py.main
desktop: desktop:
cd sundynix-desktop && wails dev cd sundynix-desktop && wails dev
@@ -1,24 +1,112 @@
"""MCP 协议网关:注册算法型工具并经 NATS 分发调用。""" """MCP 协议网关:注册算法型工具并经 NATS 分发调用。
与 Go 侧 sundynix-shared/bus 的 ServeTool 同契约(core NATS request-reply):
请求体 {"tool": str, "args": {...}, "task_id": str}
应答体 {"ok": bool, "content": str, "error": str}
订阅 sundynix.tools.py.>,队列组 mcp-py-workers(多副本负载均衡)。
"""
from __future__ import annotations from __future__ import annotations
import asyncio
import json
import logging
import os
import nats
from .interpreter import CodeInterpreter from .interpreter import CodeInterpreter
from .mineru import MultimodalParser from .mineru import MultimodalParser
from .sandbox import SecureSandbox from .sandbox import SecureSandbox
log = logging.getLogger("mcp_py")
# 与 contract.SubjectToolsPyAll / QueueToolsPy 保持一致。
SUBJECT_PY_ALL = "sundynix.tools.py.>"
QUEUE_PY = "mcp-py-workers"
class McpGateway: class McpGateway:
def __init__(self) -> None: def __init__(self) -> None:
self.sandbox = SecureSandbox() # gVisor / KataVM + Static Code Guard self.sandbox = SecureSandbox() # gVisor / KataVM + Static Code Guard
self.parser = MultimodalParser() # MinerU / PaddleOCR self.parser = MultimodalParser() # MinerU / PaddleOCR
self.interpreter = CodeInterpreter() # Docker 隔离沙箱 self.interpreter = CodeInterpreter() # Docker 隔离沙箱
self._nc: nats.NATS | None = None
self._tools: dict[str, callable] = {}
async def register_tools(self) -> None: async def register_tools(self) -> None:
# TODO: 向 MCP server 注册工具 schema """注册工具名 → 处理协程。"""
... self._tools = {
"echo": self._echo,
async def serve(self) -> None: "run_code": self._run_code,
# TODO: 连接 NATS,订阅 sundynix.tools.py.*,按 MCP JSON-RPC 路由 "parse_document": self._parse_document,
import asyncio "secure_sandbox": self._secure_sandbox,
}
async def serve(self, url: str | None = None) -> None:
url = url or os.getenv("NATS_URL", "nats://localhost:4222")
# 容忍服务先于 NATS 启动:无限重连 + 等待间隔。
self._nc = await nats.connect(
url,
allow_reconnect=True,
max_reconnect_attempts=-1,
reconnect_time_wait=1,
connect_timeout=5,
)
log.info("[mcp_py] connected %s", url)
await self._nc.subscribe(SUBJECT_PY_ALL, queue=QUEUE_PY, cb=self._on_call)
log.info(
"[mcp_py] tools ready on %s (queue=%s): %s",
SUBJECT_PY_ALL, QUEUE_PY, ", ".join(self._tools),
)
await asyncio.Event().wait() await asyncio.Event().wait()
async def _on_call(self, msg) -> None:
"""解析 ToolCall → 路由到工具 → Respond ToolResult。"""
try:
req = json.loads(msg.data)
except Exception as e: # noqa: BLE001
await self._reply(msg, ok=False, error=f"bad tool call: {e}")
return
tool = req.get("tool", "")
task_id = req.get("task_id", "")
args = req.get("args") or {}
log.info("[mcp_py] tool=%s task=%s args=%s", tool, task_id, args)
fn = self._tools.get(tool)
if fn is None:
await self._reply(msg, ok=False, error=f"unknown tool: {tool}")
return
try:
content = await fn(args)
await self._reply(msg, ok=True, content=content)
except Exception as e: # noqa: BLE001
await self._reply(msg, ok=False, error=str(e))
@staticmethod
async def _reply(msg, *, ok: bool, content: str = "", error: str = "") -> None:
payload = json.dumps({"ok": ok, "content": content, "error": error})
await msg.respond(payload.encode())
# ---- 工具实现(算法层目前为桩,但调用链路做真)----
async def _echo(self, args: dict) -> str:
return str(args.get("text", ""))
async def _run_code(self, args: dict) -> str:
code = str(args.get("code", ""))
result = await self.interpreter.execute(code) # Docker 隔离沙箱(桩)
return f"[run_code] Docker 隔离执行(桩) stdout={result.get('stdout','')!r}"
async def _parse_document(self, args: dict) -> str:
path = str(args.get("path", ""))
result = await self.parser.parse(path) # MinerU / PaddleOCR(桩)
return f"[parse_document] MinerU 解析(桩) path={result.get('path','')!r} blocks={len(result.get('blocks', []))}"
async def _secure_sandbox(self, args: dict) -> str:
code = str(args.get("code", ""))
out = await self.sandbox.run(code) # gVisor/KataVM(桩,含静态守卫)
return f"[secure_sandbox] gVisor 隔离执行(桩) out={out!r}"
async def close(self) -> None:
if self._nc is not None:
await self._nc.drain()