Skip to content

Commit 9080470

Browse files
simonrosenbergDebug Agentclaudeopenhands-agent
authored
feat(sdk): forward MCP servers to the ACP subprocess (#3458)
Co-authored-by: Debug Agent <debug@example.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 68164af commit 9080470

4 files changed

Lines changed: 420 additions & 54 deletions

File tree

openhands-sdk/openhands/sdk/agent/acp_agent.py

Lines changed: 139 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,14 @@
3535
AgentMessageChunk,
3636
AgentThoughtChunk,
3737
AllowedOutcome,
38+
EnvVariable,
39+
HttpHeader,
40+
HttpMcpServer,
3841
ImageContentBlock,
42+
McpServerStdio,
3943
PromptResponse,
4044
RequestPermissionResponse,
45+
SseMcpServer,
4146
TextContentBlock,
4247
ToolCallProgress,
4348
ToolCallStart,
@@ -386,6 +391,103 @@ def _extract_session_models(
386391
return current, available
387392

388393

394+
# The ACP MCP server union accepted by new_session() / load_session().
395+
_ACPMcpServer = HttpMcpServer | SseMcpServer | McpServerStdio
396+
397+
398+
def _mcp_config_to_acp_servers(
399+
mcp_config: dict[str, Any],
400+
mcp_capabilities: Any,
401+
) -> list[_ACPMcpServer]:
402+
"""Translate an OpenHands ``mcp_config`` dict into ACP MCP server objects.
403+
404+
Reads the standard ``{"mcpServers": {name: {...}}}`` shape (the same shape
405+
:attr:`AgentBase.mcp_config` carries for the built-in Agent) and returns the
406+
list to pass to ``new_session()`` / ``load_session()`` so the ACP
407+
subprocess connects to those servers itself. Unlike the built-in Agent
408+
these are *not* turned into in-process OpenHands MCP tools
409+
(:attr:`ACPAgent.supports_openhands_mcp` stays ``False``) — the ACP server
410+
owns the MCP connection and exposes the tools through its own turn.
411+
412+
Each entry maps by transport:
413+
414+
- ``command`` present → :class:`McpServerStdio` (always forwarded; the
415+
protocol gates only the remote transports behind a capability flag).
416+
- ``url`` present, transport ``sse`` → :class:`SseMcpServer`, forwarded only
417+
when the server advertises ``mcp_capabilities.sse``.
418+
- ``url`` present, any other / absent transport → :class:`HttpMcpServer`
419+
(covers ``http`` and ``streamable-http``), forwarded only when the server
420+
advertises ``mcp_capabilities.http``.
421+
422+
A remote server whose transport the ACP server does not advertise is dropped
423+
with a warning rather than failing init — one misconfigured server should
424+
not sink the whole conversation. ``env`` / ``headers`` maps are converted
425+
to the protocol's ``[{name, value}]`` list form; their values were already
426+
decrypted by :class:`AgentBase`'s ``mcp_config`` validator.
427+
"""
428+
servers = mcp_config.get("mcpServers")
429+
if not isinstance(servers, dict):
430+
return []
431+
http_ok = bool(getattr(mcp_capabilities, "http", False))
432+
sse_ok = bool(getattr(mcp_capabilities, "sse", False))
433+
result: list[_ACPMcpServer] = []
434+
for name, spec in servers.items():
435+
if not isinstance(spec, dict):
436+
logger.warning("Skipping malformed ACP MCP server %r", name)
437+
continue
438+
command = spec.get("command")
439+
url = spec.get("url")
440+
if command:
441+
env = [
442+
EnvVariable(name=str(k), value=str(v))
443+
for k, v in (spec.get("env") or {}).items()
444+
]
445+
result.append(
446+
McpServerStdio(
447+
name=str(name),
448+
command=str(command),
449+
args=[str(a) for a in (spec.get("args") or [])],
450+
env=env,
451+
)
452+
)
453+
elif url:
454+
headers = [
455+
HttpHeader(name=str(k), value=str(v))
456+
for k, v in (spec.get("headers") or {}).items()
457+
]
458+
is_sse = str(spec.get("transport") or "http").lower() == "sse"
459+
if not (sse_ok if is_sse else http_ok):
460+
logger.warning(
461+
"ACP server does not advertise %s MCP support; "
462+
"dropping MCP server %r (%s)",
463+
"SSE" if is_sse else "HTTP",
464+
name,
465+
url,
466+
)
467+
continue
468+
# Construct each transport explicitly so the ``type`` literal stays
469+
# narrow (the union's two arms require distinct ``Literal``s).
470+
if is_sse:
471+
result.append(
472+
SseMcpServer(
473+
type="sse", name=str(name), url=str(url), headers=headers
474+
)
475+
)
476+
else:
477+
result.append(
478+
HttpMcpServer(
479+
type="http", name=str(name), url=str(url), headers=headers
480+
)
481+
)
482+
else:
483+
logger.warning(
484+
"Skipping ACP MCP server %r: needs a 'command' (stdio) or "
485+
"'url' (http/sse)",
486+
name,
487+
)
488+
return result
489+
490+
389491
async def _maybe_set_session_model(
390492
conn: ClientSideConnection,
391493
agent_name: str,
@@ -1370,7 +1472,14 @@ def supports_openhands_tools(self) -> bool:
13701472

13711473
@property
13721474
def supports_openhands_mcp(self) -> bool:
1373-
"""``False`` — MCP configuration is owned by the ACP subprocess."""
1475+
"""``False`` — OpenHands does not create in-process MCP *tools* here.
1476+
1477+
This stays ``False`` even though ``mcp_config`` is honored: any
1478+
configured MCP servers are forwarded to the ACP subprocess at session
1479+
creation (see :func:`_mcp_config_to_acp_servers`) rather than connected
1480+
in-process. The ACP server owns the MCP connection and surfaces the
1481+
tools through its own turn.
1482+
"""
13741483
return False
13751484

13761485
@property
@@ -1466,18 +1575,15 @@ def init_state(
14661575
"""Spawn the ACP server and initialize a session."""
14671576
# Validate unsupported execution features. agent_context is allowed
14681577
# because it contributes prompt-only extensions to user messages; ACP
1469-
# server tools, MCP configuration, and context-window management remain
1470-
# owned by the server.
1578+
# server tools and context-window management remain owned by the server.
1579+
# mcp_config IS supported: its servers are forwarded to the subprocess at
1580+
# session creation (see _mcp_config_to_acp_servers) rather than turned
1581+
# into in-process OpenHands MCP tools.
14711582
if self.tools:
14721583
raise NotImplementedError(
14731584
"ACPAgent does not support custom tools; "
14741585
"the ACP server manages its own tools"
14751586
)
1476-
if self.mcp_config:
1477-
raise NotImplementedError(
1478-
"ACPAgent does not support mcp_config; "
1479-
"configure MCP on the ACP server instead"
1480-
)
14811587
if self.condenser is not None:
14821588
raise NotImplementedError(
14831589
"ACPAgent does not support condenser; "
@@ -2145,6 +2251,25 @@ async def _init() -> tuple[
21452251
agent_version,
21462252
)
21472253

2254+
# Translate any configured MCP servers into ACP protocol objects,
2255+
# gating remote (http/sse) transports on what this server advertised
2256+
# in its initialize response. The same list is passed to both
2257+
# new_session and load_session: load_session does not persist the
2258+
# prior MCP set server-side, so a resume must re-send it or the
2259+
# restored session would silently lose its MCP servers.
2260+
mcp_caps = (
2261+
init_response.agent_capabilities.mcp_capabilities
2262+
if init_response.agent_capabilities is not None
2263+
else None
2264+
)
2265+
acp_mcp_servers = _mcp_config_to_acp_servers(self.mcp_config, mcp_caps)
2266+
if acp_mcp_servers:
2267+
logger.info(
2268+
"Forwarding %d MCP server(s) to ACP session: %s",
2269+
len(acp_mcp_servers),
2270+
[s.name for s in acp_mcp_servers],
2271+
)
2272+
21482273
# Authenticate if the server requires it. Some ACP servers
21492274
# (e.g. codex-acp) require an explicit authenticate call
21502275
# before session creation. We auto-detect the method from
@@ -2192,7 +2317,7 @@ async def _init() -> tuple[
21922317
load_response = await conn.load_session(
21932318
cwd=working_dir,
21942319
session_id=prior_session_id,
2195-
mcp_servers=[],
2320+
mcp_servers=acp_mcp_servers,
21962321
)
21972322
session_id = prior_session_id
21982323
reported_model_id, available_models = _extract_session_models(
@@ -2222,7 +2347,11 @@ async def _init() -> tuple[
22222347
# _meta dict in the JSON-RPC request — do NOT wrap in _meta=
22232348
# (that double-nests).
22242349
session_meta = build_session_model_meta(agent_name, self.acp_model)
2225-
response = await conn.new_session(cwd=working_dir, **session_meta)
2350+
response = await conn.new_session(
2351+
cwd=working_dir,
2352+
mcp_servers=acp_mcp_servers,
2353+
**session_meta,
2354+
)
22262355
session_id = response.session_id
22272356
reported_model_id, available_models = _extract_session_models(response)
22282357
# Initial-selection protocol call for providers that use it

0 commit comments

Comments
 (0)