diff --git a/api/mcp_server_get_detail.py.dox.md b/api/mcp_server_get_detail.py.dox.md index cacfa6b40..d71ed2551 100644 --- a/api/mcp_server_get_detail.py.dox.md +++ b/api/mcp_server_get_detail.py.dox.md @@ -18,6 +18,7 @@ - HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. - The request accepts `server_name` and optional `project_name`; when `project_name` is present, detail resolves through the project-scoped MCP configuration. +- Detail responses include the server tools visible to the manager UI. Tools disabled through a server `disabled_tools` config list remain present in this detail list with a `disabled` flag so the UI can re-enable them. - Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. - `McpServerGetDetail` is an `ApiHandler`. - `McpServerGetDetail` defines `process(...)`. diff --git a/api/mcp_servers_status.py.dox.md b/api/mcp_servers_status.py.dox.md index cfa87e848..90b4065be 100644 --- a/api/mcp_servers_status.py.dox.md +++ b/api/mcp_servers_status.py.dox.md @@ -18,6 +18,7 @@ - HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. - The request accepts optional `project_name`; when present, status resolves through the merged project-scoped MCP configuration. +- `tool_count` reports enabled MCP tools only; tools disabled by a server `disabled_tools` list stay hidden from agent-facing status counts. - Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. - `McpServersStatuss` is an `ApiHandler`. - `McpServersStatuss` defines `process(...)`. diff --git a/helpers/mcp_handler.py b/helpers/mcp_handler.py index e40e9a9e7..6aadfc80c 100644 --- a/helpers/mcp_handler.py +++ b/helpers/mcp_handler.py @@ -103,6 +103,12 @@ def _split_qualified_tool_name(tool_name: str) -> tuple[str, str]: return server_name_part, tool_name_part +def _normalize_disabled_tools(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if str(item).strip()] + + def initialize_mcp(mcp_servers_config: str): if not MCPConfig.get_instance().is_initialized(): try: @@ -450,6 +456,7 @@ class MCPServerRemote(BaseModel): tool_timeout: int = Field(default=0) verify: bool = Field(default=True, description="Verify SSL certificates") disabled: bool = Field(default=False) + disabled_tools: list[str] = Field(default_factory=list) scope: str = Field(default="global") __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock()) @@ -469,12 +476,26 @@ class MCPServerRemote(BaseModel): return self.__client.get_log() # type: ignore def get_tools(self) -> List[dict[str, Any]]: - """Get all tools from the server""" + """Get enabled tools from the server""" with self.__lock: - return self.__client.get_tools() # type: ignore + tools = self.__client.get_tools() # type: ignore + disabled = set(self.disabled_tools) + return [tool for tool in tools if tool.get("name") not in disabled] + + def get_all_tools(self) -> List[dict[str, Any]]: + """Get all tools from the server and mark disabled tools for UI detail views.""" + with self.__lock: + tools = self.__client.get_tools() # type: ignore + disabled = set(self.disabled_tools) + return [ + {**tool, "disabled": tool.get("name") in disabled} + for tool in tools + ] def has_tool(self, tool_name: str) -> bool: """Check if a tool is available""" + if tool_name in self.disabled_tools: + return False with self.__lock: return self.__client.has_tool(tool_name) # type: ignore @@ -485,6 +506,8 @@ class MCPServerRemote(BaseModel): client = self.__client if client is None: raise RuntimeError("MCP remote client is not initialized") + if tool_name in self.disabled_tools: + raise ValueError(f"Tool {tool_name} is disabled for server {self.name}.") return await client.call_tool(tool_name, input_data) def update(self, config: dict[str, Any]) -> "MCPServerRemote": @@ -500,6 +523,7 @@ class MCPServerRemote(BaseModel): "init_timeout", "tool_timeout", "disabled", + "disabled_tools", "verify", "scope", ]: @@ -507,6 +531,8 @@ class MCPServerRemote(BaseModel): value = normalize_name(value) if key == "serverUrl": key = "url" # remap serverUrl to url + if key == "disabled_tools": + value = _normalize_disabled_tools(value) setattr(self, key, value) return self @@ -531,6 +557,7 @@ class MCPServerLocal(BaseModel): tool_timeout: int = Field(default=0) verify: bool = Field(default=True, description="Verify SSL certificates") disabled: bool = Field(default=False) + disabled_tools: list[str] = Field(default_factory=list) scope: str = Field(default="global") __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock()) @@ -550,12 +577,26 @@ class MCPServerLocal(BaseModel): return self.__client.get_log() # type: ignore def get_tools(self) -> List[dict[str, Any]]: - """Get all tools from the server""" + """Get enabled tools from the server""" with self.__lock: - return self.__client.get_tools() # type: ignore + tools = self.__client.get_tools() # type: ignore + disabled = set(self.disabled_tools) + return [tool for tool in tools if tool.get("name") not in disabled] + + def get_all_tools(self) -> List[dict[str, Any]]: + """Get all tools from the server and mark disabled tools for UI detail views.""" + with self.__lock: + tools = self.__client.get_tools() # type: ignore + disabled = set(self.disabled_tools) + return [ + {**tool, "disabled": tool.get("name") in disabled} + for tool in tools + ] def has_tool(self, tool_name: str) -> bool: """Check if a tool is available""" + if tool_name in self.disabled_tools: + return False with self.__lock: return self.__client.has_tool(tool_name) # type: ignore @@ -566,6 +607,8 @@ class MCPServerLocal(BaseModel): client = self.__client if client is None: raise RuntimeError("MCP local client is not initialized") + if tool_name in self.disabled_tools: + raise ValueError(f"Tool {tool_name} is disabled for server {self.name}.") return await client.call_tool(tool_name, input_data) def update(self, config: dict[str, Any]) -> "MCPServerLocal": @@ -583,10 +626,13 @@ class MCPServerLocal(BaseModel): "init_timeout", "tool_timeout", "disabled", + "disabled_tools", "scope", ]: if key == "name": value = normalize_name(value) + if key == "disabled_tools": + value = _normalize_disabled_tools(value) setattr(self, key, value) return self @@ -852,6 +898,11 @@ class MCPConfig(BaseModel): ) continue + server_item = dict(server_item) + server_item["disabled_tools"] = _normalize_disabled_tools( + server_item.get("disabled_tools") + ) + if server_item.get("disabled", False): # get server name if available server_name = server_item.get("name", "unnamed_server") @@ -987,7 +1038,8 @@ class MCPConfig(BaseModel): for server in self.servers: if server.name == server_name: try: - tools = server.get_tools() + get_all_tools = getattr(server, "get_all_tools", None) + tools = get_all_tools() if callable(get_all_tools) else server.get_tools() except Exception: tools = [] return { diff --git a/helpers/mcp_handler.py.dox.md b/helpers/mcp_handler.py.dox.md index 17ca16498..d645a0b16 100644 --- a/helpers/mcp_handler.py.dox.md +++ b/helpers/mcp_handler.py.dox.md @@ -20,6 +20,7 @@ - `get_error(self) -> str` - `get_log(self) -> str` - `get_tools(self) -> List[dict[str, Any]]` + - `get_all_tools(self) -> List[dict[str, Any]]` - `has_tool(self, tool_name: str) -> bool` - `async call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult` - `update(self, config: dict[str, Any]) -> 'MCPServerRemote'` @@ -28,6 +29,7 @@ - `get_error(self) -> str` - `get_log(self) -> str` - `get_tools(self) -> List[dict[str, Any]]` + - `get_all_tools(self) -> List[dict[str, Any]]` - `has_tool(self, tool_name: str) -> bool` - `async call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult` - `update(self, config: dict[str, Any]) -> 'MCPServerLocal'` @@ -63,6 +65,7 @@ - `_determine_server_type(config_dict: dict) -> str`: Determine the server type based on configuration, with backward compatibility. - `_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. - `initialize_mcp(mcp_servers_config: str)` - Notable constants/configuration names: `DEFAULT_MCP_SERVERS_CONFIG`, `MCP_MEDIA_TOKENS_ESTIMATE`, `MAX_MCP_RESOURCE_TEXT_CHARS`, `T`. @@ -76,6 +79,7 @@ - Project-scoped MCP servers overlay global servers by normalized name. The resulting `MCPConfig` cache key is derived from both config strings so project instances refresh when either scope changes. - Server status and detail responses include `scope`, and MCP tools resolve through `MCPConfig.get_for_agent(agent)` before execution. - 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. - 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. diff --git a/tests/test_mcp_handler_multimodal.py b/tests/test_mcp_handler_multimodal.py index 0a764ae92..421fa4224 100644 --- a/tests/test_mcp_handler_multimodal.py +++ b/tests/test_mcp_handler_multimodal.py @@ -222,6 +222,60 @@ def test_mcp_status_marks_servers_with_errors_disconnected(mcp_handler_module): assert status[0]["has_log"] is True +def test_mcp_disabled_tools_are_hidden_from_agent_paths_but_visible_in_detail(mcp_handler_module): + module, _tmp_path = mcp_handler_module + + server = module.MCPServerLocal( + { + "name": "files", + "command": "npx", + "disabled_tools": ["write_file"], + } + ) + client = getattr(server, "_MCPServerLocal__client") + client.tools = [ + { + "name": "read_file", + "description": "Read a file", + "input_schema": {}, + }, + { + "name": "write_file", + "description": "Write a file", + "input_schema": {}, + }, + ] + + config = module.MCPConfig(servers_list=[]) + config.servers = [server] + + assert [tool["name"] for tool in server.get_tools()] == ["read_file"] + assert server.has_tool("write_file") is False + assert config.has_tool("files.write_file") is False + assert config.get_servers_status()[0]["tool_count"] == 1 + assert "files.write_file" not in config.get_tools_prompt() + + detail_tools = config.get_server_detail("files")["tools"] + assert [(tool["name"], tool.get("disabled", False)) for tool in detail_tools] == [ + ("read_file", False), + ("write_file", True), + ] + + with pytest.raises(ValueError): + asyncio.run(server.call_tool("write_file", {})) + + malformed_config = module.MCPConfig( + servers_list=[ + { + "name": "malformed", + "command": "npx", + "disabled_tools": "write_file", + } + ] + ) + assert malformed_config.servers[0].disabled_tools == [] + + 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 f44e4a596..4fb443bb9 100644 --- a/webui/components/settings/AGENTS.md +++ b/webui/components/settings/AGENTS.md @@ -8,17 +8,21 @@ - `settings.html` and `settings-store.js` own the settings shell and state. - Subdirectories own settings areas such as agent, external, developer, MCP, backup, plugins, secrets, skills, tunnel, and A2A. +- `mcp/client/` owns the global/project MCP server manager, server search, raw JSON editor surface, examples modal, server tool detail modal, MCP scanner modal, scan checks, and scan prompt assets. ## Local Contracts - Keep settings payloads synchronized with backend APIs and plugin settings contracts. - Do not store secrets in localStorage, URLs, or console output. - 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. ## Work Guidance - Prefer subsection-local stores for complex settings areas. - Coordinate plugin settings UI changes with `webui/components/plugins/` and `plugins/AGENTS.md`. +- Keep MCP scanner checks and prompt assets close to the MCP client modal so scanner behavior remains reviewable with the UI that invokes it. +- Keep MCP manager search and toggle affordances consistent between global and project scope because both are rendered by the same client modal. ## Verification diff --git a/webui/components/settings/mcp/client/example.html b/webui/components/settings/mcp/client/example.html index 690765d7f..d92a0251a 100644 --- a/webui/components/settings/mcp/client/example.html +++ b/webui/components/settings/mcp/client/example.html @@ -7,22 +7,33 @@
Agent Zero uses standard JSON configuration known from other AI applications.
- The configuration is a JSON object containing "mcpServers" object where each key is an individual MCP
- server.
- Local servers are defined by a "command", "args", "env" variables.
- Remote servers are defined by a "url", "headers".
- "disabled" can be set to true to disable a server without removing config.
- Custom "description" can be set to provide additional information about the server to A0.
- All servers can also define "init_timeout" and "tool_timeout" which override global settings.
+ Agent Zero uses the standard mcpServers JSON shape used by MCP-compatible clients.
+ Each key under mcpServers is one MCP server.
+
command, optional args, and optional env.
+ url, optional headers, and a transport type.
+ description, disabled, init_timeout, and tool_timeout work on either kind of server.
+