issue-triage — Claude Code skill: what it does, how to install
Triages incoming GitHub issues and external pull requests, scores priority, and sweeps valid tasks onto the Servers V2 project board.
npx skills add modelcontextprotocol/servers --skill issue-triage -a claude-codeWhat issue-triage does
The skill handles intake sorting for modelcontextprotocol/servers. It inspects new GitHub issues and filters out misdirected submissions, such as new server listings or SDK questions. Those get closed as not planned with pre-defined canned responses. Valid bug reports and feature requests move to the Incoming column on project board #43 with a scored priority.
Board mutations rely on the board-ops skill, which must be loaded before any write takes place. API operations run via GitHub CLI with an enforced sleep 8 delay between calls. That prevents tripping secondary rate limits. If a call fails with a 403, the loop stops immediately instead of leaving a half-applied batch.
External pull request manifests are stored strictly in a temporary mktemp -d directory so board dumps never stay in the working tree. If an issue is flagged as a vulnerability report, triage halts on the spot to avoid restating security details publicly.
What it helps with
- sweep valid open issues onto the Servers V2 project board
- close catalog requests and spam with canned responses
- score issue priority and post evaluation comments
- identify duplicate issues and delete their board cards
- generate outside PR manifests for maintainer sign-off
Example prompts for the agent
- Triage new issues and sweep them onto the board
- Triage the PRs and prepare a manifest for review
- Score the priority of issue #4876 using the rubric
How to install issue-triage
1Skills CLI (Vercel)
npx skills add modelcontextprotocol/servers --skill issue-triage -a claude-code2Manually: put the files in place
~/.claude/skills/issue-triage/SKILL.mdCopy the whole skill folder; for one project use .claude/skills/issue-triage/
1Skills CLI (Vercel)
npx skills add modelcontextprotocol/servers --skill issue-triage -a codex2Manually: put the files in place
.agents/skills/issue-triage/SKILL.mdFor all projects use ~/.agents/skills/
1Skills CLI (Vercel)
npx skills add modelcontextprotocol/servers --skill issue-triage -a cursor2Manually: put the files in place
.cursor/skills/issue-triage/SKILL.mdCursor also reads .agents/skills/ and .claude/skills/
1GitHub CLI
gh skill install modelcontextprotocol/servers issue-triage2Manually: put the files in place
.github/skills/issue-triage/SKILL.md1Skills CLI (Vercel)
npx skills add modelcontextprotocol/servers --skill issue-triage -a gemini-cli2Platform CLI
gemini skills install https://github.com/modelcontextprotocol/servers --path .claude/skills/issue-triageManually: put the files in place
.devin/skills/issue-triage/SKILL.mdThe legacy .windsurf/skills/ path still works
Skills CLI (Vercel)
npx skills add modelcontextprotocol/servers --skill issue-triage -a opencodeClaude.ai
Zip the folder and upload it in Settings → Capabilities → Skills
Step by step, with commands for each platform: How to install skills in Claude Code: five ways
Questions about issue-triage
How to install issue-triage in Claude Code?
Run npx skills add modelcontextprotocol/servers --skill issue-triage -a claude-code.
What does issue-triage do?
It sorts incoming issues and pull requests, moves accepted tasks to board #43 with a priority score, and closes off-topic items.
What must be loaded before modifying project cards?
Load the board-ops skill before the first board write, as it holds board IDs and card mutation commands.
Source text
SKILL.md · Apache-2.0
---
name: issue-triage
description: "Triage this repo's inflow — sweep unboarded issues onto the Servers V2 board (#43) as Incoming, score Priority with the rubric and post the score, classify outside PRs and spam (server listings, new servers, duplicates, no-ops) and close them with their canned comments, and run the board audit. Use for \"triage new issues\" or \"triage the PRs\"; when scoring an issue's Priority; when answering or closing an outside PR; or when auditing the board for drift."
disable-model-invocation: false
---
# Triaging issues and outside PRs
This repository receives far more outside issues and pull requests than it
opens itself, much of it misdirected (server listings, new servers, questions
for the SDK) and much of it racing (several PRs for the same bug). This skill is
the procedure for all of it: classify first, answer the classes that do not
belong here with a canned response, sweep the rest onto the board unapproved,
and check the board afterwards.
The **rules** it carries out are in [`AGENTS.md`](../../../AGENTS.md), under
**Contributing** and **Issue-driven work style**. The board's IDs, and the
recipes that add, move and delete cards, are in `board-ops`, and only there:
**load `board-ops` before the first board write.** Labels for an issue you file
come from `issue-create`. The plan for the outside-PR backlog is
[`docs/contribution-model.md`](../../../docs/contribution-model.md), "The plan";
[Outside PRs](#outside-prs) below is how it is carried out.
**"Triage new issues" means:** the [class check](#step-0--classify-before-boarding)
and [pass 1](#pass-1--sweep-onto-the-board-no-approval-implied) over every
unboarded open issue, then the [board audit](#the-board-audit). **"Triage the
PRs" means:** snapshot and classify the open outside PRs into a manifest and
stop for a maintainer's review ([Outside PRs](#outside-prs)). Pass 2 and every
close of an outside PR need a human, and neither is done unprompted.
The one-time sweep of the existing backlog is tracked in #4875 (#4876 for
issues, #4877 for PRs). It uses this skill's classes and responses, plus the
conventions that tracker sets for the sweep, such as a close label and a
reference to #4875 in each close comment.
## Outward-facing actions
Every label, comment and close lands on someone else's issue or PR, and a
reporter is notified of each one. So:
- **A batch of closes happens only after a maintainer has reviewed the
manifest** that lists each item, its class, and the issue it will point at.
A wrong class is cheap to fix in a file and expensive to fix after a
notification went out. Closing a single item the maintainer named is fine.
- **Pace every write loop** by `AGENTS.md` **Pacing GitHub writes**, and
**stop on the first failed call** rather than carrying on blind. GitHub's
secondary rate limit trips well before its primary one, and a loop that
keeps going after a 403 leaves a half-applied batch nobody can see.
- **Never restate a vulnerability in public**, not even to summarize a report
that was already filed publicly. See [Security reports](#security-reports).
- **Keep dumps outside the repository.** Every snapshot and manifest below goes
in a `mktemp -d` directory. The board is private, and a dump left in the
working tree is one `git add -A` away from being published.
## Step 0 — classify before boarding
Read each unboarded issue before sweeping it in. Most are real reports, but some
belong elsewhere, and boarding one of those would park it in a review queue for
work this repository does not do. A class whose Action is **Close** is closed
with its [canned response](#canned-responses), gets **no card** (`Done` means
shipped, and closed-not-planned work never goes on the board), and needs no
labels. A security report is never closed by triage.
| Class | Recognized by | Action | Response |
| --- | --- | --- | --- |
| **Server listing or submission** | Asks for a server to be listed here, in the README or `ADDITIONAL.md` | Close, `not planned` | [Registry, issue](#registry-issue) |
| **New server request** | Asks this repository to build or host a new server | Close, `not planned` | [Registry, issue](#registry-issue) |
| **Archived server** | About a server in [`servers-archived`](https://github.com/modelcontextprotocol/servers-archived) (GitHub, Slack, Postgres, Puppeteer, …) | Close, `not planned` | [Archived](#archived) |
| **SDK or specification** | Reproduces against the SDK directly, or asks for a protocol change | Close, `not planned` | [Elsewhere](#elsewhere) |
| **Duplicate** | An open or closed issue already reports it (search all states, as in `issue-create` step 0) | Close as `duplicate` (the `board-ops` API call); delete its card if it has one | [Duplicate](#duplicate) |
| **Spam or empty** | No content, unrelated or generated content | Close, `not planned` | None |
| **Security report** | Describes a vulnerability in one of the seven servers | **Stop. A maintainer decides** | [Security reports](#security-reports) |
| **Everything else** | A bug, a usability problem, an enhancement, a question about these servers | [Pass 1](#pass-1--sweep-onto-the-board-no-approval-implied) | None; the score comment |
When a class is a judgment call (a feature a maintainer might still take, a
report that could be the SDK or the server), sweep it in rather than closing
it. Pass 1 approves nothing, and a maintainer reading `Incoming` can still close
it; a wrong close cannot be quietly undone. For the same reason, closes follow
[Outward-facing actions](#outward-facing-actions): when a run finds more than
one, list them with their classes for a maintainer to confirm, and go on with
pass 1 for the rest meanwhile.
```sh
# The run's temp dir; create it once, before the first close, and reuse it.
D=${D:-$(mktemp -d)}
# Copy the class's response from Canned responses into "$D/response.md" (the
# text inside the quote, with #ORIGINAL or the SDK link filled in) first.
# Close as not planned, with that response (body in a file, so quoting is
# safe). The comment and the close are two writes (`gh issue close --comment`
# sends them back to back), and a run closes several issues, so every write
# here is followed by the PR-close loop's `sleep 8`, including the last one
# before the next issue. NOT for a duplicate: post the Duplicate response with
# `gh issue comment`, close it with the `board-ops` state_reason=duplicate
# call, and delete its card if it has one (`board-ops`, Delete a card), with
# the same `sleep 8` after each of those three writes. An empty or missing
# response stops here instead of closing silently.
BODY=$(cat "$D/response.md") && [ -n "$BODY" ] \
&& gh issue comment <N> --repo modelcontextprotocol/servers --body "$BODY" \
&& sleep 8 \
&& gh issue close <N> --repo modelcontextprotocol/servers --reason "not planned" \
&& sleep 8 \
|| { echo "no response, or the comment or close failed for #<N> — look before retrying" >&2; false; }
# Spam or empty, the one class with no response:
gh issue close <N> --repo modelcontextprotocol/servers --reason "not planned" && sleep 8
```
## Pass 1 — sweep onto the board (no approval implied)
**An issue needs triage when it has no card on #43, whoever filed it.** The test
is about state, not authorship. An outside reporter cannot board anything, but a
maintainer who opens an issue in the web UI instead of through `issue-create`
leaves it unboarded too, and write access makes the board reachable, not
automatic. The milestone is not part of the test; it decides where the card
lands.
For each unboarded open issue left after step 0:
1. **Labels.** `v2`, **exactly one** type label (`bug` / `enhancement` /
`documentation` / `chore` / `question`; the table is in `issue-create`), and
the server's **scope label** (`server-<name>`) when it concerns one server.
The issue forms apply `v2` and `bug` or `enhancement` on their own, but not
the scope label, which triage reads off the form's server dropdown. Check
the preset type too: a "bug report" that is a usage question is `question`.
2. **Board it** with the `board-ops` add-card recipe, Status **`Incoming`**.
3. **Priority** from [the rubric](#the-priority-rubric), and **post the score as
a comment** ([Recording the score](#recording-the-score)). That is an
assessment for ordering the queue, not an approval.
4. **Leave the milestone unset.** An empty milestone is what marks it as
awaiting review.
**The one exception is an unboarded issue that already has a milestone.**
Someone recorded the approval and only the card is missing, so it goes straight
into **`Todo`**, keeping the milestone. Otherwise triage would silently
un-approve work a maintainer had already scheduled.
### Finding the unboarded ones
```sh
D=$(mktemp -d); R=modelcontextprotocol/servers
# --limit has headroom over the repo's open issues, and a listing that fills it
# is treated as truncated: gh does not say when it stops early.
gh issue list --repo $R --state open --limit 5000 \
--json number,title,milestone,labels > "$D/open.json"
jq -e 'length < 5000' "$D/open.json" >/dev/null \
|| { echo "issue listing truncated or failed — raise --limit" >&2; rm -f "$D/open.json"; }
# item-list truncates SILENTLY past --limit, and a card missing from the dump
# reads as an unboarded issue that then gets a second card.
gh project item-list 43 --owner modelcontextprotocol --format json --limit 2000 > "$D/b43.json"
jq -e '(.items | length) == .totalCount' "$D/b43.json" >/dev/null \
|| { echo "board #43 listing INCOMPLETE — raise --limit and re-run" >&2; rm -f "$D/b43.json"; }
# An org board can hold other repositories' issues; keep this repo's only.
jq -r --slurpfile b "$D/b43.json" --arg R "$R" '
[$b[0].items[] | select(.content.type=="Issue" and .content.repository==$R)
| .content.number] as $boarded
| .[] | select(.number as $n | $boarded | index($n) | not)
| "#\(.number)\t→ \(if .milestone then "Todo (has milestone \(.milestone.title))" else "Incoming" end)\t\(.title[0:70])"' \
"$D/open.json"
```
A failed or truncated listing deletes its file, so the last step fails on the
missing file instead of reporting every issue as unboarded.
The finder is only half the resume point. An issue whose card was added but
whose Priority or score comment then failed has left the finder's list, and the
audit catches a missing Priority but not a missing comment. So after an
interrupted batch, **finish the last issue you were working on first** (read its
card back with `board-ops` and check its comments), then resume from the
finder's output, never from memory.
## Pass 2 — approve what should ship
A maintainer reads the `Incoming` column and, for each issue worth doing,
**assigns a milestone** and moves the card to **`Todo`** (or **`In Progress`**
when picking it up now). That is the whole approval gesture.
**Pass 2 is reserved for a human.** Deciding what ships in which release is not
something to infer from a rubric score, so never promote a card out of
`Incoming` as part of a routine sweep. An issue that should not ship stays in
`Incoming` or is closed, and a closed one has its card deleted.
## The priority rubric
Score it rather than assert it: rate two axes 1–5, add the signal bonuses, and
read the band off the table. Two people triaging the same issue should land in
the same place, and the reasoning should survive in a form someone can argue
with later.
**Axis 1: severity and impact (1–5).** How bad is it when it happens?
| Score | Means |
| --- | --- |
| 1 | Cosmetic: a typo in a README or a tool description, a wording nit. |
| 2 | Minor friction with an easy workaround. |
| 3 | A tool, resource or prompt is broken or missing, and the workaround is annoying or partial. |
| 4 | A server's core function is unusable, or a server **reports something false about the protocol** (a result or schema a conforming client rejects or misreads). |
| 5 | Data loss; a security vulnerability, such as an operation that **escapes an allowed root**, SSRF, or command or argument injection; or a release broken on arrival for everyone. |
**Axis 2: urgency and staleness (1–5).** How time-sensitive or neglected is it?
| Score | Means |
| --- | --- |
| 1 | No time pressure; nothing waits on it. |
| 2 | Wanted eventually. |
| 3 | Wanted this milestone, or has sat more than 90 days with no activity. |
| 4 | Blocking other work, or tied to a dated external dependency (an SDK release, a spec revision's date). |
| 5 | Blocking a release, or hurting users of a published version right now. |
**Signal bonuses (+1 each, not an axis of their own).** Corroborating evidence
the two axes may have undercounted:
- Carries the `bug` label, or concerns security.
- Has a milestone, meaning it is **already approved**. This is the
_re-scoring_ case: an issue scored in pass 1 has no milestone by rule, so it
never earns this bonus. If every issue in a batch is earning it, the milestone
is being treated as a formality.
- High engagement (many comments, reactions, or linked outside PRs racing to
fix it).
- Assigned to someone.
- A sub-issue of a larger tracker.
- The reporter set **Fields → Priority** to `Urgent` or `High`: **+1, flat**,
whichever of the two.
**Bands.** The axes give 2–10 and there are six bonuses, so totals run 2–16.
| Total | Priority | Meaning |
| --- | --- | --- |
| 12+ | **Urgent** | Drop what you're doing. |
| 9–11 | **High** | Next up after current work. |
| 6–8 | **Medium** | Scheduled normally. |
| ≤5 | **Low** | Nice to have; may sit. |
Severity alone does not reach Urgent: a 5/5 with no corroborating signal totals
10 and lands **High**. Urgent is for a severe problem that something _else_ also
says is burning. Override the band when it is plainly wrong, and say why in the
score comment; a rubric nobody may overrule is a rubric people route around.
### Trust boundary: who can set what
**Board #43 is private.** Its Status and Priority are visible only to people
with project access. It is the maintainers' working queue, not a published
commitment. The **score comment** below is public, though: it is how the
reasoning survives, so write it for the reporter to read too.
**Fields → Priority** on the issue page is the opposite: an org-level issue
field, public, and settable by people outside maintainer triage (the two fields
are compared in `board-ops`). A value there is a preference, not an assessment,
so it earns the flat +1 and nothing more. It can lift an issue at most one band,
and nothing a reporter sets reaches Urgent by itself, because Urgent needs 12
and the issue would already need 11 from the maintainer-assessed axes (at most
10) plus at least one other bonus. **Never copy
the value across:** a reporter choosing `Urgent` does not make the card Urgent,
or the queue would sort by assertiveness instead of impact.
### Recording the score
The board stores only the result, and the board is private. So **post the
arithmetic as a comment on the issue**; without it, a later re-scoring cannot
tell a considered judgment from a guess.
```sh
gh issue comment <N> --repo modelcontextprotocol/servers --body \
'**Triage:** Priority **Medium** (total 7)
- Severity 3: `directory_tree` fails on a symlinked root; workaround is listing by hand
- Urgency 2: wanted eventually, nothing blocked on it
- Bonuses: +1 `bug` label, +1 reporter set Fields → Priority to High
Board: Servers V2 (#43), Status `Incoming`, awaiting maintainer review (no milestone yet).'
```
Name each bonus you claimed rather than only summing them. The milestone bonus
in particular should be conspicuously absent from a pass-1 comment; a comment
that lists it shows the approval semantics were misapplied.
## Security reports
A vulnerability in one of the seven servers is reported privately, through the
[advisory form](https://github.com/modelcontextprotocol/servers/security/advisories/new),
and worked in the `security-advisory` flow (#4870). When one arrives as a public
issue or a public PR anyway:
- **Do not restate or discuss the details**, in a comment, in a harvest issue,
or in a triage score. The score comment for such an issue names only the
bands.
- **Check the private advisory backlog first** (a maintainer, or an agent with
access, reads it; the listing is private data and stays out of the repo). A
public report of something already under advisory must not be answered in a
way that confirms the advisory.
- **Hand it to a maintainer.** Whether to close it, move it into a private
advisory, or fix it in the open is theirs to decide. The response below is
the one they usually post.
<a id="security-response"></a>
> Thank you for reporting this. Please don't add further details here: this
> repository takes vulnerability reports privately, through
> [the advisory form](https://github.com/modelcontextprotocol/servers/security/advisories/new).
> A maintainer will follow up.
## Outside PRs
**An outside PR is closed, not reviewed or merged**, with a pointer to the issue
flow, after anything worth keeping in it is tracked by an issue (`AGENTS.md`,
Contributing). An outside PR is any open PR whose author is **not a repository
maintainer and not Dependabot**. A maintainer is an author whose role on this
repository is `admin` or `maintain`; write access alone is not enough, which is
the policy's point.
**A Dependabot PR should not exist.** Dependency updates are issue-driven
(`AGENTS.md`, Dependencies): `.github/dependabot.yml` is gone and security-update
PRs are switched off, and three scheduled sweeps file issues instead. One that
appears anyway means a config came back or the `automated-security-fixes`
setting was switched on again. Close it with a pointer to the sweep issue that
covers the same bump, and raise the cause with a maintainer. If no sweep issue
covers it yet, do **not** file one with `issue-create`: the sweeps trust only
issues the automation wrote, so the next run would file a duplicate beside it.
Dispatch the sweep instead (`gh workflow run dependabot-alerts.yml`, or
`dependency-refresh.yml` for a version bump) and point at the issue it files.
⚠️ **Until the milestone merge carries the sweeps to `main`**, none of this
applies yet: a workflow can only be dispatched or scheduled from the default
branch, and `main` still has `dependabot.yml`. Leave Dependabot PRs open until
then. The merge PR closes them, each with the pointer #5065 records.
Not touched by triage:
- **Maintainer-authored PRs.** List any that target `main` for their authors to
retarget or close; do nothing else with them.
- **An outside PR an open issue already names as the thing to decide** (for
example #3260 under #4860). That issue owns its fate; leave the PR open and
name it in the report.
### 1. Snapshot and pre-classify
```sh
D=$(mktemp -d); R=modelcontextprotocol/servers
gh pr list --repo $R --state open --limit 2000 \
--json number,author,baseRefName,title,files,closingIssuesReferences,labels,createdAt \
> "$D/prs.json"
jq -e 'length < 2000' "$D/prs.json" >/dev/null \
|| { echo "PR listing truncated or failed — raise --limit" >&2; rm -f "$D/prs.json"; }
# Captured first, so a failed page fails the step instead of yielding a short
# list that would turn maintainers into outside authors.
RAW=$(gh api "repos/$R/collaborators?per_page=100" --paginate --slurp) \
&& jq '[.[][] | select(.role_name=="admin" or .role_name=="maintain") | .login]' \
<<<"$RAW" > "$D/maintainers.json" \
|| { echo "maintainer list failed" >&2; rm -f "$D/maintainers.json"; }
# A first-guess class from the paths alone. It is a HINT for the reading below,
# never the manifest: a "server change" can be a no-op, a racing duplicate or a
# security fix, and only reading the diff tells which.
jq -r --slurpfile m "$D/maintainers.json" '
["everything","filesystem","memory","sequentialthinking","fetch","git","time"] as $seven
| .[] | .author.login as $a | [.files[].path] as $p
| (if $a == "app/dependabot" then "dependabot"
elif ($m[0] | index($a)) then "maintainer"
elif ($p | length) > 0 and ($p | all(. == "README.md" or . == "ADDITIONAL.md")) then "listing?"
elif ($p | any(startswith("src/") and ((split("/")[1]) as $d | $seven | index($d) | not))) then "new-server?"
elif ($p | any(startswith("src/"))) then "server-change?"
else "repo-level?" end) as $c
| [.number, $c, ([.closingIssuesReferences[].number] | map("#\(.)") | join(" ")), .title[0:70]]
| @tsv' "$D/prs.json" > "$D/pre.tsv" \
&& cut -f2 "$D/pre.tsv" | sort | uniq -c \
|| { echo "pre-class failed (a missing input above?)" >&2; rm -f "$D/pre.tsv"; }
```
`gh pr list` stops early without saying so, so a listing that fills its limit
is treated as truncated, like the issue listing in pass 1. The
`collaborators` call needs push access to the repository.
### 2. Classify and write the manifest
Read each outside PR (its description, its diff, its linked issues) and give it
exactly one class. The manifest is a TSV in `$D`, four fields per line: PR
number, the class **slug** from the table, the issue its close comment will
name **or `-`** when nothing is harvested, and a one-line reason. Every field is
filled: `read` collapses adjacent tabs, so an empty field would shift the reason
into the issue column.
| Class | Slug | Recognized by | Harvest? | Close with |
| --- | --- | --- | --- | --- |
| **Listing** | `listing` | Adds a server entry to `README.md` or `ADDITIONAL.md`, or promotes one (a correction to an existing entry is read on its merits, and may be `keep`) | No | [Registry, PR](#registry-pr) |
| **New server** | `new-server` | Adds a server implementation, under `src/` or anywhere else | No | [Registry, PR](#registry-pr) |
| **Archived server** | `archived` | Changes a server that moved to `servers-archived` (it pre-classifies as `new-server?`, since its directory is gone) | No | [Archived](#archived) |
| **No-op or spam** | `no-op` | No effective change (a rename to the same name, whitespace, a README "rename" to itself), or unrelated or generated content | No | [General, PR](#general-pr), without a tracking issue |
| **Duplicate** | `duplicate` | Races another open PR, or an existing issue, for the same fix | Once per group, through its best member | [General, PR](#general-pr), naming the group's issue |
| **Security fix** | `security` | Fixes a vulnerability: path traversal, symlink or Roots escape, SSRF, injection, a vulnerable dependency | See [Security reports](#security-reports) first | **Not by the loop**: a maintainer's call |
| **Fix or enhancement to keep** | `keep` | A bug fix, or an in-scope enhancement (`CONTRIBUTING.md`, "What we act on"), to one of the seven servers or the repository | Yes, unless an open issue already covers it | [General, PR](#general-pr), naming the issue |
| **Out of scope** | `out-of-scope` | A feature `CONTRIBUTING.md` is selective about and a maintainer would not take, or a fix for behavior already gone on `v2/main` | No | [General, PR](#general-pr), without a tracking issue |
**Finding duplicate groups.** Racing PRs usually share a linked issue or the
same changed files, so group by those first, then read each group to confirm
they fix the same thing:
```sh
# Outside PRs grouped by their exact set of changed files; each line is a candidate group.
jq -r --slurpfile m "$D/maintainers.json" '
[.[] | select(.author.login != "app/dependabot")
| select(.author.login as $a | $m[0] | index($a) | not)
| {n: .number, key: ([.files[].path] | sort | join(" "))}]
| group_by(.key)[] | select(length > 1)
| "\(map("#\(.n)") | join(" "))\t\(.[0].key[0:100])"' "$D/prs.json"
```
**A maintainer reviews the manifest before anything is closed.** Stop here and
hand it over, with the counts per class and the list of maintainer PRs still
targeting `main`.
### 3. Harvest
For each PR marked for harvest, once the manifest is approved:
- **If an issue already covers the change** (the PR's linked issues first, then
a search of open and closed issues), file nothing. Comment on that issue
naming the PR as a prototype and crediting its author, and point the close
comment at the issue.
- **Otherwise file one issue**, with `issue-create`'s labels (`v2`, one type,
the scope label where one applies) and its board card, with one difference:
**board it `Incoming`, with no milestone.** Harvesting preserves a fix; it
does not approve it. The body states the problem in its own words (never just
"see the PR"), links the PR as a prototype, and credits its author. Then score
it in pass 1 like any other inflow.
- **A duplicate group gets one issue**, linking every PR in the group.
### 4. Close
Close each PR with its class's comment, filling in the harvest issue. Closing
keeps the PR, its branch and its discussion readable; the harvest issue links
it.
```sh
# manifest.tsv: <PR>\t<slug>\t<issue or ->\t<reason>, AFTER maintainer review.
# responses/general-tracked.md holds the general comment with "#ISSUE" where the
# issue goes; general-untracked.md, registry.md and archived.md hold the others.
# For the backlog sweep only (#4875): set SWEEP_LABEL to the close label it
# settled on (it must already exist) and SWEEP_REF to "#4875"; leave both empty
# on a routine pass.
SWEEP_LABEL=; SWEEP_REF=
while IFS=$'\t' read -r PR CLASS ISSUE _; do
ISSUE=${ISSUE#\#} # accept "#123" as well as "123"
case "$CLASS" in
listing|new-server) BODY=$(cat "$D/responses/registry.md") ;;
archived) BODY=$(cat "$D/responses/archived.md") ;;
duplicate|keep) [ "$ISSUE" != "-" ] \
|| { echo "#$PR is $CLASS but names no issue — stopping; harvest first" >&2; break; }
BODY=$(sed "s/#ISSUE/#$ISSUE/" "$D/responses/general-tracked.md") ;;
no-op|out-of-scope)
if [ "$ISSUE" != "-" ]; then BODY=$(sed "s/#ISSUE/#$ISSUE/" "$D/responses/general-tracked.md")
else BODY=$(cat "$D/responses/general-untracked.md"); fi ;;
security) echo "skipping #$PR: a security fix closes only on a maintainer's call" >&2; continue ;;
*) echo "unknown class '$CLASS' on #$PR — stopping; fix the manifest" >&2; break ;;
esac
# A missing or unfilled response must never become a close with no explanation.
case "$BODY" in ""|*"#ISSUE"*) echo "no usable response for #$PR — stopping" >&2; break ;; esac
[ -n "$SWEEP_REF" ] && BODY="$BODY"$'\n\n'"This is part of the backlog triage in $SWEEP_REF."
if [ -n "$SWEEP_LABEL" ]; then
gh pr edit "$PR" --repo modelcontextprotocol/servers --add-label "$SWEEP_LABEL" </dev/null \
|| { echo "label FAILED on #$PR — stopping" >&2; break; }
sleep 8
fi
# Comment and close as two writes, so each is paced: `gh pr close --comment`
# sends both back to back.
gh pr comment "$PR" --repo modelcontextprotocol/servers --body "$BODY" </dev/null \
|| { echo "comment FAILED on #$PR — stopping; look before retrying, it may have landed" >&2; break; }
sleep 8
gh pr close "$PR" --repo modelcontextprotocol/servers </dev/null \
|| { echo "close FAILED on #$PR (its comment is posted; close it, do not re-comment) — stopping" >&2; break; }
sleep 8
done < "$D/manifest.tsv"
```
Each `sleep 8` paces a sustained loop, long enough to stay inside every limit
in `AGENTS.md` **Pacing GitHub writes** for as long as the batch runs.
The step 0 issue closes take the same two sweep additions during the backlog
sweep: the close label (`gh issue edit <N> --add-label`) and the `#4875`
sentence at the end of the response, in the original comment rather than a
later edit.
### 5. Verify
The pass is complete when every open PR is maintainer-authored or named in the
report as left open by an issue that owns it; every closed
PR marked for harvest names an issue that links back to it; and every harvest
issue carries `v2`, one type label, its scope label where one applies, and sits
in `Incoming` with no milestone. Re-run step 1: every remaining line should be
`maintainer`, or a PR the report names. A `dependabot` line is a PR to close
(see above).
### README-only PRs: what `readme-pr-check.yml` did
Until this skill, a workflow answered every PR whose only changed file was
`README.md`: it labeled it `readme: pending`, posted a Registry redirect, and let
the author continue by replying `/i-promise-this-is-not-a-new-server`, which
swapped the label for `readme: ready for review`. That escape hatch invited
exactly the outside PR the policy now closes, so the workflow was retired and
its behavior folded in here. ⚠️ It is deleted on `v2/main` only: a
`pull_request_target` workflow runs from the PR's base branch and an
`issue_comment` one from the default branch, so the copy on `main` keeps
answering PRs against `main` until the next milestone merge.
- Its test, "only `README.md` changed", is the **listing** pre-class above,
widened to `ADDITIONAL.md`.
- Its Registry redirect is the [Registry, PR](#registry-pr) response.
- A PR carrying `readme: pending` or `readme: ready for review` was labeled by
it. Treat the label as a hint, not a verdict: a PR that only corrects an
existing entry (a dead link in the archived list, say) is a fix to keep if it
is worth doing, and is harvested like any other.
## Canned responses
Each class answers the same way on every pass, so a reporter hears one policy,
not whichever wording a given triager chose. Post them as written; adjust a
sentence only when the class is right but a detail is not.
<a id="general-pr"></a>
**General, PR** (every PR class except listings, new servers and archived
servers). Keep the
sentence for the case that applies:
> Thank you for this pull request. This repository now accepts **issues, not
> pull requests**, from anyone but its maintainers: design and implementation
> are done by the maintainers through a prompt-driven workflow, so outside PRs
> are closed rather than reviewed. The policy is in
> [`CONTRIBUTING.md`](https://github.com/modelcontextprotocol/se...
© modelcontextprotocol · Apache-2.0 · modelcontextprotocol/servers