CI/CD Integration
Integrating MCP server testing into CI/CD pipelines ensures every change is tested before reaching production. This chapter covers patterns for GitHub Actions, GitLab CI, and other CI systems.
Why CI/CD matters for MCP servers:
- Catches bugs before they reach production
- Ensures consistent quality across all changes
- Provides confidence for rapid iteration
- Documents the expected behavior through passing tests
- Enables safe, automated deployments
Pipeline Architecture
A well-designed pipeline progresses through stages, with each stage adding more confidence. If any stage fails, deployment stops. This "fail fast" approach catches problems early when they're cheapest to fix.
┌─────────────────────────────────────────────────────────────────────┐
│ MCP Server CI/CD Pipeline │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ COMMIT │ │
│ └─────────────────────────────┬──────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ STAGE 1: Build & Unit Tests │ │
│ │ • cargo build --release │ │
│ │ • cargo test --all-features │ │
│ │ • cargo clippy │ │
│ │ ⏱ ~3-5 minutes │ │
│ └─────────────────────────────┬──────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ STAGE 2: Integration Tests │ │
│ │ • Start local server with test database │ │
│ │ • cargo pmcp test run (full suite) │ │
│ │ • Generate coverage report │ │
│ │ ⏱ ~5-10 minutes │ │
│ └─────────────────────────────┬──────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ STAGE 3: Deploy to Staging │ │
│ │ • Build container/package │ │
│ │ • Deploy to staging environment │ │
│ │ • Wait for deployment to stabilize │ │
│ │ ⏱ ~5-10 minutes │ │
│ └─────────────────────────────┬──────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ STAGE 4: Staging Tests │ │
│ │ • Smoke tests (critical paths) │ │
│ │ • Full integration suite │ │
│ │ • Performance validation │ │
│ │ ⏱ ~5-15 minutes │ │
│ └─────────────────────────────┬──────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ STAGE 5: Production Deployment │ │
│ │ • Canary deployment (10%) │ │
│ │ • Smoke tests on canary │ │
│ │ • Gradual rollout (25%, 50%, 100%) │ │
│ │ • Monitor for errors │ │
│ │ ⏱ ~15-30 minutes │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
GitHub Actions Configuration
GitHub Actions is the most common CI/CD platform for Rust projects. The workflows below are production-ready templates you can adapt for your MCP server.
Complete Workflow
This workflow demonstrates a full pipeline from commit to production. Study each job to understand its purpose, then customize for your needs.
# .github/workflows/ci.yml
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
# ============================================
# Stage 1: Build and Lint
# ============================================
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-action@stable
with:
components: rustfmt, clippy
- name: Cache dependencies
uses: Swatinem/rust-cache@v2
- name: Check formatting
run: cargo fmt --check
- name: Clippy
run: cargo clippy --all-features -- -D warnings
- name: Build
run: cargo build --release
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: mcp-server
path: target/release/mcp-server
# ============================================
# Stage 1b: Unit Tests (parallel with build)
# ============================================
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-action@stable
- name: Cache dependencies
uses: Swatinem/rust-cache@v2
- name: Run unit tests
run: cargo test --all-features --lib
- name: Generate coverage
run: |
cargo install cargo-tarpaulin
cargo tarpaulin --out Xml --output-dir coverage/
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: coverage/cobertura.xml
# ============================================
# Stage 2: Integration Tests
# ============================================
integration-tests:
needs: [build, unit-tests]
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: test_password
POSTGRES_DB: mcp_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Download binary
uses: actions/download-artifact@v4
with:
name: mcp-server
path: ./bin
- name: Make executable
run: chmod +x ./bin/mcp-server
- name: Setup database
run: |
PGPASSWORD=test_password psql -h localhost -U postgres -d mcp_test \
-f tests/fixtures/schema.sql
- name: Start MCP server
run: |
./bin/mcp-server &
echo $! > /tmp/server.pid
sleep 5
env:
DATABASE_URL: postgres://postgres:test_password@localhost:5432/mcp_test
PORT: 3000
- name: Install pmcp
run: cargo install cargo-pmcp
- name: Run mcp-tester
run: |
cargo pmcp test run \
--server http://localhost:3000/mcp \
--scenario tests/scenarios/ \
--format junit \
--output test-results/integration.xml
- name: Stop server
if: always()
run: kill $(cat /tmp/server.pid) || true
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-results
path: test-results/
- name: Publish test report
uses: dorny/test-reporter@v1
if: always()
with:
name: Integration Tests
path: test-results/*.xml
reporter: java-junit
# ============================================
# Stage 3: Deploy to Staging
# ============================================
deploy-staging:
needs: integration-tests
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: staging
outputs:
deployment_url: ${{ steps.deploy.outputs.url }}
steps:
- uses: actions/checkout@v4
- name: Download binary
uses: actions/download-artifact@v4
with:
name: mcp-server
- name: Deploy to staging
id: deploy
run: |
# Example: Deploy to Cloud Run
gcloud run deploy mcp-server-staging \
--source . \
--region us-central1 \
--set-env-vars "ENV=staging" \
--format "value(status.url)" > /tmp/url.txt
echo "url=$(cat /tmp/url.txt)" >> $GITHUB_OUTPUT
- name: Wait for deployment
run: |
# Wait for service to be ready
for i in {1..30}; do
if curl -sf "${{ steps.deploy.outputs.url }}/health"; then
echo "Service is healthy"
exit 0
fi
echo "Waiting for service... ($i/30)"
sleep 10
done
echo "Service failed to become healthy"
exit 1
# ============================================
# Stage 4: Staging Tests
# ============================================
test-staging:
needs: deploy-staging
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Install pmcp
run: cargo install cargo-pmcp
- name: Smoke tests
run: |
cargo pmcp test run \
--server "${{ needs.deploy-staging.outputs.deployment_url }}/mcp" \
--header "Authorization: Bearer ${{ secrets.STAGING_API_KEY }}" \
--scenario tests/scenarios/smoke/ \
--fail-fast \
--format junit \
--output test-results/staging-smoke.xml
- name: Full integration tests
run: |
cargo pmcp test run \
--server "${{ needs.deploy-staging.outputs.deployment_url }}/mcp" \
--header "Authorization: Bearer ${{ secrets.STAGING_API_KEY }}" \
--scenario tests/scenarios/integration/ \
--format junit \
--output test-results/staging-full.xml
- name: Upload results
uses: actions/upload-artifact@v4
if: always()
with:
name: staging-results
path: test-results/
# ============================================
# Stage 5: Production Deployment
# ============================================
deploy-production:
needs: test-staging
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy canary (10%)
run: |
gcloud run services update-traffic mcp-server \
--to-revisions LATEST=10
- name: Test canary
run: |
cargo pmcp test run \
--server "https://mcp.example.com/mcp" \
--header "Authorization: Bearer ${{ secrets.PROD_API_KEY }}" \
--scenario tests/scenarios/smoke/ \
--fail-fast
- name: Promote to 50%
run: |
gcloud run services update-traffic mcp-server \
--to-revisions LATEST=50
- name: Monitor for 5 minutes
run: |
# Check error rates
for i in {1..10}; do
cargo pmcp test run \
--server "https://mcp.example.com/mcp" \
--header "Authorization: Bearer ${{ secrets.PROD_API_KEY }}" \
--scenario tests/scenarios/smoke/ \
--quiet
sleep 30
done
- name: Full rollout
run: |
gcloud run services update-traffic mcp-server \
--to-revisions LATEST=100
- name: Rollback on failure
if: failure()
run: |
gcloud run services update-traffic mcp-server \
--to-revisions PREVIOUS=100
Reusable Workflow
# .github/workflows/mcp-test.yml
name: MCP Test Workflow
on:
workflow_call:
inputs:
server_url:
required: true
type: string
scenarios:
required: false
type: string
default: "tests/scenarios/"
fail_fast:
required: false
type: boolean
default: false
secrets:
api_key:
required: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pmcp
run: cargo install cargo-pmcp
- name: Run tests
run: |
AUTH_HEADER=""
if [ -n "${{ secrets.api_key }}" ]; then
AUTH_HEADER="--header \"Authorization: Bearer ${{ secrets.api_key }}\""
fi
FAIL_FAST=""
if [ "${{ inputs.fail_fast }}" == "true" ]; then
FAIL_FAST="--fail-fast"
fi
cargo pmcp test run \
--server "${{ inputs.server_url }}" \
$AUTH_HEADER \
--scenario "${{ inputs.scenarios }}" \
$FAIL_FAST \
--format junit \
--output test-results/results.xml
- name: Upload results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: test-results/
Using the reusable workflow:
# .github/workflows/test-all-environments.yml
name: Test All Environments
on:
schedule:
- cron: '0 */6 * * *' # Every 6 hours
jobs:
test-staging:
uses: ./.github/workflows/mcp-test.yml
with:
server_url: https://staging.mcp.example.com/mcp
scenarios: tests/scenarios/
secrets:
api_key: ${{ secrets.STAGING_API_KEY }}
test-production:
uses: ./.github/workflows/mcp-test.yml
with:
server_url: https://mcp.example.com/mcp
scenarios: tests/scenarios/smoke/
fail_fast: true
secrets:
api_key: ${{ secrets.PROD_API_KEY }}
Test Result Reporting
Good reporting makes the difference between "tests failed" and "tests failed and here's exactly what broke." CI systems can parse standardized formats like JUnit XML to display results inline with pull requests.
JUnit Format for CI Systems
JUnit XML is the universal format for test results. Almost every CI system can parse it to show test results, highlight failures, and track trends over time.
# Generate JUnit XML for CI parsing
cargo pmcp test run \
--server http://localhost:3000/mcp \
--format junit \
--output test-results/results.xml
The output looks like:
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="mcp-tests" tests="15" failures="1" time="5.234">
<testsuite name="smoke/health_check.yaml" tests="3" failures="0" time="1.234">
<testcase name="Server responds" time="0.456"/>
<testcase name="Execute simple query" time="0.789"/>
<testcase name="Sample rows work" time="0.234"/>
</testsuite>
<testsuite name="integration/crud.yaml" tests="5" failures="1" time="2.567">
<testcase name="Create record" time="0.234"/>
<testcase name="Read record" time="0.123"/>
<testcase name="Update record" time="0.345">
<failure message="Assertion failed: content.contains('updated')">
Expected content to contain 'updated', got: '{"status":"unchanged"}'
</failure>
</testcase>
<testcase name="Delete record" time="0.234"/>
<testcase name="Verify deletion" time="0.123"/>
</testsuite>
</testsuites>
GitHub Annotations
- name: Annotate failures
if: failure()
run: |
# Parse JUnit and create annotations
python3 << 'EOF'
import xml.etree.ElementTree as ET
tree = ET.parse('test-results/results.xml')
for testsuite in tree.findall('.//testsuite'):
for testcase in testsuite.findall('testcase'):
failure = testcase.find('failure')
if failure is not None:
name = testcase.get('name')
message = failure.get('message')
print(f"::error title=Test Failed: {name}::{message}")
EOF
Slack Notifications
- name: Notify on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "MCP Tests Failed",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*MCP Server Tests Failed* :x:\n\n*Branch:* ${{ github.ref_name }}\n*Commit:* ${{ github.sha }}\n*Author:* ${{ github.actor }}"
}
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "View Run"},
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
]
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Parallel Test Execution
Large test suites can take a long time to run. Parallelization splits tests across multiple runners, dramatically reducing total time. The trade-off: more complex configuration and potential for resource contention.
Matrix Strategy
GitHub Actions' matrix feature runs the same job with different parameters. Use it to split tests by category (smoke, integration, security) or by test file.
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
scenario-dir:
- tests/scenarios/smoke
- tests/scenarios/integration
- tests/scenarios/performance
- tests/scenarios/security
fail-fast: false
steps:
- uses: actions/checkout@v4
- name: Run tests
run: |
cargo pmcp test run \
--server http://localhost:3000/mcp \
--scenario ${{ matrix.scenario-dir }}/ \
--format junit \
--output test-results/${{ matrix.scenario-dir }}.xml
aggregate:
needs: test
runs-on: ubuntu-latest
if: always()
steps:
- name: Download all results
uses: actions/download-artifact@v4
with:
path: all-results
- name: Merge results
run: |
# Combine all JUnit files
npx junit-merge -d all-results -o final-results.xml
Parallel Within mcp-tester
# Run scenarios in parallel
cargo pmcp test run \
--server http://localhost:3000/mcp \
--scenario tests/scenarios/ \
--parallel 4 # Run 4 scenarios concurrently
Caching Strategies
Rust builds are notoriously slow because of the compilation model. Caching compiled dependencies between runs can cut build times from 10+ minutes to under 2 minutes. The key is caching the right things.
Rust Build Cache
The rust-cache action intelligently caches compiled dependencies while invalidating when Cargo.lock or Cargo.toml changes. This single action can save 5-10 minutes per CI run.
- name: Cache Rust
uses: Swatinem/rust-cache@v2
with:
shared-key: "mcp-server"
cache-targets: true
Docker Layer Cache
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
Summary
Effective CI/CD integration requires:
- Staged pipeline - Build → Test → Deploy → Verify
- Parallel execution - Run independent jobs concurrently
- Proper reporting - JUnit format for CI parsing
- Notifications - Alert on failures
- Caching - Speed up builds with proper caching
- Rollback strategy - Auto-rollback on test failures
Practice Ideas
These informal exercises help reinforce the concepts. For structured exercises with starter code and tests, see the chapter exercise pages.
- Set up GitHub Actions - Create a complete CI pipeline for an MCP server
- Add test reporting - Configure JUnit reporting and GitHub annotations
- Implement canary deployment - Add gradual rollout with testing gates
- Add Slack notifications - Alert the team on test failures
Continue to Regression Testing →