Deep Dive · AWS Compliance

AWS Config for MCP Servers: Three Control Patterns for Drift Detection, Custom Evaluation, and Multi-Account Compliance

Published 2026-09-20 · 22 min read

AWS Config is a detective compliance service — it tells you what exists, whether it matches your rules, and what changed. But "detective" is not the same as "preventive," and teams building MCP server infrastructure consistently conflate the two. Config does not block anything. It cannot stop a developer from creating an unencrypted DynamoDB table. What it can do is catch that table within minutes, publish a finding, trigger an SSM Automation document to encrypt it, surface the incident in Security Hub, and aggregate that signal across every AWS account your MCP fleet spans — all without manual intervention. This post synthesizes five Config primitives — managed rules, custom Lambda evaluators, conformance packs, SSM remediation, and the multi-account aggregator — around three structural patterns that separate teams with working compliance automation from teams who have Config deployed but get almost nothing from it.

The core mental model: Config is the detector, not the enforcer

Every Config problem I see traces back to teams expecting Config to do things it cannot do. Before the three patterns, let's be precise about what the five primitives actually are:

Primitive What it does When it fires Write access to AWS resources?
Managed rule Evaluates resources against an AWS-authored compliance check On resource change (CONFIGURATION_CHANGE) or periodically (PERIODIC) No — read-only evaluation
Custom Lambda rule Invokes your Lambda to evaluate resources against custom logic On resource change or periodically No — evaluation only; Lambda must call putEvaluations to publish results
Conformance pack Bundles rules + optional remediation actions into one deployable CloudFormation artifact Deploys the bundled rules — each rule then fires on its own schedule No — the pack itself doesn't write; only attached remediation actions do
SSM remediation Runs an SSM Automation document when a resource becomes NON_COMPLIANT Triggered by Config NON_COMPLIANT event (auto) or manually invoked Yes — the SSM document has write permissions scoped to its remediation role
Aggregator Collects Config data from multiple accounts/regions into a single read plane Continuous — mirrors source account data with 3-6h snapshot lag No — read-only; aggregator cannot push rules or remediations to member accounts

The other thing to establish before diving in: the Config recorder must be running before any rule can evaluate anything. A Config rule created before the recorder is enabled will sit in NO_CONFIGURATION_ITEMS_SEEN state indefinitely. And the recorder's scope — which resource types it watches — must include every resource type your rules evaluate. Both mistakes are silent: Config will not warn you, and rules will appear to exist without producing any findings. Check first:

# Verify recorder is RUNNING and see its resource type scope
aws configservice describe-configuration-recorder-status \
  --query "ConfigurationRecordersStatus[*].{Name:name,Recording:recording,LastStatus:lastStatus}"

# See which resource types the recorder is actually capturing
aws configservice describe-configuration-recorders \
  --query "ConfigurationRecorders[*].recordingGroup"

Pattern 1: The detective-preventive-remediation control stack

Config rules are detective — they evaluate resources after they exist. But a well-designed compliance architecture layers three control types in sequence: prevention, detection, and remediation. Teams often deploy Config rules without the other two layers, which means non-compliant resources exist long enough to matter (no prevention) and remain non-compliant until someone manually intervenes (no remediation).

The correct layering for MCP server compliance:

Layer Control type AWS mechanism When it fires MCP example
1 — Prevent Preventive Service Control Policy (SCP) At API call time — denies the operation before it happens Deny dynamodb:CreateTable unless SSESpecification.SSEType=KMS
2 — Detect Detective Config rules (managed or custom) Within minutes of resource creation or change Flag any DynamoDB table not using a customer-managed KMS key
3 — Remediate Corrective SSM Automation triggered by Config NON_COMPLIANT Immediately on NON_COMPLIANT finding (auto) or on operator request (manual) Enable encryption, add missing tags, create VPC flow log

The SCP prevents a non-compliant resource from ever being created in accounts under your OU. But SCPs are not always available — sandbox accounts are often intentionally excluded from organizational SCPs, and legacy accounts may predate your Organization's SCP structure. Config rules handle those cases: they continuously re-evaluate existing resources, catching drift from resources that existed before the SCP was applied or in accounts the SCP doesn't reach.

The recorder scope trap

