Guide · AWS Core Services
MCP Server CloudWatch — structured logging, custom metrics, alarms
Amazon CloudWatch is the AWS-native observability platform for MCP servers deployed on ECS, Lambda, or EC2. Log groups collect structured JSON logs from the CloudWatch Logs agent or the awslogs Docker driver; metric filters extract numeric signals from those logs without a separate SDK call; custom metrics via PutMetricData let you track domain-specific counts (tool call rates, error rates per tool, upstream API latency). The three things that trip developers up are: structured JSON logging is not automatic (you must emit one JSON object per log line; CloudWatch Logs receives the line as a string and the metric filter extracts fields from it), PutMetricData is eventual (metrics appear in dashboards 1–2 minutes after publish; alarms evaluate on a 60-second minimum period), and log retention must be set explicitly (the default is no expiration — without a retention policy, logs accumulate indefinitely and cost grows unbounded).
TL;DR
For ECS Fargate: use the awslogs log driver in your task definition — logs go to CloudWatch automatically without any SDK. For custom metrics: install @aws-sdk/client-cloudwatch and call PutMetricDataCommand after each tool invocation. Set log group retention to 30 or 90 days. Create metric alarms on error rate and p95 latency using the metrics derived from log metric filters or PutMetricData.
Structured logging: one JSON object per line
CloudWatch Logs expects one log event per console.log() call (in Lambda/ECS). For CloudWatch metric filters and Logs Insights to parse fields, each line must be a complete, valid JSON object. Multi-line logs (stack traces, pretty-printed JSON) cannot be queried field-by-field.
// Structured logger for MCP server tool handlers
// Emits one JSON line per event — compatible with CloudWatch Logs metric filters
function log(level: "INFO" | "WARN" | "ERROR", event: string, context: Record) {
const entry = {
timestamp: new Date().toISOString(),
level,
event,
service: process.env.SERVICE_NAME ?? "mcp-server",
version: process.env.SERVICE_VERSION ?? "unknown",
...context,
};
// console.log goes to stdout → picked up by awslogs driver → CloudWatch Logs
console.log(JSON.stringify(entry));
}
// Usage in an MCP tool handler:
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 processFile(file_url);
const duration_ms = Date.now() - start;
log("INFO", "tool.completed", { tool: "process_file", duration_ms, bytes: result.size });
return { content: [{ type: "text", text: result.output }] };
} catch (err) {
const duration_ms = Date.now() - start;
log("ERROR", "tool.failed", { tool: "process_file", duration_ms,
error: (err as Error).message });
throw err;
}
});
ECS task definition: awslogs log driver
The simplest CloudWatch Logs setup for ECS Fargate uses the awslogs driver. The ECS agent ships logs to the specified log group without any SDK or agent in your container.
// ECS task definition (JSON) — logConfiguration section
{
"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"
// awslogs-create-group creates the log group if it doesn't exist;
// still set retention separately — creation does not set retention
}
}
}]
}
// AWS CDK: log group with 30-day retention
import * as logs from "aws-cdk-lib/aws-logs";
import * as cdk from "aws-cdk-lib";
const logGroup = new logs.LogGroup(this, "McpServerLogs", {
logGroupName: "/ecs/mcp-server",
retention: logs.RetentionDays.ONE_MONTH, // 30 days; default is INFINITE
removalPolicy: cdk.RemovalPolicy.DESTROY, // destroy log group with stack
});
Custom metrics with PutMetricData
CloudWatch custom metrics let you track domain-level signals: tool call counts, error counts per tool, upstream API latency, queue depth. PutMetricData accepts up to 1,000 metric data points per call and up to 20 metric data points per MetricDatum. Batch metrics in 60-second windows to reduce API cost.
import { CloudWatchClient, PutMetricDataCommand } from "@aws-sdk/client-cloudwatch";
const cw = new CloudWatchClient({ region: process.env.AWS_REGION ?? "us-east-1" });
const NAMESPACE = "McpServer"; // custom namespace — appears under "Custom Namespaces" in console
// Publish a latency metric after each tool call
async function recordToolMetrics(tool: string, duration_ms: number, success: boolean) {
await cw.send(new PutMetricDataCommand({
Namespace: NAMESPACE,
MetricData: [
{
MetricName: "ToolDuration",
Value: duration_ms,
Unit: "Milliseconds",
Dimensions: [
{ Name: "Tool", Value: tool },
{ Name: "Service", Value: process.env.SERVICE_NAME ?? "mcp-server" },
],
Timestamp: new Date(),
},
{
MetricName: "ToolErrors",
Value: success ? 0 : 1,
Unit: "Count",
Dimensions: [
{ Name: "Tool", Value: tool },
{ Name: "Service", Value: process.env.SERVICE_NAME ?? "mcp-server" },
],
Timestamp: new Date(),
},
],
}));
}
// In the tool handler wrapper:
async function instrumentedTool(name: string, fn: () => Promise): Promise {
const start = Date.now();
try {
const result = await fn();
await recordToolMetrics(name, Date.now() - start, true);
return result;
} catch (err) {
await recordToolMetrics(name, Date.now() - start, false);
throw err;
}
}
Log metric filters: extract metrics from log lines
Metric filters parse structured log lines and create CloudWatch metrics without any SDK calls in your code. For an MCP server that logs JSON, you can extract tool duration and error counts from the log stream itself. Metric filters process new log events going forward — they do not back-fill historical data.
// AWS CDK: metric filter to count tool errors from structured logs
import * as logs from "aws-cdk-lib/aws-logs";
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
// Count log lines where level=ERROR
const errorMetricFilter = new logs.MetricFilter(this, "ToolErrorFilter", {
logGroup,
filterPattern: logs.FilterPattern.stringValue("$.level", "=", "ERROR"),
metricNamespace: "McpServer/Logs",
metricName: "ToolErrors",
metricValue: "1",
defaultValue: 0,
dimensions: { Tool: "$.context.tool" },
});
// Alarm on error rate: more than 5 errors in a 5-minute period
const errorAlarm = new cloudwatch.Alarm(this, "ToolErrorAlarm", {
metric: errorMetricFilter.metric({ period: cdk.Duration.minutes(5), statistic: "Sum" }),
threshold: 5,
evaluationPeriods: 1,
alarmDescription: "MCP tool error rate too high",
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});
// Alarm on p95 duration: percentile alarms on log-derived metrics require extended statistics
// Use PutMetricData approach instead for percentile alarms
CloudWatch Logs Insights: querying MCP logs
Logs Insights queries structured JSON logs with a SQL-like syntax. For MCP servers emitting JSON, you can find slow tools, error spikes, and per-tool call counts without exporting to a data warehouse.
// Example Logs Insights queries for an MCP server
// 1. Average tool duration by tool name over the last hour
// fields @timestamp, event, context.tool, context.duration_ms
// | filter event = "tool.completed"
// | stats avg(context.duration_ms) as avg_ms, count(*) as calls by context.tool
// | sort avg_ms desc
// 2. Error rate per tool in the last 24 hours
// filter event = "tool.failed"
// | stats count(*) as errors by context.tool
// | sort errors desc
// 3. Slow tool calls (p95 latency) — top 20
// fields @timestamp, context.tool, context.duration_ms
// | filter event = "tool.completed" and context.duration_ms > 1000
// | sort context.duration_ms desc
// | limit 20
// Run a query programmatically
import { CloudWatchLogsClient, StartQueryCommand, GetQueryResultsCommand } from "@aws-sdk/client-cloudwatch-logs";
const cwLogs = new CloudWatchLogsClient({ region: process.env.AWS_REGION ?? "us-east-1" });
const { queryId } = await cwLogs.send(new StartQueryCommand({
logGroupName: "/ecs/mcp-server",
startTime: Math.floor((Date.now() - 3600 * 1000) / 1000), // 1 hour ago (Unix seconds)
endTime: Math.floor(Date.now() / 1000),
queryString: `fields context.tool, context.duration_ms | filter event = "tool.completed" | stats avg(context.duration_ms) as avg_ms by context.tool | sort avg_ms desc | limit 10`,
}));
// Poll until complete
let status = "Running";
let results;
while (status === "Running" || status === "Scheduled") {
await new Promise(r => setTimeout(r, 1000));
const resp = await cwLogs.send(new GetQueryResultsCommand({ queryId }));
status = resp.status ?? "Running";
results = resp.results;
}
IAM policy for CloudWatch
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"cloudwatch:PutMetricData",
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogStreams"
],
"Resource": "*"
// PutMetricData does not support resource-level restrictions — must use "*"
// For logs, restrict to specific log group ARNs in production
}
]
}
// ECS task role needs the above.
// ECS execution role also needs ecr:GetAuthorizationToken and ecr:BatchGetImage separately.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Metric filter extracts no data | Logs not valid JSON per line (multi-line or string output) | Ensure each log call emits a single JSON object with JSON.stringify() |
| Custom metrics not appearing in console | PutMetricData is eventual (1–2 min delay) | Wait 2+ minutes; also verify namespace matches exactly (case-sensitive) |
| Log group retention not set | awslogs-create-group creates group but does not set retention | Create log group explicitly in CDK/CloudFormation with RetentionDays |
| Logs Insights query returns empty | startTime/endTime in seconds not milliseconds | Divide Date.now() by 1000 for Unix timestamp in seconds |
| Alarm never transitions state | TreatMissingData not set; no data during evaluation period | Set TreatMissingData to NOT_BREACHING for sparse metrics |
| ECS container logs not in CloudWatch | Execution role missing logs:CreateLogStream or awslogs-group doesn't exist | Add logs permissions to execution role and pre-create log group |