Skip to content

Commit 9b34a68

Browse files
ducphamle2trungbachquangdz1704xingyaoww
authored andcommitted
feat (backend): Add support for MCP servers natively via CodeActAgent (All-Hands-AI#7637)
Co-authored-by: trungbach <[email protected]> Co-authored-by: quangdz1704 <[email protected]> Co-authored-by: Xingyao Wang <[email protected]>
1 parent 807ccfa commit 9b34a68

40 files changed

+803
-34
lines changed

openhands/agenthub/codeact_agent/codeact_agent.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,21 +62,21 @@ def __init__(
6262
6363
Parameters:
6464
- llm (LLM): The llm to be used by this agent
65+
- config (AgentConfig): The configuration for this agent
6566
"""
6667
super().__init__(llm, config)
6768
self.pending_actions: deque[Action] = deque()
6869
self.reset()
6970

70-
# Retrieve the enabled tools
71-
self.tools = codeact_function_calling.get_tools(
71+
built_in_tools = codeact_function_calling.get_tools(
7272
codeact_enable_browsing=self.config.codeact_enable_browsing,
7373
codeact_enable_jupyter=self.config.codeact_enable_jupyter,
7474
codeact_enable_llm_editor=self.config.codeact_enable_llm_editor,
7575
llm=self.llm,
7676
)
77-
logger.debug(
78-
f"TOOLS loaded for CodeActAgent: {', '.join([tool.get('function').get('name') for tool in self.tools])}"
79-
)
77+
78+
self.tools = built_in_tools
79+
8080
self.prompt_manager = PromptManager(
8181
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts'),
8282
)
@@ -137,10 +137,23 @@ def step(self, state: State) -> Action:
137137
'messages': self.llm.format_messages_for_llm(messages),
138138
}
139139
params['tools'] = self.tools
140+
141+
if self.mcp_tools:
142+
# Only add tools with unique names
143+
existing_names = {tool['function']['name'] for tool in params['tools']}
144+
unique_mcp_tools = [
145+
tool
146+
for tool in self.mcp_tools
147+
if tool['function']['name'] not in existing_names
148+
]
149+
params['tools'] += unique_mcp_tools
150+
140151
# log to litellm proxy if possible
141152
params['extra_body'] = {'metadata': state.to_llm_metadata(agent_name=self.name)}
142153
response = self.llm.completion(**params)
154+
logger.debug(f'Response from LLM: {response}')
143155
actions = codeact_function_calling.response_to_actions(response)
156+
logger.debug(f'Actions after response_to_actions: {actions}')
144157
for action in actions:
145158
self.pending_actions.append(action)
146159
return self.pending_actions.popleft()

openhands/agenthub/codeact_agent/function_calling.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
FunctionCallNotExistsError,
2525
FunctionCallValidationError,
2626
)
27+
from openhands.core.logger import openhands_logger as logger
2728
from openhands.events.action import (
2829
Action,
2930
AgentDelegateAction,
@@ -37,9 +38,11 @@
3738
IPythonRunCellAction,
3839
MessageAction,
3940
)
41+
from openhands.events.action.mcp import McpAction
4042
from openhands.events.event import FileEditSource, FileReadSource
4143
from openhands.events.tool import ToolCallMetadata
4244
from openhands.llm import LLM
45+
from openhands.mcp import MCPClientTool
4346

4447

4548
def combine_thought(action: Action, thought: str) -> Action:
@@ -70,6 +73,7 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
7073
# Process each tool call to OpenHands action
7174
for i, tool_call in enumerate(assistant_msg.tool_calls):
7275
action: Action
76+
logger.debug(f'Tool call in function_calling.py: {tool_call}')
7377
try:
7478
arguments = json.loads(tool_call.function.arguments)
7579
except json.decoder.JSONDecodeError as e:
@@ -191,6 +195,15 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
191195
f'Missing required argument "url" in tool call {tool_call.function.name}'
192196
)
193197
action = BrowseURLAction(url=arguments['url'])
198+
199+
# ================================================
200+
# McpAction (MCP)
201+
# ================================================
202+
elif tool_call.function.name.endswith(MCPClientTool.postfix()):
203+
action = McpAction(
204+
name=tool_call.function.name.rstrip(MCPClientTool.postfix()),
205+
arguments=tool_call.function.arguments,
206+
)
194207
else:
195208
raise FunctionCallNotExistsError(
196209
f'Tool {tool_call.function.name} is not registered. (arguments: {arguments}). Please check the tool name and retry with an existing tool.'

openhands/controller/agent.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def __init__(
3737
self.config = config
3838
self._complete = False
3939
self.prompt_manager: 'PromptManager' | None = None
40+
self.mcp_tools: list[dict] = []
4041

4142
@property
4243
def complete(self) -> bool:
@@ -111,3 +112,11 @@ def list_agents(cls) -> list[str]:
111112
if not bool(cls._registry):
112113
raise AgentNotRegisteredError()
113114
return list(cls._registry.keys())
115+
116+
def set_mcp_tools(self, mcp_tools: list[dict]) -> None:
117+
"""Sets the list of MCP tools for the agent.
118+
119+
Args:
120+
- mcp_tools (list[dict]): The list of MCP tools.
121+
"""
122+
self.mcp_tools = mcp_tools

openhands/core/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
FileEditObservation,
4040
)
4141
from openhands.io import read_task
42+
from openhands.mcp import fetch_mcp_tools_from_config
4243

4344
prompt_session = PromptSession()
4445

@@ -195,7 +196,8 @@ async def main(loop: asyncio.AbstractEventLoop) -> None:
195196
display_message(f'Session ID: {sid}')
196197

197198
agent = create_agent(config)
198-
199+
mcp_tools = await fetch_mcp_tools_from_config(config.mcp)
200+
agent.set_mcp_tools(mcp_tools)
199201
runtime = create_runtime(
200202
config,
201203
sid=sid,

openhands/core/config/app_config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
)
1212
from openhands.core.config.extended_config import ExtendedConfig
1313
from openhands.core.config.llm_config import LLMConfig
14+
from openhands.core.config.mcp_config import MCPConfig
1415
from openhands.core.config.sandbox_config import SandboxConfig
1516
from openhands.core.config.security_config import SecurityConfig
1617

@@ -47,6 +48,7 @@ class AppConfig(BaseModel):
4748
file_uploads_allowed_extensions: Allowed file extensions. `['.*']` allows all.
4849
cli_multiline_input: Whether to enable multiline input in CLI. When disabled,
4950
input is read line by line. When enabled, input continues until /exit command.
51+
mcp: MCP configuration settings.
5052
"""
5153

5254
llms: dict[str, LLMConfig] = Field(default_factory=dict)
@@ -88,6 +90,7 @@ class AppConfig(BaseModel):
8890
max_concurrent_conversations: int = Field(
8991
default=3
9092
) # Maximum number of concurrent agent loops allowed per user
93+
mcp: MCPConfig = Field(default_factory=MCPConfig)
9194

9295
defaults_dict: ClassVar[dict] = {}
9396

openhands/core/config/mcp_config.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from typing import List
2+
from urllib.parse import urlparse
3+
4+
from pydantic import BaseModel, Field, ValidationError
5+
6+
7+
class MCPSSEConfig(BaseModel):
8+
"""Configuration for MCP SSE (Server-Sent Events) settings.
9+
10+
Attributes:
11+
mcp_servers: List of MCP server URLs.
12+
"""
13+
14+
mcp_servers: List[str] = Field(default_factory=list)
15+
16+
model_config = {'extra': 'forbid'}
17+
18+
def validate_servers(self) -> None:
19+
"""Validate that server URLs are valid and unique."""
20+
# Check for duplicate server URLs
21+
if len(set(self.mcp_servers)) != len(self.mcp_servers):
22+
raise ValueError('Duplicate MCP server URLs are not allowed')
23+
24+
# Validate URLs
25+
for url in self.mcp_servers:
26+
try:
27+
result = urlparse(url)
28+
if not all([result.scheme, result.netloc]):
29+
raise ValueError(f'Invalid URL format: {url}')
30+
except Exception as e:
31+
raise ValueError(f'Invalid URL {url}: {str(e)}')
32+
33+
34+
class MCPConfig(BaseModel):
35+
"""Configuration for MCP (Message Control Protocol) settings.
36+
37+
Attributes:
38+
sse: SSE-specific configuration.
39+
"""
40+
41+
sse: MCPSSEConfig = Field(default_factory=MCPSSEConfig)
42+
43+
model_config = {'extra': 'forbid'}
44+
45+
@classmethod
46+
def from_toml_section(cls, data: dict) -> dict[str, 'MCPConfig']:
47+
"""
48+
Create a mapping of MCPConfig instances from a toml dictionary representing the [mcp] section.
49+
50+
The configuration is built from all keys in data.
51+
52+
Returns:
53+
dict[str, MCPConfig]: A mapping where the key "mcp" corresponds to the [mcp] configuration
54+
"""
55+
# Initialize the result mapping
56+
mcp_mapping: dict[str, MCPConfig] = {}
57+
58+
try:
59+
# Create SSE config if present
60+
sse_config = MCPSSEConfig.model_validate(data)
61+
sse_config.validate_servers()
62+
63+
# Create the main MCP config
64+
mcp_mapping['mcp'] = cls(sse=sse_config)
65+
except ValidationError as e:
66+
raise ValueError(f'Invalid MCP configuration: {e}')
67+
68+
return mcp_mapping

openhands/core/config/utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
)
2424
from openhands.core.config.extended_config import ExtendedConfig
2525
from openhands.core.config.llm_config import LLMConfig
26+
from openhands.core.config.mcp_config import MCPConfig
2627
from openhands.core.config.sandbox_config import SandboxConfig
2728
from openhands.core.config.security_config import SecurityConfig
2829
from openhands.storage import get_file_store
@@ -202,6 +203,21 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml') -> None:
202203
# Re-raise ValueError from SandboxConfig.from_toml_section
203204
raise ValueError('Error in [sandbox] section in config.toml')
204205

206+
# Process MCP sections if present
207+
if 'mcp' in toml_config:
208+
try:
209+
mcp_mapping = MCPConfig.from_toml_section(toml_config['mcp'])
210+
# We only use the base mcp config for now
211+
if 'mcp' in mcp_mapping:
212+
cfg.mcp = mcp_mapping['mcp']
213+
except (TypeError, KeyError, ValidationError) as e:
214+
logger.openhands_logger.warning(
215+
f'Cannot parse MCP config from toml, values have not been applied.\nError: {e}'
216+
)
217+
except ValueError:
218+
# Re-raise ValueError from MCPConfig.from_toml_section
219+
raise ValueError('Error in MCP sections in config.toml')
220+
205221
# Process condenser section if present
206222
if 'condenser' in toml_config:
207223
try:
@@ -259,6 +275,7 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml') -> None:
259275
'security',
260276
'sandbox',
261277
'condenser',
278+
'mcp',
262279
}
263280
for key in toml_config:
264281
if key.lower() not in known_sections:

