openhands-sdk 1.9.1__py3-none-any.whl → 1.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. openhands/sdk/agent/agent.py +54 -13
  2. openhands/sdk/agent/base.py +32 -45
  3. openhands/sdk/context/condenser/llm_summarizing_condenser.py +0 -23
  4. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +1 -5
  5. openhands/sdk/context/view.py +108 -122
  6. openhands/sdk/conversation/__init__.py +2 -0
  7. openhands/sdk/conversation/conversation.py +13 -3
  8. openhands/sdk/conversation/exceptions.py +18 -0
  9. openhands/sdk/conversation/impl/local_conversation.py +192 -23
  10. openhands/sdk/conversation/impl/remote_conversation.py +141 -12
  11. openhands/sdk/critic/impl/api/critic.py +10 -7
  12. openhands/sdk/event/condenser.py +52 -2
  13. openhands/sdk/git/cached_repo.py +19 -0
  14. openhands/sdk/hooks/__init__.py +2 -0
  15. openhands/sdk/hooks/config.py +44 -4
  16. openhands/sdk/hooks/executor.py +2 -1
  17. openhands/sdk/llm/llm.py +47 -13
  18. openhands/sdk/llm/message.py +65 -27
  19. openhands/sdk/llm/options/chat_options.py +2 -1
  20. openhands/sdk/mcp/client.py +53 -6
  21. openhands/sdk/mcp/tool.py +24 -21
  22. openhands/sdk/mcp/utils.py +31 -23
  23. openhands/sdk/plugin/__init__.py +12 -1
  24. openhands/sdk/plugin/fetch.py +118 -14
  25. openhands/sdk/plugin/loader.py +111 -0
  26. openhands/sdk/plugin/plugin.py +155 -13
  27. openhands/sdk/plugin/types.py +163 -1
  28. openhands/sdk/utils/__init__.py +2 -0
  29. openhands/sdk/utils/async_utils.py +36 -1
  30. openhands/sdk/utils/command.py +28 -1
  31. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.10.0.dist-info}/METADATA +1 -1
  32. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.10.0.dist-info}/RECORD +34 -33
  33. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.10.0.dist-info}/WHEEL +1 -1
  34. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.10.0.dist-info}/top_level.txt +0 -0
@@ -10,7 +10,6 @@ from openhands.sdk.logger import get_logger
10
10
  from openhands.sdk.mcp.client import MCPClient
11
11
  from openhands.sdk.mcp.exceptions import MCPTimeoutError
12
12
  from openhands.sdk.mcp.tool import MCPToolDefinition
13
- from openhands.sdk.tool.tool import ToolDefinition
14
13
 
15
14
 
16
15
  logger = get_logger(__name__)
@@ -32,37 +31,38 @@ async def log_handler(message: LogMessage):
32
31
  logger.log(level, msg, extra=extra)
33
32
 
34
33
 
35
- async def _list_tools(client: MCPClient) -> list[ToolDefinition]:
36
- """List tools from an MCP client."""
37
- tools: list[ToolDefinition] = []
38
-
39
- async with client:
40
- assert client.is_connected(), "MCP client is not connected."
41
- mcp_type_tools: list[mcp.types.Tool] = await client.list_tools()
42
- for mcp_tool in mcp_type_tools:
43
- tool_sequence = MCPToolDefinition.create(
44
- mcp_tool=mcp_tool, mcp_client=client
45
- )
46
- tools.extend(tool_sequence) # Flatten sequence into list
47
- assert not client.is_connected(), (
48
- "MCP client should be disconnected after listing tools."
49
- )
50
- return tools
34
+ async def _connect_and_list_tools(client: MCPClient) -> None:
35
+ """Connect to MCP server and populate client._tools."""
36
+ await client.connect()
37
+ mcp_type_tools: list[mcp.types.Tool] = await client.list_tools()
38
+ for mcp_tool in mcp_type_tools:
39
+ tool_sequence = MCPToolDefinition.create(mcp_tool=mcp_tool, mcp_client=client)
40
+ client._tools.extend(tool_sequence)
51
41
 
52
42
 
