diff --git a/README.md b/README.md
index 01812038..8d101a2e 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ Built by [Nous Research](https://nousresearch.com). Under the hood, the same arc
## Quick Install
-**Linux/macOS:**
+**Linux / macOS / WSL:**
```bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
```
@@ -42,18 +42,25 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri
irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex
```
+**Windows (CMD):**
+```cmd
+curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.cmd -o install.cmd && install.cmd && del install.cmd
+```
+
+> **Windows note:** [Git for Windows](https://git-scm.com/download/win) is required. Hermes uses Git Bash internally for shell commands.
+
The installer will:
- Install [uv](https://docs.astral.sh/uv/) (fast Python package manager) if not present
- Install Python 3.11 via uv if not already available (no sudo needed)
- Clone to `~/.hermes/hermes-agent` (with submodules: mini-swe-agent, tinker-atropos)
- Create a virtual environment with Python 3.11
- Install all dependencies and submodule packages
-- Symlink `hermes` into `~/.local/bin` so it works globally (no venv activation needed)
+- Set up the `hermes` command globally (no venv activation needed)
- Run the interactive setup wizard
After installation, reload your shell and run:
```bash
-source ~/.bashrc # or: source ~/.zshrc
+source ~/.bashrc # or: source ~/.zshrc (Windows: restart your terminal)
hermes setup # Configure API keys (if you skipped during install)
hermes # Start chatting!
```
@@ -271,22 +278,30 @@ SLACK_ALLOWED_USERS=U01234ABCDE # Comma-separated Slack user IDs
### WhatsApp Setup
-WhatsApp doesn't have a simple bot API like Telegram or Discord. Hermes includes a built-in bridge using [Baileys](https://github.com/WhiskeySockets/Baileys) that connects via WhatsApp Web. The agent links to your WhatsApp account and responds to incoming messages.
+WhatsApp doesn't have a simple bot API like Telegram or Discord. Hermes includes a built-in bridge using [Baileys](https://github.com/WhiskeySockets/Baileys) that connects via WhatsApp Web.
-1. **Run the setup command:**
+**Two modes are supported:**
+
+| Mode | How it works | Best for |
+|------|-------------|----------|
+| **Separate bot number** (recommended) | Dedicate a phone number to the bot. People message that number directly. | Clean UX, multiple users |
+| **Personal self-chat** | Use your own WhatsApp. You message yourself to talk to the agent. | Quick setup, single user |
+
+**Setup:**
```bash
hermes whatsapp
```
-This will:
-- Enable WhatsApp in your config
-- Ask for your phone number (for the allowlist)
-- Install bridge dependencies (Node.js required)
-- Display a QR code — scan it with your phone (WhatsApp → Settings → Linked Devices → Link a Device)
-- Exit automatically once paired
+The wizard will:
+1. Ask which mode you want
+2. For **bot mode**: guide you through getting a second number (WhatsApp Business app on a dual-SIM, Google Voice, or cheap prepaid SIM)
+3. Configure the allowlist
+4. Install bridge dependencies (Node.js required)
+5. Display a QR code — scan from WhatsApp (or WhatsApp Business) → Settings → Linked Devices → Link a Device
+6. Exit once paired
-2. **Start the gateway:**
+**Start the gateway:**
```bash
hermes gateway # Foreground
@@ -295,7 +310,7 @@ hermes gateway install # Or install as a system service (Linux)
The gateway starts the WhatsApp bridge automatically using the saved session.
-> **Note:** WhatsApp Web sessions can disconnect if WhatsApp updates their protocol. The gateway reconnects automatically. If you see persistent failures, re-pair with `hermes whatsapp`. Agent responses are prefixed with "⚕ Hermes Agent" so you can distinguish them from your own messages in self-chat.
+> **Note:** WhatsApp Web sessions can disconnect if WhatsApp updates their protocol. The gateway reconnects automatically. If you see persistent failures, re-pair with `hermes whatsapp`. Agent responses are prefixed with "⚕ Hermes Agent" for easy identification.
See [docs/messaging.md](docs/messaging.md) for advanced WhatsApp configuration.
@@ -488,6 +503,23 @@ hermes tools
**Available toolsets:** `web`, `terminal`, `file`, `browser`, `vision`, `image_gen`, `moa`, `skills`, `tts`, `todo`, `memory`, `session_search`, `cronjob`, `code_execution`, `delegation`, `clarify`, and more.
+### 🔌 MCP (Model Context Protocol)
+
+Connect to any MCP-compatible server to extend Hermes with external tools. Just add servers to your config:
+
+```yaml
+mcp_servers:
+ time:
+ command: uvx
+ args: ["mcp-server-time"]
+ notion:
+ url: https://mcp.notion.com/mcp
+```
+
+Supports stdio and HTTP transports, auto-reconnection, and env var filtering. See [docs/mcp.md](docs/mcp.md) for details.
+
+Install MCP support: `pip install hermes-agent[mcp]`
+
### 🖥️ Terminal & Process Management
The terminal tool can execute commands in different environments, with full background process management via the `process` tool:
@@ -1212,8 +1244,8 @@ brew install git
brew install ripgrep node
```
-**Windows (WSL recommended):**
-Use the [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/install) and follow the Ubuntu instructions above. Alternatively, use the PowerShell quick-install script at the top of this README.
+**Windows (native):**
+Hermes runs natively on Windows using [Git for Windows](https://git-scm.com/download/win) (which provides Git Bash for shell commands). Install Git for Windows first, then use the PowerShell or CMD quick-install command at the top of this README. WSL also works — follow the Ubuntu instructions above.
@@ -1635,6 +1667,7 @@ All variables go in `~/.hermes/.env`. Run `hermes config set VAR value` to set t
| `SLACK_ALLOWED_USERS` | Comma-separated Slack user IDs |
| `SLACK_HOME_CHANNEL` | Default Slack channel for cron delivery |
| `WHATSAPP_ENABLED` | Enable WhatsApp bridge (`true`/`false`) |
+| `WHATSAPP_MODE` | `bot` (separate number, recommended) or `self-chat` (message yourself) |
| `WHATSAPP_ALLOWED_USERS` | Comma-separated phone numbers (with country code) |
| `MESSAGING_CWD` | Working directory for terminal in messaging (default: ~) |
| `GATEWAY_ALLOW_ALL_USERS` | Allow all users without allowlist (`true`/`false`, default: `false`) |
diff --git a/TODO.md b/TODO.md
index 01153c68..f6ec5e55 100644
--- a/TODO.md
+++ b/TODO.md
@@ -63,33 +63,27 @@ Full Python plugin interface that goes beyond the current hook system.
- `hermes plugin list|install|uninstall|create` CLI commands
- Plugin discovery and validation on startup
-### Phase 3: MCP support (industry standard)
-- MCP client that can connect to external MCP servers (stdio, SSE, HTTP)
-- This is the big one -- Codex, Cline, and OpenCode all support MCP
-- Allows Hermes to use any MCP-compatible tool server (hundreds exist)
-- Config: `mcp_servers` list in config.yaml with connection details
-- Each MCP server's tools get registered as a new toolset
+### Phase 3: MCP support (industry standard) ✅ DONE
+- ✅ MCP client that connects to external MCP servers (stdio + HTTP/StreamableHTTP)
+- ✅ Config: `mcp_servers` in config.yaml with connection details
+- ✅ Each MCP server's tools auto-registered as a dynamic toolset
+- Future: Resources, Prompts, Progress notifications, `hermes mcp` CLI command
---
-## 6. MCP (Model Context Protocol) Support 🔗
+## 6. MCP (Model Context Protocol) Support 🔗 ✅ DONE
-**Status:** Not started
-**Priority:** High -- this is becoming an industry standard
+**Status:** Implemented (PR #301)
+**Priority:** Complete
-MCP is the protocol that Codex, Cline, and OpenCode all support for connecting to external tool servers. Supporting MCP would instantly give Hermes access to hundreds of community tool servers.
+Native MCP client support with stdio and HTTP/StreamableHTTP transports, auto-discovery, reconnection with exponential backoff, env var filtering, and credential stripping. See `docs/mcp.md` for full documentation.
-**What other agents do:**
-- **Codex**: Full MCP integration with skill dependencies
-- **Cline**: `use_mcp_tool` / `access_mcp_resource` / `load_mcp_documentation` tools
-- **OpenCode**: MCP client support (stdio, SSE, StreamableHTTP transports), OAuth auth
-
-**Our approach:**
-- Implement an MCP client that can connect to external MCP servers
-- Config: list of MCP servers in `~/.hermes/config.yaml` with transport type and connection details
-- Each MCP server's tools auto-registered as a dynamic toolset
-- Start with stdio transport (most common), then add SSE and HTTP
-- Could also be part of the Plugin system (#5, Phase 3) since MCP is essentially a plugin protocol
+**Still TODO:**
+- `hermes mcp` CLI subcommand (list/test/status)
+- `hermes tools` UI integration for MCP toolsets
+- MCP Resources and Prompts support
+- OAuth authentication for remote servers
+- Progress notifications for long-running tools
---
@@ -121,7 +115,7 @@ Automatic filesystem snapshots after each agent loop iteration so the user can r
### Tier 1: Next Up
-1. MCP Support -- #6
+1. ~~MCP Support -- #6~~ ✅ Done (PR #301)
### Tier 2: Quality of Life
diff --git a/cli-config.yaml.example b/cli-config.yaml.example
index 9fcf11d5..170c142b 100644
--- a/cli-config.yaml.example
+++ b/cli-config.yaml.example
@@ -442,6 +442,41 @@ toolsets:
# toolsets:
# - safe
+# =============================================================================
+# MCP (Model Context Protocol) Servers
+# =============================================================================
+# Connect to external MCP servers to add tools from the MCP ecosystem.
+# Each server's tools are automatically discovered and registered.
+# See docs/mcp.md for full documentation.
+#
+# Stdio servers (spawn a subprocess):
+# command: the executable to run
+# args: command-line arguments
+# env: environment variables (only these + safe defaults passed to subprocess)
+#
+# HTTP servers (connect to a URL):
+# url: the MCP server endpoint
+# headers: HTTP headers (e.g., for authentication)
+#
+# Optional per-server settings:
+# timeout: tool call timeout in seconds (default: 120)
+# connect_timeout: initial connection timeout (default: 60)
+#
+# mcp_servers:
+# time:
+# command: uvx
+# args: ["mcp-server-time"]
+# filesystem:
+# command: npx
+# args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user"]
+# notion:
+# url: https://mcp.notion.com/mcp
+# github:
+# command: npx
+# args: ["-y", "@modelcontextprotocol/server-github"]
+# env:
+# GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_..."
+
# =============================================================================
# Voice Transcription (Speech-to-Text)
# =============================================================================
diff --git a/cli.py b/cli.py
index faa6586d..4079d89c 100755
--- a/cli.py
+++ b/cli.py
@@ -386,6 +386,11 @@ def _run_cleanup():
_cleanup_all_browsers()
except Exception:
pass
+ try:
+ from tools.mcp_tool import shutdown_mcp_servers
+ shutdown_mcp_servers()
+ except Exception:
+ pass
# ============================================================================
# ASCII Art & Branding
@@ -685,6 +690,7 @@ COMMANDS = {
"/cron": "Manage scheduled tasks (list, add, remove)",
"/skills": "Search, install, inspect, or manage skills from online registries",
"/platforms": "Show gateway/messaging platform status",
+ "/reload-mcp": "Reload MCP servers from config.yaml",
"/quit": "Exit the CLI (also: /exit, /q)",
}
@@ -847,7 +853,7 @@ class HermesCLI:
or os.getenv("OPENAI_BASE_URL")
or os.getenv("OPENROUTER_BASE_URL", CLI_CONFIG["model"]["base_url"])
)
- self.api_key = api_key or os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY")
+ self.api_key = api_key or os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY")
self._nous_key_expires_at: Optional[str] = None
self._nous_key_source: Optional[str] = None
# Max turns priority: CLI arg > config file > env var > default
@@ -916,6 +922,15 @@ class HermesCLI:
# History file for persistent input recall across sessions
self._history_file = Path.home() / ".hermes_history"
+ self._last_invalidate: float = 0.0 # throttle UI repaints
+
+ def _invalidate(self, min_interval: float = 0.25) -> None:
+ """Throttled UI repaint — prevents terminal blinking on slow/SSH connections."""
+ import time as _time
+ now = _time.monotonic()
+ if hasattr(self, "_app") and self._app and (now - self._last_invalidate) >= min_interval:
+ self._last_invalidate = now
+ self._app.invalidate()
def _ensure_runtime_credentials(self) -> bool:
"""
@@ -1756,6 +1771,8 @@ class HermesCLI:
self._manual_compress()
elif cmd_lower == "/usage":
self._show_usage()
+ elif cmd_lower == "/reload-mcp":
+ self._reload_mcp()
else:
# Check for skill slash commands (/gif-search, /axolotl, etc.)
base_cmd = cmd_lower.split()[0]
@@ -1877,6 +1894,91 @@ class HermesCLI:
for quiet_logger in ('tools', 'minisweagent', 'run_agent', 'trajectory_compressor', 'cron', 'hermes_cli'):
logging.getLogger(quiet_logger).setLevel(logging.ERROR)
+ def _reload_mcp(self):
+ """Reload MCP servers: disconnect all, re-read config.yaml, reconnect.
+
+ After reconnecting, refreshes the agent's tool list so the model
+ sees the updated tools on the next turn.
+ """
+ try:
+ from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _load_mcp_config, _servers, _lock
+
+ # Capture old server names
+ with _lock:
+ old_servers = set(_servers.keys())
+
+ print("🔄 Reloading MCP servers...")
+
+ # Shutdown existing connections
+ shutdown_mcp_servers()
+
+ # Reconnect (reads config.yaml fresh)
+ new_tools = discover_mcp_tools()
+
+ # Compute what changed
+ with _lock:
+ connected_servers = set(_servers.keys())
+
+ added = connected_servers - old_servers
+ removed = old_servers - connected_servers
+ reconnected = connected_servers & old_servers
+
+ if reconnected:
+ print(f" ♻️ Reconnected: {', '.join(sorted(reconnected))}")
+ if added:
+ print(f" ➕ Added: {', '.join(sorted(added))}")
+ if removed:
+ print(f" ➖ Removed: {', '.join(sorted(removed))}")
+ if not connected_servers:
+ print(" No MCP servers connected.")
+ else:
+ print(f" 🔧 {len(new_tools)} tool(s) available from {len(connected_servers)} server(s)")
+
+ # Refresh the agent's tool list so the model can call new tools
+ if self.agent is not None:
+ from model_tools import get_tool_definitions
+ self.agent.tools = get_tool_definitions(
+ enabled_toolsets=self.agent.enabled_toolsets
+ if hasattr(self.agent, "enabled_toolsets") else None,
+ quiet_mode=True,
+ )
+ self.agent.valid_tool_names = {
+ tool["function"]["name"] for tool in self.agent.tools
+ } if self.agent.tools else set()
+
+ # Inject a message at the END of conversation history so the
+ # model knows tools changed. Appended after all existing
+ # messages to preserve prompt-cache for the prefix.
+ change_parts = []
+ if added:
+ change_parts.append(f"Added servers: {', '.join(sorted(added))}")
+ if removed:
+ change_parts.append(f"Removed servers: {', '.join(sorted(removed))}")
+ if reconnected:
+ change_parts.append(f"Reconnected servers: {', '.join(sorted(reconnected))}")
+ tool_summary = f"{len(new_tools)} MCP tool(s) now available" if new_tools else "No MCP tools available"
+ change_detail = ". ".join(change_parts) + ". " if change_parts else ""
+ self.conversation_history.append({
+ "role": "user",
+ "content": f"[SYSTEM: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]",
+ })
+
+ # Persist session immediately so the session log reflects the
+ # updated tools list (self.agent.tools was refreshed above).
+ if self.agent is not None:
+ try:
+ self.agent._persist_session(
+ self.conversation_history,
+ self.conversation_history,
+ )
+ except Exception:
+ pass # Best-effort
+
+ print(f" ✅ Agent updated — {len(self.agent.tools if self.agent else [])} tool(s) available")
+
+ except Exception as e:
+ print(f" ❌ MCP reload failed: {e}")
+
def _clarify_callback(self, question, choices):
"""
Platform callback for the clarify tool. Called from the agent thread.
@@ -1903,8 +2005,7 @@ class HermesCLI:
self._clarify_freetext = is_open_ended
# Trigger prompt_toolkit repaint from this (non-main) thread
- if hasattr(self, '_app') and self._app:
- self._app.invalidate()
+ self._invalidate()
# Poll in 1-second ticks so the countdown refreshes in the UI.
# Each tick triggers an invalidate() to repaint the hint line.
@@ -1918,15 +2019,13 @@ class HermesCLI:
if remaining <= 0:
break
# Repaint so the countdown updates
- if hasattr(self, '_app') and self._app:
- self._app.invalidate()
+ self._invalidate()
# Timed out — tear down the UI and let the agent decide
self._clarify_state = None
self._clarify_freetext = False
self._clarify_deadline = 0
- if hasattr(self, '_app') and self._app:
- self._app.invalidate()
+ self._invalidate()
_cprint(f"\n{_DIM}(clarify timed out after {timeout}s — agent will decide){_RST}")
return (
"The user did not provide a response within the time limit. "
@@ -1951,16 +2050,14 @@ class HermesCLI:
}
self._sudo_deadline = _time.monotonic() + timeout
- if hasattr(self, '_app') and self._app:
- self._app.invalidate()
+ self._invalidate()
while True:
try:
result = response_queue.get(timeout=1)
self._sudo_state = None
self._sudo_deadline = 0
- if hasattr(self, '_app') and self._app:
- self._app.invalidate()
+ self._invalidate()
if result:
_cprint(f"\n{_DIM} ✓ Password received (cached for session){_RST}")
else:
@@ -1970,13 +2067,11 @@ class HermesCLI:
remaining = self._sudo_deadline - _time.monotonic()
if remaining <= 0:
break
- if hasattr(self, '_app') and self._app:
- self._app.invalidate()
+ self._invalidate()
self._sudo_state = None
self._sudo_deadline = 0
- if hasattr(self, '_app') and self._app:
- self._app.invalidate()
+ self._invalidate()
_cprint(f"\n{_DIM} ⏱ Timeout — continuing without sudo{_RST}")
return ""
@@ -2002,28 +2097,24 @@ class HermesCLI:
}
self._approval_deadline = _time.monotonic() + timeout
- if hasattr(self, '_app') and self._app:
- self._app.invalidate()
+ self._invalidate()
while True:
try:
result = response_queue.get(timeout=1)
self._approval_state = None
self._approval_deadline = 0
- if hasattr(self, '_app') and self._app:
- self._app.invalidate()
+ self._invalidate()
return result
except queue.Empty:
remaining = self._approval_deadline - _time.monotonic()
if remaining <= 0:
break
- if hasattr(self, '_app') and self._app:
- self._app.invalidate()
+ self._invalidate()
self._approval_state = None
self._approval_deadline = 0
- if hasattr(self, '_app') and self._app:
- self._app.invalidate()
+ self._invalidate()
_cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
return "deny"
diff --git a/docs/mcp.md b/docs/mcp.md
new file mode 100644
index 00000000..1017f61c
--- /dev/null
+++ b/docs/mcp.md
@@ -0,0 +1,527 @@
+# MCP (Model Context Protocol) Support
+
+MCP lets Hermes Agent connect to external tool servers — giving the agent access to databases, APIs, filesystems, and more without any code changes.
+
+## Overview
+
+The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) is an open standard for connecting AI agents to external tools and data sources. MCP servers expose tools over a lightweight RPC protocol, and Hermes Agent can connect to any compliant server automatically.
+
+What this means for you:
+
+- **Thousands of ready-made tools** — browse the [MCP server directory](https://github.com/modelcontextprotocol/servers) for servers covering GitHub, Slack, databases, file systems, web scraping, and more.
+- **No code changes needed** — add a few lines to `~/.hermes/config.yaml` and the tools appear alongside built-in ones.
+- **Mix and match** — run multiple MCP servers simultaneously, combining stdio-based and HTTP-based servers.
+- **Secure by default** — environment variables are filtered and credentials are stripped from error messages returned to the LLM.
+
+## Prerequisites
+
+Install MCP support as an optional dependency:
+
+```bash
+pip install hermes-agent[mcp]
+```
+
+Depending on which MCP servers you want to use, you may need additional runtimes:
+
+| Server Type | Runtime Needed | Example |
+|-------------|---------------|---------|
+| HTTP/remote | Nothing extra | `url: "https://mcp.example.com"` |
+| npm-based (npx) | Node.js 18+ | `command: "npx"` |
+| Python-based | uv (recommended) | `command: "uvx"` |
+
+Most popular MCP servers are distributed as npm packages and launched via `npx`. Python-based servers typically use `uvx` (from the [uv](https://docs.astral.sh/uv/) package manager).
+
+## Configuration
+
+MCP servers are configured in `~/.hermes/config.yaml` under the `mcp_servers` key. Each entry is a named server with its connection details.
+
+### Stdio Servers (command + args + env)
+
+Stdio servers run as local subprocesses. Communication happens over stdin/stdout.
+
+```yaml
+mcp_servers:
+ filesystem:
+ command: "npx"
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
+ env: {}
+
+ github:
+ command: "npx"
+ args: ["-y", "@modelcontextprotocol/server-github"]
+ env:
+ GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_xxxxxxxxxxxx"
+```
+
+| Key | Required | Description |
+|-----|----------|-------------|
+| `command` | Yes | Executable to run (e.g., `npx`, `uvx`, `python`) |
+| `args` | No | List of command-line arguments |
+| `env` | No | Environment variables to pass to the subprocess |
+
+**Note:** Only explicitly listed `env` variables plus a safe baseline (PATH, HOME, USER, LANG, SHELL, TMPDIR, XDG_*) are passed to the subprocess. Your shell's API keys, tokens, and secrets are **not** leaked. See [Security](#security) for details.
+
+### HTTP Servers (url + headers)
+
+HTTP servers run remotely and are accessed over HTTP/StreamableHTTP.
+
+```yaml
+mcp_servers:
+ remote_api:
+ url: "https://my-mcp-server.example.com/mcp"
+ headers:
+ Authorization: "Bearer sk-xxxxxxxxxxxx"
+```
+
+| Key | Required | Description |
+|-----|----------|-------------|
+| `url` | Yes | Full URL of the MCP HTTP endpoint |
+| `headers` | No | HTTP headers to include (e.g., auth tokens) |
+
+### Per-Server Timeouts
+
+Each server can have custom timeouts:
+
+```yaml
+mcp_servers:
+ slow_database:
+ command: "npx"
+ args: ["-y", "@modelcontextprotocol/server-postgres"]
+ env:
+ DATABASE_URL: "postgres://user:pass@localhost/mydb"
+ timeout: 300 # Tool call timeout in seconds (default: 120)
+ connect_timeout: 90 # Initial connection timeout in seconds (default: 60)
+```
+
+| Key | Default | Description |
+|-----|---------|-------------|
+| `timeout` | 120 | Maximum seconds to wait for a single tool call to complete |
+| `connect_timeout` | 60 | Maximum seconds to wait for the initial connection and tool discovery |
+
+### Mixed Configuration Example
+
+You can combine stdio and HTTP servers freely:
+
+```yaml
+mcp_servers:
+ # Local filesystem access via stdio
+ filesystem:
+ command: "npx"
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
+
+ # GitHub API via stdio with auth
+ github:
+ command: "npx"
+ args: ["-y", "@modelcontextprotocol/server-github"]
+ env:
+ GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_xxxxxxxxxxxx"
+
+ # Remote database via HTTP
+ company_db:
+ url: "https://mcp.internal.company.com/db"
+ headers:
+ Authorization: "Bearer sk-xxxxxxxxxxxx"
+ timeout: 180
+
+ # Python-based server via uvx
+ memory:
+ command: "uvx"
+ args: ["mcp-server-memory"]
+```
+
+## Config Translation (Claude/Cursor JSON → Hermes YAML)
+
+Many MCP server docs show configuration in Claude Desktop JSON format. Here's how to translate:
+
+**Claude Desktop JSON** (`claude_desktop_config.json`):
+
+```json
+{
+ "mcpServers": {
+ "filesystem": {
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
+ "env": {}
+ },
+ "github": {
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-github"],
+ "env": {
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx"
+ }
+ }
+ }
+}
+```
+
+**Hermes Agent YAML** (`~/.hermes/config.yaml`):
+
+```yaml
+mcp_servers: # mcpServers → mcp_servers (snake_case)
+ filesystem:
+ command: "npx"
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
+ env: {}
+ github:
+ command: "npx"
+ args: ["-y", "@modelcontextprotocol/server-github"]
+ env:
+ GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_xxxxxxxxxxxx"
+```
+
+Translation rules:
+
+1. **Key name**: `mcpServers` → `mcp_servers` (snake_case)
+2. **Format**: JSON → YAML (remove braces/brackets, use indentation)
+3. **Arrays**: `["a", "b"]` stays the same in YAML flow style, or use block style with `- a`
+4. **Everything else**: Keys (`command`, `args`, `env`) are identical
+
+## How It Works
+
+### Startup & Discovery
+
+When Hermes Agent starts, the tool discovery system calls `discover_mcp_tools()`:
+
+1. **Config loading** — Reads `mcp_servers` from `~/.hermes/config.yaml`
+2. **Background loop** — Spins up a dedicated asyncio event loop in a daemon thread for MCP connections
+3. **Connection** — Connects to each configured server (stdio subprocess or HTTP)
+4. **Session init** — Initializes the MCP client session (protocol handshake)
+5. **Tool discovery** — Calls `list_tools()` on each server to get available tools
+6. **Registration** — Registers each MCP tool into the Hermes tool registry with a prefixed name
+
+### Tool Registration
+
+Each discovered MCP tool is registered with a prefixed name following this pattern:
+
+```
+mcp_{server_name}_{tool_name}
+```
+
+Hyphens and dots in both server and tool names are replaced with underscores for API compatibility. For example:
+
+| Server Name | MCP Tool Name | Registered As |
+|-------------|--------------|---------------|
+| `filesystem` | `read_file` | `mcp_filesystem_read_file` |
+| `github` | `create-issue` | `mcp_github_create_issue` |
+| `my-api` | `query.data` | `mcp_my_api_query_data` |
+
+Tools appear alongside built-in tools — the agent sees them in its tool list and can call them like any other tool.
+
+### Tool Calling
+
+When the agent calls an MCP tool:
+
+1. The handler is invoked by the tool registry (sync interface)
+2. The handler schedules the actual MCP `call_tool()` RPC on the background event loop
+3. The call blocks (with timeout) until the MCP server responds
+4. Response content blocks are collected and returned as JSON
+5. Errors are sanitized to strip credentials before returning to the LLM
+
+### Shutdown
+
+On agent exit, `shutdown_mcp_servers()` is called:
+
+1. All server tasks are signalled to exit via their shutdown events
+2. Each server's `async with` context manager exits, cleaning up transports
+3. The background event loop is stopped and its thread is joined
+4. All server state is cleared
+
+## Security
+
+### Environment Variable Filtering
+
+When launching stdio MCP servers, Hermes does **not** pass your full shell environment to the subprocess. The `_build_safe_env()` function constructs a minimal environment:
+
+**Always passed through** (from your current environment):
+- `PATH`, `HOME`, `USER`, `LANG`, `LC_ALL`, `TERM`, `SHELL`, `TMPDIR`
+- Any variable starting with `XDG_`
+
+**Explicitly added**: Any variables you list in the server's `env` config.
+
+**Everything else is excluded** — your `OPENAI_API_KEY`, `AWS_SECRET_ACCESS_KEY`, database passwords, and other secrets are never leaked to MCP server subprocesses unless you explicitly add them.
+
+```yaml
+mcp_servers:
+ github:
+ command: "npx"
+ args: ["-y", "@modelcontextprotocol/server-github"]
+ env:
+ # Only this token is passed — nothing else from your shell
+ GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_xxxxxxxxxxxx"
+```
+
+### Credential Stripping in Errors
+
+If an MCP tool call fails, the error message is sanitized by `_sanitize_error()` before being returned to the LLM. The following patterns are replaced with `[REDACTED]`:
+
+- GitHub PATs (`ghp_...`)
+- OpenAI-style keys (`sk-...`)
+- Bearer tokens (`Bearer ...`)
+- Query parameters (`token=...`, `key=...`, `API_KEY=...`, `password=...`, `secret=...`)
+
+This prevents accidental credential exposure through error messages in the conversation.
+
+## Transport Types
+
+### Stdio Transport
+
+The default transport for locally-installed MCP servers. The server runs as a subprocess and communicates over stdin/stdout.
+
+```yaml
+mcp_servers:
+ my_server:
+ command: "npx" # or "uvx", "python", any executable
+ args: ["-y", "package"]
+ env:
+ MY_VAR: "value"
+```
+
+**Pros:** Simple setup, no network needed, works offline.
+**Cons:** Server must be installed locally, one process per server.
+
+### HTTP / StreamableHTTP Transport
+
+For remote MCP servers accessible over HTTP. Uses the StreamableHTTP protocol from the MCP SDK.
+
+```yaml
+mcp_servers:
+ my_remote:
+ url: "https://mcp.example.com/endpoint"
+ headers:
+ Authorization: "Bearer token"
+```
+
+**Pros:** No local installation needed, shared servers, cloud-hosted.
+**Cons:** Requires network, slightly higher latency, needs `mcp` package with HTTP support.
+
+**Note:** If HTTP transport is not available in your installed `mcp` package version, Hermes will log a clear error and skip that server.
+
+## Reconnection
+
+If an MCP server connection drops after initial setup (e.g., process crash, network hiccup), Hermes automatically attempts to reconnect with exponential backoff:
+
+| Attempt | Delay Before Retry |
+|---------|--------------------|
+| 1 | 1 second |
+| 2 | 2 seconds |
+| 3 | 4 seconds |
+| 4 | 8 seconds |
+| 5 | 16 seconds |
+
+- Maximum of **5 retry attempts** before giving up
+- Backoff is capped at **60 seconds** (relevant if the formula exceeds this)
+- Reconnection only triggers for **established connections** that drop — initial connection failures are reported immediately without retries
+- If shutdown is requested during reconnection, the retry loop exits cleanly
+
+## Troubleshooting
+
+### Common Errors
+
+**"mcp package not installed"**
+
+```
+MCP SDK not available -- skipping MCP tool discovery
+```
+
+Solution: Install the MCP optional dependency:
+
+```bash
+pip install hermes-agent[mcp]
+```
+
+---
+
+**"command not found" or server fails to start**
+
+The MCP server command (`npx`, `uvx`, etc.) is not on PATH.
+
+Solution: Install the required runtime:
+
+```bash
+# For npm-based servers
+npm install -g npx # or ensure Node.js 18+ is installed
+
+# For Python-based servers
+pip install uv # then use "uvx" as the command
+```
+
+---
+
+**"MCP server 'X' has no 'command' in config"**
+
+Your stdio server config is missing the `command` key.
+
+Solution: Check your `~/.hermes/config.yaml` indentation and ensure `command` is present:
+
+```yaml
+mcp_servers:
+ my_server:
+ command: "npx" # <-- required for stdio servers
+ args: ["-y", "package-name"]
+```
+
+---
+
+**Server connects but tools fail with authentication errors**
+
+Your API key or token is missing or invalid.
+
+Solution: Ensure the key is in the server's `env` block (not your shell env):
+
+```yaml
+mcp_servers:
+ github:
+ command: "npx"
+ args: ["-y", "@modelcontextprotocol/server-github"]
+ env:
+ GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_your_actual_token" # <-- check this
+```
+
+---
+
+**"MCP server 'X' is not connected"**
+
+The server disconnected and reconnection failed (or was never established).
+
+Solution:
+1. Check the Hermes logs for connection errors (`hermes --verbose`)
+2. Verify the server works standalone (e.g., run the `npx` command manually)
+3. Increase `connect_timeout` if the server is slow to start
+
+---
+
+**Connection timeout during discovery**
+
+```
+Failed to connect to MCP server 'X': TimeoutError
+```
+
+Solution: Increase the `connect_timeout` for slow-starting servers:
+
+```yaml
+mcp_servers:
+ slow_server:
+ command: "npx"
+ args: ["-y", "heavy-server-package"]
+ connect_timeout: 120 # default is 60
+```
+
+---
+
+**HTTP transport not available**
+
+```
+mcp.client.streamable_http is not available
+```
+
+Solution: Upgrade the `mcp` package to a version that includes HTTP support:
+
+```bash
+pip install --upgrade mcp
+```
+
+## Popular MCP Servers
+
+Here are some popular free MCP servers you can use immediately:
+
+| Server | Package | Description |
+|--------|---------|-------------|
+| Filesystem | `@modelcontextprotocol/server-filesystem` | Read/write/search local files |
+| GitHub | `@modelcontextprotocol/server-github` | Issues, PRs, repos, code search |
+| Git | `@modelcontextprotocol/server-git` | Git operations on local repos |
+| Fetch | `@modelcontextprotocol/server-fetch` | HTTP fetching and web content extraction |
+| Memory | `@modelcontextprotocol/server-memory` | Persistent key-value memory |
+| SQLite | `@modelcontextprotocol/server-sqlite` | Query SQLite databases |
+| PostgreSQL | `@modelcontextprotocol/server-postgres` | Query PostgreSQL databases |
+| Brave Search | `@modelcontextprotocol/server-brave-search` | Web search via Brave API |
+| Puppeteer | `@modelcontextprotocol/server-puppeteer` | Browser automation |
+| Sequential Thinking | `@modelcontextprotocol/server-sequential-thinking` | Step-by-step reasoning |
+
+### Example Configs for Popular Servers
+
+```yaml
+mcp_servers:
+ # Filesystem — no API key needed
+ filesystem:
+ command: "npx"
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
+
+ # Git — no API key needed
+ git:
+ command: "uvx"
+ args: ["mcp-server-git", "--repository", "/home/user/my-repo"]
+
+ # GitHub — requires a personal access token
+ github:
+ command: "npx"
+ args: ["-y", "@modelcontextprotocol/server-github"]
+ env:
+ GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_xxxxxxxxxxxx"
+
+ # Fetch — no API key needed
+ fetch:
+ command: "uvx"
+ args: ["mcp-server-fetch"]
+
+ # SQLite — no API key needed
+ sqlite:
+ command: "uvx"
+ args: ["mcp-server-sqlite", "--db-path", "/home/user/data.db"]
+
+ # Brave Search — requires API key (free tier available)
+ brave_search:
+ command: "npx"
+ args: ["-y", "@modelcontextprotocol/server-brave-search"]
+ env:
+ BRAVE_API_KEY: "BSA_xxxxxxxxxxxx"
+```
+
+## Advanced
+
+### Multiple Servers
+
+You can run as many MCP servers as you want simultaneously. Each server gets its own subprocess (stdio) or HTTP connection, and all tools are registered into a single unified namespace.
+
+Servers are connected sequentially during startup. If one server fails to connect, the others still work — failed servers are logged as warnings and skipped.
+
+### Tool Naming Convention
+
+All MCP tools follow the naming pattern:
+
+```
+mcp_{server_name}_{tool_name}
+```
+
+Both the server name and tool name are sanitized: hyphens (`-`) and dots (`.`) are replaced with underscores (`_`). This ensures compatibility with LLM function-calling APIs that restrict tool name characters.
+
+If you configure a server named `my-api` that exposes a tool called `query.users`, the agent will see it as `mcp_my_api_query_users`.
+
+### Configurable Timeouts
+
+Fine-tune timeouts per server based on expected response times:
+
+```yaml
+mcp_servers:
+ fast_cache:
+ command: "npx"
+ args: ["-y", "mcp-server-redis"]
+ timeout: 30 # Fast lookups — short timeout
+ connect_timeout: 15
+
+ slow_analysis:
+ url: "https://analysis.example.com/mcp"
+ timeout: 600 # Long-running analysis — generous timeout
+ connect_timeout: 120
+```
+
+### Idempotent Discovery
+
+`discover_mcp_tools()` is idempotent — calling it multiple times only connects to servers that aren't already running. Already-connected servers keep their existing connections and tool registrations.
+
+### Custom Toolsets
+
+Each MCP server's tools are automatically grouped into a toolset named `mcp-{server_name}`. These toolsets are also injected into all `hermes-*` platform toolsets, so MCP tools are available in CLI, Telegram, Discord, and other platforms.
+
+### Thread Safety
+
+The MCP subsystem is fully thread-safe. A dedicated background event loop runs in a daemon thread, and all server state is protected by a lock. This works correctly even with Python 3.13+ free-threading builds.
diff --git a/docs/messaging.md b/docs/messaging.md
index e695308b..afcebc47 100644
--- a/docs/messaging.md
+++ b/docs/messaging.md
@@ -141,7 +141,12 @@ pip install discord.py>=2.0
### WhatsApp
-WhatsApp uses a built-in bridge powered by [Baileys](https://github.com/WhiskeySockets/Baileys) that connects via WhatsApp Web. The agent links to your WhatsApp account and responds to incoming messages.
+WhatsApp uses a built-in bridge powered by [Baileys](https://github.com/WhiskeySockets/Baileys) that connects via WhatsApp Web.
+
+**Two modes:**
+
+- **`bot` mode (recommended):** Use a dedicated phone number for the bot. Other people message that number directly. All `fromMe` messages are treated as bot echo-backs and ignored.
+- **`self-chat` mode:** Use your own WhatsApp account. You talk to the agent by messaging yourself (WhatsApp → "Message Yourself").
**Setup:**
@@ -149,12 +154,7 @@ WhatsApp uses a built-in bridge powered by [Baileys](https://github.com/WhiskeyS
hermes whatsapp
```
-This will:
-- Enable WhatsApp in your `.env`
-- Ask for your phone number (for the allowlist)
-- Install bridge dependencies (Node.js required)
-- Display a QR code — scan it with your phone (WhatsApp → Settings → Linked Devices → Link a Device)
-- Exit automatically once paired
+The wizard walks you through mode selection, allowlist configuration, dependency installation, and QR code pairing. For bot mode, you'll need a second phone number with WhatsApp installed on some device (dual-SIM with WhatsApp Business app is the easiest approach).
Then start the gateway:
@@ -162,16 +162,23 @@ Then start the gateway:
hermes gateway
```
-The gateway starts the WhatsApp bridge automatically using the saved session credentials in `~/.hermes/whatsapp/session/`.
-
**Environment variables:**
```bash
WHATSAPP_ENABLED=true
-WHATSAPP_ALLOWED_USERS=15551234567 # Comma-separated phone numbers with country code
+WHATSAPP_MODE=bot # "bot" (separate number) or "self-chat" (message yourself)
+WHATSAPP_ALLOWED_USERS=15551234567 # Comma-separated phone numbers with country code
```
-Agent responses are prefixed with "⚕ **Hermes Agent**" so you can distinguish them from your own messages when messaging yourself.
+**Getting a second number for bot mode:**
+
+| Option | Cost | Notes |
+|--------|------|-------|
+| WhatsApp Business app + dual-SIM | Free (if you have dual-SIM) | Install alongside personal WhatsApp, no second phone needed |
+| Google Voice | Free (US only) | voice.google.com, verify WhatsApp via the Google Voice app |
+| Prepaid SIM | $3-10/month | Any carrier; verify once, phone can go in a drawer on WiFi |
+
+Agent responses are prefixed with "⚕ **Hermes Agent**" for easy identification.
> **Re-pairing:** If WhatsApp Web sessions disconnect (protocol updates, phone reset), re-pair with `hermes whatsapp`.
diff --git a/docs/tools.md b/docs/tools.md
index d0cad2cd..0b96550b 100644
--- a/docs/tools.md
+++ b/docs/tools.md
@@ -55,6 +55,7 @@ async def web_search(query: str) -> dict:
| **Clarify** | `clarify_tool.py` | `clarify` (interactive multiple-choice / open-ended questions, CLI-only) |
| **Code Execution** | `code_execution_tool.py` | `execute_code` (run Python scripts that call tools via RPC sandbox) |
| **Delegation** | `delegate_tool.py` | `delegate_task` (spawn subagents with isolated context, single + parallel batch) |
+| **MCP (External)** | `tools/mcp_tool.py` | Auto-discovered from configured MCP servers |
## Tool Registration
@@ -414,3 +415,20 @@ The Skills Hub enables searching, installing, and managing skills from online re
**CLI:** `hermes skills search|install|inspect|list|audit|uninstall|publish|snapshot|tap`
**Slash:** `/skills search|install|inspect|list|audit|uninstall|publish|snapshot|tap`
+
+## MCP Tools
+
+MCP (Model Context Protocol) tools are **dynamically registered** from external MCP servers configured in `cli-config.yaml`. Unlike built-in tools which are defined in Python source files, MCP tools are discovered at startup by connecting to each configured server and querying its available tools.
+
+Each MCP tool is automatically wrapped with an OpenAI-compatible schema and registered in the tool registry under the `mcp` toolset. Tool names are prefixed with the server name (e.g., `time__get_current_time`) to avoid collisions.
+
+**Key characteristics:**
+- Tools are discovered and registered at agent startup — no code changes needed
+- Supports both stdio (subprocess) and HTTP (streamable HTTP) transports
+- Auto-reconnects on connection failures with exponential backoff
+- Environment variables passed to stdio servers are filtered for security
+- Each server can have independent timeout settings
+
+**Configuration:** Add servers to `mcp_servers` in `cli-config.yaml`. See [docs/mcp.md](mcp.md) for full documentation.
+
+**Installation:** MCP support requires the optional `mcp` extra: `pip install hermes-agent[mcp]`
diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py
index 076e97ff..1e4d2ab8 100644
--- a/gateway/platforms/telegram.py
+++ b/gateway/platforms/telegram.py
@@ -29,7 +29,17 @@ except ImportError:
Bot = Any
Message = Any
Application = Any
- ContextTypes = Any
+ CommandHandler = Any
+ TelegramMessageHandler = Any
+ filters = None
+ ParseMode = None
+ ChatType = None
+
+ # Mock ContextTypes so type annotations using ContextTypes.DEFAULT_TYPE
+ # don't crash during class definition when the library isn't installed.
+ class _MockContextTypes:
+ DEFAULT_TYPE = Any
+ ContextTypes = _MockContextTypes
import sys
from pathlib import Path as _Path
diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py
index eb0d6f1b..7ffa5743 100644
--- a/gateway/platforms/whatsapp.py
+++ b/gateway/platforms/whatsapp.py
@@ -19,7 +19,10 @@ import asyncio
import json
import logging
import os
+import platform
import subprocess
+
+_IS_WINDOWS = platform.system() == "Windows"
from pathlib import Path
from typing import Dict, List, Optional, Any
@@ -157,16 +160,18 @@ class WhatsAppAdapter(BasePlatformAdapter):
pass
# Start the bridge process in its own process group
+ whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat")
self._bridge_process = subprocess.Popen(
[
"node",
str(bridge_path),
"--port", str(self._bridge_port),
"--session", str(self._session_path),
+ "--mode", whatsapp_mode,
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
- preexec_fn=os.setsid,
+ preexec_fn=None if _IS_WINDOWS else os.setsid,
)
# Wait for bridge to be ready via HTTP health check
@@ -211,13 +216,19 @@ class WhatsAppAdapter(BasePlatformAdapter):
# Kill the entire process group so child node processes die too
import signal
try:
- os.killpg(os.getpgid(self._bridge_process.pid), signal.SIGTERM)
+ if _IS_WINDOWS:
+ self._bridge_process.terminate()
+ else:
+ os.killpg(os.getpgid(self._bridge_process.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
self._bridge_process.terminate()
await asyncio.sleep(1)
if self._bridge_process.poll() is None:
try:
- os.killpg(os.getpgid(self._bridge_process.pid), signal.SIGKILL)
+ if _IS_WINDOWS:
+ self._bridge_process.kill()
+ else:
+ os.killpg(os.getpgid(self._bridge_process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
self._bridge_process.kill()
except Exception as e:
diff --git a/gateway/run.py b/gateway/run.py
index 8db99487..7471bc55 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -645,7 +645,7 @@ class GatewayRunner:
# Emit command:* hook for any recognized slash command
_known_commands = {"new", "reset", "help", "status", "stop", "model",
"personality", "retry", "undo", "sethome", "set-home",
- "compress", "usage"}
+ "compress", "usage", "reload-mcp"}
if command and command in _known_commands:
await self.hooks.emit(f"command:{command}", {
"platform": source.platform.value if source.platform else "",
@@ -686,6 +686,9 @@ class GatewayRunner:
if command == "usage":
return await self._handle_usage_command(event)
+
+ if command == "reload-mcp":
+ return await self._handle_reload_mcp_command(event)
# Skill slash commands: /skill-name loads the skill and sends to agent
if command:
@@ -1086,6 +1089,7 @@ class GatewayRunner:
"`/sethome` — Set this chat as the home channel",
"`/compress` — Compress conversation context",
"`/usage` — Show token usage for this session",
+ "`/reload-mcp` — Reload MCP servers from config",
"`/help` — Show this message",
]
try:
@@ -1379,6 +1383,76 @@ class GatewayRunner:
)
return "No usage data available for this session."
+ async def _handle_reload_mcp_command(self, event: MessageEvent) -> str:
+ """Handle /reload-mcp command -- disconnect and reconnect all MCP servers."""
+ loop = asyncio.get_event_loop()
+ try:
+ from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _load_mcp_config, _servers, _lock
+
+ # Capture old server names before shutdown
+ with _lock:
+ old_servers = set(_servers.keys())
+
+ # Read new config before shutting down, so we know what will be added/removed
+ new_config = _load_mcp_config()
+ new_server_names = set(new_config.keys())
+
+ # Shutdown existing connections
+ await loop.run_in_executor(None, shutdown_mcp_servers)
+
+ # Reconnect by discovering tools (reads config.yaml fresh)
+ new_tools = await loop.run_in_executor(None, discover_mcp_tools)
+
+ # Compute what changed
+ with _lock:
+ connected_servers = set(_servers.keys())
+
+ added = connected_servers - old_servers
+ removed = old_servers - connected_servers
+ reconnected = connected_servers & old_servers
+
+ lines = ["🔄 **MCP Servers Reloaded**\n"]
+ if reconnected:
+ lines.append(f"♻️ Reconnected: {', '.join(sorted(reconnected))}")
+ if added:
+ lines.append(f"➕ Added: {', '.join(sorted(added))}")
+ if removed:
+ lines.append(f"➖ Removed: {', '.join(sorted(removed))}")
+ if not connected_servers:
+ lines.append("No MCP servers connected.")
+ else:
+ lines.append(f"\n🔧 {len(new_tools)} tool(s) available from {len(connected_servers)} server(s)")
+
+ # Inject a message at the END of the session history so the
+ # model knows tools changed on its next turn. Appended after
+ # all existing messages to preserve prompt-cache for the prefix.
+ change_parts = []
+ if added:
+ change_parts.append(f"Added servers: {', '.join(sorted(added))}")
+ if removed:
+ change_parts.append(f"Removed servers: {', '.join(sorted(removed))}")
+ if reconnected:
+ change_parts.append(f"Reconnected servers: {', '.join(sorted(reconnected))}")
+ tool_summary = f"{len(new_tools)} MCP tool(s) now available" if new_tools else "No MCP tools available"
+ change_detail = ". ".join(change_parts) + ". " if change_parts else ""
+ reload_msg = {
+ "role": "user",
+ "content": f"[SYSTEM: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]",
+ }
+ try:
+ session_entry = self.session_store.get_or_create_session(event.source)
+ self.session_store.append_to_transcript(
+ session_entry.session_id, reload_msg
+ )
+ except Exception:
+ pass # Best-effort; don't fail the reload over a transcript write
+
+ return "\n".join(lines)
+
+ except Exception as e:
+ logger.warning("MCP reload failed: %s", e)
+ return f"❌ MCP reload failed: {e}"
+
def _set_session_env(self, context: SessionContext) -> None:
"""Set environment variables for the current session."""
os.environ["HERMES_SESSION_PLATFORM"] = context.source.platform.value
@@ -2217,7 +2291,14 @@ async def start_gateway(config: Optional[GatewayConfig] = None) -> bool:
# Stop cron ticker cleanly
cron_stop.set()
cron_thread.join(timeout=5)
-
+
+ # Close MCP server connections
+ try:
+ from tools.mcp_tool import shutdown_mcp_servers
+ shutdown_mcp_servers()
+ except Exception:
+ pass
+
return True
diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py
index 974dfaa1..be1b3a95 100644
--- a/hermes_cli/banner.py
+++ b/hermes_cli/banner.py
@@ -196,6 +196,28 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
if remaining_toolsets > 0:
right_lines.append(f"[dim #B8860B](and {remaining_toolsets} more toolsets...)[/]")
+ # MCP Servers section (only if configured)
+ try:
+ from tools.mcp_tool import get_mcp_status
+ mcp_status = get_mcp_status()
+ except Exception:
+ mcp_status = []
+
+ if mcp_status:
+ right_lines.append("")
+ right_lines.append("[bold #FFBF00]MCP Servers[/]")
+ for srv in mcp_status:
+ if srv["connected"]:
+ right_lines.append(
+ f"[dim #B8860B]{srv['name']}[/] [#FFF8DC]({srv['transport']})[/] "
+ f"[dim #B8860B]—[/] [#FFF8DC]{srv['tools']} tool(s)[/]"
+ )
+ else:
+ right_lines.append(
+ f"[red]{srv['name']}[/] [dim]({srv['transport']})[/] "
+ f"[red]— failed[/]"
+ )
+
right_lines.append("")
right_lines.append("[bold #FFBF00]Available Skills[/]")
skills_by_category = get_available_skills()
@@ -216,7 +238,12 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
right_lines.append("[dim #B8860B]No skills installed[/]")
right_lines.append("")
- right_lines.append(f"[dim #B8860B]{len(tools)} tools · {total_skills} skills · /help for commands[/]")
+ mcp_connected = sum(1 for s in mcp_status if s["connected"]) if mcp_status else 0
+ summary_parts = [f"{len(tools)} tools", f"{total_skills} skills"]
+ if mcp_connected:
+ summary_parts.append(f"{mcp_connected} MCP servers")
+ summary_parts.append("/help for commands")
+ right_lines.append(f"[dim #B8860B]{' · '.join(summary_parts)}[/]")
right_content = "\n".join(right_lines)
layout_table.add_row(left_content, right_content)
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 583cb9cf..cb62db9d 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -13,11 +13,14 @@ This module provides:
"""
import os
+import platform
import sys
import subprocess
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
+_IS_WINDOWS = platform.system() == "Windows"
+
import yaml
from hermes_cli.colors import Colors, color
@@ -618,7 +621,10 @@ def load_env() -> Dict[str, str]:
env_vars = {}
if env_path.exists():
- with open(env_path) as f:
+ # On Windows, open() defaults to the system locale (cp1252) which can
+ # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.
+ open_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
+ with open(env_path, **open_kw) as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
@@ -633,10 +639,14 @@ def save_env_value(key: str, value: str):
ensure_hermes_home()
env_path = get_env_path()
- # Load existing
+ # On Windows, open() defaults to the system locale (cp1252) which can
+ # cause OSError errno 22 on UTF-8 .env files.
+ read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
+ write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {}
+
lines = []
if env_path.exists():
- with open(env_path) as f:
+ with open(env_path, **read_kw) as f:
lines = f.readlines()
# Find and update or append
@@ -653,7 +663,7 @@ def save_env_value(key: str, value: str):
lines[-1] += "\n"
lines.append(f"{key}={value}\n")
- with open(env_path, 'w') as f:
+ with open(env_path, 'w', **write_kw) as f:
f.writelines(lines)
diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py
index 30bd8565..525950e9 100644
--- a/hermes_cli/gateway.py
+++ b/hermes_cli/gateway.py
@@ -21,39 +21,59 @@ PROJECT_ROOT = Path(__file__).parent.parent.resolve()
def find_gateway_pids() -> list:
"""Find PIDs of running gateway processes."""
pids = []
+ patterns = [
+ "hermes_cli.main gateway",
+ "hermes gateway",
+ "gateway/run.py",
+ ]
+
try:
- # Look for gateway processes with multiple patterns
- patterns = [
- "hermes_cli.main gateway",
- "hermes gateway",
- "gateway/run.py",
- ]
-
- result = subprocess.run(
- ["ps", "aux"],
- capture_output=True,
- text=True
- )
-
- for line in result.stdout.split('\n'):
- # Skip grep and current process
- if 'grep' in line or str(os.getpid()) in line:
- continue
-
- for pattern in patterns:
- if pattern in line:
- parts = line.split()
- if len(parts) > 1:
+ if is_windows():
+ # Windows: use wmic to search command lines
+ result = subprocess.run(
+ ["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"],
+ capture_output=True, text=True
+ )
+ # Parse WMIC LIST output: blocks of "CommandLine=...\nProcessId=...\n"
+ current_cmd = ""
+ for line in result.stdout.split('\n'):
+ line = line.strip()
+ if line.startswith("CommandLine="):
+ current_cmd = line[len("CommandLine="):]
+ elif line.startswith("ProcessId="):
+ pid_str = line[len("ProcessId="):]
+ if any(p in current_cmd for p in patterns):
try:
- pid = int(parts[1])
- if pid not in pids:
+ pid = int(pid_str)
+ if pid != os.getpid() and pid not in pids:
pids.append(pid)
except ValueError:
- continue
- break
+ pass
+ current_cmd = ""
+ else:
+ result = subprocess.run(
+ ["ps", "aux"],
+ capture_output=True,
+ text=True
+ )
+ for line in result.stdout.split('\n'):
+ # Skip grep and current process
+ if 'grep' in line or str(os.getpid()) in line:
+ continue
+ for pattern in patterns:
+ if pattern in line:
+ parts = line.split()
+ if len(parts) > 1:
+ try:
+ pid = int(parts[1])
+ if pid not in pids:
+ pids.append(pid)
+ except ValueError:
+ continue
+ break
except Exception:
pass
-
+
return pids
@@ -64,7 +84,7 @@ def kill_gateway_processes(force: bool = False) -> int:
for pid in pids:
try:
- if force:
+ if force and not is_windows():
os.kill(pid, signal.SIGKILL)
else:
os.kill(pid, signal.SIGTERM)
@@ -102,7 +122,10 @@ def get_launchd_plist_path() -> Path:
return Path.home() / "Library" / "LaunchAgents" / "ai.hermes.gateway.plist"
def get_python_path() -> str:
- venv_python = PROJECT_ROOT / "venv" / "bin" / "python"
+ if is_windows():
+ venv_python = PROJECT_ROOT / "venv" / "Scripts" / "python.exe"
+ else:
+ venv_python = PROJECT_ROOT / "venv" / "bin" / "python"
if venv_python.exists():
return str(venv_python)
return sys.executable
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 10745093..f13fce5b 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -168,7 +168,7 @@ def cmd_gateway(args):
def cmd_whatsapp(args):
- """Set up WhatsApp: enable, configure allowed users, install bridge, pair via QR."""
+ """Set up WhatsApp: choose mode, configure, install bridge, pair via QR."""
import os
import subprocess
from pathlib import Path
@@ -177,12 +177,55 @@ def cmd_whatsapp(args):
print()
print("⚕ WhatsApp Setup")
print("=" * 50)
- print()
- print("This will link your WhatsApp account to Hermes Agent.")
- print("The agent will respond to messages sent to your WhatsApp number.")
- print()
- # Step 1: Enable WhatsApp
+ # ── Step 1: Choose mode ──────────────────────────────────────────────
+ current_mode = get_env_value("WHATSAPP_MODE") or ""
+ if not current_mode:
+ print()
+ print("How will you use WhatsApp with Hermes?")
+ print()
+ print(" 1. Separate bot number (recommended)")
+ print(" People message the bot's number directly — cleanest experience.")
+ print(" Requires a second phone number with WhatsApp installed on a device.")
+ print()
+ print(" 2. Personal number (self-chat)")
+ print(" You message yourself to talk to the agent.")
+ print(" Quick to set up, but the UX is less intuitive.")
+ print()
+ try:
+ choice = input(" Choose [1/2]: ").strip()
+ except (EOFError, KeyboardInterrupt):
+ print("\nSetup cancelled.")
+ return
+
+ if choice == "1":
+ save_env_value("WHATSAPP_MODE", "bot")
+ wa_mode = "bot"
+ print(" ✓ Mode: separate bot number")
+ print()
+ print(" ┌─────────────────────────────────────────────────┐")
+ print(" │ Getting a second number for the bot: │")
+ print(" │ │")
+ print(" │ Easiest: Install WhatsApp Business (free app) │")
+ print(" │ on your phone with a second number: │")
+ print(" │ • Dual-SIM: use your 2nd SIM slot │")
+ print(" │ • Google Voice: free US number (voice.google) │")
+ print(" │ • Prepaid SIM: $3-10, verify once │")
+ print(" │ │")
+ print(" │ WhatsApp Business runs alongside your personal │")
+ print(" │ WhatsApp — no second phone needed. │")
+ print(" └─────────────────────────────────────────────────┘")
+ else:
+ save_env_value("WHATSAPP_MODE", "self-chat")
+ wa_mode = "self-chat"
+ print(" ✓ Mode: personal number (self-chat)")
+ else:
+ wa_mode = current_mode
+ mode_label = "separate bot number" if wa_mode == "bot" else "personal number (self-chat)"
+ print(f"\n✓ Mode: {mode_label}")
+
+ # ── Step 2: Enable WhatsApp ──────────────────────────────────────────
+ print()
current = get_env_value("WHATSAPP_ENABLED")
if current and current.lower() == "true":
print("✓ WhatsApp is already enabled")
@@ -190,26 +233,36 @@ def cmd_whatsapp(args):
save_env_value("WHATSAPP_ENABLED", "true")
print("✓ WhatsApp enabled")
- # Step 2: Allowed users
+ # ── Step 3: Allowed users ────────────────────────────────────────────
current_users = get_env_value("WHATSAPP_ALLOWED_USERS") or ""
if current_users:
print(f"✓ Allowed users: {current_users}")
- response = input("\n Update allowed users? [y/N] ").strip()
+ try:
+ response = input("\n Update allowed users? [y/N] ").strip()
+ except (EOFError, KeyboardInterrupt):
+ response = "n"
if response.lower() in ("y", "yes"):
- phone = input(" Phone number(s) (e.g. 15551234567, comma-separated): ").strip()
+ if wa_mode == "bot":
+ phone = input(" Phone numbers that can message the bot (comma-separated): ").strip()
+ else:
+ phone = input(" Your phone number (e.g. 15551234567): ").strip()
if phone:
save_env_value("WHATSAPP_ALLOWED_USERS", phone.replace(" ", ""))
print(f" ✓ Updated to: {phone}")
else:
print()
- phone = input(" Your phone number (e.g. 15551234567): ").strip()
+ if wa_mode == "bot":
+ print(" Who should be allowed to message the bot?")
+ phone = input(" Phone numbers (comma-separated, or * for anyone): ").strip()
+ else:
+ phone = input(" Your phone number (e.g. 15551234567): ").strip()
if phone:
save_env_value("WHATSAPP_ALLOWED_USERS", phone.replace(" ", ""))
print(f" ✓ Allowed users set: {phone}")
else:
print(" ⚠ No allowlist — the agent will respond to ALL incoming messages")
- # Step 3: Install bridge deps
+ # ── Step 4: Install bridge dependencies ──────────────────────────────
project_root = Path(__file__).resolve().parents[1]
bridge_dir = project_root / "scripts" / "whatsapp-bridge"
bridge_script = bridge_dir / "bridge.js"
@@ -234,13 +287,16 @@ def cmd_whatsapp(args):
else:
print("✓ Bridge dependencies already installed")
- # Step 4: Check for existing session
+ # ── Step 5: Check for existing session ───────────────────────────────
session_dir = Path.home() / ".hermes" / "whatsapp" / "session"
session_dir.mkdir(parents=True, exist_ok=True)
if (session_dir / "creds.json").exists():
print("✓ Existing WhatsApp session found")
- response = input("\n Re-pair? This will clear the existing session. [y/N] ").strip()
+ try:
+ response = input("\n Re-pair? This will clear the existing session. [y/N] ").strip()
+ except (EOFError, KeyboardInterrupt):
+ response = "n"
if response.lower() in ("y", "yes"):
import shutil
shutil.rmtree(session_dir, ignore_errors=True)
@@ -251,11 +307,16 @@ def cmd_whatsapp(args):
print(" Start the gateway with: hermes gateway")
return
- # Step 5: Run bridge in pair-only mode (no HTTP server, exits after QR scan)
+ # ── Step 6: QR code pairing ──────────────────────────────────────────
print()
print("─" * 50)
- print("📱 Scan the QR code with your phone:")
- print(" WhatsApp → Settings → Linked Devices → Link a Device")
+ if wa_mode == "bot":
+ print("📱 Open WhatsApp (or WhatsApp Business) on the")
+ print(" phone with the BOT's number, then scan:")
+ else:
+ print("📱 Open WhatsApp on your phone, then scan:")
+ print()
+ print(" Settings → Linked Devices → Link a Device")
print("─" * 50)
print()
@@ -267,12 +328,28 @@ def cmd_whatsapp(args):
except KeyboardInterrupt:
pass
+ # ── Step 7: Post-pairing ─────────────────────────────────────────────
print()
if (session_dir / "creds.json").exists():
print("✓ WhatsApp paired successfully!")
print()
- print("Start the gateway with: hermes gateway")
- print("Or install as a service: hermes gateway install")
+ if wa_mode == "bot":
+ print(" Next steps:")
+ print(" 1. Start the gateway: hermes gateway")
+ print(" 2. Send a message to the bot's WhatsApp number")
+ print(" 3. The agent will reply automatically")
+ print()
+ print(" Tip: Agent responses are prefixed with '⚕ Hermes Agent'")
+ else:
+ print(" Next steps:")
+ print(" 1. Start the gateway: hermes gateway")
+ print(" 2. Open WhatsApp → Message Yourself")
+ print(" 3. Type a message — the agent will reply")
+ print()
+ print(" Tip: Agent responses are prefixed with '⚕ Hermes Agent'")
+ print(" so you can tell them apart from your own messages.")
+ print()
+ print(" Or install as a service: hermes gateway install")
else:
print("⚠ Pairing may not have completed. Run 'hermes whatsapp' to try again.")
@@ -697,6 +774,96 @@ def cmd_uninstall(args):
run_uninstall(args)
+def _update_via_zip(args):
+ """Update Hermes Agent by downloading a ZIP archive.
+
+ Used on Windows when git file I/O is broken (antivirus, NTFS filter
+ drivers causing 'Invalid argument' errors on file creation).
+ """
+ import shutil
+ import tempfile
+ import zipfile
+ from urllib.request import urlretrieve
+
+ branch = "main"
+ zip_url = f"https://github.com/NousResearch/hermes-agent/archive/refs/heads/{branch}.zip"
+
+ print("→ Downloading latest version...")
+ try:
+ tmp_dir = tempfile.mkdtemp(prefix="hermes-update-")
+ zip_path = os.path.join(tmp_dir, f"hermes-agent-{branch}.zip")
+ urlretrieve(zip_url, zip_path)
+
+ print("→ Extracting...")
+ with zipfile.ZipFile(zip_path, 'r') as zf:
+ zf.extractall(tmp_dir)
+
+ # GitHub ZIPs extract to hermes-agent-/
+ extracted = os.path.join(tmp_dir, f"hermes-agent-{branch}")
+ if not os.path.isdir(extracted):
+ # Try to find it
+ for d in os.listdir(tmp_dir):
+ candidate = os.path.join(tmp_dir, d)
+ if os.path.isdir(candidate) and d != "__MACOSX":
+ extracted = candidate
+ break
+
+ # Copy updated files over existing installation, preserving venv/node_modules/.git
+ preserve = {'venv', 'node_modules', '.git', '__pycache__', '.env'}
+ update_count = 0
+ for item in os.listdir(extracted):
+ if item in preserve:
+ continue
+ src = os.path.join(extracted, item)
+ dst = os.path.join(str(PROJECT_ROOT), item)
+ if os.path.isdir(src):
+ if os.path.exists(dst):
+ shutil.rmtree(dst)
+ shutil.copytree(src, dst)
+ else:
+ shutil.copy2(src, dst)
+ update_count += 1
+
+ print(f"✓ Updated {update_count} items from ZIP")
+
+ # Cleanup
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+
+ except Exception as e:
+ print(f"✗ ZIP update failed: {e}")
+ sys.exit(1)
+
+ # Reinstall Python dependencies
+ print("→ Updating Python dependencies...")
+ import subprocess
+ uv_bin = shutil.which("uv")
+ if uv_bin:
+ subprocess.run(
+ [uv_bin, "pip", "install", "-e", ".", "--quiet"],
+ cwd=PROJECT_ROOT, check=True,
+ env={**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
+ )
+ else:
+ venv_pip = PROJECT_ROOT / "venv" / ("Scripts" if sys.platform == "win32" else "bin") / "pip"
+ if venv_pip.exists():
+ subprocess.run([str(venv_pip), "install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True)
+
+ # Sync skills
+ try:
+ from tools.skills_sync import sync_skills
+ print("→ Checking for new bundled skills...")
+ result = sync_skills(quiet=True)
+ if result["copied"]:
+ print(f" + {len(result['copied'])} new skill(s): {', '.join(result['copied'])}")
+ else:
+ print(" ✓ Skills are up to date")
+ except Exception:
+ pass
+
+ print()
+ print("✓ Update complete!")
+
+
def cmd_update(args):
"""Update Hermes Agent to the latest version."""
import subprocess
@@ -705,21 +872,44 @@ def cmd_update(args):
print("⚕ Updating Hermes Agent...")
print()
- # Check if we're in a git repo
+ # Try git-based update first, fall back to ZIP download on Windows
+ # when git file I/O is broken (antivirus, NTFS filter drivers, etc.)
+ use_zip_update = False
git_dir = PROJECT_ROOT / '.git'
- if not git_dir.exists():
- print("✗ Not a git repository. Please reinstall:")
- print(" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash")
- sys.exit(1)
+ if not git_dir.exists():
+ if sys.platform == "win32":
+ use_zip_update = True
+ else:
+ print("✗ Not a git repository. Please reinstall:")
+ print(" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash")
+ sys.exit(1)
+
+ # On Windows, git can fail with "unable to write loose object file: Invalid argument"
+ # due to filesystem atomicity issues. Set the recommended workaround.
+ if sys.platform == "win32" and git_dir.exists():
+ subprocess.run(
+ ["git", "-c", "windows.appendAtomically=false", "config", "windows.appendAtomically", "false"],
+ cwd=PROJECT_ROOT, check=False, capture_output=True
+ )
+
+ if use_zip_update:
+ # ZIP-based update for Windows when git is broken
+ _update_via_zip(args)
+ return
+
# Fetch and pull
try:
print("→ Fetching updates...")
- subprocess.run(["git", "fetch", "origin"], cwd=PROJECT_ROOT, check=True)
+ git_cmd = ["git"]
+ if sys.platform == "win32":
+ git_cmd = ["git", "-c", "windows.appendAtomically=false"]
+
+ subprocess.run(git_cmd + ["fetch", "origin"], cwd=PROJECT_ROOT, check=True)
# Get current branch
result = subprocess.run(
- ["git", "rev-parse", "--abbrev-ref", "HEAD"],
+ git_cmd + ["rev-parse", "--abbrev-ref", "HEAD"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
@@ -729,7 +919,7 @@ def cmd_update(args):
# Check if there are updates
result = subprocess.run(
- ["git", "rev-list", f"HEAD..origin/{branch}", "--count"],
+ git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
@@ -743,7 +933,7 @@ def cmd_update(args):
print(f"→ Found {commit_count} new commit(s)")
print("→ Pulling updates...")
- subprocess.run(["git", "pull", "origin", branch], cwd=PROJECT_ROOT, check=True)
+ subprocess.run(git_cmd + ["pull", "origin", branch], cwd=PROJECT_ROOT, check=True)
# Reinstall Python dependencies (prefer uv for speed, fall back to pip)
print("→ Updating Python dependencies...")
@@ -755,7 +945,7 @@ def cmd_update(args):
env={**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
)
else:
- venv_pip = PROJECT_ROOT / "venv" / "bin" / "pip"
+ venv_pip = PROJECT_ROOT / "venv" / ("Scripts" if sys.platform == "win32" else "bin") / "pip"
if venv_pip.exists():
subprocess.run([str(venv_pip), "install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True)
else:
@@ -851,8 +1041,14 @@ def cmd_update(args):
print(" hermes model # Select provider and model")
except subprocess.CalledProcessError as e:
- print(f"✗ Update failed: {e}")
- sys.exit(1)
+ if sys.platform == "win32":
+ print(f"⚠ Git update failed: {e}")
+ print("→ Falling back to ZIP download...")
+ print()
+ _update_via_zip(args)
+ else:
+ print(f"✗ Update failed: {e}")
+ sys.exit(1)
def main():
diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py
index 51de8d36..c930e0c7 100644
--- a/hermes_cli/runtime_provider.py
+++ b/hermes_cli/runtime_provider.py
@@ -74,8 +74,8 @@ def _resolve_openrouter_runtime(
api_key = (
explicit_api_key
- or os.getenv("OPENAI_API_KEY")
or os.getenv("OPENROUTER_API_KEY")
+ or os.getenv("OPENAI_API_KEY")
or ""
)
diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py
index fa4dcebb..0bc9acc0 100644
--- a/hermes_cli/setup.py
+++ b/hermes_cli/setup.py
@@ -390,11 +390,17 @@ def run_setup_wizard(args):
config = load_config()
hermes_home = get_hermes_home()
- # Check if this is an existing installation with config (any provider or config file)
+ # Check if this is an existing installation with a provider configured.
+ # Just having config.yaml is NOT enough — the installer creates it from
+ # a template, so it always exists after install. We need an actual
+ # inference provider to consider it "existing" (otherwise quick mode
+ # would skip provider selection, leaving hermes non-functional).
+ from hermes_cli.auth import get_active_provider
+ active_provider = get_active_provider()
is_existing = (
get_env_value("OPENROUTER_API_KEY") is not None
or get_env_value("OPENAI_BASE_URL") is not None
- or get_config_path().exists()
+ or active_provider is not None
)
# Import migration helpers
@@ -1382,21 +1388,13 @@ def run_setup_wizard(args):
existing_whatsapp = get_env_value('WHATSAPP_ENABLED')
if not existing_whatsapp and prompt_yes_no("Set up WhatsApp?", False):
print_info("WhatsApp connects via a built-in bridge (Baileys).")
- print_info("Requires Node.js (already installed if you have browser tools).")
- print_info("On first gateway start, you'll scan a QR code with your phone.")
+ print_info("Requires Node.js. Run 'hermes whatsapp' for guided setup.")
print()
- if prompt_yes_no("Enable WhatsApp?", True):
+ if prompt_yes_no("Enable WhatsApp now?", True):
save_env_value("WHATSAPP_ENABLED", "true")
print_success("WhatsApp enabled")
-
- allowed_users = prompt(" Your phone number (e.g. 15551234567, comma-separated for multiple)")
- if allowed_users:
- save_env_value("WHATSAPP_ALLOWED_USERS", allowed_users.replace(" ", ""))
- print_success("WhatsApp allowlist configured")
- else:
- print_info("⚠️ No allowlist set — anyone who messages your WhatsApp will get a response!")
-
- print_info("Start the gateway with 'hermes gateway' and scan the QR code.")
+ print_info("Run 'hermes whatsapp' to choose your mode (separate bot number")
+ print_info("or personal self-chat) and pair via QR code.")
# Gateway reminder
any_messaging = (
diff --git a/landingpage/index.html b/landingpage/index.html
index bc1aa859..2d1f9997 100644
--- a/landingpage/index.html
+++ b/landingpage/index.html
@@ -69,14 +69,38 @@