Agents and MCP

An agent — Claude Code, Cursor, Claude Desktop, one you build — searches and remembers through tools it calls. fenecdb serves those tools over MCP, the Model Context Protocol: on its HTTP endpoint, or as a process the agent starts.

Each tool runs a statement as /query runs one, under the token the agent was given: a scoped token's agent reads its own rows and no others, the tools that write are off unless the server is told, and an answer too large for a model's context is refused with what to narrow — never cut, since a model told nothing of the cut takes half an answer for the whole.

Turn it on

On a server, --mcp serves the endpoint at POST /_mcp (and /t/<tenant>/_mcp on a tenant node, which a router forwards like any tenant path) — an underscore, as /_metrics has one, so that no collection's path is taken:

fenec-server --file notes.fenec --http-token "$FENEC_TOKEN" --mcp
fenec-server --file notes.fenec --http-token "$FENEC_TOKEN" --mcp-write --mcp-memory memories

Over a file, fenec mcp is the server as a process the agent starts, speaking stdio — a message a line on stdin, an answer a line on stdout, its log on stderr:

fenec mcp --file notes.fenec --memory memories
fenec mcp --url http://127.0.0.1:8080/_mcp        # in front of a server, the token from FENEC_TOKEN

--file holds the database itself and takes notes.fenec.lock while the session lasts — a server or an app over the same file is refused, as it is refused while they hold it: two processes writing one file corrupt it. Each write is on the disk before it is answered. --read-only takes no lock and writes nothing, and reads the file as it stood when the session began, beside a server writing it; to read what a server serves now, use --url. Over --url the tools are the server's, under its options and the token's rules, and the bridge decides nothing.

Claude Code

claude mcp add fenecdb -- fenec mcp --file notes.fenec --memory memories
claude mcp add --transport http fenecdb http://127.0.0.1:8080/_mcp --header "Authorization: Bearer $FENEC_TOKEN"
claude mcp list                                  # fenecdb: ... - ✓ Connected

With --scope project each goes into the project's .mcp.json, which Claude Code loads for whoever opens it — the token left in the environment:

.mcp.json
{
  "mcpServers": {
    "fenecdb": {
      "type": "stdio",
      "command": "fenec",
      "args": ["mcp", "--file", "notes.fenec", "--memory", "memories"]
    }
  }
}
.mcp.json, a server
{
  "mcpServers": {
    "fenecdb": {
      "type": "http",
      "url": "http://127.0.0.1:8080/_mcp",
      "headers": { "Authorization": "Bearer ${FENEC_TOKEN}" }
    }
  }
}

Cursor

.cursor/mcp.json
{
  "mcpServers": {
    "fenecdb": {
      "command": "fenec",
      "args": ["mcp", "--file", "notes.fenec", "--memory", "memories"]
    }
  }
}
.cursor/mcp.json, a server
{
  "mcpServers": {
    "fenecdb": {
      "url": "http://127.0.0.1:8080/_mcp",
      "headers": { "Authorization": "Bearer ${env:FENEC_TOKEN}" }
    }
  }
}

Claude Desktop

Claude Desktop starts its tools as processes, so a server is reached through the bridge. An app started from the dock does not see a shell's PATH: name fenec by its full path.

claude_desktop_config.json
{
  "mcpServers": {
    "fenecdb": {
      "command": "fenec",
      "args": ["mcp", "--url", "http://127.0.0.1:8080/_mcp"],
      "env": { "FENEC_TOKEN": "the token" }
    }
  }
}

Every configuration on this page is loaded by a test (integrations/mcp/test.js, make mcp-test) and each of its servers called with the official TypeScript MCP client.

The tools

toolwhat it doestakes
schemathe collections the token may read, each as the FenecQL that makes it, and — to the server's token — how many rows each holdscollection
searchrows ranked best first: by words over an @text field (BM25), by an embedding over an @hnsw field, or both fused; each row with _score and, by words, a snippet with the words found in **bold**collection, text, vector, filter, params, limit, fields, text_field, vector_field
queryone FenecQL read: get with any clause, aggregates, count, lookup, explainquery, params, include_vectors
getrows by id; the ids with no row come back in missingcollection, ids, fields
writeone put, insert, set or del, with require n if it must touch n rows (--mcp-write)statement, params
rememberkeeps a memory (--mcp-memory)text, vector, tags, expires_in
recallthe memories that best match, or the latesttext, vector, tags, limit
forgetdeletes a memory by its idid

