Skip to content

MCP tools

1. The toolkit

memhtml serve mcp serves 15 tools over the same repository the CLI writes to. They appear below in registration order, which is the order tools/list returns them and the order a client reads.

The second column names the internal capabilities a tool’s handler declares, which bounds what that tool can touch.

Tool Ports it declares
memory_write Store, Indexer, IndexRecorder, ExtractorPort, Embedder
memory_write_batch Store, Indexer, IndexRecorder, ExtractorPort, Embedder
memory_read Store, IndexRecorder, DatabaseService
memory_search Retrieval, DatabaseService
memory_recall Retrieval, DatabaseService
memory_correct Store, Indexer, IndexRecorder, ExtractorPort, Embedder
memory_link Store, Indexer
memory_neighbors DatabaseService
memory_resolve DatabaseService
memory_archive Store, Indexer
memory_reinforce DatabaseService
memory_list DatabaseService
trace_search DatabaseService
trace_links DatabaseService
memory_status Store, DatabaseService, RetrievalPolicy

2. Why the surface is arranged this way

The fifteen tools: design.md §8 verbatim, plus memory_write_batch (spec 004 D7) and memory_resolve.

parameters is always Schema.Struct, never Schema.Class. A client sends a plain object literal, and a class schema’s decode expects an instance. The failure is a decode error on every call, at runtime, for every tool. This is the one trap the whole surface is arranged around.

Sleep is deliberately absent. It is a cron/operator action producing a reviewable branch, not something an agent fires mid-conversation: a sleep run rewrites confidence across the corpus, archives memories, and creates a branch a human is expected to read. memhtml sleep run is the entry point. A read-only sleep_status is the only shape this surface could ever take for it; the write side stays behind an operator.

Every success schema is also a Schema.Struct, so tools/list publishes a JSON Schema the client can validate a response against rather than an opaque object.

A description has to FOLD to a string by AST, so it is built from string literals, +, and identifiers declared in this file — never a template literal with a substitution and never an imported value. The docs site reads each description straight out of this source (foldString in apps/docs/src/loaders/repo-sources.ts) so the reference page and the published bytes are the same string; an expression it cannot fold throws at that build instead of rendering a paraphrase. A number that must not drift from a constant is asserted in tests/tools.test.ts against the constant, which is the check a literal cannot make for itself.

Every tool declares failure: ToolFailure, and the omission is a silent wire bug. A tool with no declared failure schema gets Schema.Never (Tool.make’s options?.failure ?? Schema.Never, effect 4.0.0-rc.109), so McpServer’s isDeclaredFailure predicate rejects everything and every failure, typed domain error included, is rewritten to INTERNAL_TOOL_ERROR_MESSAGE, “Tool execution failed due to an internal server error”, before it reaches the caller. The declaration is what puts a tool’s failures on the branch that passes prose through; see failure.ts for the mechanism. failureMode is left at its "error" default on purpose: the error CHANNEL is what McpServer catches, and "return" would instead fold the failure into the success union, where the server would see a successful call carrying a failure payload no MCP client knows to read.

3. The tools

Each description below is the exact string tools/list publishes. A client reads it to decide which tool to call, so it is the wording that has to carry the tool’s contract.

3.1. memory_write

Ports: Store, Indexer, IndexRecorder, ExtractorPort, Embedder

