Guide · Lambda Powertools

AWS Lambda Powertools Metrics for MCP Servers

AWS Lambda Powertools Metrics emits custom CloudWatch metrics via Embedded Metric Format (EMF) — structured JSON written to stdout that the CloudWatch Logs agent converts into CloudWatch Metrics automatically, with no PutMetricData API call and no added latency to your Lambda execution. For MCP server instrumentation this is the right approach for per-tool invocation counters, tool latency histograms, and error-rate metrics that you want to alert on. Three things teams get wrong: forgetting @metrics.log_metrics on the handler — without it, metrics accumulate in memory but never flush to stdout, so they silently disappear; adding too many dimension combinations — CloudWatch charges per metric stream, and each unique set of dimension values is a separate stream; emitting more than 100 metrics per EMF blob without calling metrics.flush_metrics() mid-invocation, which causes a SchemaValidationError on flush.

TL;DR

Always use @metrics.log_metrics(capture_cold_start_metric=True). Set POWERTOOLS_METRICS_NAMESPACE in env vars. Call metrics.set_default_dimensions(Service="mcp-tool-dispatcher", Environment="prod") once at module level. For high-volume handlers emitting more than 100 metrics per invocation, call metrics.flush_metrics() at the 100-metric boundary.

How EMF works — no API calls, no latency

Traditional CloudWatch custom metric publishing requires a PutMetricData API call — network round-trip, IAM permission check, SDK overhead, and a 20 metrics/call limit that forces batching logic. EMF bypasses all of this: Powertools writes a specially-structured JSON blob to stdout, the same place Lambda sends log output. The CloudWatch Logs agent that already processes all Lambda stdout reads the EMF JSON and converts it to CloudWatch metric data points automatically.

The result: metric emission adds zero milliseconds to your Lambda duration and requires no cloudwatch:PutMetricData permission. The only requirement is logs:CreateLogGroup and logs:PutLogEvents, which Lambda already needs.

from aws_lambda_powertools import Metrics
from aws_lambda_powertools.metrics import MetricUnit
from aws_lambda_powertools.utilities.typing import LambdaContext

# POWERTOOLS_METRICS_NAMESPACE env var sets the CloudWatch namespace
metrics = Metrics(namespace="MCPServer")

@metrics.log_metrics(capture_cold_start_metric=True)
def handler(event: dict, context: LambdaContext) -> dict:
    tool_name = event.get("toolName", "unknown")

    metrics.add_dimension(name="ToolName", value=tool_name)
    metrics.add_metric(name="ToolInvocation", unit=MetricUnit.Count, value=1)

    start = time.monotonic()
    try:
        result = execute_tool(event)
        metrics.add_metric(name="ToolSuccess", unit=MetricUnit.Count, value=1)
        return result
    except Exception as e:
        metrics.add_metric(name="ToolError", unit=MetricUnit.Count, value=1)
        raise
    finally:
        duration_ms = (time.monotonic() - start) * 1000
        metrics.add_metric(name="ToolDuration", unit=MetricUnit.Milliseconds, value=duration_ms)

Default dimensions — set once, apply to all metrics

Dimensions slice CloudWatch metrics: you can filter by ToolName=search_codebase or graph ToolError broken down by Environment. But every unique combination of dimension values creates a separate metric stream in CloudWatch, which is what you're charged for. Set dimensions that are truly constant for all metrics in the handler as default dimensions:

from aws_lambda_powertools import Metrics

metrics = Metrics(namespace="MCPServer")

# Set at module level — applies to all metrics in this Lambda function
metrics.set_default_dimensions(
    Service="mcp-tool-dispatcher",
    Environment=os.environ.get("ENVIRONMENT", "prod"),
)

# Now every add_metric() call inherits Service and Environment automatically
# You don't need to call add_dimension() before each metric

Default dimensions are not cleared between invocations (unlike add_dimension, which is cleared after each flush). If you have per-invocation dimensions that vary (like ToolName), add those with add_dimension() at the start of the handler after extracting from the event.

Dimension cardinality guideline: each unique dimension value combination costs ~$0.30/month in CloudWatch metric streams. With 10 tools and 3 environments = 30 streams × $0.30 = $9/month — acceptable. If you add user_id as a dimension at thousands of distinct values, that's thousands of streams and a large bill. Keep dimensions low-cardinality (tool name, environment, tier).

Cold start metric

Lambda cold starts add latency and are invisible in standard CloudWatch metrics. Powertools Metrics can automatically emit a ColdStart metric on the first invocation of each execution context:

