Add SSH MCP server and Gmail attachment download

Features:
- SSH MCP server with two tools:
  * ssh_execute: Run commands on remote hosts via SSH
  * ssh_file_upload: Upload files via SFTP
- Support for both password and SSH key authentication
- Auto-accept SSH host keys (AutoAddPolicy) for homelab use
- Gmail attachment download functionality
- Added download_attachment tool for Gmail API

Technical changes:
- Created mcp_servers/mcp_ssh.py with MCP-compliant text output
- Updated llm_interface.py to load SSH MCP server
- Added paramiko>=3.4.0 to requirements.txt
- Updated .env.example with SSH configuration template
- Enhanced gmail_client.py with download_attachment() method
- Added download_attachment tool handler in tools.py

SSH credentials configured via environment variables:
- PROXMOX_SSH_HOST, PROXMOX_SSH_USER, PROXMOX_SSH_PORT
- PROXMOX_SSH_PASSWORD (or) PROXMOX_SSH_KEY_FILE

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 12:32:05 -07:00
parent a9efdc0a01
commit 58de3e55dc
6 changed files with 489 additions and 10 deletions

261
mcp_servers/mcp_ssh.py Normal file
View File

@@ -0,0 +1,261 @@
"""SSH MCP Server for remote command execution via SSH.
Provides SSH access to remote hosts for the bot.
"""
import asyncio
from typing import Any, Dict
try:
import paramiko
PARAMIKO_AVAILABLE = True
except ImportError:
PARAMIKO_AVAILABLE = False
from claude_agent_sdk import tool, create_sdk_mcp_server
@tool(
name="ssh_execute",
description="Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
input_schema={
"host": str,
"username": str,
"password": str,
"key_filename": str,
"command": str,
"port": int,
},
)
async def ssh_execute(args: Dict[str, Any]) -> Dict[str, Any]:
"""Execute a command on a remote host via SSH."""
if not PARAMIKO_AVAILABLE:
return {
"content": [{"type": "text", "text": "Error: paramiko not installed. Run: pip install paramiko"}],
"isError": True
}
host = args.get("host")
username = args.get("username")
password = args.get("password")
key_filename = args.get("key_filename")
command = args.get("command")
port = args.get("port", 22)
if not all([host, username, command]):
return {
"content": [{"type": "text", "text": "Error: Missing required parameters: host, username, command"}],
"isError": True
}
if not password and not key_filename:
return {
"content": [{"type": "text", "text": "Error: Must provide either password or key_filename for authentication"}],
"isError": True
}
try:
# Run SSH command in thread pool to avoid blocking
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
_execute_ssh_sync,
host,
port,
username,
password,
key_filename,
command
)
# Format result as MCP-compliant text content
if result["success"]:
output_parts = [f"SSH command executed on {result['host']} (auth: {result['auth_method']})"]
output_parts.append(f"Exit code: {result['exit_code']}")
if result["stdout"]:
stdout = result["stdout"]
if len(stdout) > 5000:
stdout = stdout[:5000] + "\n... (stdout truncated)"
output_parts.append(f"\nSTDOUT:\n{stdout}")
if result["stderr"]:
stderr = result["stderr"]
if len(stderr) > 5000:
stderr = stderr[:5000] + "\n... (stderr truncated)"
output_parts.append(f"\nSTDERR:\n{stderr}")
if not result["stdout"] and not result["stderr"]:
output_parts.append("\n(no output)")
return {
"content": [{"type": "text", "text": "\n".join(output_parts)}],
"isError": result["exit_code"] != 0
}
else:
return {
"content": [{"type": "text", "text": f"SSH Error: {result['error']}"}],
"isError": True
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"SSH execution failed: {str(e)}"}],
"isError": True
}
def _execute_ssh_sync(host: str, port: int, username: str, password: str, key_filename: str, command: str) -> Dict[str, Any]:
"""Synchronous SSH execution (runs in thread pool)."""
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
# Build connection parameters
connect_kwargs = {
"hostname": host,
"port": port,
"username": username,
"timeout": 30,
}
# Use key-based auth if key_filename provided, otherwise use password
if key_filename:
connect_kwargs["key_filename"] = key_filename
else:
connect_kwargs["password"] = password
client.connect(**connect_kwargs)
stdin, stdout, stderr = client.exec_command(command)
stdout_text = stdout.read().decode('utf-8')
stderr_text = stderr.read().decode('utf-8')
exit_code = stdout.channel.recv_exit_status()
return {
"success": True,
"stdout": stdout_text,
"stderr": stderr_text,
"exit_code": exit_code,
"host": host,
"auth_method": "key" if key_filename else "password",
}
finally:
client.close()
@tool(
name="ssh_file_upload",
description="Upload a file to a remote host via SFTP. Returns success status and file paths.",
input_schema={
"host": str,
"username": str,
"password": str,
"key_filename": str,
"local_path": str,
"remote_path": str,
"port": int,
},
)
async def ssh_file_upload(args: Dict[str, Any]) -> Dict[str, Any]:
"""Upload a file to a remote host via SFTP."""
if not PARAMIKO_AVAILABLE:
return {
"content": [{"type": "text", "text": "Error: paramiko not installed. Run: pip install paramiko"}],
"isError": True
}
host = args.get("host")
username = args.get("username")
password = args.get("password")
key_filename = args.get("key_filename")
local_path = args.get("local_path")
remote_path = args.get("remote_path")
port = args.get("port", 22)
if not all([host, username, local_path, remote_path]):
return {
"content": [{"type": "text", "text": "Error: Missing required parameters: host, username, local_path, remote_path"}],
"isError": True
}
if not password and not key_filename:
return {
"content": [{"type": "text", "text": "Error: Must provide either password or key_filename for authentication"}],
"isError": True
}
try:
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
_upload_file_sync,
host,
port,
username,
password,
key_filename,
local_path,
remote_path
)
# Format result as MCP-compliant text content
if result["success"]:
text = (
f"File uploaded successfully via SFTP\n"
f"Host: {result['host']}\n"
f"Auth: {result['auth_method']}\n"
f"Local: {result['local_path']}\n"
f"Remote: {result['remote_path']}"
)
return {
"content": [{"type": "text", "text": text}]
}
else:
return {
"content": [{"type": "text", "text": f"SFTP Error: {result['error']}"}],
"isError": True
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"SFTP upload failed: {str(e)}"}],
"isError": True
}
def _upload_file_sync(host: str, port: int, username: str, password: str, key_filename: str, local_path: str, remote_path: str) -> Dict[str, Any]:
"""Synchronous SFTP upload (runs in thread pool)."""
transport = paramiko.Transport((host, port))
try:
# Use key-based auth if key_filename provided, otherwise use password
if key_filename:
private_key = paramiko.RSAKey.from_private_key_file(key_filename)
transport.connect(username=username, pkey=private_key)
else:
transport.connect(username=username, password=password)
sftp = paramiko.SFTPClient.from_transport(transport)
sftp.put(local_path, remote_path)
return {
"success": True,
"local_path": local_path,
"remote_path": remote_path,
"host": host,
"auth_method": "key" if key_filename else "password",
}
finally:
sftp.close() if 'sftp' in locals() else None
transport.close()
# Create the MCP server
ssh_mcp_server = create_sdk_mcp_server(
name="ssh",
version="1.0.0",
tools=[ssh_execute, ssh_file_upload],
)