53
43
  def create_mcp_tools(
54
44
  config: dict | MCPConfig,
55
45
  timeout: float = 30.0,
56
- ) -> list[MCPToolDefinition]:
57
- """Create MCP tools from MCP configuration."""
58
- tools: list[MCPToolDefinition] = []
46
+ ) -> MCPClient:
47
+ """Create MCP tools from MCP configuration.
48
+
49
+ Returns an MCPClient with tools populated. Use as a context manager:
50
+
51
+ with create_mcp_tools(config) as client:
52
+ for tool in client.tools:
53
+ # use tool
54
+ # Connection automatically closed
55
+ """
59
56
  if isinstance(config, dict):
60
57
  config = MCPConfig.model_validate(config)
61
58
  client = MCPClient(config, log_handler=log_handler)
62
59
 
63
60
  try:
64
- tools = client.call_async_from_sync(_list_tools, timeout=timeout, client=client)
61
+ client.call_async_from_sync(
62
+ _connect_and_list_tools, timeout=timeout, client=client
63
+ )
65
64
  except TimeoutError as e:
65
+ client.sync_close()
66
66
  # Extract server names from config for better error message
67
67
  server_names = (
68
68
  list(config.mcpServers.keys()) if config.mcpServers else ["unknown"]
@@ -78,6 +78,14 @@ def create_mcp_tools(
78
78
  raise MCPTimeoutError(
79
79
  error_msg, timeout=timeout, config=config.model_dump()
80
80
  ) from e
81
+ except BaseException:
82
+ try:
83
+ client.sync_close()
84
+ except Exception as close_exc:
85
+ logger.warning(
86
+ "Failed to close MCP client during error cleanup", exc_info=close_exc
87
+ )
88
+ raise
81
89
 
82
- logger.info(f"Created {len(tools)} MCP tools: {[t.name for t in tools]}")
83
- return tools
90
+ logger.info(f"Created {len(client.tools)} MCP tools: {[t.name for t in client]}")
91
+ return client
@@ -7,7 +7,11 @@ It also provides support for plugin marketplaces - directories that list
7
7
  available plugins with their metadata and source locations.
8
8
  """
9
9
 
10
- from openhands.sdk.plugin.fetch import PluginFetchError
10
+ from openhands.sdk.plugin.fetch import (
11
+ PluginFetchError,
12
+ fetch_plugin_with_resolution,
13
+ )
14
+ from openhands.sdk.plugin.loader import load_plugins
11
15
  from openhands.sdk.plugin.plugin import Plugin
12
16
  from openhands.sdk.plugin.types import (
13
17
  AgentDefinition,
@@ -19,6 +23,8 @@ from openhands.sdk.plugin.types import (
19
23
  MarketplacePluginSource,
20
24
  PluginAuthor,
21
25
  PluginManifest,
26
+ PluginSource,
27
+ ResolvedPluginSource,
22
28
  )
23
29
 
24
30
 
@@ -28,8 +34,13 @@ __all__ = [
28
34
  "PluginFetchError",
29
35
  "PluginManifest",
30
36
  "PluginAuthor",
37
+ "PluginSource",
38
+ "ResolvedPluginSource",
31
39
  "AgentDefinition",
32
40
  "CommandDefinition",
41
+ # Plugin loading
42
+ "load_plugins",
43
+ "fetch_plugin_with_resolution",
33
44
  # Marketplace classes
34
45
  "Marketplace",
35
46
  "MarketplaceOwner",
@@ -104,23 +104,22 @@ def get_cache_path(source: str, cache_dir: Path | None = None) -> Path:
104
104
  return cache_dir / cache_name
105
105
 
106
106
 
107
- def _resolve_local_source(url: str, subpath: str | None) -> Path:
107
+ def _resolve_local_source(url: str) -> Path:
108
108
  """Resolve a local plugin source to a path.
109
109
 
110
110
  Args:
111
111
  url: Local path string (may contain ~ for home directory).
112
- subpath: Optional subdirectory within the local path.
113
112
 
114
113
  Returns:
115
114
  Resolved absolute path to the plugin directory.
116
115
 
117
116
  Raises:
118
- PluginFetchError: If path doesn't exist or subpath is invalid.
117
+ PluginFetchError: If path doesn't exist.
119
118
  """
120
119
  local_path = Path(url).expanduser().resolve()
121
120
  if not local_path.exists():
122
121
  raise PluginFetchError(f"Local plugin path does not exist: {local_path}")
123
- return _apply_subpath(local_path, subpath, "local plugin path")
122
+ return local_path
124
123
 
125
124
 
126
125
  def _fetch_remote_source(
@@ -194,38 +193,143 @@ def fetch_plugin(
194
193
  cache_dir: Path | None = None,
195
194
  ref: str | None = None,
196
195
  update: bool = True,
197
- subpath: str | None = None,
196
+ repo_path: str | None = None,
198
197
  git_helper: GitHelper | None = None,
199
198
  ) -> Path:
200
199
  """Fetch a plugin from a remote source and return the local cached path.
201
200
 
202
201
  Args:
203
202
  source: Plugin source - can be:
204
- - "github:owner/repo" - GitHub repository shorthand
205
- - "https://github.com/owner/repo.git" - Full git URL
203
+ - Any git URL (GitHub, GitLab, Bitbucket, Codeberg, self-hosted, etc.)
204
+ e.g., "https://gitlab.com/org/repo", "git@bitbucket.org:team/repo.git"
205
+ - "github:owner/repo" - GitHub shorthand (convenience syntax)
206
206
  - "/local/path" - Local path (returned as-is)
207
207
  cache_dir: Directory for caching. Defaults to ~/.openhands/cache/plugins/
208
208
  ref: Optional branch, tag, or commit to checkout.
209
209
  update: If True and cache exists, update it. If False, use cached version as-is.
210
- subpath: Optional subdirectory path within the repo. If specified, the returned
211
- path will point to this subdirectory instead of the repository root.
210
+ repo_path: Subdirectory path within the git repository
211
+ (e.g., 'plugins/my-plugin' for monorepos). Only relevant for git
212
+ sources, not local paths. If specified, the returned path will
213
+ point to this subdirectory instead of the repository root.
212
214
  git_helper: GitHelper instance (for testing). Defaults to global instance.
213
215
 
214
216
  Returns:
215
217
  Path to the local plugin directory (ready for Plugin.load()).
216
- If subpath is specified, returns the path to that subdirectory.
218
+ If repo_path is specified, returns the path to that subdirectory.
219
+
220
+ Raises:
221
+ PluginFetchError: If fetching fails or repo_path doesn't exist.
222
+ """
223
+ path, _ = fetch_plugin_with_resolution(
224
+ source=source,
225
+ cache_dir=cache_dir,
226
+ ref=ref,
227
+ update=update,
228
+ repo_path=repo_path,
229
+ git_helper=git_helper,
230
+ )
231
+ return path
232
+
233
+
234
+ def fetch_plugin_with_resolution(
235
+ source: str,
236
+ cache_dir: Path | None = None,
237
+ ref: str | None = None,
238
+ update: bool = True,
239
+ repo_path: str | None = None,
240
+ git_helper: GitHelper | None = None,
241
+ ) -> tuple[Path, str | None]:
242
+ """Fetch a plugin and return both the path and the resolved commit SHA.
243
+
244
+ This is similar to fetch_plugin() but also returns the actual commit SHA
245
+ that was checked out. This is useful for persistence - storing the resolved
246
+ SHA ensures that conversation resume gets exactly the same plugin version.
247
+
248
+ Args:
249
+ source: Plugin source (see fetch_plugin for formats).
250
+ cache_dir: Directory for caching. Defaults to ~/.openhands/cache/plugins/
251
+ ref: Optional branch, tag, or commit to checkout.
252
+ update: If True and cache exists, update it. If False, use cached version as-is.
253
+ repo_path: Subdirectory path within the git repository.
254
+ git_helper: GitHelper instance (for testing). Defaults to global instance.
255
+
256
+ Returns:
257
+ Tuple of (path, resolved_ref) where:
258
+ - path: Path to the local plugin directory
259
+ - resolved_ref: Commit SHA that was checked out (None for local sources)
217
260
 
218
261
  Raises:
219
- PluginFetchError: If fetching fails or subpath doesn't exist.
262
+ PluginFetchError: If fetching fails or repo_path doesn't exist.
220
263
  """
221
264
  source_type, url = parse_plugin_source(source)
222
265
 
223
266
  if source_type == "local":
224
- return _resolve_local_source(url, subpath)
267
+ if repo_path is not None:
268
+ raise PluginFetchError(
269
+ f"repo_path is not supported for local plugin sources. "
270
+ f"Specify the full path directly instead of "
271
+ f"source='{source}' + repo_path='{repo_path}'"
272
+ )
273
+ return _resolve_local_source(url), None
225
274
 
226
275
  if cache_dir is None:
227
276
  cache_dir = DEFAULT_CACHE_DIR
228
277
 
229
- return _fetch_remote_source(
230
- url, cache_dir, ref, update, subpath, git_helper, source
278
+ git = git_helper if git_helper is not None else GitHelper()
279
+
280
+ plugin_path, resolved_ref = _fetch_remote_source_with_resolution(
281
+ url, cache_dir, ref, update, repo_path, git, source
282
+ )
283
+ return plugin_path, resolved_ref
284
+
285
+
286
+ def _fetch_remote_source_with_resolution(
287
+ url: str,
288
+ cache_dir: Path,
289
+ ref: str | None,
290
+ update: bool,
291
+ subpath: str | None,
292
+ git_helper: GitHelper,
293
+ source: str,
294
+ ) -> tuple[Path, str]:
295
+ """Fetch a remote plugin source and return path + resolved commit SHA.
296
+
297
+ Args:
298
+ url: Git URL to fetch.
299
+ cache_dir: Base directory for caching.
300
+ ref: Optional branch, tag, or commit to checkout.
301
+ update: Whether to update existing cache.
302
+ subpath: Optional subdirectory within the repository.
303
+ git_helper: GitHelper instance for git operations.
304
+ source: Original source string (for error messages).
305
+
306
+ Returns:
307
+ Tuple of (path, resolved_ref) where resolved_ref is the commit SHA.
308
+
309
+ Raises:
310
+ PluginFetchError: If fetching fails or subpath is invalid.
311
+ """
312
+ repo_cache_path = get_cache_path(url, cache_dir)
313
+ cache_dir.mkdir(parents=True, exist_ok=True)
314
+
315
+ result = try_cached_clone_or_update(
316
+ url=url,
317
+ repo_path=repo_cache_path,
318
+ ref=ref,
319
+ update=update,
320
+ git_helper=git_helper,
231
321
  )
322
+
323
+ if result is None:
324
+ raise PluginFetchError(f"Failed to fetch plugin from {source}")
325
+
326
+ # Get the actual commit SHA that was checked out
327
+ try:
328
+ resolved_ref = git_helper.get_head_commit(repo_cache_path)
329
+ except Exception as e:
330
+ logger.warning(f"Could not get commit SHA for {source}: {e}")
331
+ # Fall back to the requested ref if we can't get the SHA
332
+ resolved_ref = ref or "HEAD"
333
+
334
+ final_path = _apply_subpath(repo_cache_path, subpath, "plugin repository")
335
+ return final_path, resolved_ref
@@ -0,0 +1,111 @@
1
+ """Plugin loading utility for multi-plugin support.
2
+
3
+ This module provides the canonical function for loading multiple plugins
4
+ and merging them into an agent. It is used by:
5
+ - LocalConversation (for SDK-direct users)
6
+ - ConversationService (for agent-server users)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from openhands.sdk.hooks import HookConfig
14
+ from openhands.sdk.logger import get_logger
15
+ from openhands.sdk.plugin.plugin import Plugin
16
+ from openhands.sdk.plugin.types import PluginSource
17
+
18
+
19
+ if TYPE_CHECKING:
20
+ from openhands.sdk.agent.base import AgentBase
21
+ from openhands.sdk.context import AgentContext
22
+
23
+
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ def load_plugins(
28
+ plugin_specs: list[PluginSource],
29
+ agent: AgentBase,
30
+ max_skills: int = 100,
31
+ ) -> tuple[AgentBase, HookConfig | None]:
32
+ """Load multiple plugins and merge them into the agent.
33
+
34
+ This is the canonical function for plugin loading, used by:
35
+ - LocalConversation (for SDK-direct users)
36
+ - ConversationService (for agent-server users)
37
+
38
+ Plugins are loaded in order and their contents are merged with these semantics:
39
+ - Skills: Override by name (last plugin wins)
40
+ - MCP config: Override by key (last plugin wins)
41
+ - Hooks: Concatenate (all hooks run)
42
+
43
+ Args:
44
+ plugin_specs: List of plugin sources to load.
45
+ agent: Agent to merge plugins into.
46
+ max_skills: Maximum total skills allowed (defense-in-depth limit).
47
+
48
+ Returns:
49
+ Tuple of (updated_agent, merged_hook_config).
50
+ The agent has updated agent_context (with merged skills) and mcp_config.
51
+ The hook_config contains all hooks from all plugins concatenated.
52
+
53
+ Raises:
54
+ PluginFetchError: If any plugin fails to fetch.
55
+ FileNotFoundError: If any plugin fails to load (e.g., path not found).
56
+ ValueError: If max_skills limit is exceeded.
57
+
58
+ Example:
59
+ >>> from openhands.sdk.plugin import PluginSource
60
+ >>> plugins = [
61
+ ... PluginSource(source="github:owner/security-plugin", ref="v1.0.0"),
62
+ ... PluginSource(source="/local/custom-plugin"),
63
+ ... ]
64
+ >>> updated_agent, hooks = load_plugins(plugins, agent)
65
+ """
66
+ if not plugin_specs:
67
+ return agent, None
68
+
69
+ # Start with agent's existing context and MCP config
70
+ merged_context: AgentContext | None = agent.agent_context
71
+ merged_mcp: dict[str, Any] = dict(agent.mcp_config) if agent.mcp_config else {}
72
+ all_hooks: list[HookConfig] = []
73
+
74
+ for spec in plugin_specs:
75
+ logger.info(f"Loading plugin from {spec.source}")
76
+
77
+ # Fetch (downloads if needed, returns cached path)
78
+ path = Plugin.fetch(
79
+ source=spec.source,
80
+ ref=spec.ref,
81
+ repo_path=spec.repo_path,
82
+ )
83
+ plugin = Plugin.load(path)
84
+
85
+ logger.info(
86
+ f"Loaded plugin '{plugin.name}': "
87
+ f"{len(plugin.skills)} skills, "
88
+ f"hooks={'yes' if plugin.hooks else 'no'}, "
89
+ f"mcp_config={'yes' if plugin.mcp_config else 'no'}"
90
+ )
91
+
92
+ # Merge skills and MCP config separately
93
+ merged_context = plugin.add_skills_to(merged_context, max_skills=max_skills)
94
+ merged_mcp = plugin.add_mcp_config_to(merged_mcp)
95
+
96
+ # Collect hooks for later combination
97
+ if plugin.hooks and not plugin.hooks.is_empty():
98
+ all_hooks.append(plugin.hooks)
99
+
100
+ # Combine all hook configs (concatenation semantics)
101
+ combined_hooks = HookConfig.merge(all_hooks)
102
+
103
+ # Create updated agent with merged content
104
+ updated_agent = agent.model_copy(
105
+ update={
106
+ "agent_context": merged_context,
107
+ "mcp_config": merged_mcp,
108
+ }
109
+ )
110
+
111
+ return updated_agent, combined_hooks
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  from pathlib import Path
7
- from typing import Any
7
+ from typing import TYPE_CHECKING, Any
8
8
 
9
9
  from pydantic import BaseModel, Field
10
10
 
@@ -25,6 +25,9 @@ from openhands.sdk.plugin.types import (
25
25
  )
26
26
 
27
27
 
28
+ if TYPE_CHECKING:
29
+ from openhands.sdk.context import AgentContext
30
+
28
31
  logger = get_logger(__name__)
29
32
 
30
33
  # Directories to check for plugin manifest
@@ -84,6 +87,135 @@ class Plugin(BaseModel):
84
87
  """Get the plugin description."""
85
88
  return self.manifest.description
86
89
 
90
+ def get_all_skills(self) -> list[Skill]:
91
+ """Get all skills including those converted from commands.
92
+
93
+ Returns skills from both the skills/ directory and commands/ directory.
94
+ Commands are converted to keyword-triggered skills using the format
95
+ /<plugin-name>:<command-name>.
96
+
97
+ Returns:
98
+ Combined list of skills (original + command-derived skills).
99
+ """
100
+ all_skills = list(self.skills)
101
+
102
+ # Convert commands to skills with keyword triggers
103
+ for command in self.commands:
104
+ skill = command.to_skill(self.name)
105
+ all_skills.append(skill)
106
+
107
+ return all_skills
108
+
109
+ def add_skills_to(
110
+ self,
111
+ agent_context: AgentContext | None = None,
112
+ max_skills: int | None = None,
113
+ ) -> AgentContext:
114
+ """Add this plugin's skills to an agent context.
115
+
116
+ Plugin skills override existing skills with the same name.
117
+ Includes both explicit skills and command-derived skills.
118
+
119
+ Args:
120
+ agent_context: Existing agent context (or None to create new)
121
+ max_skills: Optional max total skills (raises ValueError if exceeded)
122
+
123
+ Returns:
124
+ New AgentContext with this plugin's skills added
125
+
126
+ Raises:
127
+ ValueError: If max_skills limit would be exceeded
128
+
129
+ Example:
130
+ >>> plugin = Plugin.load(Plugin.fetch("github:owner/plugin"))
131
+ >>> new_context = plugin.add_skills_to(agent.agent_context, max_skills=100)
132
+ >>> agent = agent.model_copy(update={"agent_context": new_context})
133
+ """
134
+ # Import at runtime to avoid circular import
135
+ from openhands.sdk.context import AgentContext
136
+
137
+ existing_skills = agent_context.skills if agent_context else []
138
+
139
+ # Get all skills including command-derived skills
140
+ all_skills = self.get_all_skills()
141
+
142
+ skills_by_name = {s.name: s for s in existing_skills}
143
+ for skill in all_skills:
144
+ if skill.name in skills_by_name:
145
+ logger.warning(f"Plugin skill '{skill.name}' overrides existing skill")
146
+ skills_by_name[skill.name] = skill
147
+
148
+ if max_skills is not None and len(skills_by_name) > max_skills:
149
+ raise ValueError(
150
+ f"Total skills ({len(skills_by_name)}) exceeds maximum ({max_skills})"
151
+ )
152
+
153
+ merged_skills = list(skills_by_name.values())
154
+
155
+ if agent_context:
156
+ return agent_context.model_copy(update={"skills": merged_skills})
157
+ return AgentContext(skills=merged_skills)
158
+
159
+ def add_mcp_config_to(
160
+ self,
161
+ mcp_config: dict[str, Any] | None = None,
162
+ ) -> dict[str, Any]:
163
+ """Add this plugin's MCP servers to an MCP config.
164
+
165
+ Plugin MCP servers override existing servers with the same name.
166
+
167
+ Merge semantics (Claude Code compatible):
168
+ - mcpServers: deep-merge by server name (last plugin wins for same server)
169
+ - Other top-level keys: shallow override (plugin wins)
170
+
171
+ Args:
172
+ mcp_config: Existing MCP config (or None to create new)
173
+
174
+ Returns:
175
+ New MCP config dict with this plugin's servers added
176
+
177
+ Example:
178
+ >>> plugin = Plugin.load(Plugin.fetch("github:owner/plugin"))
179
+ >>> new_mcp = plugin.add_mcp_config_to(agent.mcp_config)
180
+ >>> agent = agent.model_copy(update={"mcp_config": new_mcp})
181
+ """
182
+ base_config = mcp_config
183
+ plugin_config = self.mcp_config
184
+
185
+ if base_config is None and plugin_config is None:
186
+ return {}
187
+ if base_config is None:
188
+ return dict(plugin_config) if plugin_config else {}
189
+ if plugin_config is None:
190
+ return dict(base_config)
191
+
192
+ # Shallow copy to avoid mutating inputs
193
+ result = dict(base_config)
194
+
195
+ # Merge mcpServers by server name (Claude Code compatible behavior)
196
+ if "mcpServers" in plugin_config:
197
+ existing_servers = result.get("mcpServers", {})
198
+ for server_name in plugin_config["mcpServers"]:
199
+ if server_name in existing_servers:
200
+ logger.warning(
201
+ f"Plugin MCP server '{server_name}' overrides existing server"
202
+ )
203
+ result["mcpServers"] = {
204
+ **existing_servers,
205
+ **plugin_config["mcpServers"],
206
+ }
207
+
208
+ # Other top-level keys: plugin wins (shallow override)
209
+ for key, value in plugin_config.items():
210
+ if key != "mcpServers":
211
+ if key in result:
212
+ logger.warning(
213
+ f"Plugin MCP config key '{key}' overrides existing value"
214
+ )
215
+ result[key] = value
216
+
217
+ return result
218
+
87
219
  @classmethod
88
220
  def fetch(
89
221
  cls,
@@ -91,7 +223,7 @@ class Plugin(BaseModel):
91
223
  cache_dir: Path | None = None,
92
224
  ref: str | None = None,
93
225
  update: bool = True,
94
- subpath: str | None = None,
226
+ repo_path: str | None = None,
95
227
  ) -> Path:
96
228
  """Fetch a plugin from a remote source and return the local cached path.
97
229
 
@@ -101,22 +233,24 @@ class Plugin(BaseModel):
101
233
 
102
234
  Args:
103
235
  source: Plugin source - can be:
104
- - "github:owner/repo" - GitHub repository shorthand
105
- - "https://github.com/owner/repo.git" - Full git URL
236
+ - Any git URL (GitHub, GitLab, Bitbucket, Codeberg, self-hosted, etc.)
237
+ e.g., "https://gitlab.com/org/repo", "git@bitbucket.org:team/repo.git"
238
+ - "github:owner/repo" - GitHub shorthand (convenience syntax)
106
239
  - "/local/path" - Local path (returned as-is)
107
240
  cache_dir: Directory for caching. Defaults to ~/.openhands/cache/plugins/
108
241
  ref: Optional branch, tag, or commit to checkout.
109
242
  update: If True and cache exists, update it. If False, use cached as-is.
110
- subpath: Optional subdirectory path within the repo. If specified, the
111
- returned path will point to this subdirectory instead of the
112
- repository root.
243
+ repo_path: Subdirectory path within the git repository
244
+ (e.g., 'plugins/my-plugin' for monorepos). Only relevant for git
245
+ sources, not local paths. If specified, the returned path will
246
+ point to this subdirectory instead of the repository root.
113
247
 
114
248
  Returns:
115
249
  Path to the local plugin directory (ready for Plugin.load()).
116
- If subpath is specified, returns the path to that subdirectory.
250
+ If repo_path is specified, returns the path to that subdirectory.
117
251
 
118
252
  Raises:
119
- PluginFetchError: If fetching fails or subpath doesn't exist.
253
+ PluginFetchError: If fetching fails or repo_path doesn't exist.
120
254
 
121
255
  Example:
122
256
  >>> path = Plugin.fetch("github:owner/my-plugin")
@@ -126,15 +260,15 @@ class Plugin(BaseModel):
126
260
  >>> path = Plugin.fetch("github:owner/my-plugin", ref="v1.0.0")
127
261
  >>> plugin = Plugin.load(path)
128
262
 
129
- >>> # Fetch a plugin from a subdirectory
130
- >>> path = Plugin.fetch("github:owner/monorepo", subpath="plugins/sub")
263
+ >>> # Fetch a plugin from a subdirectory in a monorepo
264
+ >>> path = Plugin.fetch("github:owner/monorepo", repo_path="plugins/sub")
131
265
  >>> plugin = Plugin.load(path)
132
266
 
133
267
  >>> # Fetch and load in one step
134
268
  >>> plugin = Plugin.load(Plugin.fetch("github:owner/my-plugin"))
135
269
  """
136
270
  return fetch_plugin(
137
- source, cache_dir=cache_dir, ref=ref, update=update, subpath=subpath
271
+ source, cache_dir=cache_dir, ref=ref, update=update, repo_path=repo_path
138
272
  )
139
273
 
140
274
  @classmethod
@@ -299,6 +433,7 @@ def _load_hooks(plugin_dir: Path) -> HookConfig | None:
299
433
  if hook_config.is_empty():
300
434
  logger.info(f"No hooks configured in {hooks_json}")
301
435
  return HookConfig()
436
+ logger.info(f"Loaded hooks from {hooks_json}")
302
437
  return hook_config
303
438
  except Exception as e:
304
439
  logger.warning(f"Failed to load hooks from {hooks_json}: {e}")
@@ -312,7 +447,14 @@ def _load_mcp_config(plugin_dir: Path) -> dict[str, Any] | None:
312
447
  return None
313
448
 
314
449
  try:
315
- return load_mcp_config(mcp_json, skill_root=plugin_dir)
450
+ config = load_mcp_config(mcp_json, skill_root=plugin_dir)
451
+ if config and "mcpServers" in config:
452
+ server_names = list(config["mcpServers"].keys())
453
+ logger.info(
454
+ f"Loaded MCP config from {mcp_json} "
455
+ f"with {len(server_names)} server(s): {server_names}"
456
+ )
457
+ return config
316
458
  except Exception as e:
317
459
  logger.warning(f"Failed to load MCP config from {mcp_json}: {e}")
318
460
  return None