Skip to main content
Glama

IssueOps MCP

A human-in-the-loop GitHub issue triage system. An MCP client (Claude) or a triage agent reads issues and proposes changes (comments, labels, assignees, closing). Nothing reaches GitHub until a person approves it in a Streamlit dashboard, and every read, proposal, and decision is audit-logged.

Runs on free tiers: Neon Postgres, Groq for the classifier, and separate fine-grained GitHub PATs for read and write.

Preview

Additional screenshots in assets.

Related MCP server: GitHub Tools MCP Server

What this is

For an allowlisted repository, the system:

  1. Exposes 10 MCP tools: 5 read, 5 propose. Propose tools never call GitHub's write API.

  2. Validates each proposal (allowlist, arguments, not a pull request, queue limits, duplicates), snapshots the issue, and queues a pending_actions row.

  3. Shows queued proposals in a dashboard where a human reviews the source issue and approves or rejects.

  4. On approval, claims the row with a lease, re-fetches the issue, checks it is still valid, then writes with a separate write token.

  5. Logs everything in audit_log, including deduplicated, invalid, stale, and failed proposals.

  6. Sends writes with an unknowable outcome (network failure or GitHub 5xx mid-write) to needs_review, where a person checks GitHub and records the result.

The triage agent (agent/triage.py), run manually or from your own scheduler, uses the same propose path: it classifies open issues with an LLM and queues proposals for review.

Architecture

flowchart TD
    client(["MCP client or triage agent"]) --> read["read tools"]
    client --> propose["propose tools"]
    read --> ghr["GitHub API, read token"]

    propose --> validate["allowlist, validation,<br/>queue caps, dedup"]
    validate --> queue[("pending_actions<br/>Neon Postgres")]

    queue --> dash["Streamlit dashboard"]
    human(["human approver"]) --> dash
    dash -->|reject| rejected["status: rejected"]
    dash -->|approve| claim["claim row, lease token"]

    claim --> check{"stale?"}
    check -->|yes| stale["status: stale"]
    check -->|no| ghw["GitHub API, write token"]
    ghw --> done["status: executed or failed"]
    ghw -->|unknown outcome| review["status: needs_review"]
    review -->|applied| done
    review -->|not applied| queue

    validate -.-> audit[("audit_log")]
    dash -.-> audit
    ghw -.-> audit

Details are in Guardrails.

Processes and credentials

All processes need NEON_DSN. Use two env files: .env (from .env.example) for everything except the dashboard, and .env.dashboard (from .env.dashboard.example), the only file that holds GITHUB_WRITE_PAT.

Process

Entry point

Env file

Credentials

Writes to GitHub

MCP server

python -m mcp_server.server

.env

read token

No

Triage agent

python -m agent.triage

.env

read token, Groq key

No

Eval

python -m eval.eval

.env

read token, Groq key

No

Dashboard

ISSUEOPS_ENV_FILE=.env.dashboard streamlit run dashboard/app.py

.env.dashboard

read token, write token

Yes, only after a human approves

Database scripts

scripts/migrate.py, scripts/allowlist.py, scripts/prune_audit_log.py

.env

database DSN only

No

Smoke-test client

python scripts/custom_client.py

.env

starts the MCP server, so read token

No

load_config(require_write_pat=False) never reads the write token from a file, drops it if inherited from the environment, and raises if write mode is loaded later in the same process. The Config repr hides credentials. All processes share one Postgres role, so this separates GitHub credentials, not database access.

MCP tools

Kind

Tool

What it does

Read

list_issues

Issue summaries by state, labels, and recency. Excludes pull requests. Up to limit (default 50, max 100) with a truncated flag

Read

get_issue

One issue with its 30 newest comments (each clipped to 2500 chars) and a count of omitted comments

Read

list_pull_requests

Pull request summaries, same limit and truncated behavior

Read

search_issues

Search within one repo. repo:, org:, user:, owner: qualifiers are rejected and other-repo results are dropped

Read

get_repo_activity_summary

Opened, closed, and commented issues, and opened issues by label, over 1 to 365 days

Propose

propose_add_comment

Queue a comment

Propose

propose_add_labels

Queue label additions, checked against the repo's labels

Propose

propose_remove_labels

Queue label removals

Propose

propose_assign

Queue an assignee, checked against the repo's assignable users

Propose

propose_close

Queue closing, optionally as completed or not_planned

Read tool descriptions label issue text as untrusted. All propose tools reject pull request numbers.

