Engineering · 2026-05-31
GitHub Action gate: enforcing MCP security grades in CI/CD — the complete setup guide
The policy playbook gives you the one-paragraph policy and the 12-week rollout calendar. This post is the implementation layer: every workflow file you need, the five configuration decisions your team will debate, how to wire the gate at the org level without a required API key, and the weekly re-scan cron that catches grade regressions before they hit production. All workflows are copy-paste with one variable to change.
What this gate does (and doesn't do)
The gate answers one question on every PR: does this change add an MCP server that is below the team's minimum grade threshold? If yes, it annotates the PR with an error that links directly to the failing server's audit page. If no, it passes silently.
It does not run its own security scan. It does not download the MCP server code. It does not add any network dependencies other than a single HTTPS GET to skillaudit.dev/audit-index.json — a CDN-served static file that resolves in under 100ms from any GitHub Actions runner. The actual audit work happens asynchronously on SkillAudit's side as servers are submitted; the gate reads the result, not the input. This means the gate adds less than 3 seconds to your PR checks, even in a large monorepo.
What it catches that a human reviewer cannot: the grade regression. An MCP server that passed your last review with a C grade can drop to F the week after because the maintainer pushed a commit that reintroduced an SSRF the engine had previously cleared. Without the weekly re-scan cron below, your team's lockfile still says "approved" while the upstream is now failing. The gate on PRs catches new installs; the cron catches regressions on existing ones.
Workflow 1 — the PR gate
Drop this into .github/workflows/mcp-gate.yml. Change MIN_GRADE to A, B, C, or D depending on your policy. Start at C unless your team is in a regulated context — see the threshold selection guide for the data behind this recommendation.
# .github/workflows/mcp-gate.yml
name: mcp-install-gate
on:
pull_request:
paths:
- '.claude/plugins.lock'
- '.cursor/extensions/*.json' # optional: add if team uses Cursor
- '.windsurf/plugins.json' # optional: add if team uses Windsurf
types: [opened, synchronize, reopened]
jobs:
gate:
name: MCP grade gate (min ${{ env.MIN_GRADE }})
runs-on: ubuntu-latest
env:
MIN_GRADE: 'C' # one of: A, B, C, D
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Diff added MCP entries
id: diff
run: |
git diff origin/${{ github.base_ref }} -- .claude/plugins.lock \
| grep -E '^\+\s+"[a-z0-9_.-]+/[a-z0-9_.-]+"' \
| sed -E 's/.*"([^"]+)".*/\1/' > added.txt
COUNT=$(wc -l < added.txt | tr -d ' ')
echo "count=$COUNT" >> "$GITHUB_OUTPUT"
if [ "$COUNT" -gt 0 ]; then
echo "New MCP entries to gate-check:"
cat added.txt
else
echo "No new MCP entries detected — gate skipped."
fi
- name: Fetch audit index
if: steps.diff.outputs.count != '0'
run: |
curl -fsSL "https://skillaudit.dev/audit-index.json" -o audit-index.json
echo "Audit index fetched. Entry count: $(jq 'length' audit-index.json)"
- name: Check grades against threshold
if: steps.diff.outputs.count != '0'
run: |
rank() { case "$1" in A) echo 4;; B) echo 3;; C) echo 2;; D) echo 1;; *) echo 0;; esac; }
MIN=$(rank "$MIN_GRADE")
FAIL=0
while IFS= read -r repo; do
[ -z "$repo" ] && continue
slug=$(echo "$repo" | tr '/' '-' | tr '[:upper:]' '[:lower:]')
entry=$(jq -r --arg k "$repo" '.[$k] // empty' audit-index.json)
if [ -z "$entry" ]; then
echo "::error title=Not yet audited::$repo has no SkillAudit grade. Submit it at https://skillaudit.dev/#audit-req and re-run when the audit completes (typically <2 min)."
FAIL=1
continue
fi
grade=$(echo "$entry" | jq -r '.grade // "F"')
scanned_at=$(echo "$entry" | jq -r '.scanned_at // "unknown"')
grade_rank=$(rank "$grade")
if [ "$grade_rank" -lt "$MIN" ]; then
echo "::error title=Below threshold ($grade < $MIN_GRADE)::$repo — grade $grade does not meet the minimum $MIN_GRADE policy. Audit detail: https://skillaudit.dev/audits/$slug/ | Scanned: $scanned_at"
FAIL=1
else
echo "PASS $repo grade=$grade (min=$MIN_GRADE) scanned=$scanned_at"
fi
done < added.txt
exit "$FAIL"
- name: Summary (no new entries)
if: steps.diff.outputs.count == '0'
run: echo "No new MCP server entries added in this PR — gate passes automatically."
Two things to call out before you ship this. The paths: filter means the workflow only runs when the lockfile changes — it will not show up in the required-checks list on PRs that touch unrelated files, which avoids the "required check never ran" blocker that trips teams in the first week. And the types: [opened, synchronize, reopened] ensures it re-runs if a developer pushes a new commit to the branch after fixing the gate failure, rather than treating a prior passing run as permanent.
The five decisions your team will debate
1. Threshold: C vs A vs D
See the full threshold guide for the corpus data. Short version: C is the right week-1 default for most teams — it clears 49 of 101 public MCP servers (all the useful database and developer-tool MCPs) while blocking the 42 F-grade servers and 10 D-grade servers that account for most of the SSRF and credential-exposure findings. Regulated contexts (ITAR, HIPAA PHI, financial data agent flows) should start at A; that clears only 19 of 101 but those 19 are the cleanest surface-area servers in the corpus.
Set it as the MIN_GRADE env var in the workflow — one change, one redeploy, effective across every PR from that point forward. No per-server allow-listing required.
2. Fail vs warn mode
The workflow above exits 1 on a below-threshold entry. For the first 2 weeks, run in observe-only mode by adding continue-on-error: true to the gate step:
- name: Check grades against threshold
if: steps.diff.outputs.count != '0'
continue-on-error: true # remove this line when ready to enforce
run: |
...
During observe mode, the annotation still appears on the PR in yellow (warning) rather than red (error), and the PR is not blocked. After 2 weeks of watching the annotation count, remove the line and flip to enforcing. Teams that skip observe-only typically get one developer with a blocked PR on their first day and a frustrated message to the security channel — observe-only avoids this entirely.
3. Lockfile path and multi-agent-client support
The paths: trigger and the grep pattern in the diff step are written for the Claude Code lockfile at .claude/plugins.lock. Teams using multiple agent clients need to extend both. The lockfile formats differ:
- Claude Code —
.claude/plugins.lock, JSON with top-level object keyed byorg/repostrings. - Cursor — no single lockfile; extension configuration is per-user. For org enforcement, the pattern is to require a team-managed
.cursor/mcp-policy.jsoncommitted to the team-policy repo, and diff that file instead. - Windsurf —
.windsurf/plugins.json, similar structure to Claude Code. The grep pattern in the workflow above covers this if you add the path to thepaths:trigger. - Codex —
~/.config/codex/plugins.json; not committable by default. Enforce via the weekly cron audit against each developer's self-attested install list (see Step 1 in the policy playbook).
For the typical team on Claude Code, the workflow above is complete as written. For teams on a mix of clients, the simplest org-level enforcement is a team-policy repo that developers commit their agent configuration to, with the gate running on PRs to that repo rather than to individual project repos.
4. Org-level enforcement via reusable workflow
For org-level enforcement — where every team repo runs the same gate without copy-pasting the workflow into each — use a reusable workflow in a central security-policies repo:
# .github/workflows/mcp-gate-reusable.yml (in your-org/security-policies repo)
name: mcp-gate-reusable
on:
workflow_call:
inputs:
min_grade:
description: 'Minimum allowed SkillAudit grade (A, B, C, D)'
required: false
default: 'C'
type: string
lockfile_path:
description: 'Path to the MCP lockfile'
required: false
default: '.claude/plugins.lock'
type: string
jobs:
gate:
name: MCP grade gate (min ${{ inputs.min_grade }})
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 2 }
- name: Run MCP grade gate
env:
MIN_GRADE: ${{ inputs.min_grade }}
LOCKFILE: ${{ inputs.lockfile_path }}
run: |
# [same gate logic as Workflow 1 above, using $LOCKFILE instead of hardcoded path]
...
Team repos then call it with a two-line caller workflow:
# .github/workflows/mcp-gate.yml (in each team repo)
name: mcp-gate
on:
pull_request:
paths: ['.claude/plugins.lock']
jobs:
gate:
uses: your-org/security-policies/.github/workflows/mcp-gate-reusable.yml@main
with:
min_grade: 'C'
The advantage: when the threshold changes org-wide (e.g. from C to B after the v0.3 calibration ships), you change one file in security-policies and every team repo inherits it on the next PR. No distributed change across 50 repos.
5. Exception path — wire it before you need it
The gate will block a legitimate developer within 72 hours of going live. They will need a D-grade MCP that has no viable replacement yet, and they will need an exception. If the exception path isn't ready, the exception request lands as a Slack ping to the security engineer at 6pm. Wire the exception path first:
- Create a
mcp-exception-request.mdtemplate in your security wiki with three fields: named engineering owner, written remediation plan with timeline, re-scan deadline (≤30 days). - Designate one Slack channel (e.g.
#mcp-policy-exceptions) as the filing destination. A 24-hour review SLA from the named security reviewer. - For approved exceptions: the reviewer adds the repo to an
exceptions.jsonfile in the team-policy repo, and the gate workflow checks that file before failing. Example:
# exceptions.json in team-policy repo
{
"anthropic/mcp-github": {
"approved_by": "alice@yourteam.com",
"expires": "2026-06-30",
"reason": "No viable replacement — remediation PR open upstream"
}
}
# In the gate workflow, before the grade check:
exception=$(jq -r --arg k "$repo" '.[$k] // empty' exceptions.json)
if [ -n "$exception" ]; then
expires=$(echo "$exception" | jq -r '.expires')
if [[ "$expires" > "$(date +%Y-%m-%d)" ]]; then
echo "::warning title=Exception active ($grade)::$repo — grade $grade, exception expires $expires"
continue
fi
echo "::error title=Exception expired::$repo — grade $grade, exception expired $expires. Renew via #mcp-policy-exceptions."
FAIL=1
continue
fi
The exception path is not optional. Teams that deploy the gate without it either create an informal "ask and the security engineer will override" culture (which undermines the gate entirely) or create a productivity blocker that produces resentment toward the security function. The 20 minutes to wire the exception template and Slack channel are load-bearing infrastructure for the policy's long-term health.
Workflow 2 — the weekly re-scan cron
The PR gate catches new installs. This cron catches grade regressions on MCP servers that were approved weeks ago. It runs every Monday at 09:00 UTC, audits every entry currently in the lockfile (not just newly added ones), and posts a Slack summary of any grade changes.
# .github/workflows/mcp-rescan.yml
name: mcp-weekly-rescan
on:
schedule:
- cron: '0 9 * * 1' # every Monday 09:00 UTC
workflow_dispatch: # allow manual runs
jobs:
rescan:
name: MCP weekly grade re-scan
runs-on: ubuntu-latest
env:
MIN_GRADE: 'C'
SLACK_WEBHOOK: ${{ secrets.MCP_POLICY_SLACK_WEBHOOK }} # optional
steps:
- uses: actions/checkout@v4
- name: Fetch audit index
run: curl -fsSL "https://skillaudit.dev/audit-index.json" -o audit-index.json
- name: Audit all lockfile entries
id: audit
run: |
rank() { case "$1" in A) echo 4;; B) echo 3;; C) echo 2;; D) echo 1;; *) echo 0;; esac; }
MIN=$(rank "$MIN_GRADE")
BELOW=""; MISSING=""; OK=0
# Extract all repo entries from lockfile
jq -r 'keys[]' .claude/plugins.lock 2>/dev/null > all-entries.txt || true
while IFS= read -r repo; do
[ -z "$repo" ] && continue
slug=$(echo "$repo" | tr '/' '-' | tr '[:upper:]' '[:lower:]')
entry=$(jq -r --arg k "$repo" '.[$k] // empty' audit-index.json)
if [ -z "$entry" ]; then
MISSING="$MISSING\n• $repo (not audited)"
continue
fi
grade=$(echo "$entry" | jq -r '.grade // "F"')
scanned_at=$(echo "$entry" | jq -r '.scanned_at // "unknown"')
grade_rank=$(rank "$grade")
if [ "$grade_rank" -lt "$MIN" ]; then
BELOW="$BELOW\n• $repo grade=$grade https://skillaudit.dev/audits/$slug/ (scanned $scanned_at)"
else
OK=$((OK+1))
fi
done < all-entries.txt
echo "below<> "$GITHUB_OUTPUT"
printf "%b" "$BELOW" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
echo "missing<> "$GITHUB_OUTPUT"
printf "%b" "$MISSING" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
echo "ok_count=$OK" >> "$GITHUB_OUTPUT"
- name: Post Slack summary
if: env.SLACK_WEBHOOK != '' && (steps.audit.outputs.below != '' || steps.audit.outputs.missing != '')
run: |
PAYLOAD=$(jq -n \
--arg below "${{ steps.audit.outputs.below }}" \
--arg missing "${{ steps.audit.outputs.missing }}" \
--arg ok "${{ steps.audit.outputs.ok_count }}" \
'{
text: "*MCP weekly re-scan: \($ok) passing — action required*",
blocks: [
{ type: "section", text: { type: "mrkdwn",
text: ":white_check_mark: *\($ok) entries passing*\n:red_circle: *Below threshold:*\($below)\n:warning: *Not yet audited:*\($missing)\n\nReview at or file an exception in #mcp-policy-exceptions." } }
]
}')
curl -fsSL -X POST -H 'Content-type: application/json' --data "$PAYLOAD" "$SLACK_WEBHOOK"
- name: Fail if below-threshold entries exist
if: steps.audit.outputs.below != ''
run: |
echo "::error::The following MCP servers are below the $MIN_GRADE threshold and require action:"
printf "%b\n" "${{ steps.audit.outputs.below }}"
echo "File an exception or replace each server by next Monday."
exit 1
The Slack webhook is optional — the cron fails the workflow run regardless, and your team's CI notification channel will catch it. But the Slack block format above makes the weekly summary much more useful than a raw CI log link: it shows the specific servers by name, links directly to their audit pages, and includes the grade so the on-call engineer can immediately tell whether this is a C-that-dropped-to-D (usually a new minor finding, often a 2-hour fix) or an A-that-dropped-to-F (something more serious happened upstream, needs immediate review).
Add MCP_POLICY_SLACK_WEBHOOK as an org-level Actions secret so all team repos inherit it without per-repo configuration. The webhook only needs incoming-webhook scope — not bot tokens, not OAuth.
Workflow 3 — branch protection integration
The workflows above run checks. Making them required checks that cannot be bypassed is a separate step in GitHub's branch protection settings.
- Go to Settings → Branches → Branch protection rules for your main branch (or the team-policy repo's default branch).
- Enable Require status checks to pass before merging. Add
MCP grade gate (min C)(the job name from the workflow above) to the required checks list. GitHub populates this list from recent workflow runs — you may need to push a test PR that triggers the workflow once before the check name appears in the dropdown. - Enable Do not allow bypassing the above settings. Without this, org admins can merge PRs that bypass required checks — which defeats the policy entirely when a senior developer is blocked and decides to merge directly to main.
- For the re-scan cron, there is no branch protection integration (crons are not PR-triggered). Configure a Slack notification for failed workflow runs in the channel settings instead so cron failures surface to the security reviewer.
One edge case: the paths: filter means the gate check only runs on PRs that change the lockfile. On PRs that don't change the lockfile, the required check never runs, which GitHub interprets as "not yet run" — not "passed". This typically causes GitHub to mark those PRs as blocked on the required check, which is wrong. The fix: add the gate job with an early-exit condition that always passes if no lockfile changes are detected. The workflow above already handles this with the steps.diff.outputs.count == '0' summary step — make sure you keep that step; it ensures the job always completes with exit 0 even when nothing was scanned.
Team plan: policy export and version-pinned grades
The workflows above use the public audit-index.json endpoint, which is open and unauthenticated. Teams on the Team plan get two additional capabilities that matter for CI/CD:
Policy export format. The Team-plan grade endpoint returns a richer payload:
{
"repo": "anthropic/mcp-github",
"grade": "C",
"version_hash": "a3f8bc2d", // git SHA of the scanned commit
"scanned_at": "2026-05-28T14:22:00Z",
"axes": {
"security": "C",
"credentials": "A",
"permissions": "B",
"maintenance": "C",
"compatibility": "A",
"documentation": "B"
},
"finding_counts": {
"HIGH": 1,
"WARN": 3,
"PASS": 41,
"INFO": 7
},
"policy_export": {
"approved_for_install": true,
"approval_threshold": "C",
"expires": "2026-06-28T14:22:00Z" // 30-day TTL
}
}
Version-pinned grade checking. The version_hash field lets the CI gate verify that the grade was generated against the same commit your lockfile pins. If a developer pins anthropic/mcp-github@a3f8bc2d in the lockfile and the grade was generated against a different commit, the gate fails with a "version hash mismatch — re-audit required" annotation rather than silently passing an outdated grade. This prevents a subtle attack path: a developer submits the server for audit, gets a C, pins an older (cleaner) version, and the gate reads the grade as passing when the installed version is actually older than what was audited.
For private repo audits on the Team plan, the endpoint requires a bearer token in the Authorization header. Add the token as an Actions secret (SKILLAUDIT_API_TOKEN) and modify the curl step:
curl -fsSL \
-H "Authorization: Bearer ${{ secrets.SKILLAUDIT_API_TOKEN }}" \
"https://skillaudit.dev/api/v1/grade/$slug" \
-o grade-response.json
What to do when the gate blocks a developer
This is worth thinking through before it happens, because the framing matters. The gate blocking a PR is not a policy failure — it is the policy working. But the developer experience of the block determines whether the policy is seen as a useful safety net or a bureaucratic obstacle.
The annotation format above links directly to the audit page for the blocked server. The audit page shows every finding with the file path, line number, and a short explanation of why it matters. A developer who clicks that link and reads for 2 minutes usually understands immediately why the server is blocked — and often finds that the fix is a one-line change the upstream maintainer hasn't gotten to yet, or finds a better alternative on the public board that solves the same problem with an A grade.
When there is no alternative and no near-term upstream fix: the exception process. The gate's error message should point to the exception channel explicitly:
echo "::error title=Below threshold ($grade < $MIN_GRADE)::$repo — grade $grade does not meet minimum $MIN_GRADE policy. \
Audit detail: https://skillaudit.dev/audits/$slug/ | \
No viable alternative? File an exception in #mcp-policy-exceptions (24h SLA)."
The Slack channel link in the error annotation means the blocked developer doesn't need to hunt for the exception process — it's one click away from the PR check failure. This is the difference between a policy that produces 6pm Slack pings to the security engineer and one that self-routes through a defined process.
Measuring the gate's impact
After 4 weeks of live enforcement, pull these three numbers from your Actions run history:
- F-grade install attempts blocked per week. This is the primary signal. A team that was installing 2–3 below-threshold MCPs per week before the gate should trend toward zero within 4 weeks. If it stays constant, developers are routing around the gate via the exception path or via direct-to-main merges — both indicate a policy or enforcement gap to investigate.
- Below-threshold-to-exception-filed conversion rate. Of every gate failure, what fraction results in a filed exception vs. the developer finding an alternative? A high exception rate (>50%) with long exception lifespans suggests the threshold is set too aggressively for your team's current MCP set. A low exception rate (<10%) suggests the grade distribution has shifted or your team's MCP selection was already mostly above threshold — worth confirming by running a manual audit of the full installed set.
- Grade regression catches per month from the weekly cron. If this is zero for 3+ months, either the corpus is unusually stable, or the cron is failing silently and your team hasn't noticed. Pull the cron run history in Actions to confirm it's actually running weekly.
For teams on the Team plan, the audit log exports these counts as a CSV for the monthly security stand-up. For teams on the public tier, the Actions run history is the source of truth.
Further reading
- Block 52 of 101 community MCP servers with one CI gate — the 2026 team policy template — the policy playbook this post implements: threshold selection data, the 12-week rollout calendar, the exception process, and the four gotchas your security engineer will hit in week 1.
- How to Read a SkillAudit Report — what each section of the audit report means, so your developers can interpret the gate's audit-page links without asking the security engineer.
- MCP server OWASP Top 10 — the threat model behind the six-axis grade, for teams that want to understand what the gate is actually blocking and why it matters.
- The public audit board — every grade in the corpus, every finding linked. Useful during threshold selection and when a developer is looking for an A-grade alternative to a blocked server.
- Methodology — how grades are generated, how long they stay valid, and how to request a re-scan after a maintainer pushes a fix.
Wiring a grade gate for your team? Start with the public board to baseline your current installs.
See every grade → Team plan — policy export + version pinning →