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.
- Modes: local (stdio + optionally-tokened HTTP) / saas (HTTP with mandatory PAT-or-JWT Bearer)
- Key dirs:
lib/mcp/,bin/,scripts/build-mcp-bin.mjs,lib/auth/pat.ts,lib/pair/,lib/setup/,lib/agents-md/,lib/openclaw/,app/api/mcp/,app/api/pair/,app/_actions/{pats,setup}.ts - Depends on: C1 storage, C3 hybrid search, C6 versions, C7 Supabase/RLS
- Feeds: C8 clipper auth, C6 agent-attributed version rows, C2 SSE editor refresh
Capabilities
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
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.
Sequence (b): HTTP tool call with PAT auth, rate limit, mode divergence, and the current 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
StorageAdapterinterface (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:
searchwithkind:'hybrid'|'semantic'dynamically importshybridSearchfromlib/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) callstorage.updateNote(..., {authorKind:'agent', agentName, message}), producing the agent-attributed version rows thatlist_versions, the version-history UI, and SaaS audit surfaces consume. - C2 authoring UX: local writes emit
note:updatedon the events bus →/api/eventsSSE → open editor tabs. For stdio-process writes, the Next server’s chokidar watcher (booted ininstrumentation.ts) detects the file change, re-indexes the shared SQLite file, and streams its own SSE. - C7 SaaS: PATs live in
personal_access_tokensunder RLS (members see self, admins see all);authForMcpuses 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 transactionalnotes_save()/notes_rename()RPCs. - C8 capture:
/api/clip(app/api/clip/route.ts:163-235) consumes both credential systems minted here — localeli_pair_*pairing tokens (verifyPairingToken) withMCP_LOCAL_TOKENfallback, and SaaSeli_pat_*PATs. The MCP transports themselves never accept pairing tokens. - C4 AI platform: the
/v1OpenAI-compatible surface reuses this cluster’s PAT verification and the sharedcheckRateLimitlimiter; itslib/ai/tools.tsvault 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 plainlist_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:testproves 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 countingregisterTool(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 usestimingSafeEqual. sha256 was chosen over argon2id deliberately because the secret is 256-bit high-entropy (lib/auth/pat.ts:16-29).parseBeareruses 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 throwsRateLimitedError('rate_limited')carryingretryAfterMsso the client sees a rate-limit code rather than a 401 (lib/mcp/auth.ts:237-244); buckets are pinned toglobalThis.__ELI_RATELIMIT__to survive Turbopack module isolation (lib/mcp/ratelimit.ts:101-104). - Local HTTP auth is opt-in: without
MCP_LOCAL_TOKEN,/api/mcpin local mode is completely unauthenticated and un-rate-limited (app/api/mcp/[transport]/route.ts:79-84; open-modelib/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).authForLocalsynthesizes the same AuthInfo in both open and tokened mode — agentName is alwayseli-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 asctx.localToken(app/_actions/setup.ts:73), the writers emit it into agent configs as env varMCP_LOCAL_TOKEN(lib/setup/json-config.ts:37), and the server-side checks readprocess.env.MCP_LOCAL_TOKENraw (lib/mcp/auth.ts:100, route.ts:82). All verified in source.env.ts:21documentsELI_LOCAL_TOKENas 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.jsonall sharewriteJsonMcpEntry(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 ismcpServers.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 themcpServerskey — OpenClaw included, since its writer passes no key override (lib/setup/openclaw.ts:41-45);~/.codex/config.tomlis 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 namedtoken— backward-compat, “treat it as opaque hash material” (lib/pair/extension.ts:53-58) — alongside the config’s other secretsmcpToken/aiKey) are consumed only by/api/clip— MCP transports never accept them. The reveal page postMessages the plaintext towindow.openerwith targetOrigin'*'by design (components/pair-approval.tsx:53-58);GET /api/pairlists pairings with hashes redacted. rotatePatActionis 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, aliasesserver-only/client-onlyto a stub, and runs on prepack;bin/eli-mcp.mjsexits code 2 with apnpm build:mcphint ifdist/lib/mcp/stdio.jsis missing (bin/eli-mcp.mjs:31-44). - MCP server identity: name
eli, version0.0.1hard-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 (
serializeNoteround-trips exact frontmatter+body markdown, preserving underscore system keys,lib/mcp/format.ts:36-39) plus machinestructuredContent— and convert caller-fault storage errors intoisError:trueresults 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 UIsaveNoteAction(app/_actions/notes.ts:96-105).pnpm embed:backfillis 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’sextra.authInfoentirely, returningauthInfo {userId:null, workspaceId:null, role:null, scopes:['*']}(lib/mcp/context.ts:79-91). Storage then resolves viaSupabaseAdapter.forActiveWorkspace(), which requires a session cookie +eli_wscookie (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.agentNameis undefined, so HTTP-path writes lose PAT-name attribution andadd_commentgets a null authorId. The comment inlib/mcp/auth.ts:26-28claims the context provider mapsextraback — it does not. - PAT scopes are stored but never enforced. Scopes surface in AuthInfo (empty →
['*'],lib/mcp/auth.ts:158-160) andlib/mcp/context.ts:39-42documents narrowing, but no tool or dispatcher checksctx.authInfo.scopes— a['read','search']PAT can still calldelete_artifact(subject only to RLS role in Postgres). - AGENTS.md generation and OpenClaw scaffolding are unwired libraries.
restoreAgentsMd(lib/agents-md/restore.ts:58) andscaffoldOpenClaw(lib/openclaw/scaffold.ts:53) have zero production call sites (grep confirms: only tests plus an error-message mention inlib/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=redisthrows 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 —checkRateLimitalways applies the global 60/10k defaults. - No central audit of MCP tool calls. SaaS
audit_logrows are only written by the/v1chat vault tools and membership/comment actions; the MCP trail is versions rows + git commits + PATlast_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_templateis local-only in effect:SupabaseAdapter.applyTemplatethrows 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
searchtool 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.mdnamesrestore_sectionandget_daily_notewhere the registered tools arerestore_versionanddaily_note, and claims the HTTP route is SaaS-only though local serves it too;lib/setup/json-config.ts:51-53claims “OpenClaw usesservers” though the OpenClaw writer emitsmcpServerslike the rest. get_diffmostly runs its fallback: the section-awarediffVersionspath “almost always lands in the fallback” per its own comment (lib/mcp/tools/diff.ts:148-150), returning a single synthetic section withfallback:true.- OpenClaw setup writer swallows CLI failure:
spawnSync('openclaw', ['mcp','add','eli','--from-config'])errors are silently discarded;SetupResult.warningis 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 againstlib/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).