Guide · AWS API Gateway

MCP Server API Gateway WebSocket — persistent connections, route selection, and server-push

API Gateway WebSocket APIs give an MCP server a fully managed WebSocket endpoint where each connected client gets a unique connectionId that any backend Lambda can use to push messages at any time. Unlike HTTP, where the client always initiates requests, a WebSocket API lets the MCP server push tool results, status updates, or streaming partial output directly to the client after the connection is established. The routing model uses three built-in routes — $connect (connection establishment), $disconnect (client disconnect), and $default (unmatched messages) — plus custom routes you define by parsing a field from the incoming message body (e.g., $request.body.action). The critical operational constraint: sending to a disconnected client returns GoneException — your backend must catch this and delete the stale connectionId from DynamoDB to prevent unbounded accumulation of dead connection records.

TL;DR

Store connectionId in DynamoDB on $connect and delete it on $disconnect and on any GoneException from the Management API. Push messages from any Lambda using ApiGatewayManagementApiClient.PostToConnection with the Management API endpoint https://{api-id}.execute-api.{region}.amazonaws.com/{stage}. Apply a Lambda authorizer only on the $connect route — subsequent messages from the same connection are not re-authorized. Set idleTimeoutInSeconds to 7200 (2 hours) if you need long-lived connections; the default is 600 seconds (10 minutes). Maximum message payload is 128 KB; route-selection responses (messages that get a synchronous reply via WebSocket) are limited to 32 KB.

Connection lifecycle: $connect, $disconnect, and storing connectionId

When a client opens a WebSocket connection to the API Gateway endpoint, the $connect route fires. This is the only point where the Lambda authorizer runs — if the authorizer denies the connection or the $connect Lambda returns a non-2xx status, the WebSocket handshake fails and the client never connects. The connectionId is available in event.requestContext.connectionId and is stable for the lifetime of the connection. Store it in DynamoDB alongside any auth context (userId, sessionId) needed to route server-push messages later.

// $connect Lambda handler — store connection in DynamoDB
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';

const dynamo = new DynamoDBClient({});

export const handler = async (event) => {
  const { connectionId, domainName, stage } = event.requestContext;
  // Query params are available on $connect only
  const sessionId = event.queryStringParameters?.sessionId;

  if (!sessionId) {
    // Reject connections without a sessionId — return 401 to deny the handshake
    return { statusCode: 401, body: 'sessionId required' };
  }

  await dynamo.send(new PutItemCommand({
    TableName: process.env.CONNECTIONS_TABLE,
    Item: {
      connectionId: { S: connectionId },
      sessionId: { S: sessionId },
      // domainName + stage needed to construct Management API endpoint later
      endpoint: { S: `https://${domainName}/${stage}` },
      connectedAt: { S: new Date().toISOString() },
      // TTL: auto-expire after 2 hours (matches idleTimeoutInSeconds)
      ttl: { N: String(Math.floor(Date.now() / 1000) + 7200) }
    }
  }));

  return { statusCode: 200 };
};

// $disconnect Lambda handler — remove connection from DynamoDB
export const disconnectHandler = async (event) => {
  const { connectionId } = event.requestContext;
  await dynamo.send(new DeleteItemCommand({
    TableName: process.env.CONNECTIONS_TABLE,
    Key: { connectionId: { S: connectionId } }
  }));
  return { statusCode: 200 };
};

Route selection and custom routes

The route selection expression (set at the API level, typically $request.body.action) determines which route handles each incoming message. API Gateway evaluates the JSONPath expression against the message body and routes to the matching route key. If no route key matches and a $default route exists, the message goes there. If no $default route exists and no route key matches, the message is silently dropped.

// Custom route: client sends { "action": "invokeTool", "toolName": "...", "input": {...} }
// API Gateway routes this to the "invokeTool" Lambda

// invokeTool Lambda ($request.body.action === "invokeTool")
export const invokeToolHandler = async (event) => {
  const { connectionId, domainName, stage } = event.requestContext;
  const body = JSON.parse(event.body);
  const { toolName, toolCallId, input } = body;

  // Immediately acknowledge receipt so the client knows the tool was queued
  // Send an immediate response via the Management API
  const managementClient = new ApiGatewayManagementApiClient({
    endpoint: `https://${domainName}/${stage}`
  });

  await managementClient.send(new PostToConnectionCommand({
    ConnectionId: connectionId,
    Data: JSON.stringify({
      type: 'tool_queued',
      toolCallId,
      toolName
    })
  }));

  // Enqueue the actual tool invocation asynchronously
  await sqs.send(new SendMessageCommand({
    QueueUrl: process.env.TOOL_QUEUE_URL,
    MessageBody: JSON.stringify({ connectionId, toolCallId, toolName, input,
      endpoint: `https://${domainName}/${stage}` })
  }));

  // Return 200 — this does NOT send a message to the client via WebSocket
  // (only $default or custom routes with route response enabled do that)
  return { statusCode: 200 };
};

Custom route Lambdas do not need to send a WebSocket response — the statusCode: 200 return acknowledges the message to API Gateway but does not send anything to the client over WebSocket. To send messages to the client, use the Management API (see next section). If you enable route response on a custom route, the Lambda's response body is forwarded to the client, but this makes the exchange synchronous (the client waits for a response before sending more messages), which is rarely desirable for MCP tool calls.

Management API: server-push to connected clients

