MCP Server

engrava-mcp is a Model Context Protocol (MCP) server that exposes a memory store to any MCP-capable client — Claude Desktop, Claude Code, Cursor, Windsurf, VS Code, and others. Point a client at it and the assistant can search, read, and (optionally) write the same engrava store your application uses, with no glue code.

The server is an API consumer, not an engrava extension: it ships as a separate package (engrava-mcp), installed and versioned independently of engrava core, and it wraps engrava’s public async API over stdio. Think of it as a sibling of the CLI that talks to MCP clients instead of a terminal.

Install

The server is the standalone engrava-mcp package. The quickest path is to run it directly with uvx — no install step, no virtualenv to manage:

uvx engrava-mcp

To install it into an environment instead:

pip install engrava-mcp

For daily use, uv tool install engrava-mcp gives a persistent install. uvx keeps its environment in a cache, so a first start on a cold cache waits for the download.

engrava-mcp pulls engrava and the MCP SDK as its own dependencies, so it lives outside engrava core: plain pip install engrava is unaffected and stays dependency-light, and applications that embed engrava as a library never pay for the server they do not run.

Taking a new engrava release. A new install gets the newest engrava that engrava-mcp accepts (engrava>=0.7,<0.8 for 0.7.x). An existing install can keep its engrava until you upgrade it the way you installed it:

pip install -U --upgrade-strategy eager engrava-mcp   # pip
uv tool upgrade engrava-mcp                           # uv tool install
uvx --refresh engrava-mcp                             # uvx, once

With pip, --upgrade-strategy eager is what moves engrava: it takes the newest engrava inside the range, and updates engrava-mcp’s other dependencies within theirs. Without it, pip install -U engrava-mcp keeps an engrava that still fits the range, and pip install -U engrava takes the newest engrava even when it is outside the range. Run in a terminal with no store configured, uvx --refresh engrava-mcp exits with a missing-store error after the update; the next start uses the new engrava. uvx runs an installed tool when there is one, so after uv tool install use uv tool upgrade.

Upgrading from engrava 0.4? Replace pip install "engrava[mcp]" with pip install engrava-mcp (or run the server with uvx engrava-mcp). In 0.5, the MCP server moved out of the engrava package; the store environment variables (ENGRAVA_MCP_CONFIG, ENGRAVA_DB_PATH, and ENGRAVA_MCP_READ_ONLY) keep the same meaning. See Upgrade to 0.5 for the full cutover checklist, then Upgrade to 0.6 and Upgrade to 0.7 for the next hops.

Coming from 0.6? engrava-mcp follows engrava’s version, so run engrava-mcp 0.7.x against engrava 0.7 (engrava>=0.7,<0.8). It needs the MCP SDK 2.x line (mcp>=2.2,<3). Upgrade to 0.7 covers the library side. Two changes can turn a request into an error:

  • update_thought fails with a conflict error, applying nothing, if, when the server writes the update, there is no thought with that id at the revision the server read. The message says to re-read the thought and retry. It is not a check against what the caller read earlier.
  • query_memory refuses a query whose own LIMIT is outside 1–5000 when no limit argument is passed. See Numeric bounds.

Other 0.7 behaviour is visible without being an error:

  • query_memory returns at most 5000 rows when neither a limit argument nor a query LIMIT is given.
  • memory_stats and engrava://stats carry a measured flag.
  • More failures come back as clear messages (see Notes).
  • update_thought is no longer annotated idempotent.
  • The offset maximum is published in the tool schema.
  • search_memory with lifecycle_status=ARCHIVED finds archived thoughts.
  • Tool, resource and prompt names and counts are unchanged.

Run

The server is a standalone process served over stdio. Once installed (or via uvx), the engrava-mcp command starts it:

engrava-mcp
uvx engrava-mcp

Both build the same server and serve it on stdio. You normally do not launch it by hand — an MCP client spawns it as a subprocess using one of these commands (see Client configuration below). Running it directly in a terminal is mostly useful for a quick smoke test; it will wait for an MCP client to speak to it over stdin and exits on EOF.

Pointing the server at a store

The server resolves its store from the environment when it starts. Two variables are recognised, in priority order:

Variable Value Effect
ENGRAVA_MCP_CONFIG Path to an engrava.yaml Builds the store with the configured embedding provider, vector backend, journal, and TTL settings (SqliteEngravaCore.from_config).
ENGRAVA_DB_PATH Path to a SQLite database file Opens that file directly and ensures the schema. No embedding provider or vector backend is configured, so hybrid search runs without its vector arm, and no store hooks are configured. This route builds the store with no journal. Search otherwise runs under engrava’s default policy.

ENGRAVA_MCP_CONFIG takes precedence: if both are set, the config file wins. If neither is set, the server has no store to open: startup fails with an error naming both variables, rather than coming up and failing one tool call at a time. In a client that means the server never becomes usable.

Use ENGRAVA_MCP_CONFIG whenever you want semantic (vector) search or any non-default storage settings; the database created by your application via engrava.yaml is the same file the server should open. Use ENGRAVA_DB_PATH for a quick connection to a bare database file when you can do without the vector signal.

# Full configuration — semantic search, journal, TTL, etc.
export ENGRAVA_MCP_CONFIG=/path/to/engrava.yaml
engrava-mcp

# Or a bare database file — no vector signal
export ENGRAVA_DB_PATH=/path/to/agent-memory.db
engrava-mcp

In a client configuration these become env entries on the server block, shown next.

What the two routes can and cannot do

The two variables are not the same store with different ergonomics — they differ in capability, and the difference is worth knowing before you pick one:

ENGRAVA_MCP_CONFIG ENGRAVA_DB_PATH
Semantic (vector) search Configured when the yaml declares an embedding provider — the server reads the declaration, it does not test that the provider answers No — the vector signal is inert
Full-text search, graph, MindQL Yes Yes
search_memory’s recency_now Honoured under whatever search policy the yaml declares — a policy that gives recency no weight neutralises it Honoured under engrava’s default search policy
Store-hook extensions Yes, via the yaml’s hooks: section No — the route carries no configuration, so it attaches none

Two of those rows are worth reading twice. The first promises less than it looks like: this server sees that a provider is declared, not that it is reachable or correctly keyed, so a misconfigured provider is a runtime discovery either way — backends_used on a search_memory response is what tells you whether vectors actually took part.

The last row is the one that surprises people. An extension that hooks the store is wired through an engrava.yaml:

hooks:
  class: "my_package.hooks.MyHooks"

ENGRAVA_DB_PATH opens a bare database file and has no configuration channel, so an extension installed in the same environment is inert on that route — its store hooks are never attached. That is deliberate: the bare-database route is an intentionally minimal read/write facade, not a degraded version of the config route.

The server says so instead of leaving you to infer it. Two startup warnings are worth knowing about, and on the bare-database route neither is emitted until the database is open and its schema is ready — a launch that fails on the file itself emits neither of them, and fails with the underlying error instead.

The first is not specific to this route: whenever no embedding provider is resolved, the server warns that semantic search is inert. That covers every ENGRAVA_DB_PATH launch that reaches a working store, and also an ENGRAVA_MCP_CONFIG launch whose yaml declares no provider. The config route is where the timing differs: there the warning comes out of reading the configuration, before the database is opened, so it can be the last thing you see if that open then fails.

The second belongs to the bare-database route alone. When installed packages advertise an Engrava extension, the server names them, states that this route configures no store hooks, and points at the config route. With nothing advertised there is nothing to say and the warning does not appear; if the installed-package metadata cannot be read at all, the server reports that read failure in its place and carries on starting.

The extension warning reports what a package advertises, read from installed-package metadata. The server never loads or inspects the extension itself, so the list tells you something is installed — not what it does, and not that it would have hooked the store.

Two INFO lines also come out at startup: one names the variable the store is being opened from, and one gives the seconds the store took to become ready. They carry no path and no configuration content. These lines and the warnings go through Python’s standard logging, so whether and where they surface is up to the logging configuration of the process — with a client that discards the server’s stderr you may not see them at all.

Client configuration

MCP clients that speak stdio are configured with the same three things: a command, its arguments, and an environment block. Most put them under a mcpServers key; VS Code uses servers and also requires "type": "stdio" on each entry. Engrava is a native stdio server, so clients spawn engrava-mcp directly. There is no HTTP endpoint to host and — unlike HTTP-only MCP servers — no npx mcp-remote shim to wedge between the client and the server. Fewer moving parts, one process, local by default.

Ready-to-copy samples live in examples/. Replace the ENGRAVA_MCP_CONFIG path (or swap it for ENGRAVA_DB_PATH) with your own store.

Two caveats apply to the entries below:

  • Use absolute paths. A relative ENGRAVA_DB_PATH, or a relative database.path in the engrava.yaml, resolves against the server’s working directory, which the client chooses — not against the yaml’s folder.
  • Add the variable behind a ${VAR} to the env block. A ${VAR} value for the embeddings api_key in the engrava.yaml, such as ${OPENAI_API_KEY}, is read from the server’s own environment, so add that variable to the same env block as the entry’s other variables.

Claude Desktop

Edit claude_desktop_config.json (Settings → Developer → Edit Config):

{
  "mcpServers": {
    "engrava": {
      "command": "engrava-mcp",
      "env": {
        "ENGRAVA_MCP_CONFIG": "/absolute/path/to/engrava.yaml"
      }
    }
  }
}

Restart Claude Desktop; “engrava” appears in the tools menu.

Claude Code

Register the server from the project root:

claude mcp add engrava --env ENGRAVA_MCP_CONFIG=/absolute/path/to/engrava.yaml -- engrava-mcp

That writes an mcpServers entry of the same shape into your Claude Code configuration. Equivalent JSON, if you prefer to edit it directly:

{
  "mcpServers": {
    "engrava": {
      "command": "engrava-mcp",
      "env": {
        "ENGRAVA_MCP_CONFIG": "/absolute/path/to/engrava.yaml"
      }
    }
  }
}

Cursor

Add an entry to .cursor/mcp.json (project-scoped) or the global ~/.cursor/mcp.json:

{
  "mcpServers": {
    "engrava": {
      "command": "engrava-mcp",
      "env": {
        "ENGRAVA_MCP_CONFIG": "/absolute/path/to/engrava.yaml"
      }
    }
  }
}

Windsurf

Add an entry to ~/.codeium/windsurf/mcp_config.json:

{
  "mcpServers": {
    "engrava": {
      "command": "engrava-mcp",
      "env": {
        "ENGRAVA_MCP_CONFIG": "/absolute/path/to/engrava.yaml"
      }
    }
  }
}

VS Code

VS Code reads a top-level servers object. Add this to a workspace .vscode/mcp.json, or to the user-level mcp.json:

{
  "servers": {
    "engrava": {
      "type": "stdio",
      "command": "engrava-mcp",
      "env": {
        "ENGRAVA_MCP_CONFIG": "/absolute/path/to/engrava.yaml"
      }
    }
  }
}

Other clients. Cline, Codex, and most other stdio MCP clients take the same command / args / env values as Claude Desktop and Cursor above, in whatever format the client’s own configuration file uses. If a client cannot find engrava-mcp on its PATH, set command to the absolute path of the script inside your virtual environment (for example /path/to/.venv/bin/engrava-mcp), or run it through uvx with "command": "uvx" and "args": ["engrava-mcp"].

Tool reference

The server registers thirteen tools: eight read tools that are always available, and five write tools that are available unless the server is started in read-only mode. Tools return JSON; a tool that creates or updates a record returns that record’s key fields, while the two delete tools report only whether something was removed.

Tools carry MCP annotations so clients can present them safely: the read tools are marked read-only, the write tools are marked as writes, and the two delete tools additionally carry a destructive hint so a client can warn before it runs them.

Read tools (always available)

