Guide · AWS Systems Manager · Fleet Operations
AWS SSM Run Command for MCP Servers: Fleet Operations Without SSH
SSM Run Command lets you execute scripts and administrative tasks across your entire MCP server fleet without opening a single inbound port. No SSH keys to rotate, no bastion hosts to maintain, no security group rules that slowly accumulate exceptions. When an upstream API key gets compromised at 3 AM, you can push a key rotation to every running instance in under two minutes — authenticated through IAM, logged to CloudWatch, with rate controls so a bad script cannot cascade across your fleet at once. This guide covers Run Command setup, the MCP server operations you should automate with it, and how to integrate it with Session Manager for interactive debugging and Maintenance Windows for scheduled tasks.
TL;DR
Attach the AmazonSSMManagedInstanceCore managed policy to your EC2 instance role (or ECS task role for ECS Exec). Install the SSM Agent on your instances (it is pre-installed on Amazon Linux 2/2023 and recent Ubuntu AMIs). Create a Systems Manager document or use the built-in AWS-RunShellScript document. Run aws ssm send-command targeting by tag, instance ID, or resource group. Set MaxConcurrency to limit parallel execution and MaxErrors to stop the run if too many instances fail. Output goes to CloudWatch Logs or S3. AliveMCP detects any probe failures during the execution window within 60 seconds.
Why Run Command instead of SSH for MCP server operations
MCP servers attract tighter security requirements than typical web services because they often hold API keys for downstream tools — web search, code execution, database access — that have real spending and data access implications. Every inbound port is an attack surface. Most security teams that review MCP server deployments will flag open port 22 immediately. Run Command eliminates that surface entirely: the SSM Agent on your instance connects outbound to the SSM endpoint over HTTPS. No inbound rules needed.
Beyond security, the operational advantages compound over time. Every command execution is logged with the caller's IAM identity, the exact document and parameters used, and the full stdout/stderr output from each instance. When an incident happens, you have a complete audit trail of what was run and by whom. SSH logs are typically scattered across instance auth logs with no correlation to the operation that caused them.
| Capability | SSH | SSM Run Command |
|---|---|---|
| Inbound port required | Port 22 open | None — outbound HTTPS only |
| Authentication | SSH key pair | IAM role + policy |
| Audit trail | auth.log per instance | CloudWatch Logs, centralized |
| Multi-instance execution | Manual per instance or Ansible | Native — tag-based targeting |
| Rate control | None built-in | MaxConcurrency + MaxErrors |
| Output capture | Terminal only | S3 or CloudWatch Logs |
| Bastion host required | Yes (private subnets) | No |
Prerequisites: SSM Agent and instance role
Run Command requires two things on every managed instance: the SSM Agent running and an IAM instance profile that grants permission to communicate with the SSM service.
For Amazon Linux 2023 and Ubuntu 22.04+ AMIs, the SSM Agent is pre-installed and starts automatically. For older AMIs or custom images, install it manually:
# Amazon Linux 2
sudo yum install -y amazon-ssm-agent
sudo systemctl enable amazon-ssm-agent
sudo systemctl start amazon-ssm-agent
# Ubuntu 22.04
sudo snap install amazon-ssm-agent --classic
sudo systemctl enable snap.amazon-ssm-agent.amazon-ssm-agent.service
sudo systemctl start snap.amazon-ssm-agent.amazon-ssm-agent.service
The instance IAM role needs the AmazonSSMManagedInstanceCore managed policy. In CDK:
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as iam from 'aws-cdk-lib/aws-iam';
const instanceRole = new iam.Role(this, 'McpInstanceRole', {
assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'),
],
});
const instance = new ec2.Instance(this, 'McpServer', {
vpc,
instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.SMALL),
machineImage: ec2.MachineImage.latestAmazonLinux2023(),
role: instanceRole,
// No keyPair — no SSH access needed
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
securityGroup: mcpSecurityGroup, // no inbound rules for port 22
});
If your instances are in private subnets without a NAT gateway, add VPC endpoints for SSM so the agent can reach the service without traversing the public internet. You need endpoints for com.amazonaws.{region}.ssm, com.amazonaws.{region}.ssmmessages, and com.amazonaws.{region}.ec2messages.
Running commands: AWS CLI and SDK
The simplest Run Command invocation uses the built-in AWS-RunShellScript document. Target instances by tag — this is the right default because it works as your fleet scales without updating instance ID lists:
# Run a command on all instances tagged Service=mcp-server, Env=production
aws ssm send-command \
--document-name "AWS-RunShellScript" \
--targets '[{"Key":"tag:Service","Values":["mcp-server"]},{"Key":"tag:Env","Values":["production"]}]' \
--parameters '{"commands":["systemctl reload mcp-server || systemctl restart mcp-server"]}' \
--max-concurrency "30%" \
--max-errors "10%" \
--cloud-watch-output-config '{"CloudWatchLogGroupName":"/mcp/run-command","CloudWatchOutputEnabled":true}' \
--output text \
--query "Command.CommandId"
The --max-concurrency "30%" flag means only 30% of targeted instances run the command simultaneously — the rest wait. If more than 10% of instances fail (--max-errors "10%"), SSM stops sending to the remaining instances. This prevents a bad command from taking down your entire fleet.
To wait for completion and check results from the SDK:
import {
SSMClient,
SendCommandCommand,
GetCommandInvocationCommand,
waitUntilCommandExecuted,
} from '@aws-sdk/client-ssm';
const ssm = new SSMClient({ region: process.env.AWS_REGION });
async function runOnFleet(commands: string[]): Promise<void> {
const { Command } = await ssm.send(new SendCommandCommand({
DocumentName: 'AWS-RunShellScript',
Targets: [
{ Key: 'tag:Service', Values: ['mcp-server'] },
{ Key: 'tag:Env', Values: ['production'] },
],
Parameters: { commands },
MaxConcurrency: '25%',
MaxErrors: '10%',
CloudWatchOutputConfig: {
CloudWatchLogGroupName: '/mcp/run-command',
CloudWatchOutputEnabled: true,
},
TimeoutSeconds: 120,
}));
const commandId = Command!.CommandId!;
console.log(`Run Command ${commandId} started`);
// Poll until complete (SDK waiter handles backoff)
// Note: waiter works per-instance; use ListCommandInvocations for fleet-wide status
const result = await ssm.send(new GetCommandInvocationCommand({
CommandId: commandId,
InstanceId: 'i-placeholder', // replace with actual instance ID for per-instance check
}));
console.log('Status:', result.StatusDetails);
console.log('Stdout:', result.StandardOutputContent);
console.log('Stderr:', result.StandardErrorContent);
}
Custom SSM documents for MCP server operations
Custom documents let you define reusable, parameterized runbooks for common MCP server operations. They show up in the Systems Manager console under "Documents" and can be run by any operator with the right IAM permissions — eliminating ad-hoc one-liner errors.
A document for rotating the upstream API key of a running MCP server:
# mcp-rotate-api-key.yaml — SSM Automation/Command document
schemaVersion: "2.2"
description: "Rotate the upstream API key for a running MCP server without restart"
parameters:
ApiKeyParameterPath:
type: String
description: "SSM Parameter Store path for the new API key (e.g. /mcp/production/upstream-api-key)"
ServiceName:
type: String
description: "systemd service name"
default: "mcp-server"
mainSteps:
- action: aws:runShellScript
name: rotateKey
inputs:
runCommand:
- |
set -euo pipefail
# Fetch new key from Parameter Store (instance role provides access)
NEW_KEY=$(aws ssm get-parameter \
--name "{{ ApiKeyParameterPath }}" \
--with-decryption \
--query "Parameter.Value" \
--output text)
# Write to the service's environment file
sed -i "s|^UPSTREAM_API_KEY=.*|UPSTREAM_API_KEY=${NEW_KEY}|" /etc/mcp-server/env
# Signal the process to reload (SIGHUP if the server supports it)
systemctl kill --signal=SIGHUP {{ ServiceName }} 2>/dev/null || \
systemctl restart {{ ServiceName }}
echo "Key rotation complete. Service status:"
systemctl is-active {{ ServiceName }}
Register this document via CDK or CLI, then invoke it with parameters:
aws ssm send-command \
--document-name "mcp-rotate-api-key" \
--targets '[{"Key":"tag:Service","Values":["mcp-server"]}]' \
--parameters '{"ApiKeyParameterPath":["/mcp/production/upstream-api-key"],"ServiceName":["mcp-server"]}' \
--max-concurrency "1" \
--max-errors "0"
Setting MaxConcurrency 1 and MaxErrors 0 for key rotation is intentional: rotate one instance at a time and stop immediately on the first failure. A bad key affects one instance; you fix it before proceeding.
MCP server operations suited for Run Command
| Operation | Document / command | MaxConcurrency | Notes |
|---|---|---|---|
| Reload config without restart | systemctl kill --signal=SIGHUP mcp-server |
25% | Only if the server handles SIGHUP gracefully; verify in staging first |
| Rotate API keys | Custom mcp-rotate-api-key document |
1 | Single-instance serialization avoids all-at-once key swap |
| Flush in-memory tool caches | HTTP POST to localhost:PORT/admin/cache/flush |
50% | Curl against localhost admin endpoint; no port exposure needed |
| Collect diagnostics | AWS-RunShellScript — journalctl, netstat, /proc/meminfo |
100% | Read-only; safe to parallelize |
| Update MCP server binary | Custom deploy document (download, verify hash, swap, restart) | 10% | Use with Maintenance Window for zero-downtime |
| Emergency shutdown | systemctl stop mcp-server |
100% | Use when a compromised tool must be disabled fleet-wide immediately |
AliveMCP + Run Command: monitoring the maintenance window
Run Command operations that restart the MCP server process — key rotation, binary updates, config reload — create a brief unavailability window. AliveMCP probes every 60 seconds using a real JSON-RPC tools/list handshake. If a restart takes longer than expected (a common issue when a new binary version has a slow initialization or a dependency timeout), AliveMCP alerts before any user notices the gap.
The practical workflow: trigger Run Command → watch the AliveMCP dashboard for the instance's probe status → if probes start failing beyond the expected restart window, use the CloudWatch Logs output from Run Command to diagnose the failure on the affected instance. Run Command's per-instance stdout/stderr logs are available within seconds of execution completion, giving you the server's own error output alongside AliveMCP's external probe result.
For high-availability configurations where instances are behind a load balancer, combine Run Command's MaxConcurrency 25% with AliveMCP's fleet-level probe view: you see the aggregate probe success rate drop proportionally as instances cycle through the maintenance operation and recover, rather than seeing a single binary healthy/unhealthy signal.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Instance shows "Connection Lost" in SSM Fleet Manager | SSM Agent not running, instance role missing AmazonSSMManagedInstanceCore, or no route to SSM endpoints |
Check sudo systemctl status amazon-ssm-agent on the instance; verify instance profile; add SSM VPC endpoints for private subnets |
| Command times out before completing | Default TimeoutSeconds is 3600s but some operations take longer; or the process hangs waiting for user input |
Add --timeout-seconds appropriate for the operation; ensure scripts use -y flags for package managers and non-interactive mode |
| Run Command succeeds but MCP server is still using old config | SIGHUP reload not implemented; reload command ran but service was restarted by systemd before the new config was applied | Check that the service unit file has ExecReload= defined; alternatively restart (not reload) and accept the brief downtime window |
| MaxErrors threshold triggers mid-rollout | A subset of instances has a different OS version, missing dependency, or the script makes a wrong assumption about the environment | Reduce MaxConcurrency to 1 for the next attempt; inspect the failed invocation stdout/stderr in CloudWatch Logs; fix the script before re-running |
AccessDeniedException on ssm:SendCommand |
The caller IAM role is missing the ssm:SendCommand action, or the resource ARN in the policy doesn't match the document or instance |
Ensure the caller policy includes ssm:SendCommand, ssm:GetCommandInvocation, and ssm:ListCommandInvocations; scope resource to arn:aws:ssm:*:*:document/mcp-* for custom documents |