Guide · CI/CD

MCP Servers with Jenkins — declarative pipeline, Docker builds, and protocol verification

Jenkins remains one of the most widely deployed CI/CD systems in the world, particularly in enterprise environments where self-hosted infrastructure is required, where existing Jenkins investment is deep, or where the sheer volume of build jobs makes a SaaS CI cost prohibitive. For MCP server teams in such organisations, understanding how to write a clean Jenkins declarative pipeline — covering Docker builds, credential injection, parallel test stages, deployment, and post-deploy protocol verification — is practical, immediate knowledge. This guide covers the Jenkins Declarative Pipeline syntax from first principles, the Docker Pipeline plugin for containerised build steps, Jenkins Shared Libraries for reusable MCP protocol probe logic, and how to wire the post {} block to register deployments with AliveMCP.

TL;DR

Write a Jenkinsfile using Declarative Pipeline syntax with an agent block (Docker image per stage), when { branch 'main' } to gate deploy stages, withCredentials or the credentials() function for secret injection, and a post { success { } } block that runs the MCP protocol probe and AliveMCP registration after a successful deploy — keeping monitoring always synchronised with the running server.

Declarative Pipeline vs Scripted Pipeline

Jenkins has two pipeline syntaxes: Declarative and Scripted. Scripted Pipelines are written in full Groovy — essentially arbitrary code wrapped in a node { } block. They are maximally flexible but also maximally difficult to audit, review, and enforce guardrails on. Declarative Pipelines are a structured DSL with a fixed schema: the top-level pipeline { } block contains exactly agent, environment, stages, post, and a few other keys. If your Declarative Pipeline file does not match the expected structure, Jenkins rejects it at parse time with a clear error message rather than failing silently at runtime.

For MCP server teams, Declarative Pipeline is the right choice in almost every case. The structural constraint makes Jenkinsfiles readable by engineers who do not know Groovy, auditable by security teams, and consistent across projects. The one common reason to reach for Scripted Pipeline is when you need dynamic stage generation — creating a variable number of stages at runtime based on a list. Even then, the preferred approach is to use a single Declarative stage with a Scripted block inside (script { }), keeping as much structure as possible in the Declarative wrapper.

The fundamental anatomy of a Declarative Pipeline for an MCP server is: agent at the top level (default agent for the pipeline), an environment { } block that declares pipeline-wide variables and credential bindings, a stages { } block containing one stage() per major phase, and a post { } block with condition blocks (always { }, success { }, failure { }) for cleanup and notifications. Each stage has its own steps { } block with the commands to run. Stages can override the agent, specify their own environment, and declare their own when { } conditions.

Docker Pipeline plugin: per-stage container agents

The Docker Pipeline plugin (officially "Docker Pipeline") allows a stage to specify agent { docker { image 'node:20-alpine' } }, which runs the stage's steps inside that Docker container rather than directly on the Jenkins agent host. This is the key mechanism for reproducible MCP server builds: you specify the exact Node.js version you need, and Jenkins pulls that image and runs your build commands inside it. No npm version conflicts with other jobs, no leftover node_modules from a previous run, no dependency on what is installed on the Jenkins host.

For this to work, the Jenkins agent must have Docker installed and accessible. The Jenkins user (or the user the agent runs as) must be a member of the docker group, or the Jenkins agent must be running as root (not recommended for production). On agents managed by the Kubernetes Plugin or the Docker Cloud plugin, the container execution is handled by the plugin infrastructure; for bare metal or VM agents, a simple usermod -aG docker jenkins on the agent host followed by a service restart is the typical setup.

When using label-based agent selection (agent { label 'docker' }), you are directing the pipeline to run on an agent that has been configured with the docker label in Jenkins' Manage Nodes view. This is the recommended pattern for production Jenkins setups where some agents have Docker and some do not — label your Docker-capable agents and your pipeline will never accidentally run on an agent without Docker. The when { } condition on build stages should include a branch check so Docker-dependent steps only run on branches that are actually deployed.