@metrics.log_metrics(capture_cold_start_metric=True)
def handler(event: dict, context: LambdaContext) -> dict:
    # ColdStart metric is emitted automatically before handler runs
    # Value: 1 on cold start, not emitted on warm invocations
    # Dimensions: Service + FunctionName
    pass

The ColdStart metric uses a separate EMF flush with its own namespace and dimensions so it doesn't mix with your business metrics. You can build a CloudWatch alarm: if ColdStart count increases sharply, provisioned concurrency may be needed for your SLA-sensitive MCP tools.

Important: the ColdStart metric is emitted in a separate log_metrics call from your other metrics, so it does not count against your 100-metric-per-EMF-blob limit.

High-resolution metrics

Standard CloudWatch metric resolution is 60 seconds — you can't detect a 10-second spike in tool errors unless it happens to align with a 1-minute period boundary. High-resolution metrics store data at 1-second granularity. Powertools Metrics supports them via the resolution parameter:

from aws_lambda_powertools.metrics import MetricUnit, MetricResolution

# High resolution (1s) — $0.02/metric/month vs $0.10/metric/month for standard
# Use for time-sensitive SLOs: P99 latency, error spike detection
metrics.add_metric(
    name="ToolDuration",
    unit=MetricUnit.Milliseconds,
    value=duration_ms,
    resolution=MetricResolution.High,   # 1-second resolution
)

# Standard resolution (60s) — for coarse business counters
metrics.add_metric(
    name="ToolInvocation",
    unit=MetricUnit.Count,
    value=1,
    resolution=MetricResolution.Standard,
)

Use high-resolution sparingly: it costs 5× more per metric than standard resolution. Reserve it for latency metrics where you need sub-minute alarming, and use standard resolution for volume counters where minute-level aggregation is fine.

single_metric — flush immediately without decorator

The log_metrics decorator flushes all accumulated metrics at the end of the handler. Sometimes you need to emit a metric immediately — before the handler completes, from a background thread, or from a module that doesn't own the handler. Use the single_metric context manager:

from aws_lambda_powertools.metrics import single_metric, MetricUnit

def record_tool_timeout(tool_name: str) -> None:
    # Flushes immediately on context exit — no decorator needed
    with single_metric(
        name="ToolTimeout",
        unit=MetricUnit.Count,
        value=1,
        namespace="MCPServer",
    ) as metric:
        metric.add_dimension(name="ToolName", value=tool_name)

single_metric creates an isolated Metrics instance that flushes on __exit__. It does not inherit default dimensions from the module-level Metrics instance, so you must set dimensions explicitly inside the context manager.

Flushing at 100-metric boundary

A single EMF blob can contain at most 100 metrics. Lambda functions that process SQS batches or fan out across many tools can easily exceed this limit. Powertools raises SchemaValidationError on flush if the limit is exceeded. Flush mid-invocation to reset the counter:

def process_tool_batch(tool_calls: list) -> list:
    results = []
    for i, call in enumerate(tool_calls):
        metrics.add_metric(name="ToolInvocation", unit=MetricUnit.Count, value=1)
        result = execute_tool(call)
        metrics.add_metric(name="ToolDuration", unit=MetricUnit.Milliseconds, value=result["ms"])
        results.append(result)

        # Flush every 40 tools (2 metrics each = 80 per batch; flush before hitting 100)
        if i > 0 and i % 40 == 0:
            metrics.flush_metrics()

    return results

Common failures

Symptom Root cause Fix
Metrics never appear in CloudWatch @metrics.log_metrics decorator missing — metrics accumulate but never flush to stdout Add @metrics.log_metrics to the handler; or call metrics.flush_metrics() explicitly before return
SchemaValidationError on flush More than 100 metrics added since last flush, or missing required namespace Call metrics.flush_metrics() before reaching 100; set POWERTOOLS_METRICS_NAMESPACE env var
Metrics appear but without expected dimensions add_dimension() called after add_metric() — dimensions must precede metrics in EMF spec Always call add_dimension() before add_metric() in the handler
CloudWatch metric cost much higher than expected High-cardinality dimension (user_id, request_id) creating thousands of metric streams Remove high-cardinality dimensions; keep to tool name, environment, tier
ColdStart metric not appearing capture_cold_start_metric=True not set, or namespace not matching filter Add param to log_metrics; check CloudWatch for ColdStart metric under the correct namespace

Monitor MCP server availability alongside your CloudWatch metrics

CloudWatch shows you Lambda health from the inside. AliveMCP shows you MCP server reachability from the outside — continuous probes that detect when your endpoint is unreachable before clients do.

Join the waitlist →