Write one memory to the corpus. Returns the existing path with deduped=true when an active memory already holds this exact content. A duplicate creates no file and no commit. Supply EXACTLY ONE of body or article_html. Both or neither is refused. article_html is raw <article> inner markup used verbatim, and the caller owns the format: exactly one <mark>, inside the first <p> or the first <li>, and never inside <aside> or <details>; only elements from the closed vocabulary in docs/format.md; no class attribute, no style attribute, no <script>, no event handlers. The FIRST <time datetime=“…”> element becomes the memory’s event time, which is what the recency arm ranks by, so an episodic memory about last week should carry last week’s date, not today’s. Markup that violates the format is refused before any file is written or committed. Code snippets: in body prose, a paragraph that is entirely a fenced code block (ts … ) becomes <figure><pre><code data-lang=“ts”>, whitespace verbatim, and the language promotes to a lang:ts entity; a blank line inside the fence does not split it. In article_html, author the same markup yourself: data-lang, never class (forbidden) and never lang= (that names human languages). path is optional and rarely worth sending: without it the placement rule picks the directory from the memory’s type, workspace, and entities, and the title becomes the filename. A path that is not a usable memory path (rooted in a PARA bucket, ending in .html, no . or .. segment) is IGNORED, and the placement rule decides instead — so a malformed override lands the memory somewhere you did not name. Send strict_path: true to have that REFUSED instead, with ERR_INVALID_MEMORY naming the clause the path broke and nothing written, staged, or committed; it governs the path you named, so with no path it changes nothing. Reach for it when you place documents at paths you compute, where a silent re-derivation makes a write look like it landed where you asked. A path that a file ALREADY occupies is REFUSED with ERR_WRITE_CONFLICT, and nothing is written or committed: this corpus overwrites nothing, and an explicit path gets no -2 suffix because you named one path. To replace what a memory says, call memory_correct on it — that archives the file it supersedes in the same commit and leaves it readable under archive/. Call memory_write_batch ONCE rather than memory_write N times whenever this task will write more than about three memories: a batch stages every file, makes ONE commit, and reindexes ONCE, so it costs less than N calls and leaves a history a reader can follow. It returns one result per op in INPUT ORDER, each naming that op’s index, its path, and whether it deduped. A batch is ATOMIC by default: the first refused op aborts the whole call, no file is written and no commit is made, and the failure names the offending op as ops[N]. Set continue_on_error to true for best-effort instead, and a refused op comes back as a failed result carrying its own code and reason while every surviving op lands in the one commit. A duplicate is never a failure: an op whose exact content is already stored returns ok with deduped=true and the existing path. Each op supplies EXACTLY ONE of body or article_html, the same rule memory_write follows.

3.2. memory_write_batch

Ports: Store, Indexer, IndexRecorder, ExtractorPort, Embedder

