Most "AI memory" projects treat memory as a feature of one tool: Cursor with persistence, Claude with notebooks. That misses the actual pain.
The pain is handoff. You think with one AI, code with another, debug with a third. Every switch is a context bankruptcy:
- Re-paste the plan
- Re-paste the file list
- Re-paste the decision and the reason
- Re-paste what you already tried
- Hope you didn't miss anything
This is the AI dev experience nobody talks about: manual context shipping between tools.
The industry's answer is "use a longer context window." That's like saying you don't need shared file storage because your laptop has more RAM now. A bigger context window helps within one conversation. It does nothing the moment you close the tab.
What I wanted instead: a small piece of plumbing that both AIs can read and write to. Handoffs become a tool call, not a copy-paste.
That's SessionVault.
The Workflow This Unlocks
The handoff is one tool call in each direction. No copy-paste, no re-explaining.
The killer feature isn't memory. It's interop.
Honest caveat: which clients work today
You need MCP support on both ends. As of mid-2026:
| Client | Status |
|---|---|
| Claude Desktop | Full MCP support |
| Cursor | Full MCP support |
| ChatGPT (consumer) | Not yet |
| Custom, via the OpenAI SDKs | Works |
So today's real-world handoff is Claude to Cursor and back. As more clients ship MCP, this gets bigger.
Architecture
Version 1 of this ran locally: a stdio MCP server on my laptop, Mem0 for fact extraction, LM Studio for inference, Postgres in Docker. It worked, but every user had to clone a repo, run a container, and paste absolute file paths and a plaintext DB password into claude_desktop_config.json.
Version 2 is a hosted service. You paste a URL and a key.
| Layer | Tech | Role |
|---|---|---|
| Transport | MCP over Streamable HTTP | Any MCP-aware client connects with a URL |
| Runtime | Next.js 16 on Vercel, Node runtime | Serverless, 60s max duration |
| Validation | Zod | Per-field caps on every tool input |
| Storage | Neon Postgres + pgvector | Encrypted content, HNSW vector index |
| Embeddings | OpenAI text-embedding-3-small | 1536 dims, ~$0.02 per million tokens |
| Crypto | AES-256-GCM envelope encryption | Per-user data keys, master key in env |
| Isolation | Postgres row-level security | Forced, so app bugs fail closed |
Why I Dropped Mem0
The v1 bug that shaped everything: load_session quietly returned wrong data.
You'd save auth-jwt-v1. Later, load_session("auth-jwt-v1") would run Mem0's semantic search internally. But Mem0 extracts atomic facts, not literal session text, and those facts didn't contain the literal session name. So the search returned vaguely-similar facts from other sessions. No error. Just plausible-looking garbage — the worst possible failure mode when one AI just handed "the plan" to another.
The v1 fix was to store every session twice: once verbatim with infer: false, once as extracted facts. That worked, but it meant two writes, two row types, and an LLM call on the write path that could fail.
v2 removes the fact-extraction layer entirely. One row per session:
const record = { ...input, savedAt: new Date().toISOString() };
const { nonce, ciphertext } = encryptString(JSON.stringify(record), user.dek);
const embedding = await this._embed(sessionText(input));
// INSERT ... ON CONFLICT (user_id, name) DO UPDATE ...The embedding is the semantic index. There's no separate fact table to drift out of sync, and no LLM on the write path.
That splits the two access patterns cleanly:
load_sessionis deterministic. It'sSELECT ... WHERE user_id = $1 AND name = $2. You get exactly what you saved, orfound: false. A vector index is never consulted, so it cannot return a near-miss.search_sessionsis semantic. It embeds your query and orders by cosine distance using the<=>operator against an HNSW index.
Exact lookup and fuzzy lookup are different problems. v1's bug came from serving the first with machinery built for the second.
One useful consequence: if OpenAI is having a bad day, _embed returns null, the row still saves without an embedding, and it just isn't semantically searchable until you re-save it. Search skips rows where embedding IS NULL. A provider blip degrades recall instead of losing your data.
Encryption: Envelope, Not Blanket
Session content is the transcript of how you think about your codebase. It gets encrypted in the application layer before it reaches the database.
The scheme is standard envelope encryption:
Master Key (env var, never in the DB)
│ wraps
▼
Per-user Data Encryption Key ──stored in users.dek_wrapped
│ encrypts
▼
sessions.content_ct (AES-256-GCM ciphertext || 16-byte auth tag)
sessions.content_nonce (12-byte GCM nonce)
Three properties fall out of this, and each one is why I didn't just encrypt everything with a single key:
- Master key rotation is cheap. Rotating means re-wrapping one small DEK per user, not re-encrypting every session body.
- A database dump alone is useless. The attacker also needs the master key, which lives in the environment and never touches Postgres.
- Blast radius is bounded. One leaked DEK exposes one user.
GCM gives authenticated encryption, so tampering with a ciphertext throws on decrypt rather than yielding plausible junk. The auth tag is 16 bytes and the nonce is 12, per NIST SP 800-38D.
The honest tradeoff: embeddings are not encrypted. They can't be — cosine search has to happen inside Postgres, and you can't index ciphertext by meaning. A 1536-float vector leaks far less than plaintext, but it isn't nothing; embedding-inversion research keeps getting better. Same reasoning applies to content_preview, the first 120 characters of the summary, stored in plaintext so list_sessions doesn't have to decrypt 200 rows to render a list.
That's the deal: exact content is sealed, semantic shape is not.
Tenant Isolation That Doesn't Trust My Own Code
The obvious way to keep users apart is WHERE user_id = $1 on every query. The obvious failure is forgetting it once.
So isolation lives in the database instead:
ALTER TABLE sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE sessions FORCE ROW LEVEL SECURITY;
CREATE POLICY sessions_tenant ON sessions
USING (user_id = app_current_user_id())
WITH CHECK(user_id = app_current_user_id());FORCE is the important word. Plain ENABLE exempts the table owner, which is usually the role your app connects as — so RLS silently does nothing. Forcing it subjects the owner too.
Every user-scoped query runs through one helper that opens a transaction and sets the tenant for that transaction only:
await client.query("BEGIN");
await client.query("SELECT set_config('app.user_id', $1, true)", [userId]);The true makes it transaction-local, so a pooled connection can't leak tenant context into the next request that borrows it. If application code ever forgets to scope a query, app.user_id is NULL, the policy matches nothing, and the query returns zero rows. The bug becomes an empty result instead of a data breach.
Identity tables (users, api_keys) are deliberately outside this: they're the identity plane, reachable only through server paths and CLI scripts, never exposed to MCP tools.
The Request Lifecycle
Every call to /api/mcp passes the same gauntlet before a tool ever runs:
A few details worth calling out:
Auth is a hash lookup, not a comparison. Keys look like sv_live_ plus 24 random bytes base64url-encoded — 192 bits of entropy. Only SHA-256(key) is stored, so a database dump yields no usable credentials. The first 12 plaintext characters are kept as key_prefix so the dashboard can show sv_live_a1b2… next to a label like "laptop" without revealing the secret. Lookup is a single indexed probe on the hash, which sidesteps per-byte timing leaks. On success the user's DEK is unwrapped with the master key and carried for the rest of the request.
Every auth failure looks identical. Unknown key, revoked key, disabled user — all return the same vague unauthorized. The specific reason goes to the server-side audit trail, never to the caller, so nobody can probe which keys exist.
Rate limiting is DB-backed on purpose. A sliding window over a rate_limits table, 60 requests per minute and 1000 per day by default. That's a query instead of a Redis dependency, and it prunes stale rows opportunistically on each check, so an idle user's rows disappear the next time they show up. No background job.
The audit log keeps no PII. IPs and user agents are stored as SHA-256(value + salt), so abuse can be correlated without retaining identifying data. Errors are recorded as a short class — auth, rate_limit, db, provider, timeout — never a full message, because error strings are exactly where secrets leak.
Config fails fast. assertHostedConfig() checks DATABASE_URL, OPENAI_API_KEY, SESSIONVAULT_AUDIT_SALT, and PUBLIC_URL (plus RESEND_API_KEY in production), logs one structured line per missing variable, and refuses to serve. Loading the master key rejects placeholder values like CHANGE_ME, so a half-configured deploy can't quietly run with a fake key.
The Five Tools
Every registered MCP tool costs roughly 500 tokens of the host's context just by existing. Five is the budget.
| Tool | What it does |
|---|---|
save_session | Encrypt, embed, upsert. Same name overwrites via UNIQUE(user_id, name) |
load_session | Exact-name lookup. brief / normal / full modes |
search_sessions | Semantic search, optional repo filter and max_tokens cap |
list_sessions | Newest first, plaintext previews, no decryption needed |
delete_session | Hard delete by exact name |
Two design details I keep coming back to:
max_tokens on search. The caller is an LLM with a finite context. Rather than returning n results and hoping they fit, searchWithinBudget fetches the top 20, then packs hits until the estimated token count would exceed the cap. The consumer states its budget; the server respects it.
Field caps in Zod, not just at the edge. Strings cap at 4,000 characters and arrays at 200 entries, on top of the 1MB body limit in the HTTP handler. A confused agent looping on decisions hits a validation error with a clear message instead of writing a 40MB row.
load_session in full mode does something I like: it returns the record plus the top semantic hits from other sessions related to it. Cross-context recall — "here's the plan, and here are the three other times you touched this."
Try It
{
"mcpServers": {
"sessionvault": {
"url": "https://your-app.vercel.app/api/mcp",
"headers": { "Authorization": "Bearer sv_live_..." }
}
}
}That's the whole client config, and it's the same in ~/.cursor/mcp.json and claude_desktop_config.json. Compare it to v1: no absolute paths, no local Postgres, no plaintext DB password, no Docker.
Self-hosting is four commands:
npm install
npm run sv:generate-keys # master key + audit salt
export DATABASE_URL="postgres://...neon.../neondb?sslmode=require"
npm run sv:setup-db && npx vercelSource and integration docs: github.com/saiphanindra1010/SessionVault.
The Takeaway
The interesting frontier in AI tooling isn't longer context windows. It's interop — letting different AIs share state so you can use the right tool for each step.
Building it taught me more about boring infrastructure than about AI. The three decisions that mattered were all unglamorous: separate exact lookup from semantic search so the first can't silently guess, put tenant isolation in the database so application bugs fail closed, and encrypt at the application layer with per-user keys so a database dump is worthless.
Plan with Claude. Build in Cursor. Skip the copy-paste.
Built with TypeScript, the Model Context Protocol, Next.js on Vercel, Neon Postgres with pgvector, and OpenAI embeddings.
Using a different MCP-aware client? Open an issue — I'd love to expand the compatibility list.