Tool Purpose Key arguments
get_thought Fetch a single thought by its identifier. Returns a found flag and the thought (or null). thought_id
search_memory Hybrid ranked search (lexical + vector + recency). Returns ranked thought_id/score pairs and the backends_used. query_text; top_k (default 10, accepted 1–1000); include_reflections (default true); optional thought_type, lifecycle_status, priority, recency_now
search_keywords Pure full-text BM25 keyword search. Supports AND, OR, NOT, and prefix *. Returns ranked thought_id/score pairs. query; top_k (default 10, accepted 1–1000)
list_memory Unranked browse over stored thoughts — no score, ordered by cognitive cycle (highest first; ties unspecified). The home for “list memory by structured field”. thought_type, lifecycle_status, priority, min_cycle, max_cycle, include_expired (default false); limit (default 50, accepted 1–5000), offset (default 0; see Numeric bounds)
query_memory Run a structured MindQL FIND query, e.g. FIND thoughts WHERE lifecycle_status = 'ACTIVE' LIMIT 10. Only FIND is accepted, against thoughts, edges, or actions. Returns columns and rows, capped at 5000 rows. query; optional limit (accepted 1–5000; replaces any LIMIT in the query — see Numeric bounds)
memory_stats Aggregate counts and store-health metrics: thought and edge counts (by type/status), total storage size, and a measured flag. measured is false when the store’s metrics collection is disabled, and the counts in metrics are then zero-filled placeholders rather than a measurement. (none)
get_edges Fetch the edges connected to one thought. Returns full edge records — including each edge’s metadata — and a count. With limit, at most that many edges come back, the highest-weight ones first; without it, every matching edge does. The read companion to link_thoughts and delete_edge. thought_id; direction — OUT (edges leaving the thought), IN (edges arriving at it), or BOTH (default); optional limit (accepted 1–5000)
list_edges Browse stored edges with optional filters. Returns full edge records — including each edge’s metadata — and a count. optional edge_type, source, metadata_equals, metadata_in; limit (default 100, accepted 1–5000)

A note on search_memory filters: this MCP tool exposes simple structured filters (thought_type, lifecycle_status, priority) and applies them after ranking. A filtered call may therefore return fewer than top_k results and adds a filtered block reporting how many ranked hits were scanned, matched, and dropped — so a short list is never mistaken for “nothing was found”. When you want an exhaustive, paginated listing by those same fields, use list_memory instead.

search_memory leaves archived thoughts out of the ranking unless lifecycle_status is ARCHIVED, which searches them instead. search_keywords leaves them out and has no filter to widen that.

query_memory deliberately accepts only the MindQL FIND command; raw-SQL passthrough (SELECT), aggregate COUNT, and any extension commands are rejected over the wire. FIND is accepted against thoughts, edges, and actions; FIND embeddings is refused, since a stored embedding is raw vector bytes with no JSON representation.

A note on ordering: list_memory, engrava://recent, and summarize_recent_memory all order by cognitive cycle, highest first, with no tie-breaker among thoughts that share a cycle — not newest first, and not guaranteed stable there. A thought this server creates starts at cycle 0, and an update through it does not advance that cycle, so thoughts written only through this server all share cycle 0 and their relative order is unspecified.

query_memory also accepts the valid-time predicates (valid_now, valid_at, valid_within, valid_between) in the WHERE clause, e.g. FIND thoughts WHERE valid_now or FIND thoughts WHERE valid_at '2026-01-01T00:00:00+00:00'. This is the only way to do point-in-time / time-travel filtering over MCP. See the Bi-temporal Model for the semantics.

search_memory accepts an optional recency_now: an ISO-8601 timestamp used as “now” for the recency signal, so a stateless client can rank by transaction time — how long ago a thought was last written or updated. Pass something that is not a valid ISO-8601 timestamp and the call is rejected with a format hint.

It is worth being precise about what omitting it means, because it is not “the ranker falls back to its own recency default”. Recency needs a reference point, and this server has no cognitive-cycle clock to offer one: over MCP, recency_now is the reference. Without it the recency signal does not participate at all, on either launch route. You can see this directly: recency shows up in the response’s backends_used only on calls that carry the timestamp — and, on a configured route, only if the search policy weighs recency at all.

Supply it and it is honoured on both routes. The ENGRAVA_DB_PATH quick-start opens the store under engrava’s default search policy, so a timestamp supplied there ranks rather than being accepted and ignored — under those default weights, which is what an engrava.yaml declaring no search section also resolves to. A yaml that declares one can weigh recency differently, including down to nothing. What the bare launch still does not have either way is a vector signal — see Optional vector search.

Reading the graph

