MCP Git Backend Behind Cloudflare
Giving Claude a Real Memory: A Git-Backed MCP Server Behind Cloudflare
The problem with "just use Google Drive"
I've been running a personal "second brain" for a while now — a folder of plain markdown files that Claude reads and writes to: task lists, habit tracking, project notes, homelab documentation. For a long time it lived in Google Drive, accessed through Claude's Drive connector. It worked, but it had three problems that got more annoying the more I leaned on it.
No real version history. Every "edit" Drive's API exposes to a connector is actually a trash-and-recreate, not a diff. If an automated agent silently mangled a file at 6:30am, I had no way to see what it looked like an hour before, or to answer "when did this line actually change?" A personal knowledge base without version history is just a folder that occasionally goes wrong for reasons you can't reconstruct.
Token cost that scales badly. Drive's connector doesn't expose a cheap "list files" call — every enumeration goes through a search endpoint that returns a full metadata envelope per file (id, mime type, timestamps, owner, view URL, parent id...) before you've read a single byte of actual content. File content itself comes back with every markdown special character backslash-escaped and a trailing double-space on every line (Drive's internal "safe markdown" format), which on a like-for-like comparison I measured at roughly 4% more characters than the raw file — and token overhead from stray escape characters tends to run a bit higher than the character overhead, since a lone \ often tokenizes on its own rather than folding into a neighboring token. None of that is fatal on its own. It's fatal in aggregate: the first time I had an agent enumerate and fetch everything in the folder — around 40 files — it burned through an entire session's rate limit doing nothing but discovery and reads.
No portability story. The whole point of a "second brain" is that it outlives any one tool. Markdown files trapped behind a proprietary trash-and-recreate API with no git history aren't actually portable — they just look like they are because you can technically download them.
What I wanted instead: the files live in a real git repository I control, every write is a real commit with a real message, and Claude — whether it's running locally, in a chat, or as a scheduled unattended agent — can read and write that repository directly, from anywhere, without me needing to open a port on my home network or hand out raw SSH access to a cloud service.
That's a remote MCP server with a local git backend, fronted by Cloudflare.
Architecture
┌─────────────────────────┐
Claude (Code / │ Cloudflare Tunnel │
Chat / scheduled ─▶│ mcp.example.com ──┼──▶ cloudflared ──▶ MCP server
agents) │ (TLS, Access/Service │ (daemon) (local process)
│ Auth, no open ports) │ │
└─────────────────────────┘ │
▼
local git repository
(bare + working copy)
The three pieces:
- A local git repository on a machine I control, holding the actual markdown files. Nothing fancy — could be a bare repo with a self-hosted git server (Gitea, Forgejo, gitolite, whatever) in front of it, or even just a plain repo a process has filesystem access to.
- A small MCP server process on the same network as the repo, implementing a handful of tools (
list_files,read_file,write_file,delete_file,git_log,search) that operate on that repo via normal git commands. - A Cloudflare Tunnel exposing that MCP server's HTTP endpoint at a public hostname, so it's reachable by Claude's cloud infrastructure (needed for chat and for scheduled/unattended agents, which don't run on my laptop) without opening any inbound port on my home network or exposing raw git protocol to the internet.
The git server itself never needs to be internet-facing at all. Only the narrow, purpose-built MCP surface is.
Why an MCP server instead of just exposing git directly
I could have skipped the middle layer and exposed the git repo's smart-HTTP or SSH endpoint straight through the tunnel. I didn't, for a few reasons:
- The tool surface is the security boundary. An MCP server that only implements
read_file,write_file,list_files,delete_file,git_log, andsearchcannot be used to get a shell, run arbitrary git commands, or touch anything outside the one repo it's scoped to. Raw git access is a much bigger attack surface — pack protocol parsing, hooks, ref manipulation — for zero benefit here. - Forcing a commit message on every write is a feature, not friction. My
write_filetool requires acommit_messageargument. That means every single edit Claude makes — whether triggered by me typing something in chat or by a 6:30am scheduled check-in nobody watched happen — leaves behind a real, human-readable description of why, not just what. That's a better audit trail than most of my actual work repos get. - It matches how MCP clients already think. Claude (and any other MCP-speaking client) already knows how to discover tools, call them with typed arguments, and render results. Building on that instead of asking the model to shell out to git commands directly is both safer and simpler — no prompt-injection surface from parsing raw git CLI output, no risk of a malformed command running something unintended.
The MCP server itself
The MCP TypeScript SDK makes this small. A trimmed-down version of the actual tool set:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { simpleGit } from "simple-git";
import { promises as fs } from "fs";
import path from "path";
const REPO_ROOT = "/srv/second-brain"; // working copy the server owns
const git = simpleGit(REPO_ROOT);
const server = new McpServer({ name: "second-brain", version: "1.0.0" });
function safeResolve(relativePath: string): string {
// Refuse anything that escapes the repo root — this is the whole
// security model for path handling, so it earns a dedicated function.
const resolved = path.resolve(REPO_ROOT, relativePath);
if (!resolved.startsWith(REPO_ROOT)) {
throw new Error("path escapes repo root");
}
return resolved;
}
server.tool(
"read_file",
"Read the full content of a file (path relative to repo root).",
{ path: z.string() },
async ({ path: relPath }) => {
const content = await fs.readFile(safeResolve(relPath), "utf-8");
return { content: [{ type: "text", text: content }] };
}
);
server.tool(
"write_file",
"Create or overwrite a file, then commit and push with the given message.",
{ path: z.string(), content: z.string(), commit_message: z.string() },
async ({ path: relPath, content, commit_message }) => {
const full = safeResolve(relPath);
await fs.mkdir(path.dirname(full), { recursive: true });
await fs.writeFile(full, content, "utf-8");
await git.add(relPath);
const result = await git.commit(commit_message);
await git.push("origin", "main");
return {
content: [{
type: "text",
text: `Committed ${relPath} as ${result.commit}: ${commit_message}`,
}],
};
}
);
server.tool(
"git_log",
"Show recent commit history for a file, or the whole repo if path is empty.",
{ path: z.string().default(""), limit: z.number().default(10) },
async ({ path: relPath, limit }) => {
const log = await git.log({ file: relPath || undefined, maxCount: limit });
return {
content: [{
type: "text",
text: log.all.map(c => `${c.hash.slice(0, 7)} ${c.date.slice(0, 10)} ${c.message}`).join("\n"),
}],
};
}
);
list_files, delete_file, and search follow the same shape — resolve and validate the path, do the filesystem/git operation, commit if it's a write. The whole server is a few hundred lines, and the git plumbing is the least interesting part of it; simple-git (or just shelling out) handles it fine.
Two things worth calling out from actually running this:
write_filedoingadd+commit+pushsynchronously, in one tool call, keeps the model's mental model simple: it doesn't need to reason about a separate "save" step, and every tool call either fully succeeds (committed and pushed) or fails loudly. A queued/async push would be more resilient to transient network issues but adds a whole class of "did my edit actually land" ambiguity that isn't worth it for a single-writer personal tool.- Commit only if there's an actual diff. Worth an explicit check before calling
git.commit()— an LLM asked to "sync" or "update" a file will happily write back byte-identical content, and you don't want a git history full of no-op commits from that.
Fronting it with Cloudflare
The MCP server binds to a local port and never touches the public internet directly. cloudflared runs as a daemon on the same box (or anywhere on the same network) and handles everything else:
# cloudflared config.yml
tunnel: <tunnel-id>
credentials-file: /etc/cloudflared/<tunnel-id>.json
ingress:
- hostname: mcp.example.com
service: http://localhost:8080
- service: http_status:404
cloudflared tunnel run second-brain-mcp
That's the entire public-ingress story: no port forwarding on the router, no dynamic DNS, no certificate to manage — Cloudflare terminates TLS at the edge and proxies the connection to cloudflared, which holds an outbound-only connection back to the tunnel daemon. The origin box makes zero inbound listeners reachable from the internet.
Bearer-token auth, via Cloudflare Access — not a login page
A tunnel hostname on its own is just routing — anyone who finds mcp.example.com can hit the MCP server. The piece that actually gates it is Cloudflare Access, configured to authenticate the machine calling the tool, not a human clicking through a login screen. Two things matter here, and both are easy to get wrong the first time through the dashboard:
1. Create a Service Token, not a user login. Under Zero Trust → Access → Service Auth → Service Tokens, generate a token scoped to the hostname. This gives you a Client ID and a Client Secret — the MCP client sends these as the CF-Access-Client-Id and CF-Access-Client-Secret headers on every request. There's no browser redirect, no identity provider prompt; it's meant for exactly this case, a non-interactive system authenticating on its own behalf.
2. Set the Access policy's action to "Service Auth" — not "Allow." This is the detail that actually matters, and it's not the default anyone reaches for first. "Allow" is built for interactive sessions: a human hits the hostname, gets redirected to your identity provider, logs in, and is let through. Service tokens technically can satisfy an "Allow" policy, but that's not what the action type is for, and it doesn't give you the option to layer in the other non-interactive checks. Service Auth is the policy action purpose-built for systems authenticating via service tokens, mTLS client certificates, or IP address rules — used either alongside or entirely instead of an identity-provider login. Point the policy at the service token you created, and optionally add a "Valid Certificate" rule for mutual TLS on top of it (this requires a root certificate configured for the domain first, under Access → Certificates).
Either way, once Access approves the request, Cloudflare mints a signed JWT and forwards it to your origin as the Cf-Access-Jwt-Assertion header. The MCP server never has to implement its own auth from scratch — it can trust that anything reaching it already cleared Access, and optionally verify the JWT itself against Cloudflare's public keys for defense in depth if it wants to be sure the request didn't somehow reach it by another path.
A few more things I'd add if this were serving more than one person, or anything more sensitive than my own task list:
- A dedicated, narrowly-scoped git identity for the MCP server's commits — not a personal account's credentials — so the audit trail in
git logclearly shows "this commit came from the automated tool," not "this commit came from me." - Per-tool rate limiting or an allowlist of calling identities, if more than one MCP client will ever have access — a single personal setup doesn't need this, but it's the first thing I'd add before sharing the tunnel hostname with anyone else.
Was it worth it?
For a single-user personal tool, yes — clearly. The concrete wins:
- Actual version history.
git logon any file now answers "what changed and when and why" in a way Drive structurally cannot. - Cheaper, and more predictable, token usage. No per-file metadata envelope, no escaped markdown, a real
list_filescall instead of a search endpoint standing in for one. - Genuinely portable. The "second brain" is now just... a git repo. Any tool that can read markdown and shell out to git can use it, forever, independent of any one AI product's connector ecosystem.
The one thing I didn't do, and would think hard about before doing: I didn't try to make the old system (Drive) an active two-way sync target once the git repo became primary. Keeping two live, mutually-authoritative copies of the same mutable state in sync is a much harder problem than it looks — you inherit every distributed-systems conflict-resolution question for very little benefit. Instead the old copy became a frozen, deliberately-stale backup: useful as a last-resort fallback if the git host is ever unreachable, not something I pretend is current. If you're doing a similar migration, that's the boundary I'd draw too — pick one source of truth, and let the other one honestly be a cold backup rather than a second thing you have to keep believing is in sync.