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
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]:
+13 -1
View File
@@ -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]:
+30
View File
@@ -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
@@ -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"})