Guide · Developer Experience

MCP server CLI tools

The MCP Inspector is great for interactive development, but you also need scripts: something to verify a deployment worked, something to dump the current tool schema to a file so you can diff it against the previous version, something to smoke-test a live endpoint in CI without spinning up a full browser client. This guide covers the CLI scripts that MCP server authors use repeatedly — from a 3-line health check to a full deployment verification script.

TL;DR

The MCP protocol over HTTP+SSE or Streamable HTTP is plain JSON-RPC — you can probe it with curl. For stdio servers, pipe JSON-RPC messages through the server process directly: echo '{"jsonrpc":"2.0","method":"initialize",...}' | node dist/index.js. Build a minimal scripts/ directory with: health-check.sh (ping + initialize handshake), dump-schema.ts (ListTools → JSON file), smoke-test.ts (call each tool with test args), and verify-deploy.sh (post-deployment check). Wire them as npm run scripts and in CI.

Health check — is the MCP endpoint alive?

The minimal health check for an HTTP MCP server is an initialize JSON-RPC request. A healthy server returns a valid InitializeResult; an unhealthy one returns a non-200 HTTP status, a timeout, or a malformed JSON body.

#!/bin/bash
# scripts/health-check.sh — probe an HTTP MCP endpoint
# Usage: ./scripts/health-check.sh https://mcp.example.com

set -e

URL="${1:-$MCP_SERVER_URL}"
if [[ -z "$URL" ]]; then
  echo "Usage: $0 <server-url>" >&2
  exit 1
fi

RESPONSE=$(curl -sf --max-time 10 \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
      "protocolVersion": "2024-11-05",
      "capabilities": {},
      "clientInfo": { "name": "health-check-script", "version": "1.0.0" }
    }
  }' \
  "$URL/mcp" 2>&1) || { echo "FAIL: curl error or timeout"; exit 1; }

# Check for a valid result (not an error response)
if echo "$RESPONSE" | grep -q '"result"'; then
  SERVER_NAME=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['result']['serverInfo']['name'])" 2>/dev/null || echo "unknown")
  echo "OK: $URL — server: $SERVER_NAME"
  exit 0
else
  echo "FAIL: unexpected response: $RESPONSE"
  exit 1
fi

For stdio servers, pipe the initialize message directly to the server process:

#!/bin/bash
# scripts/health-check-stdio.sh
RESPONSE=$(printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"check","version":"1.0.0"}}}' \
  | timeout 5 node dist/index.js 2>/dev/null | head -1)

if echo "$RESPONSE" | grep -q '"result"'; then
  echo "OK"
else
  echo "FAIL: $RESPONSE"
  exit 1
fi

Schema dump — capture the current tool list

A schema dump captures the current ListTools response as a JSON file. Commit the dump to git; diff it in CI to detect accidental schema changes before deployment.

// scripts/dump-schema.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createServer } from '../src/server.js';
import { createDeps } from '../src/deps.js';
import { writeFileSync } from 'fs';

async function main() {
  const deps = await createDeps();
  const server = createServer(deps);

  const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
  await server.connect(serverTransport);

  const client = new Client({ name: 'schema-dump', version: '1.0.0' }, { capabilities: {} });
  await client.connect(clientTransport);

  const { tools } = await client.listTools();

  const output = JSON.stringify({ generated_at: new Date().toISOString(), tools }, null, 2);
  writeFileSync('schema.json', output);

  await client.close();
  await deps.close();

  console.log(`Dumped ${tools.length} tools to schema.json`);
}

main().catch(console.error);
# package.json scripts
"dump-schema":    "tsx scripts/dump-schema.ts",
"check-schema":   "tsx scripts/dump-schema.ts && git diff --exit-code schema.json"

check-schema regenerates the dump and fails if it differs from the committed version. Add it to your CI pipeline to catch unintended schema changes in PRs.

Smoke test — call every tool with test inputs

