Guide · AWS Observability

MCP Server X-Ray Lambda Integration — active tracing, cold start, auto-instrumentation

AWS Lambda X-Ray active tracing gives MCP servers deployed as Lambda functions a complete trace of every invocation — including the Init phase (cold start) as a separate segment before the handler runs. Three things catch Lambda-specific MCP deployments off guard: the Lambda runtime provides the root segment automatically (you do not create it yourself as with ECS — if you try to open a new root segment in a Lambda handler, you create a detached orphan that never appears in traces), response streaming Lambda functions have a different trace structure (the streamifyResponse wrapper extends the root segment duration to match the stream close, not the handler return), and the _X_AMZN_TRACE_ID environment variable is set per-invocation by the runtime (it changes every invocation and must be read fresh each time — caching it across invocations causes trace-context corruption where downstream calls appear under the wrong parent trace).

TL;DR

Enable active tracing with tracing: lambda.Tracing.ACTIVE in CDK or TracingConfig: Mode: Active in SAM/CloudFormation. Use captureAWSv3Client for each SDK client — call it in module scope so it runs once per cold start, not once per invocation. Do not create a root segment in the handler. Add xray:PutTraceSegments and xray:GetSamplingRules to the Lambda execution role. For Lambda function URLs serving MCP over SSE, trace context passes through the URL's built-in HTTP headers.

Lambda trace segment structure

Lambda creates two segments per invocation when active tracing is enabled: an Init segment covering the cold start (module initialization, global scope execution) and an Invocation segment covering the handler execution. Your code only has access to the Invocation segment. The Init segment is created and closed by the Lambda runtime before the handler runs.

// Lambda X-Ray segment hierarchy (shown in X-Ray timeline view)
//
// Root segment: "my-mcp-server" (created by Lambda runtime)
//   ├── Subsegment: "Initialization"  (cold start only — module-level code)
//   │     Duration: 800ms first invocation, absent on warm invocations
//   └── Subsegment: "Invocation"      (handler execution — always present)
//         └── Subsegment: "tool:list_files"  (your custom subsegment)
//               └── Subsegment: "DynamoDB"   (auto via captureAWSv3Client)
//
// On a warm invocation, only "Invocation" appears — no "Initialization".
// This makes cold start latency directly visible per-request in the trace.

Enabling active tracing

// AWS CDK
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as lambdaNode from "aws-cdk-lib/aws-lambda-nodejs";

const mcpHandler = new lambdaNode.NodejsFunction(this, "McpServerFunction", {
  entry: "src/handler.ts",
  runtime: lambda.Runtime.NODEJS_22_X,
  tracing: lambda.Tracing.ACTIVE,  // enables X-Ray active tracing
  // Tracing.PASS_THROUGH: honors the sampling decision from the upstream caller
  // (useful if the caller already decided to sample this request)
  // Tracing.ACTIVE: always samples every invocation regardless of upstream header
  environment: {
    AWS_XRAY_CONTEXT_MISSING: "LOG_ERROR",  // don't crash if subsegment created outside context
  },
});

// The execution role automatically gets xray:PutTraceSegments when tracing is enabled
// but NOT GetSampling* — add those manually for centralized sampling:
mcpHandler.addToRolePolicy(new iam.PolicyStatement({
  actions: [
    "xray:GetSamplingRules",
    "xray:GetSamplingTargets",
    "xray:GetSamplingStatisticSummaries",
  ],
  resources: ["*"],
}));
# AWS SAM template.yaml
McpServerFunction:
  Type: AWS::Serverless::Function
  Properties:
    CodeUri: src/
    Handler: handler.lambdaHandler
    Runtime: nodejs22.x
    Tracing: Active   # or PassThrough
    Policies:
      - XRayDaemonWriteAccess  # managed policy: PutTraceSegments + PutTelemetryRecords
    Environment:
      Variables:
        AWS_XRAY_CONTEXT_MISSING: LOG_ERROR

SDK v3 client instrumentation in Lambda module scope