Building and pushing Docker images from within a Jenkins Declarative Pipeline uses the Docker Pipeline plugin's docker global variable. docker.build("my-mcp-server:${env.GIT_COMMIT_SHORT}") returns a Docker image object. docker.withRegistry("https://registry.example.com", "registry-credentials") authenticates with the registry using a credential stored in the Jenkins Credentials Store and provides a scope within which you can call image.push(). This keeps the registry password out of the shell command history and out of the Jenkins console log, unlike the equivalent sh 'docker login -u user -p password registry.example.com' command.

Jenkins Credentials Store and secret injection

Jenkins stores secrets in its Credentials Store, accessible at Manage Jenkins → Credentials. The relevant credential types for MCP server pipelines are: SSH Username with private key (for SSH-based deployment), Secret text (for API keys like your AliveMCP API key), Username with password (for Docker registry login), and Secret file (for kubeconfig files used with Kubernetes deployments).

In a Declarative Pipeline, credentials are injected via the environment { } block using the credentials() helper, or within a step using withCredentials([...]). The environment block approach is cleaner for variables used across multiple stages. For a Username with password credential, Jenkins automatically creates two variables: MY_CREDS_USR and MY_CREDS_PSW from a binding named MY_CREDS:

environment {
  // Binds DOCKER_CREDS_USR and DOCKER_CREDS_PSW automatically
  DOCKER_CREDS       = credentials('docker-registry-credentials')
  // Binds the secret text value directly
  ALIVEMCP_API_KEY   = credentials('alivemcp-api-key')
  // Non-secret config — hardcoded here or from Jenkins global properties
  MCP_ENDPOINT       = 'https://mcp.example.com/mcp'
  IMAGE_REGISTRY     = 'registry.example.com'
  IMAGE_NAME         = 'myorg/mcp-server'
}

For SSH keys used in a single deploy stage, withCredentials is more appropriate because it limits the secret's exposure to that specific block:

withCredentials([sshUserPrivateKey(
  credentialsId: 'deploy-ssh-key',
  keyFileVariable: 'SSH_KEY_FILE',
  usernameVariable: 'SSH_USER'
)]) {
  sh """
    chmod 600 \${SSH_KEY_FILE}
    ssh -i \${SSH_KEY_FILE} -o StrictHostKeyChecking=no \\
      \${SSH_USER}@\${DEPLOY_HOST} \\
      'docker pull \${IMAGE_TAG} && docker restart mcp-server'
  """
}

Jenkins automatically masks the values of credentials-bound variables in the console log — any occurrence of the secret value in the output is replaced with ****. However, masking is applied on a best-effort basis. If you concatenate a secret with other strings in a shell command, the combined string may appear in logs unmasked. Always quote credential variables and avoid printing them in debug output.

when{} directives, parallel stages, and stash/unstash

The when { } directive controls whether a stage runs. The most common conditions for MCP server pipelines are when { branch 'main' } to restrict deploy stages to the main branch, and when { changeRequest() } to run PR-specific stages only on pull requests. Multiple conditions can be combined with allOf { } or anyOf { }. The when block is evaluated before the stage's agent is allocated, which means a skipped stage does not consume a build executor while the condition is being evaluated.

Parallel stages run multiple build processes simultaneously, reducing wall-clock time for test phases that can be parallelised. Jenkins Declarative Pipeline supports parallelism via the parallel { } block inside a stage:

stage('Test') {
  parallel {
    stage('Unit tests') {
      agent { docker { image 'node:20-alpine'; args '-u root' } }
      steps { sh 'npm ci && npm test -- --ci' }
    }
    stage('Lint') {
      agent { docker { image 'node:20-alpine'; args '-u root' } }
      steps { sh 'npm ci && npm run lint' }
    }
    stage('Type check') {
      agent { docker { image 'node:20-alpine'; args '-u root' } }
      steps { sh 'npm ci && npx tsc --noEmit' }
    }
  }
}