get_edges and list_edges are the read half of the memory graph. Before them the write tools could create and remove edges that a client had no way to read back.

  • get_edges starts from one thought and follows its edges: OUT for edges leaving it, IN for edges arriving at it, BOTH for either. An identifier that does not exist simply has no edges — that is not an error. Pass limit (1–5000) to get at most that many edges, the highest-weight ones first. Without it every edge of the thought comes back, so a thought with many edges is returned in full.
  • list_edges browses edges across the whole store, narrowed by edge_type, by knowledge source, and by the edge’s own metadata.

Both return complete edge records, each including its metadata, alongside a count.

Metadata filtering takes two arguments, and they combine (every supplied condition must hold):

  • metadata_equals — a mapping of field name to a required value; the field must equal it exactly.
  • metadata_in — a mapping of field name to a list of allowed values; the field must equal one of them.

Keys are simple, top-level field names — letters, digits, and underscores (session_id, topic2). Values are JSON scalars: a string, number, boolean, or null. A dotted or bracketed key (run.id, tags[0]) is rejected at the boundary, before any query is built, and so is a structured value. That is a deliberate boundary rather than a missing feature: nested metadata addressing is not part of this server’s surface, and the rejection is what keeps it that way.

list_edges returns at most limit edges, default 100. That default belongs to this server rather than to the library underneath it: an MCP response is read into an agent’s context window, so a focused page is a better default over the wire than a bulk dump. Raise limit explicitly (up to 5000) when you genuinely want more.

Numeric bounds

limit, top_k, offset, min_cycle, and max_cycle are validated against the range each one publishes in the tool schema — as minimum / maximum on the argument — so a client can read it before it calls:

Argument Tools Range in the schema
top_k search_memory, search_keywords 1–1000
limit list_memory, list_edges, query_memory (optional), get_edges (optional) 1–5000
offset list_memory 0–2⁶³ − 1
min_cycle, max_cycle list_memory −2⁶³ to 2⁶³ − 1

A value outside those ranges — a negative or zero page size, one past the ceiling, a negative offset, an offset or cycle bound outside SQLite’s 64-bit integer range — is rejected before the tool runs, and the call comes back as an error. That error is written by the server framework’s argument validation rather than by this server, so read the range from the published schema rather than parsing the message for it. Zero is a perfectly good offset.

A query_memory query can carry its own LIMIT and OFFSET, so it has a few more rules:

  • A limit argument replaces the query’s LIMIT outright; the two are not combined.
  • With no limit argument, the query’s own LIMIT stays, and a LIMIT outside 1–5000 is refused.
  • With neither, the server applies 5000, so the result has at most 5000 rows.
  • A query’s own OFFSET outside 0–2⁶³ − 1 is refused with a message that names the clause. This server writes that message, so it does not come from the framework.

Before 0.6 an out-of-range value was passed through: a negative limit reached SQLite, which reads LIMIT -1 as “no limit” and defeated the cap it was meant to impose. If your client computes a page size, check it cannot land below 1.

Write tools (hidden in read-only mode)

Tool Purpose Key arguments Annotation
store_thought Create a new thought node. New thoughts start in CREATED lifecycle state. Returns the created thought’s identifier and key fields. essence, content; optional thought_type (default NOTE), priority (default P3), source (default "agent"), confidence, thought_id, deduplicate write
update_thought Update selected fields of an existing thought. Only supplied fields are edited; the store also refreshes updated_at and bumps the thought’s revision. If, when the server writes the update, there is no thought with that id at the revision the server read, the call fails with a conflict and nothing is applied. thought_id; optional essence, content, priority, lifecycle_status, confidence write
link_thoughts Create a typed edge between two existing thoughts. Both endpoints must already exist. Returns the edge’s key fields, including its metadata. from_thought_id, to_thought_id, edge_type; optional weight (default 1.0, accepted 0.0–1.0), edge_id, metadata write
delete_thought Delete a thought by its identifier. The thought’s edges, embeddings, and action records are deleted with it. Deleting an absent id returns deleted: false, not an error. thought_id destructive
delete_edge Delete an edge by its identifier. Deleting an absent id is a no-op (returns deleted: false), not an error. edge_id destructive

link_thoughts edges are unique per (source, target, type): linking the same pair with the same type twice is rejected rather than ignored, so this write is not idempotent. The valid thought_type, lifecycle_status, priority, and edge_type values are the engrava enums (for example thought_type is one of TASK, OBSERVATION, BELIEF, REFLECTION, OUTPUT_DRAFT, NOTE).

link_thoughts also takes an optional metadata object: a flat mapping of field names to JSON scalars, stored on the edge and returned by get_edges and list_edges. It is the same metadata list_edges filters on, so it is how you tag an edge with, say, a session_id or a topic and pull those edges back later. Omit it and the edge is stored with empty metadata.

Two details are worth knowing before you design keys around it. Only flat objects cross this surface — a nested object as a value is rejected, so structure the fields flat. And list_edges accepts only simple field names as filter keys, which means a key that is not a simple field name can be written here but never filtered on afterwards. Keep stored keys to letters, digits, and underscores and the two halves stay in step.

Resources

Where tools are invoked, resources are addressable engrava:// URIs that a client surfaces as attachable context (drop them into a conversation, no tool call). Three resources are registered — two static resources (engrava://stats, engrava://recent) and one resource template (engrava://thought/{thought_id}, parameterised by id). They are reads by definition, so they are always available — they are not hidden by read-only mode — and each returns a JSON document (application/json).

Resource Kind Returns
engrava://thought/{thought_id} template A single thought as JSON. Reading an unknown identifier yields a graceful not-found payload rather than an error.
engrava://stats static Store-health counts and total size, with the measured flag that marks zero-filled placeholder metrics — the same payload as the memory_stats tool (both call one implementation).
engrava://recent static The highest-cycle thoughts (ties unspecified) as JSON.

Prompts

Prompts are parameterised templates a client surfaces as slash-commands or buttons. Each renders a ready-to-send instruction that guides the assistant to gather context with the read tools and resources above before answering. They open no write path and call no mutation. find_related and reflect_on_topic are pure templates; summarize_recent_memory is the one that reads the store as it renders, embedding the thoughts it reads in the instruction it hands back. Like resources, prompts are read-oriented and are always available, including in read-only mode.

Prompt What it scaffolds Arguments
summarize_recent_memory A concise summary of the highest-cycle thoughts (ties unspecified), highlighting themes and anything unresolved. optional limit — how many thoughts to consider (default 5, accepted 1–5000)
find_related Gather and synthesise stored thoughts related to a topic, grouping related points and noting gaps or contradictions. required topic
reflect_on_topic A structured reflection over what memory holds about a topic: what is established, open questions, and tensions, with concrete follow-ups. required topic

Read-only mode

Set ENGRAVA_MCP_READ_ONLY to a truthy value (1, true, or yes, case-insensitive) to start the server with a retrieval-only surface:

export ENGRAVA_MCP_READ_ONLY=true
export ENGRAVA_MCP_CONFIG=/absolute/path/to/engrava.yaml
engrava-mcp

In read-only mode the five write tools — store_thought, update_thought, link_thoughts, delete_thought, delete_edge — are not registered at all, so the client is not offered them. The eight read tools, all three resources, and all three prompts remain available. Reads also leave a thought’s access_count as they found it: with access tracking on, a plain read would buffer an update that is written when the store closes, and in this mode it does not.

Use it for a deployment that should retrieve from memory and not change it through this server — a demo, an analytics or question-answering client.

Read-only mode governs the tools, not how the database is opened. At startup the server still opens the database read-write, creates it if it is missing, and upgrades its schema to the one its engrava version uses, so a database file the server cannot write cannot be served in this mode either.

As an env block on a server entry, shown in the mcpServers shape (VS Code uses servers, with "type": "stdio" on the entry):

{
  "mcpServers": {
    "engrava": {
      "command": "engrava-mcp",
      "env": {
        "ENGRAVA_MCP_CONFIG": "/absolute/path/to/engrava.yaml",
        "ENGRAVA_MCP_READ_ONLY": "true"
      }
    }
  }
}

