Guide · AWS AppSync

MCP Server AppSync Data Sources — Lambda resolvers, DynamoDB direct, and pipeline functions

An AppSync data source is the backend resource (Lambda function, DynamoDB table, HTTP endpoint, or none) that a GraphQL resolver calls when a query, mutation, or subscription field is invoked. For MCP tool delivery, the three data source types that matter are: Lambda (invoke any Node/Python/Go function — maximum flexibility, cold-start overhead), DynamoDB (CRUD operations via mapping templates — no Lambda cold start, but limited to what DynamoDB operations can express), and NONE (local resolver with no backend — triggers subscription events without persistence, ideal for streaming partial tool results). Pipeline resolvers chain multiple data sources in a single GraphQL field resolution: for example, a mutation that first checks authorization in DynamoDB, then invokes a Lambda for the actual tool call, then writes the result back to DynamoDB — all as a single AppSync resolver with three pipeline functions.

TL;DR

Use JavaScript resolvers (AppSync JS runtime) instead of VTL for new data sources — they are easier to test locally with @aws-appsync/utils and support the same DynamoDB helper functions as VTL. A Lambda data source invokes the function with a fixed event shape: { arguments, identity, source, request, info } — the Lambda must return the exact GraphQL type shape. DynamoDB data sources use $util.dynamodb.toMapValues(input) to convert a JSON object to DynamoDB attribute format in a single call — do not manually construct AttributeValue maps. The AppSync service role needs dynamodb:GetItem/PutItem/Query/etc. on the table or lambda:InvokeFunction on the Lambda — never on *.

Lambda data source: event shape and response requirements

When AppSync invokes a Lambda data source, it sends a fixed-shape event — not the raw GraphQL request. The Lambda receives the field arguments, the caller's identity, and the parent object (for nested resolvers). The Lambda must return the exact shape of the GraphQL field type being resolved. If batching is enabled, AppSync sends an array of invocation contexts and expects an array of results in the same order.

// AppSync Lambda data source event shape
const appSyncEvent = {
  arguments: {   // GraphQL field arguments
    input: {
      toolCallId: "tc-001",
      sessionId: "sess-abc",
      toolName: "search",
      input: { query: "MCP server health" }
    }
  },
  identity: {
    // For Cognito: { sub, issuer, username, claims, sourceIp, defaultAuthStrategy }
    // For API key: { apiKeyId }
    // For IAM: { accountId, cognitoIdentityPoolId, cognitoIdentityId, userArn, ... }
    sub: "user-uuid",
    issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_xxx",
    username: "johndoe",
    claims: { sub: "user-uuid", email: "john@example.com" }
  },
  source: null,  // parent object for nested resolvers (e.g., if this field is on a type)
  request: {
    headers: { "x-forwarded-for": "1.2.3.4" }
  },
  info: {
    fieldName: "createToolResult",
    parentTypeName: "Mutation",
    variables: {},
    selectionSetList: ["toolCallId", "sessionId", "status", "output"],
    selectionSetGraphQL: "{ toolCallId sessionId status output }"
  }
};

// Lambda handler for createToolResult mutation
export const handler = async (event) => {
  const { input } = event.arguments;
  const userId = event.identity?.sub;

  // Authorization check — reject if caller doesn't own the session
  const session = await getSession(input.sessionId);
  if (session.userId !== userId) {
    throw new Error('Unauthorized');  // AppSync surfaces this as a GraphQL error
  }

  const result = await executeToolCall(input);

  // Return MUST match the ToolResult GraphQL type shape exactly
  // Missing non-nullable fields → GraphQL null error propagation
  return {
    toolCallId: result.toolCallId,
    sessionId: result.sessionId,
    toolName: result.toolName,
    status: result.status,
    output: result.output ?? null,
    durationMs: result.durationMs ?? null,
    createdAt: result.createdAt.toISOString()
  };
};

DynamoDB data source: JavaScript resolvers with $util helpers

DynamoDB data sources use resolver mapping templates — either VTL (Velocity Template Language) or the newer JavaScript runtime — to translate GraphQL operations into DynamoDB API calls. JavaScript resolvers are the preferred modern approach: they run in the AppSync JS runtime (a strict subset of ECMAScript 2022), support TypeScript via the @aws-appsync/utils package, and can be unit-tested without deploying to AppSync.

// JavaScript resolver for a DynamoDB data source
// File: resolvers/getToolResult.js — resolves Query.getToolResult

import { util } from '@aws-appsync/utils';

// request() runs before the DynamoDB call — returns the DynamoDB operation
export function request(ctx) {
  return {
    operation: 'GetItem',
    key: {
      // util.dynamodb.toDynamoDB() converts JS primitives to AttributeValue format
      toolCallId: util.dynamodb.toDynamoDB(ctx.args.toolCallId),
      sessionId: util.dynamodb.toDynamoDB(ctx.args.sessionId)
    }
  };
}

// response() runs after the DynamoDB call — transforms DynamoDB result to GraphQL type
export function response(ctx) {
  if (ctx.error) {
    util.error(ctx.error.message, ctx.error.type);
  }
  // ctx.result is the DynamoDB Item — util.dynamodb.toMapValues reverse-converts
  // AttributeValue format back to a plain JS object
  return ctx.result;
}

