From 227e26c8db66c85761ea5e0f6868e5048867bf09 Mon Sep 17 00:00:00 2001 From: Jason Stedwell Date: Tue, 28 Jul 2026 23:01:47 -0500 Subject: [PATCH] =?UTF-8?q?2.2.0=20=E2=80=94=20echo-mcp:=20the=20container?= =?UTF-8?q?ized=20MCP=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ECHO as 14 typed MCP tools (streamable HTTP, stateless JSON, bearer auth, open /health) wrapping the 2.1.1 *_op cores in-process: - mcp-server/app.py: FastMCP app; duplicate gate + offline queueing surface as DATA (merge_into/force are parameters, never blind retries); recall packs excerpts into budget_chars by score; get_note is traversal-guarded with section/max_chars; patch_note enriches invalid-target errors with the note's actual headings; log_session wraps the session-end bundle (heartbeat-last); ECHO_MCP_TOOLS=core exposes only the six daily drivers; all MCP writes serialize in-process; startup fails fast on missing env. - Dockerfile (legacy format — no BuildKit on the CI runner), python:3.12-slim, healthcheck probes 127.0.0.1; .dockerignore keeps the context lean. - .gitea/workflows/docker-build.yml: standard image build; PORT redeploy trigger targets the echo-mcp container explicitly (repo name is echo). - deploy.unraid.yml: br0/auto-IP, /data volume, vault adjacency (ECHO_BASE=http://10.2.0.35:27123), secrets as SECRET: refs. - SKILL.md: prefer the echo_* tools when the connector is present; CLI recipes stay as the fallback. Spec header marked BUILT (tool count corrected to 14). - eval/test_mcp_server.py: e2e over real streamable HTTP (health/auth/ initialize/tools-list + capture->gate->merge->recall->log_session); skips cleanly when the SDK is absent. All seven suites green. Co-Authored-By: Claude Fable 5 --- .dockerignore | 11 + .gitea/workflows/docker-build.yml | 50 +++ CHANGELOG.md | 34 ++ Dockerfile | 26 ++ README.md | 3 +- deploy.unraid.yml | 28 ++ docs/MCP-SERVER-SPEC.md | 8 +- echo-memory.plugin | Bin 195559 -> 195895 bytes .../.claude-plugin/plugin.json | 2 +- .../skills/echo-memory/SKILL.md | 13 + eval/test_mcp_server.py | 171 +++++++ mcp-server/app.py | 417 ++++++++++++++++++ mcp-server/requirements.txt | 4 + 13 files changed, 761 insertions(+), 6 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/docker-build.yml create mode 100644 Dockerfile create mode 100644 deploy.unraid.yml create mode 100644 eval/test_mcp_server.py create mode 100644 mcp-server/app.py create mode 100644 mcp-server/requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..071e93a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# Only mcp-server/ + the plugin scripts reach the image; keep the context lean. +.git +*.plugin +dist/ +CODEX/ +docs/ +eval/ +echo-icon* +*.pdf +*.html +__pycache__/ diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml new file mode 100644 index 0000000..5ed352c --- /dev/null +++ b/.gitea/workflows/docker-build.yml @@ -0,0 +1,50 @@ +name: Build and Push Docker Image + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build: + # Runs on the forgerunner host: bundled Docker CLI + mounted /var/run/docker.sock. + runs-on: host + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: registry.alwisp.com + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and Push + run: | + IMAGE="registry.alwisp.com/${{ gitea.repository }}" + GIT_SHA="$(git rev-parse --short HEAD)" + COMMIT_COUNT="$(git rev-list --count HEAD)" + docker build \ + --label org.alwisp.git-sha="${{ gitea.sha }}" \ + --label org.alwisp.version="v2.${COMMIT_COUNT}" \ + --label org.alwisp.repo="${{ gitea.repository }}" \ + -t "${IMAGE}:latest" . + docker push "${IMAGE}:latest" + + # Dangling-only prune: removes untagged leftovers from previous builds. Never + # removes the tagged :latest or any image referenced by a running container. + - name: Prune dangling images on host + if: always() + run: docker image prune -f 2>/dev/null || true + + - name: Trigger PORT redeploy + if: success() + run: | + # Repo is 'echo' but the container is 'echo-mcp' — target it explicitly. + curl -fsS -X POST https://port.alwisp.com/hooks/gitea \ + -H "X-Deploy-Token: ${{ secrets.WEBHOOK_SECRET }}" \ + -H "Content-Type: application/json" \ + -d '{"container":"echo-mcp"}' || echo "PORT redeploy trigger failed (non-fatal)" diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c909e..f7a0295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## 2.2.0 + +### Added — echo-mcp: the containerized MCP server + +ECHO's operations are now reachable as **typed MCP tools** from any surface with +an MCP connector (Claude Code, CoWork, claude.ai) — no Python-capable shell, no +`$ECHO` path resolution, no output re-parsing. New `mcp-server/` + `Dockerfile` +in this repo; deployed on ALPHA as the `echo-mcp` container behind +`https://echomcp.alwisp.com` (streamable HTTP, stateless JSON, bearer auth, +open `/health` for Docker/Kuma). + +- **14 tools** wrapping the 2.1.1 `*_op` cores: `echo_load`, `echo_recall` + (score-packed `budget_chars`), `echo_resolve`, `echo_get_note` + (`section`/`max_chars`, traversal-guarded), `echo_get_scope`/`echo_set_scope`, + `echo_health` (`deep` runs the linter), `echo_capture` (gate as data — + `merge_into`/`force` are parameters), `echo_link` (paths OR resolvable names), + `echo_append_note`/`echo_patch_note` (invalid-target errors include the note's + actual headings), `echo_triage_inbox` (list/preview/apply in one tool), + `echo_reflect`, `echo_log_session` (the session-end bundle, heartbeat-last). +- **Tool profiles**: `ECHO_MCP_TOOLS=core` exposes only the six daily drivers. +- **Vault adjacency**: the container talks to the Obsidian REST API's HTTP + binding on the same box (`ECHO_BASE=http://10.2.0.35:27123`) — vault ops stop + depending on Cloudflare/NPM/DNS; the public chain only fronts the `echomcp` + ingress. Server-side offline queue at `/data`. +- **All MCP writes serialize** through the server process; the vault advisory + lock still coordinates with CLI clients. +- SKILL.md: prefer the `echo_*` tools when present; CLI recipes are the fallback. +- New suite `eval/test_mcp_server.py` (skips without the SDK): health/auth/ + initialize/tools-list + the capture→gate→merge→recall→log_session flow over + real streamable HTTP. + +The plugin itself is unchanged apart from the SKILL.md note — the container +vendors `skills/echo-memory/scripts/` at build time (one canonical tree). + ## 2.1.1 ### Changed — Phase 0 of the MCP build: every high-level op returns an envelope diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b2f6ec2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# echo-mcp — containerized MCP server over the ECHO vault (docs/MCP-SERVER-SPEC.md). +# LEGACY Dockerfile format on purpose: the git.alwisp.com CI runner has no BuildKit +# (no `# syntax=` line, no RUN --mount). +FROM python:3.12-slim + +WORKDIR /app + +COPY mcp-server/requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +# The canonical plugin scripts ARE the server's ops layer — vendored at build time, +# never a second source tree. +COPY echo-memory.plugin.src/skills/echo-memory/scripts /app/scripts +COPY mcp-server/app.py /app/app.py + +ENV ECHO_STATE_DIR=/data \ + ECHO_MCP_PORT=8765 \ + PYTHONUNBUFFERED=1 +VOLUME /data +EXPOSE 8765 + +# Probe 127.0.0.1, NOT localhost (::1-vs-IPv4 lesson from cpas/memer/breedr). +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python3 -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8765/health', timeout=4)" || exit 1 + +CMD ["python3", "/app/app.py"] diff --git a/README.md b/README.md index bf9045b..4600b50 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# echo-memory — v2.1.1 +# echo-memory — v2.2.0 Persistent memory for Claude / CoWork sessions via the **ECHO** Obsidian vault, driven over the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api). The skill makes direct REST calls through a bundled validated client (`scripts/echo.py`). The whole toolchain is **pure Python** (stdlib only), so it runs identically on Windows, macOS, and Linux — no bash, no platform-specific `date`. @@ -421,6 +421,7 @@ From the credential-free harness (`eval/run_eval.py` against the deterministic m | Version | Highlights | |---------|-----------| +| **2.2.0** | **echo-mcp — the containerized MCP server.** ECHO as 14 typed MCP tools from any connector-capable surface (Claude Code, CoWork, claude.ai): `mcp-server/` + Dockerfile in-repo, deployed on ALPHA as `echo-mcp` behind `https://echomcp.alwisp.com` (streamable HTTP, stateless JSON, bearer auth, open `/health`). Talks to the Obsidian REST API directly on the LAN (`10.2.0.35:27123`) — one client round trip per tool call, all vault chatter host-local. Duplicate gate & offline queueing surface as data; `ECHO_MCP_TOOLS=core` trims the surface to six tools; writes serialize server-side. New `eval/test_mcp_server.py` e2e suite. Full spec: `docs/MCP-SERVER-SPEC.md`. | | **2.1.1** | **MCP Phase 0 — return-not-print.** Every high-level op gains a core `*_op` function returning an envelope dict with a stdout-purity guarantee (helper chatter → stderr); CLI verbs become thin wrappers with identical output and exit codes. Duplicate gate and offline queueing become data (`action: duplicate-gate` / `queued: true`). New `eval/test_ops_api.py` contract suite. This is the seam the 2.2 containerized MCP server wraps. | | **2.1.0** | **Quick-wins train: cheaper sessions, durable capture, one-call session end.** (1) **`load --brief`** — a token-budgeted cold-start digest (Fact/Pattern in full, last ~10 Observations, scope+freshness, last session's key sections, Agent-Log lines, inbox *count*; `ECHO_LOAD_BUDGET` default ~8000 chars) now injected by the SessionStart hook, replacing the full six-file dump that grew unboundedly; all `load` reads are fetched in parallel. (2) **Offline capture durability** — `capture` on an unreachable vault queues the *whole operation* as one semantic record; `flush` replays it **through capture** so routing/gate/aliasing re-run against the current index; a gate stop on replay is kept + flagged, never landed blind; `ensure_daily_log`/update-path writes ride the queue too. (3) **`session-end`** — one call (one lock) writes session log → Agent-Log line → reflect proposals → optional scope switch → **heartbeat last as the commit marker**; dry-run by default; `ECHO_NOW` pins the HHMM. Also fixes the `__main__` twin-module trap so helper-module `EchoError`s exit with their intended codes. +19 end-to-end checks. | | **2.0.0** | **Packaging & structure major — memory behavior unchanged.** Skills-only: the legacy `commands/` directory is deleted (breaking for pre-skills clients; the 1.6.0-verified skills are the sole entry points). Artifact policy: the repo tracks only the `echo-memory.plugin` pointer; the 15 historical versioned zips leave the tree and versioned builds ship as **Gitea releases** (one per `v` tag) from `v2.0.0` on; `.gitignore` blocks `*.plugin` except the pointer. README gains the "packaging for other agent runtimes" port note (build-target rule, never a second source tree). | diff --git a/deploy.unraid.yml b/deploy.unraid.yml new file mode 100644 index 0000000..c2a176c --- /dev/null +++ b/deploy.unraid.yml @@ -0,0 +1,28 @@ +# echo-mcp — PORT deploy manifest (ALPHA / br0). See docs/MCP-SERVER-SPEC.md §8. +# Container name deliberately differs from the repo (echo): the repo ships the +# plugin AND this server; only the server is a container. +name: echo-mcp +image: registry.alwisp.com/jason/echo:latest +network: br0 +ip: auto +ports: [] # br0 static IP — app serves :8765 directly +volumes: + - /mnt/user/appdata/echo-mcp:/data # outbox + read cache (+ backups/history, v1.1) +env: + # Vault adjacency: the Obsidian Local REST API's HTTP binding on the same box — + # never the public echoapi.alwisp.com hairpin (spec §8; cleartext stays on the LAN). + ECHO_BASE: http://10.2.0.35:27123 + ECHO_OWNER: Jason Stedwell + ECHO_STATE_DIR: /data + ECHO_MCP_PORT: "8765" + ECHO_MCP_TOOLS: full + # Secrets from the PORT secret store — values never appear in this file or chat. + ECHO_KEY: SECRET:echo-vault-key + ECHO_MCP_TOKEN: SECRET:echo-mcp-token +health_check_path: /health +health_check_port: 8765 +webui: https://echomcp.alwisp.com +proxy: + forward_port: 8765 + forward_scheme: http + websockets: true diff --git a/docs/MCP-SERVER-SPEC.md b/docs/MCP-SERVER-SPEC.md index bc84b14..eef54b2 100644 --- a/docs/MCP-SERVER-SPEC.md +++ b/docs/MCP-SERVER-SPEC.md @@ -1,9 +1,9 @@ # echo-mcp — MCP Server Build Spec (containerized) -> Status: **spec for a future build session** (written 2026-07-28 against v1.5.1; -> revised same day: **remote container architecture**, operator decision — heavy -> lifting belongs in a deployed container, not on any one machine). -> Companion plan for the other nine review items: `docs/IMPROVEMENT-PLANS.md`. +> Status: **BUILT — shipped as 2.2.0, 2026-07-28** (`mcp-server/app.py`, 14 tools — +> the count below saying 13 undercounted the append/patch pair; e2e suite +> `eval/test_mcp_server.py`). This document remains the design record; §7.2 is the +> live v1.1 backlog. Companion plan: `docs/IMPROVEMENT-PLANS.md`. > > **Prerequisites before starting this build:** > 1. The `session-end` verb (IMPROVEMENT-PLANS #7) — the MCP tool wraps it. diff --git a/echo-memory.plugin b/echo-memory.plugin index 6ed6d87c4f27a14143d7d18a11f11bc8a80cd5a4..0abcaaa8c692a69abf586bd16eed0eac5cb9de84 100644 GIT binary patch delta 19853 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 diff --git a/echo-memory.plugin.src/.claude-plugin/plugin.json b/echo-memory.plugin.src/.claude-plugin/plugin.json index 840f542..b84dafc 100644 --- a/echo-memory.plugin.src/.claude-plugin/plugin.json +++ b/echo-memory.plugin.src/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "echo-memory", - "version": "2.1.1", + "version": "2.2.0", "homepage": "https://git.alwisp.com/jason/echo", "repository": "https://git.alwisp.com/jason/echo", "description": "Persistent memory via the ECHO Obsidian vault over the Obsidian Local REST API. Cross-platform Python client: one-call capture/resolve/recall/link/triage over an entity index, hybrid BM25 + graph recall spanning entities + sessions/journal (recency/status-aware), a pre-write duplicate gate, complete-frontmatter capture, session hooks that self-fire load/reflect, offline write-ahead queue, lock-guarded concurrency, linter-enforced routing, and /echo-* commands.", diff --git a/echo-memory.plugin.src/skills/echo-memory/SKILL.md b/echo-memory.plugin.src/skills/echo-memory/SKILL.md index fe60e63..1f95bd9 100644 --- a/echo-memory.plugin.src/skills/echo-memory/SKILL.md +++ b/echo-memory.plugin.src/skills/echo-memory/SKILL.md @@ -52,6 +52,19 @@ Executable logic ships under `scripts/` — **pure Python**, so the whole toolch - `scripts/check_routing.py` — verifies the routing docs stay in sync with `routing.json` (dev/CI; offline) - `scripts/bootstrap.py` / `scripts/migrate.py` — deterministic vault setup/repair and schema migration +## MCP tools first (when the echo-mcp connector is available) + +When this session has the **`echo_*` MCP tools** (the deployed echo-mcp server — +`echo_load`, `echo_recall`, `echo_resolve`, `echo_get_note`, `echo_get_scope`/`echo_set_scope`, +`echo_health`, `echo_capture`, `echo_link`, `echo_append_note`/`echo_patch_note`, +`echo_triage_inbox`, `echo_reflect`, `echo_log_session`), **prefer them over the CLI +for every operation they cover** — typed calls, structured results, no path +resolution or quoting. The procedures in this skill (reconcile at load, search-first, +scope discipline, third person, preview-before-apply) are unchanged and apply to both +surfaces. A `duplicate-gate` tool result is the same contract as capture exit 76: +`merge_into` or confirmed `force`, never a blind retry. The CLI recipes below are the +fallback for hosts without the connector or when the server is unreachable. + ## Bundled Tooling (prefer over raw curl) All paths below are under `${CLAUDE_PLUGIN_ROOT}/skills/echo-memory/`. **Invoke with `python3`** (on Windows where that isn't on PATH, use `python` or `py -3`). diff --git a/eval/test_mcp_server.py b/eval/test_mcp_server.py new file mode 100644 index 0000000..5aba21b --- /dev/null +++ b/eval/test_mcp_server.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""test_mcp_server.py — echo-mcp (2.2) end-to-end against the mock vault. + +Spawns mcp-server/app.py + mock_olrapi and drives the real streamable-HTTP MCP +protocol: health (open), auth (401), initialize, tools/list, and the memory-day +tools/call flow (capture -> duplicate gate as DATA -> merge_into -> recall -> +get_note -> log_session with heartbeat commit). + +Requires the `mcp` package in the interpreter that runs the SERVER. Set +ECHO_MCP_PYTHON to a venv python that has it; otherwise the current interpreter +is tried and the suite SKIPS (exit 0) when the SDK is absent — the other suites +don't depend on it. + +Run: python test_mcp_server.py [--port 8862] [--mcp-port 8767] +""" +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path + +HERE = Path(__file__).resolve().parent +APP = HERE.parent / "mcp-server" / "app.py" +KEY = "test-key-not-a-real-secret" +TOKEN = "local-test-token" + +failures = [] + + +def check(name, cond, detail=""): + print(f"{'ok ' if cond else 'FAIL'} {name}" + (f" -- {detail}" if not cond else "")) + if not cond: + failures.append(name) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--port", type=int, default=8862) + ap.add_argument("--mcp-port", type=int, default=8767) + a = ap.parse_args() + mock_base = f"http://127.0.0.1:{a.port}" + mcp_url = f"http://127.0.0.1:{a.mcp_port}/mcp" + + server_py = os.environ.get("ECHO_MCP_PYTHON") or sys.executable + probe = subprocess.run([server_py, "-c", "import mcp"], capture_output=True) + if probe.returncode != 0: + print("SKIP: `mcp` SDK not installed for the server interpreter " + "(set ECHO_MCP_PYTHON to a venv python that has it)") + return 0 + + def http(method, url, body=None, headers=None, timeout=30): + data = body.encode() if isinstance(body, str) else body + req = urllib.request.Request(url, data=data, method=method, headers=headers or {}) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.status, r.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return e.code, e.read().decode("utf-8", "replace") + except Exception as e: # noqa: BLE001 + return 0, str(e) + + _id = [0] + + def rpc(method, params): + _id[0] += 1 + st, body = http("POST", mcp_url, json.dumps( + {"jsonrpc": "2.0", "id": _id[0], "method": method, "params": params}), + headers={"Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "Authorization": f"Bearer {TOKEN}"}) + return st, (json.loads(body) if body.strip().startswith("{") else {}) + + def call(name, args): + st, d = rpc("tools/call", {"name": name, "arguments": args}) + res = d.get("result") or {} + payload = {} + if res.get("content"): + try: + payload = json.loads(res["content"][0]["text"]) + except Exception: # noqa: BLE001 + payload = {} + return res.get("isError", False), payload + + mock = subprocess.Popen([sys.executable, str(HERE / "mock_olrapi.py"), "--port", str(a.port)], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + srv = None + try: + for _ in range(50): + try: + urllib.request.urlopen(f"{mock_base}/__debug__reset", data=b"", timeout=1) + break + except Exception: + time.sleep(0.1) + http("PUT", f"{mock_base}/vault/_agent/echo-vault.md", + "---\nschema_version: 4\n---\n# marker\n", + headers={"Authorization": f"Bearer {KEY}"}) + + env = dict(os.environ, ECHO_BASE=mock_base, ECHO_KEY=KEY, ECHO_MCP_TOKEN=TOKEN, + ECHO_MCP_PORT=str(a.mcp_port), ECHO_STATE_DIR=tempfile.mkdtemp(), + ECHO_TODAY="2026-07-28") + srv = subprocess.Popen([server_py, str(APP)], env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + for _ in range(60): + st, _b = http("GET", f"http://127.0.0.1:{a.mcp_port}/health", timeout=2) + if st == 200: + break + time.sleep(0.5) + + st, body = http("GET", f"http://127.0.0.1:{a.mcp_port}/health") + try: + hb = json.loads(body) + except Exception: # noqa: BLE001 + hb = {} + check("health is open and green", st == 200 and hb.get("ok") is True + and hb.get("vault_reachable") is True, body[:200]) + st, _b = http("POST", mcp_url, "{}", headers={"Content-Type": "application/json"}) + check("MCP endpoint requires the bearer token (401)", st == 401, str(st)) + + st, d = rpc("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "eval", "version": "0"}}) + check("initialize answers with serverInfo", + d.get("result", {}).get("serverInfo", {}).get("name") == "echo-mcp", json.dumps(d)[:200]) + st, d = rpc("tools/list", {}) + tools = [t["name"] for t in d.get("result", {}).get("tools", [])] + check("tools/list exposes the full profile (14 tools)", len(tools) == 14, str(tools)) + + e, b = call("echo_capture", {"title": "Vera Lumen", "kind": "person", + "body": "CTO at Fluxcorp, met at the summit."}) + check("capture create over MCP", not e and b.get("action") == "created" + and b.get("path") == "resources/people/vera-lumen.md", json.dumps(b)) + e, b = call("echo_capture", {"title": "Vera Lumen Jr", "kind": "person"}) + check("duplicate gate is data, not isError", not e + and b.get("action") == "duplicate-gate" and b.get("candidates"), json.dumps(b)) + e, b = call("echo_capture", {"title": "Vera Lumen Jr", "kind": "person", + "merge_into": "vera-lumen", "body": "Follow-up."}) + check("merge_into resolves the gate as an update", not e and b.get("action") == "updated", + json.dumps(b)) + e, b = call("echo_recall", {"query": "fluxcorp", "budget_chars": 900}) + check("recall over MCP finds by body term", not e and any( + h["path"] == "resources/people/vera-lumen.md" for h in b.get("primary", [])), + json.dumps(b)[:300]) + e, b = call("echo_get_note", {"path": "resources/people/vera-lumen.md"}) + check("get_note returns frontmatter + content", not e + and b.get("frontmatter", {}).get("type") == "person", json.dumps(b)[:200]) + e, b = call("echo_get_note", {"path": "../etc/passwd"}) + check("get_note rejects path traversal", not b.get("ok")) + e, b = call("echo_log_session", {"slug": "mcp-e2e", "hhmm": "2345", "apply": True, + "log_body": "---\ntype: session-log\n---\n# S\n\n## Goal\ne2e\n"}) + check("log_session commits with the heartbeat", not e + and b.get("steps", {}).get("heartbeat") == "ok", json.dumps(b)) + e, b = call("echo_capture", {"title": "Bad Kind", "kind": "wizard"}) + check("unknown kind rejected with the valid list", not b.get("ok") + and "wizard" in b.get("error", ""), json.dumps(b)) + + print(f"\n{len(failures)} failure(s)" if failures else "\nall mcp-server tests passed") + return 1 if failures else 0 + finally: + if srv: + srv.terminate() + mock.terminate() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/mcp-server/app.py b/mcp-server/app.py new file mode 100644 index 0000000..00a3789 --- /dev/null +++ b/mcp-server/app.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +"""echo-mcp — the containerized MCP server over the ECHO ops layer. [2.2] + +Streamable-HTTP (stateless JSON) MCP server exposing the plugin's `*_op` cores +(Phase 0, shipped 2.1.1) as typed tools. Runs next to the Obsidian REST API on +the same box, so every vault round-trip behind a tool call is LAN-local. + +Spec: docs/MCP-SERVER-SPEC.md. Highlights implemented here: + * bearer-token auth on everything except /health (constant-time compare); + * tool results = the op envelopes, returned as structured JSON; + * duplicate gate / offline queueing are DATA (never protocol errors), so the + model deliberately chooses merge_into/force instead of blind-retrying; + * tool profiles: ECHO_MCP_TOOLS=core exposes only the six daily drivers + (schema tokens cost context on every surface that lists them); + * result budgets: echo_recall(budget_chars), echo_get_note(section/max_chars); + * per-write serialization (one process mediates all MCP writes, so a plain + lock removes the advisory-lock race window for server-mediated writes); + * startup validation: fail fast (crash-loop visibly) on missing env. + +Deliberately NOT here in v1 (spec §7.2 backlog): entity-index TTL cache — the +atomic_index_update correctness fix depends on a FRESH re-read under the lock, +so caching idx_mod.load would reintroduce the clobber race it closed. LAN +adjacency + the warm keep-alive pool make the read ~ms anyway. + +Env: ECHO_BASE, ECHO_KEY, ECHO_MCP_TOKEN (required) · ECHO_OWNER · + ECHO_MCP_PORT=8765 · ECHO_MCP_TOOLS=full|core · ECHO_STATE_DIR=/data +""" +from __future__ import annotations + +import contextlib +import hmac +import json +import os +import re +import subprocess +import sys +import threading +from pathlib import Path + +# --- locate the plugin scripts (image layout first, repo layout for local dev) --- +_HERE = Path(__file__).resolve().parent +for _cand in (_HERE / "scripts", + _HERE.parent / "echo-memory.plugin.src" / "skills" / "echo-memory" / "scripts"): + if _cand.is_dir(): + SCRIPTS = _cand + break +else: # pragma: no cover + sys.exit("echo-mcp: cannot locate the ECHO scripts directory") +sys.path.insert(0, str(SCRIPTS)) + +# --- startup validation: a misdeployed container must crash-loop visibly --------- +_missing = [k for k in ("ECHO_BASE", "ECHO_KEY", "ECHO_MCP_TOKEN") if not os.environ.get(k)] +if _missing: + sys.exit(f"echo-mcp: missing required env: {', '.join(_missing)} " + "— check the PORT template / secret refs") + +# Modules aliased *_mod: several tool functions below share a module's name +# (echo_recall, echo_reflect, ...) and would shadow it at module scope otherwise. +import echo # noqa: E402 +import echo_doctor as doctor_mod # noqa: E402 +import echo_index as idx_mod # noqa: E402 +import echo_ops as ops_mod # noqa: E402 +import echo_queue as queue_mod # noqa: E402 +import echo_recall as recall_mod # noqa: E402 +import echo_reflect as reflect_mod # noqa: E402 +import echo_session as session_mod # noqa: E402 +import echo_triage as triage_mod # noqa: E402 + +from mcp.server.fastmcp import FastMCP # noqa: E402 +from starlette.requests import Request # noqa: E402 +from starlette.responses import JSONResponse, Response # noqa: E402 +from starlette.middleware.base import BaseHTTPMiddleware # noqa: E402 + +PORT = int(os.environ.get("ECHO_MCP_PORT", "8765")) +TOKEN = os.environ["ECHO_MCP_TOKEN"] +PROFILE = os.environ.get("ECHO_MCP_TOOLS", "full").strip().lower() +KINDS = sorted(idx_mod.KIND_FOLDER) + +mcp = FastMCP( + "echo-mcp", + instructions=( + "Persistent memory over the operator's ECHO Obsidian vault. Use echo_load at " + "the start of substantive sessions, echo_recall/echo_resolve to read, " + "echo_capture as the default write, and echo_log_session to end a session. " + "A duplicate-gate result is a decision point, not an error — merge_into or " + "(confirmed-distinct) force. Write about the operator in third person."), + host="0.0.0.0", + port=PORT, + stateless_http=True, + json_response=True, +) + +WRITE_LOCK = threading.Lock() # all MCP-path writes serialize through this process + + +def _guard(write: bool, fn, *args, **kw) -> dict: + """Run an op core with stdout redirected to stderr (belt-and-braces stream + purity) and EchoError mapped to an actionable error envelope, not a protocol + error. Write ops serialize on WRITE_LOCK.""" + lock = WRITE_LOCK if write else contextlib.nullcontext() + try: + with lock, contextlib.redirect_stdout(sys.stderr): + return fn(*args, **kw) + except RuntimeError as exc: # EchoError (either module instance) subclasses it + code = getattr(exc, "code", 1) + err = {"ok": False, "error": str(exc), "code": code} + if code == 78: + err["hint"] = ("server deployment is missing vault credentials — operator: " + "check the PORT template env (SECRET: refs)") + elif code == 44: + err["code_name"] = "not-found" + elif "unreachable" in str(exc): + err["hint"] = ("vault unreachable (Obsidian/REST plugin likely down) — " + "proceed without memory; writes queue durably") + return err + + +_SAFE_PATH = re.compile(r"^[^/][^\0]*$") + + +def _check_path(path: str) -> str | None: + if not path or path.startswith("/") or ".." in path.split("/") or not _SAFE_PATH.match(path): + return "invalid path: must be vault-relative, no leading '/' and no '..'" + return None + + +def _frontmatter(text: str) -> dict: + out: dict = {} + if text.startswith("---"): + end = text.find("\n---", 3) + for ln in text[3:end if end != -1 else len(text)].splitlines(): + m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", ln) + if m: + out[m.group(1)] = m.group(2).strip().strip('"').strip("'") + return out + + +# ------------------------------------------------------------------ tools ------- +CORE_TOOLS = {"echo_load", "echo_recall", "echo_capture", "echo_triage_inbox", + "echo_log_session", "echo_health"} + + +def tool(fn): + """Register `fn` as an MCP tool unless the core profile excludes it.""" + if PROFILE == "core" and fn.__name__ not in CORE_TOOLS: + return fn + return mcp.tool()(fn) + + +@tool +def echo_load(brief: bool = True) -> dict: + """Cold-start memory orientation — call at the start of a substantive session. + brief=true (default) returns a token-budgeted digest plus structured sections; + brief=false returns the six raw sections. Also flushes writes queued offline.""" + return _guard(True, echo.load_op, brief) + + +@tool +def echo_recall(query: str, limit: int = 6, budget_chars: int = 4000, + include_linked: bool = True) -> dict: + """Search memory: ranked matches for a topic/person/project PLUS their linked + neighbourhood. Excerpts are packed into budget_chars by score — call + echo_get_note for any hit's full content. Freshness/status-aware ranking.""" + limit = max(1, min(int(limit), 20)) + budget = max(500, min(int(budget_chars), 20000)) + env = _guard(False, recall_mod.recall_op, query, limit) + if not env.get("ok"): + return env + if not include_linked: + env["linked"] = [] + used = 0 + for hit in (env.get("primary") or []) + (env.get("linked") or []): + ex = hit.get("excerpt") or "" + room = max(0, budget - used) + if len(ex) > room: + hit["excerpt"] = ex[:room] + ("… (truncated — echo_get_note for full)" if room else "") + hit["truncated"] = True + used += len(hit.get("excerpt") or "") + return env + + +@tool +def echo_resolve(mention: str) -> dict: + """Resolve a name/mention to its canonical vault note (alias-aware), or get + did-you-mean candidates. Call before creating any note by hand; echo_capture + does this automatically.""" + return _guard(False, ops_mod.resolve_op, mention) + + +@tool +def echo_get_note(path: str, section: str = "", max_chars: int = 8000) -> dict: + """Read one vault note (vault-relative path). section='Status' returns only that + ## section. Content over max_chars is truncated with a marker.""" + bad = _check_path(path) + if bad: + return {"ok": False, "error": bad} + + def _read() -> dict: + status, body = echo.request("GET", echo.vault_url(path)) + if status == 404: + return {"ok": False, "code_name": "not-found", "path": path, + "error": f"{path}: not found"} + echo.check(status, body, f"get {path}") + text = body.decode(errors="replace") + fm = _frontmatter(text) + if section: + text = echo.extract_heading(text, section) + if not text: + return {"ok": False, "path": path, + "error": f"no '## {section}' section in {path}"} + truncated = len(text) > max_chars + return {"ok": True, "path": path, "frontmatter": fm, + "content": text[:max_chars] + ("\n… (truncated)" if truncated else ""), + "truncated": truncated} + return _guard(False, _read) + + +@tool +def echo_get_scope() -> dict: + """The operator's active scope + freshness. If sessions_since >= 3, treat the + recorded scope as suspect and confirm with the operator before working.""" + return _guard(False, echo.scope_show_op) + + +@tool +def echo_set_scope(scope: str) -> dict: + """Switch the active scope atomically (archives the prior scope to Scope History + and stamps freshness). Use when the session's work diverges from the recorded + scope.""" + return _guard(True, echo.scope_set_op, scope) + + +@tool +def echo_health(deep: bool = False) -> dict: + """ECHO readiness: config, vault reachability, auth, bootstrap/schema, and the + offline-queue depth. deep=true also runs the full vault-invariant linter.""" + env = _guard(False, doctor_mod.run_op) + try: + env["outbox_depth"] = len(queue_mod.pending()) + env["needs_attention"] = len(queue_mod.needs_attention()) + except Exception: # noqa: BLE001 + pass + if deep and env.get("ok"): + r = subprocess.run([sys.executable, str(SCRIPTS / "vault_lint.py")], + capture_output=True, text=True, + env=dict(os.environ), timeout=120) + env["lint_exit"] = r.returncode + env["lint_report"] = (r.stdout or r.stderr)[-6000:] + return env + + +@tool +def echo_capture(title: str, kind: str = "", body: str = "", tags: list[str] = [], + aliases: list[str] = [], sources: list[str] = [], status: str = "", + date: str = "", domain: str = "business", merge_into: str = "", + force: bool = False, dry_run: bool = False) -> dict: + """The default memory write: routes by kind (person, company, concept, reference, + meeting, project, area, semantic, episodic, working, skill, decision), stamps + complete frontmatter, indexes, auto-links, and writes the Agent-Log line — one + call. Omit kind for an inbox capture. If the result is action='duplicate-gate', + do NOT retry blindly: call again with merge_into= to update the + existing entity, or force=true only after confirming they are genuinely distinct. + Offline, the whole capture queues durably (queued=true).""" + if kind and kind not in KINDS: + return {"ok": False, "error": f"unknown kind '{kind}' — one of: {', '.join(KINDS)}"} + return _guard(True, ops_mod.capture_op, kind or None, title, + body_text=body or "", status_v=status, aliases=aliases, + sources=sources, tags=tags, date=date or None, domain=domain, + inbox=not kind, dry_run=dry_run, force=force, + merge_into=merge_into or None) + + +@tool +def echo_link(a: str, b: str) -> dict: + """Add reciprocal '## Related' links between two notes. Accepts vault paths or + resolvable entity names (aliases work).""" + def _to_path(x: str) -> str | None: + if "/" in x: + return x if x.endswith(".md") else x + ".md" + nmap = idx_mod.name_map(idx_mod.load()) + return nmap.get(idx_mod.slugify(x)) + def _do() -> dict: + pa, pb = _to_path(a), _to_path(b) + if not pa or not pb: + missing = a if not pa else b + return {"ok": False, "error": f"'{missing}' resolves to no note — " + "pass a vault path or a known entity name"} + return ops_mod.link_op(pa, pb) + return _guard(True, _do) + + +@tool +def echo_append_note(path: str, line: str) -> dict: + """Append one line to a note, idempotently (skipped if the exact line already + exists). For inbox lines, Agent-Log entries, Observations bullets.""" + bad = _check_path(path) + if bad: + return {"ok": False, "error": bad} + def _do() -> dict: + rc = echo.cmd_append(path, line) + return {"ok": rc == 0, "action": "append", "path": path} + return _guard(True, _do) + + +@tool +def echo_patch_note(path: str, operation: str, target_type: str, target: str, + content: str) -> dict: + """Targeted edit: append/prepend/replace under a heading, frontmatter field, or + block. HEADING TARGETS must be the full '::'-delimited path from the top-level + heading (e.g. 'Operator Preferences::Fact / Pattern') — on an invalid target the + error includes the note's actual headings. replace overwrites the section.""" + bad = _check_path(path) + if bad: + return {"ok": False, "error": bad} + def _do() -> dict: + rc = echo.cmd_patch(path, operation, target_type, target, + echo.temp_file(content.encode("utf-8"))) + return {"ok": rc == 0, "action": f"patch:{operation}", "path": path, + "target": target} + env = _guard(True, _do) + if not env.get("ok") and "HTTP 400" in str(env.get("error", "")) and target_type == "heading": + st, body = echo.request("GET", echo.vault_url(path), + headers={"Accept": "application/vnd.olrapi.document-map+json"}) + if st == 200: + try: + headings = [h.get("heading") or h for h in + json.loads(body).get("headings", [])][:40] + env["available_headings"] = headings + env["hint"] = "use one of available_headings verbatim as the Target" + except Exception: # noqa: BLE001 + pass + return env + + +@tool +def echo_triage_inbox(proposals: list[dict] = [], apply: bool = False) -> dict: + """Inbox triage. No proposals => structured listing of captures (line, date, text, + age_days). With proposals (reflect schema + optional 'line'): apply=false previews + the routing; apply=true routes via capture and writes the processing-log audit. + List first, propose, preview, then apply only after the operator confirms.""" + if not proposals: + return _guard(False, triage_mod.list_op) + return _guard(True, triage_mod.route_op, proposals, apply) + + +@tool +def echo_reflect(proposals: list[dict], apply: bool = False) -> dict: + """Session-reflection proposals: validate -> classify against the entity index -> + preview (apply=false) -> apply (routes each via capture). Never apply without the + operator's go-ahead; never invent memories to have something to save.""" + return _guard(True, reflect_mod.apply_op, proposals, apply) + + +@tool +def echo_log_session(slug: str, log_body: str, agent_log_line: str = "", + scope: str = "", reflect: list[dict] = [], + apply: bool = False, hhmm: str = "") -> dict: + """End a substantive session in ONE call: session log -> Agent-Log line -> reflect + proposals -> optional scope switch -> heartbeat LAST (the commit marker). + apply=false previews the plan. Pass hhmm (local time, e.g. '1430') so the log + filename sorts truthfully.""" + bundle = {"slug": slug, "log_body": log_body} + if agent_log_line: + bundle["agent_log_line"] = agent_log_line + if scope: + bundle["scope"] = scope + if reflect: + bundle["reflect"] = reflect + def _do() -> dict: + prev = os.environ.get("ECHO_NOW") + try: + if hhmm: + os.environ["ECHO_NOW"] = hhmm + return session_mod.session_end_op(bundle, apply=apply) + finally: + if hhmm: + if prev is None: + os.environ.pop("ECHO_NOW", None) + else: + os.environ["ECHO_NOW"] = prev + return _guard(True, _do) + + +# ------------------------------------------------------------- health + auth ---- +@mcp.custom_route("/health", methods=["GET"]) +async def health(_request: Request) -> JSONResponse: + """Unauthenticated liveness for Docker HEALTHCHECK + Kuma. No vault data.""" + st, _ = echo.request("GET", echo.vault_url("_agent/echo-vault.md")) + try: + outbox = len(queue_mod.pending()) + except Exception: # noqa: BLE001 + outbox = -1 + return JSONResponse({"ok": True, "vault_reachable": st != 0, + "outbox_depth": outbox, "profile": PROFILE}) + + +class BearerAuth(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + if request.url.path == "/health": + return await call_next(request) + auth = request.headers.get("authorization", "") + if not hmac.compare_digest(auth, f"Bearer {TOKEN}"): + return Response(status_code=401) + return await call_next(request) + + +def main() -> None: + import uvicorn + app = mcp.streamable_http_app() + app.add_middleware(BearerAuth) + print(f"echo-mcp: serving on :{PORT} (profile={PROFILE}, vault={echo.BASE})", + file=sys.stderr) + uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/mcp-server/requirements.txt b/mcp-server/requirements.txt new file mode 100644 index 0000000..e4995d4 --- /dev/null +++ b/mcp-server/requirements.txt @@ -0,0 +1,4 @@ +# echo-mcp server deps. The PLUGIN stays pure-stdlib; the dependency budget for the +# container is deliberately tiny: the official MCP SDK (brings starlette/uvicorn/ +# pydantic) and nothing else. +mcp>=1.9,<2