Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# AGENTS.md — deploying and testing this sample

Guidance for AI coding agents (and humans in a hurry). Read the README for the
architecture; this file is the operational fast path.

## What you are deploying

Two CDK stacks (`infrastructure/cdk`):

- **`EdgeWafStack`** — always deploys to **us-east-1** (CLOUDFRONT-scope WAF
Web ACLs can only live there). ChatGPT/Claude IP allowlist, AWS managed
rules, rate limiting, default action BLOCK.
- **`AgentCoreMcpStack`** — deploys to your configured region (AgentCore
regions: us-east-1, us-west-2, eu-central-1, ap-southeast-2). Contains the
AgentCore Runtime + Gateway, the business Lambda/DynamoDB, and a
**CloudFront front door** for the Gateway with the WAF attached and a
CloudFront Function serving `/.well-known/oauth-protected-resource`.

The public MCP endpoint is the **`GatewayResourceUrl`** stack output
(`https://<distribution>.cloudfront.net/mcp`). The `GatewayDirectUrl` output is
the raw Gateway URL — it bypasses CloudFront/WAF; never hand it out.

## Prerequisites

- Node.js >= 22, AWS CLI, valid credentials (`aws sts get-caller-identity`)
- `AWS_REGION` set (deploy fails without a region)
- If deploying outside us-east-1: nothing extra — `deploy.sh` bootstraps
us-east-1 for the WAF stack automatically.

## Deploy

```bash
./deploy.sh --require-approval never # No Auth gateway (default)
./deploy.sh --require-approval never -c auth=cognito # Cognito JWT inbound auth
```

Everything after `deploy.sh` is passed to `cdk deploy` verbatim, so `-c`
context flags and `--tags` work. Expect ~6–20 minutes; the CLI looks stalled
while the two CloudFront distributions propagate — that is normal.

### Auth modes

| Mode | Gateway inbound | Notes |
|---|---|---|
| default (`auth=none`) | No Auth | WAF IP allowlist is the only gate. The raw Gateway URL is unauthenticated — treat it as secret. |
| `-c auth=cognito` | Cognito JWT (client_credentials) | Creates a machine-to-machine User Pool + app client + hosted domain. Requests without a valid Bearer token are rejected by the Gateway itself, so the direct-URL bypass is closed. The CloudFront Function advertises the Cognito issuer in `authorization_servers`. |

Switching modes **replaces the Gateway** (the service forbids in-place
authorizer-type changes; the CDK bakes the mode into the gateway's logical ID
to force it). No teardown needed, but the `GatewayDirectUrl` changes — the
public CloudFront `GatewayResourceUrl` stays the same.

## Test end to end

```bash
./verify.sh # automated e2e; add --keep-ip to keep your IP allowlisted
./inspect.sh # interactive testing with MCP Inspector (run verify --keep-ip first)
./get-token.sh # print a Cognito access token (Cognito deployments only)
```

`verify.sh` does the whole e2e loop and cleans up after itself:

1. Reads `GatewayResourceUrl`; if `CognitoTokenEndpoint` exists in the outputs
it fetches the app-client secret and gets a client_credentials token.
2. Confirms the WAF blocks you (expects 403), then temporarily adds your IPv4
`/32` to the `*-chatgpt-ips` IP set (**CLOUDFRONT scope, us-east-1**).
3. Runs MCP `initialize` → `tools/list` → `tools/call list_unicorns`, checks
the OAuth discovery endpoint returns the front-door domain, and (Cognito
mode) checks that tokenless requests still get 401/403.
4. Removes your IP again, even on failure or Ctrl-C.

### Gotchas that will waste your time

