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 memoriesOver 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: ... - ✓ ConnectedWith --scope project each goes into the project's
.mcp.json, which Claude Code loads for whoever opens it — the
token left in the environment:
{
"mcpServers": {
"fenecdb": {
"type": "stdio",
"command": "fenec",
"args": ["mcp", "--file", "notes.fenec", "--memory", "memories"]
}
}
}{
"mcpServers": {
"fenecdb": {
"type": "http",
"url": "http://127.0.0.1:8080/_mcp",
"headers": { "Authorization": "Bearer ${FENEC_TOKEN}" }
}
}
}Cursor
{
"mcpServers": {
"fenecdb": {
"command": "fenec",
"args": ["mcp", "--file", "notes.fenec", "--memory", "memories"]
}
}
}{
"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.
{
"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
| tool | what it does | takes |
|---|---|---|
schema | the collections the token may read, each as the FenecQL that makes it, and — to the server's token — how many rows each holds | collection |
search | rows 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 |
query | one FenecQL read: get with any clause, aggregates, count, lookup, explain | query, params, include_vectors |
get | rows by id; the ids with no row come back in missing | collection, ids, fields |
write | one put, insert, set or del, with require n if it must touch n rows (--mcp-write) | statement, params |
remember | keeps a memory (--mcp-memory) | text, vector, tags, expires_in |
recall | the memories that best match, or the latest | text, vector, tags, limit |
forget | deletes a memory by its id | id |
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:
{"name": "search", "arguments": {"collection": "docs", "text": "fox", "filter": "year >= $1", "params": [2024]}}{"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
| flag | default | past it |
|---|---|---|
--mcp-max-rows | 100 | refused, 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-bytes | 64K | refused, 413, measured on the JSON the model would read |
--mcp-calls-per-sec | 20 | refused, 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
/querytakes: 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/querysees.schemalists 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-writeaddswrite, and--mcp-memorythe memory tools alone.writerunsput,insert,setanddel, each held to the token's grants and to its policy as the row it writes; a schema change or acompactis never made over MCP, by any token. A server with--http-read-onlytakes no write over MCP either. - Where a request comes from is asked before its token. An
Originother than the server's own, or the one--http-corsnames, is refused (403). A server that asks for no token answers only aHostnaming this machine —localhostor 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
httpsURL. The stdio bridge speaks plain HTTP only. - An agent's writes are audited. With
--auditeach call ofwrite,rememberorforgetis a line ("event":"mcp", the tool, whether it failed), beside the schema changes and the refused tokens./_metricscounts calls infenec_mcp_tool_calls_totalby 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} -- → 1recall 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 -- → 0Every 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.subAn 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.GETandDELETEare 405, and a batch is refused. - Resources and prompts are not offered. A collection as a resource is
what
schemaanswers 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):
/query | MCP | |
|---|---|---|
a row by id, in process, query tool | 1.2 us | 3.8 us |
a row by id, in process, get tool | 1.2 us | 4.4 us |
| two words, top ten with snippets, in process | 261 us | 263 us |
| a row by id over HTTP, p50 | 17.7 us | 21.9 us |
| two words over HTTP, p50 | 316 us | 323 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.