From deb99afbe2cb97fbb30fe7a3d9a1f6a9b0177c8b Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Sat, 12 Sep 2026 15:43:59 +0530 Subject: [PATCH 1/2] feat(examples): add MCP server runtime and examples/README.md (closes #5) --- examples/README.md | 106 +++++++++++++++++ examples/mcp-config.json | 15 +++ examples/mcp_server.py | 239 +++++++++++++++++++++++++++++++++++++++ tests/test_mcp_server.py | 92 +++++++++++++++ 4 files changed, 452 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/mcp-config.json create mode 100644 examples/mcp_server.py create mode 100644 tests/test_mcp_server.py diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..c0cf3dc --- /dev/null +++ b/examples/README.md @@ -0,0 +1,106 @@ +# Examples & Agent Integrations + +This directory contains reference integrations wiring `sqlite-graph-memory` into different AI agent harnesses. + +--- + +## Available Examples + +| File | Harness / Protocol | Description | +| :--- | :--- | :--- | +| [`mcp_server.py`](mcp_server.py) | **MCP (Model Context Protocol)** | Stdio MCP server exposing memory recall as an agent tool. Works with Cursor, Claude Desktop, Antigravity, Zed, and Windsurf. | +| [`mcp-config.json`](mcp-config.json) | **MCP Client Config** | Sample configuration block for `claude_desktop_config.json` or Cursor MCP settings. | +| [`claude-code-stop-hook.json`](claude-code-stop-hook.json) | **Claude Code Stop Hook** | Configuration snippet registering `turnstate_hook.py` as an automatic post-turn ledger hook. | + +--- + +## 1. MCP Server Integration (`mcp_server.py`) + +The MCP server exposes a single tool (`memory_recall`) that provides associative memory to any MCP-compliant agent. + +### Prerequisites + +1. Install requirements: + ```bash + pip install -r requirements.txt + ``` +2. Build the embedding index over your markdown notes: + ```bash + python index_notes.py /path/to/your/notes + ``` + +### Tool Definition + +- **Tool Name:** `memory_recall` +- **Arguments:** + - `query` (string, required): The search question or topic. + - `mode` (string, optional, default: `"associative"`): + - `"associative"`: Vector search + 1-hop `[[wikilink]]` graph expansion + cross-encoder reranking. + - `"direct"`: Vector search + cross-encoder reranking (no graph expansion). + - `"ab"`: Runs both pipelines, logs comparative telemetry to `turnstate.db`, and returns the comparison. + +### MCP Client Setup + +Add the server to your MCP client config (e.g. `claude_desktop_config.json` or Cursor `Settings > MCP`): + +```json +{ + "mcpServers": { + "sqlite-graph-memory": { + "command": "python", + "args": [ + "/absolute/path/to/sqlite-graph-memory/examples/mcp_server.py" + ], + "env": { + "BRAIN_INDEX_DIR": "/absolute/path/to/sqlite-graph-memory/index", + "TURNSTATE_DB": "/absolute/path/to/sqlite-graph-memory/turnstate.db" + } + } + } +} +``` + +### Direct CLI Verification + +You can verify the MCP server directly from the command line without launching an MCP client: + +```bash +# Test default associative recall +python examples/mcp_server.py --test "how do I think about agent memory" + +# Test direct (vector-only) recall +python examples/mcp_server.py --test "how do I think about agent memory" --direct + +# Test A/B recall mode +python examples/mcp_server.py --test "how do I think about agent memory" --ab +``` + +--- + +## 2. Claude Code Stop Hook (`claude-code-stop-hook.json`) + +Registers `turnstate_hook.py` to run after every assistant turn in Claude Code. It extracts session context (asks, summaries, files touched, tools used, commands run, decisions made) and appends a row to `turnstate.db` at **0 token cost**. + +To register, add the snippet from [`claude-code-stop-hook.json`](claude-code-stop-hook.json) to `~/.claude/settings.json`. + +Inspect the recorded turns anytime: +```bash +python turnstate_show.py --stats +python turnstate_show.py --n 10 +``` + +--- + +## Environment Variables Reference + +All components share standard configuration variables: + +| Variable | Default | Purpose | +| :--- | :--- | :--- | +| `BRAIN_INDEX_DIR` | `./index` | Directory where `index_notes.py` writes and `brain_ask.py` reads `_brain_e5.npy` and metadata. | +| `TURNSTATE_DB` | `./turnstate.db` | SQLite database holding per-turn ledger (`turns`) and A/B telemetry (`ab_recall`). | +| `BRAIN_ANSWER_OUT` | `./_brain_answer.txt` | File where the formatted context bundle or answer is mirrored. | +| `BRAIN_INDEX_HIDDEN` | `0` | If `1`, indexes hidden dot-directories too (by default skipped). | +| `BRAIN_AB_JSON` | `None` | If set, `--ab` mode writes a structured JSON diff to this file path. | +| `AB_SOURCE` | `work` | Telemetry label written to `ab_recall.source` (`work`, `eval`, etc.). | + diff --git a/examples/mcp-config.json b/examples/mcp-config.json new file mode 100644 index 0000000..7240d11 --- /dev/null +++ b/examples/mcp-config.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "sqlite-graph-memory": { + "command": "python", + "args": [ + "/path/to/sqlite-graph-memory/examples/mcp_server.py" + ], + "env": { + "BRAIN_INDEX_DIR": "/path/to/sqlite-graph-memory/index", + "TURNSTATE_DB": "/path/to/sqlite-graph-memory/turnstate.db" + } + } + } +} + diff --git a/examples/mcp_server.py b/examples/mcp_server.py new file mode 100644 index 0000000..31efd50 --- /dev/null +++ b/examples/mcp_server.py @@ -0,0 +1,239 @@ +# -*- coding: utf-8 -*- +"""mcp_server.py — Model Context Protocol (MCP) server for sqlite-graph-memory. + +Exposes associative memory recall as a standard MCP tool over stdio transport. +Compatible with any MCP client (Cursor, Claude Desktop, Antigravity, Zed, Windsurf). + +Protocol: JSON-RPC 2.0 over stdio (newline-delimited JSON). +Zero third-party dependencies required (pure standard library). + +USAGE: + # Normal stdio mode (started by MCP client) + python examples/mcp_server.py + + # Direct test mode (runs a recall query without an MCP client) + python examples/mcp_server.py --test "how do I think about agent memory" + +Config (env, optional): + BRAIN_INDEX_DIR dir holding embedding index (default: ./index) + TURNSTATE_DB SQLite db for telemetry (default: ./turnstate.db) + BRAIN_ANSWER_OUT file to mirror answer into (default: ./_brain_answer.txt) +""" +import sys, os, json, subprocess +from pathlib import Path + +# Ensure UTF-8 for stdio communication on all platforms (including Windows console) +try: + sys.stdin.reconfigure(encoding='utf-8') + sys.stdout.reconfigure(encoding='utf-8') + sys.stderr.reconfigure(encoding='utf-8') +except Exception: + pass + +ROOT_DIR = Path(__file__).resolve().parents[1] +BRAIN_ASK_SCRIPT = ROOT_DIR / "brain_ask.py" + +SERVER_INFO = { + "name": "sqlite-graph-memory", + "version": "0.1.3" +} + +TOOLS = [ + { + "name": "memory_recall", + "description": ( + "Associative memory recall over linked markdown notes. " + "Retrieves relevant context using vector search, 1-hop [[wikilink]] graph expansion, " + "and cross-encoder reranking. Returns a formatted context bundle." + ), + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query or topic to retrieve memory for." + }, + "mode": { + "type": "string", + "enum": ["associative", "direct", "ab"], + "default": "associative", + "description": ( + "Retrieval mode: 'associative' (vector + graph expansion, recommended), " + "'direct' (vector search only), or 'ab' (run both and log comparison telemetry)." + ) + } + }, + "required": ["query"] + } + } +] + + +def run_recall(query: str, mode: str = "associative") -> str: + """Invoke brain_ask.py in a crash-safe subprocess and return the retrieved text.""" + if not BRAIN_ASK_SCRIPT.exists(): + return f"Error: brain_ask.py not found at {BRAIN_ASK_SCRIPT}" + + cmd = [sys.executable, str(BRAIN_ASK_SCRIPT)] + if mode == "ab": + cmd.append("--ab") + elif mode == "direct": + cmd.append("--ask") + else: # associative (default) + cmd.extend(["--graph", "--ask"]) + cmd.append(query) + + try: + res = subprocess.run( + cmd, + cwd=str(ROOT_DIR), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60 + ) + if res.returncode != 0: + err = (res.stderr or res.stdout or "").strip() + return f"Recall error (exit code {res.returncode}):\n{err}" + + # If --ask mode was used, check BRAIN_ANSWER_OUT for the context bundle + ans_file = Path(os.getenv("BRAIN_ANSWER_OUT", ROOT_DIR / "_brain_answer.txt")) + if ans_file.exists(): + try: + content = ans_file.read_text(encoding="utf-8", errors="replace").strip() + if content: + return content + except Exception: + pass + + return res.stdout.strip() or "(no matching notes found)" + except subprocess.TimeoutExpired: + return "Recall error: timed out after 60 seconds." + except Exception as e: + return f"Recall exception: {e}" + + +def handle_request(req: dict) -> dict: + """Handle a single JSON-RPC 2.0 MCP request.""" + method = req.get("method") + req_id = req.get("id") + + if method == "initialize": + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "serverInfo": SERVER_INFO + } + } + + elif method == "notifications/initialized": + # Notification from client, no response required + return None + + elif method == "ping": + return { + "jsonrpc": "2.0", + "id": req_id, + "result": {} + } + + elif method == "tools/list": + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "tools": TOOLS + } + } + + elif method == "tools/call": + params = req.get("params") or {} + tool_name = params.get("name") + args = params.get("arguments") or {} + + if tool_name == "memory_recall": + query = args.get("query", "") + mode = args.get("mode", "associative") + if not query: + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "content": [{"type": "text", "text": "Error: 'query' argument is required."}], + "isError": True + } + } + result_text = run_recall(query, mode=mode) + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "content": [{"type": "text", "text": result_text}], + "isError": False + } + } + else: + return { + "jsonrpc": "2.0", + "id": req_id, + "error": { + "code": -32601, + "message": f"Tool not found: {tool_name}" + } + } + + else: + if req_id is not None: + return { + "jsonrpc": "2.0", + "id": req_id, + "error": { + "code": -32601, + "message": f"Method not found: {method}" + } + } + return None + + +def main(): + # Direct CLI testing mode: python examples/mcp_server.py --test "query" + if len(sys.argv) > 1 and sys.argv[1] in ("--test", "-t", "--query"): + flags = {"--test", "-t", "--query", "--direct", "--ab", "--associative"} + mode = "associative" + if "--direct" in sys.argv: + mode = "direct" + elif "--ab" in sys.argv: + mode = "ab" + query_parts = [a for a in sys.argv[1:] if a not in flags] + q = " ".join(query_parts) if query_parts else "agent memory" + print(f"=== MCP Test Query: {q} (mode={mode}) ===") + print(run_recall(q, mode=mode)) + return + + + # Standard MCP stdio loop + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + req = json.loads(line) + except Exception as e: + sys.stderr.write(f"JSON decode error: {e}\n") + continue + + resp = handle_request(req) + if resp is not None: + sys.stdout.write(json.dumps(resp, ensure_ascii=False) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() + diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..e69b851 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +"""Unit tests for examples/mcp_server.py (MCP Protocol and tool handling). + +Runs offline with no model download or network. +""" +import sys, json +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "examples")) +from mcp_server import handle_request, TOOLS, SERVER_INFO + + + +def test_mcp_initialize(): + req = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0.0"} + } + } + resp = handle_request(req) + assert resp["id"] == 1 + assert resp["jsonrpc"] == "2.0" + assert resp["result"]["serverInfo"]["name"] == "sqlite-graph-memory" + assert "tools" in resp["result"]["capabilities"] + + +def test_mcp_ping(): + req = {"jsonrpc": "2.0", "id": 2, "method": "ping"} + resp = handle_request(req) + assert resp["id"] == 2 + assert resp["result"] == {} + + +def test_mcp_tools_list(): + req = {"jsonrpc": "2.0", "id": 3, "method": "tools/list"} + resp = handle_request(req) + assert resp["id"] == 3 + tools = resp["result"]["tools"] + assert len(tools) == 1 + assert tools[0]["name"] == "memory_recall" + assert "query" in tools[0]["inputSchema"]["properties"] + assert "mode" in tools[0]["inputSchema"]["properties"] + assert "query" in tools[0]["inputSchema"]["required"] + + +def test_mcp_unknown_method(): + req = {"jsonrpc": "2.0", "id": 4, "method": "non_existent_method"} + resp = handle_request(req) + assert resp["id"] == 4 + assert resp["error"]["code"] == -32601 + + +def test_mcp_notifications_no_response(): + req = {"jsonrpc": "2.0", "method": "notifications/initialized"} + resp = handle_request(req) + assert resp is None + + +def test_mcp_tool_call_missing_query(): + req = { + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "memory_recall", + "arguments": {} + } + } + resp = handle_request(req) + assert resp["id"] == 5 + assert resp["result"]["isError"] is True + assert "required" in resp["result"]["content"][0]["text"].lower() + + +def test_mcp_tool_call_unknown_tool(): + req = { + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "unknown_tool", + "arguments": {"query": "test"} + } + } + resp = handle_request(req) + assert resp["id"] == 6 + assert resp["error"]["code"] == -32601 From 1913627febd6e76ea70804a1f018ca8bff2cefd7 Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Sat, 12 Sep 2026 18:44:47 +0530 Subject: [PATCH 2/2] fix(mcp): scope answer file per call and add run_recall unit tests --- examples/mcp_server.py | 23 ++++++++++++---- tests/test_mcp_server.py | 58 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/examples/mcp_server.py b/examples/mcp_server.py index 31efd50..624aaa9 100644 --- a/examples/mcp_server.py +++ b/examples/mcp_server.py @@ -19,7 +19,7 @@ TURNSTATE_DB SQLite db for telemetry (default: ./turnstate.db) BRAIN_ANSWER_OUT file to mirror answer into (default: ./_brain_answer.txt) """ -import sys, os, json, subprocess +import sys, os, json, subprocess, tempfile from pathlib import Path # Ensure UTF-8 for stdio communication on all platforms (including Windows console) @@ -74,6 +74,9 @@ def run_recall(query: str, mode: str = "associative") -> str: if not BRAIN_ASK_SCRIPT.exists(): return f"Error: brain_ask.py not found at {BRAIN_ASK_SCRIPT}" + if mode not in ("associative", "direct", "ab"): + mode = "associative" + cmd = [sys.executable, str(BRAIN_ASK_SCRIPT)] if mode == "ab": cmd.append("--ab") @@ -83,10 +86,15 @@ def run_recall(query: str, mode: str = "associative") -> str: cmd.extend(["--graph", "--ask"]) cmd.append(query) + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tf: + temp_ans_file = Path(tf.name) + try: + call_env = {**os.environ, "BRAIN_ANSWER_OUT": str(temp_ans_file)} res = subprocess.run( cmd, cwd=str(ROOT_DIR), + env=call_env, capture_output=True, text=True, encoding="utf-8", @@ -97,11 +105,10 @@ def run_recall(query: str, mode: str = "associative") -> str: err = (res.stderr or res.stdout or "").strip() return f"Recall error (exit code {res.returncode}):\n{err}" - # If --ask mode was used, check BRAIN_ANSWER_OUT for the context bundle - ans_file = Path(os.getenv("BRAIN_ANSWER_OUT", ROOT_DIR / "_brain_answer.txt")) - if ans_file.exists(): + # If --ask mode was used, read the scoped per-call answer file + if temp_ans_file.exists(): try: - content = ans_file.read_text(encoding="utf-8", errors="replace").strip() + content = temp_ans_file.read_text(encoding="utf-8", errors="replace").strip() if content: return content except Exception: @@ -112,6 +119,12 @@ def run_recall(query: str, mode: str = "associative") -> str: return "Recall error: timed out after 60 seconds." except Exception as e: return f"Recall exception: {e}" + finally: + if temp_ans_file.exists(): + try: + temp_ans_file.unlink() + except Exception: + pass def handle_request(req: dict) -> dict: diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index e69b851..4a86aa9 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -90,3 +90,61 @@ def test_mcp_tool_call_unknown_tool(): resp = handle_request(req) assert resp["id"] == 6 assert resp["error"]["code"] == -32601 + + +def test_run_recall_success_with_scoped_answer_file(tmp_path, monkeypatch): + """Verify run_recall passes a scoped BRAIN_ANSWER_OUT and reads it back cleanly.""" + stub = tmp_path / "stub_brain_ask.py" + stub.write_text( + "import os, sys\n" + "out_path = os.environ.get('BRAIN_ANSWER_OUT')\n" + "if out_path:\n" + " with open(out_path, 'w', encoding='utf-8') as f:\n" + " f.write('STUB CONTEXT BUNDLE FOR: ' + sys.argv[-1])\n", + encoding="utf-8" + ) + import mcp_server + monkeypatch.setattr(mcp_server, "BRAIN_ASK_SCRIPT", stub) + res = mcp_server.run_recall("test agent memory query", mode="associative") + assert "STUB CONTEXT BUNDLE FOR: test agent memory query" in res + + +def test_run_recall_exit_code_error(tmp_path, monkeypatch): + """Verify non-zero exit codes from brain_ask.py are captured and returned safely.""" + stub = tmp_path / "stub_fail.py" + stub.write_text("import sys\nsys.stderr.write('SYNTHETIC_FAILURE_MSG\\n')\nsys.exit(1)\n", encoding="utf-8") + import mcp_server + monkeypatch.setattr(mcp_server, "BRAIN_ASK_SCRIPT", stub) + res = mcp_server.run_recall("test query") + assert "Recall error (exit code 1)" in res + assert "SYNTHETIC_FAILURE_MSG" in res + + +def test_run_recall_stdout_fallback(tmp_path, monkeypatch): + """Verify stdout is returned when no answer file is produced.""" + stub = tmp_path / "stub_stdout.py" + stub.write_text("print('Direct stdout text from stub')\n", encoding="utf-8") + import mcp_server + monkeypatch.setattr(mcp_server, "BRAIN_ASK_SCRIPT", stub) + res = mcp_server.run_recall("test query") + assert "Direct stdout text from stub" in res + + +def test_run_recall_missing_script(tmp_path, monkeypatch): + """Verify missing brain_ask.py script returns error rather than raising.""" + import mcp_server + monkeypatch.setattr(mcp_server, "BRAIN_ASK_SCRIPT", tmp_path / "non_existent.py") + res = mcp_server.run_recall("test query") + assert "Error: brain_ask.py not found" in res + + +def test_run_recall_timeout(tmp_path, monkeypatch): + """Verify subprocess timeout is caught and returns clear error text.""" + import mcp_server, subprocess + def mock_run(*args, **kwargs): + raise subprocess.TimeoutExpired(cmd=kwargs.get("args") or [], timeout=60) + monkeypatch.setattr(mcp_server.subprocess, "run", mock_run) + res = mcp_server.run_recall("test query") + assert "timed out after 60 seconds" in res + +