AWS AppConfig · 2026-10-15 · AWS AppConfig arc

Dynamic Configuration for MCP Servers with AWS AppConfig: Schema, Delivery, and Safe Rollout

Four AWS AppConfig topics composed into three structural patterns for MCP server dynamic configuration — AppConfig foundational setup for MCP servers (Application/Environment/Configuration Profile CDK stack; freeform JSON config with toolEnabled, rateLimits, and upstreamTimeoutMs fields; JSON Schema validator on CfnConfigurationProfile rejects invalid documents at authoring time; AppConfigPoller class with module-level singleton, session token rotation on every GetLatestConfiguration call, empty response body means config unchanged — keep cached version as-is; ECS sidecar agent pattern on port 2772 isolates SDK version management from application code), AppConfig Lambda Extension for sub-millisecond config retrieval (extension runs as a sibling process, polls AppConfig asynchronously, caches in memory, serves via HTTP on localhost:2772; warm latency under 1ms vs 25–40ms for direct SDK call; region-specific layer ARNs must be pinned to a specific version in production — never use dynamic latest resolution; AWS_APPCONFIG_EXTENSION_POLL_INTERVAL_SECONDS minimum is 15s, default 45s; AWS_APPCONFIG_EXTENSION_POLL_WAIT_COMPLETE=true blocks handler start until first poll completes — adds 30–80ms to cold start but required for security boundaries like allowedCallers lists; first invocation after cold start may receive HTTP 408 if extension hasn't polled yet — handle with fallback to safe default config; never use request IDs or timestamps as stable instance IDs for percentage rollout assignment), Feature Flag profiles for per-tool toggles (profile type AWS.AppConfig.FeatureFlags has AppConfig-managed schema with structured flags + values maps; isToolEnabled() must return an MCP protocol error via throw new McpError(ErrorCode.InvalidRequest, ...) not a JavaScript exception — throwing causes SDK to emit unhandled error response; every flag must be present in every document version — omitting a tool from the values map causes isToolEnabled to return false for that tool via fail-safe default, disabling it unintentionally; percentageEnabled requires a deterministic stable instance ID for cohort assignment — use AWS_LAMBDA_LOG_STREAM_NAME or a module-level UUID, never a request ID or timestamp; CloudWatch alarm → AppConfig automatic rollback requires the monitor IAM role to have cloudwatch:DescribeAlarms on the specific alarm ARN — AppConfig silently fails the check if permissions are missing; rollback reverts to the previous version but does not mark the bad version as invalid — fix the root cause and deploy a new corrected version), and AppConfig deployment strategies for config rollout blast-radius control (four built-in strategies: AllAtOnce for kill-switches needing immediate propagation; Linear10PercentEvery10Minutes for new feature launches; Exponential10PercentEvery5Minutes for rate limit increases that stress upstream systems; Canary10Percent20Minutes for security-sensitive changes where a long observation window at 10% is worth the delay; deployment lifecycle: DEPLOYING → BAKING → COMPLETE, or DEPLOYING/BAKING → ROLLING_BACK → ROLLED_BACK; FinalBakeTimeInMinutes is the last window for automatic rollback — after COMPLETE, automatic rollback is no longer available and you must start a new deployment with the previous version number; StopDeployment reverts immediately at any point before COMPLETE; automatic rollback always uses AllAtOnce strategy regardless of the original deployment strategy; EventBridge rule on aws.appconfig source captures COMPLETE/ROLLED_BACK/ROLLING_BACK transitions for Slack notifications; AliveMCP's 60-second probe provides 1–7 minutes of head start over CloudWatch alarm-triggered rollback). 10-row consolidated failure modes table.

Pattern 1: The configuration data plane — what to put in AppConfig and which profile type to use

An MCP server's dynamic configuration falls into two categories with fundamentally different properties: operational parameters (rate limits, timeouts, caller allow-lists) and tool toggles (which tools are available right now). AppConfig gives you two profile types optimized for these two categories, and choosing the wrong one introduces operational risk.

For operational parameters, use a freeform JSON profile (AWS.Freeform) with a JSON Schema validator. The validator runs at authoring time — every call to CreateHostedConfigurationVersion is rejected if the document violates the schema, before any deployment starts. This means a typo in a field name (e.g., upstreamTimeoutMS instead of upstreamTimeoutMs) is caught at the console or CI/CD step, not at 2 AM when your tool starts timing out with the wrong duration. Use additionalProperties: false at the top level to reject unknown keys:

