Skip to content

feat(sdk): project-level .mcp.json discovery and merge#2772

Open
nehaaprasad wants to merge 3 commits intoOpenHands:mainfrom
nehaaprasad:feat/aut-dis-prj-mcp-json
Open

feat(sdk): project-level .mcp.json discovery and merge#2772
nehaaprasad wants to merge 3 commits intoOpenHands:mainfrom
nehaaprasad:feat/aut-dis-prj-mcp-json

Conversation

@nehaaprasad
Copy link
Copy Markdown

why

  • Share MCP server config in the repo so it applies to conversations in that project.

Summary

  • Load .openhands/.mcp.json or .mcp.json when trust_project_mcp=True.
  • Merge order: project → user/agent → plugins (later wins on conflicts).

How to Test

  • uv run pytest tests/sdk/mcp/test_project_mcp_config.py

Type

[x] Feature

Notes

  • trust_project_mcp defaults to false; turn it on only after the user approves.

fix : #2754

@nehaaprasad nehaaprasad changed the title Feat/aut dis prj mcp json feat(sdk): project-level .mcp.json discovery and merge Apr 9, 2026
Copy link
Copy Markdown
Contributor

@VascoSch92 VascoSch92 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @nehaaprasad

thanks for your contribution.

I left some comment.

Moreover, Env variable expansion is missing. The issue explicitly requires ${VAR} and ${VAR:-default} syntax support, but try_load_project_mcp_config doesn't expand env variables in the loaded config.

The good news is this already exists in the codebase: load_mcp_config() in skills/utils.py calls expand_mcp_variables() (line 144), which handles both ${VAR} (checks provided variables, then os.environ) and ${VAR:-default} fallbacks.

Therefore, you can reuse load_mcp_config() inside try_load_project_mcp_config instead of loading the JSON manually, this should give you env var expansion, validation via MCPConfig, and consistent error handling for free.

Comment on lines +156 to +157
settings. UIs should set this only after the user approves
project-scoped servers.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
settings. UIs should set this only after the user approves
project-scoped servers.
settings.

all_plugin_hooks: list[HookConfig] = []
all_plugin_agents: list[AgentDefinition] = []

project_dir = Path(self.workspace.working_dir)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_ensure_plugins_loaded is about loading plugins, discovering and merging project-level MCP config is a separate concern that happens to run at the same time.

Can you move that into another method ?

Something like

def _load_project_mcp_config(self) -> dict[str, Any] | None:               
      """Load project .mcp.json if trusted, return merged config or None."""                                                                                                                                         
      if not self._trust_project_mcp:                                        
          return None                                                                                                                                                                                                
      project_dir = Path(self.workspace.working_dir)                         
      project_mcp = try_load_project_mcp_config(project_dir)                                                                                                                                                         
      if project_mcp is None:                                                                                                                                                                                        
          return None
      merged = merge_mcp_configs(project_mcp, self.agent.mcp_config)                                                                                                                                                 
      if merged != self.agent.mcp_config:                                    
          self.agent = self.agent.model_copy(update={"mcp_config": merged})                                                                                                                                          
          with self._state:                                                                                                                                                                                          
              self._state.agent = self.agent                                                                                                                                                                         
      return merged

and then you can call it before _ensure_plugins_loaded() in the _ensure_agent_ready method

@@ -0,0 +1,38 @@
"""Project-level .mcp.json discovery and loading."""

from __future__ import annotations
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you don't need this import.

Comment on lines +21 to +22
project_dir / ".openhands" / ".mcp.json",
project_dir / ".mcp.json",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be constant at the top of the file.

_PROJECT_MCP_CANDIDATES: Final[tuple[str]] = (".openhands/.mcp.json", ".mcp.json")

logger = get_logger(__name__)


def find_project_mcp_json(project_dir: Path) -> Path | None:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

private method?

return None


def try_load_project_mcp_config(project_dir: Path) -> dict[str, Any] | None:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def try_load_project_mcp_config(project_dir: Path) -> dict[str, Any] | None:
def load_project_mcp_config(project_dir: Path) -> dict[str, Any] | None:

just make clear in the docstring that it returns None if there is a problem

logger = get_logger(__name__)


def merge_mcp_configs(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this method in another place. It is a general MCP utility. Perhaps something like openhands/sdk/mcp/merge.py

Comment on lines +39 to +45
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can write that with a match :-)

match (base, overlay):
      case (None, None):
          return {}                                                                                                                                                                                                  
      case (None, _):
          return dict(overlay)                                                                                                                                                                                       
      case (_, None):                                                        
          return dict(base)

if overlay is None:
return dict(base)

result = dict(base)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things:

  1. The loop at the end skips "mcpServers" but overwrites every other key — that's just result.update() with one exclusion. You can simplify by merging everything first, then deep-merging mcpServers separately.
  2. With the match rewrite:
  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."""
      match (base, overlay):                                                                                                                                                                                         
          case (None, None):                                                 
              return {}
          case (None, _):
              return dict(overlay)                                                                                                                                                                                   
          case (_, None):
              return dict(base)                                                                                                                                                                                      
                                                                             
      result = {**base, **overlay}
      if "mcpServers" in base and "mcpServers" in overlay:
          result["mcpServers"] = {**base["mcpServers"], **overlay["mcpServers"]}                                                                                                                                     
      return result

The {**base, **overlay} handles all top-level keys (overlay wins). Then the mcpServers fix-up deep-merges server entries instead of letting overlay completely replace the dict. If only one side has mcpServers, the spread already did the right thing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use TestLLM instead of LLM with SecretStr.

The tests create LLM(model="test/model", api_key=SecretStr("test-key")) which is fragile — it may try to resolve model config or validate against real providers. TestLLM exists exactly for this and is already used in similar LocalConversation integration tests (test_switch_model.py, test_agent_status_transition.py).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Auto-discover project-scoped .mcp.json for MCP server configuration

2 participants