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 0000000..d93a57a Binary files /dev/null and b/docs/integrations/assets/hermes/hermes-mcp-test.png differ diff --git a/docs/integrations/assets/hermes/hermes-mcp-test.txt b/docs/integrations/assets/hermes/hermes-mcp-test.txt new file mode 100644 index 0000000..e304594 --- /dev/null +++ b/docs/integrations/assets/hermes/hermes-mcp-test.txt @@ -0,0 +1,17 @@ + + Testing 'alice_core'... + Transport: stdio → npx + Auth: none + ✓ Connected (653ms) + ✓ Tools discovered: 9 + + alice_capture Capture continuity input into deterministic continuity ... + alice_recall Recall continuity objects with deterministic ranking an... + alice_resume Compile continuity resumption brief for decisions, open... + alice_open_loops List continuity open loops grouped by deterministic pos... + alice_recent_decisions List most recent continuity decisions in deterministic ... + alice_recent_changes List recent continuity changes from the shipped resumpt... + alice_memory_review List correction review queue or fetch review detail for... + alice_memory_correct Apply deterministic continuity correction actions and r... + alice_context_pack Assemble a deterministic continuity context pack for sc... + diff --git a/docs/integrations/assets/hermes/hermes-runtime-smoke.png b/docs/integrations/assets/hermes/hermes-runtime-smoke.png new file mode 100644 index 0000000..698baef Binary files /dev/null and b/docs/integrations/assets/hermes/hermes-runtime-smoke.png differ diff --git a/docs/integrations/assets/hermes/hermes-runtime-smoke.txt b/docs/integrations/assets/hermes/hermes-runtime-smoke.txt new file mode 100644 index 0000000..b890fe5 --- /dev/null +++ b/docs/integrations/assets/hermes/hermes-runtime-smoke.txt @@ -0,0 +1 @@ +{"open_loop_count":1,"recall_items":2,"registered_tools":["mcp_alice_core_alice_open_loops","mcp_alice_core_alice_recall","mcp_alice_core_alice_resume"],"resume_last_decision_title":"Decision: Keep Alice MCP local-first for Hermes verification."} diff --git a/docs/integrations/hermes.md b/docs/integrations/hermes.md new file mode 100644 index 0000000..43cfbd3 --- /dev/null +++ b/docs/integrations/hermes.md @@ -0,0 +1,162 @@ +# Hermes MCP Integration + +This guide connects Hermes Agent to Alice MCP and verifies the exact tool path +for: + +- `alice_recall` +- `alice_resume` +- `alice_open_loops` + +## Prerequisites + +- Hermes Agent with MCP support (`hermes mcp --help` works). +- Alice local runtime is available (`./.venv/bin/python -m alicebot_api.mcp_server --help` works). +- Postgres is reachable from the machine where Hermes runs. + +## Config (`~/.hermes/config.yaml`) + +Use `mcp_servers` in Hermes config. + +### Option A: local command (direct Python) + +```yaml +mcp_servers: + alice_core: + command: "/ABS/PATH/TO/AliceBot/.venv/bin/python" + args: ["-m", "alicebot_api.mcp_server"] + env: + DATABASE_URL: "postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot" + ALICEBOT_AUTH_USER_ID: "00000000-0000-0000-0000-000000000001" + PYTHONPATH: "/ABS/PATH/TO/AliceBot/apps/api/src:/ABS/PATH/TO/AliceBot/workers" + tools: + include: [alice_recall, alice_resume, alice_open_loops] + resources: false + prompts: false +``` + +### Option B: `npx` command (via `alice-cli` package) + +```yaml +mcp_servers: + alice_core: + command: "npx" + args: ["-y", "--package", "/ABS/PATH/TO/AliceBot/packages/alice-cli", "alice", "mcp"] + env: + NPM_CONFIG_CACHE: "/tmp/alice-npm-cache" + ALICEBOT_PYTHON: "/ABS/PATH/TO/AliceBot/.venv/bin/python" + DATABASE_URL: "postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot" + ALICEBOT_AUTH_USER_ID: "00000000-0000-0000-0000-000000000001" + PYTHONPATH: "/ABS/PATH/TO/AliceBot/apps/api/src:/ABS/PATH/TO/AliceBot/workers" + tools: + include: [alice_recall, alice_resume, alice_open_loops] + resources: false + prompts: false +``` + +`alice mcp` shells out to `${ALICEBOT_PYTHON} -m alicebot_api.mcp_server`. + +If you have a published CLI version with `mcp` support, you can replace args +with: + +```yaml +args: ["-y", "@aliceos/alice-cli", "mcp"] +``` + +## Verify Connection + +```bash +hermes mcp test alice_core +``` + +Expected: + +- `Connected` +- `Tools discovered` +- includes `alice_recall`, `alice_resume`, `alice_open_loops` + +## Verify Tool Calls (Hermes Runtime Path) + +Run the smoke script: + +```bash +./scripts/run_hermes_mcp_smoke.py +``` + +Expected JSON output includes: + +- `registered_tools` containing: + - `mcp_alice_core_alice_recall` + - `mcp_alice_core_alice_resume` + - `mcp_alice_core_alice_open_loops` +- non-zero `recall_items` +- `open_loop_count` >= `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",