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
| Failure | Symptom | Fix |
|---|---|---|
| defineData requires GraphQL schema | TypeError or schema validation error when trying to use raw DynamoDB table config | defineData 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 costs | Multiple sandbox stacks idle in the AWS account from different developers | Run 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 build | Frontend 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 failure | Lambda function throws "cannot find module" for native binaries | Add native modules to externalPackages in defineFunction and provide them as a Lambda Layer |
| GSI sort key composite fields | Query returns wrong results when sorting by multiple fields | Amplify 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 event | Pre-sign-up trigger defined in defineAuth but not firing | Verify 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 |