AWS Core Services · 2026-08-14 · AWS Core Services arc

AWS Core Services for MCP Servers: SQS, SNS, EventBridge, CloudWatch, and ECS — IAM Role Trap, Async Job Queuing, Event Routing Spectrum, and Zero-Code Observability

Five AWS services — ECS Fargate, SQS, SNS, EventBridge, and CloudWatch — are routinely assembled into a production MCP server stack. Each service has its own documentation, its own SDK package, and its own failure modes. What the individual docs don't cover are the four cross-cutting patterns that appear in every AWS-hosted MCP server, regardless of which subset of services you use. Pattern 1 — The two-IAM-role trap: ECS Fargate tasks require two distinct IAM roles — the execution role (used by the ECS agent to pull your container image and ship logs) and the task role (used by your application code to call SQS, SNS, EventBridge, CloudWatch, and any other AWS service); the symptoms of a missing execution role and a missing task role look completely different; confusing the two accounts for the majority of first-ECS-deployment debugging sessions. Pattern 2 — Async tool execution with SQS: the Model Context Protocol's request-response model has a practical latency ceiling — if a tool takes more than a few seconds, agents experience timeouts, retries, or degraded performance; the SQS submit-and-poll pattern decouples tool submission from tool execution, returning a job ID in under 200 ms regardless of how long the underlying work takes; the three settings that drive every SQS integration are visibility timeout (must exceed the longest possible job duration), dead-letter queue configuration (prevents poison messages from consuming worker capacity indefinitely), and the delete-only-on-success discipline (a message deleted before processing completes is lost permanently). Pattern 3 — The event routing spectrum: SQS, SNS, and EventBridge are all described as "messaging" services, but they solve different routing problems — SQS is a durable pull queue (consumers poll, messages are buffered indefinitely, exactly-once delivery is achievable); SNS is a push fanout (one publish reaches every subscriber simultaneously, but HTTP subscribers have no buffer if they are down); EventBridge is a content-based router (event patterns match on any JSON field, enabling content-based delivery to different targets without per-event-type topics); choosing the wrong service for a routing problem creates either data loss (SNS HTTP subscriptions with no SQS backing) or unnecessary complexity (EventBridge for simple fanout that SNS-to-SQS handles cleanly). Pattern 4 — Zero-code observability with CloudWatch: the awslogs Docker log driver in an ECS task definition ships all stdout/stderr output to CloudWatch Logs without any SDK calls or agent in your container; if that output is structured JSON, a CloudWatch metric filter can extract numeric signals (error counts, latency values, call rates) from the log stream and populate CloudWatch alarms — no PutMetricData call, no additional SDK package, no configuration changes in your application code; the only costs are structured logging discipline (one JSON object per console.log() line) and log group retention configuration (the default is no expiration — without a retention policy, logs grow indefinitely).

TL;DR

Four rules that apply regardless of which AWS services your MCP server uses. (1) Two IAM roles, always: ECS execution role = AmazonECSTaskExecutionRolePolicy (agent, not your code); ECS task role = the permissions your MCP tool handlers actually need (SQS, SNS, S3, DynamoDB); missing execution role = container won't start; missing task role = 403 at runtime. (2) SQS async pattern: set VisibilityTimeout to 2× max job duration; set WaitTimeSeconds: 20 (long poll); configure a DLQ with maxReceiveCount: 3; delete the message only after successful processing — never before. (3) Routing spectrum: SQS when you need durable buffering and pull; SNS-to-SQS when you need fanout with durability; EventBridge when you need content-based routing on JSON fields. (4) Zero-code observability: awslogs driver in the task definition + JSON structured logging + CloudWatch metric filters = alerts on error rates and latency without a single extra line of application code.

Pattern 1 — The two-IAM-role trap: execution role vs task role on ECS Fargate

Every ECS Fargate task definition references two separate IAM roles. The naming is confusing, and the AWS documentation treats them interchangeably in some places, but they are used by entirely different systems and need entirely different permissions.