Stash and unstash pass files between stages that run on different agents. After running npm run build in a Node.js Docker container, you can stash name: 'dist', includes: 'dist/**' to archive the output, then unstash 'dist' in the Docker build stage to make the compiled output available without re-running the build. This avoids redundant compilation and ensures the Docker image contains exactly the build output that was tested — the same artifact that passed the test stage.

Complete Declarative Jenkinsfile for MCP server CI/CD

The following Jenkinsfile implements the full pipeline: parallel tests, Docker build and push, SSH-based deploy to a VPS, MCP protocol verification, and AliveMCP registration in the post block.

pipeline {
  agent none  // Each stage specifies its own agent

  environment {
    IMAGE_REGISTRY   = 'registry.example.com'
    IMAGE_NAME       = 'myorg/mcp-server'
    DEPLOY_HOST      = 'mcp.example.com'
    MCP_ENDPOINT     = 'https://mcp.example.com/mcp'
    ALIVEMCP_API_KEY = credentials('alivemcp-api-key')
    DOCKER_CREDS     = credentials('docker-registry-credentials')
  }

  stages {

    // ── 1. Parallel test stage ─────────────────────────────
    stage('Test') {
      parallel {

        stage('Unit tests') {
          agent { docker { image 'node:20-alpine'; args '-u root' } }
          steps {
            sh 'npm ci'
            sh 'npm test -- --ci --coverage --reporters=default --reporters=jest-junit'
          }
          post {
            always { junit allowEmptyResults: true, testResults: 'junit-results.xml' }
          }
        }

        stage('Lint & type-check') {
          agent { docker { image 'node:20-alpine'; args '-u root' } }
          steps {
            sh 'npm ci'
            sh 'npm run lint'
            sh 'npx tsc --noEmit'
          }
        }

      }
    }

    // ── 2. Build and push Docker image ─────────────────────
    stage('Build image') {
      agent { label 'docker' }
      when {
        anyOf { branch 'main'; buildingTag() }
      }
      steps {
        script {
          def shortSha = sh(returnStdout: true, script: 'git rev-parse --short HEAD').trim()
          def imageTag = "${env.IMAGE_REGISTRY}/${env.IMAGE_NAME}:${shortSha}"
          env.IMAGE_TAG = imageTag

          def image = docker.build(imageTag, '--pull .')
          docker.withRegistry("https://${env.IMAGE_REGISTRY}", 'docker-registry-credentials') {
            image.push()
            image.push('latest')
          }
        }
      }
    }

    // ── 3. Deploy to production ────────────────────────────
    stage('Deploy') {
      agent { docker { image 'alpine:3.19' } }
      when { branch 'main' }
      input {
        message "Deploy ${env.IMAGE_TAG} to production?"
        ok "Deploy"
        submitter "devops-team"
      }
      steps {
        withCredentials([sshUserPrivateKey(
          credentialsId: 'deploy-ssh-key',
          keyFileVariable: 'SSH_KEY',
          usernameVariable: 'SSH_USER'
        )]) {
          sh """
            apk add --no-cache openssh-client curl jq
            chmod 600 \${SSH_KEY}

            ssh -i \${SSH_KEY} \\
                -o StrictHostKeyChecking=no \\
                -o BatchMode=yes \\
                \${SSH_USER}@${env.DEPLOY_HOST} << 'REMOTE'
              set -euo pipefail
              docker login ${env.IMAGE_REGISTRY} \\
                -u ${env.DOCKER_CREDS_USR} \\
                -p ${env.DOCKER_CREDS_PSW}
              docker pull ${env.IMAGE_TAG}
              docker stop mcp-server 2>/dev/null || true
              docker rm mcp-server 2>/dev/null || true
              docker run -d \\
                --name mcp-server \\
                --restart unless-stopped \\
                -p 3000:3000 \\
                -e NODE_ENV=production \\
                ${env.IMAGE_TAG}
REMOTE
          """
        }

        // Allow the container startup time before probing
        sh 'sleep 10'

        // MCP Protocol Verification
        sh """
          RESPONSE=\$(curl -sf --max-time 20 \\
            -X POST "${env.MCP_ENDPOINT}" \\
            -H "Content-Type: application/json" \\
            -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"jenkins-probe","version":"1.0"}}}')
          echo "MCP response: \$RESPONSE"
          PROTO=\$(echo "\$RESPONSE" | jq -r '.result.protocolVersion // empty')
          if [ -z "\$PROTO" ]; then
            echo "ERROR: MCP protocol verification failed — no protocolVersion in response"
            exit 1
          fi
          echo "MCP server protocol verified: \$PROTO"
        """
      }
    }

  }

  post {
    success {
      script {
        if (env.BRANCH_NAME == 'main') {
          sh """
            curl -sf \\
              -X POST "https://alivemcp.com/api/register" \\
              -H "Authorization: Bearer ${env.ALIVEMCP_API_KEY}" \\
              -H "Content-Type: application/json" \\
              -d "{\"slug\":\"my-mcp-server\",\"url\":\"${env.MCP_ENDPOINT}\",\"version\":\"${env.IMAGE_TAG}\"}" \\
              && echo "AliveMCP registration OK" \\
              || echo "WARNING: AliveMCP registration failed (non-fatal)"
          """
        }
      }
    }
    failure {
      mail to: 'oncall@example.com',
           subject: "Jenkins pipeline failed: ${env.JOB_NAME} #${env.BUILD_NUMBER}",
           body: "Build failed. Console output: ${env.BUILD_URL}console"
    }
    always {
      cleanWs()
    }
  }
}

