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 <noreply@anthropic.com>
24 KiB
echo-mcp — MCP Server Build Spec (containerized)
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 suiteeval/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:
- The
session-endverb (IMPROVEMENT-PLANS #7) — the MCP tool wraps it.- Phase 0 below (the return-not-print refactor) — lands in the plugin tree first and is independently shippable.
1. Why an MCP server
Every ECHO operation today rides through Bash: resolve $ECHO (with the CoWork
path-fallback snippet copy-pasted everywhere), quote a Python invocation, run
it, re-parse stdout. Costs:
- Tokens — each memory op carries bash scaffolding + output re-parsing; a capture is ~3× the tokens of a typed tool call.
- Fragility — quoting hazards, the
${CLAUDE_PLUGIN_ROOT}sandbox mismatch (the whole 1.4.2 release), Windowspython3-vs-pythondispatch. - Reach — surfaces without a Python-capable shell can't use ECHO at all.
- Efficiency — every CLI invocation is a cold Python start that re-reads the entity index from the vault over HTTPS.
What the server does not replace: SKILL.md remains the procedure authority (when to load, reconcile, search-first, third-person, etc.). The server replaces the mechanics — tools are the verbs, the skill is the discipline.
2. Architecture: remote container (decided)
A standalone Docker container on the Unraid box (ALPHA), published behind
Cloudflare + NPM as echomcp.alwisp.com, speaking MCP streamable HTTP
(stateless JSON) with bearer-token auth. Chosen over the earlier local-stdio
draft for four reasons:
- The backend is already remote. Clients round-trip to
echoapi.alwisp.comtoday; a server co-located with the vault host turns every vault op into a LAN hop and collapses N client→vault calls into one client→server call. - Any device, no per-machine anything. Desktop, CoWork sandbox, claude.ai
(custom connector), mobile — same URL + token. The
${CLAUDE_PLUGIN_ROOT}CoWork registration risk from the stdio draft disappears entirely. - Credentials consolidate server-side. The vault key lives in the container env (PORT secret store); the image is credential-free; clients hold only an MCP bearer token. The per-user baked-key builds (1.4.x) become unnecessary for any surface that has MCP. Rotation = redeploy.
- Room to grow. The plugin's pure-stdlib constraint exists because its scripts run in arbitrary sandboxes. The container is ours: real SDK, SQLite/in-memory indexes that stay warm, background jobs, embeddings later — all without touching the plugin.
What stays client-side (unchanged and shipped): the skill + CLI path — it is the fallback when the server or network is down, the offline queue + read cache keep their per-machine role there, and the SessionStart/Stop hooks stay CLI-based (hooks run shell commands regardless of MCP).
Cost acknowledged: a second service to run and an exposed auth surface. Mitigations are existing infra: CF + NPM cert, long random bearer token, Kuma monitor, PORT deploy/rollback. Server down ⇒ exactly today's behavior.
3. Decisions (made — don't relitigate in the build session)
| Decision | Choice | Why |
|---|---|---|
| Language | Python | Imports the existing echo_*.py modules in-process — the entire ops layer is reused, not wrapped. |
| SDK | Official mcp Python SDK (FastMCP) |
The stdlib-only rule doesn't bind inside our own image. Hand-rolling the protocol (stdio draft) is no longer justified. |
| Transport | Streamable HTTP, stateless JSON | Remote, multi-client, simplest to scale; SSE/sessions explicitly avoided. |
| Auth | Static bearer token (ECHO_MCP_TOKEN), constant-time compare, 401 otherwise |
Single-operator service; OAuth is overkill. Token from the PORT secret store. |
| Vault credentials | Container env: ECHO_BASE, ECHO_KEY, ECHO_OWNER (SECRET: refs in the PORT template) |
echo_config.py already resolves env-first — zero code change. Image ships no secrets. |
| Server name / repo home | echo-mcp; lives in this repo under mcp-server/ (own Dockerfile), deployed from git.alwisp.com/jason/echo CI |
One canonical tree — the server vendors skills/echo-memory/scripts/ into the image at build time; no second hand-maintained copy (the Codex-drift lesson). |
| Tool prefix | echo_ |
Namespace safety next to other servers. |
| Result shape | structuredContent (the echo_output.envelope dict) + a 1–3 line human text block |
Envelope shape already exists. |
| Statefulness | Stateless protocol; warm in-process caches | See §7. |
| Version target | 2.2 — ALL prerequisites shipped 2026-07-28 (session-end in 2.1.0; Phase 0 in 2.1.1, contract-tested by eval/test_ops_api.py) |
The build session starts directly at §5 (the server app). |
| Local stdio variant | Dropped for v1 (documented, not built) | The CLI/skill fallback covers the no-server case; two transports = two test matrices for little gain. Phase 0 keeps the door open — the *_op core is transport-agnostic. |
| Result budgets | Every read tool takes an explicit size budget (budget_chars / max_chars) and truncates with a marker + "call X for more" |
The single biggest token lever: one right-sized answer instead of follow-up fetches. |
| Tool profiles | ECHO_MCP_TOOLS=core|full env: core exposes only load/recall/capture/triage_inbox/log_session/health |
Tool schemas cost context on every surface that lists them; the claude.ai connector doesn't need the escape hatches. Desktop registers full. |
4. Phase 0 — the return-not-print refactor (prerequisite, lands in the plugin)
Unchanged from the stdio draft, and still first: the ops layer prints results
(even --json mode prints from inside echo_ops.capture) and returns exit
codes; the server needs return values.
-
Each high-level op gets a core function returning an envelope dict, raising
echo.EchoErroron failure; CLI entry points become thin printing wrappers:Module New core fn CLI wrapper keeps echo_opscapture_op,resolve_op,link_opcapture/resolve/linkecho_recallrecall_op(query, limit)recallecho_triagelist_op()·route_op(proposals, apply)list_inbox/applyecho_reflectapply_op(proposals, apply)applyecho.pyload_op(brief)·scope_show_op/scope_set_op·doctor_opcmd_*echo_session(new, #7)session_end_op(bundle, apply)subcommand -
Special results are data, not exit codes: duplicate gate ⇒
{ok: false, action: "duplicate-gate", candidates}(CLI maps to exit 76); offline queueing ⇒{ok: true, queued: true}. -
Helper chatter (
ok: PUT …) moves behind anotify(msg)callback defaulting to stderr; delete theredirect_stdouthack incapture. -
Tests: existing suites pass unchanged; new unit tests hit
*_opdirectly against the mock and assert envelopes.
Shippable on its own as a 1.6.x refactor with zero behavior change.
5. Server application (mcp-server/app.py)
-
FastMCP app, streamable HTTP, stateless. One
@mcp.toolper tool in §6, each a thin adapter: validate → call the*_opcore → wrap. -
Auth middleware: require
Authorization: Bearer <ECHO_MCP_TOKEN>on every request (constant-time compare); 401 with no detail otherwise. Additionally bind the app to the container interface only; exposure policy lives at NPM/Cloudflare. -
Result wrapper:
def tool_result(env: dict, summary: str) -> ...: # content: [{type: "text", text: summary}] (1–3 lines, human) # structuredContent: env (echo_output envelope) # isError: not env.get("ok", True) — except the deliberate non-errors below -
Error map (tool-level results, never protocol errors):
Condition Result Vault unreachable on a read isError: true— "vault unreachable (Obsidian/REST plugin likely down); proceed without memory, writes will queue server-side".Vault unreachable on a write Not an error: {ok: true, queued: true}— the server-side outbox (§7) replays when the vault returns.404 isError: true,{code: "not-found", path}.Duplicate gate Not an error: {ok: false, action: "duplicate-gate", candidates}+ text naming the two resolutions (merge_into/force) — the model must decide, not blind-retry.Lock held {ok: false, action: "lock-held", holder}.Misconfigured deployment (no ECHO_BASE/ECHO_KEY)isError: true— "server deployment is missing vault credentials — operator: check the PORT template env". Startup also fails loudly (see §8 healthcheck).Anything else isError: true, first line only; traceback to the container log. -
Validation: FastMCP/pydantic handles types/enums/required; add
model_config = ConfigDict(extra="forbid")so unknown params (model typos) fail with a field-naming message.
6. Tool surface (13 tools)
Same surface as the stdio draft minus echo_configure — with a remote
server there is no per-machine config to import; credentials are
deployment-side. Descriptions are written for the model: what it does, when to
use it, what it returns, ≤3 sentences.
6.1 Orientation & read
echo_load— cold-start orientation.brief: bool = true(IMPROVEMENT-PLANS #1 digest;false= full six-file dump). Returns{sections: {marker, preferences, scope, last_session, today, inbox_count, inbox_oldest_days}, queued_flushed, offline}. Also flushes the server-side outbox.readOnlyHint: false(flush),openWorldHint: true.echo_recall—query: str,limit: int = 6 (1–20),include_linked: bool = true,budget_chars: int = 4000 (500–20000). Returns therecall --jsonshape (primary/linkedwith path/score/type/updated/status/excerpt) packed to the budget: the server allocates excerpt space by score, so the model gets the most relevant content in one right-sized answer instead of follow-up fetches. Description: truncated hits are marked — callecho_get_notefor full content.readOnlyHint: true,idempotentHint: true.echo_resolve—mention: str→ match{slug, path, kind, title, aliases}or{match: false, suggest_slug, candidates}. "Call before creating any note by hand;echo_capturedoes this automatically."readOnlyHint: true.echo_get_note—path: str(vault-relative; reject../leading/; warn-not-block on paths outsiderouting.json);section: str = null(a heading name — return only that section, e.g.Status);max_chars: int = 8000. Returns{path, content, frontmatter, truncated?}.readOnlyHint: true.echo_get_scope—{scope, scope_updated, sessions_since}; description tells the model to confirm scope with the operator whensessions_since ≥ 3.readOnlyHint: true.echo_set_scope—scope: str; atomic switch (history + replace + stamp).idempotentHint: true.echo_health—deep: bool = false. Shallow = doctor checks (vault reachability, auth, marker/schema, outbox depth). Deep = fullvault_lint→{violations: [{check, path, detail}]}.readOnlyHint: true.
6.2 Write
echo_capture— the default write (route + frontmatter + index + auto-link + agent-log in one call). Params:title(req);kind: enum[person, company, concept, reference, meeting, project, area, semantic, episodic, working, skill, decision](omit ⇒ inbox line);body: str = ""(markdown, inline);tags/aliases/sources: str[];status: str;date: YYYY-MM-DD(meeting/decision);domain: enum[business, personal, learning, systems] = business(area);merge_into: str(slug);force: bool = false;dry_run: bool = false. Returns the capture envelope (action: created|updated|inbox|duplicate-gate,path,links_added,near_duplicates?,candidates?). Description spells out the gate contract (never blind-retry;merge_intoor confirmedforce).destructiveHint: false,idempotentHint: true.echo_link—a,b(paths or resolvable names — server resolves via the index). Returns{a, b, a_changed, b_changed}.idempotentHint: true.echo_append_note—path,line; whole-line idempotent append.echo_patch_note—path,operation: enum[append, prepend, replace],target_type: enum[heading, frontmatter, block],target,content. Description carries the hard-won rules: heading targets are the full::-delimited path from the H1; on a 400 invalid-target the server fetches the document map and returns the actual heading list in the error — the worst silent-loss failure becomes self-correcting.destructiveHint: true. (Deliberately noecho_put_note/echo_delete_notein v1 — whole-file overwrite and deletion stay behind the CLI + operator explicitness; session logs go throughecho_log_session.)echo_triage_inbox—proposals: object[] = [](PROPOSAL_SCHEMA + optionalline),apply: bool = false. Empty ⇒ structured listing; with proposals ⇒ preview, then route + processing-log audit onapply: true. "List → propose → preview → apply only after the operator confirms."echo_reflect—proposals: object[](req),apply: bool = false. Same preview/apply contract as the CLI; description restates: never apply without the operator's go-ahead, never invent memories.echo_log_session— the session-end bundle (wrapssession_end_op, #7):slug,log_body,agent_log_line,scope?: str,reflect?: object[],apply: bool = false. Per-step results; heartbeat written last as the commit marker.
6.3 v1.1 tools (specced now, built after v1 ships)
echo_rollup_data—period: enum[week, month],date: YYYY-MM-DD. Assembles the rollup digest data in one call: open threads acrossprojects/active/(each note's## Status+updated:), inbox items aging past 7 days, and the period's## Scope Historyentries. The model writes the prose; the server does the N fetches. Same division of labor as reflect.readOnlyHint: true.echo_note_history—path: str→{versions: [{id, ts, bytes, summary_line}]}from the server's shadow write history (§7).readOnlyHint: true.echo_restore_note—path: str,version_id: str,confirm: bool = false(preview diff unless confirmed). The undo for a badreplacePATCH or merge PUT. Description: operator confirmation required before calling withconfirm: true.destructiveHint: true.
Deliberately absent from v1 (documented in the server README): sweep, bootstrap, migrate, lock/unlock (vault-wide maintenance stays operator-initiated via slash commands — routine sweeps run as background jobs instead), raw search/ls/map (recall/resolve/get_note cover reads; add only if transcripts show the need), and reflect extraction (only the model has the conversation — that division of labor is permanent, not a v1 scope cut).
7. Server internals — where the container earns its keep
7.1 v1
- Warm entity index: loaded once, invalidated on any index-writing tool and
on a short TTL (
ECHO_MCP_INDEX_TTL, default 60s) to pick up other clients' writes. Ends the per-invocation index re-read entirely. - Recall index in memory (+ SQLite file at
/data): the BM25 index lives in process memory, persisted to the container volume — recall answers in milliseconds with zero vault round-trips. This supersedes the server-side half of IMPROVEMENT-PLANS #2; #2's local-state-dir design still applies to the CLI fallback path. - Note read cache: short-TTL (30–60s) body cache for
get_noteand recall's neighbourhood expansion — repeated same-session reads of the same files stop hitting the vault at all. - Per-path write serialization: an internal per-path mutex serializes conflicting MCP-path writes — since all server-mediated writes flow through one process, the advisory-lock race window disappears for them. The server still takes the vault advisory lock around index updates to coordinate with CLI clients; idempotent appends stay as the last line of defense.
- Server-side outbox: the existing
echo_queuepointed at/data(ECHO_STATE_DIR=/data) — writes queue when the vault is down and flush on recovery/load. One queue at the always-on host instead of per-laptop. - Concurrency: FastMCP serves requests concurrently; guard the in-process
caches with a plain
threading.Lock; write integrity per the bullet above. - Logging: one line per call to stdout (container log): tool, ms, ok/queued/gated/error. Never log bodies or keys.
7.2 v1.1 container dividends (backlog, in priority order)
- Nightly vault backup — the vault currently has no disaster-recovery
story; if the Obsidian host dies, memory is gone. A timer job walks the
vault via
read_many, writes a dated zip to/data/backups/(rotate 30), and reports the last-backup age in/health. Zero tokens; arguably the highest-value item in this spec. - Shadow write history + undo — before any PUT / PATCH-replace the server
passes through, snapshot the prior body to
/data/history/(content- addressed, per-path ring of ~20 versions). Backs theecho_note_history/echo_restore_notetools (§6.3). The vault's additive philosophy finally gets an undo for its residual risk: a badreplaceor merge PUT. - Background maintenance jobs — a timer thread running
sweep --fast(IMPROVEMENT-PLANS #4) hourly and the decay pass (#5) proposals weekly; both publish findings to/healthand the vault-health note rather than auto-fixing. - Ops alerting — outbox stuck > N hours, vault unreachable > 1h, new lint
violations after a background sweep ⇒ notify via the existing Unraid
notification plumbing (Kuma already watches
/healthfor liveness). Problems surface without anyone polling. - Embeddings (explicitly deferred): the container is where an embedding recall tier would land later (model API or local), as an image upgrade — no plugin change. Noted so nobody bolts it into the plugin.
8. Container & deployment
- Image:
python:3.12-slim(not alpine — no musl surprises),pip install mcppinned, copyskills/echo-memory/scripts/+mcp-server/in. Dockerfile must be legacy-format — the git.alwisp.com CI runner has no BuildKit: no# syntax=line, noRUN --mount. - Healthcheck: probe
http://127.0.0.1:<port>/health— 127.0.0.1, not localhost (the ::1-vs-IPv4 lesson from cpas/memer/breedr)./health(unauthenticated, no vault data):{ok, vault_reachable, outbox_depth, index_age_s}— also the Kuma target. - Env (via the PORT template, secrets as
SECRET:refs):ECHO_BASE,ECHO_KEY,ECHO_OWNER,ECHO_MCP_TOKEN,ECHO_STATE_DIR=/data,ECHO_MCP_TOOLS=full(orcore— see §3 tool profiles), optionalECHO_WORKERS/ECHO_TIMEOUT/ECHO_MCP_INDEX_TTL. Volume:/data(outbox + recall index + backups + shadow history). - Startup validation: fail fast (non-zero exit) if
ECHO_BASE/ECHO_KEY/ECHO_MCP_TOKENare missing — a misdeployed container should crash-loop visibly, not serve errors. - CI/deploy: Gitea workflow on push-to-main (tags/releases do not
autobuild on this host) builds the image and the PORT autodeploy webhook
rolls it out;
deploy.unraid.ymlmanifest inmcp-server/. Publishechomcp.alwisp.comvia the usual CF + NPM flow; add the Kuma monitor. - Vault adjacency (confirmed: Obsidian runs on ALPHA): the Local REST
API's non-encrypted HTTP binding is enabled (2026-07-28) —
ECHO_BASE=http://10.2.0.35:27123— so the container talks to Obsidian directly, never through the publicechoapi.alwisp.comhairpin. All vault chatter behind a tool call stays host/LAN-local; vault ops stop depending on Cloudflare/DNS/NPM health entirely (the public chain is only needed for theechomcpingress). Why HTTP not HTTPS here: the REST API's HTTPS port (27124) uses a self-signed cert, whichecho.py's default-verifyingHTTPSConnectionrejects — cleartext on the internal network beats adding an insecure-TLS flag to the client. Exposure check (do once): 27123 carries the vault bearer token in cleartext, so it must not be reachable from outside — confirm no router/firewall forward for 27123; the only public doors stay HTTPS 443 via NPM (echoapifor the CLI fallback path, which remains unchanged, andechomcpfor this server).
9. Client registration & fallback
- Claude Code / CoWork: register as a remote MCP server —
https://echomcp.alwisp.com/mcpwith theAuthorization: Bearerheader (project or user scope). No plugin-manifest coupling; the plugin does not register the server. - claude.ai: custom connector with the same URL + token — ECHO memory from the browser/phone, a surface the plugin could never reach.
- SKILL.md gains: "When the
echo_*MCP tools are available, prefer them for every operation they cover; the$ECHOCLI recipes are the fallback for hosts without the connector or when the server is unreachable." Procedures unchanged. - Fallback matrix: server up ⇒ tools. Server down, machine has the plugin ⇒ CLI path exactly as today (including its own offline queue). Both down ⇒ today's "vault unreachable, proceed without memory".
10. Multi-user note (Andy / Bryan / Gretchen / Arsalan)
One container serves one vault. The image is credential-free, so additional
users are additional deployments of the same image with their own env (their
vault endpoint/key + their own ECHO_MCP_TOKEN), e.g. echomcp-bryan on
another port/subdomain. Cheap, isolated, no code change. A single multi-tenant
server (token → vault map) is deliberately out of scope — that's CHORUS's
territory.
11. Testing & eval
- Protocol/integration tests (
eval/test_mcp_server.py): run the FastMCP app in-process (or viahttpxtest client) againstmock_olrapi; assert initialize/tools-list schemas, auth (no token ⇒ 401; bad token ⇒ 401;/healthopen), envelope purity on every tool. - Tool behavior: per tool — happy path, validation failure (unknown param
rejected with field name), vault-down (writes ⇒
queued: true, reads ⇒ actionable error), duplicate-gate flow (gate →merge_intoretry succeeds). - Parity:
capturevia MCP and via CLI against twin mock vaults produce byte-identical notes/index entries (guards Phase 0). - Container smoke (CI): build image, start with mock env,
/healthgreen, one authedtools/callround-trip; healthcheck probes 127.0.0.1. - MCP Inspector manual pass against the deployed container.
- Eval set (mcp-builder Phase 4): 10 read-only Q&A pairs against a seeded
mock vault exercising recall→get_note→resolve chains;
eval/mcp_eval.xml.
12. Build-session plan of record
| Phase | Deliverable | Est. size |
|---|---|---|
| 0 | Return-not-print refactor in the plugin tree (*_op cores); suites green |
~⅓ the session |
| 1 | mcp-server/: FastMCP app, auth middleware, result wrapper, error map, /health |
~200 lines |
| 2 | Read tools (load, recall, resolve, get_note, scope×2, health) | wiring |
| 3 | Write tools (capture, link, append/patch, triage, reflect, log_session) | wiring + descriptions |
| 4 | Warm caches + /data outbox + startup validation |
small |
| 5 | Dockerfile (legacy syntax) + CI workflow + PORT manifest + deploy + CF/NPM publish + Kuma | infra pass |
| 6 | Tests (§11) + Inspector pass + eval XML; SKILL.md/README/CHANGELOG | ~⅓ the session |
Definition of done: Inspector lists 13 tools against echomcp.alwisp.com; a
live session (desktop and CoWork) performs a full memory day — load →
recall → capture → gate → merge_into → triage → log_session — without one Bash
call; parity test green; container smoke test in CI; Kuma monitor green.