Skip to main content
Glama
JigeeshaJain

gh-review-queue-mcp

README.md
# gh-review-queue-mcp

[![M8ven Score](https://m8ven.ai/badge/mcp/jigeeshajain-reviewqueuemcp-5yot4l)](https://m8ven.ai/mcp/jigeeshajain-reviewqueuemcp-5yot4l)

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](https://docs.astral.sh/uv/).

```bash
git clone <this repo>
cd ReviewQueueMcp
uv sync
```

### Token

The server reads a GitHub personal access token from `GITHUB_TOKEN`:

```bash
cp .env.example .env      # then edit it
export GITHUB_TOKEN=ghp_...
```

Scopes needed:

- **`repo`** — read pull requests in private repositories
- **`read: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

```bash
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

```bash
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`:

```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:

```json
{
  "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

```bash
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.

## License

This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details.


TDQS

A4.4/5.0

Scored across 1 tool

Disambiguation5/5

The set contains only one tool, so there is no possibility of overlap or selecting the wrong tool. Its purpose is clearly and specifically described.

Naming Consistency5/5

The single tool name follows the conventional verb_noun pattern with a clear action and resource. There are no other tool names to create inconsistency.

Tool Count4/5

One tool is small, but the server is narrow by design: it exists specifically to fetch a GitHub review queue. The tool is substantial rather than trivial, so the count is slightly lean but still appropriate for the server's scope.

Completeness5/5

The tool covers the full review queue surface described: own PRs, requested changes, direct review requests, and team review requests, along with ranking reasons and match counts. There are no obvious read-model gaps within this narrow domain.

Maintenance

ActivityMaintained
ResponsivenessNo issues