diff --git a/roboco/mcp/do_server.py b/roboco/mcp/do_server.py index 83bd0652..9db55bd5 100644 --- a/roboco/mcp/do_server.py +++ b/roboco/mcp/do_server.py @@ -1,15 +1,24 @@ """roboco-do MCP server — smart-wrapped content tools. -Forwards to /api/v2/do/* on the orchestrator. Tools are not role-scoped -(any agent role can use them), so the path is fixed (no role segment). +Forwards to /api/v2/do/* on the orchestrator. Tools are role-scoped at *spawn* +time: the orchestrator writes ``do_tools`` into the per-agent manifest and we +register only those names on this server. The orchestrator's API is not +role-scoped here (any allowed role can call commit/note/say/dm/evidence), so +the path is fixed (no role segment). + +If the manifest is missing or unreadable (local test runs without the bind +mount) the full registry is registered as a failsafe and a warning is logged. """ from __future__ import annotations +import json import os +from pathlib import Path from typing import Any import httpx +import structlog from mcp.server.fastmcp import FastMCP ORCHESTRATOR_URL = os.environ.get( @@ -23,6 +32,7 @@ _HEADERS = {"X-Agent-ID": AGENT_ID, "X-Agent-Role": AGENT_ROLE} _TIMEOUT = 30 mcp = FastMCP("roboco-do") +log = structlog.get_logger() def _post(path: str, body: dict[str, Any]) -> dict[str, Any]: @@ -38,19 +48,16 @@ def _post(path: str, body: dict[str, Any]) -> dict[str, Any]: return result -@mcp.tool() def commit(message: str, files: list[str] | None = None) -> dict[str, Any]: """Make a git commit. [task-id] prefix auto-applied. Validates message.""" return _post("/api/v2/do/commit", {"message": message, "files": files}) -@mcp.tool() def note(text: str, scope: str = "note", task_id: str | None = None) -> dict[str, Any]: """Write a journal entry. scope in note|decision|reflect|learning|struggle.""" return _post("/api/v2/do/note", {"text": text, "scope": scope, "task_id": task_id}) -@mcp.tool() def say(channel: str, text: str, task_id: str | None = None) -> dict[str, Any]: """Post to a channel. task_id auto-injected if you have an active task.""" return _post( @@ -59,7 +66,6 @@ def say(channel: str, text: str, task_id: str | None = None) -> dict[str, Any]: ) -@mcp.tool() def dm( recipient: str, text: str, @@ -73,11 +79,89 @@ def dm( ) -@mcp.tool() def evidence(task_id: str) -> dict[str, Any]: """Inspect a task's PR diff, commits, files. Fetches dev branch into workspace.""" return _post("/api/v2/do/evidence", {"task_id": task_id}) +# ---------- Tool registry ---------- +# +# Maps the tool name an agent calls (matches manifest entries and the +# orchestrator's API path) to the Python implementation. + +_TOOLS: dict[str, Any] = { + "commit": commit, + "note": note, + "say": say, + "dm": dm, + "evidence": evidence, +} + + +def _load_manifest_do_tools() -> list[str] | None: + """Read the spawn manifest and return its ``do_tools`` list. + + Returns ``None`` when the manifest is missing or unreadable so callers can + fall back to registering the full tool set. Never raises. + """ + manifest_path = Path( + os.environ.get("ROBOCO_TOOL_MANIFEST_PATH", "/app/tool-manifest.json"), + ) + if not manifest_path.exists(): + return None + try: + manifest = json.loads(manifest_path.read_text()) + except (OSError, json.JSONDecodeError) as exc: + log.warning( + "do_server: cannot read manifest", + path=str(manifest_path), + error=str(exc), + ) + return None + do_tools = manifest.get("do_tools") + if not isinstance(do_tools, list): + log.warning( + "do_server: manifest missing do_tools list", + path=str(manifest_path), + ) + return None + return [str(verb) for verb in do_tools] + + +def _register_tools() -> list[str]: + """Register MCP tools according to the manifest, or all tools as a failsafe. + + Returns the list of tool names actually registered. + """ + allowed = _load_manifest_do_tools() + if allowed is None: + log.warning( + "do_server: manifest unavailable; registering all do tools", + role=AGENT_ROLE, + ) + names = list(_TOOLS) + else: + unknown = [verb for verb in allowed if verb not in _TOOLS] + if unknown: + log.warning( + "do_server: manifest references unknown do tools", + role=AGENT_ROLE, + missing=sorted(unknown), + ) + names = [verb for verb in allowed if verb in _TOOLS] + + for verb in names: + mcp.tool(name=verb)(_TOOLS[verb]) + log.info( + "do_server: registered tools", + role=AGENT_ROLE, + tools=sorted(names), + ) + return names + + +_REGISTERED_TOOLS = _register_tools() + + if __name__ == "__main__": mcp.run()