From d393acab811e3d02836525c12414f4e0a1ea39c6 Mon Sep 17 00:00:00 2001 From: Redacted User Date: Thu, 9 Apr 2026 19:20:55 +0200 Subject: [PATCH] Add Hermes MCP integration docs, smoke test, and runtime compatibility --- README.md | 1 + apps/api/src/alicebot_api/mcp_server.py | 81 ++++++-- .../assets/hermes/hermes-mcp-test.png | Bin 0 -> 24638 bytes .../assets/hermes/hermes-mcp-test.txt | 17 ++ .../assets/hermes/hermes-runtime-smoke.png | Bin 0 -> 5656 bytes .../assets/hermes/hermes-runtime-smoke.txt | 1 + docs/integrations/hermes.md | 162 ++++++++++++++++ docs/integrations/mcp.md | 6 + packages/alice-cli/README.md | 19 ++ packages/alice-cli/bin/alice.js | 54 +++++- scripts/run_hermes_mcp_smoke.py | 178 ++++++++++++++++++ tests/integration/test_mcp_server.py | 14 ++ 12 files changed, 507 insertions(+), 26 deletions(-) create mode 100644 docs/integrations/assets/hermes/hermes-mcp-test.png create mode 100644 docs/integrations/assets/hermes/hermes-mcp-test.txt create mode 100644 docs/integrations/assets/hermes/hermes-runtime-smoke.png create mode 100644 docs/integrations/assets/hermes/hermes-runtime-smoke.txt create mode 100644 docs/integrations/hermes.md create mode 100755 scripts/run_hermes_mcp_smoke.py diff --git a/README.md b/README.md index 69a07de..aff0e9b 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,7 @@ This makes it easy to connect Alice to MCP-capable assistants and development en See: - [docs/integrations/mcp.md](docs/integrations/mcp.md) +- [docs/integrations/hermes.md](docs/integrations/hermes.md) ### OpenClaw diff --git a/apps/api/src/alicebot_api/mcp_server.py b/apps/api/src/alicebot_api/mcp_server.py index b319e03..c069ecf 100644 --- a/apps/api/src/alicebot_api/mcp_server.py +++ b/apps/api/src/alicebot_api/mcp_server.py @@ -4,7 +4,7 @@ import json import os import sys -from typing import Any, BinaryIO +from typing import Any, BinaryIO, Literal from uuid import UUID from alicebot_api import __version__ @@ -22,6 +22,9 @@ _MCP_PROTOCOL_VERSION = "2024-11-05" _MCP_SERVER_NAME = "alice-core-mcp" _DEFAULT_MCP_USER_ID = "00000000-0000-0000-0000-000000000001" +_TRANSPORT_CONTENT_LENGTH = "content-length" +_TRANSPORT_JSON_LINE = "json-line" +_TransportMode = Literal["content-length", "json-line"] def _parse_uuid(value: str) -> UUID: @@ -46,14 +49,27 @@ def _build_runtime_context(args: argparse.Namespace) -> MCPRuntimeContext: return MCPRuntimeContext(database_url=database_url, user_id=user_id) -def _read_message(stream: BinaryIO) -> dict[str, Any] | None: - headers: dict[str, str] = {} +def _parse_json_rpc_payload(raw_payload: str) -> dict[str, Any]: + payload = json.loads(raw_payload) + if not isinstance(payload, dict): + raise ValueError("JSON-RPC payload must be an object") + return payload - while True: - line = stream.readline() - if line == b"": - return None +def _read_message(stream: BinaryIO) -> tuple[dict[str, Any], _TransportMode] | None: + first_line = stream.readline() + if first_line == b"": + return None + + # MCP SDK >=1.0 stdio transport sends one JSON-RPC message per line. + stripped_first_line = first_line.strip() + if stripped_first_line.startswith(b"{"): + payload = _parse_json_rpc_payload(stripped_first_line.decode("utf-8")) + return payload, _TRANSPORT_JSON_LINE + + headers: dict[str, str] = {} + line = first_line + while True: if line in {b"\r\n", b"\n"}: break @@ -63,6 +79,10 @@ def _read_message(stream: BinaryIO) -> dict[str, Any] | None: key, value = decoded.split(":", 1) headers[key.strip().lower()] = value.strip() + line = stream.readline() + if line == b"": + return None + content_length_raw = headers.get("content-length") if content_length_raw is None: raise ValueError("missing Content-Length header") @@ -76,17 +96,23 @@ def _read_message(stream: BinaryIO) -> dict[str, Any] | None: body = stream.read(content_length) if len(body) != content_length: return None - payload = json.loads(body.decode("utf-8")) - if not isinstance(payload, dict): - raise ValueError("JSON-RPC payload must be an object") - return payload + payload = _parse_json_rpc_payload(body.decode("utf-8")) + return payload, _TRANSPORT_CONTENT_LENGTH -def _write_message(stream: BinaryIO, message: dict[str, Any]) -> None: +def _write_message( + stream: BinaryIO, + message: dict[str, Any], + *, + transport_mode: _TransportMode, +) -> None: encoded = json.dumps(message, separators=(",", ":"), sort_keys=True).encode("utf-8") - header = f"Content-Length: {len(encoded)}\r\n\r\n".encode("ascii") - stream.write(header) - stream.write(encoded) + if transport_mode == _TRANSPORT_JSON_LINE: + stream.write(encoded + b"\n") + else: + header = f"Content-Length: {len(encoded)}\r\n\r\n".encode("ascii") + stream.write(header) + stream.write(encoded) stream.flush() @@ -114,6 +140,7 @@ def __init__(self, *, context: MCPRuntimeContext, input_stream: BinaryIO, output self._context = context self._input_stream = input_stream self._output_stream = output_stream + self._transport_mode: _TransportMode = _TRANSPORT_CONTENT_LENGTH def _handle_request(self, request: dict[str, Any]) -> dict[str, Any] | None: if request.get("jsonrpc") != _JSONRPC_VERSION: @@ -202,22 +229,36 @@ def _handle_request(self, request: dict[str, Any]) -> dict[str, Any] | None: def run(self) -> int: while True: try: - request = _read_message(self._input_stream) + framed_request = _read_message(self._input_stream) except json.JSONDecodeError as exc: response = _response_error(None, code=-32700, message=f"parse error: {exc.msg}") - _write_message(self._output_stream, response) + _write_message( + self._output_stream, + response, + transport_mode=self._transport_mode, + ) continue except ValueError as exc: response = _response_error(None, code=-32600, message=str(exc)) - _write_message(self._output_stream, response) + _write_message( + self._output_stream, + response, + transport_mode=self._transport_mode, + ) continue - if request is None: + if framed_request is None: return 0 + request, transport_mode = framed_request + self._transport_mode = transport_mode response = self._handle_request(request) if response is not None: - _write_message(self._output_stream, response) + _write_message( + self._output_stream, + response, + transport_mode=self._transport_mode, + ) def build_parser() -> argparse.ArgumentParser: diff --git a/docs/integrations/assets/hermes/hermes-mcp-test.png b/docs/integrations/assets/hermes/hermes-mcp-test.png new file mode 100644 index 0000000000000000000000000000000000000000..d93a57aa88d124874993302e50c8525f7feb6961 GIT binary patch literal 24638 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 z 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 z zf5a7bE$86GHT<`Y2{mp^Pu3&fQ=N*#OWyFG5y>SRJdQv5gW*0)%93z{OZGl_+Ruqd z6B{mHfhlp}R|AKWO4HnyE=?1|$ndv>E(6u+OZ9iQ%bVvigANOZ!d(-O8qq1FRC<*8 z?n1+R$8A6%OD;kg-ZXC8qP|Z~=A#qbID3vq_eYy5nIj*x%*= `1` + +## Sample Hermes Prompts + +Hermes prefixes MCP tools as `mcp__`. With server name +`alice_core`, the names are: + +- `mcp_alice_core_alice_recall` +- `mcp_alice_core_alice_resume` +- `mcp_alice_core_alice_open_loops` + +Prompts: + +```text +Use mcp_alice_core_alice_recall with {"query":"Hermes docs","limit":5} and summarize the top 3 memories. +``` + +```text +Use mcp_alice_core_alice_resume with {"thread_id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","max_recent_changes":5,"max_open_loops":5}. Return only decisions, next action, and blockers. +``` + +```text +Use mcp_alice_core_alice_open_loops with {"thread_id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","limit":10}. Group results by waiting_for, blocker, stale, next_action. +``` + +## Troubleshooting + +### `Connection failed` in `hermes mcp test` + +- Confirm `command` points to an existing executable. +- Use absolute paths for `command` and `PYTHONPATH`. +- Run the server command directly: + - `"/ABS/PATH/TO/AliceBot/.venv/bin/python" -m alicebot_api.mcp_server --help` + +### Tool list is missing `alice_recall`/`alice_resume`/`alice_open_loops` + +- Check `tools.include` values are unprefixed tool names: + - `alice_recall`, `alice_resume`, `alice_open_loops` +- Run `/reload-mcp` in Hermes after config changes. +- Re-run `hermes mcp test alice_core`. + +### Tools register but calls fail at runtime + +- Validate `DATABASE_URL` is reachable and points to a migrated DB. +- Validate `ALICEBOT_AUTH_USER_ID` is a UUID string. +- Run `./scripts/run_hermes_mcp_smoke.py` to isolate server/runtime issues. + +### `npx` path fails + +- Check `npx --version`. +- Ensure `args` contains a valid local package path or a published package. +- If npm cache permissions are locked down, set `NPM_CONFIG_CACHE` to a writable path. +- If `npx` is blocked in your environment, use Option A (local command). + +## Demo Screenshots + +`hermes mcp test` against Alice: + +![Hermes MCP test with Alice](assets/hermes/hermes-mcp-test.png) + +Hermes runtime tool-call smoke result: + +![Hermes runtime smoke result](assets/hermes/hermes-runtime-smoke.png) + +## Test Record + +Validated on `2026-04-09`: + +- `HERMES_HOME=/tmp/alice-hermes-home ./.venv/bin/hermes mcp test alice_core` (local command config) +- `HERMES_HOME=/tmp/alice-hermes-home ./.venv/bin/hermes mcp test alice_core` (`npx --package ... alice mcp` config) +- `./scripts/run_hermes_mcp_smoke.py` diff --git a/docs/integrations/mcp.md b/docs/integrations/mcp.md index a57d2fa..8b80ae1 100644 --- a/docs/integrations/mcp.md +++ b/docs/integrations/mcp.md @@ -50,6 +50,12 @@ MCP uses the same local runtime scope as CLI: } ``` +## Hermes + +For Hermes Agent-specific setup, prompts, and troubleshooting: + +- `docs/integrations/hermes.md` + ## Contract Guardrails - tool set is intentionally narrow and stable diff --git a/packages/alice-cli/README.md b/packages/alice-cli/README.md index 8250d75..c50733c 100644 --- a/packages/alice-cli/README.md +++ b/packages/alice-cli/README.md @@ -12,6 +12,25 @@ npm install -g @aliceos/alice-cli ```bash alice hello +alice mcp --help alice --help alice --version ``` + +## MCP passthrough + +`alice mcp` launches the Python Alice MCP server: + +```bash +ALICEBOT_PYTHON=/ABS/PATH/TO/AliceBot/.venv/bin/python alice mcp --help +``` + +For `npx` usage: + +```bash +ALICEBOT_PYTHON=/ABS/PATH/TO/AliceBot/.venv/bin/python npx -y @aliceos/alice-cli mcp --help +``` + +Prerequisite: the selected Python runtime must be able to import +`alicebot_api.mcp_server` (for example, run from this repository after editable +install). diff --git a/packages/alice-cli/bin/alice.js b/packages/alice-cli/bin/alice.js index 1712ae5..11400b9 100755 --- a/packages/alice-cli/bin/alice.js +++ b/packages/alice-cli/bin/alice.js @@ -1,30 +1,72 @@ #!/usr/bin/env node -import { helloAlice } from "@aliceos/alice-core"; +import { spawn } from "node:child_process"; const args = process.argv.slice(2); const version = "0.1.0"; +const defaultPythonCommand = process.platform === "win32" ? "python" : "python3"; if (args.includes("--version") || args.includes("-v")) { console.log(version); process.exit(0); } -if (args.length === 0 || args.includes("--help") || args.includes("-h")) { +if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { console.log(`alice ${version} Usage: alice hello + alice mcp [alicebot-mcp-args...] alice --help alice --version`); process.exit(0); } if (args[0] === "hello") { - console.log(helloAlice()); - process.exit(0); + try { + const { helloAlice } = await import("@aliceos/alice-core"); + console.log(helloAlice()); + process.exit(0); + } catch (error) { + console.error( + `Failed to load @aliceos/alice-core: ${error.message} +Install dependencies with npm install.`, + ); + process.exit(1); + } } -console.error(`Unknown command: ${args[0]} +if (args[0] === "mcp") { + const pythonCommand = process.env.ALICEBOT_PYTHON || defaultPythonCommand; + const child = spawn( + pythonCommand, + ["-m", "alicebot_api.mcp_server", ...args.slice(1)], + { + stdio: "inherit", + env: process.env, + }, + ); + + child.on("error", (error) => { + console.error( + `Failed to start Alice MCP server using "${pythonCommand}": ${error.message} +Set ALICEBOT_PYTHON to your Alice Python runtime (for example: /abs/path/.venv/bin/python).`, + ); + process.exit(1); + }); + + child.on("exit", (code, signal) => { + if (typeof code === "number") { + process.exit(code); + } + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(1); + }); +} else { + console.error(`Unknown command: ${args[0]} Run "alice --help" for usage.`); -process.exit(1); + process.exit(1); +} diff --git a/scripts/run_hermes_mcp_smoke.py b/scripts/run_hermes_mcp_smoke.py new file mode 100755 index 0000000..6715b36 --- /dev/null +++ b/scripts/run_hermes_mcp_smoke.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys +from uuid import UUID, uuid4 + +from alicebot_api.config import DEFAULT_DATABASE_URL +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +THREAD_ID = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") +REQUIRED_HERMES_TOOL_NAMES = ( + "mcp_alice_core_alice_recall", + "mcp_alice_core_alice_resume", + "mcp_alice_core_alice_open_loops", +) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="run_hermes_mcp_smoke.py", + description=( + "Verify Hermes MCP runtime can discover and call Alice MCP tools " + "(alice_recall, alice_resume, alice_open_loops)." + ), + ) + parser.add_argument( + "--database-url", + default=os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL), + help="Database URL for seeding and runtime calls.", + ) + parser.add_argument( + "--python-command", + default=sys.executable, + help="Python executable Hermes should use for the Alice MCP server.", + ) + parser.add_argument( + "--repo-root", + default=str(Path(__file__).resolve().parents[1]), + help="Alice repository root used to compose PYTHONPATH for the MCP server.", + ) + return parser + + +def _dispatch_mcp_tool(registry, *, tool_name: str, arguments: dict[str, object]) -> dict[str, object]: + payload = json.loads(registry.dispatch(tool_name, arguments)) + if "error" in payload: + raise RuntimeError(f"{tool_name} returned error: {payload['error']}") + result = payload.get("result") + if not isinstance(result, dict): + raise RuntimeError(f"{tool_name} returned unexpected payload: {payload}") + return result + + +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + + # Import Hermes MCP runtime lazily so the script can print a clear error + # when Hermes dependencies are not installed in this Python environment. + try: + from tools.mcp_tool import register_mcp_servers, shutdown_mcp_servers + from tools.registry import registry + except ModuleNotFoundError as exc: + print( + "error: Hermes runtime modules are unavailable. " + "Install hermes-agent and mcp in this Python environment.", + file=sys.stderr, + ) + print(f"detail: {exc}", file=sys.stderr) + return 1 + + user_id = uuid4() + email = f"hermes-smoke-{user_id}@example.com" + pythonpath = f"{args.repo_root}/apps/api/src:{args.repo_root}/workers" + + with user_connection(args.database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, "Hermes Smoke") + + decision_capture = store.create_continuity_capture_event( + raw_content="Decision: Keep Alice MCP local-first for Hermes verification.", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + decision = store.create_continuity_object( + capture_event_id=decision_capture["id"], + object_type="Decision", + status="active", + title="Decision: Keep Alice MCP local-first for Hermes verification.", + body={"decision_text": "Keep Alice MCP local-first for Hermes verification."}, + provenance={"thread_id": str(THREAD_ID), "source_event_ids": ["hermes-smoke-1"]}, + confidence=0.95, + ) + + waiting_capture = store.create_continuity_capture_event( + raw_content="Waiting For: Hermes docs sign-off", + explicit_signal="waiting_for", + admission_posture="DERIVED", + admission_reason="explicit_signal_waiting_for", + ) + waiting_for = store.create_continuity_object( + capture_event_id=waiting_capture["id"], + object_type="WaitingFor", + status="active", + title="Waiting For: Hermes docs sign-off", + body={"waiting_for_text": "Hermes docs sign-off"}, + provenance={"thread_id": str(THREAD_ID), "source_event_ids": ["hermes-smoke-2"]}, + confidence=0.93, + ) + + server_config = { + "alice_core": { + "command": args.python_command, + "args": ["-m", "alicebot_api.mcp_server"], + "env": { + "DATABASE_URL": args.database_url, + "ALICEBOT_AUTH_USER_ID": str(user_id), + "PYTHONPATH": pythonpath, + }, + "tools": { + "include": ["alice_recall", "alice_resume", "alice_open_loops"], + "resources": False, + "prompts": False, + }, + } + } + + try: + registered_tools = set(register_mcp_servers(server_config)) + required_tools = set(REQUIRED_HERMES_TOOL_NAMES) + if not required_tools.issubset(registered_tools): + missing = sorted(required_tools - registered_tools) + raise RuntimeError(f"Hermes did not register expected tools: {missing}") + + recall = _dispatch_mcp_tool( + registry, + tool_name="mcp_alice_core_alice_recall", + arguments={"thread_id": str(THREAD_ID), "query": "Hermes", "limit": 5}, + ) + resume = _dispatch_mcp_tool( + registry, + tool_name="mcp_alice_core_alice_resume", + arguments={"thread_id": str(THREAD_ID), "max_recent_changes": 5, "max_open_loops": 5}, + ) + open_loops = _dispatch_mcp_tool( + registry, + tool_name="mcp_alice_core_alice_open_loops", + arguments={"thread_id": str(THREAD_ID), "limit": 5}, + ) + + if recall["summary"]["returned_count"] < 1: + raise RuntimeError("Recall returned no continuity items.") + if resume["brief"]["last_decision"]["item"]["id"] != str(decision["id"]): + raise RuntimeError("Resume did not surface the seeded decision.") + if open_loops["dashboard"]["waiting_for"]["items"][0]["id"] != str(waiting_for["id"]): + raise RuntimeError("Open loops did not surface the seeded waiting-for item.") + + summary = { + "registered_tools": sorted(required_tools), + "recall_items": recall["summary"]["returned_count"], + "resume_last_decision_title": resume["brief"]["last_decision"]["item"]["title"], + "open_loop_count": open_loops["dashboard"]["summary"]["total_count"], + } + print(json.dumps(summary, separators=(",", ":"), sort_keys=True)) + finally: + shutdown_mcp_servers() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py index 5079ac5..e8df32f 100644 --- a/tests/integration/test_mcp_server.py +++ b/tests/integration/test_mcp_server.py @@ -206,6 +206,7 @@ def test_mcp_server_tool_calls_and_correction_flow(migrated_database_urls) -> No tool_names = [tool["name"] for tool in tools_list["result"]["tools"]] assert "alice_recall" in tool_names assert "alice_resume" in tool_names + assert "alice_open_loops" in tool_names assert "alice_memory_correct" in tool_names recall_before = _call_tool( @@ -233,6 +234,19 @@ def test_mcp_server_tool_calls_and_correction_flow(migrated_database_urls) -> No assert resume_before["isError"] is False assert resume_before["structuredContent"]["brief"]["last_decision"]["item"]["id"] == str(legacy_decision["id"]) + open_loops = _call_tool( + client, + name="alice_open_loops", + arguments={ + "thread_id": str(thread_id), + "limit": 20, + }, + ) + assert open_loops["isError"] is False + open_loop_dashboard = open_loops["structuredContent"]["dashboard"] + assert open_loop_dashboard["summary"]["total_count"] == 1 + assert open_loop_dashboard["waiting_for"]["items"][0]["id"] == str(waiting_for["id"]) + correction = _call_tool( client, name="alice_memory_correct",