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 # Get token from: https://t.me/BotFather
AJARBOT_TELEGRAM_BOT_TOKEN=123456:ABC-your-bot-token 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) # Obsidian MCP Integration (Optional)
# ======================================== # ========================================

View File

@@ -1,5 +1,8 @@
"""Gmail API client for sending and reading emails.""" """Gmail API client for sending and reading emails."""
import base64
import os
from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
from googleapiclient.discovery import build from googleapiclient.discovery import build
@@ -201,13 +204,27 @@ class GmailClient:
# Get attachment info if any # Get attachment info if any
payload = message.get("payload", {}) payload = message.get("payload", {})
attachments = [] attachments = []
for part in payload.get("parts", []):
if part.get("filename"): def extract_attachments(parts):
attachments.append({ """Recursively extract attachments from message parts."""
"filename": part["filename"], for part in parts:
"mime_type": part.get("mimeType"), filename = part.get("filename")
"size": part.get("body", {}).get("size", 0), if filename:
}) body = part.get("body", {})
attachment_id = body.get("attachmentId")
if attachment_id:
attachments.append({
"filename": filename,
"attachment_id": attachment_id,
"mime_type": part.get("mimeType"),
"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 email_data["attachments"] = attachments
@@ -218,3 +235,62 @@ class GmailClient:
except HttpError as e: except HttpError as e:
return {"success": False, "error": str(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 to import Agent SDK (optional dependency)
try: try:
from claude_agent_sdk import ( from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions, ClaudeAgentOptions,
ResultMessage, ResultMessage,
) )
@@ -345,8 +346,12 @@ class LLMInterface:
""" """
try: try:
from mcp_tools import file_system_server 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 # All tools registered in the MCP server
allowed_tools = [ allowed_tools = [
@@ -356,6 +361,9 @@ class LLMInterface:
"edit_file", "edit_file",
"list_directory", "list_directory",
"run_command", "run_command",
# SSH tools
"ssh_execute",
"ssh_file_upload",
# Web tool # Web tool
"web_fetch", "web_fetch",
# Zettelkasten tools # Zettelkasten tools
@@ -404,6 +412,46 @@ class LLMInterface:
except Exception as e: except Exception as e:
print(f"[LLM] Obsidian MCP unavailable: {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: def _stderr_callback(line: str) -> None:
"""Log Claude CLI stderr for debugging transport failures.""" """Log Claude CLI stderr for debugging transport failures."""
logger.debug("[CLI stderr] %s", line) logger.debug("[CLI stderr] %s", line)
@@ -517,15 +565,20 @@ class LLMInterface:
# Collect text from AssistantMessage objects # Collect text from AssistantMessage objects
if isinstance(message, AssistantMessage): if isinstance(message, AssistantMessage):
logger.debug(f"[LLM] AssistantMessage: has_content={hasattr(message, 'content')}")
if hasattr(message, 'content') and message.content: if hasattr(message, 'content') and message.content:
# Extract text from content blocks # Extract text from content blocks
if isinstance(message.content, str): if isinstance(message.content, str):
assistant_messages.append(message.content) assistant_messages.append(message.content)
logger.debug(f"[LLM] → Collected string: {len(message.content)} chars")
elif isinstance(message.content, list): elif isinstance(message.content, list):
for block in message.content: for block in message.content:
if hasattr(block, 'type') and block.type == 'text': if hasattr(block, 'type') and block.type == 'text':
if hasattr(block, 'text'): if hasattr(block, 'text'):
assistant_messages.append(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): if isinstance(message, ResultMessage):
# Use ResultMessage.result if available, otherwise use collected assistant messages # Use ResultMessage.result if available, otherwise use collected assistant messages
@@ -537,7 +590,9 @@ class LLMInterface:
getattr(message, "num_turns", "?"), getattr(message, "num_turns", "?"),
) )
if not message.result and assistant_messages: 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 break
# Log non-result messages to detect loops # 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 # Web fetching dependencies
httpx>=0.27.0 httpx>=0.27.0
beautifulsoup4>=4.12.0 beautifulsoup4>=4.12.0
# SSH dependencies
paramiko>=3.4.0

View File

@@ -190,6 +190,33 @@ TOOL_DEFINITIONS = [
"required": ["message_id"], "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 # Calendar tools
{ {
"name": "read_calendar", "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"], message_id=tool_input["message_id"],
format_type=tool_input.get("format", "text"), 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": elif tool_name == "read_calendar":
result_str = _read_calendar( result_str = _read_calendar(
days_ahead=tool_input.get("days_ahead", 7), 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', '')}") output.append(f"\n{email_data.get('body', '')}")
if email_data.get("attachments"): 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) full_output = "\n".join(output)
if len(full_output) > _MAX_TOOL_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')}" 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( def _read_calendar(
days_ahead: int = 7, days_ahead: int = 7,
calendar_id: str = "primary", calendar_id: str = "primary",