// The four fields that belong in every MCP server's freeform AppConfig document
const MCP_CONFIG_SCHEMA = {
  $schema: 'http://json-schema.org/draft-07/schema#',
  type: 'object',
  required: ['toolEnabled', 'rateLimits', 'upstreamTimeoutMs'],
  additionalProperties: false,  // rejects unknown keys — catches typos at authoring time
  properties: {
    toolEnabled: {
      type: 'object',
      additionalProperties: { type: 'boolean' }
    },
    rateLimits: {
      type: 'object',
      additionalProperties: {
        type: 'object',
        required: ['requestsPerMinute'],
        properties: {
          requestsPerMinute: { type: 'number', minimum: 0, maximum: 10000 },
          burstSize: { type: 'number', minimum: 1 }
        }
      }
    },
    upstreamTimeoutMs: {
      type: 'object',
      additionalProperties: { type: 'number', minimum: 100, maximum: 60000 }
    },
    allowedCallers: { type: 'array', items: { type: 'string' } },
    logLevel: { type: 'string', enum: ['debug', 'info', 'warn', 'error'] },
    maintenanceMode: { type: 'boolean' }
  }
};

For tool toggles, use a Feature Flag profile (AWS.AppConfig.FeatureFlags). AppConfig owns the schema for this profile type — you get a structured console UI, percentage-based rollout via percentageEnabled, and alarm-triggered rollback wired directly into the deployment. The critical document structure constraint: the values map must include an entry for every tool in the flags map in every version you deploy. When you deploy a new version, AppConfig replaces the entire document — it does not merge the new version with the previous one. A tool absent from the values map has no corresponding enabled property, so your isToolEnabled() function returns the fail-safe default (false), silently disabling that tool for every user until you fix the document:

// DO: every tool appears in both flags and values in every document version
const flagDocument = {
  flags: {
    search:        { name: 'search' },
    summarize:     { name: 'summarize' },
    fetchUrl:      { name: 'fetchUrl' },
    generateImage: { name: 'generateImage' },  // don't omit even if you only changed search
  },
  values: {
    search:        { enabled: true },
    summarize:     { enabled: true },
    fetchUrl:      { enabled: true },
    generateImage: { enabled: true, percentageEnabled: 25 },  // the one you actually changed
  },
  version: '3',
};

// DON'T: partial update — omitting fetchUrl causes it to be disabled for all users
const badFlagDocument = {
  flags: { search: { name: 'search' }, generateImage: { name: 'generateImage' } },
  values: { search: { enabled: true }, generateImage: { enabled: true, percentageEnabled: 25 } },
  // fetchUrl missing from values → isToolEnabled('fetchUrl') returns false
};

One deployment strategy applies to both profile types: use freeform JSON for config that changes with operational urgency (disabling a broken tool, adjusting a rate limit) and Feature Flag profiles for config that follows a progressive rollout cadence (launching a new tool to 10% of invocations, then 25%, then 100%). The toolEnabled field in the freeform profile is appropriate for kill-switches where you need AllAtOnce propagation in seconds. The Feature Flag profile's percentageEnabled is appropriate for careful enablement where you want a linear 10%/10-minute rollout across 100 minutes.

The AppConfig foundational setup guide has the CDK stack for creating the Application, Environment, and Configuration Profile resources, plus the full production-realistic config document. The feature flags guide has the CDK for the Feature Flag profile type and the complete isToolEnabled() helper.

Pattern 2: The delivery layer — getting config from AppConfig to running instances

Once you have a valid config document in AppConfig, the delivery question is how your running Lambda containers or ECS tasks actually receive it. AppConfig does not push config to instances — instances poll. The two architecturally meaningful delivery patterns are the Lambda Extension (a sibling process that handles polling for you) and direct SDK polling (an AppConfigPoller class in your application code). On ECS, a sidecar container replays the same tradeoffs as the Lambda Extension.

The Lambda Extension is the preferred pattern for Lambda deployments. It runs as a separate process within the Lambda execution environment, opens a configuration session with AppConfig on startup, and polls on a configurable interval. Your handler code calls GET http://localhost:2772/applications/{app}/environments/{env}/configurations/{profile} — a local loopback request that returns in under 1ms on warm containers. The extension handles session token rotation automatically; this is the most error-prone aspect of direct SDK polling (the token returned by GetLatestConfiguration must be used on the next call — if you use a stale token the session is invalid and you must restart). The tradeoff: the extension adds ~50ms to cold start time as the process initializes, versus zero overhead for a direct SDK poller that only starts its session on the first invocation.