A smoke test calls every tool in the ListTools response with a predefined set of test arguments and verifies the response is not an error. Unlike unit tests, smoke tests run against the real server process (including real database, real HTTP calls to upstream APIs) — they verify the integration, not just the handler logic.

// scripts/smoke-test.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';

// Map of tool name → test arguments to call with
const TEST_CASES: Record<string, Record<string, unknown>> = {
  search_users:  { query: 'test', pageSize: 1 },
  get_user:      { userId: '00000000-0000-0000-0000-000000000001' }, // known test user
  list_items:    { status: 'active', limit: 1 },
};

async function main() {
  const transport = new StdioClientTransport({
    command: 'node',
    args: ['dist/index.js'],
    env: { ...process.env, NODE_ENV: 'test' },
  });

  const client = new Client({ name: 'smoke-test', version: '1.0.0' }, { capabilities: {} });
  await client.connect(transport);

  const { tools } = await client.listTools();
  console.log(`Found ${tools.length} tools`);

  let passed = 0, failed = 0;

  for (const tool of tools) {
    const testArgs = TEST_CASES[tool.name];
    if (!testArgs) {
      console.warn(`  SKIP ${tool.name} — no test case defined`);
      continue;
    }

    try {
      const result = await client.callTool({ name: tool.name, arguments: testArgs });
      if (result.isError) {
        console.error(`  FAIL ${tool.name}: ${JSON.stringify(result.content)}`);
        failed++;
      } else {
        console.log(`  OK   ${tool.name}`);
        passed++;
      }
    } catch (err) {
      console.error(`  FAIL ${tool.name}: ${err instanceof Error ? err.message : String(err)}`);
      failed++;
    }
  }

  await client.close();

  console.log(`\n${passed} passed, ${failed} failed`);
  if (failed > 0) process.exit(1);
}

main().catch(err => { console.error(err); process.exit(1); });
# package.json
"smoke": "npm run build && tsx scripts/smoke-test.ts"

Deployment verification script

After deploying a new version, a deployment verification script confirms the live endpoint responds correctly and serves the expected version. Run this from CI after deploying, or manually after a risky change.

#!/bin/bash
# scripts/verify-deploy.sh — post-deployment check
# Usage: ./scripts/verify-deploy.sh https://mcp.example.com 1.2.3

set -e

URL="$1"
EXPECTED_VERSION="$2"

if [[ -z "$URL" ]]; then
  echo "Usage: $0 <url> [expected-version]" >&2
  exit 1
fi

echo "Checking $URL ..."

RESPONSE=$(curl -sf --max-time 15 \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"verify","version":"1.0.0"}}}' \
  "$URL/mcp")

if ! echo "$RESPONSE" | grep -q '"result"'; then
  echo "FAIL: no result in response: $RESPONSE"
  exit 1
fi

# Check tool count
TOOL_RESPONSE=$(curl -sf --max-time 10 \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
  "$URL/mcp")

TOOL_COUNT=$(echo "$TOOL_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('result',{}).get('tools',[])))" 2>/dev/null || echo "0")

echo "OK: endpoint healthy, $TOOL_COUNT tools registered"

if [[ -n "$EXPECTED_VERSION" ]]; then
  ACTUAL_VERSION=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['result']['serverInfo']['version'])" 2>/dev/null || echo "unknown")
  if [[ "$ACTUAL_VERSION" == "$EXPECTED_VERSION" ]]; then
    echo "OK: version $ACTUAL_VERSION matches expected"
  else
    echo "WARN: version mismatch — expected $EXPECTED_VERSION, got $ACTUAL_VERSION"
    exit 1
  fi
fi

Registry publish helper

Submitting an MCP server to registries (MCP.so, Glama, Smithery, the official MCP Registry) requires filling in metadata — name, description, endpoint URL, schema URL, contact. A publish helper script gathers this from your package.json and the server itself, then generates the submission payload.

// scripts/publish-registry.ts
import { readFileSync } from 'fs';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createServer } from '../src/server.js';
import { createDeps } from '../src/deps.js';