RoleUsed byNeedsMissing permission symptom
Execution roleECS agent (not your code)ECR image pull, CloudWatch Logs write, SSM/Secrets Manager read (for secrets injection)Task stuck in PROVISIONING, then fails with "CannotPullContainerError" — container never starts
Task roleYour MCP server applicationAny AWS service your tool handlers call: SQS, SNS, S3, DynamoDB, EventBridge, CloudWatch PutMetricDataContainer starts and runs; tool calls return 403 AccessDenied at runtime — container logs show the error

The symptom difference is the whole diagnostic key. If the container never starts — task fails during PROVISIONING or PENDING, ECS console shows "CannotPullContainerError", "ResourceInitializationError", or the log group cannot be created — the problem is the execution role. If the container starts, the health check passes, the MCP server is reachable, but tool calls fail with 403 errors visible in CloudWatch Logs — the problem is the task role.

The minimum execution role is AWS's managed policy AmazonECSTaskExecutionRolePolicy, which covers ECR authentication, image layer pulls, and CloudWatch Logs stream creation. When you use the secrets array in a task definition to inject values from SSM Parameter Store or Secrets Manager, you also need to add read permissions for those specific secrets to the execution role — not the task role.

// AWS CDK: complete two-role ECS task definition for an MCP server
import * as ecs from "aws-cdk-lib/aws-ecs";
import * as iam from "aws-cdk-lib/aws-iam";
import * as logs from "aws-cdk-lib/aws-logs";
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";

// Role 1: Execution role — only the ECS agent uses this
const executionRole = new iam.Role(this, "ExecutionRole", {
  assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
  managedPolicies: [
    iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AmazonECSTaskExecutionRolePolicy"),
    // Covers: ecr:GetAuthorizationToken, ecr:BatchGetImage, ecr:GetDownloadUrlForLayer,
    //         logs:CreateLogStream, logs:PutLogEvents
  ],
});

// If you inject secrets from Secrets Manager, execution role needs read permission
const apiKeySecret = secretsmanager.Secret.fromSecretNameV2(this, "ApiKey", "mcp-server/api-key");
apiKeySecret.grantRead(executionRole);  // execution role reads this at task start

// Role 2: Task role — your application code uses this at runtime
const taskRole = new iam.Role(this, "TaskRole", {
  assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
});
taskRole.addToPolicy(new iam.PolicyStatement({
  effect: iam.Effect.ALLOW,
  actions: [
    "sqs:SendMessage", "sqs:ReceiveMessage", "sqs:DeleteMessage",
    "sqs:ChangeMessageVisibility", "sqs:GetQueueAttributes",
    "sns:Publish",
    "events:PutEvents",
    "cloudwatch:PutMetricData",
    "s3:GetObject", "s3:PutObject",
  ],
  resources: ["*"],  // narrow to specific ARNs in production
}));

// Task definition: wires both roles
const taskDef = new ecs.FargateTaskDefinition(this, "TaskDef", {
  cpu: 512,
  memoryLimitMiB: 1024,
  executionRole,
  taskRole,  // the application code uses this one; the ECS agent uses executionRole
});

The secrets injection model follows the same two-role logic. Secrets in the secrets array of a task definition are fetched by the ECS agent at task start time — the execution role reads the secret, the agent injects it as an environment variable, and the container starts with the value already available. The task role is not involved. This matters during secret rotation: when you rotate a secret in Secrets Manager, running containers continue using the pre-rotation value. Force a new deployment (aws ecs update-service --force-new-deployment) to pick up the rotated value — a redeploy starts new tasks that fetch the new value at startup.

The two-role pattern applies to every AWS service in this arc. When an MCP tool handler publishes to SQS, that call uses the task role. When the container logs to CloudWatch via the awslogs driver, that call uses the execution role. Understanding which role is used by which component eliminates the confusion between "the service is broken" (execution role) and "the tool call is broken" (task role).

Pattern 2 — Async tool execution with SQS: submit-and-poll, visibility timeout, and dead-letter queues

