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:
@@ -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
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import nats
|
||||
|
||||
from .interpreter import CodeInterpreter
|
||||
from .mineru import MultimodalParser
|
||||
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:
|
||||
def __init__(self) -> None:
|
||||
self.sandbox = SecureSandbox() # gVisor / KataVM + Static Code Guard
|
||||
self.parser = MultimodalParser() # MinerU / PaddleOCR
|
||||
self.interpreter = CodeInterpreter() # Docker 隔离沙箱
|
||||
self._nc: nats.NATS | None = None
|
||||
self._tools: dict[str, callable] = {}
|
||||
|
||||
async def register_tools(self) -> None:
|
||||
# TODO: 向 MCP server 注册工具 schema
|
||||
...
|
||||
|
||||
async def serve(self) -> None:
|
||||
# TODO: 连接 NATS,订阅 sundynix.tools.py.*,按 MCP JSON-RPC 路由
|
||||
import asyncio
|
||||
"""注册工具名 → 处理协程。"""
|
||||
self._tools = {
|
||||
"echo": self._echo,
|
||||
"run_code": self._run_code,
|
||||
"parse_document": self._parse_document,
|
||||
"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()
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user