# 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`.
>
> **Prerequisites before starting this build:**
> 1. The `session-end` verb (IMPROVEMENT-PLANS #7) — the MCP tool wraps it.
> 2. 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), Windows `python3`-vs-`python` dispatch.
- **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:
1.**The backend is already remote.** Clients round-trip to
`echoapi.alwisp.com` today; 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.
2.**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.
3.**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.
4.**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. |
| 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.
1. Each high-level op gets a **core function returning an envelope dict**,
raising `echo.EchoError` on failure; CLI entry points become thin printing
| 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. |