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.

CapabilitySSHSSM Run Command
Inbound port requiredPort 22 openNone — outbound HTTPS only
AuthenticationSSH key pairIAM role + policy
Audit trailauth.log per instanceCloudWatch Logs, centralized
Multi-instance executionManual per instance or AnsibleNative — tag-based targeting
Rate controlNone built-inMaxConcurrency + MaxErrors
Output captureTerminal onlyS3 or CloudWatch Logs
Bastion host requiredYes (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

OperationDocument / commandMaxConcurrencyNotes
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

SymptomCauseFix
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