Skip to content

MCP Server Module

Model Context Protocol (MCP) server implementation for LLM integration.

Overview

The MCP server module implements a Model Context Protocol server that exposes Shannot's sandboxed command execution capabilities to LLM clients like Claude Desktop and Claude Code. It provides tools and resources for secure, read-only system diagnostics and monitoring.

Key Components:

  • ShannotMCPServer - Main MCP server class implementing the protocol
  • MCP Tools - Exposed as callable functions for LLM clients
  • run_sandbox_command - Execute commands in sandbox
  • read_sandbox_file - Read files from sandboxed paths
  • MCP Resources - Profile configurations available as resources

MCP Integration

The server integrates with LLM clients through the Model Context Protocol:

{
  "mcpServers": {
    "shannot": {
      "command": "shannot-mcp",
      "args": ["--profile", "diagnostics"]
    }
  }
}

Common Usage Patterns

Starting the MCP Server

# Start with default profile
shannot-mcp

# Use specific profile
shannot-mcp --profile diagnostics

# Enable verbose logging
shannot-mcp --verbose

# Use remote executor
shannot-mcp --target production

Programmatic Server Creation

from shannot.mcp_server import ShannotMCPServer
from shannot.executors import LocalExecutor

# Create server
executor = LocalExecutor()
server = ShannotMCPServer(
    server_name="shannot",
    profile_specs=["diagnostics"],
    executor=executor
)

# Run server (stdio transport)
await server.run_stdio()

Multi-Profile Server

# Server with multiple profiles
server = ShannotMCPServer(
    server_name="shannot-multi",
    profile_specs=[
        "minimal",
        "diagnostics",
        Path("/etc/shannot/custom.json")
    ]
)

MCP Tools

The server exposes these tools to LLM clients:

run_sandbox_command

Execute arbitrary commands in the sandbox.

Parameters: - command (list[str]): Command to execute (e.g., ["df", "-h"]) - args (list[str]): Additional arguments (usually empty)

Returns: ProcessResult with stdout, stderr, returncode

Example from LLM:

Can you check the disk usage?
→ Calls run_sandbox_command with command=["df", "-h"]

read_sandbox_file

Read file contents from sandboxed paths.

Parameters: - filepath (str): Absolute path to file - max_lines (int, optional): Limit output lines

Returns: File content as string

Example from LLM:

What's in /etc/os-release?
→ Calls read_sandbox_file with filepath="/etc/os-release"

MCP Resources

Profiles are exposed as MCP resources for inspection:

# List available resources
resources = await server.list_resources()

# Read profile resource
profile_content = await server.read_resource(
    "shannot://profile/diagnostics"
)

Installation for LLM Clients

Claude Desktop

shannot mcp install claude-desktop --profile diagnostics

Configuration added to ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "shannot": {
      "command": "shannot-mcp",
      "args": ["--profile", "diagnostics"]
    }
  }
}

Claude Code

shannot mcp install claude-code --profile diagnostics

Configuration added to ~/.config/claude/config.json or platform-specific location.

Server Lifecycle

# Server supports async context manager
async with ShannotMCPServer(
    server_name="shannot",
    profile_specs=["diagnostics"]
) as server:
    await server.run_stdio()
# Automatically cleaned up

Security Considerations

  • Read-only enforcement: All commands execute in read-only sandbox
  • Command filtering: Only allowed commands in profile can execute
  • Path restrictions: Only mounted paths are accessible
  • Network isolation: Network access disabled by default
  • Process isolation: Separate PID namespace prevents host process access

API Reference

mcp_server

MCP server implementation for Shannot sandbox.

This module exposes Shannot sandbox capabilities as MCP tools, allowing Claude Desktop and other MCP clients to interact with the sandbox.

Classes

ShannotMCPServer

MCP server exposing sandbox profiles as tools.

Source code in shannot/mcp_server.py
class ShannotMCPServer:
    """MCP server exposing sandbox profiles as tools."""

    def __init__(
        self,
        profile_paths: Sequence[Path | str] | None = None,
        executor: SandboxExecutor | None = None,
        executor_label: str | None = None,
    ):
        """Initialize the MCP server.

        Args:
            profile_paths: List of profile paths to load. If None, loads from default locations.
            executor: Optional executor used to run sandbox commands (local or remote).
        """
        self.server: Server = Server("shannot-sandbox")
        self.deps_by_profile: dict[str, SandboxDeps] = {}
        self._executor_label: str | None = executor_label

        # Load profiles
        if profile_paths is None:
            profile_paths = self._discover_profiles()

        for spec in profile_paths:
            try:
                deps = self._create_deps_from_spec(spec, executor)
                self.deps_by_profile[deps.profile.name] = deps
                logger.info(f"Loaded profile: {deps.profile.name}")
            except Exception as e:
                logger.error(f"Failed to load profile {spec}: {e}")

        # Register handlers
        self._register_tools()
        self._register_resources()

    def _discover_profiles(self) -> list[Path]:
        """Discover profiles from default locations."""
        paths: list[Path] = []

        # User config directory
        user_config = Path.home() / ".config" / "shannot"
        if user_config.exists():
            paths.extend(user_config.glob("*.json"))

        # Bundled profiles
        bundled_dir = Path(__file__).parent.parent / "profiles"
        if bundled_dir.exists():
            paths.extend(bundled_dir.glob("*.json"))

        return paths

    def _create_deps_from_spec(
        self,
        spec: Path | str,
        executor: SandboxExecutor | None,
    ) -> SandboxDeps:
        """Create SandboxDeps from a profile specification.

        Args:
            spec: Path to profile JSON or profile name string.
            executor: Optional executor to attach.

        Returns:
            SandboxDeps configured for the requested profile.
        """
        if isinstance(spec, Path):
            return SandboxDeps(profile_path=spec, executor=executor)

        # Accept either path-like strings or profile names
        possible_path = Path(spec).expanduser()
        if possible_path.exists() or "/" in spec or spec.endswith(".json") or "\\" in spec:
            return SandboxDeps(profile_path=possible_path, executor=executor)

        # Treat as profile name.
        return SandboxDeps(profile_name=spec, executor=executor)

    def _register_tools(self) -> None:
        """Register MCP tools for each profile."""

        # Register a generic tool for each profile
        for profile_name in self.deps_by_profile.keys():
            self._register_profile_tools(profile_name)

    def _register_profile_tools(self, profile_name: str) -> None:
        """Register tools for a specific profile."""

        # Generic command execution tool
        @self.server.list_tools()
        async def list_tools() -> list[Tool]:
            """List available MCP tools."""
            tools: list[Tool] = []

            for pname, pdeps in self.deps_by_profile.items():
                tool_name = self._make_tool_name(pname)
                # Main command tool
                tools.append(
                    Tool(
                        name=tool_name,
                        description=self._generate_tool_description(pdeps),
                        inputSchema={
                            "type": "object",
                            "properties": {
                                "command": {
                                    "type": "array",
                                    "items": {"type": "string"},
                                    "description": "Command and arguments to execute",
                                }
                            },
                            "required": ["command"],
                        },
                    )
                )

            return tools

        @self.server.call_tool()
        async def call_tool(name: str, arguments: dict[str, object]) -> list[TextContent]:  # type: ignore[misc]
            """Handle MCP tool calls."""
            # Parse tool name to extract profile and action
            profile_name = None
            for pname in self.deps_by_profile.keys():
                if name == self._make_tool_name(pname):
                    profile_name = pname
                    break

            if profile_name is None:
                return [TextContent(type="text", text=f"Unknown tool: {name}")]

            pdeps = self.deps_by_profile[profile_name]

            try:
                cmd_input = CommandInput(**arguments)  # type: ignore[arg-type]
                result = await run_command(pdeps, cmd_input)
                return [
                    TextContent(
                        type="text",
                        text=self._format_command_output(result),
                    )
                ]

            except Exception as e:
                logger.error(f"Tool execution failed: {e}", exc_info=True)
                return [TextContent(type="text", text=f"Error executing tool: {str(e)}")]

    def _register_resources(self) -> None:
        """Register MCP resources for profile inspection."""

        @self.server.list_resources()
        async def list_resources() -> list[Resource]:
            """List available resources."""
            resources: list[Resource] = []

            # Profile resources
            for name in self.deps_by_profile.keys():
                resources.append(
                    Resource(
                        uri=f"sandbox://profiles/{name}",  # type: ignore[arg-type]
                        name=f"Sandbox Profile: {name}",
                        mimeType="application/json",
                        description=f"Configuration for {name} sandbox profile",
                    )
                )

            return resources

        @self.server.read_resource()
        async def read_resource(uri: object) -> str:  # type: ignore[misc]
            """Read resource content."""
            uri_str = str(uri)
            if uri_str.startswith("sandbox://profiles/"):
                profile_name = uri_str.split("/")[-1]
                if profile_name in self.deps_by_profile:
                    deps = self.deps_by_profile[profile_name]
                    return json.dumps(
                        {
                            "name": deps.profile.name,
                            "allowed_commands": deps.profile.allowed_commands,
                            "network_isolation": deps.profile.network_isolation,
                            "tmpfs_paths": deps.profile.tmpfs_paths,
                            "environment": deps.profile.environment,
                        },
                        indent=2,
                    )
                else:
                    return json.dumps({"error": f"Profile not found: {profile_name}"})
            else:
                return json.dumps({"error": f"Unknown resource: {uri}"})

    def _generate_tool_description(self, deps: SandboxDeps) -> str:
        """Generate a description for a profile's tool."""
        commands_list = deps.profile.allowed_commands[:5]
        commands = ", ".join(commands_list)
        if len(deps.profile.allowed_commands) > 5:
            commands += f", ... ({len(deps.profile.allowed_commands)} total)"
        if not commands:
            commands = "commands permitted by the profile rules"

        executor = getattr(deps, "executor", None)
        if executor is None:
            host_info = "local sandbox"
        else:
            host = getattr(executor, "host", None)
            if host:
                host_info = f"remote host {host}"
            else:
                host_info = f"{executor.__class__.__name__}"

        network_note = (
            "network isolated" if deps.profile.network_isolation else "network access allowed"
        )

        return (
            f"Execute read-only commands in '{deps.profile.name}' sandbox on {host_info}. "
            f"Allowed commands include: {commands}. "
            f'{network_note}. Provide arguments as {{"command": ["ls", "/"]}}.'
        )

    def _make_tool_name(self, profile_name: str) -> str:
        """Create deterministic tool names optionally including executor label."""
        if self._executor_label:
            return f"sandbox_{self._executor_label}_{profile_name}"
        return f"sandbox_{profile_name}"

    def _format_command_output(self, result: CommandOutput) -> str:
        """Format command output for MCP response."""
        output = f"Exit code: {result.returncode}\n"
        output += f"Duration: {result.duration:.2f}s\n\n"

        if result.stdout:
            output += "--- stdout ---\n"
            output += result.stdout
            output += "\n"

        if result.stderr:
            output += "--- stderr ---\n"
            output += result.stderr
            output += "\n"

        if not result.succeeded:
            output += "\n⚠️  Command failed"

        return output

    async def run(self) -> None:
        """Run the MCP server."""
        options = InitializationOptions(
            server_name="shannot-sandbox",
            server_version=__version__,
            capabilities=ServerCapabilities(
                tools=ToolsCapability(),  # We provide tools
                resources=ResourcesCapability(),  # We provide resources
            ),
        )

        async with stdio_server() as (read_stream, write_stream):
            await self.server.run(read_stream, write_stream, options)

    async def cleanup(self) -> None:
        """Cleanup resources associated with the server."""
        for deps in self.deps_by_profile.values():
            try:
                await deps.cleanup()
            except Exception as exc:
                logger.debug("Failed to cleanup sandbox dependencies: %s", exc)
Functions
run() async

Run the MCP server.

Source code in shannot/mcp_server.py
async def run(self) -> None:
    """Run the MCP server."""
    options = InitializationOptions(
        server_name="shannot-sandbox",
        server_version=__version__,
        capabilities=ServerCapabilities(
            tools=ToolsCapability(),  # We provide tools
            resources=ResourcesCapability(),  # We provide resources
        ),
    )

    async with stdio_server() as (read_stream, write_stream):
        await self.server.run(read_stream, write_stream, options)
cleanup() async

Cleanup resources associated with the server.

Source code in shannot/mcp_server.py
async def cleanup(self) -> None:
    """Cleanup resources associated with the server."""
    for deps in self.deps_by_profile.values():
        try:
            await deps.cleanup()
        except Exception as exc:
            logger.debug("Failed to cleanup sandbox dependencies: %s", exc)

Functions