#!/usr/bin/env python3 """ Hermes WebUI MCP Server — exposes project and session management as MCP tools for any MCP-compatible agent. Option A rewrite (2026-05-08): imports api.models and api.profiles directly from the webui codebase, using canonical helpers for locking, profile scoping, index consistency, and validation. pip install mcp # one-time setup python3 mcp_server.py # start via stdio MCP config for Hermes Agent (add to config.yaml): mcp_servers: hermes-webui: command: /path/to/venv/bin/python3 args: [/path/to/hermes-webui/mcp_server.py] env: HERMES_WEBUI_PASSWORD: your_password Profile override (optional): args: [/path/to/hermes-webui/mcp_server.py, --profile, myprofile] AI-authoring disclosure: this file was rewritten by MILO (Hermes Agent) under human direction, per maintainer guidelines for #1616. """ import argparse import json import os import re import sys import time import uuid from pathlib import Path from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent # ── Ensure the repo root is on sys.path so api.* imports work ───────────── _REPO_ROOT = Path(__file__).parent.resolve() if str(_REPO_ROOT) not in sys.path: sys.path.insert(0, str(_REPO_ROOT)) # ── CLI: optional --profile override ────────────────────────────────────── _profile_arg: str | None = None _parser = argparse.ArgumentParser(add_help=False) _parser.add_argument("--profile", type=str, default=None) _args, _unknown = _parser.parse_known_args() _profile_arg = _args.profile # ── Import webui canonical modules (after path setup) ───────────────────── import api.config as _cfg from api.config import ( STATE_DIR, SESSION_DIR, SESSION_INDEX_FILE, PROJECTS_FILE, HOME, ) from api.models import load_projects, save_projects from api.profiles import get_active_profile_name, _is_root_profile, _profiles_match # ── Apply --profile override before any module uses get_active_profile_name if _profile_arg is not None: import api.profiles as _profiles _profiles._active_profile = _profile_arg # ── API auth state ───────────────────────────────────────────────────────── # Mirror the env-var contract used by api/config.py:32-33 so a non-default # WebUI port/host (e.g. when 8787 is held by another service on the host) # Just Works without configuration drift between the WebUI process and MCP. WEBUI_HOST = os.environ.get("HERMES_WEBUI_HOST", "127.0.0.1") WEBUI_PORT = os.environ.get("HERMES_WEBUI_PORT", "8787") WEBUI_URL = f"http://{WEBUI_HOST}:{WEBUI_PORT}" _auth_cookie: str | None = None _auth_expires: float = 0 # unix timestamp after which we re-auth server = Server("hermes-webui") # ═══════════════════════════════════════════════════════════════════════════ # Helpers — filesystem (project CRUD via canonical api.models) # ═══════════════════════════════════════════════════════════════════════════ def _active_profile() -> str: """Shorthand for the current profile name (--profile or auto-detected).""" return get_active_profile_name() or 'default' def _validate_color(color: str | None) -> str | None: """Return an error string if color is invalid, else None.""" if color is not None and not re.match(r"^#[0-9a-fA-F]{3,8}$", color): return "Invalid color format (use #RGB, #RRGGBB, or #RRGGBBAA)" return None def _load_index() -> list: """Read the session index. Falls back to empty list on failure.""" if not SESSION_INDEX_FILE.exists(): return [] try: return json.loads(SESSION_INDEX_FILE.read_text(encoding="utf-8")) except Exception: return [] def _session_compact(row: dict) -> dict: """Lightweight compact representation of a session index entry.""" return { "session_id": row.get("session_id"), "title": row.get("title"), "project_id": row.get("project_id"), "workspace": row.get("workspace"), "model": row.get("model"), "message_count": row.get("message_count", 0), "source_tag": row.get("source_tag"), "is_cli_session": row.get("is_cli_session", False), "profile": row.get("profile"), } # ═══════════════════════════════════════════════════════════════════════════ # Helpers — HTTP API (for mutations that need cache sync) # ═══════════════════════════════════════════════════════════════════════════ def _api_password() -> str | None: """Return the plaintext webui password from HERMES_WEBUI_PASSWORD, or None. settings.json stores only the bcrypt hash, which the login endpoint cannot accept — it calls verify_password(plaintext) against the stored hash. So there's no usable fallback when the env var is unset; the MCP simply runs in unauthenticated mode and any auth-protected mutation will fail clearly with the server's 401 instead of silently sending an unusable hash. """ pw = os.environ.get("HERMES_WEBUI_PASSWORD", "").strip() return pw or None def _api_auth() -> str | None: """Authenticate and return cookie value, or None if auth disabled/fails.""" global _auth_cookie, _auth_expires pw = _api_password() if not pw: return None # auth not enabled — API calls will fail anyway # Reuse cookie if still valid (25 days — server issues 30-day cookies) if _auth_cookie and time.time() < _auth_expires: return _auth_cookie import urllib.request try: req = urllib.request.Request( f"{WEBUI_URL}/api/auth/login", data=json.dumps({"password": pw}).encode(), headers={"Content-Type": "application/json"}, method="POST", ) resp = urllib.request.urlopen(req, timeout=5) cookie = resp.headers.get("Set-Cookie", "") if cookie: _auth_cookie = cookie.split(";")[0] # "hermes_session=VALUE; ..." _auth_expires = time.time() + 25 * 86400 # 25 days return _auth_cookie except Exception: _auth_cookie = None return None def _api_post(endpoint: str, body: dict) -> dict: """POST to webui API with auth cookie. Returns parsed JSON response.""" import urllib.request import urllib.error cookie = _api_auth() headers = {"Content-Type": "application/json"} if cookie: headers["Cookie"] = cookie try: req = urllib.request.Request( f"{WEBUI_URL}{endpoint}", data=json.dumps(body).encode(), headers=headers, method="POST", ) resp = urllib.request.urlopen(req, timeout=5) return json.loads(resp.read()) except urllib.error.HTTPError as e: err_body = json.loads(e.read()) return {"error": f"API {e.code}: {err_body.get('error', 'unknown')}"} except Exception as e: return {"error": f"API unreachable: {e}"} # ═══════════════════════════════════════════════════════════════════════════ # Tool handlers — read-only (filesystem, profile-aware) # ═══════════════════════════════════════════════════════════════════════════ async def handle_list_projects(_arguments: dict) -> list[TextContent]: """List all projects with session counts, scoped to active profile.""" projects = load_projects() active = _active_profile() index = _load_index() # Session counts per project (from index) counts: dict[str, int] = {} for s in index: pid = s.get("project_id") if pid: counts[pid] = counts.get(pid, 0) + 1 result = [] for p in projects: # Profile filter: legacy untagged rows are treated as 'default' by # _profiles_match, so non-root profiles correctly hide them. if not _profiles_match(p.get("profile"), active): continue entry = dict(p) entry["session_count"] = counts.get(p["project_id"], 0) result.append(entry) return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))] async def handle_list_sessions(arguments: dict) -> list[TextContent]: """List sessions, optionally filtered by project or unassigned status.""" project_id = arguments.get("project_id") unassigned = arguments.get("unassigned", False) limit = max(1, min(500, arguments.get("limit", 50))) active = _active_profile() index = _load_index() sessions = [_session_compact(s) for s in index if s.get("session_id")] # Filter by profile: legacy untagged rows are treated as 'default' by # _profiles_match (canonical convention), so non-root profiles hide them. sessions = [s for s in sessions if _profiles_match(s.get("profile"), active)] if unassigned: sessions = [s for s in sessions if not s["project_id"]] elif project_id: sessions = [s for s in sessions if s["project_id"] == project_id] sessions = sessions[:limit] return [TextContent(type="text", text=json.dumps(sessions, ensure_ascii=False, indent=2))] # ═══════════════════════════════════════════════════════════════════════════ # Tool handlers — project CRUD (canonical helpers, profile-scoped) # ═══════════════════════════════════════════════════════════════════════════ async def handle_create_project(arguments: dict) -> list[TextContent]: """Create a new project (profile-scoped, exact-match title collision).""" name = arguments.get("name", "").strip()[:128] if not name: return [TextContent(type="text", text=json.dumps( {"error": "name is required"}, ensure_ascii=False))] color = arguments.get("color") color_err = _validate_color(color) if color_err: return [TextContent(type="text", text=json.dumps( {"error": color_err}, ensure_ascii=False))] active = _active_profile() projects = load_projects() # Title collision: exact match (consistent with ensure_cron_project) if any(p.get("name") == name and _profiles_match(p.get("profile"), active) for p in projects): return [TextContent(type="text", text=json.dumps( {"error": f"Project '{name}' already exists"}, ensure_ascii=False))] proj = { "project_id": uuid.uuid4().hex[:12], "name": name, "color": color, "profile": active, "created_at": time.time(), } projects.append(proj) save_projects(projects) proj["session_count"] = 0 return [TextContent(type="text", text=json.dumps(proj, ensure_ascii=False, indent=2))] async def handle_rename_project(arguments: dict) -> list[TextContent]: """Rename a project and optionally change its color (profile-checked).""" project_id = arguments.get("project_id") name = arguments.get("name", "").strip()[:128] if not project_id or not name: return [TextContent(type="text", text=json.dumps( {"error": "project_id and name are required"}, ensure_ascii=False))] color = arguments.get("color") color_err = _validate_color(color) if color_err: return [TextContent(type="text", text=json.dumps( {"error": color_err}, ensure_ascii=False))] active = _active_profile() projects = load_projects() proj = next((p for p in projects if p["project_id"] == project_id), None) if not proj: return [TextContent(type="text", text=json.dumps( {"error": "Project not found"}, ensure_ascii=False))] # #1614: profile ownership check if not _profiles_match(proj.get("profile"), active): return [TextContent(type="text", text=json.dumps( {"error": "Project not found"}, ensure_ascii=False))] proj["name"] = name if color is not None: proj["color"] = color save_projects(projects) return [TextContent(type="text", text=json.dumps(proj, ensure_ascii=False, indent=2))] async def handle_delete_project(arguments: dict) -> list[TextContent]: """Delete a project and unassign all its sessions (profile-checked).""" project_id = arguments.get("project_id") if not project_id: return [TextContent(type="text", text=json.dumps( {"error": "project_id is required"}, ensure_ascii=False))] active = _active_profile() projects = load_projects() proj = next((p for p in projects if p["project_id"] == project_id), None) if not proj: return [TextContent(type="text", text=json.dumps( {"error": "Project not found"}, ensure_ascii=False))] # #1614: profile ownership check if not _profiles_match(proj.get("profile"), active): return [TextContent(type="text", text=json.dumps( {"error": "Project not found"}, ensure_ascii=False))] projects = [p for p in projects if p["project_id"] != project_id] save_projects(projects) # Unassign sessions only when we can do it cache-safely via the HTTP API. # The previous filesystem fallback wrote session_data directly with # os.replace(), which bypassed _write_session_index() in api/models.py # and left _index.json holding the stale project_id — a running WebUI # would still group those sessions under the deleted project until a # subsequent re-compact. Even calling Session.save() in-process would # not help because the WebUI's SESSIONS dict cache (a separate process) # still has the old project_id and overwrites our update on its next # save. The HTTP API is the only cache-safe path; without auth we # refuse and surface the limitation so the operator can act. has_auth = bool(_api_password()) if not has_auth: return [TextContent(type="text", text=json.dumps({ "ok": True, "deleted": proj["name"], "unassigned_sessions": 0, "warning": "Set HERMES_WEBUI_PASSWORD to unassign sessions; " "without auth the session index cannot be safely " "updated and direct filesystem writes would cause " "index drift in a running WebUI.", }, ensure_ascii=False))] unassigned = 0 if SESSION_DIR.exists(): for p in SESSION_DIR.glob("*.json"): if p.name.startswith("_"): continue try: session_data = json.loads(p.read_text(encoding="utf-8")) if session_data.get("project_id") == project_id: sid = p.stem result = _api_post("/api/session/move", {"session_id": sid, "project_id": None}) if "ok" in result or "session" in result: unassigned += 1 except Exception: pass return [TextContent(type="text", text=json.dumps({ "ok": True, "deleted": proj["name"], "unassigned_sessions": unassigned, }, ensure_ascii=False))] # ═══════════════════════════════════════════════════════════════════════════ # Tool handlers — mutations (HTTP API with auth, cache-safe) # ═══════════════════════════════════════════════════════════════════════════ async def handle_rename_session(arguments: dict) -> list[TextContent]: """Rename a session via the authenticated webui API (cache-safe).""" session_id = arguments.get("session_id") title = arguments.get("title", "").strip()[:80] if not session_id or not title: return [TextContent(type="text", text=json.dumps( {"error": "session_id and title are required"}, ensure_ascii=False))] result = _api_post("/api/session/rename", {"session_id": session_id, "title": title}) if "error" in result: return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False))] session = result.get("session", {}) return [TextContent(type="text", text=json.dumps({ "ok": True, "session_id": session_id, "title": session.get("title", title), "method": "api", }, ensure_ascii=False, indent=2))] async def handle_move_session(arguments: dict) -> list[TextContent]: """Assign a session to a project via the authenticated webui API (cache-safe).""" session_id = arguments.get("session_id") project_id = arguments.get("project_id") # None/null = unassign if not session_id: return [TextContent(type="text", text=json.dumps( {"error": "session_id is required"}, ensure_ascii=False))] # If project_id is provided, verify it exists and is profile-accessible if project_id is not None: projects = load_projects() active = _active_profile() target = next((p for p in projects if p["project_id"] == project_id), None) if not target: return [TextContent(type="text", text=json.dumps( {"error": "Project not found"}, ensure_ascii=False))] # #1614: refuse moves into projects owned by another profile if not _profiles_match(target.get("profile"), active): return [TextContent(type="text", text=json.dumps( {"error": "Project not found"}, ensure_ascii=False))] result = _api_post("/api/session/move", {"session_id": session_id, "project_id": project_id}) if "error" in result: return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False))] session = result.get("session", {}) return [TextContent(type="text", text=json.dumps({ "ok": True, "session_id": session_id, "project_id": project_id, "title": session.get("title"), "method": "api", }, ensure_ascii=False, indent=2))] # ═══════════════════════════════════════════════════════════════════════════ # MCP Server wiring # ═══════════════════════════════════════════════════════════════════════════ TOOLS = [ Tool( name="list_projects", description="List all session projects with their IDs, names, colors, and session counts (scoped to active profile).", inputSchema={"type": "object", "properties": {}, "required": []}, ), Tool( name="create_project", description="Create a new project for organizing sessions (profile-scoped).", inputSchema={ "type": "object", "properties": { "name": {"type": "string", "description": "Project name (max 128 chars)"}, "color": {"type": "string", "description": "Optional hex color (#RGB, #RRGGBB, or #RRGGBBAA)"}, }, "required": ["name"], }, ), Tool( name="rename_project", description="Rename a project and optionally change its color (profile-checked).", inputSchema={ "type": "object", "properties": { "project_id": {"type": "string", "description": "12-char project ID"}, "name": {"type": "string", "description": "New name (max 128 chars)"}, "color": {"type": "string", "description": "Optional new hex color"}, }, "required": ["project_id", "name"], }, ), Tool( name="delete_project", description="Delete a project and unassign all its sessions (profile-checked).", inputSchema={ "type": "object", "properties": { "project_id": {"type": "string", "description": "12-char project ID to delete"}, }, "required": ["project_id"], }, ), Tool( name="rename_session", description="Rename a session (updates sidebar via authenticated API, cache-safe).", inputSchema={ "type": "object", "properties": { "session_id": {"type": "string", "description": "Session ID"}, "title": {"type": "string", "description": "New title (max 80 chars)"}, }, "required": ["session_id", "title"], }, ), Tool( name="move_session", description="Assign a session to a project. Pass project_id=null to unassign. Uses authenticated API for cache safety (profile-checked).", inputSchema={ "type": "object", "properties": { "session_id": {"type": "string", "description": "Session ID"}, "project_id": {"type": ["string", "null"], "description": "Project ID (or null to unassign)"}, }, "required": ["session_id", "project_id"], }, ), Tool( name="list_sessions", description="List sessions, optionally filtered by project or unassigned status (profile-scoped).", inputSchema={ "type": "object", "properties": { "project_id": {"type": "string", "description": "Filter sessions by project ID"}, "unassigned": {"type": "boolean", "description": "Show only sessions with no project"}, "limit": {"type": "integer", "description": "Max results (default: 50, max: 500)"}, }, "required": [], }, ), ] HANDLERS = { "list_projects": handle_list_projects, "create_project": handle_create_project, "rename_project": handle_rename_project, "delete_project": handle_delete_project, "rename_session": handle_rename_session, "move_session": handle_move_session, "list_sessions": handle_list_sessions, } @server.list_tools() async def list_tools() -> list[Tool]: return TOOLS @server.call_tool() async def call_tool(name: str, arguments: dict) -> list[TextContent]: handler = HANDLERS.get(name) if not handler: return [TextContent(type="text", text=json.dumps( {"error": f"Unknown tool: {name}"}, ensure_ascii=False))] return await handler(arguments) async def main(): async with stdio_server() as (read, write): await server.run(read, write, server.create_initialization_options()) if __name__ == "__main__": import asyncio asyncio.run(main())