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
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.
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 for the next hop.
Coming from 0.5?
engrava-mcpfollows engrava’s version, so runengrava-mcp0.6.x against engrava 0.6. The surface gains two edge read tools, optional edgemetadataonlink_thoughts, and an optionalrecency_nowonsearch_memory. It also validates numeric bounds: alimit,top_koroffsetoutside the accepted range is now rejected where it used to be answered, which is the one change here that turns a working request into an error. Upgrade to 0.6 covers the library side.
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. 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, audit trail | 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.
Since 0.6 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 is new in 0.6 and 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.
These 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
Every MCP client that speaks stdio uses the same mcpServers shape: a command,
its arguments, and an environment block. 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.
A ready-to-copy sample for each client below lives in
examples/. Replace
the ENGRAVA_MCP_CONFIG path (or swap it for ENGRAVA_DB_PATH) with your own
store.
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’s MCP support nests the servers under an mcp key. Add this to your
user settings.json or a workspace .vscode/mcp.json:
{
"mcp": {
"servers": {
"engrava": {
"command": "engrava-mcp",
"env": {
"ENGRAVA_MCP_CONFIG": "/absolute/path/to/engrava.yaml"
}
}
}
}
}
Other clients. Cline, Codex, and most other stdio MCP clients use the same
command/args/envblock as Claude Desktop and Cursor above — copy any of those entries. 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 |
Deterministic, unranked browse over stored thoughts — no score, newest first. 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, accepted 0 or greater) |
query_memory |
Run a structured MindQL FIND query, e.g. FIND thoughts WHERE lifecycle_status = 'ACTIVE' LIMIT 10. Only FIND is accepted. Returns columns and rows. |
query; optional limit (accepted 1–5000; overrides any LIMIT in the query) |
memory_stats |
Aggregate counts and store-health metrics: thought and edge counts (by type/status) and total storage size. | (none) |
get_edges |
Fetch the edges connected to one thought. Returns full edge records — including each edge’s metadata — and a count. 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) |
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.
query_memory deliberately accepts only the MindQL FIND command; raw-SQL
passthrough (SELECT), aggregate COUNT, and any extension commands are
rejected over the wire.
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.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, and offset are validated, and each accepted range is
published in the tool schema — as minimum / maximum on the argument — so a
client can read it before it calls:
| Argument | Tools | Accepted |
|---|---|---|
top_k |
search_memory, search_keywords |
1–1000 |
limit |
list_memory, list_edges, query_memory |
1–5000 |
offset |
list_memory |
0 or greater (no upper bound) |
Anything outside those ranges — a negative or zero page size, one past the
ceiling, a negative offset — is rejected before the tool runs, and the call
comes back as an error. In practice that error names the argument, the value you
sent, and the bound you crossed — but it 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, and an offset has no ceiling: skipping ahead cannot widen a scan.
This is the one 0.6 change that turns a previously-answered request into an
error. Such a value used to be passed through: a negative limit in particular
reaches SQLite, which reads LIMIT -1 as “no limit” and defeats the cap
entirely. If your client computes a page size, check it cannot land below 1.
Other 0.6 behaviour is visible without being an error — a recency_now that the
ENGRAVA_DB_PATH route previously accepted and ignored now takes part in
ranking.
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 change; the rest are untouched. | thought_id; optional essence, content, priority, lifecycle_status, confidence |
write (idempotent) |
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. Deleting an absent id is a no-op (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 — the same payload as the memory_stats tool (both share one implementation, so they always agree). |
engrava://recent |
static | The most-recently-updated thoughts (newest first) 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 recent thoughts 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 most recently stored thoughts, highlighting themes and anything unresolved. | optional limit — how many recent 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 they are never advertised to the client. The eight read tools, all three
resources, and all three prompts remain available.
Use it for any deployment that should only retrieve from memory and must not be able to change it — a shared read-only store, a demo, an analytics or question-answering client. Because the write tools are absent rather than merely guarded, a client in read-only mode has no path to mutate the store.
As an env block on a server entry (any client):
{
"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_memoryuses semantic vectors. Installing engrava’svecextra (pip install "engrava[vec]") in the same environment adds thesqlite-vecbackend for faster KNN; without it the vector signal still works via the built-in numpy backend. - 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.
- The failures this server recognises are returned as clean, actionable messages
— 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, or an invalid field value — rather than raw tracebacks, and those messages never expose internal table/column names or other deployment internals. A condition the server does not recognise is left to propagate as it is rather than dressed in a curated wording. An out-of-range bound is a third 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, not this server’s. 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.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.