The most expensive Config mistake is leaving the recorder in its default mode: allSupported: true. This records configuration items for every supported AWS resource type in the region — hundreds of types, including high-frequency ones like EC2 instances (which generate items on every metadata change), Auto Scaling groups, and ELB listener rules. An MCP server account with active EC2 or ECS workloads can generate tens of thousands of configuration items per day this way, all landing in your S3 delivery bucket and billed at $0.003 per configuration item recorded after the free tier.

The fix is an explicit resourceTypes allowlist scoped to what your MCP infrastructure actually uses:

import * as cdk from "aws-cdk-lib";
import * as config from "aws-cdk-lib/aws-config";
import * as iam from "aws-cdk-lib/aws-iam";
import * as s3 from "aws-cdk-lib/aws-s3";

export class McpConfigStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string) {
    super(scope, id);

    const deliveryBucket = new s3.Bucket(this, "ConfigDelivery", {
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      lifecycleRules: [{ expiration: cdk.Duration.days(365) }],
    });

    const recorderRole = new iam.Role(this, "ConfigRecorderRole", {
      assumedBy: new iam.ServicePrincipal("config.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWS_ConfigRole"),
      ],
    });

    // Explicit allowlist — only record the types your MCP infra uses
    // Avoids billing for EC2 metadata changes, ELB listener rules, etc.
    new config.CfnConfigurationRecorder(this, "McpRecorder", {
      name: "mcp-config-recorder",
      roleArn: recorderRole.roleArn,
      recordingGroup: {
        allSupported: false,
        includeGlobalResourceTypes: false, // set true in exactly ONE region (us-east-1) for IAM
        resourceTypes: [
          "AWS::DynamoDB::Table",
          "AWS::Lambda::Function",
          "AWS::ECS::Cluster",
          "AWS::ECS::TaskDefinition",
          "AWS::ECS::Service",
          "AWS::EC2::VPC",
          "AWS::EC2::SecurityGroup",
          "AWS::EC2::FlowLog",
          "AWS::SecretsManager::Secret",
          "AWS::SSM::Parameter",
          "AWS::CloudTrail::Trail",
          "AWS::KMS::Key",
        ],
      },
    });

    new config.CfnDeliveryChannel(this, "DeliveryChannel", {
      s3BucketName: deliveryBucket.bucketName,
      configSnapshotDeliveryProperties: {
        deliveryFrequency: "Six_Hours",
      },
    });
  }
}

