Skip to content

Commit a348840

Browse files
[Feat]: support streamable http mcp (#8864)
Co-authored-by: openhands <[email protected]>
1 parent 1850d57 commit a348840

File tree

13 files changed

+208
-56
lines changed

13 files changed

+208
-56
lines changed

openhands/cli/main.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,14 +263,12 @@ def on_event(event: Event) -> None:
263263
# Add MCP tools to the agent
264264
if agent.config.enable_mcp:
265265
# Add OpenHands' MCP server by default
266-
openhands_mcp_server, openhands_mcp_stdio_servers = (
266+
_, openhands_mcp_stdio_servers = (
267267
OpenHandsMCPConfigImpl.create_default_mcp_server_config(
268268
config.mcp_host, config, None
269269
)
270270
)
271-
# FIXME: OpenHands' SSE server may not be running when CLI mode is started
272-
# if openhands_mcp_server:
273-
# config.mcp.sse_servers.append(openhands_mcp_server)
271+
274272
config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers)
275273

276274
await add_mcp_tools_to_agent(agent, runtime, memory, config)

openhands/core/config/mcp_config.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ def __eq__(self, other):
5454
and set(self.env.items()) == set(other.env.items())
5555
)
5656

57+
class MCPSHTTPServerConfig(BaseModel):
58+
url: str
59+
api_key: str | None = None
60+
5761