openhands/core/main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from openhands.events.event import Event
3131
from openhands.events.observation import AgentStateChangedObservation
3232
from openhands.io import read_input, read_task
33+
from openhands.mcp import fetch_mcp_tools_from_config
3334
from openhands.memory.memory import Memory
3435
from openhands.runtime.base import Runtime
3536
from openhands.utils.async_utils import call_async_from_sync
@@ -95,6 +96,8 @@ async def run_controller(
9596

9697
if agent is None:
9798
agent = create_agent(config)
99+
mcp_tools = await fetch_mcp_tools_from_config(config.mcp)
100+
agent.set_mcp_tools(mcp_tools)
98101

99102
# when the runtime is created, it will be connected and clone the selected repository
100103
repo_directory = None

openhands/core/schema/action.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ class ActionType(str, Enum):
3838
"""Interact with the browser instance.
3939
"""
4040

41+
MCP = 'call_tool_mcp'
42+
"""Interact with the MCP server.
43+
"""
44+
4145
DELEGATE = 'delegate'
4246
"""Delegates a task to another agent.
4347
"""

openhands/core/schema/observation.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,6 @@ class ObservationType(str, Enum):
4949

5050
RECALL = 'recall'
5151
"""Result of a recall operation. This can be the workspace context, a microagent, or other types of information."""
52+
53+
MCP = 'mcp'
54+
"""Result of a MCP Server operation"""

0 commit comments

Comments
 (0)