gh-review-queue-mcp
MCP server that ranks and deduplicates your GitHub pull request review queue across direct requests, team requests, and your own waiting PRs into a single prioritized list via one get_review_queue tool.
README
gh-review-queue-mcp
An MCP server that answers one question: what should I review next?
It exposes exactly one tool, get_review_queue, which returns a ranked, deduplicated
view of your GitHub pull request review queue — reviews requested of you, reviews
requested of your teams, and your own pull requests that are waiting on someone else.
One tool is a deliberate constraint. An assistant that has to pick between
list_prs, search_prs, and get_pr_status spends its first turn choosing; an
assistant with one tool that returns an already-prioritized list can just answer.
What it actually does
When the tool is called, four things happen in order.
1. Identify you and your teams
The server issues a GraphQL query for viewer { login } plus the teams you belong to
(organizations.teams(role: MEMBER)). The team slugs matter because GitHub's search
API has no "requested of any of my teams" qualifier — you have to name each team
explicitly. This is the only reason the token needs the read:org scope.
2. Fan out into one batched search
GitHub has no single query for "everything needing my attention", so the server runs several searches and combines them. All of them go out in one GraphQL document using aliases, so it is one HTTP round trip regardless of how many teams you're on:
| Alias | Search | Becomes reason |
|---|---|---|
requested_of_me |
is:pr is:open archived:false review-requested:@me |
requested_of_me |
my_pr_awaiting_review |
is:pr is:open archived:false author:@me |
my_pr_awaiting_review |
team_0, team_1, … |
is:pr is:open archived:false team-review-requested:<org>/<team> |
requested_of_my_teams |
Search strings are passed as GraphQL variables, never interpolated into the query document, so a team slug can't reshape the query.
The same query also asks for rateLimit { remaining resetAt }, so every response can
report your remaining budget without a second call.
Two notes on the response shape. GitHub's search(type: ISSUE) returns issues as well
as pull requests; because the selection set is an inline fragment on PullRequest,
issues come back as empty nodes and are dropped during parsing. And statusCheckRollup
is read from commits(last: 1) — the CI state of the head commit, not the whole branch
history.
3. Merge, dedupe, filter, rank
The same pull request routinely comes back from several searches — a PR where you're a direct reviewer and your team is requested appears in two buckets. They're deduplicated on GraphQL node id, and the reasons accumulate onto one entry, so the response says "this is here for two reasons" instead of listing it twice.
Then your filters are applied, and what survives is scored and sorted.
4. Serialize
The ranked list comes back as structured output — the tool declares a full JSON output schema, so a client gets typed fields, not prose it has to parse.
How ranking works
Ranking is tiered, not weight-tuned. Each pull request lands in exactly one tier, and the tier is worth vastly more than anything that accumulates inside one:
| Tier | Condition | Base |
|---|---|---|
| 3 | Your own PR with failing CI | 300 |
| 2 | Your own PR with changes requested | 200 |
| 1 | A review requested of you directly | 100 |
| 0 | A team request, or your own PR that's simply waiting | 0 |
Within a tier, two smaller signals apply:
- Age — 2 points per day since the PR was opened, capped at 20. Old review requests surface, but a six-month-old PR can't dominate forever.
- Small diff — a flat 8-point bonus for diffs of 100 lines or fewer, on the theory that a small review you can finish now beats a large one you'll defer.
The cap is the whole point. The most anything can accumulate inside a tier is 20 + 8 = 28, well under the tier step of 100, so tier dominance holds by construction: a brand-new direct request always outranks an ancient team request, and no future weight tuning can silently flip that. If you add a scoring signal, keep the within-tier total under 100 or that guarantee breaks.
Ties break on most recent activity (updatedAt), so an active discussion outranks a
stalled one at the same score.
Every item carries priority_reasons — human-readable strings like
["my PR, CI failing", "3 days old"] — so the ranking can be explained back to you
instead of arriving as an unexplained number.
Installation
Requires Python 3.11+ and uv.
git clone <this repo>
cd ReviewQueueMcp
uv sync
Token
The server reads a GitHub personal access token from GITHUB_TOKEN:
cp .env.example .env # then edit it
export GITHUB_TOKEN=ghp_...
Scopes needed:
repo— read pull requests in private repositoriesread:org— read your team memberships, for the team-review-requested searches
A classic PAT is simplest. Fine-grained tokens work if granted "Pull requests: read" plus organization member read. Create one at https://github.com/settings/tokens.
GITHUB_GRAPHQL_URL optionally overrides the endpoint for GitHub Enterprise Server.
The token is read per tool call, not at startup — the server starts cleanly without one and returns an actionable error when called, rather than dying during the MCP handshake where the client would only see a broken pipe.
Running it
uv run gh-review-queue-mcp
It speaks MCP over stdio and expects a client on the other end; run directly, it just waits.
With MCP Inspector
npx @modelcontextprotocol/inspector uv --directory /absolute/path/to/ReviewQueueMcp run gh-review-queue-mcp
Open the printed URL, connect, and the tool appears under Tools with its generated input schema.
With Claude Desktop
Add to claude_desktop_config.json — on macOS at
~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"gh-review-queue": {
"command": "uv",
"args": [
"--directory",
"/absolute/path/to/ReviewQueueMcp",
"run",
"gh-review-queue-mcp"
],
"env": {
"GITHUB_TOKEN": "ghp_..."
}
}
}
}
Paths must be absolute — Claude Desktop doesn't launch servers from your shell, so it has no working directory or exported environment to inherit. Restart Claude Desktop after editing. Then ask it "what should I review today?"
Tool reference
get_review_queue
All arguments are optional.
| Argument | Type | Default | Meaning |
|---|---|---|---|
include |
array of requested_of_me | requested_of_my_teams | my_pr_awaiting_review |
all three | Which reasons to include. An item survives if any of its reasons is included. |
exclude_drafts |
boolean | true |
Drop drafts. They're excluded, not demoted — a draft isn't reviewable yet. |
max_age_days |
integer | none | Drop PRs opened more than this many days ago. Inclusive at the boundary. |
repos |
array of owner/name |
none | Restrict to these repositories. Exact match. |
limit |
integer 1–100 | 25 |
Maximum items returned. total_matching still reports the full count. |
Response:
{
"viewer": "octocat",
"generated_at": "2026-08-20T12:00:00Z",
"returned": 5,
"total_matching": 5,
"rate_limit_remaining": 4712,
"warnings": [],
"items": [
{
"repository": "acme/payments-api",
"number": 4830,
"title": "Add idempotency keys",
"url": "https://github.com/acme/payments-api/pull/4830",
"author": "octocat",
"reasons": ["my_pr_awaiting_review"],
"priority_score": 306.0,
"priority_reasons": ["my PR, CI failing", "3 days old"],
"age_days": 3.0,
"diff_size": 374,
"changed_files": 12,
"is_draft": false,
"review_decision": "REVIEW_REQUIRED",
"ci_status": "FAILURE"
}
]
}
returned vs total_matching distinguishes "here are 25" from "there are numerous" —
without it, a limited response is indistinguishable from a complete one.
warnings carries GraphQL partial failures. GitHub can return usable data alongside
errors (one org unreadable, one search failing); rather than throwing away the whole
queue, those degrade to warnings and the rest of the results still come back.
Architecture
Four modules under src/gh_review_queue/, and the boundaries are load-bearing:
server.py MCP wiring. Parse arguments -> call client -> domain layer -> serialize.
| Deliberately thin; its docstring sets a ~120-line budget.
v
github.py The only module that touches the network. Builds GraphQL, handles HTTP
| and GraphQL errors, returns domain objects. Never ranks or filters.
v
queue.py Pure functions: merge -> apply_filters -> rank/score, via build_queue.
| Input is a snapshot and a clock. Nothing else.
v
models.py Frozen pydantic value objects. The only place GitHub's nested GraphQL
shape is flattened. No network types.
The payoff is queue.py: because it takes a QueueSnapshot and a datetime and
nothing else, every ranking rule is tested with plain data and no mocks, no network,
and no clock patching. That's the reason for the split, and why an httpx import must
never reach it.
Degrading instead of failing
Unknown enum values from GitHub — a new reviewDecision, a new CI rollup state — are
mapped to None rather than raising. A state added on GitHub's side should never break
your whole queue. The same instinct runs through the parsing layer: missing authors
become ghost (GitHub's own convention for deleted accounts), non-PR search results are
dropped, and absent timestamps are the one genuinely unrecoverable case that does raise.
Development
uv run pytest # all tests
uv run pytest tests/test_queue.py # one file
uv run pytest -k "rank or score" # by name
uv run ruff check . # lint
uv run ruff format . # format
uv run mypy # typecheck (strict)
Run mypy bare — it takes its targets from [tool.mypy] files in pyproject.toml, so
passing a path checks less than intended.
Testing approach
Tests run off tests/fixtures/queue_response.json, one captured GraphQL response built
to contain the awkward cases: a PR that appears in two buckets, a draft, a very stale PR,
a failing-CI PR of the viewer's, and a null status rollup.
test_rank_orders_the_fixture_the_way_a_reviewer_would_read_it asserts exact scores
against a fixed clock. It's the canary for scoring changes — if it fails, decide whether
the new ordering is genuinely better before updating the numbers.
Status
| Phase | Scope | State |
|---|---|---|
| 1 | Scaffold, packaging, tooling | done |
| 2 | models.py, queue.py, domain tests |
done |
| 3 | github.py GraphQL client, real server.py |
done |
| 4 | Client and server tests | not started |
| 5 | Documentation | this file |
Phase 3 is verified end to end — a real MCP stdio handshake, tool discovery, and a tool
call — but tests/test_server.py is still a placeholder. The client's error paths
(401, 403, partial GraphQL failures, unreachable host) are written but not yet covered
by automated tests.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
mcp-server-qdrant
这个仓库展示了如何为向量搜索引擎 Qdrant 创建一个 MCP (Managed Control Plane) 服务器的示例。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器