The input { } directive pauses the pipeline and displays an approval dialog in the Blue Ocean or classic Jenkins UI. The submitter field restricts who can approve the deploy — in this example, members of the devops-team Jenkins group. If no approval is given within a configurable timeout, the pipeline is aborted. This is the Jenkins equivalent of a GitLab manual job or a GitHub Environment approval gate.

Jenkins Shared Libraries for reusable MCP protocol probe

When you maintain multiple MCP servers each with their own Jenkins pipeline, duplicating the MCP protocol probe shell script in every Jenkinsfile is a maintenance liability. Jenkins Shared Libraries let you extract reusable Groovy code and shell scripts into a separate Git repository that any pipeline can import. Create a repository with the following structure:

mcp-shared-lib/
  vars/
    mcpProtocolProbe.groovy   # callable as mcpProtocolProbe(url: '...', timeout: 20)
    registerAliveMcp.groovy   # callable as registerAliveMcp(slug: '...', url: '...')
  src/
    com/example/McpUtils.groovy  # optional Groovy class for complex logic

The vars/mcpProtocolProbe.groovy file defines a call(Map config) method. In the consuming Jenkinsfile, declare the library with @Library('mcp-shared-lib') _ and call mcpProtocolProbe url: env.MCP_ENDPOINT, timeout: 20 in any stage. Changes to the probe logic in the shared library are immediately picked up by all pipelines on their next run — no per-pipeline Jenkinsfile changes required. Configure shared libraries in Manage Jenkins → Configure System → Global Pipeline Libraries, pointing to the Git repository and specifying a default branch or tag.

Regarding the Blue Ocean UI versus the classic Jenkins UI: Blue Ocean provides a visual pipeline graph with colour-coded stage status, making it easy to see which parallel stage failed and to follow log streams per stage. The classic UI shows a textual stage view. Both UIs access the same pipeline execution data. For teams new to Jenkins, Blue Ocean significantly reduces the cognitive overhead of interpreting pipeline results — enable it even if you keep the classic UI for administrative tasks.

Frequently asked questions

What is the difference between Declarative and Scripted Pipeline, and which should I use for an MCP server?

