mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
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:
parent
afa5231a5a
commit
6998789ed3
5 changed files with 84 additions and 2 deletions
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue