Guide · AWS Amplify · Gen 2

Amplify Gen 2 CDK Patterns for MCP Server Backends — TypeScript IaC, defineBackend, and Sandbox Environments

Amplify Gen 2 (released 2024) replaces the YAML-file-based Amplify CLI with a code-first, TypeScript-native approach where your backend infrastructure lives in an amplify/ directory as typed CDK constructs. For MCP server teams, Gen 2 is compelling because it collapses the gap between "Amplify magic" and "real CDK" — you can use defineData to provision a DynamoDB-backed AppSync API with a GraphQL schema, then escape the abstraction by accessing the underlying CDK L2 constructs via backend.data.resources.cfnTables. The two core Gen 2 workflows: sandbox (npx ampx sandbox) deploys a personal isolated backend stack to your AWS account in watch mode — every file save triggers a CDK diff and incremental deployment; branch deployment (npx ampx pipeline-deploy) is called from your CI/CD pipeline (or Amplify Hosting's build phase) to deploy to a shared environment. The critical Gen 2 constraint: defineData requires a GraphQL schema file, not raw DynamoDB table definitions — if you want pure DynamoDB without GraphQL, use defineBackend with CDK aws_dynamodb.Table directly.

TL;DR

Create amplify/backend.ts with defineBackend(), add amplify/data/resource.ts with defineData() for DynamoDB-backed AppSync, add amplify/auth/resource.ts with defineAuth() for Cognito, and add amplify/functions/<name>/resource.ts with defineFunction() for Lambda. Run npx ampx sandbox for local development. Access raw CDK constructs via backend.*.resources to add policies, environment variables, or custom resources not covered by the Amplify abstractions. Deploy to branches via Amplify Hosting by adding npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID to amplify.yml.

Project structure and backend.ts

Gen 2 uses an amplify/ directory at the root of your repository. Each backend capability (auth, data, functions, storage) lives in its own subdirectory with a resource.ts file. The amplify/backend.ts file imports and composes all capabilities.

// Directory structure:
// amplify/
//   backend.ts          ← entry point, composes all resources
//   auth/resource.ts    ← Cognito User Pool + Identity Pool
//   data/resource.ts    ← DynamoDB + AppSync GraphQL API
//   functions/
//     mcpToolDispatch/
//       resource.ts     ← Lambda function config
//       handler.ts      ← Lambda handler code

// amplify/backend.ts
import { defineBackend } from '@aws-amplify/backend';
import { auth } from './auth/resource';
import { data } from './data/resource';
import { mcpToolDispatch } from './functions/mcpToolDispatch/resource';

const backend = defineBackend({
  auth,
  data,
  mcpToolDispatch
});

// Escape hatch: access underlying CDK constructs
// Grant the function access to the DynamoDB table created by defineData
const { cfnTables } = backend.data.resources;
const sessionTable = cfnTables['Session'];

backend.mcpToolDispatch.resources.lambda.addEnvironment(
  'SESSION_TABLE_NAME',
  sessionTable.ref  // CloudFormation Ref for the table logical ID
);

backend.mcpToolDispatch.resources.lambda.addToRolePolicy(
  new aws_iam.PolicyStatement({
    actions: ['dynamodb:GetItem', 'dynamodb:PutItem', 'dynamodb:UpdateItem'],
    resources: [
      `arn:aws:dynamodb:*:*:table/${sessionTable.ref}`
    ]
  })
);

The backend.* escape hatch is the key Gen 2 feature for non-trivial MCP server backends. Amplify's defineData and defineAuth handle common patterns but don't expose every DynamoDB or Cognito configuration option. The backend.data.resources.cfnTables gives you the underlying CfnTable construct so you can set TTL attributes, global secondary indexes with custom read/write capacity, or DynamoDB Streams ARNs for event-driven processing.

defineData: GraphQL schema and DynamoDB tables

defineData provisions an AppSync GraphQL API backed by DynamoDB. You define your schema as a TypeScript object using Amplify's schema DSL. Each model becomes a DynamoDB table. Authorization rules are set per-model or per-field.

// amplify/data/resource.ts
import { defineData, a } from '@aws-amplify/backend';
import type { Schema } from './resource';

const schema = a.schema({
  McpSession: a.model({
    sessionId: a.id().required(),
    userId: a.string().required(),
    teamId: a.string().required(),
    status: a.enum(['active', 'expired', 'revoked']),
    expiresAt: a.integer(),
    createdAt: a.datetime()
  })
  .authorization((allow) => [
    allow.owner(),                    // owner (userId) can CRUD their own records
    allow.group('admins').to(['read', 'update'])  // admins group can read + update
  ]),

  McpToolCall: a.model({
    callId: a.id().required(),
    sessionId: a.string().required(),
    toolName: a.string().required(),
    status: a.enum(['pending', 'running', 'completed', 'failed']),
    durationMs: a.integer(),
    errorMessage: a.string()
  })
  .secondaryIndexes((index) => [
    index('sessionId')                // GSI for listing calls by session
      .sortKeys(['createdAt'])
      .queryField('listToolCallsBySession')
  ])
  .authorization((allow) => [
    allow.owner().identityClaim('sub'),  // use Cognito sub as the owner field
    allow.authenticated().to(['read'])   // any authenticated user can read
  ])
});

export type Schema = ClientSchema<typeof schema>;

export const data = defineData({
  schema,
  authorizationModes: {
    defaultAuthorizationMode: 'userPool',
    apiKeyAuthorizationMode: {
      expiresInDays: 30          // API key for unauthenticated reads (if needed)
    }
  }
});

The .secondaryIndexes() DSL creates a DynamoDB GSI and a corresponding AppSync query resolver (listToolCallsBySession in the example). The GSI is provisioned by Amplify — no manual DynamoDB table editing. If you need a composite sort key with multiple fields, use .sortKeys(['field1', 'field2']) — Amplify concatenates them with # separators in the GSI sort key.

defineAuth: Cognito User Pool and Identity Pool

defineAuth provisions a Cognito User Pool (for username/password and social sign-in) and a Cognito Identity Pool (for IAM-based access from the browser). The Identity Pool is optional but required if you want the frontend to call AWS services directly (e.g., uploading to S3 using temporary IAM credentials).

// amplify/auth/resource.ts
import { defineAuth } from '@aws-amplify/backend';

export const auth = defineAuth({
  loginWith: {
    email: true,           // email/password sign-in
    // phone: true,        // SMS MFA
    // externalProviders: {  // social sign-in
    //   google: { clientId: ..., clientSecret: ... }
    // }
  },
  userAttributes: {
    // Standard attributes auto-verified
    email: { required: true, mutable: false },
    // Custom attributes
    'custom:teamId': { dataType: 'String', mutable: true }
  },
  groups: ['admins', 'members'],
  // Triggers — Lambda functions that run during auth events
  triggers: {
    preSignUp: defineFunction({
      entry: './functions/pre-sign-up/handler.ts'
    }),
    postConfirmation: defineFunction({
      entry: './functions/post-confirmation/handler.ts'
    })
  },
  multifactor: {
    mode: 'OPTIONAL',        // OPTIONAL, OFF, REQUIRED
    totp: true               // time-based OTP authenticator app
  }
});

Cognito triggers defined in defineAuth are automatically granted cognito-idp:DescribeUserPool permissions and receive a typed event payload. The preSignUp trigger is useful for MCP server teams to reject sign-ups from non-allowlisted email domains — return { ...event, response: { autoConfirmUser: false } } and throw an error to block the sign-up without creating the user.

defineFunction and Lambda integration patterns

defineFunction wraps a Lambda function handler file and handles bundling (esbuild), IAM execution role creation, and integration with other Amplify resources. The handler file is a standard Lambda handler — no Amplify-specific wrapping required.

// amplify/functions/mcpToolDispatch/resource.ts
import { defineFunction } from '@aws-amplify/backend';

export const mcpToolDispatch = defineFunction({
  name: 'mcpToolDispatch',
  entry: './handler.ts',
  timeoutSeconds: 30,
  memoryMB: 512,
  environment: {
    LOG_LEVEL: 'INFO',
    // Dynamic values (e.g., table names) are injected via backend.ts escape hatch
  },
  runtime: 20   // Node.js 20.x
});

// amplify/functions/mcpToolDispatch/handler.ts
import type { Handler } from 'aws-lambda';

export const handler: Handler = async (event) => {
  const { toolName, sessionId, input } = event;
  // ... tool dispatch logic
  return {
    status: 'completed',
    result: { /* tool output */ }
  };
};

Amplify Gen 2 uses esbuild to bundle the handler file — no node_modules zip required. The bundled artifact is a single JavaScript file. External modules that require native binaries (e.g., sharp, bcrypt) must be added as Lambda layers or excluded from bundling and deployed as a zip layer separately.

Sandbox and pipeline-deploy workflows

The sandbox workflow runs a CDK watcher in your terminal that deploys infrastructure changes incrementally as you edit files. Each developer gets their own isolated stack named after their IAM username. Pipeline deploy is the CI/CD variant — it deploys to a named branch environment.

# Local development — start sandbox
npx ampx sandbox
# Creates stack: amplify-{appName}-{username}-sandbox-{hash}
# Watch mode: file changes trigger CDK diff + incremental deploy (~30s)

# Stop sandbox (clean up resources to avoid charges)
npx ampx sandbox delete

# CI/CD pipeline deploy (in amplify.yml preBuild phase)
npx ampx pipeline-deploy \
  --branch $AWS_BRANCH \
  --app-id $AWS_APP_ID

# amplify.yml with Gen 2 backend deploy
version: 1
backend:
  phases:
    build:
      commands:
        - npm ci
        - npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID
frontend:
  phases:
    preBuild:
      commands:
        - nvm use 20
        - npm ci
    build:
      commands:
        - npm run build
  artifacts:
    baseDirectory: dist
    files:
      - '**/*'

The pipeline-deploy command reads the Amplify App's environment from the AWS_BRANCH and AWS_APP_ID environment variables that Amplify Hosting injects automatically. It outputs an amplify_outputs.json file containing the deployed resource endpoints (AppSync URL, Cognito User Pool ID, etc.) — the frontend build phase reads this file to configure the Amplify client library.

Failure modes reference

FailureSymptomFix
defineData requires GraphQL schemaTypeError or schema validation error when trying to use raw DynamoDB table configdefineData only supports AppSync+DynamoDB via GraphQL schema DSL; for pure DynamoDB without GraphQL, use defineBackend with CDK aws_dynamodb.Table directly
Sandbox stack per developer accumulates costsMultiple sandbox stacks idle in the AWS account from different developersRun npx ampx sandbox delete when not in use; set a CloudWatch billing alarm for sandbox stack names matching the pattern
amplify_outputs.json not found in frontend buildFrontend build fails with "cannot find amplify_outputs.json"The backend phase must complete before the frontend phase — verify amplify.yml has backend.phases before frontend.phases; pipeline-deploy writes amplify_outputs.json to the project root
Native module bundling failureLambda function throws "cannot find module" for native binariesAdd native modules to externalPackages in defineFunction and provide them as a Lambda Layer
GSI sort key composite fieldsQuery returns wrong results when sorting by multiple fieldsAmplify concatenates sort key fields with '#' — the sort key value is 'field1#field2'; update query sort key expressions to match the concatenated format
Cognito trigger not receiving eventPre-sign-up trigger defined in defineAuth but not firingVerify the trigger Lambda function name in defineAuth matches the actual function export; Amplify does not grant the trigger invocation permission if the function isn't linked via defineAuth