diff --git a/roboco/mcp/do_server.py b/roboco/mcp/do_server.py index 0abcff6b..db304991 100644 --- a/roboco/mcp/do_server.py +++ b/roboco/mcp/do_server.py @@ -23,6 +23,8 @@ import httpx import structlog from mcp.server.fastmcp import FastMCP +from roboco.agents_config import get_agent_team + ORCHESTRATOR_URL = os.environ.get( "ROBOCO_ORCHESTRATOR_URL", "http://roboco-orchestrator:8000", @@ -226,11 +228,21 @@ def _build_headers() -> dict[str, str]: orchestrator's middleware can bind it to structlog and the audit row, and the envelope echoes it back to the agent. """ - return { + # X-Agent-Token + X-Agent-Team must travel with every do verb or the + # API's ROBOCO_AGENT_AUTH_REQUIRED gate 401s — mirrors flow_server and + # the ApiClient header path used by the other MCP servers. + headers = { "X-Agent-ID": AGENT_ID, "X-Agent-Role": AGENT_ROLE, "X-Correlation-ID": str(uuid.uuid4()), } + team = get_agent_team(AGENT_ID) + if team: + headers["X-Agent-Team"] = team + token = os.environ.get("ROBOCO_AGENT_TOKEN") + if token: + headers["X-Agent-Token"] = token + return headers def _post(path: str, body: dict[str, Any]) -> dict[str, Any]: diff --git a/roboco/mcp/flow_server.py b/roboco/mcp/flow_server.py index 31993c7c..1660c351 100644 --- a/roboco/mcp/flow_server.py +++ b/roboco/mcp/flow_server.py @@ -25,6 +25,7 @@ import structlog from mcp.server.fastmcp import FastMCP from pydantic import BeforeValidator +from roboco.agents_config import get_agent_team from roboco.foundation.policy.content.validators import coerce_str_list # A ``list[str]`` field that tolerates the Claude SDK's XML-ish tool-input @@ -260,11 +261,22 @@ def _build_headers() -> dict[str, str]: every log line and the audit row carry the same id and the agent receives it back on the envelope. """ - return { + # X-Agent-Token (HMAC over id:role:team, issued by the orchestrator at + # spawn) and X-Agent-Team must travel with every flow verb or the API's + # ROBOCO_AGENT_AUTH_REQUIRED gate 401s with "Missing X-Agent-Token" — + # the same headers ApiClient injects for the other MCP servers. + headers = { "X-Agent-ID": AGENT_ID, "X-Agent-Role": AGENT_ROLE, "X-Correlation-ID": str(uuid.uuid4()), } + team = get_agent_team(AGENT_ID) + if team: + headers["X-Agent-Team"] = team + token = os.environ.get("ROBOCO_AGENT_TOKEN") + if token: + headers["X-Agent-Token"] = token + return headers def _post(path: str, body: dict[str, Any]) -> dict[str, Any]: diff --git a/tests/unit/mcp_servers/test_do_server.py b/tests/unit/mcp_servers/test_do_server.py index c1de0fad..98b37ed9 100644 --- a/tests/unit/mcp_servers/test_do_server.py +++ b/tests/unit/mcp_servers/test_do_server.py @@ -89,6 +89,36 @@ def test_note_with_scope_reflect(do_module: Any) -> None: assert kwargs["json"]["scope"] == "reflect" +def test_build_headers_carries_auth_token_and_team( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """do verbs must carry X-Agent-Token + X-Agent-Team or the API's + ROBOCO_AGENT_AUTH_REQUIRED gate 401s with "Missing X-Agent-Token" — + regression: the manual header dict omitted both, latent until auth was + armed on the NAS deploy.""" + import importlib + + be_dev_1 = "00000000-0000-0000-0001-000000000001" # role=developer, team=backend + manifest = Path(tempfile.mkdtemp()) / "tool-manifest.json" + manifest.write_text(json.dumps({**_DO_TEST_MANIFEST, "agent_id": be_dev_1})) + monkeypatch.setenv("ROBOCO_AGENT_ID", be_dev_1) + monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer") + monkeypatch.setenv("ROBOCO_AGENT_TOKEN", "test-hmac-token") + monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000") + monkeypatch.setenv("ROBOCO_TOOL_MANIFEST_PATH", str(manifest)) + + import roboco.mcp.do_server as srv + + importlib.reload(srv) + headers = srv._build_headers() + + assert headers["X-Agent-ID"] == be_dev_1 + assert headers["X-Agent-Role"] == "developer" + assert headers["X-Agent-Team"] == "backend" + assert headers["X-Agent-Token"] == "test-hmac-token" + assert "X-Correlation-ID" in headers + + def test_dm_posts_all_fields(do_module: Any) -> None: fake_client = MagicMock() fake_client.__enter__.return_value = fake_client diff --git a/tests/unit/mcp_servers/test_flow_server.py b/tests/unit/mcp_servers/test_flow_server.py index 281d4326..15a0daa4 100644 --- a/tests/unit/mcp_servers/test_flow_server.py +++ b/tests/unit/mcp_servers/test_flow_server.py @@ -134,6 +134,34 @@ def test_give_me_work_posts_to_orchestrator(flow_module: types.ModuleType) -> No assert kwargs["headers"]["X-Agent-Role"] == "developer" +def test_build_headers_carries_auth_token_and_team( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """flow verbs must carry X-Agent-Token + X-Agent-Team or the API's + ROBOCO_AGENT_AUTH_REQUIRED gate 401s with "Missing X-Agent-Token" — + regression: the manual header dict omitted both, latent until auth was + armed on the NAS deploy.""" + be_dev_1 = "00000000-0000-0000-0001-000000000001" # role=developer, team=backend + manifest = tmp_path / "tool-manifest.json" + manifest.write_text(json.dumps({**_FULL_MANIFEST, "agent_id": be_dev_1})) + monkeypatch.setenv("ROBOCO_AGENT_ID", be_dev_1) + monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer") + monkeypatch.setenv("ROBOCO_AGENT_TOKEN", "test-hmac-token") + monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000") + monkeypatch.setenv("ROBOCO_TOOL_MANIFEST_PATH", str(manifest)) + + import roboco.mcp.flow_server as srv + + importlib.reload(srv) + headers = srv._build_headers() + + assert headers["X-Agent-ID"] == be_dev_1 + assert headers["X-Agent-Role"] == "developer" + assert headers["X-Agent-Team"] == "backend" + assert headers["X-Agent-Token"] == "test-hmac-token" + assert "X-Correlation-ID" in headers + + def test_i_will_work_on_passes_plan(flow_module: types.ModuleType) -> None: fake_client = _make_fake_client({"status": "in_progress"})