diff --git a/helpers/mcp_handler.py b/helpers/mcp_handler.py index b1f888ec1..e30c3916c 100644 --- a/helpers/mcp_handler.py +++ b/helpers/mcp_handler.py @@ -21,6 +21,7 @@ from contextlib import AsyncExitStack from shutil import which from datetime import timedelta import json +import shlex import uuid from helpers import errors from helpers import settings @@ -112,6 +113,44 @@ def _normalize_disabled_tools(value: Any) -> list[str]: return [str(item).strip() for item in value if str(item).strip()] +def _split_stdio_command(command: Any) -> tuple[str, list[str]]: + text = str(command or "").strip() + if not text: + return "", [] + try: + parts = shlex.split(text) + except ValueError: + return text, [] + if not parts: + return "", [] + return parts[0], parts[1:] + + +def _split_stdio_arg_fragment(arg: str) -> list[str]: + try: + parts = shlex.split(arg) + except ValueError: + return [arg] + if len(parts) <= 1: + return parts or [] + if parts[0].startswith("-") and "=" not in parts[0]: + return parts + if any(part.startswith("-") for part in parts[1:]): + return parts + return [arg] + + +def _normalize_stdio_args(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + args: list[str] = [] + for item in value: + text = str(item).strip() + if text: + args.extend(_split_stdio_arg_fragment(text)) + return args + + def initialize_mcp(mcp_servers_config: str): if not MCPConfig.get_instance().is_initialized(): try: @@ -649,6 +688,15 @@ class MCPServerLocal(BaseModel): def update(self, config: dict[str, Any]) -> "MCPServerLocal": with self.__lock: + command = self.command + command_args: list[str] = [] + args = list(self.args) + if "command" in config: + command, command_args = _split_stdio_command(config.get("command")) + args = [*command_args, *args] + if "args" in config: + args = [*command_args, *_normalize_stdio_args(config.get("args"))] + for key, value in config.items(): if key in [ "name", @@ -665,11 +713,15 @@ class MCPServerLocal(BaseModel): "disabled_tools", "scope", ]: + if key in ["command", "args"]: + continue if key == "name": value = normalize_name(value) if key == "disabled_tools": value = _normalize_disabled_tools(value) setattr(self, key, value) + self.command = command + self.args = args return self async def initialize(self) -> "MCPServerLocal": diff --git a/helpers/mcp_handler.py.dox.md b/helpers/mcp_handler.py.dox.md index 4faff9fe1..1cab10612 100644 --- a/helpers/mcp_handler.py.dox.md +++ b/helpers/mcp_handler.py.dox.md @@ -66,6 +66,9 @@ - `_is_streaming_http_type(server_type: str) -> bool`: Check if the server type is a streaming HTTP variant. - `_split_qualified_tool_name(tool_name: str) -> tuple[str, str]`: Split `server.tool` names while preserving dots inside MCP tool names. - `_normalize_disabled_tools(value: Any) -> list[str]`: Normalize the optional per-server disabled tool list. +- `_split_stdio_command(command: Any) -> tuple[str, list[str]]`: Split shell-style local MCP command lines into an executable plus leading arguments. +- `_split_stdio_arg_fragment(arg: str) -> list[str]`: Split collapsed option/value argument fragments while preserving obvious single values with spaces. +- `_normalize_stdio_args(value: Any) -> list[str]`: Normalize local MCP argument lists after manager/raw JSON parsing. - `initialize_mcp(mcp_servers_config: str)` - Notable constants/configuration names: `DEFAULT_MCP_SERVERS_CONFIG`, `MCP_MEDIA_TOKENS_ESTIMATE`, `MAX_MCP_RESOURCE_TEXT_CHARS`, `MCP_SESSION_CLEANUP_TIMEOUT_SECONDS`, `MCP_OPERATION_TIMEOUT_GRACE_SECONDS`, `T`. @@ -81,12 +84,13 @@ - MCP tool names are qualified as `server_name.tool_name`; server names are normalized without dots, and the tool portion may contain dots. - Servers may define `disabled_tools` as a list of MCP tool names. Disabled tools are omitted from agent-facing prompts, status counts, `has_tool`, and calls, while detail views can still retrieve them through `get_all_tools()` with a `disabled` flag so users can re-enable them. - Server-specific `init_timeout` and `tool_timeout` override global MCP client timeout settings for list-tools and call-tool operations. +- Local stdio server configs accept either strict MCP JSON (`command: "uvx", args: [...]`) or manager-style command lines (`command: "uvx package"`) and normalize them before spawning the process. - MCP image and image-resource content is materialized to scoped artifact files and returned both as model-visible image attachments and as path metadata (`attachments`/`media_paths`) for downstream delivery. - MCP config locks must not be held across awaited server initialization or tool-call operations. Slow or wedged MCP servers must not block status reads, prompt construction, unrelated MCP servers, or later tool calls through the shared config lock. - MCP client session work runs inside disposable isolated `DeferredTask` workers with an outer timeout. Normal `AsyncExitStack` cleanup is also bounded; if cleanup or transport shutdown does not finish, the operation reports failure or warning while Agent Zero keeps control of the agent loop. - Server status marks initialized server objects with cached initialization errors as disconnected, even if the config object exists. - Observed side-effect areas: filesystem writes, network calls, WebSocket state, settings/state persistence, secret handling. -- Imported dependency areas include: `abc`, `anyio.streams.memory`, `asyncio`, `contextlib`, `datetime`, `helpers`, `helpers.defer`, `helpers.log`, `helpers.print_style`, `helpers.tool`, `httpx`, `json`, `mcp`, `mcp.client.sse`, `mcp.client.stdio`, `mcp.client.streamable_http`, `mcp.shared.message`. +- Imported dependency areas include: `abc`, `anyio.streams.memory`, `asyncio`, `contextlib`, `datetime`, `helpers`, `helpers.defer`, `helpers.log`, `helpers.print_style`, `helpers.tool`, `httpx`, `json`, `mcp`, `mcp.client.sse`, `mcp.client.stdio`, `mcp.client.streamable_http`, `mcp.shared.message`, `shlex`. ## Key Concepts diff --git a/tests/test_mcp_handler_multimodal.py b/tests/test_mcp_handler_multimodal.py index 19b4ca123..fd402dcc7 100644 --- a/tests/test_mcp_handler_multimodal.py +++ b/tests/test_mcp_handler_multimodal.py @@ -345,6 +345,31 @@ def test_mcp_disabled_tools_are_hidden_from_agent_paths_but_visible_in_detail(mc assert malformed_config.servers[0].disabled_tools == [] +def test_mcp_local_server_accepts_manager_style_command_lines(mcp_handler_module): + module, _tmp_path = mcp_handler_module + + server = module.MCPServerLocal( + { + "name": "google_workspace", + "command": "uvx workspace-mcp", + "args": [ + "--tool-tier core", + "/tmp/path with spaces", + "--label=Two Words", + ], + } + ) + + assert server.command == "uvx" + assert server.args == [ + "workspace-mcp", + "--tool-tier", + "core", + "/tmp/path with spaces", + "--label=Two Words", + ] + + def test_mcp_client_call_tool_uses_server_tool_timeout(mcp_handler_module, monkeypatch): module, _tmp_path = mcp_handler_module session_timeouts = [] diff --git a/webui/components/settings/AGENTS.md b/webui/components/settings/AGENTS.md index 2f5529227..463462a05 100644 --- a/webui/components/settings/AGENTS.md +++ b/webui/components/settings/AGENTS.md @@ -18,6 +18,7 @@ - Preserve Store Gating and modal footer conventions in settings components. - MCP manager tool toggles write `disabled_tools` into the draft JSON and require Apply before changing the running MCP tool set. - Confirmed MCP server removals apply immediately and refresh server status; other MCP manager draft edits still require Apply. +- MCP manager local command forms accept shell-style command and argument lines; quote argument values that intentionally contain spaces. ## Work Guidance diff --git a/webui/components/settings/mcp/client/mcp-servers-store.js b/webui/components/settings/mcp/client/mcp-servers-store.js index 0963438c0..3d73d60d8 100644 --- a/webui/components/settings/mcp/client/mcp-servers-store.js +++ b/webui/components/settings/mcp/client/mcp-servers-store.js @@ -117,7 +117,7 @@ function parseArgsText(text) { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.map((item) => String(item)) : []; } - return raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + return raw.split(/\r?\n/).flatMap((line) => splitCommandLine(line.trim())).filter(Boolean); } function formatArgsText(value) {