diff --git a/api/mcp_server_get_detail.py b/api/mcp_server_get_detail.py index bc5552c4d..25f865a41 100644 --- a/api/mcp_server_get_detail.py +++ b/api/mcp_server_get_detail.py @@ -9,9 +9,11 @@ class McpServerGetDetail(ApiHandler): # try: server_name = input.get("server_name") + project_name = str(input.get("project_name", "") or "").strip() if not server_name: return {"success": False, "error": "Missing server_name"} - detail = MCPConfig.get_instance().get_server_detail(server_name) + config = MCPConfig.get_project_instance(project_name) if project_name else MCPConfig.get_instance() + detail = config.get_server_detail(server_name) return {"success": True, "detail": detail} # except Exception as e: # return {"success": False, "error": str(e)} diff --git a/api/mcp_server_get_detail.py.dox.md b/api/mcp_server_get_detail.py.dox.md index e4539d725..cacfa6b40 100644 --- a/api/mcp_server_get_detail.py.dox.md +++ b/api/mcp_server_get_detail.py.dox.md @@ -3,7 +3,7 @@ ## Purpose - Own the `mcp_server_get_detail.py` API endpoint. -- This module handles MCP server server get detail requests. +- This module handles MCP server detail requests for global or project scope. - Keep this file-level DOX profile synchronized with `mcp_server_get_detail.py` because this directory is intentionally flat. ## Ownership @@ -17,6 +17,7 @@ ## Runtime Contracts - 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. - 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(...)`. @@ -24,7 +25,7 @@ ## Key Concepts -- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_server_detail`, `MCPConfig.get_instance`. +- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_server_detail`, `MCPConfig.get_project_instance`, `MCPConfig.get_instance`. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. ## Work Guidance diff --git a/api/mcp_server_get_log.py b/api/mcp_server_get_log.py index 3305430ea..89b53b58f 100644 --- a/api/mcp_server_get_log.py +++ b/api/mcp_server_get_log.py @@ -9,9 +9,11 @@ class McpServerGetLog(ApiHandler): # try: server_name = input.get("server_name") + project_name = str(input.get("project_name", "") or "").strip() if not server_name: return {"success": False, "error": "Missing server_name"} - log = MCPConfig.get_instance().get_server_log(server_name) + config = MCPConfig.get_project_instance(project_name) if project_name else MCPConfig.get_instance() + log = config.get_server_log(server_name) return {"success": True, "log": log} # except Exception as e: # return {"success": False, "error": str(e)} diff --git a/api/mcp_server_get_log.py.dox.md b/api/mcp_server_get_log.py.dox.md index 3baf7453e..0937274a4 100644 --- a/api/mcp_server_get_log.py.dox.md +++ b/api/mcp_server_get_log.py.dox.md @@ -3,7 +3,7 @@ ## Purpose - Own the `mcp_server_get_log.py` API endpoint. -- This module handles MCP server server get log requests. +- This module handles MCP server log requests for global or project scope. - Keep this file-level DOX profile synchronized with `mcp_server_get_log.py` because this directory is intentionally flat. ## Ownership @@ -17,6 +17,7 @@ ## Runtime Contracts - 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, logs resolve through the project-scoped MCP configuration. - Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. - `McpServerGetLog` is an `ApiHandler`. - `McpServerGetLog` defines `process(...)`. @@ -24,7 +25,7 @@ ## Key Concepts -- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_server_log`, `MCPConfig.get_instance`. +- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_server_log`, `MCPConfig.get_project_instance`, `MCPConfig.get_instance`. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. ## Work Guidance diff --git a/api/mcp_server_scan.py b/api/mcp_server_scan.py new file mode 100644 index 000000000..4639426d3 --- /dev/null +++ b/api/mcp_server_scan.py @@ -0,0 +1,232 @@ +import asyncio +from shutil import which +from typing import Any +from urllib.parse import urlparse + +from helpers.api import ApiHandler, Request, Response +from helpers.mcp_handler import MCPConfig, normalize_name + + +_PROMPT_INJECTION_MARKERS = ( + "ignore previous", + "ignore all previous", + "system prompt", + "developer message", + "hidden instruction", + "exfiltrate", + "leak secret", + "credential", +) + + +class McpServerScan(ApiHandler): + async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response: + server = dict(input.get("server") or {}) + allow_local_execution = bool(input.get("allow_local_execution", False)) + allow_remote_network = bool(input.get("allow_remote_network", False)) + inspect_runtime = input.get("inspect_runtime", True) is not False + + server = self._normalize_server(server) + warnings = self._static_warnings(server) + is_local = not (server.get("url") or server.get("serverUrl")) + has_static_errors = any(warning.get("level") == "error" for warning in warnings) + + runtime_status: list[dict[str, Any]] = [] + runtime_detail: dict[str, Any] = {} + runtime_error = "" + + should_inspect_runtime = ( + inspect_runtime + and not has_static_errors + and ((is_local and allow_local_execution) or (not is_local and allow_remote_network)) + ) + + if should_inspect_runtime: + try: + scan_config = await asyncio.to_thread( + lambda: MCPConfig(servers_list=[server], config_scope="scan") + ) + runtime_status = scan_config.get_servers_status() + runtime_detail = scan_config.get_server_detail(server.get("name", "")) + warnings.extend(self._tool_warnings(runtime_detail.get("tools", []))) + except Exception as exc: + runtime_error = str(exc) + warnings.append( + { + "level": "error", + "title": "Runtime inspection failed", + "message": runtime_error, + } + ) + elif is_local and inspect_runtime: + warnings.append( + { + "level": "warning", + "title": "Local command not executed", + "message": "Local stdio MCP inspection requires explicit trust because it runs the configured command.", + } + ) + elif not is_local and inspect_runtime and has_static_errors: + warnings.append( + { + "level": "info", + "title": "Runtime inspection skipped", + "message": "Fix static scan errors before attempting runtime MCP inspection.", + } + ) + elif not is_local and inspect_runtime: + warnings.append( + { + "level": "info", + "title": "Remote runtime inspection skipped", + "message": "Enable trusted remote inspection to contact the MCP URL and list exposed tools.", + } + ) + + return { + "success": True, + "server": self._redact_server(server), + "risk_level": self._risk_level(warnings), + "warnings": warnings, + "status": runtime_status, + "detail": runtime_detail, + "runtime_error": runtime_error, + } + + def _normalize_server(self, server: dict[str, Any]) -> dict[str, Any]: + name = str(server.get("name") or "").strip() + url = str(server.get("url") or server.get("serverUrl") or "").strip() + command = str(server.get("command") or "").strip() + + if not name: + name = self._derive_name(url, command) + server["name"] = normalize_name(name or "mcp_server") + + if url: + server["url"] = url + server.setdefault("type", "streamable-http") + elif command: + server["command"] = command + server["type"] = "stdio" + + return server + + def _derive_name(self, url: str, command: str) -> str: + if url: + parsed = urlparse(url) + parts = [part for part in parsed.path.split("/") if part] + return parts[-1] if parts else parsed.hostname or "remote_mcp" + if command: + return command.rsplit("/", 1)[-1] + return "mcp_server" + + def _static_warnings(self, server: dict[str, Any]) -> list[dict[str, str]]: + warnings: list[dict[str, str]] = [] + url = str(server.get("url") or "").strip() + command = str(server.get("command") or "").strip() + + if url: + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"}: + warnings.append( + { + "level": "error", + "title": "Unsupported URL scheme", + "message": "Remote MCP URLs should use http or https.", + } + ) + elif parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}: + warnings.append( + { + "level": "warning", + "title": "Unencrypted remote URL", + "message": "Prefer HTTPS for remote MCP servers outside localhost.", + } + ) + if not parsed.netloc: + warnings.append( + { + "level": "error", + "title": "Invalid remote URL", + "message": "The remote MCP URL is missing a host.", + } + ) + elif command: + if which(command) is None: + warnings.append( + { + "level": "warning", + "title": "Command not found", + "message": f"'{command}' is not currently available on PATH.", + } + ) + if command in {"bash", "sh", "zsh", "fish", "python", "python3", "node"}: + warnings.append( + { + "level": "warning", + "title": "General-purpose interpreter", + "message": "Review the command and arguments carefully before running this local MCP server.", + } + ) + else: + warnings.append( + { + "level": "error", + "title": "Missing connection target", + "message": "Provide either a remote URL or a local command.", + } + ) + + if isinstance(server.get("headers"), dict) and server["headers"]: + warnings.append( + { + "level": "info", + "title": "Headers configured", + "message": "Header values are redacted in scan output. Keep tokens in trusted settings only.", + } + ) + + if isinstance(server.get("env"), dict) and server["env"]: + warnings.append( + { + "level": "info", + "title": "Environment configured", + "message": "Environment values are redacted in scan output. Avoid hardcoding secrets in MCP configs.", + } + ) + + return warnings + + def _tool_warnings(self, tools: Any) -> list[dict[str, str]]: + warnings: list[dict[str, str]] = [] + if not isinstance(tools, list): + return warnings + + for tool in tools: + if not isinstance(tool, dict): + continue + haystack = f"{tool.get('name', '')}\n{tool.get('description', '')}".lower() + if any(marker in haystack for marker in _PROMPT_INJECTION_MARKERS): + warnings.append( + { + "level": "warning", + "title": "Suspicious tool description", + "message": f"Review tool '{tool.get('name', 'unknown')}' for prompt-injection style language.", + } + ) + return warnings + + def _redact_server(self, server: dict[str, Any]) -> dict[str, Any]: + redacted = dict(server) + for key in ("headers", "env"): + if isinstance(redacted.get(key), dict): + redacted[key] = {name: "***" for name in redacted[key]} + return redacted + + def _risk_level(self, warnings: list[dict[str, str]]) -> str: + levels = {warning.get("level", "info") for warning in warnings} + if "error" in levels: + return "error" + if "warning" in levels: + return "warning" + return "ok" diff --git a/api/mcp_server_scan.py.dox.md b/api/mcp_server_scan.py.dox.md new file mode 100644 index 000000000..5487106aa --- /dev/null +++ b/api/mcp_server_scan.py.dox.md @@ -0,0 +1,45 @@ +# mcp_server_scan.py DOX + +## Purpose + +- Own the `mcp_server_scan.py` API endpoint. +- Provide static and optional runtime inspection for a single MCP server draft before it is added to global or project MCP config. +- Keep this file-level DOX profile synchronized with `mcp_server_scan.py` because this directory is intentionally flat. + +## Ownership + +- `mcp_server_scan.py` owns the runtime implementation. +- `mcp_server_scan.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. +- Classes: +- `McpServerScan` (`ApiHandler`) + - `async process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response` + +## Runtime Contracts + +- HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- The request accepts a `server` draft object, `inspect_runtime`, `allow_remote_network`, and `allow_local_execution`. +- Remote runtime inspection may contact the configured MCP URL to list tools only when `allow_remote_network` is true and static checks have no errors. +- Local stdio runtime inspection must not execute unless `allow_local_execution` is true. +- Response data redacts `headers` and `env` values. +- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. +- Imported dependency areas include: `asyncio`, `helpers.api`, `helpers.mcp_handler`, `shutil`, `typing`, `urllib.parse`. + +## Key Concepts + +- Static checks report invalid URLs, non-HTTPS remote URLs, missing local commands, interpreter-style local commands, headers/env presence, and obvious prompt-injection markers in inspected tool descriptions. +- Static errors skip runtime inspection; remote network inspection and local command execution both require explicit trust flags. +- Runtime inspection creates a temporary `MCPConfig` in a worker thread so stdio/remote tool listing does not call `asyncio.run()` inside the request event loop. + +## Work Guidance + +- Preserve authentication, CSRF, loopback, and API-key checks unless the endpoint contract explicitly changes. +- Do not return secret values, raw environment values, or private files. +- Keep scanner warnings explicit about local command execution risk. + +## Verification + +- Run endpoint-specific or MCP helper tests for changed behavior; smoke-test remote URL and local-command scan paths when practical. + +## Child DOX Index + +No child DOX files. diff --git a/api/mcp_servers_apply.py b/api/mcp_servers_apply.py index 7ea5275db..36877807a 100644 --- a/api/mcp_servers_apply.py +++ b/api/mcp_servers_apply.py @@ -5,20 +5,27 @@ from typing import Any from helpers.mcp_handler import MCPConfig from helpers.settings import set_settings_delta +from helpers import projects class McpServersApply(ApiHandler): async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response: mcp_servers = input["mcp_servers"] + project_name = str(input.get("project_name", "") or "").strip() try: - # MCPConfig.update(mcp_servers) # done in settings automatically - set_settings_delta({"mcp_servers": "[]"}) # to force reinitialization - set_settings_delta({"mcp_servers": mcp_servers}) + if project_name: + projects.save_project_mcp_servers(project_name, mcp_servers) + config = MCPConfig.refresh_project(project_name) + else: + # MCPConfig.update(mcp_servers) # done in settings automatically + set_settings_delta({"mcp_servers": "[]"}) # to force reinitialization + set_settings_delta({"mcp_servers": mcp_servers}) - time.sleep(1) # wait at least a second - # MCPConfig.wait_for_lock() # wait until config lock is released - status = MCPConfig.get_instance().get_servers_status() - return {"success": True, "status": status} + time.sleep(1) # wait at least a second + # MCPConfig.wait_for_lock() # wait until config lock is released + config = MCPConfig.get_instance() + status = config.get_servers_status() + return {"success": True, "status": status, "mcp_servers": mcp_servers, "project_name": project_name} except Exception as e: return {"success": False, "error": str(e)} diff --git a/api/mcp_servers_apply.py.dox.md b/api/mcp_servers_apply.py.dox.md index 1a0a0d326..361c331b2 100644 --- a/api/mcp_servers_apply.py.dox.md +++ b/api/mcp_servers_apply.py.dox.md @@ -3,7 +3,7 @@ ## Purpose - Own the `mcp_servers_apply.py` API endpoint. -- This module handles MCP server servers apply requests. +- This module handles MCP servers apply requests for global or project scope. - Keep this file-level DOX profile synchronized with `mcp_servers_apply.py` because this directory is intentionally flat. ## Ownership @@ -17,15 +17,18 @@ ## Runtime Contracts - HTTP handlers must derive from `helpers.api.ApiHandler`; WebSocket handlers must derive from `helpers.ws.WsHandler`. +- The request accepts `config` and optional `project_name`. +- Without `project_name`, the endpoint persists global `mcp_servers_config` through settings and refreshes the global `MCPConfig`. +- With `project_name`, the endpoint saves `.a0proj/mcp_servers.json` through `helpers.projects.save_project_mcp_servers(...)` and refreshes that project's merged MCP config. - Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. - `McpServersApply` is an `ApiHandler`. - `McpServersApply` defines `process(...)`. -- Observed side-effect areas: settings/state persistence. -- Imported dependency areas include: `helpers.api`, `helpers.mcp_handler`, `helpers.settings`, `time`, `typing`. +- Observed side-effect areas: filesystem writes, settings/state persistence. +- Imported dependency areas include: `helpers.api`, `helpers.mcp_handler`, `helpers.projects`, `helpers.settings`, `time`, `typing`. ## Key Concepts -- Important called helpers/classes observed in the source: `set_settings_delta`, `time.sleep`, `MCPConfig.get_instance.get_servers_status`, `MCPConfig.get_instance`. +- Important called helpers/classes observed in the source: `set_settings_delta`, `projects.save_project_mcp_servers`, `MCPConfig.refresh_project`, `time.sleep`, `MCPConfig.get_instance.get_servers_status`, `MCPConfig.get_instance`. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. ## Work Guidance diff --git a/api/mcp_servers_status.py b/api/mcp_servers_status.py index 20f8a64c8..01afff421 100644 --- a/api/mcp_servers_status.py +++ b/api/mcp_servers_status.py @@ -9,7 +9,9 @@ class McpServersStatuss(ApiHandler): async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response: # try: - status = MCPConfig.get_instance().get_servers_status() + project_name = (input or {}).get("project_name") if isinstance(input, dict) else None + config = MCPConfig.get_project_instance(project_name) if project_name else MCPConfig.get_instance() + status = config.get_servers_status() return {"success": True, "status": status} # except Exception as e: # return {"success": False, "error": str(e)} diff --git a/api/mcp_servers_status.py.dox.md b/api/mcp_servers_status.py.dox.md index 6a66a3eb3..cfa87e848 100644 --- a/api/mcp_servers_status.py.dox.md +++ b/api/mcp_servers_status.py.dox.md @@ -3,7 +3,7 @@ ## Purpose - Own the `mcp_servers_status.py` API endpoint. -- This module handles MCP server servers status requests. +- This module handles MCP servers status requests for global or project scope. - Keep this file-level DOX profile synchronized with `mcp_servers_status.py` because this directory is intentionally flat. ## Ownership @@ -17,6 +17,7 @@ ## Runtime Contracts - 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. - 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(...)`. @@ -24,7 +25,7 @@ ## Key Concepts -- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_servers_status`, `MCPConfig.get_instance`. +- Important called helpers/classes observed in the source: `MCPConfig.get_instance.get_servers_status`, `MCPConfig.get_project_instance`, `MCPConfig.get_instance`. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. ## Work Guidance diff --git a/extensions/python/system_prompt/_12_mcp_prompt.py b/extensions/python/system_prompt/_12_mcp_prompt.py index 30ca34454..44c760a7b 100644 --- a/extensions/python/system_prompt/_12_mcp_prompt.py +++ b/extensions/python/system_prompt/_12_mcp_prompt.py @@ -22,7 +22,7 @@ class MCPToolsPrompt(Extension): @extensible async def build_prompt(agent: Agent) -> str: - mcp_config = MCPConfig.get_instance() + mcp_config = MCPConfig.get_for_agent(agent) if not mcp_config.servers: return "" diff --git a/helpers/mcp_handler.py b/helpers/mcp_handler.py index ea23d61f4..e40e9a9e7 100644 --- a/helpers/mcp_handler.py +++ b/helpers/mcp_handler.py @@ -47,6 +47,7 @@ from helpers.tool import Tool, Response MCP_MEDIA_TOKENS_ESTIMATE = 1500 MAX_MCP_RESOURCE_TEXT_CHARS = 12_000 +DEFAULT_MCP_SERVERS_CONFIG = '{\n "mcpServers": {}\n}' def _mcp_get(item: Any, key: str, default: Any = None) -> Any: @@ -92,6 +93,16 @@ def _is_streaming_http_type(server_type: str) -> bool: return server_type.lower() in ["http-stream", "streaming-http", "streamable-http", "http-streaming"] +def _split_qualified_tool_name(tool_name: str) -> tuple[str, str]: + """Split Agent Zero's server.tool MCP name while preserving dots in MCP tool names.""" + if "." not in tool_name: + raise ValueError(f"Tool {tool_name} not found") + server_name_part, tool_name_part = tool_name.split(".", 1) + if not server_name_part or not tool_name_part: + raise ValueError(f"Tool {tool_name} not found") + return server_name_part, tool_name_part + + def initialize_mcp(mcp_servers_config: str): if not MCPConfig.get_instance().is_initialized(): try: @@ -344,7 +355,7 @@ class MCPTool(Tool): error = "" additional: dict[str, Any] | None = None try: - response: CallToolResult = await MCPConfig.get_instance().call_tool( + response: CallToolResult = await MCPConfig.get_for_agent(self.agent).call_tool( self.name, kwargs ) message, additional = self._format_tool_result(response) @@ -439,6 +450,7 @@ class MCPServerRemote(BaseModel): tool_timeout: int = Field(default=0) verify: bool = Field(default=True, description="Verify SSL certificates") disabled: bool = Field(default=False) + scope: str = Field(default="global") __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock()) __client: Optional["MCPClientRemote"] = PrivateAttr(default=None) @@ -459,7 +471,7 @@ class MCPServerRemote(BaseModel): def get_tools(self) -> List[dict[str, Any]]: """Get all tools from the server""" with self.__lock: - return self.__client.tools # type: ignore + return self.__client.get_tools() # type: ignore def has_tool(self, tool_name: str) -> bool: """Check if a tool is available""" @@ -470,9 +482,10 @@ class MCPServerRemote(BaseModel): self, tool_name: str, input_data: Dict[str, Any] ) -> CallToolResult: """Call a tool with the given input data""" - with self.__lock: - # We already run in an event loop, dont believe Pylance - return await self.__client.call_tool(tool_name, input_data) # type: ignore + client = self.__client + if client is None: + raise RuntimeError("MCP remote client is not initialized") + return await client.call_tool(tool_name, input_data) def update(self, config: dict[str, Any]) -> "MCPServerRemote": with self.__lock: @@ -488,6 +501,7 @@ class MCPServerRemote(BaseModel): "tool_timeout", "disabled", "verify", + "scope", ]: if key == "name": value = normalize_name(value) @@ -517,6 +531,7 @@ class MCPServerLocal(BaseModel): tool_timeout: int = Field(default=0) verify: bool = Field(default=True, description="Verify SSL certificates") disabled: bool = Field(default=False) + scope: str = Field(default="global") __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock()) __client: Optional["MCPClientLocal"] = PrivateAttr(default=None) @@ -537,7 +552,7 @@ class MCPServerLocal(BaseModel): def get_tools(self) -> List[dict[str, Any]]: """Get all tools from the server""" with self.__lock: - return self.__client.tools # type: ignore + return self.__client.get_tools() # type: ignore def has_tool(self, tool_name: str) -> bool: """Check if a tool is available""" @@ -548,9 +563,10 @@ class MCPServerLocal(BaseModel): self, tool_name: str, input_data: Dict[str, Any] ) -> CallToolResult: """Call a tool with the given input data""" - with self.__lock: - # We already run in an event loop, dont believe Pylance - return await self.__client.call_tool(tool_name, input_data) # type: ignore + client = self.__client + if client is None: + raise RuntimeError("MCP local client is not initialized") + return await client.call_tool(tool_name, input_data) def update(self, config: dict[str, Any]) -> "MCPServerLocal": with self.__lock: @@ -567,6 +583,7 @@ class MCPServerLocal(BaseModel): "init_timeout", "tool_timeout", "disabled", + "scope", ]: if key == "name": value = normalize_name(value) @@ -590,17 +607,137 @@ MCPServer = Annotated[ class MCPConfig(BaseModel): servers: list[MCPServer] = Field(default_factory=list) disconnected_servers: list[dict[str, Any]] = Field(default_factory=list) + config_scope: str = Field(default="global") __lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock()) __instance: ClassVar[Any] = PrivateAttr(default=None) __initialized: ClassVar[bool] = PrivateAttr(default=False) + __project_instances: ClassVar[dict[str, tuple[str, "MCPConfig"]]] = {} @classmethod def get_instance(cls) -> "MCPConfig": # with cls.__lock: if cls.__instance is None: - cls.__instance = cls(servers_list=[]) + cls.__instance = cls(servers_list=[], config_scope="global") return cls.__instance + @classmethod + def clear_project_instances(cls): + with cls.__lock: + cls.__project_instances = {} + + @classmethod + def parse_config_string(cls, config_str: str) -> List[Dict[str, Any]]: + servers_data: List[Dict[str, Any]] = [] + + if not (config_str and config_str.strip()): + return servers_data + + try: + parsed_value = dirty_json.try_parse(config_str) + normalized = cls.normalize_config(parsed_value) + + if isinstance(normalized, list): + for item in normalized: + if isinstance(item, dict): + servers_data.append(dict(item)) + else: + PrintStyle( + background_color="yellow", + font_color="black", + padding=True, + ).print( + f"Warning: MCP config item was not a dictionary and was ignored: {item}" + ) + else: + PrintStyle( + background_color="red", font_color="white", padding=True + ).print( + f"Error: Parsed MCP config top-level structure is not a list. Config string was: '{config_str}'" + ) + except Exception as e_json: + PrintStyle.error( + f"Error parsing MCP config string: {e_json}. Config string was: '{config_str}'" + ) + + return servers_data + + @classmethod + def merge_config_strings( + cls, global_config: str, project_config: str + ) -> tuple[list[dict[str, Any]], str]: + merged: dict[str, dict[str, Any]] = {} + unnamed: list[dict[str, Any]] = [] + + def add_servers(config_str: str, scope: str): + for server in cls.parse_config_string(config_str): + server_copy = dict(server) + server_copy["scope"] = scope + name = str(server_copy.get("name", "") or "").strip() + if not name: + unnamed.append(server_copy) + continue + normalized_name = normalize_name(name) + server_copy["name"] = normalized_name + merged[normalized_name] = server_copy + + add_servers(global_config or DEFAULT_MCP_SERVERS_CONFIG, "global") + add_servers(project_config or DEFAULT_MCP_SERVERS_CONFIG, "project") + + servers = [*unnamed, *merged.values()] + cache_key = dirty_json.stringify( + { + "mcpServers": { + s.get("name", f"unnamed_{i}"): s + for i, s in enumerate(servers) + } + } + ) + return servers, cache_key + + @classmethod + def get_project_instance(cls, project_name: str | None, *, force: bool = False) -> "MCPConfig": + project_key = str(project_name or "").strip() + if not project_key: + return cls.get_instance() + + from helpers import projects + project_key = projects.validate_project_name(project_key) + + global_config = settings.get_settings().get( + "mcp_servers", DEFAULT_MCP_SERVERS_CONFIG + ) + project_config = projects.load_project_mcp_servers(project_key) + servers_data, cache_key = cls.merge_config_strings(global_config, project_config) + + with cls.__lock: + cached = cls.__project_instances.get(project_key) + if cached and cached[0] == cache_key and not force: + return cached[1] + + instance = cls(servers_list=servers_data, config_scope=f"project:{project_key}") + with cls.__lock: + cls.__project_instances[project_key] = (cache_key, instance) + return instance + + @classmethod + def refresh_project(cls, project_name: str) -> "MCPConfig": + project_key = str(project_name or "").strip() + with cls.__lock: + cls.__project_instances.pop(project_key, None) + return cls.get_project_instance(project_key, force=True) + + @classmethod + def get_for_agent(cls, agent: Any) -> "MCPConfig": + try: + from helpers import projects + + project_name = projects.get_context_project_name(agent.context) + if project_name: + return cls.get_project_instance(project_name) + except Exception: + pass + return cls.get_instance() + @classmethod def wait_for_lock(cls): with cls.__lock: @@ -609,70 +746,7 @@ class MCPConfig(BaseModel): @classmethod def update(cls, config_str: str) -> Any: with cls.__lock: - servers_data: List[Dict[str, Any]] = [] # Default to empty list - - if ( - config_str and config_str.strip() - ): # Only parse if non-empty and not just whitespace - try: - # Try with standard json.loads first, as it should handle escaped strings correctly - parsed_value = dirty_json.try_parse(config_str) - normalized = cls.normalize_config(parsed_value) - - if isinstance(normalized, list): - valid_servers = [] - for item in normalized: - if isinstance(item, dict): - valid_servers.append(item) - else: - PrintStyle( - background_color="yellow", - font_color="black", - padding=True, - ).print( - f"Warning: MCP config item (from json.loads) was not a dictionary and was ignored: {item}" - ) - servers_data = valid_servers - else: - PrintStyle( - background_color="red", font_color="white", padding=True - ).print( - f"Error: Parsed MCP config (from json.loads) top-level structure is not a list. Config string was: '{config_str}'" - ) - # servers_data remains empty - except ( - Exception - ) as e_json: # Catch json.JSONDecodeError specifically if possible, or general Exception - PrintStyle.error( - f"Error parsing MCP config string: {e_json}. Config string was: '{config_str}'" - ) - - # # Fallback to DirtyJson or log error if standard json.loads fails - # PrintStyle(background_color="orange", font_color="black", padding=True).print( - # f"Standard json.loads failed for MCP config: {e_json}. Attempting DirtyJson as fallback." - # ) - # try: - # parsed_value = DirtyJson.parse_string(config_str) - # if isinstance(parsed_value, list): - # valid_servers = [] - # for item in parsed_value: - # if isinstance(item, dict): - # valid_servers.append(item) - # else: - # PrintStyle(background_color="yellow", font_color="black", padding=True).print( - # f"Warning: MCP config item (from DirtyJson) was not a dictionary and was ignored: {item}" - # ) - # servers_data = valid_servers - # else: - # PrintStyle(background_color="red", font_color="white", padding=True).print( - # f"Error: Parsed MCP config (from DirtyJson) top-level structure is not a list. Config string was: '{config_str}'" - # ) - # # servers_data remains empty - # except Exception as e_dirty: - # PrintStyle(background_color="red", font_color="white", padding=True).print( - # f"Error parsing MCP config string with DirtyJson as well: {e_dirty}. Config string was: '{config_str}'" - # ) - # # servers_data remains empty, allowing graceful degradation + servers_data = cls.parse_config_string(config_str) # Initialize/update the singleton instance with the (potentially empty) list of server data instance = cls.get_instance() @@ -683,7 +757,8 @@ class MCPConfig(BaseModel): } # Prepare data for re-initialization or update # Option 1: Re-initialize the existing instance (if __init__ is idempotent for other fields) - instance.__init__(servers_list=servers_data) + instance.__init__(servers_list=servers_data, config_scope="global") + cls.__project_instances = {} # Option 2: Or, if __init__ has side effects we don't want to repeat, # and 'servers' is the primary thing 'update' changes: @@ -708,23 +783,24 @@ class MCPConfig(BaseModel): if isinstance(servers, list): for server in servers: if isinstance(server, dict): - normalized.append(server) + normalized.append(dict(server)) elif isinstance(servers, dict): if "mcpServers" in servers: if isinstance(servers["mcpServers"], dict): for key, value in servers["mcpServers"].items(): if isinstance(value, dict): - value["name"] = key - normalized.append(value) + server = dict(value) + server["name"] = key + normalized.append(server) elif isinstance(servers["mcpServers"], list): for server in servers["mcpServers"]: if isinstance(server, dict): - normalized.append(server) + normalized.append(dict(server)) else: - normalized.append(servers) # single server? + normalized.append(dict(servers)) # single server? return normalized - def __init__(self, servers_list: List[Dict[str, Any]]): + def __init__(self, servers_list: List[Dict[str, Any]], config_scope: str = "global"): from collections.abc import Mapping, Iterable # # DEBUG: Print the received servers_list @@ -741,6 +817,7 @@ class MCPConfig(BaseModel): # Clear any servers potentially initialized by super().__init__() before we populate based on servers_list self.servers = [] + self.config_scope = config_scope # initialize failed servers list self.disconnected_servers = [] @@ -867,10 +944,10 @@ class MCPConfig(BaseModel): name = server.name # get tool count tool_count = len(server.get_tools()) - # check if server is connected - connected = True # tool_count > 0 # get error message if any error = server.get_error() + # A server object can exist while its initialization failed. + connected = not bool(error) # get log bool has_log = server.get_log() != "" @@ -878,6 +955,9 @@ class MCPConfig(BaseModel): result.append( { "name": name, + "scope": getattr(server, "scope", self.config_scope), + "type": getattr(server, "type", ""), + "description": getattr(server, "description", ""), "connected": connected, "error": error, "tool_count": tool_count, @@ -890,6 +970,9 @@ class MCPConfig(BaseModel): result.append( { "name": disconnected["name"], + "scope": disconnected.get("config", {}).get("scope", self.config_scope), + "type": disconnected.get("config", {}).get("type", ""), + "description": disconnected.get("config", {}).get("description", ""), "connected": False, "error": disconnected["error"], "tool_count": 0, @@ -910,6 +993,8 @@ class MCPConfig(BaseModel): return { "name": server.name, "description": server.description, + "scope": getattr(server, "scope", self.config_scope), + "type": getattr(server, "type", ""), "tools": tools, } return {} @@ -986,9 +1071,10 @@ class MCPConfig(BaseModel): def has_tool(self, tool_name: str) -> bool: """Check if a tool is available""" - if "." not in tool_name: + try: + server_name_part, tool_name_part = _split_qualified_tool_name(tool_name) + except ValueError: return False - server_name_part, tool_name_part = tool_name.split(".") with self.__lock: for server in self.servers: if server.name == server_name_part: @@ -996,6 +1082,9 @@ class MCPConfig(BaseModel): return False def get_tool(self, agent: Any, tool_name: str) -> MCPTool | None: + effective_config = MCPConfig.get_for_agent(agent) + if effective_config is not self: + return effective_config.get_tool(agent, tool_name) if not self.has_tool(tool_name): return None return MCPTool(agent=agent, name=tool_name, method=None, args={}, message="", loop_data=None) @@ -1004,9 +1093,7 @@ class MCPConfig(BaseModel): self, tool_name: str, input_data: Dict[str, Any] ) -> CallToolResult: """Call a tool with the given input data""" - if "." not in tool_name: - raise ValueError(f"Tool {tool_name} not found") - server_name_part, tool_name_part = tool_name.split(".") + server_name_part, tool_name_part = _split_qualified_tool_name(tool_name) with self.__lock: for server in self.servers: if server.name == server_name_part and server.has_tool(tool_name_part): @@ -1119,16 +1206,21 @@ class MCPClientBase(ABC): } for tool in response.tools ] + self.error = "" PrintStyle(font_color="green").print( f"MCPClientBase ({self.server.name}): Tools updated. Found {len(self.tools)} tools." ) try: - set = settings.get_settings() + current_settings = settings.get_settings() + init_timeout = ( + self.server.init_timeout + or current_settings.get("mcp_client_init_timeout", 10) + or 10 + ) await self._execute_with_session( list_tools_op, - read_timeout_seconds=self.server.init_timeout - or set["mcp_client_init_timeout"], + read_timeout_seconds=init_timeout, ) except Exception as e: # e = eg.exceptions[0] @@ -1155,7 +1247,7 @@ class MCPClientBase(ABC): def get_tools(self) -> List[dict[str, Any]]: """Get all tools from the server (uses cached tools)""" with self.__lock: - return self.tools + return [dict(tool) for tool in self.tools] async def call_tool( self, tool_name: str, input_data: Dict[str, Any] @@ -1177,19 +1269,28 @@ class MCPClientBase(ABC): f"MCPClientBase ({self.server.name}): Tool '{tool_name}' found after updating tools." ) + current_settings = settings.get_settings() + tool_timeout = ( + self.server.tool_timeout + or current_settings.get("mcp_client_tool_timeout", 120) + or 120 + ) + async def call_tool_op(current_session: ClientSession): - set = settings.get_settings() # PrintStyle(font_color="cyan").print(f"MCPClientBase ({self.server.name}): Executing 'call_tool' for '{tool_name}' via MCP session...") response: CallToolResult = await current_session.call_tool( tool_name, input_data, - read_timeout_seconds=timedelta(seconds=set["mcp_client_tool_timeout"]), + read_timeout_seconds=timedelta(seconds=tool_timeout), ) # PrintStyle(font_color="green").print(f"MCPClientBase ({self.server.name}): Tool '{tool_name}' call successful via session.") return response try: - return await self._execute_with_session(call_tool_op) + return await self._execute_with_session( + call_tool_op, + read_timeout_seconds=tool_timeout, + ) except Exception as e: # Error logged by _execute_with_session. Re-raise a specific error for the caller. PrintStyle( @@ -1304,11 +1405,19 @@ class MCPClientRemote(MCPClientBase): ]: """Connect to an MCP server, init client and save stdio/write streams""" server: MCPServerRemote = cast(MCPServerRemote, self.server) - set = settings.get_settings() + current_settings = settings.get_settings() # Resolve timeout: check server config first, then settings, defaulting to 5s/10s - init_timeout = server.init_timeout or set["mcp_client_init_timeout"] or 5 - tool_timeout = server.tool_timeout or set["mcp_client_tool_timeout"] or 10 + init_timeout = ( + server.init_timeout + or current_settings.get("mcp_client_init_timeout", 10) + or 10 + ) + tool_timeout = ( + server.tool_timeout + or current_settings.get("mcp_client_tool_timeout", 120) + or 120 + ) client_factory = CustomHTTPClientFactory(verify=server.verify) # Check if this is a streaming HTTP type diff --git a/helpers/mcp_handler.py.dox.md b/helpers/mcp_handler.py.dox.md index fc98db4f0..17ca16498 100644 --- a/helpers/mcp_handler.py.dox.md +++ b/helpers/mcp_handler.py.dox.md @@ -3,7 +3,7 @@ ## Purpose - Own the `mcp_handler.py` helper module. -- This module loads MCP server configuration and exposes MCP tools to agents. +- This module loads global and project-scoped MCP server configuration and exposes MCP tools to agents. - Keep this file-level DOX profile synchronized with `mcp_handler.py` because this directory is intentionally flat. ## Ownership @@ -34,6 +34,12 @@ - `async initialize(self) -> 'MCPServerLocal'` - `MCPConfig` (`BaseModel`) - `get_instance(cls) -> 'MCPConfig'` + - `clear_project_instances(cls)` + - `parse_config_string(cls, config_str: str) -> List[Dict[str, Any]]` + - `merge_config_strings(cls, global_config: str, project_config: str) -> tuple[List[Dict[str, Any]], str]` + - `get_project_instance(cls, project_name: str | None, *, force: bool = False) -> 'MCPConfig'` + - `refresh_project(cls, project_name: str) -> 'MCPConfig'` + - `get_for_agent(cls, agent: Any) -> 'MCPConfig'` - `wait_for_lock(cls)` - `update(cls, config_str: str) -> Any` - `normalize_config(cls, servers: Any)` @@ -56,8 +62,9 @@ - `normalize_name(name: str) -> str` - `_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. - `initialize_mcp(mcp_servers_config: str)` -- Notable constants/configuration names: `MCP_MEDIA_TOKENS_ESTIMATE`, `MAX_MCP_RESOURCE_TEXT_CHARS`, `T`. +- Notable constants/configuration names: `DEFAULT_MCP_SERVERS_CONFIG`, `MCP_MEDIA_TOKENS_ESTIMATE`, `MAX_MCP_RESOURCE_TEXT_CHARS`, `T`. ## Runtime Contracts @@ -65,12 +72,18 @@ - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change. - `MCPTool` is a `Tool`. - `MCPTool` defines `execute(...)`. +- Global MCP configuration remains backed by settings; project MCP configuration is loaded through `helpers.projects` and merged with global config when an active agent context has `context.project`. +- 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. +- 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. - Imported dependency areas include: `abc`, `anyio.streams.memory`, `asyncio`, `contextlib`, `datetime`, `helpers`, `helpers.log`, `helpers.print_style`, `helpers.tool`, `httpx`, `json`, `mcp`, `mcp.client.sse`, `mcp.client.stdio`, `mcp.client.streamable_http`, `mcp.shared.message`. ## Key Concepts -- Important called helpers/classes observed in the source: `TypeVar`, `name.strip.lower`, `re.sub`, `Field`, `PrivateAttr`, `threading.Lock`, `config_dict.lower`, `server_type.lower`, `MCPConfig.get_instance.is_initialized`, `self.agent.context.log.log`, `str.strip`, `media_artifacts.guess_extension`, `callable`, `self._content_item_dump`, `join`, `Response`, `self.get_log_object`, `self._raw_tool_response`, `additional.pop`, `self._coerce_media_token_estimate`. +- Important called helpers/classes observed in the source: `TypeVar`, `name.strip.lower`, `re.sub`, `Field`, `PrivateAttr`, `threading.Lock`, `_split_qualified_tool_name`, `config_dict.lower`, `server_type.lower`, `MCPConfig.get_instance.is_initialized`, `MCPConfig.get_for_agent`, `projects.validate_project_name`, `projects.load_project_mcp_servers`, `settings.get_settings`, `self.agent.context.log.log`, `str.strip`, `media_artifacts.guess_extension`, `callable`, `self._content_item_dump`, `join`, `Response`, `self.get_log_object`, `self._raw_tool_response`, `additional.pop`, `self._coerce_media_token_estimate`. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. ## Work Guidance diff --git a/helpers/projects.py b/helpers/projects.py index e103e5f87..67e855978 100644 --- a/helpers/projects.py +++ b/helpers/projects.py @@ -13,7 +13,9 @@ PROJECT_META_DIR = ".a0proj" PROJECT_INSTRUCTIONS_DIR = "instructions" PROJECT_KNOWLEDGE_DIR = "knowledge" PROJECT_HEADER_FILE = "project.json" +PROJECT_MCP_SERVERS_FILE = "mcp_servers.json" PROJECT_AGENTS_MD_FILES = ("AGENTS.md", "Agents.md", "agents.md") +DEFAULT_MCP_SERVERS_CONFIG = '{\n "mcpServers": {}\n}' CONTEXT_DATA_KEY_PROJECT = "project" @@ -34,6 +36,7 @@ class BasicProjectData(TypedDict): description: str instructions: str include_agents_md: NotRequired[bool] + mcp_servers: NotRequired[str] color: str git_url: str file_structure: FileStructureInjectionSettings @@ -53,6 +56,7 @@ class EditProjectData(BasicProjectData): knowledge_files_count: int variables: str secrets: str + mcp_servers: str subagents: dict[str, SubAgentSettings] git_status: GitStatusData @@ -70,6 +74,17 @@ def get_project_meta(name: str, *sub_dirs: str): return files.get_abs_path(get_project_folder(name), PROJECT_META_DIR, *sub_dirs) +def validate_project_name(name: str | None) -> str: + candidate = str(name or "").strip() + if ( + not candidate + or candidate in {".", ".."} + or os.path.basename(candidate) != candidate + ): + raise ValueError("Invalid project name") + return candidate + + def delete_project(name: str): abs_path = files.get_abs_path(PROJECTS_PARENT_DIR, name) files.delete_dir(abs_path) @@ -79,12 +94,14 @@ def delete_project(name: str): def create_project(name: str, data: BasicProjectData): llm_data = data.get("llm") if isinstance(data, dict) else None + mcp_servers = data.get("mcp_servers") if isinstance(data, dict) else None abs_path = files.create_dir_safe( files.get_abs_path(PROJECTS_PARENT_DIR, name), rename_format="{name}_{number}" ) create_project_meta_folders(name) data = _normalizeBasicData(data) save_project_header(name, data) + save_project_mcp_servers(name, mcp_servers or DEFAULT_MCP_SERVERS_CONFIG) save_project_llm_settings(name, llm_data) return name @@ -94,6 +111,7 @@ def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjec from helpers import git llm_data = data.get("llm") if isinstance(data, dict) else None + mcp_servers = data.get("mcp_servers") if isinstance(data, dict) else None abs_path = files.create_dir_safe( files.get_abs_path(PROJECTS_PARENT_DIR, name), rename_format="{name}_{number}" @@ -121,6 +139,8 @@ def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjec data["git_url"] = clean_url save_project_header(actual_name, data) + if mcp_servers: + save_project_mcp_servers(actual_name, mcp_servers) save_project_llm_settings(actual_name, llm_data) return actual_name @@ -183,6 +203,7 @@ def _normalizeEditData(data: EditProjectData) -> EditProjectData: data.get("include_agents_md", True) ), "variables": data.get("variables", ""), + "mcp_servers": data.get("mcp_servers", DEFAULT_MCP_SERVERS_CONFIG), "color": data.get("color", ""), "git_url": data.get("git_url", ""), "git_status": data.get("git_status", {"is_git_repo": False}), @@ -234,6 +255,7 @@ def update_project(name: str, data: EditProjectData): # save secrets save_project_variables(name, current["variables"]) save_project_secrets(name, current["secrets"]) + save_project_mcp_servers(name, current["mcp_servers"]) save_project_subagents(name, current["subagents"]) save_project_llm_settings(name, llm_data) @@ -253,6 +275,7 @@ def load_edit_project_data(name: str) -> EditProjectData: data = load_basic_project_data(name) additional_instructions = get_additional_instructions_files(name) variables = load_project_variables(name) + mcp_servers = load_project_mcp_servers(name) secrets = load_project_secrets_masked(name) subagents = load_project_subagents(name) knowledge_files_count = get_knowledge_files_count(name) @@ -266,6 +289,7 @@ def load_edit_project_data(name: str) -> EditProjectData: "instruction_files_count": len(additional_instructions), "knowledge_files_count": knowledge_files_count, "variables": variables, + "mcp_servers": mcp_servers, "secrets": secrets, "subagents": subagents, "git_status": git_status, @@ -335,6 +359,20 @@ def save_project_llm_settings(name: str, llm_data: object): plugins.save_plugin_config("_model_config", name, "", config_to_save) +def load_project_mcp_servers(name: str) -> str: + project_name = validate_project_name(name) + try: + return files.read_file(get_project_meta(project_name, PROJECT_MCP_SERVERS_FILE)) + except Exception: + return DEFAULT_MCP_SERVERS_CONFIG + + +def save_project_mcp_servers(name: str, mcp_servers: str): + project_name = validate_project_name(name) + content = mcp_servers if isinstance(mcp_servers, str) else DEFAULT_MCP_SERVERS_CONFIG + files.write_file(get_project_meta(project_name, PROJECT_MCP_SERVERS_FILE), content) + + def get_active_projects_list(): return _get_projects_list(get_projects_parent_folder()) diff --git a/helpers/projects.py.dox.md b/helpers/projects.py.dox.md index 08b674859..bf6a11e58 100644 --- a/helpers/projects.py.dox.md +++ b/helpers/projects.py.dox.md @@ -3,7 +3,7 @@ ## Purpose - Own the `projects.py` helper module. -- This module owns project metadata, workspace creation, Git status, and project-scoped settings. +- This module owns project metadata, workspace creation, Git status, and project-scoped settings including per-project MCP server config. - Keep this file-level DOX profile synchronized with `projects.py` because this directory is intentionally flat. ## Ownership @@ -20,6 +20,7 @@ - `get_projects_parent_folder()` - `get_project_folder(name: str)` - `get_project_meta(name: str, *sub_dirs)` +- `validate_project_name(name: str | None) -> str` - `delete_project(name: str)` - `create_project(name: str, data: BasicProjectData)` - `clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjectData)`: Clone a git repository as a new A0 project. Token is used only for cloning via http header. @@ -35,6 +36,8 @@ - `save_project_header(name: str, data: BasicProjectData)` - `load_project_llm_data(name: str) -> dict` - `save_project_llm_settings(name: str, llm_data: object)` +- `load_project_mcp_servers(name: str) -> str` +- `save_project_mcp_servers(name: str, mcp_servers: str)` - `get_active_projects_list()` - `_get_projects_list(parent_dir)` - `activate_project(context_id: str, name: str, mark_dirty: bool=...)` @@ -47,18 +50,21 @@ - `get_project_agents_md_instruction_file(name: str) -> tuple[str, str] | None` - `_format_project_instruction_files(instruction_files: list[tuple[str, str]]) -> str` - `_normalize_include_agents_md(value: object) -> bool` -- Notable constants/configuration names: `PROJECTS_PARENT_DIR`, `PROJECT_META_DIR`, `PROJECT_INSTRUCTIONS_DIR`, `PROJECT_KNOWLEDGE_DIR`, `PROJECT_HEADER_FILE`, `PROJECT_AGENTS_MD_FILES`, `CONTEXT_DATA_KEY_PROJECT`. +- Notable constants/configuration names: `PROJECTS_PARENT_DIR`, `PROJECT_META_DIR`, `PROJECT_INSTRUCTIONS_DIR`, `PROJECT_KNOWLEDGE_DIR`, `PROJECT_HEADER_FILE`, `PROJECT_MCP_SERVERS_FILE`, `PROJECT_AGENTS_MD_FILES`, `DEFAULT_MCP_SERVERS_CONFIG`, `CONTEXT_DATA_KEY_PROJECT`. ## Runtime Contracts - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together. - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change. +- Per-project MCP server configuration is persisted as `.a0proj/mcp_servers.json`, exposed through `load_edit_project_data(...)`, and saved during project create/clone/update flows. +- Project MCP config uses the same JSON string shape as global MCP settings: an object with `mcpServers`. +- Project MCP load/save paths validate project names as simple folder basenames before touching `.a0proj/mcp_servers.json`. - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, plugin state, settings/state persistence, secret handling. - Imported dependency areas include: `helpers`, `helpers.print_style`, `os`, `typing`. ## Key Concepts -- Important called helpers/classes observed in the source: `files.get_abs_path`, `files.delete_dir`, `deactivate_project_in_chats`, `files.create_dir_safe`, `create_project_meta_folders`, `_normalizeBasicData`, `save_project_header`, `save_project_llm_settings`, `files.basename`, `dirty_json.parse`, `FileStructureInjectionSettings`, `cast`, `_normalizeEditData`, `load_edit_project_data`, `_edit_data_to_basic_data`, `save_project_variables`, `save_project_secrets`, `save_project_subagents`, `reactivate_project_in_chats`, `load_basic_project_data`. +- Important called helpers/classes observed in the source: `files.get_abs_path`, `files.delete_dir`, `deactivate_project_in_chats`, `files.create_dir_safe`, `create_project_meta_folders`, `_normalizeBasicData`, `save_project_header`, `save_project_mcp_servers`, `load_project_mcp_servers`, `save_project_llm_settings`, `files.basename`, `dirty_json.parse`, `FileStructureInjectionSettings`, `cast`, `_normalizeEditData`, `load_edit_project_data`, `_edit_data_to_basic_data`, `save_project_variables`, `save_project_secrets`, `save_project_subagents`, `reactivate_project_in_chats`, `load_basic_project_data`. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. ## Work Guidance diff --git a/tests/test_mcp_handler_multimodal.py b/tests/test_mcp_handler_multimodal.py index 777e23151..0a764ae92 100644 --- a/tests/test_mcp_handler_multimodal.py +++ b/tests/test_mcp_handler_multimodal.py @@ -153,6 +153,109 @@ def _agent_recorder(context_id: str = "ctx-mcp"): return agent, log, tool_results, messages, updates, warnings +def test_mcp_config_preserves_dotted_tool_names(mcp_handler_module): + module, _tmp_path = mcp_handler_module + called: list[tuple[str, dict]] = [] + + class _FakeServer: + name = "server" + description = "Fake MCP server" + type = "stdio" + scope = "global" + + def get_tools(self): + return [ + { + "name": "alpha.beta", + "description": "Dotted MCP tool", + "input_schema": {}, + } + ] + + def has_tool(self, tool_name): + return tool_name == "alpha.beta" + + async def call_tool(self, tool_name, input_data): + called.append((tool_name, input_data)) + return _FakeCallToolResult(content=[], isError=False) + + def get_error(self): + return "" + + def get_log(self): + return "" + + config = module.MCPConfig(servers_list=[]) + config.servers = [_FakeServer()] + + assert config.has_tool("server.alpha.beta") is True + asyncio.run(config.call_tool("server.alpha.beta", {"value": 7})) + + assert called == [("alpha.beta", {"value": 7})] + + +def test_mcp_status_marks_servers_with_errors_disconnected(mcp_handler_module): + module, _tmp_path = mcp_handler_module + + class _FakeServer: + name = "broken" + description = "Broken MCP server" + type = "stdio" + scope = "global" + + def get_tools(self): + return [] + + def get_error(self): + return "Failed to initialize" + + def get_log(self): + return "stderr" + + config = module.MCPConfig(servers_list=[]) + config.servers = [_FakeServer()] + + status = config.get_servers_status() + + assert status[0]["connected"] is False + assert status[0]["error"] == "Failed to initialize" + assert status[0]["has_log"] is True + + +def test_mcp_client_call_tool_uses_server_tool_timeout(mcp_handler_module, monkeypatch): + module, _tmp_path = mcp_handler_module + session_timeouts = [] + call_timeouts = [] + + monkeypatch.setattr( + module.settings, + "get_settings", + lambda: {"mcp_client_init_timeout": 10, "mcp_client_tool_timeout": 120}, + raising=False, + ) + + class _FakeSession: + async def call_tool(self, tool_name, input_data, read_timeout_seconds=None): + call_timeouts.append(read_timeout_seconds) + return _FakeCallToolResult(content=[], isError=False) + + class _FakeClient(module.MCPClientBase): + async def _create_stdio_transport(self, current_exit_stack): + raise AssertionError("transport should be bypassed by fake session") + + async def _execute_with_session(self, coro_func, read_timeout_seconds=60): + session_timeouts.append(read_timeout_seconds) + return await coro_func(_FakeSession()) + + client = _FakeClient(SimpleNamespace(name="server", tool_timeout=7, init_timeout=0)) + client.tools = [{"name": "run"}] + + asyncio.run(client.call_tool("run", {"x": 1})) + + assert session_timeouts == [7] + assert call_timeouts[0].total_seconds() == 7 + + def test_mcp_image_content_becomes_history_image_attachment(mcp_handler_module, monkeypatch): module, _tmp_path = mcp_handler_module agent, log, tool_results, messages, updates, warnings = _agent_recorder() @@ -166,7 +269,7 @@ def test_mcp_image_content_becomes_history_image_attachment(mcp_handler_module, async def call_tool(self, name, kwargs): return result - monkeypatch.setattr(module.MCPConfig, "get_instance", lambda: _FakeConfig()) + monkeypatch.setattr(module.MCPConfig, "get_for_agent", lambda agent: _FakeConfig()) tool = module.MCPTool( agent=agent, @@ -209,7 +312,7 @@ def test_mcp_audio_content_is_saved_instead_of_discarded(mcp_handler_module, mon async def call_tool(self, name, kwargs): return result - monkeypatch.setattr(module.MCPConfig, "get_instance", lambda: _FakeConfig()) + monkeypatch.setattr(module.MCPConfig, "get_for_agent", lambda agent: _FakeConfig()) tool = module.MCPTool( agent=agent, @@ -259,7 +362,7 @@ def test_mcp_image_resource_blob_becomes_history_image_attachment(mcp_handler_mo async def call_tool(self, name, kwargs): return result - monkeypatch.setattr(module.MCPConfig, "get_instance", lambda: _FakeConfig()) + monkeypatch.setattr(module.MCPConfig, "get_for_agent", lambda agent: _FakeConfig()) tool = module.MCPTool( agent=agent, @@ -308,7 +411,7 @@ def test_mcp_resource_text_is_preserved(mcp_handler_module, monkeypatch): async def call_tool(self, name, kwargs): return result - monkeypatch.setattr(module.MCPConfig, "get_instance", lambda: _FakeConfig()) + monkeypatch.setattr(module.MCPConfig, "get_for_agent", lambda agent: _FakeConfig()) tool = module.MCPTool( agent=agent, diff --git a/tests/test_projects.py b/tests/test_projects.py index fd3a28357..0dc0b4ac5 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -6,6 +6,8 @@ from helpers import dirty_json, files, projects def _prepare_project_tree(monkeypatch, tmp_path: Path) -> None: monkeypatch.setattr(files, "_base_dir", str(tmp_path)) (tmp_path / "usr" / "projects").mkdir(parents=True, exist_ok=True) + (tmp_path / "usr" / "plugins").mkdir(parents=True, exist_ok=True) + (tmp_path / "plugins").mkdir(parents=True, exist_ok=True) def test_project_include_agents_md_defaults_true_and_saves(monkeypatch, tmp_path): @@ -24,6 +26,39 @@ def test_project_include_agents_md_defaults_true_and_saves(monkeypatch, tmp_path assert saved["include_agents_md"] is True +def test_project_mcp_servers_persist_in_project_meta(monkeypatch, tmp_path): + _prepare_project_tree(monkeypatch, tmp_path) + config = '{"mcpServers":{"demo":{"url":"https://example.com/mcp"}}}' + + projects.create_project( + "demo", + { + "title": "Demo", + "mcp_servers": config, + }, + ) + + assert projects.load_project_mcp_servers("demo") == config + assert projects.load_edit_project_data("demo")["mcp_servers"] == config + + updated = '{"mcpServers":{"other":{"command":"uvx","args":["pkg"]}}}' + projects.save_project_mcp_servers("demo", updated) + + assert projects.load_project_mcp_servers("demo") == updated + + +def test_project_mcp_servers_reject_path_names(monkeypatch, tmp_path): + _prepare_project_tree(monkeypatch, tmp_path) + + for name in ("../escape", "nested/project", ".", "..", ""): + try: + projects.save_project_mcp_servers(name, '{"mcpServers":{}}') + except ValueError: + pass + else: + raise AssertionError(f"Expected invalid project name: {name!r}") + + def test_project_system_prompt_includes_root_agents_md_with_path(monkeypatch, tmp_path): _prepare_project_tree(monkeypatch, tmp_path) projects.create_project( diff --git a/webui/components/chat/input/bottom-actions.html b/webui/components/chat/input/bottom-actions.html index 43aaedec1..88befe494 100644 --- a/webui/components/chat/input/bottom-actions.html +++ b/webui/components/chat/input/bottom-actions.html @@ -6,6 +6,7 @@ import { store as historyStore } from "/components/modals/history/history-store.js"; import { store as contextStore } from "/components/modals/context/context-store.js"; import { store as chatsStore } from "/components/sidebar/chats/chats-store.js"; + import { store as mcpServersStore } from "/components/settings/mcp/client/mcp-servers-store.js";
@@ -48,6 +49,15 @@ Attach folder + +