5862
class MCPConfig(BaseModel):
5963
"""Configuration for MCP (Message Control Protocol) settings.
@@ -65,11 +69,12 @@ class MCPConfig(BaseModel):
6569

6670
sse_servers: list[MCPSSEServerConfig] = Field(default_factory=list)
6771
stdio_servers: list[MCPStdioServerConfig] = Field(default_factory=list)
72+
shttp_servers: list[MCPSHTTPServerConfig] = Field(default_factory=list)
6873

6974
model_config = {'extra': 'forbid'}
7075

7176
@staticmethod
72-
def _normalize_sse_servers(servers_data: list[dict | str]) -> list[dict]:
77+
def _normalize_servers(servers_data: list[dict | str]) -> list[dict]:
7378
"""Helper method to normalize SSE server configurations."""
7479
normalized = []
7580
for server in servers_data:
@@ -82,8 +87,13 @@ def _normalize_sse_servers(servers_data: list[dict | str]) -> list[dict]:
8287
@model_validator(mode='before')
8388
def convert_string_urls(cls, data):
8489
"""Convert string URLs to MCPSSEServerConfig objects."""
85-
if isinstance(data, dict) and 'sse_servers' in data:
86-
data['sse_servers'] = cls._normalize_sse_servers(data['sse_servers'])
90+
if isinstance(data, dict):
91+
if 'sse_servers' in data:
92+
data['sse_servers'] = cls._normalize_servers(data['sse_servers'])
93+
94+
if 'shttp_servers' in data:
95+
data['shttp_servers'] = cls._normalize_servers(data['shttp_servers'])
96+
8797
return data
8898

8999
def validate_servers(self) -> None:
@@ -119,7 +129,7 @@ def from_toml_section(cls, data: dict) -> dict[str, 'MCPConfig']:
119129
try:
120130
# Convert all entries in sse_servers to MCPSSEServerConfig objects
121131
if 'sse_servers' in data:
122-
data['sse_servers'] = cls._normalize_sse_servers(data['sse_servers'])
132+
data['sse_servers'] = cls._normalize_servers(data['sse_servers'])
123133
servers = []
124134
for server in data['sse_servers']:
125135
servers.append(MCPSSEServerConfig(**server))
@@ -132,6 +142,13 @@ def from_toml_section(cls, data: dict) -> dict[str, 'MCPConfig']:
132142
servers.append(MCPStdioServerConfig(**server))
133143
data['stdio_servers'] = servers
134144

145+
if 'shttp_servers' in data:
146+
data['shttp_servers'] = cls._normalize_servers(data['shttp_servers'])
147+
servers = []
148+
for server in data['shttp_servers']:
149+
servers.append(MCPSHTTPServerConfig(**server))
150+
data['shttp_servers'] = servers
151+
135152
# Create SSE config if present
136153
mcp_config = MCPConfig.model_validate(data)
137154
mcp_config.validate_servers()
@@ -169,7 +186,7 @@ def add_search_engine(app_config: 'OpenHandsConfig') -> MCPStdioServerConfig | N
169186
@staticmethod
170187
def create_default_mcp_server_config(
171188
host: str, config: 'OpenHandsConfig', user_id: str | None = None
172-
) -> tuple[MCPSSEServerConfig, list[MCPStdioServerConfig]]:
189+
) -> tuple[MCPSHTTPServerConfig, list[MCPStdioServerConfig]]:
173190
"""
174191
Create a default MCP server configuration.
175192
@@ -179,12 +196,13 @@ def create_default_mcp_server_config(
179196
Returns:
180197
tuple[MCPSSEServerConfig, list[MCPStdioServerConfig]]: A tuple containing the default SSE server configuration and a list of MCP stdio server configurations
181198
"""
182-
sse_server = MCPSSEServerConfig(url=f'http://{host}/mcp/sse', api_key=None)
183199
stdio_servers = []
184200
search_engine_stdio_server = OpenHandsMCPConfig.add_search_engine(config)
185201
if search_engine_stdio_server:
186202
stdio_servers.append(search_engine_stdio_server)
187-
return sse_server, stdio_servers
203+
204+
shttp_servers = MCPSHTTPServerConfig(url=f'http://{host}/mcp/mcp', api_key=None)
205+
return shttp_servers, stdio_servers
188206

189207

190208
openhands_mcp_config_cls = os.environ.get(

openhands/core/main.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,11 @@ async def run_controller(
134134
# Add MCP tools to the agent
135135
if agent.config.enable_mcp:
136136
# Add OpenHands' MCP server by default
137-
openhands_mcp_server, openhands_mcp_stdio_servers = (
137+
_, openhands_mcp_stdio_servers = (
138138
OpenHandsMCPConfigImpl.create_default_mcp_server_config(
139139
config.mcp_host, config, None
140140
)
141141
)
142-
# FIXME: OpenHands' SSE server may not be running when headless mode is started
143-
# if openhands_mcp_server:
144-
# config.mcp.sse_servers.append(openhands_mcp_server)
145142
config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers)
146143

147144
await add_mcp_tools_to_agent(agent, runtime, memory, config)

openhands/mcp/client.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import asyncio
2+
import datetime
23
from contextlib import AsyncExitStack
34
from typing import Optional
45

56
from mcp import ClientSession
67
from mcp.client.sse import sse_client
8+
from mcp.client.streamable_http import streamablehttp_client
79
from pydantic import BaseModel, Field
810

911
from openhands.core.logger import openhands_logger as logger
@@ -58,14 +60,21 @@ async def connect_with_timeout():
5860
if conversation_id:
5961
headers['X-OpenHands-Conversation-ID'] = conversation_id
6062

63+
# Convert float timeout to datetime.timedelta for consistency
64+
timeout_delta = datetime.timedelta(seconds=timeout)
65+
6166
streams_context = sse_client(
6267
url=server_url,
6368
headers=headers if headers else None,
6469
timeout=timeout,
6570
)
6671
streams = await self.exit_stack.enter_async_context(streams_context)
72+
# For SSE client, we only get read_stream and write_stream (2 values)
73+
read_stream, write_stream = streams
6774
self.session = await self.exit_stack.enter_async_context(
68-
ClientSession(*streams)
75+
ClientSession(
76+
read_stream, write_stream, read_timeout_seconds=timeout_delta
77+
)
6978
)
7079
await self._initialize_and_list_tools()
7180

@@ -117,6 +126,77 @@ async def call_tool(self, tool_name: str, args: dict):
117126
raise RuntimeError('Client session is not available.')
118127
return await self.session.call_tool(name=tool_name, arguments=args)
119128

129+
async def connect_shttp(
130+
self,
131+
server_url: str,
132+
api_key: str | None = None,
133+
conversation_id: str | None = None,
134+
timeout: float = 30.0,
135+
) -> None:
136+
"""Connect to an MCP server using StreamableHTTP transport.
137+
138+
Args:
139+
server_url: The URL of the StreamableHTTP server to connect to.
140+
api_key: Optional API key for authentication.
141+
conversation_id: Optional conversation ID for session tracking.
142+
timeout: Connection timeout in seconds. Default is 30 seconds.
143+
"""
144+
if not server_url:
145+
raise ValueError('Server URL is required.')
146+
if self.session:
147+
await self.disconnect()
148+
149+
try:
150+
# Use asyncio.wait_for to enforce the timeout
151+
async def connect_with_timeout():
152+
headers = (
153+
{
154+
'Authorization': f'Bearer {api_key}',
155+
's': api_key, # We need this for action execution server's MCP Router
156+
'X-Session-API-Key': api_key, # We need this for Remote Runtime
157+
}
158+
if api_key
159+
else {}
160+
)
161+
162+
if conversation_id:
163+
headers['X-OpenHands-Conversation-ID'] = conversation_id
164+
165+
# Convert float timeout to datetime.timedelta
166+
timeout_delta = datetime.timedelta(seconds=timeout)
167+
sse_read_timeout_delta = datetime.timedelta(
168+
seconds=timeout * 10
169+
) # 10x longer for read timeout
170+
171+
streams_context = streamablehttp_client(
172+
url=server_url,
173+
headers=headers if headers else None,
174+
timeout=timeout_delta,
175+
sse_read_timeout=sse_read_timeout_delta,
176+
)
177+
streams = await self.exit_stack.enter_async_context(streams_context)
178+
# For StreamableHTTP client, we get read_stream, write_stream, and get_session_id (3 values)
179+
read_stream, write_stream, _ = streams
180+
self.session = await self.exit_stack.enter_async_context(
181+
ClientSession(
182+
read_stream, write_stream, read_timeout_seconds=timeout_delta
183+
)
184+
)
185+
await self._initialize_and_list_tools()
186+
187+
# Apply timeout to the entire connection process
188+
await asyncio.wait_for(connect_with_timeout(), timeout=timeout)
189+
except asyncio.TimeoutError:
190+
logger.error(
191+
f'Connection to {server_url} timed out after {timeout} seconds'
192+
)
193+
await self.disconnect() # Clean up resources
194+
raise # Re-raise the TimeoutError
195+
except Exception as e:
196+
logger.error(f'Error connecting to {server_url}: {str(e)}')
197+
await self.disconnect() # Clean up resources
198+
raise
199+
120200
async def disconnect(self) -> None:
121201
"""Disconnect from the MCP server and clean up resources."""
122202
if self.session:

openhands/mcp/utils.py

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
if TYPE_CHECKING:
55
from openhands.controller.agent import Agent
66

7+
78
from openhands.core.config.mcp_config import (
89
MCPConfig,
10+
MCPSHTTPServerConfig,
911
MCPSSEServerConfig,
1012
)
1113
from openhands.core.config.openhands_config import OpenHandsConfig
@@ -48,7 +50,9 @@ def convert_mcp_clients_to_tools(mcp_clients: list[MCPClient] | None) -> list[di
4850

4951

5052
async def create_mcp_clients(
51-
sse_servers: list[MCPSSEServerConfig], conversation_id: str | None = None
53+
sse_servers: list[MCPSSEServerConfig],
54+
shttp_servers: list[MCPSHTTPServerConfig],
55+
conversation_id: str | None = None,
5256
) -> list[MCPClient]:
5357
import sys
5458

@@ -59,42 +63,60 @@ async def create_mcp_clients(
5963
)
6064
return []
6165

62-
mcp_clients: list[MCPClient] = []
63-
# Initialize SSE connections
64-
if sse_servers:
65-
for server_url in sse_servers:
66-
logger.info(
67-
f'Initializing MCP agent for {server_url} with SSE connection...'
68-
)
66+
servers: list[MCPSSEServerConfig | MCPSHTTPServerConfig] = sse_servers.copy()
67+
servers.extend(shttp_servers.copy())
6968

70-
client = MCPClient()
71-
try:
69+
if not servers:
70+
return []
71+
72+
mcp_clients = []
73+
74+
for server in servers:
75+
is_sse = isinstance(server, MCPSSEServerConfig)
76+
connection_type = 'SSE' if is_sse else 'SHTTP'
77+
logger.info(
78+
f'Initializing MCP agent for {server} with {connection_type} connection...'
79+
)
80+
client = MCPClient()
81+
82+
try:
83+
if is_sse:
7284
await client.connect_sse(
73-
server_url.url,
74-
api_key=server_url.api_key,
85+
server.url,
86+
api_key=server.api_key,
7587
conversation_id=conversation_id,
7688
)
77-
# Only add the client to the list after a successful connection
78-
mcp_clients.append(client)
79-
logger.info(f'Connected to MCP server {server_url} via SSE')
80-
except Exception as e:
81-
logger.error(
82-
f'Failed to connect to {server_url}: {str(e)}', exc_info=True
89+
else:
90+
await client.connect_shttp(
91+
server.url,
92+
api_key=server.api_key,
93+
conversation_id=conversation_id,
8394
)
84-
try:
85-
await client.disconnect()
86-
except Exception as disconnect_error:
87-
logger.error(
88-
f'Error during disconnect after failed connection: {str(disconnect_error)}'
89-
)
9095

96+
# Only add the client to the list after a successful connection
97+
mcp_clients.append(client)
98+
99+
except Exception as e:
100+
logger.error(f'Failed to connect to {server}: {str(e)}', exc_info=True)
101+
try:
102+
await client.disconnect()
103+
except Exception as disconnect_error:
104+
logger.error(
105+
f'Error during disconnect after failed connection: {str(disconnect_error)}'
106+
)
91107
return mcp_clients
92108

93109

94-
async def fetch_mcp_tools_from_config(mcp_config: MCPConfig) -> list[dict]:
110+
async def fetch_mcp_tools_from_config(
111+
mcp_config: MCPConfig, conversation_id: str | None = None
112+
) -> list[dict]:
95113
"""
96114
Retrieves the list of MCP tools from the MCP clients.
97115
116+
Args:
117+
mcp_config: The MCP configuration
118+
conversation_id: Optional conversation ID to associate with the MCP clients
119+
98120
Returns:
99121
A list of tool dictionaries. Returns an empty list if no connections could be established.
100122
"""
@@ -111,7 +133,7 @@ async def fetch_mcp_tools_from_config(mcp_config: MCPConfig) -> list[dict]:
111133
logger.debug(f'Creating MCP clients with config: {mcp_config}')
112134
# Create clients - this will fetch tools but not maintain active connections
113135
mcp_clients = await create_mcp_clients(
114-
mcp_config.sse_servers,
136+
mcp_config.sse_servers, mcp_config.shttp_servers, conversation_id
115137
)
116138

117139
if not mcp_clients:

openhands/runtime/impl/action_execution/action_execution_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ async def call_tool_mcp(self, action: MCPAction) -> Observation:
464464
)
465465

466466
# Create clients for this specific operation
467-
mcp_clients = await create_mcp_clients(updated_mcp_config.sse_servers, self.sid)
467+
mcp_clients = await create_mcp_clients(updated_mcp_config.sse_servers, updated_mcp_config.shttp_servers, self.sid)
468468

469469
# Call the tool and return the result
470470
# No need for try/finally since disconnect() is now just resetting state

openhands/server/app.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import contextlib
12
import warnings
23
from contextlib import asynccontextmanager
34
from typing import AsyncIterator
@@ -29,6 +30,20 @@
2930
from openhands.server.routes.trajectory import app as trajectory_router
3031
from openhands.server.shared import conversation_manager
3132

33+
mcp_app = mcp_server.http_app(path='/mcp')
34+
35+
36+
def combine_lifespans(*lifespans):
37+
# Create a combined lifespan to manage multiple session managers
38+
@contextlib.asynccontextmanager
39+
async def combined_lifespan(app):
40+
async with contextlib.AsyncExitStack() as stack:
41+
for lifespan in lifespans:
42+
await stack.enter_async_context(lifespan(app))
43+
yield
44+
45+
return combined_lifespan
46+
3247

3348
@asynccontextmanager
3449
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
@@ -40,8 +55,8 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
4055
title='OpenHands',
4156
description='OpenHands: Code Less, Make More',
4257
version=__version__,
43-
lifespan=_lifespan,
44-
routes=[Mount(path='/mcp', app=mcp_server.sse_app())],
58+
lifespan=combine_lifespans(_lifespan, mcp_app.lifespan),
59+
routes=[Mount(path='/mcp', app=mcp_app)],
4560
)
4661

4762

0 commit comments

Comments
 (0)