The Model Context Protocol request-response model has a practical ceiling: if a tool call takes more than a few seconds, agent frameworks experience timeouts, retry the call, or display degraded UI to users. Many real MCP tool operations — file processing, external API calls, database migrations, report generation — can take 30 seconds to several minutes. The SQS submit-and-poll pattern resolves this by decoupling the tool invocation (fast, under 200 ms) from the underlying work (slow, unbounded).

The pattern uses two tools: a submit tool that enqueues the job and returns a job ID, and a poll tool that checks job status by job ID. The agent calls submit, stores the job ID, and polls at an appropriate interval until the job completes. The actual work runs in a background worker process that consumes the SQS queue.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { SQSClient, SendMessageCommand, ReceiveMessageCommand,
  DeleteMessageCommand, ChangeMessageVisibilityCommand } from "@aws-sdk/client-sqs";
import { z } from "zod";
import { randomUUID } from "crypto";

const server = new McpServer({ name: "async-worker", version: "1.0.0" });
const sqs = new SQSClient({ region: process.env.AWS_REGION ?? "us-east-1" });
const QUEUE_URL = process.env.SQS_QUEUE_URL!;

// Tool 1: submit — enqueues work, returns job ID immediately
server.tool(
  "submit_report",
  "Enqueue a report generation job. Returns a job ID for polling.",
  {
    dataset: z.string().describe("Dataset identifier to generate the report from"),
    format: z.enum(["pdf", "csv", "json"]).default("pdf"),
  },
  async ({ dataset, format }) => {
    const jobId = randomUUID();
    await sqs.send(new SendMessageCommand({
      QueueUrl: QUEUE_URL,
      MessageBody: JSON.stringify({ jobId, dataset, format, submitted_at: new Date().toISOString() }),
    }));
    return { content: [{ type: "text", text: JSON.stringify({ jobId, status: "queued" }) }] };
  }
);

// Tool 2: poll — checks job status from a results store (DynamoDB, Redis, etc.)
server.tool(
  "check_report",
  "Check the status of a previously submitted report job.",
  { job_id: z.string().uuid() },
  async ({ job_id }) => {
    const status = await getJobStatus(job_id);  // reads from a results store
    return { content: [{ type: "text", text: JSON.stringify(status) }] };
  }
);

Visibility timeout: the most critical SQS setting

When a worker receives a message from SQS, the service hides that message from all other consumers for the visibility timeout period. If the worker does not delete the message before the timeout expires, SQS makes the message visible again — and another worker picks it up, causing the same job to be processed twice.

The rule is: visibility timeout = 2× the maximum possible job duration. If report generation takes up to 5 minutes in the worst case, set the visibility timeout to 600 seconds. For jobs with variable duration (some take 10 seconds, some take 10 minutes), use a heartbeat that extends the visibility timeout while the job is running:

// Heartbeat: extends visibility timeout every 30 seconds
// Prevents double-processing for variable-duration jobs
async function processWithHeartbeat(receiptHandle: string, jobFn: () => Promise) {
  const EXTENSION_SECONDS = 60;
  const interval = setInterval(async () => {
    try {
      await sqs.send(new ChangeMessageVisibilityCommand({
        QueueUrl: QUEUE_URL,
        ReceiptHandle: receiptHandle,
        VisibilityTimeout: EXTENSION_SECONDS,  // reset the clock every 30 seconds
      }));
    } catch { /* message may have been deleted — stop extending */ }
  }, (EXTENSION_SECONDS / 2) * 1000);  // extend at half the visibility timeout

  try {
    await jobFn();
  } finally {
    clearInterval(interval);
  }
}

Dead-letter queues: the poison message defense

Without a dead-letter queue, a message that always fails processing cycles forever: it becomes visible, a worker picks it up, it throws, the visibility timeout expires, another worker picks it up, it throws again — indefinitely. A DLQ with maxReceiveCount: 3 moves the message to the DLQ after 3 failed attempts, freeing the workers for good messages.

