refactor(mcp): scope do_server tool registration to per-agent manifest

Same change as flow_server: read the spawn manifest's do_tools list and
register only those names on this MCP server, so e.g. a Cell PM agent
no longer sees commit() in its tool palette (Cell PMs don't write code,
they only coordinate). Falls back to registering all do tools when the
manifest is missing, with a warning, so test runs without the bind
mount keep working.
This commit is contained in:
Renn F
2026-05-02 04:45:11 +02:00
parent 3d6028b036
commit f2211a15a9
+91 -7
View File
@@ -1,15 +1,24 @@
"""roboco-do MCP server — smart-wrapped content tools. """roboco-do MCP server — smart-wrapped content tools.
Forwards to /api/v2/do/* on the orchestrator. Tools are not role-scoped Forwards to /api/v2/do/* on the orchestrator. Tools are role-scoped at *spawn*
(any agent role can use them), so the path is fixed (no role segment). 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 from __future__ import annotations
import json
import os import os
from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
import structlog
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
ORCHESTRATOR_URL = os.environ.get( ORCHESTRATOR_URL = os.environ.get(
@@ -23,6 +32,7 @@ _HEADERS = {"X-Agent-ID": AGENT_ID, "X-Agent-Role": AGENT_ROLE}
_TIMEOUT = 30 _TIMEOUT = 30
mcp = FastMCP("roboco-do") mcp = FastMCP("roboco-do")
log = structlog.get_logger()
def _post(path: str, body: dict[str, Any]) -> dict[str, Any]: 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 return result
@mcp.tool()
def commit(message: str, files: list[str] | None = None) -> dict[str, Any]: def commit(message: str, files: list[str] | None = None) -> dict[str, Any]:
"""Make a git commit. [task-id] prefix auto-applied. Validates message.""" """Make a git commit. [task-id] prefix auto-applied. Validates message."""
return _post("/api/v2/do/commit", {"message": message, "files": files}) 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]: 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.""" """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}) 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]: 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.""" """Post to a channel. task_id auto-injected if you have an active task."""
return _post( return _post(
@@ -59,7 +66,6 @@ def say(channel: str, text: str, task_id: str | None = None) -> dict[str, Any]:
) )
@mcp.tool()
def dm( def dm(
recipient: str, recipient: str,
text: str, text: str,
@@ -73,11 +79,89 @@ def dm(
) )
@mcp.tool()
def evidence(task_id: str) -> dict[str, Any]: def evidence(task_id: str) -> dict[str, Any]:
"""Inspect a task's PR diff, commits, files. Fetches dev branch into workspace.""" """Inspect a task's PR diff, commits, files. Fetches dev branch into workspace."""
return _post("/api/v2/do/evidence", {"task_id": task_id}) 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__": if __name__ == "__main__":
mcp.run() mcp.run()