Write many memories in ONE commit: every op is validated first, every surviving file is staged, and the batch commits and reindexes exactly once. commit_sha is null when nothing was written: an all-deduped batch, or an aborted one. Supply EXACTLY ONE of body or article_html. Both or neither is refused. article_html is raw <article> inner markup used verbatim, and the caller owns the format: exactly one <mark>, inside the first <p> or the first <li>, and never inside <aside> or <details>; only elements from the closed vocabulary in docs/format.md; no class attribute, no style attribute, no <script>, no event handlers. The FIRST <time datetime=“…”> element becomes the memory’s event time, which is what the recency arm ranks by, so an episodic memory about last week should carry last week’s date, not today’s. Markup that violates the format is refused before any file is written or committed. Code snippets: in body prose, a paragraph that is entirely a fenced code block (ts … ) becomes <figure><pre><code data-lang=“ts”>, whitespace verbatim, and the language promotes to a lang:ts entity; a blank line inside the fence does not split it. In article_html, author the same markup yourself: data-lang, never class (forbidden) and never lang= (that names human languages). path is optional and rarely worth sending: without it the placement rule picks the directory from the memory’s type, workspace, and entities, and the title becomes the filename. A path that is not a usable memory path (rooted in a PARA bucket, ending in .html, no . or .. segment) is IGNORED, and the placement rule decides instead — so a malformed override lands the memory somewhere you did not name. Send strict_path: true to have that REFUSED instead, with ERR_INVALID_MEMORY naming the clause the path broke and nothing written, staged, or committed; it governs the path you named, so with no path it changes nothing. Reach for it when you place documents at paths you compute, where a silent re-derivation makes a write look like it landed where you asked. A path that a file ALREADY occupies is REFUSED with ERR_WRITE_CONFLICT, and nothing is written or committed: this corpus overwrites nothing, and an explicit path gets no -2 suffix because you named one path. To replace what a memory says, call memory_correct on it — that archives the file it supersedes in the same commit and leaves it readable under archive/. Call memory_write_batch ONCE rather than memory_write N times whenever this task will write more than about three memories: a batch stages every file, makes ONE commit, and reindexes ONCE, so it costs less than N calls and leaves a history a reader can follow. It returns one result per op in INPUT ORDER, each naming that op’s index, its path, and whether it deduped. A batch is ATOMIC by default: the first refused op aborts the whole call, no file is written and no commit is made, and the failure names the offending op as ops[N]. Set continue_on_error to true for best-effort instead, and a refused op comes back as a failed result carrying its own code and reason while every surviving op lands in the one commit. A duplicate is never a failure: an op whose exact content is already stored returns ok with deduped=true and the existing path. Each op supplies EXACTLY ONE of body or article_html, the same rule memory_write follows. Set detect_conflicts to true and each per-op result gains a conflict field naming what that op’s claim CONTRADICTS. This is not dedupe: dedupe catches an op whose content is IDENTICAL to something stored, while this catches an op that says something DIFFERENT about the same thing, the case dedupe is blind to, and the one that actually rots a corpus. The match is grammatical rather than semantic. A claim splits into a frame (the subject and relation up to its LAST of/is/in/to/by/as) and a value, and two claims conflict when they share a frame: ‘The pool ceiling is 64’ and ‘The pool ceiling is 128’ both key on ‘the pool ceiling is’. conflict.path names an ACTIVE memory already holding that slot. conflict.batch_index names an EARLIER op in this same call, which no other tool can see because neither op is stored yet; it has no path for that reason. conflict.claim is the other claim’s own text, so you can decide without a second call. conflict is null when nothing matched, when detect_conflicts was absent, when the claim states no frame shape (the rule refuses frames under three tokens and values over six, so short claims and claims trailed by a clause are deliberately unmatched rather than loosely matched), and always on an op that used article_html. The claim is inside your markup there and is not read until the store renders it. THE ASSIST NEVER CHANGES WHAT IS WRITTEN. An op carrying a conflict is written exactly as it would have been without the flag: nothing is archived, nothing is refused, later does not win, and the summary counts are unchanged. That is deliberate, not a limitation. Sometimes the contradiction IS the answer. A memory recording that a runbook step changed necessarily contradicts the memory stating the old step, and a system that resolved that for you would destroy the pair a reader needs in order to see the change at all. So YOU decide, per conflict: keep both (they are about different things, or both are true), call memory_correct on the named path instead (the new claim supersedes the old one, which stays readable under archive/), or drop the op. Archived memories never match, so a superseded claim stops contradicting the claim that superseded it. Set consolidate to “last-wins” and the batch RESOLVES frame-key matches instead of only reporting them: for ops sharing a claim slot (the same deterministic frame key the conflict rule uses), the LATER value wins. Exactly one file is written, at the FIRST index that claimed the slot, and every later restatement reports consolidated_into naming that slot instead of a path of its own. A stored ACTIVE memory occupying a surviving slot is archived with a supersedes link from the new file, its archive path reported on the winner as superseded_path. Off by default, and claims with no frame shape are never consolidated. The guards fail closed, so this only ever acts on claims the conflict rule would have matched. Set detect_near_duplicates to true and each per-op result gains a near_duplicates list naming what that op’s text nearly RESTATES, by embedding cosine at or above 0.92, best match first. This is the third distinct check: dedupe catches IDENTICAL content, detect_conflicts catches a DIFFERENT value in the same grammatical slot, and this catches a REWORDING — same fact, different words — which the other two are blind to. Each entry carries the other claim’s text, the measured similarity, and ONE of path (an ACTIVE stored memory) or batch_index (an EARLIER op in this same call). The score is geometry, not judgment: negations and small numeric edits also sit above 0.92, so read the paired claim before folding anything. near_duplicates is null when nothing matched, when the flag was absent, on an op that used article_html (the claim is inside your markup and not read until the store renders it), and whenever near_duplicates_degraded is true — that top-level flag means the assist could not run (no embedder bound, or the embed call failed), so null then means UNCHECKED, not unique. THE ASSIST NEVER CHANGES WHAT IS WRITTEN, for detect_conflicts’ reason. YOU decide, per finding: keep both, call memory_correct on the named path, or drop the op; left alone, the next sleep run’s dedup-merge folds true rewordings under its divergence guards.