Hybrid search (search_memory) combines lexical, vector, and recency signals. Whether the vector signal is active depends on how the store is configured:

  • With ENGRAVA_MCP_CONFIG pointing at an engrava.yaml that configures an embedding provider, search_memory embeds the query with that provider and uses semantic vectors. Whether a thought written through this server gets an embedding is decided by that engrava.yaml’s embeddings.auto_embed, which engrava leaves off by default. With it off, a thought created through store_thought gets no embedding, so the vector ranking cannot match it — keyword ranking still can — and update_thought leaves any existing embedding as it was. With it on, creating a thought, or changing its essence or content, also calls the provider. engrava-mcp already installs the sqlite-vec backend; engrava uses it only when the yaml sets extensions.vector.backend: sqlite-vec — the default is numpy.
  • With ENGRAVA_DB_PATH (a bare database file) — or any store without an embedding provider — there is no vector signal, and search_memory degrades gracefully to the signals that remain: lexical (BM25) relevance and the other non-vector signals the store’s search policy applies. It is the vector arm that is missing, not the ranking. search_keywords is pure BM25 either way.

The backends_used field on a search_memory response names the search backends and ranking signals that were available for that query — an entry is listed even when it contributed no hits, and its absence means it was unavailable or did not apply. It is where you check what a given call actually had to work with: whether vectors were in play, and whether recency was — which over MCP needs a recency_now on the call, and a search policy that weighs recency at all.

Notes

  • The server is single-writer, like engrava itself — point it at a store that is not being written concurrently by another process. Concurrency covers what a store promises concurrent callers.
  • The failures this server recognises are returned as clean, actionable messages rather than raw tracebacks, and the server writes those messages to leave out internal table and column names and other deployment internals. The set is open-ended; examples are an unknown thought_id on a tool that needs the thought to exist, a non-FIND query, a duplicate link_thoughts edge, an unparseable recency_now, an invalid metadata filter, an invalid field value, a busy store, a write that timed out waiting behind another write, a store left unusable until the server restarts, an update_thought conflict, a search_memory whose embedding provider no longer matches the stored vectors, a write whose embedding does not match the store’s embedding model (or cannot be produced when the configuration requires embeddings), and a derived record the store rejected. In the last two cases the write to the thought itself stands; the embedding or derived record is what was not written.
  • An out-of-range bound on a published argument is a separate case: the framework’s argument validation refuses it before the tool handler and this server’s error mapping run, so that reply is the SDK’s to phrase. A LIMIT or OFFSET written inside a query_memory query is checked by this server, and the message names it.
  • What a failure message says about the write depends on what the store reported and on the tool. When the store reports that it could not get SQLite’s write lock, a write tool replies that the store is busy, that nothing was changed, and that retrying the call is safe. When SQLite itself reports the database busy, link_thoughts, delete_thought and delete_edge say that nothing was written (or deleted) and that retrying is safe. store_thought instead says it could not confirm whether the thought was stored, and reports what a read of that id just now found — a thought with that id, or none. If that read fails, it says to read the thought back before retrying, or, with deduplicate on, that reading the id back cannot settle what happened. The read does not say whether this call stored it, and with deduplicate on, finding the id settles nothing, because the call may have matched an existing thought. A write that timed out waiting behind another write does not say that nothing changed; it says to read the affected thought back first if you are not sure whether the request was applied.
  • For store_thought and update_thought only, a failure the server does not recognise gets a message that says so, says that whether the write took effect could not be confirmed, and points at reading the thought back with get_thought before retrying. With deduplicate on, the store_thought message says instead that reading the id back cannot settle what happened, because finding it shows only that a thought with that id exists, not that this call stored it. The message does not say where the error came from.
  • Where an absent record is not an error the tools say so in their payload instead: get_thought reports found: false, get_edges returns an empty list, and the deletes return deleted: false.
  • Thoughts and edges created through the write tools start at cycle 0: this API consumer has no notion of the agent cycle clock, which your application owns.

Next

  • Upgrade to 0.7 — moving from engrava 0.6, and the library-side behaviour changes behind this surface.
  • Upgrade to 0.6 — moving from engrava 0.5, and the library-side behaviour changes behind this surface.
  • Upgrade to 0.5 — moving from engrava 0.4, including MCP migration.
  • Hybrid Search — how the retrieval ranking works.
  • Bi-temporal Model — the valid-time predicates query_memory accepts.
  • Configuration — wiring an embedding provider via engrava.yaml.
  • API Reference — the store methods the server wraps.