// The complete worker loop: long poll, heartbeat, delete-on-success, DLQ on failure
async function runWorkerLoop(signal: AbortSignal) {
  while (!signal.aborted) {
    const response = await sqs.send(new ReceiveMessageCommand({
      QueueUrl: QUEUE_URL,
      MaxNumberOfMessages: 10,
      WaitTimeSeconds: 20,   // ALWAYS use long polling — reduces empty-receive cost by ~95%
      AttributeNames: ["ApproximateReceiveCount"],
    }));

    if (!response.Messages?.length) continue;

    await Promise.allSettled(
      response.Messages.map(async (msg) => {
        try {
          const payload = JSON.parse(msg.Body!);
          await processWithHeartbeat(msg.ReceiptHandle!, () => doWork(payload));

          // Delete ONLY after successful processing
          await sqs.send(new DeleteMessageCommand({
            QueueUrl: QUEUE_URL,
            ReceiptHandle: msg.ReceiptHandle!,
          }));
        } catch (err) {
          // Do NOT delete — let SQS retry; after maxReceiveCount attempts, moves to DLQ
          console.error(JSON.stringify({ event: "worker.failed", jobId: JSON.parse(msg.Body!).jobId, err }));
        }
      })
    );
  }
}

The delete-on-success discipline is non-negotiable. If you delete the message before calling doWork(), or inside a try block where the actual work might fail, messages disappear even when processing fails — the job is lost with no retry and no DLQ record. Delete only after the work is confirmed successful.

Batch operations matter for cost at scale. SendMessageBatch sends up to 10 messages per API call; DeleteMessageBatch deletes up to 10. SQS charges per API request, not per message. At high throughput, batching reduces cost by 10×. One critical caveat: both batch commands return HTTP 200 on partial failure. Always inspect result.Failed — a non-empty Failed array means some entries were not processed, and the retry logic must handle them explicitly.

Pattern 3 — The event routing spectrum: SQS, SNS, and EventBridge solve different problems

MCP servers that integrate with AWS event services frequently need to choose between SQS, SNS, and EventBridge for a given routing problem. The three services are described as "messaging" in AWS documentation, but they solve different routing shapes. Using the wrong service creates either data loss or unnecessary complexity.

ServiceDelivery modelConsumer patternBuffer when consumer is downBest for
SQSPull (consumer polls)One consumer per messageYes — messages persist up to 14 daysAsync job queues, background workers, rate limiting upstream consumers
SNSPush (SNS delivers)All subscribers receive every messageOnly if subscriber is SQS — HTTP/Lambda subscribers have limited retryFan-out to multiple consumers simultaneously
EventBridgePush with routing rulesRules match events; matched events go to targetsSQS target provides buffer; Lambda/HTTP targets do notContent-based routing, AWS service event consumption, scheduled triggers

SQS: when you need durable buffering

SQS is the right choice when an MCP tool needs to enqueue work for a background process, or when the rate of incoming jobs exceeds the processing rate and you need the excess to be buffered. SQS messages persist for up to 14 days. The consumer runs at its own pace. If the consumer is down for maintenance or restart, messages accumulate and are processed when the consumer comes back — zero data loss.

SNS-to-SQS fan-out: when you need multiple consumers with durability

SNS delivers one event to multiple subscribers simultaneously. The common failure mode is using an HTTP subscriber without SQS backing: if the HTTP endpoint is down when SNS delivers, SNS retries with exponential backoff but eventually gives up — the event is lost. The reliable pattern is SNS-to-SQS fan-out: subscribe SQS queues to the SNS topic. Each queue buffers the event durably; each consumer processes from its own queue at its own pace.

// SNS-to-SQS fan-out: reliable multi-consumer event delivery
// AWS CDK: one topic, two SQS queues as subscribers
import * as sns from "aws-cdk-lib/aws-sns";
import * as sqs from "aws-cdk-lib/aws-sqs";
import * as subs from "aws-cdk-lib/aws-sns-subscriptions";

const topic = new sns.Topic(this, "EventTopic", { topicName: "mcp-events" });
const analyticsQueue = new sqs.Queue(this, "AnalyticsQueue");
const auditQueue = new sqs.Queue(this, "AuditQueue");

