An MCPL connector for GitHub. A repository's issues, pull requests, comments, reviews and check results reach an agent as push events and as channels, and the agent acts back through tools grouped into feature sets the host grants (and can roll back). One server serves one repository or several of one owner; one process per owner.
- Wake, not poll-and-hope. An issue, a comment, a pull request, a review, an inline review comment or a failing check suite becomes one normalized event with tags and metadata, so the host's wake policy decides what is worth an inference and what is not.
- A channel per thread. Every issue and pull request is an MCPL channel
(
github:<owner>/<name>#<number>);channels/publishanswers in it, and a reply withthreadId: "rt:<id>"lands inside that review conversation rather than at the bottom of the page. - Feature sets with rollback.
github.commentsis checkpointed: a rollback deletes the comments created after the checkpoint and restores the bodies of the ones edited after it. Nothing GitHub cannot undo is checkpointed, and nothing is granted that the host did not grant. - No merge, ever. Not in v1, not behind a flag. The base branch of any pull request the server opens or retargets is checked against an allow-list.
Built on @animalabs/mcpl-core; sibling of
slack-mcpl and telegram-mcpl.
Status: v1, run end to end against a live GitHub App. The server answers a host's handshake in MCPL or in plain MCP, applies the grant it is given (absence is denial), serves the twenty-two tools of the five tool feature sets, registers a channel per thread and publishes into one, checkpoints every comment write and rolls it back, and turns a webhook delivery or a poll pass into a
push/eventand achannels/incomingwith tags and wake metadata. Both ingress paths have been exercised on a private sandbox repository: polling, and webhooks over a public tunnel, plus a soak across an installation-token expiry. docs/e2e-report.md has every scenario, its timings and every GitHub write the runs made.
Everything below runs as the user the agent runs as — on a typical agent
host that is ubuntu — and needs no sudo. The server is a child process
of the host, so it lives in the agent's home, beside the agent's other
logins. docs/deploy.md says what that
does and does not protect.
Everything after this runs commands out of this repository — step 3 uses a script from it before GitHub has even created the App — so the checkout comes first. Node 22 or newer (nvm is fine), and one clone:
node --version # v22 or newer
git clone <this repository> ~/github-mcpl
cd ~/github-mcplStep 8 builds this same checkout — do not clone it a second time;
git -C ~/github-mcpl pull is how you update it later.
Everything below writes into ~/.config/github-mcpl. Create it now, mode
700, so that no key is ever written somewhere looser and chmodded afterwards:
install -d -m 700 ~/.config/github-mcplapp-manifest.json is a GitHub App manifest: it names
the five permissions the tools need and the eight events the ingress
understands, so you do not have to tick 40 checkboxes correctly. GitHub's
manifest flow is three steps — post the manifest, catch the code GitHub sends
back, exchange the code for the App's credentials — and the third is the one
that hands you the private key and the webhook secret without a download
folder ever touching them.
a. Post the manifest. Save this beside the manifest as create-app.html,
open it in a browser, paste app-manifest.json into the field and submit.
(For an organisation, change the action to
https://github.com/organizations/<org>/settings/apps/new.)
<form action="https://github.com/settings/apps/new" method="post">
<textarea name="manifest" rows="20" cols="100"></textarea>
<button type="submit">Create the App</button>
</form>Name it on the GitHub page that follows: rayforce-clone is the proposal for
the maintainer's clone. The name becomes the bot login with [bot] appended
(rayforce-clone[bot]), which is what GITHUB_BOT_LOGIN must hold.
b. Catch the code. GitHub creates the App and redirects your browser to
the manifest's redirect_url with ?code=… — a temporary code, good for one
hour, and the only thing that can fetch the App's credentials. The manifest
points it at http://localhost:8723/manifest-callback, so run this one-off
listener on the machine with the browser before you submit the form:
cd ~/github-mcpl # the checkout from step 1
CODE=$(node deploy/capture-manifest-code.mjs) # then go and submit the formdeploy/capture-manifest-code.mjs listens
on loopback for that one redirect, prints the code on stdout — so the command
substitution above captures it — and exits. Everything else it says goes to
stderr, where you can see it. (If you missed the redirect, or your browser is
not on this machine, delete the half-made App and start again, or take path B
below: the code cannot be re-issued.)
This redirect is not OAuth. It is how the manifest flow returns the
registration result; the App has no user-authorization flow at all
(request_oauth_on_install: false, no callback_urls), and it only ever
acts as itself.
c. Exchange the code for the credentials. POST /app-manifests/<code>/conversions needs no authentication — the code is the
credential — and answers with the App id, the PEM, the webhook secret and
some OAuth fields this App does not use:
cd ~/.config/github-mcpl
# Create all three files empty first, mode 600 from the first byte: a
# redirect into a file that already exists keeps that file's mode.
for f in conversion.json app-private-key.pem webhook-secret; do
install -m 600 /dev/null "$f"
done
curl -sS -X POST -H 'Accept: application/vnd.github+json' \
"https://api.github.com/app-manifests/$CODE/conversions" \
> conversion.json
# Split it into the two files the server reads, and nothing else.
jq -r .pem conversion.json > app-private-key.pem
jq -r .webhook_secret conversion.json > webhook-secret
# The two values that are not secret, for the environment file in step 7.
jq -r '"GITHUB_APP_ID=\(.id)\nGITHUB_BOT_LOGIN=\(.slug)[bot]"' conversion.json
rm -f conversion.json # `shred -u`, where you have itCreating the files before anything writes to them is what keeps the key at
mode 600 for its whole life: install sets the mode on an empty file, and
the later > only truncates and refills it. Never write a key somewhere
looser and move it afterwards. Do not keep conversion.json, and do not
paste any of it into a terminal that is being logged: it holds the private
key. The client_id and client_secret in the response belong to the OAuth
flow this App does not use — there is nothing to store.
Path B, by hand. Without the manifest flow: create the App with exactly the permissions and events the manifest lists (Security says why each one is there), then Private keys → Generate a private key (a
.pemdownloads once), then Webhook → Secret, and generate one withopenssl rand -hex 32. Make the two files the same way — empty, mode 600, and only where missing — and fill them in place, so the key is never readable by anyone else even for a moment:cd ~/.config/github-mcpl for f in app-private-key.pem webhook-secret; do [ -e "$f" ] || install -m 600 /dev/null "$f" done cat ~/Downloads/<app>.private-key.pem > app-private-key.pem cat > webhook-secret # paste the secret, then Ctrl-D shred -u ~/Downloads/<app>.private-key.pem # or rmWhen the key downloaded on another machine (a laptop), fill the file over ssh, which writes into the existing file and keeps its mode:
ssh <host> 'cat > ~/.config/github-mcpl/app-private-key.pem' < <app>.private-key.pem. The App id is on the App's settings page, andGITHUB_BOT_LOGINis its slug with[bot]appended.
The App's Webhook → URL has to be a public HTTPS address that reaches this host. Set it now if you have one; otherwise come back after choosing how deliveries reach the host. If you mean to run polling-only, uncheck Active instead, so GitHub stops queueing deliveries nobody collects.
X-Hub-Signature-256 is verified with the webhook secret before a delivery
is parsed, so that secret is the only thing standing between your agent and
anyone who learns the URL.
Install App → your account or organisation → Only select repositories →
pick the ones GITHUB_REPOS will name, and no more. The allow-list in the
environment is the second fence; the installation is the first. Start with
one repository: the agent's prompt and wake policies are easier to get right
for one, and a second can be added to GITHUB_REPOS later without touching
the App.
ls -l ~/.config/github-mcpl/
# -rw------- 1 ubuntu ubuntu … app-private-key.pem
# -rw------- 1 ubuntu ubuntu … webhook-secretMode 600 is enforced, not advised: readConfig stats both files and refuses
to start while either carries a group or other bit, naming the file and its
mode (never its contents).
readConfig (in src/types.ts) validates all of it and
throws listing every offending variable at once. Secret material is read from
files, never from a variable, and never appears in an error or a log line.
The launcher deploy/github-mcpl loads one file,
~/.config/github-mcpl/github-mcpl.env, in both deployment shapes, and
neither starts without it. Start from the example, record the Node to use,
and fill in every REPLACE_WITH_ value:
cd ~/github-mcpl
install -m 600 deploy/github-mcpl.env.example ~/.config/github-mcpl/github-mcpl.env
echo "GITHUB_MCPL_NODE=$(command -v node)" >> ~/.config/github-mcpl/github-mcpl.env
"${EDITOR:-vi}" ~/.config/github-mcpl/github-mcpl.envGITHUB_MCPL_NODE is the launcher's, not the server's: a host started by
systemd does not have nvm's directories on its PATH, so the launcher is
told where Node is instead of looking.
| Variable | Required | Default | Meaning |
|---|---|---|---|
GITHUB_APP_ID |
yes | — | Numeric App id, from the App's settings page. |
GITHUB_APP_PRIVATE_KEY_FILE |
yes | — | Path to the PEM, mode 600. |
GITHUB_APP_INSTALLATION_ID |
no | discovered from GITHUB_REPOS |
Installation to authenticate as. |
GITHUB_WEBHOOK_SECRET_FILE |
when GITHUB_WEBHOOK_PORT is set |
— | Path to the webhook secret, mode 600. |
GITHUB_WEBHOOK_PORT |
no | unset = listener off | TCP port of the webhook listener (bind it to loopback, terminate TLS in front). |
GITHUB_WEBHOOK_PATH |
no | /github/webhook |
Path the listener answers on. |
GITHUB_POLL_INTERVAL_MS |
no | 0 (off) |
Polling fallback: issues and PRs, issue comments and review comments updated since each list's cursor, plus the checks on the heads of the open PRs seen. 0 is off; a non-zero value below 60000 is refused. Partial — it cannot see a submitted review, a review request or a push (what polling misses) — and it is not meant to run alongside the webhook. |
GITHUB_REPOS |
yes | — | Comma-separated owner/name allow-list; a write anywhere else is refused by name. Several entries must share one owner (one App installation, one token); a second owner is a second server process. With several, every tool call names its repository. |
GITHUB_PR_BASE_ALLOW |
no | empty = no PR target allowed | Branches a pull request may target or be retargeted to. A plain branch applies to every repository, owner/name:branch to that one: anima-research/rayforce:dev,main. |
GITHUB_STATE_FILE |
no | unset = in-memory only | Durable JSON: checkpoint lineage, rollback bookkeeping, and the polling state: a cursor for each repository and list (pollCursors), lastPollAt where a list with none starts, and the rows announced as new (pollNew). A list with a cursor of its own starts from it, not from lastPollAt. |
GITHUB_BOT_LOGIN |
yes | — | The App's bot login, e.g. rayforce-clone[bot]. From-self and mention detection depend on it. |
GITHUB_MAINTAINERS |
no | empty | Comma-separated logins marked isMaintainer in metadata and tagged github:actor=maintainer. |
cd ~/github-mcpl # the checkout from step 1
npm ci
npm run build # tsc into dist/
# Prove it before the host tries. Expect the startup line on stderr, nothing
# at all on stdout (that is the JSON-RPC channel), and exit 0 at end of input.
deploy/github-mcpl --stdio < /dev/null
# [github-mcpl] info github-mcpl starting {"repos":["owner/repo"], …, "transport":"stdio"}The host starts it for you from then on. Copy
examples/recipe-maintainer-clone.json
(maintainer's clone, full tool surface) or
examples/recipe-contributor.json
(contributor-side resident: mentions, review requests and the threads it has
taken part in)
into your host recipe: both carry the mcpServers.github entry — the
launcher's absolute path and --stdio, nothing else, since every value comes
from the environment file — and a modules.wake policy set sized for
GitHub's event volume. Change /home/ubuntu in command if the agent's home
is elsewhere.
For a standalone daemon under systemd, getting webhook deliveries in, polling only, who can read the key, and log hygiene, continue in docs/deploy.md.
A rayforce-sized repository produces 60–115 events a day, and one wake costs roughly 100k input tokens. A policy set that wakes on everything is about 10M tokens a day of ambient chatter. Both example recipes therefore skip by default and wake only on what is addressed to the agent:
The order is the design, because the host matches first-rule-wins and
metadataTrue is an OR-list: one rule that wakes on isOwnThread placed
above the CI-success skip wakes the agent on every green build of its own
pull request. Both recipes therefore read exclusions first, the one
exception next, and the wake rules last:
| # | Rule | Matches | Behavior |
|---|---|---|---|
| 1 | github-self |
fromSelf |
skip — otherwise the agent answers itself |
| 2 | github-machine-noise |
github:push, github:ci=success |
skip |
| 3 | github-other-bots |
chat:from-bot, without chat:mention or github:ci=failure |
skip — dependabot does not get a turn |
| 4 | github-ci-failure-own-thread |
github:ci=failure and isOwnThread |
always — the exception rules 2 and 3 leave room for |
| 5 | github-direct-address |
mentioned, isReviewRequested, isOwnThread, isReplyToSelf |
always |
| 6 | github-new-work (maintainer clone only) |
github:issue=opened, github:pr=opened |
debounce: 60000 — one wake once the burst settles |
| 7 | github-ambient |
anything else from github |
skip, and default is skip too |
test/wp8-policies.test.ts runs both recipes through connectome-host's own
EventGate matcher and asserts that ordering: a green build on the agent's
own pull request is skipped, a broken one wakes it, a bot's pull request
neither wakes nor debounces, a human mention wakes it, and no rule is left
unreachable by an earlier one.
When the host does not advertise experimental.mcpl in initialize, the
server serves tools only: every feature set's tools are listed and
callable, and there are no push events, no channels and no rollback. That is
the whole difference — the same binary, the same environment, no flag. It
makes github-mcpl usable from a stock MCP client, at the cost of the part
that makes it interesting (nothing wakes anyone).
The host grants feature sets; absence is denial (MCPL §5.3). Until the grant
arrives nothing is registered, nothing is listed and no tool runs. Under MCPL
tools/list offers exactly the granted surface, so a listing never advertises
a call that would be refused; a plain-MCP client sees every tool, because there
is no grant on that connection to narrow it by.
github.events needs only pushEvents: a host with no channel support at all
still wakes through push/event, and a host that grants the channel paths but
not pushEvents still gets its channels/incoming deliveries. Each channel
surface is checked at its own call — channels.register to announce a channel,
channels.incoming to deliver to one, channels.publish to write back,
channels.lifecycle to open and close.
| Feature set | Tools | Rollback |
|---|---|---|
github.events |
— (push events and channels/incoming delivery) |
no |
github.read |
get_issue, get_pull_request, list_comments, list_review_threads, list_pull_request_files, get_check_runs, search_issues |
no |
github.comments |
comment, reply_in_thread, edit_own_comment, delete_own_comment, upsert_sticky_comment |
yes |
github.review |
create_review, resolve_thread, unresolve_thread |
no (GitHub cannot delete a review) |
github.issues |
create_issue, edit_issue, close_issue, reopen_issue |
no |
github.pr |
create_pull_request, edit_pull_request, request_review |
no |
Notes worth knowing before you grant them:
upsert_sticky_comment(thread, marker, body)finds the agent's own comment containing an HTML marker and edits it in place, else creates it. One running status comment instead of a tail of them — the same thingrayforce-auditdoes with<!-- rayforce-audit-failure-comment -->.create_pull_requestand a base change inedit_pull_requestrefuse a base outsideGITHUB_PR_BASE_ALLOW. Empty means no pull request may be opened.github.prhas no merge tool and no branch deletion. There is nothing to disable; the capability does not exist.- Every write tool refuses a repository outside
GITHUB_REPOS, by name, before the call reaches GitHub.
Every event carries RFC-001 core tags, the github: namespace, and a flat
metadata record the host matches metadataTrue against. The per-event detail
lands in docs/events.md with work package .4; the vocabulary itself is the
contract and is listed here in full.
Core tags (producer github):
| Tag | When |
|---|---|
chat:mention |
the body mentions @<GITHUB_BOT_LOGIN>, on an event that brings that body (opened, edited, a comment, a review); never on a close, reopen or push |
chat:reply |
the event is on a thread the bot authored or commented in, or replies to the bot's comment |
chat:ambient |
neither of the above |
chat:from-human / chat:from-bot / chat:from-self |
who acted |
chat:thread |
a review comment (it lives in a review conversation) |
chat:edited, chat:deleted |
an edit or a delete |
chat:has-link |
the body carries a URL |
chat:dm is never emitted: GitHub has no direct message.
github: tags:
| Tag | Values |
|---|---|
github:issue= |
opened, edited, closed, reopened |
github:pr= |
opened, synchronize, ready_for_review, review_requested, closed, merged |
github:review= |
approved, changes_requested, commented |
github:review-comment |
an inline comment on a diff |
github:ci= |
success, failure |
github:push |
a push with no thread attached |
github:actor=maintainer |
the actor is in GITHUB_MAINTAINERS |
Metadata. Booleans first — these are what metadataTrue matches:
| Key | Meaning |
|---|---|
mentioned |
the body mentions the bot |
isOwnThread |
the bot authored the thread or has commented in it |
isReplyToSelf |
the event replies to a comment the bot wrote: a review comment whose in_reply_to_id is one of the bot's, or a top-level comment that immediately follows one of the bot's on the thread (amendment 2b, turn order). Quoting the bot's text does not count in v1 — see docs/contract-notes.md. |
isReviewRequested |
the bot was asked for a review |
isCiFailure |
a check suite on this thread's head failed |
fromSelf |
the bot itself acted (skip this first in every policy set) |
isMaintainer |
the actor is in GITHUB_MAINTAINERS |
and the fields repo (owner/name), number, kind (issue | pull),
actor, sha, htmlUrl. Every string is bounded; raw webhook payloads never
reach metadata and never reach the model.
- Secrets live in files, not in the environment and not in the recipe. The
App private key and the webhook secret are read from paths named by
GITHUB_APP_PRIVATE_KEY_FILEandGITHUB_WEBHOOK_SECRET_FILE, both enforced at mode 600. A value in the environment is visible to every child process and tosystemctl show; a value in a recipe ends up in git. - The key is as private as the agent's user. The server runs as the
agent's own user, so the agent can read the App private key, as it can its
other logins. A separate service user would not change that on a host where
the agent has passwordless
sudo, which is the usual agent host. What bounds a leaked key is the rest of this list: the repository allow-list, the permissions below, no merge. Generating a new key in the App's settings and deleting the old one revokes it. docs/deploy.md has the cases where isolation is real. - An allow-list of repositories.
GITHUB_REPOSis required. Events from elsewhere are dropped before they are rendered; writes elsewhere are refused by name. Install the App only on the repositories it names: the allow-list is a second fence, not the first. Several repositories share one agent, one context and one prompt, so keep them to repositories whose rules the prompt can state together. - A base allow-list.
GITHUB_PR_BASE_ALLOWis empty by default, which means no pull request may be opened or retargeted at all. For rayforce it isdev, so the release branch is unreachable even by mistake. - No merge. No tool merges, closes a release branch or deletes a branch.
- Bodies from strangers are data, not instructions. An issue body, a comment, a review and a check's output are text written by other people, reaching a model that holds a write token. The server forwards them as content and nothing more — it never acts on them — but the agent's own prompt has to say so, and both example recipes do. Treat anyone who can open an issue on the repository as someone who can type into your agent's context.
- The permissions are the minimum the tools need:
issues: write(issue tools and issue comments),pull_requests: write(PR tools, reviews, review-comment replies, review-thread resolution),contents: read(file lists and refs; never write),checks: read(check runs and conclusions),metadata: read(mandatory for every App). Noadministration, noworkflows, nomembers, no OAuth user flow — the App acts only as itself.
- One owner per process. Repositories of two owners are two App installations and two tokens; run a process per owner.
- No attachments.
channels/publishwith an attachment is refused with a clear error; images in an incoming body stay as the links GitHub put there. - No CI log fetch. A failing check arrives with its job names and URLs; the agent reads the log by following the URL as a human would, or does without. (RFC-005 references for logs and diffs are on the roadmap, not here.)
- No merge, no branch deletion, no release-branch write.
- Polling is no faster than 60 s and cannot see pushes that touch no thread.
- No reactions, no labels, no assignees, no projects, no releases.
npm ci
npm run typecheck
npm test
npm run build # tsc into dist/Node 22 (npm test runs on 22.12 or newer), ESM, TypeScript. Stdout
carries only JSON-RPC: every diagnostic goes through the Logger, which
writes to stderr. No test in this package reaches the network.
| File | Work package | Role |
|---|---|---|
types.ts |
.1 | Every shared type and constant, the channel and thread id helpers, readConfig, Logger. Read by all, edited by nobody else. |
grant.ts, errors.ts |
.1 | Capability-grant state, uses validation, the §6.7 degradation receipt, the §6.6 error codes. |
github-adapter.ts |
.2 | GitHubAdapter implements GitHubApi over octokit: App auth, installation-token refresh, rate-limit backoff. The only file that knows octokit exists. |
webhook.ts, poller.ts |
.3 | Ingress: signature-checked deliveries and repository since-polls become NormalizedEvents; dedup; allow-list. |
tags.ts, content.ts |
.4 | Pure functions: tagsFor, renderEvent, renderThread, renderPullRequestSummary, renderChecks, all bounded by LIMITS. |
channels.ts |
.5 | Channel registry and descriptors, lazy registration, channels/incoming, channels/publish, typing as a no-op. |
feature-sets.ts, tools-*.ts |
.6 | Feature-set declarations and the read, issues, pr and review tools. |
tools-comments.ts, state.ts |
.7 | Comment tools including the sticky upsert; checkpoint lineage and rollback. |
server.ts, index.ts |
.9 | Handshake, featureSets/update, tool routing, the event pipeline, plain-MCP mode. |
Changelog entries land as fragments in changelog.d/.
Contract decisions taken where TASK-SRHKG.1's contract left a type
under-specified are in docs/contract-notes.md. A
target repository's own operating notes — branch model, commit style, CI
gates, review rounds, issue style — belong with the host's deployment, beside
its recipe, not in this repository: the connector enforces none of them, and
the agent's prompt is where they take effect.