|
35 | 35 | AgentMessageChunk, |
36 | 36 | AgentThoughtChunk, |
37 | 37 | AllowedOutcome, |
| 38 | + EnvVariable, |
| 39 | + HttpHeader, |
| 40 | + HttpMcpServer, |
38 | 41 | ImageContentBlock, |
| 42 | + McpServerStdio, |
39 | 43 | PromptResponse, |
40 | 44 | RequestPermissionResponse, |
| 45 | + SseMcpServer, |
41 | 46 | TextContentBlock, |
42 | 47 | ToolCallProgress, |
43 | 48 | ToolCallStart, |
@@ -386,6 +391,103 @@ def _extract_session_models( |
386 | 391 | return current, available |
387 | 392 |
|
388 | 393 |
|
| 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 | + |
389 | 491 | async def _maybe_set_session_model( |
390 | 492 | conn: ClientSideConnection, |
391 | 493 | agent_name: str, |
@@ -1370,7 +1472,14 @@ def supports_openhands_tools(self) -> bool: |
1370 | 1472 |
|
1371 | 1473 | @property |
1372 | 1474 | 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 | + """ |
1374 | 1483 | return False |
1375 | 1484 |
|
1376 | 1485 | @property |
@@ -1466,18 +1575,15 @@ def init_state( |
1466 | 1575 | """Spawn the ACP server and initialize a session.""" |
1467 | 1576 | # Validate unsupported execution features. agent_context is allowed |
1468 | 1577 | # 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. |
1471 | 1582 | if self.tools: |
1472 | 1583 | raise NotImplementedError( |
1473 | 1584 | "ACPAgent does not support custom tools; " |
1474 | 1585 | "the ACP server manages its own tools" |
1475 | 1586 | ) |
1476 | | - if self.mcp_config: |
1477 | | - raise NotImplementedError( |
1478 | | - "ACPAgent does not support mcp_config; " |
1479 | | - "configure MCP on the ACP server instead" |
1480 | | - ) |
1481 | 1587 | if self.condenser is not None: |
1482 | 1588 | raise NotImplementedError( |
1483 | 1589 | "ACPAgent does not support condenser; " |
@@ -2145,6 +2251,25 @@ async def _init() -> tuple[ |
2145 | 2251 | agent_version, |
2146 | 2252 | ) |
2147 | 2253 |
|
| 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 | + |
2148 | 2273 | # Authenticate if the server requires it. Some ACP servers |
2149 | 2274 | # (e.g. codex-acp) require an explicit authenticate call |
2150 | 2275 | # before session creation. We auto-detect the method from |
@@ -2192,7 +2317,7 @@ async def _init() -> tuple[ |
2192 | 2317 | load_response = await conn.load_session( |
2193 | 2318 | cwd=working_dir, |
2194 | 2319 | session_id=prior_session_id, |
2195 | | - mcp_servers=[], |
| 2320 | + mcp_servers=acp_mcp_servers, |
2196 | 2321 | ) |
2197 | 2322 | session_id = prior_session_id |
2198 | 2323 | reported_model_id, available_models = _extract_session_models( |
@@ -2222,7 +2347,11 @@ async def _init() -> tuple[ |
2222 | 2347 | # _meta dict in the JSON-RPC request — do NOT wrap in _meta= |
2223 | 2348 | # (that double-nests). |
2224 | 2349 | 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 | + ) |
2226 | 2355 | session_id = response.session_id |
2227 | 2356 | reported_model_id, available_models = _extract_session_models(response) |
2228 | 2357 | # Initial-selection protocol call for providers that use it |
|
0 commit comments