A tool answers JSON — {"rows": [...], "count": n} for a read — as its structured content and, written out, as the text a model reads. Vector fields are left out of the rows: a 768-dim embedding is some 7 KB of JSON a row, which a model reads as noise and pays for. fields names the ones to keep, and query's include_vectors keeps them. A search's call and its answer:

tools/call
{"name": "search", "arguments": {"collection": "docs", "text": "fox", "filter": "year >= $1", "params": [2024]}}
structuredContent
{"rows": [{"id": 3, "title": "cat", "body": "the cat watches the fox", "year": 2025,
           "snippet": "the cat watches the **fox**", "_score": 0.67}], "count": 1}

Embeddings are the client's to compute. fenecdb stores and searches vectors and runs no model: an agent that has one passes vector, made with the model the collection was filled with — a vector of another length is refused, naming the field's. Without one, search by words.

Values go in params, never into the text. A filter is a FenecQL condition, year >= $1 and tags has $2, parsed on its own and refused unless it is a condition and nothing more — no clause after it, no second statement, no parenthesis closing on the statement around it — and it goes into the statement the tool builds as the condition parsed, never as text. Names are checked against FenecQL's rule for one.

What a tool refuses comes back for the model to correct, as its result with isError: an argument it does not take, a vector of the wrong length, a statement the token's rules refuse, an answer past a bound — each saying what to change, and with the status /query would have answered it with. A call that is not one — an unknown tool, a malformed message — is a JSON-RPC error.

Bounds

flagdefaultpast it
--mcp-max-rows100refused, 413: a query with no limit is asked for one row more than the bound, so a larger answer is known without being fetched
--mcp-max-bytes64Krefused, 413, measured on the JSON the model would read
--mcp-calls-per-sec20refused, 429, with when to call again: a scoped token's subject, or an address for the server's token

A hundred rows and 64 KB are about 16 000 tokens of JSON, inside what Claude Code takes from one tool before it warns. A tenant node's quotas hold an agent's calls as they hold a client's requests.

Security

  • The token is the request's. The endpoint takes the bearer token /query takes: the server's token reads and writes everything, and a JSON Web Token is held to its policy, ANDed into every statement a tool runs — so an agent given a scoped token sees exactly what its /query sees. schema lists only the collections it may read and counts none of their rows, and on a tenant node the token reaches only the tenant its claim names.
  • Writes are off unless asked for. --mcp-write adds write, and --mcp-memory the memory tools alone. write runs put, insert, set and del, each held to the token's grants and to its policy as the row it writes; a schema change or a compact is never made over MCP, by any token. A server with --http-read-only takes no write over MCP either.
  • Where a request comes from is asked before its token. An Origin other than the server's own, or the one --http-cors names, is refused (403). A server that asks for no token answers only a Host naming this machine — localhost or an address — so a page whose name was rebound to 127.0.0.1 reaches nothing.
  • No TLS. fenecdb speaks none: on an open network put a TLS terminator in front of the server, and give the agent's client the https URL. The stdio bridge speaks plain HTTP only.
  • An agent's writes are audited. With --audit each call of write, remember or forget is a line ("event":"mcp", the tool, whether it failed), beside the schema changes and the refused tokens. /_metrics counts calls in fenec_mcp_tool_calls_total by tool and outcome — no collection is a label.

Memory

--mcp-memory memories gives an agent a memory that outlives its conversations: remember keeps a sentence, recall finds the ones that match what it is asked, and forget lets a wrong one go. The collection is made at the first memory, with a vector field if that memory came with a vector — and given one at the first that does:

create collection if not exists memories (text text required @text, tags [text], owner text @hash, at timestamp @sorted, expires timestamp @ttl(1ms), vector vector<3> @hnsw(cosine))

