A reproducible benchmark comparing raw aws CLI against the official
awslabs.aws-api-mcp-server as tool transports for an LLM agent,
on five read-only tasks against a real AWS account. 250 runs total,
clean Anthropic API (no claude-agent-sdk context pollution), automated
verification against ground truth snapshotted from boto3.
Article with the full narrative and discussion: MCP vs CLI для AI-агентов: реальный бенчмарк на AWS (link will go live once the article is published).
- A well-engineered CLI tool (
cli-fullin this repo) beatsawslabs.aws-api-mcp-serverby 43–60% on input tokens on every one of the five tasks in the benchmark, at parity on success rate. - But getting there takes about half a day of engineering per service: rich tool description, batch input schema, runtime context in the system prompt, and a whitelist that covers the optimal commands instead of only the naive ones.
- The real question isn't "MCP or CLI?" but "do you have enough QPS
to justify that engineering work over the 30-second
uvxinstall of the MCP server?" - Three effects explain the whole gap: HTTP response metadata (the
MCP server passes AWS response headers back to the model, including
Date), batch calling (cli_commandaccepts a list), and a broader allowlist. None are properties of the MCP protocol itself.
Full numbers in docs/findings.md.
| Task | cli-full input | mcp input | Δ input | cli-full ok% | mcp ok% |
|---|---|---|---|---|---|
ec2_running |
3,053 | 5,368 | −43% | 90%* | 100% |
s3_bucket_policy |
2,975 | 5,425 | −45% | 100% | 100% |
s3_bucket_regions |
5,801 | 14,317 | −60% | 100% | 100% |
iam_admin_roles |
2,934 | 5,213 | −44% | 100% | 100% |
ec2_cpu_last_hour |
5,345 | 9,461 | −44% | 100% | 100% |
* single Anthropic API 529 Overloaded, not a transport failure.
Full summary (with p25/p75, wall clock, tool call counts) in
results/scrubbed/final_summary.json.
| ID | What it is |
|---|---|
cli |
Plain aws_cli tool: subprocess wrapper, ~500-char description. |
cli-ctx |
Same tool, but the system prompt injects current UTC time, default region, and identity ARN. Four lines. |
cli-v2 |
Rich tool description (~2,700 chars, mirrors awslabs structure), cli_command accepts string or list of strings, parallel execution via asyncio.gather, batch results joined with indexed headers. |
cli-full |
cli-v2 tool spec + cli-ctx system prompt. The complete "purpose-built CLI" recommended by the article. |
mcp |
Official awslabs.aws-api-mcp-server launched via uvx stdio; the mcp python library handles the handshake. READ_OPERATIONS_ONLY=true. |
| ID | Category | What it tests |
|---|---|---|
ec2_running |
simple | single call + filtering |
s3_bucket_policy |
edge | optional resource (NoSuchBucketPolicy) |
s3_bucket_regions |
chained | list + per-item lookup, batch-friendly |
iam_admin_roles |
filter | pagination + content filtering (ground truth: empty list) |
ec2_cpu_last_hour |
chained | composition, CloudWatch time windows |
Task prompts are verbatim in src/tasks.py. Ground
truth is fetched at benchmark time via boto3
(src/ground_truth.py), so account drift is
detected automatically.
- Model:
claude-sonnet-4-6via direct Anthropic API. - Agent loop: hand-rolled in
src/agent_loop.py, ~150 lines. Noclaude-agent-sdk, no Claude Code — both leak the parent~/.claude.jsoninto the context (figma, pencil, PubMed and friends). That would contaminate every measurement and did: we lost 1.5 days to this before rewriting. - CLI transport:
subprocess.run(['aws', ...])behind a strict whitelist insrc/safety.py. - MCP transport:
mcppython library spawnsawslabs.aws-api-mcp-serverviauvxstdio and drives the real MCP handshake; tool descriptions are read live from the server, not hardcoded. - n = 10 per
(task, transport)cell for the main series, plus ablation runs for the hypothesis investigation.
input_tokens,cache_creation_input_tokens,cache_read_input_tokensoutput_tokenstool_call_count, full tool call list with inputswall_clock_ms,num_turnsstop_reason,error(if any)- Verdict against ground truth (
ok: bool,reason: str)
See src/runner.py and src/aggregate.py.
Two layers of defence keep the agent from doing anything destructive:
- IAM
ReadOnlyAccesspolicy on a dedicated benchmark user (mcp-benchmark). Write operations fail at the AWS edge regardless of what the agent or the runner code does. This is the real guarantee. - Whitelist in
src/safety.py, covering a curated set ofservice + actionpairs. This is the second line of defence and also catches obvious errors before they reach AWS.
The whitelist caused one of the most interesting findings in the
benchmark: our early version blocked
iam list-entities-for-policy, which is the optimal command for the
iam_admin_roles task. The model tried it first on every run, got
rejected by our own code, and fell back to a naive 36-call loop. We
were measuring our whitelist, not MCP vs CLI. Article has the full
story; the fix is one line in safety.ALLOWED.
# 1. Clone and set up
git clone https://github.com/webmaster-ramos/mcp-vs-cli-aws-benchmark
cd mcp-vs-cli-aws-benchmark
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
# 2. Create a read-only IAM user and put its credentials under the
# profile "mcp-benchmark" (see docs/setup.md for the aws iam
# commands). Then:
cp .env.example .env
$EDITOR .env # AWS_PROFILE, AWS_REGION, ANTHROPIC_API_KEY
# 3. Confirm the IAM user works and sees your account
./scripts/discover.sh
# 4. Snapshot ground truth for your account
python -m src.ground_truth
# 5. Run the full series (5 tasks x 5 transports x n=10 ≈ 15-20 min)
python -m src.runner --n 10
# 6. Summarise
python -m src.aggregate results/raw/latest.jsonlIndividual smoke tests and ablation variants are in
src/dry_run.py:
python -m src.dry_run cli ec2_running
python -m src.dry_run cli-ctx ec2_cpu_last_hour
python -m src.dry_run cli-v2 iam_admin_roles
python -m src.dry_run mcp s3_bucket_regions
python -m src.dry_run cli-full ec2_cpu_last_hourmcp-vs-cli-aws-benchmark/
├── README.md # this file
├── pyproject.toml
├── .env.example
├── src/
│ ├── agent_loop.py # hand-rolled Anthropic agent loop
│ ├── safety.py # whitelist + subprocess wrapper
│ ├── tasks.py # 5 task definitions
│ ├── ground_truth.py # boto3 ground truth fetcher
│ ├── verify.py # verdict vs ground truth
│ ├── tools_cli.py # plain CLI transport
│ ├── tools_cli_v2.py # batch + rich description CLI
│ ├── tools_cli_rich.py # description ablation (unused in final)
│ ├── tools_cli_renamed.py # naming ablation (unused in final)
│ ├── tools_cli_with_fake_suggest.py # scaffolding ablation (unused in final)
│ ├── tools_mcp.py # mcp python lib wrapper for awslabs server
│ ├── dry_run.py # single-task smoke tests
│ ├── runner.py # full benchmark loop, JSONL output
│ └── aggregate.py # median + IQR + success rate table
├── scripts/
│ ├── discover.sh # read-only AWS account probe
│ └── scrub.py # account ID / ARN redaction
├── tests/
│ └── test_safety.py # 24 whitelist tests
├── results/
│ ├── raw/ # JSONL per run (gitignored, contains
│ │ # real account / instance / bucket names)
│ └── scrubbed/
│ ├── final_summary.json # aggregated n=10 summary, publishable
│ ├── sample_runs.jsonl # 8 hand-curated runs, 3 mechanisms
│ └── sample_runs.README.md
└── docs/
└── findings.md # analytical writeup (basis of the article)
MIT.
- Real account ID, ARNs, bucket names and instance IDs appear in
results/raw/and are gitignored. scripts/scrub.pymaps them to stable placeholders (<ACCOUNT_ID>,bucket-N,i-instance0N) before anything moves toresults/scrubbed/.- Only scrubbed artefacts are committed or published.