Guide · CloudWatch Synthetics
MCP Server CloudWatch Synthetics — CreateCanaryCommand, canary lifecycle, IAM
AWS CloudWatch Synthetics lets you run synthetic monitors — called canaries — that ping your endpoints on a schedule and emit pass/fail metrics to CloudWatch. From an MCP server, you manage canaries through the @aws-sdk/client-synthetics package: create with CreateCanaryCommand, start/stop with StartCanaryCommand and StopCanaryCommand, and read results with GetCanaryRunsCommand. The three fields that trip up first-time callers are the execution role ARN (a custom IAM role that Synthetics uses to run the canary Lambda — not your MCP server's role), the artifact S3 location (Synthetics writes screenshots and logs here — the bucket must exist and the execution role must have write access), and the code source (either an S3 reference or an inline base64-encoded zip containing a handler export).
TL;DR
Install @aws-sdk/client-synthetics. Call CreateCanaryCommand with Name, Code (S3 key or zip), ArtifactS3Location, ExecutionRoleArn, RuntimeVersion ("syn-nodejs-puppeteer-9.1"), and Schedule ({ Expression: 'rate(5 minutes)' }). Then call StartCanaryCommand — canaries start in CREATING state and transition through READY → STARTING → RUNNING before the first run fires. Your MCP server's IAM role needs synthetics:CreateCanary, synthetics:StartCanary, iam:PassRole (scoped to the execution role), and s3:PutObject on the artifact bucket.
Installing the SDK and creating the client
CloudWatch Synthetics uses a dedicated package separate from the main CloudWatch SDK. Install it alongside @aws-sdk/client-cloudwatch if you need to read canary metrics or attach alarms.
npm install @aws-sdk/client-synthetics
Construct the client once at module initialization — it is stateless and safe to share across concurrent tool calls.
import { SyntheticsClient } from '@aws-sdk/client-synthetics';
const synthetics = new SyntheticsClient({
region: process.env.AWS_REGION ?? 'us-east-1',
// Credentials resolve from the standard chain:
// 1. AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env vars
// 2. ~/.aws/credentials
// 3. ECS task role / Lambda execution role / EC2 instance profile (preferred)
});
Synthetics is available in all major AWS regions. The canary itself runs in the same region as the client — if you need to monitor a regional endpoint from multiple origins, create a canary in each region.
Creating a canary with CreateCanaryCommand
CreateCanaryCommand registers the canary definition but does not start it. After creation, the canary is in READY state. You must call StartCanaryCommand separately to begin scheduled runs.
import {
SyntheticsClient,
CreateCanaryCommand,
type CreateCanaryCommandInput,
} from '@aws-sdk/client-synthetics';
const synthetics = new SyntheticsClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
export async function createApiCanary(params: {
name: string; // 1–21 chars, lowercase letters, numbers, hyphens only
scriptS3Bucket: string; // S3 bucket containing the canary script zip
scriptS3Key: string; // S3 key of the zip file, e.g. 'canaries/my-script.zip'
handler: string; // 'script-filename.handler' — file + export name
executionRoleArn: string; // IAM role that Synthetics assumes to run the canary
artifactBucket: string; // S3 bucket for screenshots, logs, HAR files
rateExpression?: string; // e.g. 'rate(5 minutes)' — defaults to 5 minutes
}): Promise<string> { // returns canary ARN
const input: CreateCanaryCommandInput = {
Name: params.name,
Code: {
S3Bucket: params.scriptS3Bucket,
S3Key: params.scriptS3Key,
Handler: params.handler, // e.g. 'api-check.handler'
},
// S3 URI where Synthetics stores run artifacts (screenshots, logs, HAR)
ArtifactS3Location: `s3://${params.artifactBucket}/canary-artifacts/${params.name}/`,
// IAM role that the canary Lambda execution assumes — NOT your MCP server role
ExecutionRoleArn: params.executionRoleArn,
// Runtime version — check AWS docs for latest; 9.1 supports Node.js 18
RuntimeVersion: 'syn-nodejs-puppeteer-9.1',
Schedule: {
// Rate expression or cron: 'rate(5 minutes)', 'cron(0 * * * ? *)'
Expression: params.rateExpression ?? 'rate(5 minutes)',
// DurationInSeconds: 0 means run indefinitely (default)
},
// Retain the last 14 days of run results
SuccessRetentionPeriodInDays: 14,
FailureRetentionPeriodInDays: 30,
// Optional: run configuration overrides
RunConfig: {
TimeoutInSeconds: 60, // max time for a single canary run
MemoryInMB: 960, // 960 MB is the default
ActiveTracing: false, // set true to emit X-Ray traces
},
// Optional: tags
Tags: {
'created-by': 'mcp-tool',
'monitor-type': 'api',
},
};
const response = await synthetics.send(new CreateCanaryCommand(input));
return response.Canary?.Arn ?? '';
}
The Name field has strict constraints: 1–21 characters, only lowercase letters, numbers, and hyphens. Names must be unique per account per region. CloudWatch will return ConflictException if a canary with that name already exists — even if it was deleted; deleted canary names are released after a propagation delay.
Starting and stopping canaries
After CreateCanaryCommand returns, the canary is in READY state. Call StartCanaryCommand to begin the schedule. The canary transitions through STARTING → RUNNING before the first run fires at the next scheduled interval.
import {
SyntheticsClient,
StartCanaryCommand,
StopCanaryCommand,
GetCanaryCommand,
} from '@aws-sdk/client-synthetics';
export async function startCanary(name: string): Promise<void> {
await synthetics.send(new StartCanaryCommand({ Name: name }));
}
export async function stopCanary(name: string): Promise<void> {
await synthetics.send(new StopCanaryCommand({ Name: name }));
}
// Poll until canary reaches a terminal state
export async function waitForCanaryState(
name: string,
targetState: string,
pollIntervalMs = 5000,
maxAttempts = 24, // 2 minutes at 5s interval
): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
const result = await synthetics.send(new GetCanaryCommand({ Name: name }));
const state = result.Canary?.Status?.State;
if (state === targetState) return;
// Error states — stop polling immediately
if (state === 'ERROR' || state === 'DELETED') {
throw new Error(
`Canary ${name} entered ${state} state: ${result.Canary?.Status?.StateReason}`
);
}
await new Promise(r => setTimeout(r, pollIntervalMs));
}
throw new Error(`Canary ${name} did not reach ${targetState} within timeout`);
}
Canary lifecycle state machine
Understanding canary states prevents incorrect assumptions about whether a canary is healthy. The Status.State field on the canary object represents the provisioning lifecycle, not whether recent runs passed or failed.
| State | Meaning | Transitions to |
|---|---|---|
CREATING | CreateCanaryCommand accepted, resources provisioning | READY or ERROR |
READY | Created but not running — waiting for StartCanaryCommand | STARTING (after Start) |
STARTING | Start accepted, scheduler enabling | RUNNING |
RUNNING | Canary is active and executing on schedule | STOPPING (after Stop), UPDATING (after Update) |
UPDATING | UpdateCanaryCommand in progress — canary paused during update | RUNNING (after update completes) |
STOPPING | StopCanaryCommand accepted, in-flight run completing | STOPPED |
STOPPED | Canary halted — no new runs until restarted | STARTING (after Start) |
ERROR | Provisioning or configuration error — check StateReason | Can retry Start after fixing underlying issue |
DELETED | DeleteCanaryCommand completed | Terminal |
A canary in RUNNING state can still have recent run failures. The Status.State field tells you the canary is scheduled and executing; the individual run results (from GetCanaryRunsCommand) tell you whether those runs are passing or failing.
Updating a running canary
Use UpdateCanaryCommand to change the script, schedule, or runtime version without deleting and re-creating. The canary transitions to UPDATING state during the update — in-flight runs complete before the update applies. You can update a RUNNING canary directly; you do not need to stop it first.
import {
UpdateCanaryCommand,
type UpdateCanaryCommandInput,
} from '@aws-sdk/client-synthetics';
export async function updateCanarySchedule(
name: string,
newExpression: string, // e.g. 'rate(1 minute)'
): Promise<void> {
const input: UpdateCanaryCommandInput = {
Name: name,
Schedule: {
Expression: newExpression,
},
// Only specify fields you want to change — omitted fields keep their current values
};
await synthetics.send(new UpdateCanaryCommand(input));
}
export async function updateCanaryScript(
name: string,
s3Bucket: string,
s3Key: string,
handler: string,
): Promise<void> {
await synthetics.send(new UpdateCanaryCommand({
Name: name,
Code: { S3Bucket: s3Bucket, S3Key: s3Key, Handler: handler },
}));
}
Listing and describing canaries
Use ListCanariesCommand to enumerate all canaries in the account/region. The response is paginated — iterate using the NextToken field.
import {
ListCanariesCommand,
GetCanaryCommand,
} from '@aws-sdk/client-synthetics';
export async function listAllCanaries() {
const canaries = [];
let nextToken: string | undefined;
do {
const response = await synthetics.send(
new ListCanariesCommand({ NextToken: nextToken, MaxResults: 20 })
);
canaries.push(...(response.Canaries ?? []));
nextToken = response.NextToken;
} while (nextToken);
return canaries;
}
export async function getCanaryStatus(name: string) {
const response = await synthetics.send(new GetCanaryCommand({ Name: name }));
const canary = response.Canary;
return {
name: canary?.Name,
state: canary?.Status?.State,
stateReason: canary?.Status?.StateReason,
lastRun: canary?.Status?.StateReasonCode,
nextRun: canary?.Timeline?.LastStarted, // approximate next run time
runtime: canary?.RuntimeVersion,
schedule: canary?.Schedule?.Expression,
};
}
Deleting a canary
DeleteCanaryCommand accepts a DeleteLambda flag that controls whether the underlying Lambda function and Lambda layer are deleted alongside the canary definition. Set DeleteLambda: true for a clean teardown — otherwise the Lambda function and its associated log group persist and continue to incur storage costs.
import { DeleteCanaryCommand } from '@aws-sdk/client-synthetics';
export async function deleteCanary(name: string): Promise<void> {
// Stop the canary first if it is RUNNING — cannot delete a running canary
try {
await synthetics.send(new StopCanaryCommand({ Name: name }));
await waitForCanaryState(name, 'STOPPED');
} catch {
// Ignore error if canary was already STOPPED or READY
}
await synthetics.send(new DeleteCanaryCommand({
Name: name,
DeleteLambda: true, // also deletes the Lambda function and log group
}));
}
Attempting to delete a RUNNING canary returns ConflictException. Stop it first and wait for the STOPPED state before deleting. The CREATING state also blocks deletion — wait for READY or ERROR before attempting to delete a newly created canary.
IAM: roles for the MCP server and for the canary execution
CloudWatch Synthetics requires two distinct IAM principals: the MCP server role (which calls the Synthetics control-plane API), and the canary execution role (which Synthetics assumes when running the canary Lambda). These are separate roles with different trust policies.
// IAM policy for the MCP server role (control-plane operations)
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SyntheticsControlPlane",
"Effect": "Allow",
"Action": [
"synthetics:CreateCanary",
"synthetics:UpdateCanary",
"synthetics:DeleteCanary",
"synthetics:StartCanary",
"synthetics:StopCanary",
"synthetics:GetCanary",
"synthetics:ListCanaries",
"synthetics:GetCanaryRuns",
"synthetics:DescribeCanaries",
"synthetics:DescribeCanariesLastRun"
],
"Resource": "*"
},
{
"Sid": "PassExecutionRole",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::123456789012:role/canary-execution-role",
"Condition": {
"StringEquals": {
"iam:PassedToService": "lambda.amazonaws.com"
}
}
},
{
"Sid": "UploadCanaryScript",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": "arn:aws:s3:::my-canary-scripts/*"
}
]
}
// Trust policy for the canary execution role
// (the role that Synthetics assumes to run the canary Lambda)
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
// Permissions policy for the canary execution role
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "WriteArtifacts",
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::my-canary-artifacts/*"
},
{
"Sid": "ReadCanaryScript",
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::my-canary-scripts/*"
},
{
"Sid": "WriteLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:log-group:/aws/lambda/cwsyn-*"
},
{
"Sid": "EmitMetrics",
"Effect": "Allow",
"Action": ["cloudwatch:PutMetricData"],
"Resource": "*",
"Condition": {
"StringEquals": {
"cloudwatch:namespace": "CloudWatchSynthetics"
}
}
}
]
}
The iam:PassRole condition iam:PassedToService: "lambda.amazonaws.com" is required because Synthetics creates a Lambda function backed by this role. Without it, PassRole calls for Synthetics succeed during canary creation but the underlying Lambda provisioning fails with an AccessDenied error that surfaces as the canary entering ERROR state.
Schedule expressions
The Schedule.Expression field accepts both rate expressions and cron expressions in the same format as EventBridge rules.
| Expression | Runs at | Notes |
|---|---|---|
rate(1 minute) | Every 60 seconds | Minimum supported rate |
rate(5 minutes) | Every 5 minutes | Typical API health-check cadence |
rate(1 hour) | Every hour | Use for slow/expensive checks |
cron(0 * * * ? *) | Top of every hour | Standard EventBridge cron syntax |
cron(0 8 * * ? *) | 8:00 AM UTC daily | Use ? for day-of-week when day-of-month specified |
cron(0 9 ? * MON-FRI *) | 9:00 AM UTC weekdays | Business-hours monitoring |
Rate expressions below 1 minute are not supported. If you need sub-minute monitoring, run multiple canaries offset by seconds — or use a continuous Lambda polling approach instead of Synthetics.
Error handling reference
| Error | Cause | Fix |
|---|---|---|
ConflictException | Canary name already exists (even deleted ones during propagation) | Use a unique name or wait a few minutes after deleting before reusing the name |
ValidationException | Invalid Name format, bad cron expression, or missing required field | Canary names: 1–21 chars, lowercase letters/numbers/hyphens only |
ResourceNotFoundException | GetCanary/StartCanary on a name that does not exist | Verify the name and region match — canaries are regional |
AccessDeniedException | Missing synthetics:* action or iam:PassRole on execution role ARN | Add the missing permissions to the MCP server's IAM policy |
InternalFailureException | Transient AWS service error | Retry with exponential backoff |
| Canary enters ERROR state after create | Execution role missing lambda.amazonaws.com trust or missing S3/logs permissions | Check canary Status.StateReason; fix the execution role and re-create |
Related pages
- Writing CloudWatch Synthetics API canary scripts — Node.js canary script structure, synthetics module, step patterns
- Reading canary run results with GetCanaryRunsCommand — run status, artifact retrieval, state machine
- CloudWatch alarms for canary failures — SuccessPercent metric, PutMetricAlarmCommand, SNS actions
- CloudWatch overview for MCP servers
- CloudWatch alarm management