// Lambda Extension delivery — handler code is a plain fetch(), no SDK dependency
const APPCONFIG_PORT = process.env.AWS_APPCONFIG_EXTENSION_HTTP_PORT ?? '2772';
const CONFIG_URL = `http://localhost:${APPCONFIG_PORT}/applications/${process.env.APPCONFIG_APP}/environments/${process.env.APPCONFIG_ENV}/configurations/${process.env.APPCONFIG_PROFILE}`;

let cachedConfig: McpServerConfig = DEFAULT_CONFIG;

export async function refreshConfig(): Promise<McpServerConfig> {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 500);

  try {
    const response = await fetch(CONFIG_URL, { signal: controller.signal });
    clearTimeout(timeoutId);
    if (!response.ok) throw new Error(`AppConfig extension returned HTTP ${response.status}`);
    const text = await response.text();
    if (text.length > 0) cachedConfig = JSON.parse(text) as McpServerConfig;
    return cachedConfig;
  } catch (err: unknown) {
    clearTimeout(timeoutId);
    // 408 on first invocation: extension hasn't polled yet — return safe default
    if (err instanceof Error && err.name === 'AbortError') {
      console.warn('AppConfig extension not ready; using cached/default config');
      return cachedConfig;
    }
    throw err;
  }
}

The cold start behavior of the Lambda Extension requires careful attention for security-boundary config. The extension polls AppConfig asynchronously during cold start — your handler can start before the extension has completed its first poll, which means the first invocation may read the safe default rather than the live AppConfig document. For most operational config (rate limits, timeouts) this is fine: the default values are safe and the extension catches up within 15–45 seconds. For security-boundary config (allowedCallers, authentication flags), set AWS_APPCONFIG_EXTENSION_POLL_WAIT_COMPLETE=true to block handler start until the first poll completes. This costs 30–80ms of additional cold start latency — a worthwhile tradeoff when the config contains access-control values.

Delivery approachWarm latencyCold start overheadToken rotationBest for
Lambda Extension (localhost:2772)<1ms~50ms (extension init)AutomaticLambda functions where tool latency matters; security-boundary configs with POLL_WAIT_COMPLETE
Direct SDK poller (AppConfigPoller class)25–40ms on poll cyclesNone beyond SDK initManual (must rotate on every GetLatestConfiguration call)Non-Lambda runtimes; environments where adding an extension layer is infeasible
ECS AppConfig Agent sidecar (localhost:2772)<1msTask startup onlyAutomaticECS/Fargate tasks; same localhost:2772 API as Lambda Extension

