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")
]
)
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