Guide · CI/CD
MCP Servers with Azure DevOps Pipelines — build, deploy to Azure, and verify the MCP protocol
Azure DevOps Pipelines is Microsoft's YAML-based CI/CD platform, deeply integrated with Azure Container Registry, Azure Kubernetes Service, and Azure Key Vault. For organisations running MCP servers in the Azure cloud, Azure DevOps offers native service connections to these resources that eliminate most of the credential wiring required when using a generic CI system. This guide covers the full azure-pipelines.yml structure for an MCP server — building and pushing to ACR, deploying to AKS with deployment jobs, using Azure Key Vault as a secret backend via variable groups, configuring approval gates on the production environment, and running an MCP protocol verification step after every deploy to confirm the server is actually serving valid responses.
TL;DR
Define stages in azure-pipelines.yml (build → deploy-staging → deploy-production), use the Docker@2 task with an Azure Container Registry service connection for image builds, use deployment jobs with strategy: runOnce or strategy: rolling for Kubernetes deploys, link an Azure Key Vault to a variable group for secret management, add an approval check on the production Environment, and end every deploy stage with a Bash MCP protocol probe and AliveMCP registration call.
azure-pipelines.yml structure: triggers, stages, jobs, and steps
An azure-pipelines.yml file has four major levels of hierarchy: pipeline-level keys (trigger, pr, variables, stages), stages (major phases with a dependsOn graph), jobs within each stage (each job runs on a single agent), and steps within each job (individual tasks or scripts). The trigger key controls which branch pushes start the pipeline; the pr key controls pull request validation builds.
Azure DevOps and GitHub Actions are conceptually similar — both use YAML pipelines triggered by Git events with jobs running on hosted or self-hosted agents — but they differ in ecosystem. Azure DevOps uses service connections instead of plain secrets for connecting to Azure resources, Docker registries, and Kubernetes clusters. A service connection encapsulates the authentication details (client ID, tenant ID, subscription ID for ARM; registry URL and credentials for Docker; kubeconfig or endpoint for Kubernetes) and is referenced by name in the pipeline YAML without exposing the credentials in the file. This is the key operational difference: changing a service connection's credentials in Azure DevOps project settings updates every pipeline that references it, without touching the YAML files.
The dependsOn key creates explicit stage dependencies. A deploy-production stage with dependsOn: deploy-staging will not run until the staging stage completes successfully. Combined with condition: expressions, you can create sophisticated deployment gatekeeping — for example, only deploying to production on builds triggered by the main branch, not by pull requests: condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main')).
The distinction between a regular job and a deployment job is significant. A regular job has no concept of environments or deployment strategies — it just runs steps. A deployment job (declared with deployment: instead of job:) is linked to an Azure DevOps Environment, records the deployment in the Deployments view, and supports deployment strategies (runOnce, rolling, canary). For any job that deploys to a production system, always use a deployment job — it gives you deployment history, rollback visibility, and access to Environment-level approval gates.
Service connections: ACR, AKS, and ARM
Three service connections are relevant for an MCP server pipeline on Azure. An Azure Container Registry (ACR) service connection authenticates the Docker@2 task to push and pull images. Create it in Project Settings → Service connections → New → Docker Registry → Azure Container Registry, then select the subscription and registry. The resulting connection is referenced in the pipeline as containerRegistry: 'my-acr-connection'. Azure DevOps uses a system-assigned managed identity or a service principal with AcrPush role to authenticate — no password to rotate manually.
A Kubernetes service connection authenticates KubernetesManifest@1 and Kubectl@1 tasks to the AKS cluster. Create it under New service connection → Kubernetes → Azure Subscription, then select the cluster and namespace. Azure DevOps creates a namespace-scoped service account on the cluster with the permissions needed to deploy to that namespace. The connection is referenced as kubernetesServiceConnection: 'my-aks-connection' in the task. Namespace scoping is important for security: the pipeline service account can only deploy to the specified namespace, not to other namespaces or cluster-wide resources.
An Azure Resource Manager (ARM) service connection is used when pipeline steps need to interact with other Azure resources — for example, running az acr build for remote ACR builds, or using AzureCLI@2 to call the AliveMCP registration API via the Azure CLI (if you prefer the CLI over plain curl). Create it under New service connection → Azure Resource Manager → Service principal (automatic), which creates and configures a service principal automatically.
Variable groups and Azure Key Vault integration
Azure DevOps variable groups are named collections of variables that can be linked to pipelines. The most powerful variant is a Key Vault-linked variable group: in Library → Variable groups → Link secrets from an Azure Key Vault, you select a Key Vault and choose which secrets to expose as pipeline variables. The secrets are fetched at pipeline run time using the ARM service connection's identity — no secrets are stored in Azure DevOps itself, and secret rotation in Key Vault is immediately reflected in the next pipeline run.
Reference a variable group in your pipeline with:
variables:
- group: mcp-server-production-secrets # links Key Vault secrets
- name: IMAGE_REPOSITORY
value: 'myregistry.azurecr.io/mcp-server'
- name: K8S_NAMESPACE
value: 'production'
Variable groups can be scoped to specific stages or jobs using the stage-level variables: key rather than the pipeline-level key. This is the recommended pattern for production secrets: link the production Key Vault variable group only in the deploy-production stage so it is never available in the build or staging stages, reducing the blast radius if a build stage step were to inadvertently print environment variables.
For non-secret pipeline configuration, use plain pipeline variables defined in the YAML file or in the Azure DevOps pipeline definition UI (Pipeline → Edit → Variables). These are appropriate for things like the ACR registry hostname, the AKS cluster name, the MCP server's external URL, and the AliveMCP server slug — values that do not need to be secret and benefit from being visible in the YAML file for reviewability.
Deployment jobs: runOnce, rolling, and canary strategies
Azure DevOps deployment jobs support three strategies, each suited to a different deployment risk profile for MCP servers.
runOnce is the simplest: the deploy steps run once on a single agent. This is appropriate for VPS deploys over SSH or for small Kubernetes deployments with a single replica where you accept a brief downtime window. Use strategy: runOnce for staging environments and for simple production deployments where the MCP server has low traffic and the startup time is short.
rolling replaces instances incrementally. In Kubernetes terms, this maps to a rolling update: Azure DevOps applies the new manifest, waits for a configured percentage of pods to become healthy before continuing, and rolls back automatically if the health checks fail. Use strategy: rolling with maxParallel: 1 for production MCP servers running multiple replicas — this ensures at least one instance is always serving traffic during the rollout. The lifecycle hooks (preDeploy, deploy, routeTraffic, postRouteTraffic, on: failure, on: success) let you insert the MCP protocol probe at the postRouteTraffic hook, which runs after the new pods have received traffic but before the deploy job is marked as succeeded.
canary deploys to a percentage of capacity first, verifies, then proceeds to full rollout. You define the percentage steps with increments: [25, 50] — the pipeline will deploy to 25% of instances, pause, run the post-increment steps (your MCP probe), then deploy to 50%, pause, probe again, then complete. This is the highest-safety option for production MCP servers with traffic, as it limits the blast radius of a bad deploy to a fraction of users before detecting the problem. The canary strategy is only supported for Kubernetes and App Service targets — it is not applicable to VPS SSH deploys.
Azure DevOps Environments and approval gates
An Azure DevOps Environment is a logical deployment target (analogous to GitHub Environments or GitLab Environments). Environments appear in Pipelines → Environments and track deployment history. A deployment job that specifies environment: production will record each deployment against that environment, showing which pipeline run deployed which image version and when.
The most important feature of Azure DevOps Environments is checks and gates. On the production environment, go to Environments → production → Approvals and checks → Add → Approvals. You can configure a list of required approvers, a timeout, and instructions for the approver. When the pipeline reaches the deploy-production stage, it pauses, sends an email notification to the approvers, and waits for approval before running the deployment steps. This is the primary mechanism for human-in-the-loop release management in Azure DevOps — no code changes required in the YAML file, and the approval configuration is controlled by project administrators rather than developers.
Other available checks include: a Business hours gate (deployment only runs during specified hours), an Azure Monitor gate (deployment is blocked if an alert is currently firing), and a REST API gate (a custom webhook that your system must confirm before the deployment proceeds). For MCP server teams that integrate with an incident management system, the REST API gate can automatically block production deploys when an active incident is in progress — preventing "deploy during outage" mistakes.
Complete azure-pipelines.yml for MCP server CI/CD
The following pipeline builds the MCP server image, pushes it to ACR, deploys to AKS staging, runs a protocol probe, then deploys to AKS production after approval gate, with a final MCP verification and AliveMCP registration.
trigger:
branches:
include:
- main
paths:
exclude:
- '**/*.md'
pr:
branches:
include:
- main
variables:
- group: mcp-server-shared # non-secret config variable group
- name: IMAGE_REPOSITORY
value: 'mymcpregistry.azurecr.io/mcp-server'
- name: K8S_NAMESPACE_STAGING
value: 'mcp-staging'
- name: K8S_NAMESPACE_PROD
value: 'mcp-production'
- name: MCP_URL_STAGING
value: 'https://staging-mcp.example.com/mcp'
- name: MCP_URL_PROD
value: 'https://mcp.example.com/mcp'
stages:
# ── Build stage ───────────────────────────────────────────
- stage: Build
displayName: 'Build & push to ACR'
jobs:
- job: BuildImage
displayName: 'Docker build and push'
pool:
vmImage: 'ubuntu-latest'
steps:
- task: Docker@2
displayName: 'Build and push MCP server image'
inputs:
command: buildAndPush
containerRegistry: 'my-acr-service-connection'
repository: 'mcp-server'
dockerfile: '$(Build.SourcesDirectory)/Dockerfile'
buildContext: '$(Build.SourcesDirectory)'
tags: |
$(Build.BuildId)
$(Build.SourceVersion)
latest
# Export the image tag for downstream stages
- bash: |
echo "##vso[task.setvariable variable=IMAGE_TAG;isOutput=true]$(IMAGE_REPOSITORY):$(Build.BuildId)"
name: SetImageTag
displayName: 'Export image tag for downstream stages'
# ── Deploy to staging ─────────────────────────────────────
- stage: DeployStaging
displayName: 'Deploy to staging'
dependsOn: Build
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
variables:
# Staging secrets only available in this stage
- group: mcp-server-staging-secrets
IMAGE_TAG: $[ stageDependencies.Build.BuildImage.outputs['SetImageTag.IMAGE_TAG'] ]
jobs:
- deployment: DeployToStaging
displayName: 'Deploy MCP server to staging AKS'
environment: staging
pool:
vmImage: 'ubuntu-latest'
strategy:
runOnce:
deploy:
steps:
- task: KubernetesManifest@1
displayName: 'Set image in staging deployment'
inputs:
action: patch
kubernetesServiceConnection: 'aks-staging-connection'
namespace: $(K8S_NAMESPACE_STAGING)
resourceToPatch: 'deployment/mcp-server'
resourceType: 'Deployment'
patch: |
spec:
template:
spec:
containers:
- name: mcp-server
image: $(IMAGE_TAG)
- task: Kubernetes@1
displayName: 'Wait for staging rollout'
inputs:
connectionType: Kubernetes Service Connection
kubernetesServiceEndpoint: 'aks-staging-connection'
namespace: $(K8S_NAMESPACE_STAGING)
command: rollout
arguments: 'status deployment/mcp-server --timeout=5m'
# MCP Protocol Verification — staging
- task: Bash@3
displayName: 'Verify MCP protocol — staging'
inputs:
targetType: inline
script: |
set -euo pipefail
sleep 10 # allow ingress to propagate
RESPONSE=$(curl -sf --max-time 20 \
-X POST "$(MCP_URL_STAGING)" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": { "name": "ado-probe", "version": "1.0" }
}
}')
echo "MCP response: $RESPONSE"
PROTO=$(echo "$RESPONSE" | python3 -c \
"import sys,json; d=json.load(sys.stdin); print(d['result']['protocolVersion'])" \
2>/dev/null || echo "")
if [ -z "$PROTO" ]; then
echo "##vso[task.logissue type=error]MCP protocol verification FAILED on staging"
exit 1
fi
echo "Staging MCP server verified. Protocol version: $PROTO"
# ── Deploy to production ──────────────────────────────────
- stage: DeployProduction
displayName: 'Deploy to production'
dependsOn: DeployStaging
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
variables:
# Production secrets from Key Vault via variable group
- group: mcp-server-production-secrets
IMAGE_TAG: $[ stageDependencies.Build.BuildImage.outputs['SetImageTag.IMAGE_TAG'] ]
jobs:
- deployment: DeployToProduction
displayName: 'Deploy MCP server to production AKS'
# Approval gate is configured on this Environment in Azure DevOps UI
environment: production
pool:
vmImage: 'ubuntu-latest'
strategy:
rolling:
maxParallel: 1 # replace one pod at a time
deploy:
steps:
- task: KubernetesManifest@1
displayName: 'Rolling update — production'
inputs:
action: patch
kubernetesServiceConnection: 'aks-production-connection'
namespace: $(K8S_NAMESPACE_PROD)
resourceToPatch: 'deployment/mcp-server'
resourceType: 'Deployment'
patch: |
spec:
template:
spec:
containers:
- name: mcp-server
image: $(IMAGE_TAG)
- task: Kubernetes@1
displayName: 'Wait for production rollout'
inputs:
connectionType: Kubernetes Service Connection
kubernetesServiceEndpoint: 'aks-production-connection'
namespace: $(K8S_NAMESPACE_PROD)
command: rollout
arguments: 'status deployment/mcp-server --timeout=10m'
postRouteTraffic:
steps:
# MCP Protocol Verification — production
- task: Bash@3
displayName: 'Verify MCP protocol — production'
inputs:
targetType: inline
script: |
set -euo pipefail
RESPONSE=$(curl -sf --max-time 20 \
-X POST "$(MCP_URL_PROD)" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": { "name": "ado-probe", "version": "1.0" }
}
}')
echo "MCP response: $RESPONSE"
PROTO=$(echo "$RESPONSE" | python3 -c \
"import sys,json; d=json.load(sys.stdin); print(d['result']['protocolVersion'])" \
2>/dev/null || echo "")
if [ -z "$PROTO" ]; then
echo "##vso[task.logissue type=error]MCP protocol verification FAILED on production"
exit 1
fi
echo "Production MCP server verified. Protocol: $PROTO"
# AliveMCP registration
- task: Bash@3
displayName: 'Register with AliveMCP'
inputs:
targetType: inline
script: |
curl -sf \
-X POST "https://alivemcp.com/api/register" \
-H "Authorization: Bearer $(ALIVEMCP_API_KEY)" \
-H "Content-Type: application/json" \
-d "{
\"slug\": \"my-mcp-server\",
\"url\": \"$(MCP_URL_PROD)\",
\"version\": \"$(Build.BuildId)\"
}" \
&& echo "AliveMCP registration successful" \
|| echo "WARNING: AliveMCP registration failed (non-fatal)"
on:
failure:
steps:
- task: Bash@3
displayName: 'Rollback on failure'
inputs:
targetType: inline
script: |
echo "Deployment failed — triggering rollback"
kubectl rollout undo deployment/mcp-server \
--namespace=$(K8S_NAMESPACE_PROD)
echo "##vso[task.logissue type=warning]Production rollback triggered"
The postRouteTraffic lifecycle hook in the rolling strategy runs after the new pods have received live traffic. This is the right place for the MCP protocol probe — it verifies that the server is actually serving protocol-compliant responses to real traffic, not just that the pods are ready according to the liveness probe. The on: failure: hook triggers an automatic kubectl rollout undo if any step in the deploy lifecycle fails, which reverts the Kubernetes deployment to the previous revision.
Pipeline templates for DRY MCP verification
When you operate multiple MCP servers — each with its own Azure DevOps pipeline — duplicating the protocol verification Bash script in every pipeline file is a maintenance problem. Azure DevOps Pipeline Templates solve this: extract the verification steps into a separate YAML file, store it in a shared repository, and reference it from any pipeline with template: templates/mcp-verify.yml@shared-templates.
A template file at templates/mcp-verify.yml in a repository registered as a resource looks like:
# templates/mcp-verify.yml
parameters:
- name: mcpUrl
type: string
- name: alivemcpApiKey
type: string
- name: serverSlug
type: string
- name: buildId
type: string
steps:
- task: Bash@3
displayName: 'Verify MCP protocol at ${{ parameters.mcpUrl }}'
inputs:
targetType: inline
script: |
RESPONSE=$(curl -sf --max-time 20 \
-X POST "${{ parameters.mcpUrl }}" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{}}}')
echo "$RESPONSE" | python3 -c \
"import sys,json,sys; d=json.load(sys.stdin); v=d['result']['protocolVersion']; print(f'Protocol: {v}')" \
|| (echo "MCP probe failed" && exit 1)
- task: Bash@3
displayName: 'Register deployment with AliveMCP'
inputs:
targetType: inline
script: |
curl -sf \
-X POST "https://alivemcp.com/api/register" \
-H "Authorization: Bearer ${{ parameters.alivemcpApiKey }}" \
-H "Content-Type: application/json" \
-d "{\"slug\":\"${{ parameters.serverSlug }}\",\"url\":\"${{ parameters.mcpUrl }}\",\"version\":\"${{ parameters.buildId }}\"}"
Call this template from any pipeline stage with the template: key and pass the required parameters. When the MCP protocol version changes or the AliveMCP API format is updated, you modify the template in one repository and every pipeline using it picks up the change on its next run — no changes to individual pipeline YAML files required.
Frequently asked questions
What is a service connection in Azure DevOps and how is it different from a GitHub Actions secret?
A service connection is an Azure DevOps entity that encapsulates authentication to an external service — Azure Container Registry, Azure Kubernetes Service, a generic Docker registry, or an ARM subscription. It is defined once in Project Settings → Service connections and referenced by name in pipeline YAML (e.g., containerRegistry: 'my-acr-connection'). The credentials themselves are stored encrypted in Azure DevOps and are never exposed in the pipeline YAML file or in pipeline logs. A GitHub Actions secret is a named secret value stored encrypted in a repository or organisation, injected as an environment variable into workflow steps. The key practical difference is that Azure service connections use Azure AD managed identities or service principals with granular Azure RBAC roles — for example, the pipeline service connection might have only AcrPush role on the specific registry, not broader contributor access. This is more granular than a GitHub secret, which is just an opaque value that the step uses however it chooses. For MCP server teams already in Azure, service connections also eliminate credential rotation work: when a managed identity is used, there is no password to rotate at all.
How do Azure DevOps Environments and approval gates work for MCP server production deploys?
An Azure DevOps Environment is a deployment target entity defined in Pipelines → Environments. When a deployment job specifies environment: production, Azure DevOps links the deployment record to that environment and checks whether any checks are configured. Checks are configured in the Environment's settings — not in the pipeline YAML — which means project administrators control deployment approval requirements independently from the pipeline authors. The most common check for production MCP server deployments is the Approvals check: you specify a list of users or groups who must approve before the deployment proceeds. When the pipeline reaches the deployment job, it pauses and sends email notifications to approvers. An approver can view the pipeline run details, see which image is being deployed, and approve or reject. If the approval timeout expires without action, the deployment is automatically cancelled. Other check types include: Gates (polling an Azure Monitor alert or a REST API endpoint), which are re-evaluated on a schedule until they pass; and Exclusive lock (ensuring only one deployment to an environment can run at a time, preventing race conditions when multiple pull requests merge simultaneously).
When should I use runOnce vs rolling vs canary deployment strategy for an MCP server?
Use runOnce when your MCP server runs a single replica, during staging deploys, or when brief downtime is acceptable (e.g., maintenance window deployments). It is the simplest strategy and the right default for non-critical or low-traffic environments. Use rolling when your MCP server runs two or more Kubernetes replicas and must serve traffic continuously during the deployment. The rolling strategy replaces replicas incrementally, ensuring some instances are always available. Set maxParallel to 1 for conservative rolling (replace one pod at a time) or to 25% for faster rollouts that accept up to 25% simultaneous replacement. Use canary when your MCP server has significant user traffic and you want to validate the new version under real traffic conditions before full rollout. Canary requires the deployment infrastructure to support traffic splitting (an ingress controller like nginx or Istio that supports percentage-based routing). For most MCP server deployments serving developer tools or agentic systems (where traffic patterns are batch-like rather than continuous), rolling with maxParallel of 1 is the right balance of safety and simplicity.
How do I link Azure Key Vault secrets to an Azure DevOps pipeline?
In Azure DevOps, go to Pipelines → Library → Variable groups → New variable group. Toggle "Link secrets from an Azure Key Vault as variables", select your Azure subscription (via an ARM service connection), and select the Key Vault. You will see a list of all secrets in the vault — select the ones you want available in the pipeline (for example, ALIVEMCP-API-KEY, DATABASE-URL). Save the variable group with a name like mcp-server-production-secrets. In your pipeline YAML, reference it with - group: mcp-server-production-secrets in the variables: section of the stage where you need the secrets. The secrets are fetched at pipeline run time using the ARM service connection's identity — the values are never stored in Azure DevOps, and rotating a secret in Key Vault immediately affects the next pipeline run. Key Vault secrets are always masked in pipeline logs regardless of whether the variable group marks them as secret. For MCP server pipelines, store the AliveMCP API key, all database credentials, and any third-party API keys that your MCP tools call in Key Vault, and expose only the non-sensitive configuration (registry names, cluster names, namespace names) as plain YAML variables.
How do Azure DevOps pipeline templates help with MCP server monitoring across multiple servers?
Azure DevOps pipeline templates are YAML files stored in a repository that define reusable stages, jobs, or steps with parameters. For MCP server monitoring, extract the protocol verification curl command and the AliveMCP registration call into a template file in a shared repository. Any MCP server pipeline that needs post-deploy verification imports the template with template: templates/mcp-verify.yml@my-shared-templates and passes the parameters (MCP URL, server slug, API key variable name). The template handles all the curl logic, jq or python3 parsing, error handling, and log formatting consistently across all servers. When the MCP protocol version is updated from 2025-03-26 to a newer version in the initialize request, or when the AliveMCP API endpoint changes, you update the template in one place and all pipelines using it are updated automatically on their next run. Register the shared template repository as a resource in each consuming pipeline with the resources.repositories key and grant the pipeline service account read access to that repository in Azure DevOps project settings.
Further reading
- MCP Server CI/CD — general patterns and pipeline design principles
- MCP Servers with GitHub Actions — workflow files, secrets, and deployment
- MCP Servers with GitLab CI/CD — pipeline stages, container registry, and protocol verification
- MCP Servers with Bitbucket Pipelines — build, deploy, and protocol verification
- MCP Servers with Tekton — cloud-native Kubernetes CI/CD
- MCP Servers with Jenkins — declarative pipeline and Docker builds
- MCP Servers on Kubernetes — deployment, services, and health checks
- MCP Servers with Docker — building and running containers
- MCP Server Health Checks — implementing and monitoring protocol probes
- MCP Server Zero-Downtime Deployment — rolling updates and blue/green strategies
- MCP Servers with ArgoCD — GitOps continuous delivery