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

@@ -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