diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f2ea0bdb78c8..e6acc5542833 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -11439,6 +11439,15 @@ async def auth_mcp_server(name: str, request: Request, profile: Optional[str] = status_code=429, detail="Too many MCP OAuth flows are already in progress", ) + if any( + flow.server_name == name + and flow.status in {"starting", "authorization_required"} + for flow in _mcp_oauth_flows.values() + ): + raise HTTPException( + status_code=409, + detail=f"MCP OAuth for '{name}' is already in progress", + ) with _profile_scope(profile): servers = _get_mcp_servers() if name not in servers: diff --git a/tests/hermes_cli/test_mcp_dashboard_oauth.py b/tests/hermes_cli/test_mcp_dashboard_oauth.py index 142b4ba54ac1..fdf4be82f15f 100644 --- a/tests/hermes_cli/test_mcp_dashboard_oauth.py +++ b/tests/hermes_cli/test_mcp_dashboard_oauth.py @@ -159,6 +159,29 @@ def test_hosted_auth_start_bounds_pending_flow_registry(): assert response.status_code == 429 +def test_hosted_auth_rejects_overlapping_flow_for_same_server(): + from hermes_cli import web_server + from tools.mcp_dashboard_oauth import DashboardOAuthFlow + + client = _client() + client.post( + "/api/mcp/servers", + json={"name": "reports", "url": "https://mcp.example/mcp", "auth": "oauth"}, + ) + existing = DashboardOAuthFlow( + flow_id="existing-reports", + server_name="reports", + profile="other-profile", + redirect_uri="https://agent.example/callback/existing", + ) + web_server._mcp_oauth_flows[existing.flow_id] = existing + + response = client.post("/api/mcp/servers/reports/auth") + + assert response.status_code == 409 + assert "already in progress" in response.text + + def test_flow_status_does_not_expose_authorization_code(): from hermes_cli import web_server from tools.mcp_dashboard_oauth import DashboardOAuthFlow diff --git a/tests/tools/test_mcp_dashboard_oauth.py b/tests/tools/test_mcp_dashboard_oauth.py index 77e9630e340a..116612668604 100644 --- a/tests/tools/test_mcp_dashboard_oauth.py +++ b/tests/tools/test_mcp_dashboard_oauth.py @@ -72,6 +72,28 @@ def test_dashboard_flow_rejects_second_callback(): flow.deliver_callback(code="second", state="state", error=None) +def test_dashboard_flow_cannot_resurrect_after_terminal_error(): + from tools.mcp_dashboard_oauth import DashboardOAuthFlow + + flow = DashboardOAuthFlow( + flow_id="flow-terminal", + server_name="reports", + profile=None, + redirect_uri="https://agent.example/mcp/oauth/callback/flow-terminal", + ) + flow.mark_error("start timed out") + + with pytest.raises(RuntimeError, match="already ended"): + asyncio.run( + flow.publish_authorization_url( + "https://idp.example/authorize?state=too-late" + ) + ) + + assert flow.status == "error" + assert flow.authorization_url is None + + def test_dashboard_context_overrides_redirect_and_handlers(): from tools.mcp_dashboard_oauth import ( DashboardOAuthFlow, diff --git a/tools/mcp_dashboard_oauth.py b/tools/mcp_dashboard_oauth.py index 112049e4b4c2..da2fda39bc7a 100644 --- a/tools/mcp_dashboard_oauth.py +++ b/tools/mcp_dashboard_oauth.py @@ -36,6 +36,8 @@ class DashboardOAuthFlow: _callback_ready: threading.Event = field(default_factory=threading.Event, init=False, repr=False) async def publish_authorization_url(self, url: str) -> None: + if self.status in {"approved", "error"}: + raise RuntimeError("OAuth flow already ended") state = parse_qs(urlparse(url).query).get("state", [None])[0] if not state: raise ValueError("OAuth authorization URL did not include state")