fix(mcp): send X-Agent-Token + X-Agent-Team from flow/do servers

flow_server._build_headers and do_server._build_headers constructed
only X-Agent-ID/Role/Correlation-ID, omitting X-Agent-Token and
X-Agent-Team (unlike ApiClient._get_agent_headers used by the other
MCP servers). Latent since the gateway refactor — surfaced when
ROBOCO_AGENT_AUTH_REQUIRED=true was armed on the NAS, 401-ing every
flow/do verb with 'Missing X-Agent-Token header'. Add both headers
(mirroring ApiClient) so the HMAC gate passes. Tests assert the
headers are now injected.
This commit is contained in:
Renn F
2026-07-06 03:42:40 +02:00
parent 51b92bed45
commit 6ed4e1391b
4 changed files with 84 additions and 2 deletions
+13 -1
View File
@@ -23,6 +23,8 @@ import httpx
import structlog import structlog
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from roboco.agents_config import get_agent_team
ORCHESTRATOR_URL = os.environ.get( ORCHESTRATOR_URL = os.environ.get(
"ROBOCO_ORCHESTRATOR_URL", "ROBOCO_ORCHESTRATOR_URL",
"http://roboco-orchestrator:8000", "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, orchestrator's middleware can bind it to structlog and the audit row,
and the envelope echoes it back to the agent. 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-ID": AGENT_ID,
"X-Agent-Role": AGENT_ROLE, "X-Agent-Role": AGENT_ROLE,
"X-Correlation-ID": str(uuid.uuid4()), "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]: def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
+13 -1
View File
@@ -25,6 +25,7 @@ import structlog
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from pydantic import BeforeValidator from pydantic import BeforeValidator
from roboco.agents_config import get_agent_team
from roboco.foundation.policy.content.validators import coerce_str_list from roboco.foundation.policy.content.validators import coerce_str_list
# A ``list[str]`` field that tolerates the Claude SDK's XML-ish tool-input # 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 every log line and the audit row carry the same id and the agent
receives it back on the envelope. 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-ID": AGENT_ID,
"X-Agent-Role": AGENT_ROLE, "X-Agent-Role": AGENT_ROLE,
"X-Correlation-ID": str(uuid.uuid4()), "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]: def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
+30
View File
@@ -89,6 +89,36 @@ def test_note_with_scope_reflect(do_module: Any) -> None:
assert kwargs["json"]["scope"] == "reflect" 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: def test_dm_posts_all_fields(do_module: Any) -> None:
fake_client = MagicMock() fake_client = MagicMock()
fake_client.__enter__.return_value = fake_client fake_client.__enter__.return_value = fake_client
@@ -134,6 +134,34 @@ def test_give_me_work_posts_to_orchestrator(flow_module: types.ModuleType) -> No
assert kwargs["headers"]["X-Agent-Role"] == "developer" 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: def test_i_will_work_on_passes_plan(flow_module: types.ModuleType) -> None:
fake_client = _make_fake_client({"status": "in_progress"}) fake_client = _make_fake_client({"status": "in_progress"})