async function main() {
  const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
  const deps = await createDeps();
  const server = createServer(deps);
  const [st, ct] = InMemoryTransport.createLinkedPair();
  await server.connect(st);
  const client = new Client({ name: 'publisher', version: '1.0.0' }, { capabilities: {} });
  await client.connect(ct);
  const { tools } = await client.listTools();
  await client.close();
  await deps.close();

  const endpointUrl = process.env.MCP_ENDPOINT_URL;
  if (!endpointUrl) { console.error('Set MCP_ENDPOINT_URL'); process.exit(1); }

  const payload = {
    name:          pkg.name,
    version:       pkg.version,
    description:   pkg.description,
    homepage:      pkg.homepage,
    endpoint_url:  endpointUrl,
    tool_count:    tools.length,
    tools:         tools.map(t => ({ name: t.name, description: t.description })),
    contact_email: pkg.author?.email,
    submitted_at:  new Date().toISOString(),
  };

  console.log('Registry submission payload:');
  console.log(JSON.stringify(payload, null, 2));
  console.log('\nSubmit this to: https://registry.modelcontextprotocol.io/submit');
}

main().catch(console.error);

Recommended scripts in package.json

ScriptCommandWhen to run
devtsx --watch src/index.tsDaily development
inspectnpx @modelcontextprotocol/inspector npm run devInteractive testing
typechecktsc --noEmitPre-commit, CI
testvitest runPre-commit, CI
buildtscBefore deploy, before smoke test
startnode dist/index.jsProduction, Docker
smokenpm run build && tsx scripts/smoke-test.tsAfter build, before deploy
dump-schematsx scripts/dump-schema.tsAfter any tool change
check-schematsx scripts/dump-schema.ts && git diff --exit-code schema.jsonCI pull request check
publish-registryMCP_ENDPOINT_URL=https://... tsx scripts/publish-registry.tsAfter first deployment

CI pipeline integration

A minimal GitHub Actions workflow that runs all checks:

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'npm' }
      - run: npm ci
      - run: npm run typecheck
      - run: npm test
      - run: npm run build
      - run: npm run check-schema   # fails if schema.json is out of date
      - name: Smoke test (if endpoint configured)
        if: env.MCP_ENDPOINT_URL != ''
        run: ./scripts/health-check.sh "$MCP_ENDPOINT_URL"
        env:
          MCP_ENDPOINT_URL: ${{ secrets.MCP_ENDPOINT_URL }}

Related questions

Can I use the MCP CLI (mcp command) instead of writing scripts?

The official @modelcontextprotocol/cli package provides a mcp CLI with mcp dev (starts the server with Inspector), mcp inspect (connects Inspector to a running server), and mcp list-tools (prints the tool list). It is a good starting point. Custom scripts become necessary when you need integration with your CI pipeline, smoke tests with specific test arguments, schema diffing, or registry submission with your own metadata format.

How do I test an SSE MCP server from the command line?

SSE (Server-Sent Events) MCP uses a two-connection pattern: POST to /mcp for client-to-server messages, SSE stream at /mcp for server-to-client messages. The simplest CLI test is the health check script above (which sends initialize via POST). For full interactive testing, use the Inspector — SSE transport is not practical to test manually with curl because of the two-connection requirement.

Should schema.json be committed to git?

Yes, if you want schema-diff CI checks. The schema file should be regenerated by npm run dump-schema and committed whenever a tool is added, removed, or modified. The CI check (check-schema) regenerates and diffs — if a developer changed a tool without updating the committed schema, CI fails with a clear message. This is analogous to committing package-lock.json: the committed file represents the source of truth, and CI enforces that it stays in sync with the code.

How do I monitor my server after running these deployment scripts?

The deployment verification script is a one-time check — it confirms the current version is live but does not watch the endpoint going forward. For ongoing monitoring, AliveMCP runs the full MCP initialize handshake every 60 seconds and alerts you within 1 minute if the server goes down. This is the same probe as the health check script, but running continuously with alerting — so you know about failures before your users do.

Further reading