Skip to main content
Glama
shanemccaw

@workspace/github-mcp-server

by shanemccaw

@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.

Related MCP server: GitHub MCP Server

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-hierarchylist_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 repositorytotal_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 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).

Related MCP Connectors

Related MCP Servers