mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
+13
-1
@@ -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]:
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user