Guide · AWS AppConfig · Configuration Management
AWS AppConfig for MCP Servers: Dynamic Configuration Without Redeployment
AWS AppConfig lets you change how your MCP server behaves — disabling a broken tool, throttling a rate limit, toggling an experimental feature — without touching your deployment pipeline. When a tool starts returning 500s at 2 AM, you do not want to wait 90 seconds for a Lambda cold-start wave to clear before users stop seeing errors. AppConfig decouples configuration from code so you can push a change in seconds. This guide covers the full setup for Lambda and ECS. If you run your server on Lambda, see the companion guide on the AppConfig Lambda Extension for sub-millisecond config retrieval. For feature-flag-based tool toggling, see Feature Flags for MCP Servers with AWS AppConfig.
TL;DR
Create an AppConfig Application, Environment, and Configuration Profile. Store your MCP server's tool toggles, rate limits, and upstream timeouts as a freeform JSON document. Use StartConfigurationSession + GetLatestConfiguration in a 30-second polling loop (or the Lambda Extension for Lambda deployments). Grant your execution role appconfig:StartConfigurationSession and appconfig:GetLatestConfiguration. Deploy a new config version through the console or CDK — your running server picks it up without a restart. AliveMCP's external probes will detect any gap between a bad config push and its recovery within 60 seconds.
Why MCP servers need dynamic configuration
MCP servers expose a fixed list of tools over a long-lived connection. When one of those tools breaks — because an upstream API changed, a quota was exhausted, or a new code path introduced a regression — your options without dynamic configuration are grim. A Lambda redeployment triggers a new cold start wave: the 30–90 seconds while new containers initialize is a window where concurrent requests either queue or fail. An ECS rolling update is faster but still takes one to three minutes before all tasks are replaced. Meanwhile, every user hitting the broken tool sees an error.
Environment variables are one step better — you can set them without redeploying code — but updating a Lambda environment variable still forces a function update that briefly disrupts in-flight requests, and ECS requires a new task definition revision. Neither approach has built-in validation, deployment strategy, or rollback.
| Approach | Change latency | Validation | Rollback | In-flight request disruption |
|---|---|---|---|---|
| Hard-coded in source | Minutes (CI/CD pipeline) | Unit tests only | Redeploy previous version | Full cold-start wave |
| Environment variables | 30–90s (Lambda update) | None | Redeploy previous env config | Brief disruption on update |
| SSM Parameter Store | Seconds (poll interval) | None built-in | Manual re-put of old value | None (hot reload) |
| AWS AppConfig | Seconds (poll interval) | JSON Schema validators | One-click or alarm-triggered | None (hot reload) |
AppConfig's key advantage over SSM Parameter Store is its deployment model: every config change goes through a deployment with a configurable rollout strategy and automatic rollback if a CloudWatch alarm fires. You can require a 5-minute bake time before a config change is considered stable, catch errors via AliveMCP or your own metrics, and revert to the last known-good config with one API call.
Core AppConfig concepts for MCP server operators
AppConfig organizes configuration into three levels. An Application is a logical container — one per MCP server or per service. An Environment represents a deployment stage: production, staging, dev. A Configuration Profile holds the actual config document. When you deploy a new version of the profile to an environment, AppConfig manages the rollout across your polling clients.
Configuration profiles come in two types. A Freeform profile stores arbitrary JSON (or YAML) validated by an optional JSON Schema validator. A Feature Flag profile uses a structured schema managed by AppConfig and adds percentage-based rollout and deterministic instance assignment — covered in depth on the feature flags page. For most MCP server configs (rate limits, timeouts, upstream URLs), a freeform profile is sufficient.
Below is a CDK v2 stack that creates the AppConfig resources:
import * as cdk from 'aws-cdk-lib';
import * as appconfig from 'aws-cdk-lib/aws-appconfig';
export class McpAppConfigStack extends cdk.Stack {
constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const app = new appconfig.CfnApplication(this, 'McpApp', {
name: 'mcp-server',
});
const prodEnv = new appconfig.CfnEnvironment(this, 'ProdEnv', {
applicationId: app.ref,
name: 'production',
});
// Freeform JSON profile with a JSON Schema validator
const profile = new appconfig.CfnConfigurationProfile(this, 'McpServerConfig', {
applicationId: app.ref,
name: 'mcp-server-config',
locationUri: 'hosted',
type: 'AWS.Freeform',
validators: [
{
type: 'JSON_SCHEMA',
content: JSON.stringify({
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
required: ['toolEnabled', 'rateLimits', 'upstreamTimeoutMs'],
properties: {
toolEnabled: { type: 'object', additionalProperties: { type: 'boolean' } },
rateLimits: {
type: 'object',
additionalProperties: {
type: 'object',
required: ['requestsPerMinute'],
properties: { requestsPerMinute: { type: 'number', minimum: 0 } },
},
},
upstreamTimeoutMs: { type: 'object', additionalProperties: { type: 'number', minimum: 100 } },
allowedCallers: { type: 'array', items: { type: 'string' } },
},
}),
},
],
});
// Hosted configuration version — initial values baked into CDK
const initialConfig = new appconfig.CfnHostedConfigurationVersion(this, 'InitialConfig', {
applicationId: app.ref,
configurationProfileId: profile.ref,
content: JSON.stringify({
toolEnabled: { search: true, summarize: true, fetchUrl: true, executeCode: false },
rateLimits: {
search: { requestsPerMinute: 60 },
fetchUrl: { requestsPerMinute: 30 },
},
upstreamTimeoutMs: { search: 5000, fetchUrl: 8000, summarize: 3000 },
allowedCallers: [],
}),
contentType: 'application/json',
});
new cdk.CfnOutput(this, 'AppId', { value: app.ref });
new cdk.CfnOutput(this, 'ProdEnvId', { value: prodEnv.ref });
}
}
What to put in MCP server config
The most useful patterns for MCP server configuration are tool toggles, per-tool rate limits, upstream timeouts, and caller allow-lists. Here is a production-realistic JSON document:
{
"toolEnabled": {
"search": true,
"summarize": true,
"fetchUrl": true,
"executeCode": false,
"generateImage": true,
"queryDatabase": false
},
"rateLimits": {
"search": { "requestsPerMinute": 60, "burstSize": 10 },
"fetchUrl": { "requestsPerMinute": 30, "burstSize": 5 },
"generateImage": { "requestsPerMinute": 10, "burstSize": 2 }
},
"upstreamTimeoutMs": {
"search": 5000,
"fetchUrl": 8000,
"summarize": 3000,
"generateImage": 15000,
"queryDatabase": 10000
},
"allowedCallers": [],
"logLevel": "info",
"maintenanceMode": false
}
Attach a JSON Schema validator to the AppConfig profile so any document that violates the schema is rejected before it can affect running instances. Key constraints: toolEnabled values must be booleans, rateLimits.*.requestsPerMinute must be a number between 0 and 10000, upstreamTimeoutMs values must be between 100 and 60000ms, and logLevel must be one of debug, info, warn, error. Use additionalProperties: false at the top level to reject documents with unknown keys — this catches typos in field names before they silently become no-ops at runtime. Pass the schema as the content field of the validators array in your CfnConfigurationProfile as shown in the CDK stack above. AppConfig validates on every CreateHostedConfigurationVersion call, so invalid documents are rejected at authoring time, not at deployment time.
Retrieving config in a Lambda MCP server
The direct SDK approach — without the Lambda Extension — works in any runtime and is the baseline. You open a configuration session once per Lambda container lifetime, then call GetLatestConfiguration in a polling loop. When the response body is empty, your cached config is still current. This avoids re-transmitting unchanged documents on every poll.
import {
AppConfigDataClient,
StartConfigurationSessionCommand,
GetLatestConfigurationCommand,
} from '@aws-sdk/client-appconfigdata';
const client = new AppConfigDataClient({ region: process.env.AWS_REGION });
interface McpServerConfig {
toolEnabled: Record<string, boolean>;
rateLimits: Record<string, { requestsPerMinute: number; burstSize?: number }>;
upstreamTimeoutMs: Record<string, number>;
allowedCallers: string[];
logLevel: string;
maintenanceMode: boolean;
}
class AppConfigPoller {
private sessionToken: string | undefined;
private cachedConfig: McpServerConfig | undefined;
private lastPollAt = 0;
private readonly pollIntervalMs: number;
constructor(
private readonly appId: string,
private readonly envId: string,
private readonly profileId: string,
pollIntervalMs = 30_000,
) {
this.pollIntervalMs = pollIntervalMs;
}
async getConfig(): Promise<McpServerConfig> {
const now = Date.now();
if (this.cachedConfig && now - this.lastPollAt < this.pollIntervalMs) {
return this.cachedConfig;
}
await this.poll();
if (!this.cachedConfig) {
throw new Error('AppConfig: no configuration loaded yet');
}
return this.cachedConfig;
}
private async poll(): Promise<void> {
if (!this.sessionToken) {
const session = await client.send(
new StartConfigurationSessionCommand({
ApplicationIdentifier: this.appId,
EnvironmentIdentifier: this.envId,
ConfigurationProfileIdentifier: this.profileId,
RequiredMinimumPollIntervalInSeconds: 30,
}),
);
this.sessionToken = session.InitialConfigurationToken!;
}
const result = await client.send(
new GetLatestConfigurationCommand({ ConfigurationToken: this.sessionToken }),
);
// Token must be rotated on every call — even if config hasn't changed
this.sessionToken = result.NextPollConfigurationToken!;
this.lastPollAt = Date.now();
const body = result.Configuration
? Buffer.from(result.Configuration).toString('utf-8')
: '';
if (body.length > 0) {
this.cachedConfig = JSON.parse(body) as McpServerConfig;
}
// Empty body means config unchanged — keep cachedConfig as-is
}
}
// Module-level singleton so the session persists across warm Lambda invocations
const configPoller = new AppConfigPoller(
process.env.APPCONFIG_APP_ID!,
process.env.APPCONFIG_ENV_ID!,
process.env.APPCONFIG_PROFILE_ID!,
);
export async function getMcpServerConfig(): Promise<McpServerConfig> {
return configPoller.getConfig();
}
For Lambda deployments, the Extension approach covered in AppConfig Lambda Extension for MCP Servers reduces latency further by replacing the SDK calls with a single fetch('http://localhost:2772/...') — the extension handles session management for you.
Retrieving config in an ECS/Fargate MCP server
On ECS, you have two options: poll AppConfig directly from your application container (same SDK code as above, running in a background interval), or deploy the AppConfig Agent as a sidecar container that serves config over HTTP on localhost:2772 — the same interface as the Lambda Extension.
The sidecar approach is cleaner because it isolates AppConfig SDK version management and keeps polling logic out of your application. Add the following container definition to your ECS task definition:
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as iam from 'aws-cdk-lib/aws-iam';
// Inside your ECS task definition construction:
const taskDef = new ecs.FargateTaskDefinition(this, 'McpTaskDef', {
cpu: 512,
memoryLimitMiB: 1024,
});
// Grant the task role permission to read AppConfig
taskDef.taskRole.addToPrincipalPolicy(new iam.PolicyStatement({
actions: [
'appconfig:StartConfigurationSession',
'appconfig:GetLatestConfiguration',
],
resources: ['*'],
}));
// AppConfig Agent sidecar
taskDef.addContainer('AppConfigAgent', {
image: ecs.ContainerImage.fromRegistry(
'public.ecr.aws/aws-appconfig/aws-appconfig-agent:latest',
),
essential: false,
environment: {
SERVICE_REGION: 'us-east-1',
PREFETCH_LIST: `applications/${process.env.APPCONFIG_APP_ID}/environments/${process.env.APPCONFIG_ENV_ID}/configuration/${process.env.APPCONFIG_PROFILE_ID}`,
},
portMappings: [{ containerPort: 2772, protocol: ecs.Protocol.TCP }],
logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'appconfig-agent' }),
});
// Your MCP server container
taskDef.addContainer('McpServer', {
image: ecs.ContainerImage.fromEcrRepository(mcpRepo, 'latest'),
essential: true,
environment: {
APPCONFIG_AGENT_URL: 'http://localhost:2772',
APPCONFIG_APP_ID: process.env.APPCONFIG_APP_ID!,
APPCONFIG_ENV_ID: process.env.APPCONFIG_ENV_ID!,
APPCONFIG_PROFILE_ID: process.env.APPCONFIG_PROFILE_ID!,
},
logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'mcp-server' }),
});
Your MCP server application then fetches config the same way it would with the Lambda Extension: GET http://localhost:2772/applications/{app}/environments/{env}/configurations/{profile}. The agent handles the polling loop, session token rotation, and caching.
AliveMCP uptime monitoring + AppConfig: complementary layers
AppConfig manages internal configuration state — it controls what your MCP server does. AliveMCP monitors external availability — it probes from outside your AWS account every 60 seconds using a real JSON-RPC tools/list handshake and verifies that the response is valid. These two systems catch different failure modes.
The gap AppConfig cannot fill: it cannot tell you whether your server is actually responding to clients. A misconfigured deployment that corrupts your config parsing will not trigger an AppConfig alarm — because AppConfig only knows whether the deployment completed, not whether your application successfully consumed the new config. AliveMCP's probe catches exactly this: the server stops responding to tools/list within 60 seconds, regardless of why. Because AppConfig deployments use bake time windows of 5–30 minutes before marking a deployment complete, AliveMCP will alert you well before AppConfig's own rollback logic activates. Together, they give you both a kill-switch (AppConfig rollback) and an early warning system (AliveMCP alert).
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
New config version deployment is stuck in DEPLOYING state indefinitely |
JSON Schema validator rejected the document; AppConfig silently holds and the deployment never reaches COMPLETE |
Check the AppConfig console for validator error messages; fix schema violations in the document; redeploy |
| Server is using old config after a successful deployment | Poll interval hasn't elapsed; cached config in memory is still fresh; RequiredMinimumPollIntervalInSeconds set too high |
Reduce poll interval to 15–30s for critical toggles; restart the poller by redeploying or clearing Lambda container state |
AccessDeniedException on GetLatestConfiguration |
Lambda execution role or ECS task role is missing appconfig:GetLatestConfiguration |
Add both appconfig:StartConfigurationSession and appconfig:GetLatestConfiguration to the role's policy; these are separate IAM actions |
| Server crashes on startup after a config update | Config document changed shape (e.g., a field was renamed or its type changed) without a matching schema update; application-side parsing threw an exception | Treat config schema changes as breaking changes; add backward-compatible defaults in your parsing layer; roll back the AppConfig deployment immediately |
| Config loads successfully in staging but wrong values appear in production | Environment IDs in APPCONFIG_ENV_ID environment variable are swapped; staging env ID deployed to production containers |
Use CDK outputs or SSM to inject the correct environment IDs; add an assertion in your startup code that verifies the environment name matches NODE_ENV |