Skip to content

Commit ab94043

Browse files
committed
feat(sdk): auto-discover project .mcp.json with trust flag
1 parent 3612cad commit ab94043

File tree

3 files changed

+187
-2
lines changed

3 files changed

+187
-2
lines changed

openhands-sdk/openhands/sdk/conversation/conversation.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def __new__(
8080
secrets: dict[str, SecretValue] | dict[str, str] | None = None,
8181
delete_on_close: bool = True,
8282
tags: dict[str, str] | None = None,
83+
trust_project_mcp: bool = False,
8384
) -> "LocalConversation": ...
8485

8586
@overload
@@ -128,6 +129,7 @@ def __new__(
128129
secrets: dict[str, SecretValue] | dict[str, str] | None = None,
129130
delete_on_close: bool = True,
130131
tags: dict[str, str] | None = None,
132+
trust_project_mcp: bool = False,
131133
) -> BaseConversation:
132134
from openhands.sdk.conversation.impl.local_conversation import LocalConversation
133135
from openhands.sdk.conversation.impl.remote_conversation import (
@@ -199,4 +201,5 @@ def __new__(
199201
secrets=secrets,
200202
delete_on_close=delete_on_close,
201203
tags=tags,
204+
trust_project_mcp=trust_project_mcp,
202205
)

openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,13 @@
4242
from openhands.sdk.llm.llm_registry import LLMRegistry
4343
from openhands.sdk.logger import get_logger
4444
from openhands.sdk.observability.laminar import observe
45+
from openhands.sdk.mcp.project_config import try_load_project_mcp_config
4546
from openhands.sdk.plugin import (
4647
Plugin,
4748
PluginSource,
4849
ResolvedPluginSource,
4950
fetch_plugin_with_resolution,
51+
merge_mcp_configs,
5052
)
5153
from openhands.sdk.security.analyzer import SecurityAnalyzerBase
5254
from openhands.sdk.security.confirmation_policy import (
@@ -78,6 +80,7 @@ class LocalConversation(BaseConversation):
7880
_cleanup_initiated: bool
7981
_hook_processor: HookEventProcessor | None
8082
delete_on_close: bool = True
83+
_trust_project_mcp: bool
8184
# Plugin lazy loading state
8285
_plugin_specs: list[PluginSource] | None
8386
_resolved_plugins: list[ResolvedPluginSource] | None
@@ -106,6 +109,7 @@ def __init__(
106109
delete_on_close: bool = True,
107110
cipher: Cipher | None = None,
108111
tags: dict[str, str] | None = None,
112+
trust_project_mcp: bool = False,
109113
**_: object,
110114
):
111115
"""Initialize the conversation.
@@ -147,6 +151,10 @@ def __init__(
147151
(lost) on serialization.
148152
tags: Optional key-value tags for the conversation. Keys must be
149153
lowercase alphanumeric, values up to 256 characters.
154+
trust_project_mcp: When True, load ``.openhands/.mcp.json`` or root
155+
``.mcp.json`` from the workspace and merge under user/agent MCP
156+
settings. UIs should set this only after the user approves
157+
project-scoped servers.
150158
"""
151159
super().__init__() # Initialize with span tracking
152160
# Mark cleanup as initiated as early as possible to avoid races or partially
@@ -160,6 +168,7 @@ def __init__(
160168
self._plugins_loaded = False
161169
self._pending_hook_config = hook_config # Will be combined with plugin hooks
162170
self._agent_ready = False # Agent initialized lazily after plugins loaded
171+
self._trust_project_mcp = trust_project_mcp
163172

164173
self.agent = agent
165174
if isinstance(workspace, (str, Path)):
@@ -328,14 +337,20 @@ def _ensure_plugins_loaded(self) -> None:
328337
all_plugin_hooks: list[HookConfig] = []
329338
all_plugin_agents: list[AgentDefinition] = []
330339

340+
project_dir = Path(self.workspace.working_dir)
341+
project_mcp = (
342+
try_load_project_mcp_config(project_dir)
343+
if self._trust_project_mcp
344+
else None
345+
)
346+
merged_mcp = merge_mcp_configs(project_mcp, self.agent.mcp_config)
347+
331348
# Load plugins if specified
332349
if self._plugin_specs:
333350
logger.info(f"Loading {len(self._plugin_specs)} plugin(s)...")
334351
self._resolved_plugins = []
335352

336-
# Start with agent's existing context and MCP config
337353
merged_context = self.agent.agent_context
338-
merged_mcp = dict(self.agent.mcp_config) if self.agent.mcp_config else {}
339354

340355
for spec in self._plugin_specs:
341356
# Fetch plugin and get resolved commit SHA
@@ -382,6 +397,11 @@ def _ensure_plugins_loaded(self) -> None:
382397

383398
logger.info(f"Loaded {len(self._plugin_specs)} plugin(s) via Conversation")
384399

400+
elif merged_mcp != self.agent.mcp_config:
401+
self.agent = self.agent.model_copy(update={"mcp_config": merged_mcp})
402+
with self._state:
403+
self._state.agent = self.agent
404+
385405
# Register file-based agents defined in plugins
386406
if all_plugin_agents:
387407
register_plugin_agents(
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Tests for project-level .mcp.json discovery and merge behavior."""
2+
3+
import json
4+
from pathlib import Path
5+
6+
import pytest
7+
from pydantic import SecretStr
8+
9+
from openhands.sdk import Agent, LLM
10+
from openhands.sdk.conversation.impl.local_conversation import LocalConversation
11+
from openhands.sdk.mcp.project_config import find_project_mcp_json, try_load_project_mcp_config
12+
from openhands.sdk.plugin import PluginSource, merge_mcp_configs
13+
14+
15+
def _minimal_mcp_file() -> dict:
16+
return {"mcpServers": {"proj": {"command": "echo", "args": ["mcp"]}}}
17+
18+
19+
def test_find_project_mcp_json_prefers_openhands_dir(tmp_path: Path) -> None:
20+
root = tmp_path / "ws"
21+
root.mkdir()
22+
(root / ".mcp.json").write_text(json.dumps(_minimal_mcp_file()))
23+
oh = root / ".openhands"
24+
oh.mkdir()
25+
preferred = _minimal_mcp_file()
26+
preferred["mcpServers"]["proj"]["args"] = ["preferred"]
27+
(oh / ".mcp.json").write_text(json.dumps(preferred))
28+
29+
found = find_project_mcp_json(root)
30+
assert found == oh / ".mcp.json"
31+
32+
33+
def test_find_project_mcp_json_falls_back_to_root(tmp_path: Path) -> None:
34+
root = tmp_path / "ws"
35+
root.mkdir()
36+
(root / ".mcp.json").write_text(json.dumps(_minimal_mcp_file()))
37+
38+
assert find_project_mcp_json(root) == root / ".mcp.json"
39+
40+
41+
def test_merge_mcp_configs_overlay_wins() -> None:
42+
base = {"mcpServers": {"a": {"command": "x"}, "b": {"command": "y"}}}
43+
overlay = {"mcpServers": {"a": {"command": "z"}}}
44+
merged = merge_mcp_configs(base, overlay)
45+
assert merged["mcpServers"]["a"]["command"] == "z"
46+
assert merged["mcpServers"]["b"]["command"] == "y"
47+
48+
49+
@pytest.fixture
50+
def mock_llm():
51+
return LLM(model="test/model", api_key=SecretStr("test-key"))
52+
53+
54+
@pytest.fixture
55+
def basic_agent(mock_llm):
56+
return Agent(llm=mock_llm, tools=[])
57+
58+
59+
def test_trust_project_mcp_merges_under_user_config(
60+
tmp_path: Path, basic_agent: Agent, monkeypatch: pytest.MonkeyPatch
61+
) -> None:
62+
ws = tmp_path / "ws"
63+
ws.mkdir()
64+
(ws / ".mcp.json").write_text(json.dumps(_minimal_mcp_file()))
65+
66+
agent = basic_agent.model_copy(
67+
update={
68+
"mcp_config": {
69+
"mcpServers": {"proj": {"command": "user-wins", "args": []}},
70+
}
71+
}
72+
)
73+
74+
monkeypatch.setattr(
75+
"openhands.sdk.agent.base.create_mcp_tools",
76+
lambda config, timeout: [],
77+
)
78+
79+
conv = LocalConversation(
80+
agent=agent,
81+
workspace=ws,
82+
visualizer=None,
83+
trust_project_mcp=True,
84+
)
85+
conv._ensure_agent_ready()
86+
87+
assert conv.agent.mcp_config["mcpServers"]["proj"]["command"] == "user-wins"
88+
conv.close()
89+
90+
91+
def test_project_mcp_skipped_without_trust(tmp_path: Path, basic_agent: Agent) -> None:
92+
ws = tmp_path / "ws"
93+
ws.mkdir()
94+
(ws / ".mcp.json").write_text(json.dumps(_minimal_mcp_file()))
95+
96+
conv = LocalConversation(
97+
agent=basic_agent,
98+
workspace=ws,
99+
visualizer=None,
100+
trust_project_mcp=False,
101+
)
102+
conv._ensure_plugins_loaded()
103+
104+
assert conv.agent.mcp_config == {}
105+
conv.close()
106+
107+
108+
def test_project_mcp_layer_before_plugin(
109+
tmp_path: Path, basic_agent: Agent, monkeypatch: pytest.MonkeyPatch
110+
) -> None:
111+
ws = tmp_path / "ws"
112+
ws.mkdir()
113+
(ws / ".mcp.json").write_text(
114+
json.dumps({"mcpServers": {"shared": {"command": "from-project"}}})
115+
)
116+
117+
plugin_dir = tmp_path / "plugin"
118+
manifest_dir = plugin_dir / ".plugin"
119+
manifest_dir.mkdir(parents=True)
120+
(manifest_dir / "plugin.json").write_text(
121+
json.dumps(
122+
{
123+
"name": "t",
124+
"version": "1.0.0",
125+
"description": "d",
126+
}
127+
)
128+
)
129+
(plugin_dir / ".mcp.json").write_text(
130+
json.dumps({"mcpServers": {"shared": {"command": "from-plugin"}}})
131+
)
132+
133+
monkeypatch.setattr(
134+
"openhands.sdk.agent.base.create_mcp_tools",
135+
lambda config, timeout: [],
136+
)
137+
138+
conv = LocalConversation(
139+
agent=basic_agent,
140+
workspace=ws,
141+
plugins=[PluginSource(source=str(plugin_dir))],
142+
visualizer=None,
143+
trust_project_mcp=True,
144+
)
145+
conv._ensure_agent_ready()
146+
147+
assert conv.agent.mcp_config["mcpServers"]["shared"]["command"] == "from-plugin"
148+
conv.close()
149+
150+
151+
def test_try_load_project_mcp_config_invalid_json_logs(
152+
tmp_path: Path, caplog: pytest.LogCaptureFixture
153+
) -> None:
154+
ws = tmp_path / "ws"
155+
ws.mkdir()
156+
(ws / ".mcp.json").write_text("not json")
157+
158+
import logging
159+
160+
with caplog.at_level(logging.WARNING):
161+
assert try_load_project_mcp_config(ws) is None
162+
assert "Ignoring invalid project MCP config" in caplog.text

0 commit comments

Comments
 (0)