Guide · Automation
Deploying MCP Servers with Ansible
An Ansible playbook turns a bare VPS into a production-ready MCP server host in a single command: it installs Node.js, creates a dedicated system user, clones your application, installs npm dependencies, deploys a systemd service, configures nginx as a reverse proxy with correct SSE settings, provisions an SSL certificate, and opens only the ports you actually need. Re-run that same playbook a week later and it converges the server to the same state without touching anything that hasn't changed. That's the practical value of Ansible for MCP server deployments — not magic, but reproducibility at the level of individual tasks rather than the whole machine.
TL;DR
Ansible automates the full setup of a VPS-hosted MCP server: Node.js installation, application directory and system user, systemd service with auto-restart, nginx reverse proxy with SSE-safe settings, SSL certificate, and ufw firewall rules. A post-deploy uri task sends a real MCP initialize request to the live endpoint and fails the play if the response doesn't contain protocolVersion — stopping a rolling update before bad code reaches every host. After the playbook finishes, AliveMCP takes over with continuous external monitoring: probing the same endpoint every 60 seconds so that failures appearing hours after deployment — memory leaks, certificate expiry, dependency outages — are caught before users report them.
When Ansible makes sense for MCP servers
Ansible occupies a specific niche in the infrastructure toolchain, and it's the right choice for a particular class of MCP server deployment. If you're running an MCP server on a VPS — a DigitalOcean Droplet, a Hetzner Cloud instance, a Linode, or bare metal — and you want automated, repeatable configuration without adopting Kubernetes, Ansible is a natural fit. It connects over SSH, requires no agent installed on the target host, and its playbooks are idempotent: each task checks whether the desired state already exists before making changes, so re-running a playbook is safe.
The comparison with alternatives helps clarify the boundaries:
| Tool | Strength | Weakness for MCP deployments | Verdict |
|---|---|---|---|
| Ansible | Agentless, SSH-based, idempotent, excellent for configuration management and application deployment | Not designed to provision cloud resources (VMs, DNS records, load balancers) | Use for everything after the VPS exists |
| Terraform | Infrastructure-as-code for cloud resources: creates VMs, floating IPs, firewalls, DNS | Poor fit for OS-level configuration; providers for systemd/nginx don't exist | Use to create the VPS; hand off to Ansible after |
| Manual SSH | Fastest for a one-off experiment | Not repeatable, not reviewable, not testable; breaks down beyond two servers | Avoid for anything you'll run more than once |
| Kubernetes | Container orchestration, rolling deploys, horizontal scaling | Significant operational overhead for a single-process MCP server; overkill unless you already operate a cluster | Use only if you already have a cluster |
The sweet spot for an Ansible-managed MCP server deployment is a fleet of one to twenty VPS instances running a Node.js MCP server behind nginx. Below that scale, a single playbook manages everything. Above it, the same playbook still works, but you might layer in a CI/CD system to trigger runs automatically — see the CI/CD guide for wiring Ansible into GitHub Actions or GitLab CI.
Inventory and playbook structure
Ansible projects gain maintainability quickly when you follow a consistent directory layout from the start. The structure below separates concerns cleanly: the inventory/ directory holds host addresses and group variables for each environment, and the roles/ directory holds the actual task logic, each role responsible for one concern.
mcp-ansible/
├── inventory/
│ ├── production/
│ │ ├── hosts.yml
│ │ └── group_vars/
│ │ └── mcp_servers.yml
│ └── staging/
│ └── hosts.yml
├── roles/
│ ├── common/ # Node.js, git, ufw firewall
│ ├── mcp_app/ # app clone, npm install, systemd service
│ └── nginx/ # reverse proxy, SSL certificate
├── site.yml # full provision playbook
└── deploy.yml # code-only deploy playbook
Having a separate deploy.yml that only runs the mcp_app role tasks is important for day-to-day operations. You don't need to re-configure nginx or re-install Node.js every time you push a new version of your application; deploy.yml limits the blast radius of a routine code deploy to only the tasks that change application code and restart the service.
The inventory file for production lists each server's connection details. Using hosts.yml in YAML format rather than the legacy INI format makes the structure explicit and easier to extend:
# inventory/production/hosts.yml
all:
children:
mcp_servers:
hosts:
mcp-prod-1:
ansible_host: 203.0.113.10
ansible_user: ubuntu
mcp-prod-2:
ansible_host: 203.0.113.11
ansible_user: ubuntu
Group variables shared across all mcp_servers hosts live in group_vars/mcp_servers.yml. This keeps the inventory file lean and puts all tunable parameters in one place:
# inventory/production/group_vars/mcp_servers.yml
node_version: "20"
mcp_app_repo: "git@github.com:your-org/your-mcp-server.git"
mcp_app_dir: /opt/mcp
mcp_port: 3000
mcp_domain: mcp.example.com
mcp_app_version: main
The staging inventory has its own hosts.yml pointing at staging servers, and can override any of these variables — most usefully mcp_domain and mcp_app_version — to deploy a different branch to a separate domain for testing.
Full site.yml and role tasks
The top-level site.yml applies all three roles in sequence to the mcp_servers group. The become: true directive tells Ansible to use sudo for privileged tasks, while individual tasks that need to run as the mcp application user use become_user to drop privileges back down:
# site.yml
- name: Provision and configure MCP servers
hosts: mcp_servers
become: true
roles:
- common
- mcp_app
- nginx
The common role handles system-level dependencies. Node.js installation on Debian-based systems uses the NodeSource repository rather than the outdated version in the default apt cache. The firewall configuration uses ufw with an explicit deny-all default policy and whitelists only SSH, HTTP, and HTTPS:
# roles/common/tasks/main.yml
- name: Install Node.js via NodeSource
block:
- name: Download NodeSource setup script
get_url:
url: https://deb.nodesource.com/setup_{{ node_version }}.x
dest: /tmp/nodesource_setup.sh
mode: '0755'
- name: Run NodeSource setup
command: /tmp/nodesource_setup.sh
args:
creates: /etc/apt/sources.list.d/nodesource.list
- name: Install nodejs
apt:
name: nodejs
state: present
update_cache: true
- name: Configure ufw firewall
ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: ['22', '80', '443']
- name: Enable ufw
ufw:
state: enabled
policy: deny
The creates: argument on the NodeSource setup command makes that task idempotent: if the apt sources file already exists, Ansible skips the command. This is the pattern to follow for any command that is not inherently idempotent — always provide a way for Ansible to detect that the desired state already exists.
The mcp_app role creates a dedicated system user, deploys the application code, installs dependencies, and manages the systemd unit. Using a non-login system user for the application process is a security baseline: if the MCP server process is compromised, the attacker cannot use that user account to SSH into the server or escalate privileges easily.
# roles/mcp_app/tasks/main.yml
- name: Create app user
user:
name: mcp
system: true
shell: /usr/sbin/nologin
home: "{{ mcp_app_dir }}"
- name: Clone/update MCP server repo
git:
repo: "{{ mcp_app_repo }}"
dest: "{{ mcp_app_dir }}"
version: "{{ mcp_app_version | default('main') }}"
force: true
become_user: mcp
notify: restart mcp
- name: Install npm dependencies
npm:
path: "{{ mcp_app_dir }}"
production: true
become_user: mcp
notify: restart mcp
- name: Deploy systemd service
template:
src: mcp.service.j2
dest: /etc/systemd/system/mcp.service
notify:
- daemon-reload
- restart mcp
- name: Enable and start MCP service
systemd:
name: mcp
enabled: true
state: started
The notify directives on the git, npm, and template tasks queue the restart mcp handler, but Ansible only executes handlers once at the end of the play — not once per notification. If all three tasks change, the service is still restarted exactly once. This prevents double-restart thrash during the initial provision.
The systemd service template injects environment variables from Ansible Vault-encrypted values (covered below) and configures aggressive restart behavior appropriate for a production MCP server:
# roles/mcp_app/templates/mcp.service.j2
[Unit]
Description=MCP Server
After=network.target
[Service]
Type=simple
User=mcp
WorkingDirectory={{ mcp_app_dir }}
ExecStart=/usr/bin/node {{ mcp_app_dir }}/index.js
Restart=always
RestartSec=5
TimeoutStopSec=30
KillMode=mixed
{% for key, value in mcp_env_vars.items() %}
Environment="{{ key }}={{ value }}"
{% endfor %}
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mcp
[Install]
WantedBy=multi-user.target
Restart=always with RestartSec=5 means the service will restart five seconds after any unexpected exit — including crashes, out-of-memory kills, and unhandled exceptions that exit the Node.js process. TimeoutStopSec=30 gives the server 30 seconds to close active SSE connections gracefully before systemd sends SIGKILL. For more details on structuring the systemd unit, see the dedicated systemd configuration guide.
nginx role for MCP server reverse proxy
Running the MCP server behind nginx rather than exposing Node.js directly on port 443 provides SSL termination, request buffering controls, and the ability to add rate limiting or access controls at the proxy layer. However, the default nginx buffering configuration is wrong for MCP servers that use SSE (Server-Sent Events) as their transport — nginx buffers SSE responses by default, which means clients don't receive events until the buffer fills or flushes, completely breaking the streaming model that SSE is designed for.
The nginx virtual host template for an MCP server requires several non-default settings to work correctly:
# roles/nginx/templates/mcp.conf.j2
server {
listen 80;
server_name {{ mcp_domain }};
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name {{ mcp_domain }};
ssl_certificate /etc/letsencrypt/live/{{ mcp_domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ mcp_domain }}/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://127.0.0.1:{{ mcp_port }};
# Required for SSE (Server-Sent Events) transport
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600;
# HTTP/1.1 keep-alive for SSE connections
proxy_http_version 1.1;
proxy_set_header Connection '';
# Standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Three of these settings deserve explicit explanation because they're easy to get wrong and the failure modes are subtle. First, proxy_buffering off disables nginx's response buffer for this location block — without this, nginx holds SSE event data in memory and only forwards it downstream in chunks, so clients receive events in batches rather than as they're emitted, destroying the real-time behavior. Second, proxy_read_timeout 3600 extends nginx's patience for reading from the upstream to one hour; the default of 60 seconds would cause nginx to close long-lived SSE connections after one minute of no new events. Third, proxy_set_header Connection '' prevents nginx from forwarding its own Connection: close header to the upstream, which would cause Node.js to close the SSE connection after each response rather than keeping the stream open.
The SSL certificate is provisioned by certbot. The nginx role task that calls certbot only runs if the certificate directory doesn't already exist, making the task idempotent:
# roles/nginx/tasks/main.yml (excerpt)
- name: Install certbot and nginx plugin
apt:
name:
- certbot
- python3-certbot-nginx
state: present
- name: Obtain SSL certificate
command: >
certbot --nginx
--non-interactive
--agree-tos
--email {{ admin_email }}
-d {{ mcp_domain }}
args:
creates: /etc/letsencrypt/live/{{ mcp_domain }}/fullchain.pem
- name: Deploy nginx virtual host
template:
src: mcp.conf.j2
dest: /etc/nginx/sites-available/{{ mcp_domain }}.conf
notify: reload nginx
- name: Enable nginx virtual host
file:
src: /etc/nginx/sites-available/{{ mcp_domain }}.conf
dest: /etc/nginx/sites-enabled/{{ mcp_domain }}.conf
state: link
notify: reload nginx
For the full set of nginx settings relevant to MCP server deployments — including connection limits, upstream health checks, and cache-control headers — see the nginx reverse proxy configuration guide.
Rolling updates with serial
The default Ansible behavior is to run each task across all hosts in the play before moving to the next task. For a fleet of MCP servers behind a load balancer, this means all servers restart simultaneously, causing a complete outage during the code update. The serial keyword changes this: when serial: 1 is set, Ansible runs the entire play on one host at a time, completing all tasks on the first host before touching the second.
# deploy.yml
- name: Rolling deploy
hosts: mcp_servers
serial: 1
max_fail_percentage: 0
become: true
tasks:
- name: Pull latest code
git:
repo: "{{ mcp_app_repo }}"
dest: "{{ mcp_app_dir }}"
version: "{{ deploy_version }}"
force: true
become_user: mcp
notify: restart mcp
- name: Install npm dependencies
npm:
path: "{{ mcp_app_dir }}"
production: true
become_user: mcp
notify: restart mcp
- meta: flush_handlers
- name: Wait for MCP server to accept connections
wait_for:
host: 127.0.0.1
port: "{{ mcp_port }}"
delay: 5
timeout: 60
- name: Verify MCP protocol endpoint after deploy
uri:
url: https://{{ mcp_domain }}/
method: POST
body_format: json
body:
jsonrpc: "2.0"
id: 1
method: initialize
params:
protocolVersion: "2024-11-05"
clientInfo:
name: ansible-probe
version: "1.0"
return_content: true
status_code: 200
register: mcp_probe_result
retries: 3
delay: 10
until: mcp_probe_result.status == 200 and '"protocolVersion"' in mcp_probe_result.content
handlers:
- name: restart mcp
systemd:
name: mcp
state: restarted
daemon_reload: true
The max_fail_percentage: 0 setting is what makes rolling updates safe in practice. With the default behavior, Ansible tolerates a configurable percentage of host failures before aborting the play — which means a bad deploy could successfully reach several servers before the failure threshold trips the circuit breaker. Setting max_fail_percentage: 0 means that any failure on any host immediately aborts the play, preventing the bad version from propagating to the remaining hosts. The servers that haven't been updated yet continue serving the old version while you diagnose the failure.
The meta: flush_handlers task is critical placement. Handlers normally run at the end of the play, but in a rolling update you need the service restart to happen before the protocol probe — not after. Flushing handlers mid-play forces the restart mcp handler to execute immediately at that point in the task sequence, so the wait_for and uri probe tasks run against the newly deployed code, not the old version.
For load-balancer-aware rolling deploys that drain connections before restarting the service, see the zero-downtime deployment guide.
Ansible Vault for MCP server secrets
MCP servers typically need secrets at runtime: database connection strings, third-party API keys, signing keys. These cannot live as plaintext in group_vars files committed to a repository. Ansible Vault solves this by encrypting individual values (or entire files) in place, so the encrypted ciphertext is safe to commit to git while the actual secret value never appears in the repository.
Encrypting a single variable value with ansible-vault encrypt_string:
ansible-vault encrypt_string \
--vault-password-file .vault_pass \
'postgres://mcp:s3cr3t@db.internal:5432/mcp_prod' \
--name DATABASE_URL
Ansible prints the encrypted value, which you paste directly into your group_vars file:
# inventory/production/group_vars/mcp_servers.yml
mcp_env_vars:
NODE_ENV: production
PORT: "3000"
DATABASE_URL: !vault |
$ANSIBLE_VAULT;1.1;AES256
61393765653130333366303834653032623465623263623437323532313761643334336637616639
3533316334623661373433343463656536396333623933640a393638623863363931623235363934
64386462663861303932323834356462353338393739393635326338396239373832336437343066
3534333937616663310a623930343465336533636464653934316131333739326534386232623336
3630
API_KEY: !vault |
$ANSIBLE_VAULT;1.1;AES256
38333131623135386530376666333334313537393637393635323736363031373933323534353164
3363383635613966373339643066313136323332356338310a633866353030353636303738396233
32656265333561336362613335373962653262336136353166643361356666316665333732613062
3534326235656231330a396138616334336235623264326466366263376665356462393164633533
3464
The vault password file (.vault_pass) must be in .gitignore. It contains only the plaintext password on a single line. When running Ansible, pass it with --vault-password-file .vault_pass, or set vault_password_file = .vault_pass in ansible.cfg so you don't need to specify it on every command invocation.
For team environments, the vault password should be stored in a shared secret manager (1Password, Bitwarden Secrets Manager, AWS Secrets Manager, HashiCorp Vault) rather than passed around in files. In GitHub Actions, store it as a repository secret and retrieve it at run time:
# .github/workflows/deploy.yml (excerpt)
- name: Run Ansible deploy
run: |
echo "$ANSIBLE_VAULT_PASS" > /tmp/.vault_pass
chmod 600 /tmp/.vault_pass
ansible-playbook \
-i inventory/production \
--vault-password-file /tmp/.vault_pass \
deploy.yml
rm /tmp/.vault_pass
env:
ANSIBLE_VAULT_PASS: ${{ secrets.ANSIBLE_VAULT_PASS }}
For a broader treatment of secret rotation, scoping, and audit logging for MCP server deployments, see the secrets management guide.
Verifying MCP server deployments with Ansible and AliveMCP
A successful Ansible play — all tasks green, no errors — does not by itself prove that your MCP server is working correctly. It proves that the files are in the right place, the process is running, and port 3000 is accepting TCP connections. It says nothing about whether the MCP protocol is functional, whether the initialize handshake returns a valid response, or whether the nginx SSL termination is passing requests through correctly. That's why the post-deploy protocol probe is a critical final step in any Ansible MCP server deployment.
The uri module in Ansible can send a real MCP initialize request over HTTPS and validate the response content, not just the HTTP status code:
- name: Verify MCP protocol endpoint after deploy
uri:
url: https://{{ mcp_domain }}/
method: POST
body_format: json
body:
jsonrpc: "2.0"
id: 1
method: initialize
params:
protocolVersion: "2024-11-05"
clientInfo:
name: ansible-probe
version: "1.0"
return_content: true
status_code: 200
register: mcp_probe_result
retries: 3
delay: 10
until: mcp_probe_result.status == 200 and '"protocolVersion"' in mcp_probe_result.content
- name: Fail if MCP protocol probe did not return expected response
fail:
msg: "MCP protocol probe failed. Response: {{ mcp_probe_result.content }}"
when: '"protocolVersion" not in mcp_probe_result.content'
This probe does several things simultaneously. The until condition checks both the HTTP status code and the response body — a server returning HTTP 200 with an error payload would still fail the probe. The retries: 3 and delay: 10 parameters give the MCP server 30 seconds to come up cleanly after systemd restarts it; Node.js processes sometimes take a few seconds to complete their startup sequence and begin accepting protocol connections even after the TCP port is open. And critically, in a serial: 1 rolling update, a probe failure on the first host stops the entire play — the second host never gets touched.
The probe validates the SSL certificate chain (because uri uses Python's SSL stack by default), tests that nginx is forwarding requests to Node.js correctly, confirms that Node.js is executing application code rather than failing at import time, and verifies that the MCP protocol handler is registered and returning a valid response. It's the minimum viable smoke test for a deployed MCP server.
That said, the Ansible probe runs exactly once, at deploy time. It catches deploy-phase failures: bad code that crashes on startup, nginx misconfigurations, SSL certificate problems introduced by the deploy. It does not catch the other class of MCP server failures — the ones that develop hours or days after a successful deployment.
Memory leaks are a common example. A Node.js MCP server that processes large tool call results may accumulate heap allocations over hours until the process exhausts available memory and crashes. The Ansible probe will be green at deploy time; the failure happens at 3 AM on a Tuesday. Similarly, Let's Encrypt certificates expire on a 90-day cycle — if your certbot auto-renewal configuration breaks silently (a cron job that stopped running, a DNS record that expired), your SSL certificate will fail 90 days after the last successful renewal. The Ansible probe runs at deploy time, not at expiry time.
AliveMCP complements the Ansible probe by running the same initialize request against your live endpoint every 60 seconds, continuously. It detects protocol errors at the MCP layer (not just HTTP 200), checks the SSL certificate for upcoming expiry, and pages you the moment the endpoint stops responding correctly — whether the failure happens one minute after deployment or six weeks later. You configure an AliveMCP monitor once after your first successful Ansible deploy, and it runs indefinitely in the background, catching the failures your deployment tooling cannot see.
The combination is clean: Ansible handles the deterministic, point-in-time verification that deployment was successful; AliveMCP handles the probabilistic, continuous verification that the server remains healthy. For implementation details on health check endpoint design that both your Ansible probe and AliveMCP can use, see the health check guide.
Frequently asked questions
Is Ansible the right choice for a single-server MCP deployment?
Yes, even for one server. The common objection is that Ansible adds overhead for a single machine — install Ansible locally, write a playbook, maintain an inventory file — when you could just SSH in and run commands. That objection holds for a true one-off experiment that you will never repeat. But in practice, you will re-run the setup: after wiping the VPS for a major OS upgrade, after provisioning an identical staging server to test a configuration change, after recovering from a compromised server. Manual SSH commands aren't reproducible; you'll spend an hour trying to remember what you installed and in what order, and you'll get it slightly wrong. A 50-line Ansible playbook documents exactly what was configured and re-creates the same state reliably. The overhead of writing the playbook pays for itself the first time you use it to provision a second environment.
How do I update the MCP server code without re-provisioning the whole server?
Write a separate deploy.yml playbook that only includes the tasks from the mcp_app role that handle code updates: the git task, the npm task, and the service restart handler. Run it with ansible-playbook -i inventory/production deploy.yml -e deploy_version=v1.2.3 rather than the full site.yml. This is faster — it skips all the Node.js installation, ufw configuration, and nginx setup tasks that haven't changed — and it limits the blast radius of a deploy. If the deploy.yml fails, the server retains whatever version was running before; the site.yml provisioning configuration is untouched. Pass the target version as an extra variable (-e deploy_version=v1.2.3) so you can deploy specific git tags rather than always deploying the default branch.
How do I manage Ansible Vault passwords across a team?
Store the vault password in your team's shared secret manager — 1Password, Bitwarden Secrets Manager, AWS Secrets Manager, or HashiCorp Vault — rather than distributing it in files or Slack messages. Each team member retrieves the vault password locally when they need to run a playbook; the password never lives in the repository or in email. For CI/CD systems (GitHub Actions, GitLab CI), store the vault password as a repository secret or environment variable in the CI system and pass it to Ansible at run time with --vault-password-file /dev/stdin <<< "$VAULT_PASS" to avoid writing it to disk. Rotate the vault password when a team member with access leaves, which requires re-encrypting all vault-encrypted values with the new password — a one-time cost worth paying for security hygiene. If vault password rotation is too cumbersome at your scale, consider moving to a dynamic secret backend where Ansible fetches secrets from Vault or Secrets Manager at play time rather than embedding them as encrypted strings in group_vars files.
Can I use Ansible with Docker instead of systemd for the MCP server?
Yes. Replace the systemd service role with an Ansible community.docker.docker_container task that pulls and runs your MCP server container image. Ansible manages the Docker CLI invocation; Docker handles process lifecycle, log collection, and restart behavior. The nginx role stays the same — nginx still proxies to a local port, whether that port is served by a systemd-managed Node.js process or a Docker-managed container. This approach is useful when the VPS already runs Docker for other workloads and you want consistent container-based process management. The main tradeoff is that you're adding Docker as a runtime dependency on the VPS, and Docker's networking model adds a layer of indirection that can complicate debugging. For a VPS dedicated to a single MCP server, systemd is simpler; for a multi-tenant VPS running several services, Docker's isolation may justify the overhead. See the PM2 guide for a middle ground that's lighter than Docker but provides more process management features than raw systemd.
How do I handle zero-downtime restarts of the MCP server during Ansible deployments?
For systemd + nginx deployments without a load balancer, configure TimeoutStopSec=30 in the systemd unit so systemd waits 30 seconds for the process to handle the SIGTERM and close active SSE connections before sending SIGKILL. Your Node.js application should listen for SIGTERM and stop accepting new connections while draining existing ones — this is the standard graceful shutdown pattern. For a multi-server fleet behind a load balancer, the serial: 1 rolling update strategy ensures that only one server is restarting at any given time while the others continue serving traffic. For stricter zero-downtime requirements, drain the target server from the load balancer before running the Ansible tasks on it (remove it from the load balancer's backend pool, wait for in-flight requests to complete, run the deploy, verify the protocol probe passes, then re-add to the load balancer) — the full implementation of this pattern is described in the zero-downtime deployment guide.
Further reading
- MCP server systemd service configuration — unit file options, restart policies, and security hardening with systemd sandboxing
- nginx reverse proxy for MCP servers — SSE-safe buffering, upstream health checks, and rate limiting
- MCP server with PM2 — PM2 as a Node.js process manager alternative to systemd for VPS deployments
- MCP server health check implementation — dedicated health endpoints that Ansible probes and AliveMCP monitors can both use
- Zero-downtime MCP server deployments — load balancer drain, connection tracking, and graceful shutdown sequencing
- MCP server secrets management — Ansible Vault, external secret stores, secret rotation, and audit logging
- CI/CD pipeline for MCP servers — automating Ansible playbook runs from GitHub Actions with protocol compliance gates