Initialize and patch AWS SDK clients in module scope (outside the handler function). This runs once per cold start and the patched clients are reused across all warm invocations in the same execution environment.

import AWSXRay from "aws-xray-sdk-node";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { S3Client } from "@aws-sdk/client-s3";

// Module scope: runs once per cold start
// captureAWSv3Client wraps the client — must be called here, not inside the handler
const rawDynamo = new DynamoDBClient({ region: process.env.AWS_REGION });
const dynamo = DynamoDBDocumentClient.from(
  AWSXRay.captureAWSv3Client(rawDynamo)
);

const rawS3 = new S3Client({ region: process.env.AWS_REGION });
const s3 = AWSXRay.captureAWSv3Client(rawS3);

// Handler: runs per invocation — do NOT create new clients here
export const lambdaHandler = async (event: APIGatewayProxyEventV2) => {
  // Lambda runtime has already created the root segment from _X_AMZN_TRACE_ID
  // AWSXRay.resolveSegment() returns that root segment here
  const segment = AWSXRay.resolveSegment();

  // Add per-invocation annotations to the root segment
  segment?.addAnnotation("request_id", event.requestContext.requestId);
  segment?.addAnnotation("route", event.routeKey ?? "unknown");

  // Tool call: add a custom subsegment
  const sub = segment?.addNewSubsegment("tool:list_files");
  try {
    const result = await dynamo.send(/* ... */);  // creates DynamoDB child subsegment
    sub?.close();
    return { statusCode: 200, body: JSON.stringify(result) };
  } catch (err) {
    sub?.addError(err as Error);
    sub?.close();
    throw err;
  }
};

Lambda function URLs and MCP over HTTP streaming

Lambda function URLs deliver the X-Amzn-Trace-Id header to the Lambda event object. For MCP servers using Lambda function URLs with response streaming (streamifyResponse), the trace context is available in the event headers.

import AWSXRay from "aws-xray-sdk-node";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

// Lambda response streaming handler for MCP
// awslambda.streamifyResponse wraps the handler and enables chunked response streaming
export const handler = awslambda.streamifyResponse(
  async (event: any, responseStream: any, context: any) => {
    // The _X_AMZN_TRACE_ID env var is set by Lambda runtime for each invocation
    // X-Ray SDK reads it automatically — no manual setup needed
    const segment = AWSXRay.resolveSegment();
    segment?.addAnnotation("function_url_auth", event.requestContext?.authorizer?.iam ? "iam" : "none");

    const mcpServer = new McpServer({ name: "mcp-server", version: "1.0.0" });
    registerTools(mcpServer, segment); // pass segment to tool registration

    // StreamableHTTPServerTransport adapted for Lambda streaming
    const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
    await mcpServer.connect(transport);

    // The streamifyResponse wrapper keeps the Lambda invocation alive until the
    // stream is closed — the Invocation subsegment in X-Ray reflects this full duration,
    // making it easy to identify long-running tool calls that approach Lambda timeouts.
    const httpResponse = new LambdaStreamableHTTPResponse(responseStream);
    await transport.handleRequest(
      new LambdaRequest(event),
      httpResponse,
      event.body ? JSON.parse(event.body) : undefined
    );
  }
);

Cold start trace analysis

The Init subsegment duration is the cold start cost. Use X-Ray filter expressions to find invocations with Init segments (cold starts) and compare their total duration vs warm invocations.

// X-Ray filter expressions for Lambda cold start analysis:

// Find all cold start invocations in the last hour:
// service("my-mcp-server") AND annotation.init_duration > 500
// (requires annotating init duration — Lambda does not expose it as a built-in annotation)

// Programmatic annotation: measure and record init duration
let initStart = Date.now();
// ... module-level initialization (SDK clients, DB connections) ...
const initDurationMs = Date.now() - initStart;

