---
title: MCP tools
description: The tools the stdio server publishes, in the order a client receives them.
---

## 1. The toolkit

[`memhtml serve mcp`](/reference/commands/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.

### 3.4. memory\_search

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).

### 3.7. memory\_link

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.

### 3.13. trace\_search

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.

### 3.14. trace\_links

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.