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:

  1. 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.
  2. 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.
  3. 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 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:

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:

Was it worth it?

For a single-user personal tool, yes — clearly. The concrete wins:

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.