The text is searched by its words (@text), so an agent with no embedding model still recalls: BM25 finds the deploy runs on fridays for when do we deploy. With vectors — vector<N> of the embedding's length; 3 here so the examples fit — recall fuses the words with the meaning. expires is when a memory goes: @ttl(1ms) takes a row out of every read the moment its time passes, and the sweeper deletes it; a memory with none is kept for good.

remember runs insert into memories {text: $1, tags: $2, at: now(), expires: now() + $3, vector: $4}, its expiry the milliseconds expires_in names (30m, 12h, 7d). With the values written in:

insert into memories {text: "the deploy runs on fridays", tags: ["ops"], at: now(), expires: null, vector: [0.9, 0.1, 0]}   -- → 1
insert into memories {text: "staging is in eu-west-1", tags: ["ops"], at: now(), expires: null, vector: [0, 0.2, 1]}   -- → 1
insert into memories {text: "the user is debugging the login page", tags: ["session"], at: now(), expires: now() + 10000}   -- → 1

recall with words alone, with words and a vector, by a tag, and with nothing — the latest first:

get memories select text, tags, at match text "when do we deploy" limit 5   -- → "the deploy runs on fridays"
get memories select text, tags, at match text "which region" near vector [0, 0.1, 1] fuse limit 5   -- → "staging is in eu-west-1"
get memories select text where tags has "session" limit 5   -- → "the user is debugging the login page"
get memories select text, tags, at order at desc, id desc limit 5   -- → "the user is debugging the login page"

Ten seconds on, the session's memory is out of every read:

get memories where tags has "session" count   -- → 1
-- 20 s later
get memories where tags has "session" count   -- → 0

Every FenecQL block on this page is run, statement by statement, by a test (crates/fenec-core/tests/mcp_docs.rs), and the collection above is held to the one remember makes.

Each user's memories apart. A policy line keeps each token's memories its own: a memory written without an owner gets the token's subject, and a recall reads that subject's alone. The collection is made by the server's token, once — a scoped one may not make collections, and its remember says what to run.

memories  read,write  where owner = $jwt.sub

An example agent. examples/mcp-agent is Claude with these tools over fenec mcp --file, through the Anthropic SDK's tool runner: told something in one run, it recalls it in the next.

cd examples/mcp-agent && npm install
node agent.mjs "I indent with tabs, and the deploy runs on Fridays"
node agent.mjs "when does the deploy run?"

The protocol

The revision is 2026-07-28, the stateless one: each request names its revision and the client's capabilities in _meta, there is no handshake and no session, and server/discover says what is here. Beside it are the handshake revisions clients still open with — 2025-11-25, 2025-06-18 and 2025-03-26, an initialize first — served the same way and without a session. A request carrying _meta's version is served by 2026-07-28's rules, an initialize by the revision it agrees on.

  • Over HTTP a 2026-07-28 request's headers — MCP-Protocol-Version, Mcp-Method, Mcp-Name — must say what its body says (400, -32020); a revision not served is refused naming those that are (400, -32022); an unknown method is a 404 with a JSON-RPC body. An answer is one JSON object, never a stream: no tool here has anything to say before its answer. GET and DELETE are 405, and a batch is refused.
  • Resources and prompts are not offered. A collection as a resource is what schema answers already, and the clients here put tools before the model and resources behind a person's click; a prompt is a recipe this page gives.

What it costs

A tool call runs the statement /query would, and adds reading the JSON-RPC message, building the statement and writing the answer twice — as structured content and as its text. Over 10 000 rows (make mcp-bench, an M1):

/queryMCP
a row by id, in process, query tool1.2 us3.8 us
a row by id, in process, get tool1.2 us4.4 us
two words, top ten with snippets, in process261 us263 us
a row by id over HTTP, p5017.7 us21.9 us
two words over HTTP, p50316 us323 us

A few microseconds a call, against the seconds a model's turn takes. A server without --mcp asks one comparison more of a request's path and nothing else: make requests-bench in eight runs taken in turns against the server before it, a row by id answered one client in 0.027 ms against 0.028 at the median and eight in 122 000 requests a second against 111 000, inside the runs' own spread.