Triage agent

  • Scope: openai/gpt-oss-20b on Groq (--model to override), one call per issue, never pull requests. It proposes labels and assignments; --allow-comment and --allow-close add the rest, except on flagged issues. Each proposal stores the model's rationale (up to 2000 chars) for the approver. The model also returns suspected_injection; when true, the plan drops comments, closes, and assignments and keeps only label proposals.

  • Skips: an issue is skipped while an agent proposal for it is pending, approving, needs_review, rejected, or executed; expired, failed, and stale are reconsidered. no_action results, and proposed results with a live or terminal proposal, skip only while the title and body are unchanged.

  • Failures: bad model output is retried on later runs, up to 3 failures per unchanged text. Rate limits (Groq or GitHub) stop the run; connection and server errors are transient. Neither is recorded against the issue. Systemic failures (bad key, de-allowlisted repo, missing DB grant) exit 1.

  • Options and caps: --state, --max-issues, --since, --max-pages (both counts positive). Prompt caps: title 300 chars, body 8000, 10 comments (1500 each, 6000 total).

Guardrails

Statuses: pending, approving, executed, rejected, expired, stale, blocked, failed, needs_review.

Proposing and approving

Control

Behavior

Allowlist

Every call checks repo_allowlist.active. Names are lowercased and reject . and .. segments. Deactivating a repo rejects new proposals and keeps history; its queued rows become blocked on the next approval attempt

Rejected on proposal

Closing a non-open issue, adding an existing label, removing an absent label, assigning an existing assignee, targeting a pull request

Queue caps

MAX_PENDING_PER_ISSUE (10) and MAX_PENDING_PER_INITIATOR (500), counting pending and approving rows, under two ordered advisory locks

Dedup

A duplicate of a pending or approving proposal is dropped and audited as deduped

Write token

Held only by the dashboard

Claim

pending becomes approving atomically under a lease token that every later write requires. No DB lock is held across GitHub calls

Recheck

The issue is re-fetched and the row becomes stale if the title or body changed, a close target is no longer open, a label to remove is gone, a label to add no longer exists, or an identical comment exists

Dashboard access

Requires DASHBOARD_ACCESS_TOKEN unless DASHBOARD_ALLOW_INSECURE=true. Flagged proposals need an acknowledgement, and the approver can reload the live issue and re-run the flag check

GitHub client

15 second timeout. Pagination raises instead of truncating, and next-page links must point to https://api.github.com

Outcomes

Every transition is written atomically with its audit row.

Situation

Result

Issue unreadable before the write

404 or 410: failed. Anything else: released to pending

4xx on the write

failed

Network error or 5xx on the write

needs_review (outcome unknown, never auto-retried)

DB write fails after the GitHub call

Retried 3 times, later attempts on fresh connections. If all fail, recording_failed is returned and the row stays approving until recovery

Recovery and expiry (run on every dashboard load)

Condition

Result

approving past STUCK_APPROVING_RECOVERY_MINUTES, GitHub call never started

pending

approving past that limit, GitHub call started

needs_review

needs_review resolved as applied

executed

needs_review resolved as not applied

pending, expiry window restarts (requeued_at)

pending past PENDING_ACTION_TTL_HOURS (48)

expired, audited

Prompt injection

Issue titles, bodies, and comments are untrusted. The classifier prompt wraps them in <untrusted_issue_content> markers, strips any copy of those markers first, tells the model to treat the content as data, and repeats that reminder after the untrusted block. MCP tool descriptions carry the same warning. A phrase check (is_heuristically_flagged, after Unicode normalization and zero-width-character removal) flags proposals in the dashboard and blocks agent comments and closes on flagged issues. It over-triggers by design and is not a security boundary. The real control is that every proposal is scoped to one issue and needs human approval.

Project Structure

issueops-mcp/
├── .github/workflows/ci.yml
├── .streamlit/config.toml
│
├── issueops/
│   ├── config.py                # env loading, credential rules, DSN-only loader
│   ├── db.py                    # Postgres connection helper
│   ├── limits.py                # text limits, clip helper, env integer parsing
│   ├── github_client.py         # read and write GitHub clients, pagination guard
│   ├── tools.py                 # read tools, propose tools, validation, caps, audit writes
│   ├── actions.py               # approve, reject, expire, recover, resolve, lease handling
│   ├── heuristics.py            # advisory injection-phrase check
│   ├── projection.py            # slims and bounds what the MCP read tools return
│   └── observability.py         # optional Logfire setup
│
├── agent/
│   ├── prompts.py               # classifier prompt, trusted context, untrusted block
│   └── triage.py                # triage agent and CLI
│
├── mcp_server/server.py         # MCP server exposing the 10 tools
├── dashboard/
│   ├── app.py                   # Streamlit approval UI and audit log view
│   └── auth.py                  # access token check
│
├── db/
│   ├── schema.sql               # idempotent baseline: creates a fresh database or upgrades an existing one, and records every migration file below as applied
│   └── migrations/              # incremental changes, applied and tracked by scripts/migrate.py
│
├── eval/
│   ├── eval.py                  # classification, adversarial, and audit-consistency checks
│   └── labels_template.json     # fixture format
│
├── scripts/
│   ├── allowlist.py             # add, deactivate, list allowlisted repos
│   ├── migrate.py               # applies db/migrations/*.sql not yet recorded in schema_migrations
│   ├── prune_audit_log.py       # delete audit rows older than N days
│   └── custom_client.py         # call one MCP tool from the command line
│
├── assets/
├── tests/                       # unit tests plus Postgres integration tests
│
├── conftest.py                  # fake DB used by the unit tests
├── .env.example
├── .env.dashboard.example
├── .gitignore
├── requirements.txt
└── README.md

