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
| Failure | Symptom | Fix |
|---|---|---|
| Lambda returns undefined or void | GraphQL field resolves to null even for non-nullable fields; parent mutation may return an error | Always 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 succeeded | In 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 object | Nested object serialized as a DynamoDB M type but response mapping expects plain JS object | Use 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 MB | AppSync returns a partial error; subscription events not delivered for that invocation | Return 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 run | Pipeline continues despite earlier failure; partial or inconsistent state written to DynamoDB | Call 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:InvokeFunction | All Lambda data source calls fail with 401/403; resolver returns an execution error | Attach 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 null | Subscription events not fired even though mutation resolver returned successfully | NONE data source request() must return a non-null payload object: return { payload: ctx.args.input }; returning null cancels subscription delivery for that event |