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]" 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 for the next hop.

Coming from 0.5? engrava-mcp follows engrava’s version, so run engrava-mcp 0.6.x against engrava 0.6. The surface gains two edge read tools, optional edge metadata on link_thoughts, and an optional recency_now on search_memory. It also validates numeric bounds: a limit, top_k or offset outside 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 / env block as Claude Desktop and Cursor above — copy any of those entries. 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 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; directionOUT (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_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.
  • 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, 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"
      }
    }
  }
}

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 uses semantic vectors. Installing engrava’s vec extra (pip install "engrava[vec]") in the same environment adds the sqlite-vec backend 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, 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.
  • The failures this server recognises are returned as clean, actionable messages — 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, 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_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