Skip to content

Running passmcp in CI

A diagnostic you run by hand tells you what was true the afternoon you ran it. The point of putting passmcp in a pipeline is that the answer stays current: the server changes, the tool catalog changes, the protocol revision changes, and the build tells you before an agent does.

This page is the whole contract — what passmcp returns, what it writes, and where the sharp edges are.

The exit-code contract

passmcp check has three outcomes and they mean different things. Treat them differently or CI will be useless in both directions.

Exit Meaning What CI should do
0 The run completed and nothing failed. Warnings may still be present. Pass.
1 passmcp could not do its job: bad flags, an endpoint it could not reach, an authorization step that did not complete. No verdict was reached. Fail the job loudly. This is passmcp or the pipeline being broken, not the server.
2 The run completed and at least one finding has status fail. This is the finding you came for.

The distinction between 1 and 2 is the one that matters. A pipeline that treats "could not connect" the same as "connected and found four problems" will eventually go green because the endpoint was down.

Exit 2 is triggered by counts.fail > 0 — a warn never fails a build on its own. If you want warnings to fail yours, gate on the JSON instead; see below, or use a policy, which replaces that rule with one you wrote down.

GitHub Actions

name: MCP server diagnostic

on:
  schedule:
    - cron: "17 6 * * *"
  workflow_dispatch:

permissions:
  contents: read

jobs:
  passmcp:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/setup-go@v5
        with:
          go-version: "1.26"

      - name: Install passmcp
        run: go install satellion.com/passmcp/cmd/[email protected]

      - name: Diagnose
        env:
          MCP_TOKEN: ${{ secrets.MCP_TOKEN }}
        run: |
          passmcp check https://mcp.example.com/mcp \
            --token-env MCP_TOKEN \
            --report-dir "$RUNNER_TEMP/passmcp" \
            --no-color \
            --rps 2

      - name: Keep the evidence
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: passmcp-report
          path: ${{ runner.temp }}/passmcp

Three things in there are deliberate.

--token-env MCP_TOKEN rather than --token "$MCP_TOKEN". The value never becomes a process argument, so it never appears in ps, in a shell trace, or in the log line a runner prints when a step fails.

if: always() on the upload. The run you most want the HAR from is the one that just failed the job.

--rps 2 is the default and it is there to be seen. passmcp throttles because a scheduled job pointed at a production server is a scheduled load test if you let it be. Raise it only against something you own.

Gating on a policy

jq gating works and it lives in your pipeline, which means the rule your organisation actually enforces is a shell line in a YAML file. Past one repository, put it in a file instead:

passmcp check https://mcp.example.com/mcp --token-env MCP_TOKEN --policy .passmcp/policy.json

The policy replaces the default "any failure fails" rule, so exit 2 now means the policy was not met. It can require named checks, cap failures and warnings, set a floor on the score or on one category — and it can carry exceptions that have a reason, a ticket and an expiry date, which is the difference between a gate a team keeps and a gate a team switches off. A policy written for a later passmcp is refused rather than partly applied, so exit 1 still means nothing was judged.

See Acceptance policies. The same file governs passmcp verify, so a gateway checking a signed attestation months later applies what the pipeline applied.

With --report-dir, the answer is written to policy.json beside the evidence — which is the artefact to attach to a change request, because it names every rule, every exemption, and which of them were used.

Gating on a score, not just a verdict

Exit 2 fires on any failure at any severity. If you want a threshold without a policy file, take the JSON:

passmcp check https://mcp.example.com/mcp \
  --token-env MCP_TOKEN --output json --no-color > report.json || true

jq -e '.score.total >= 85' report.json > /dev/null \
  || { echo "score $(jq .score.total report.json) is below 85"; exit 1; }

The || true is load-bearing: without it set -e ends the step on exit 2 before you ever read the file. Check the file for .counts first if you need to tell "scored 40" apart from "never ran".

Other fields worth gating on:

jq '.counts'                                             # pass/warn/fail/skip/info
jq '[.phases[].findings[] | select(.status == "fail")]'  # every failure, with evidence
jq '[.phases[].findings[]
     | select(.status == "fail" and .severity == "critical")] | length'
jq '.score.categories[] | select(.assessed) | {name, score}'

# Every failure as a line somebody can act on, with a link to what it means.
jq -r '.phases[].findings[] | select(.status == "fail")
       | "\(.id)\t\(.detail)\t\(.doc_url)"'

Every failing finding carries evidence, an array like ["req#8"], which indexes the wire log in the report directory. See Reading the evidence for following one back to the bytes.

Surfacing findings where people already look

