CI/CD for MCP Servers

An MCP server that works in development can break in production in subtle ways: a tool’s Zod schema changes and clients that cached the old schema break; a new tool is added and existing clients need to discover it; a bug fix changes a tool’s output format and downstream agents that parse it stop working. This lesson covers the testing strategy, versioning approach, and CI/CD pipeline that makes MCP server deployments safe and repeatable.

CI/CD pipeline for MCP server showing test stages build Docker push deploy rolling update dark diagram
MCP CI/CD: unit tests -> integration tests against a real MCP server -> build -> push -> rolling deploy.

Testing Strategy for MCP Servers

Unit Tests: Tool Handlers in Isolation

// test/tools/search-products.test.js
import { test, describe, mock } from 'node:test';
import assert from 'node:assert';
import { searchProductsHandler } from '../../tools/search-products.js';

describe('searchProductsHandler', () => {
  test('returns products matching query', async () => {
    const mockDb = {
      query: mock.fn(async () => ({ rows: [{ id: 1, name: 'Laptop X1', price: 999 }] })),
    };

    const result = await searchProductsHandler({ query: 'laptop', limit: 10 }, { db: mockDb });

    assert.ok(!result.isError);
    assert.ok(result.content[0].text.includes('Laptop X1'));
    assert.strictEqual(mockDb.query.mock.calls.length, 1);
  });

  test('returns error on empty query', async () => {
    const result = await searchProductsHandler({ query: '', limit: 10 }, {});
    assert.ok(result.isError);
  });
});

Integration Tests: Full MCP Client-Server Round Trip

// test/integration/mcp-server.test.js
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';

let client;
let transport;

before(async () => {
  transport = new StdioClientTransport({
    command: 'node',
    args: ['src/server.js'],
    env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL },
  });
  client = new Client({ name: 'test-client', version: '1.0.0' });
  await client.connect(transport);
});

after(async () => {
  await client.close();
});

describe('MCP server integration', () => {
  test('lists expected tools', async () => {
    const { tools } = await client.listTools();
    const toolNames = tools.map(t => t.name);
    assert.ok(toolNames.includes('search_products'), 'search_products tool missing');
    assert.ok(toolNames.includes('get_product'), 'get_product tool missing');
  });

  test('search_products returns results', async () => {
    const result = await client.callTool({
      name: 'search_products',
      arguments: { query: 'laptop', limit: 5 },
    });
    assert.ok(!result.isError);
    const parsed = JSON.parse(result.content[0].text);
    assert.ok(Array.isArray(parsed));
  });

  test('get_product returns 404 error for unknown id', async () => {
    const result = await client.callTool({
      name: 'get_product',
      arguments: { id: 'nonexistent-99999' },
    });
    assert.ok(result.isError);
    assert.ok(result.content[0].text.includes('not found'));
  });
});
Testing pyramid for MCP servers unit handlers integration client-server contract tests e2e dark diagram
Testing pyramid: unit tests for handlers, integration tests for the full MCP round trip, e2e for business flows.

Protocol Versioning

// server.js - declare server version in metadata
const server = new McpServer({
  name: 'product-server',
  version: process.env.npm_package_version ?? '1.0.0',
});

// Add a version resource so clients can check compatibility
server.resource('server://version', 'application/json', async () => ({
  contents: [{
    uri: 'server://version',
    mimeType: 'application/json',
    text: JSON.stringify({
      serverVersion: process.env.npm_package_version,
      mcpProtocolVersion: '2025-11-05',
      minimumClientVersion: '1.0.0',
      breaking_changes: [],
    }),
  }],
}));

GitHub Actions CI Pipeline

name: MCP Server CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: mcp_test
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: testpass
        ports:
          - 5432:5432
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm test
        env:
          TEST_DATABASE_URL: postgresql://postgres:testpass@localhost:5432/mcp_test

  build-and-push:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }},ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to production
        run: |
          ssh deploy@production "
            docker pull ghcr.io/${{ github.repository }}:${{ github.sha }} &&
            docker service update --image ghcr.io/${{ github.repository }}:${{ github.sha }} mcp-product-server
          "

Zero-Downtime Deployment with Docker Swarm

# Rolling update: replace instances one at a time, wait for health checks
docker service update \
  --image ghcr.io/myorg/mcp-product-server:v1.3.0 \
  --update-parallelism 1 \
  --update-delay 10s \
  --update-failure-action rollback \
  --health-cmd "wget -qO- http://localhost:3000/health || exit 1" \
  --health-interval 10s \
  --health-retries 3 \
  mcp-product-server

What to Build Next

  • Write integration tests for your top 3 most-used tools using the client-server pattern above. Run them with node --test.
  • Add the GitHub Actions pipeline from this lesson to your MCP server repo. Verify that a failing test blocks the build.

nJoy 😉

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.