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

View File

@@ -35,6 +35,23 @@ AJARBOT_SLACK_APP_TOKEN=xapp-your-app-token
# Get token from: https://t.me/BotFather
AJARBOT_TELEGRAM_BOT_TOKEN=123456:ABC-your-bot-token
# ========================================
# SSH Access (Optional)
# ========================================
# Proxmox SSH credentials for remote management
PROXMOX_SSH_HOST=192.168.2.100
PROXMOX_SSH_USER=root
PROXMOX_SSH_PORT=22
# Authentication: Use EITHER password OR key (key is more secure)
# Option 1: Password-based (easier but less secure)
PROXMOX_SSH_PASSWORD=your-proxmox-password
# Option 2: Key-based (recommended for security)
# PROXMOX_SSH_KEY_FILE=C:/Users/YourName/.ssh/id_rsa
# Generate key: ssh-keygen -t rsa -b 4096
# Copy to Proxmox: ssh-copy-id root@192.168.2.100
# ========================================
# Obsidian MCP Integration (Optional)
# ========================================

View File

@@ -1,5 +1,8 @@
"""Gmail API client for sending and reading emails."""
import base64
import os
from pathlib import Path
from typing import Dict, List, Optional
from googleapiclient.discovery import build
@@ -201,13 +204,27 @@ class GmailClient:
# Get attachment info if any
payload = message.get("payload", {})
attachments = []
for part in payload.get("parts", []):
if part.get("filename"):
def extract_attachments(parts):
"""Recursively extract attachments from message parts."""
for part in parts:
filename = part.get("filename")
if filename:
body = part.get("body", {})
attachment_id = body.get("attachmentId")
if attachment_id:
attachments.append({
"filename": part["filename"],
"filename": filename,
"attachment_id": attachment_id,
"mime_type": part.get("mimeType"),
"size": part.get("body", {}).get("size", 0),
"size": body.get("size", 0),
})
# Recursively check nested parts
if "parts" in part:
extract_attachments(part["parts"])
if "parts" in payload:
extract_attachments(payload["parts"])
email_data["attachments"] = attachments
@@ -218,3 +235,62 @@ class GmailClient:
except HttpError as e:
return {"success": False, "error": str(e)}
def download_attachment(
self,
message_id: str,
attachment_id: str,
filename: str,
output_dir: str = "downloads",
) -> Dict:
"""Download an email attachment.
Args:
message_id: Gmail message ID
attachment_id: Attachment ID from the message
filename: Original filename of the attachment
output_dir: Directory to save the attachment (default: "downloads")
Returns:
Dict with success status and file path or error
"""
if not self.service:
if not self._initialize_service():
return {
"success": False,
"error": "Not authorized. Run: python bot_runner.py --setup-google",
}
try:
# Get the attachment data
attachment = (
self.service.users()
.messages()
.attachments()
.get(userId="me", messageId=message_id, id=attachment_id)
.execute()
)
# Decode the attachment data
file_data = base64.urlsafe_b64decode(attachment["data"])
# Create output directory if it doesn't exist
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
# Save the file
file_path = output_path / filename
with open(file_path, "wb") as f:
f.write(file_data)
return {
"success": True,
"file_path": str(file_path),
"filename": filename,
"size": len(file_data),
}
except HttpError as e:
return {"success": False, "error": str(e)}
except Exception as e:
return {"success": False, "error": f"Failed to save attachment: {str(e)}"}

View File

