🧭 How toAgileIntermediate✨ AI-assisted

How to Test an MCP Server in CI With MCP Inspector

WittyTech Daily··5 min read
#mcp#mcp-inspector#ci#testing

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. Set MCP_INSPECTOR_SECRET_STORE=memory, or encrypt the file with MCP_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 --header from 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-era accepts legacy, auto or modern; 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.

← More in Agile