Note the IAM recording caveat: IAM is a global service. Config only records IAM resources (AWS::IAM::Role, AWS::IAM::Policy) when includeGlobalResourceTypes: true is set. If you have Config recorders in multiple regions, set includeGlobalResourceTypes: true in exactly one (us-east-1 is conventional since that's where IAM API calls land) and false in all others. Deploy IAM-specific Config rules only in us-east-1. Recording IAM in every region multiplies your storage costs without adding signal — every IAM role change appears once per region.

Bundling the stack into a conformance pack

A conformance pack is a CloudFormation template that bundles AWS::Config::ConfigRule and AWS::Config::RemediationConfiguration resources. You deploy the pack once to get all your rules and remediation wiring together, rather than creating each rule and remediation configuration separately. The template constraint is important: packs can only contain Config rule and remediation configuration resources — no IAM roles, no Lambda functions, no S3 buckets. Those prerequisites must exist in the account before the pack deploys.

# conformance-pack.yaml — upload to S3, then deploy with put-conformance-pack
# All prerequisite resources (Lambda fns, IAM roles, S3 buckets) must pre-exist

Parameters:
  RemediationRoleArn:
    Type: String
    Description: "IAM role ARN that SSM Automation assumes for remediation actions"
  FlowLogsDestinationBucket:
    Type: String
    Description: "S3 bucket ARN for VPC flow log delivery"

Resources:

  DynamoDbEncryptionRule:
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: mcp-pack-dynamodb-encryption
      Source:
        Owner: AWS
        SourceIdentifier: DYNAMODB_TABLE_ENCRYPTION_ENABLED
      Scope:
        ComplianceResourceTypes:
          - AWS::DynamoDB::Table

  VpcFlowLogsRule:
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: mcp-pack-vpc-flow-logs
      Source:
        Owner: AWS
        SourceIdentifier: VPC_FLOW_LOGS_ENABLED
      InputParameters:
        trafficType: ALL
      Scope:
        ComplianceResourceTypes:
          - AWS::EC2::VPC

  RequiredTagsRule:
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: mcp-pack-required-tags
      Source:
        Owner: AWS
        SourceIdentifier: REQUIRED_TAGS
      InputParameters:
        tag1Key: Environment
        tag1Value: prod,staging,dev
        tag2Key: Service
        tag3Key: Owner

  # Auto-remediation: enable VPC flow logs (low-risk — creates new resource, doesn't modify existing)
  VpcFlowLogsRemediation:
    Type: AWS::Config::RemediationConfiguration
    Properties:
      ConfigRuleName: !Ref VpcFlowLogsRule
      TargetType: SSM_DOCUMENT
      TargetId: AWSConfigRemediation-CreateVPCFlowLogToS3Bucket
      Automatic: true
      MaximumAutomaticAttempts: 3
      RetryAttemptSeconds: 60
      ExecutionControls:
        SsmControls:
          ConcurrentExecutionRatePercentage: 25
          ErrorPercentage: 20
      Parameters:
        VpcId:
          ResourceValue:
            Value: RESOURCE_ID
        S3BucketArn:
          StaticValue:
            Values:
              - !Ref FlowLogsDestinationBucket
        AutomationAssumeRole:
          StaticValue:
            Values:
              - !Ref RemediationRoleArn

  # Manual remediation: tags need human review before being corrected
  RequiredTagsRemediation:
    Type: AWS::Config::RemediationConfiguration
    Properties:
      ConfigRuleName: !Ref RequiredTagsRule
      TargetType: SSM_DOCUMENT
      TargetId: AWS-AddTagsToResource
      Automatic: false  # operator must click Remediate in the console
      MaximumAutomaticAttempts: 1
      RetryAttemptSeconds: 60
      Parameters:
        ResourceARN:
          ResourceValue:
            Value: RESOURCE_ID
        Tags:
          StaticValue:
            Values:
              - '{"Environment":"requires-tagging","Service":"unknown","Owner":"ops@company.com"}'
        AutomationAssumeRole:
          StaticValue:
            Values:
              - !Ref RemediationRoleArn

For the remediation role, the key principle is least-privilege write access: the remediation role needs write permissions only for the specific actions each SSM document takes. Separate it from the Config recorder role (which needs only read). A common mistake is giving the remediation role AdministratorAccess "to keep things simple" — this turns every auto-remediation into a potential blast radius amplifier if the SSM document logic has a bug.

To deploy organization-wide, use put-organization-conformance-pack from your management or delegated Config admin account. Every member account gets its own copy of the pack's rules and remediations, deployed automatically as member accounts are added to your Organization. Accounts with Config disabled will show CREATE_FAILED for the pack — that's a separate problem to fix, but it doesn't block the pack from deploying in other accounts.

Auto vs manual remediation — the decision matrix

Not everything should auto-remediate. The remediation guide has the full decision table, but the headline rule is: auto-remediate only actions that create a new resource or add metadata — never auto-remediate actions that modify existing resource behavior or could affect running workloads.

Remediation action Auto or manual Why
Add missing required tags Auto (after review of tag values) Tag changes don't affect data plane; flag resources without silently leaving them untagged
Enable VPC flow logs Auto Creating a new flow log resource doesn't affect network traffic
Enable SSE-KMS on existing DynamoDB table Manual Table becomes temporarily unavailable during the encryption operation — requires maintenance window
Remove hardcoded secrets from Lambda env vars Manual Changes Lambda function config, causes function restart; needs deployment pipeline coordination
Revoke port 22 open to 0.0.0.0/0 in security group Manual (with alert) Revoking SSH access can break maintenance workflows; needs human to verify intent
Enable CloudTrail trail encryption Manual Deleting and recreating a trail creates an audit gap; never auto-remediate audit infrastructure

Set MaximumAutomaticAttempts to 3-5 for any auto-remediation. Without this cap, a resource that keeps drifting (because the root cause isn't addressed — a developer's IaC template keeps recreating it without the required configuration) will trigger remediation in a tight loop. Add an alert when any resource requires more than two remediation attempts: that signal indicates a persistent misconfiguration upstream, not a one-time drift.

Pattern 2: Lambda evaluator authoring — four traps every team hits

Managed rules cover a large percentage of compliance use cases, but MCP-specific requirements often fall outside what AWS ships. Does every Lambda function used as an MCP tool have a reserved concurrency limit? Do ECS task definitions avoid raw secret strings in environment variables? Does every DynamoDB table use a customer-managed KMS key rather than the default AWS-managed key? These require custom Lambda evaluators. And custom evaluators come with four traps that are easy to fall into, all of which produce silent failures rather than visible errors.

Trap 1: the double JSON.parse

When a CONFIGURATION_CHANGE rule fires your Lambda, the event structure is nested in a way that surprises almost everyone. The event has an invokingEvent field that is a JSON string. You parse it to get an object. That object has a configurationItem field. The configurationItem has a configuration field that is itself another JSON string — the serialized AWS resource configuration snapshot. You must parse both layers to reach the actual resource properties.

import {
  ConfigServiceClient,
  PutEvaluationsCommand,
  ComplianceType,
} from "@aws-sdk/client-config-service";

const configClient = new ConfigServiceClient({});

interface ConfigEvent {
  invokingEvent: string;   // JSON string — layer 1
  resultToken: string;     // MUST be passed back to putEvaluations
  eventLeftScope: boolean; // true if resource was deleted or moved out of scope
}

interface ConfigurationItem {
  resourceType: string;
  resourceId: string;
  configuration: string;                // ANOTHER JSON string — layer 2
  configurationItemCaptureTime: string;
  configurationItemStatus: "OK" | "ResourceDeleted" | "ResourceNotRecorded";
}

export async function handler(event: ConfigEvent): Promise<void> {
  // Layer 1: parse invokingEvent
  const invokingEvent = JSON.parse(event.invokingEvent) as {
    configurationItem?: ConfigurationItem;
    messageType: string;
  };

  // ScheduledNotification = PERIODIC trigger — no configurationItem
  if (invokingEvent.messageType === "ScheduledNotification") return;

  const item = invokingEvent.configurationItem!;

  // Always handle deleted resources — returning nothing leaves stale state in Config
  if (item.configurationItemStatus === "ResourceDeleted") {
    await putEvaluation(event.resultToken, item, ComplianceType.NOT_APPLICABLE,
      "Resource deleted — no compliance evaluation");
    return;
  }

  // Layer 2: parse configuration (the actual DynamoDB snapshot)
  const tableConfig = JSON.parse(item.configuration) as {
    sseDescription?: {
      status: string;
      sseType?: "AES256" | "KMS";
      kmsMasterKeyArn?: string;
    };
  };

  const sse = tableConfig.sseDescription;

  if (!sse || sse.status !== "ENABLED" || sse.sseType !== "KMS") {
    await putEvaluation(event.resultToken, item, ComplianceType.NON_COMPLIANT,
      "DynamoDB table not using SSE-KMS encryption");
    return;
  }

  if (!sse.kmsMasterKeyArn || sse.kmsMasterKeyArn.includes("alias/aws/dynamodb")) {
    await putEvaluation(event.resultToken, item, ComplianceType.NON_COMPLIANT,
      "DynamoDB table uses AWS-managed key — customer-managed CMK required");
    return;
  }

  await putEvaluation(event.resultToken, item, ComplianceType.COMPLIANT,
    `SSE-KMS with CMK: ${sse.kmsMasterKeyArn}`);
}

async function putEvaluation(
  resultToken: string,
  item: ConfigurationItem,
  complianceType: ComplianceType,
  annotation: string
): Promise<void> {
  await configClient.send(new PutEvaluationsCommand({
    ResultToken: resultToken, // Trap 2 — see below
    Evaluations: [{
      ComplianceResourceType: item.resourceType,
      ComplianceResourceId: item.resourceId,
      ComplianceType: complianceType,
      Annotation: annotation.slice(0, 256), // max 256 chars
      OrderingTimestamp: new Date(item.configurationItemCaptureTime), // Trap 4 — see below
    }],
  }));
}

Trap 2: the ResultToken requirement

Every call to putEvaluations must include ResultToken set to the exact value from event.resultToken. Config uses this token to match your evaluation results to the specific rule invocation that produced them. If you omit it, or pass a hardcoded string, or pass a modified version, the evaluations do not appear in the Config console. No error is thrown. The Lambda succeeds, the evaluations are silently discarded, and the resource retains whatever compliance status it had from its last valid evaluation. This is the most common cause of "custom rule runs but doesn't show any compliance results."

Trap 3: PERIODIC rule batching and the 100-evaluation limit

A PERIODIC rule fires on a schedule and receives no resource data — your Lambda must enumerate the resources itself using AWS APIs. The Lambda then publishes all its compliance evaluations by calling putEvaluations. The catch: putEvaluations accepts a maximum of 100 evaluations per call. An account with 500 DynamoDB tables means 5 calls minimum. An account with thousands of ECS task definition ARNs (each revision is a separate ARN) can mean dozens of calls, and your Lambda must page through ListTaskDefinitions to enumerate them all.

import {
  ECSClient,
  paginateListTaskDefinitions,
  DescribeTaskDefinitionCommand,
} from "@aws-sdk/client-ecs";
import { ConfigServiceClient, PutEvaluationsCommand, ComplianceType } from "@aws-sdk/client-config-service";

const ecsClient = new ECSClient({});
const configClient = new ConfigServiceClient({});

const SECRET_PATTERNS = [
  /^(AKIA|ASIA|AROA)[A-Z0-9]{16}$/,  // AWS access key ID
  /^sk-[a-zA-Z0-9]{32,}/,             // OpenAI API key
  /^ghp_[a-zA-Z0-9]{36}$/,           // GitHub PAT
  /password|secret|api.?key|token/i,  // suspicious env var name
];

export async function handler(event: { resultToken: string }): Promise<void> {
  const evaluations: Parameters<typeof PutEvaluationsCommand>[0]["Evaluations"] = [];

  // paginateListTaskDefinitions handles pagination automatically
  for await (const page of paginateListTaskDefinitions(
    { client: ecsClient },
    { status: "ACTIVE" }
  )) {
    for (const arn of page.taskDefinitionArns ?? []) {
      const { taskDefinition } = await ecsClient.send(
        new DescribeTaskDefinitionCommand({ taskDefinition: arn })
      );
      if (!taskDefinition) continue;

      const exposedSecrets: string[] = [];
      for (const container of taskDefinition.containerDefinitions ?? []) {
        for (const env of container.environment ?? []) {
          if (SECRET_PATTERNS.some(p => p.test(env.name ?? ""))) {
            exposedSecrets.push(`${container.name}:${env.name} (suspicious name)`);
          }
          if (env.value && SECRET_PATTERNS.some(p => p.test(env.value!))) {
            exposedSecrets.push(`${container.name}:${env.name} (suspicious value)`);
          }
        }
      }

      evaluations.push({
        ComplianceResourceType: "AWS::ECS::TaskDefinition",
        ComplianceResourceId: arn,
        ComplianceType: exposedSecrets.length > 0 ? ComplianceType.NON_COMPLIANT : ComplianceType.COMPLIANT,
        Annotation: (exposedSecrets.length > 0
          ? `Possible raw secrets: ${exposedSecrets.slice(0, 3).join(", ")}`
          : "No raw secrets detected").slice(0, 256),
        OrderingTimestamp: new Date(), // PERIODIC rules: use current time, not capture time
      });

      // Flush in batches of 100 — API limit
      if (evaluations.length === 100) {
        await configClient.send(new PutEvaluationsCommand({
          ResultToken: event.resultToken,
          Evaluations: evaluations.splice(0, 100),
        }));
      }
    }
  }

  // Flush remaining evaluations
  if (evaluations.length > 0) {
    await configClient.send(new PutEvaluationsCommand({
      ResultToken: event.resultToken,
      Evaluations: evaluations,
    }));
  }
}

Set your PERIODIC Lambda's timeout to 10-15 minutes for accounts with large numbers of resources. The enumeration + individual DescribeTaskDefinition calls add up. Use the errorPercentage execution control on the remediation side to halt batch processing if too many invocations fail — throttling cascades fast when you're making hundreds of API calls in a tight loop.

Trap 4: OrderingTimestamp semantics differ by trigger type

Every evaluation result needs an OrderingTimestamp. The correct value depends on how the rule triggered. For CONFIGURATION_CHANGE rules, use configurationItemCaptureTime from the configuration item — this tells Config "the resource had this configuration at this time." For PERIODIC rules, use new Date() — the evaluation represents the resource's current state as of right now, not when it last changed. Using capture time for a PERIODIC rule can produce confusing results: the timestamp shows a time in the past (when the resource was last modified), but the evaluation was run now with potentially different data.

Deploying custom rules with CDK

import * as config from "aws-cdk-lib/aws-config";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as iam from "aws-cdk-lib/aws-iam";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import * as cdk from "aws-cdk-lib";

// CONFIGURATION_CHANGE rule — fires on every DynamoDB table change
const dynamoEvaluator = new NodejsFunction(this, "DynamoDbCmkEvaluator", {
  entry: "src/config-rules/dynamodb-cmk-evaluator.ts",
  handler: "handler",
  runtime: lambda.Runtime.NODEJS_22_X,
  timeout: cdk.Duration.minutes(5),
  memorySize: 256,
});

new config.CustomRule(this, "DynamoDbCmkRule", {
  lambdaFunction: dynamoEvaluator,
  configRuleName: "mcp-dynamodb-cmk-required",
  description: "Every DynamoDB table must use a customer-managed KMS key",
  configurationChanges: true,
  periodic: false,
  ruleScope: config.RuleScope.fromResources([config.ResourceType.DYNAMODB_TABLE]),
});

// PERIODIC rule — fires every 24h, enumerates all ECS task definitions
const ecsScanner = new NodejsFunction(this, "EcsSecretScanner", {
  entry: "src/config-rules/ecs-secret-evaluator.ts",
  handler: "handler",
  runtime: lambda.Runtime.NODEJS_22_X,
  timeout: cdk.Duration.minutes(15), // needs time to enumerate all task definitions
  memorySize: 512,
});

ecsScanner.addToRolePolicy(new iam.PolicyStatement({
  actions: ["ecs:ListTaskDefinitions", "ecs:DescribeTaskDefinition"],
  resources: ["*"],
}));

new config.CustomRule(this, "EcsSecretScanRule", {
  lambdaFunction: ecsScanner,
  configRuleName: "mcp-ecs-no-raw-secrets",
  configurationChanges: false,
  periodic: true,
  maximumExecutionFrequency: config.MaximumExecutionFrequency.TWENTY_FOUR_HOURS,
});
// CDK CustomRule automatically grants the Lambda config:PutEvaluations permission

Pattern 3: Aggregator as compliance query plane

Once Config rules are running in multiple accounts, the natural next question is "give me a cross-account view of compliance." The Config aggregator is the answer — but it works differently from what most teams expect, and understanding that difference determines whether it's useful or misleading.

What the aggregator is and is not

The aggregator is a read plane. It collects Config data (configuration items and compliance evaluations) from member accounts and makes it queryable from a single account. It does not push rules to member accounts, does not trigger remediations, and does not deploy conformance packs. It only reads. This distinction is critical: teams sometimes expect that creating a rule in the aggregator account will evaluate resources in member accounts. It won't. Rules must be deployed to each account — either by the account owner's IaC pipeline or via organizational conformance packs.

The organization aggregator is the right choice for MCP server fleets. It automatically includes all current and future member accounts in your AWS Organization, handles authorization without per-account put-aggregation-authorization calls, and picks up new accounts as they join. Deploy it in your security tooling account (registered as delegated Config admin) rather than the root management account:

# Register delegated Config admin — run from management account
aws organizations register-delegated-administrator \
  --account-id 999888777666 \   # your security/compliance tooling account
  --service-principal config.amazonaws.com

# From the delegated admin account, create the org aggregator
aws configservice put-configuration-aggregator \
  --configuration-aggregator-name mcp-org-aggregator \
  --organization-aggregation-source \
    '{"RoleArn":"arn:aws:iam::999888777666:role/aws-service-role/config.amazonaws.com/AWSServiceRoleForConfig","AllAwsRegions":true}' \
  --region us-east-1

Config advanced queries — SQL against the aggregated snapshot

Config advanced queries are the aggregator's most powerful feature. They let you run SQL-like queries against the aggregated configuration snapshot — the set of configuration items across all accounts. The SelectAggregateResourceConfig API accepts a query expression in a subset of SQL (no joins, no subqueries, but SELECT/WHERE/GROUP BY/ORDER BY work) and returns JSON results.

The critical distinction: advanced queries operate on resource configuration properties, not compliance status. You can query "show me every DynamoDB table where sseDescription.sseType is not KMS," but you cannot filter by "NON_COMPLIANT resources for rule X" using the advanced query syntax. For compliance status, use GetAggregateComplianceDetailsByConfigRule. For resource property queries, use advanced queries.

import {
  ConfigServiceClient,
  SelectAggregateResourceConfigCommand,
  GetAggregateComplianceDetailsByConfigRuleCommand,
} from "@aws-sdk/client-config-service";

const client = new ConfigServiceClient({ region: "us-east-1" });
const AGGREGATOR = "mcp-org-aggregator";

// Resource property query: DynamoDB tables without CMK encryption
// Uses advanced query SQL — operates on configuration snapshot
async function getUnencryptedDynamoDbTables() {
  const results: unknown[] = [];
  let nextToken: string | undefined;

  do {
    const response = await client.send(
      new SelectAggregateResourceConfigCommand({
        ConfigurationAggregatorName: AGGREGATOR,
        Expression: `
          SELECT
            accountId,
            awsRegion,
            resourceId,
            resourceName,
            configuration.sseDescription.sseType,
            configuration.sseDescription.kmsMasterKeyArn,
            tags
          WHERE
            resourceType = 'AWS::DynamoDB::Table'
            AND (
              configuration.sseDescription.sseType <> 'KMS'
              OR configuration.sseDescription IS NULL
            )
          ORDER BY accountId, awsRegion, resourceName
        `,
        MaxResults: 100,
        NextToken: nextToken,
      })
    );
    results.push(...(response.Results ?? []).map(r => JSON.parse(r)));
    nextToken = response.NextToken;
  } while (nextToken);

  return results;
}

// Compliance status query: non-compliant resources for a specific rule, across all accounts
// Uses GetAggregateComplianceDetailsByConfigRule — NOT advanced queries
async function getNonCompliantForRule(ruleName: string) {
  const results: unknown[] = [];
  let nextToken: string | undefined;

  do {
    const response = await client.send(
      new GetAggregateComplianceDetailsByConfigRuleCommand({
        ConfigurationAggregatorName: AGGREGATOR,
        ConfigRuleName: ruleName,
        ComplianceType: "NON_COMPLIANT",
        MaxResults: 100,
        NextToken: nextToken,
      })
    );
    results.push(...(response.AggregateEvaluationResults ?? []));
    nextToken = response.NextToken;
  } while (nextToken);

  return results;
}

// Resource missing required tags — SQL query approach
async function getUntaggedMcpResources() {
  const response = await client.send(
    new SelectAggregateResourceConfigCommand({
      ConfigurationAggregatorName: AGGREGATOR,
      Expression: `
        SELECT
          accountId,
          awsRegion,
          resourceType,
          resourceName,
          tags
        WHERE
          (
            resourceType = 'AWS::DynamoDB::Table'
            OR resourceType = 'AWS::Lambda::Function'
            OR resourceType = 'AWS::ECS::TaskDefinition'
          )
          AND tags IS NULL
        ORDER BY accountId, resourceType
      `,
      MaxResults: 500,
    })
  );
  return (response.Results ?? []).map(r => JSON.parse(r));
}

The snapshot lag — when to use the aggregator vs direct account queries

The aggregator's configuration data is a snapshot — it lags behind actual resource state by up to the delivery frequency you configured (typically 3-6 hours). Compliance evaluation results in the aggregator lag similarly. This makes the aggregator well-suited for two use cases and poorly suited for a third:

Use case Aggregator (snapshot) or direct? Why
Weekly compliance report: how many DynamoDB tables are unencrypted? Aggregator Trend data; snapshot lag doesn't matter for periodic reporting
Cross-account resource property survey: which accounts have tables without CMK? Aggregator Snapshot is accurate enough for a point-in-time survey; no need to log into 20 accounts
Real-time alerting: did this specific resource just become NON_COMPLIANT? Direct account query Snapshot lag means the aggregator may show old status; query the account directly with get-compliance-details-by-config-rule

Org conformance pack + org aggregator: the complete "deploy everywhere, view from one place" setup

The fully integrated setup uses organizational conformance packs to push rules and remediation to every member account, and the organizational aggregator to collect all compliance data back into a single view. Both run from the delegated Config admin account. This is the pattern that gives you:

# Deploy rules to all accounts (organizational conformance pack)
aws configservice put-organization-conformance-pack \
  --organization-conformance-pack-name mcp-compliance-baseline \
  --template-s3-uri s3://mcp-config-templates-999888777666/mcp-compliance-pack.yaml \
  --delivery-s3-bucket mcp-config-delivery \
  --excluded-accounts "111222333444"  # sandbox account — intentionally excluded

# Check deployment status across all member accounts
aws configservice describe-organization-conformance-pack-statuses \
  --organization-conformance-pack-names mcp-compliance-baseline \
  --query "OrganizationConformancePackStatuses[*].{Account:accountId,Status:status,Reason:errorMessage}"

# Query cross-account compliance via aggregator (run from delegated admin account)
aws configservice select-aggregate-resource-config \
  --configuration-aggregator-name mcp-org-aggregator \
  --expression "SELECT accountId, awsRegion, COUNT(*) as tableCount WHERE resourceType = 'AWS::DynamoDB::Table' GROUP BY accountId, awsRegion ORDER BY tableCount DESC"

Failure modes reference

Across all five Config primitives, these are the failure modes that take the most time to diagnose because they are all silent — no exception is thrown, no obvious error appears.

Symptom Root cause Fix
Rule stuck in NO_CONFIGURATION_ITEMS_SEEN Config recorder wasn't running when the rule was created, or the resource type isn't in the recorder's scope Enable recorder first, then create rules; add missing resource type to the recorder's resourceTypes list; run start-config-rules-evaluation to force initial evaluation
IAM rules show NO_CONFIGURATION_ITEMS_SEEN in all regions includeGlobalResourceTypes: false in all recorders — no region records IAM Set includeGlobalResourceTypes: true in the us-east-1 recorder only; deploy IAM rules only in us-east-1
Custom rule runs but evaluations never appear in Config console ResultToken omitted from putEvaluations call, or hardcoded instead of using event.resultToken Always pass the exact event.resultToken value — Config uses this to match evaluations to rule invocations
PERIODIC rule Lambda times out Enumerating thousands of resources with individual API calls; not batching putEvaluations in groups of 100 Batch evaluations at 100 per putEvaluations call; use SDK paginator utilities; set Lambda timeout to 15 min
Conformance pack stuck in CREATE_IN_PROGRESS for 30+ minutes Template references a custom Lambda that doesn't exist, or S3 delivery bucket missing Create prerequisites (Lambda, S3 bucket, IAM roles) before deploying the pack; check describe-conformance-pack-status for the error reason
Auto-remediation fires repeatedly for the same resource Fix doesn't resolve the compliance check, or resource keeps drifting back (upstream IaC template recreating it non-compliant) Cap with MaximumAutomaticAttempts: 3; alert on resources needing more than 2 remediation attempts — indicates upstream IaC misconfiguration
SSM Automation fails with AccessDenied Remediation role missing the specific write permission; or iam:PassRole missing for sub-roles used by the SSM document Check SSM execution step output for the denied IAM action; add least-privilege write permission to the remediation role
VPC_FLOW_LOGS_ENABLED shows COMPLIANT but logs not arriving in S3 Flow log resource is ACTIVE but S3 bucket policy doesn't allow the flow logs service principal — delivery silently fails Add delivery.logs.amazonaws.com as principal to S3 bucket policy; monitor bucket growth or CloudWatch delivery metrics
Aggregator shows no data for some accounts Config not enabled in those accounts — aggregator can only read what's there Enable Config recorder in every member account; accounts with Config disabled appear as gaps, not errors
Advanced query returns no results for known non-compliant resources Query using compliance-status fields (complianceType) which aren't in the advanced query schema — compliance data requires GetAggregateComplianceDetailsByConfigRule Use advanced queries for resource configuration properties only; use GetAggregateComplianceDetailsByConfigRule for compliance status

Connecting Config to the broader MCP observability stack

Config is the compliance detection layer, but it works best when connected to the other layers of your MCP server's observability and security stack. Config findings route to Security Hub automatically when the integration is enabled — each NON_COMPLIANT finding appears with ProductFields.RuleId set to the rule name, letting you build unified dashboards and alert routing across your entire security signal set. From Security Hub, route critical Config findings (root MFA disabled, unencrypted tables, missing flow logs) to Slack or PagerDuty via EventBridge.

For audit logging of the API calls themselves — not the resource compliance state, but which principals called which APIs and when — pair Config with CloudTrail data events. Config tells you "this DynamoDB table doesn't have CMK encryption." CloudTrail tells you "this IAM principal ran PutItem against that table at 14:32:07." The two are complementary: Config is the state detector, CloudTrail is the action recorder.

For secrets specifically — Config custom rules can detect that a Lambda function has suspicious environment variable names, but they can't detect that a Secrets Manager secret has been accessed unexpectedly or that a rotation has failed. Pair the Lambda environment variable scanner with Secrets Manager's own event signals for complete secrets hygiene coverage.

Finally, Config's compliance status is one input to your MCP server's observability posture. A server with NON_COMPLIANT DynamoDB tables is a server operating below its security baseline — that's a signal that belongs on your team's operational dashboard alongside latency percentiles and error rates, not hidden in the Config console where only compliance engineers look.

Summary: the three patterns in a sentence each