- **IPv6**: CloudFront is dual-stack; the allowlist IP sets are IPv4-only. Any
manual `curl` you run must use `-4`, or WAF will 403 you from your IPv6
address even though your IPv4 is allowlisted. `verify.sh` already does this.
The same applies to Node-based clients (e.g. MCP Inspector's proxy): launch
them with `NODE_OPTIONS=--dns-result-order=ipv4first` or WAF returns its
403 "Request blocked" page despite the allowlisted IPv4.
- **WAF API scope**: all `aws wafv2` calls for this Web ACL need
`--scope CLOUDFRONT --region us-east-1`, regardless of the app region.
- **Propagation**: IP set changes take ~30–60 s to reach the edge. Don't
declare failure on the first 403 after an update.
- **Tool names through the Gateway** are `<target>___<tool>`
(e.g. `unicorn-mcp-runtime-target___list_unicorns`); the Gateway also injects
its own `x_amz_bedrock_agentcore_search` tool.
- **Manual MCP calls** need `Accept: application/json, text/event-stream` and,
in Cognito mode, `Authorization: Bearer $(./get-token.sh)` (tokens last
1 hour; the script wraps the describe-user-pool-client + token-endpoint
dance).
- **MCP Inspector**: use `./inspect.sh` — it prints the endpoint URL and the
Authorization header (clipboard on macOS) and launches Inspector with
IPv4-first DNS. In Inspector choose transport **Streamable HTTP**. Your IP
must already be allowlisted (`./verify.sh --keep-ip`).
- **Protocol versions**: the Gateway accepts MCP `2025-06-18` (modern
stateless streamable HTTP) and `2025-03-26`. If a "modern/stateless" client
fails while "legacy/auto" works, something reintroduced a version pin.

## Clean up

```bash
cd infrastructure/cdk
npx cdk destroy --all
```
59 changes: 46 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This sample shows how to deploy an [MCP (Model Context Protocol)](https://modelc
- **MCP Server**: Node.js 22 / TypeScript
- **Business Logic**: Python Lambda (DynamoDB access)
- **Gateway**: Amazon Bedrock AgentCore Gateway (MCP protocol, No Auth inbound)
- **Edge**: Amazon CloudFront front door + AWS WAF (CLOUDFRONT scope) + CloudFront Function for OAuth discovery
- **Infrastructure**: AWS CDK (TypeScript)
- **Runtime**: Amazon Bedrock AgentCore Runtime (NODEJS_22)

Expand Down Expand Up @@ -51,7 +52,8 @@ You will be able to interact with the app with requests like:
**How it works:**
| Component | Purpose |
|-----------|---------|
| **AgentCore Gateway** | Public MCP endpoint for AI hosts — aggregates MCP targets, handles tool discovery, and enforces WAF rules |
| **CloudFront front door + WAF** | The public MCP entry point. CloudFront reverse-proxies to the Gateway; the associated WAF Web ACL (CLOUDFRONT scope) enforces the IP allowlist, managed rules and rate limiting at the edge. A CloudFront Function answers OAuth protected-resource discovery with the front-door domain (no Lambda@Edge needed) |
| **AgentCore Gateway** | MCP endpoint behind the front door — aggregates MCP targets and handles tool discovery |
| **AgentCore Runtime (MCP Server)** | Managed runtime hosting the MCP server (Node.js 22) — handles MCP protocol, tool definitions, structured output, widget resources, and delegates business operations to the service Lambda |
| **Unicorn Rental Service** (Python) | Lambda function that implements business logic (list, book, view, return unicorns) with DynamoDB access |
| **DynamoDB** | Stores unicorn inventory and booking records |
Expand All @@ -61,16 +63,16 @@ You will be able to interact with the app with requests like:

#### Registration (Connecting the MCP App to an AI Host)

1. **You provide the App details** to the AI host (ChatGPT, Claude, etc.), including the MCP Server URL — the AgentCore Gateway endpoint.
2. **The AI host** sends MCP `tools/list` and `resources/list` requests to the Gateway URL to discover available capabilities.
1. **You provide the App details** to the AI host (ChatGPT, Claude, etc.), including the MCP Server URL — the CloudFront front-door endpoint (`GatewayResourceUrl` output).
2. **The AI host** sends MCP `tools/list` and `resources/list` requests to the front-door URL. CloudFront evaluates them against the WAF Web ACL and forwards allowed requests to the AgentCore Gateway.
3. **AgentCore Gateway** forwards the requests to the AgentCore Runtime via the configured MCP Server target (authenticated with IAM SigV4).
4. **AgentCore Runtime (MCP Server)** receives the requests. The MCP App hosted on it defines MCP tools (e.g., `list_unicorns`, `book_unicorn`) and MCP resources (e.g., widget HTML templates). It responds with the full list of tools and resources.
5. **The AI host** receives the tool and resource definitions and may cache them for future use, enabling tool invocation and widget rendering in subsequent interactions.

#### Request Flow (Tool Calls)

1. **The MCP host** (ChatGPT, Claude, etc.) sends an MCP JSON-RPC request (e.g., `tools/call` with `list_unicorns`) to the AgentCore Gateway URL.
1. **AgentCore Gateway** receives the request. The associated WAF Web ACL evaluates the request against IP allowlist rules, rate limiting, and managed rule sets. Blocked requests are rejected before reaching any target.
1. **The MCP host** (ChatGPT, Claude, etc.) sends an MCP JSON-RPC request (e.g., `tools/call` with `list_unicorns`) to the CloudFront front-door URL.
1. **CloudFront** receives the request. The associated WAF Web ACL evaluates it against IP allowlist rules, rate limiting, and managed rule sets — blocked requests are rejected at the edge, before ever reaching AWS Region infrastructure. Allowed requests are proxied (caching disabled) to the AgentCore Gateway.
1. **AgentCore Gateway** forwards the MCP request to the AgentCore Runtime via the configured MCP Server target, authenticating with IAM (SigV4).
1. **AgentCore Runtime (MCP Server)** receives the MCP request and invokes the Unicorn Service Lambda.
1. **Unicorn Service Lambda** executes the business logic against DynamoDB and returns the results.
Expand Down Expand Up @@ -111,11 +113,12 @@ Useful variations:
```bash
./deploy.sh --require-approval never # skip the IAM approval prompt
./deploy.sh -c projectName=my-unicorns # override the default 'unicorn-mcp' project name
./deploy.sh -c auth=cognito # Cognito JWT inbound auth on the Gateway (see Security)
```

### Verify your deployment

The Gateway sits behind AWS WAF with a **default-deny** policy that only allows the ChatGPT and Claude egress ranges (see [Security](#security)). A useful consequence is that the endpoint is not publicly reachable — but it also means **you cannot call your own endpoint** after deploying: every request returns `HTTP 403`.
The Gateway sits behind a CloudFront front door protected by AWS WAF with a **default-deny** policy that only allows the ChatGPT and Claude egress ranges (see [Security](#security)). A useful consequence is that the endpoint is not publicly reachable — but it also means **you cannot call your own endpoint** after deploying: every request returns `HTTP 403`.

To smoke-test it anyway:

Expand All @@ -125,7 +128,10 @@ To smoke-test it anyway:

This temporarily adds your public IP to the WAF allowlist, runs `initialize` → `tools/list` → `tools/call list_unicorns` against the live endpoint, prints a pass/fail summary, then **removes your IP again** (including if a check fails or you interrupt it).

If you want to keep poking at the endpoint yourself — for example with [MCP Inspector](https://github.com/modelcontextprotocol/inspector) — use `./verify.sh --keep-ip` and remember to remove the entry afterwards.
If you want to keep poking at the endpoint yourself, use `./verify.sh --keep-ip` (remember to remove the entry afterwards — rerunning plain `./verify.sh` does it) and then:

- **[MCP Inspector](https://github.com/modelcontextprotocol/inspector)** — run `./inspect.sh`. It prints the endpoint URL and (on Cognito deployments) the ready-to-paste `Authorization` header, then launches Inspector with IPv4-first DNS. That DNS flag matters: CloudFront is dual-stack but the WAF allowlist is IPv4-only, so Node-based clients on IPv6 networks get `403 Request blocked` without it. For the same reason, use `curl -4` for manual calls.
- **Cognito token** (`-c auth=cognito` deployments) — `TOKEN=$(./get-token.sh)` mints a 1-hour client_credentials token; pass it as `Authorization: Bearer $TOKEN`.

> **Note on tool names:** through the Gateway, tools are exposed as `<target>___<tool>` (for example `unicorn-mcp-runtime-target___list_unicorns`), and the Gateway also injects its own `x_amz_bedrock_agentcore_search` tool. AI hosts handle this for you; it only matters if you are calling the MCP API directly.

Expand Down Expand Up @@ -198,7 +204,7 @@ CDK will:
4. Create the IAM role for AgentCore with S3 read and Lambda invoke permissions
5. Create the AgentCore Runtime (MCP Server) pointing to the service Lambda
6. Deploy the **AgentCore Gateway** with No Auth inbound and MCP Server target (IAM outbound auth)
7. Associate the **WAF Web ACL** with the Gateway (IP allowlist + managed rules)
7. Deploy the **WAF Web ACL** (CLOUDFRONT scope, `EdgeWafStack` in us-east-1) and a **CloudFront front door** for the Gateway with the Web ACL attached, plus a **CloudFront Function** that serves `/.well-known/oauth-protected-resource` with the front-door domain
8. Apply a **resource-based policy** restricting runtime invocation to the Gateway only

Note the outputs printed after deployment — you'll need the `GatewayResourceUrl` to connect an MCP host.
Expand All @@ -216,9 +222,9 @@ Both guides cover configuration steps, demo prompts, and troubleshooting. You'll

This project implements multiple layers of security to protect the MCP endpoint and backend services:

### 1. WAF IP Allowlisting (AgentCore Gateway)
### 1. WAF IP Allowlisting (CloudFront front door)

AWS WAF is associated with the AgentCore Gateway with a **default-deny** policy. Only requests originating from allowlisted IP ranges are permitted through. The deployed stack includes outbound IP ranges for both ChatGPT ([OpenAI outbound IPs](https://openai.com/chatgpt-actions.json)) and Claude ([Anthropic outbound IPs](https://docs.anthropic.com/en/api/ip-addresses)). To connect additional MCP hosts or for testing the MCP server directly using tools like MCP Inspector, add their outbound IP ranges to the WAF IP set.
AWS WAF (CLOUDFRONT scope, deployed in us-east-1) is associated with the CloudFront distribution in front of the AgentCore Gateway, with a **default-deny** policy. Only requests originating from allowlisted IP ranges are permitted through. The deployed stack includes outbound IP ranges for both ChatGPT ([OpenAI outbound IPs](https://openai.com/chatgpt-actions.json)) and Claude ([Anthropic outbound IPs](https://docs.anthropic.com/en/api/ip-addresses)). To connect additional MCP hosts or for testing the MCP server directly using tools like MCP Inspector, add their outbound IP ranges to the WAF IP set.

### 2. WAF Managed Rules (Common Attack Protection)

Expand All @@ -230,17 +236,44 @@ AWS WAF is associated with the AgentCore Gateway with a **default-deny** policy.

The AgentCore Gateway authenticates to the AgentCore Runtime using IAM (SigV4 signing). The Gateway's execution role is granted `bedrock-agentcore:InvokeAgentRuntime` permission on the runtime ARN.

### 4. Resource-Based Policy (AgentCore Runtime)
### 4. Custom domain readiness (CloudFront Function instead of Lambda@Edge)

The [AgentCore custom-domains guide](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-custom-domains.html) recommends a Lambda@Edge `ORIGIN_RESPONSE` function to fix the `/.well-known/oauth-protected-resource` discovery document, which otherwise advertises the Gateway's own domain. This sample uses a **CloudFront Function** on the viewer request instead: it generates the discovery response directly at the edge from the request's `Host` header, so it is correct for the default `*.cloudfront.net` domain and for any custom domain you attach later — at a fraction of Lambda@Edge's cost and latency, with no us-east-1 Lambda replication. If you switch the Gateway to an OAuth (e.g. Amazon Cognito) inbound authorizer, add the issuer to `authorization_servers` in `infrastructure/cdk/lib/functions/oauth-discovery.js`.

> **Known limitation (default deployment):** the Gateway's own `*.gateway.bedrock-agentcore.*` URL (the `GatewayDirectUrl` output) remains reachable and bypasses CloudFront/WAF, since WAF is no longer associated with the Gateway itself and the Gateway uses No Auth inbound. Do not distribute that URL — or deploy with `-c auth=cognito` (below), which closes the bypass.

### 4b. Optional: Cognito JWT inbound auth (`-c auth=cognito`)

Deploying with `./deploy.sh -c auth=cognito` switches the Gateway's inbound authorizer from No Auth to **Amazon Cognito**:

- A machine-to-machine **Cognito User Pool** (no sign-ups, no human users), a resource server exposing the `mcp-gateway/invoke` scope, a hosted domain for the `/oauth2/token` endpoint, and an app client with the **client_credentials** flow.
- The Gateway validates every request's `Authorization: Bearer` JWT against the pool (`GatewayAuthorizer.usingCognito`), restricted to that app client. Requests without a valid token are rejected **by the Gateway itself**, so the direct-URL bypass above no longer applies — WAF at the edge and JWT auth at the Gateway become independent layers.
- The CloudFront Function automatically advertises the Cognito issuer in `authorization_servers` of the `/.well-known/oauth-protected-resource` document.

Fetch a token and call the endpoint (outputs `CognitoTokenEndpoint`, `CognitoClientId`, `CognitoUserPoolId`):

```bash
SECRET=$(aws cognito-idp describe-user-pool-client --user-pool-id <CognitoUserPoolId> \
--client-id <CognitoClientId> --query 'UserPoolClient.ClientSecret' --output text)
TOKEN=$(curl -s -X POST <CognitoTokenEndpoint> -u "<CognitoClientId>:$SECRET" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials&scope=mcp-gateway/invoke' | jq -r .access_token)
curl -4 -X POST <GatewayResourceUrl> -H "Authorization: Bearer $TOKEN" ...
```

> Note: ChatGPT/Claude connectors negotiate OAuth via dynamic client registration, which Cognito does not offer — the Cognito mode is aimed at programmatic MCP clients (and at demonstrating the pattern); the default No Auth + IP-allowlist mode is what the ChatGPT/Claude setup guides assume.

### 5. Resource-Based Policy (AgentCore Runtime)

A resource-based access policy is attached directly to the AgentCore Runtime. It explicitly allows only the AgentCore Gateway's execution role to invoke the runtime, and denies all other principals. This ensures the runtime cannot be accessed directly, bypassing the Gateway and its WAF protections.

## Cleanup

The deployed stack has standing costs even when idle — the WAF Web ACL, the CloudFront distribution and the AgentCore Runtime all bill while they exist. Tear everything down when you are finished:
The deployed stacks have standing costs even when idle — the WAF Web ACL, the CloudFront distributions and the AgentCore Runtime all bill while they exist. Tear everything down when you are finished:

```bash
cd infrastructure/cdk
npx cdk destroy
npx cdk destroy --all
```

Deletion takes a few minutes, again mostly waiting on CloudFront. The DynamoDB tables and S3 buckets are configured to delete with the stack, so nothing is left behind.
Loading