Agents · 2026-05-26 · By RedDB team · 4 min read
An MCP server for RedDB — one database, every agent CLI
The Model Context Protocol lets every modern agent CLI — Claude Code, Codex, Cursor, Gemini CLI — share the same toolbelt. Wrap RedDB once and you give all of them agent memory, document storage, and semantic search through a single endpoint. Manifest, server, wiring.
Why MCP is the right seam
Earlier posts in this pillar wired RedDB into Claude Code directly: a SessionStart hook for retrieval, a skill for writes, slash commands for human-driven edits. Each of those is a Claude Code-shaped integration — a hook config, a SKILL.md, a markdown file in ~/.claude/commands/. None of it survives a port to Codex or Cursor.
The Model Context Protocol moves the seam one level down. An MCP server exposes a typed set of tools over stdio (or HTTP/SSE), and any MCP-aware host can mount it. Write the integration once, register it in every host’s config, and the same memory.write tool is available from every agent CLI you run. The host’s harness handles the rest — tool advertisement, schema validation, call routing.
For RedDB this maps cleanly. The database already speaks four data models (KV, document, vector, graph); MCP wants a flat list of tools with JSON Schema. We pick the four operations that pay rent in a daily agent loop:
| MCP tool | RedDB operation | Used for |
|---|---|---|
memory.write | INSERT into memory table with embedding | Capturing a fact mid-turn |
memory.search | SELECT … ORDER BY embedding <=> $1 | Pulling top-k context on demand |
doc.upsert | Document store write, keyed by path | Saving a generated artifact (spec, ADR) |
vector.search | Raw HNSW query against a named index | RAG over a corpus the agent doesn’t own |
Everything else (transactions, graph traversals, multi-statement SQL) stays behind the server. Tools should be small.
The manifest
MCP servers are described by a tiny JSON manifest. The host reads it once and uses the tools list to populate the model’s tool registry on every turn. Schemas are JSON Schema 2020-12.
{
"name": "reddb",
"version": "0.1.0",
"description": "RedDB memory and document storage for agent CLIs.",
"tools": [
{
"name": "memory.write",
"description": "Persist a short fact the agent wants to remember across sessions. Embeds the body and writes a row to the memory table.",
"inputSchema": {
"type": "object",
"required": ["body"],
"properties": {
"body": { "type": "string", "maxLength": 2000 },
"tags": { "type": "array", "items": { "type": "string" } },
"project": { "type": "string" }
}
}
},
{
"name": "memory.search",
"description": "Semantic search over previously remembered facts. Returns top-k rows ranked by cosine similarity.",
"inputSchema": {
"type": "object",
"required": ["query"],
"properties": {
"query": { "type": "string" },
"k": { "type": "integer", "minimum": 1, "maximum": 20, "default": 5 },
"project": { "type": "string" }
}
}
},
{
"name": "doc.upsert",
"description": "Write or replace a document at the given path. Body is plain text or markdown.",
"inputSchema": {
"type": "object",
"required": ["path", "body"],
"properties": {
"path": { "type": "string" },
"body": { "type": "string" }
}
}
},
{
"name": "vector.search",
"description": "Raw similarity search against a named HNSW index. Use for RAG over corpora not owned by the agent (codebases, docs).",
"inputSchema": {
"type": "object",
"required": ["index", "query"],
"properties": {
"index": { "type": "string" },
"query": { "type": "string" },
"k": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10 }
}
}
}
]
} A few choices worth flagging. project is optional on memory.* because the server defaults it from an environment variable — the host sets REDDB_PROJECT=rdb-lair once and every call inherits it. The model never has to remember which project it’s in. body is capped at 2000 characters; longer artifacts go through doc.upsert. vector.search doesn’t auto-embed — the index name dictates the embedding model, and the server handles it server-side, so the model passes a plain string.
The server
A minimal implementation in Node, using the official @modelcontextprotocol/sdk and pg for RedDB’s Postgres-wire protocol. Roughly 80 lines.
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import pg from "pg"
import { readFileSync } from "node:fs"
const manifest = JSON.parse(readFileSync(new URL("./manifest.json", import.meta.url), "utf8"))
const project = process.env.REDDB_PROJECT ?? "default"
const db = new pg.Pool({ connectionString: process.env.REDDB_URL })
async function embed(text: string): Promise<number[]> {
const r = await fetch("https://api.voyageai.com/v1/embeddings", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.VOYAGE_API_KEY}`,
},
body: JSON.stringify({ model: "voyage-3", input: text }),
})
const json = await r.json()
return json.data[0].embedding
}
const handlers: Record<string, (args: any) => Promise<unknown>> = {
"memory.write": async ({ body, tags = [], project: p = project }) => {
const v = await embed(body)
const { rows } = await db.query(
"INSERT INTO memory (project, body, tags, embedding) VALUES ($1,$2,$3,$4) RETURNING id, created_at",
[p, body, tags, JSON.stringify(v)],
)
return rows[0]
},
"memory.search": async ({ query, k = 5, project: p = project }) => {
const v = await embed(query)
const { rows } = await db.query(
`SELECT id, body, tags, created_at, 1 - (embedding <=> $1) AS score
FROM memory
WHERE project = $2 AND deleted_at IS NULL
ORDER BY embedding <=> $1
LIMIT $3`,
[JSON.stringify(v), p, k],
)
return rows
},
"doc.upsert": async ({ path, body }) => {
const { rows } = await db.query(
`INSERT INTO doc (path, body, project) VALUES ($1,$2,$3)
ON CONFLICT (project, path) DO UPDATE SET body = EXCLUDED.body, updated_at = now()
RETURNING path, updated_at`,
[path, body, project],
)
return rows[0]
},
"vector.search": async ({ index, query, k = 10 }) => {
const { rows } = await db.query(
"SELECT * FROM reddb.vector_search($1, $2, $3)",
[index, query, k],
)
return rows
},
}
const server = new Server({ name: manifest.name, version: manifest.version }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: manifest.tools }))
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const fn = handlers[req.params.name]
if (!fn) throw new Error(`unknown tool: ${req.params.name}`)
const result = await fn(req.params.arguments ?? {})
return { content: [{ type: "text", text: JSON.stringify(result) }] }
})
await server.connect(new StdioServerTransport()) Things to notice:
- Schema lives in
manifest.json, not in code. TheListToolshandler ships the manifest verbatim. One file is the source of truth; hosts and humans read the same thing. embedis one HTTP call. No batching, no retries. The model only calls these tools when it has decided a fact is worth a round-trip; latency budget is generous.- Errors propagate. If the database rejects the insert, the SDK turns the thrown
Errorinto a tool error the model sees on the next turn. No silent failures. vector.searchdefers to a SQL function. The server doesn’t pick an embedding model; the functionreddb.vector_searchlooks up the index’s configured model and does the right thing. Keeps the tool surface tiny.
Run it once locally with node --env-file=.env server.js. Wrong env var names will surface as a connect-time error; nothing about an MCP server is mysterious once you’ve started it.
Wiring into Claude Code
Claude Code reads MCP servers from ~/.claude/mcp.json (global) or .mcp.json at the repo root (per-project). A project-scoped config keeps secrets pinned to one tree:
{
"mcpServers": {
"reddb": {
"command": "node",
"args": ["./tools/reddb-mcp/server.js"],
"env": {
"REDDB_URL": "postgres://reddb@localhost:5432/lair",
"REDDB_PROJECT": "rdb-lair",
"VOYAGE_API_KEY": "${env:VOYAGE_API_KEY}"
}
}
}
} ${env:…} references inherit from the user’s shell, so the file is checkable into the repo without leaking secrets. After saving, restart the harness and /mcp lists the server with its four tools. The model can now call memory.write directly — no skill, no slash command, no hook.
The same config syntax works for Codex (~/.codex/mcp.json) and Cursor (settings UI, JSON-equivalent). Gemini CLI uses a slightly different shape but the same command/args/env triplet. One server, four CLIs.
What you gave up, and what you bought
You gave up Claude Code-specific affordances. A skill can include a long SKILL.md describing when to use it; an MCP tool gets one description line. A slash command can be invoked deterministically by the user; an MCP tool is only ever called by the model. If your write path needs human-in-the-loop confirmation, the slash command from the D4 post is still the right tool.
What you bought is portability and a single audit point. Every read and every write goes through one process; logging it is a console.error away. Every CLI on the team can share one memory store without each engineer maintaining four parallel hook configs. And the next agent CLI someone in the org wants to try — the one that hasn’t been built yet — gets RedDB for free, the day it ships MCP support.
That is the whole pitch: the database is the integration, the protocol is the bus, the CLIs are interchangeable.
Next
- Multi-agent shared memory — the same MCP server, but with several agents writing into it concurrently and a conflict-resolution doc keeping them honest.
- Agent observability — wrap the MCP server with a tool-call audit log and build the “what did Claude Code cost me last sprint” query.
RedDB Cloud
Join the private beta.
One-click managed deploy. Free during beta. Founding pricing locked at GA.
Request access →