diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 3c83f1fa85c0..65d2fe02f5d2 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -289,6 +289,7 @@ export enum I18nKey { OBSERVATION_MESSAGE$EDIT = "OBSERVATION_MESSAGE$EDIT", OBSERVATION_MESSAGE$WRITE = "OBSERVATION_MESSAGE$WRITE", OBSERVATION_MESSAGE$BROWSE = "OBSERVATION_MESSAGE$BROWSE", + OBSERVATION_MESSAGE$MCP_CALL_TOOL = "OBSERVATION_MESSAGE$MCP_CALL_TOOL", EXPANDABLE_MESSAGE$SHOW_DETAILS = "EXPANDABLE_MESSAGE$SHOW_DETAILS", EXPANDABLE_MESSAGE$HIDE_DETAILS = "EXPANDABLE_MESSAGE$HIDE_DETAILS", AI_SETTINGS$TITLE = "AI_SETTINGS$TITLE", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 03a19b499dc5..9c4611962d78 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -4313,6 +4313,21 @@ "es": "Navegación completada", "tr": "Gezinme tamamlandı" }, + "OBSERVATION_MESSAGE$MCP_CALL_TOOL": { + "en": "Called an MCP tool", + "zh-CN": "调用了 MCP 工具", + "zh-TW": "調用了 MCP 工具", + "ko-KR": "MCP 도구 호출됨", + "ja": "MCPツールを呼び出しました", + "no": "Kalt et MCP-verktøy", + "ar": "تم استدعاء أداة MCP", + "de": "Ein MCP-Tool aufgerufen", + "fr": "Outil MCP appelé", + "it": "Chiamato uno strumento MCP", + "pt": "Ferramenta MCP chamada", + "es": "Se ha llamado a una herramienta MCP", + "tr": "Bir MCP aracı çağrıldı" + }, "EXPANDABLE_MESSAGE$SHOW_DETAILS": { "en": "Show details", "zh-CN": "显示详情", diff --git a/frontend/src/services/observations.ts b/frontend/src/services/observations.ts index d867ff971e12..1b1df446b1f6 100644 --- a/frontend/src/services/observations.ts +++ b/frontend/src/services/observations.ts @@ -50,6 +50,7 @@ export function handleObservationMessage(message: ObservationMessage) { case ObservationType.READ: case ObservationType.EDIT: case ObservationType.THINK: + case ObservationType.MCP_CALL_TOOL: case ObservationType.NULL: break; // We don't display the default message for these observations default: @@ -125,6 +126,21 @@ export function handleObservationMessage(message: ObservationMessage) { }), ); break; + case "mcp_call_tool": + store.dispatch( + addAssistantObservation({ + ...baseObservation, + observation: "mcp_call_tool" as const, + extras: { + tool_name: String(message.extras.tool_name || ""), + kwargs: + typeof message.extras.kwargs === "object" + ? (message.extras.kwargs as Record) + : {}, + }, + }), + ); + break; case "delegate": store.dispatch( addAssistantObservation({ diff --git a/frontend/src/state/chat-slice.ts b/frontend/src/state/chat-slice.ts index 97e19e65e30b..f53fdff8a0c4 100644 --- a/frontend/src/state/chat-slice.ts +++ b/frontend/src/state/chat-slice.ts @@ -22,6 +22,7 @@ const HANDLED_ACTIONS: OpenHandsEventType[] = [ "browse", "browse_interactive", "edit", + "mcp_call_tool", ]; function getRiskText(risk: ActionSecurityRisk) { @@ -112,6 +113,12 @@ export const chatSlice = createSlice({ } else if (actionID === "browse_interactive") { // Include the browser_actions in the content text = `**Action:**\n\n\`\`\`python\n${action.payload.args.browser_actions}\n\`\`\``; + } else if (actionID === "mcp_call_tool") { + text = `Tool name: \`${action.payload.args.tool_name}\`\n\nArguments:\n\`\`\`python\n${JSON.stringify( + action.payload.args.kwargs, + null, + 2, + )}\n\`\`\``; } if (actionID === "run" || actionID === "run_ipython") { if ( @@ -193,6 +200,18 @@ export const chatSlice = createSlice({ } else { causeMessage.content = observation.payload.content; } + } else if (observationID === "mcp_call_tool") { + let { content } = observation.payload; + if (content.length > MAX_CONTENT_LENGTH) { + content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`; + } + const toolName = observation.payload.extras.tool_name; + const toolKwargs = JSON.stringify( + observation.payload.extras.kwargs, + null, + 2, + ); + causeMessage.content = `Tool name: \`${toolName}\`\n\nArguments:\n\`\`\`python\n${toolKwargs}\n\`\`\`\n\nOutput:\n\`\`\`python\n${content.trim()}\n\`\`\``; } else if (observationID === "browse") { let content = `**URL:** ${observation.payload.extras.url}\n`; if (observation.payload.extras.error) { diff --git a/frontend/src/types/action-type.tsx b/frontend/src/types/action-type.tsx index 315abcc7b074..638fbca2be3f 100644 --- a/frontend/src/types/action-type.tsx +++ b/frontend/src/types/action-type.tsx @@ -29,6 +29,9 @@ enum ActionType { // Logs a thought. THINK = "think", + // Calls an MCP tool + MCP_CALL_TOOL = "mcp_call_tool", + // If you're absolutely certain that you've completed your task and have tested your work, // use the finish action to stop working. FINISH = "finish", diff --git a/frontend/src/types/core/actions.ts b/frontend/src/types/core/actions.ts index 4f018e587f8a..ce7a2fdd6e3e 100644 --- a/frontend/src/types/core/actions.ts +++ b/frontend/src/types/core/actions.ts @@ -58,6 +58,16 @@ export interface FinishAction extends OpenHandsActionEvent<"finish"> { }; } +export interface MCPCallToolAction + extends OpenHandsActionEvent<"mcp_call_tool"> { + source: "agent"; + args: { + thought: string; + tool_name: string; + kwargs: Record; + }; +} + export interface DelegateAction extends OpenHandsActionEvent<"delegate"> { source: "agent"; timeout: number; @@ -146,4 +156,5 @@ export type OpenHandsAction = | FileReadAction | FileEditAction | FileWriteAction + | MCPCallToolAction | RejectAction; diff --git a/frontend/src/types/core/base.ts b/frontend/src/types/core/base.ts index e67b7af7c737..bff3faaceec8 100644 --- a/frontend/src/types/core/base.ts +++ b/frontend/src/types/core/base.ts @@ -12,6 +12,7 @@ export type OpenHandsEventType = | "reject" | "think" | "finish" + | "mcp_call_tool" | "error"; interface OpenHandsBaseEvent { diff --git a/frontend/src/types/core/observations.ts b/frontend/src/types/core/observations.ts index 4224aa5e4b42..b7c048102503 100644 --- a/frontend/src/types/core/observations.ts +++ b/frontend/src/types/core/observations.ts @@ -109,6 +109,15 @@ export interface AgentThinkObservation }; } +export interface MCPCallToolObservation + extends OpenHandsObservationEvent<"mcp_call_tool"> { + source: "agent"; + extras: { + tool_name: string; + kwargs?: Record; + }; +} + export type OpenHandsObservation = | AgentStateChangeObservation | AgentThinkObservation @@ -120,4 +129,5 @@ export type OpenHandsObservation = | WriteObservation | ReadObservation | EditObservation + | MCPCallToolObservation | ErrorObservation; diff --git a/frontend/src/types/observation-type.tsx b/frontend/src/types/observation-type.tsx index 08e7d3bffa5a..80b5e9182382 100644 --- a/frontend/src/types/observation-type.tsx +++ b/frontend/src/types/observation-type.tsx @@ -29,6 +29,9 @@ enum ObservationType { // A response to the agent's thought (usually a static message) THINK = "think", + // MCP tool call result + MCP_CALL_TOOL = "mcp_call_tool", + // A no-op observation NULL = "null", } diff --git a/openhands/agenthub/codeact_agent/function_calling.py b/openhands/agenthub/codeact_agent/function_calling.py index d6f0c4b5aa4b..52ecbce93383 100644 --- a/openhands/agenthub/codeact_agent/function_calling.py +++ b/openhands/agenthub/codeact_agent/function_calling.py @@ -15,6 +15,7 @@ FinishTool, IPythonTool, LLMBasedFileEditTool, + MCPCallTool, ThinkTool, WebReadTool, create_cmd_run_tool, @@ -35,6 +36,7 @@ FileEditAction, FileReadAction, IPythonRunCellAction, + MCPCallToolAction, MessageAction, ) from openhands.events.event import FileEditSource, FileReadSource @@ -191,6 +193,20 @@ def response_to_actions(response: ModelResponse) -> list[Action]: f'Missing required argument "url" in tool call {tool_call.function.name}' ) action = BrowseURLAction(url=arguments['url']) + + # ================================================ + # MCPCallTool (MCP) + # ================================================ + elif tool_call.function.name == MCPCallTool['function']['name']: + if 'tool_name' not in arguments: + raise FunctionCallValidationError( + f'Missing required argument "tool_name" in tool call {tool_call.function.name}' + ) + action = MCPCallToolAction( + tool_name=arguments['tool_name'], + kwargs=arguments['kwargs'] if 'kwargs' in arguments else None, + ) + else: raise FunctionCallNotExistsError( f'Tool {tool_call.function.name} is not registered. (arguments: {arguments}). Please check the tool name and retry with an existing tool.' @@ -245,6 +261,7 @@ def get_tools( create_cmd_run_tool(use_simplified_description=use_simplified_tool_desc), ThinkTool, FinishTool, + MCPCallTool, ] if codeact_enable_browsing: tools.append(WebReadTool) diff --git a/openhands/agenthub/codeact_agent/tools/__init__.py b/openhands/agenthub/codeact_agent/tools/__init__.py index 49dcba2ebbe5..497ff0c261e7 100644 --- a/openhands/agenthub/codeact_agent/tools/__init__.py +++ b/openhands/agenthub/codeact_agent/tools/__init__.py @@ -3,6 +3,7 @@ from .finish import FinishTool from .ipython import IPythonTool from .llm_based_edit import LLMBasedFileEditTool +from .mcp import MCPCallTool from .str_replace_editor import create_str_replace_editor_tool from .think import ThinkTool from .web_read import WebReadTool @@ -16,4 +17,5 @@ 'create_str_replace_editor_tool', 'WebReadTool', 'ThinkTool', + 'MCPCallTool', ] diff --git a/openhands/agenthub/codeact_agent/tools/mcp.py b/openhands/agenthub/codeact_agent/tools/mcp.py new file mode 100644 index 000000000000..eae56c0114a5 --- /dev/null +++ b/openhands/agenthub/codeact_agent/tools/mcp.py @@ -0,0 +1,33 @@ +from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk + +_MCP_CALL_DESCRIPTION = """Custom tool to call other tools exposed by MCP (Model Context Protocol) servers. + +For example, if you see a tool in the format: \ +"Tool(name='tool_name', description='Tool description', inputSchema={'type': 'object', 'properties': +{'param1': {'type': 'string'}}})" + +You can call it by setting `tool_name` to `tool_name` and `kwargs` to \ +`{'param1': 'value'}`. \ +""" + +MCPCallTool = ChatCompletionToolParam( + type='function', + function=ChatCompletionToolParamFunctionChunk( + name='mcp_call_tool', + description=_MCP_CALL_DESCRIPTION, + parameters={ + 'type': 'object', + 'properties': { + 'tool_name': { + 'type': 'string', + 'description': 'The name of the tool to call.', + }, + 'kwargs': { + 'type': 'object', + 'description': 'The optional arguments dict to pass to the tool call.', + }, + }, + 'required': ['tool_name'], + }, + ), +) diff --git a/openhands/core/schema/action.py b/openhands/core/schema/action.py index 9e24bea54221..b1356413312b 100644 --- a/openhands/core/schema/action.py +++ b/openhands/core/schema/action.py @@ -83,3 +83,6 @@ class ActionType(str, Enum): CONDENSATION = 'condensation' """Condenses a list of events into a summary.""" + + MCP_CALL_TOOL = 'mcp_call_tool' + """Calls a MCP tool.""" diff --git a/openhands/core/schema/observation.py b/openhands/core/schema/observation.py index e10e5460b3ee..543c42a28aa0 100644 --- a/openhands/core/schema/observation.py +++ b/openhands/core/schema/observation.py @@ -49,3 +49,6 @@ class ObservationType(str, Enum): RECALL = 'recall' """Result of a recall operation. This can be the workspace context, a microagent, or other types of information.""" + + MCP_CALL_TOOL = 'mcp_call_tool' + """Result of a tool call to the MCP server.""" diff --git a/openhands/core/setup.py b/openhands/core/setup.py index b1491c9911cd..224631573e2a 100644 --- a/openhands/core/setup.py +++ b/openhands/core/setup.py @@ -162,6 +162,13 @@ def create_memory( ) memory.load_user_workspace_microagents(microagents) + # get MCP tools from the runtime + mcp_tools_definition = runtime.get_mcp_tool_definitions( + memory.get_mcp_configs() + ) + logger.info(f'All MCP tools: {mcp_tools_definition}') + memory.populate_mcp_tool_definitions(mcp_tools_definition) + if selected_repository and repo_directory: memory.set_repository_info(selected_repository, repo_directory) diff --git a/openhands/events/action/__init__.py b/openhands/events/action/__init__.py index bd610678a11e..fdb6c9bb66c6 100644 --- a/openhands/events/action/__init__.py +++ b/openhands/events/action/__init__.py @@ -15,6 +15,7 @@ FileReadAction, FileWriteAction, ) +from openhands.events.action.mcp import MCPCallToolAction from openhands.events.action.message import MessageAction __all__ = [ @@ -35,4 +36,5 @@ 'ActionConfirmationStatus', 'AgentThinkAction', 'RecallAction', + 'MCPCallToolAction', ] diff --git a/openhands/events/action/mcp.py b/openhands/events/action/mcp.py new file mode 100644 index 000000000000..730de6c3ef99 --- /dev/null +++ b/openhands/events/action/mcp.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from typing import Any, ClassVar + +from openhands.core.schema import ActionType +from openhands.events.action.action import Action + + +@dataclass +class MCPCallToolAction(Action): + runnable: ClassVar[bool] = True + thought: str = '' + tool_name: str = '' + kwargs: dict[str, Any] | None = None + action: str = ActionType.MCP_CALL_TOOL + + @property + def message(self) -> str: + msg: str = f'Calling MCP tool `{self.tool_name}` with arguments: {self.kwargs}' + return msg diff --git a/openhands/events/observation/__init__.py b/openhands/events/observation/__init__.py index 9ca577c300f4..8fa7e931ee90 100644 --- a/openhands/events/observation/__init__.py +++ b/openhands/events/observation/__init__.py @@ -21,6 +21,7 @@ FileReadObservation, FileWriteObservation, ) +from openhands.events.observation.mcp import MCPCallToolObservation from openhands.events.observation.observation import Observation from openhands.events.observation.reject import UserRejectObservation from openhands.events.observation.success import SuccessObservation @@ -44,4 +45,5 @@ 'AgentCondensationObservation', 'RecallObservation', 'RecallType', + 'MCPCallToolObservation', ] diff --git a/openhands/events/observation/mcp.py b/openhands/events/observation/mcp.py new file mode 100644 index 000000000000..f8d86d1721aa --- /dev/null +++ b/openhands/events/observation/mcp.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from typing import Any + +from openhands.core.schema import ObservationType +from openhands.events.observation.observation import Observation + + +@dataclass +class MCPCallToolObservation(Observation): + observation: str = ObservationType.MCP_CALL_TOOL + tool_name: str = '' + kwargs: dict[str, Any] | None = None + + @property + def message(self) -> str: + return self.content diff --git a/openhands/events/serialization/action.py b/openhands/events/serialization/action.py index 9e6d366cb658..0cf375e5ee46 100644 --- a/openhands/events/serialization/action.py +++ b/openhands/events/serialization/action.py @@ -22,6 +22,7 @@ FileReadAction, FileWriteAction, ) +from openhands.events.action.mcp import MCPCallToolAction from openhands.events.action.message import MessageAction actions = ( @@ -41,6 +42,7 @@ ChangeAgentStateAction, MessageAction, CondensationAction, + MCPCallToolAction, ) ACTION_TYPE_TO_CLASS = {action_class.action: action_class for action_class in actions} # type: ignore[attr-defined] diff --git a/openhands/events/serialization/observation.py b/openhands/events/serialization/observation.py index 6785468da394..e1ea14612c2b 100644 --- a/openhands/events/serialization/observation.py +++ b/openhands/events/serialization/observation.py @@ -25,6 +25,7 @@ FileReadObservation, FileWriteObservation, ) +from openhands.events.observation.mcp import MCPCallToolObservation from openhands.events.observation.observation import Observation from openhands.events.observation.reject import UserRejectObservation from openhands.events.observation.success import SuccessObservation @@ -45,6 +46,7 @@ AgentCondensationObservation, AgentThinkObservation, RecallObservation, + MCPCallToolObservation, ) OBSERVATION_TYPE_TO_CLASS = { diff --git a/openhands/memory/conversation_memory.py b/openhands/memory/conversation_memory.py index 5858639fb080..3973d3752a6d 100644 --- a/openhands/memory/conversation_memory.py +++ b/openhands/memory/conversation_memory.py @@ -15,6 +15,7 @@ FileEditAction, FileReadAction, IPythonRunCellAction, + MCPCallToolAction, MessageAction, ) from openhands.events.event import Event, RecallType @@ -27,6 +28,7 @@ FileEditObservation, FileReadObservation, IPythonRunCellObservation, + MCPCallToolObservation, UserRejectObservation, ) from openhands.events.observation.agent import ( @@ -191,6 +193,7 @@ def _process_action( FileReadAction, BrowseInteractiveAction, BrowseURLAction, + MCPCallToolAction, ), ) or (isinstance(action, CmdRunAction) and action.source == 'agent'): tool_metadata = action.tool_call_metadata @@ -396,6 +399,9 @@ def _process_observation( elif isinstance(obs, AgentCondensationObservation): text = truncate_content(obs.content, max_message_chars) message = Message(role='user', content=[TextContent(text=text)]) + elif isinstance(obs, MCPCallToolObservation): + text = truncate_content(obs.content, max_message_chars) + message = Message(role='user', content=[TextContent(text=text)]) elif ( isinstance(obs, RecallObservation) and self.agent_config.enable_prompt_extensions diff --git a/openhands/memory/memory.py b/openhands/memory/memory.py index 948aefede3f6..aeac1070007d 100644 --- a/openhands/memory/memory.py +++ b/openhands/memory/memory.py @@ -4,6 +4,8 @@ from datetime import datetime, timezone from typing import Callable +from mcp import StdioServerParameters + import openhands from openhands.core.logger import openhands_logger as logger from openhands.events.action.agent import RecallAction @@ -299,3 +301,33 @@ async def _send_status_message(self, msg_type: str, id: str, message: str): """Sends a status message to the client.""" if self.status_callback: self.status_callback(msg_type, id, message) + + def get_mcp_configs(self) -> dict[str, dict[str, StdioServerParameters]]: + """Get the dictionary mapping from microagent name to MCP server configurations.""" + microagent_name_to_mcp_configs = {} + for microagent_name, microagent in self.knowledge_microagents.items(): + microagent_name_to_mcp_configs[microagent_name] = ( + microagent.metadata.mcp_servers + ) + return microagent_name_to_mcp_configs + + def populate_mcp_tool_definitions( + self, microagent_name_to_mcp_tool_definitions: dict[str, dict[str, str]] + ) -> None: + """Populate the knowledge microagents with the MCP tools' definitions.""" + # Append the MCP tools' definition to the knowledge microagent's content + for microagent_name, microagent in self.knowledge_microagents.items(): + if microagent_name not in microagent_name_to_mcp_tool_definitions: + continue + + formatted_tools = 'MCP Tools that you can use via the `mcp_call_tool`:\n\n' + # Format the mcp_tools dict into a string + for server_name, tools_def in microagent_name_to_mcp_tool_definitions[ + microagent_name + ].items(): + formatted_tools += f' - {server_name}:\n{tools_def}\n' + + microagent.content += f'\n{formatted_tools}' + logger.info( + f'Updated content for microagent {microagent_name} with MCP tools loaded: {microagent.content}' + ) diff --git a/openhands/microagent/types.py b/openhands/microagent/types.py index 0962553d93b2..c3775101d29c 100644 --- a/openhands/microagent/types.py +++ b/openhands/microagent/types.py @@ -1,5 +1,6 @@ from enum import Enum +from mcp import StdioServerParameters from pydantic import BaseModel, Field @@ -19,6 +20,9 @@ class MicroAgentMetadata(BaseModel): version: str = Field(default='1.0.0') agent: str = Field(default='CodeActAgent') triggers: list[str] = [] # optional, only exists for knowledge microagents + mcp_servers: dict[ + str, StdioServerParameters + ] = {} # optional, map from server name to config class TaskInput(BaseModel): diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index 3ad16c0948b5..1a41076b0b5f 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -14,6 +14,7 @@ from zipfile import ZipFile import httpx +from mcp import StdioServerParameters from pydantic import SecretStr from openhands.core.config import AppConfig, SandboxConfig @@ -30,6 +31,7 @@ FileReadAction, FileWriteAction, IPythonRunCellAction, + MCPCallToolAction, ) from openhands.events.event import Event from openhands.events.observation import ( @@ -37,6 +39,7 @@ CmdOutputObservation, ErrorObservation, FileReadObservation, + MCPCallToolObservation, NullObservation, Observation, UserRejectObservation, @@ -157,6 +160,12 @@ def __init__( # TODO: remove once done debugging expired github token self.prev_token: SecretStr | None = None + # MCP configs + self.microagent_name_to_mcp_configs: ( + dict[str, dict[str, StdioServerParameters]] | None + ) = None + self.microagent_name_to_tools_def: dict[str, dict[str, str]] | None = None + def setup_initial_env(self) -> None: if self.attach_to_existing: return @@ -464,6 +473,41 @@ def get_microagents_from_selected_repo( return loaded_microagents + def get_mcp_tool_definitions( + self, + microagent_name_to_mcp_configs: dict[str, dict[str, StdioServerParameters]], + ) -> dict[str, dict[str, str]]: + """Get the list of tools exposed by all MCP servers from all microagents.""" + self.microagent_name_to_mcp_configs = microagent_name_to_mcp_configs + + microagent_name_to_tools_def = {} + for microagent_name, mcp_configs_dict in microagent_name_to_mcp_configs.items(): + if not mcp_configs_dict: + continue + + server_name_to_tools_def = {} + for server_name, server_config in mcp_configs_dict.items(): + if not server_config: + continue + + ipython_code = f"""\ + tools = await list_tools(config={server_config.model_dump()}) + print(tools)""" + action = IPythonRunCellAction(ipython_code, include_extra=False) + obs = self.run_action(action) + # Check if we can retrieve tool definitions from the server + if 'Tool(' not in obs.content: + raise RuntimeError( + f'Failed to retrieve tool definitions from MCP server {server_name}: {obs.content}' + ) + self.log('info', f'Got tools for {server_name}: {obs.content}') + server_name_to_tools_def[server_name] = obs.content + + microagent_name_to_tools_def[microagent_name] = server_name_to_tools_def + + self.microagent_name_to_tools_def = microagent_name_to_tools_def + return microagent_name_to_tools_def + def run_action(self, action: Action) -> Observation: """Run an action and return the resulting observation. If the action is not runnable in any runtime, a NullObservation is returned. @@ -538,6 +582,52 @@ def browse(self, action: BrowseURLAction) -> Observation: def browse_interactive(self, action: BrowseInteractiveAction) -> Observation: pass + def mcp_call_tool(self, action: MCPCallToolAction) -> Observation: + """Call a tool exposed by the MCP server.""" + tool_name = action.tool_name + + if ( + not self.microagent_name_to_tools_def + or not self.microagent_name_to_mcp_configs + ): + return ErrorObservation('MCP tool definitions or configs not found.') + + # Get the MCP server config + server_name: str = '' + for _, server_to_tools_def in self.microagent_name_to_tools_def.items(): + search_str = f"Tool(name='{tool_name}'," + for server, tools_list in server_to_tools_def.items(): + if search_str in tools_list: + server_name = server + break + + if not server_name: + return ErrorObservation(f'MCP server not found for tool: {tool_name}') + + # Get the MCP server config + mcp_config: StdioServerParameters | None = None + for _, server_to_config in self.microagent_name_to_mcp_configs.items(): + if server_name in server_to_config: + mcp_config = server_to_config[server_name] + break + + if not mcp_config: + return ErrorObservation(f'MCP config not found for server: {server_name}') + + # Call the tool + ipython_code = f"""\ + result = await call_tool({mcp_config.model_dump()}, '{tool_name}', {action.kwargs}) + print(result)""" + + call_action = IPythonRunCellAction(ipython_code, include_extra=False) + obs = self.run_ipython(call_action) + + return MCPCallToolObservation( + tool_name=tool_name, + kwargs=action.kwargs, + content=obs.content, + ) + # ==================================================================== # File operations # ==================================================================== diff --git a/openhands/runtime/plugins/agent_skills/agentskills.py b/openhands/runtime/plugins/agent_skills/agentskills.py index 046f8af20c61..918d70a085de 100644 --- a/openhands/runtime/plugins/agent_skills/agentskills.py +++ b/openhands/runtime/plugins/agent_skills/agentskills.py @@ -27,5 +27,9 @@ # Add file_editor (a function) from openhands.runtime.plugins.agent_skills.file_editor import file_editor # noqa: E402 +from openhands.runtime.plugins.agent_skills.mcp import ( # noqa: E402 + call_tool, + list_tools, +) -__all__ += ['file_editor'] +__all__ += ['file_editor', 'list_tools', 'call_tool'] diff --git a/openhands/runtime/plugins/agent_skills/mcp/__init__.py b/openhands/runtime/plugins/agent_skills/mcp/__init__.py new file mode 100644 index 000000000000..3865b1367ded --- /dev/null +++ b/openhands/runtime/plugins/agent_skills/mcp/__init__.py @@ -0,0 +1,6 @@ +from openhands.runtime.plugins.agent_skills.mcp.session import call_tool, list_tools + +__all__ = [ + 'list_tools', + 'call_tool', +] diff --git a/openhands/runtime/plugins/agent_skills/mcp/session.py b/openhands/runtime/plugins/agent_skills/mcp/session.py new file mode 100644 index 000000000000..c8339743ace0 --- /dev/null +++ b/openhands/runtime/plugins/agent_skills/mcp/session.py @@ -0,0 +1,100 @@ +from contextlib import AsyncExitStack, asynccontextmanager +from typing import Any + +from mcp import ClientSession, StdioServerParameters, stdio_client + + +@asynccontextmanager +async def _create_session(config: StdioServerParameters): + """ + Create a temporary session for a single request. + + Args: + config: Configuration dictionary for StdIO MCP servers + + Yields: + An initialized ClientSession + """ + # Set defaults for optional parameters + args = config.get('args', []) + env = config.get('env', None) + encoding = config.get('encoding', 'utf-8') + encoding_error_handler = config.get('encoding_error_handler', 'strict') + + # Create server parameters + server_params = StdioServerParameters( + command=config['command'], + args=args, + env=env, + encoding=encoding, + encoding_error_handler=encoding_error_handler, + ) + + # Create and yield session + async with AsyncExitStack() as stack: + try: + stdio_transport = await stack.enter_async_context( + stdio_client(server_params) + ) + stdio, write = stdio_transport + session = await stack.enter_async_context(ClientSession(stdio, write)) + await session.initialize() + yield session + except Exception as e: + raise ConnectionError(f'Failed to connect to MCP server: {str(e)}') from e + + +async def list_tools(config: StdioServerParameters): + """ + List all available tools from the MCP server. + Automatically handles connection and cleanup. + + Args: + config: Configuration dictionary for StdIO MCP servers + + Returns: + List of tool objects + + Raises: + ConnectionError: If connection to the server fails + RuntimeError: If listing tools fails + """ + async with _create_session(config) as session: + try: + response = await session.list_tools() + return response.tools + except Exception as e: + raise RuntimeError(f'Failed to list tools: {str(e)}') from e + + +async def call_tool( + config: StdioServerParameters, + tool_name: str, + input_data: dict[str, Any] | None = None, +): + """ + Call a specific tool on the MCP server. + Automatically handles connection and cleanup. + + Args: + config: Configuration dictionary for StdIO MCP servers + tool_name: Name of the tool to call + input_data: Input data for the tool + + Returns: + The tool's response + + Raises: + ValueError: If tool name is empty + ConnectionError: If connection to the server fails + RuntimeError: If the tool call fails + """ + if not tool_name: + raise ValueError('Tool name cannot be empty') + + async with _create_session(config) as session: + try: + response = await session.call_tool(tool_name, input_data) + return response + except Exception as e: + raise RuntimeError(f"Failed to call tool '{tool_name}': {str(e)}") from e diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py index 27daf479fb10..f4b98f77ece1 100644 --- a/openhands/server/session/agent_session.py +++ b/openhands/server/session/agent_session.py @@ -414,6 +414,10 @@ async def _create_memory( ) memory.load_user_workspace_microagents(microagents) + # get MCP tools from the runtime + mcp_tools = self.runtime.get_mcp_tool_definitions(memory.get_mcp_configs()) + memory.populate_mcp_tool_definitions(mcp_tools) + if selected_repository and repo_directory: memory.set_repository_info(selected_repository, repo_directory) return memory diff --git a/poetry.lock b/poetry.lock index bc7732dadc0a..3b3ed60d07c3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2652,7 +2652,7 @@ grpcio = {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_versi grpcio-status = {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} proto-plus = [ {version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""}, - {version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""}, + {version = ">=1.22.3,<2.0.0dev"}, ] protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" requests = ">=2.18.0,<3.0.0.dev0" @@ -2865,7 +2865,7 @@ google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" grpc-google-iam-v1 = ">=0.14.0,<1.0.0dev" proto-plus = [ {version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""}, - {version = ">=1.22.3,<2.0.0dev"}, + {version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""}, ] protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" @@ -3351,6 +3351,18 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "httpx-sse" +version = "0.4.0" +description = "Consume Server-Sent Event (SSE) messages with HTTPX." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"}, + {file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"}, +] + [[package]] name = "huggingface-hub" version = "0.29.2" @@ -4776,6 +4788,33 @@ files = [ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "mcp" +version = "1.4.1" +description = "Model Context Protocol SDK" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "mcp-1.4.1-py3-none-any.whl", hash = "sha256:a7716b1ec1c054e76f49806f7d96113b99fc1166fc9244c2c6f19867cb75b593"}, + {file = "mcp-1.4.1.tar.gz", hash = "sha256:b9655d2de6313f9d55a7d1df62b3c3fe27a530100cc85bf23729145b0dba4c7a"}, +] + +[package.dependencies] +anyio = ">=4.5" +httpx = ">=0.27" +httpx-sse = ">=0.4" +pydantic = ">=2.7.2,<3.0.0" +pydantic-settings = ">=2.5.2" +sse-starlette = ">=1.6.1" +starlette = ">=0.27" +uvicorn = ">=0.23.1" + +[package.extras] +cli = ["python-dotenv (>=1.0.0)", "typer (>=0.12.4)"] +rich = ["rich (>=13.9.4)"] +ws = ["websockets (>=15.0.1)"] + [[package]] name = "mdurl" version = "0.1.2" @@ -6408,6 +6447,27 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydantic-settings" +version = "2.8.1" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c"}, + {file = "pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" + +[package.extras] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + [[package]] name = "pydeck" version = "0.9.1" @@ -9828,4 +9888,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "dcc17facf3429a7f9b38f41228e63a5ece541047a8f80052b263a368094cd2af" +content-hash = "23f9a3236d175a377c4fedde0cafc880b8a6e62eeb867fac3b14cfce5a304725" diff --git a/pyproject.toml b/pyproject.toml index 3d9b59c7823f..01ee8cc9b839 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,7 @@ qtconsole = "^5.6.1" memory-profiler = "^0.61.0" daytona-sdk = "0.11.2" python-json-logger = "^3.2.1" +mcp = "^1.4.1" [tool.poetry.group.dev.dependencies] ruff = "0.11.2" diff --git a/tests/runtime/conftest.py b/tests/runtime/conftest.py index bb0c1eca696b..44da71c34ed1 100644 --- a/tests/runtime/conftest.py +++ b/tests/runtime/conftest.py @@ -214,6 +214,7 @@ def _load_runtime( force_rebuild_runtime: bool = False, runtime_startup_env_vars: dict[str, str] | None = None, docker_runtime_kwargs: dict[str, str] | None = None, + use_host_network: bool = False, ) -> tuple[Runtime, AppConfig]: sid = 'rt_' + str(random.randint(100000, 999999)) @@ -226,6 +227,7 @@ def _load_runtime( config.sandbox.force_rebuild_runtime = force_rebuild_runtime config.sandbox.keep_runtime_alive = False config.sandbox.docker_runtime_kwargs = docker_runtime_kwargs + config.sandbox.use_host_network = use_host_network # Folder where all tests create their own folder global test_mount_path if use_workspace: diff --git a/tests/runtime/test_runtime_mcp.py b/tests/runtime/test_runtime_mcp.py new file mode 100644 index 000000000000..c7bf477bbdf5 --- /dev/null +++ b/tests/runtime/test_runtime_mcp.py @@ -0,0 +1,83 @@ +"""Runtime tests for the MCP wrapper functionality.""" + +from conftest import _close_test_runtime, _load_runtime + +from openhands.core.logger import openhands_logger as logger +from openhands.events.action import CmdRunAction, IPythonRunCellAction + + +def test_mcp_postgres_integration(temp_dir, runtime_cls): + """Test MCP wrapper integration with a postgres server.""" + runtime, _ = _load_runtime( + temp_dir, + runtime_cls, + use_host_network=True, + ) + + # Install postgres + action = CmdRunAction( + command='sudo apt-get update && sudo apt-get install -y postgresql postgresql-contrib' + ) + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert obs.exit_code == 0 + + # Start PostgreSQL service using `service` command + action = CmdRunAction(command='sudo service postgresql start') + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert obs.exit_code == 0 + + # Set password for `postgres` user + action = CmdRunAction( + command='sudo -u postgres psql -c "ALTER USER postgres WITH PASSWORD \'yourpassword\';"' + ) + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert obs.exit_code == 0 + + # Create test table and data + action = CmdRunAction( + command=""" + sudo -u postgres psql -c "CREATE TABLE students (id SERIAL PRIMARY KEY, name TEXT, grade INTEGER);" && + sudo -u postgres psql -c "INSERT INTO students (name, grade) VALUES ('Alice', 95), ('Bob', 87), ('Charlie', 92);" + """ + ) + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert obs.exit_code == 0 + + # Test MCP wrapper via IPython + test_code = """ +config = { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://postgres:yourpassword@localhost:5432/postgres" + ] +} + +# List available tools +tools = await list_tools(config) +print("Available tools:", tools) +assert len(tools) > 0 + +# Query students table +result = await call_tool(config, "query", {"sql": "SELECT * FROM students;"}) +print("Query result:", result) +assert len(result["rows"]) == 3 # We inserted 3 students +""" + + action = IPythonRunCellAction(code=test_code) + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'Available tools' in obs.content + assert 'Query result' in obs.content + + _close_test_runtime(runtime) diff --git a/tests/unit/test_cli_sid.py b/tests/unit/test_cli_sid.py index 67db79b63e19..2f6381d2ca8e 100644 --- a/tests/unit/test_cli_sid.py +++ b/tests/unit/test_cli_sid.py @@ -29,6 +29,8 @@ def mock_runtime(): mock_runtime_instance.status_callback = None # Mock get_microagents_from_selected_repo mock_runtime_instance.get_microagents_from_selected_repo = Mock(return_value=[]) + # Mock get_mcp_tool_definitions + mock_runtime_instance.get_mcp_tool_definitions = Mock(return_value={}) mock_create_runtime.return_value = mock_runtime_instance yield mock_runtime_instance