// Both queues receive every event — independently buffered, independently consumed
topic.addSubscription(new subs.SqsSubscription(analyticsQueue));
topic.addSubscription(new subs.SqsSubscription(auditQueue));

When SNS delivers to SQS, the SQS message body is the SNS notification envelope — a JSON object with Type, TopicArn, Message, and metadata. The consumer must double-parse: JSON.parse(sqsMsg.Body) gets the envelope, then JSON.parse(envelope.Message) gets the original payload. This double-parse is the most common bug in SNS-to-SQS integrations — consumers expecting to parse the SQS body as their original payload instead get the envelope structure.

Message filtering on subscriptions enables content-based fan-out without multiplying topics. A filter policy on a subscription tells SNS to only deliver events to that subscription when the MessageAttributes match. An analytics queue might subscribe only to eventType = "job.completed" events while an audit queue subscribes to all events. The filtering happens at SNS delivery time — the queue only receives matching events, and the MCP server publishes to one topic regardless of how many consumers subscribe with different filters.

// Publishing from an MCP tool: one call, filtered delivery to multiple queues
server.tool(
  "emit_event",
  "Publish a named event to the SNS topic",
  {
    event_type: z.string().describe("Dot-separated event name, e.g. job.completed"),
    payload: z.record(z.unknown()),
    priority: z.number().int().min(1).max(3).default(1),
  },
  async ({ event_type, payload, priority }) => {
    await sns.send(new PublishCommand({
      TopicArn: process.env.SNS_TOPIC_ARN!,
      Message: JSON.stringify(payload),
      MessageAttributes: {
        eventType: { DataType: "String", StringValue: event_type },
        priority:  { DataType: "Number", StringValue: String(priority) },
      },
    }));
    return { content: [{ type: "text", text: JSON.stringify({ event_type, status: "published" }) }] };
  }
);

EventBridge: when you need content-based routing or scheduled invocations

EventBridge solves a different routing problem: content-based routing on the event body, not just header attributes. An EventBridge rule can match events where detail.status === "failed" and detail.priority >= 2 and route only those events to an incident-response target — without a separate topic or queue for each combination. SNS filter policies can only match on MessageAttributes headers; EventBridge patterns match on any field in the event JSON, including nested objects.

The fixed envelope format is the source of the most common EventBridge bug: Source, DetailType, and Detail are all required. Missing any of them causes the event to be silently dropped — no error, no DLQ, no retry. Detail must be a JSON-serialised string, not an object. PutEvents returns HTTP 200 even on partial entry failure, so FailedEntryCount must be checked on every call.

import { EventBridgeClient, PutEventsCommand } from "@aws-sdk/client-eventbridge";
const eb = new EventBridgeClient({ region: process.env.AWS_REGION ?? "us-east-1" });

async function publishEvent(detailType: string, detail: object) {
  const result = await eb.send(new PutEventsCommand({
    Entries: [{
      EventBusName: process.env.EVENTBRIDGE_BUS_NAME ?? "mcp-app-events",
      Source: "com.myapp.mcp",       // REQUIRED — omitting causes silent drop
      DetailType: detailType,         // REQUIRED
      Detail: JSON.stringify(detail), // REQUIRED, must be a JSON string not an object
    }],
  }));

  // HTTP 200 does not mean success — check FailedEntryCount
  if (result.FailedEntryCount && result.FailedEntryCount > 0) {
    const failure = result.Entries?.find(e => e.ErrorCode);
    throw new Error(`EventBridge publish failed: ${failure?.ErrorMessage}`);
  }
  return result.Entries?.[0]?.EventId;
}

EventBridge's second use case for MCP servers is scheduled invocations: triggering an MCP tool on a rate or cron schedule without a separate Lambda or cron container. EventBridge Scheduler (the newer service) extends this with per-schedule IAM roles and one-time invocations (ActionAfterCompletion: "DELETE" auto-deletes the schedule after it fires once). For an MCP server that needs to send daily reports, rotate credentials, or audit endpoint health on a schedule, EventBridge Scheduler is the lowest-infrastructure option on AWS.

