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.
This commit is contained in:
Alessandro 2026-07-05 20:11:19 +02:00
parent afa5231a5a
commit 6998789ed3
5 changed files with 84 additions and 2 deletions

View file

@ -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":

View file

@ -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

View file

@ -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 = []

View file

@ -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

View file

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