From 309de23fa8e1f66fb71eca0b724f4097939308fb Mon Sep 17 00:00:00 2001 From: Zeid Derhally Date: Sun, 1 Feb 2026 06:40:00 -0500 Subject: [PATCH 1/6] feat: Add custom MCP server support Introduces the ability to configure additional MCP (Model Context Protocol) servers through organization and project configuration files. This feature extends the agent's capabilities by allowing users to define custom tools. Implements a robust configuration system that: - Loads and validates MCP server definitions and org-level blocked tools. - Supports variable substitution in commands, arguments, and environment variables. - Resolves configuration hierarchy, allowing project-level definitions to override organization-level ones. - Integrates custom tools into the agent's security context, enforcing explicit tool allowlists and organization-wide blocks. Updates `CLAUDE.md` with detailed documentation on configuring and managing custom MCP servers, including schema and key behaviors. Provides example configuration files. --- CLAUDE.md | 55 ++++ client.py | 96 +++++- examples/org_config.yaml | 72 +++++ examples/project_allowed_commands.yaml | 47 +++ security.py | 331 ++++++++++++++++++- test_security.py | 430 +++++++++++++++++++++++++ ui/package-lock.json | 14 + 7 files changed, 1042 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ef1d7d08..dd42fddb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -372,6 +372,61 @@ blocked_commands: - `examples/org_config.yaml` - Org config example (all commented by default) - `examples/README.md` - Comprehensive guide with use cases, testing, and troubleshooting +#### Custom MCP Servers + +The agent supports adding custom MCP (Model Context Protocol) servers via configuration files. This allows extending the agent's capabilities with additional tools. + +**Configuration Locations:** +- **Org-level**: `~/.autocoder/config.yaml` - Available to ALL projects +- **Project-level**: `{project}/.autocoder/allowed_commands.yaml` - Project-specific + +**MCP Server Config Schema:** + +```yaml +# ~/.autocoder/config.yaml (org-level) +version: 1 + +mcp_servers: + - name: filesystem + command: npx + args: + - "@anthropic/mcp-server-filesystem" + - "${PROJECT_DIR}" # Variable substitution + env: + SOME_VAR: value + allowed_tools: # REQUIRED - explicit list of tools to allow + - read_file + - list_directory + +# Org-level can block specific tools across ALL projects +blocked_mcp_tools: + - filesystem__write_file # Block write across all projects + - filesystem__delete_file +``` + +**Variable Substitution:** +- `${PROJECT_DIR}` - Absolute path to project directory +- `${HOME}` - User's home directory + +**Tool Naming Convention:** +- MCP tools follow the pattern `mcp__{server}__{tool}` +- Config uses short names: `allowed_tools: [read_file]` +- Becomes: `mcp__filesystem__read_file` + +**Key Behaviors:** +- `allowed_tools` is REQUIRED for each MCP server - must explicitly list tools +- Org `blocked_mcp_tools` takes precedence - projects cannot allow blocked tools +- Environment variables merge with parent environment +- Project can override org MCP server by using the same name + +**Example Output:** + +``` +Created security settings at /path/to/project/.claude_settings.json + - MCP servers: playwright (browser), features (database), filesystem (custom) + - Blocked MCP tools (org): filesystem__write_file, filesystem__delete_file +``` + ### Vertex AI Configuration (Optional) Run coding agents via Google Cloud Vertex AI: diff --git a/client.py b/client.py index d31b5add..e2e0dd76 100644 --- a/client.py +++ b/client.py @@ -1,5 +1,6 @@ """ Claude SDK Client Configuration +Claude SDK Client Configuration =============================== Functions for creating and configuring the Claude Agent SDK client. @@ -11,13 +12,19 @@ import shutil import sys from pathlib import Path +from typing import Any from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient from claude_agent_sdk.types import HookContext, HookInput, HookMatcher, SyncHookJSONOutput from dotenv import load_dotenv from env_constants import API_ENV_VARS -from security import SENSITIVE_DIRECTORIES, bash_security_hook +from security import ( + SENSITIVE_DIRECTORIES, + bash_security_hook, + get_effective_mcp_servers, + get_effective_mcp_tools, +) # Load environment variables from .env file if present load_dotenv() @@ -182,6 +189,37 @@ def get_extra_read_paths() -> list[Path]: return validated_paths +def substitute_variables(value: str, project_dir: Path) -> str: + """ + Substitute variables in a configuration value. + + Supported variables: + - ${PROJECT_DIR} - Absolute path to project directory + - ${HOME} - User's home directory + + Args: + value: String value that may contain variables + project_dir: Path to the project directory + + Returns: + String with variables substituted + """ + result = value + result = result.replace("${PROJECT_DIR}", str(project_dir.resolve())) + result = result.replace("${HOME}", str(Path.home())) + return result + + +def substitute_variables_in_list(values: list[str], project_dir: Path) -> list[str]: + """Substitute variables in a list of strings.""" + return [substitute_variables(v, project_dir) for v in values] + + +def substitute_variables_in_dict(values: dict[str, str], project_dir: Path) -> dict[str, str]: + """Substitute variables in a dict of strings.""" + return {k: substitute_variables(v, project_dir) for k, v in values.items()} + + # Per-agent-type MCP tool lists. # Only expose the tools each agent type actually needs, reducing tool schema # overhead and preventing agents from calling tools meant for other roles. @@ -309,6 +347,7 @@ def create_client( Note: Authentication is handled by start.bat/start.sh before this runs. The Claude SDK auto-detects credentials from the Claude CLI configuration """ + # Select the feature MCP tools appropriate for this agent type feature_tools_map = { "coding": CODING_AGENT_TOOLS, @@ -327,12 +366,21 @@ def create_client( } max_turns = max_turns_map.get(agent_type, 300) + # Load user-configured MCP servers from org and project configs + user_mcp_servers, blocked_mcp_tools = get_effective_mcp_servers(project_dir) + user_mcp_tools, user_mcp_permissions = get_effective_mcp_tools( + user_mcp_servers, blocked_mcp_tools + ) + # Build allowed tools list based on mode and agent type. # In YOLO mode, exclude Playwright tools for faster prototyping. allowed_tools = [*BUILTIN_TOOLS, *feature_tools] if not yolo_mode: allowed_tools.extend(PLAYWRIGHT_TOOLS) + # Add user-configured MCP tools + allowed_tools.extend(user_mcp_tools) + # Build permissions list. # We permit ALL feature MCP tools at the security layer (so the MCP server # can respond if called), but the LLM only *sees* the agent-type-specific @@ -367,6 +415,9 @@ def create_client( # Allow Playwright MCP tools for browser automation (standard mode only) permissions_list.extend(PLAYWRIGHT_TOOLS) + # Add user-configured MCP tool permissions + permissions_list.extend(user_mcp_permissions) + # Create comprehensive security settings # Note: Using relative paths ("./**") restricts access to project directory # since cwd is set to project_dir @@ -394,10 +445,22 @@ def create_client( if extra_read_paths: print(f" - Extra read paths (validated): {', '.join(str(p) for p in extra_read_paths)}") print(" - Bash commands restricted to allowlist (see security.py)") - if yolo_mode: + + # Report MCP servers + builtin_servers = ["features (database)"] + if not yolo_mode: + builtin_servers.insert(0, "playwright (browser)") + user_server_names = [s["name"] for s in user_mcp_servers] + if user_server_names: + all_servers = builtin_servers + [f"{name} (custom)" for name in user_server_names] + print(f" - MCP servers: {', '.join(all_servers)}") + elif yolo_mode: print(" - MCP servers: features (database) - YOLO MODE (no Playwright)") else: print(" - MCP servers: playwright (browser), features (database)") + + if blocked_mcp_tools: + print(f" - Blocked MCP tools (org): {', '.join(sorted(blocked_mcp_tools))}") print(" - Project settings enabled (skills, commands, CLAUDE.md)") print() @@ -448,6 +511,35 @@ def create_client( "args": playwright_args, } + # Add user-configured MCP servers from org and project configs + for server_config in user_mcp_servers: + server_name = server_config["name"] + + # Skip if server name conflicts with built-in servers + if server_name in mcp_servers: + print(f" - Warning: Custom MCP server '{server_name}' conflicts with built-in, skipping") + continue + + # Build server entry with variable substitution + server_entry: dict[str, Any] = { + "command": substitute_variables(server_config["command"], project_dir), + } + + # Add args with variable substitution + if "args" in server_config: + server_entry["args"] = substitute_variables_in_list( + server_config["args"], project_dir + ) + + # Add env with variable substitution, merging with parent environment + if "env" in server_config: + server_entry["env"] = substitute_variables_in_dict( + server_config["env"], project_dir + ) + + mcp_servers[server_name] = server_entry + print(f" - Custom MCP server '{server_name}': {server_config['command']}") + # Build environment overrides for API endpoint configuration # These override system env vars for the Claude CLI subprocess, # allowing AutoCoder to use alternative APIs (e.g., GLM) without diff --git a/examples/org_config.yaml b/examples/org_config.yaml index f86d9f3a..debbfa41 100644 --- a/examples/org_config.yaml +++ b/examples/org_config.yaml @@ -91,6 +91,78 @@ blocked_commands: [] # - puppet +# ========================================== +# Custom MCP Servers +# ========================================== +# Configure additional MCP (Model Context Protocol) servers that will be +# available to the agent in ALL projects. +# +# Each MCP server MUST specify: +# - name: Unique identifier for the server +# - command: Command to run (e.g., "npx", "python") +# - allowed_tools: REQUIRED list of tools to allow (explicit allowlist) +# +# Optional fields: +# - args: List of command arguments +# - env: Environment variables (merged with parent environment) +# +# Variable substitution is supported: +# - ${PROJECT_DIR} - Absolute path to current project directory +# - ${HOME} - User's home directory +# +# By default, no custom MCP servers are configured. + +mcp_servers: [] + + # Example: Filesystem MCP server (read-only) + # - name: filesystem + # command: npx + # args: + # - "@anthropic/mcp-server-filesystem" + # - "${PROJECT_DIR}" + # allowed_tools: + # - read_file + # - list_directory + # - search_files + + # Example: Memory MCP server for persistent context + # - name: memory + # command: npx + # args: + # - "@anthropic/mcp-server-memory" + # env: + # MEMORY_DIR: "${HOME}/.autocoder/memory" + # allowed_tools: + # - store_memory + # - retrieve_memory + # - search_memory + + +# ========================================== +# Blocked MCP Tools (Organization-Wide) +# ========================================== +# Tools listed here are BLOCKED across ALL projects. +# Projects CANNOT override these blocks. +# +# Tool naming convention: {server}__{tool} or mcp__{server}__{tool} +# Examples: +# - filesystem__write_file (blocks filesystem server's write_file tool) +# - filesystem__delete_file (blocks filesystem server's delete_file tool) +# - myserver__* (blocks ALL tools from myserver - wildcard) +# +# By default, no tools are blocked. + +blocked_mcp_tools: [] + + # Block dangerous filesystem operations + # - filesystem__write_file + # - filesystem__delete_file + # - filesystem__move_file + + # Block all tools from a specific server (wildcard) + # - dangerous_server__* + + # ========================================== # Global Settings (Phase 3 feature) # ========================================== diff --git a/examples/project_allowed_commands.yaml b/examples/project_allowed_commands.yaml index 2a3bdf54..499742be 100644 --- a/examples/project_allowed_commands.yaml +++ b/examples/project_allowed_commands.yaml @@ -103,6 +103,53 @@ commands: [] # description: Deploy to staging environment +# ========================================== +# Custom MCP Servers +# ========================================== +# Configure additional MCP (Model Context Protocol) servers for THIS PROJECT. +# These are added to any org-level MCP servers defined in ~/.autocoder/config.yaml +# +# Each MCP server MUST specify: +# - name: Unique identifier for the server +# - command: Command to run (e.g., "npx", "python") +# - allowed_tools: REQUIRED list of tools to allow (explicit allowlist) +# +# Optional fields: +# - args: List of command arguments +# - env: Environment variables (merged with parent environment) +# +# Variable substitution is supported: +# - ${PROJECT_DIR} - Absolute path to current project directory +# - ${HOME} - User's home directory +# +# By default, no custom MCP servers are configured. + +mcp_servers: [] + + # Example: SQLite MCP server for this project's database + # - name: sqlite + # command: npx + # args: + # - "@anthropic/mcp-server-sqlite" + # - "${PROJECT_DIR}/data.db" + # allowed_tools: + # - read_query + # - list_tables + # - describe_table + + # Example: Custom project-specific MCP server + # - name: my_project_tools + # command: python + # args: + # - "${PROJECT_DIR}/tools/mcp_server.py" + # env: + # PROJECT_ROOT: "${PROJECT_DIR}" + # allowed_tools: + # - build_project + # - run_tests + # - deploy_staging + + # ========================================== # Notes and Best Practices # ========================================== diff --git a/security.py b/security.py index 1e7455f5..ddf87e47 100644 --- a/security.py +++ b/security.py @@ -11,10 +11,19 @@ import re import shlex from pathlib import Path -from typing import Optional +from typing import Any, Optional, TypedDict import yaml + +class MCPServerConfig(TypedDict, total=False): + """Configuration for a custom MCP server.""" + name: str # Required: unique identifier for the server + command: str # Required: command to run (e.g., "npx", "python") + args: list[str] # Optional: command arguments + env: dict[str, str] # Optional: environment variables + allowed_tools: list[str] # Required: explicit list of tools to allow + # Logger for security-related events (fallback parsing, validation failures, etc.) logger = logging.getLogger(__name__) @@ -825,6 +834,326 @@ def get_effective_pkill_processes(project_dir: Optional[Path]) -> set[str]: return processes +# ============================================================================= +# MCP Server Configuration +# ============================================================================= + + +def validate_mcp_server(server_config: dict, source: str = "config") -> tuple[bool, str]: + """ + Validate a single MCP server configuration entry. + + Args: + server_config: Dict with MCP server configuration + source: Description of where the config came from (for error messages) + + Returns: + Tuple of (is_valid, error_message) + """ + if not isinstance(server_config, dict): + return False, "MCP server config must be a dict" + + # Validate name (required) + if "name" not in server_config: + return False, "MCP server must have 'name' field" + name = server_config["name"] + if not isinstance(name, str) or not name.strip(): + return False, "MCP server name must be a non-empty string" + + # Validate command (required) + if "command" not in server_config: + return False, f"MCP server '{name}' must have 'command' field" + command = server_config["command"] + if not isinstance(command, str) or not command.strip(): + return False, f"MCP server '{name}' command must be a non-empty string" + + # Validate args (optional, must be list of strings) + if "args" in server_config: + args = server_config["args"] + if not isinstance(args, list): + return False, f"MCP server '{name}' args must be a list" + for i, arg in enumerate(args): + if not isinstance(arg, str): + return False, f"MCP server '{name}' args[{i}] must be a string" + + # Validate env (optional, must be dict of string keys and values) + if "env" in server_config: + env = server_config["env"] + if not isinstance(env, dict): + return False, f"MCP server '{name}' env must be a dict" + for key, value in env.items(): + if not isinstance(key, str): + return False, f"MCP server '{name}' env key must be a string" + if not isinstance(value, str): + return False, f"MCP server '{name}' env['{key}'] must be a string" + + # Validate allowed_tools (required, must be list of strings) + if "allowed_tools" not in server_config: + return False, f"MCP server '{name}' must have 'allowed_tools' field (explicit tool list required)" + allowed_tools = server_config["allowed_tools"] + if not isinstance(allowed_tools, list): + return False, f"MCP server '{name}' allowed_tools must be a list" + if len(allowed_tools) == 0: + return False, f"MCP server '{name}' allowed_tools cannot be empty" + for i, tool in enumerate(allowed_tools): + if not isinstance(tool, str) or not tool.strip(): + return False, f"MCP server '{name}' allowed_tools[{i}] must be a non-empty string" + + return True, "" + + +def _validate_mcp_servers_list( + servers: Any, + config_path: Path, + max_servers: int = 20 +) -> Optional[list[dict]]: + """ + Validate a list of MCP server configurations. + + Args: + servers: The mcp_servers value from config (should be a list) + config_path: Path to config file (for error messages) + max_servers: Maximum number of servers allowed + + Returns: + Validated list of server configs, or None if invalid + """ + if not isinstance(servers, list): + logger.warning(f"Config at {config_path}: 'mcp_servers' must be a list") + return None + + if len(servers) > max_servers: + logger.warning( + f"Config at {config_path} exceeds {max_servers} MCP server limit ({len(servers)} servers)" + ) + return None + + validated = [] + for i, server in enumerate(servers): + valid, error = validate_mcp_server(server, str(config_path)) + if not valid: + logger.warning(f"Config at {config_path}: mcp_servers[{i}] - {error}") + return None + validated.append(server) + + return validated + + +def _validate_blocked_mcp_tools( + tools: Any, + config_path: Path +) -> Optional[list[str]]: + """ + Validate blocked_mcp_tools list from org config. + + Args: + tools: The blocked_mcp_tools value from config + config_path: Path to config file (for error messages) + + Returns: + Validated list of blocked tool patterns, or None if invalid + """ + if not isinstance(tools, list): + logger.warning(f"Config at {config_path}: 'blocked_mcp_tools' must be a list") + return None + + validated = [] + for i, tool in enumerate(tools): + if not isinstance(tool, str): + logger.warning(f"Config at {config_path}: blocked_mcp_tools[{i}] must be a string") + return None + tool = tool.strip() + if not tool: + logger.warning(f"Config at {config_path}: blocked_mcp_tools[{i}] cannot be empty") + return None + validated.append(tool) + + return validated + + +def get_mcp_servers_from_org_config() -> tuple[list[dict], set[str]]: + """ + Load MCP server configurations from org-level config. + + Returns: + Tuple of (mcp_servers, blocked_mcp_tools) + """ + org_config = load_org_config() + if not org_config: + return [], set() + + mcp_servers = [] + blocked_tools: set[str] = set() + + # Load mcp_servers + if "mcp_servers" in org_config: + config_path = get_org_config_path() + validated_servers = _validate_mcp_servers_list( + org_config["mcp_servers"], + config_path, + max_servers=20 + ) + if validated_servers: + mcp_servers = validated_servers + + # Load blocked_mcp_tools + if "blocked_mcp_tools" in org_config: + config_path = get_org_config_path() + validated_tools = _validate_blocked_mcp_tools( + org_config["blocked_mcp_tools"], + config_path + ) + if validated_tools: + blocked_tools = set(validated_tools) + + return mcp_servers, blocked_tools + + +def get_mcp_servers_from_project_config(project_dir: Path) -> list[dict]: + """ + Load MCP server configurations from project-level config. + + Args: + project_dir: Path to the project directory + + Returns: + List of validated MCP server configurations + """ + project_config = load_project_commands(project_dir) + if not project_config: + return [] + + if "mcp_servers" not in project_config: + return [] + + config_path = project_dir.resolve() / ".autocoder" / "allowed_commands.yaml" + validated = _validate_mcp_servers_list( + project_config["mcp_servers"], + config_path, + max_servers=20 + ) + + return validated if validated else [] + + +def get_effective_mcp_servers(project_dir: Optional[Path]) -> tuple[list[dict], set[str]]: + """ + Get effective MCP server configurations after hierarchy resolution. + + Hierarchy: + 1. Org MCP servers - available to all projects with their allowed_tools + 2. Org blocked_mcp_tools - tools blocked across ALL projects + 3. Project MCP servers - project-specific additions + + Args: + project_dir: Path to the project directory, or None + + Returns: + Tuple of (mcp_servers, blocked_mcp_tools) + - mcp_servers: List of all MCP server configs (org + project) + - blocked_mcp_tools: Set of tool patterns blocked at org level + """ + # Get org-level servers and blocked tools + org_servers, blocked_tools = get_mcp_servers_from_org_config() + + # Get project-level servers + project_servers = [] + if project_dir: + project_servers = get_mcp_servers_from_project_config(project_dir) + + # Merge servers (org first, then project) + # Use dict to prevent duplicate server names (project can override org) + servers_by_name: dict[str, dict] = {} + for server in org_servers: + servers_by_name[server["name"]] = server + for server in project_servers: + servers_by_name[server["name"]] = server + + return list(servers_by_name.values()), blocked_tools + + +def get_effective_mcp_tools( + mcp_servers: list[dict], + blocked_tools: set[str] +) -> tuple[list[str], list[str]]: + """ + Get effective allowed and blocked MCP tools from server configurations. + + Tool naming convention: mcp__{server}__{tool} + Config uses short names: allowed_tools: [read_file] + Becomes: mcp__filesystem__read_file + + Args: + mcp_servers: List of MCP server configurations + blocked_tools: Set of tool patterns blocked at org level + + Returns: + Tuple of (allowed_tools, permissions) + - allowed_tools: List of full tool names (mcp__server__tool format) + - permissions: List of permission strings for the allowed tools + """ + allowed_tools: list[str] = [] + permissions: list[str] = [] + + for server in mcp_servers: + server_name = server["name"] + server_tools = server.get("allowed_tools", []) + + for tool in server_tools: + # Build full tool name + full_tool_name = f"mcp__{server_name}__{tool}" + + # Check if tool is blocked (exact match or pattern) + is_blocked = False + for blocked_pattern in blocked_tools: + if _matches_mcp_tool_pattern(full_tool_name, blocked_pattern, server_name): + is_blocked = True + logger.debug( + f"MCP tool '{full_tool_name}' blocked by pattern '{blocked_pattern}'" + ) + break + + if not is_blocked: + allowed_tools.append(full_tool_name) + permissions.append(full_tool_name) + + return allowed_tools, permissions + + +def _matches_mcp_tool_pattern(tool_name: str, pattern: str, server_name: str) -> bool: + """ + Check if an MCP tool matches a blocked pattern. + + Patterns can be: + - Full name: "mcp__filesystem__write_file" + - Short name with server: "filesystem__write_file" + - Wildcard: "filesystem__*" (blocks all tools from server) + + Args: + tool_name: Full tool name (mcp__server__tool) + pattern: Blocked pattern to check against + server_name: The server name for context + + Returns: + True if tool matches the blocked pattern + """ + # Normalize pattern (add mcp__ prefix if not present) + if not pattern.startswith("mcp__"): + pattern = f"mcp__{pattern}" + + # Exact match + if tool_name == pattern: + return True + + # Wildcard match (e.g., mcp__filesystem__* or filesystem__*) + if pattern.endswith("__*"): + prefix = pattern[:-1] # Remove trailing * + if tool_name.startswith(prefix): + return True + + return False + + def is_command_allowed(command: str, allowed_commands: set[str]) -> bool: """ Check if a command is allowed (supports patterns). diff --git a/test_security.py b/test_security.py index 40c1fa14..6d7ec352 100644 --- a/test_security.py +++ b/test_security.py @@ -15,15 +15,21 @@ from pathlib import Path from security import ( + _matches_mcp_tool_pattern, bash_security_hook, extract_commands, get_effective_commands, + get_effective_mcp_servers, + get_effective_mcp_tools, get_effective_pkill_processes, + get_mcp_servers_from_org_config, + get_mcp_servers_from_project_config, load_org_config, load_project_commands, matches_pattern, validate_chmod_command, validate_init_script, + validate_mcp_server, validate_pkill_command, validate_project_command, ) @@ -923,6 +929,405 @@ def test_pkill_extensibility(): return passed, failed +def test_mcp_server_validation(): + """Test MCP server configuration validation.""" + print("\nTesting MCP server validation:\n") + passed = 0 + failed = 0 + + # Test cases: (config, should_be_valid, description) + test_cases = [ + # Valid configurations + ( + { + "name": "test_server", + "command": "npx", + "args": ["@anthropic/mcp-server-test"], + "allowed_tools": ["read_file", "write_file"], + }, + True, + "valid complete config", + ), + ( + { + "name": "minimal", + "command": "python", + "allowed_tools": ["tool1"], + }, + True, + "valid minimal config (no args/env)", + ), + ( + { + "name": "with_env", + "command": "npx", + "args": ["@test/mcp"], + "env": {"VAR": "value", "ANOTHER": "test"}, + "allowed_tools": ["tool1"], + }, + True, + "valid config with env", + ), + + # Invalid configurations + ({}, False, "empty config"), + ({"command": "npx", "allowed_tools": ["tool"]}, False, "missing name"), + ({"name": "test", "allowed_tools": ["tool"]}, False, "missing command"), + ({"name": "test", "command": "npx"}, False, "missing allowed_tools"), + ({"name": "", "command": "npx", "allowed_tools": ["tool"]}, False, "empty name"), + ({"name": "test", "command": "", "allowed_tools": ["tool"]}, False, "empty command"), + ({"name": "test", "command": "npx", "allowed_tools": []}, False, "empty allowed_tools"), + ( + {"name": "test", "command": "npx", "args": "invalid", "allowed_tools": ["tool"]}, + False, + "args not a list", + ), + ( + {"name": "test", "command": "npx", "args": [123], "allowed_tools": ["tool"]}, + False, + "args item not string", + ), + ( + {"name": "test", "command": "npx", "env": "invalid", "allowed_tools": ["tool"]}, + False, + "env not a dict", + ), + ( + {"name": "test", "command": "npx", "env": {"key": 123}, "allowed_tools": ["tool"]}, + False, + "env value not string", + ), + ( + {"name": "test", "command": "npx", "allowed_tools": ["", "tool"]}, + False, + "empty tool in allowed_tools", + ), + ] + + for config, should_be_valid, description in test_cases: + valid, error = validate_mcp_server(config) + if valid == should_be_valid: + print(f" PASS: {description}") + passed += 1 + else: + expected = "valid" if should_be_valid else "invalid" + actual = "valid" if valid else "invalid" + print(f" FAIL: {description}") + print(f" Expected: {expected}, Got: {actual}") + if error: + print(f" Error: {error}") + failed += 1 + + return passed, failed + + +def test_mcp_tool_pattern_matching(): + """Test MCP tool pattern matching for blocked tools.""" + print("\nTesting MCP tool pattern matching:\n") + passed = 0 + failed = 0 + + # Test cases: (tool_name, pattern, server_name, should_match, description) + test_cases = [ + # Exact matches + ("mcp__filesystem__read_file", "filesystem__read_file", "filesystem", True, "short pattern match"), + ("mcp__filesystem__read_file", "mcp__filesystem__read_file", "filesystem", True, "full pattern match"), + + # Wildcard matches + ("mcp__filesystem__read_file", "filesystem__*", "filesystem", True, "wildcard matches read_file"), + ("mcp__filesystem__write_file", "filesystem__*", "filesystem", True, "wildcard matches write_file"), + ("mcp__filesystem__delete_file", "mcp__filesystem__*", "filesystem", True, "full wildcard pattern"), + + # Non-matches + ("mcp__filesystem__read_file", "other__read_file", "filesystem", False, "different server"), + ("mcp__filesystem__read_file", "filesystem__write_file", "filesystem", False, "different tool"), + ("mcp__memory__store", "filesystem__*", "memory", False, "wildcard wrong server"), + ] + + for tool_name, pattern, server_name, should_match, description in test_cases: + result = _matches_mcp_tool_pattern(tool_name, pattern, server_name) + if result == should_match: + print(f" PASS: {description}") + passed += 1 + else: + expected = "match" if should_match else "no match" + actual = "match" if result else "no match" + print(f" FAIL: {description}") + print(f" Tool: {tool_name}, Pattern: {pattern}") + print(f" Expected: {expected}, Got: {actual}") + failed += 1 + + return passed, failed + + +def test_mcp_server_config_loading(): + """Test MCP server configuration loading from org and project configs.""" + print("\nTesting MCP server config loading:\n") + passed = 0 + failed = 0 + + # Test 1: Org config with MCP servers + with tempfile.TemporaryDirectory() as tmphome: + with temporary_home(tmphome): + org_dir = Path(tmphome) / ".autocoder" + org_dir.mkdir() + org_config_path = org_dir / "config.yaml" + + org_config_path.write_text("""version: 1 +mcp_servers: + - name: test_server + command: npx + args: + - "@test/mcp" + allowed_tools: + - tool1 + - tool2 +blocked_mcp_tools: + - other__dangerous_tool +""") + + servers, blocked = get_mcp_servers_from_org_config() + + if len(servers) == 1 and servers[0]["name"] == "test_server": + print(" PASS: Org MCP servers loaded correctly") + passed += 1 + else: + print(f" FAIL: Org MCP servers not loaded correctly: {servers}") + failed += 1 + + if "other__dangerous_tool" in blocked: + print(" PASS: Org blocked_mcp_tools loaded correctly") + passed += 1 + else: + print(f" FAIL: Org blocked_mcp_tools not loaded correctly: {blocked}") + failed += 1 + + # Test 2: Project config with MCP servers + with tempfile.TemporaryDirectory() as tmpproject: + project_dir = Path(tmpproject) + autocoder_dir = project_dir / ".autocoder" + autocoder_dir.mkdir() + + config_path = autocoder_dir / "allowed_commands.yaml" + config_path.write_text("""version: 1 +commands: [] +mcp_servers: + - name: project_server + command: python + args: + - "${PROJECT_DIR}/tools/mcp.py" + env: + MY_VAR: "${HOME}/data" + allowed_tools: + - my_tool +""") + + servers = get_mcp_servers_from_project_config(project_dir) + + if len(servers) == 1 and servers[0]["name"] == "project_server": + print(" PASS: Project MCP servers loaded correctly") + passed += 1 + else: + print(f" FAIL: Project MCP servers not loaded correctly: {servers}") + failed += 1 + + if servers[0].get("env", {}).get("MY_VAR") == "${HOME}/data": + print(" PASS: Project MCP server env preserved (substitution happens in client.py)") + passed += 1 + else: + print(f" FAIL: Project MCP server env not preserved: {servers[0]}") + failed += 1 + + # Test 3: Invalid MCP server config is rejected + with tempfile.TemporaryDirectory() as tmphome: + with temporary_home(tmphome): + org_dir = Path(tmphome) / ".autocoder" + org_dir.mkdir() + org_config_path = org_dir / "config.yaml" + + org_config_path.write_text("""version: 1 +mcp_servers: + - name: invalid_server + command: npx + # Missing required allowed_tools +""") + + servers, _ = get_mcp_servers_from_org_config() + + if len(servers) == 0: + print(" PASS: Invalid MCP server config rejected") + passed += 1 + else: + print(f" FAIL: Invalid MCP server config should be rejected: {servers}") + failed += 1 + + return passed, failed + + +def test_mcp_hierarchy_resolution(): + """Test MCP server hierarchy resolution (org + project).""" + print("\nTesting MCP hierarchy resolution:\n") + passed = 0 + failed = 0 + + with tempfile.TemporaryDirectory() as tmphome: + with tempfile.TemporaryDirectory() as tmpproject: + with temporary_home(tmphome): + # Set up org config + org_dir = Path(tmphome) / ".autocoder" + org_dir.mkdir() + org_config_path = org_dir / "config.yaml" + + org_config_path.write_text("""version: 1 +mcp_servers: + - name: org_server + command: npx + args: ["@org/mcp"] + allowed_tools: + - org_tool + - name: shared_server + command: org_cmd + allowed_tools: + - org_version_tool +blocked_mcp_tools: + - org_server__blocked_tool +""") + + # Set up project config + project_dir = Path(tmpproject) + autocoder_dir = project_dir / ".autocoder" + autocoder_dir.mkdir() + + config_path = autocoder_dir / "allowed_commands.yaml" + config_path.write_text("""version: 1 +commands: [] +mcp_servers: + - name: project_server + command: python + allowed_tools: + - project_tool + - name: shared_server + command: project_cmd + allowed_tools: + - project_version_tool +""") + + # Test get_effective_mcp_servers + servers, blocked_tools = get_effective_mcp_servers(project_dir) + server_names = {s["name"] for s in servers} + + # Should have org_server, project_server, and shared_server (project version) + if "org_server" in server_names and "project_server" in server_names: + print(" PASS: Both org and project servers included") + passed += 1 + else: + print(f" FAIL: Expected org_server and project_server in {server_names}") + failed += 1 + + # shared_server should use project version (project overrides org) + shared = next((s for s in servers if s["name"] == "shared_server"), None) + if shared and shared["command"] == "project_cmd": + print(" PASS: Project overrides org for same server name") + passed += 1 + else: + print(f" FAIL: shared_server should use project_cmd: {shared}") + failed += 1 + + if "org_server__blocked_tool" in blocked_tools: + print(" PASS: Org blocked_mcp_tools preserved") + passed += 1 + else: + print(f" FAIL: Expected org_server__blocked_tool in {blocked_tools}") + failed += 1 + + # Test get_effective_mcp_tools + allowed_tools, permissions = get_effective_mcp_tools(servers, blocked_tools) + + # Check that org_tool is in allowed list + if "mcp__org_server__org_tool" in allowed_tools: + print(" PASS: Org server tools included") + passed += 1 + else: + print(f" FAIL: Expected mcp__org_server__org_tool in {allowed_tools}") + failed += 1 + + # Check that project_tool is in allowed list + if "mcp__project_server__project_tool" in allowed_tools: + print(" PASS: Project server tools included") + passed += 1 + else: + print(f" FAIL: Expected mcp__project_server__project_tool in {allowed_tools}") + failed += 1 + + return passed, failed + + +def test_mcp_blocked_tools_enforcement(): + """Test that org-level blocked MCP tools are enforced.""" + print("\nTesting MCP blocked tools enforcement:\n") + passed = 0 + failed = 0 + + # Create a server config with some tools + servers = [ + { + "name": "filesystem", + "command": "npx", + "allowed_tools": ["read_file", "write_file", "delete_file"], + } + ] + + # Block write and delete + blocked_tools = {"filesystem__write_file", "filesystem__delete_file"} + + allowed_tools, _ = get_effective_mcp_tools(servers, blocked_tools) + + # read_file should be allowed + if "mcp__filesystem__read_file" in allowed_tools: + print(" PASS: Unblocked tool (read_file) is allowed") + passed += 1 + else: + print(f" FAIL: read_file should be allowed: {allowed_tools}") + failed += 1 + + # write_file should be blocked + if "mcp__filesystem__write_file" not in allowed_tools: + print(" PASS: Blocked tool (write_file) is excluded") + passed += 1 + else: + print(f" FAIL: write_file should be blocked: {allowed_tools}") + failed += 1 + + # delete_file should be blocked + if "mcp__filesystem__delete_file" not in allowed_tools: + print(" PASS: Blocked tool (delete_file) is excluded") + passed += 1 + else: + print(f" FAIL: delete_file should be blocked: {allowed_tools}") + failed += 1 + + # Test wildcard blocking + servers2 = [ + { + "name": "dangerous", + "command": "danger", + "allowed_tools": ["tool1", "tool2", "tool3"], + } + ] + blocked_wildcard = {"dangerous__*"} + + allowed_tools2, _ = get_effective_mcp_tools(servers2, blocked_wildcard) + + if len([t for t in allowed_tools2 if "dangerous" in t]) == 0: + print(" PASS: Wildcard blocks all tools from server") + passed += 1 + else: + print(f" FAIL: Wildcard should block all tools: {allowed_tools2}") + failed += 1 + + return passed, failed + + def main(): print("=" * 70) print(" SECURITY HOOK TESTS") @@ -991,6 +1396,31 @@ def main(): passed += pkill_passed failed += pkill_failed + # Test MCP server validation + mcp_val_passed, mcp_val_failed = test_mcp_server_validation() + passed += mcp_val_passed + failed += mcp_val_failed + + # Test MCP tool pattern matching + mcp_pattern_passed, mcp_pattern_failed = test_mcp_tool_pattern_matching() + passed += mcp_pattern_passed + failed += mcp_pattern_failed + + # Test MCP server config loading + mcp_load_passed, mcp_load_failed = test_mcp_server_config_loading() + passed += mcp_load_passed + failed += mcp_load_failed + + # Test MCP hierarchy resolution + mcp_hier_passed, mcp_hier_failed = test_mcp_hierarchy_resolution() + passed += mcp_hier_passed + failed += mcp_hier_failed + + # Test MCP blocked tools enforcement + mcp_block_passed, mcp_block_failed = test_mcp_blocked_tools_enforcement() + passed += mcp_block_passed + failed += mcp_block_failed + # Commands that SHOULD be blocked # Note: blocklisted commands (sudo, shutdown, dd, aws) are tested in # test_blocklist_enforcement(). chmod validation is tested in diff --git a/ui/package-lock.json b/ui/package-lock.json index ae46a24c..b0864e6d 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -81,6 +81,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2694,6 +2695,7 @@ "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2704,6 +2706,7 @@ "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2714,6 +2717,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2763,6 +2767,7 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -3067,6 +3072,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3184,6 +3190,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3396,6 +3403,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -3587,6 +3595,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4570,6 +4579,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4675,6 +4685,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4684,6 +4695,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4993,6 +5005,7 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5131,6 +5144,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", From 5e98858234965aaa3868086f79aa0327e1deada3 Mon Sep 17 00:00:00 2001 From: Zeid Derhally Date: Sun, 1 Feb 2026 07:03:44 -0500 Subject: [PATCH 2/6] Remove whitespace from blank line --- client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client.py b/client.py index e2e0dd76..234f5ce4 100644 --- a/client.py +++ b/client.py @@ -347,7 +347,6 @@ def create_client( Note: Authentication is handled by start.bat/start.sh before this runs. The Claude SDK auto-detects credentials from the Claude CLI configuration """ - # Select the feature MCP tools appropriate for this agent type feature_tools_map = { "coding": CODING_AGENT_TOOLS, From 82841518b8fab8a2d063be1b874d3505c53a9c88 Mon Sep 17 00:00:00 2001 From: Zeid Derhally Date: Sun, 1 Feb 2026 07:12:46 -0500 Subject: [PATCH 3/6] Update client.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client.py b/client.py index 234f5ce4..45a3766b 100644 --- a/client.py +++ b/client.py @@ -365,7 +365,7 @@ def create_client( } max_turns = max_turns_map.get(agent_type, 300) - # Load user-configured MCP servers from org and project configs + # Load user-configured MCP servers from org and project configs user_mcp_servers, blocked_mcp_tools = get_effective_mcp_servers(project_dir) user_mcp_tools, user_mcp_permissions = get_effective_mcp_tools( user_mcp_servers, blocked_mcp_tools From e7a37c753de6c29a0adbb4ab9feb45103c2da293 Mon Sep 17 00:00:00 2001 From: Zeid Derhally Date: Sun, 1 Feb 2026 07:12:53 -0500 Subject: [PATCH 4/6] Update client.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client.py b/client.py index 45a3766b..f8803a4e 100644 --- a/client.py +++ b/client.py @@ -1,6 +1,7 @@ """ +""" Claude SDK Client Configuration -Claude SDK Client Configuration +=============================== =============================== Functions for creating and configuring the Claude Agent SDK client. From 0cd71777e3b69d93909addd53242d43abe04db20 Mon Sep 17 00:00:00 2001 From: Zeid Derhally Date: Sun, 1 Feb 2026 07:23:32 -0500 Subject: [PATCH 5/6] Remove duplicate lines from bad merge --- client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client.py b/client.py index f8803a4e..093d705f 100644 --- a/client.py +++ b/client.py @@ -1,8 +1,6 @@ """ -""" Claude SDK Client Configuration =============================== -=============================== Functions for creating and configuring the Claude Agent SDK client. """ @@ -366,7 +364,7 @@ def create_client( } max_turns = max_turns_map.get(agent_type, 300) - # Load user-configured MCP servers from org and project configs + # Load user-configured MCP servers from org and project configs user_mcp_servers, blocked_mcp_tools = get_effective_mcp_servers(project_dir) user_mcp_tools, user_mcp_permissions = get_effective_mcp_tools( user_mcp_servers, blocked_mcp_tools From c8ef4893ec49801e0f1fb4a3d076053583493003 Mon Sep 17 00:00:00 2001 From: Zeid Derhally Date: Sun, 1 Feb 2026 12:27:32 -0500 Subject: [PATCH 6/6] Adjust comment indentation --- client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client.py b/client.py index 093d705f..704ae812 100644 --- a/client.py +++ b/client.py @@ -364,7 +364,7 @@ def create_client( } max_turns = max_turns_map.get(agent_type, 300) - # Load user-configured MCP servers from org and project configs + # Load user-configured MCP servers from org and project configs user_mcp_servers, blocked_mcp_tools = get_effective_mcp_servers(project_dir) user_mcp_tools, user_mcp_permissions = get_effective_mcp_tools( user_mcp_servers, blocked_mcp_tools