// PutItem resolver for createToolResult mutation
export function putRequest(ctx) {
  const { input } = ctx.args;
  return {
    operation: 'PutItem',
    // util.dynamodb.toMapValues converts the entire JS object to AttributeValue format
    // { toolCallId: "abc" } → { toolCallId: { S: "abc" } }
    // Much simpler than manually constructing each AttributeValue
    key: {
      toolCallId: util.dynamodb.toDynamoDB(input.toolCallId),
      sessionId: util.dynamodb.toDynamoDB(input.sessionId)
    },
    attributeValues: util.dynamodb.toMapValues({
      toolName: input.toolName,
      status: input.status,
      output: input.output,
      durationMs: input.durationMs,
      createdAt: util.time.nowISO8601()
    }),
    // Prevent overwriting an existing result with the same toolCallId
    condition: {
      expression: 'attribute_not_exists(toolCallId)'
    }
  };
}

export function putResponse(ctx) {
  if (ctx.error) {
    util.error(ctx.error.message, ctx.error.type);
  }
  // PutItem returns the item as stored (not the DynamoDB representation)
  return ctx.result;
}

Pipeline resolvers: chaining multiple data sources

A pipeline resolver chains multiple pipeline functions — each function has its own data source (Lambda, DynamoDB, HTTP, or NONE) and its own request/response mapping. The pipeline resolver itself has a before and after mapping that prepares input and finalizes output. Each function receives ctx.prev.result containing the result from the previous function in the pipeline. A function can call util.error() or return extensions.setErrors() to short-circuit the pipeline — later functions will not run.

// Pipeline resolver for createToolResult mutation
// Function 1: DynamoDB — verify session exists and caller owns it
// Function 2: Lambda — invoke the actual tool
// Function 3: DynamoDB — persist the result

// Pipeline resolver BEFORE mapping (runs before function 1)
export function request(ctx) {
  // Stash the original arguments for later pipeline functions
  ctx.stash.callerUserId = ctx.identity.sub;
  ctx.stash.input = ctx.args.input;
  return {};
}

// Function 1: getSession — DynamoDB GetItem
export function getSessionRequest(ctx) {
  return {
    operation: 'GetItem',
    key: { sessionId: util.dynamodb.toDynamoDB(ctx.stash.input.sessionId) }
  };
}
export function getSessionResponse(ctx) {
  if (!ctx.result) util.error('Session not found', 'NOT_FOUND');
  if (ctx.result.userId !== ctx.stash.callerUserId) util.error('Forbidden', 'UNAUTHORIZED');
  ctx.stash.session = ctx.result;
  return ctx.result;
}

// Function 2: invokeTool — Lambda data source
export function invokeToolRequest(ctx) {
  return {
    operation: 'Invoke',
    payload: {
      toolName: ctx.stash.input.toolName,
      input: ctx.stash.input.input,
      sessionId: ctx.stash.input.sessionId,
      userId: ctx.stash.callerUserId
    }
  };
}
export function invokeToolResponse(ctx) {
  if (ctx.error) util.error(ctx.error.message, 'TOOL_ERROR');
  ctx.stash.toolResult = ctx.result;
  return ctx.result;
}

// Function 3: saveResult — DynamoDB PutItem
export function saveResultRequest(ctx) {
  return {
    operation: 'PutItem',
    key: { toolCallId: util.dynamodb.toDynamoDB(ctx.stash.input.toolCallId) },
    attributeValues: util.dynamodb.toMapValues({
      sessionId: ctx.stash.input.sessionId,
      toolName: ctx.stash.input.toolName,
      status: 'complete',
      output: JSON.stringify(ctx.stash.toolResult),
      createdAt: util.time.nowISO8601()
    })
  };
}
export function saveResultResponse(ctx) {
  if (ctx.error) util.error(ctx.error.message, 'SAVE_ERROR');
  return ctx.result;
}

// Pipeline resolver AFTER mapping (runs after function 3)
export function response(ctx) {
  return ctx.prev.result;  // return the last function's result as the mutation response
}

Failure modes reference

FailureSymptomFix
Lambda returns undefined or voidGraphQL field resolves to null even for non-nullable fields; parent mutation may return an errorAlways return an explicit object from Lambda; for mutations that return void, return an empty object {} and use GraphQL type Boolean or a nullable type
VTL resolver missing #return($ctx.result)Response mapping returns null; data not delivered to client even though DynamoDB query succeededIn VTL response templates, always end with #return($ctx.result) or $util.toJson($ctx.result); without a return statement, the template returns null
util.dynamodb.toDynamoDB on a nested objectNested object serialized as a DynamoDB M type but response mapping expects plain JS objectUse util.dynamodb.toMapValues() for the full item, not toDynamoDB() on the entire object; toDynamoDB() on an object creates a single M-typed AttributeValue, not a flat attribute map
Lambda data source response > 1 MBAppSync returns a partial error; subscription events not delivered for that invocationReturn a reference (S3 pre-signed URL or DynamoDB key) from the Lambda and fetch large payloads on the client; AppSync has a 1 MB response limit per resolver invocation
Pipeline function throws — later functions still runPipeline continues despite earlier failure; partial or inconsistent state written to DynamoDBCall util.error() to short-circuit the pipeline; util.error() terminates the pipeline immediately and the after-mapping receives the error in ctx.error
AppSync service role missing lambda:InvokeFunctionAll Lambda data source calls fail with 401/403; resolver returns an execution errorAttach a policy to the AppSync service role: allow lambda:InvokeFunction on the specific function ARN; using * resource is a security anti-pattern
NONE data source request() returns nullSubscription events not fired even though mutation resolver returned successfullyNONE data source request() must return a non-null payload object: return { payload: ctx.args.input }; returning null cancels subscription delivery for that event