Normalize MCP stdio command lines

Accept shell-style local MCP command values from the manager before spawning stdio clients, so configs like uvx workspace-mcp resolve to the correct executable and args. Split collapsed option/value argument lines in the MCP manager, document the input contract, and add a regression for the google_workspace MCP shape.

Alessandro committed Jul 5, 2026 at 20:11 UTC 6998789ed379da65bab7074843b103795fea9bff
5 files changed +84 -2
helpers/mcp_handler.py
+52
@@ -21,6 +21,7 @@ from contextlib import AsyncExitStack
21 from shutil import which
22 from datetime import timedelta
23 import json
24 +import shlex
25 import uuid
26 from helpers import errors
27 from helpers import settings
@@ -112,6 +113,44 @@ def _normalize_disabled_tools(value: Any) -> list[str]:
113 return [str(item).strip() for item in value if str(item).strip()]
114
115
116 +def _split_stdio_command(command: Any) -> tuple[str, list[str]]:
117 + text = str(command or "").strip()
118 + if not text:
119 + return "", []
120 + try:
121 + parts = shlex.split(text)
122 + except ValueError:
123 + return text, []
124 + if not parts:
125 + return "", []
126 + return parts[0], parts[1:]
127 +
128 +
129 +def _split_stdio_arg_fragment(arg: str) -> list[str]:
130 + try:
131 + parts = shlex.split(arg)
132 + except ValueError:
133 + return [arg]
134 + if len(parts) <= 1:
135 + return parts or []
136 + if parts[0].startswith("-") and "=" not in parts[0]:
137 + return parts
138 + if any(part.startswith("-") for part in parts[1:]):
139 + return parts
140 + return [arg]
141 +
142 +
143 +def _normalize_stdio_args(value: Any) -> list[str]:
144 + if not isinstance(value, list):
145 + return []
146 + args: list[str] = []
147 + for item in value:
148 + text = str(item).strip()
149 + if text:
150 + args.extend(_split_stdio_arg_fragment(text))
151 + return args
152 +
153 +
154 def initialize_mcp(mcp_servers_config: str):
155 if not MCPConfig.get_instance().is_initialized():
156 try:
@@ -649,6 +688,15 @@ class MCPServerLocal(BaseModel):
688
689 def update(self, config: dict[str, Any]) -> "MCPServerLocal":
690 with self.__lock:
691 + command = self.command
692 + command_args: list[str] = []
693 + args = list(self.args)
694 + if "command" in config:
695 + command, command_args = _split_stdio_command(config.get("command"))
696 + args = [*command_args, *args]
697 + if "args" in config:
698 + args = [*command_args, *_normalize_stdio_args(config.get("args"))]
699 +
700 for key, value in config.items():
701 if key in [
702 "name",
@@ -665,11 +713,15 @@ class MCPServerLocal(BaseModel):
713 "disabled_tools",
714 "scope",
715 ]:
716 + if key in ["command", "args"]:
717 + continue
718 if key == "name":
719 value = normalize_name(value)
720 if key == "disabled_tools":
721 value = _normalize_disabled_tools(value)
722 setattr(self, key, value)
723 + self.command = command
724 + self.args = args
725 return self
726
727 async def initialize(self) -> "MCPServerLocal":
helpers/mcp_handler.py.dox.md
+5 -1
@@ -66,6 +66,9 @@
66 - `_is_streaming_http_type(server_type: str) -> bool`: Check if the server type is a streaming HTTP variant.
67 - `_split_qualified_tool_name(tool_name: str) -> tuple[str, str]`: Split `server.tool` names while preserving dots inside MCP tool names.
68 - `_normalize_disabled_tools(value: Any) -> list[str]`: Normalize the optional per-server disabled tool list.
69 +- `_split_stdio_command(command: Any) -> tuple[str, list[str]]`: Split shell-style local MCP command lines into an executable plus leading arguments.
70 +- `_split_stdio_arg_fragment(arg: str) -> list[str]`: Split collapsed option/value argument fragments while preserving obvious single values with spaces.
71 +- `_normalize_stdio_args(value: Any) -> list[str]`: Normalize local MCP argument lists after manager/raw JSON parsing.
72 - `initialize_mcp(mcp_servers_config: str)`
73 - 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`.
74
@@ -81,12 +84,13 @@
84 - MCP tool names are qualified as `server_name.tool_name`; server names are normalized without dots, and the tool portion may contain dots.
85 - 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.
86 - Server-specific `init_timeout` and `tool_timeout` override global MCP client timeout settings for list-tools and call-tool operations.
87 +- 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.
88 - 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.
89 - 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.
90 - 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.
91 - Server status marks initialized server objects with cached initialization errors as disconnected, even if the config object exists.
92 - Observed side-effect areas: filesystem writes, network calls, WebSocket state, settings/state persistence, secret handling.
89 -- 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`.
93 +- 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`.
94
95 ## Key Concepts
96
tests/test_mcp_handler_multimodal.py
+25
@@ -345,6 +345,31 @@ def test_mcp_disabled_tools_are_hidden_from_agent_paths_but_visible_in_detail(mc
345 assert malformed_config.servers[0].disabled_tools == []
346
347
348 +def test_mcp_local_server_accepts_manager_style_command_lines(mcp_handler_module):
349 + module, _tmp_path = mcp_handler_module
350 +
351 + server = module.MCPServerLocal(
352 + {
353 + "name": "google_workspace",
354 + "command": "uvx workspace-mcp",
355 + "args": [
356 + "--tool-tier core",
357 + "/tmp/path with spaces",
358 + "--label=Two Words",
359 + ],
360 + }
361 + )
362 +
363 + assert server.command == "uvx"
364 + assert server.args == [
365 + "workspace-mcp",
366 + "--tool-tier",
367 + "core",
368 + "/tmp/path with spaces",
369 + "--label=Two Words",
370 + ]
371 +
372 +
373 def test_mcp_client_call_tool_uses_server_tool_timeout(mcp_handler_module, monkeypatch):
374 module, _tmp_path = mcp_handler_module
375 session_timeouts = []
webui/components/settings/AGENTS.md
+1
@@ -18,6 +18,7 @@
18 - Preserve Store Gating and modal footer conventions in settings components.
19 - MCP manager tool toggles write `disabled_tools` into the draft JSON and require Apply before changing the running MCP tool set.
20 - Confirmed MCP server removals apply immediately and refresh server status; other MCP manager draft edits still require Apply.
21 +- MCP manager local command forms accept shell-style command and argument lines; quote argument values that intentionally contain spaces.
22
23 ## Work Guidance
24
webui/components/settings/mcp/client/mcp-servers-store.js
+1 -1
@@ -117,7 +117,7 @@ function parseArgsText(text) {
117 const parsed = JSON.parse(raw);
118 return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
119 }
120 - return raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
120 + return raw.split(/\r?\n/).flatMap((line) => splitCommandLine(line.trim())).filter(Boolean);
121 }
122
123 function formatArgsText(value) {