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]"withpip install engrava-mcp(or run the server withuvx engrava-mcp). In 0.5, the MCP server moved out of theengravapackage; the store environment variables (ENGRAVA_MCP_CONFIG,ENGRAVA_DB_PATH, andENGRAVA_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-mcpfollows engrava’s version, so runengrava-mcp0.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_thoughtfails with a conflict error, applying nothing, if, when the server writes the update, there is no thought with that id at therevisionthe server read. The message says to re-read the thought and retry. It is not a check against what the caller read earlier.query_memoryrefuses a query whose ownLIMITis outside 1–5000 when nolimitargument is passed. See Numeric bounds.Other 0.7 behaviour is visible without being an error:
query_memoryreturns at most 5000 rows when neither alimitargument nor a queryLIMITis given.memory_statsandengrava://statscarry ameasuredflag.- More failures come back as clear messages (see Notes).
update_thoughtis no longer annotated idempotent.- The
offsetmaximum is published in the tool schema.search_memorywithlifecycle_status=ARCHIVEDfinds 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 relativedatabase.pathin theengrava.yaml, resolves against the server’s working directory, which the client chooses — not against theyaml’s folder. - Add the variable behind a
${VAR}to theenvblock. A${VAR}value for the embeddingsapi_keyin theengrava.yaml, such as${OPENAI_API_KEY}, is read from the server’s own environment, so add that variable to the sameenvblock 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/envvalues as Claude Desktop and Cursor above, in whatever format the client’s own configuration file uses. If a client cannot findengrava-mcpon itsPATH, setcommandto the absolute path of the script inside your virtual environment (for example/path/to/.venv/bin/engrava-mcp), or run it throughuvxwith"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_edgesstarts from one thought and follows its edges:OUTfor edges leaving it,INfor edges arriving at it,BOTHfor either. An identifier that does not exist simply has no edges — that is not an error. Passlimit(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_edgesbrowses edges across the whole store, narrowed byedge_type, by knowledgesource, 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
limitargument replaces the query’sLIMIToutright; the two are not combined. - With no
limitargument, the query’s ownLIMITstays, and aLIMIToutside 1–5000 is refused. - With neither, the server applies 5000, so the result has at most 5000 rows.
- A query’s own
OFFSEToutside 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"
}
}
}
}
Optional vector search
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_CONFIGpointing at anengrava.yamlthat configures an embedding provider,search_memoryembeds the query with that provider and uses semantic vectors. Whether a thought written through this server gets an embedding is decided by thatengrava.yaml’sembeddings.auto_embed, which engrava leaves off by default. With it off, a thought created throughstore_thoughtgets no embedding, so the vector ranking cannot match it — keyword ranking still can — andupdate_thoughtleaves any existing embedding as it was. With it on, creating a thought, or changing itsessenceorcontent, also calls the provider.engrava-mcpalready installs thesqlite-vecbackend; engrava uses it only when theyamlsetsextensions.vector.backend: sqlite-vec— the default isnumpy. - With
ENGRAVA_DB_PATH(a bare database file) — or any store without an embedding provider — there is no vector signal, andsearch_memorydegrades 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_keywordsis 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_idon a tool that needs the thought to exist, a non-FINDquery, a duplicatelink_thoughtsedge, an unparseablerecency_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, anupdate_thoughtconflict, asearch_memorywhose 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
LIMITorOFFSETwritten inside aquery_memoryquery 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_thoughtanddelete_edgesay that nothing was written (or deleted) and that retrying is safe.store_thoughtinstead 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, withdeduplicateon, that reading the id back cannot settle what happened. The read does not say whether this call stored it, and withdeduplicateon, 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_thoughtandupdate_thoughtonly, 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 withget_thoughtbefore retrying. Withdeduplicateon, thestore_thoughtmessage 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_thoughtreportsfound: false,get_edgesreturns an empty list, and the deletes returndeleted: 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_memoryaccepts. - Configuration — wiring an embedding provider via
engrava.yaml. - API Reference — the store methods the server wraps.