@@ -41,6 +41,7 @@ if not logger.handlers:
# Try to import Agent SDK (optional dependency)
try:
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ResultMessage,
)
@@ -345,8 +346,12 @@ class LLMInterface:
"""
try:
from mcp_tools import file_system_server
from mcp_servers.mcp_ssh import ssh_mcp_server
mcp_servers = {"file_system": file_system_server}
mcp_servers = {
"file_system": file_system_server,
"ssh": ssh_mcp_server,
}
# All tools registered in the MCP server
allowed_tools = [
@@ -356,6 +361,9 @@ class LLMInterface:
"edit_file",
"list_directory",
"run_command",
# SSH tools
"ssh_execute",
"ssh_file_upload",
# Web tool
"web_fetch",
# Zettelkasten tools
@@ -404,6 +412,46 @@ class LLMInterface:
except Exception as e:
print(f"[LLM] Obsidian MCP unavailable: {e}")
# Conditionally add Cloudflare Code Mode MCP server
try:
from mcp_servers.cloudflare.cloudflare_mcp import (
is_cloudflare_enabled,
get_cloudflare_server_config,
CLOUDFLARE_TOOLS,
)
if is_cloudflare_enabled():
cloudflare_config = get_cloudflare_server_config()
mcp_servers["cloudflare"] = cloudflare_config
allowed_tools.extend(CLOUDFLARE_TOOLS)
print("[LLM] Cloudflare MCP server registered (2 tools: search, execute)")
else:
print("[LLM] Cloudflare MCP disabled or no API token set")
except ImportError:
pass
except Exception as e:
print(f"[LLM] Cloudflare MCP unavailable: {e}")
# Conditionally add Loki MCP server (homelab log querying)
try:
from mcp_servers.loki.loki_mcp import (
is_loki_enabled,
get_loki_server_config,
LOKI_TOOLS,
)
if is_loki_enabled():
loki_config = get_loki_server_config()
mcp_servers["loki"] = loki_config
allowed_tools.extend(LOKI_TOOLS)
print(f"[LLM] Loki MCP server registered ({len(LOKI_TOOLS)} tools: {', '.join(LOKI_TOOLS)})")
else:
print("[LLM] Loki MCP disabled")
except ImportError:
pass
except Exception as e:
print(f"[LLM] Loki MCP unavailable: {e}")
def _stderr_callback(line: str) -> None:
"""Log Claude CLI stderr for debugging transport failures."""
logger.debug("[CLI stderr] %s", line)
@@ -517,15 +565,20 @@ class LLMInterface:
# Collect text from AssistantMessage objects
if isinstance(message, AssistantMessage):
logger.debug(f"[LLM] AssistantMessage: has_content={hasattr(message, 'content')}")
if hasattr(message, 'content') and message.content:
# Extract text from content blocks
if isinstance(message.content, str):
assistant_messages.append(message.content)
logger.debug(f"[LLM] → Collected string: {len(message.content)} chars")
elif isinstance(message.content, list):
for block in message.content:
if hasattr(block, 'type') and block.type == 'text':
if hasattr(block, 'text'):
assistant_messages.append(block.text)
logger.debug(f"[LLM] → Collected text block: {len(block.text)} chars")
else:
logger.debug(f"[LLM] → AssistantMessage has no content or empty")
if isinstance(message, ResultMessage):
# Use ResultMessage.result if available, otherwise use collected assistant messages
@@ -537,7 +590,9 @@ class LLMInterface:
getattr(message, "num_turns", "?"),
)
if not message.result and assistant_messages:
logger.debug(f"[LLM] ResultMessage.result was empty, using {len(assistant_messages)} collected assistant messages")
logger.info(f"[LLM] ResultMessage.result was empty, using {len(assistant_messages)} collected assistant messages")
elif not message.result and not assistant_messages:
logger.warning(f"[LLM] PROBLEM: Both ResultMessage.result and assistant_messages are empty!")
break
# Log non-result messages to detect loops

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

View File

@@ -32,3 +32,6 @@ python-dotenv>=1.0.0
# Web fetching dependencies
httpx>=0.27.0
beautifulsoup4>=4.12.0
# SSH dependencies
paramiko>=3.4.0

View File

@@ -190,6 +190,33 @@ TOOL_DEFINITIONS = [
"required": ["message_id"],
},
},
{
"name": "download_attachment",
"description": "Download an email attachment from Gmail. Use get_email first to get attachment IDs.",
"input_schema": {
"type": "object",
"properties": {
"message_id": {
"type": "string",
"description": "Gmail message ID containing the attachment",
},
"attachment_id": {
"type": "string",
"description": "Attachment ID from the email (obtained from get_email)",
},
"filename": {
"type": "string",
"description": "Original filename of the attachment",
},
"output_dir": {
"type": "string",
"description": "Directory to save the file (default: 'downloads')",
"default": "downloads",
},
},
"required": ["message_id", "attachment_id", "filename"],
},
},
# Calendar tools
{
"name": "read_calendar",
@@ -411,6 +438,13 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any
message_id=tool_input["message_id"],
format_type=tool_input.get("format", "text"),
)
elif tool_name == "download_attachment":
result_str = _download_attachment(
message_id=tool_input["message_id"],
attachment_id=tool_input["attachment_id"],
filename=tool_input["filename"],
output_dir=tool_input.get("output_dir", "downloads"),
)
elif tool_name == "read_calendar":
result_str = _read_calendar(
days_ahead=tool_input.get("days_ahead", 7),
@@ -844,7 +878,10 @@ def _get_email(message_id: str, format_type: str = "text") -> str:
output.append(f"\n{email_data.get('body', '')}")
if email_data.get("attachments"):
output.append(f"\nAttachments: {', '.join(email_data['attachments'])}")
output.append("\nAttachments:")
for att in email_data["attachments"]:
att_info = f" - {att['filename']} ({att.get('size', 0)} bytes, ID: {att.get('attachment_id', 'N/A')})"
output.append(att_info)
full_output = "\n".join(output)
if len(full_output) > _MAX_TOOL_OUTPUT:
@@ -855,6 +892,36 @@ def _get_email(message_id: str, format_type: str = "text") -> str:
return f"Error getting email: {result.get('error', 'Unknown error')}"
def _download_attachment(
message_id: str,
attachment_id: str,
filename: str,
output_dir: str = "downloads",
) -> str:
"""Download an email attachment."""
gmail_client, _, _ = _initialize_google_clients()
if not gmail_client:
return "Error: Google not authorized. Run: python bot_runner.py --setup-google"
result = gmail_client.download_attachment(
message_id=message_id,
attachment_id=attachment_id,
filename=filename,
output_dir=output_dir,
)
if result["success"]:
return (
f"Downloaded attachment successfully:\n"
f" File: {result['filename']}\n"
f" Path: {result['file_path']}\n"
f" Size: {result['size']:,} bytes"
)
else:
return f"Error downloading attachment: {result.get('error', 'Unknown error')}"
def _read_calendar(
days_ahead: int = 7,
calendar_id: str = "primary",