The routing decision in practice

For most MCP server integrations, the routing decision follows a simple cascade:

  1. Does this work need to be queued and processed by a background worker? → SQS
  2. Does this event need to reach more than one consumer? → SNS-to-SQS fan-out (with SQS subscribers, not HTTP, for durability)
  3. Does the routing depend on the content of the event body, or does the trigger need to be time-based? → EventBridge

EventBridge is often the right choice for consuming AWS service events (EC2 state changes, S3 object created, Secrets Manager rotation completed). Those events appear on the default event bus automatically — an EventBridge rule can route them to an SQS queue that your MCP server polls, connecting AWS service events to MCP tool handlers without any webhook infrastructure.

Pattern 4 — Zero-code observability: awslogs driver + structured logging + metric filters

MCP servers on ECS Fargate can have complete CloudWatch observability — log aggregation, error rate alarms, latency tracking — without a single additional SDK package or additional line of application code (beyond the structured logging discipline). The three components are the awslogs log driver, structured JSON logging, and CloudWatch metric filters.

The awslogs driver: free log shipping

The awslogs log driver is configured in the ECS task definition's logConfiguration block. When the driver is active, the ECS agent captures all stdout and stderr output from the container and ships it to the specified CloudWatch Logs group — at the execution role's permissions, with no application code involved. The awslogs driver is included with ECS Fargate at no extra cost beyond standard CloudWatch Logs ingestion pricing.

// ECS task definition JSON — logConfiguration for awslogs driver
{
  "containerDefinitions": [{
    "name": "mcp-server",
    "image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-server:latest",
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "/ecs/mcp-server",
        "awslogs-region": "us-east-1",
        "awslogs-stream-prefix": "ecs",
        "awslogs-create-group": "true"
        // Creates the log group if missing — but does NOT set retention
        // You must set retention separately or costs grow without bound
      }
    }
  }]
}

// AWS CDK: log group with explicit 30-day retention
import * as logs from "aws-cdk-lib/aws-logs";
const logGroup = new logs.LogGroup(this, "McpLogs", {
  logGroupName: "/ecs/mcp-server",
  retention: logs.RetentionDays.ONE_MONTH,  // default is INFINITE — always set this
});

The execution role needs logs:CreateLogStream and logs:PutLogEvents on the log group. Both are included in AmazonECSTaskExecutionRolePolicy. The task role does not need any CloudWatch Logs permissions if you're only using the awslogs driver.

Structured logging: one JSON object per line

CloudWatch metric filters and Logs Insights both parse log lines as structured data — but only if each line is a complete, valid JSON object. Multi-line output (pretty-printed JSON, stack traces, multi-line console.log calls) cannot be queried field-by-field. The discipline is: every console.log() call emits a single JSON.stringify() call on a single object.

// Structured logger — one JSON line per event, parsed by metric filters and Logs Insights
function log(level: "INFO" | "WARN" | "ERROR", event: string, context: Record) {
  console.log(JSON.stringify({
    timestamp: new Date().toISOString(),
    level,
    event,
    service: process.env.SERVICE_NAME ?? "mcp-server",
    ...context,
  }));
  // stdout → awslogs driver → CloudWatch Logs
}

// Usage in MCP tool handlers:
server.tool("process_file", "...", { file_url: z.string().url() }, async ({ file_url }) => {
  const start = Date.now();
  log("INFO", "tool.started", { tool: "process_file", file_url });

  try {
    const result = await doWork(file_url);
    log("INFO", "tool.completed", { tool: "process_file", duration_ms: Date.now() - start, bytes: result.size });
    return { content: [{ type: "text", text: result.output }] };
  } catch (err) {
    log("ERROR", "tool.failed", { tool: "process_file", duration_ms: Date.now() - start, error: (err as Error).message });
    throw err;
  }
});

Metric filters: alarms without PutMetricData