3.3. memory_read

Ports: Store, IndexRecorder, DatabaseService

Read one memory in full: its head metadata, authored links, and complete article body. The only path to a <details> body, which recall never quotes. An explicit open of a named path COUNTS as salience. This is the read that moves the access plane, while a search or recall hit does not.

Ports: Retrieval, DatabaseService

Ranked search over the corpus: lexical, vector, recency, and salience arms fused with RRF, then diversified. Each hit carries a snippet: the text of the file’s best-matching chunk for this query (its opening chunk when the vector arm did not fire), truncated with a trailing … when cut. degraded is true when the vector arm did not fire, so the result came from fewer signals; vector_coverage (the share of indexed chunks carrying a vector, 0 to 1) tells a sparse index, where the arm is dropped on purpose because a few embedded files would outrank every exact match, from an embedder outage. Each hit also carries entities in type:name form; pass one of those values back as entity to make the next call the second hop of a chain. That is two calls, not a guess about spelling. An entity scope that matches nothing returns NO hits and says so through scope_empty: this tool never widens a scope it could not satisfy. as_of is a point-in-time view: pass an ISO instant and the result is what was believed valid at that moment, including since-superseded memories (marked superseded_by). Returning a path changes nothing: a hit is this ranker’s guess, so it never bumps salience. Call memory_read to open the one you chose, and memory_reinforce to record whether it was right. query is prose; a double-quoted span in it demands those words in that order, and nothing else is syntax. facets narrows by the corpus’s own <dl> facets, each entry spelled name=value (the value may contain =, the name may not). THE COMPOSITION IS FIXED: values under the SAME name broaden, so [“doc-type=runbook”,“doc-type=guide”] is either; DIFFERENT names narrow, so [“doc-type=runbook”,“tier=1”] is both. This is the extension axis. memhtml’s element and meta vocabularies are closed, so your own document kinds, states, and tiers belong in <dt>/<dd> pairs inside the article, and this is how you query them back. The match is on the facet’s TEXT with no case folding, so write the halves you mean to query. The stored form is the element’s text content, which the parser collapses whitespace runs in and trims, so <dd>runbook rollback</dd> is stored and queried single-spaced. There is no numeric comparison and that is deliberate: a <data value> is indexed UNITLESS, because the unit lives in the prose beside it, so you own the unit and match the text you wrote.

3.5. memory_recall

Ports: Retrieval, DatabaseService

A context pack under a character budget: full bodies for what fits, one index line each for what does not. Arcs are folded under their own envelope so a synthesis cannot crowd out the evidence behind it.

3.6. memory_correct

Ports: Store, Indexer, IndexRecorder, ExtractorPort, Embedder

Supersede a memory: write the corrected version and archive the target in ONE commit, linked in both directions. Never edits in place. The superseded memory stays readable under archive/. Supply EXACTLY ONE of body or article_html. Both or neither is refused. article_html is raw <article> inner markup used verbatim, and the caller owns the format: exactly one <mark>, inside the first <p> or the first <li>, and never inside <aside> or <details>; only elements from the closed vocabulary in docs/format.md; no class attribute, no style attribute, no <script>, no event handlers. The FIRST <time datetime=“…”> element becomes the memory’s event time, which is what the recency arm ranks by, so an episodic memory about last week should carry last week’s date, not today’s. Markup that violates the format is refused before any file is written or committed. Code snippets: in body prose, a paragraph that is entirely a fenced code block (ts … ) becomes <figure><pre><code data-lang=“ts”>, whitespace verbatim, and the language promotes to a lang:ts entity; a blank line inside the fence does not split it. In article_html, author the same markup yourself: data-lang, never class (forbidden) and never lang= (that names human languages).

Ports: Store, Indexer

Assert an edge between two memories. Written into the source file’s head, so it survives an index rebuild. Idempotent: re-linking the same pair commits nothing.

3.8. memory_neighbors

Ports: DatabaseService

