#!/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()