--output sarif writes SARIF 2.1.0, which GitHub code scanning reads directly. Each alert links to the check's entry in the inventory, because every rule carries the finding's doc_url as its helpUri.

      - name: Diagnose
        env:
          MCP_TOKEN: ${{ secrets.MCP_TOKEN }}
        run: |
          passmcp check https://mcp.example.com/mcp \
            --token-env MCP_TOKEN --output sarif --no-color > passmcp.sarif || true

      - uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: passmcp.sarif
          category: passmcp

That needs security-events: write in the job's permissions. The || true is the same story as before: exit 2 would end the step before the upload.

Passing checks are in the file too, as "kind": "pass" with "level": "none" — code scanning shows only the alerts, and a consumer that wants the full picture can tell "checked and fine" apart from "not checked".

--output junit writes JUnit XML, so a run lands beside the unit tests in whatever panel your CI already has. A warning becomes a <failure> typed warning, because JUnit has no third state and a deviation reported as a pass stops being read; gate on the type attribute if you only want real failures.

      - run: passmcp check "$ENDPOINT" --token-env MCP_TOKEN --output junit > passmcp.xml || true
      - uses: mikepenz/action-junit-report@v5
        if: always()
        with:
          report_paths: passmcp.xml

For a human-readable summary on the run page itself, --output md is the shareable report, failures and warnings first, and renders as-is:

passmcp check "$ENDPOINT" --token-env MCP_TOKEN --output md --no-color \
  >> "$GITHUB_STEP_SUMMARY" || true

GitLab CI

mcp-diagnostic:
  image: golang:1.26
  timeout: 10m
  script:
    - go install satellion.com/passmcp/cmd/[email protected]
    - passmcp check "$MCP_ENDPOINT" --token-env MCP_TOKEN
        --report-dir passmcp-report --no-color
  artifacts:
    when: always
    paths:
      - passmcp-report/
    expire_in: 30 days

Set MCP_TOKEN as a masked, protected CI/CD variable. passmcp reads it from the environment; it is never an argument.

What not to run in a pipeline

--allow-mutations and --allow-destructive against anything you did not stand up for the run. By default passmcp invokes only tools that declare readOnlyHint, which is what makes it safe to point at a live server — see ADR 0004. Those two flags remove that guarantee. In CI they belong against an ephemeral instance, never against production, and never in a job any contributor can trigger.

--allow-load. It runs the burst unthrottled. That is a load test, and a load test on a cron against someone else's infrastructure is an outage with a changelog entry.

--capture-bodies without reading the security model first. Bodies are redacted structurally at the recorder and capped, but the artifact is still the closest thing to a copy of your traffic that passmcp produces. Decide who can download build artifacts before you turn it on.

A fork-triggered pull_request job with the credential attached. pull_request from a fork must not see repository secrets. If the diagnostic needs a real token, run it on schedule or workflow_dispatch, or on push to your own branches.

Shipping the run to your telemetry backend

A scheduled diagnostic is worth more when it lands where the rest of your signals already are:

      - name: Diagnose
        env:
          MCP_TOKEN: ${{ secrets.MCP_TOKEN }}
          OTLP_KEY: ${{ secrets.OTLP_KEY }}
        run: |
          passmcp check https://mcp.example.com/mcp \
            --token-env MCP_TOKEN \
            --otlp-endpoint https://otlp.example.com \
            --otlp-header "Authorization: Bearer $OTLP_KEY" \
            --log-format json || true

The endpoint may be a bare host; passmcp appends /v1/traces when the URL has no path of its own. A collector that is down produces a warning and nothing else — the exit code is whatever the run earned, because a telemetry backend being unreachable is not a finding about the server under test.

--log-format json makes passmcp's own stderr one JSON object per line, each carrying the run's trace_id — the same id on the report and on the exported spans, so a log line joins to the run that produced it.

Pinning the version

Pin a release tag, as every example on this page does, and move it on purpose. A new release can add a check, and a gate that moves by itself turns a new check into a red build nobody changed anything to cause:

go install satellion.com/passmcp/cmd/[email protected]

The report records what ran, under passmcp.version, so an archived report.json says which binary produced it.

Interpreting a run that changed

Two runs against an unchanged server should produce the same findings. The score is derived from findings, not asserted independently (ADR 0002), so a moved score always has a finding under it. Diff the ids rather than the number:

jq -r '[.phases[].findings[] | select(.status == "fail") | .id] | sort[]' \
  report.json > today.txt
diff yesterday.txt today.txt

What legitimately moves without the server changing: latency percentiles in the performance phase, and the generated arguments if you change --seed. Everything else moving is a real difference.