From 4815327a44a41679b0e57b5f8a6f4891303d0b08 Mon Sep 17 00:00:00 2001 From: naaa760 Date: Thu, 9 Apr 2026 23:27:30 +0530 Subject: [PATCH 1/3] merge_mcp_configs, add_mcp_config_to refactor --- .../openhands/sdk/mcp/project_config.py | 38 ++++++++++++++ openhands-sdk/openhands/sdk/plugin/plugin.py | 52 ++++++++++++------- 2 files changed, 71 insertions(+), 19 deletions(-) create mode 100644 openhands-sdk/openhands/sdk/mcp/project_config.py diff --git a/openhands-sdk/openhands/sdk/mcp/project_config.py b/openhands-sdk/openhands/sdk/mcp/project_config.py new file mode 100644 index 0000000000..37689ff187 --- /dev/null +++ b/openhands-sdk/openhands/sdk/mcp/project_config.py @@ -0,0 +1,38 @@ +"""Project-level .mcp.json discovery and loading.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from openhands.sdk.context.skills.exceptions import SkillValidationError +from openhands.sdk.context.skills.utils import load_mcp_config +from openhands.sdk.logger import get_logger + +logger = get_logger(__name__) + + +def find_project_mcp_json(project_dir: Path) -> Path | None: + """Return the first project MCP config path if present. + + Preference order: ``.openhands/.mcp.json``, then root ``.mcp.json``. + """ + for candidate in ( + project_dir / ".openhands" / ".mcp.json", + project_dir / ".mcp.json", + ): + if candidate.is_file(): + return candidate + return None + + +def try_load_project_mcp_config(project_dir: Path) -> dict[str, Any] | None: + """Load and validate project ``.mcp.json``, or return None if missing or invalid.""" + path = find_project_mcp_json(project_dir) + if path is None: + return None + try: + return load_mcp_config(path, skill_root=project_dir) + except SkillValidationError as e: + logger.warning("Ignoring invalid project MCP config at %s: %s", path, e) + return None diff --git a/openhands-sdk/openhands/sdk/plugin/plugin.py b/openhands-sdk/openhands/sdk/plugin/plugin.py index dfd47fa777..075a26071e 100644 --- a/openhands-sdk/openhands/sdk/plugin/plugin.py +++ b/openhands-sdk/openhands/sdk/plugin/plugin.py @@ -30,6 +30,32 @@ logger = get_logger(__name__) + +def merge_mcp_configs( + base: dict[str, Any] | None, + overlay: dict[str, Any] | None, +) -> dict[str, Any]: + """Merge two MCP config dicts; overlay wins on key conflicts.""" + if base is None and overlay is None: + return {} + if base is None: + return dict(overlay) if overlay is not None else {} + if overlay is None: + return dict(base) + + result = dict(base) + if "mcpServers" in overlay: + existing_servers = result.get("mcpServers", {}) + result["mcpServers"] = { + **existing_servers, + **overlay["mcpServers"], + } + for key, value in overlay.items(): + if key != "mcpServers": + result[key] = value + return result + + # Directories to check for plugin manifest PLUGIN_MANIFEST_DIRS = [".plugin", ".claude-plugin"] PLUGIN_MANIFEST_FILE = "plugin.json" @@ -201,36 +227,24 @@ def add_mcp_config_to( if base_config is None and plugin_config is None: return {} if base_config is None: - return dict(plugin_config) if plugin_config else {} + return dict(plugin_config) if plugin_config is not None else {} if plugin_config is None: return dict(base_config) - # Shallow copy to avoid mutating inputs - result = dict(base_config) - - # Merge mcpServers by server name (Claude Code compatible behavior) if "mcpServers" in plugin_config: - existing_servers = result.get("mcpServers", {}) + existing_servers = base_config.get("mcpServers", {}) for server_name in plugin_config["mcpServers"]: if server_name in existing_servers: logger.warning( f"Plugin MCP server '{server_name}' overrides existing server" ) - result["mcpServers"] = { - **existing_servers, - **plugin_config["mcpServers"], - } - - # Other top-level keys: plugin wins (shallow override) for key, value in plugin_config.items(): - if key != "mcpServers": - if key in result: - logger.warning( - f"Plugin MCP config key '{key}' overrides existing value" - ) - result[key] = value + if key != "mcpServers" and key in base_config: + logger.warning( + f"Plugin MCP config key '{key}' overrides existing value" + ) - return result + return merge_mcp_configs(base_config, plugin_config) @classmethod def fetch( From 3612cad0d46549a2696ee776442b0f3fb51ce0bb Mon Sep 17 00:00:00 2001 From: naaa760 Date: Thu, 9 Apr 2026 23:28:10 +0530 Subject: [PATCH 2/3] exports merge_mcp_configs --- openhands-sdk/openhands/sdk/plugin/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openhands-sdk/openhands/sdk/plugin/__init__.py b/openhands-sdk/openhands/sdk/plugin/__init__.py index 7b67494994..7d13cb8d7a 100644 --- a/openhands-sdk/openhands/sdk/plugin/__init__.py +++ b/openhands-sdk/openhands/sdk/plugin/__init__.py @@ -28,7 +28,7 @@ update_plugin, ) from openhands.sdk.plugin.loader import load_plugins -from openhands.sdk.plugin.plugin import Plugin +from openhands.sdk.plugin.plugin import Plugin, merge_mcp_configs from openhands.sdk.plugin.source import ( GitHubURLComponents, is_local_path, @@ -54,6 +54,7 @@ __all__ = [ # Plugin classes "Plugin", + "merge_mcp_configs", "PluginFetchError", "PluginManifest", "PluginAuthor", From ab940437dbb7a1089c0b12a4b384d4fdcfa6d07d Mon Sep 17 00:00:00 2001 From: naaa760 Date: Thu, 9 Apr 2026 23:30:04 +0530 Subject: [PATCH 3/3] feat(sdk): auto-discover project .mcp.json with trust flag --- .../sdk/conversation/conversation.py | 3 + .../conversation/impl/local_conversation.py | 24 ++- tests/sdk/mcp/test_project_mcp_config.py | 162 ++++++++++++++++++ 3 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 tests/sdk/mcp/test_project_mcp_config.py diff --git a/openhands-sdk/openhands/sdk/conversation/conversation.py b/openhands-sdk/openhands/sdk/conversation/conversation.py index 0c9e51a386..5c64820124 100644 --- a/openhands-sdk/openhands/sdk/conversation/conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/conversation.py @@ -80,6 +80,7 @@ def __new__( secrets: dict[str, SecretValue] | dict[str, str] | None = None, delete_on_close: bool = True, tags: dict[str, str] | None = None, + trust_project_mcp: bool = False, ) -> "LocalConversation": ... @overload @@ -128,6 +129,7 @@ def __new__( secrets: dict[str, SecretValue] | dict[str, str] | None = None, delete_on_close: bool = True, tags: dict[str, str] | None = None, + trust_project_mcp: bool = False, ) -> BaseConversation: from openhands.sdk.conversation.impl.local_conversation import LocalConversation from openhands.sdk.conversation.impl.remote_conversation import ( @@ -199,4 +201,5 @@ def __new__( secrets=secrets, delete_on_close=delete_on_close, tags=tags, + trust_project_mcp=trust_project_mcp, ) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 99c794d1ff..e96a56d266 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -42,11 +42,13 @@ from openhands.sdk.llm.llm_registry import LLMRegistry from openhands.sdk.logger import get_logger from openhands.sdk.observability.laminar import observe +from openhands.sdk.mcp.project_config import try_load_project_mcp_config from openhands.sdk.plugin import ( Plugin, PluginSource, ResolvedPluginSource, fetch_plugin_with_resolution, + merge_mcp_configs, ) from openhands.sdk.security.analyzer import SecurityAnalyzerBase from openhands.sdk.security.confirmation_policy import ( @@ -78,6 +80,7 @@ class LocalConversation(BaseConversation): _cleanup_initiated: bool _hook_processor: HookEventProcessor | None delete_on_close: bool = True + _trust_project_mcp: bool # Plugin lazy loading state _plugin_specs: list[PluginSource] | None _resolved_plugins: list[ResolvedPluginSource] | None @@ -106,6 +109,7 @@ def __init__( delete_on_close: bool = True, cipher: Cipher | None = None, tags: dict[str, str] | None = None, + trust_project_mcp: bool = False, **_: object, ): """Initialize the conversation. @@ -147,6 +151,10 @@ def __init__( (lost) on serialization. tags: Optional key-value tags for the conversation. Keys must be lowercase alphanumeric, values up to 256 characters. + trust_project_mcp: When True, load ``.openhands/.mcp.json`` or root + ``.mcp.json`` from the workspace and merge under user/agent MCP + settings. UIs should set this only after the user approves + project-scoped servers. """ super().__init__() # Initialize with span tracking # Mark cleanup as initiated as early as possible to avoid races or partially @@ -160,6 +168,7 @@ def __init__( self._plugins_loaded = False self._pending_hook_config = hook_config # Will be combined with plugin hooks self._agent_ready = False # Agent initialized lazily after plugins loaded + self._trust_project_mcp = trust_project_mcp self.agent = agent if isinstance(workspace, (str, Path)): @@ -328,14 +337,20 @@ def _ensure_plugins_loaded(self) -> None: all_plugin_hooks: list[HookConfig] = [] all_plugin_agents: list[AgentDefinition] = [] + project_dir = Path(self.workspace.working_dir) + project_mcp = ( + try_load_project_mcp_config(project_dir) + if self._trust_project_mcp + else None + ) + merged_mcp = merge_mcp_configs(project_mcp, self.agent.mcp_config) + # Load plugins if specified if self._plugin_specs: logger.info(f"Loading {len(self._plugin_specs)} plugin(s)...") self._resolved_plugins = [] - # Start with agent's existing context and MCP config merged_context = self.agent.agent_context - merged_mcp = dict(self.agent.mcp_config) if self.agent.mcp_config else {} for spec in self._plugin_specs: # Fetch plugin and get resolved commit SHA @@ -382,6 +397,11 @@ def _ensure_plugins_loaded(self) -> None: logger.info(f"Loaded {len(self._plugin_specs)} plugin(s) via Conversation") + elif merged_mcp != self.agent.mcp_config: + self.agent = self.agent.model_copy(update={"mcp_config": merged_mcp}) + with self._state: + self._state.agent = self.agent + # Register file-based agents defined in plugins if all_plugin_agents: register_plugin_agents( diff --git a/tests/sdk/mcp/test_project_mcp_config.py b/tests/sdk/mcp/test_project_mcp_config.py new file mode 100644 index 0000000000..8f926b0755 --- /dev/null +++ b/tests/sdk/mcp/test_project_mcp_config.py @@ -0,0 +1,162 @@ +"""Tests for project-level .mcp.json discovery and merge behavior.""" + +import json +from pathlib import Path + +import pytest +from pydantic import SecretStr + +from openhands.sdk import Agent, LLM +from openhands.sdk.conversation.impl.local_conversation import LocalConversation +from openhands.sdk.mcp.project_config import find_project_mcp_json, try_load_project_mcp_config +from openhands.sdk.plugin import PluginSource, merge_mcp_configs + + +def _minimal_mcp_file() -> dict: + return {"mcpServers": {"proj": {"command": "echo", "args": ["mcp"]}}} + + +def test_find_project_mcp_json_prefers_openhands_dir(tmp_path: Path) -> None: + root = tmp_path / "ws" + root.mkdir() + (root / ".mcp.json").write_text(json.dumps(_minimal_mcp_file())) + oh = root / ".openhands" + oh.mkdir() + preferred = _minimal_mcp_file() + preferred["mcpServers"]["proj"]["args"] = ["preferred"] + (oh / ".mcp.json").write_text(json.dumps(preferred)) + + found = find_project_mcp_json(root) + assert found == oh / ".mcp.json" + + +def test_find_project_mcp_json_falls_back_to_root(tmp_path: Path) -> None: + root = tmp_path / "ws" + root.mkdir() + (root / ".mcp.json").write_text(json.dumps(_minimal_mcp_file())) + + assert find_project_mcp_json(root) == root / ".mcp.json" + + +def test_merge_mcp_configs_overlay_wins() -> None: + base = {"mcpServers": {"a": {"command": "x"}, "b": {"command": "y"}}} + overlay = {"mcpServers": {"a": {"command": "z"}}} + merged = merge_mcp_configs(base, overlay) + assert merged["mcpServers"]["a"]["command"] == "z" + assert merged["mcpServers"]["b"]["command"] == "y" + + +@pytest.fixture +def mock_llm(): + return LLM(model="test/model", api_key=SecretStr("test-key")) + + +@pytest.fixture +def basic_agent(mock_llm): + return Agent(llm=mock_llm, tools=[]) + + +def test_trust_project_mcp_merges_under_user_config( + tmp_path: Path, basic_agent: Agent, monkeypatch: pytest.MonkeyPatch +) -> None: + ws = tmp_path / "ws" + ws.mkdir() + (ws / ".mcp.json").write_text(json.dumps(_minimal_mcp_file())) + + agent = basic_agent.model_copy( + update={ + "mcp_config": { + "mcpServers": {"proj": {"command": "user-wins", "args": []}}, + } + } + ) + + monkeypatch.setattr( + "openhands.sdk.agent.base.create_mcp_tools", + lambda config, timeout: [], + ) + + conv = LocalConversation( + agent=agent, + workspace=ws, + visualizer=None, + trust_project_mcp=True, + ) + conv._ensure_agent_ready() + + assert conv.agent.mcp_config["mcpServers"]["proj"]["command"] == "user-wins" + conv.close() + + +def test_project_mcp_skipped_without_trust(tmp_path: Path, basic_agent: Agent) -> None: + ws = tmp_path / "ws" + ws.mkdir() + (ws / ".mcp.json").write_text(json.dumps(_minimal_mcp_file())) + + conv = LocalConversation( + agent=basic_agent, + workspace=ws, + visualizer=None, + trust_project_mcp=False, + ) + conv._ensure_plugins_loaded() + + assert conv.agent.mcp_config == {} + conv.close() + + +def test_project_mcp_layer_before_plugin( + tmp_path: Path, basic_agent: Agent, monkeypatch: pytest.MonkeyPatch +) -> None: + ws = tmp_path / "ws" + ws.mkdir() + (ws / ".mcp.json").write_text( + json.dumps({"mcpServers": {"shared": {"command": "from-project"}}}) + ) + + plugin_dir = tmp_path / "plugin" + manifest_dir = plugin_dir / ".plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text( + json.dumps( + { + "name": "t", + "version": "1.0.0", + "description": "d", + } + ) + ) + (plugin_dir / ".mcp.json").write_text( + json.dumps({"mcpServers": {"shared": {"command": "from-plugin"}}}) + ) + + monkeypatch.setattr( + "openhands.sdk.agent.base.create_mcp_tools", + lambda config, timeout: [], + ) + + conv = LocalConversation( + agent=basic_agent, + workspace=ws, + plugins=[PluginSource(source=str(plugin_dir))], + visualizer=None, + trust_project_mcp=True, + ) + conv._ensure_agent_ready() + + assert conv.agent.mcp_config["mcpServers"]["shared"]["command"] == "from-plugin" + conv.close() + + +def test_try_load_project_mcp_config_invalid_json_logs( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + ws = tmp_path / "ws" + ws.mkdir() + (ws / ".mcp.json").write_text("not json") + + import logging + + with caplog.at_level(logging.WARNING): + assert try_load_project_mcp_config(ws) is None + assert "Ignoring invalid project MCP config" in caplog.text