diff --git a/README.md b/README.md index 1278d04..4434956 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,23 @@ -## RemoteShell MCP +# ๐Ÿ”— RemoteShell MCP -RemoteShell is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that lets an LLM: +A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that enables LLMs to securely manage and execute commands on remote SSH servers. -- Save SSH server profiles once (so the user doesnโ€™t retype credentials) -- Execute **non-interactive** shell commands remotely -- Upload/download files via SFTP +## โœจ Features -This server is built with [FastMCP](https://gofastmcp.com/) and Paramiko. +- ๐Ÿ” **Secure credential management** - Save SSH server profiles once, no need to retype credentials +- ๐Ÿ’ป **Remote command execution** - Execute non-interactive shell commands remotely +- ๐Ÿ“ **File operations** - Upload/download files via SFTP +- ๐Ÿค– **LLM-powered** - Built with [FastMCP](https://gofastmcp.com/) and Paramiko -## Installation / Client setup (recommended) +## ๐Ÿš€ Installation -Add this to your MCP client config: +### For Claude Code users +```bash +claude mcp add remoteshell --scope user -- uvx remoteshell-mcp +``` + +### For other MCP clients +Add this to your MCP client configuration: ```json { @@ -22,32 +29,43 @@ Add this to your MCP client config: } } ``` +## ๐Ÿ“– Usage + +Getting started is easy! You can either: -## Persistent storage +1. **๐Ÿค– Let the LLM configure for you** - Simply tell the LLM your host, username, password, etc., and ask it to set up the server configuration +2. **โš™๏ธ Manual configuration** - Directly edit the configuration file at `~/.config/remoteshell/hosts.json` -RemoteShell persists servers to: -- `~/.config/remoteshell/hosts.json` +## ๐Ÿ’พ Configuration & Storage -The LLM is expected to manage this file by calling `save_server` / `remove_server`. -You can also edit it manually. +RemoteShell securely stores your server configurations in: + +``` +~/.config/remoteshell/hosts.json +``` -Example `hosts.json`: +### ๐Ÿ”ง Configuration Management + +- **LLM-managed**: The LLM automatically manages this file using `save_server` and `remove_server` tools +- **Manual editing**: You can also directly edit the JSON file for advanced configurations + +### ๐Ÿ“‹ Example Configuration ```json { "version": 1, "servers": { - "srv1": { + "production-server": { "host": "1.2.3.4", "user": "root", "port": 22, "auth_type": "password", - "password": "your_password_here", + "password": "your_secure_password", "last_connected": null }, - "srv2": { - "host": "example.com", + "staging-server": { + "host": "staging.example.com", "user": "ubuntu", "port": 22, "auth_type": "private_key", @@ -58,56 +76,102 @@ Example `hosts.json`: } ``` -On POSIX systems you should protect the file: +### ๐Ÿ”’ Security Note + +On POSIX systems, protect your configuration file: ```bash chmod 600 ~/.config/remoteshell/hosts.json ``` -## Tools +## ๐Ÿ› ๏ธ Available Tools + +RemoteShell provides the following MCP tools for remote server management: + +### ๐Ÿ“‹ `list_servers()` + +**Purpose**: Display all saved server profiles with their connection status and last activity. + +**When to use**: +- User asks to "connect to server" or "show machines" +- No specific `connection_id` is provided +- Need to see available servers + +**Example**: *"Show me which servers I have configured"* โ†’ Returns list of all saved servers with online status + +### ๐Ÿ’พ `save_server(connection_id, host, user, auth_type, credential)` + +**Purpose**: Create or update a server profile with authentication credentials. + +**Parameters**: +- `connection_id`: Unique identifier for the server (e.g., "production", "staging") +- `host`: Server hostname or IP address +- `user`: SSH username +- `auth_type`: `"password"` or `"private_key"` +- `credential`: + - For `password`: Plain text password string + - For `private_key`: File path (e.g., `~/.ssh/id_rsa`) or PEM key content + +**When to use**: +- Adding a new server configuration +- Updating credentials after authentication failure +- Changing server connection details + +### ๐Ÿ—‘๏ธ `remove_server(connection_id)` + +**Purpose**: Permanently delete a server profile from storage. + +**When to use**: +- User explicitly requests to remove or forget a server +- Server is no longer accessible or needed + +โš ๏ธ **Warning**: This action cannot be undone + +### โšก `execute_command(connection_id, command)` + +**Purpose**: Execute non-interactive shell commands remotely and return results. -RemoteShell exposes exactly these tools: +**Returns**: `stdout`, `stderr`, and `exit_code` -### `list_servers()` +**When to use**: +- Running system commands, scripts, or utilities +- Checking server status, disk usage, process lists +- File operations, package management, etc. -- **Purpose**: List saved servers, including cached online status and `last_connected`. -- **When to use**: When the user says โ€œconnect serverโ€, โ€œshow machinesโ€, or did not specify a `connection_id`. -- **Example**: โ€œShow me which servers I have.โ€ +**When NOT to use**: +- Interactive programs (vim, htop, top) +- Commands requiring manual input (`[Y/n]` prompts) - unless using flags like `-y` -### `save_server(connection_id, host, user, auth_type, credential)` +**Example**: `execute_command(connection_id="production", command="df -h")` -- **Purpose**: Create/update a saved server profile. -- **auth_type**: `password` or `private_key` -- **credential**: - - For `password`: the password string - - For `private_key`: either a private key path (e.g. `~/.ssh/id_rsa`) or PEM key text -- **When to use**: New server info, or after `auth_failed` to update credentials. +### ๐Ÿ“ค `upload_file(connection_id, local_path, remote_path)` -### `remove_server(connection_id)` +**Purpose**: Upload files from your local machine to a remote server via SFTP. -- **Purpose**: Permanently delete a saved server profile. -- **When to use**: Only when the user explicitly asks to remove/forget a server. +**Parameters**: +- `local_path`: Path to the file on your local machine +- `remote_path`: Destination path on the remote server -### `execute_command(connection_id, command)` +**Notes**: +- If `remote_path` is a directory, the original filename is preserved +- If `local_path` is omitted, server selects a default and returns it in response -- **Purpose**: Execute a **non-interactive** command remotely and return `stdout`, `stderr`, `exit_code`. -- **When NOT to use**: Interactive programs (vim/htop/top) or commands requiring manual `[Y/n]` prompts (unless you add flags like `-y`). -- **Example**: `execute_command(connection_id="srv1", command="df -h")` +### ๐Ÿ“ฅ `download_file(connection_id, remote_path, local_path)` -### `upload_file(connection_id, local_path, remote_path)` +**Purpose**: Download files from a remote server to your local machine via SFTP. -- **Purpose**: Upload a local file (local to the machine running this MCP server) to the remote. -- **Note**: If `remote_path` is a directory, the local filename is preserved. -- **Auto local_path**: If `local_path` is omitted, the server picks a default path and returns it in the response/error. +**Parameters**: +- `remote_path`: Path to the file on the remote server +- `local_path`: Destination path on your local machine -### `download_file(connection_id, remote_path, local_path)` +**Notes**: +- If `local_path` is omitted, defaults to: `~/.config/remoteshell/downloads//` -- **Purpose**: Download a remote file to a local path (local to the machine running this MCP server). -- **Auto local_path**: If `local_path` is omitted, the server defaults to `~/.config/remoteshell/downloads//`. +## ๐Ÿงช Development -## Claude Code shortcut (local dev) +### Local Development Setup -If you want to run the server from a local checkout: +For local development, use this MCP configuration: ```json { @@ -120,7 +184,7 @@ If you want to run the server from a local checkout: } ``` -## Development +### Running Tests ```bash uv run pytest diff --git a/src/remoteshell_mcp/server.py b/src/remoteshell_mcp/server.py index 531ce2c..4d2ae01 100644 --- a/src/remoteshell_mcp/server.py +++ b/src/remoteshell_mcp/server.py @@ -3,9 +3,10 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Dict, Optional +from typing import Annotated, Any, Dict, Optional from fastmcp import FastMCP +from pydantic import Field from .command_validator import CommandValidator, DangerousCommandError from .connection_manager import ConnectionManager @@ -80,10 +81,10 @@ def _default_upload_path(remote_path: str) -> str: @mcp.tool( description=( - "Purpose: List all servers saved locally by this MCP server (persistent inventory).\n" + "List all servers saved locally by this MCP server (persistent inventory).\n\n" "When to use: When the user asks to connect to a server, manage machines, or did not specify a connection_id.\n" - "When NOT to use: Not needed if you already know the correct connection_id.\n" - "Example: \"Show me which servers I have.\"" + "When NOT to use: Not needed if you already know the correct connection_id.\n\n" + 'Example: "Show me which servers I have."' ) ) def list_servers() -> Dict[str, Any]: @@ -99,13 +100,19 @@ def list_servers() -> Dict[str, Any]: @mcp.tool( description=( - "Purpose: Persist (create or update) a server connection profile in the local host store.\n" + "Persist (create or update) a server connection profile in the local host store.\n\n" "When to use: When the user provides new SSH details, or after an auth_failed error to update credentials.\n" - "When NOT to use: Do not ask for credentials again if they are already saved and still valid.\n" - "Example: save_server(connection_id=\"srv1\", host=\"1.2.3.4\", user=\"root\", auth_type=\"password\", credential=\"\")" + "When NOT to use: Do not ask for credentials again if they are already saved and still valid.\n\n" + 'Example: save_server(connection_id="srv1", host="1.2.3.4", user="root", auth_type="password", credential="")' ) ) -def save_server(connection_id: str, host: str, user: str, auth_type: str, credential: str) -> Dict[str, Any]: +def save_server( + connection_id: Annotated[str, Field(description="Unique identifier for this server connection")], + host: Annotated[str, Field(description="Server hostname or IP address")], + user: Annotated[str, Field(description="SSH username")], + auth_type: Annotated[str, Field(description="Authentication method: 'password', 'ssh_key', or 'ssh_agent'")], + credential: Annotated[str, Field(description="Password for 'password' auth, or path to private key for 'ssh_key' auth (empty for 'ssh_agent')")], +) -> Dict[str, Any]: manager = _manager() try: cfg = manager.host_store.upsert( @@ -140,13 +147,15 @@ def save_server(connection_id: str, host: str, user: str, auth_type: str, creden @mcp.tool( description=( - "Purpose: Permanently delete a saved server profile from the local host store.\n" + "Permanently delete a saved server profile from the local host store.\n\n" "When to use: Only when the user explicitly asks to forget/remove a server.\n" - "When NOT to use: Do not remove servers just because a connection failed.\n" - "Example: remove_server(connection_id=\"srv1\")" + "When NOT to use: Do not remove servers just because a connection failed.\n\n" + 'Example: remove_server(connection_id="srv1")' ) ) -def remove_server(connection_id: str) -> Dict[str, Any]: +def remove_server( + connection_id: Annotated[str, Field(description="Unique identifier of the server to remove")], +) -> Dict[str, Any]: manager = _manager() removed = manager.host_store.remove(connection_id) manager.close_connection(connection_id) @@ -157,13 +166,16 @@ def remove_server(connection_id: str) -> Dict[str, Any]: @mcp.tool( description=( - "Purpose: Execute a non-interactive shell command on a remote server and return stdout/stderr/exit_code.\n" + "Execute a non-interactive shell command on a remote server and return stdout/stderr/exit_code.\n\n" "When to use: Status checks (df, ls), file ops (cp, mv), and scripts that do not require live interaction.\n" - "When NOT to use: Do not run interactive tools (vim, htop, top) or commands that require manual prompts.\n" - "Example: execute_command(connection_id=\"srv1\", command=\"df -h\")" + "When NOT to use: Do not run interactive tools (vim, htop, top) or commands that require manual prompts.\n\n" + 'Example: execute_command(connection_id="srv1", command="df -h")' ) ) -def execute_command(connection_id: str, command: str) -> Dict[str, Any]: +def execute_command( + connection_id: Annotated[str, Field(description="Unique identifier of the server connection")], + command: Annotated[str, Field(description="Shell command to execute (non-interactive only)")], +) -> Dict[str, Any]: manager = _manager() # Validate command for safety before execution @@ -210,13 +222,17 @@ def execute_command(connection_id: str, command: str) -> Dict[str, Any]: @mcp.tool( description=( - "Purpose: Upload a local file (on the machine running this MCP server) to a remote server via SFTP.\n" + "Upload a local file (on the machine running this MCP server) to a remote server via SFTP.\n\n" "When to use: Deploy configs, scripts, or artifacts to the remote.\n" - "When NOT to use: Do not upload huge files blindly; verify size/permissions first.\n" - "Example: upload_file(connection_id=\"srv1\", local_path=\"./config.yaml\", remote_path=\"/etc/app/\")" + "When NOT to use: Do not upload huge files blindly; verify size/permissions first.\n\n" + 'Example: upload_file(connection_id="srv1", local_path="./config.yaml", remote_path="/etc/app/")' ) ) -def upload_file(connection_id: str, local_path: Optional[str] = None, *, remote_path: str) -> Dict[str, Any]: +def upload_file( + connection_id: Annotated[str, Field(description="Unique identifier of the server connection")], + remote_path: Annotated[str, Field(description="Destination path on the remote server")], + local_path: Annotated[Optional[str], Field(description="Local file path to upload. Defaults to a path in ~/.config/remoteshell/uploads/")] = None, +) -> Dict[str, Any]: manager = _manager() chosen_local_path = local_path or _default_upload_path(remote_path) try: @@ -243,13 +259,17 @@ def upload_file(connection_id: str, local_path: Optional[str] = None, *, remote_ @mcp.tool( description=( - "Purpose: Download a remote file to a local path (on the machine running this MCP server) via SFTP.\n" + "Download a remote file to a local path (on the machine running this MCP server) via SFTP.\n\n" "When to use: Fetch logs, reports, or backups from the remote.\n" - "When NOT to use: Avoid very large downloads (>100MB) unless you verified size first.\n" - "Example: download_file(connection_id=\"srv1\", remote_path=\"/var/log/syslog\", local_path=\"./logs/\")" + "When NOT to use: Avoid very large downloads (>100MB) unless you verified size first.\n\n" + 'Example: download_file(connection_id="srv1", remote_path="/var/log/syslog", local_path="./logs/")' ) ) -def download_file(connection_id: str, remote_path: str, local_path: Optional[str] = None) -> Dict[str, Any]: +def download_file( + connection_id: Annotated[str, Field(description="Unique identifier of the server connection")], + remote_path: Annotated[str, Field(description="Path to the remote file to download")], + local_path: Annotated[Optional[str], Field(description="Local destination path. Defaults to ~/.config/remoteshell/downloads//")] = None, +) -> Dict[str, Any]: manager = _manager() chosen_local_path = local_path or _default_download_path(connection_id, remote_path) try: