Guide · AWS SSM Parameter Store

MCP Server SSM Parameter Store Hierarchy — /mcp/{env}/{service}/ naming, GetParametersByPath, IAM path policies

SSM Parameter Store is the right tool for non-secret MCP server configuration: feature flags, endpoint URLs, timeout values, poll intervals, allowed tenant lists, and runtime knobs that need to be updated without a redeployment. Unlike Secrets Manager, Parameter Store has no built-in rotation and is priced per API call (no rotation Lambda cost), making it cheaper for high-read, low-sensitivity configuration. The hierarchy design — how you structure the /-delimited path — determines whether your IAM policies are concise or sprawling, whether GetParametersByPath can load an entire service's config in one paginated call, and whether a misconfigured staging environment can accidentally read production parameters. The canonical pattern for MCP servers is /mcp/{env}/{service}/{parameter-name}.

TL;DR

Structure SSM parameters as /mcp/{env}/{service}/{name} (e.g., /mcp/prod/probe-collector/poll-interval-ms). Use GetParametersByPath with Recursive=true and handle NextToken pagination (max 10 results per page). Write IAM policies scoped to /mcp/{env}/* paths — this prevents staging MCP roles from reading production parameters. For parameters that change across environments (base URLs, feature flags), use the same relative path under each /mcp/{env}/ prefix and load them all at startup via a single GetParametersByPath call.

Hierarchy design: /mcp/{env}/{service}/{name}

A flat namespace (all parameters at the root) makes IAM scoping impossible — you can't grant read access to staging parameters without also granting production access. A hierarchy that starts with /mcp/{env}/ allows environment-scoped IAM policies and bulk-loading all parameters for a given environment in one API call.

# Recommended hierarchy for an MCP server deployment
# Pattern: /mcp/{env}/{service}/{parameter-name}

# Probe collector service — prod
/mcp/prod/probe-collector/poll-interval-ms          = "60000"
/mcp/prod/probe-collector/max-concurrent-probes     = "50"
/mcp/prod/probe-collector/timeout-ms                = "5000"
/mcp/prod/probe-collector/registry-url              = "https://mcp.so/api/v1/servers"
/mcp/prod/probe-collector/retry-count               = "3"

# Same parameters for staging — identical relative paths, different env
/mcp/staging/probe-collector/poll-interval-ms       = "30000"   # faster in staging
/mcp/staging/probe-collector/max-concurrent-probes  = "5"       # lower in staging
/mcp/staging/probe-collector/timeout-ms             = "10000"
/mcp/staging/probe-collector/registry-url           = "https://mcp.so/api/v1/servers"
/mcp/staging/probe-collector/retry-count            = "2"

# Alert router service — prod
/mcp/prod/alert-router/webhook-timeout-ms           = "3000"
/mcp/prod/alert-router/max-retry-attempts           = "5"
/mcp/prod/alert-router/dead-letter-queue-url        = "https://sqs.us-east-1.amazonaws.com/..."

# Shared parameters (not env-specific) — use /mcp/shared/ prefix
/mcp/shared/monitoring/datadog-site                 = "datadoghq.com"
/mcp/shared/limits/max-tools-per-server             = "100"

The /mcp/shared/ prefix is for parameters that are truly environment-agnostic. Keep this prefix small — almost everything that looks environment-agnostic today will need an environment-specific override in six months. When in doubt, put it under /mcp/{env}/.

GetParametersByPath with pagination

GetParametersByPath returns at most 10 parameters per page and always requires pagination handling. Omitting the NextToken loop silently truncates the parameter set — a production bug that is hard to detect because the service starts with partial config and fails in non-obvious ways.

// Load all parameters for a service at startup — with correct pagination
import { SSMClient, GetParametersByPathCommand } from "@aws-sdk/client-ssm";

const ssm = new SSMClient({ region: process.env.AWS_REGION });

async function loadConfig(env, service) {
  const path = `/mcp/${env}/${service}/`;
  const config = {};
  let nextToken = undefined;

  do {
    const resp = await ssm.send(new GetParametersByPathCommand({
      Path: path,
      Recursive: true,        // include sub-paths below /mcp/{env}/{service}/
      WithDecryption: true,   // decrypt SecureString parameters automatically
      MaxResults: 10,         // API max; explicit for clarity
      NextToken: nextToken,
    }));

    for (const param of resp.Parameters ?? []) {
      // Strip the path prefix to get the bare parameter name
      const name = param.Name.replace(path, "");
      config[name] = param.Value;
    }

    nextToken = resp.NextToken;
  } while (nextToken);

  return config;
}

// Usage at MCP server startup
const env = process.env.DEPLOYMENT_ENV ?? "prod"; // "prod" | "staging" | "dev"
const config = await loadConfig(env, "probe-collector");

// Access individual values with type conversion
const pollInterval = parseInt(config["poll-interval-ms"] ?? "60000", 10);
const maxProbes    = parseInt(config["max-concurrent-probes"] ?? "50", 10);
const registryUrl  = config["registry-url"];

// Validate required keys before starting (fail fast)
const REQUIRED = ["poll-interval-ms", "registry-url", "timeout-ms"];
const missing = REQUIRED.filter(k => !(k in config));
if (missing.length > 0) {
  throw new Error(`Missing required SSM parameters: ${missing.join(", ")}`);
}

Path-based IAM policies

IAM resource ARNs for SSM parameters include the path, enabling least-privilege policies scoped to a specific environment or service. A staging MCP role should not be able to read production parameters — and with path-based policies, it doesn't need to.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "MCPStagingReadConfig",
      "Effect": "Allow",
      "Action": [
        "ssm:GetParametersByPath",
        "ssm:GetParameter",
        "ssm:GetParameters"
      ],
      "Resource": [
        "arn:aws:ssm:us-east-1:ACCOUNT_ID:parameter/mcp/staging/*"
      ]
    },
    {
      "Sid": "MCPStagingSharedRead",
      "Effect": "Allow",
      "Action": ["ssm:GetParametersByPath", "ssm:GetParameter"],
      "Resource": [
        "arn:aws:ssm:us-east-1:ACCOUNT_ID:parameter/mcp/shared/*"
      ]
    }
  ]
}

# Production role — separate policy, separate role, no staging access
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "MCPProdReadConfig",
      "Effect": "Allow",
      "Action": [
        "ssm:GetParametersByPath",
        "ssm:GetParameter",
        "ssm:GetParameters"
      ],
      "Resource": [
        "arn:aws:ssm:us-east-1:ACCOUNT_ID:parameter/mcp/prod/*",
        "arn:aws:ssm:us-east-1:ACCOUNT_ID:parameter/mcp/shared/*"
      ]
    }
  ]
}

If SecureString parameters are mixed in the hierarchy (e.g., /mcp/prod/probe-collector/db-password as SecureString), add kms:Decrypt on the KMS key ARN used to encrypt those parameters. The same GetParametersByPath call retrieves both String and SecureString parameters when WithDecryption: true is set — the KMS call is made server-side by SSM on the caller's behalf.

Caching and change propagation

MCP servers should cache SSM parameters at startup and reload periodically or on a SIGHUP signal — not on every tool call. A cache TTL of 5 minutes is typical for feature flags; 60 minutes for stable config like endpoint URLs. AWS Parameter Store has no push notification mechanism (unlike AppConfig), so polling or restart-based reload is required for dynamic config updates.

// Config cache with background refresh
class ConfigCache {
  constructor(env, service, ttlMs = 5 * 60 * 1000) {
    this.env = env;
    this.service = service;
    this.ttlMs = ttlMs;
    this.cache = null;
    this.lastFetched = 0;
    this.refreshPromise = null;
  }

  async get(key, defaultValue) {
    await this._ensureFresh();
    return this.cache?.[key] ?? defaultValue;
  }

  async _ensureFresh() {
    if (Date.now() - this.lastFetched < this.ttlMs && this.cache) return;
    // Prevent concurrent refreshes
    if (!this.refreshPromise) {
      this.refreshPromise = this._refresh().finally(() => {
        this.refreshPromise = null;
      });
    }
    await this.refreshPromise;
  }

  async _refresh() {
    this.cache = await loadConfig(this.env, this.service);
    this.lastFetched = Date.now();
  }
}

const configCache = new ConfigCache(env, "probe-collector");

// In MCP tool handler:
const pollInterval = parseInt(await configCache.get("poll-interval-ms", "60000"), 10);

Failure modes reference

FailureSymptomFix
Pagination loop omittedMCP server loads only 10 parameters on startup; missing config causes subtle failures at runtimeAlways loop on NextToken until it is undefined; log the total parameter count at startup for validation
Recursive=false on nested pathsGetParametersByPath returns empty list for /mcp/prod/service/ if parameters are at /mcp/prod/service/subgroup/nameSet Recursive=true unless your hierarchy is guaranteed flat at exactly one level below the path
IAM policy uses parameter name instead of path ARNAccessDeniedException for GetParametersByPath even though GetParameter on individual parameters worksGetParametersByPath requires ARN with wildcard path suffix (arn:aws:ssm:...:parameter/mcp/prod/*); GetParameter requires ARN with exact name
WithDecryption=false for SecureString parametersSecureString values returned as encrypted ciphertext; config loading fails on type conversionSet WithDecryption=true; ensure IAM role has kms:Decrypt on the CMK used for SecureString parameters
No DEPLOYMENT_ENV variable setMCP server loads staging config in production or vice versaFail fast at startup if DEPLOYMENT_ENV is missing or not in allowed set; do not fall back silently to a default environment