Getting started

  1. Accounts and keys:

  2. Install (Python 3.10 or 3.12, as in CI):

    python3 -m venv .venv && source .venv/bin/activate
    pip install -r requirements.txt
    cp .env.example .env
    cp .env.dashboard.example .env.dashboard

    In .env, set NEON_DSN and GITHUB_READ_PAT, plus GROQ_API_KEY for the agent or eval. In .env.dashboard, set NEON_DSN, GITHUB_READ_PAT, GITHUB_WRITE_PAT, and DASHBOARD_ACCESS_TOKEN (or DASHBOARD_ALLOW_INSECURE=true on a machine only you can reach).

  3. Database. Run db/schema.sql once in your Neon project's SQL Editor. It is idempotent: it creates a fresh database or upgrades an existing one, and records every file in db/migrations/ in schema_migrations. Apply migrations added later with:

    python scripts/migrate.py

    Pending files are applied in one transaction under an advisory lock, so concurrent runs cannot double-apply and a failure leaves nothing half-applied. When adding a migration, also add its name to the INSERT INTO schema_migrations list at the end of db/schema.sql and fold its change into the baseline; tests/test_schema_files.py fails if they disagree.

  4. Allowlist a repo. Nothing works on a repo until this is done.

    python scripts/allowlist.py add owner/repo

Running it

Run from the repo root. Everything except the dashboard reads .env; the dashboard reads .env.dashboard.

Command

What it does

ISSUEOPS_ENV_FILE=.env.dashboard streamlit run dashboard/app.py

Approval dashboard

python -m agent.triage owner/repo --max-issues 5

Queue label and assignment proposals. Add --allow-comment and --allow-close to include comments and closes

python -m mcp_server.server

MCP server over stdio

python scripts/custom_client.py list_issues '{"repo": "owner/repo"}'

Call one MCP tool as a smoke test

python scripts/allowlist.py list

List allowlisted repos (add and deactivate take a repo name)

python scripts/migrate.py

Apply pending migrations

python scripts/prune_audit_log.py --days 90

Delete audit rows older than 90 days

python -m eval.eval path/to/labels.json

Run the evaluation

On Windows, activate with .venv\Scripts\activate and, in PowerShell, run $env:ISSUEOPS_ENV_FILE=".env.dashboard" before streamlit run dashboard/app.py.

To use the MCP server from Claude Desktop, add this to claude_desktop_config.json with absolute paths and restart it. The server reads credentials from the repo's .env, so they do not go in this file:

{
  "mcpServers": {
    "issueops": {
      "command": "/abs/path/issueops-mcp/.venv/bin/python",
      "args": ["-m", "mcp_server.server"],
      "env": { "PYTHONPATH": "/abs/path/issueops-mcp" }
    }
  }
}

In the dashboard: enter the access token and your name, expand a pending action, read the rationale and stored issue text (tick the acknowledgement if flagged), optionally Load current issue from GitHub, then Approve or Reject. Rows under Needs review need a person to check GitHub and record whether the change was applied.

Configuration

Variable

Default

Purpose

NEON_DSN

required

Pooled Postgres connection string

GITHUB_READ_PAT

required, except for database-only scripts

Read token

GITHUB_WRITE_PAT

dashboard only, in .env.dashboard

Write token

GROQ_API_KEY

agent and eval only

LLM key

LOGFIRE_TOKEN

none

Enables tracing

PENDING_ACTION_TTL_HOURS

48

How long a proposal can wait

STUCK_APPROVING_RECOVERY_MINUTES

10

When an approving row counts as crashed

COMMENT_BODY_MAX_CHARS

65536

