diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..aa5a760 --- /dev/null +++ b/AGENTS.md @@ -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://.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 `___` + (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 +``` diff --git a/README.md b/README.md index a21ed32..9124973 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 | @@ -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. @@ -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: @@ -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 `___` (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. @@ -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. @@ -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) @@ -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 \ + --client-id --query 'UserPoolClient.ClientSecret' --output text) +TOKEN=$(curl -s -X POST -u ":$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 -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. diff --git a/deploy.sh b/deploy.sh index 6619411..02fe70f 100755 --- a/deploy.sh +++ b/deploy.sh @@ -112,12 +112,23 @@ else npx cdk bootstrap fi +# The CLOUDFRONT-scope WAF stack always deploys to us-east-1, so that region +# must be bootstrapped too when deploying the app elsewhere. +if [ "$REGION" != "us-east-1" ]; then + if aws cloudformation describe-stacks --stack-name CDKToolkit --region us-east-1 > /dev/null 2>&1; then + echo " CDKToolkit stack found in us-east-1 (WAF stack region)." + else + echo " Bootstrapping us-east-1 for the WAF stack..." + npx cdk bootstrap "aws://${ACCOUNT}/us-east-1" + fi +fi + echo "" # ============================================================================= # Step 5: Deploy # ============================================================================= -echo -e "${GREEN}[5/5] Deploying AgentCoreMcpStack...${NC}" +echo -e "${GREEN}[5/5] Deploying EdgeWafStack (us-east-1) + AgentCoreMcpStack...${NC}" echo " This usually takes 10-20 minutes, occasionally longer. Everything" echo " except CloudFront is done in the first few minutes; the CLI then" echo " looks stalled around 43/49 while CloudFront propagates. That is" @@ -125,7 +136,8 @@ echo " expected — leave it running unless you see an actual error." echo "" # "$@" (not a single joined string) so multi-word args survive intact. -npx cdk deploy AgentCoreMcpStack "$@" +# --all deploys EdgeWafStack first (AgentCoreMcpStack depends on its Web ACL). +npx cdk deploy --all "$@" echo "" echo -e "${BLUE}============================================${NC}" diff --git a/docs/architecture-diagram.drawio b/docs/architecture-diagram.drawio index 2d1c3da..5712fc5 100644 --- a/docs/architecture-diagram.drawio +++ b/docs/architecture-diagram.drawio @@ -14,18 +14,18 @@ - + - + - + - + @@ -41,15 +41,15 @@ - + - - + + @@ -77,23 +77,38 @@ - + - + - + - - + + + + + + + + + + + + + + + + + diff --git a/docs/architecture-diagram.png b/docs/architecture-diagram.png index b76e89a..2611701 100644 Binary files a/docs/architecture-diagram.png and b/docs/architecture-diagram.png differ diff --git a/get-token.sh b/get-token.sh new file mode 100755 index 0000000..231f549 --- /dev/null +++ b/get-token.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# ============================================================================= +# get-token.sh — mint a Cognito access token for the deployed MCP endpoint +# +# Only meaningful for `-c auth=cognito` deployments: reads the stack outputs, +# fetches the app-client secret, and prints a client_credentials access token +# (valid 1 hour) to stdout. Use it as: +# +# TOKEN=$(./get-token.sh) +# curl -4 https:///mcp -H "Authorization: Bearer $TOKEN" ... +# +# ./get-token.sh --header # print "Authorization: Bearer " +# ./get-token.sh --stack MyStack # non-default stack name +# ============================================================================= +set -euo pipefail + +STACK_NAME="AgentCoreMcpStack" +AS_HEADER="false" +while [ $# -gt 0 ]; do + case "$1" in + --stack) STACK_NAME="$2"; shift 2 ;; + --header) AS_HEADER="true"; shift ;; + -h|--help) sed -n '2,14p' "$0"; exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +REGION="${AWS_REGION:-${AWS_DEFAULT_REGION:-$(aws configure get region 2>/dev/null || true)}}" +[ -n "$REGION" ] || { echo "Error: no AWS region configured." >&2; exit 1; } + +out() { + aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "$REGION" \ + --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" --output text +} + +TOKEN_ENDPOINT="$(out CognitoTokenEndpoint || true)" +if [ -z "$TOKEN_ENDPOINT" ] || [ "$TOKEN_ENDPOINT" = "None" ]; then + echo "Error: stack '$STACK_NAME' has no CognitoTokenEndpoint output." >&2 + echo "This deployment uses No Auth inbound — no token is needed." >&2 + echo "(Deploy with './deploy.sh -c auth=cognito' to enable Cognito auth.)" >&2 + exit 1 +fi + +CLIENT_ID="$(out CognitoClientId)" +CLIENT_SECRET="$(aws cognito-idp describe-user-pool-client --region "$REGION" \ + --user-pool-id "$(out CognitoUserPoolId)" --client-id "$CLIENT_ID" \ + --query 'UserPoolClient.ClientSecret' --output text)" + +TOKEN="$(curl -4 -fsS -m 20 -X POST "$TOKEN_ENDPOINT" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -u "${CLIENT_ID}:${CLIENT_SECRET}" \ + -d 'grant_type=client_credentials&scope=mcp-gateway/invoke' \ + | python3 -c "import sys, json; print(json.load(sys.stdin)['access_token'])")" + +if [ "$AS_HEADER" = "true" ]; then + printf 'Authorization: Bearer %s\n' "$TOKEN" +else + printf '%s\n' "$TOKEN" +fi diff --git a/infrastructure/cdk/bin/app.ts b/infrastructure/cdk/bin/app.ts index 07e1b57..af062d0 100644 --- a/infrastructure/cdk/bin/app.ts +++ b/infrastructure/cdk/bin/app.ts @@ -1,21 +1,39 @@ -#!/usr/bin/env node -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: MIT-0 -import * as cdk from "aws-cdk-lib"; -import { Aspects } from "aws-cdk-lib"; -import { AwsSolutionsChecks } from "cdk-nag"; -import { AgentCoreMcpStack } from "../lib/agentcore-mcp-stack"; - -const app = new cdk.App(); - -new AgentCoreMcpStack(app, "AgentCoreMcpStack", { - description: "AgentCore MCP Server stack for Unicorn Rentals (uksb-y11hag3nx1).", - env: { - account: process.env.CDK_DEFAULT_ACCOUNT, - region: process.env.CDK_DEFAULT_REGION, - }, - projectName: app.node.tryGetContext("projectName") || "unicorn-mcp", -}); - -// cdk-nag: apply AWS Solutions security checks at synth time. -Aspects.of(app).add(new AwsSolutionsChecks({ verbose: true })); +#!/usr/bin/env node +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 +import * as cdk from "aws-cdk-lib"; +import { Aspects } from "aws-cdk-lib"; +import { AwsSolutionsChecks } from "cdk-nag"; +import { AgentCoreMcpStack } from "../lib/agentcore-mcp-stack"; +import { EdgeWafStack } from "../lib/edge-waf-stack"; + +const app = new cdk.App(); + +const projectName = app.node.tryGetContext("projectName") || "unicorn-mcp"; + +// CLOUDFRONT-scope WAF Web ACLs can only be created in us-east-1, so the WAF +// lives in its own stack pinned there. crossRegionReferences lets the main +// stack consume the Web ACL ARN even when it deploys to another region. +const wafStack = new EdgeWafStack(app, "EdgeWafStack", { + description: "CLOUDFRONT-scope WAF for the AgentCore Gateway front door.", + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: "us-east-1", + }, + crossRegionReferences: true, + projectName, +}); + +new AgentCoreMcpStack(app, "AgentCoreMcpStack", { + description: "AgentCore MCP Server stack for Unicorn Rentals (uksb-y11hag3nx1).", + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION, + }, + crossRegionReferences: true, + projectName, + webAclArn: wafStack.webAcl.attrArn, +}); + +// cdk-nag: apply AWS Solutions security checks at synth time. +Aspects.of(app).add(new AwsSolutionsChecks({ verbose: true })); diff --git a/infrastructure/cdk/lib/agentcore-mcp-stack.ts b/infrastructure/cdk/lib/agentcore-mcp-stack.ts index 0c83b14..e9cb6ea 100644 --- a/infrastructure/cdk/lib/agentcore-mcp-stack.ts +++ b/infrastructure/cdk/lib/agentcore-mcp-stack.ts @@ -1,782 +1,779 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: MIT-0 -/** - * CDK Stack for deploying the Unicorn Rentals MCP Server on AgentCore Runtime. - * - * Resources created: - * 1. S3 + CloudFront for image hosting - * 2. S3 bucket for MCP server deployment package - * 3. DynamoDB tables for unicorn and booking data - * 4. IAM Role for AgentCore Runtime - * 5. AgentCore Runtime (MCP Server) via direct code deploy (Node.js 22) - * 6. AgentCore Gateway (No Auth inbound, MCP target pointing to Runtime) - * 7. WAF Web ACL — ChatGPT/Claude IP allowlist + AWS Managed Rules - */ - -import * as cdk from "aws-cdk-lib"; -import * as s3 from "aws-cdk-lib/aws-s3"; -import * as s3deploy from "aws-cdk-lib/aws-s3-deployment"; -import * as cloudfront from "aws-cdk-lib/aws-cloudfront"; -import * as origins from "aws-cdk-lib/aws-cloudfront-origins"; -import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; -import * as iam from "aws-cdk-lib/aws-iam"; -import * as lambda from "aws-cdk-lib/aws-lambda"; -import * as wafv2 from "aws-cdk-lib/aws-wafv2"; -import * as cr from "aws-cdk-lib/custom-resources"; -import * as agentcore from "aws-cdk-lib/aws-bedrockagentcore"; -import { NagSuppressions } from "cdk-nag"; -import { Construct } from "constructs"; -import * as path from "path"; - -interface AgentCoreMcpStackProps extends cdk.StackProps { - projectName: string; -} - -export class AgentCoreMcpStack extends cdk.Stack { - constructor(scope: Construct, id: string, props: AgentCoreMcpStackProps) { - super(scope, id, props); - - const project = props.projectName; - - // ---------------------------------------------------------------- - // CDK Parameters - // ---------------------------------------------------------------- - const corsAllowedOriginsParam = new cdk.CfnParameter(this, "CorsAllowedOrigins", { - type: "String", - description: "Comma-separated list of allowed CORS origins", - default: "https://chatgpt.com,https://chat.openai.com,https://claude.ai,https://www.claude.ai,http://localhost:8000", - }); - - // ---------------------------------------------------------------- - // 1. S3 + CloudFront for Images - // ---------------------------------------------------------------- - const widgetsBucket = new s3.Bucket(this, "WidgetsBucket", { - bucketName: `${project}-widgets-${this.account}`, - removalPolicy: cdk.RemovalPolicy.DESTROY, - autoDeleteObjects: true, - blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, - }); - - const widgetsCdn = new cloudfront.Distribution(this, "WidgetsCdn", { - defaultBehavior: { - origin: origins.S3BucketOrigin.withOriginAccessControl(widgetsBucket), - viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, - }, - defaultRootObject: "index.html", - comment: `${project} widgets CDN`, - }); - - // Deploy unicorn images to the widgets bucket under /images/ - new s3deploy.BucketDeployment(this, "UnicornImagesDeployment", { - sources: [ - s3deploy.Source.asset(path.join(__dirname, "..", "..", "..", "src", "assets")), - ], - destinationBucket: widgetsBucket, - destinationKeyPrefix: "images", - distribution: widgetsCdn, - distributionPaths: ["/images/*"], - }); - - // ---------------------------------------------------------------- - // 2. S3 Bucket for MCP Server Deployment Package - // ---------------------------------------------------------------- - const deploymentBucket = new s3.Bucket(this, "DeploymentBucket", { - bucketName: `${project}-deployment-${this.account}`, - removalPolicy: cdk.RemovalPolicy.DESTROY, - autoDeleteObjects: true, - blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, - }); - - // Upload the MCP server zip (built by src/scripts/package-mcp-server.sh) - const mcpServerZipKey = "mcp-server/mcp-server-deployment.zip"; - const zipDeployment = new s3deploy.BucketDeployment(this, "McpServerZipDeployment", { - sources: [ - s3deploy.Source.asset(path.join(__dirname, "..", "..", "..", "build"), { - exclude: ["deployment_package"], - }), - ], - destinationBucket: deploymentBucket, - destinationKeyPrefix: "mcp-server", - prune: true, - }); - - // ---------------------------------------------------------------- - // 3. DynamoDB Tables - // ---------------------------------------------------------------- - const unicornsTable = new dynamodb.Table(this, "UnicornsTable", { - tableName: `${project}-unicorns`, - partitionKey: { name: "unicorn_id", type: dynamodb.AttributeType.STRING }, - billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, - removalPolicy: cdk.RemovalPolicy.DESTROY, - }); - - const bookingsTable = new dynamodb.Table(this, "BookingsTable", { - tableName: `${project}-bookings`, - partitionKey: { name: "booking_id", type: dynamodb.AttributeType.STRING }, - billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, - removalPolicy: cdk.RemovalPolicy.DESTROY, - }); - - // Seed unicorn data into the table - const widgetBaseUrl = `https://${widgetsCdn.domainName}`; - const unicornsSeedData = [ - { - unicorn_id: { S: "uc-001" }, - name: { S: "Stardust" }, - type: { S: "Classic" }, - description: { S: "A gentle, silver-maned unicorn perfect for beginners." }, - hourly_rate: { N: "75" }, - image_url: { S: `${widgetBaseUrl}/images/stardust.png` }, - available: { BOOL: true }, - }, - { - unicorn_id: { S: "uc-002" }, - name: { S: "Moonbeam" }, - type: { S: "Classic" }, - description: { S: "An elegant white unicorn with a pearlescent horn." }, - hourly_rate: { N: "85" }, - image_url: { S: `${widgetBaseUrl}/images/moonbeam.png` }, - available: { BOOL: true }, - }, - { - unicorn_id: { S: "uc-003" }, - name: { S: "Prism" }, - type: { S: "Rainbow" }, - description: { S: "A dazzling rainbow-maned unicorn that shimmers in sunlight." }, - hourly_rate: { N: "120" }, - image_url: { S: `${widgetBaseUrl}/images/prism.png` }, - available: { BOOL: true }, - }, - { - unicorn_id: { S: "uc-004" }, - name: { S: "Aurora" }, - type: { S: "Rainbow" }, - description: { S: "A majestic unicorn with aurora-colored flowing mane." }, - hourly_rate: { N: "130" }, - image_url: { S: `${widgetBaseUrl}/images/aurora.png` }, - available: { BOOL: false }, - }, - { - unicorn_id: { S: "uc-005" }, - name: { S: "Zephyr" }, - type: { S: "Winged" }, - description: { S: "A rare winged unicorn capable of short flights." }, - hourly_rate: { N: "200" }, - image_url: { S: `${widgetBaseUrl}/images/zephyr.png` }, - available: { BOOL: true }, - }, - { - unicorn_id: { S: "uc-006" }, - name: { S: "Tempest" }, - type: { S: "Winged" }, - description: { S: "A powerful winged unicorn with storm-grey coat." }, - hourly_rate: { N: "250" }, - image_url: { S: `${widgetBaseUrl}/images/tempest.png` }, - available: { BOOL: true }, - }, - ]; - - // BatchWriteItem supports max 25 items per call — we have 6, so one call is fine - new cr.AwsCustomResource(this, "SeedUnicornsData", { - onCreate: { - service: "DynamoDB", - action: "batchWriteItem", - parameters: { - RequestItems: { - [unicornsTable.tableName]: unicornsSeedData.map((item) => ({ - PutRequest: { Item: item }, - })), - }, - }, - physicalResourceId: cr.PhysicalResourceId.of("seed-unicorns-v1"), - }, - policy: cr.AwsCustomResourcePolicy.fromStatements([ - new iam.PolicyStatement({ - actions: ["dynamodb:BatchWriteItem"], - resources: [unicornsTable.tableArn], - }), - ]), - }); - - // Seed a sample booking record - new cr.AwsCustomResource(this, "SeedBookingsData", { - onCreate: { - service: "DynamoDB", - action: "putItem", - parameters: { - TableName: bookingsTable.tableName, - Item: { - booking_id: { S: "BK-SAMPLE01" }, - unicorn: { - M: { - unicorn_id: { S: "uc-001" }, - name: { S: "Stardust" }, - type: { S: "Classic" }, - hourly_rate: { N: "75" }, - available: { BOOL: true }, - }, - }, - customer_name: { S: "Jane Doe" }, - date: { S: "2026-06-01" }, - duration_hours: { N: "2" }, - total_cost: { N: "150" }, - status: { S: "confirmed" }, - booked_at: { S: "2026-05-19T10:00:00" }, - }, - }, - physicalResourceId: cr.PhysicalResourceId.of("seed-bookings-v1"), - }, - policy: cr.AwsCustomResourcePolicy.fromStatements([ - new iam.PolicyStatement({ - actions: ["dynamodb:PutItem"], - resources: [bookingsTable.tableArn], - }), - ]), - }); - - // ---------------------------------------------------------------- - // 4a. Unicorn Service Lambda (business logic, no MCP awareness) - // ---------------------------------------------------------------- - const unicornServiceFn = new lambda.Function(this, "UnicornServiceFunction", { - functionName: `${project}-service`, - runtime: lambda.Runtime.PYTHON_3_12, - handler: "unicorn_service.lambda_handler", - code: lambda.Code.fromAsset(path.join(__dirname, "..", "..", "..", "src", "lambda", "unicorn-rental-service")), - timeout: cdk.Duration.seconds(30), - environment: { - UNICORNS_TABLE: unicornsTable.tableName, - BOOKINGS_TABLE: bookingsTable.tableName, - }, - }); - - // Grant the service Lambda DynamoDB access - unicornServiceFn.addToRolePolicy( - new iam.PolicyStatement({ - actions: [ - "dynamodb:Scan", - "dynamodb:GetItem", - "dynamodb:PutItem", - "dynamodb:UpdateItem", - "dynamodb:Query", - ], - resources: [unicornsTable.tableArn, bookingsTable.tableArn], - }) - ); - - // ---------------------------------------------------------------- - // 4b. IAM Role for AgentCore Runtime - // ---------------------------------------------------------------- - const agentCoreRole = new iam.Role(this, "AgentCoreRole", { - roleName: `${project}-agentcore-role`, - assumedBy: new iam.ServicePrincipal("bedrock-agentcore.amazonaws.com"), - }); - - // S3 permissions to read the deployment zip - agentCoreRole.addToPolicy( - new iam.PolicyStatement({ - actions: ["s3:GetObject", "s3:GetObjectVersion"], - resources: [`${deploymentBucket.bucketArn}/*`], - }) - ); - - // Lambda invoke permission — MCP server calls the service Lambda - agentCoreRole.addToPolicy( - new iam.PolicyStatement({ - actions: ["lambda:InvokeFunction"], - resources: [unicornServiceFn.functionArn], - }) - ); - - // ---------------------------------------------------------------- - // 5. AgentCore Runtime (MCP Server) — Direct Code Deploy (Node.js 22) - // ---------------------------------------------------------------- - const agentCoreRuntime = new cdk.CfnResource(this, "McpRuntime", { - type: "AWS::BedrockAgentCore::Runtime", - properties: { - AgentRuntimeName: `${project.replace(/-/g, "_")}_runtime`, - Description: `${project} MCP Server on AgentCore Runtime (Node.js)`, - RoleArn: agentCoreRole.roleArn, - AgentRuntimeArtifact: { - CodeConfiguration: { - Code: { - S3: { - Bucket: deploymentBucket.bucketName, - Prefix: mcpServerZipKey, - }, - }, - EntryPoint: ["main.js"], - Runtime: "NODE_22", - }, - }, - NetworkConfiguration: { - NetworkMode: "PUBLIC", - }, - ProtocolConfiguration: "MCP", - EnvironmentVariables: { - UNICORN_SERVICE_FUNCTION: unicornServiceFn.functionName, - CORS_ALLOWED_ORIGINS: corsAllowedOriginsParam.valueAsString, - }, - }, - }); - agentCoreRuntime.node.addDependency(zipDeployment); - - const runtimeArn = agentCoreRuntime.getAtt("AgentRuntimeArn").toString(); - const runtimeId = agentCoreRuntime.getAtt("AgentRuntimeId").toString(); - - new cdk.CfnResource(this, "McpEndpoint", { - type: "AWS::BedrockAgentCore::RuntimeEndpoint", - properties: { - Name: `${project.replace(/-/g, "_")}_endpoint`, - AgentRuntimeId: runtimeId, - Description: `${project} MCP endpoint`, - }, - }); - - // ---------------------------------------------------------------- - // 6. AgentCore Gateway — No Auth inbound, MCP Server target - // ---------------------------------------------------------------- - - // The Gateway uses No Auth for inbound requests (WAF provides IP-based protection) - const gateway = new agentcore.Gateway(this, "McpGateway", { - gatewayName: `${project}-gateway`, - description: "AgentCore Gateway for Unicorn Rentals MCP Server", - protocolConfiguration: new agentcore.McpProtocolConfiguration({ - instructions: "Use this gateway to access the Unicorn Rentals MCP tools", - searchType: agentcore.McpGatewaySearchType.SEMANTIC, - supportedVersions: [agentcore.MCPProtocolVersion.MCP_2025_03_26], - }), - authorizerConfiguration: agentcore.GatewayAuthorizer.withNoAuth(), - }); - - // The MCP endpoint URL of the AgentCore Runtime - // The endpoint requires URL-encoded ARN: https://bedrock-agentcore..amazonaws.com/runtimes//invocations?qualifier=DEFAULT - // Since runtimeArn is a CFN token, we use Fn.join to perform URL-encoding at deploy time. - // ARN format: arn:aws:bedrock-agentcore:::runtime/ - // Encoded: arn%3Aaws%3Abedrock-agentcore%3A%3A%3Aruntime%2F - const encodedRuntimeArn = cdk.Fn.join("", [ - "arn%3Aaws%3Abedrock-agentcore%3A", - this.region, - "%3A", - this.account, - "%3Aruntime%2F", - runtimeId, - ]); - const runtimeMcpEndpoint = `https://bedrock-agentcore.${this.region}.amazonaws.com/runtimes/${encodedRuntimeArn}/invocations?qualifier=DEFAULT`; - - // Add MCP Server target pointing to the AgentCore Runtime's MCP endpoint - // Uses IAM (SigV4) authentication for outbound calls to the Runtime - // Note: The L2 fromIamRole() doesn't pass service/region, but the service - // requires them for MCP Server targets. Using L1 (CfnResource) directly. - const runtimeMcpTarget = new cdk.CfnResource(this, "RuntimeMcpTarget", { - type: "AWS::BedrockAgentCore::GatewayTarget", - properties: { - GatewayIdentifier: gateway.gatewayId, - Name: `${project}-runtime-target`, - Description: "AgentCore Runtime MCP Server target with IAM auth", - TargetConfiguration: { - Mcp: { - McpServer: { - Endpoint: runtimeMcpEndpoint, - }, - }, - }, - CredentialProviderConfigurations: [ - { - CredentialProviderType: "GATEWAY_IAM_ROLE", - CredentialProvider: { - IamCredentialProvider: { - Service: "bedrock-agentcore", - Region: this.region, - }, - }, - }, - ], - }, - }); - - // Grant the Gateway's execution role permission to invoke the Runtime - const gatewayPolicyGrant = gateway.role.addToPrincipalPolicy( - new iam.PolicyStatement({ - actions: ["bedrock-agentcore:InvokeAgentRuntime"], - resources: [`${runtimeArn}*`], - }) - ); - - // Ensure the target isn't created until the Gateway role's IAM policy has been applied. - // Without this, CFN may create the target before IAM propagates, causing "Authorization error". - if (gatewayPolicyGrant.policyDependable) { - runtimeMcpTarget.node.addDependency(gatewayPolicyGrant.policyDependable); - } - - // ---------------------------------------------------------------- - // 6b. Resource-Based Policy — restrict runtime invocation to Gateway only - // ---------------------------------------------------------------- - const resourcePolicyFn = new lambda.Function(this, "ResourcePolicyFunction", { - functionName: `${project}-resource-policy-cr`, - runtime: lambda.Runtime.PYTHON_3_12, - handler: "index.lambda_handler", - code: lambda.Code.fromAsset(path.join(__dirname, "..", "lambda", "resource-policy-cr")), - timeout: cdk.Duration.seconds(60), - }); - - resourcePolicyFn.addToRolePolicy( - new iam.PolicyStatement({ - actions: [ - "bedrock-agentcore:PutResourcePolicy", - "bedrock-agentcore:GetResourcePolicy", - "bedrock-agentcore:DeleteResourcePolicy", - ], - resources: ["*"], - }) - ); - - const resourcePolicyProvider = new cr.Provider(this, "ResourcePolicyProvider", { - onEventHandler: resourcePolicyFn, - }); - - const runtimeResourcePolicy = new cdk.CustomResource(this, "RuntimeResourcePolicy", { - serviceToken: resourcePolicyProvider.serviceToken, - properties: { - ResourceArn: runtimeArn, - Policy: JSON.stringify({ - Version: "2012-10-17", - Statement: [ - { - Sid: "AllowGatewayOnly", - Effect: "Allow", - Principal: { - AWS: gateway.role.roleArn, - }, - Action: "bedrock-agentcore:InvokeAgentRuntime", - Resource: runtimeArn, - }, - { - Sid: "DenyAllOtherPrincipals", - Effect: "Deny", - Principal: "*", - Action: "bedrock-agentcore:InvokeAgentRuntime", - Resource: runtimeArn, - Condition: { - StringNotEquals: { - "aws:PrincipalArn": gateway.role.roleArn, - }, - }, - }, - ], - }), - }, - }); - - // Ensure the Gateway Target is not created until the resource policy is in place, - // otherwise CFN's connectivity validation fails with "Authorization error". - runtimeMcpTarget.node.addDependency(runtimeResourcePolicy); - - // ---------------------------------------------------------------- - // 7. WAF — IP Allowlist + Basic Protection - // ---------------------------------------------------------------- - - // ChatGPT Actions outbound IP ranges (source: https://openai.com/chatgpt-actions.json) - const chatGptIpSet = new wafv2.CfnIPSet(this, "ChatGptIpSet", { - name: `${project}-chatgpt-ips`, - scope: "REGIONAL", - ipAddressVersion: "IPV4", - addresses: [ - "104.210.139.192/28", "104.210.139.224/28", "13.65.138.112/28", - "13.65.138.96/28", "13.67.72.16/28", "13.70.107.160/28", - "13.71.2.208/28", "13.76.115.224/28", "13.76.116.80/28", - "13.76.32.208/28", "13.83.237.176/28", "132.196.82.48/28", - "135.119.134.128/28", "135.119.134.192/28", "135.220.73.208/28", - "135.237.133.48/28", "137.135.191.176/28", "15.168.252.168/32", - "172.170.8.208/28", "172.177.53.240/28", "172.183.143.224/28", - "172.196.40.208/28", "172.202.102.112/28", "172.204.16.64/28", - "172.212.159.64/28", "172.213.11.144/28", "191.233.1.128/28", - "191.233.1.224/28", "191.233.196.112/28", "191.234.167.128/28", - "191.235.98.144/28", "191.237.249.64/28", "20.0.53.96/28", - "20.102.212.144/28", "20.113.218.16/28", "20.125.112.224/28", - "20.168.7.192/28", "20.168.7.240/28", "20.169.72.112/28", - "20.169.72.96/28", "20.169.78.208/28", "20.169.78.48/28", - "20.169.78.64/28", "20.169.78.80/28", "20.169.78.96/28", - "20.169.86.224/28", "20.17.108.96/28", "20.172.29.32/28", - "20.193.50.32/28", "20.194.0.208/28", "20.194.157.176/28", - "20.198.67.96/28", "20.203.245.32/28", "20.206.107.192/28", - "20.210.154.128/28", "20.210.174.208/28", "20.215.187.208/28", - "20.215.214.16/28", "20.215.219.208/28", "20.215.220.128/28", - "20.215.220.144/28", "20.215.220.160/28", "20.215.220.64/28", - "20.215.220.80/28", "20.227.140.32/28", "20.228.106.176/28", - "20.235.75.208/28", "20.235.87.224/28", "20.249.63.208/28", - "20.42.250.32/28", "20.44.100.224/28", "20.45.178.144/28", - "20.55.229.144/28", "20.57.199.192/28", "20.63.221.64/28", - "23.102.141.32/28", "23.97.109.224/28", "23.98.186.64/28", - "23.98.186.96/28", "4.151.119.48/28", "4.151.71.176/28", - "4.189.118.208/28", "4.196.198.80/28", "4.197.115.112/28", - "4.197.19.176/28", "4.197.64.0/28", "4.197.64.48/28", - "4.205.128.176/28", "4.226.226.32/28", "40.67.183.160/28", - "40.67.183.176/28", "40.81.134.128/28", "40.84.181.32/28", - "44.249.227.138/32", "48.193.44.32/28", "51.116.2.64/28", - "52.148.129.32/28", "52.153.130.48/28", "52.165.212.48/28", - "52.17.188.55/32", "52.172.129.160/28", "52.172.251.112/28", - "52.173.123.0/28", "52.173.221.16/28", "52.173.234.16/28", - "52.173.234.80/28", "52.176.139.176/28", "52.190.137.144/28", - "52.190.137.16/28", "52.190.139.48/28", "52.190.142.64/28", - "52.208.217.159/32", "52.231.30.48/28", "52.231.39.144/28", - "52.231.39.192/28", "52.242.132.224/28", "52.242.132.240/28", - "52.242.245.208/28", "52.252.113.240/28", "52.255.109.112/28", - "52.255.109.128/28", "52.255.109.144/28", "52.255.109.80/28", - "52.255.109.96/28", "52.255.111.0/28", "52.255.111.16/28", - "52.255.111.32/28", "52.43.161.225/32", "56.155.71.179/32", - "57.151.131.224/28", "57.154.174.112/28", "57.154.187.32/28", - "68.154.28.96/28", "68.220.57.64/28", "68.221.67.160/28", - "68.221.67.240/28", "68.221.75.16/28", "74.226.253.160/28", - "74.249.86.176/28", "74.7.35.112/28", "74.7.35.48/28", - "74.7.36.64/28", "74.7.36.80/28", "74.7.36.96/28", - "9.160.163.224/28", "9.234.96.192/28", - ], - }); - - // Anthropic/Claude outbound IP ranges (source: https://docs.anthropic.com/claude/reference/ip-addresses) - const claudeIpSet = new wafv2.CfnIPSet(this, "ClaudeIpSet", { - name: `${project}-claude-ips`, - scope: "REGIONAL", - ipAddressVersion: "IPV4", - addresses: [ - "160.79.104.0/21", - ], - }); - - // Web ACL with IP allowlist + AWS Managed Rules for common threats - const webAcl = new wafv2.CfnWebACL(this, "ApiWafAcl", { - name: `${project}-gateway-waf`, - scope: "REGIONAL", - defaultAction: { block: {} }, - visibilityConfig: { - cloudWatchMetricsEnabled: true, - metricName: `${project}-waf-metrics`, - sampledRequestsEnabled: true, - }, - rules: [ - // Rule 1: Allow ChatGPT IPs - { - name: "AllowChatGptIPs", - priority: 1, - action: { allow: {} }, - statement: { - ipSetReferenceStatement: { - arn: chatGptIpSet.attrArn, - }, - }, - visibilityConfig: { - cloudWatchMetricsEnabled: true, - metricName: `${project}-chatgpt-ip-allow`, - sampledRequestsEnabled: true, - }, - }, - // Rule 2: Allow Claude/Anthropic IPs - { - name: "AllowClaudeIPs", - priority: 2, - action: { allow: {} }, - statement: { - ipSetReferenceStatement: { - arn: claudeIpSet.attrArn, - }, - }, - visibilityConfig: { - cloudWatchMetricsEnabled: true, - metricName: `${project}-claude-ip-allow`, - sampledRequestsEnabled: true, - }, - }, - // Rule 3: AWS Managed Rules — Common Rule Set - { - name: "AWSManagedRulesCommonRuleSet", - priority: 3, - overrideAction: { none: {} }, - statement: { - managedRuleGroupStatement: { - vendorName: "AWS", - name: "AWSManagedRulesCommonRuleSet", - }, - }, - visibilityConfig: { - cloudWatchMetricsEnabled: true, - metricName: `${project}-common-rules`, - sampledRequestsEnabled: true, - }, - }, - // Rule 4: AWS Managed Rules — Known Bad Inputs - { - name: "AWSManagedRulesKnownBadInputsRuleSet", - priority: 4, - overrideAction: { none: {} }, - statement: { - managedRuleGroupStatement: { - vendorName: "AWS", - name: "AWSManagedRulesKnownBadInputsRuleSet", - }, - }, - visibilityConfig: { - cloudWatchMetricsEnabled: true, - metricName: `${project}-known-bad-inputs`, - sampledRequestsEnabled: true, - }, - }, - // Rule 5: Rate limiting — 1000 requests per 5 minutes per IP - { - name: "RateLimitRule", - priority: 5, - action: { block: {} }, - statement: { - rateBasedStatement: { - limit: 1000, - aggregateKeyType: "IP", - }, - }, - visibilityConfig: { - cloudWatchMetricsEnabled: true, - metricName: `${project}-rate-limit`, - sampledRequestsEnabled: true, - }, - }, - ], - }); - - // Associate WAF Web ACL with the AgentCore Gateway - new wafv2.CfnWebACLAssociation(this, "GatewayWafAssociation", { - resourceArn: gateway.gatewayArn, - webAclArn: webAcl.attrArn, - }); - - // ---------------------------------------------------------------- - // Outputs - // ---------------------------------------------------------------- - new cdk.CfnOutput(this, "GatewayResourceUrl", { - description: "MCP Server URL — copy and paste this into your AI host (ChatGPT, Claude, etc.)", - value: gateway.gatewayUrl!, - }); - new cdk.CfnOutput(this, "GatewayArn", { - description: "AgentCore Gateway ARN", - value: gateway.gatewayArn, - }); - new cdk.CfnOutput(this, "WidgetBaseUrl", { - description: "CloudFront URL for images", - value: `https://${widgetsCdn.domainName}`, - }); - new cdk.CfnOutput(this, "RuntimeArn", { - description: "AgentCore Runtime ARN", - value: runtimeArn, - }); - new cdk.CfnOutput(this, "CloudFrontDistributionId", { - description: "CloudFront distribution ID", - value: widgetsCdn.distributionId, - }); - new cdk.CfnOutput(this, "UnicornServiceFunctionArn", { - description: "Unicorn Service Lambda ARN", - value: unicornServiceFn.functionArn, - }); - - // ---------------------------------------------------------------- - // cdk-nag Suppressions (per-resource) - // ---------------------------------------------------------------- - - // S3 Buckets — access logs not required for demo/sample application - NagSuppressions.addResourceSuppressions(widgetsBucket, [ - { id: "AwsSolutions-S1", reason: "Demo app — access logs add cost with no benefit for sample code." }, - ]); - NagSuppressions.addResourceSuppressions(deploymentBucket, [ - { id: "AwsSolutions-S1", reason: "Demo app — access logs add cost with no benefit for sample code." }, - ]); - - // S3 Bucket Policies — SSL enforcement: buckets accessed only via CloudFront OAC and CDK internals - NagSuppressions.addResourceSuppressions( - [widgetsBucket, deploymentBucket], - [{ id: "AwsSolutions-S10", reason: "Buckets accessed only via CloudFront OAC or CDK-internal operations, not directly by users." }], - true, // applyToChildren (catches the Policy/Resource child) - ); - - // CloudFront — demo serves public unicorn images, no custom domain - NagSuppressions.addResourceSuppressions(widgetsCdn, [ - { id: "AwsSolutions-CFR1", reason: "Demo app requires global access; geo restrictions not applicable." }, - { id: "AwsSolutions-CFR2", reason: "Static image CDN for public assets; WAF protection is on the Gateway instead." }, - { id: "AwsSolutions-CFR3", reason: "Demo app — CloudFront access logs not required for sample workload." }, - { id: "AwsSolutions-CFR4", reason: "No custom domain configured; cannot override default CloudFront viewer certificate TLS policy." }, - ]); - - // DynamoDB — demo tables with seed data, easily recreated via CDK deploy - NagSuppressions.addResourceSuppressions(unicornsTable, [ - { id: "AwsSolutions-DDB3", reason: "Demo tables with seed data — PITR not needed; data recreated on deploy." }, - ]); - NagSuppressions.addResourceSuppressions(bookingsTable, [ - { id: "AwsSolutions-DDB3", reason: "Demo tables with seed data — PITR not needed; data recreated on deploy." }, - ]); - - // Lambda runtimes — CDK BucketDeployment uses its own internal runtime; others use Python 3.12 - NagSuppressions.addResourceSuppressionsByPath(this, - "/AgentCoreMcpStack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Resource", - [{ id: "AwsSolutions-L1", reason: "CDK BucketDeployment internal Lambda — runtime managed by CDK, not user-configurable." }], - ); - NagSuppressions.addResourceSuppressions(unicornServiceFn, [ - { id: "AwsSolutions-L1", reason: "Python 3.12 is the latest stable runtime supported by the service code." }, - ]); - NagSuppressions.addResourceSuppressions(resourcePolicyFn, [ - { id: "AwsSolutions-L1", reason: "Python 3.12 is the latest stable runtime supported by the custom resource handler." }, - ]); - - // IAM4 — AWS managed policies: CDK-generated Lambda basic execution roles (standard CDK pattern) - NagSuppressions.addResourceSuppressionsByPath(this, - "/AgentCoreMcpStack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/Resource", - [{ id: "AwsSolutions-IAM4", reason: "CDK BucketDeployment internal role — managed by CDK.", appliesTo: ["Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"] }], - ); - NagSuppressions.addResourceSuppressionsByPath(this, - "/AgentCoreMcpStack/AWS679f53fac002430cb0da5b7982bd2287/ServiceRole/Resource", - [{ id: "AwsSolutions-IAM4", reason: "CDK AwsCustomResource internal role — managed by CDK.", appliesTo: ["Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"] }], - ); - NagSuppressions.addResourceSuppressions(unicornServiceFn, [ - { id: "AwsSolutions-IAM4", reason: "Standard CDK Lambda execution role pattern.", appliesTo: ["Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"] }, - ], true); - NagSuppressions.addResourceSuppressions(resourcePolicyFn, [ - { id: "AwsSolutions-IAM4", reason: "Standard CDK Lambda execution role pattern.", appliesTo: ["Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"] }, - ], true); - NagSuppressions.addResourceSuppressionsByPath(this, - "/AgentCoreMcpStack/ResourcePolicyProvider/framework-onEvent/ServiceRole/Resource", - [{ id: "AwsSolutions-IAM4", reason: "CDK Provider framework internal role — managed by CDK.", appliesTo: ["Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"] }], - ); - - // IAM5 — Wildcard permissions on CDK-managed and custom resource roles - NagSuppressions.addResourceSuppressionsByPath(this, - "/AgentCoreMcpStack/AgentCoreRole/DefaultPolicy/Resource", - [{ id: "AwsSolutions-IAM5", reason: "AgentCore role needs s3:GetObject on all objects in deployment bucket.", appliesTo: ["Resource::/*"] }], - ); - NagSuppressions.addResourceSuppressionsByPath(this, - "/AgentCoreMcpStack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy/Resource", - [{ - id: "AwsSolutions-IAM5", - reason: "CDK BucketDeployment requires broad S3 permissions to deploy assets — managed by CDK.", - appliesTo: [ - "Action::s3:GetObject*", - "Action::s3:GetBucket*", - "Action::s3:List*", - "Action::s3:DeleteObject*", - "Action::s3:Abort*", - { regex: "/^Resource::arn::s3:::cdk-hnb659fds-assets-.*/" }, - "Resource::/*", - "Resource::/*", - "Resource::*", - ], - }], - ); - NagSuppressions.addResourceSuppressionsByPath(this, - "/AgentCoreMcpStack/ResourcePolicyFunction/ServiceRole/DefaultPolicy/Resource", - [{ id: "AwsSolutions-IAM5", reason: "bedrock-agentcore:PutResourcePolicy does not support ARN-level scoping at deploy time (runtime ARN is a CFN token).", appliesTo: ["Resource::*"] }], - ); - NagSuppressions.addResourceSuppressionsByPath(this, - "/AgentCoreMcpStack/ResourcePolicyProvider/framework-onEvent/ServiceRole/DefaultPolicy/Resource", - [{ id: "AwsSolutions-IAM5", reason: "CDK Provider framework invokes the onEvent handler — wildcard qualifier is CDK-managed.", appliesTo: ["Resource:::*"] }], - ); - - // Gateway role — needs InvokeAgentRuntime with wildcard qualifier on the runtime ARN - NagSuppressions.addResourceSuppressionsByPath(this, - "/AgentCoreMcpStack/McpGateway/ServiceRole/DefaultPolicy/Resource", - [{ id: "AwsSolutions-IAM5", reason: "Gateway role requires bedrock-agentcore:InvokeAgentRuntime on runtime ARN with wildcard qualifier for endpoint routing.", appliesTo: ["Resource::*"] }], - ); - } -} +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 +/** + * CDK Stack for deploying the Unicorn Rentals MCP Server on AgentCore Runtime. + * + * Resources created: + * 1. S3 + CloudFront for image hosting + * 2. S3 bucket for MCP server deployment package + * 3. DynamoDB tables for unicorn and booking data + * 4. IAM Role for AgentCore Runtime + * 5. AgentCore Runtime (MCP Server) via direct code deploy (Node.js 22) + * 6. AgentCore Gateway (No Auth inbound, MCP target pointing to Runtime) + * 7. CloudFront front door for the Gateway — WAF (CLOUDFRONT scope, from + * EdgeWafStack in us-east-1) + CloudFront Function that fixes the OAuth + * protected-resource discovery response (replaces Lambda@Edge) + */ + +import * as cdk from "aws-cdk-lib"; +import * as s3 from "aws-cdk-lib/aws-s3"; +import * as s3deploy from "aws-cdk-lib/aws-s3-deployment"; +import * as cloudfront from "aws-cdk-lib/aws-cloudfront"; +import * as origins from "aws-cdk-lib/aws-cloudfront-origins"; +import * as cognito from "aws-cdk-lib/aws-cognito"; +import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; +import * as iam from "aws-cdk-lib/aws-iam"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import * as cr from "aws-cdk-lib/custom-resources"; +import * as agentcore from "aws-cdk-lib/aws-bedrockagentcore"; +import { NagSuppressions } from "cdk-nag"; +import { Construct } from "constructs"; +import * as fs from "fs"; +import * as path from "path"; + +interface AgentCoreMcpStackProps extends cdk.StackProps { + projectName: string; + /** ARN of the CLOUDFRONT-scope WAF Web ACL (created in us-east-1 by EdgeWafStack). */ + webAclArn: string; +} + +export class AgentCoreMcpStack extends cdk.Stack { + constructor(scope: Construct, id: string, props: AgentCoreMcpStackProps) { + super(scope, id, props); + + const project = props.projectName; + + // ---------------------------------------------------------------- + // CDK Parameters + // ---------------------------------------------------------------- + const corsAllowedOriginsParam = new cdk.CfnParameter(this, "CorsAllowedOrigins", { + type: "String", + description: "Comma-separated list of allowed CORS origins", + default: "https://chatgpt.com,https://chat.openai.com,https://claude.ai,https://www.claude.ai,http://localhost:8000", + }); + + // ---------------------------------------------------------------- + // 1. S3 + CloudFront for Images + // ---------------------------------------------------------------- + const widgetsBucket = new s3.Bucket(this, "WidgetsBucket", { + bucketName: `${project}-widgets-${this.account}`, + removalPolicy: cdk.RemovalPolicy.DESTROY, + autoDeleteObjects: true, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + }); + + const widgetsCdn = new cloudfront.Distribution(this, "WidgetsCdn", { + defaultBehavior: { + origin: origins.S3BucketOrigin.withOriginAccessControl(widgetsBucket), + viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + }, + defaultRootObject: "index.html", + comment: `${project} widgets CDN`, + }); + + // Deploy unicorn images to the widgets bucket under /images/ + new s3deploy.BucketDeployment(this, "UnicornImagesDeployment", { + sources: [ + s3deploy.Source.asset(path.join(__dirname, "..", "..", "..", "src", "assets")), + ], + destinationBucket: widgetsBucket, + destinationKeyPrefix: "images", + distribution: widgetsCdn, + distributionPaths: ["/images/*"], + }); + + // ---------------------------------------------------------------- + // 2. S3 Bucket for MCP Server Deployment Package + // ---------------------------------------------------------------- + const deploymentBucket = new s3.Bucket(this, "DeploymentBucket", { + bucketName: `${project}-deployment-${this.account}`, + removalPolicy: cdk.RemovalPolicy.DESTROY, + autoDeleteObjects: true, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + }); + + // Upload the MCP server zip (built by src/scripts/package-mcp-server.sh) + const mcpServerZipKey = "mcp-server/mcp-server-deployment.zip"; + const zipDeployment = new s3deploy.BucketDeployment(this, "McpServerZipDeployment", { + sources: [ + s3deploy.Source.asset(path.join(__dirname, "..", "..", "..", "build"), { + exclude: ["deployment_package"], + }), + ], + destinationBucket: deploymentBucket, + destinationKeyPrefix: "mcp-server", + prune: true, + }); + + // ---------------------------------------------------------------- + // 3. DynamoDB Tables + // ---------------------------------------------------------------- + const unicornsTable = new dynamodb.Table(this, "UnicornsTable", { + tableName: `${project}-unicorns`, + partitionKey: { name: "unicorn_id", type: dynamodb.AttributeType.STRING }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + const bookingsTable = new dynamodb.Table(this, "BookingsTable", { + tableName: `${project}-bookings`, + partitionKey: { name: "booking_id", type: dynamodb.AttributeType.STRING }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + // Seed unicorn data into the table + const widgetBaseUrl = `https://${widgetsCdn.domainName}`; + const unicornsSeedData = [ + { + unicorn_id: { S: "uc-001" }, + name: { S: "Stardust" }, + type: { S: "Classic" }, + description: { S: "A gentle, silver-maned unicorn perfect for beginners." }, + hourly_rate: { N: "75" }, + image_url: { S: `${widgetBaseUrl}/images/stardust.png` }, + available: { BOOL: true }, + }, + { + unicorn_id: { S: "uc-002" }, + name: { S: "Moonbeam" }, + type: { S: "Classic" }, + description: { S: "An elegant white unicorn with a pearlescent horn." }, + hourly_rate: { N: "85" }, + image_url: { S: `${widgetBaseUrl}/images/moonbeam.png` }, + available: { BOOL: true }, + }, + { + unicorn_id: { S: "uc-003" }, + name: { S: "Prism" }, + type: { S: "Rainbow" }, + description: { S: "A dazzling rainbow-maned unicorn that shimmers in sunlight." }, + hourly_rate: { N: "120" }, + image_url: { S: `${widgetBaseUrl}/images/prism.png` }, + available: { BOOL: true }, + }, + { + unicorn_id: { S: "uc-004" }, + name: { S: "Aurora" }, + type: { S: "Rainbow" }, + description: { S: "A majestic unicorn with aurora-colored flowing mane." }, + hourly_rate: { N: "130" }, + image_url: { S: `${widgetBaseUrl}/images/aurora.png` }, + available: { BOOL: false }, + }, + { + unicorn_id: { S: "uc-005" }, + name: { S: "Zephyr" }, + type: { S: "Winged" }, + description: { S: "A rare winged unicorn capable of short flights." }, + hourly_rate: { N: "200" }, + image_url: { S: `${widgetBaseUrl}/images/zephyr.png` }, + available: { BOOL: true }, + }, + { + unicorn_id: { S: "uc-006" }, + name: { S: "Tempest" }, + type: { S: "Winged" }, + description: { S: "A powerful winged unicorn with storm-grey coat." }, + hourly_rate: { N: "250" }, + image_url: { S: `${widgetBaseUrl}/images/tempest.png` }, + available: { BOOL: true }, + }, + ]; + + // BatchWriteItem supports max 25 items per call — we have 6, so one call is fine + new cr.AwsCustomResource(this, "SeedUnicornsData", { + onCreate: { + service: "DynamoDB", + action: "batchWriteItem", + parameters: { + RequestItems: { + [unicornsTable.tableName]: unicornsSeedData.map((item) => ({ + PutRequest: { Item: item }, + })), + }, + }, + physicalResourceId: cr.PhysicalResourceId.of("seed-unicorns-v1"), + }, + policy: cr.AwsCustomResourcePolicy.fromStatements([ + new iam.PolicyStatement({ + actions: ["dynamodb:BatchWriteItem"], + resources: [unicornsTable.tableArn], + }), + ]), + }); + + // Seed a sample booking record + new cr.AwsCustomResource(this, "SeedBookingsData", { + onCreate: { + service: "DynamoDB", + action: "putItem", + parameters: { + TableName: bookingsTable.tableName, + Item: { + booking_id: { S: "BK-SAMPLE01" }, + unicorn: { + M: { + unicorn_id: { S: "uc-001" }, + name: { S: "Stardust" }, + type: { S: "Classic" }, + hourly_rate: { N: "75" }, + available: { BOOL: true }, + }, + }, + customer_name: { S: "Jane Doe" }, + date: { S: "2026-06-01" }, + duration_hours: { N: "2" }, + total_cost: { N: "150" }, + status: { S: "confirmed" }, + booked_at: { S: "2026-05-19T10:00:00" }, + }, + }, + physicalResourceId: cr.PhysicalResourceId.of("seed-bookings-v1"), + }, + policy: cr.AwsCustomResourcePolicy.fromStatements([ + new iam.PolicyStatement({ + actions: ["dynamodb:PutItem"], + resources: [bookingsTable.tableArn], + }), + ]), + }); + + // ---------------------------------------------------------------- + // 4a. Unicorn Service Lambda (business logic, no MCP awareness) + // ---------------------------------------------------------------- + const unicornServiceFn = new lambda.Function(this, "UnicornServiceFunction", { + functionName: `${project}-service`, + runtime: lambda.Runtime.PYTHON_3_12, + handler: "unicorn_service.lambda_handler", + code: lambda.Code.fromAsset(path.join(__dirname, "..", "..", "..", "src", "lambda", "unicorn-rental-service")), + timeout: cdk.Duration.seconds(30), + environment: { + UNICORNS_TABLE: unicornsTable.tableName, + BOOKINGS_TABLE: bookingsTable.tableName, + }, + }); + + // Grant the service Lambda DynamoDB access + unicornServiceFn.addToRolePolicy( + new iam.PolicyStatement({ + actions: [ + "dynamodb:Scan", + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + "dynamodb:Query", + ], + resources: [unicornsTable.tableArn, bookingsTable.tableArn], + }) + ); + + // ---------------------------------------------------------------- + // 4b. IAM Role for AgentCore Runtime + // ---------------------------------------------------------------- + const agentCoreRole = new iam.Role(this, "AgentCoreRole", { + roleName: `${project}-agentcore-role`, + assumedBy: new iam.ServicePrincipal("bedrock-agentcore.amazonaws.com"), + }); + + // S3 permissions to read the deployment zip + agentCoreRole.addToPolicy( + new iam.PolicyStatement({ + actions: ["s3:GetObject", "s3:GetObjectVersion"], + resources: [`${deploymentBucket.bucketArn}/*`], + }) + ); + + // Lambda invoke permission — MCP server calls the service Lambda + agentCoreRole.addToPolicy( + new iam.PolicyStatement({ + actions: ["lambda:InvokeFunction"], + resources: [unicornServiceFn.functionArn], + }) + ); + + // ---------------------------------------------------------------- + // 5. AgentCore Runtime (MCP Server) — Direct Code Deploy (Node.js 22) + // ---------------------------------------------------------------- + const agentCoreRuntime = new cdk.CfnResource(this, "McpRuntime", { + type: "AWS::BedrockAgentCore::Runtime", + properties: { + AgentRuntimeName: `${project.replace(/-/g, "_")}_runtime`, + Description: `${project} MCP Server on AgentCore Runtime (Node.js)`, + RoleArn: agentCoreRole.roleArn, + AgentRuntimeArtifact: { + CodeConfiguration: { + Code: { + S3: { + Bucket: deploymentBucket.bucketName, + Prefix: mcpServerZipKey, + }, + }, + EntryPoint: ["main.js"], + Runtime: "NODE_22", + }, + }, + NetworkConfiguration: { + NetworkMode: "PUBLIC", + }, + ProtocolConfiguration: "MCP", + EnvironmentVariables: { + UNICORN_SERVICE_FUNCTION: unicornServiceFn.functionName, + CORS_ALLOWED_ORIGINS: corsAllowedOriginsParam.valueAsString, + }, + }, + }); + agentCoreRuntime.node.addDependency(zipDeployment); + + const runtimeArn = agentCoreRuntime.getAtt("AgentRuntimeArn").toString(); + const runtimeId = agentCoreRuntime.getAtt("AgentRuntimeId").toString(); + + new cdk.CfnResource(this, "McpEndpoint", { + type: "AWS::BedrockAgentCore::RuntimeEndpoint", + properties: { + Name: `${project.replace(/-/g, "_")}_endpoint`, + AgentRuntimeId: runtimeId, + Description: `${project} MCP endpoint`, + }, + }); + + // ---------------------------------------------------------------- + // 6. AgentCore Gateway — inbound auth + MCP Server target + // + // Default: No Auth inbound (the CloudFront front door's WAF provides + // IP-based protection). Deploy with `-c auth=cognito` to switch inbound + // auth to a Cognito JWT authorizer (client_credentials machine flow) — + // this also closes the "call the Gateway URL directly" bypass, because + // requests without a valid Cognito token are rejected by the Gateway + // itself, not just by the WAF at the edge. + // ---------------------------------------------------------------- + const authMode: string = this.node.tryGetContext("auth") || "none"; + + let authorizerConfiguration = agentcore.GatewayAuthorizer.withNoAuth(); + let cognitoIssuer: string | undefined; + let mcpUserPool: cognito.UserPool | undefined; + let mcpAuthClient: cognito.UserPoolClient | undefined; + let mcpAuthDomain: cognito.UserPoolDomain | undefined; + if (authMode === "cognito") { + mcpUserPool = new cognito.UserPool(this, "McpUserPool", { + userPoolName: `${project}-users`, + selfSignUpEnabled: false, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + const invokeScope = new cognito.ResourceServerScope({ + scopeName: "invoke", + scopeDescription: "Invoke MCP tools through the gateway", + }); + const resourceServer = mcpUserPool.addResourceServer("McpResourceServer", { + identifier: "mcp-gateway", + scopes: [invokeScope], + }); + // Hosted domain is required for the /oauth2/token endpoint + mcpAuthDomain = mcpUserPool.addDomain("McpAuthDomain", { + cognitoDomain: { domainPrefix: `${project}-${this.account}` }, + }); + // Machine-to-machine client: client_credentials flow, secret generated + mcpAuthClient = mcpUserPool.addClient("McpAuthClient", { + userPoolClientName: `${project}-m2m`, + generateSecret: true, + oAuth: { + flows: { clientCredentials: true }, + scopes: [cognito.OAuthScope.resourceServer(resourceServer, invokeScope)], + }, + }); + authorizerConfiguration = agentcore.GatewayAuthorizer.usingCognito({ + userPool: mcpUserPool, + allowedClients: [mcpAuthClient], + }); + cognitoIssuer = `https://cognito-idp.${this.region}.amazonaws.com/${mcpUserPool.userPoolId}`; + } else if (authMode !== "none") { + throw new Error(`Unsupported -c auth=${authMode}; use "none" or "cognito".`); + } + + // The service rejects in-place authorizer-type updates, so the auth mode + // is baked into the construct id and gateway name: switching modes + // replaces the Gateway (the CloudFront front-door URL stays the same — + // its origin just points at the new gateway hostname). + const gateway = new agentcore.Gateway(this, authMode === "cognito" ? "McpGatewayJwt" : "McpGateway", { + gatewayName: authMode === "cognito" ? `${project}-gateway-jwt` : `${project}-gateway`, + description: "AgentCore Gateway for Unicorn Rentals MCP Server", + protocolConfiguration: new agentcore.McpProtocolConfiguration({ + instructions: "Use this gateway to access the Unicorn Rentals MCP tools", + searchType: agentcore.McpGatewaySearchType.SEMANTIC, + // 2025-06-18 is the modern stateless streamable-HTTP revision (what + // current MCP clients negotiate); 2025-03-26 stays for older hosts. + supportedVersions: [ + agentcore.MCPProtocolVersion.MCP_2025_06_18, + agentcore.MCPProtocolVersion.MCP_2025_03_26, + ], + }), + authorizerConfiguration, + }); + + // The MCP endpoint URL of the AgentCore Runtime + // The endpoint requires URL-encoded ARN: https://bedrock-agentcore..amazonaws.com/runtimes//invocations?qualifier=DEFAULT + // Since runtimeArn is a CFN token, we use Fn.join to perform URL-encoding at deploy time. + // ARN format: arn:aws:bedrock-agentcore:::runtime/ + // Encoded: arn%3Aaws%3Abedrock-agentcore%3A%3A%3Aruntime%2F + const encodedRuntimeArn = cdk.Fn.join("", [ + "arn%3Aaws%3Abedrock-agentcore%3A", + this.region, + "%3A", + this.account, + "%3Aruntime%2F", + runtimeId, + ]); + const runtimeMcpEndpoint = `https://bedrock-agentcore.${this.region}.amazonaws.com/runtimes/${encodedRuntimeArn}/invocations?qualifier=DEFAULT`; + + // Add MCP Server target pointing to the AgentCore Runtime's MCP endpoint + // Uses IAM (SigV4) authentication for outbound calls to the Runtime + // Note: The L2 fromIamRole() doesn't pass service/region, but the service + // requires them for MCP Server targets. Using L1 (CfnResource) directly. + const runtimeMcpTarget = new cdk.CfnResource(this, "RuntimeMcpTarget", { + type: "AWS::BedrockAgentCore::GatewayTarget", + properties: { + GatewayIdentifier: gateway.gatewayId, + Name: `${project}-runtime-target`, + Description: "AgentCore Runtime MCP Server target with IAM auth", + TargetConfiguration: { + Mcp: { + McpServer: { + Endpoint: runtimeMcpEndpoint, + }, + }, + }, + CredentialProviderConfigurations: [ + { + CredentialProviderType: "GATEWAY_IAM_ROLE", + CredentialProvider: { + IamCredentialProvider: { + Service: "bedrock-agentcore", + Region: this.region, + }, + }, + }, + ], + }, + }); + + // Grant the Gateway's execution role permission to invoke the Runtime + const gatewayPolicyGrant = gateway.role.addToPrincipalPolicy( + new iam.PolicyStatement({ + actions: ["bedrock-agentcore:InvokeAgentRuntime"], + resources: [`${runtimeArn}*`], + }) + ); + + // Ensure the target isn't created until the Gateway role's IAM policy has been applied. + // Without this, CFN may create the target before IAM propagates, causing "Authorization error". + if (gatewayPolicyGrant.policyDependable) { + runtimeMcpTarget.node.addDependency(gatewayPolicyGrant.policyDependable); + } + + // ---------------------------------------------------------------- + // 6b. Resource-Based Policy — restrict runtime invocation to Gateway only + // ---------------------------------------------------------------- + const resourcePolicyFn = new lambda.Function(this, "ResourcePolicyFunction", { + functionName: `${project}-resource-policy-cr`, + runtime: lambda.Runtime.PYTHON_3_12, + handler: "index.lambda_handler", + code: lambda.Code.fromAsset(path.join(__dirname, "..", "lambda", "resource-policy-cr")), + timeout: cdk.Duration.seconds(60), + }); + + resourcePolicyFn.addToRolePolicy( + new iam.PolicyStatement({ + actions: [ + "bedrock-agentcore:PutResourcePolicy", + "bedrock-agentcore:GetResourcePolicy", + "bedrock-agentcore:DeleteResourcePolicy", + ], + resources: ["*"], + }) + ); + + const resourcePolicyProvider = new cr.Provider(this, "ResourcePolicyProvider", { + onEventHandler: resourcePolicyFn, + }); + + const runtimeResourcePolicy = new cdk.CustomResource(this, "RuntimeResourcePolicy", { + serviceToken: resourcePolicyProvider.serviceToken, + properties: { + ResourceArn: runtimeArn, + Policy: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Sid: "AllowGatewayOnly", + Effect: "Allow", + Principal: { + AWS: gateway.role.roleArn, + }, + Action: "bedrock-agentcore:InvokeAgentRuntime", + Resource: runtimeArn, + }, + { + Sid: "DenyAllOtherPrincipals", + Effect: "Deny", + Principal: "*", + Action: "bedrock-agentcore:InvokeAgentRuntime", + Resource: runtimeArn, + Condition: { + StringNotEquals: { + "aws:PrincipalArn": gateway.role.roleArn, + }, + }, + }, + ], + }), + }, + }); + + // Ensure the Gateway Target is not created until the resource policy is in place, + // otherwise CFN's connectivity validation fails with "Authorization error". + runtimeMcpTarget.node.addDependency(runtimeResourcePolicy); + + // ---------------------------------------------------------------- + // 7. CloudFront front door for the Gateway + // + // The Gateway keeps No Auth inbound; all edge protection moves to a + // CloudFront distribution in front of it: + // - WAF Web ACL (CLOUDFRONT scope, from EdgeWafStack in us-east-1) + // with the ChatGPT/Claude IP allowlist, managed rules and rate limit + // - A CloudFront Function that generates the OAuth protected-resource + // discovery response with the front-door domain. The AgentCore + // custom-domains guide does this with Lambda@Edge; a CloudFront + // Function is cheaper and simpler since it never leaves the edge. + // + // To attach a custom domain later, add `domainNames` + `certificate` + // to the distribution per the AgentCore custom-domains guide: + // https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-custom-domains.html + // ---------------------------------------------------------------- + + // gateway.gatewayUrl is "https://.gateway.bedrock-agentcore..amazonaws.com/mcp"; + // CloudFront origins need the bare hostname. + const gatewayHostName = cdk.Fn.select(2, cdk.Fn.split("/", gateway.gatewayUrl!)); + + const gatewayOrigin = new origins.HttpOrigin(gatewayHostName, { + protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY, + }); + + // The function source carries an __AUTH_SERVERS__ placeholder, filled at + // synth time: [] for No Auth, or the Cognito issuer for -c auth=cognito. + // (The issuer contains a CFN token, so the substitution resolves at deploy.) + const oauthDiscoverySource = fs + .readFileSync(path.join(__dirname, "functions", "oauth-discovery.js"), "utf8") + .split("__AUTH_SERVERS__") + .join(cognitoIssuer ? `["${cognitoIssuer}"]` : "[]"); + const oauthDiscoveryFn = new cloudfront.Function(this, "OauthDiscoveryFunction", { + functionName: `${project}-oauth-discovery`, + code: cloudfront.FunctionCode.fromInline(oauthDiscoverySource), + runtime: cloudfront.FunctionRuntime.JS_2_0, + comment: "Returns the OAuth protected-resource discovery document with the front-door domain", + }); + + const gatewayCdn = new cloudfront.Distribution(this, "GatewayCdn", { + defaultBehavior: { + origin: gatewayOrigin, + viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY, + // A reverse proxy for a dynamic MCP endpoint must never cache... + cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED, + // ...but must forward everything else the MCP protocol needs + // (Content-Type, Accept, Mcp-Session-Id, ...). The Host header is + // excluded so the TLS handshake with the Gateway origin succeeds. + originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER, + allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL, + }, + additionalBehaviors: { + // OAuth discovery is answered entirely at the edge by the CloudFront + // Function — the request never reaches the Gateway. + "/.well-known/oauth-protected-resource": { + origin: gatewayOrigin, + viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY, + cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED, + functionAssociations: [ + { + function: oauthDiscoveryFn, + eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, + }, + ], + }, + }, + webAclId: props.webAclArn, + httpVersion: cloudfront.HttpVersion.HTTP2_AND_3, + comment: `${project} gateway front door (WAF + OAuth discovery rewrite)`, + }); + + // ---------------------------------------------------------------- + // Outputs + // ---------------------------------------------------------------- + new cdk.CfnOutput(this, "GatewayResourceUrl", { + description: "MCP Server URL (CloudFront front door) — copy and paste this into your AI host (ChatGPT, Claude, etc.)", + value: `https://${gatewayCdn.domainName}/mcp`, + }); + new cdk.CfnOutput(this, "GatewayDirectUrl", { + description: "Raw AgentCore Gateway URL (bypasses CloudFront/WAF — do not distribute)", + value: gateway.gatewayUrl!, + }); + new cdk.CfnOutput(this, "GatewayCdnDistributionId", { + description: "CloudFront distribution ID for the gateway front door", + value: gatewayCdn.distributionId, + }); + new cdk.CfnOutput(this, "GatewayArn", { + description: "AgentCore Gateway ARN", + value: gateway.gatewayArn, + }); + if (mcpUserPool && mcpAuthClient && mcpAuthDomain) { + new cdk.CfnOutput(this, "CognitoUserPoolId", { + description: "Cognito User Pool ID (gateway inbound JWT authorizer)", + value: mcpUserPool.userPoolId, + }); + new cdk.CfnOutput(this, "CognitoClientId", { + description: "Cognito app client ID for the client_credentials flow (secret: aws cognito-idp describe-user-pool-client)", + value: mcpAuthClient.userPoolClientId, + }); + new cdk.CfnOutput(this, "CognitoTokenEndpoint", { + description: "OAuth2 token endpoint — POST grant_type=client_credentials&scope=mcp-gateway/invoke", + value: `${mcpAuthDomain.baseUrl()}/oauth2/token`, + }); + } + new cdk.CfnOutput(this, "WidgetBaseUrl", { + description: "CloudFront URL for images", + value: `https://${widgetsCdn.domainName}`, + }); + new cdk.CfnOutput(this, "RuntimeArn", { + description: "AgentCore Runtime ARN", + value: runtimeArn, + }); + new cdk.CfnOutput(this, "CloudFrontDistributionId", { + description: "CloudFront distribution ID", + value: widgetsCdn.distributionId, + }); + new cdk.CfnOutput(this, "UnicornServiceFunctionArn", { + description: "Unicorn Service Lambda ARN", + value: unicornServiceFn.functionArn, + }); + + // ---------------------------------------------------------------- + // cdk-nag Suppressions (per-resource) + // ---------------------------------------------------------------- + + // S3 Buckets — access logs not required for demo/sample application + NagSuppressions.addResourceSuppressions(widgetsBucket, [ + { id: "AwsSolutions-S1", reason: "Demo app — access logs add cost with no benefit for sample code." }, + ]); + NagSuppressions.addResourceSuppressions(deploymentBucket, [ + { id: "AwsSolutions-S1", reason: "Demo app — access logs add cost with no benefit for sample code." }, + ]); + + // S3 Bucket Policies — SSL enforcement: buckets accessed only via CloudFront OAC and CDK internals + NagSuppressions.addResourceSuppressions( + [widgetsBucket, deploymentBucket], + [{ id: "AwsSolutions-S10", reason: "Buckets accessed only via CloudFront OAC or CDK-internal operations, not directly by users." }], + true, // applyToChildren (catches the Policy/Resource child) + ); + + // CloudFront — demo serves public unicorn images, no custom domain + NagSuppressions.addResourceSuppressions(widgetsCdn, [ + { id: "AwsSolutions-CFR1", reason: "Demo app requires global access; geo restrictions not applicable." }, + { id: "AwsSolutions-CFR2", reason: "Static image CDN for public assets; WAF protection is on the gateway front-door distribution instead." }, + { id: "AwsSolutions-CFR3", reason: "Demo app — CloudFront access logs not required for sample workload." }, + { id: "AwsSolutions-CFR4", reason: "No custom domain configured; cannot override default CloudFront viewer certificate TLS policy." }, + ]); + + // Cognito (only with -c auth=cognito) — machine-to-machine pool, no human users + if (mcpUserPool) { + NagSuppressions.addResourceSuppressions(mcpUserPool, [ + { id: "AwsSolutions-COG1", reason: "Machine-to-machine pool (client_credentials only) — no human passwords exist." }, + { id: "AwsSolutions-COG2", reason: "Machine-to-machine pool — MFA does not apply to the client_credentials flow." }, + { id: "AwsSolutions-COG3", reason: "Demo app — advanced security mode adds cost; no user accounts to protect." }, + { id: "AwsSolutions-COG8", reason: "Machine-to-machine pool with no sign-ins — the Plus tier's sign-in protections add cost with nothing to protect." }, + ]); + } + + // CloudFront gateway front door — WAF attached (CFR2 satisfied); demo has no custom domain + NagSuppressions.addResourceSuppressions(gatewayCdn, [ + { id: "AwsSolutions-CFR1", reason: "Demo app requires global access; geo restrictions not applicable." }, + { id: "AwsSolutions-CFR3", reason: "Demo app — CloudFront access logs not required for sample workload." }, + { id: "AwsSolutions-CFR4", reason: "No custom domain configured; cannot override default CloudFront viewer certificate TLS policy." }, + ]); + + // DynamoDB — demo tables with seed data, easily recreated via CDK deploy + NagSuppressions.addResourceSuppressions(unicornsTable, [ + { id: "AwsSolutions-DDB3", reason: "Demo tables with seed data — PITR not needed; data recreated on deploy." }, + ]); + NagSuppressions.addResourceSuppressions(bookingsTable, [ + { id: "AwsSolutions-DDB3", reason: "Demo tables with seed data — PITR not needed; data recreated on deploy." }, + ]); + + // Lambda runtimes — CDK BucketDeployment uses its own internal runtime; others use Python 3.12 + NagSuppressions.addResourceSuppressionsByPath(this, + "/AgentCoreMcpStack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Resource", + [{ id: "AwsSolutions-L1", reason: "CDK BucketDeployment internal Lambda — runtime managed by CDK, not user-configurable." }], + ); + NagSuppressions.addResourceSuppressions(unicornServiceFn, [ + { id: "AwsSolutions-L1", reason: "Python 3.12 is the latest stable runtime supported by the service code." }, + ]); + NagSuppressions.addResourceSuppressions(resourcePolicyFn, [ + { id: "AwsSolutions-L1", reason: "Python 3.12 is the latest stable runtime supported by the custom resource handler." }, + ]); + + // IAM4 — AWS managed policies: CDK-generated Lambda basic execution roles (standard CDK pattern) + NagSuppressions.addResourceSuppressionsByPath(this, + "/AgentCoreMcpStack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/Resource", + [{ id: "AwsSolutions-IAM4", reason: "CDK BucketDeployment internal role — managed by CDK.", appliesTo: ["Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"] }], + ); + NagSuppressions.addResourceSuppressionsByPath(this, + "/AgentCoreMcpStack/AWS679f53fac002430cb0da5b7982bd2287/ServiceRole/Resource", + [{ id: "AwsSolutions-IAM4", reason: "CDK AwsCustomResource internal role — managed by CDK.", appliesTo: ["Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"] }], + ); + NagSuppressions.addResourceSuppressions(unicornServiceFn, [ + { id: "AwsSolutions-IAM4", reason: "Standard CDK Lambda execution role pattern.", appliesTo: ["Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"] }, + ], true); + NagSuppressions.addResourceSuppressions(resourcePolicyFn, [ + { id: "AwsSolutions-IAM4", reason: "Standard CDK Lambda execution role pattern.", appliesTo: ["Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"] }, + ], true); + NagSuppressions.addResourceSuppressionsByPath(this, + "/AgentCoreMcpStack/ResourcePolicyProvider/framework-onEvent/ServiceRole/Resource", + [{ id: "AwsSolutions-IAM4", reason: "CDK Provider framework internal role — managed by CDK.", appliesTo: ["Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"] }], + ); + + // IAM5 — Wildcard permissions on CDK-managed and custom resource roles + NagSuppressions.addResourceSuppressionsByPath(this, + "/AgentCoreMcpStack/AgentCoreRole/DefaultPolicy/Resource", + [{ id: "AwsSolutions-IAM5", reason: "AgentCore role needs s3:GetObject on all objects in deployment bucket.", appliesTo: ["Resource::/*"] }], + ); + NagSuppressions.addResourceSuppressionsByPath(this, + "/AgentCoreMcpStack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy/Resource", + [{ + id: "AwsSolutions-IAM5", + reason: "CDK BucketDeployment requires broad S3 permissions to deploy assets — managed by CDK.", + appliesTo: [ + "Action::s3:GetObject*", + "Action::s3:GetBucket*", + "Action::s3:List*", + "Action::s3:DeleteObject*", + "Action::s3:Abort*", + { regex: "/^Resource::arn::s3:::cdk-hnb659fds-assets-.*/" }, + "Resource::/*", + "Resource::/*", + "Resource::*", + ], + }], + ); + NagSuppressions.addResourceSuppressionsByPath(this, + "/AgentCoreMcpStack/ResourcePolicyFunction/ServiceRole/DefaultPolicy/Resource", + [{ id: "AwsSolutions-IAM5", reason: "bedrock-agentcore:PutResourcePolicy does not support ARN-level scoping at deploy time (runtime ARN is a CFN token).", appliesTo: ["Resource::*"] }], + ); + NagSuppressions.addResourceSuppressionsByPath(this, + "/AgentCoreMcpStack/ResourcePolicyProvider/framework-onEvent/ServiceRole/DefaultPolicy/Resource", + [{ id: "AwsSolutions-IAM5", reason: "CDK Provider framework invokes the onEvent handler — wildcard qualifier is CDK-managed.", appliesTo: ["Resource:::*"] }], + ); + + // Gateway role — needs InvokeAgentRuntime with wildcard qualifier on the runtime ARN + NagSuppressions.addResourceSuppressionsByPath(this, + `/AgentCoreMcpStack/${authMode === "cognito" ? "McpGatewayJwt" : "McpGateway"}/ServiceRole/DefaultPolicy/Resource`, + [{ id: "AwsSolutions-IAM5", reason: "Gateway role requires bedrock-agentcore:InvokeAgentRuntime on runtime ARN with wildcard qualifier for endpoint routing.", appliesTo: ["Resource::*"] }], + ); + } +} diff --git a/infrastructure/cdk/lib/edge-waf-stack.ts b/infrastructure/cdk/lib/edge-waf-stack.ts new file mode 100644 index 0000000..204d9cd --- /dev/null +++ b/infrastructure/cdk/lib/edge-waf-stack.ts @@ -0,0 +1,205 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 +/** + * WAF stack for the CloudFront front door. + * + * CLOUDFRONT-scoped WAF Web ACLs must be created in us-east-1 regardless of + * where the rest of the application deploys, so the Web ACL lives in its own + * stack pinned to us-east-1 and is passed to the main stack via a + * cross-region reference. + * + * Rules (same policy the sample previously attached directly to the Gateway): + * - Default action: BLOCK + * - Allow ChatGPT Actions egress IPs + * - Allow Anthropic/Claude egress IPs + * - AWS Managed Rules: Common Rule Set + Known Bad Inputs + * - Rate limit: 1000 requests / 5 min / IP + */ + +import * as cdk from "aws-cdk-lib"; +import * as wafv2 from "aws-cdk-lib/aws-wafv2"; +import { Construct } from "constructs"; + +interface EdgeWafStackProps extends cdk.StackProps { + projectName: string; +} + +export class EdgeWafStack extends cdk.Stack { + public readonly webAcl: wafv2.CfnWebACL; + + constructor(scope: Construct, id: string, props: EdgeWafStackProps) { + super(scope, id, props); + + const project = props.projectName; + + // ChatGPT Actions outbound IP ranges (source: https://openai.com/chatgpt-actions.json) + const chatGptIpSet = new wafv2.CfnIPSet(this, "ChatGptIpSet", { + name: `${project}-chatgpt-ips`, + scope: "CLOUDFRONT", + ipAddressVersion: "IPV4", + addresses: [ + "104.210.139.192/28", "104.210.139.224/28", "13.65.138.112/28", + "13.65.138.96/28", "13.67.72.16/28", "13.70.107.160/28", + "13.71.2.208/28", "13.76.115.224/28", "13.76.116.80/28", + "13.76.32.208/28", "13.83.237.176/28", "132.196.82.48/28", + "135.119.134.128/28", "135.119.134.192/28", "135.220.73.208/28", + "135.237.133.48/28", "137.135.191.176/28", "15.168.252.168/32", + "172.170.8.208/28", "172.177.53.240/28", "172.183.143.224/28", + "172.196.40.208/28", "172.202.102.112/28", "172.204.16.64/28", + "172.212.159.64/28", "172.213.11.144/28", "191.233.1.128/28", + "191.233.1.224/28", "191.233.196.112/28", "191.234.167.128/28", + "191.235.98.144/28", "191.237.249.64/28", "20.0.53.96/28", + "20.102.212.144/28", "20.113.218.16/28", "20.125.112.224/28", + "20.168.7.192/28", "20.168.7.240/28", "20.169.72.112/28", + "20.169.72.96/28", "20.169.78.208/28", "20.169.78.48/28", + "20.169.78.64/28", "20.169.78.80/28", "20.169.78.96/28", + "20.169.86.224/28", "20.17.108.96/28", "20.172.29.32/28", + "20.193.50.32/28", "20.194.0.208/28", "20.194.157.176/28", + "20.198.67.96/28", "20.203.245.32/28", "20.206.107.192/28", + "20.210.154.128/28", "20.210.174.208/28", "20.215.187.208/28", + "20.215.214.16/28", "20.215.219.208/28", "20.215.220.128/28", + "20.215.220.144/28", "20.215.220.160/28", "20.215.220.64/28", + "20.215.220.80/28", "20.227.140.32/28", "20.228.106.176/28", + "20.235.75.208/28", "20.235.87.224/28", "20.249.63.208/28", + "20.42.250.32/28", "20.44.100.224/28", "20.45.178.144/28", + "20.55.229.144/28", "20.57.199.192/28", "20.63.221.64/28", + "23.102.141.32/28", "23.97.109.224/28", "23.98.186.64/28", + "23.98.186.96/28", "4.151.119.48/28", "4.151.71.176/28", + "4.189.118.208/28", "4.196.198.80/28", "4.197.115.112/28", + "4.197.19.176/28", "4.197.64.0/28", "4.197.64.48/28", + "4.205.128.176/28", "4.226.226.32/28", "40.67.183.160/28", + "40.67.183.176/28", "40.81.134.128/28", "40.84.181.32/28", + "44.249.227.138/32", "48.193.44.32/28", "51.116.2.64/28", + "52.148.129.32/28", "52.153.130.48/28", "52.165.212.48/28", + "52.17.188.55/32", "52.172.129.160/28", "52.172.251.112/28", + "52.173.123.0/28", "52.173.221.16/28", "52.173.234.16/28", + "52.173.234.80/28", "52.176.139.176/28", "52.190.137.144/28", + "52.190.137.16/28", "52.190.139.48/28", "52.190.142.64/28", + "52.208.217.159/32", "52.231.30.48/28", "52.231.39.144/28", + "52.231.39.192/28", "52.242.132.224/28", "52.242.132.240/28", + "52.242.245.208/28", "52.252.113.240/28", "52.255.109.112/28", + "52.255.109.128/28", "52.255.109.144/28", "52.255.109.80/28", + "52.255.109.96/28", "52.255.111.0/28", "52.255.111.16/28", + "52.255.111.32/28", "52.43.161.225/32", "56.155.71.179/32", + "57.151.131.224/28", "57.154.174.112/28", "57.154.187.32/28", + "68.154.28.96/28", "68.220.57.64/28", "68.221.67.160/28", + "68.221.67.240/28", "68.221.75.16/28", "74.226.253.160/28", + "74.249.86.176/28", "74.7.35.112/28", "74.7.35.48/28", + "74.7.36.64/28", "74.7.36.80/28", "74.7.36.96/28", + "9.160.163.224/28", "9.234.96.192/28", + ], + }); + + // Anthropic/Claude outbound IP ranges (source: https://docs.anthropic.com/claude/reference/ip-addresses) + const claudeIpSet = new wafv2.CfnIPSet(this, "ClaudeIpSet", { + name: `${project}-claude-ips`, + scope: "CLOUDFRONT", + ipAddressVersion: "IPV4", + addresses: [ + "160.79.104.0/21", + ], + }); + + // Web ACL with IP allowlist + AWS Managed Rules for common threats + this.webAcl = new wafv2.CfnWebACL(this, "GatewayFrontDoorWafAcl", { + name: `${project}-gateway-waf`, + scope: "CLOUDFRONT", + defaultAction: { block: {} }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: `${project}-waf-metrics`, + sampledRequestsEnabled: true, + }, + rules: [ + // Rule 1: Allow ChatGPT IPs + { + name: "AllowChatGptIPs", + priority: 1, + action: { allow: {} }, + statement: { + ipSetReferenceStatement: { + arn: chatGptIpSet.attrArn, + }, + }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: `${project}-chatgpt-ip-allow`, + sampledRequestsEnabled: true, + }, + }, + // Rule 2: Allow Claude/Anthropic IPs + { + name: "AllowClaudeIPs", + priority: 2, + action: { allow: {} }, + statement: { + ipSetReferenceStatement: { + arn: claudeIpSet.attrArn, + }, + }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: `${project}-claude-ip-allow`, + sampledRequestsEnabled: true, + }, + }, + // Rule 3: AWS Managed Rules — Common Rule Set + { + name: "AWSManagedRulesCommonRuleSet", + priority: 3, + overrideAction: { none: {} }, + statement: { + managedRuleGroupStatement: { + vendorName: "AWS", + name: "AWSManagedRulesCommonRuleSet", + }, + }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: `${project}-common-rules`, + sampledRequestsEnabled: true, + }, + }, + // Rule 4: AWS Managed Rules — Known Bad Inputs + { + name: "AWSManagedRulesKnownBadInputsRuleSet", + priority: 4, + overrideAction: { none: {} }, + statement: { + managedRuleGroupStatement: { + vendorName: "AWS", + name: "AWSManagedRulesKnownBadInputsRuleSet", + }, + }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: `${project}-known-bad-inputs`, + sampledRequestsEnabled: true, + }, + }, + // Rule 5: Rate limiting — 1000 requests per 5 minutes per IP + { + name: "RateLimitRule", + priority: 5, + action: { block: {} }, + statement: { + rateBasedStatement: { + limit: 1000, + aggregateKeyType: "IP", + }, + }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: `${project}-rate-limit`, + sampledRequestsEnabled: true, + }, + }, + ], + }); + + new cdk.CfnOutput(this, "WebAclArn", { + description: "CLOUDFRONT-scope WAF Web ACL ARN (associated with the gateway front-door distribution)", + value: this.webAcl.attrArn, + }); + } +} diff --git a/infrastructure/cdk/lib/functions/oauth-discovery.js b/infrastructure/cdk/lib/functions/oauth-discovery.js new file mode 100644 index 0000000..6dc3a15 --- /dev/null +++ b/infrastructure/cdk/lib/functions/oauth-discovery.js @@ -0,0 +1,40 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 +/** + * CloudFront Function (viewer-request) for /.well-known/oauth-protected-resource + * + * By default the Gateway returns a resource URL containing its own + * *.gateway.bedrock-agentcore.* domain instead of the front-door domain, which + * breaks OAuth clients when a custom domain (or the CloudFront domain) is used. + * The AgentCore custom-domains guide works around this with a Lambda@Edge + * ORIGIN_RESPONSE function; a CloudFront Function is cheaper, faster, and + * simpler because it can generate the response directly at the edge — the + * request never reaches the Gateway. + * + * The Host header is used so the answer is correct for both the default + * *.cloudfront.net domain and any custom domain attached to the distribution. + * + * The AUTH_SERVERS placeholder below is substituted at synth time by the CDK + * stack: it becomes [] when the Gateway uses No Auth inbound, and the Cognito + * issuer URL array when deployed with -c auth=cognito. + */ +var AUTH_SERVERS = __AUTH_SERVERS__; + +function handler(event) { + var host = event.request.headers.host.value; + var doc = { + resource: 'https://' + host + '/mcp' + }; + if (AUTH_SERVERS.length > 0) { + doc.authorization_servers = AUTH_SERVERS; + } + return { + statusCode: 200, + statusDescription: 'OK', + headers: { + 'content-type': { value: 'application/json' }, + 'cache-control': { value: 'no-store' } + }, + body: JSON.stringify(doc) + }; +} diff --git a/inspect.sh b/inspect.sh new file mode 100755 index 0000000..acd892a --- /dev/null +++ b/inspect.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# ============================================================================= +# inspect.sh — interactive testing with MCP Inspector, preconfigured correctly +# +# Wraps the two footguns that make manual testing fail with WAF 403s: +# - Your IP must be in the WAF allowlist -> run `./verify.sh --keep-ip` first +# - Node resolves CloudFront to IPv6, but the allowlist is IPv4-only +# -> Inspector is launched with --dns-result-order=ipv4first +# +# On Cognito deployments it also mints a token and prints the exact +# Authorization header to paste into Inspector's Custom Headers. +# +# Usage: +# ./verify.sh --keep-ip # once, to allowlist your IP (remove it later!) +# ./inspect.sh +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_NAME="${1:-AgentCoreMcpStack}" + +REGION="${AWS_REGION:-${AWS_DEFAULT_REGION:-$(aws configure get region 2>/dev/null || true)}}" +[ -n "$REGION" ] || { echo "Error: no AWS region configured." >&2; exit 1; } + +MCP_URL="$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "$REGION" \ + --query "Stacks[0].Outputs[?OutputKey=='GatewayResourceUrl'].OutputValue" --output text)" +[ -n "$MCP_URL" ] && [ "$MCP_URL" != "None" ] || { echo "Error: could not read GatewayResourceUrl." >&2; exit 1; } + +echo "MCP endpoint (transport: Streamable HTTP):" +echo " $MCP_URL" +echo "" + +if AUTH_HEADER="$("$SCRIPT_DIR/get-token.sh" --stack "$STACK_NAME" --header 2>/dev/null)"; then + echo "Cognito deployment — add this Custom Header in Inspector (valid 1 hour):" + echo " $AUTH_HEADER" + if command -v pbcopy >/dev/null 2>&1; then + printf '%s' "${AUTH_HEADER#Authorization: }" | pbcopy + echo " (header value copied to clipboard)" + fi +else + echo "No Auth deployment — no Authorization header needed." +fi +echo "" +echo "Reminder: your IP must be allowlisted (./verify.sh --keep-ip) or WAF returns 403." +echo "Launching MCP Inspector (IPv4-first DNS)..." +echo "" + +NODE_OPTIONS="--dns-result-order=ipv4first" exec npx -y @modelcontextprotocol/inspector diff --git a/verify.sh b/verify.sh index c3e1376..e08c215 100755 --- a/verify.sh +++ b/verify.sh @@ -2,14 +2,16 @@ # ============================================================================= # Verify Script: smoke-test the deployed MCP endpoint # -# The Gateway sits behind AWS WAF with a default-deny policy that only allows -# the ChatGPT and Claude egress ranges. That means you cannot call your own -# endpoint straight after deploying — every request returns HTTP 403. This -# script closes that gap: +# The Gateway sits behind a CloudFront front door protected by AWS WAF +# (CLOUDFRONT scope, deployed in us-east-1) with a default-deny policy that +# only allows the ChatGPT and Claude egress ranges. That means you cannot call +# your own endpoint straight after deploying — every request returns HTTP 403. +# This script closes that gap: # -# 1. Reads the Gateway URL from the deployed stack outputs -# 2. Temporarily adds your public IP to the WAF allowlist -# 3. Runs initialize -> tools/list -> tools/call list_unicorns +# 1. Reads the front-door MCP URL from the deployed stack outputs +# 2. Confirms WAF blocks unlisted IPs, then temporarily allowlists yours +# 3. Runs initialize -> tools/list -> tools/call list_unicorns, plus the +# OAuth protected-resource discovery endpoint (CloudFront Function) # 4. Removes your IP again (always, even if a test fails or you Ctrl-C) # # Usage: @@ -71,6 +73,30 @@ GATEWAY_URL="$(aws cloudformation describe-stacks \ || fail "Could not read GatewayResourceUrl from stack '$STACK_NAME' in $REGION. Has ./deploy.sh finished successfully?" echo " $GATEWAY_URL" + +# Cognito inbound auth (present only on `-c auth=cognito` deployments): fetch a +# client_credentials access token so the MCP calls below carry a valid JWT. +ACCESS_TOKEN="" +TOKEN_ENDPOINT="$(aws cloudformation describe-stacks \ + --stack-name "$STACK_NAME" --region "$REGION" \ + --query "Stacks[0].Outputs[?OutputKey=='CognitoTokenEndpoint'].OutputValue" \ + --output text 2>/dev/null || true)" +if [ -n "$TOKEN_ENDPOINT" ] && [ "$TOKEN_ENDPOINT" != "None" ]; then + CLIENT_ID="$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "$REGION" \ + --query "Stacks[0].Outputs[?OutputKey=='CognitoClientId'].OutputValue" --output text)" + POOL_ID="$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "$REGION" \ + --query "Stacks[0].Outputs[?OutputKey=='CognitoUserPoolId'].OutputValue" --output text)" + CLIENT_SECRET="$(aws cognito-idp describe-user-pool-client --region "$REGION" \ + --user-pool-id "$POOL_ID" --client-id "$CLIENT_ID" \ + --query 'UserPoolClient.ClientSecret' --output text)" + ACCESS_TOKEN="$(curl -4 -fsS -m 20 -X POST "$TOKEN_ENDPOINT" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -u "${CLIENT_ID}:${CLIENT_SECRET}" \ + -d 'grant_type=client_credentials&scope=mcp-gateway/invoke' \ + | python3 -c "import sys, json; print(json.load(sys.stdin)['access_token'])")" \ + || fail "Could not obtain a Cognito access token from $TOKEN_ENDPOINT." + echo " Cognito auth detected — obtained a client_credentials token" +fi echo "" # ============================================================================= @@ -81,15 +107,31 @@ echo "" # ============================================================================= echo -e "${GREEN}[2/4] Allowlisting your IP in WAF...${NC}" -IP_SET_NAME="$(aws wafv2 list-ip-sets --scope REGIONAL --region "$REGION" \ +# The Web ACL is CLOUDFRONT-scoped, so all WAF API calls go to us-east-1 +# regardless of where the application stack deployed. +WAF_REGION="us-east-1" + +# Prove the WAF front door is actually enforcing before poking holes in it. +# All endpoint calls force IPv4 (-4): the allowlist IP sets are IPv4-only, and +# CloudFront (unlike the regional Gateway endpoint) is dual-stack — a machine +# with IPv6 would otherwise reach it from an address the allowlist can't match. +BLOCKED_CODE="$(curl -4 -s -o /dev/null -w '%{http_code}' -m 30 -X POST "$GATEWAY_URL" \ + -H "Content-Type: application/json" -d '{}' || true)" +if [ "$BLOCKED_CODE" = "403" ]; then + pass "WAF default-deny (got 403 before allowlisting)" +else + bad "WAF default-deny — expected 403 before allowlisting, got '$BLOCKED_CODE'" +fi + +IP_SET_NAME="$(aws wafv2 list-ip-sets --scope CLOUDFRONT --region "$WAF_REGION" \ --query "IPSets[?contains(Name, 'chatgpt-ips')].Name | [0]" --output text 2>/dev/null || true)" -IP_SET_ID="$(aws wafv2 list-ip-sets --scope REGIONAL --region "$REGION" \ +IP_SET_ID="$(aws wafv2 list-ip-sets --scope CLOUDFRONT --region "$WAF_REGION" \ --query "IPSets[?contains(Name, 'chatgpt-ips')].Id | [0]" --output text 2>/dev/null || true)" [ -n "$IP_SET_ID" ] && [ "$IP_SET_ID" != "None" ] \ - || fail "Could not find the WAF IP set for this stack in $REGION." + || fail "Could not find the CLOUDFRONT-scope WAF IP set for this stack in $WAF_REGION." -MY_IP="$(curl -fsS -m 10 https://checkip.amazonaws.com | tr -d '[:space:]')" \ +MY_IP="$(curl -4 -fsS -m 10 https://checkip.amazonaws.com | tr -d '[:space:]')" \ || fail "Could not determine your public IP address." MY_CIDR="${MY_IP}/32" @@ -97,14 +139,14 @@ MY_CIDR="${MY_IP}/32" # WAF requires the current lock token, so always re-read immediately before writing. update_ip_set() { local mode="$1" # add | remove - python3 - "$REGION" "$IP_SET_NAME" "$IP_SET_ID" "$MY_CIDR" "$mode" <<'PY' + python3 - "$WAF_REGION" "$IP_SET_NAME" "$IP_SET_ID" "$MY_CIDR" "$mode" <<'PY' import json, subprocess, sys region, name, ip_id, cidr, mode = sys.argv[1:6] def aws(*args): return subprocess.run(["aws", *args], check=True, capture_output=True, text=True).stdout -info = json.loads(aws("wafv2", "get-ip-set", "--scope", "REGIONAL", "--region", region, +info = json.loads(aws("wafv2", "get-ip-set", "--scope", "CLOUDFRONT", "--region", region, "--name", name, "--id", ip_id)) addrs = list(info["IPSet"]["Addresses"]) lock = info["LockToken"] @@ -118,7 +160,7 @@ else: print("already-absent"); sys.exit(0) addrs = [a for a in addrs if a != cidr] -aws("wafv2", "update-ip-set", "--scope", "REGIONAL", "--region", region, +aws("wafv2", "update-ip-set", "--scope", "CLOUDFRONT", "--region", region, "--name", name, "--id", ip_id, "--lock-token", lock, "--addresses", *addrs) print("ok") PY @@ -145,8 +187,8 @@ trap cleanup EXIT INT TERM update_ip_set add >/dev/null IP_ADDED="true" echo " Added $MY_CIDR to '$IP_SET_NAME'" -echo " Waiting for the WAF rule to propagate..." -sleep 20 +echo " Waiting for the WAF rule to propagate to the edge..." +sleep 30 echo "" # ============================================================================= @@ -157,12 +199,31 @@ echo -e "${GREEN}[3/4] Testing the MCP endpoint...${NC}" # Tools are exposed through the Gateway as "___", so discover the # real name from tools/list rather than assuming the bare "list_unicorns". mcp_call() { - curl -fsS -m 45 -X POST "$GATEWAY_URL" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d "$1" 2>/dev/null + if [ -n "$ACCESS_TOKEN" ]; then + curl -4 -fsS -m 45 -X POST "$GATEWAY_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -d "$1" 2>/dev/null + else + curl -4 -fsS -m 45 -X POST "$GATEWAY_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d "$1" 2>/dev/null + fi } +# With Cognito auth enabled, an allowlisted request without a token must still +# be rejected by the Gateway itself (this is what closes the WAF-bypass gap). +if [ -n "$ACCESS_TOKEN" ]; then + NOAUTH_CODE="$(curl -4 -s -o /dev/null -w '%{http_code}' -m 30 -X POST "$GATEWAY_URL" \ + -H "Content-Type: application/json" -d '{}' || true)" + case "$NOAUTH_CODE" in + 401|403) pass "gateway rejects requests without a Cognito token (HTTP $NOAUTH_CODE)" ;; + *) bad "gateway accepted an unauthenticated request (HTTP $NOAUTH_CODE)" ;; + esac +fi + INIT_BODY='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"verify.sh","version":"1.0"}}}' if RESP="$(mcp_call "$INIT_BODY")" && printf '%s' "$RESP" | grep -q '"protocolVersion"'; then pass "initialize" @@ -208,6 +269,20 @@ except Exception: fi fi +# The CloudFront Function must answer OAuth discovery with the front-door +# domain (not the *.gateway.bedrock-agentcore.* origin domain). +FRONT_HOST="$(printf '%s' "$GATEWAY_URL" | sed -E 's#https://([^/]+).*#\1#')" +DISCOVERY_URL="https://${FRONT_HOST}/.well-known/oauth-protected-resource" +if RESP="$(curl -4 -fsS -m 30 "$DISCOVERY_URL" 2>/dev/null)" \ + && printf '%s' "$RESP" | grep -q "https://${FRONT_HOST}/mcp"; then + pass "oauth-protected-resource discovery returns the front-door domain" + if [ -n "$ACCESS_TOKEN" ] && ! printf '%s' "$RESP" | grep -q "cognito-idp"; then + bad "oauth-protected-resource discovery — Cognito issuer missing from authorization_servers" + fi +else + bad "oauth-protected-resource discovery — unexpected response: $(printf '%s' "${RESP:-no response}" | head -c 200)" +fi + # cleanup() runs here via the EXIT trap, then we report. if [ "$FAILURES" -eq 0 ]; then trap - EXIT; cleanup; trap - INT TERM