The memory graph around one path, to at most two hops, in both directions. Includes sleep-mined edges: lateral retrieval is what they are for, and each node’s derived says which kind of edge reached it. nodes holds at most 200 distinct paths, each at its minimal hop. limit chooses that ceiling and an ask outside 1..200 is clamped into it rather than refused, the same shape memory_list and trace_search have; node_limit echoes the bound the answer was built under. edges counts something DIFFERENT and is not a node count: it is the distinct edges the walk enumerated, including edges to paths the node clamp dropped, so it can exceed what the returned nodes account for. TWO markers report truncation, because they need different answers: dropped_node_count is the paths the walk reached and limit turned away, which a larger limit returns, while scan_saturated is the walk stopping at its own 10000-edge-row cap, which no limit recovers — narrow that one with rels or depth: 1 instead.

3.9. memory_resolve

Ports: DatabaseService

Follow a path an older answer, receipt, or external citation recorded FORWARD to the memory that carries the fact now. A path is the id of a memory and it is derived from the title, so a correction that rewords the title moves the file: the cited path stops resolving through no fault of the citation. stop_reason is what decides whether the answer is citable, and only live means yes. archived is a memory that was EVICTED rather than corrected, so nothing supersedes it and citing it as current would be wrong. unindexed is no such path here, which may also mean the index does not yet describe the commit that holds it — indexed_commit says which commit it does describe. cycle and hop_limit are the two abnormal endings: a cycle is two memories each claiming to supersede the other, an authoring defect, and hop_limit means path is where the walk stopped rather than the end of the chain, so resolving it again continues. steps names the mechanism of every hop, supersedes for an authored link and archive_move for a git mv into the archive, and each node is named by the path that holds it NOW. hops: 0 with stop_reason: live does NOT mean the bytes are unchanged: a correction whose title did not change lands at the same path. Read pinned_uri for the grain that answers that — it is a resource URI naming these bytes at a commit, so it cannot move, where memhtml://file/<path> always returns whatever is at the path today.

3.10. memory_archive

Ports: Store, Indexer

Soft-evict a memory: git mv into archive/<YYYY>/ with the archive stamps. Nothing is ever deleted, and git log --follow reads straight through.

3.11. memory_reinforce

Ports: DatabaseService

Record that a memory helped or misled. Gated by a 900-second per-path cooldown, so a replayed query cannot inflate a memory’s ranking; cooled_down lists the paths the cooldown held back.

3.12. memory_list

Ports: DatabaseService

Page through the corpus by facet. next_cursor is a keyset on the path, so a page stays correct even while a sleep cycle archives files. facets narrows by the corpus’s own <dl> facets, each entry spelled name=value (the value may contain =, the name may not). THE COMPOSITION IS FIXED: values under the SAME name broaden, so [“doc-type=runbook”,“doc-type=guide”] is either; DIFFERENT names narrow, so [“doc-type=runbook”,“tier=1”] is both. This is the extension axis. memhtml’s element and meta vocabularies are closed, so your own document kinds, states, and tiers belong in <dt>/<dd> pairs inside the article, and this is how you query them back. The match is on the facet’s TEXT with no case folding, so write the halves you mean to query. The stored form is the element’s text content, which the parser collapses whitespace runs in and trims, so <dd>runbook rollback</dd> is stored and queried single-spaced. There is no numeric comparison and that is deliberate: a <data value> is indexed UNITLESS, because the unit lives in the prose beside it, so you own the unit and match the text you wrote.

Ports: DatabaseService

Find past Claude Code sessions by what was asked in them. A read-only index over transcript files: no session content is stored, only pointers and capped heads.

Ports: DatabaseService

Which memories a session produced, or which sessions touched a memory. Needs a session_id or a path. Both absent is refused rather than returning every link ever recorded.

3.15. memory_status

Ports: Store, DatabaseService, RetrievalPolicy

Corpus health in one call: HEAD, dirty state, counts by type, edge totals, whether the index describes the current commit, how much of the index the vector arm can see, and when sleep last ran.

4. Provenance

A loader generates this page from apps/mcp/src/tools.ts while the site builds, so no file in the repository holds it: each description is folded from the shared constants the file concatenates, so the page cannot state a contract the server does not publish. Change the registry and this page changes with it.