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) {