Deep Dive · AWS AppSync & API Gateway

AppSync and API Gateway for MCP Servers: Subscriptions, WebSocket, HTTP API, Data Sources, and Lambda Authorizers — Five Real-Time and Gateway Patterns

Published 2026-09-18 · 12 min read

An MCP server is not just an HTTP endpoint — it is a long-lived session between an AI agent and a set of tools. That session demands real-time feedback (the user needs to see that the search tool is running, not just stare at a spinner), durable connections (a 3-minute data-processing tool call cannot fit in a Lambda's 30-second HTTP timeout if the client is polling), and secure authentication at every layer (the tool endpoint, the WebSocket connection, and the streaming channel all need auth). AWS provides two complementary services for these requirements: AppSync (managed GraphQL with built-in WebSocket subscriptions and data source connectors) and API Gateway (REST, HTTP, and WebSocket API types with Lambda integrations). This post synthesizes five production patterns from the AliveMCP engineering notes.

Pattern 1: AppSync GraphQL subscriptions for real-time tool result streaming

The canonical problem: the MCP client submits a tool call and needs to receive incremental status updates — "running", partial output chunks, "complete" — without polling. AppSync subscriptions solve this with a push model: the server fires a createToolResult mutation when the status changes, and every subscribed client receives the event within ~50 ms over a persistent MQTT-over-WebSocket connection.

The schema pattern is straightforward: add @aws_subscribe(mutations: ["createToolResult"]) to the subscription field, and subscribe with a sessionId argument. AppSync applies server-side filtering — if the subscriber passes sessionId: "abc", they only receive events where the mutation response's sessionId field equals "abc". No extra Lambda, no filtering logic in the client.

type Subscription {
  onToolResult(sessionId: ID!): ToolResult
    @aws_subscribe(mutations: ["createToolResult"])
    @aws_api_key @aws_cognito_user_pools
}

The critical constraint: a mutation resolver that throws an error will not trigger any subscription events — even if the mutation partially succeeded. For streaming partial results, use a NONE data source for the mutation (no backend persistence — pure local resolver) so that partial updates are never blocked by database errors. Persist the final result to DynamoDB in a separate pipeline function that runs after the subscription event has been dispatched.

For browser clients, Amplify's generateClient() handles the MQTT WebSocket lifecycle including reconnection. For server-side Node.js clients (e.g., an MCP client library), use the aws-appsync package — raw WebSocket requires implementing keep-alive pings every 300 seconds to prevent the 2-hour idle timeout from closing the connection prematurely.

AliveMCP monitoring note: AppSync subscription endpoints are a common failure mode in MCP server deployments. If the AppSync API itself goes down (AWS service disruption) or the MQTT WebSocket endpoint becomes unreachable, all real-time updates stop. AliveMCP monitors the AppSync health endpoint and the WebSocket connection lifecycle, alerting within 60 seconds if subscription delivery stalls.

→ Full reference: AppSync subscriptions for MCP servers

Pattern 2: API Gateway WebSocket for persistent MCP connections

AppSync is the right choice when the MCP server communicates over GraphQL. For binary protocols, non-GraphQL message formats, or when the MCP server needs full control over the WebSocket framing and routing logic, API Gateway WebSocket APIs provide a lower-level but more flexible option.

The key abstraction: every connected client gets a unique connectionId. Any Lambda — including ones invoked by SQS workers, Step Functions, or cron jobs — can push a message to that client using the Management API: POST /@connections/{connectionId} at the API's management endpoint. This decouples the tool execution from the client connection: the tool worker runs wherever it runs, and when it has output, it looks up the connectionId from DynamoDB and pushes.

// Any Lambda can push to any connected client
const mgmt = new ApiGatewayManagementApiClient({ endpoint });
await mgmt.send(new PostToConnectionCommand({
  ConnectionId: connectionId,
  Data: JSON.stringify({ type: 'tool_result', toolCallId, output: result })
}));

The operational checklist for WebSocket APIs:

  1. Store connectionId on $connect. Write to DynamoDB with a TTL matching the idleTimeoutInSeconds setting (default: 600s, recommended: 7200s for long tool calls).
  2. Delete connectionId on $disconnect and on GoneException. Failing to clean up stale connection records is the most common operational issue — the connections table grows unbounded and every push attempt to a dead connection generates a GoneException that must be caught.
  3. Authorize only on $connect. The Lambda authorizer on the $connect route is the only point where auth runs — all subsequent messages from the same connection bypass the authorizer. Store auth context in DynamoDB alongside the connectionId and re-verify it in downstream Lambdas if needed.
  4. Route with $request.body.action. Define a route selection expression to dispatch different message types to different Lambdas — e.g., invokeTool routes to the tool-invocation Lambda, cancelTool routes to the cancellation Lambda.

→ Full reference: API Gateway WebSocket APIs for MCP servers

Pattern 3: API Gateway HTTP API for low-latency MCP tool endpoints

For synchronous MCP tool calls that complete within 30 seconds (the HTTP API integration timeout), API Gateway HTTP API (the v2 API type) is 70% cheaper and ~20 ms faster than REST API. The native JWT authorizer — requiring only the OIDC issuer URL and audience — eliminates the Lambda cold start on every auth check. HTTP API CORS support is first-class: configure it once at the API level and API Gateway handles OPTIONS preflight without a Lambda.

The most common migration mistake is payload format. HTTP API defaults to format version 2.0, with a different event shape than REST API:

FieldREST API (v1.0)HTTP API (v2.0)
HTTP methodevent.httpMethodevent.requestContext.http.method
Pathevent.pathevent.rawPath
Query stringevent.queryStringParametersevent.rawQueryString (also event.queryStringParameters)
Auth claims (JWT)N/Aevent.requestContext.authorizer.jwt.claims
Auth claims (Lambda)event.requestContext.authorizer.*event.requestContext.authorizer.lambda.*

If reusing a Lambda handler from a REST API in an HTTP API without updating the field access, the handler will silently receive undefined for method and path. Fix: update the handler to read from the v2.0 paths, or set payloadFormatVersion: "1.0" on the Lambda integration to force the old shape.

HTTP API lacks three REST API features that occasionally matter: usage plans (API key rate limiting per customer), request validation (JSON Schema validation before Lambda invocation), and per-method X-Ray tracing. If any of these are required, use REST API — otherwise HTTP API is the better default.

→ Full reference: API Gateway HTTP API for MCP endpoints

Pattern 4: AppSync data sources — Lambda, DynamoDB, and NONE resolvers

An AppSync data source connects a GraphQL field to a backend resource. For MCP tool delivery, three data source types cover the full range of use cases:

Lambda data source — maximum flexibility. AppSync invokes the Lambda with a fixed event shape ({ arguments, identity, source, info }), and the Lambda returns the exact GraphQL type shape. The Lambda can make network calls, run business logic, and interact with any AWS service. The cost is cold-start latency on infrequently-called resolvers.

DynamoDB data source with JavaScript resolvers — no Lambda cold start. The resolver runs in the AppSync JS runtime (a strict ECMAScript 2022 subset) and uses the @aws-appsync/utils helper library. The util.dynamodb.toMapValues(obj) function converts a flat JS object to DynamoDB AttributeValue format in a single call — the most common DynamoDB data source mistake is manually constructing { S: "value" } attribute maps instead of using this helper.

NONE data source — zero latency, zero persistence. The resolver runs locally in AppSync without calling any backend. The mutation result is delivered directly to subscription subscribers. This is the right choice for streaming partial tool results: each partial result fires as a NONE mutation, subscribers receive it immediately, and the final result is persisted in a separate DynamoDB write after streaming is complete.

Pipeline resolvers chain multiple data sources into a single GraphQL field resolution. A common MCP pattern: Function 1 (DynamoDB GetItem — verify session ownership), Function 2 (Lambda — invoke the tool), Function 3 (DynamoDB PutItem — persist the result). Each function receives ctx.prev.result from the previous function. Call util.error() in any function to short-circuit the pipeline — later functions will not run.

→ Full reference: AppSync data sources for MCP tool delivery

Pattern 5: Lambda authorizers for MCP gateway authentication

The API Gateway native JWT authorizer handles the 80% case — validate a Cognito or Auth0 JWT, check expiry and audience, forward claims. For the remaining 20% — custom API key formats, multi-factor auth (JWT + API key), database-backed revocation lists, or combining authentication with rate-limit context — Lambda authorizers provide full flexibility.

The two authorizer types for REST API differ in what the Lambda receives:

The two most impactful configuration settings:

Resource ARN in the policy: use a wildcard ARN (arn:aws:execute-api:region:account:apiId/stage/*/*) in the IAM policy document. If the ARN is narrow (just the one route that triggered the authorizer), the cached policy only covers that route — every other route the same caller hits triggers a new authorizer invocation. A wildcard ARN means one Lambda call covers the entire API for that token's TTL.

TTL: set authorizerResultTtlInSeconds to match the shortest reasonable token lifetime — typically 300 seconds (5 minutes). Setting it to 0 disables caching entirely and adds authorizer latency + cold-start risk to every request. For API keys that are rarely revoked, 600 seconds is appropriate. For tokens that need immediate revocation support, use a DynamoDB blocklist check inside the authorizer (a single GetItem adds ~5 ms) and set TTL to 60 seconds — the blocklist check is the revocation mechanism, and the short TTL limits the window for a cached-but-revoked token.

Context variables — key-value pairs set in the authorizer response — are forwarded to the integration Lambda as event.requestContext.authorizer.*. They can only be strings, numbers, or booleans (not objects). Use them to pass the decoded userId, plan tier, and rate limit to the tool Lambda without repeating JWT verification. Serialize complex data (e.g., an array of scopes) as a JSON string and parse it in the integration Lambda.

→ Full reference: Lambda authorizers for MCP API Gateway endpoints

Combined failure mode table

PatternFailureSymptomFix
AppSync subscriptionsMutation resolver throwsNo subscription events delivered even for partial successReturn a valid ToolResult with status "error" instead of throwing; use NONE data source for subscriptions that must not be blocked by persistence errors
AppSync subscriptionsMissing auth directive on subscription field401 on all subscription connectionsAdd @aws_api_key or @aws_cognito_user_pools directly to the subscription field — it does not inherit from the linked mutation
AppSync subscriptionsNONE data source request() returns nullSubscribers connected but receive no eventsReturn { payload: ctx.args.input } from request() — null cancels subscription delivery
API GW WebSocketGoneException not caughtconnectionIds accumulate in DynamoDB; future push attempts always fail for dead sessionsCatch GoneException in every PostToConnection call; delete connectionId from DynamoDB immediately
API GW WebSocket$connect Lambda takes >29sWebSocket handshake fails; client sees connection refusedMove slow operations out of $connect; only do auth + one DynamoDB put; return within 5 seconds for safety margin
API GW HTTP APILambda reads event.httpMethod on v2.0 payloadundefined — route logic fails silentlyUse event.requestContext.http.method for HTTP API; or set payloadFormatVersion: "1.0" on the Lambda integration
API GW HTTP APIJWT issuer URL trailing slash mismatch401 Unauthorized on every requestThe iss claim in the JWT must exactly match the Issuer URL — check with jwt.io; trailing slash is a literal character match
AppSync data sourcesLambda returns undefinedGraphQL field resolves to null; non-nullable type propagates errorAlways return an explicit object from Lambda resolvers; never return undefined or void
AppSync data sourcesutil.dynamodb.toDynamoDB() on full objectEntire object becomes a single M-type attribute; downstream query failsUse util.dynamodb.toMapValues(obj) to convert an object to a flat AttributeValue map; use toDynamoDB() only for single scalar values
Lambda authorizerNarrow resource ARN in policyCached policy only allows the first route; 403 on all other routesUse wildcard ARN: arn:aws:execute-api:region:account:apiId/stage/*/* so the cached policy covers all routes
Lambda authorizerContext value is an object500 from authorizer invocation; API Gateway returns 500 to callerContext values must be primitives; JSON.stringify() complex values and parse them in the integration Lambda
Lambda authorizerHTTP API returns IAM policy documentAuthorization decision ignored; all requests passHTTP API simple response format: return { isAuthorized: true/false }; IAM policy format requires authorizerPayloadFormatVersion: "1.0"

Pattern selection: AppSync vs API Gateway WebSocket vs HTTP API

The right choice depends on three factors: the message protocol (GraphQL vs arbitrary), the direction of communication (bidirectional vs server-push-only), and whether persistence is needed alongside real-time delivery.

RequirementBest choiceWhy
GraphQL-native schema + subscriptionsAppSyncBuilt-in subscription filtering, managed WebSocket, NONE data source for fast streaming
Binary or non-GraphQL protocolAPI GW WebSocketFull control over message format and routing logic
Short tool calls (<30s), synchronous responseAPI GW HTTP APILowest cost, lowest latency, native JWT authorizer
Multi-step tool orchestration with partial resultsAppSync (NONE data source) + DynamoDB pipelineStream partial results via NONE mutations; persist final result in same pipeline
Complex auth: API key + JWT + rate limitLambda authorizer (REQUEST type)Full request access; context variables for claim propagation
Simple JWT auth, OIDC providerHTTP API native JWT authorizerNo Lambda, no cold start, automatic JWKS rotation
Push from SQS worker to browserAPI GW WebSocket Management APIAny Lambda can push to any connectionId via Management API; AppSync requires a mutation via the GraphQL API

How AliveMCP monitors AppSync and API Gateway endpoints

AppSync APIs and API Gateway endpoints are not just HTTP — they have WebSocket surfaces that require active probing. A health check that only pings the HTTPS endpoint misses the most common failure modes: the WebSocket endpoint becomes unreachable (firewall rule change, VPC configuration drift), the Lambda authorizer starts returning 500s (cold-start timeout), or the AppSync subscription delivery stalls (MQTT broker issue).

AliveMCP monitors MCP endpoints at the protocol level — not just "does the HTTPS endpoint return 200" but "does a WebSocket connection succeed, does the subscription receive an event within 60 seconds, and does the Lambda authorizer complete within its configured timeout." For API Gateway HTTP API endpoints, AliveMCP sends a synthetic JWT (signed with a test key registered in the authorizer's allowed issuers) and verifies the full auth → integration → response pipeline every 60 seconds.

Authors who have claimed their AliveMCP listing get alerting when any of these checks fail — within 60 seconds of the failure, before users start seeing errors in their agent workflows.