Running scout in CI¶
A diagnostic you run by hand tells you what was true the afternoon you ran it. The point of putting scout 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 scout returns, what it writes, and where the sharp edges are.
The exit-code contract¶
scout 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 |
scout 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 scout 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.
GitHub Actions¶
name: MCP server diagnostic
on:
schedule:
- cron: "17 6 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
scout:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/setup-go@v5
with:
go-version: "1.26"
- name: Install scout
run: go install github.com/sebastienrousseau/scout/cmd/scout@latest
- name: Diagnose
env:
MCP_TOKEN: ${{ secrets.MCP_TOKEN }}
run: |
scout check https://mcp.example.com/mcp \
--token-env MCP_TOKEN \
--report-dir "$RUNNER_TEMP/scout" \
--no-color \
--rps 2
- name: Keep the evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: scout-report
path: ${{ runner.temp }}/scout
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. scout 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 score, not just a verdict¶
Exit 2 fires on any failure at any severity. If you want a threshold
instead, take the JSON:
scout 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: |
scout check https://mcp.example.com/mcp \
--token-env MCP_TOKEN --output sarif --no-color > scout.sarif || true
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: scout.sarif
category: scout
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: scout check "$ENDPOINT" --token-env MCP_TOKEN --output junit > scout.xml || true
- uses: mikepenz/action-junit-report@v5
if: always()
with:
report_paths: scout.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:
scout 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 github.com/sebastienrousseau/scout/cmd/scout@latest
- scout check "$MCP_ENDPOINT" --token-env MCP_TOKEN
--report-dir scout-report --no-color
artifacts:
when: always
paths:
- scout-report/
expire_in: 30 days
Set MCP_TOKEN as a masked, protected CI/CD variable. scout 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 scout 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
scout 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: |
scout 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; scout 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 scout'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¶
@latest is fine for a scheduled job whose failure you will read. For a
build that gates a merge, pin it, so a new check in a new release does not
turn into a red build nobody changed anything to cause:
go install github.com/sebastienrousseau/scout/cmd/scout@v0.0.1 # a tag, not @latest
No version has been tagged yet, so @latest currently resolves to the tip
of the default branch. Until the first release, a build that must not move
under you should pin the commit.
The report records what ran, under scout.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.