On ECS/Fargate, the AppConfig Agent sidecar (public.ecr.aws/aws-appconfig/aws-appconfig-agent:latest) exposes the same localhost:2772 HTTP interface as the Lambda Extension. Set it as a non-essential container (container crash doesn't restart the task) and use the PREFETCH_LIST environment variable to pre-warm the profile before the first request. Your MCP server application fetches the same URL format as in Lambda, enabling shared config-fetching code across Lambda and ECS deployments.

One delivery-layer constraint that affects both Lambda and ECS: during a non-AllAtOnce rollout, different instances pick up the new config at different times. This creates a mixed-config window where some containers are running the old config and some the new. For rate limit changes this is fine — a few minutes of inconsistent limits won't cause user-visible errors. For config that affects tool availability (enabling or disabling a tool), the mixed window means some users see the tool enabled while others don't. Accept this as expected behavior for non-AllAtOnce strategies; use AllAtOnce when consistency across all instances is required. The Lambda Extension guide covers the full environment variable reference and ARN table by region.

Pattern 3: The safety layer — deployment strategies, rollback mechanics, and the AliveMCP complement

The delivery layer gets config to instances. The safety layer controls how much of your fleet is exposed to a bad config change, how quickly you can revert, and how you know the change caused a problem in the first place. These three concerns map to deployment strategies, rollback mechanics, and external monitoring.

Deployment strategies as blast-radius control. The strategy controls what percentage of polling requests AppConfig serves the new config at each step of the rollout. AllAtOnce delivers the new config to every instance on its next poll (within one poll interval — the same 15–45 second window). Linear10PercentEvery10Minutes delivers it to 10% of instances immediately, then another 10% every 10 minutes, taking 100 minutes to reach full rollout. The mixed-config window for a kill-switch (disabling a broken tool) should always be one poll interval, not 100 minutes — use AllAtOnce. The mixed-config window for launching a new tool (enabling code that wasn't exercised in production before) should be as long as needed to observe error rates — use Linear.

Config change typeStrategyRationale
Disable broken tool (kill-switch)AllAtOnceSpeed is priority; blast radius of broken config is already happening
Enable new tool (feature launch)Linear10PercentEvery10MinutesNew code path; slow rollout gives 10-minute windows to catch errors at each step
Increase rate limitExponential10PercentEvery5MinutesHigher limits stress upstream systems; exponential ramp validates upstream capacity quickly without 100-minute window
Change upstream timeout or allowedCallersCanary10Percent20MinutesLong 20-minute observation at 10% catches p99 latency issues and auth edge cases before committing to full rollout
Log level changeAllAtOnceNo user-visible impact; want consistent log verbosity across all instances immediately

Rollback mechanics. AppConfig supports two rollback paths. Automatic rollback triggers when a CloudWatch alarm attached to the AppConfig environment's monitor configuration enters ALARM state during the deployment's bake window. The alarm must fire while the deployment is in DEPLOYING or BAKING state — after the deployment reaches COMPLETE, automatic rollback is no longer available. Manual rollback via the StopDeployment API works at any point before COMPLETE. The deployment lifecycle is: DEPLOYING (rollout in progress, percentage climbing) → BAKING (reached 100%, waiting for FinalBakeTimeInMinutes to elapse) → COMPLETE. If an alarm fires: ROLLING_BACK → ROLLED_BACK. The rollback itself always uses AllAtOnce regardless of the original strategy — AppConfig reverts fast.

// Manual rollback: call StopDeployment before the deployment reaches COMPLETE
import { AppConfigClient, StopDeploymentCommand, GetDeploymentCommand } from '@aws-sdk/client-appconfig';

const client = new AppConfigClient({ region: process.env.AWS_REGION });

async function rollbackIfActive(applicationId: string, environmentId: string, deploymentNumber: number) {
  // Check state first: StopDeployment is idempotent on COMPLETE but won't revert
  const status = await client.send(new GetDeploymentCommand({
    ApplicationId: applicationId,
    EnvironmentId: environmentId,
    DeploymentNumber: deploymentNumber,
  }));

  if (status.State === 'COMPLETE') {
    console.error('Deployment already COMPLETE — must start a new deployment with the previous version');
    return;
  }

  await client.send(new StopDeploymentCommand({
    ApplicationId: applicationId,
    EnvironmentId: environmentId,
    DeploymentNumber: deploymentNumber,
  }));
  console.log(`Deployment ${deploymentNumber} stopped; AppConfig reverting to previous config version.`);
}

// EventBridge rule to notify Slack on terminal deployment states
// Add this to your CDK stack alongside the AppConfig resources:
//
// new events.Rule(this, 'AppConfigDeployRule', {
//   eventPattern: {
//     source: ['aws.appconfig'],
//     detailType: ['AWS AppConfig Deployment'],
//     detail: { applicationId: [app.ref], deploymentState: ['COMPLETE', 'ROLLED_BACK', 'ROLLING_BACK'] },
//   },
//   targets: [new targets.LambdaFunction(slackNotifierFn)],
// });

The FinalBakeTimeInMinutes setting is where operators most often misconfigure AppConfig. The bake timer only starts after the deployment reaches 100% — it is not part of the rollout duration. A linear 10%/10min strategy with a 10-minute bake time takes 110 minutes total (100 minutes to roll out + 10 minutes bake). During the entire 110 minutes, a CloudWatch alarm can trigger automatic rollback. After the deployment transitions to COMPLETE at minute 110, you have no automatic safety net — errors that emerge after that point require a manual new deployment with the previous version number. Set bake time to at least 5 minutes for configs that affect tool execution paths, and 15+ minutes for security-sensitive config.

The AliveMCP complement. AppConfig's alarm-triggered rollback has an irreducible latency: the CloudWatch alarm evaluation period is at minimum 1 minute, and the alarm must sustain the breach for that period before transitioning to ALARM state. In practice, alarm-triggered rollback activates 2–8 minutes after the first failure. For a linear 10%/10-minute rollout, this means 10–30% of your fleet may have the bad config before an alarm fires and reverses the deployment.

AliveMCP's external probe fills the gap between a bad config deployment and AppConfig's own rollback activation. The probe fires every 60 seconds using a real JSON-RPC tools/list handshake. Here is the timeline for a bad config deployment that disables a tool: T=0, deployment starts with Linear strategy. T=45s, first 10% of Lambda containers poll AppConfig and pick up the bad config. T=60s, AliveMCP's probe detects that a previously-available tool no longer appears in tools/list. T=65s, AliveMCP alert is sent. T=120s, you see the alert and call StopDeployment. T=165s, all containers have reverted to the previous config. Compare with alarm-triggered rollback: first failures at T=45s, alarm evaluation period elapses at T=105s (assuming 1-minute periods), alarm fires and AppConfig starts rolling back at T=165s, rollback complete at T=210s. AliveMCP's probe gave a 45-second head start, allowing manual rollback to complete 45 seconds before the CloudWatch alarm would have triggered automatic rollback — and with only 10% of the fleet ever seeing the bad config instead of 20–30%.

AppConfig manages internal configuration state — it controls what your MCP server does. AliveMCP monitors external availability — it validates that your server is responding to real tool calls from outside AWS. These systems catch different failure modes. A misconfigured AppConfig deployment that corrupts your config parsing will not trigger an AppConfig alarm (AppConfig only knows whether the deployment completed, not whether your application successfully consumed the new config). AliveMCP's probe will detect within 60 seconds that tools/list is returning a parse error or an unexpected tool list. Together: AppConfig is the kill-switch mechanism, AliveMCP is the early warning that the kill-switch is needed.

The deployment strategies guide covers custom CfnDeploymentStrategy CDK for rollout curves that don't match the four built-in options, the full deployment event log structure, and the ReplicateTo: SSM_DOCUMENT option for multi-account deployments.

Consolidated failure modes

SymptomRoot causeFix
New config version deployment stuck in DEPLOYING and never advances JSON Schema validator rejected the document; or a CloudWatch alarm is already in ALARM state when the deployment starts — AppConfig won't advance a deployment while a monitor alarm is firing Check AppConfig console for validator errors; resolve the alarm before starting the deployment; check the event log for ALARM_TRIGGERED entries
Tool disabled for all users after a Feature Flag document update The values map in the new version omitted a tool that should remain enabled; AppConfig replaced the entire document; isToolEnabled() returns false for tools absent from the map Always copy the full previous document and modify only the changed flag; never submit partial updates to a Feature Flag profile
Extension returns HTTP 408 on first Lambda invocation after cold start Extension hasn't completed its initial AppConfig poll; handler called localhost:2772 before extension was ready Handle 408 by falling back to safe default config; or set AWS_APPCONFIG_EXTENSION_POLL_WAIT_COMPLETE=true for configs that must be current before any request is processed
AccessDeniedException in extension CloudWatch logs on every poll Lambda execution role has appconfig:GetConfiguration (the deprecated old API) but not appconfig:GetLatestConfiguration; the two are different IAM actions Add both appconfig:StartConfigurationSession and appconfig:GetLatestConfiguration to the role; remove the deprecated appconfig:GetConfiguration action
CloudWatch alarm fires but AppConfig does not roll back automatically The IAM role attached to the AppConfig environment monitor lacks cloudwatch:DescribeAlarms on the alarm ARN; AppConfig silently fails the check Verify the monitor role policy; check CloudTrail for AccessDenied from the appconfig.amazonaws.com principal; test with IAM policy simulator before relying on automatic rollback
Deployment reached COMPLETE but errors appeared after bake time expired The bake window expired before errors were detected; StopDeployment is no longer available on a completed deployment Start a new deployment with the previous config version number; retrieve the previous version's content with GetHostedConfigurationVersion and create a new hosted configuration version from it
Server uses old config minutes after a successful deployment Poll interval hasn't elapsed on warm containers; or extension is running with the default 45-second poll interval for configs that need faster propagation Reduce AWS_APPCONFIG_EXTENSION_POLL_INTERVAL_SECONDS to 15–30s for kill-switch configs; accept that max propagation time equals the poll interval
Percentage rollout is non-deterministic — same client sees tool enabled on one request, disabled on the next Hash function for cohort assignment uses a non-stable identifier (request ID, timestamp) that changes on every invocation Use a stable instance-level identifier: AWS_LAMBDA_LOG_STREAM_NAME (per container), ECS task ARN, or a module-level UUID generated once at process start
Connection refused on localhost:2772 Extension layer ARN not added to the Lambda function; wrong port in AWS_APPCONFIG_EXTENSION_HTTP_PORT; ARM64 function using x86_64 layer ARN Verify layer is listed in function configuration; ARM64 functions must use the AWS-AppConfig-Extension-Arm64 layer variant; confirm port variable matches the fetch URL
Config changes affect ECS tasks inconsistently — some tasks have new config, others old Normal behavior during a non-AllAtOnce rollout; different tasks poll at different times and pick up the new config at different points in the rollout window Accept as expected during rollout; use AllAtOnce for config changes that must be atomic across all running instances; implement client-side retry logic to tolerate inconsistent responses during the mixed-config window