Skip to main content
Glama
debadatta30

s3-mcp-server

by debadatta30
README.md
# S3 MCP server on ECS Fargate

A remote, OAuth2-authenticated MCP server exposing read-only S3 tools
(`list_buckets`, `list_objects`, `get_bucket_public_access`,
`get_bucket_size`), designed to run as an ECS Fargate task behind an
ALB and CloudFront, with **Auth0** as the identity provider.

## How it fits together

- **CloudFront** terminates TLS at the edge and forwards every header
  (including `Authorization`) to an internal ALB over plain HTTP.
- **ALB** hands the request to the **ECS Fargate task** on port 8080.
- The task (`server.py`) validates the bearer token against **Auth0**
  before running any tool, then talks to **S3** using the task's IAM
  role — no static AWS credentials anywhere.
- The task reaches both Auth0's JWKS endpoint and S3's public API
  through a NAT Gateway, since it runs in private subnets and no S3
  VPC endpoint is configured.

The OAuth handshake (login, PKCE code exchange) happens entirely
between the MCP client (e.g. Claude Desktop) and Auth0 — the server
is only involved at the very start (serving its own discovery
document) and the very end (validating the resulting token). Keep
that in mind when debugging: a failed login never shows up in this
server's logs, because the server was never part of that exchange.

## Auth0 setup

You need an Auth0 tenant (the free tier is enough) with two things in it:

1. **An API** — this is what makes Auth0 issue real JWT access tokens
   instead of opaque ones.
   - Dashboard → **Applications → APIs → Create API**
   - **Identifier**: the exact URL clients will connect to, e.g.
     `https://your-domain.example.com/sse` — this becomes `AUTH0_AUDIENCE`
     and must match byte-for-byte what you deploy behind.
   - Signing algorithm: RS256 (default)

2. **An Application** — what the MCP client authenticates as.
   - Dashboard → **Applications → Applications → Create Application**
   - Type: **Regular Web Application**
   - In **Settings**, set **Allowed Callback URLs** to whatever redirect
     URI(s) your MCP client uses (for Claude Desktop:
     `https://claude.ai/api/mcp/auth_callback`)
   - Note the **Domain**, **Client ID**, and **Client Secret** — these
     become `AUTH0_DOMAIN` / `AUTH0_CLIENT_ID` / the client secret you
     give your MCP client (the server itself never needs the secret).

3. **Authorize the Application for the API — this step is easy to
   miss.** Creating an API and an Application separately does **not**
   link them. Go to the API → **Application Access** tab → toggle your
   Application on. Skip this and every authorization attempt fails
   with `invalid_request` / "Client is not authorized to access
   resource server", before the client ever sees a login page.

## Local test (no AWS needed for the transport layer)

```bash
cp .env.example .env
# edit .env with your Auth0 tenant details if you want to test auth locally
pip install -r requirements.txt
python server.py
```

Server listens on `http://localhost:8080/sse`. `/health` returns `200 ok`
with no auth required — that's the ALB target group health check path.

Every other route requires `Authorization: Bearer <token>` where the
token is an Auth0 **access token** (JWT, `aud` matching `AUTH0_AUDIENCE`)
for an app client matching `AUTH0_CLIENT_ID`.

## Docker

```bash
docker build -t s3-mcp-server .
docker run -p 8080:8080 --env-file .env s3-mcp-server
```

## Deploying to Fargate

```bash
cd infra
pip install -r requirements.txt   # into a venv
cdk bootstrap   # first time only, per account/region
cdk deploy \
  -c auth0_domain=your-tenant.us.auth0.com \
  -c auth0_client_id=your-application-client-id \
  -c auth0_audience=https://your-domain.example.com/sse
```

`auth0_audience` should match the CloudFront domain CDK is about to
create, with `/sse` appended — you likely won't know it on the very
first deploy. Deploy once to get the `DistributionURL` output, then
redeploy with the real audience value (this only needs to happen
once; the CloudFront domain is stable across subsequent deploys of
the same stack).

Instead of passing `-c` flags every time, you can put them in a
gitignored `infra/cdk.context.json`:

```json
{
  "auth0_domain": "your-tenant.us.auth0.com",
  "auth0_client_id": "your-application-client-id",
  "auth0_audience": "https://your-domain.example.com/sse"
}
```

### Things that bite people

1. **ALB idle timeout.** SSE connections are long-lived. The ALB's
   default idle timeout (60s) will kill them. This stack sets it to
   `300` (5 min) — raise it further if your client doesn't send
   periodic pings.

2. **Health check path.** Target group health check points at
   `/health`, not `/sse` — `/sse` requires auth and is a streaming
   response, neither of which the ALB health checker expects.

3. **Credentials.** The container never sets AWS credentials — boto3
   picks them up automatically from the Fargate task's IAM role via
   the container credentials endpoint. Don't bake keys into the image
   or env vars.

4. **Egress.** The task fetches Auth0's JWKS over HTTPS on first
   request (then caches for an hour), and calls S3's public API for
   every tool call — both go out through the NAT Gateway, since this
   stack doesn't configure an S3 VPC Gateway Endpoint. Make sure the
   task's subnet actually has a NAT route, or add a Gateway Endpoint
   for S3 (free, and keeps that traffic off the public internet).

5. **The MCP SDK's own DNS-rebinding protection will silently break
   this behind any real domain.** `FastMCP`'s `TransportSecuritySettings`
   defaults to an *empty* allowed-hosts list, which rejects every
   request that arrives with a real `Host` header (like your
   CloudFront domain) — even after auth succeeds — with
   `421 Misdirected Request`. This happens *after* the OAuth flow
   completes, so it's easy to mistake for an auth bug. This server
   disables it in `server.py`, since `Auth0AuthMiddleware` already
   gates every route with bearer-token auth:
   ```python
   mcp = FastMCP(
       "s3-mcp-server",
       transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
   )
   ```

## Minimal IAM task role policy (read-only tools above)

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "S3ReadOnly",
      "Effect": "Allow",
      "Action": [
        "s3:ListAllMyBuckets",
        "s3:ListBucket",
        "s3:GetBucketAcl",
        "s3:GetBucketPolicyStatus",
        "s3:GetBucketPolicy"
      ],
      "Resource": "*"
    }
  ]
}
```

Scope `Resource` down to specific bucket ARNs once you know which
buckets this agent should actually be allowed to see. If you later add
a write tool (e.g. lifecycle policy changes), give it its own narrower
statement rather than widening this one.

## MCP client config

Point any MCP client that supports remote SSE servers at your
deployed URL, with the Auth0 Application's Client ID and Secret from
the setup steps above:

```json
{
  "mcpServers": {
    "s3": {
      "url": "https://your-domain.example.com/sse",
      "oauth_client_id": "your-application-client-id",
      "oauth_client_secret": "your-application-client-secret"
    }
  }
}
```

The exact config shape depends on the client — Claude Desktop exposes
these as form fields under its Connectors settings rather than raw
JSON. Either way, the OAuth redirect/token exchange is handled by the
client per the MCP authorization spec; this server only validates the
resulting bearer token on each request.