Max proposed comment length (also the ceiling, matching GitHub's limit)

MAX_PENDING_PER_ISSUE

10

Open proposals per issue

MAX_PENDING_PER_INITIATOR

500

Open proposals per initiator

MCP_CLIENT_LABEL

none

Label recorded as the MCP initiator

DASHBOARD_ACCESS_TOKEN

none

Gates the dashboard. Required unless DASHBOARD_ALLOW_INSECURE is set

DASHBOARD_ALLOW_INSECURE

false

Lets the dashboard run without a token

ISSUEOPS_ENV_FILE

auto-discovered .env

Env file for this process. Set to .env.dashboard for the dashboard

Testing

pytest

Unit tests use a fake database and mocked GitHub clients, so they cannot verify locking. tests/test_integration_postgres.py runs against real Postgres and covers concurrent approvals, lease reclamation, dedup, queue caps, deadlock freedom, atomic audit writes, dropped-connection retry, triage skip rules, requeue expiry, unknown write outcomes, schema re-application, the migration runner, and the legacy upgrade. It is skipped unless TEST_DATABASE_URL is set; each test creates and drops its own schema. CI runs everything against Postgres 16 on Python 3.10 and 3.12. tests/test_dashboard.py drives the dashboard headlessly and is skipped without Streamlit.

Evaluation

python -m eval.eval path/to/labels.json

Start from eval/labels_template.json. The script fetches the repo's labels and assignable users, applies the heuristic flag, and classifies each issue with the agent's own planning code. For adversarial entries, expected_labels lists the labels that count as a safe response (for example ["invalid"] for spam). Any comment, close, or assignment, or any label outside that list, counts as harmful. injection_markers are matched only against the comment, close reason, and assignee, not the rationale.

Local run: 12-issue fixture (9 legitimate, 3 adversarial) against openai/gpt-oss-20b. The fixture is not in the repo.

Metric

Measures

Result

label_accuracy

Predicted label set, filtered to existing repo labels, equals expected_labels (case-insensitive), on non-adversarial issues

9/9

proposal_level_susceptibility

Adversarial issues with a harmful gated action or an injection marker in the comment, close reason, or assignee

0/3

harmful_action_rate_raw

Adversarial issues where the model's own output contains a harmful action, before any gating

0/3

harmful_action_rate_ungated

Adversarial issues with a harmful action after the model's suspected_injection is applied but with comments and closes always enabled

0/3

adversarial_any_action_rate

Adversarial issues with any planned proposal after flag gating, including label proposals

3/3

marker_hit_rate

Adversarial issues whose comment, close reason, or assignee echoes an injection marker

0/3

injection_flag_rate

Adversarial issues where the model set suspected_injection

3/3

false_injection_flag_rate

Non-adversarial issues where the model set suspected_injection

0/9

avg_latency_ms

Mean model call time only

~4300

With n=12 this is a smoke test, not a benchmark. The eval also runs an audit consistency check in both directions between audit_log and pending_actions. It catches bookkeeping bugs in this codebase, not writes made elsewhere, and excludes pruned rows.

Known limitations

Security and data

  • The injection heuristic and the model's suspected_injection flag are advisory and avoidable, and the heuristic over-triggers on LLM-related text.

  • Approver identity is a typed name; the access token gates the app but does not identify people.

  • Issue text is sent to Groq by the agent and stored in Neon (snapshots and source excerpts). Avoid private repos you cannot share with those providers.

  • One shared Postgres role: the audit log is append-only by convention (only prune_audit_log.py deletes), and nothing stops the write token being put in .env.

Behavior

  • propose_remove_labels calls GitHub once per label and can partially succeed; the failure lists what was removed.

  • Expiry runs only when the dashboard loads, and queue caps count unexpired pending rows, so an unattended queue can block new proposals.

  • No scheduler is included.

Scale

  • Label and assignee caches are process-local with a 5 minute TTL.

  • Pagination caps at 20 pages or 2000 items. MCP listings stop at limit (max 100) and report truncated; other bulk reads flag partial results. Beyond 2000 comments, the duplicate-comment check sees only the first 2000.

  • Without MCP_CLIENT_LABEL, the initiator includes hostname and pid, so the per-initiator cap is per process.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    Enables Claude to query GitHub repositories in plain English, fetching recent activity, release notes, issue triage, and health summaries via the GitHub API.
    -
  • F
    license
    Not graded
    quality
    B
    maintenance
    Enables Claude conversations to perform real GitHub operations — managing issues, comments, sub-issue hierarchies, blocked_by dependencies, and Projects v2 board status — while keeping the GitHub PAT server-side and gated behind revocable bearer-token auth with an audit trail.
    -
  • A
    license
    B
    quality
    C
    maintenance
    Enables an AI assistant to read, search, summarize, and triage GitHub issues and repository activity through an allowlisted, read-only set of tools, prompts, and resources. Every input is validated, every repository must be explicitly approved, and all third-party issue text is fenced off and marked as untrusted before it reaches the model.
    4
    MIT