Guide · AWS AppSync · Pipeline Resolvers

AppSync Pipeline Resolvers for MCP Servers — Sequenced Functions, VTL, and the JavaScript Runtime

An AppSync pipeline resolver chains two or more named functions — each with its own data source — into a single GraphQL field resolution without any Lambda glue code. For MCP server backends, the common pipeline shape is: (1) an authorization function that reads the caller's session from DynamoDB and short-circuits with util.error() on invalid sessions, (2) a tool-dispatch function that invokes the appropriate Lambda data source, and (3) an audit-write function that persists the tool call to a logging DynamoDB table. Each function receives the same ctx object and can pass data forward to the next function using ctx.stash — a mutable map scoped to the entire pipeline execution. The critical rule: if any function calls util.error() or returns an error from its response mapping, the pipeline aborts and no subsequent functions run. The before-handler (pipeline resolver's own request function) runs first to prepare stash values; the after-handler runs last to shape the final GraphQL response.

TL;DR

Use pipeline resolvers when a single GraphQL field requires sequential calls to multiple data sources (e.g., auth check in DynamoDB → tool invoke via Lambda → audit write in DynamoDB). Use ctx.stash to pass data between functions — it persists for the lifetime of the pipeline. Write functions in the JavaScript AppSync runtime (APPSYNC_JS runtime version 1.0.0) rather than VTL for new code — JavaScript has full ES2022 support except eval and new Function. A function that calls util.error() in its response handler aborts the pipeline immediately; a function that throws in its request handler does the same. The before-handler of the pipeline resolver itself runs before any function's request handler — use it to validate arguments and populate ctx.stash.

Pipeline resolver structure: before-handler, functions, after-handler

A pipeline resolver has three named slots that run in sequence: the before-handler (the resolver's own request mapping, runs once at the start), the function chain (each function's request and response mapping run in order), and the after-handler (the resolver's own response mapping, runs once at the end to shape the final result). All three slots share the same ctx object, including ctx.stash.

// AppSync pipeline resolver — before-handler (JavaScript runtime)
// Runs before any pipeline function
// Populate ctx.stash with data that functions need
export function request(ctx) {
  // ctx.args: GraphQL field arguments
  // ctx.identity: caller identity (Cognito claims, IAM ARN, API key)
  // ctx.stash: empty map at start — populate it here
  ctx.stash.sessionId = ctx.args.sessionId;
  ctx.stash.toolName = ctx.args.toolName;
  ctx.stash.callerUserId = ctx.identity?.sub ?? null;

  // Validate required arguments before any data source calls
  if (!ctx.args.sessionId || !ctx.args.toolName) {
    util.error("Missing required argument", "ArgumentError");
    // util.error() throws immediately — pipeline aborts, no functions run
  }

  // Before-handler must return {} for pipeline resolvers
  // (no data source call in the before-handler)
  return {};
}

// After-handler — runs after all functions complete
// ctx.result is the output of the last function's response handler
export function response(ctx) {
  if (ctx.error) {
    util.appendError(ctx.error.message, ctx.error.type, ctx.result);
  }
  return ctx.result;
}

Each function in the pipeline chain runs its own request and response handlers against its registered data source. The function's request handler shapes the data source payload; the response handler processes the data source result before passing control to the next function. Functions do NOT call each other's request handlers — the pipeline runtime orchestrates the sequence.

Writing pipeline functions: auth check → tool dispatch → audit write

The canonical MCP pipeline has three functions. Function 1 reads the session from DynamoDB and validates it. Function 2 invokes the tool Lambda. Function 3 writes an audit record. ctx.stash carries session data from function 1 so functions 2 and 3 don't repeat the DynamoDB read.

// Function 1: Session auth check (DynamoDB data source)
// Reads the session record and aborts pipeline if invalid

export function request(ctx) {
  return {
    operation: "GetItem",
    key: {
      sessionId: util.dynamodb.toDynamoDB(ctx.stash.sessionId)
    }
  };
}

export function response(ctx) {
  const session = ctx.result;
  if (!session) {
    util.error("Session not found", "AuthError");
  }
  if (session.expiresAt < util.time.nowEpochSeconds()) {
    util.error("Session expired", "AuthError");
  }
  if (session.userId !== ctx.stash.callerUserId) {
    util.error("Session belongs to a different user", "AuthError");
  }
  // Pass session data forward
  ctx.stash.session = session;
  ctx.stash.teamId = session.teamId;
  return session; // function result — ignored by pipeline unless it's the last function
}

// ---

// Function 2: Tool dispatch (Lambda data source)
// Invokes the tool Lambda with stash data populated by function 1

export function request(ctx) {
  return {
    operation: "Invoke",
    payload: {
      toolName: ctx.stash.toolName,
      sessionId: ctx.stash.sessionId,
      teamId: ctx.stash.teamId,
      toolInput: ctx.args.input,
      callerUserId: ctx.stash.callerUserId
    }
  };
}

export function response(ctx) {
  if (ctx.error) {
    util.appendError(ctx.error.message, "ToolInvocationError");
    return null;
  }
  // Store result for the audit function
  ctx.stash.toolResult = ctx.result;
  return ctx.result; // becomes the pipeline result if this is last
}

// ---

// Function 3: Audit write (DynamoDB data source)
// PutItem for audit log — runs even if tool returned an error result

export function request(ctx) {
  const auditRecord = {
    auditId: util.autoId(),
    sessionId: ctx.stash.sessionId,
    teamId: ctx.stash.teamId,
    toolName: ctx.stash.toolName,
    status: ctx.stash.toolResult?.status ?? "error",
    durationMs: ctx.stash.toolResult?.durationMs ?? 0,
    createdAt: util.time.nowISO8601()
  };
  return {
    operation: "PutItem",
    key: { auditId: util.dynamodb.toDynamoDB(auditRecord.auditId) },
    attributeValues: util.dynamodb.toMapValues(auditRecord)
  };
}

export function response(ctx) {
  // Return the tool result (from stash), not the audit write result
  return ctx.stash.toolResult;
}

Notice that function 3's response handler returns ctx.stash.toolResult — not ctx.result (which is the DynamoDB PutItem response). The last function's response handler return value becomes the pipeline's final result, passed to the after-handler as ctx.result.

JavaScript AppSync runtime vs VTL: when to use each

AppSync supports two resolver runtimes: the JavaScript runtime (APPSYNC_JS version 1.0.0, ES2022 subset) and Apache Velocity Template Language (VTL). For new code, use JavaScript — it supports if/else, loops, array methods, destructuring, template literals, and the full util.* helper namespace. VTL is still required for some complex DynamoDB operation types (e.g., TransactWriteItems with complex condition expressions in older AppSync versions), but AppSync JS supports all common DynamoDB operations since 2023.

// JavaScript runtime — available util.* helpers for DynamoDB

// Type conversion
util.dynamodb.toDynamoDB("string")    // { S: "string" }
util.dynamodb.toDynamoDB(42)          // { N: "42" }
util.dynamodb.toDynamoDB(true)        // { BOOL: true }
util.dynamodb.toDynamoDB(null)        // { NULL: true }
util.dynamodb.toMapValues({ k: "v" }) // { k: { S: "v" } }

// Deserialization (response handler, converting DynamoDB item back to plain JS)
util.dynamodb.toObject(ctx.result)    // converts AttributeValue map to plain object

// Time
util.time.nowISO8601()                // "2026-10-02T10:00:00.000Z"
util.time.nowEpochSeconds()           // 1759449600

// IDs
util.autoId()                         // random UUID v4

// Error handling
util.error("message", "ErrorType")   // throws, aborts pipeline
util.appendError("message", "Type")  // adds to errors array, continues execution
util.warn("message")                  // logs warning to CloudWatch, continues

// JSON
util.toJson(object)                   // JSON.stringify equivalent
util.parseJson(string)                // JSON.parse equivalent

// Transformations
util.base64Encode(string)
util.base64Decode(string)

The key util.* distinction for error handling: util.error() aborts the current function immediately and propagates the error to the pipeline (stopping subsequent functions). util.appendError() adds to the GraphQL error list but allows the function's response handler to continue returning a partial result — useful when you want to return data AND an error simultaneously (e.g., a degraded result with a warning).

CDK setup for pipeline resolvers

In AWS CDK v2, pipeline resolvers and their component functions are separate CfnFunctionConfiguration and CfnResolver constructs. The kind property on the resolver must be PIPELINE and the pipelineConfig lists function IDs in execution order.

import * as appsync from 'aws-cdk-lib/aws-appsync';

const api = new appsync.GraphqlApi(this, 'McpApi', {
  name: 'mcp-tool-api',
  definition: appsync.Definition.fromFile('schema.graphql'),
  authorizationConfig: {
    defaultAuthorization: {
      authorizationType: appsync.AuthorizationType.USER_POOL,
      userPoolConfig: { userPool }
    }
  }
});

// Data sources
const sessionTable = new appsync.DynamoDbDataSource(this, 'SessionTable', {
  api,
  table: sessionDynamoTable,
  serviceRole: sessionTableRole
});
const toolLambdaDs = new appsync.LambdaDataSource(this, 'ToolLambda', {
  api,
  lambdaFunction: toolDispatchLambda
});
const auditTable = new appsync.DynamoDbDataSource(this, 'AuditTable', {
  api,
  table: auditDynamoTable,
  serviceRole: auditTableRole
});

// Pipeline functions
const authFn = new appsync.AppsyncFunction(this, 'AuthFn', {
  api,
  dataSource: sessionTable,
  name: 'SessionAuthFunction',
  code: appsync.Code.fromAsset('resolvers/auth-function.js'),
  runtime: appsync.FunctionRuntime.JS_1_0_0
});
const toolFn = new appsync.AppsyncFunction(this, 'ToolFn', {
  api,
  dataSource: toolLambdaDs,
  name: 'ToolDispatchFunction',
  code: appsync.Code.fromAsset('resolvers/tool-function.js'),
  runtime: appsync.FunctionRuntime.JS_1_0_0
});
const auditFn = new appsync.AppsyncFunction(this, 'AuditFn', {
  api,
  dataSource: auditTable,
  name: 'AuditWriteFunction',
  code: appsync.Code.fromAsset('resolvers/audit-function.js'),
  runtime: appsync.FunctionRuntime.JS_1_0_0
});

// Pipeline resolver
new appsync.Resolver(this, 'InvokeToolResolver', {
  api,
  typeName: 'Mutation',
  fieldName: 'invokeTool',
  pipelineConfig: [authFn, toolFn, auditFn],
  code: appsync.Code.fromAsset('resolvers/invoke-tool-pipeline.js'),
  runtime: appsync.FunctionRuntime.JS_1_0_0
});

Failure modes reference

FailureSymptomFix
Before-handler returns non-empty objectAppSync treats the return value as a data source request — CloudFormation or schema validation errorPipeline resolver before-handler must return {} (empty object) — it has no data source
Function's request handler calls util.error()Pipeline aborts before the data source call; subsequent functions never runOnly call util.error() in response handlers — in request handlers, return a valid data source operation and let the response handler decide to abort based on the result
ctx.stash key missing in downstream functionTypeError or null reference in function 2/3 when stash wasn't populated by function 1Always set stash values in the before-handler or guard with null checks; stash is shared but not typed
Last function returns data source result instead of business objectGraphQL field returns DynamoDB AttributeValue map ({ S: "..." }) instead of plain stringsIn the last function's response handler, call util.dynamodb.toObject(ctx.result) or return ctx.stash.toolResult (the plain object stored by an earlier function)
Pipeline function order mismatch in CDKAuth check runs after tool dispatch — unauthenticated callers can invoke toolsVerify pipelineConfig array order in CDK matches intended execution sequence; CDK Resolver executes functions in the exact order listed
JavaScript runtime syntax errorAppSync returns "Runtime error" for all calls to the resolver; no CloudWatch log entry for the specific errorEnable CloudWatch logging at FIELD_RESOLVER level on the AppSync API; test functions locally using the AppSync local emulator or appsync evaluate-code CLI command
util.appendError vs util.error confusionPipeline continues when it should have aborted (or vice versa)util.error() aborts; util.appendError() continues — use appendError only for non-fatal warnings where you still want to return partial data