Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
11c97fb
Add contributor mode: user-token auth and a notifications poller
The-Glassy Oct 17, 2026
d2787d9
Contributor mode: README section, example recipe, changelog fragment
The-Glassy Oct 17, 2026
bf83ada
Contributor mode: cover the new example recipe in the docs and policy…
The-Glassy Oct 17, 2026
f0daf7a
Contributor mode: rework how the notifications poller reads, keeps it…
Oct 18, 2026
55c024e
Contributor mode: read reviews from their last page, keep each cursor…
Oct 18, 2026
5c80bc6
Contributor mode: deliver review requests, look up checks on the user…
The-Glassy Oct 20, 2026
b682a6f
Contributor mode: look up a settled head's checks again only when it …
Oct 20, 2026
a3e9640
Contributor mode: leave a head's checks only once every run finished,…
Oct 20, 2026
8b11949
README: say a pass looks up 20 heads' checks in turns
Oct 20, 2026
23e3c56
Contributor mode: say when a head's checks are left alone, and test t…
Oct 20, 2026
05cd9db
Test that bad input from the host ends only what it should
Oct 27, 2026
50afa96
Use @animalabs/mcpl-core 0.4.0, and hear the errors it reports
Oct 27, 2026
6d72345
Test on Node 24 in CI too, and check the U+2028 answer is a result
Oct 27, 2026
94b403d
Merge main into the mcpl-core 0.4.0 raise
Oct 28, 2026
3dd5289
Merge main into the mcpl-core 0.4.0 raise
Oct 28, 2026
089ad1f
Merge upstream PR16: MCPL core 0.4 and connection errors
theaspirational Oct 29, 2026
27e4331
Merge upstream PR6: reconcile contributor mode with current polling a…
theaspirational Oct 29, 2026
30b5e01
Test integration boundaries and document shared-account editing limit…
theaspirational Oct 29, 2026
7286add
Launch synthetic lifecycle fixtures through the host Node path
theaspirational Oct 29, 2026
046668a
Retain notification progress when host delivery is unavailable
theaspirational Oct 29, 2026
36918c7
Keep truncated notification listing windows available for retry
theaspirational Oct 29, 2026
fe9dad2
Bound descending review pagination and stop on empty pages
theaspirational Oct 29, 2026
fd8bd81
Document notification retry and saturation limits
theaspirational Oct 29, 2026
ee9878f
Preserve first-page reviews after an empty tail page
theaspirational Oct 29, 2026
cd48ad3
Accept delivery through either granted event egress
theaspirational Oct 29, 2026
3553b44
Pause contributor polling without a host and bound refusal retries
theaspirational Oct 29, 2026
00eaaa2
Document contributor delivery, retry, and truncation contracts
theaspirational Oct 29, 2026
172aba1
Document the separate refused-check delivery schedule
theaspirational Oct 29, 2026
92d941a
Continue bounded review pagination through empty pages
theaspirational Oct 29, 2026
2d39f8b
Clarify thread retry scope and final sibling egress declines
theaspirational Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,9 @@ permissions:
contents: read

jobs:
# The whole suite runs once, at Node 22.12.0. Node 22.8.0 takes npm test's
# --experimental-test-isolation=none, but there every test file fails to
# load with 'Unknown file extension ".ts"'. The install and start check
# below runs on engines' floor, 22.0.0; on Node 24, a maintenance LTS line
# hosts still run; and on the newest Node release.
# The whole suite runs once, at Node 22.12.0, engines' floor. The install
# and start check below runs on Node 24, a maintenance LTS line hosts still
# run, and on the newest Node release.
typecheck-and-test:
name: Typecheck & Test (node 22.12.0)
runs-on: ubuntu-latest
Expand Down Expand Up @@ -52,7 +50,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node: ['22.0.0', '24', 'latest']
node: ['24', 'latest']

steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand Down
51 changes: 43 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
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.
sets the host grants (and can roll back). With the App, one server serves
one repository or several of one owner, so one process per owner; with a
user token ([contributor mode](#contributor-mode-user-token)), one serves
the repositories it works in, whoever owns them.

- **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
Expand Down Expand Up @@ -47,10 +49,10 @@ 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:
first. Node 22.12.0 or newer (nvm is fine), and one clone:

```sh
node --version # v22 or newer
node --version # v22.12.0 or newer
git clone <this repository> ~/github-mcpl
cd ~/github-mcpl
```
Expand Down Expand Up @@ -291,7 +293,7 @@ exception next, and the wake rules last:
| --- | --- | --- | --- |
| 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 |
| 3 | `github-other-bots` | `chat:from-bot`, without `chat:mention`, `github:pr=review_requested` or `github:ci=failure` | `skip` — dependabot does not get a turn, though a review asked of the agent on its pull request does |
| 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 |
Expand All @@ -303,6 +305,38 @@ 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.

## Contributor mode (user token)

For a resident that contributes to repositories it **cannot install the App on**, such as someone else's upstream: it wakes on reviews, comments and state changes on its own pull requests and on the threads it takes part in.

| Variable | Required | Default | Purpose |
|---|---|---|---|
| `GITHUB_TOKEN_FILE` | yes (instead of the App variables) | — | Path to a personal access token (classic), mode 600. See **The token** below. |
| `GITHUB_BOT_LOGIN` | no | the token's login | The token owner's login, spelled as GitHub spells it (no `[bot]`). From-self and mention detection use it. Unset, the server takes it from `GET /user` at start, and stops when GitHub doesn't answer. Set, it is checked against the token's own login when GitHub answers, and the server stops if they differ; when GitHub doesn't answer, it starts unchecked and logs a warning. |
| `GITHUB_REPOS` | yes | — | The repositories the agent works in, and the write allow-list. In this mode it may span several owners. Notifications from any other repository are dropped. Spell each as GitHub does: at start the server checks each against GitHub and stops on a different spelling, or a repository the token can't see. |
| `GITHUB_POLL_INTERVAL_MS` | no | `60000` | Notifications poll interval; values below `60000` are refused. GitHub's `X-Poll-Interval` is honoured when it asks for longer. |
| `GITHUB_STATE_FILE` | recommended | — | Keeps the notifications watermark, when GitHub last answered, each thread's cursor, the threads to read again and the dedup ring across restarts. Without it, every start begins as a first start does (below): activity from a longer downtime isn't delivered, and some from before the start can arrive twice. |

Setting both `GITHUB_TOKEN_FILE` and the App variables is a startup error, and so is `GITHUB_APP_INSTALLATION_ID`. `GITHUB_WEBHOOK_PORT` is refused in this mode: GitHub doesn't send webhooks for repositories you don't administer.

**The token.** GitHub serves the notifications endpoints to a personal access token (classic) only; a fine-grained token can't read them. Give it the narrowest scopes that work: `notifications`, plus `public_repo` when the repositories are public. `repo` is full read and write access to every private repository the account can reach, so use it only when a private repository needs it.

**The account.** Use an account the agent alone posts from, such as a machine account. GitHub shows the agent's comments, reviews and pull requests as that account's, and this server treats everything that account does as the agent's own: its own events are tagged from-self and don't wake it, and the comment tools let it edit and delete that account's comments. This is account ownership, not proof that this process wrote a comment. A human's later edits are not protected by a version check: an edit or sticky update can overwrite them, and rollback can restore an older body or delete an agent-created comment after a human changed it. Do not share the posting account or treat rollback as safe concurrent editing.

**How events get in.** Notification polling pauses without API calls or state changes until a connected MCPL host has at least one granted event egress, and pauses again if that ceases to be true. The server lists `GET /notifications?all=true&participating=true&since=…` from where the last listing got to, with `If-Modified-Since` after a complete listing, so an idle inbox can return a `304` that doesn't count against the rate limit. Truncated listings instead retain their window for an unconditional retry. Read and unread threads are listed alike, and nothing is marked read. For each listed issue or pull request in `GITHUB_REPOS`, it reads what is new since its last read: comments, a pull request's review comments and submitted reviews, and its state. Each becomes the same `push/event` and `channels/incoming`, with the same tags and metadata, as in App mode. A thread opened since the last pass arrives as opened, a close or merge as closed (a merge tagged `github:pr=merged` too), with whoever closed or merged it as the actor, and an edit to an issue as edited, attributed to whoever opened it, since GitHub's row doesn't say who edited it; a thread opened and closed between two passes arrives as closed. An edit to a closed issue or pull request arrives as nothing. A reopened issue arrives as edited, as with App mode's repository poll, and a reopened pull request as nothing, as with a webhook delivery. A listed pull request whose notification gives `review_requested` as its reason arrives as `pull.review_requested` too, with the pull request's author as the actor, since the notification doesn't say who asked. It arrives once for each update of the notification listed with that reason, so if GitHub keeps the reason while the thread moves on, a later update, a comment for one, brings it again. Each pass also looks up the checks on the heads of the open pull requests the token's user authored in `GITHUB_REPOS`, the 100 most recently updated, 20 a pass and in turns past that, and a head whose checks have all finished arrives as `checks.completed`, as in App mode, once for each conclusion and set of failed jobs. Once a head's result has arrived with every run finished, it isn't looked up again until its pull request is updated or ten minutes have passed since its head was read, so a re-run on the same head is seen on the first look after that, a pass or so past ten minutes. A head with no check runs two minutes after it was read is left the same way, so runs created later than that arrive with the next look. A failure that arrives while other runs go is looked up each pass until they finish. The tools' `search_issues` searches one repository in `GITHUB_REPOS`: it drops `repo:`, `user:`, `org:` and `owner:` qualifiers from the query, and leaves out, and counts, any result on the page from another repository.

**What a pass costs.** The listing, one request a page of 50 threads, free when it's a `304`. One search for the token user's open pull requests, on the search API's own limit of 30 a minute, which the tools' `search_issues` shares, on every pass whose thread reads met no rate limit, a `304`'s included. Then for each of them not left as above, up to 20 a pass and in turns past that, one check-runs request, more past 100 runs, and one more for its head when it moved since the last pass or that read is ten minutes old. With 20 open pull requests whose checks have finished and nothing moving, that's about 240 requests an hour, besides the 60 searches. Then, for each thread it lists or that waits, two requests for an issue (its row and its comments) and four for a pull request (its row, its comments, its review comments and its reviews), each list taking more when its new rows run past a page, and the reviews one more once a pull request has more than 100 reviews. The first time the server handles an event on a thread after it starts, its event pipeline also reads the thread itself (for a pull request, with its head's check runs), its comments and a pull request's review threads, to learn whether the agent took part there, as in App mode. The agent's own tool calls draw on the same 5,000 an hour.

**When a thread can't be read or delivered.** A thread whose read or thread-event handoff fails (neither granted egress accepts the thread event) waits on a retry list that keeps its place, and is read first on each later eligible pass whose listing gets through, for up to five failed passes in a row. A refused `checks.completed` instead stays on its head/TTL schedule, with no retry limit and no drop notice. Acceptance by either `channels/incoming` or `push/event` counts as delivery; a decline by the sibling egress is logged and treated as final, so the message is not re-sent on that egress. Failed handoffs hold the thread's cursor, not the global listing watermark or conditional header. At the retry limit the server stops trying that notification update and attempts one `thread.read_failed` notice, naming the cause, tagged `chat:mention`; a thread gone or out of the token's reach gets that at once. A retained terminal retry record prevents the same thread/update from starting another retry cycle or repeating the notice, including across restart and if the notice itself was refused. A strictly newer notification update reopens the thread from its retained cursor. GitHub's rate limit counts no pass against a thread: it ends the pass's reads, and the rest wait for the next pass. A thread with more new rows in one of its lists (comments, review comments or reviews) than one read takes, 2,000 a list, is read from its latest update on, and its `thread.read_failed` event says its backlog was skipped. A listing that runs past 20 pages (1,000 threads) delivers what it listed, retains the original window and reach-back anchor, clears the conditional header, and logs an error before retrying unconditionally on the next eligible pass. A persistently saturated window can repeat without reaching its tail; its repeated listing pages and thread reads can exhaust the token's rate limit and degrade tools that share it.

**Where delivery begins.** With no watermark stored, the first start or pass with an eligible host begins one poll interval before that point and logs the instant to stderr. No initial watermark is written while polling is paused; an existing watermark survives the pause. GitHub writes a notification some seconds after the activity it reports (about 20 s on one account in October 2026). So a thread read for the first time is read from five minutes before GitHub's previous answer to a listing, or, at a first start, five minutes before that instant. Comments and reviews from those five minutes arrive, and so does an opening or a close; anything older on that thread doesn't. A thread that opened in those five minutes arrives as opened, with its text as it is now. Otherwise an edit on a thread read for the first time arrives as nothing, since with nothing to compare it with, an edit can't be told from a comment's bump: an edit that mentions the agent on an older issue it hasn't read before doesn't reach it.

**What it can't see.** Threads you aren't part of, pushes, and the checks on pull requests the token's user didn't open. GitHub lists a pull request's thread when someone pushes to it, but none of what this mode reads of a thread shows a push, so it delivers nothing for one. The agent learns of new commits from what comes with them, from a check result, or by reading the pull request. An edit to an open pull request arrives as nothing, as with a webhook delivery. A mention arrives with the comment, review or opening that carries it.

**Security.** A user token can do whatever its user can, wherever the user can. The tools' allow-list (`GITHUB_REPOS`) and the absence of merge still apply, but they're the server's limits, not GitHub's. An `APPROVE` through `github.review` is the user's own approval, and counts toward branch protection wherever the user has write access; the shipped recipe doesn't enable that feature set.

The recipe is App mode's contributor one, [`examples/recipe-contributor.json`](examples/recipe-contributor.json): its wake policies serve both modes, and the launcher reads this mode's variables from the same environment file.

## Plain-MCP mode

When the host does not advertise `experimental.mcpl` in `initialize`, the
Expand Down Expand Up @@ -380,6 +414,7 @@ contract and is listed here in full.
| `github:review-comment` | an inline comment on a diff |
| `github:ci=` | `success`, `failure` |
| `github:push` | a push with no thread attached |
| `github:read-failed` | contributor mode stopped reading a thread until it next moves, or skipped its backlog; the event also carries `chat:mention`, since the server addresses it to the agent |
| `github:actor=maintainer` | the actor is in `GITHUB_MAINTAINERS` |

**Metadata.** Booleans first — these are what `metadataTrue` matches:
Expand Down Expand Up @@ -461,9 +496,9 @@ 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.
Node 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 |
| --- | --- | --- |
Expand Down
18 changes: 18 additions & 0 deletions changelog.d/contributor-delivery.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
- Submitted-review reads continue through empty pages rather than skipping
earlier pages, with an explicit read cap even for an all-empty walk.
- Contributor notification passes pause without API or state changes when
no connected MCPL host has a granted event egress. An eligible host resumes
polling; existing cursors survive the pause.
- Refused thread-event handoffs retain their cursor without freezing the global
listing watermark or conditional header. After five failed passes, a
persisted terminal retry record prevents the same notification update from
restarting the cycle or repeating its drop notice, even across restart.
A refused `checks.completed` stays on its head/TTL schedule, with no retry
limit and no drop notice.
- Acceptance by either granted event egress counts as delivery. Declining a
push no longer makes the poller resend an accepted channel message (or the
reverse); the sibling egress's decline is warned about and treated as final,
so the message is not re-sent on that egress.
- Contributor documentation now describes retained truncated windows and their
unconditional retries, including the shared-token rate-limit cost when a
window remains saturated.
17 changes: 17 additions & 0 deletions changelog.d/contributor-mode.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
- **Contributor mode** (#5): with `GITHUB_TOKEN_FILE` instead of the App variables, the server authenticates
with a personal access token (classic) and gets its events from `GET /notifications` (the threads its user
takes part in), so a resident can follow its own pull requests and threads in repositories where the App
isn't installed. Notifications from repositories outside `GITHUB_REPOS` are dropped, and `GITHUB_REPOS`
may span several owners. A first start delivers what GitHub notified from one poll interval before it. A
thread the server stops reading after repeated failures gets one `thread.read_failed` event on its channel
(tags `github:read-failed` and `chat:mention`). The webhook listener is refused in this mode. A
notification whose reason is `review_requested` arrives as `pull.review_requested`, and the server looks
up the checks on the heads of the token user's open pull requests, the 100 most recently updated, so a
settled result arrives as `checks.completed`, as in App mode. `GITHUB_BOT_LOGIN` is optional in this mode: unset, it is the token's
login.
- **`search_issues`**, in both modes, drops `user:`, `org:` and `owner:` qualifiers from a query as it already
dropped `repo:`, since GitHub widens a search to each of them, and leaves out any result from another
repository on the page, saying how many.
- **A polled merge** is tagged `github:pr=merged` beside `github:pr=closed`, as a webhook delivery's is, in App
mode's repository poll too: the issues endpoint's row says whether a pull request was merged. Otherwise App
mode is unchanged.
2 changes: 2 additions & 0 deletions changelog.d/mcpl-core-0.4.breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- **Operators:** github-mcpl needs Node 22.12.0 or later, the floor of
`@animalabs/mcpl-core` 0.4.0, which it now uses.
9 changes: 9 additions & 0 deletions changelog.d/mcpl-core-0.4.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
- A host message whose JSON string holds U+2028 or U+2029 no longer ends the
process: on Node 24 the reader split the line there.
- A malformed line from the host is logged, and its connection stays open. A
stream error, such as a reset TCP connection, is logged and closes that
connection: over TCP the next host can then connect, and under `--stdio` the
process shuts down as it does when the host closes stdin. Before, each of
these ended the process with an unheard error.
- An error the TCP server reports while a host is connected, such as a failed
accept of another connection, is logged.
2 changes: 1 addition & 1 deletion deploy/github-mcpl.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
# in src/types.ts is what validates them.

# --- runtime ----------------------------------------------------------------
# The absolute path of Node 22+. README step 7 appends it with
# The absolute path of Node 22.12.0 or newer. README step 7 appends it with
# `command -v node`, because a host started by systemd does not see nvm's PATH.
#GITHUB_MCPL_NODE=

Expand Down
Loading
Loading