Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 113 additions & 49 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
{
Expand All @@ -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",
Expand All @@ -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/<connection_id>/<basename>`

- **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/<connection_id>/<basename>`.
## 🧪 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
{
Expand All @@ -120,7 +184,7 @@ If you want to run the server from a local checkout:
}
```

## Development
### Running Tests

```bash
uv run pytest
Expand Down
68 changes: 44 additions & 24 deletions src/remoteshell_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand All @@ -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=\"<password>\")"
"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="<password>")'
)
)
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(
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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/<connection_id>/")] = None,
) -> Dict[str, Any]:
manager = _manager()
chosen_local_path = local_path or _default_download_path(connection_id, remote_path)
try:
Expand Down