I keep a folder of markdown notes called secondbrain. It is exactly what it
sounds like: every time I work out how some corner of my homelab fits together -- the mail
server, the backup stack, the git server, which config lives in which jail on which host --
I write it down. Over a couple of years it has turned into the single most useful thing on
my disk. When something breaks at 7am, the answer is almost always already in there.
The notes live on the machine where I run Claude Code, and Claude Code can read them through a little MCP server I wrote. That works great at the desk. But half the time I want to ask a question, I am on my phone, or on a work laptop, in the claude.ai web app -- which has no idea my second brain exists. It is sitting on a box at home behind a NAT, and claude.ai is out on the public internet. The two never meet.
This post is the writeup of closing that gap: exposing my local MCP tools -- the second brain and everything else Claude Code can reach -- to claude.ai as a proper remote MCP server, over HTTPS, with authentication, without opening a single inbound port on my router. It took a few evenings and one genuinely annoying memory bug to get right.
Model Context Protocol is just a way for a model to call tools that live outside it. A server advertises a list of tools -- name, description, JSON schema for the arguments -- and the client (the thing driving the model) calls them and feeds the results back into the conversation. That is the whole idea. The interesting part is the transport.
The reference transport is stdio: the client launches the server as a subprocess and they
talk JSON-RPC over stdin/stdout. That is perfect for a tool running on the same machine, and
it is how my second-brain server works locally. My secondbrain server exposes
three tools and nothing else:
list_notes -- enumerate every markdown file with its titleread_note -- return the full text of one note by pathsearch_notes -- keyword search across all notes, with a few lines of contextDeliberately boring. No write access, no shell, no deletion. If claude.ai is going to read my notes from the public internet I want the worst case to be "it read a note it shouldn't have," not "it rewrote my backup runbook."
Claude Code itself aggregates a pile of these MCP servers -- the second brain, my todo.txt
server, a few others -- and presents them as one toolset. So the trick is not to expose one
server; it is to expose Claude Code's whole aggregated toolset to a remote client. And
Claude Code has a subcommand for exactly that: claude mcp serve, which turns the
running instance into an MCP server over stdio.
Which brings us straight back to the transport problem. stdio is a local subprocess pipe. claude.ai is a website. You cannot pipe stdin across the internet.
The modern MCP answer is the "streamable HTTP" transport -- the server speaks JSON-RPC over
HTTP with a server-sent-events channel for the streaming half. claude.ai's remote connector
speaks exactly this. So I need something that runs claude mcp serve as a stdio
subprocess and re-exposes it as a streamable-HTTP endpoint.
That something is supergateway, a small npm tool whose entire job is bridging MCP transports. The invocation is one line:
supergateway \
--stdio "claude mcp serve" \
--port 8765 \
--outputTransport streamableHttp \
--stateful \
--healthEndpoint /healthz
It launches claude mcp serve, holds the pipe open, and serves the same protocol
on http://localhost:8765/mcp. I wrapped that in a script at
/usr/local/bin/claude-mcp-server and a systemd unit, claude-mcp.service,
that runs it as my user and restarts on failure. The --stateful flag matters a
lot, and I will come back to it -- it is the fix for the memory bug.
The rest of my homelab already has a standard way of being reachable from outside: a Cloudflare
tunnel into a cloudflared container, which hands traffic to traefik, which routes
by hostname to whatever Docker service should answer. No ports are forwarded on the router;
cloudflared makes an outbound connection to Cloudflare's edge and everything rides back down
that tunnel. Every service I have written up on this site -- the git server, the todo app --
sits behind that same pattern.
So the public name mcp.$HOMELAB resolves at Cloudflare's edge, tunnels down to
cloudflared, and traefik needs to send it at my MCP bridge. The supergateway process listens
on the host at :8765, and traefik reaches it across the Docker bridge gateway at
172.25.0.1:8765. The wildcard *.$HOMELAB cert I already maintain
handles TLS. Adding the service was, as usual, a block of Docker labels and zero new
infrastructure.
Except I did not point traefik straight at supergateway. I put a small container in front of it first, because of authentication.
supergateway has no auth. None. If I routed mcp.$HOMELAB directly at it, anyone
who guessed the hostname would have my second brain -- and worse, every other tool Claude Code
can reach -- one HTTP request away. That is unacceptable for something on the public internet.
claude.ai's remote connector authenticates with OAuth 2.0. When you add a remote MCP server it expects to discover an authorization server, run the full PKCE flow in a browser, and come back holding a bearer token it attaches to every request. There is no "just paste an API key" option in the connector UI.
I did not want to stand up Keycloak or a real identity provider for a single-user homelab. So I
wrote the smallest possible thing that makes claude.ai happy: a ~200-line Node service that
implements just enough of OAuth 2.0 to satisfy the connector, and reverse-proxies authenticated
traffic to supergateway. It lives in a container at ~/docker/claude-mcp/server.js
and listens on :3457. traefik routes the public hostname to it, not to
supergateway, so every request through the front door has to clear auth first.
What it implements:
GET /.well-known/oauth-authorization-server -- the discovery document
claude.ai fetches first to learn where to authorize and get tokensGET /.well-known/oauth-protected-resource -- MCP resource metadataPOST /register -- dynamic client registration; it accepts any client, because
the real gate is the password on the next stepGET /authorize -- renders a page with an "Authorize" button and a password
prompt; on success it issues a single-use authorization codePOST /token -- exchanges that code plus the PKCE verifier for an access tokenPOST /mcp -- the token-gated proxy to supergatewayGET /healthz -- proxies through to supergateway's own health endpoint so a dead
backend shows as unhealthy
The dirty secret is that the OAuth flow always issues the same static bearer token at the
end -- a 32-byte hex string in ~/docker/.env as CLAUDE_MCP_TOKEN. The
PKCE dance is real and the browser password gate (CLAUDE_MCP_PASSWORD) is real;
those are what actually keep strangers out. The "authorization server" behind them is a polite
fiction whose only job is to speak the protocol claude.ai insists on. Rotating the token is
openssl rand -hex 32, edit the env file, docker compose up -d claude-mcp.
So the connect flow from claude.ai's side looks like this:
/.well-known/oauth-authorization-server/authorize; I type the password/token with its PKCE verifier/mcp call carries Authorization: Bearer <token>
On the claude.ai side the whole setup is: Settings, Integrations, Add MCP server, paste
https://mcp.$HOMELAB/mcp, and it auto-detects the OAuth endpoints and pops the
browser flow. After that my second brain is just... there, in the tool list, in the web app, on
my phone.
Stacking it all up, here is what happens when claude.ai calls search_notes from
my phone:
claude.ai
-- HTTPS --> Cloudflare edge
-- tunnel --> cloudflared (Docker)
-- bridge net --> traefik
-- Docker route --> claude-mcp container (OAuth check, :3457)
-- proxy --> supergateway (host systemd, :8765)
-- stdio --> claude mcp serve
-- MCP --> secondbrain server
-- read --> ~/secondbrain/*.md
Seven hops for a grep over some text files. It sounds absurd written out, and it kind of is, but every hop is doing one honest job -- TLS termination, NAT traversal, routing, auth, transport translation, tool aggregation, the actual search -- and each was already pulling its weight for something else on the network. The only genuinely new pieces are supergateway and the little OAuth shim.
The first version of this worked and then slowly tried to kill the host.
I had originally run supergateway in its default stateless mode. In that mode it spawns a fresh
claude mcp serve child process per connection. Each of those children is a
full Node + Claude Code instance and weighs about 175 MB. claude.ai's connector is chatty -- it
reconnects, it polls, it opens new sessions -- and nothing was reaping the old children. They
just accumulated. Available RAM ticked down over a day until the box was staring down the OOM
killer.
The fix was the --stateful flag from earlier. In stateful mode supergateway keeps
one persistent claude mcp serve child and multiplexes every session through
it. Connection count stops mattering because they all share the one process. (I went looking for
a --maxConnections cap first; supergateway 3.4.3 has no such flag, and in stateful
mode the question is moot anyway.)
That was the real fix, but a runaway process that can OOM the host is the kind of thing I want a seatbelt on regardless, so I added a few:
MemoryMax=800M and MemorySwapMax=0, so
the cgroup gets killed and restarted long before it can hurt anything else, and never spills into
swap.claude-mcp-maybe-restart script -- it only
actually restarts the service if current memory is over 500 MB, so a healthy long-lived session
is left alone but a leaky one gets recycled.mem-snapshot script on a 15-minute timer logging system + per-process + cgroup
memory, plus a mem-alert script on a 2-minute timer that fires a Pushover
notification if free RAM drops under 1 GB (with a lock file so a breach alerts once, not every
two minutes until it clears).After a week of those 15-minute snapshots -- 355 of them -- the picture was reassuring. Idle or freshly restarted under 50 MB for 44% of the time, a connected session in the 50-200 MB band for another 48%, active tool calls pushing 200-500 MB about 7% of the time, and over 500 MB less than 1%. Peak ever recorded was 600 MB against multiple gigabytes of headroom. So stateful mode alone fixed the actual leak; the 800 MB ceiling and the nightly recycle are pure backstop. The lesson filed away: when you bridge a chatty network client to a process-per-connection backend, find out what gets reaped before you point it at the internet.
Because the second brain only earns its keep if I can reach it from wherever the question shows up, and the questions rarely show up at the desk. Now the same notes that Claude Code reads locally are reachable from the claude.ai app on any device, and the connection is authenticated, read-only into the notes, and travels over a tunnel with no inbound ports open. The box at home stays exactly as locked down as it was; it just has one more carefully fenced door.
There is also a tidier benefit I did not plan on. Standing this up replaced an older self-hosted chat front-end I had been running to talk to my homelab -- a whole web app with its own maintenance burden -- with "use the official claude.ai client and let it call my tools." One fewer thing to patch.
The one piece I am not thrilled about is that the OAuth server hands out a static token. It is gated behind a password and PKCE so it is not exposed, but a real per-session token with an expiry would be the correct thing. For a single-user setup it has not bitten me, and rotating it is one command, so it sits on the someday list.
The other obvious extension is to expose more than the read-only second brain -- carefully. Claude Code can already reach tools that do real things on my network, and the bridge happily carries all of them. So far I have kept the genuinely powerful tools off the remote surface on purpose; the calculus for "a tool claude.ai can call from my phone over the internet" is very different from "a tool I can call from a terminal I am already logged into." Read-only notes were the right place to start. Anything I add past that gets the same paranoid once-over the OAuth shim got.