Skip to documentation

Capability clusters

C5 · Agent-native surface (MCP)

The cluster that makes the eli workspace a first-class tool for external AI agents: one 16-tool MCP surface served over two transports, plus every credential, setup, and guidance mechanism that gets agents connected and identified.

A single 16-tool MCP surface — registered once in lib/mcp/server.ts — is served over two transports: a packaged stdio binary (bin/eli-mcp.mjs, local mode only) and a Next.js streamable-HTTP/SSE route (app/api/mcp/[transport]/route.ts, both modes). The cluster also owns every credential agents use (SaaS workspace PATs, the local MCP_LOCAL_TOKEN shared secret, per-extension pairing tokens), the setup wizard that writes MCP config files for five agent brands (Claude Code, Cursor, Codex CLI, Gemini CLI, OpenClaw), the AGENTS.md guidance generator (a complete but currently unwired library), and the OpenClaw .openclaw/ memory convention that lets agents persist and search memory as ordinary notes. Every tool call bottoms out in the C1 StorageAdapter, so agents get the same versioning, indexing, and side-effect fan-out as the human UI.

Capabilities

CapabilityWhat it doesEntry pointsModes
Tool list_artifactsLists notes filtered by frontmatter type, tag, free-text query; limit default 50, max 500; workspace param is a documented no-op (workspace pinned at adapter construction). Storage calls: storage.listNotes(filter, {limit})lib/mcp/tools/read.ts:49-114both
Tool read_artifactReads a note by ULID or vault-relative path; returns round-trippable frontmatter+body markdown (serializeNote) plus structured id/path/title/type/tags/frontmatter/body/version_id, outgoing links and incoming backlinks. Storage calls: storage.getNote, storage.backlinkslib/mcp/tools/read.ts:120-169both
Tool list_versionsNewest-first version history: id, timestamp, authorKind (user/agent/clipper/system), agentName, message. Storage calls: storage.listVersionslib/mcp/tools/read.ts:175-209both
Tool create_artifactCreates a note at path; title/type merged into frontmatter with explicit frontmatter keys winning. Storage calls: storage.createNote — note: no agent attribution on create (adapter contract has no SaveOpts for createNote)lib/mcp/tools/write.ts:44-95both
Tool edit_artifactReplaces body_md and/or frontmatter (full replacement, not merge); at least one required. Storage calls: storage.updateNote(id, patch, {authorKind:'agent', agentName, message}) — version row is agent-attributedlib/mcp/tools/write.ts:101-157both
Tool rename_artifactMoves a note to new_path; wikilink rewrite handled by the adapter (LocalAdapter in-place loop; SupabaseAdapter delegates to the transactional notes_rename() Postgres function). Storage calls: storage.renameNotelib/mcp/tools/write.ts:163-194both
Tool delete_artifactSoft delete by default (LocalAdapter moves to .eli/trash/; SupabaseAdapter sets trashed_at); soft:false hard-deletes cascading versions/comments/attachments. Storage calls: storage.deleteNote(id, {trash})lib/mcp/tools/write.ts:200-234both
Tool append_to_artifactSmart-appends text to end-of-doc, an anchor (e.g. intro), or case-insensitive heading text (anchor tried first; heading collisions resolve to first match) via the pure smartAppend helper. Storage calls: storage.getNote + storage.updateNote (agent-attributed, message appended to <section>)lib/mcp/tools/append.ts:37-66, 81-150both
Tool searchkind fts (default) / semantic / hybrid; limit default 20, max 100. Storage calls: storage.search(query, {limit}) for fts (SQLite FTS5 local / Postgres tsvector SaaS); semantic/hybrid dynamically import hybridSearch from lib/search/hybrid.ts (FTS + cosine embeddings fused by RRF); hybrid hits expose fused_score / fts_rank / vector_rank / contributionslib/mcp/tools/search.ts:28-134both
Tool get_diffDiffs two versions of the same note. Storage calls: storage.diffVersions (section-aware); on NotImplementedError falls back to an in-tool O(n·m) LCS line diff wrapped as a single SectionDiff with fallback:truelib/mcp/tools/diff.ts:55-96, 127-200both
Tool restore_versionRolls a note back, append-only (original version rows preserved). Storage calls: storage.getVersion(version_id) + storage.updateNote (agent-attributed, message restored from <id>)lib/mcp/tools/diff.ts:206-250both
Tool add_commentAttaches a comment to a section anchor (heading slug-path or block:<ulid>), pinning the note’s current versionId; authorId from authInfo.userId (null local); optional parent_id for replies. SaaS role gating enforced by RLS only (ADR-0004 — no tool-layer recheck). Storage calls: storage.getNote + storage.addCommentlib/mcp/tools/comment.ts:36-105both
Tool daily_noteNo-arg returns/creates today’s daily note; explicit date (validated as a real calendar date) resolves daily/<date>.md. Storage calls: storage.todayNote(), or storage.getNote / storage.createNotelib/mcp/tools/daily.ts:31-83both
Tool apply_templateMaterialises a note from a template id with {{name}} variable substitution (LocalAdapter resolves <vault>/.eli/templates/<id>.md). Storage calls: storage.applyTemplate(template, {path, vars}) — SupabaseAdapter throws NotImplementedError (“Phase 6”), surfaced as isErrorlib/mcp/tools/template.ts:32-73both (effective local-only)
Tool memory_searchSearches OpenClaw memory under .openclaw/; scope long-term (default; MEMORY/AGENTS/USER/SOUL/TOOLS/DREAMS.md) / session (today’s memory/<date>.md) / all; top_k default 8, max 50. Storage calls: storage.search with limit min(top_k×5, 250), then pathInScope post-filter (lib/openclaw/format.ts:85-95). FTS-onlylib/mcp/tools/memory.ts:53-113both
Tool memory_getReads one OpenClaw memory file by canonical name, daily path memory/YYYY-MM-DD.md, or verbatim .openclaw/ path; optional 1-based inclusive line_range with bounds clamping. Storage calls: storage.getNote(vaultPath) — memory files surface as notes, so SaaS works toolib/mcp/tools/memory.ts:119-174both
stdio transport (eli-mcp binary)package.json bin eli-mcpbin/eli-mcp.mjs; requires VAULT_PATH (or ELI_VAULT); forces ELI_MODE=local; pins LocalAdapter.forVault(vaultPath) once at boot; serves all tools with scopes ['*'] and agentName eli-mcp:local-token (if MCP_LOCAL_TOKEN set) or eli-mcp:local. No per-call bearer check — OS process boundary is the isolation. Logs stderr only; SIGINT/SIGTERM close handler; exit 2 with a pnpm build:mcp hint if the esbuild bundle is missingbin/eli-mcp.mjs, pnpm build:mcp (scripts/build-mcp-bin.mjs), pnpm prepacklocal
streamable-HTTP/SSE transportWraps mcp-handler createMcpHandler (basePath /api/mcp, maxDuration 800, serverInfo eli/0.0.1); exports GET/POST/DELETE, force-dynamic. SaaS: wrapped in experimental_withMcpAuth(authForMcp, required:true). Local: wrapped with authForLocal only when MCP_LOCAL_TOKEN is set, otherwise unauthenticated (relies on 127.0.0.1 bind). Auto-uses Redis/KV for SSE state when REDIS_URL/KV_URL setGET/POST/DELETE /api/mcp/[transport] (app/api/mcp/[transport]/route.ts)both
SaaS bearer auth: PAT + JWTauthForMcp parses the bearer: PAT path (eli_pat_<8-char prefix>_<43-char base64url secret>) → service-role lookup by prefix, constant-time sha256 verifyHash, reject revoked/expired, fire-and-forget last_used_at, empty scopes → ['*']. JWT path → supabase.auth.getUser(jwt), workspaceId stays null (browser use; first-class agent auth is PAT). Rate limit applied AFTER successful authAuthorization: Bearer on /api/mcp/* (lib/mcp/auth.ts:65-92)saas
Local HTTP auth: MCP_LOCAL_TOKENauthForLocal: token unset → every request allowed with synthesized AuthInfo (scopes ['*']); token set → constant-time compare of the presented bearer against the exact token/api/mcp/* in local mode (lib/mcp/auth.ts:99-111)local
MCP rate limitingcheckRateLimit: dual window 60 req/min + 10,000 req/day per key (patId / userId), in-memory bucket Map pinned to globalThis.__ELI_RATELIMIT__; rejection does not consume budget; over-budget throws RateLimitedError('rate_limited') with retryAfterMs. Pluggable backend via ELI_RATELIMIT_BACKEND; redis currently throws “not shipped yet”. stdio and token-less local HTTP bypass entirelyapplied inside authForMcp (lib/mcp/ratelimit.ts)saas
PAT management UISettings → Developers lists workspace PATs (RLS: members see self rows, admins/owners see all) with name, prefix, role, status, last-used, expiry. Create dialog: name (1–80 chars) + role (admin/editor/commenter/viewer; the action’s zod enum also admits owner, app/_actions/pats.ts:27-32) + optional expiry-in-days (1–3650); one-time plaintext reveal. Row actions: Revoke (sets revoked_at, row kept for audit) and Rotate (self-only, revoke-then-create, non-atomic; new token via 60s toast)/w/[workspace]/settings/developers, createPatAction / revokePatAction / rotatePatAction (app/_actions/pats.ts)saas
Browser-extension pairingExtension opens /pair?extensionId=<id>&label=<opt> (404 in SaaS); approval POSTs to /api/pair; approvePairing mints eli_pair_<26-char ULID>_<32-byte base64url secret>, persists sha256(secret) in ~/.eli/config.json#pairings[], returns plaintext once; page postMessages the token to window.opener (targetOrigin '*'). Revoke/list via the same route. Consumed by /api/clip, not MCP/pair page, POST/GET /api/pair, lib/pair/extension.tslocal
External AI tools setup wizardCommand-palette entry “Set up external AI tools…” opens a 5-agent multi-select dialog (all pre-checked). Local-mode-only Server Action resolves vault path, binPath=<cwd>/bin/eli-mcp.mjs, local token, UI port, then runs per-agent writers sequentially with error isolation. Files written: ~/.claude/mcp.json, ~/.cursor/mcp.json, ~/.gemini/settings.json, ~/.openclaw/openclaw.json (JSON merge of mcpServers.eli, foreign entries preserved, atomic tmp+rename, no-op detection) and ~/.codex/config.toml (hand-rolled TOML with regex section splice). OpenClaw writer also best-effort spawnSync('openclaw', ['mcp','add','eli','--from-config']) with failure silently swallowedcommand palette → setupExternalToolsAction (app/_actions/setup.ts:38) → setupExternalTools (lib/setup/index.ts:33)local
AGENTS.md generation / restorerestoreAgentsMd(vaultPath, {optedOut, platform}) writes a vault-root AGENTS.md with an eli-managed block between <!-- eli managed start --> / <!-- eli managed end --> sentinels; spliceManaged preserves user content outside markers; CLAUDE.md and GEMINI.md become POSIX symlinks to AGENTS.md (content-copy shim on Windows); .cursorrules always a spliced content copy; optedOut short-circuits. Currently an unwired library — no production caller (only tests import it)restoreAgentsMd (lib/agents-md/restore.ts:58), AGENTS_TEMPLATE/spliceManaged (lib/agents-md/template.ts)local (unwired)
OpenClaw memory scaffold + format helpersscaffoldOpenClaw(vaultPath) idempotently creates .openclaw/ with MEMORY.md (800-word cap), AGENTS.md (700), USER.md (400), SOUL.md (500), TOOLS.md (1000), DREAMS.md (unbounded) plus today’s memory/<date>.md; never overwrites. format.ts exposes path builders, pathInScope scope filter, warning-only word-count validation, atomic FS read/write helpers. scaffoldOpenClaw also has no production caller (mentioned only in memory_get’s error text)scaffoldOpenClaw (lib/openclaw/scaffold.ts:53), lib/openclaw/format.tslocal (scaffold unwired)
MCP test harness + packagingpnpm mcp:test boots both transports (InMemoryTransport pair for stdio; createMcpHandler + auth wrapper with fake Requests for HTTP) against MemoryAdapter to prove tool parity. pnpm build:mcp bundles lib/mcp/stdio.ts via esbuild into dist/lib/mcp/stdio.js (ESM, node22, native deps external, server-only stubbed); runs automatically on prepackpnpm mcp:test, pnpm build:mcp, tests/mcp/harness.tsdev/local

System view — simplified

System view — simplified

This cluster is eli’s front door for machines: one tool surface, registered once, served over two transports. Standalone, the stdio binary is a complete product — node bin/eli-mcp.mjs with a VAULT_PATH gives any MCP client full CRUD, search, versioning, diff/restore, comments, daily notes, templates, and OpenClaw memory over a plain markdown folder, with no Next.js server, no account, and no network required (the LocalAdapter builds its own SQLite index on first touch). The HTTP route serves the identical tool set remotely, with mode-dependent auth: mandatory PAT/JWT bearer in SaaS, optional shared token locally. The setup wizard and credential machinery (PATs, pairing tokens) exist purely to get agents connected and identified; everything an agent writes lands as an agent-attributed version row, so its footprint is auditable through the ordinary history UI.

System view — detailed

System view — detailed
ComponentRoleFiles
MCP server coreSingle McpServer factory (name eli, version 0.0.1, tools-only capabilities, handshake instructions) + central registerTools wiring all 9 tool modules in dependency order; per-call McpContext {storage, authInfo} resolved by a transport-supplied providerlib/mcp/server.ts, lib/mcp/context.ts, lib/mcp/format.ts
MCP tool modules16 registered tools: read ×3, write ×4, append, search, diff ×2, comment, daily, template, memory ×2; all return content[] text + structuredContent and convert caller-fault errors into isError results rather than protocol faultslib/mcp/tools/{read,write,append,search,diff,comment,daily,template,memory}.ts
stdio transportrunStdioServer forces ELI_MODE=local, builds LocalAdapter.forVault once, connects StdioServerTransport; the bin shim loads the esbuild bundle, validates env, handles signal shutdown; the build script produces the publishable bundlelib/mcp/stdio.ts, bin/eli-mcp.mjs, scripts/build-mcp-bin.mjs
HTTP transport + authNext route wrapping mcp-handler; mode-conditional experimental_withMcpAuth; authForMcp (PAT service-role lookup + JWT getUser + post-auth rate limit) and authForLocal (optional MCP_LOCAL_TOKEN constant-time compare); dual-window in-memory rate limiter with a pluggable-backend seamapp/api/mcp/[transport]/route.ts, lib/mcp/auth.ts, lib/mcp/ratelimit.ts
PAT primitives + UIToken generate/parse/hash (eli_pat_ format, sha256, timingSafeEqual); Server Actions create/revoke/rotate against personal_access_tokens via a user-scoped RLS client; Developers settings page + create dialog + list table with one-time reveallib/auth/pat.ts, app/_actions/pats.ts, app/(saas)/(app)/w/[workspace]/settings/developers/page.tsx, components/pat/create-dialog.tsx, components/pat/pat-list.tsx
Local pairingeli_pair_ token mint/verify/revoke/list persisted (hashed) in ~/.eli/config.json#pairings[]; approval page + API route (local-only, 404 in SaaS); postMessage handoff to the extension openerlib/pair/extension.ts, app/api/pair/route.ts, app/(local)/pair/page.tsx, components/pair-approval.tsx, lib/config/schema.ts
External tools setupPer-agent config writers (Claude/Cursor/Gemini/OpenClaw JSON via shared writeJsonMcpEntry; Codex hand-rolled TOML with regex section merge; OpenClaw extra CLI spawn) orchestrated by setupExternalTools; Server Action + command-palette dialog bindinglib/setup/{index,types,json-config,claude,cursor,codex,gemini,openclaw}.ts, app/_actions/setup.ts, components/setup/external-tools-dialog.tsx, components/command-palette.tsx
AGENTS.md generationManaged-block template + splice logic; restoreAgentsMd writes AGENTS.md, CLAUDE.md/GEMINI.md symlinks (POSIX) or shims (Windows), .cursorrules copy — atomic, idempotent, opt-out aware. No production callerlib/agents-md/template.ts, lib/agents-md/restore.ts
OpenClaw memory compat7-file .openclaw/ convention (names, word caps, scope filters, path builders, direct FS r/w) + idempotent scaffolder; consumed by memory_search/memory_getlib/openclaw/format.ts, lib/openclaw/scaffold.ts
MCP test harnessBoots stdio (InMemoryTransport pair) and HTTP (createMcpHandler + auth wrapper, direct Request calls) against MemoryAdapter for transport-parity tool teststests/mcp/harness.ts, tests/mcp/tools.spec.ts, app/api/mcp/[transport]/route.test.ts

Data flow — simplified

Data flow — simplified

The load-bearing runtime flow: an agent edits a note over stdio. The tool handler stamps authorKind:'agent' + the pinned agentName into storage.updateNote, which atomically rewrites the .md file, updates the SQLite index and version history in one transaction, notifies open browser tabs over SSE, and enqueues a debounced, agent-attributed git commit. The one side effect that does not fire is the auto-embed queue — agent-written content stays out of semantic ranking until a UI save or pnpm embed:backfill.

Data flow — detailed

Sequence (a): stdio boot + edit_artifact from Claude Code, all the way down to storage and its side-effect fan-out.

Data flow — detailed (a): stdio boot and edit_artifact fan-out

Sequence (b): HTTP tool call with PAT auth, rate limit, mode divergence, and the current SaaS storage-resolution gap.

Data flow — detailed (b): HTTP PAT auth, rate limit, and the SaaS storage-resolution gap

Works separately / works together

Standalone

With every other cluster turned off, the stdio binary alone is a functioning agent gateway to a markdown folder. node bin/eli-mcp.mjs (env VAULT_PATH) pins a LocalAdapter, builds its own SQLite index (~/.eli/index/<sha256-16>.db, FTS5, migrations) on first touch, and serves all 16 tools — CRUD, full-text search, section-aware append, diff/restore with full version history, comments, daily notes, templates, and OpenClaw memory — entirely over stdin/stdout. No Next.js process, no network, no account. Version attribution (authorKind:'agent', agentName eli-mcp:local[-token]) and optional git auto-commits give a complete local audit trail. The only degradations standalone: search falls back to FTS-only when no embedding key/pipeline is available, and there is no SSE consumer to notify.

Composed

  • C1 storage: every tool runs through the StorageAdapter interface (lib/storage/adapter) — LocalAdapter (SQLite FTS5 + vault files) or SupabaseAdapter (RLS-scoped Postgres). Tools never branch on mode themselves; the transport-supplied context provider picks the adapter.
  • C3 semantic search: search with kind:'hybrid'|'semantic' dynamically imports hybridSearch from lib/search/hybrid.ts (lib/mcp/tools/search.ts:65-72) — agents get the same FTS + embedding + RRF pipeline as the in-app search UI, with graceful FTS-only degradation.
  • C6 versioning: write-path tools (edit/append/restore_version) call storage.updateNote(..., {authorKind:'agent', agentName, message}), producing the agent-attributed version rows that list_versions, the version-history UI, and SaaS audit surfaces consume.
  • C2 authoring UX: local writes emit note:updated on the events bus → /api/events SSE → open editor tabs. For stdio-process writes, the Next server’s chokidar watcher (booted in instrumentation.ts) detects the file change, re-indexes the shared SQLite file, and streams its own SSE.
  • C7 SaaS: PATs live in personal_access_tokens under RLS (members see self, admins see all); authForMcp uses the documented service-role allow-list entry to look tokens up by prefix; role enforcement for agent writes is delegated wholly to RLS per ADR-0004; SaaS writes route through the transactional notes_save()/notes_rename() RPCs.
  • C8 capture: /api/clip (app/api/clip/route.ts:163-235) consumes both credential systems minted here — local eli_pair_* pairing tokens (verifyPairingToken) with MCP_LOCAL_TOKEN fallback, and SaaS eli_pat_* PATs. The MCP transports themselves never accept pairing tokens.
  • C4 AI platform: the /v1 OpenAI-compatible surface reuses this cluster’s PAT verification and the shared checkRateLimit limiter; its lib/ai/tools.ts vault toolset (read/list/search/create/update note) is a deliberately separate, non-MCP tool surface backed by the same adapters. Agents also consume the digest note (type:'digest', produced by C4’s cron) via plain list_artifacts/read_artifact — there is no dedicated digest MCP tool.
  • C9 platform ops: pnpm build:mcp (esbuild, run on prepack) produces the publishable stdio bundle; pnpm mcp:test proves transport parity against MemoryAdapter.

Load-bearing details

  • 16 tools are registered (3 read + 4 write + append + search + 2 diff + comment + daily + template + 2 memory) via lib/mcp/server.ts:100-108; the route comment claiming “15 tools” (app/api/mcp/[transport]/route.ts:25) is stale. Verified by counting registerTool( call sites.
  • PAT format: eli_pat_<8-char [a-z0-9] prefix>_<32-byte CSPRNG base64url secret (43 chars)>; the DB stores prefix (indexed lookup) + hex sha256(secret); verification uses timingSafeEqual. sha256 was chosen over argon2id deliberately because the secret is 256-bit high-entropy (lib/auth/pat.ts:16-29). parseBearer uses fixed-position parsing because base64url secrets may legally contain _ (lib/auth/pat.ts:117-131), and validates only a floor — any [a-z0-9]+ prefix plus a ≥16-char secret parses as a PAT; the exact 43 chars is generation-side only (lib/auth/pat.ts:123-137); any 3-dot-separated string is treated as a JWT (lib/auth/pat.ts:142-144).
  • Rate limiting: 60 req/min + 10,000 req/day per patId/userId, applied only after successful SaaS auth (lib/mcp/auth.ts:81-91); rejection throws RateLimitedError('rate_limited') carrying retryAfterMs so the client sees a rate-limit code rather than a 401 (lib/mcp/auth.ts:237-244); buckets are pinned to globalThis.__ELI_RATELIMIT__ to survive Turbopack module isolation (lib/mcp/ratelimit.ts:101-104).
  • Local HTTP auth is opt-in: without MCP_LOCAL_TOKEN, /api/mcp in local mode is completely unauthenticated and un-rate-limited (app/api/mcp/[transport]/route.ts:79-84; open-mode lib/mcp/auth.ts:100-103) — the 127.0.0.1 bind is the assumed safeguard and is a deployment concern, not enforced in code (route comment at :40-43). The stdio binary never enforces the token at all; it only selects the agentName label (lib/mcp/stdio.ts:73). authForLocal synthesizes the same AuthInfo in both open and tokened mode — agentName is always eli-mcp:local-token (lib/mcp/auth.ts:113-126) — a label the HTTP context provider currently discards anyway (see Gaps).
  • Token-name mismatch across layers: the env schema validates ELI_LOCAL_TOKEN (env.ts:21), the setup action passes it as ctx.localToken (app/_actions/setup.ts:73), the writers emit it into agent configs as env var MCP_LOCAL_TOKEN (lib/setup/json-config.ts:37), and the server-side checks read process.env.MCP_LOCAL_TOKEN raw (lib/mcp/auth.ts:100, route.ts:82). All verified in source. env.ts:21 documents ELI_LOCAL_TOKEN as the “CSRF / DNS-rebinding defence cookie value for local mode. Auto-generated by config.json on first boot” — so one secret doubles as both the CSRF cookie value and the MCP/clipper shared token.
  • JWT bearer path (SaaS) authenticates via supabase.auth.getUser(jwt) but pins no workspace (workspaceId:null, scopes ['*'], agentName = user email) — intended for the user’s own browser; first-class agent auth is PAT (lib/mcp/auth.ts:184-211).
  • Setup writers: ~/.claude/mcp.json, ~/.cursor/mcp.json, ~/.gemini/settings.json, ~/.openclaw/openclaw.json all share writeJsonMcpEntry (merge-only-the-eli-key, atomic tmp+rename, deepEqual no-op detection → created/updated/unchanged, lib/setup/json-config.ts:56-116); the emitted entry is mcpServers.eli = {command:'node', args:[binPath], env:{VAULT_PATH, MCP_LOCAL_TOKEN?, WS_UI_PORT?}} (lib/setup/json-config.ts:33-44), and all four JSON writers use the mcpServers key — OpenClaw included, since its writer passes no key override (lib/setup/openclaw.ts:41-45); ~/.codex/config.toml is hand-rolled TOML merged via a regex section extractor for [mcp_servers.eli] + [mcp_servers.eli.env] (lib/setup/codex.ts:102-118).
  • Pairing tokens (eli_pair_<26-char ULID>_<32-byte base64url secret>, sha256 hash persisted in ~/.eli/config.json#pairings[] under a schema field named token — backward-compat, “treat it as opaque hash material” (lib/pair/extension.ts:53-58) — alongside the config’s other secrets mcpToken/aiKey) are consumed only by /api/clip — MCP transports never accept them. The reveal page postMessages the plaintext to window.opener with targetOrigin '*' by design (components/pair-approval.tsx:53-58); GET /api/pair lists pairings with hashes redacted.
  • rotatePatAction is explicitly non-atomic: revoke happens before create, so a failure between the two leaves no working PAT under that name (app/_actions/pats.ts:124-160); the new secret is surfaced via a 60-second toast (components/pat/pat-list.tsx:90).
  • The esbuild bundle (scripts/build-mcp-bin.mjs) marks better-sqlite3, simple-git, isomorphic-git, the MCP SDK, supabase, and next as externals, aliases server-only/client-only to a stub, and runs on prepack; bin/eli-mcp.mjs exits code 2 with a pnpm build:mcp hint if dist/lib/mcp/stdio.js is missing (bin/eli-mcp.mjs:31-44).
  • MCP server identity: name eli, version 0.0.1 hard-coded (lib/mcp/server.ts:39-50, acknowledged in-comment to drift from package.json); handshake instructions point agents at AGENTS.md at the vault root; only the tools capability is advertised — no resources or prompts yet (lib/mcp/server.ts:63-75).
  • All tools return dual output — human text (serializeNote round-trips exact frontmatter+body markdown, preserving underscore system keys, lib/mcp/format.ts:36-39) plus machine structuredContent — and convert caller-fault storage errors into isError:true results rather than protocol faults (e.g. lib/mcp/tools/read.ts:34-42).
  • Side-effect asymmetry on writes: MCP writes fan out to the version row, FTS index, SSE event, and debounced git commit — but never call scheduleEmbed; the auto-embed queue only fires from the UI saveNoteAction (app/_actions/notes.ts:96-105). pnpm embed:backfill is the current bridge (scripts/embed-backfill.mjs).
  • Cross-process index coherence: when the stdio process writes a file, the Next server’s chokidar watcher sees it (echo suppression is per-process), runs the incremental scanner against the same shared SQLite index, and emits SSE itself (instrumentation.ts:13-28, lib/watch/chokidar.ts:99-124, lib/index/incremental.ts:17-40).
  • In SaaS, agent role enforcement happens only in Postgres RLS — e.g. a viewer-role PAT’s write dies inside the notes_save() SECURITY INVOKER RPC and surfaces as an isError result; the tool layer deliberately does not re-check (ADR-0004, lib/mcp/tools/comment.ts:13-20).

Gaps and partially wired

  • SaaS MCP-over-HTTP is effectively broken for headless PAT agents. The route registers tools with defaultMcpContextProvider (app/api/mcp/[transport]/route.ts:54), which takes no arguments and ignores the SDK’s extra.authInfo entirely, returning authInfo {userId:null, workspaceId:null, role:null, scopes:['*']} (lib/mcp/context.ts:79-91). Storage then resolves via SupabaseAdapter.forActiveWorkspace(), which requires a session cookie + eli_ws cookie (lib/storage/supabase.ts:75-97) that a Bearer-only agent never sends — so a PAT passes auth but tool calls fail at storage resolution with [eli/saas] No authenticated user. Even in the cookie-present case, ctx.authInfo.agentName is undefined, so HTTP-path writes lose PAT-name attribution and add_comment gets a null authorId. The comment in lib/mcp/auth.ts:26-28 claims the context provider maps extra back — it does not.
  • PAT scopes are stored but never enforced. Scopes surface in AuthInfo (empty → ['*'], lib/mcp/auth.ts:158-160) and lib/mcp/context.ts:39-42 documents narrowing, but no tool or dispatcher checks ctx.authInfo.scopes — a ['read','search'] PAT can still call delete_artifact (subject only to RLS role in Postgres).
  • AGENTS.md generation and OpenClaw scaffolding are unwired libraries. restoreAgentsMd (lib/agents-md/restore.ts:58) and scaffoldOpenClaw (lib/openclaw/scaffold.ts:53) have zero production call sites (grep confirms: only tests plus an error-message mention in lib/mcp/tools/memory.ts:157). The “Restore eli Guidance” command referenced in their doc comments does not exist in the palette or any Server Action. Agents are only told about AGENTS.md via the handshake instructions string; nothing guarantees the file exists in a vault.
  • Rate limiting is single-instance, in-memory only. ELI_RATELIMIT_BACKEND=redis throws a “has not shipped yet” error (lib/mcp/ratelimit.ts:186-192); on serverless/multi-instance SaaS each instance keeps its own bucket. Per-workspace limit configurability from the spec is unimplemented — checkRateLimit always applies the global 60/10k defaults.
  • No central audit of MCP tool calls. SaaS audit_log rows are only written by the /v1 chat vault tools and membership/comment actions; the MCP trail is versions rows + git commits + PAT last_used_at — there is no “agent X called search 400 times” record.
  • MCP writes bypass the embed queue (see Load-bearing details) — agent-authored content is invisible to vector ranking until a UI save or pnpm embed:backfill.
  • apply_template is local-only in effect: SupabaseAdapter.applyTemplate throws NotImplementedError (“Phase 6”) (lib/storage/supabase.ts:195-200); template {{prompt:...}} placeholders resolve to a literal (TODO: ...) fallback in the storage-layer runtime.
  • Description/doc drift: the search tool description still tells the model semantic/hybrid “land in Phase 5” though both are live (lib/mcp/tools/search.ts:34-44); the route comment says 15 tools (there are 16); docs/tools.md names restore_section and get_daily_note where the registered tools are restore_version and daily_note, and claims the HTTP route is SaaS-only though local serves it too; lib/setup/json-config.ts:51-53 claims “OpenClaw uses servers” though the OpenClaw writer emits mcpServers like the rest.
  • get_diff mostly runs its fallback: the section-aware diffVersions path “almost always lands in the fallback” per its own comment (lib/mcp/tools/diff.ts:148-150), returning a single synthetic section with fallback:true.
  • OpenClaw setup writer swallows CLI failure: spawnSync('openclaw', ['mcp','add','eli','--from-config']) errors are silently discarded; SetupResult.warning is never set despite the type supporting it (lib/setup/openclaw.ts:64-73) — the writer’s own comment claims “No warnings field on SetupResult yet”, itself stale against lib/setup/types.ts:44-48.
  • Fresh-checkout footgun: the stdio bin requires the esbuild bundle; running the setup wizard before pnpm build:mcp/prepack ships agent configs that point at a binary which exits 2 until the bundle is built (bin/eli-mcp.mjs:31-44).