diff --git a/docs/mcp.md b/docs/mcp.md index 5d0e562..e162729 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,6 +1,7 @@ # MCP Server Integration -This guide explains how to use Shannot's MCP (Model Context Protocol) server to give Claude Desktop secure, read-only access to your Linux system. +This guide explains how to use Shannot's MCP (Model Context Protocol) server to give Claude Desktop, +Claude Code, or Codex CLI secure, read-only access to your Linux system. ## What is MCP? @@ -14,37 +15,142 @@ MCP (Model Context Protocol) is Anthropic's standard protocol for connecting AI **All operations are read-only and sandboxed** - Claude cannot modify your system. -## Quick Start (5 minutes) +## Quick Start -### 1. Install Shannot with MCP support +### For macOS/Windows Users (Remote Setup - 10 minutes) + +Since Shannot requires Linux, you'll need a remote Linux server: + +```bash +# 1. Install Shannot with remote support +pip install shannot[mcp,remote] + +# 2. Configure a remote Linux target +shannot remote add myserver --host your-server.com --user yourname + +# 3. Test the connection +shannot remote test myserver + +# 4. Install MCP server for Claude Code +shannot mcp install --client claude-code --target myserver + +# 5. Restart Claude Code +# Now you can ask: "Check disk space on myserver" +``` + +### For Linux Users (Local Setup - 5 minutes) ```bash -# Install with MCP dependencies (includes remote SSH support) +# 1. Install Shannot with MCP support pip install shannot[mcp] -# Or install from source -cd shannot -pip install -e ".[mcp]" +# 2. Install bubblewrap (if not already installed) +# Ubuntu/Debian: +sudo apt install bubblewrap +# Fedora/RHEL: +sudo dnf install bubblewrap +# Arch: +sudo pacman -S bubblewrap + +# 3. Install MCP server for Claude Code +shannot mcp install --client claude-code + +# 4. Restart Claude Code +# Now you can ask: "Show me /etc/os-release" ``` -### 2. Install MCP server config for Claude Desktop +## Detailed Installation + +### Option A: Using Shannot's installer (Recommended) ```bash +# Install for Claude Desktop (default) shannot mcp install +# Install for Claude Code (user scope - available across all projects) +shannot mcp install --client claude-code + +# Install for Codex CLI +shannot mcp install --client codex + # Use a configured remote target shannot mcp install --target prod +shannot mcp install --client claude-code --target prod +``` + +The Claude Code installer uses **user scope** by default, making Shannot available across all your +projects. It updates both the IDE config and your CLI configuration (e.g., `~/.claude.json`) so +`/mcp` lists it immediately. + +**Option B: Using Claude Code's CLI directly** + +If you prefer Claude Code's native MCP management, you have full control over scoping: + +```bash +# User scope (recommended - available across all your projects) +claude mcp add --transport stdio shannot --scope user -- shannot-mcp + +# With a remote target +claude mcp add --transport stdio shannot-prod --scope user \ + --env SSH_AUTH_SOCK="${SSH_AUTH_SOCK}" -- shannot-mcp --target prod + +# Local scope (only in current project, private to you) +claude mcp add --transport stdio shannot --scope local -- shannot-mcp + +# Project scope (shared with team via .mcp.json in version control) +claude mcp add --transport stdio shannot --scope project -- shannot-mcp ``` -This automatically adds Shannot to your Claude Desktop configuration. +**Understanding MCP scopes:** +- `local` (default): Only you can use it in this project +- `user`: Available to you across all projects +- `project`: Shared with your team via `.mcp.json` file (requires approval on first use) + +**Note for macOS and Windows users:** + +Shannot requires Linux to run locally (bubblewrap is Linux-only). You have two options: + +**macOS:** +1. **Use a remote Linux target** (recommended): + ```bash + shannot remote add linux-server --host server.example.com --user yourname + shannot mcp install --client claude-code --target linux-server + ``` +2. **Use a Linux VM** (Parallels, VMware, etc.) and run via SSH + +**Windows:** +1. **Use a remote Linux target** (recommended): + ```bash + shannot remote add linux-server --host server.example.com --user yourname + shannot mcp install --client claude-code --target linux-server + ``` +2. **Use WSL2** (Windows Subsystem for Linux) and install directly in WSL: + ```bash + # From WSL terminal + pip install shannot[mcp] + shannot mcp install --client claude-code + ``` + +**Why remote is required for macOS/Windows:** +Shannot uses Linux kernel features (namespaces, seccomp) via bubblewrap for sandboxing. +These features are not available on macOS or native Windows. -### 3. Restart Claude Desktop +### Verify installation (Claude Code users) -Quit and reopen Claude Desktop. You should now see Shannot tools available! +In Claude Code, check that Shannot is available: -### 4. Try it out +``` +> /mcp +``` + +You should see `shannot` listed among your MCP servers. You can also use `/mcp` to: +- View server status and available tools +- Manage server configurations +- Remove servers with `claude mcp remove shannot` -Open Claude Desktop and ask: +## Try it out + +Open your client and ask: > "Can you check how much disk space I have left?" @@ -145,10 +251,21 @@ Claude's requests now execute on the remote host through the SSH executor. ### Manual Configuration -If `shannot mcp install` doesn't work on your platform, manually edit your Claude Desktop config: - -**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` +If `shannot mcp install` doesn't work on your platform, manually edit the client config. Defaults: + +- **Claude Desktop (macOS)**: `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Claude Desktop (Windows)**: `%APPDATA%\Claude\claude_desktop_config.json` +- **Claude Code (macOS)**: `~/Library/Application Support/Claude/claude_code_config.json` + - Alternates: `~/Library/Application Support/Claude/claude_config.json`, `~/.claude/config.json` +- **Claude Code (Linux)**: `~/.config/Claude/claude_code_config.json` + - Alternates: `~/.claude/config.json`, `~/.config/claude/config.json` +- **Claude Code (Windows)**: `%APPDATA%\Claude\claude_code_config.json` + - Alternate: `%APPDATA%\Claude\claude_config.json` +- **Codex CLI (macOS)**: `~/Library/Application Support/OpenAI/Codex/codex_cli_config.json` + - Alternate: `~/.config/openai/codex_cli_config.json` +- **Codex CLI (Linux)**: `~/.config/openai/codex_cli_config.json` + - Alternate: `~/.config/codex/config.json` +- **Codex CLI (Windows)**: `%APPDATA%\OpenAI\Codex\codex_cli_config.json` Add: @@ -213,6 +330,22 @@ ls $(python -c "import shannot; print(shannot.__file__.rsplit('/',1)[0])")/../pr ``` 3. Look for errors in Claude Desktop logs (Help → View Logs) +### Claude Code doesn't show Shannot tools + +1. Reload or restart Claude Code +2. Use `/mcp` command to check server status +3. Check that the server is properly configured: + ```bash + claude mcp list + claude mcp get shannot + ``` +4. If you used `--scope project`, make sure you approved the `.mcp.json` file when prompted +5. Try removing and re-adding: + ```bash + claude mcp remove shannot + shannot mcp install --client claude-code + ``` + ### Commands fail with "not allowed" The command you tried isn't in the profile's allowlist. Either: @@ -288,6 +421,50 @@ You can connect Shannot MCP to remote systems via SSH: Now Claude can inspect remote systems! +### Team Collaboration (Claude Code) + +Share Shannot MCP server with your team using project scope: + +1. **Add server at project scope:** + ```bash + claude mcp add --transport stdio shannot --scope project -- shannot-mcp + ``` + +2. **This creates `.mcp.json` in your project root:** + ```json + { + "mcpServers": { + "shannot": { + "command": "shannot-mcp", + "args": [], + "env": {} + } + } + } + ``` + +3. **Commit to version control:** + ```bash + git add .mcp.json + git commit -m "Add Shannot MCP server for team" + ``` + +4. **Team members will be prompted to approve** the server on first use. They can: + - Review with `/mcp` command + - Approve to enable the server + - Reset choices with `claude mcp reset-project-choices` if needed + +**For remote targets shared across the team:** + +```bash +# Each team member configures the remote once +shannot remote add staging --host staging.example.com --user deploy + +# Then add to project scope +claude mcp add --transport stdio shannot-staging --scope project \ + --env SSH_AUTH_SOCK="${SSH_AUTH_SOCK}" -- shannot-mcp --target staging +``` + ### Custom Tool Names Edit the MCP server code in `shannot/mcp_server.py` to customize tool names and descriptions. @@ -335,6 +512,33 @@ Edit the MCP server code in `shannot/mcp_server.py` to customize tool names and > - PasswordAuthentication: yes (consider disabling) > - PermitRootLogin: no (good!) +## Quick Reference - Claude Code CLI Commands + +```bash +# Installation +claude mcp add --transport stdio shannot -- shannot-mcp +claude mcp add --transport stdio shannot --scope user -- shannot-mcp +claude mcp add --transport stdio shannot --scope project -- shannot-mcp + +# With remote target +claude mcp add --transport stdio shannot-prod -- shannot-mcp --target prod + +# Management +claude mcp list # List all servers +claude mcp get shannot # Get server details +claude mcp remove shannot # Remove server +claude mcp reset-project-choices # Reset approval choices + +# In Claude Code +/mcp # View server status +``` + +**Alternative: Use Shannot's installer** +```bash +shannot mcp install --client claude-code +shannot mcp install --client claude-code --target prod +``` + ## Next Steps - **[See profiles.md](profiles.md)** to learn about creating custom profiles diff --git a/shannot/__init__.py b/shannot/__init__.py index c170a79..c77956f 100644 --- a/shannot/__init__.py +++ b/shannot/__init__.py @@ -46,7 +46,20 @@ load_profile_from_path, ) -__version__ = "0.1.1" +try: + from importlib.metadata import PackageNotFoundError + from importlib.metadata import version as _metadata_version +except ImportError: # pragma: no cover - only relevant on very old Python versions + PackageNotFoundError = Exception # type: ignore + + def _metadata_version(distribution_name: str) -> str: # pragma: no cover + return "0.0.0" + + +try: + __version__ = _metadata_version("shannot") +except PackageNotFoundError: # pragma: no cover - metadata missing in dev tree + __version__ = "0.0.0" __all__ = [ "BubblewrapCommandBuilder", "SandboxBind", diff --git a/shannot/cli.py b/shannot/cli.py index a230e0c..56ee4dc 100644 --- a/shannot/cli.py +++ b/shannot/cli.py @@ -18,6 +18,7 @@ import json import logging import os +import platform import shutil import sys from collections.abc import Mapping, MutableMapping, Sequence @@ -45,6 +46,69 @@ Path("/etc/shannot/profile.json"), # Legacy system ] +_MCP_CLIENT_LABELS: dict[str, str] = { + "claude-desktop": "Claude Desktop", + "claude-code": "Claude Code", + "codex": "Codex CLI", +} + +_MCP_CLIENT_PATHS: dict[str, dict[str, tuple[tuple[str, ...], ...]]] = { + "claude-desktop": { + "Darwin": (("Library", "Application Support", "Claude", "claude_desktop_config.json"),), + "Windows": (("AppData", "Roaming", "Claude", "claude_desktop_config.json"),), + }, + "claude-code": { + "Darwin": ( + ("Library", "Application Support", "Claude", "claude_code_config.json"), + ("Library", "Application Support", "Claude", "claude_config.json"), + (".claude", "config.json"), + (".config", "claude", "config.json"), + ), + "Linux": ( + (".config", "Claude", "claude_code_config.json"), + (".claude", "config.json"), + (".config", "claude", "config.json"), + ), + "Windows": ( + ("AppData", "Roaming", "Claude", "claude_code_config.json"), + ("AppData", "Roaming", "Claude", "claude_config.json"), + ), + }, + "codex": { + "Darwin": ( + ("Library", "Application Support", "OpenAI", "Codex", "codex_cli_config.json"), + (".config", "openai", "codex_cli_config.json"), + ), + "Linux": ( + (".config", "openai", "codex_cli_config.json"), + (".config", "codex", "config.json"), + ), + "Windows": (("AppData", "Roaming", "OpenAI", "Codex", "codex_cli_config.json"),), + }, +} + +_MCP_CLIENT_SUCCESS_HINTS: dict[str, str] = { + "claude-desktop": "Restart Claude Desktop to use Shannot tools", + "claude-code": "Reload or restart Claude Code to use Shannot tools", + "codex": "Restart Codex CLI sessions to use Shannot tools", +} + + +def _claude_cli_config_path() -> Path: + """Locate Claude Code CLI configuration file.""" + override_dir = os.environ.get("CLAUDE_CONFIG_DIR") + base_dir = Path(override_dir).expanduser() if override_dir else Path.home() + + alt_path = Path.home() / ".claude" / ".config.json" + if alt_path.exists(): + return alt_path + + candidates = sorted(base_dir.glob(".claude*.json")) + if candidates: + return candidates[0] + + return base_dir / ".claude.json" + def _get_default_profile() -> Path: """Get default profile from env or standard locations.""" @@ -156,6 +220,71 @@ def _convert_bind(bind: Mapping[str, object]) -> MutableMapping[str, object]: } +def _resolve_mcp_config_path(client: str, override: str | None) -> Path: + if override: + return Path(override).expanduser() + + system_name = platform.system() + candidates = _MCP_CLIENT_PATHS.get(client, {}).get(system_name) + if not candidates: + friendly = _MCP_CLIENT_LABELS.get(client, client) + raise ValueError( + f"{friendly} config location unknown for platform '{system_name}'. " + "Specify the location explicitly with --config-path.", + ) + + selected_path: Path | None = None + for segments in candidates: + candidate_path = Path.home().joinpath(*segments) + if candidate_path.exists(): + selected_path = candidate_path + break + + if selected_path is None: + selected_path = Path.home().joinpath(*candidates[0]) + + return selected_path + + +def _update_claude_cli_user_server( + server_name: str, + command: str, + args: list[str], + env: dict[str, str] | None, +) -> tuple[Path, bool] | None: + """Write Claude Code CLI user-scope config (available across all projects).""" + config_path = _claude_cli_config_path() + try: + if config_path.exists(): + with open(config_path, encoding="utf-8") as fh: + config_data = json.load(fh) + else: + config_data = {} + except json.JSONDecodeError as exc: + _LOGGER.warning("Could not parse Claude Code config at %s: %s", config_path, exc) + return None + + # User scope: top-level mcpServers (not under projects) + mcp_servers = config_data.setdefault("mcpServers", {}) + + cli_server_config: dict[str, object] = { + "type": "stdio", + "command": command, + "args": list(args), + "env": env or {}, + } + + changed = mcp_servers.get(server_name) != cli_server_config + mcp_servers[server_name] = cli_server_config + + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w", encoding="utf-8") as fh: + json.dump(config_data, fh, indent=2) + fh.write("\n") + + return config_path, changed + + def _execute_command(manager: SandboxManager, command: Sequence[str]) -> ProcessResult: _LOGGER.debug("Executing sandbox command: %s", " ".join(command)) result = manager.run(command, check=False) @@ -376,13 +505,23 @@ def _build_parser() -> argparse.ArgumentParser: # mcp install mcp_install_parser = mcp_subparsers.add_parser( "install", - help="Install MCP server config for Claude Desktop.", + help="Install MCP server config for supported clients.", ) _ = mcp_install_parser.add_argument( "--target", "-t", help="Target system to use for MCP server (from config file).", ) + _ = mcp_install_parser.add_argument( + "--client", + choices=("claude-desktop", "claude-code", "codex"), + default="claude-desktop", + help="MCP client to configure (default: claude-desktop).", + ) + _ = mcp_install_parser.add_argument( + "--config-path", + help="Override the MCP client config file path.", + ) mcp_install_parser.set_defaults(handler=_handle_mcp_install) # mcp test @@ -456,28 +595,20 @@ def _build_parser() -> argparse.ArgumentParser: def _handle_mcp_install(args: argparse.Namespace) -> int: - """Install MCP server configuration for Claude Desktop.""" - import platform - + """Install MCP server configuration for supported clients.""" + client = cast(str, getattr(args, "client", "claude-desktop")) + config_override = cast(str | None, getattr(args, "config_path", None)) target_name = cast(str | None, getattr(args, "target", None)) + client_label = _MCP_CLIENT_LABELS.get(client, client) - if platform.system() == "Darwin": - config_path = ( - Path.home() - / "Library" - / "Application Support" - / "Claude" - / "claude_desktop_config.json" - ) - elif platform.system() == "Windows": - config_path = Path.home() / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json" - else: - _LOGGER.error("Claude Desktop config location unknown for this platform") - _LOGGER.info("Please manually add the following to your Claude Desktop config:") - server_config = {"command": "shannot-mcp", "args": []} - if target_name: - server_config["args"] = ["--target", target_name] - _LOGGER.info(json.dumps({"mcpServers": {"shannot": server_config}}, indent=2)) + try: + config_path = _resolve_mcp_config_path(client, config_override) + except ValueError as exc: + _LOGGER.error(str(exc)) + if not config_override: + _LOGGER.info( + "Example: shannot mcp install --client %s --config-path /path/to/config", client + ) return 1 # Validate target if specified @@ -495,16 +626,14 @@ def _handle_mcp_install(args: argparse.Namespace) -> int: _LOGGER.error(f"Failed to load config: {e}") return 1 - # Check if config file exists + # Read existing config if present if config_path.exists(): with open(config_path) as f: config = json.load(f) else: config = {} - # Add shannot MCP server - if "mcpServers" not in config: - config["mcpServers"] = {} + config.setdefault("mcpServers", {}) command_args: list[str] = [] resolved_command = shutil.which("shannot-mcp") @@ -527,17 +656,42 @@ def _handle_mcp_install(args: argparse.Namespace) -> int: if env_vars: server_config["env"] = env_vars + cli_update_result: tuple[Path, bool] | None = None + if client == "claude-code": + try: + cli_update_result = _update_claude_cli_user_server( + "shannot", + resolved_command, + list(server_args), + env_vars if env_vars else None, + ) + except Exception as exc: + _LOGGER.warning("Failed to update Claude Code user config: %s", exc) + config["mcpServers"]["shannot"] = server_config # Write config config_path.parent.mkdir(parents=True, exist_ok=True) - with open(config_path, "w") as f: + with open(config_path, "w", encoding="utf-8") as f: json.dump(config, f, indent=2) - - _LOGGER.info(f"✓ Installed MCP server config to {config_path}") + f.write("\n") + + _LOGGER.info("✓ Installed MCP server config for %s at %s", client_label, config_path) + if client == "claude-code": + if cli_update_result is not None: + path, changed = cli_update_result + message = "Added" if changed else "Already configured" + _LOGGER.info( + "✓ %s Claude Code user config at %s (available across all projects)", message, path + ) + # Helper already logs a warning if it cannot parse existing config if target_name: _LOGGER.info(f"✓ MCP server will use target: {target_name}") - _LOGGER.info("✓ Restart Claude Desktop to use Shannot tools") + success_hint = _MCP_CLIENT_SUCCESS_HINTS.get(client) + if success_hint: + _LOGGER.info("✓ %s", success_hint) + else: + _LOGGER.info("✓ Client may need a restart to detect new MCP server") return 0 diff --git a/shannot/mcp_server.py b/shannot/mcp_server.py index c521bdb..fd9f4c0 100644 --- a/shannot/mcp_server.py +++ b/shannot/mcp_server.py @@ -13,7 +13,14 @@ from mcp.server import InitializationOptions, Server from mcp.server.stdio import stdio_server -from mcp.types import Resource, ServerCapabilities, TextContent, Tool +from mcp.types import ( + Resource, + ResourcesCapability, + ServerCapabilities, + TextContent, + Tool, + ToolsCapability, +) from shannot import __version__ from shannot.execution import SandboxExecutor @@ -270,7 +277,10 @@ async def run(self) -> None: options = InitializationOptions( server_name="shannot-sandbox", server_version=__version__, - capabilities=ServerCapabilities(), + capabilities=ServerCapabilities( + tools=ToolsCapability(), # We provide tools + resources=ResourcesCapability(), # We provide resources + ), ) async with stdio_server() as (read_stream, write_stream): diff --git a/shannot/py.typed b/shannot/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli_mcp_install.py b/tests/test_cli_mcp_install.py index 511c907..3090663 100644 --- a/tests/test_cli_mcp_install.py +++ b/tests/test_cli_mcp_install.py @@ -12,14 +12,20 @@ pytest.importorskip("pydantic") -from shannot.cli import _handle_mcp_install +from shannot.cli import _handle_mcp_install # noqa: E402 class DummyArgs(Namespace): """Simple namespace mimicking argparse Namespace.""" def __init__(self, **kwargs: object): - super().__init__(**kwargs) + defaults: dict[str, object] = { + "target": None, + "client": "claude-desktop", + "config_path": None, + } + defaults.update(kwargs) + super().__init__(**defaults) def _patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: @@ -105,3 +111,88 @@ def test_mcp_install_with_target_appends_flag(monkeypatch: pytest.MonkeyPatch, t "SSH_AUTH_SOCK": "/tmp/agent.sock", "SSH_AGENT_PID": "12345", } + + +def test_mcp_install_supports_claude_code(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + """Installing for Claude Code should target the IDE config path.""" + _patch_home(monkeypatch, tmp_path) + monkeypatch.setattr("platform.system", lambda: "Darwin") + monkeypatch.setattr("shannot.cli.shutil.which", lambda _: "/opt/tools/shannot-mcp") + monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) + monkeypatch.delenv("SSH_AGENT_PID", raising=False) + + config_file = ( + tmp_path / "Library" / "Application Support" / "Claude" / "claude_code_config.json" + ) + + assert _handle_mcp_install(DummyArgs(client="claude-code")) == 0 + + data = json.loads(config_file.read_text()) + assert data["mcpServers"]["shannot"]["command"] == "/opt/tools/shannot-mcp" + assert data["mcpServers"]["shannot"]["args"] == [] + + +def test_mcp_install_claude_code_prefers_existing_alternate( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + """Existing alternate config locations should be reused.""" + _patch_home(monkeypatch, tmp_path) + monkeypatch.setattr("platform.system", lambda: "Darwin") + monkeypatch.setattr("shannot.cli.shutil.which", lambda _: "/opt/tools/shannot-mcp") + monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) + monkeypatch.delenv("SSH_AGENT_PID", raising=False) + + primary = tmp_path / "Library" / "Application Support" / "Claude" / "claude_code_config.json" + alternate = tmp_path / "Library" / "Application Support" / "Claude" / "claude_config.json" + alternate.parent.mkdir(parents=True, exist_ok=True) + alternate.write_text(json.dumps({"existing": True})) + + assert _handle_mcp_install(DummyArgs(client="claude-code")) == 0 + + assert not primary.exists() + data = json.loads(alternate.read_text()) + assert data["existing"] is True + assert data["mcpServers"]["shannot"]["command"] == "/opt/tools/shannot-mcp" + + +def test_mcp_install_supports_codex_cli(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + """Installing for Codex CLI should target the CLI config path.""" + _patch_home(monkeypatch, tmp_path) + monkeypatch.setattr("platform.system", lambda: "Linux") + monkeypatch.setattr("shannot.cli.shutil.which", lambda _: "/opt/tools/shannot-mcp") + monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) + monkeypatch.delenv("SSH_AGENT_PID", raising=False) + + config_file = tmp_path / ".config" / "openai" / "codex_cli_config.json" + + assert _handle_mcp_install(DummyArgs(client="codex")) == 0 + + data = json.loads(config_file.read_text()) + assert data["mcpServers"]["shannot"]["command"] == "/opt/tools/shannot-mcp" + assert data["mcpServers"]["shannot"]["args"] == [] + + +def test_mcp_install_updates_claude_code_user_config( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Claude Code user config should receive stdio server (available across all projects).""" + _patch_home(monkeypatch, tmp_path) + claude_config = tmp_path / ".claude.json" + claude_config.write_text(json.dumps({}, indent=2)) + + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) + monkeypatch.setattr("shannot.cli.shutil.which", lambda _: "/opt/tools/shannot-mcp") + monkeypatch.delenv("SSH_AGENT_PID", raising=False) + monkeypatch.setenv("SSH_AUTH_SOCK", "/tmp/agent.sock") + + result = _handle_mcp_install(DummyArgs(client="claude-code")) + assert result == 0 + + config_data = json.loads(claude_config.read_text()) + # User scope: top-level mcpServers + server_entry = config_data["mcpServers"]["shannot"] + assert server_entry["type"] == "stdio" + assert server_entry["command"] == "/opt/tools/shannot-mcp" + assert server_entry.get("args", []) == [] + assert server_entry.get("env") == {"SSH_AUTH_SOCK": "/tmp/agent.sock"} diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index e4254ce..554ec43 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -75,6 +75,8 @@ def __init__(self, **kwargs: object): types_module.TextContent = _SimpleType # type: ignore[attr-defined] types_module.Tool = _SimpleType # type: ignore[attr-defined] types_module.ServerCapabilities = _SimpleType # type: ignore[attr-defined] + types_module.ResourcesCapability = _SimpleType # type: ignore[attr-defined] + types_module.ToolsCapability = _SimpleType # type: ignore[attr-defined] sys.modules["mcp.types"] = types_module sys.modules["mcp"].types = types_module # type: ignore[attr-defined]