A CloudWatch metric filter is a rule attached to a log group that parses log events and increments a metric counter when a pattern matches. For an MCP server emitting structured JSON, a metric filter on $.level = "ERROR" produces an error count metric without any change to application code. Combine with a CloudWatch alarm to get an SNS notification or PagerDuty page when the error rate exceeds a threshold.

// AWS CDK: metric filter + alarm on tool error rate
import * as logs from "aws-cdk-lib/aws-logs";
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";

// Metric filter: count ERROR log lines
const errorFilter = new logs.MetricFilter(this, "ErrorFilter", {
  logGroup,
  filterPattern: logs.FilterPattern.stringValue("$.level", "=", "ERROR"),
  metricNamespace: "McpServer",
  metricName: "ToolErrors",
  metricValue: "1",
  defaultValue: 0,
});

// Alarm: more than 5 errors in 5 minutes triggers an alert
const errorAlarm = new cloudwatch.Alarm(this, "ErrorAlarm", {
  metric: errorFilter.metric({ period: cdk.Duration.minutes(5), statistic: "Sum" }),
  threshold: 5,
  evaluationPeriods: 1,
  treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
  alarmDescription: "MCP tool error rate too high",
});

Metric filters process new log events going forward — they do not back-fill historical data. For latency percentiles and per-tool breakdowns, add a PutMetricData call in your tool handler wrapper. But for the minimum viable observability baseline — "are errors happening, and is the service alive" — metric filters from structured logs cover it with no additional SDK dependency.

Logs Insights: ad-hoc queries without exporting

CloudWatch Logs Insights queries structured JSON logs with a SQL-like syntax directly in the CloudWatch console. For MCP servers emitting the event, tool, and duration_ms fields used above, standard diagnostic queries become one-liners:

// Logs Insights: slowest tool calls in the last hour
// fields @timestamp, context.tool, context.duration_ms
// | filter event = "tool.completed"
// | sort context.duration_ms desc
// | limit 20

// Error rate per tool in the last 24 hours
// filter event = "tool.failed"
// | stats count(*) as errors by context.tool
// | sort errors desc

// Tool call volume by tool — useful for capacity planning
// filter event = "tool.completed" or event = "tool.failed"
// | stats count(*) as calls by context.tool
// | sort calls desc

One critical time unit: Logs Insights startTime and endTime parameters accept Unix timestamps in seconds, not milliseconds. Math.floor(Date.now() / 1000) for the current time. Using milliseconds returns an empty result set — the query runs, returns HTTP 200, and produces zero rows.

When to add PutMetricData

The zero-code observability baseline (awslogs + metric filters) covers binary signals well: error rate, call rate, service alive/dead. It does not cover latency percentiles — metric filters produce count metrics, not distribution metrics. PutMetricData with explicit latency values enables p95/p99 alarms, which are the right signal for tool performance regressions. The addition costs one IAM permission on the task role (cloudwatch:PutMetricData on Resource: "*" — PutMetricData does not support resource-level restrictions) and one additional AWS SDK call per tool invocation.

import { CloudWatchClient, PutMetricDataCommand } from "@aws-sdk/client-cloudwatch";

const cw = new CloudWatchClient({ region: process.env.AWS_REGION ?? "us-east-1" });

// Call after every tool handler — records latency + error count per tool
async function recordMetrics(tool: string, duration_ms: number, success: boolean) {
  await cw.send(new PutMetricDataCommand({
    Namespace: "McpServer",
    MetricData: [
      { MetricName: "ToolDuration", Value: duration_ms, Unit: "Milliseconds",
        Dimensions: [{ Name: "Tool", Value: tool }], Timestamp: new Date() },
      { MetricName: "ToolErrors", Value: success ? 0 : 1, Unit: "Count",
        Dimensions: [{ Name: "Tool", Value: tool }], Timestamp: new Date() },
    ],
  }));
}

PutMetricData is eventual: metrics appear in CloudWatch dashboards and alarms approximately 1–2 minutes after publish. Do not use it to verify that a specific invocation succeeded in real time — it is a time-series signal for alerting and dashboards, not a confirmation mechanism.

