#!/usr/bin/env python3 """Hermes MCP Server — expose hermes-agent tools to fleet peers. Runs as a standalone MCP server that other agents can connect to and invoke hermes tools remotely. Safe tools exposed: - terminal (safe commands only) - file_read, file_search - web_search, web_extract - session_search NOT exposed (internal tools): - approval, delegate, memory, config Usage: python -m tools.mcp_server --port 8081 hermes mcp-server --port 8081 python scripts/mcp_server.py --port 8081 --auth-key SECRET """ from __future__ import annotations import argparse import asyncio import json import logging import os import sys import time from pathlib import Path from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) # Tools safe to expose to other agents SAFE_TOOLS = { "terminal": { "name": "terminal", "description": "Execute safe shell commands. Dangerous commands are blocked.", "parameters": { "type": "object", "properties": { "command": {"type": "string", "description": "Shell command to execute"}, }, "required": ["command"], }, }, "file_read": { "name": "file_read", "description": "Read the contents of a file.", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "File path to read"}, "offset": {"type": "integer", "description": "Start line", "default": 1}, "limit": {"type": "integer", "description": "Max lines", "default": 200}, }, "required": ["path"], }, }, "file_search": { "name": "file_search", "description": "Search file contents using regex.", "parameters": { "type": "object", "properties": { "pattern": {"type": "string", "description": "Regex pattern"}, "path": {"type": "string", "description": "Directory to search", "default": "."}, }, "required": ["pattern"], }, }, "web_search": { "name": "web_search", "description": "Search the web for information.", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, }, "required": ["query"], }, }, "session_search": { "name": "session_search", "description": "Search past conversation sessions.", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, "limit": {"type": "integer", "description": "Max results", "default": 3}, }, "required": ["query"], }, }, } # Tools explicitly blocked BLOCKED_TOOLS = { "approval", "delegate", "memory", "config", "skill_install", "mcp_tool", "cronjob", "tts", "send_message", } class MCPServer: """Simple MCP-compatible server for exposing hermes tools.""" def __init__(self, host: str = "127.0.0.1", port: int = 8081, auth_key: Optional[str] = None): self._host = host self._port = port self._auth_key = auth_key or os.getenv("MCP_AUTH_KEY", "") async def handle_tools_list(self, request: dict) -> dict: """Return available tools.""" tools = list(SAFE_TOOLS.values()) return {"tools": tools} async def handle_tools_call(self, request: dict) -> dict: """Execute a tool call.""" tool_name = request.get("name", "") arguments = request.get("arguments", {}) if tool_name in BLOCKED_TOOLS: return {"error": f"Tool '{tool_name}' is not exposed via MCP"} if tool_name not in SAFE_TOOLS: return {"error": f"Unknown tool: {tool_name}"} try: result = await self._execute_tool(tool_name, arguments) return {"content": [{"type": "text", "text": str(result)}]} except Exception as e: return {"error": str(e)} async def _execute_tool(self, tool_name: str, arguments: dict) -> str: """Execute a tool and return result.""" if tool_name == "terminal": import subprocess cmd = arguments.get("command", "") # Block dangerous commands from tools.approval import detect_dangerous_command is_dangerous, _, desc = detect_dangerous_command(cmd) if is_dangerous: return f"BLOCKED: Dangerous command detected ({desc}). This tool only executes safe commands." result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30) return result.stdout or result.stderr or "(no output)" elif tool_name == "file_read": path = arguments.get("path", "") offset = arguments.get("offset", 1) limit = arguments.get("limit", 200) with open(path) as f: lines = f.readlines() return "".join(lines[offset-1:offset-1+limit]) elif tool_name == "file_search": import re pattern = arguments.get("pattern", "") path = arguments.get("path", ".") results = [] for p in Path(path).rglob("*.py"): try: content = p.read_text() for i, line in enumerate(content.split("\n"), 1): if re.search(pattern, line, re.IGNORECASE): results.append(f"{p}:{i}: {line.strip()}") if len(results) >= 20: break except Exception: continue if len(results) >= 20: break return "\n".join(results) or "No matches found" elif tool_name == "web_search": try: from tools.web_tools import web_search return web_search(arguments.get("query", "")) except ImportError: return "Web search not available" elif tool_name == "session_search": try: from tools.session_search_tool import session_search return session_search( query=arguments.get("query", ""), limit=arguments.get("limit", 3), ) except ImportError: return "Session search not available" return f"Tool {tool_name} not implemented" async def start_http(self): """Start HTTP server for MCP endpoints.""" try: from aiohttp import web except ImportError: logger.error("aiohttp required: pip install aiohttp") return app = web.Application() async def handle_tools_list_route(request): if self._auth_key: auth = request.headers.get("Authorization", "") if auth != f"Bearer {self._auth_key}": return web.json_response({"error": "Unauthorized"}, status=401) result = await self.handle_tools_list({}) return web.json_response(result) async def handle_tools_call_route(request): if self._auth_key: auth = request.headers.get("Authorization", "") if auth != f"Bearer {self._auth_key}": return web.json_response({"error": "Unauthorized"}, status=401) body = await request.json() result = await self.handle_tools_call(body) return web.json_response(result) async def handle_health(request): return web.json_response({"status": "ok", "tools": len(SAFE_TOOLS)}) app.router.add_get("/mcp/tools", handle_tools_list_route) app.router.add_post("/mcp/tools/call", handle_tools_call_route) app.router.add_get("/health", handle_health) runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, self._host, self._port) await site.start() logger.info("MCP server on http://%s:%s", self._host, self._port) logger.info("Tools: %s", ", ".join(SAFE_TOOLS.keys())) if self._auth_key: logger.info("Auth: Bearer token required") else: logger.warning("Auth: No MCP_AUTH_KEY set — server is open") try: await asyncio.Event().wait() except asyncio.CancelledError: pass finally: await runner.cleanup() def main(): parser = argparse.ArgumentParser(description="Hermes MCP Server") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8081) parser.add_argument("--auth-key", default=None, help="Bearer token for auth") args = parser.parse_args() logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s") server = MCPServer(host=args.host, port=args.port, auth_key=args.auth_key) print(f"Starting MCP server on http://{args.host}:{args.port}") print(f"Exposed tools: {', '.join(SAFE_TOOLS.keys())}") asyncio.run(server.start_http()) if __name__ == "__main__": main()