md-github
by jjenkins2004
README.md
# md-github
A small MCP server that lets a **claude.ai custom connector** do surgical markdown edits across
the GitHub repositories of one account, batching any number of changes into **exactly one commit**.
Claude authenticates with OAuth 2.1 + DCR (which the connector form requires). Each person's
consent secret selects their own GitHub PAT and their own account. Zero runtime dependencies, no
database, all state in memory.
This is **not** a GitHub MCP passthrough. It exposes seven tools and nothing else.
## The tools
| Tool | What it does |
| --- | --- |
| `overview` | One call to orient in a repository: its root `INDEX.md` verbatim plus every file path with its size. Read-only. |
| `list_md` | Every .md file with byte size and git blob SHA, optionally with each file's heading outline. Read-only. |
| `read_md` | One file's exact bytes — or several files in one call — each with its blob SHA and a heading outline with line ranges. Read-only. |
| `history` | Recent commits — who authored each, when, and the message. Optional path filter. Read-only. |
| `show_commit` | One commit's author, message, and per-file diff. Read-only. |
| `commit_edits` | Applies an ordered list of edits and pushes them as **one** commit. The only tool that edits markdown. |
| `create_repo` | Creates a repository and seeds it with its own `INDEX.md`. |
Every tool except `create_repo` **requires** `repo` — a bare name (`"notes"`) or `"owner/name"`.
Start a session with `overview(repo)`; it is the one call that tells you what is in there.
`AGENT-TEMPLATE.md` is the instruction block to paste at an agent that will use this connector, and
`./connector-prompt.sh <repo>` fills in the repository name and copies it to the clipboard.
## Many repositories, one connection
An identity reaches **every repository its PAT can see** — owned, collaborated on, or via an
organization. There is no owner to configure: a token already belongs to one account and already
carries its own access, so the set of repositories it can see *is* the namespace. Configuring that
again would only create a second source of truth that can disagree with the token.
A bare `repo:"notes"` is resolved against that visible set; `repo:"owner/notes"` skips the lookup
and addresses the repository directly. A name visible under two owners is a refusal naming both,
not a guess. Nothing is enumerated at boot, so a repository made by `create_repo` resolves on the
very next call with no redeploy.
### There is no default repository
`repo` is required, and a call that omits it is an error rather than a guess. With several
projects behind one connection there is no such thing as "the" repository, and every plausible
fallback — the first one, the one configured at boot, the one touched last — is a way for an edit
to land in the wrong project while every message still reads like success. A wrong-repo commit is
also the one mistake here that `expect_sha` cannot catch, because the blob it guards is in a
repository nobody is looking at.
**Nothing lists the repositories a connection can reach** — not on results, not in the handshake.
Every tool names its repository explicitly, so a roster is never needed to make a call, and for a
broadly-scoped PAT it would put a screenful of irrelevant names on every single result. The visible
set is read from GitHub only to resolve a bare name, and it is never printed.
That resolution reads `GET /user/repos`, not `GET /users/:owner/repos` — the latter returns only
*public* repositories even for your own account, so a private notes repo would not resolve by name
at all. It is cached for five minutes, and a miss refetches once before failing, so a repository
created moments ago somewhere else still resolves.
### Nothing narrows a connection except its PAT
There is no pinned repository, no repository allowlist, no name prefix, no branch override and no
subtree confinement. Earlier versions had all of them; they were removed. Each was a second
boundary sitting next to the PAT's own scopes, able only to disagree with it, and each made
`create_repo` incoherent — a repository that does not exist yet cannot be on an allowlist written
at boot. Every repository uses its own default branch, for the same reason: one identity spans many
repositories, and a branch that exists in one rarely exists in the next.
The PAT is the boundary. Scope it in GitHub, where it actually holds.
### Each repository documents itself
There is deliberately **no cross-repository index file**. Each repository documents itself in its
own root `INDEX.md`, which is the file `create_repo` seeds and the file this server feeds back into
context. A registry in one repo would be a second place the truth lives, and it would go stale the
first time someone renamed a project outside the connector.
## Orienting: `overview`
A session starts by calling `overview(repo)`. One round trip returns that repository's root
`INDEX.md` verbatim — the router that says which file answers which question — plus every path in
the repository with its size. On this project's own context repo that is 152 paths and ~7k tokens,
after which the model knows where everything is and how big it is before fetching anything.
Everything else follows from the router: `read_md({paths:[...]})` for the folder indexes it points
at, `list_md({path_prefix, outline:true})` to narrow.
Deliberately **not** inlined: the folder-level index files. One of them in this project's context
repo is 66 KB — inlining them all would cost more than reading the files they describe.
Nothing else ever attaches an index to a result. An earlier version appended the router to every
tool result; a 13 KB index is ~3.5k tokens, so a ten-call session paid for one piece of information
ten times. `overview` delivers it once, when asked, and nothing else ever attaches one.
The index is read from the first of `INDEX.md`, `index.md`, `README.md` at the repository root,
cached for two minutes, and **invalidated by any commit through this server** — so a router the
model just rewrote is never read back stale. That cache and the name-resolution list are the only
things this server caches: neither is ever a source of a blob SHA, so a stale one cannot cause a
wrong write. The tree is deliberately **not** cached, for that reason.
### Who edited what
Each person's own PAT is injected, so GitHub records the real human as the commit author — this
is genuine git attribution, not anything the server synthesizes. `history` answers "who changed
this file", `show_commit` shows the actual diff, and `git blame` works normally outside the app.
Two limits worth knowing. `history(path)` does not follow renames, so commits from before a
rename are listed under the old path — same as `git log` without `--follow`. And a commit's
file list is paginated by GitHub at 300 files; the tool reports when it has hit that boundary
rather than presenting a partial list as complete.
### Reading several files at once
`read_md` takes `paths: [...]` (up to 20) instead of `path`. Reading five folder indexes is then
one round trip rather than five, and `max_bytes` becomes a budget shared across the batch and spent
in the order given. The batch is deliberately **not** all-or-nothing: a path that does not exist
reports its own error while the others still return. All-or-nothing is a property of `commit_edits`,
where a partial result would be a corrupt repository; here it would only cost a round trip.
`list_md` takes `outline: true` to show each file's headings without reading it. That is one read
per file, so it is refused above 40 files and says how to narrow.
### Creating a repository
`create_repo({name, overview})` creates the repository under the connection's owner and seeds it
with an `INDEX.md` of `# <name>` plus the overview. The overview is meant to be the document a
reader lands on first, not a one-line summary — it is that repository's router.
Its two effects on GitHub — the repository, then its first commit — **cannot be one transaction**,
so they are reported separately. If the seeding commit fails, the result says the repository exists
and is empty, and names the exact `commit_edits` call that finishes the job. It does not delete the
repository it just made: destroying a namespace to tidy up an error is a far worse failure than an
empty repository.
### Writing to a repository that has no commits
A brand-new repository cannot be written through GitHub's git-data API at all: blobs, trees and
commits all answer `409 Git Repository is empty.` The only endpoint that works is
`PUT /contents`, which creates the branch and the initial commit in one request — so that is what
`create_repo` seeds with, and it is the **only** place this server ever calls `PUT /contents`.
It writes exactly one file, so exactly one file is what a batch into an empty repository may
carry. A multi-file batch is refused with instructions rather than split across two commits,
because "one call, one commit" is the guarantee the whole design rests on.
`POST /user/repos` creates under **the account the token belongs to**, not under a name in the
request body — so a PAT that merely collaborates on someone else's repositories creates new ones in
its *own* account. The result reports the `full_name` GitHub returned rather than a name this server
assumed. It resolves by name on the very next call.
Creating a repository needs more than `Contents: Read and write`. A classic PAT with the `repo`
scope works; a fine-grained PAT needs `Administration: Read and write`, cannot create repositories
in a *personal* account at all (only in an organization), and if it is limited to selected
repositories it could not write to the new repository anyway — so multi-repo use wants
**All repositories**. A 403 says exactly this instead of the generic contents message.
`commit_edits` takes four operations:
| op | Fields | Notes |
| --- | --- | --- |
| `write` | `path`, `content`, `mode` | `create` (default), `overwrite` (needs `expect_sha`), `append`. |
| `str_replace` | `path`, `old_string`, `new_string`, `replace_all` | Exact byte match; must be unique unless `replace_all`. |
| `edit_section` | `path`, `heading`, `mode`, `content` | `replace` / `append` / `prepend` / `delete` a section addressed by its heading. |
| `delete` | `path`, `expect_sha` | Removes a file. |
### Why there is no start_commit / end_commit
The obvious design is a staging area you open and later close with a message. It was rejected
deliberately: it creates a place where finished-looking work can sit unpublished, so "I made the
edits and forgot to commit" becomes constructible. Three independent design reviews converged on
the same conclusion.
Instead there is **no staging area at all**. `commit_edits` is atomic — a whole batch of changes
across many files, applied and pushed in one call, or nothing sent to GitHub whatsoever. The agent
accumulates its plan in its own context (the one store a model reads reliably) and spends it in a
single call. Every tool result ends with a standing line saying nothing is pending, so the belief
that something is queued is refuted continuously rather than left to be discovered later.
There is also **no auto-commit timer**, at any timeout. An idle timer publishes work nobody
approved — a retracted deletion, a half-finished restructure. Zero unwanted commits is a designed
property, not an omission. On shutdown the server logs what it is dropping and commits nothing.
The one piece of retained state is **failure-only**: a batch that fails is held for 30 minutes as a
`retry_ref` so a large batch need not be retyped from a context that may already have been
compacted. It is announced on every subsequent result, it is cleared by any success, and it can
never produce a success receipt. It is also bound to the repository it was authored against:
replaying it into another one is refused, because those edits were built from text the other
repository has never contained.
### Atomicity, precisely
`commit_edits` runs two phases and the boundary is the guarantee.
- **Plan** — validation, snapshot fetch, `expect_sha` checks, applying every op to in-memory
buffers. Any failure aborts here having issued only `GET`s. Not "rolled back": no mutative
request was ever sent. All failures in a batch are reported together, so a 12-op batch with 3
defects costs one turn rather than three.
- **Execute** — three mutative requests (`POST /git/trees`, `POST /git/commits`,
`PATCH /git/refs`) regardless of how many files changed. Only the final `PATCH` is observable.
So after any call there are exactly two observable states: one commit exists, or the branch is
byte-identical to before.
`expect_sha` is required exactly where an operation destroys a whole file — `delete` and
`write mode=overwrite`. You cannot wholesale replace or remove a file you never observed.
`list_md` returns full blob SHAs, so a deletion never needs a content read.
### Concurrency
Content is re-read at commit time from a single pinned snapshot, so the read-modify-write window is
about a second rather than the length of a conversation. `PATCH ... force:false` is a real
server-side compare-and-swap; `force: true` is never sent anywhere. On a collision the whole plan
re-runs against the new head: if nothing the batch touches moved, it lands silently; if something
did, it stops and returns the fresh upstream content inline rather than clobbering.
## Environment
| Var | Notes |
| --- | --- |
| `JWT_SECRET` | Signs the tokens this server issues. |
| `PUBLIC_URL` | This service's own base URL, no trailing slash. |
| `PORT` | Pinned to 3000 to match the generated Railway domain. |
| `GITHUB_API_URL` | Defaults to `https://api.github.com`. The testing seam. |
One numbered triple per person:
| Var | Notes |
| --- | --- |
| `USER<N>_SECRET` | What that person types on the consent page. Identity is the secret. |
| `USER<N>_PAT` | That person's GitHub PAT. Used only for their own requests, and the whole of their reach. |
| `USER<N>_NAME` | Optional label, defaults to `user<N>`. Becomes the token `sub`. |
That is the entire per-person configuration: a secret and a PAT. There is nothing else to set —
every repository the PAT can see is reachable, and every call names the one it acts on.
`USER<N>_NAME` is an identity key, not a label: renaming someone invalidates their live tokens and
they must reconnect. Changing their `PAT` takes effect immediately with no reconnect.
**Migrating an older deployment**: delete `USER<N>_REPO`, `USER<N>_OWNER`, `USER<N>_REPOS`,
`USER<N>_REPO_PREFIX`, `USER<N>_BRANCH` and `USER<N>_ROOT` if you have any of them. None are read
any more, and a secret plus a PAT is the whole configuration.
## Connect from claude.ai
1. Settings → Connectors → **Add custom connector**.
2. **URL**: `<PUBLIC_URL>/mcp`.
3. Leave client ID and secret empty.
4. **Connect**, then type your own `USER<N>_SECRET`.
Both people add the same URL; the secret each types binds their session to their own PAT and repo.
## Tests
```bash
npm install && npm run build
npm run test:unit # 92 assertions: scanner, edit ops, byte fidelity — no network
# integration: 258 assertions against a stateful fake GitHub
USER1_NAME=alice USER1_SECRET=secret-alice USER1_PAT=pat-alice \
USER2_NAME=bob USER2_SECRET=secret-bob USER2_PAT=pat-bob \
USER3_NAME=frank USER3_SECRET=secret-frank USER3_PAT=pat-frank \
JWT_SECRET=test-jwt PUBLIC_URL=http://127.0.0.1:8787 PORT=8787 \
GITHUB_API_URL=http://127.0.0.1:8899 npm start &
npm run test:smoke
```
`tests/fake-github.mjs` is a stateful fake with a real git-blob-SHA implementation, a commit DAG,
per-token repository visibility (owned plus collaborated-on, so name resolution proves something), a
request log and injectable faults, so a commit made through the server is observable by a later
read. It is what lets the suite assert the things that actually matter: that N edits produce
exactly one commit and zero `PUT /contents` calls, that a deletion survives serialization as a
literal `"sha":null`, that `force:false` appears on every ref update, that a failed batch leaves
zero mutative requests, that a colleague's concurrent push is never clobbered, that a call naming
one repository issues requests to no other, that `create_repo` produces exactly one commit in
exactly the new repository and reports the account GitHub really created it under, and that a batch
held after a failure cannot be replayed into a different repository.
## Deploying
```bash
railway up --service mcp-github-proxy --detach
```
Builds via the `Dockerfile`, deliberately. Railway's default builder (railpack) fails this service
with `failed to solve: secret RAILWAY_GIT_REPO_OWNER not found` — its generated plan declares the
`RAILWAY_GIT_*` build secrets, which only exist when the service's source is a connected GitHub
repo, not a CLI tarball upload.
## Notes
- The markdown scanner is a real CommonMark block scanner, not a `^#{1,6}` regex. Front matter's
closing `---` is a legal setext H2 underline, so a naive scan invents a phantom heading named
after the last YAML line and an agent would edit straight into the front matter. Headings inside
fences, indented code, HTML blocks and blockquotes are correctly not addressable.
- Byte fidelity is deliberate: CRLF files keep CRLF on untouched lines, a BOM is split off so a
start-anchored `old_string` can match, and nothing is ever trimmed — two trailing spaces are a
markdown hard line break.
- `read_md` returns content **without** line-number gutters, because numbers next to text the model
is about to copy into `old_string` is exactly how a gutter ends up in the needle. Line numbers
appear only in outlines and error messages — places nothing is copied from.
- A short file returned whole gets **no** outline. An outline is a map of a file you have not read;
printing one above the twelve lines it describes is noise. It reappears the moment the file is
long enough to page through, or the window is partial.
- A GitHub 401 is surfaced as tool-error text and never as an HTTP 401 from `/mcp`. The old proxy
forwarded GitHub's `WWW-Authenticate`, which sent claude.ai to re-authenticate against GitHub and
produced a reauth loop while the real problem — a dead PAT — stayed invisible.
- The PAT is the real security boundary, and now it is also the only one that decides reach:
nothing in this server's configuration narrows it. Scope the PAT itself in GitHub. Note the tension
with `create_repo`: it wants a token that can reach repositories that did not exist when the
token was made, which is the opposite of a selected-repositories fine-grained PAT.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues