How to Test an MCP Server in CI With MCP Inspector
Nobody spots a renamed tool parameter in code review, but an agent trips over it on its first call. The MCP Inspector has a CLI mode that connects to a server, runs one request, prints JSON and exits, which is exactly what a CI job wants. By the end you'll have a script that lists your server's tools, calls one, and fails the build when the schema or the result is wrong, for both a local stdio server and a remote HTTP one.
Install a pinned MCP Inspector in CI
The Inspector needs Node 22.19.0 or newer. It runs through npx, but pin a version in CI so an upstream flag change doesn't break your build on a random Tuesday.
node --version # must be v22.19.0 or newer
npm install --global @modelcontextprotocol/inspector@<pinned-version>
mcp-inspector --cli --help
The package installs an mcp-inspector binary, used in the rest of this article. Without a global install, prefix each command with npx @modelcontextprotocol/inspector instead. --cli must come first, because the default client is the web UI.
Test an MCP server with tools/list
Start with the cheap check: does the server start, and does tools/list return what you promise? Adding --strict makes the CLI exit with code 6 on an error-severity schema portability problem.
set -euo pipefail
mcp-inspector --cli node build/index.js \
--method tools/list --strict --format json > tools.json
jq -e '.result.tools | any(.name == "get_weather")' tools.json > /dev/null
jq -e '.result.tools | all(.inputSchema.type == "object")' tools.json > /dev/null
jq -e '.result.tools | all((.description // "") | length > 0)' tools.json > /dev/null
The target is the leading run of arguments that don't start with a dash. If your server takes its own flags, put -- before the Inspector options, as the CLI reference shows. jq -e turns false or null into a failing exit code. Use any(.name == ...) rather than contains, which matches substrings.
To catch renamed parameters, snapshot the contract:
jq -S '.result.tools | map({name, inputSchema}) | sort_by(.name)' tools.json > tools.actual.json
diff -u tests/tools.expected.json tools.actual.json
Commit tests/tools.expected.json. Any schema change now shows up as a diff in the pull request, which is the point.
Call a tool and assert on the result
Listing proves the menu exists. One tools/call proves the kitchen works.
mcp-inspector --cli node build/index.js \
--method tools/call --tool-name get_weather \
--tool-arg city=Boston --format json > call.json
jq -e '.result.content[0].type == "text"' call.json > /dev/null
jq -e '.result.content[0].text | length > 0' call.json > /dev/null
--tool-arg takes key=value and parses each value as JSON when it can, so count=1 is sent as a number. For exact types, pass the whole object with --tool-args-json '{"zip":"10001"}'; the two flags are mutually exclusive. If the tool returns isError: true, the CLI still prints the payload but exits with 5, so set -e stops the job.
Test a remote HTTP MCP server in the same job
Same commands, different target. Add --transport http, and a header if the server wants one.
mcp-inspector --cli "$MCP_URL" --transport http \
--header "Authorization: Bearer ${MCP_TOKEN}" \
--stored-auth-only \
--method tools/list --strict --format json > tools.json
--stored-auth-only stops the CLI from trying to open a browser for OAuth in a job nobody can click through. It fails with exit code 3 (auth_required) instead. Exit code 4 means the server was unreachable, and on any failure the CLI writes a one-line JSON error to stderr.
If the job starts the server itself, wait for it with the connect-only initialize method:
node build/http.js &
SERVER_PID=$!
trap 'kill $SERVER_PID' EXIT
for i in $(seq 1 30); do
mcp-inspector --cli http://localhost:3000/mcp --transport http \
--method initialize > /dev/null 2>&1 && break
sleep 1
done
If all 30 tries fail, the next Inspector command fails with exit code 4, so the job still goes red.
Run the MCP Inspector in GitHub Actions
name: mcp-contract
on: [pull_request]
jobs:
mcp-inspector:
runs-on: ubuntu-latest
env:
MCP_INSPECTOR_SECRET_STORE: memory
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci && npm run build
- run: npm install --global @modelcontextprotocol/inspector@<pinned-version>
- run: bash tests/mcp-contract.sh
env:
MCP_URL: ${{ vars.MCP_STAGING_URL }}
MCP_TOKEN: ${{ secrets.MCP_STAGING_TOKEN }}
Put the commands from the steps above in tests/mcp-contract.sh. The MCP_INSPECTOR_SECRET_STORE line is explained in the first gotcha.
Gotchas
- Secrets on machines without a keychain. The Inspector keeps OAuth tokens, OAuth client secrets and stdio
env:values in the OS keychain. With no keychain (Linux without libsecret or a Secret Service, headless or SSH sessions, containers with a mounted volume) it falls back to~/.mcp-inspector/secrets.json, unencrypted unless you supply a key. CI runners are exactly that machine. SetMCP_INSPECTOR_SECRET_STORE=memory, or encrypt the file withMCP_INSPECTOR_SECRET_KEY_FILE, and never cache~/.mcp-inspector. - Headers are not moved to the secret store. They are saved as written in a config file, so pass
--headerfrom an environment variable instead of committing a config with a token in it. - Make the call deterministic. Call a tool with fixed inputs against stub data, or your build will fail when the weather changes.
- Pick the protocol era your clients use.
--protocol-eraacceptslegacy,autoormodern; run the script for each era you support. - Two exit codes worth remembering. Exit 5 covers a tool error and a tool that wasn't found, and exit 11 means the server answered HTTP 429.
A contract check on tools/list plus one tools/call costs seconds per pull request, and it means the first client to find a broken tool is your pipeline, not an agent running in production.