The AWS API MCP server lets an agent run AWS CLI commands through a single tool, and it ships its own policy file so you can block or gate specific commands. Twice this year its changelog has recorded a bug in that policy layer. In version 1.3.47, released in July 2026, the changelog says that if the index of read-only operations failed to load at startup, "the server continued running with the configured security policy (denylist / elicitList) silently skipped for the entire process lifetime, allowing denylisted operations to execute unchecked." In 1.5.1, released in August, the entry is one line: "Operation name matching in security policy (#4432)."
This is a case study of public project history. We read the changelog, the README and the policy source on the project's main branch today, November 11, 2026, and checked release dates against PyPI. We did not run the server, and we could not open NVD or an AWS bulletin from our environment, so we cite no CVE ID. Aggregator sites list one for the 1.5.1 fix; treat that as unconfirmed until you see it in an advisory from AWS, GitHub or NVD.
We picked this server because its authors wrote the layering down clearly, which makes it a good specimen for a question every team adopting MCP servers has to answer: what happens on the day the server's own guardrail is wrong?
What the policy layer is
The README describes a file at ~/.aws/aws-api-mcp/mcp-security-policy.json with two lists. Commands in denyList are blocked. Commands in elicitList require the user to confirm through MCP elicitation, which needs a client that supports it. Entries use the form aws <service> <operation> in kebab case, such as aws iam delete-user, and the README states that matching is exact string matching only, with no wildcards.
The same README is explicit about where this sits. It says IAM permissions "remain the primary security control mechanism" and that the policy file "cannot override IAM restrictions." Its disclaimer tells users they must use IAM to manage access to AWS resources. The authors are describing a second layer and telling you so. The bugs in the changelog are what happens to a second layer when it is treated as the first.
Failure mode one: the guardrail that switches itself off
The 1.3.47 entry describes a classic fail-open. A transient network error while loading startup data meant the server kept running with no policy enforcement at all, for as long as the process lived. A denylist that silently turns into an empty list gives you no signal. Agents run for days in some setups, so one bad startup can outlast many sessions.
The fix, per the changelog, is to refuse to start when that data cannot be loaded and to deny requests at execution time if the index is unavailable. That is the correct shape for a guardrail. It still leaves a question for your own deployment: do you know what your server does when its policy input is broken?
We read the current policy.py to see how the policy file itself is handled. If the file does not exist, the code logs a warning that no additional security policy is being applied and continues. If the file exists but fails to parse, the code logs an error and continues with empty lists. Both paths are reasonable defaults for a developer laptop. On a shared or production host, a typo in a JSON file silently produces a server with no denylist. We read this in source and did not run it, so verify against the version you deploy.
One more behavior from the same file is worth knowing. When an entry is in elicitList and the connected client does not support elicitation, the code treats the entry as a deny. That fails closed, which is good, but your agent will see refusals for commands you intended to allow with confirmation. Test it with the client you actually use.
Failure mode two: exact-match names
The 1.5.1 entry gives no detail beyond the title and a pull request number, so we will not guess at the mechanism. What the code shows is why this class of bug exists. Before lookup, the policy converts the operation name with botocore's xform_name, swaps underscores for hyphens, builds the string aws <service> <operation>, and checks set membership against your entries. Everything depends on two strings being byte-identical after a normalization step you did not write and cannot see.
High-level commands add a second path. For something like aws s3 cp, the code looks up a static map of customizations that lists the underlying API calls, then checks each of those against the denylist and elicit list. The Unreleased section of the changelog lists "Missing api calls for s3 customizations in static map (#4548)." That is our reading of the code and the changelog line, and AWS has not said it. We read it to mean that a command missing from that map is one the denylist cannot see by name. Exact-match policies are only as complete as the vocabulary they are matched against.
None of this is a criticism of the AWS team. Writing a policy engine that maps CLI syntax onto API operations across hundreds of services is hard, and the changelog shows them finding and fixing gaps in public. The point for an adopter is narrower: an exact-match list inside the server will have gaps over time, and you should decide in advance what catches the gap.
What we would put around it
The README already tells you the answer, and the answer is IAM. For an agent that can call AWS, we would set it up in three layers, with the server's policy file in the third position.
First, the role the server runs under should only be able to do what you would be willing to see the agent do on its worst day. A role with ReadOnlyAccess is the README's own suggestion for inspection work. If the agent needs to mutate something, create a separate role scoped to that service and resource, and keep it out of the default configuration.
Second, add explicit denies for the destructive actions you care about, in an identity policy, a permissions boundary or a service control policy if you run AWS Organizations. An explicit deny in IAM wins over any allow, and it does not depend on how a command string was normalized. A starting point, which is our example and not from the source:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "AgentNeverDeletesIdentityOrState", "Effect": "Deny", "Action": [ "iam:DeleteUser", "iam:DeleteRole", "iam:DeletePolicy", "iam:DeleteAccessKey", "cloudformation:DeleteStack", "rds:DeleteDBInstance", "s3:DeleteBucket" ], "Resource": "*" } ] } Third, keep the server's policy file for what IAM cannot express, such as requiring a human to confirm a particular command, and treat it as defense in depth. Then make the file hard to break silently.
A pre-flight check for the policy file
The cheapest control against the fail-open class is a script that runs before the server starts, in the same service unit or container entrypoint, and refuses to continue if the policy is missing, unparseable, empty, or contains entries in the wrong format. This one is ours:
#!/usr/bin/env python3 import json, re, sys from pathlib import Path path = Path.home() / ".aws" / "aws-api-mcp" / "mcp-security-policy.json" entry = re.compile(r"^aws [a-z0-9-]+ [a-z0-9-]+$") try: policy = json.loads(path.read_text())["policy"] except Exception as e: sys.exit(f"policy file unusable: {e}") deny, elicit = policy.get("denyList", []), policy.get("elicitList", []) if not deny: sys.exit("denyList is empty") bad = [c for c in deny + elicit if not entry.match(c)] if bad: sys.exit(f"entries not in 'aws <service> <operation>' form: {bad}") print(f"ok: {len(deny)} denied, {len(elicit)} need consent") Run it as python3 preflight.py && exec your-mcp-launch-command. The regular expression matches the README's stated format, so an entry like iam:delete-* or aws iam DeleteUser fails loudly instead of being a line that never matches anything.
The second check belongs in a staging account. Write a short test that sends each denylisted command through the server with a role that would otherwise allow it, and asserts the call is refused. Run it on every upgrade of the server, because the policy matching code changed in at least two releases this year. A passing test on 1.5.0 says nothing about 1.6.0, which is the version on PyPI as of today.
Tradeoffs
Explicit IAM denies are blunt. They apply to every principal that carries the policy, so an engineer who legitimately needs rds:DeleteDBInstance in a sandbox account needs a scoped exception, usually through a condition on account or a tag. That friction is real and it is the right price for a control that does not rely on string matching.
Pinning the server version helps you control when behavior changes, and it also means you stay on a release with a known gap until you move. The changelog lists the 1.5.1 fix under a version you can pin to, so the practical rule is to read the changelog's Security and Fixed sections before every bump and rerun the staging test.
Elicitation is the weakest of the three layers for unattended agents. A consent prompt needs a client that supports it and a person who is watching, and the source treats an elicit entry as a deny when the client cannot ask. For jobs that run without a human, use the denylist and IAM.
Do this week
Find every MCP server your agents can reach that offers its own allow or deny configuration, and write down for each one what it does when that configuration is missing or malformed. For any server that holds cloud credentials, move the three or four actions you would least like to explain after an incident into an explicit IAM deny, and put a pre-flight check in front of the launch command. If you run the AWS API MCP server, check your installed version against the changelog, and confirm you are on 1.5.1 or later.