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.

StateMeaningTransitions to
CREATINGCreateCanaryCommand accepted, resources provisioningREADY or ERROR
READYCreated but not running — waiting for StartCanaryCommandSTARTING (after Start)
STARTINGStart accepted, scheduler enablingRUNNING
RUNNINGCanary is active and executing on scheduleSTOPPING (after Stop), UPDATING (after Update)
UPDATINGUpdateCanaryCommand in progress — canary paused during updateRUNNING (after update completes)
STOPPINGStopCanaryCommand accepted, in-flight run completingSTOPPED
STOPPEDCanary halted — no new runs until restartedSTARTING (after Start)
ERRORProvisioning or configuration error — check StateReasonCan retry Start after fixing underlying issue
DELETEDDeleteCanaryCommand completedTerminal

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.

ExpressionRuns atNotes
rate(1 minute)Every 60 secondsMinimum supported rate
rate(5 minutes)Every 5 minutesTypical API health-check cadence
rate(1 hour)Every hourUse for slow/expensive checks
cron(0 * * * ? *)Top of every hourStandard EventBridge cron syntax
cron(0 8 * * ? *)8:00 AM UTC dailyUse ? for day-of-week when day-of-month specified
cron(0 9 ? * MON-FRI *)9:00 AM UTC weekdaysBusiness-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

ErrorCauseFix
ConflictExceptionCanary name already exists (even deleted ones during propagation)Use a unique name or wait a few minutes after deleting before reusing the name
ValidationExceptionInvalid Name format, bad cron expression, or missing required fieldCanary names: 1–21 chars, lowercase letters/numbers/hyphens only
ResourceNotFoundExceptionGetCanary/StartCanary on a name that does not existVerify the name and region match — canaries are regional
AccessDeniedExceptionMissing synthetics:* action or iam:PassRole on execution role ARNAdd the missing permissions to the MCP server's IAM policy
InternalFailureExceptionTransient AWS service errorRetry with exponential backoff
Canary enters ERROR state after createExecution role missing lambda.amazonaws.com trust or missing S3/logs permissionsCheck canary Status.StateReason; fix the execution role and re-create

Related pages