Skip to content
IsoKron

Public-server case study — not a client engagementBuilt from our outside audit of GitHub’s official MCP server, using only what is public on our audit page and in the upstream issues. GitHub did not commission this work.

Sample report

MCP server audit: github/github-mcp-server v2.1.0

Target
github/github-mcp-server v2.1.0 (commit 4bcdcdf8), run as the published container image
Environment
A throwaway GitHub organisation with one private test repository and one test account. No production data.
Scope
Security, write-path correctness and cost per task
Filed upstream
November 2026
Redactions
None needed: every item here is public. In a client report, tenant names, tokens and internal URLs are redacted.

1. Summary of findings

Three of the audit’s 17 findings are shown here, with one candidate the refuter dropped; the full register lists all 17 (13 newly filed in 11 issues). Each ID below notes the same finding’s ID in that register. Severity words match how each issue was filed. In a client report, severity is rated against the impact you agree with us at scoping.

IDFindingKindSeverityStatus
IK-GH-01GH-01 in the full registerissue_write update loses concurrent issue field changesKind: CorrectnessNot rated (correctness)Fix PR open (ours, #3781)
IK-GH-02GH-08 in the full registerGITHUB_READ_ONLY=yes (or on) silently leaves read-only mode offKind: HardeningNo formal severityFix PR open (community)
IK-GH-03GH-09 in the full registerget_file_contents directory listings put tokenised private-repo download URLs in model contextKind: HardeningLowIssue open
IK-GH-W1search_code returned nothing on a private repositoryWithdrawnNoneNot reported

2. Findings

IK-GH-01Kind: Correctness(GH-01 in the full register)

issue_write update loses concurrent issue field changes

Kind
Correctness
Severity
Not rated upstream. Filed as a correctness bug (lost writes), not as a vulnerability.
Impact
When two updates to the same issue overlap, the one that finishes last writes back the other field’s old value, and both calls report success. Clearing one field has the same effect. Separately, the read is capped at 25 values, so an issue with more loses the rest from the merged set.
Reproduction summary
An issue with a single-select field “Priority” and a date field “Target date”, both set. Two concurrent issue_write update calls: one sets Priority to P2, the other sets Target date to 2026-12-01. Read the values back several times. Result: 19 of 30 concurrent pairs lost an update, across 3 runs. Cause: updateIssue in pkg/github/issues.go reads every field value over GraphQL, merges the change into that snapshot and PATCHes the whole set back with no precondition. Tested on v2.1.0 (commit 4bcdcdf8); the same code is on main.
Control
The same pairs sent as per-field GraphQL setIssueFieldValue mutations: 0 of 28 lost.
Refuter result
Not explained away; finding kept. The control, the same updates through GitHub’s own API, lost nothing.
Confidence
High. Reproduced across three runs (19 of 30 pairs lost a change), then challenged by a separate-model refuter before filing. A deterministic unit test shows the stale snapshot in the PATCH body.
Suggested fix
Stop sending field values in the issue PATCH. Send only the requested values to the additive per-field endpoint, clear fields with the per-field delete (treating 404 as already cleared), or reuse the per-field setIssueFieldValue path that set_issue_fields already uses. Our pull request #3781 takes the per-field endpoint approach; it is not yet reviewed or merged upstream.
Upstream status
Fix PR open (ours): issue open, with IsoKron’s fix in github-mcp-server PR #3781, not yet reviewed or merged upstream.github-mcp-server issue #3751
IK-GH-02Kind: Hardening(GH-08 in the full register)

GITHUB_READ_ONLY=yes (or on) silently leaves read-only mode off

Kind
Hardening
Severity
Hardening. No formal severity in the filing.
Impact
The environment variable enables read-only mode only for 1, t, T, true, True or TRUE. Values such as yes, on, enabled or a typo are parsed as false, and the server exposes all 46 tools, including merge_pull_request and delete_file, instead of 27 read-only tools. The only sign is an INFO log line. GITHUB_LOCKDOWN_MODE behaves the same way.
Reproduction summary
Run the v2.1.0 container image with a token and GITHUB_READ_ONLY=yes, then send initialize, notifications/initialized and tools/list: 46 tools listed.
Control
The same run with GITHUB_READ_ONLY=1 lists 27 tools. The --read-only flag fails closed: --read-only=yes exits with a parse error.
Refuter result
Not explained away; finding kept. The flag and the environment variable treat the same value differently.
Confidence
High. Deterministic, and reproduced by hand against a throwaway organisation.
Suggested fix
Validate GITHUB_READ_ONLY and GITHUB_LOCKDOWN_MODE with the same strict parsing as the flags and exit on an invalid value; or accept the common truthy spellings; at minimum, log a warning when read-only is off because a value was not recognised.
Upstream status
Fix PR open (community): issue open, with a fix pull request from another contributor, github-mcp-server PR #3750.github-mcp-server issue #3747
IK-GH-03Kind: Hardening(GH-09 in the full register)

get_file_contents directory listings put tokenised private-repo download URLs in model context

Kind
Hardening
Severity
Low. Filed as a low-severity hardening suggestion, not a vulnerability report.
Impact
Listing a directory in a private repository returns a download_url for every entry, each carrying a token parameter that fetches the file without other authentication for a few minutes. An agent steered by prompt injection into posting such a URL publicly would expose that file briefly. The marginal risk is small, because a hijacked agent could paste the file itself, but the URL is a bearer credential the model does not need.
Reproduction summary
v2.1.0 container with --read-only, a fine-grained read-only token and a throwaway private repository. A src/ listing returned 4 entries, all with tokenised URLs. An unauthenticated GET of one URL returned 200.
Control
The same URL without its token parameter returned 404. GitHub’s REST contents API returns the same URL, so the server passes it through rather than creating it.
Refuter result
Not explained away; finding kept, and filed as hardening rather than as a vulnerability.
Confidence
High that the behaviour exists; the impact is low.
Suggested fix
Omit download_url from directory listings by default, or strip its token parameter; callers that need it can still ask for it. Keep the explicit download URL for files of 1 MB or more, where the tool deliberately points the model at it.
Upstream status
Issue opengithub-mcp-server issue #3748
IK-GH-W1Withdrawn by the refuter

search_code returned nothing on a private repository

The refuter ran the same search through GitHub’s REST API, which also returned nothing. That points to a GitHub indexing effect, not an MCP server bug, so it was never reported. Client reports list withdrawn candidates like this one, so you can see what the filter removed.

3. Cost per task

In our audit, Claude Opus via Claude Code completed four routine development jobs through the server using MCP tools only (bug fix to merged PR, PR review, issue triage, multi-file rename): 17–33 tool calls and about $0.77–$1.37 per job, as Claude Code’s reported API-equivalent cost, two runs per job. Source: the per-run figures in the audit article.

MeasureResult
JobsFour, two runs each: bug fix to merged PR, PR review, issue triage, multi-file rename
Tool calls per job17–33
Cost per jobabout $0.77–$1.37 (Claude Code’s reported API-equivalent cost)
Submitting a PR review with inline comments5 MCP calls; GitHub’s REST API accepts the same review, comments included, in one request

Per-task breakdown

Example layout

In a client report with the Agent Benchmark, each of 3–5 agreed tasks gets its own row, and fixes are ranked by cost saved. For this audit, the per-job figures are in the audit article: the developer runs and the before/after benchmark. The table below only shows the layout.

TaskCallsTokensCostBiggest saving
Task 1nn$The fix that saves the most, with the saving
Task 2nn$The fix that saves the most, with the saving
Task 3nn$The fix that saves the most, with the saving

4. Method

  1. Control. A script reproduces each finding, and the same operation runs through the vendor’s own API.
  2. Refuter. A separate AI refuter, in a clean context, tries to explain the finding away. If it can, the finding is dropped.
  3. Reproduction by a person. A person reruns the reproduction and reads the evidence before anything reaches the report.

Agent benchmark harnesses are hashed and frozen before the first measured run. Calls are throttled, and only test tenants are used.

5. Also in a client report

Example layout
  • An executive summary written for whoever signs off the release or the adoption.
  • A permission map: declared versus code-inferred behaviour of each tool (read, write, destructive), with unknowns listed.
  • A pinned tools/list snapshot made with mcp-catalog-snap, so you can detect catalog drift after the audit.
  • Retest results after your fixes, rerun on the same reproductions.

6. Limitations

An audit tells you what we tested and found. It is not a promise that the server is secure. An open issue is a report, not a confirmed vulnerability, until the maintainers say so.

Get this report for your server

Fixed-price reviews from $495; full audits from $1,450. We reply with a quote and a delivery date.