← Back to work

cortex-mcp

A self-hosted MCP server that gives every AI assistant I use the same memory: one folder of Markdown notes on my own server. Claude Code on the desktop and Meta AI on the phone read and write it every day, and I can swap the assistant without touching the notes. I designed it, built it test first in Go, and run it on a small VPS.

Architecture

cortex-mcp overviewAI assistants (claude.ai, ChatGPT, Meta AI, Claude Code) connect over HTTPS with OAuth 2.1 or a Bearer token to a TLS reverse proxy on the owner's server. The proxy forwards to cortex-mcp, which keeps its login state in auth.db and reads and writes a vault of Markdown files. Obsidian, Syncthing, git, and backups work on the vault outside cortex-mcp.ASSISTANTS · ANY MCP CLIENTYOUR SERVERHTTPS · OAUTH 2.1 OR BEARERFORWARDREAD / WRITESTATEYOUR CHOICE · OUTSIDE CORTEX-MCPclaude.aiOAuth 2.1ChatGPTOAuth 2.1Meta AIOAuth 2.1Claude CodeOAuth or BearerTLS proxyCaddy, nginx · :443cortex-mcpone Go binary · /mcp · 12 toolsstate_dirauth.db · writes.logYour vaulta folder of .md filesObsidianany editorSyncthinglaptop · phonegithistoryBackupsyour usual toolPROXYAPPSTATESTORE
The vault stays a plain folder of .md files. Obsidian, Syncthing, git, and backups work on it outside the server.

Assistants connect over HTTPS through a TLS reverse proxy. Apps like claude.ai, ChatGPT, and Meta AI sign in with OAuth 2.1, and CLI agents use a Bearer token. Inside the binary, each request goes through the HTTP layer (sessions, rate limits, token checks) to twelve small MCP tools, and only one package, the vault, ever touches the disk. Login state lives in a SQLite database kept outside the vault. Syncing the notes to other machines is left to whatever the owner already uses, in my case Syncthing.

Inside the cortex-mcp binaryA request to /mcp enters internal/server, which handles HTTP, sessions, rate limits, and token checks. Sign-ins go to internal/oauth and token checks to internal/tokens and authdb, which keep secrets as hashes in auth.db. Allowed calls reach the twelve MCP tools in internal/tools, and only internal/vault touches the files of the vault.ONE GO BINARYPOST /mcpAUTHORIZED CALLPATH + VERSIONSIGN-INTOKEN CHECKOWNER, CLIENTSos.Rootinternal/serverHTTP · sessions · rate limits · token checksinternal/tools12 MCP tools: read, search, edit, move, trashinternal/vaultthe only code that touches filesno symlinks · protected folders · versioned writesinternal/oauthOAuth 2.1 · login page · passkeys · TOTPtokens + authdbBearer tokens · secrets stored as hashesYour vaulta folder of .md filesEDGECOREAUTH
Inside the binary, only the vault package touches files, through Go's os.Root.

Key decisions

Go, for one small binary I can review

I compared TypeScript, Python, Go, and Rust. Go gave me a single static binary, a tiny distroless image, a short dependency list, and easy ARM builds. Rust's speed buys nothing for a server that mostly reads and writes small files, and it was the hardest of the four to review.

The AI can edit notes and nothing else

The tools have no shell, no git, and no network access. A deleted note goes to a .trash folder instead of disappearing. Every edit must carry the version of the note it read, so a stale edit from one assistant fails instead of overwriting what another one just wrote, and every write is logged with the name of the app that made it.

Three ways in, kept in three places

The owner signs in with a passkey, a TOTP code, or a single-use recovery code, each kept somewhere different, so losing a phone or a password manager never locks me out. Nothing on the web can create or reset the owner: that takes a command on the server itself.

The hard part

The sign-in endpoint that could fill the disk

Every OAuth sign-in starts with a request to /authorize, and the server stores it until I approve it. The security review of the OAuth work found that nothing capped those pending requests, so anyone could keep sending them until the disk was full. A single global rate limit was not enough either, because a few addresses could spend it and keep me out of my own server. It took three review rounds to close it all: pending requests are now capped per source and overall, each source spends its own budget before the global one, and passkey logins skip the shared code budget, since a passkey cannot be guessed.

Security and performance

  • All file access goes through Go's os.Root and stays inside the vault. Symbolic links are never followed, and .git, .obsidian, and the server's own folder are off limits.
  • OAuth follows the MCP authorization spec: PKCE S256, tokens bound to the server's /mcp URL, rotating refresh tokens with reuse detection, and redirect URIs limited to an allowlist.
  • Recovery codes, refresh tokens, and cookies are stored only as hashes, and writes stop when the disk has less than 1 GiB free.
  • Every push runs the tests with the race detector, 11 fuzz targets, staticcheck, govulncheck, and gosec. Releases are signed keylessly with cosign and ship with SBOMs.

Stack

GoMCPOAuth 2.1WebAuthnSQLiteDockerGitHub Actionscosign

Links