The Management API is the mechanism by which any Lambda — including ones invoked by SQS, Step Functions, or a cron — can push messages to a specific connected client using its connectionId. The endpoint URL is https://{api-id}.execute-api.{region}.amazonaws.com/{stage} — this is stored in DynamoDB during $connect alongside the connectionId. The Lambda that pushes must have execute-api:ManageConnections permission on the API ARN.

// Tool result Lambda — called by SQS worker after tool execution completes
// Looks up connectionId from DynamoDB, pushes result via Management API
import {
  ApiGatewayManagementApiClient,
  PostToConnectionCommand,
  GoneException
} from '@aws-sdk/client-apigatewaymanagementapi';
import { DeleteItemCommand } from '@aws-sdk/client-dynamodb';

export const toolResultHandler = async (event) => {
  for (const record of event.Records) {
    const { connectionId, toolCallId, toolName, result, endpoint } = JSON.parse(record.body);

    const mgmt = new ApiGatewayManagementApiClient({ endpoint });

    try {
      await mgmt.send(new PostToConnectionCommand({
        ConnectionId: connectionId,
        Data: JSON.stringify({
          type: 'tool_result',
          toolCallId,
          toolName,
          status: 'complete',
          output: result
        })
      }));
    } catch (err) {
      if (err instanceof GoneException) {
        // Client disconnected before result was ready — clean up the stale record
        // IMPORTANT: catch GoneException and delete the connectionId from DynamoDB.
        // Without this, the connections table accumulates dead records indefinitely.
        await dynamo.send(new DeleteItemCommand({
          TableName: process.env.CONNECTIONS_TABLE,
          Key: { connectionId: { S: connectionId } }
        }));
        // Don't rethrow — the client is gone, not a transient error
      } else {
        throw err;  // let SQS retry for other errors
      }
    }
  }
};

// IAM policy required on the tool result Lambda's execution role:
// {
//   "Effect": "Allow",
//   "Action": "execute-api:ManageConnections",
//   "Resource": "arn:aws:execute-api:us-east-1:123456789:abc123def/*"
// }

Lambda authorizer on $connect: one-time authentication

The Lambda authorizer on a WebSocket API runs only on the $connect route — once the connection is established, all subsequent messages from that connection are not re-authorized. Use the $connect authorizer to validate a bearer token (JWT) passed as a query parameter or in a header during the WebSocket upgrade request. Note: browsers cannot set custom headers during WebSocket upgrade (the browser WebSocket API does not support custom headers), so tokens must be passed as query parameters on the WebSocket URL.

// Lambda authorizer for WebSocket $connect
// Receives: event.queryStringParameters.token (browser) OR event.headers.Authorization (non-browser)
export const wsAuthorizerHandler = async (event) => {
  // Browsers pass token in query string: wss://abc.execute-api.us-east-1.amazonaws.com/prod?token=...
  const token = event.queryStringParameters?.token
    ?? event.headers?.Authorization?.replace('Bearer ', '');

  if (!token) {
    throw new Error('Unauthorized');  // throwing causes 401 — not 403
  }

  let claims;
  try {
    claims = await verifyJwt(token);  // your JWT verification logic
  } catch {
    throw new Error('Unauthorized');
  }

  // Return an IAM policy allowing the $connect invocation
  const apiArn = event.methodArn;  // arn:aws:execute-api:region:account:api-id/stage/$connect
  // Use a wildcard resource so the cached policy allows all routes, not just $connect
  const wildcardArn = apiArn.replace('$connect', '*');

  return {
    principalId: claims.sub,
    policyDocument: {
      Version: '2012-10-17',
      Statement: [{
        Action: 'execute-api:Invoke',
        Effect: 'Allow',
        Resource: wildcardArn
      }]
    },
    // Pass claims to downstream Lambdas via requestContext.authorizer
    context: {
      userId: claims.sub,
      sessionId: claims.sessionId ?? null
    }
  };
};

Failure modes reference

FailureSymptomFix
GoneException not caught on PostToConnectionDead connectionIds accumulate in DynamoDB; future push attempts always fail for that sessionCatch GoneException in every Lambda that calls PostToConnection; delete the connectionId from DynamoDB immediately
$connect Lambda takes >29 secondsAPI Gateway closes the WebSocket handshake with a timeout; client sees connection refused$connect Lambda must return within the integration timeout (max 29s); move slow operations (DB writes) to async workers and only do auth + a fast DynamoDB put in $connect
Idle timeout not extended from default 600sLong-running tool calls (>10 min) disconnect the client mid-executionSet idleTimeoutInSeconds to 7200 at API Gateway stage level; client must also send a ping frame every 9 minutes to prevent the idle timeout firing
Message exceeds 128 KBAPI Gateway returns a 1009 frame close code; the client connection is terminatedChunk large tool outputs; send tool result as a pre-signed S3 URL in the WebSocket message instead of the payload inline
execute-api:ManageConnections permission missing403 Forbidden from the Management API endpoint; PostToConnection throws AccessDeniedExceptionAdd execute-api:ManageConnections to the Lambda execution role; resource ARN is arn:aws:execute-api:region:account:api-id/*
Route selection expression evaluates to empty stringMessages fall through to $default even when a custom route matches; or are silently droppedTest route selection expression with the API Gateway console test tool; ensure the client always sends the action field as a top-level key, not nested
Custom header on WebSocket upgrade from browserHeader not sent; auth fails; client connects without authBrowsers do not support custom headers on WebSocket upgrade — pass token as query parameter; sanitize and validate query parameters in the authorizer