Declarative Pipeline is a structured DSL defined with specific keys (pipeline, agent, stages, environment, post) that Jenkins validates at parse time. If the structure is wrong, Jenkins rejects the file before starting the build. Scripted Pipeline is raw Groovy code in a node { } block — maximally flexible but with no schema, failing at runtime rather than parse time. For MCP server deployments, use Declarative Pipeline. It is more readable, easier to audit, and enforces good structure by default. The one scenario where Scripted Pipeline is genuinely necessary — dynamically generating stages from a list at runtime — should be handled by using a script { } block inside a Declarative stage, which lets you write arbitrary Groovy for that specific step while keeping the outer structure declarative. If you have existing Scripted Pipelines, migrate to Declarative incrementally by wrapping the logic in the Declarative structure.

How does Jenkins handle Docker builds — does it need Docker installed on every agent?

The Docker Pipeline plugin requires Docker to be installed on the specific agent that runs the Docker-using stage. The common patterns are: (1) label-based routing — label agents with Docker installed as docker and use agent { label 'docker' } for Docker stages; (2) the Jenkins Kubernetes Plugin — every build step runs in an ephemeral Kubernetes Pod with Docker-in-Docker or with a kaniko sidecar, avoiding the need to install Docker on Jenkins itself; (3) a static agent with Docker — a dedicated "build agent" VM has Docker installed and handles all image build jobs. The Docker Pipeline plugin's docker.build() and docker.withRegistry() methods wrap the Docker CLI commands with proper credential handling and log masking, which is preferable to raw sh 'docker login ... && docker build ...' commands because credentials are not exposed in the shell history or the console output.

How do I store and inject secrets safely in Jenkins for my MCP server pipeline?

Store all secrets in the Jenkins Credentials Store (Manage Jenkins → Credentials), never in environment variables set in the Jenkins controller configuration or hardcoded in the Jenkinsfile. The Credentials Store supports: Secret text for API keys (like your AliveMCP API key), SSH Username with private key for SSH-based deploys, Username with password for Docker registry authentication, and Secret file for kubeconfig files. In your Jenkinsfile, reference credentials using credentials('credential-id') in the environment { } block — Jenkins masks the credential value in all console output automatically. For credentials that should only be exposed in a specific stage (like SSH keys for the deploy stage), use withCredentials([...]) within that stage's steps block to limit the exposure window. Never use echo or sh 'env' commands when credentials are in scope — even with masking, debugging output can inadvertently expose credential-derived values via string concatenation that Jenkins cannot mask.

How do I roll back a failed MCP server deployment in Jenkins?

Jenkins does not have a built-in "re-deploy previous version" button like GitLab or Azure DevOps. The rollback approach depends on how you tag your images. If you use immutable tags (:git-short-sha), find the previous successful build, note the Git commit SHA from the build's environment variables, and re-run the deploy stage for that build by replaying the pipeline with the same SHA. The most robust rollback pattern is to make the image tag a Jenkinsfile parameter so any tag can be deployed on demand: parameters { string(name: 'IMAGE_TAG', defaultValue: 'latest', description: 'Docker image tag to deploy') }. Add this at the top of your pipeline and the Deploy stage can use params.IMAGE_TAG to deploy any specific version. Keep the previous N image tags in your registry — a cleanup policy that deletes all but the latest tag prevents rollback to any version other than the most recent, which limits your options during an incident.

What is a Jenkins Shared Library and when should I use one for MCP server monitoring?

A Jenkins Shared Library is a Git repository containing Groovy code and scripts that can be imported into any Jenkinsfile across your organisation. It is most useful when you repeat the same logic across multiple Jenkinsfiles — for example, the MCP protocol probe (the curl command that posts an initialize request and verifies the response) or the AliveMCP registration call. Instead of copying this logic into every Jenkinsfile for every MCP server, you extract it into a shared library function and call it with server-specific parameters. When the probe logic needs to change — for example, updating the MCP protocol version in the initialize request — you update it in one place and all pipelines pick up the change on their next run. Configure shared libraries in Manage Jenkins → Configure System → Global Pipeline Libraries. Each library points to a Git repository and a default version (branch or tag). Import in your Jenkinsfile with @Library('library-name@version') _ at the top of the file.

Further reading

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free