From d783957ff9be77cf2f37b6e967c91f110ea8fba3 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 4 May 2026 15:49:43 +0000 Subject: [PATCH 1/2] fix: handle mcpServers format in get_mcp_config and add expose_secrets Fix get_mcp_config() to work with automation-triggered conversations: 1. Add expose_secrets=true parameter to the API call, matching get_llm() behavior. Without this, credentials are masked and MCP auth fails. 2. Passthrough mcpServers format directly when API returns it. The API was updated in April 2025 to return the standard fastmcp.MCPConfig format with a mcpServers dict, but get_mcp_config() was still expecting the legacy sse_servers/shttp_servers/stdio_servers arrays. 3. Keep legacy format transformation as fallback for backward compat. Fixes OpenHands/automation#93 Co-authored-by: openhands --- .../openhands/workspace/cloud/workspace.py | 16 ++++++- .../test_cloud_workspace_sdk_settings.py | 47 ++++++++++++++++++- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/openhands-workspace/openhands/workspace/cloud/workspace.py b/openhands-workspace/openhands/workspace/cloud/workspace.py index d0dd2b7e71..802413de59 100644 --- a/openhands-workspace/openhands/workspace/cloud/workspace.py +++ b/openhands-workspace/openhands/workspace/cloud/workspace.py @@ -682,10 +682,16 @@ def get_secrets(self, names: list[str] | None = None) -> dict[str, LookupSecret] def get_mcp_config(self) -> dict[str, Any]: """Fetch MCP configuration from the user's SaaS account. - Calls ``GET /api/v1/users/me`` to retrieve the user's MCP configuration - and transforms it into the format expected by the SDK Agent and + Calls ``GET /api/v1/users/me?expose_secrets=true`` to retrieve the + user's MCP configuration in the format expected by the SDK Agent and ``fastmcp.mcp_config.MCPConfig``. + The API returns ``mcp_config`` in the standard ``fastmcp.MCPConfig`` + format with a ``mcpServers`` dict. This method passes that through + directly. For backward compatibility, it also supports transforming + the legacy format (``sse_servers``, ``shttp_servers``, ``stdio_servers`` + arrays) if encountered. + Returns: A dictionary with ``mcpServers`` key containing server configurations (compatible with ``MCPConfig.model_validate()``), or an empty dict @@ -711,6 +717,7 @@ def get_mcp_config(self) -> dict[str, Any]: resp = self._send_api_request( "GET", f"{self.cloud_api_url}/api/v1/users/me", + params={"expose_secrets": "true"}, headers={"X-Session-API-Key": self._session_api_key or ""}, ) data = resp.json() @@ -719,6 +726,11 @@ def get_mcp_config(self) -> dict[str, Any]: if not mcp_config_data: return {} + # Standard format: API returns mcpServers dict directly (fastmcp.MCPConfig) + if "mcpServers" in mcp_config_data: + return mcp_config_data + + # Legacy format: transform sse_servers/shttp_servers/stdio_servers arrays mcp_servers: dict[str, dict[str, Any]] = {} # Transform SSE servers → RemoteMCPServer format diff --git a/tests/workspace/test_cloud_workspace_sdk_settings.py b/tests/workspace/test_cloud_workspace_sdk_settings.py index e7e54990b3..628812b0d6 100644 --- a/tests/workspace/test_cloud_workspace_sdk_settings.py +++ b/tests/workspace/test_cloud_workspace_sdk_settings.py @@ -209,8 +209,52 @@ def test_get_mcp_config_returns_empty_when_no_config(self, mock_workspace): assert mcp_config == {} + def test_get_mcp_config_passthrough_mcpservers_format(self, mock_workspace): + """get_mcp_config passes through mcpServers format from API directly. + + This is the standard fastmcp.MCPConfig format returned by the API since + April 2025. The method should recognize this format and return it as-is. + """ + mock_response = MagicMock() + mock_response.json.return_value = { + "mcp_config": { + "mcpServers": { + "hubspot": { + "url": "https://mcp.example.com/hubspot", + "transport": "streamable-http", + "headers": {"Authorization": "Bearer secret-key"}, + }, + "slack": { + "url": "https://mcp.example.com/slack", + "transport": "sse", + }, + } + } + } + mock_response.raise_for_status = MagicMock() + + with patch.object( + mock_workspace, "_send_api_request", return_value=mock_response + ) as mock_req: + mcp_config = mock_workspace.get_mcp_config() + + # Verify expose_secrets is passed + mock_req.assert_called_once_with( + "GET", + f"{CLOUD_URL}/api/v1/users/me", + params={"expose_secrets": "true"}, + headers={"X-Session-API-Key": SESSION_KEY}, + ) + + # Should pass through the mcpServers format directly + assert "mcpServers" in mcp_config + assert len(mcp_config["mcpServers"]) == 2 + assert mcp_config["mcpServers"]["hubspot"]["url"] == "https://mcp.example.com/hubspot" + assert mcp_config["mcpServers"]["hubspot"]["headers"]["Authorization"] == "Bearer secret-key" + assert mcp_config["mcpServers"]["slack"]["transport"] == "sse" + def test_get_mcp_config_transforms_sse_servers(self, mock_workspace): - """get_mcp_config correctly transforms SSE servers.""" + """get_mcp_config correctly transforms legacy SSE servers format.""" mock_response = MagicMock() mock_response.json.return_value = { "mcp_config": { @@ -232,6 +276,7 @@ def test_get_mcp_config_transforms_sse_servers(self, mock_workspace): mock_req.assert_called_once_with( "GET", f"{CLOUD_URL}/api/v1/users/me", + params={"expose_secrets": "true"}, headers={"X-Session-API-Key": SESSION_KEY}, ) From e56a07a34f0ed60c0a4c656adaf62306637da7d4 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 4 May 2026 15:58:49 +0000 Subject: [PATCH 2/2] fix: format test file to pass ruff-format Co-authored-by: openhands --- tests/workspace/test_cloud_workspace_sdk_settings.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/workspace/test_cloud_workspace_sdk_settings.py b/tests/workspace/test_cloud_workspace_sdk_settings.py index 628812b0d6..5cfbe60262 100644 --- a/tests/workspace/test_cloud_workspace_sdk_settings.py +++ b/tests/workspace/test_cloud_workspace_sdk_settings.py @@ -249,8 +249,14 @@ def test_get_mcp_config_passthrough_mcpservers_format(self, mock_workspace): # Should pass through the mcpServers format directly assert "mcpServers" in mcp_config assert len(mcp_config["mcpServers"]) == 2 - assert mcp_config["mcpServers"]["hubspot"]["url"] == "https://mcp.example.com/hubspot" - assert mcp_config["mcpServers"]["hubspot"]["headers"]["Authorization"] == "Bearer secret-key" + assert ( + mcp_config["mcpServers"]["hubspot"]["url"] + == "https://mcp.example.com/hubspot" + ) + assert ( + mcp_config["mcpServers"]["hubspot"]["headers"]["Authorization"] + == "Bearer secret-key" + ) assert mcp_config["mcpServers"]["slack"]["transport"] == "sse" def test_get_mcp_config_transforms_sse_servers(self, mock_workspace):