Skip to main content
Glama
ElOleksii

github-data-mcp

by ElOleksii
README.md
# github-data-mcp

A single-user remote MCP server that exposes your own GitHub activity to Claude
as a custom connector. Three read-only tools, Streamable HTTP on one endpoint,
deployed as one Vercel function.

```
POST https://<your-project>.vercel.app/mcp
Authorization: Bearer <access token, or MCP_AUTH_TOKEN directly>
```

Authentication is OAuth 2.1 for clients that speak it (the claude.ai web
connector discovers it automatically), with the static `MCP_AUTH_TOKEN` still
accepted directly for Claude Code, curl and the smoke test.

| Tool | Answers | Arguments |
| --- | --- | --- |
| `get_recent_activity` | "what did I work on today?" | `since` (optional ISO 8601, default 24h ago) |
| `list_open_prs` | "what's waiting on me?" | none |
| `get_repo_summary` | "how is owner/name doing?" | `repo` (required, `"owner/name"`) |

---

## Quick start

```bash
npm install
cp .env.example .env    # then fill in GITHUB_TOKEN and MCP_AUTH_TOKEN
npm run smoke
```

`npm run smoke` boots the server in-process on a random port, drives it through
a **real MCP client over Streamable HTTP**, and calls all three tools against
the live GitHub API. It also checks the guards (missing bearer → 401, wrong
bearer → 401, unexpected `Origin` → 403) and that error payloads never contain
your token. Run this before wiring anything into Claude.

```bash
npm run smoke -- --repo owner/name --since 2026-08-01T00:00:00Z
```

To run the server for real:

```bash
npm run dev      # tsx watch, http://127.0.0.1:3000/mcp
```

A hand-rolled request (note that the MCP spec requires **both** Accept types,
even though this server only ever answers with JSON):

```bash
curl -sS http://127.0.0.1:3000/mcp -H "Authorization: Bearer $MCP_AUTH_TOKEN" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

---

## The GitHub token

Create a **fine-grained** PAT at
<https://github.com/settings/personal-access-tokens>:

- **Resource owner**: your own account
- **Repository access**: all repositories (or just the ones you care about)
- **Repository permissions**, all read-only:
  - `Contents` — commit history for `get_recent_activity`
  - `Metadata` — mandatory, auto-selected
  - `Issues` — open issue count for `get_repo_summary`
  - `Pull requests` — `list_open_prs`
  - `Commit statuses` — CI state on PRs

Two things that silently return *less data* rather than erroring, so check them
if a result looks empty:

1. **Private contributions.** Turn on *Settings → Public profile → Include
   private contributions on my profile*. Without it the contributions API omits
   private-repo commits from `get_recent_activity` entirely.
2. **Organisation approval.** A fine-grained PAT only reaches an org's repos if
   that org has approved fine-grained tokens and you selected it as the resource
   owner. Unapproved org repos come back empty, not 403.

---

## Configuration

| Variable | Required | Purpose |
| --- | --- | --- |
| `GITHUB_TOKEN` | yes | Fine-grained PAT, read-only |
| `MCP_AUTH_TOKEN` | yes | Master secret: accepted as a bearer, used as the OAuth sign-in credential, and derives the OAuth signing key |
| `PUBLIC_URL` | recommended | Public origin, e.g. `https://foo.vercel.app`. OAuth metadata is built from it |
| `ALLOWED_ORIGINS` | no | Comma-separated browser origins allowed to call the endpoint |
| `INCLUDE_ORG_REPOS` | no | Include org-owned repos in `get_recent_activity` (default `false`) |
| `INCLUDE_FORKS` | no | Include forks in `get_recent_activity` (default `false`) |
| `PORT` | no | Local server port (default `3000`, ignored on Vercel) |

Generate the bearer with real entropy — it is the only thing between the open
internet and your GitHub data:

```bash
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
```

### Origin handling (DNS-rebinding guard)

A browser always attaches an `Origin` header; a server-to-server MCP client
normally does not. So the rule is:

- **No `Origin` header** → allowed. This is the normal path for Claude, curl,
  and the smoke test.
- **`Origin` present** → it must appear in `ALLOWED_ORIGINS`, otherwise `403`.
  With `ALLOWED_ORIGINS` unset, *every* request carrying an `Origin` is rejected.

That is what stops a page on `evil.com` from driving this endpoint through your
browser while your session cookies ride along. Set `ALLOWED_ORIGINS` only if a
browser app of yours genuinely needs to call the server. `*` is accepted as a
wildcard and disables the guard — don't.

---

## Scope of the data

Two deliberate limits, worth knowing before you trust an empty result:

**`get_recent_activity` sees default branches only.** It issues one GraphQL
request: `contributionsCollection` names the repos you committed to in the
window, and the same query walks each repo's `defaultBranchRef` history filtered
to your author id. Getting per-commit detail off *every* branch would mean
fanning out per ref — several requests instead of one — so commits sitting on an
unmerged feature branch will not appear. The tool description tells the model
this explicitly, so it reports "nothing landed on a default branch" rather than
"you did no work". (One extra request happens on a cold process: the viewer's
node id is fetched once and cached, because `history(author:)` needs it.)

**Repo ownership filters apply to `get_recent_activity` only.** By default it
covers repos *you own*, private included, excluding forks and org-owned repos —
flip `INCLUDE_ORG_REPOS` / `INCLUDE_FORKS` to widen it. `list_open_prs` is
deliberately **not** filtered this way: review requests are by definition mostly
on repos you don't own, so filtering them out would defeat the tool.

---

## Output and error shape

Every tool returns compact JSON in a single text block, with null/empty fields
omitted rather than serialised as `null`:

```json
{"since":"2026-08-10T13:00:00.000Z","until":"2026-08-11T13:00:00.000Z","commit_count":2,"repo_count":1,
 "commits":[{"repo":"me/thing","branch":"main","message":"Fix the parser","at":"2026-08-11T09:12:44Z","additions":31,"deletions":4,"sha":"a1b2c3d","private":true}]}
```

`get_recent_activity` is capped at **50 commits**, newest first. When more exist
the response carries `"truncated": true` plus a `truncated_note` saying how many
were available, so the model can say so instead of presenting a partial list as
complete.

Nothing throws past the tool boundary. Failures come back as `isError: true`
with one consistent shape:

```json
{"error":{"code":"github_rate_limit","message":"GitHub API rate limit exhausted. Retry after the reset time; no data was returned.","reset_at":"2026-08-11T14:03:11.000Z","retry_after_seconds":1847,"limit":5000}}
```

| `code` | Meaning |
| --- | --- |
| `bad_argument` | Malformed `since` or `repo` — message says what was expected |
| `config_missing` | A required env var is unset; the message names it |
| `github_auth` | HTTP 401 from GitHub — names `GITHUB_TOKEN` as the thing to fix |
| `github_forbidden` | Valid token, missing permission or org approval |
| `github_rate_limit` | Includes `reset_at` and `retry_after_seconds` |
| `github_not_found` | Repo missing, renamed, or not visible to the token |
| `github_error` / `network_error` / `internal_error` | Everything else |

Token values are never echoed. Messages name the *environment variable*, and a
final redaction pass scrubs both configured secrets and anything matching
GitHub's token formats (`ghp_…`, `github_pat_…`) before a message leaves the
process.

---

## Deploying to Vercel

Deployment uses Vercel's **captured Node.js server** mode, so there is no
`vercel.json` and no `api/` directory to keep in sync. Vercel's Node runtime
looks for a reserved entrypoint name — `src/server.ts` is one of them — captures
the HTTP server that file starts, and routes every request to it. Our own router
then serves `POST /mcp`, so the public endpoint is
`https://<project>.vercel.app/mcp`.

The upshot is that production and `npm run dev` run *the same code path*: both
start `src/server.ts`, and the smoke test drives the same router on an ephemeral
port.

```bash
npm i -g vercel
vercel link

vercel env add GITHUB_TOKEN production
vercel env add MCP_AUTH_TOKEN production

vercel deploy --prod
```

Then confirm the deployed endpoint is reachable and rejecting anonymous callers:

```bash
curl -sS -o /dev/null -w "%{http_code}\n" -X POST https://<project>.vercel.app/mcp
```

`401` is the correct answer. Anything else — especially `200` with HTML — means
you are not hitting the function.

Four Vercel-specific things:

- **Deployment Protection.** If *Vercel Authentication* is enabled on the
  project, every request gets bounced to an SSO login page and Claude will see
  HTML instead of JSON-RPC. Turn it off for production (Project → Settings →
  Deployment Protection), or the connector cannot reach you. This is the single
  most common reason a deployed MCP server "works locally and not in Claude".
- **Use the production URL.** Preview deployments get a fresh URL per commit;
  only the production domain is stable enough to register as a connector.
- **`src/server.ts` is load-bearing, in both directions.** The reserved
  entrypoint names (`server`, `app`, `index`, `main`, at the root or in `src/`)
  are matched by *filename*, and the matched file must call `listen()` during
  module startup. Two failure modes follow, and this project hit both:
  - A file with a reserved name that does *not* call `listen()` still gets
    captured, and every request — including paths that should 404 — dies with
    `FUNCTION_INVOCATION_FAILED`. This is why the MCP server factory is named
    `src/mcp-server.ts` and not `src/server.ts`.
  - No file with a reserved name at all, and the build fails outright with
    *"No entrypoint found"*. So `src/server.ts` must exist and must listen.

  If you rename or move `src/server.ts`, the deployment breaks one way or the
  other. It is a five-line file for exactly this reason.
- **Deleting `vercel.json` was deliberate.** In captured-server mode the routing
  is ours, so rewrites are unnecessary; a `functions` block pointing at an `api/`
  path that no longer exists would fail the build. Timeouts and memory come from
  project settings instead.
- **Stateless by design.** Each request builds its own `McpServer` and transport
  with `sessionIdGenerator: undefined`, so no state is assumed to survive
  between invocations — which is exactly right for serverless, where it wouldn't.

---

## Connecting it to Claude

### claude.ai web connector — OAuth

Paste the plain endpoint URL, with no token in it:

```
https://<project>.vercel.app/mcp
```

Claude discovers the OAuth flow on its own: it gets a `401` carrying a
`resource_metadata` pointer, reads the metadata documents, registers itself, and
opens a sign-in page. **Paste `MCP_AUTH_TOKEN` into that page**, click Authorize,
and the connector is live. You do this once — afterwards Claude holds a
short-lived access token and refreshes it silently.

Leave the OAuth Client ID field empty; the server supports dynamic registration.

### Claude Code / Claude Desktop — header

```bash
claude mcp add --transport http github-data https://<project>.vercel.app/mcp --header "Authorization: Bearer <MCP_AUTH_TOKEN>"
```

The static token is still accepted directly, which keeps curl debugging and the
smoke test simple. Claude Code can also do the OAuth flow if you prefer; the
header just skips it.

---

## How the OAuth works

The server is both the resource server and its own authorization server. The
flow is standard OAuth 2.1 — discovery, dynamic client registration,
authorization code with PKCE, refresh tokens — with exactly one shortcut for the
single-user case: **the sign-in step asks for `MCP_AUTH_TOKEN` instead of looking
up a user.** There is one principal, so holding the deployment's secret *is*
being that principal. No user table, no password database, no third-party
identity provider.

| Endpoint | Purpose |
| --- | --- |
| `GET /.well-known/oauth-protected-resource` | RFC 9728 — names the authorization server |
| `GET /.well-known/oauth-authorization-server` | RFC 8414 — endpoints and capabilities |
| `POST /oauth/register` | RFC 7591 dynamic client registration |
| `GET /oauth/authorize` | Sign-in and consent page |
| `POST /oauth/authorize` | Verifies the secret, issues an authorization code |
| `POST /oauth/token` | `authorization_code` and `refresh_token` grants |

### Stateless by construction

Serverless functions have nowhere to keep an auth-code table, so nothing is
stored. Authorization codes, access tokens, refresh tokens **and registered
client IDs** are each an HMAC-signed blob carrying their own contents
(`src/oauth/tokens.ts`). Any instance can verify anything it issued because they
share a key. The key is derived from `MCP_AUTH_TOKEN`, which gives rotation for
free: change that variable and every previously issued token stops verifying.

Lifetimes: authorization codes 2 minutes, access tokens 1 hour, refresh tokens
30 days.

### What is actually enforced

- **PKCE is mandatory** and only `S256` is accepted — no `plain`, no omission.
- **Redirect URIs match exactly** against the ones registered for that client.
  No prefix or wildcard matching, which is where open redirects come from.
- **Audience binding.** Access tokens are bound to this server's own resource URL
  and validated on every MCP request, so a token minted elsewhere cannot be
  replayed here. A client that *asks* for a foreign audience still gets a token
  bound to ours — this server never mints credentials for somewhere else.
- **Codes are single-purpose.** The token kind is inside the signed payload, so
  an authorization code cannot be presented as an access token.
- Registration rejects non-HTTPS redirect URIs except on loopback.

The smoke test exercises all of this end to end: registration, the sign-in page,
a wrong secret (401, no code issued), a good secret, a wrong `code_verifier`
(400), the real exchange, calling `/mcp` with the resulting token, the refresh
grant, and a tampered token (401).

### What this does not solve

It is still one shared secret at the root of everything. Anyone holding
`MCP_AUTH_TOKEN` can complete the sign-in page and mint themselves a token, and
the GitHub PAT behind it is unchanged. What OAuth bought is narrower than it
looks, and worth being precise about: the long-lived secret is typed into a form
once instead of being stored by every client, what clients hold afterwards
expires in an hour, tokens are audience-bound, and revocation is a single env var
change. The secret no longer travels in a URL, which is the specific exposure
this replaced.

### Exactly what would change for multi-user

The protocol layer is now done — discovery, registration, PKCE, audience
binding and refresh all work for any number of clients. What remains is identity
and per-user data:

1. **Replace the sign-in step with real authentication.** `POST /oauth/authorize`
   currently compares one secret. It would redirect to an identity provider —
   GitHub OAuth being the obvious one — and carry the resulting user id into the
   authorization code's claims.
2. **Put the user in the token.** `AccessClaims` in `src/oauth/tokens.ts` carries
   only audience and scope; it would gain a subject. Everything downstream reads
   the user from there.
3. **Resolve a GitHub token per request instead of per process.** Today
   `src/http.ts` reads one `GITHUB_TOKEN` out of `Config` and hands it to
   `buildServer`. It would map the token's subject to that user's stored GitHub
   credentials — the SDK surfaces this as `AuthInfo` on the tool callback's
   `extra` argument — and pass a per-request context down to the tools, whose
   signatures already take a config object rather than reading globals.
4. **Store and refresh per-user GitHub tokens** encrypted at rest (Vercel KV,
   Postgres, whatever). This is the part that turns a stateless function into a
   service with a database, and it also forces real token revocation: today
   revoking means rotating the signing secret, which logs everyone out at once.
5. **Fix the viewer cache — this one is a real bug waiting to happen.**
   `getViewer` in `src/github.ts` memoises the authenticated user's node id and
   login in a module-level variable. For one user that is a free request saved;
   for many it is a cross-user data leak the moment two people share a warm
   Lambda. It must become keyed by user, or be dropped.
6. **Per-user rate limiting and quotas.** GitHub's 5000 points/hour is currently
   yours alone. Shared, you need per-user accounting and backpressure.

Items 1–2 are protocol work, 3–5 are this codebase, and 6 is operations. The
`Origin` guard and the error shapes need no changes.

---

## Layout

```
src/server.ts            Entrypoint: starts the HTTP server (Vercel captures this)
src/node-server.ts       node:http routing — /mcp, /oauth/*, metadata, /healthz
src/oauth/routes.ts      Authorization server: discovery, register, authorize, token
src/oauth/tokens.ts      HMAC-signed codes/tokens/client IDs, PKCE verification
src/http.ts              Origin guard, bearer check, stateless transport wiring
src/mcp-server.ts        McpServer + the three tool registrations
src/github.ts            GraphQL client, HTTP/GraphQL error mapping, viewer cache
src/tools/               One file per tool: query, shaping, and its description
src/config.ts            Env loading (names may appear in errors, values never)
src/errors.ts            ToolError, structured payloads, redaction
scripts/smoke.ts         End-to-end verification against the real API
```

The tool *descriptions* live at the bottom of each `src/tools/*.ts` file. They
are the part a model actually reads, so they carry the argument examples, the
"call this when…" triggers, the "don't call this for…" boundaries, and the
caveats needed to interpret an empty result correctly. Edit them with more care
than the code.