@workspace/github-mcp-server
by shanemccaw
README.md
# @workspace/github-mcp-server
A small MCP server that exposes real GitHub operations to a Claude conversation **while holding
the GitHub PAT server-side**, so the live credential never enters chat history. Git #3390, the
foundation of **Feature #3377** (GitHub MCP Server — server-side PAT, no token in chat).
Modeled on Shane's Life's own bearer-token MCP pattern (`web/shanes-life`, see
`web/shanes-life/docs/mcp-connection-guide.md`): JSON-RPC 2.0 over Streamable HTTP, bearer-token
auth, **no OAuth server**.
## Why
Today a chat that needs to touch GitHub pastes a raw PAT into the message and runs `curl`/`git`.
That puts the live credential in plaintext in conversation history — searchable and re-surfaceable.
This server removes that: the PAT is consumed **internally** by the server and is never returned
in a tool call input, a tool call result, a log line, or an audit row.
## Two credentials — do not confuse them
- **The GitHub PAT** — held server-side only, read from `GITHUB_MCP_PAT` (or the repo's existing
`GIT_PAT`) in the repo-root `.env.local` / the environment. The server uses it to talk to GitHub.
It is never sent to a client.
- **The MCP bearer token (`ghmcp_…`)** — what a Claude connection presents to reach *this* server.
Minted once, shown once, only its SHA-256 stored (`github_mcp_tokens`). Revocable. Every call it
makes lands in the Recent-Activity trail (`github_mcp_activity`) with its label.
## Run it
```
# from repo root — deps (pg, pino) are already in the workspace store; no install needed
node artifacts/github-mcp-server/src/index.ts
# listens on http://127.0.0.1:8770/mcp by default (GITHUB_MCP_HOST / GITHUB_MCP_PORT to override)
```
## Mint a token (the admin surface)
```
cd artifacts/github-mcp-server
npm run mint-token -- --label "Claude Desktop" # prints the token ONCE + a `claude mcp add` line
npm run list-tokens # never prints secrets
npm run revoke-token -- --id 3
```
A BuildConsole Settings UI can call the same `issueToken()` later; this CLI is what mints the
first token before any UI exists.
## Connect
```
claude mcp add --transport http github-mcp http://127.0.0.1:8770/mcp \
--header "Authorization: Bearer ghmcp_<the minted token>"
```
Clients with no header field can use the capability-URL form: `POST /mcp/t/<token>`.
## Tools
**Scaffold set (Git #3390)** — three tools prove the auth + PAT + audit spine:
- `server_status` — health; reports `patConfigured` as a boolean only (never the value).
- `github_whoami` — calls GitHub `GET /user` with the server-side PAT and returns only the
resulting public identity + token scopes. Proves the credential works without exposing it.
- `get_recent_activity` — the audit trail of what each connected Claude has done.
- `close_issue` (Git #3394) — `close_issue(number, state_reason, comment?, repo?)` closes an issue
with a real `state_reason` (`completed` | `not_planned`). `not_planned` is rejected before any
GitHub call unless a non-empty `comment` is supplied, and that comment posts FIRST, then the
issue closes — enforcing the repo's standing NOT_PLANNED-always-carries-a-comment rule (Git
#2167) in the tool itself rather than trusting the caller to remember.
- `post_comment` / `list_comments` (Git #3393) — `post_comment(number, body, repo?)` posts a
comment on an issue/PR verbatim, returning its id/htmlUrl/createdAt; `list_comments(number,
repo?)` lists every comment oldest-first (paginates through all pages), matching the standing
convention that a later comment may supersede an earlier one's stated state. Both share
`postIssueComment()` / `listIssueComments()` in `src/github.ts` with `close_issue`'s comment
plumbing.
- `move_to_status` (Git #3395) — `move_to_status(number, status, repo?)` moves an issue (or epic — an
epic is itself an issue) to one of the real Projects v2 board columns this tool is scoped to:
`Batter Up`, `Backlog`, `AI Batter Up`, `Ask Shane`, `Done`. Adds the issue to the board first
(`addProjectV2ItemById`) if it isn't already a project item, then sets the Status field
(`updateProjectV2ItemFieldValue`). `status` is validated against that exact 5-value vocabulary
before any GitHub call is made — an unrecognized string is rejected with the real allowed list,
never silently coerced. Projects v2 has no REST surface at all, so this is the first tool to use
`githubGraphQL()` in `src/github.ts` — same server-side-only-PAT discipline as `githubRequest()`,
just against `https://api.github.com/graphql`. Board/field ids
(`PVT_kwHOEiBDdc4BeoiY` / `PVTSSF_lAHOEiBDdc4BeoiYzhZBRB0`) and their real option ids are the
same ones already live in `artifacts/api-server/src/routes/admin-build-tracker.ts` — re-verify
with a read-only `gh api graphql` node lookup if this ever stops moving cards, per that file's
own "NOT STABLE ACROSS TIME" note.
- `get_board_status` (Git #3542) — `get_board_status(number, repo?)` is the read counterpart
`move_to_status` had none of: reads an issue's real current Projects v2 Status column without
changing anything. Returns `{ number, onBoard, status }` — `onBoard: false` (not an error) if
the issue has no project item yet, `status: null` if it's on the board but Status is unset.
Reuses `move-to-status.ts`'s `githubGraphQL()` call and `PROJECT_V2_ID`; read-only, no
`context` required.
- `list_board_column` (Git #3549) — `list_board_column(status, epicNumber?)` is the reverse
direction of every tool above: those go issue→project (`issue(number) { projectItems { ... } }`);
this one queries the `ProjectV2` node directly (`node(id: PROJECT_V2_ID) { ... on ProjectV2 {
items(first: 100, after: $cursor) { ... } } }`) and returns every real item currently sitting in
one Status column, paginating through the whole board (`pageInfo.hasNextPage` — not capped at one
page). Each matching item's top Epic is resolved in the same query from its nested
`parent`/`parent.parent` chain (mirrors #3336's two-level Feature→Epic walk) — whichever `parent`
is null one level up is the real top ancestor; an issue with no parent at all is genuinely
un-parented (`epicNumber`/`epicTitle`: `null`, not an error). The optional `epicNumber` filters
the result to just that Epic's descendants. Returns `{ status, epicNumber, items: [{ number,
title, epicNumber, epicTitle }], totalCount }`. Replaces the slow, error-prone workaround #3549
found live: a text `search_issues` (GitHub's search doesn't know Projects v2 Status at all)
followed by one `get_board_status` call per candidate to confirm which were actually on the
column. Read-only, no `context` required.
**Sub-issue hierarchy + blocked_by dependencies (Git #3392):**
- `add_sub_issue(parent_number, child_number, repo?)` — resolves the child's real internal `id`
internally; caller only ever passes issue numbers. Fails with GitHub's own error if the child
already has a different parent (GitHub's one-parent-at-a-time rule) — remove it there first.
- `remove_sub_issue(parent_number, child_number, repo?)` — detaches a sub-issue, for re-parenting.
- `list_sub_issues(number, repo?)` — the real, current sub-issue list of one issue. Requests
`per_page=100` (Git #3708) — GitHub's own default page is 30, which was silently truncating this
for any parent with more children than that (confirmed live: #1202 returned only 30 of a real
100). 100 is GitHub's real structural per-parent cap, so one page always covers it.
- `set_blocked_by(number, blocker_numbers[], repo?)` — makes `number`'s real `blocked_by` edges
match `blocker_numbers[]` exactly: adds missing edges, removes stale ones no longer in the list
(pass `[]` to clear). A true "set", not just an append — the CLAUDE.md Git #1987 rule that a
stale edge pointing at a closed/wrong issue silently reads as "clear" is exactly what this
reconciles.
- `list_blocked_by(number, repo?)` — real current blockers of `number` + their live GitHub state.
Same `per_page=100` fix as `list_sub_issues`.
### Epic → Feature → Issue hierarchy + proactive cap overflow (Git #3708)
Real, confirmed convention (Shane, 2026-09-11 — CLAUDE.md's "Feature-first, area epic as fallback"
section): every bug or suggestion is filed as a sub-issue of a **Feature**, never directly under an
**Epic**. Nothing previously enforced this — a real live sweep the same night found 83 issues
mis-parented directly under Epic #1202. `add_sub_issue` now enforces it at write time, and
proactively steers around GitHub's real 100-sub-issue-per-parent hard cap while it's at it
(`src/hierarchy.ts`):
- **Classification** (`classifyIssueTitle` in `src/github.ts`) — an issue's own title decides its
tier: an `EPIC:`/`Epic:` prefix is an Epic, a `Feature:` prefix is a Feature (case-insensitive),
anything else is a plain issue.
- **Enforcement** (`enforceHierarchyOrThrow`) — if `parent_number` classifies as an Epic and
`child_number` does NOT classify as a Feature, the call is rejected before any GitHub write, with
a clear error naming both real issues and the rule. A Feature under an Epic, or any issue under a
Feature (or an unclassified parent), is unrestricted.
- **Proactive overflow** (`resolveOverflowParent`) — if `parent_number` classifies as a Feature and
is already at or near GitHub's real 100-sub-issue cap (≥95 real children), the child is
automatically redirected before it can hit a raw `422`:
1. Look up the Feature's real parent Epic (`getParentIssue`, `GET /issues/{number}/parent`).
2. Search that Epic's real children for an existing `"<Feature title> — Part N"` sibling (same
naming pattern already live for #1788→#3706 / #1789→#3707) with room, and use the lowest-N one
that has it.
3. If none has room, create the next real `"Part N"` overflow Feature under the same Epic and use
that instead.
4. If the Feature has no parent Epic at all, no redirect target is defined — the call proceeds
against the originally-requested parent as a best effort; a genuine `422` from GitHub in that
case is a real, honest failure, not one this masks.
`add_sub_issue`'s response always names which real parent the child actually landed under:
`{ parentNumber, requestedParentNumber, redirected, redirectReason, childNumber, subIssues }` —
`parentNumber` is the real parent used (equal to `requestedParentNumber` unless `redirected` is
true).
- **Real verification (2026-09-12):** `npm run verify-hierarchy` — `list_sub_issues(#1096)` (a real
Epic with 72 live children) returns more than GitHub's 30-row default page; a real non-Feature
issue added under real Epic #1202 is rejected with `HierarchyViolationError` before any write,
while a real Feature-titled issue under the same Epic is not; the overflow title/naming helpers
reproduce the real, already-live `#1788 → #3706` title exactly
(`"Feature: Build Queue Panel (BuildConsole)"` → `"Feature: Build Queue Panel — Part 2
(BuildConsole)"`); `resolveOverflowParent` against a real Feature nowhere near the cap correctly
does not redirect. Deliberately does not exercise the genuinely-at-cap redirect/create branch
live, since that performs a real write (`create_issue` + `add_sub_issue`) — that branch is
covered by direct code review of the same functions the no-redirect path already proves are
wired correctly.
**Core issue operations (Git #3391)** — real reads/writes against issues:
- `create_issue(title, body?, milestone?, labels?, repo?)` — `milestone` is the milestone's real
*number* (e.g. `5` for "v1.1"), not its title.
- `get_issue(number, repo?)`.
- `update_issue(number, title?, body?, milestone?, labels?, state?, repo?)` — only the fields you
pass are changed; `labels`, when passed, replaces the issue's full label set (GitHub's own PATCH
semantics), not an add/remove diff.
- `search_issues(query, perPage?, repo?)` — real passthrough to GitHub's own search query syntax
(`is:open label:bug`, `milestone:"v1.1"`, free text, …), automatically scoped to the target repo
with `repo:owner/name`. Same result shape as GitHub's own search API (`total_count` + items).
All four call `githubRequest()` from `src/github.ts` (the one place the PAT is touched) and return
a normalized issue shape (`normalizeIssue()`) — number, title, body, state, labels, milestone,
assignees, timestamps, comment count.
With `move_to_status` (Git #3395) landed above, every tool of Feature #3377's original design is
now shipped.
**Repository contents — reading real CODE (Git #3697):**
Every tool above reads issue-tracker or board metadata. None of them could read a single line of
the repository itself, which stopped being survivable the moment the repo went private and
PAT-in-chat was retired (#3556/#3559): before that a chat could be handed a raw PAT and clone the
repo; afterwards a chat connected only to `shanes-git` could manage the tracker but could not see
the code it was discussing. These three close that gap with the same server-side-only PAT. All
three are read-only, so none takes `context`.
- `get_file_contents(path, ref?, repo?)` — one real file's actual text, base64-decoded from
GitHub's Contents API. `path` is repo-root-relative and case-sensitive (`./` and a leading `/`
are tolerated; `..` is rejected before any GitHub call). `ref` pins a branch/tag/commit SHA;
omitted reads the default branch. Returns `{ path, repo, ref, sha, size, type, htmlUrl,
downloadUrl, content, encoding, truncated, binary, note }`.
**Honest size/content handling**, per the issue's own item 4 — the tool never returns text it
didn't actually get:
- Over GitHub's ~1 MB inline limit the Contents API returns `content: ""` with
`encoding: "none"`, which decoded naively looks exactly like a real empty file. This returns
`content: null, truncated: true` with the real byte size and the real `downloadUrl` instead.
- A binary file (a NUL byte in the first 8 KiB — git's own heuristic) returns
`content: null, binary: true` plus `downloadUrl`, rather than the UTF-8 mis-decoding of a PNG.
- A submodule or symlink says so and names its target.
- Pointed at a directory it says so and names `list_directory`; a missing path names the real
path, repo and ref rather than relaying a bare `404 Not Found`.
- `list_directory(path?, ref?, repo?)` — the navigation half: one directory's real entries
(`name`, `path`, `type` of `file`/`dir`/`symlink`/`submodule`, `size`, `sha`, urls).
Non-recursive; omit `path` for the repo root. `get_file_contents` is only useful to a caller
that already knows a path — this is how it finds one. GitHub caps this endpoint at 1000 entries
with no cursor, so a listing at that count is reported `truncated: true` rather than passed off
as complete.
- `search_code(query, perPage?, page?, ref?, repo?)` — code search with **two real backends**, and
the result always names which one answered in `source`:
- `"code-search"` — GitHub's own Code Search API, the primary path, with
`Accept: application/vnd.github.text-match+json` so each hit carries the real matching
fragment rather than only a filename. Default branch only, as GitHub's index is.
- `"repo-tree-paths"` — a real PATH search over the repository's full recursive git tree
(`GET /git/trees/{ref}?recursive=1`, cached in-process for five minutes per repo+ref because
the response is ~7,000 entries here).
The fallback is not a nicety. **GitHub's code-search index genuinely returns nothing for this
repository** — `total_count: 0` with `incomplete_results: true` on every query tried (verified
2026-09-11 across seven distinct queries and three repeats, while the identical call against a
public repo returned real hits). Without it the tool would answer "no results" for code that
demonstrably exists, which is worse than having no tool: a chat reads an empty result as proof
of absence. Tracked as its own finding under Feature #3377.
The fallback matches **paths, not contents**, and says so explicitly in `note` every time. It
honours `path:`, `filename:` (with `*`) and `extension:` qualifiers, and reports any qualifier a
path alone cannot answer (`language:`, `in:file`, …) in `ignoredQualifiers` rather than silently
dropping it. A query made only of unevaluable qualifiers returns nothing rather than the whole
repo.
**Batch tools — bulk board/hierarchy management (Git #3709):**
Real, direct motivation, from the issue's own body: the #1202 sub-issue reorganization needed
166+ individual real `remove_sub_issue`/`add_sub_issue` mutation pairs for 83 issues, done via a
raw script outside this server entirely because no batch capability existed. These three close
that gap. Each is a thin loop around its existing single-item tool's own real logic — pulled out
into a shared exported function (`moveIssueToStatus()` in `move-to-status.ts`, `closeOneIssue()`
in `close-issue.ts`, `addSubIssueWithHierarchy()`/`reparentSubIssue()` in `hierarchy.ts`) so the
single-item and batch tools share ONE real implementation rather than two that can drift — run
**one item at a time, sequentially** (never in parallel, to avoid tripping GitHub's own secondary
rate limits with a burst of concurrent writes against one repo), with **independent per-item
success/failure reporting, never all-or-nothing**: one bad item never blocks or rolls back the
others. Every batch tool returns `{ totalAttempted, succeededCount, failedCount, results[] }`.
- `batch_reparent_sub_issues(moves: [{ issueNumber, fromParent, toParent }], repo?, context)` —
for each move, `remove_sub_issue(fromParent)` then `add_sub_issue(toParent)`, applying the same
real Git #3708 hierarchy enforcement + proactive 100-sub-issue-cap overflow redirect
`add_sub_issue` itself applies. GitHub's one-parent-at-a-time rule means there is no single
atomic "move" call — this really is two real writes per item. If the remove half succeeds but
the add half then fails, that is a real, distinct partial failure (the child now genuinely has
no parent) reported as `{ success: false, partial: true, error }` rather than folded into an
ordinary failure that would read as "nothing happened." A full success reports
`{ issueNumber, fromParent, toParent, requestedToParent, redirected, redirectReason, success:
true }` — `toParent` is the real parent the child actually landed under, which may differ from
`requestedToParent` if it was redirected to an overflow Feature.
- `batch_move_to_status(moves: [{ number, status }], repo?, context)` — bulk Projects v2 board
moves, same real 5-value status vocabulary and per-item validation as `move_to_status` (an
invalid `status` string fails only that one item). Success reports
`{ number, status, projectItemId, addedToBoard, success: true }`.
- `batch_close_issues(closures: [{ number, stateReason, comment? }], repo?, context)` — bulk
closing, same real per-item `close_issue` logic including the standing Git #2167 rule: a
`not_planned` closure without a non-empty `comment` fails that item before any GitHub call, and
a supplied comment posts (tagged `[chat: <context>]`) before the issue closes. This never closes
an issue without a genuine, correctly-formed request to do so per item — the "you never close an
issue" convention (Shane's own decision, or an explicit instruction on his behalf, drives every
closure) is unchanged; this tool just executes many at once instead of one call each.
**Real verification (2026-09-12):** `npm run verify-batch` — creates real, clearly-labeled
disposable test issues (`TEST(#3709 batch verify — closed by script)`), runs a real batch of at
least 10 operations through each of the three tools (each batch deliberately including one item
targeting a nonexistent issue number, to exercise a real expected failure alongside the real
successes), confirms the response reports the exact real success/failure split with an accurate
reason for the failure, confirms the real GitHub state (`getParentIssue`/`listSubIssues` /
`get_board_status`) actually reflects every reported success, then closes every real test issue
it created (via `batch_close_issues` itself) so nothing disposable is left open on the board.
## Multi-repo `repo` parameter (Git #3580, Feature #3378)
Every tool above that actually looks up or writes an issue in a specific repo — `create_issue`,
`get_issue`, `update_issue`, `search_issues`, `close_issue`, `post_comment`, `list_comments`,
`add_sub_issue`, `remove_sub_issue`, `list_sub_issues`, `set_blocked_by`, `list_blocked_by`,
`move_to_status`, `get_board_status` — now accepts an **optional** `repo` argument, `"owner/repo"`
shape (e.g. `"shanemccaw/some-other-repo"`).
- **Omitted or empty → full backward compatibility.** Every existing call site keeps resolving to
exactly what it did before this: the `GITHUB_MCP_REPO` env var, defaulting to
`shanemccaw/Shane-McCaw-MSP`. `src/env.ts`'s `resolveRepo(repoArg)` is the one shared resolver
every tool calls.
- **Malformed `repo` is rejected before any GitHub call** — same `"must be owner/repo, got: ..."`
validation `githubRepo()`'s own env-var parsing already used, now shared via
`parseOwnerRepo()`/`resolveRepo()`.
- **`add_sub_issue`/`remove_sub_issue`/`set_blocked_by` apply `repo` to every issue number they
touch** (parent + child, or `number` + every blocker) — a single call targets one repo, not a
cross-repo pairing.
- **The Projects v2 board itself does not vary by `repo`.** `move_to_status` and
`get_board_status` use `repo` only to resolve *which repo's issue* to look up
(`repository(owner: $owner, name: $repo) { issue(number: $number) { ... } }`); `PROJECT_V2_ID`
and `PROJECT_V2_STATUS_FIELD_ID` stay the single shared constants they already were — this is
the real one-shared-board design the issue's own body calls out.
- **`list_board_column` deliberately does NOT get a `repo` param.** It queries the `ProjectV2` node
directly (`node(id: PROJECT_V2_ID) { ... }`), with no owner/repo issue lookup anywhere in its
query — there is no per-call repo dimension to thread through it. Adding a `repo` argument that
the handler never consults would be a fake, no-op parameter.
- **`server_status`, `github_whoami`, `get_recent_activity` also have no `repo` param**, for the
same reason — none of them address a specific repo at all.
- **Real verification (2026-09-10):** `get_issue({ number: 3580 })` with no `repo` returned
`shanemccaw/Shane-McCaw-MSP#3580` unchanged; the same call with `repo: "octocat/Hello-World"`
returned that genuinely different public repo's real issue #1 — the connector's classic
`GITHUB_MCP_PAT` isn't restricted to one repo, so this works today. A malformed `repo` string
(`"not-a-valid-repo-string"`) was rejected pre-flight before any GitHub call fired.
## Required `context` on every write (Git #3538)
Every write ever made through this server authenticates as the same server-side PAT, so on GitHub
it always shows as authored by `shanemccaw` regardless of which chat/session actually made the
change — there was no way to trace which chat did what. Every real write tool — `create_issue`,
`update_issue`, `add_sub_issue`, `remove_sub_issue`, `set_blocked_by`, `post_comment`,
`close_issue`, `move_to_status`, `batch_reparent_sub_issues`, `batch_move_to_status`,
`batch_close_issues` (Git #3709) — now requires a `context` string arg. Read-only tools
(`get_issue`, `search_issues`, `list_sub_issues`, `list_blocked_by`, `list_comments`,
`get_recent_activity`, `server_status`, `github_whoami`, `get_board_status`,
`list_board_column`) are untouched —
nothing to trace on a read.
- **Required, not optional.** A missing or empty `context` is rejected in the handler before any
GitHub API call fires — the same pre-flight-reject pattern #3394 already established for a
commentless NOT_PLANNED close. `src/tools/args.ts`'s `requireContext()` is the one shared check
every write tool calls first.
- **Exact shape — a real, reasoned call on the issue's own open question:** a free-text string, no
fixed vocabulary or required format. A build dispatched by BuildConsole has a real numeric
buildId (e.g. `"build-2207"`); a raw interactive chat has no such id and would be forced into a
shape that doesn't fit it. Free text — described in the tool schema as "a build id, a
chat/session label, or an Epic/issue number" — covers both without inventing a structure the
caller has to fake.
- **Recorded automatically, no new plumbing needed.** `context` is just another key in the tool's
own `args`, and every tool call's full `args` already flows into `github_mcp_activity.params` via
the existing `runTool()` / `recordActivity()` path (`src/tools/registry.ts`, `src/activity.ts`) —
so it's visible in `get_recent_activity` for every write with zero schema/table changes.
- **Visible on GitHub itself — the issue's other open question, also a real call:** applied to
*every* real comment body this server writes, not `post_comment` alone. `postIssueComment()`
calls from both `post_comment` and `close_issue`'s NOT_PLANNED comment are prefixed with a
visible `[chat: <context>]\n\n` tag (`tagCommentWithContext()` in `src/tools/args.ts`) — so the
trail is readable directly on the issue, not only in the local audit log.
`create_issue`/`update_issue`, the sub-issue/`blocked_by` tools, and `move_to_status`
deliberately do **not** get a visible tag: an issue's own `body` isn't a comment, and a board
move has no text field at all to embed one into — for those, `context` is traceable only through
`get_recent_activity`, which is the issue body's own observation about why this doesn't apply
uniformly.
- **Real verification (2026-09-10):** a `post_comment` call with `context` omitted was rejected
with `context is required...` before any GitHub request fired; the same call with
`context: "build-2207"` succeeded and posted
[a real comment on #3538 itself](https://github.com/shanemccaw/Shane-McCaw-MSP/issues/3538)
carrying the visible `[chat: build-2207]` tag — that comment doubles as this decision record.
## Verify
```
npm run smoke # boots the server, mints a token, asserts auth works, a bad/revoked token is
# rejected 401, and the PAT never appears in any response body
npm run verify-contents # Git #3697 — calls get_file_contents / list_directory / search_code
# against the REAL GitHub API with the real server-side PAT: real
# decoded file text, an explicit ref, the directory/file/".." refusals,
# both search backends, and that the PAT never appears in any result.
# Needs no database.
npm run verify-hierarchy # Git #3708 — real list_sub_issues/list_blocked_by per_page=100 read,
# real Epic/Feature classification and hierarchy-violation rejection,
# real overflow-title naming against the live #1788->#3706 pattern, and
# a real no-redirect-needed resolveOverflowParent read. Never performs a
# real add/create write. Needs no database.
npm run verify-batch # Git #3709 — real batch_reparent_sub_issues / batch_move_to_status /
# batch_close_issues runs, each ≥10 real operations including a real
# expected failure, against real disposable test issues this script
# creates and then closes itself. Confirms the per-item
# success/failure split is accurate and the real GitHub state reflects
# every reported success. Needs no database.
```
## Schema
`github_mcp_tokens` and `github_mcp_activity` — Drizzle definitions in
`lib/db/src/schema/index.ts`; created by
`lib/db/migrations/manual/2026-09-10-github-mcp-tokens-and-activity-3390.sql` (already applied to
local Postgres; a Replit/Staging line is on the #1630 checklist).
## Logging
`admin.mcp` channel for server activity, `audit` channel for the Recent-Activity trail — the
locked platform taxonomy (see repo `CLAUDE.md`).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues