Guide · AWS Observability

MCP Server AWS X-Ray — distributed tracing, subsegments, trace propagation

AWS X-Ray lets you trace every MCP tool call from the initial HTTP request through every downstream dependency — DynamoDB reads, S3 fetches, external LLM API calls — and visualize the entire call graph in a service map. Three things trip MCP developers up: trace context does not flow automatically through SSE (the X-Ray SDK reads the X-Amzn-Trace-Id header on each HTTP request, but an SSE connection is a single long-lived HTTP request, so all tool calls within one SSE session share the same root trace ID unless you manually fork subsegments), the AWS SDK v3 auto-instrumentation patch must be applied before any SDK client is created (patching after creating a DynamoDB client does not retroactively instrument that client), and X-Ray daemon must be reachable (on ECS Fargate, the daemon runs as a sidecar container on UDP port 2000; on Lambda, it is managed by the runtime; on EC2, it must be installed separately).

TL;DR

Install aws-xray-sdk-node. Call AWSXRay.captureAWS(require('@aws-sdk/...')) at startup before creating any SDK clients. Wrap each tool handler body in segment.addNewSubsegment('tool:name'). For SSE transport, extract the root segment from the SSE connection request and attach tool subsegments to it — do not rely on automatic continuation. Add DAEMON_ADDRESS=127.0.0.1:2000 for ECS Fargate sidecar. Give the task role xray:PutTraceSegments and xray:PutTelemetryRecords.

How X-Ray traces map onto MCP server architecture

A conventional web framework wraps each HTTP request in a root segment automatically. An MCP server using SSE transport opens one HTTP connection per client and then streams tool call results indefinitely over that connection. X-Ray sees one incoming HTTP request and creates one root segment. Without explicit subsegment management, all tool calls within a session collapse into a single flat trace with no per-tool breakdown.

The correct model for MCP + X-Ray is: one root segment per SSE connection (created by the X-Ray middleware on the POST /mcp or GET /mcp/sse request), and one child subsegment per tool call invocation. For HTTP streaming transport (the newer Streamable HTTP transport), each POST /mcp is a separate HTTP request and gets its own root segment automatically.

// Segment hierarchy for a single MCP SSE session
//
// Root segment: "mcp-server" (created on SSE connection establishment)
//   └── Subsegment: "tool:list_files"       (first tool call)
//         └── Subsegment: "DynamoDB" (auto from AWS SDK patch)
//   └── Subsegment: "tool:read_file"        (second tool call)
//         └── Subsegment: "S3"     (auto from AWS SDK patch)
//   └── Subsegment: "tool:search_documents" (third tool call)
//         └── Subsegment: "DynamoDB"
//         └── Subsegment: "external-api:openai"  (manual)

SDK installation and daemon configuration

npm install aws-xray-sdk-node
# For TypeScript projects:
npm install --save-dev @types/aws-xray-sdk-node
import AWSXRay from "aws-xray-sdk-node";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { S3Client } from "@aws-sdk/client-s3";

// CRITICAL: patch AWS SDK v3 clients BEFORE creating any instances
// AWSXRay.captureAWS is the v2 approach; for SDK v3 use captureAWSv3Client per client
// OR use the global patch: captureHTTPsGlobal for non-AWS HTTP calls
AWSXRay.setDaemonAddress("127.0.0.1:2000"); // ECS Fargate sidecar default
AWSXRay.config([AWSXRay.plugins.ECSPlugin]); // adds ECS metadata to traces

// Wrap individual SDK v3 clients (recommended over global patch)
const rawDynamo = new DynamoDBClient({ region: process.env.AWS_REGION ?? "us-east-1" });
const dynamo = AWSXRay.captureAWSv3Client(rawDynamo);

const rawS3 = new S3Client({ region: process.env.AWS_REGION ?? "us-east-1" });
const s3 = AWSXRay.captureAWSv3Client(rawS3);

// Now every command sent through dynamo / s3 generates an automatic X-Ray subsegment

For ECS Fargate, add the X-Ray daemon as a sidecar in your task definition. The daemon listens on UDP 2000 and forwards segments to the X-Ray service over HTTPS.

// ECS task definition — X-Ray daemon sidecar (JSON)
{
  "containerDefinitions": [
    {
      "name": "mcp-server",
      "image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-server:latest",
      "environment": [
        { "name": "AWS_XRAY_DAEMON_ADDRESS", "value": "127.0.0.1:2000" },
        { "name": "AWS_XRAY_CONTEXT_MISSING", "value": "LOG_ERROR" }
        // LOG_ERROR instead of RUNTIME_ERROR — prevents crashes when
        // a subsegment is created outside a root segment context
      ],
      "portMappings": [{ "containerPort": 3000, "protocol": "tcp" }]
    },
    {
      "name": "xray-daemon",
      "image": "public.ecr.aws/xray/aws-xray-daemon:latest",
      "portMappings": [{ "containerPort": 2000, "protocol": "udp" }],
      "cpu": 32,
      "memoryReservation": 256,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/mcp-server-xray",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "xray"
        }
      }
    }
  ]
}

Express middleware for HTTP transport (Streamable HTTP)

For MCP servers using the Streamable HTTP transport, each POST creates a new HTTP request and the X-Ray Express middleware creates one root segment per request automatically.

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

const app = express();

// X-Ray middleware — MUST be first (before routes)
app.use(AWSXRay.express.openSegment("mcp-server"));

app.post("/mcp", async (req, res) => {
  // At this point AWSXRay.resolveSegment() returns the root segment
  // created by openSegment middleware for this specific POST request
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  const server = createMcpServer(); // your server setup function
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

// X-Ray middleware — MUST be last (after routes)
app.use(AWSXRay.express.closeSegment());

app.listen(3000);

Custom subsegments per tool handler

Wrap each tool handler in a custom subsegment to get per-tool latency and error data in the X-Ray timeline view. The subsegment appears as a named block in the trace waterfall with start time, end time, and any annotations or metadata you add.

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

const server = new McpServer({ name: "my-mcp-server", version: "1.0.0" });

// Helper: wraps a tool handler in an X-Ray subsegment
async function tracedTool(
  toolName: string,
  fn: (subsegment: AWSXRay.Subsegment) => Promise
): Promise {
  const segment = AWSXRay.resolveSegment();
  if (!segment) {
    // No active segment (e.g., running outside X-Ray context in local dev)
    return fn(null as any);
  }
  const sub = segment.addNewSubsegment(`tool:${toolName}`);
  sub.addAnnotation("tool", toolName); // indexed — queryable in filter expressions
  try {
    const result = await fn(sub);
    sub.close();
    return result;
  } catch (err) {
    sub.addError(err as Error);
    sub.close();
    throw err;
  }
}

server.tool(
  "list_files",
  "List files in a directory",
  { directory: z.string() },
  async ({ directory }) => {
    return tracedTool("list_files", async (sub) => {
      sub.addAnnotation("directory", directory.replace(/[^a-zA-Z0-9/_-]/g, "")); // sanitize
      sub.addMetadata("input", { directory }); // not indexed — safe for large values

      // DynamoDB call here generates an automatic child subsegment via captureAWSv3Client
      const files = await listFilesFromDynamo(directory);

      sub.addMetadata("output", { file_count: files.length });
      return { content: [{ type: "text", text: JSON.stringify(files) }] };
    });
  }
);

SSE transport: manual segment context management

The older SSE transport (deprecated in MCP SDK but still widely used) holds a single HTTP connection open and emits tool call results as server-sent events. Since the X-Ray middleware only creates a root segment for the initial GET/POST, all subsequent tool calls within that SSE session must attach their subsegments to the stored root segment explicitly.

import AWSXRay from "aws-xray-sdk-node";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";

const app = express();
const sessionSegments = new Map<string, AWSXRay.Segment>();

app.get("/mcp/sse", AWSXRay.express.openSegment("mcp-server"), async (req, res) => {
  const sessionId = crypto.randomUUID();

  // Capture the root segment created by openSegment for this SSE connection
  const rootSegment = AWSXRay.resolveSegment() as AWSXRay.Segment;
  rootSegment.addAnnotation("session_id", sessionId);
  sessionSegments.set(sessionId, rootSegment);

  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");

  const transport = new SSEServerTransport("/mcp/messages", res);
  const mcpServer = createMcpServer(sessionId); // pass sessionId to tool handlers
  await mcpServer.connect(transport);

  req.on("close", () => {
    // Close and flush the root segment when the client disconnects
    const seg = sessionSegments.get(sessionId);
    if (seg && !seg.isClosed()) seg.close();
    sessionSegments.delete(sessionId);
  });
});

// Tool handlers access the stored segment via the sessionId passed through context
function createToolWithTracing(sessionId: string, toolName: string, fn: Function) {
  return async (args: unknown) => {
    const rootSegment = sessionSegments.get(sessionId);
    const sub = rootSegment
      ? rootSegment.addNewSubsegment(`tool:${toolName}`)
      : null;

    // Run the tool within the segment context so AWS SDK calls create child subsegments
    if (sub && rootSegment) {
      return AWSXRay.resolveManualSegmentContextExt(
        async () => {
          try {
            const result = await fn(args, sub);
            sub.close();
            return result;
          } catch (err) {
            sub.addError(err as Error);
            sub.close();
            throw err;
          }
        },
        rootSegment
      );
    }
    return fn(args, null);
  };
}

Capturing external HTTP calls (non-AWS dependencies)

Tool handlers that call external APIs (OpenAI, Anthropic, Stripe, GitHub) need manual HTTP capture or the global HTTPS patch to appear in the trace timeline.

import AWSXRay from "aws-xray-sdk-node";
import https from "https";

// Patch Node.js https module globally — captures all outbound HTTPS calls
// Must be called before any HTTP client is created (axios, node-fetch, etc.)
AWSXRay.captureHTTPsGlobal(https, true); // true = subSegmentCallback enabled

// Now axios / undici / node-fetch calls that use the patched https module
// appear as "Remote call" subsegments in the X-Ray timeline.
// The subsegment shows: URL (sanitized), HTTP status, response time.

// For fetch() in Node 18+, which uses internal HTTP and bypasses the https patch:
// Use a custom subsegment manually instead:
async function tracedFetch(url: string, init?: RequestInit) {
  const segment = AWSXRay.resolveSegment();
  const sub = segment?.addNewSubsegment("external-api") ?? null;
  sub?.addAnnotation("host", new URL(url).hostname);
  try {
    const response = await fetch(url, init);
    sub?.addAnnotation("status_code", response.status);
    if (!response.ok) sub?.addErrorFlag();
    sub?.close();
    return response;
  } catch (err) {
    sub?.addError(err as Error);
    sub?.close();
    throw err;
  }
}

IAM policy for X-Ray

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "xray:PutTraceSegments",
        "xray:PutTelemetryRecords",
        "xray:GetSamplingRules",
        "xray:GetSamplingTargets",
        "xray:GetSamplingStatisticSummaries"
      ],
      "Resource": "*"
      // X-Ray does not support resource-level restrictions on PutTraceSegments —
      // "*" is required. GetSampling* permissions are needed for centralized
      // sampling rules; omit them if using only local sampling configuration.
    }
  ]
}
// Attach to the ECS task role or Lambda execution role, NOT the execution role.
// The execution role handles ECR image pulls and CloudWatch Logs; the task role
// handles runtime AWS API calls including X-Ray segment uploads.

Common failure modes

SymptomCauseFix
Traces not appearing in X-Ray consoleDaemon unreachable — wrong address or UDP port 2000 blockedVerify AWS_XRAY_DAEMON_ADDRESS env var; confirm sidecar container is running; check ECS security group allows UDP 2000 within task
AWS SDK calls not appearing as subsegmentscaptureAWSv3Client called after client was constructed, or using SDK v2 patch on v3 clientCall captureAWSv3Client immediately after new DynamoDBClient() before the client is used anywhere
Missing context error / RUNTIME_ERROR crashSubsegment created outside an active segment (Lambda cold start, background worker)Set AWS_XRAY_CONTEXT_MISSING=LOG_ERROR to log instead of crash; guard subsegment creation with AWSXRay.resolveSegment() null check
All tool calls in one SSE session share a single flat traceTool handlers not creating per-call subsegments; relying on middleware segment continuationManually create a subsegment in each tool handler using stored root segment (see SSE section above)
External API calls missing from timelineNative fetch() bypasses the patched https moduleUse manual subsegment wrapper for fetch(); or switch to node-fetch v2 which uses the patchable https module
Traces sampled at 0% — nothing visibleDefault sampling rule (1 req/sec + 5%) too low for low-traffic development environmentCreate a custom sampling rule with FixedRate: 1.0 for your service during development; revert for production