It’s been a while since my last post, and the thing that’s changed most in that gap is how much of my day-to-day work now involves AI assistants. One pattern I keep running into: I’m in the middle of something and need a quick answer about my AWS account. How many instances are running in us-east-1? What’s been driving the bill this month? Which buckets did I create last year and forget about?

Normally that means context-switching to the console or stringing together aws CLI commands. But there’s a better way now: the Model Context Protocol (MCP). MCP is an open standard that lets you expose your own tools to AI assistants like Claude. In this post, I’ll build a small read-only MCP server in Python that lets an assistant answer questions about my AWS account — EC2 instances, S3 buckets, and cost data — and wire it up to both Claude Code and Claude Desktop.

Why Read-Only?

Let me get this out of the way first, because it matters: I’m deliberately not giving the AI any ability to change my infrastructure. No terminating instances, no modifying security groups, no deleting buckets. The server only calls describe, list, and get style APIs, and the IAM credentials behind it are scoped to read-only access. That means the worst-case outcome of a confused AI (or a confused me) is a harmless API call, not an outage.

This is the same principle we apply everywhere else in ops: give a new tool the minimum permissions it needs, watch how it behaves, and expand from there if it earns it.

What You’ll Need

  • Python 3.10+ and uv (the MCP tooling leans on it, and it’s worth adopting anyway)
  • An AWS account with credentials configured locally (aws configure or SSO)
  • Claude Code and/or Claude Desktop

Step 1: Project Setup

Create a new project and pull in the MCP SDK and boto3:

uv init aws-mcp-server
cd aws-mcp-server
uv add "mcp[cli]" boto3

The mcp[cli] extra includes the development tooling we’ll use later to test and install the server.

Step 2: Scope the IAM Permissions

Before writing any code, set up the permission boundary. If you’re using a dedicated IAM user or role for this, attach the AWS-managed ViewOnlyAccess policy — or go tighter with a custom policy covering only what the server actually calls:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeInstances",
        "s3:ListAllMyBuckets",
        "ce:GetCostAndUsage"
      ],
      "Resource": "*"
    }
  ]
}

If you’re just running this against your personal account with your existing credentials, that works too — the code below never calls a mutating API. But the explicit policy is the right habit, especially if you ever run something like this in a work account.

One cost note: the Cost Explorer API (ce:GetCostAndUsage) charges $0.01 per request. Not going to break the bank, but it’s not free like the EC2 and S3 calls.

Step 3: Write the Server

Here’s the whole thing. The FastMCP class from the official SDK does the heavy lifting: each function decorated with @mcp.tool() becomes a tool the AI can call, and the docstrings and type hints become the schema it uses to understand them.

# server.py
from datetime import date, timedelta

import boto3
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("aws-readonly")


@mcp.tool()
def list_ec2_instances(region: str = "us-east-1") -> list[dict]:
    """List EC2 instances in a region with their ID, type, state, and Name tag."""
    ec2 = boto3.client("ec2", region_name=region)
    instances = []
    paginator = ec2.get_paginator("describe_instances")
    for page in paginator.paginate():
        for reservation in page["Reservations"]:
            for inst in reservation["Instances"]:
                name = next(
                    (t["Value"] for t in inst.get("Tags", []) if t["Key"] == "Name"),
                    "(unnamed)",
                )
                instances.append({
                    "id": inst["InstanceId"],
                    "name": name,
                    "type": inst["InstanceType"],
                    "state": inst["State"]["Name"],
                    "az": inst["Placement"]["AvailabilityZone"],
                })
    return instances


@mcp.tool()
def list_s3_buckets() -> list[dict]:
    """List all S3 buckets in the account with their creation dates."""
    s3 = boto3.client("s3")
    response = s3.list_buckets()
    return [
        {"name": b["Name"], "created": b["CreationDate"].isoformat()}
        for b in response["Buckets"]
    ]


@mcp.tool()
def get_cost_by_service(days: int = 30) -> list[dict]:
    """Get AWS costs grouped by service for the last N days.

    Note: each call to the Cost Explorer API costs $0.01.
    """
    ce = boto3.client("ce", region_name="us-east-1")
    end = date.today()
    start = end - timedelta(days=days)
    response = ce.get_cost_and_usage(
        TimePeriod={"Start": start.isoformat(), "End": end.isoformat()},
        Granularity="MONTHLY",
        Metrics=["UnblendedCost"],
        GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
    )
    costs = {}
    for period in response["ResultsByTime"]:
        for group in period["Groups"]:
            service = group["Keys"][0]
            amount = float(group["Metrics"]["UnblendedCost"]["Amount"])
            costs[service] = costs.get(service, 0.0) + amount
    return sorted(
        ({"service": s, "cost_usd": round(c, 2)} for s, c in costs.items()),
        key=lambda x: x["cost_usd"],
        reverse=True,
    )


if __name__ == "__main__":
    mcp.run()

A few things worth calling out:

  • mcp.run() defaults to stdio transport. The AI client launches the server as a subprocess and talks to it over stdin/stdout. No ports, no network exposure — the server only exists while the client is running.
  • Type hints are the contract. region: str = "us-east-1" tells the assistant there’s an optional region parameter and what its default is. Good docstrings matter for the same reason: they’re how the AI decides when to use each tool.
  • boto3 picks up credentials the usual way — environment, ~/.aws/credentials, SSO session. The server inherits whatever your shell has, which is exactly what you want for a local tool.

Step 4: Test It with the MCP Inspector

Before pointing an AI at it, sanity-check the server with the built-in inspector:

uv run mcp dev server.py

This opens a browser UI where you can see the three tools, inspect their schemas, and invoke them manually. If list_ec2_instances returns your instances here, you’re ready to connect a real client.

Step 5: Connect It to Claude Code

From the project directory, register the server:

claude mcp add aws-readonly -- uv run --directory /path/to/aws-mcp-server server.py

Now start a Claude Code session and just ask:

“What EC2 instances are running, and what’s been my biggest cost driver in the last 30 days?”

Claude calls list_ec2_instances and get_cost_by_service, then summarizes the results. The first time it uses a tool you’ll get a permission prompt — even for our read-only tools, the client makes you opt in.

Step 6: Connect It to Claude Desktop

For Claude Desktop, the MCP CLI can install it for you:

uv run mcp install server.py --name "AWS Read-Only"

Or add it manually to claude_desktop_config.json (on macOS: ~/Library/Application Support/Claude/; on Windows: %APPDATA%\Claude\):

{
  "mcpServers": {
    "aws-readonly": {
      "command": "uv",
      "args": ["run", "--directory", "/path/to/aws-mcp-server", "server.py"]
    }
  }
}

Restart Claude Desktop, and the tools show up under the tools icon in the chat input.

Where I’d Take This Next

The pattern generalizes to basically anything boto3 can read. Some ideas I’m tempted to add:

  • Untagged resource finder — list resources missing required tags, which pairs nicely with my earlier post on resource labeling
  • Security group audit — flag groups with 0.0.0.0/0 ingress rules
  • CloudWatch alarm status — “anything alarming right now?” as a literal question
  • A homelab version — the same approach works against the Proxmox or Rancher APIs

The deeper takeaway for me is that MCP inverts the usual integration problem. Instead of waiting for some vendor to ship an AI integration for your stack, you write fifty lines of Python and your stack is the integration. For DevOps folks especially, that’s a powerful position — we already know where all the APIs are.

If you build something with this, I’d love to hear about it.