Guide · Lambda Powertools

AWS Lambda Powertools Logger for MCP Servers

AWS Lambda Powertools Logger replaces Python's built-in logging module with a structured JSON emitter that automatically injects Lambda context into every log line. For MCP server functions this matters more than in typical REST APIs: an MCP tool call may span multiple Lambda invocations (stream resumption, async callbacks, retry after partial failure), so correlating logs across those invocations by requestId, mcp_session_id, or a custom correlation header is the only way to reconstruct what happened during a debugging session. Three things teams routinely get wrong: not using clear_state=True on inject_lambda_context — Lambda reuses execution contexts, so keys appended in invocation N appear in invocation N+1 if you don't clear them; not setting POWERTOOLS_LOGGER_SAMPLE_RATE so you only get DEBUG logs when you add them manually right before an incident, never during it; logging the full event dict (log_event=True) without stripping auth headers or PII from the incoming MCP payload first.

TL;DR

Use @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST, clear_state=True) on every Lambda handler. Set POWERTOOLS_LOGGER_SAMPLE_RATE=0.05 (5% debug sampling) in production. Use logger.append_keys(mcp_session_id=...) to attach session context once at the top of the handler. Never call logger.append_keys inside a loop — append once, log many times.

Structured JSON vs Python logging.basicConfig

Python's logging.basicConfig(format="%(asctime)s %(message)s") emits unstructured text. CloudWatch Logs Insights can't parse free-form strings into fields — you can't filter by status_code=500 or join by request_id without writing regex extractors. Lambda Powertools Logger emits a JSON object on every logger.info() call:

{
  "level": "INFO",
  "location": "handler:42",
  "message": "tool call received",
  "timestamp": "2026-09-25T10:00:01.234Z+0000",
  "service": "mcp-tool-dispatcher",
  "cold_start": false,
  "function_name": "mcp-tool-dispatcher",
  "function_memory_size": 512,
  "function_arn": "arn:aws:lambda:us-east-1:123456789012:function:mcp-tool-dispatcher",
  "function_request_id": "a1b2c3d4-...",
  "xray_trace_id": "Root=1-...",
  "mcp_session_id": "sess_abc123",
  "tool_name": "search_codebase"
}

Every field is directly queryable in CloudWatch Logs Insights: filter level = "ERROR" | stats count() by tool_name — no regex, no parsing.

from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.typing import LambdaContext

logger = Logger(service="mcp-tool-dispatcher")  # sets "service" field in every log

@logger.inject_lambda_context(
    correlation_id_path="headers.x-correlation-id",  # extract from request header
    clear_state=True,   # reset append_keys between warm invocations
    log_event=False,    # don't log raw event — may contain auth tokens
)
def handler(event: dict, context: LambdaContext) -> dict:
    logger.append_keys(
        mcp_session_id=event.get("sessionId"),
        tool_name=event.get("toolName"),
    )
    logger.info("tool call received")
    # ... tool execution ...
    logger.info("tool call complete", extra={"duration_ms": 142})
    return {"statusCode": 200}

inject_lambda_context — what it adds automatically

The @logger.inject_lambda_context decorator runs before and after your handler. On entry it injects these fields into every subsequent log call:

Field injected Source Example value
cold_start First invocation in execution context true / false
function_name context.function_name mcp-tool-dispatcher
function_memory_size context.memory_limit_in_mb 512
function_arn context.invoked_function_arn Full ARN string
function_request_id context.aws_request_id UUID per invocation
xray_trace_id _X_AMZN_TRACE_ID env var Root=1-...

The clear_state=True flag wipes any keys added via logger.append_keys() at the end of each invocation. Without it, a warm execution context will carry stale fields (mcp_session_id, user_id) from the previous invocation into the next — a subtle data-leak bug that shows up only under load when Lambda reuses containers.

Correlation IDs across MCP invocations

MCP tool calls often trigger chains: an LLM calls tool A, tool A calls tool B asynchronously, tool B retries after a transient error. To trace the entire chain you need a single ID that flows through all three Lambda invocations. Powertools Logger provides built-in extractors for common sources:

from aws_lambda_powertools.utilities.data_classes import correlation_paths

# API Gateway REST — extracts from requestContext.requestId
@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST)

# API Gateway HTTP (v2) — extracts from requestContext.requestId
@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP)

# SQS — extracts from first message messageId
@logger.inject_lambda_context(correlation_id_path=correlation_paths.SQS)

# Custom header passed from upstream MCP orchestrator
@logger.inject_lambda_context(correlation_id_path="headers.x-mcp-trace-id")

The extracted ID is stored as correlation_id in every log record automatically. You can also read it back with logger.get_correlation_id() to forward it to downstream calls:

correlation_id = logger.get_correlation_id()
# Pass downstream via header, SQS MessageAttribute, EventBridge detail field, etc.
sqs.send_message(
    QueueUrl=downstream_queue_url,
    MessageBody=json.dumps(payload),
    MessageAttributes={
        "x-mcp-trace-id": {
            "StringValue": correlation_id,
            "DataType": "String",
        }
    },
)

Debug sampling in production

Emitting DEBUG logs on every production invocation is expensive — CloudWatch Logs charges $0.50/GB ingested and DEBUG logs are typically 5–10× more verbose than INFO. But turning off DEBUG entirely means you're blind when a subtle error appears under real traffic. Powertools Logger solves this with traffic-based sampling: a configurable fraction of requests are logged at DEBUG level, the rest at INFO.

# In Lambda environment variables:
# LOG_LEVEL=INFO
# POWERTOOLS_LOGGER_SAMPLE_RATE=0.05  # 5% of requests get DEBUG logs

logger = Logger(service="mcp-tool-dispatcher")
# logger.sampling_rate is now 0.05

# For a given invocation, Powertools decides once at handler entry whether to
# elevate to DEBUG. The decision is stable for the entire invocation.
# You don't call anything special — just use logger.debug() normally.
logger.debug("raw tool input", extra={"input": event["input"]})
# This appears in 5% of requests; silently dropped in the other 95%

Sampling is decided per-invocation (not per-log-line) using a random float compared against sampling_rate. A given invocation is either fully DEBUG or fully INFO — you never get partial debug output for a single request.

For MCP servers that process high volumes of routine tool calls, set POWERTOOLS_LOGGER_SAMPLE_RATE=0.01 (1%) for background polling tools and 0.10 (10%) for user-facing interactive tools where occasional failures are harder to reproduce.

append_keys — persistent contextual fields

logger.append_keys() adds fields to every subsequent log record in the current invocation. Call it once at the top of the handler after you've extracted the MCP session context:

def handler(event: dict, context: LambdaContext) -> dict:
    # Extract MCP session context from event
    session_id = event.get("sessionId", "unknown")
    tool_name = event.get("toolName", "unknown")
    user_tier = event.get("userTier", "free")

    # Append once — these appear in EVERY log call below
    logger.append_keys(
        mcp_session_id=session_id,
        tool_name=tool_name,
        user_tier=user_tier,
    )

    logger.info("handler started")
    result = execute_tool(event["input"])
    logger.info("tool executed", extra={"result_size": len(str(result))})
    return {"result": result}

extra={...} in individual log calls adds one-off fields that appear only on that line. Use append_keys for fields that are the same across all log lines in the invocation (session ID, tool name, user tier); use extra for fields that vary per event (duration, result size, error detail).

To remove a specific key mid-invocation: logger.remove_keys(["user_tier"]). To reset all appended keys early: logger.structure_logs(reset_state=True).

Child loggers for modules

Large MCP server Lambda packages often split logic into modules (tool implementations, auth helpers, external API clients). Each module can get its own logger that inherits the parent configuration:

# In mcp_tools/search.py
from aws_lambda_powertools import Logger

logger = Logger(child=True)  # inherits service name, level, sampling from parent

def search_codebase(query: str) -> list:
    logger.debug("searching", extra={"query": query})
    # ... implementation ...

Child loggers prefix their location field with the child logger's name. This means CloudWatch Logs Insights can filter by filter location like "mcp_tools/search" to isolate logs from a specific module without a separate log group.

Common failures

Symptom Root cause Fix
Previous invocation's mcp_session_id appears in current logs inject_lambda_context without clear_state=True Add clear_state=True to the decorator
No correlation_id field in logs correlation_id_path not set, or path doesn't match event shape Log event at DEBUG level once to verify the path; use JMESPath notation
DEBUG logs never appear despite POWERTOOLS_LOGGER_SAMPLE_RATE=0.1 LOG_LEVEL=INFO overrides sampling — sampling only elevates to DEBUG if base level allows it Set LOG_LEVEL=DEBUG and let POWERTOOLS_LOGGER_SAMPLE_RATE control frequency
Log output is plain text, not JSON Module imported logging directly and called logging.basicConfig(), reconfiguring the root logger Remove basicConfig calls; Powertools Logger configures its own handler
Large log volume spikes cost on SQS batch failures Retry storms cause 100× normal log volume — all at DEBUG due to sampling coincidence Reduce POWERTOOLS_LOGGER_SAMPLE_RATE and use Powertools Batch for partial failure handling

Monitor MCP tool call failures in production

Structured logs tell you what happened. AliveMCP tells you when your MCP server is unreachable before users notice — continuous probes with Slack alerts and status page. Pair with Powertools Logger for complete observability.

Join the waitlist →