export const lambdaHandler = async (event: any) => {
  const segment = AWSXRay.resolveSegment();
  // Annotate on first invocation only (initDurationMs > 0 only on cold start
  // because initStart is set at module scope and initDurationMs computed once)
  if (initDurationMs > 0) {
    segment?.addAnnotation("cold_start", true);
    segment?.addMetadata("init_duration_ms", initDurationMs);
    // Reset to prevent re-annotating on subsequent warm invocations in same env
    // (not possible cleanly — use a boolean flag instead):
  }
  // ... handler body
};

// Filter expression to find cold starts:
// annotation.cold_start = true AND service("my-mcp-server")
// Sort by duration desc to find slowest cold starts

// Note: Lambda X-Ray always shows Init subsegment on cold start invocations.
// You do NOT need to add the annotation to see cold starts — the Init subsegment
// is the definitive indicator. The annotation helps filter in Insights queries.

SnapStart and X-Ray

Lambda SnapStart (for Java runtimes and Node.js with opt-in) takes a snapshot of the initialized execution environment and restores it for subsequent invocations. Restored SnapStart invocations do not show an Init subsegment — the restore phase is represented by a Restore subsegment instead.

// SnapStart trace segment structure (Java / Node.js SnapStart)
//
// Cold start (initial snapshot creation):
//   Root segment: "my-mcp-server"
//     └── Subsegment: "Initialization"   (full module init — 2-4s for JVM)
//
// SnapStart restore (subsequent "cold" starts):
//   Root segment: "my-mcp-server"
//     └── Subsegment: "Restore"          (snapshot restore — typically 100-300ms)
//     └── Subsegment: "Invocation"
//
// Filtering restore events vs true cold starts:
// service("my-mcp-server") AND subsegment.name = "Restore"
// vs
// service("my-mcp-server") AND subsegment.name = "Initialization"
//
// For MCP servers: SnapStart in Node.js requires opting in via Lambda SnapStart config.
// The main benefit: eliminates the 500-2000ms Init subsegment in exchange for a
// 50-200ms Restore subsegment — a 5-10× cold start improvement for initialized clients.

IAM policy for Lambda X-Ray

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "xray:PutTraceSegments",
        "xray:PutTelemetryRecords",
        "xray:GetSamplingRules",
        "xray:GetSamplingTargets"
      ],
      "Resource": "*"
    }
  ]
}
// AWS managed policy AWSXRayDaemonWriteAccess covers PutTraceSegments + PutTelemetryRecords.
// GetSamplingRules + GetSamplingTargets are NOT included in AWSXRayDaemonWriteAccess —
// add them separately or use AWSXRayFullAccess (broader than necessary for production).
//
// Lambda execution role needs this policy attached; NOT the resource-based policy.
// Lambda console "Enable X-Ray tracing" checkbox adds AWSXRayDaemonWriteAccess
// automatically but still requires manual addition of GetSampling* for centralized rules.

Common failure modes

SymptomCauseFix
Traces show only the Lambda root segment with no subsegmentscaptureAWSv3Client called inside the handler (runs per invocation but patches happen after first use)Move all captureAWSv3Client calls to module scope — above the handler function
Init subsegment missing — can't distinguish cold vs warmActive tracing not enabled; Lambda is not actually cold-starting (container reuse)Enable Tracing: Active; force a cold start by deploying a new version to confirm Init appears
RUNTIME_ERROR: "Missing context" crash in LambdaCustom subsegment created outside the Invocation context (e.g., in a background timer or after the handler returned)Set AWS_XRAY_CONTEXT_MISSING=LOG_ERROR; guard all subsegment creation with if (AWSXRay.resolveSegment())
Downstream calls not traced (DynamoDB, S3 missing)SDK v3 clients not wrapped; using raw clients created in handler scopeWrap all clients with captureAWSv3Client at module scope; confirm correct import path for SDK v3
Streaming handler trace ends before stream closesNot using streamifyResponse wrapper; Lambda closed the invocation segment at handler returnWrap handler with awslambda.streamifyResponse() so the runtime keeps the invocation open until the stream ends