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:

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:

  1. Create a mcp-exception-request.md template in your security wiki with three fields: named engineering owner, written remediation plan with timeline, re-scan deadline (≤30 days).
  2. Designate one Slack channel (e.g. #mcp-policy-exceptions) as the filing destination. A 24-hour review SLA from the named security reviewer.
  3. For approved exceptions: the reviewer adds the repo to an exceptions.json file 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.

  1. Go to Settings → Branches → Branch protection rules for your main branch (or the team-policy repo's default branch).
  2. 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.
  3. 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.
  4. 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:

  1. 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.
  2. 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.
  3. 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

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 →