"""M3D module SDK. A module = a module.json manifest + an MCP server exposing the same tools in-process (used by the DAG runner) and over stdio/HTTP (used by external agents and agentic harnesses). One class, one interface, every runtime. Built on MCP Python SDK v2 (pip install mcp==2.2.0): from mcp.server import MCPServer; @mcp.tool() registers a typed tool. """ from __future__ import annotations import json from typing import Any from mcp.server import MCPServer class M3DModule: def __init__(self, manifest: dict): assert "id" in manifest and "version" in manifest, "manifest needs id + version" assert "license" in manifest, "every M3D module declares its license graph" self.manifest = manifest self.server = MCPServer(manifest["id"]) self._tools: dict[str, Any] = {} def tool(self, name: str | None = None): """Register a typed function as both an M3D tool and an MCP tool.""" def deco(fn): tool_name = name or fn.__name__ self._tools[tool_name] = fn self.server.add_tool(fn, name=tool_name) # MCP v2 registration return fn return deco def run_tool(self, name: str, args: dict) -> Any: """Direct in-process call used by the DAG runner (isolation principle).""" if name not in self._tools: raise KeyError( f"module {self.manifest['id']} has no tool {name!r}; " f"available: {sorted(self._tools)}" ) return self._tools[name](**args) def serve(self, transport: str = "stdio") -> None: """Serve over MCP — stdio/sse/streamable-http, any agentic harness can attach.""" self.server.run(transport=transport) def manifest_json(self) -> str: return json.dumps(self.manifest, indent=2)