Putting the four patterns together: a complete AWS MCP server stack

The four patterns compose naturally into a production MCP server stack on AWS:

  1. Two-role IAM: execution role with AmazonECSTaskExecutionRolePolicy + execution role granted read on specific secrets; task role with SQS, SNS, EventBridge, S3, and CloudWatch PutMetricData permissions scoped to specific resource ARNs.
  2. SQS async pattern: submit tool enqueues via SendMessageCommand; background worker loop with WaitTimeSeconds: 20 + heartbeat + DLQ; poll tool reads from a results store populated by the worker; visibility timeout set to 2× the longest job; DLQ with maxReceiveCount: 3.
  3. Event routing: SQS for async jobs; SNS-to-SQS for fan-out to multiple services (analytics, audit, notification); EventBridge for content-based routing and consuming AWS service events (Secrets Manager rotation, S3 triggers).
  4. Zero-code observability: awslogs driver in the task definition; one structured JSON object per log call; metric filter for error rate; alarm with TreatMissingData.NOT_BREACHING; Logs Insights for ad-hoc diagnostics; PutMetricData added later if latency percentile alarms are needed.

This stack requires no application framework — no Serverless Framework, no CDK constructs library beyond CDK itself — and runs on Fargate without managing EC2 instances. The health check endpoint (GET /health → 200 in under 5 seconds) is the only infrastructure-awareness the MCP server needs. Everything else — rolling deployments, connection draining, ALB routing, log shipping, scaling — is managed by ECS and the two IAM roles.

Combined failure modes table

ServiceSymptomRoot causeFix
ECSTask stuck in PROVISIONING, then failsExecution role missing ECR pull or log permissionsAttach AmazonECSTaskExecutionRolePolicy to execution role; pre-create log group
ECSMCP tool calls return 403 AccessDeniedTask role missing the required action (SQS, SNS, S3, etc.)Add the missing action to the task role IAM policy
ECSContainer killed immediately after startingALB health check failing (wrong path, slow response, or non-200)Implement GET /health → 200 in <5 seconds; set correct path in target group
ECSRotated secret still uses old valueSecrets injected at task start; not refreshed during container lifetimeForce new deployment: aws ecs update-service --force-new-deployment
SQSSame job processed twiceVisibilityTimeout shorter than job durationSet to 2× max job duration; use heartbeat extension for variable-duration jobs
SQSPoison message loops foreverNo DLQ configuredAdd DLQ with maxReceiveCount: 3 to source queue redrive policy
SQSBatch send silently loses messagesresult.Failed not checked after SendMessageBatchAlways inspect result.Failed and retry failed entries
SNSHTTP subscriber misses events when downHTTP subscriptions have no message bufferSubscribe SQS queues to the SNS topic instead of HTTP endpoints
SNSConsumer receives SNS envelope, not payloadDouble-serialisation not handledParse sqsMsg.Body → SNS envelope → parse envelope.Message → your payload
SNSHTTP subscription stuck "Pending confirmation"Endpoint never fetched SubscribeURLImplement SubscriptionConfirmation handler that fetches SubscribeURL on receipt
EventBridgeEvent published but rule never firesSource or DetailType mismatch; or Detail not JSON stringifiedUse EventBridge test event patterns; always JSON.stringify() Detail
EventBridgePutEvents returns 200 but event droppedFailedEntryCount not checkedAlways inspect result.FailedEntryCount and per-entry ErrorCode
CloudWatchMetric filter extracts no dataLogs not valid single-line JSONUse console.log(JSON.stringify(obj)) — one object per call, no pretty-printing
CloudWatchLog retention not set; costs grow unboundedawslogs-create-group creates the group but not the retention policyCreate log group explicitly in CDK with retention: RetentionDays.ONE_MONTH
CloudWatchLogs Insights query returns emptystartTime/endTime in milliseconds instead of secondsUse Math.floor(Date.now() / 1000) for Unix timestamp in seconds