1
0
mirror of synced 2026-05-22 14:43:35 +00:00

Update demo (#1150)

* added full example of enterprise mcp platform with policy engine mcp server filtering based on user_tag, guardrail for PII data

* fixed linting

* fixed linting

* fixing lint

* fixing lint

* fixinf ruff

* FIXING RUFF

* fixing ruff

* fixed stack
added missing lib files

* fixing ruff

* fixing ruff

* Added scopes, audiences on MCP gateway
Added ALB headers conditions
Added ALB S3 logging
Added ACG VPCe with Resource Based Policy
Updated architecture diagram
Updated readme
Fixed interceptors

* auto delete objets in s3
added dependency on VPCe

* added path based routing
updated cdk context
remove print and use logger

* fixed ruff

* fixing ruff

* enfore association

---------

Signed-off-by: Anthony Bernabeu <bernabeu.anthony@gmail.com>
Co-authored-by: brnaba-aws <brnaba@amazon.com>
This commit is contained in:
Anthony Bernabeu
2026-05-05 16:41:18 +02:00
committed by GitHub
parent 6fcd3fe720
commit 74600e2e99
10 changed files with 1193 additions and 376 deletions
@@ -12,8 +12,8 @@ This CDK infrastructure deploys a complete MCP gateway solution that enables VS
- **Policy-Based Access Control** with custom Cedar policies
- **Request/Response Interception** for logging and transformation
- **PII Protection** using Bedrock Guardrails
- **Flexible Deployment Options** (ALB or API Gateway)
- **Custom Domain Support** with SSL/TLS
- **ALB Access Logging** to S3 with encryption and lifecycle management
## Architecture
@@ -25,10 +25,8 @@ This CDK infrastructure deploys a complete MCP gateway solution that enables VS
- **OAuth Clients**:
- VS Code Client (Authorization Code Grant with PKCE)
#### 2. **API Gateway Layer**
Choose between two deployment options:
- **Application Load Balancer (ALB)**: Production-grade with custom domains and SSL/TLS
- **API Gateway HTTP API**: Serverless, cost-effective for development/testing
#### 2. **Application Load Balancer**
Production-grade internet-facing ALB with custom domain, SSL/TLS, WAF, and access logging.
#### 3. **MCP Proxy Lambda**
Central component that handles:
@@ -90,12 +88,12 @@ Example Lambda functions that implement MCP tools:
| Amazon Cognito | OAuth 2.0 authentication and user management |
| AWS Lambda | Serverless compute for proxy, MCP servers, and policy engine |
| Amazon Bedrock AgentCore | MCP gateway and protocol handling |
| Application Load Balancer | Production routing with custom domains (optional) |
| API Gateway HTTP API | Serverless API endpoint (optional) |
| Amazon VPC | Network isolation for ALB deployment |
| Application Load Balancer | Internet-facing ALB with TLS, WAF, and access logging |
| Amazon VPC | Network isolation with private subnets and VPC endpoints |
| AWS IAM | Identity and access management |
| Amazon Route53 | DNS management for custom domains |
| AWS Certificate Manager | SSL/TLS certificates |
| Amazon S3 | ALB access log storage (encrypted, 90-day lifecycle) |
| Bedrock Guardrails | Content filtering and PII protection |
## Prerequisites
@@ -116,7 +114,7 @@ Example Lambda functions that implement MCP tools:
### 1. Install Dependencies
```bash
cd enterprise-mcp-infra/cdk
cd cdk
npm install
```
@@ -130,11 +128,8 @@ cdk bootstrap aws://ACCOUNT-ID/REGION
Edit `cdk/cdk.context.json` to configure your deployment:
#### Option A: ALB Deployment with Custom Domain
```json
{
"deploymentType": "ALB",
"domainName": "enterprise-mcp",
"hostedZoneName": "example.com",
"hostedZoneId": "Z1234567890ABC",
@@ -142,39 +137,14 @@ Edit `cdk/cdk.context.json` to configure your deployment:
}
```
#### Option B: API Gateway Deployment (Default URL)
```json
{
"deploymentType": "API_GATEWAY",
"domainName": "",
"hostedZoneName": "",
"hostedZoneId": "",
"certificateArn": ""
}
```
#### Option C: API Gateway with Custom Domain
```json
{
"deploymentType": "API_GATEWAY",
"domainName": "enterprise-mcp.example.com",
"hostedZoneName": "example.com",
"hostedZoneId": "Z1234567890ABC",
"certificateArn": "arn:aws:acm:region:account:certificate/xxx"
}
```
**Configuration Parameters:**
| Parameter | Description | Required | Default |
|-----------|-------------|----------|---------|
| `deploymentType` | Deployment type: `ALB` or `API_GATEWAY` | Yes | `ALB` |
| `domainName` | Custom domain name (e.g., `enterprise-mcp` for ALB, or full domain for API Gateway) | No (API Gateway only) | `""` |
| `hostedZoneName` | Route53 hosted zone name (e.g., `example.com`) | Only with custom domain | `""` |
| `hostedZoneId` | Route53 hosted zone ID (e.g., `Z1234567890ABC`) | Only with custom domain | `""` |
| `certificateArn` | ACM certificate ARN for HTTPS | Only with custom domain | `""` |
| `domainName` | Custom domain name (e.g., `enterprise-mcp`) | Yes | `""` |
| `hostedZoneName` | Route53 hosted zone name (e.g., `example.com`) | Yes | `""` |
| `hostedZoneId` | Route53 hosted zone ID (e.g., `Z1234567890ABC`) | Yes | `""` |
| `certificateArn` | ACM certificate ARN for HTTPS | Yes | `""` |
### 4. Deploy the Stack
@@ -182,11 +152,7 @@ Edit `cdk/cdk.context.json` to configure your deployment:
cdk deploy
```
You can also override context values via command line:
```bash
cdk deploy -c deploymentType=API_GATEWAY
```
> **Note:** The stack is pinned to `us-east-1` in `cdk/bin/enterprise-mcp-infra.ts`. Update the `region` value there if you need a different region.
### 5. Save CDK Outputs
@@ -208,25 +174,26 @@ EnterpriseMcpInfraStack.Gateway = agentcore-mcp-gateway-xxxxx
#### Using the Automated Script (Recommended)
1. **Edit** `enterprise-mcp-infra/scripts/script.py`:
1. **Edit** `scripts/script.py`:
- Replace the `output` variable content with your actual CDK outputs (from step 4)
- Customize the users list with your desired email addresses and passwords
2. **Run the script** to create users:
```bash
cd enterprise-mcp-infra/scripts
cd scripts
python script.py
```
The script will:
- Parse the CDK outputs to extract the User Pool ID
- Create two default users: `vscode-admin@example.com` and `vscode-user@example.com`
- Create three default users (admin, regular, and read-only)
- Set permanent passwords (no need for password reset on first login)
- Skip users that already exist
**Default Users Created:**
- `vscode-admin@example.com` / `TempPassword123!`
- `vscode-user@example.com` / `TempPassword1234!`
- `vscode-readonly@example.com` / `TempPassword1235!`
#### Manual User Creation (Alternative)
@@ -374,15 +341,26 @@ cdk deploy
### Lambda Logs
The CDK stack outputs the function names for the two most commonly debugged Lambdas (`ProxyLambdaName`, `PreTokenGenerationLambdaName`). For the others, look up the auto-generated name in the AWS Console (Lambda → Functions, filter by stack name) or use the AWS CLI:
```bash
# MCP Proxy Lambda
aws logs tail /aws/lambda/<ProxyLambdaName> --follow
# MCP Proxy Lambda name from CDK output: EnterpriseMcpInfraStack.ProxyLambdaName
aws logs tail /aws/lambda/$(aws cloudformation describe-stacks \
--stack-name EnterpriseMcpInfraStack \
--query "Stacks[0].Outputs[?OutputKey=='ProxyLambdaName'].OutputValue" \
--output text) --follow
# Policy Engine Lambda
aws logs tail /aws/lambda/<PolicyEngineLambdaName> --follow
# Pre-Token Generation Lambda name from CDK output: EnterpriseMcpInfraStack.PreTokenGenerationLambdaName
aws logs tail /aws/lambda/$(aws cloudformation describe-stacks \
--stack-name EnterpriseMcpInfraStack \
--query "Stacks[0].Outputs[?OutputKey=='PreTokenGenerationLambdaName'].OutputValue" \
--output text) --follow
# Interceptor Lambda
aws logs tail /aws/lambda/<InterceptorLambdaName> --follow
# Interceptor Lambda look up name in AWS Console (filter by stack: EnterpriseMcpInfraStack)
# aws logs tail /aws/lambda/<McpInterceptorLambda-name> --follow
# Policy Engine Lambda look up name in AWS Console (filter by stack: EnterpriseMcpInfraStack)
# aws logs tail /aws/lambda/<AgentCorePolicyEngine-PolicyFunction-name> --follow
```
### CloudWatch Insights Queries
@@ -401,24 +379,66 @@ fields @timestamp, method, path, statusCode
| sort @timestamp desc
```
## Security Considerations
## Security Posture
### Authentication
### Implemented
| Feature | Details |
|---|---|
| Cognito User Pool | Admin-only sign-up, strong password policy, Pre-Token Generation Lambda for audience/role claims |
| OAuth 2.0 | Authorization Code Grant with custom scopes (`mcp.read`, `mcp.write`) |
| JWT audience validation | Proxy Lambda validates `aud` claim before forwarding to AgentCore |
| AgentCore Cognito authorizer | Token verified a second time by AWS at the gateway level |
| Cedar policy engine | Fine-grained per-user tool access in ENFORCE mode |
| Bedrock Guardrails | PII masking (address, name, email) and blocking (credit card numbers) via interceptor |
| Lambda-in-VPC proxy | Private subnet, NAT egress only |
| VPC Interface Endpoint | AgentCore traffic stays on AWS private network, never crosses public internet |
| ALB TLS termination | TLS 1.2+ on custom domain via ACM certificate |
| ALB `dropInvalidHeaderFields` | Rejects malformed headers (request-smuggling mitigation) |
| ALB Host-header gating | Every forwarding rule requires Host header match; raw `*.elb` DNS returns 404 |
| HTTP → HTTPS redirect | Permanent redirect on port 80 |
| WAF WebACL | IP rate limit (1,000 req/5 min), AWS IP Reputation list, Core Rule Set (OWASP Top 10), Known Bad Inputs |
| WAF Bot Control | COMMON level in COUNT mode (switch to BLOCK after traffic validation) |
| Reserved Lambda concurrency | Caps on all functions to limit DoS blast radius |
| Gateway resource policy | Restricts `InvokeGateway` to the VPC |
| Shield Standard | Automatic L3/L4 DDoS protection on public ALBs |
| ALB access logging | S3 bucket with SSE, public access blocked, SSL enforced, 90-day lifecycle expiration |
| Redirect URI allowlist | `handle_callback` validates `redirect_uri` against registered Cognito callback URLs before issuing 302 redirects (prevents open-redirect / auth code theft) |
| Per-Lambda IAM roles | Four dedicated least-privilege roles: `preTokenLambdaRole` (Cognito trigger), `proxyLambdaRole` (VPC + AgentCore invoke), `interceptorLambdaRole` (Bedrock Guardrails only), `toolLambdaRole` (CloudWatch Logs only) |
### Not Implemented Consider Before Production
| Feature | Details |
|---|---|
| Shield Advanced | L7 DDoS protection, SRT access, cost protection (subscription required) |
| Bot Control TARGETED | Higher inspection level for WAF Bot Control (additional cost) |
| CloudTrail / Security Hub | Centralized audit and security findings |
| ALB access-log Athena workgroup | Query access logs via Athena for forensic analysis |
| GuardDuty findings | Threat detection integration |
| MFA enforcement | Cognito User Pool is MFA-ready but not enforced (`mfa: cognito.Mfa.REQUIRED`) |
| Scoped IAM resources | Several policies use `Resource: "*"` — scope to specific ARNs |
| PKCE enforcement | Verify PKCE is enforced on the Cognito public client (no client secret) |
| Log encryption | Lambda CloudWatch logs use default settings (no KMS CMK encryption) |
| Log retention policy | Lambda CloudWatch log retention is indefinite by default |
### Additional Security Details
#### Authentication
- OAuth 2.0 with PKCE (Proof Key for Code Exchange)
- JWT tokens with custom claims
- Secure token storage in VS Code
### Authorization
#### Authorization
- Policy-based access control using Cedar
- User attribute injection via Lambda triggers
- Gateway-level authorization enforcement
### Data Protection
#### Data Protection
- SSL/TLS encryption in transit
- PII anonymization via Bedrock Guardrails
- VPC isolation for ALB deployments
- VPC isolation with private subnets and VPC endpoints
### Secrets Management
#### Secrets Management
- Client secrets stored in environment variables
- OAuth tokens never exposed to logs
- IAM role-based access for Lambda functions
@@ -452,10 +472,12 @@ cdk destroy
## Architecture Decisions
### Why Two Deployment Options?
### Why ALB?
- **ALB**: Production environments requiring custom domains, SSL/TLS, and fine-grained routing
- **API Gateway**: Development/testing, serverless preference, cost optimization
- Production-grade with custom domains, SSL/TLS, and fine-grained routing
- WAF WebACL integration for OWASP Top 10, rate limiting, and bot control
- Access logging to S3 for forensic analysis
- VPC integration for network isolation
### Why Lambda for MCP Servers?
Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 189 KiB

@@ -1,14 +1,123 @@
# Welcome to your CDK TypeScript project
# Enterprise MCP Gateway CDK Infrastructure
This is a blank project for CDK development with TypeScript.
This CDK stack deploys an enterprise-grade MCP (Model Context Protocol) gateway backed by Amazon Bedrock AgentCore, using an Application Load Balancer (ALB) with full security hardening.
The `cdk.json` file tells the CDK Toolkit how to execute your app.
## Architecture Overview
## Useful commands
The stack provisions:
* `npm run build` compile typescript to js
* `npm run watch` watch for changes and compile
* `npm run test` perform the jest unit tests
* `npx cdk deploy` deploy this stack to your default AWS account/region
* `npx cdk diff` compare deployed stack with current state
* `npx cdk synth` emits the synthesized CloudFormation template
- **Cognito User Pool** with OAuth 2.0 Authorization Code Grant, custom scopes (`mcp.read` / `mcp.write`), and a Pre-Token Generation Lambda for audience/role claim injection.
- **AgentCore Gateway** with Cognito authorizer, Cedar policy engine (ENFORCE mode), and Bedrock Guardrails (PII masking/blocking).
- **Lambda functions**: MCP proxy, weather tool, inventory tool, user details tool, interceptor, and pre-token generation.
- **VPC** with private subnets, NAT gateway, and VPC Interface Endpoint for AgentCore.
- **Internet-facing ALB** with TLS termination, WAF WebACL, and access logging.
## Prerequisites
- AWS CDK v2 installed (`npm install -g aws-cdk`)
- Node.js 18+
- An ACM certificate and Route 53 hosted zone for the custom domain
- Python 3.12 (for Lambda bundling)
## Configuration
Set CDK context variables in `cdk.context.json` or via `-c` flags:
| Variable | Description | Default |
|---|---|---|
| `domainName` | Custom domain name (e.g. `enterprise-mcp`) | `""` |
| `hostedZoneName` | Route 53 hosted zone name | `""` |
| `hostedZoneId` | Route 53 hosted zone ID | `""` |
| `certificateArn` | ACM certificate ARN | `""` |
## Deployment
```bash
# From the cdk/ directory
npm install
npx cdk synth
npx cdk deploy
```
> **Note:** The stack is pinned to `us-east-1` in `bin/enterprise-mcp-infra.ts`. Update the `region` value there if you need a different region.
## Useful Commands
| Command | Description |
|---|---|
| `npm run build` | Compile TypeScript to JS |
| `npm run watch` | Watch for changes and compile |
| `npm run test` | Run Jest unit tests |
| `npx cdk synth` | Emit the synthesized CloudFormation template |
| `npx cdk diff` | Compare deployed stack with current state |
| `npx cdk deploy` | Deploy this stack |
| `npx cdk destroy` | Tear down the stack |
## Security Posture
### Implemented
| Feature | Details |
|---|---|
| Cognito User Pool | Admin-only sign-up, strong password policy, Pre-Token Generation Lambda for audience/role claims |
| OAuth 2.0 | Authorization Code Grant with custom scopes (`mcp.read`, `mcp.write`) |
| JWT audience validation | Proxy Lambda validates `aud` claim before forwarding to AgentCore |
| AgentCore Cognito authorizer | Token verified a second time by AWS at the gateway level |
| Cedar policy engine | Fine-grained per-user tool access in ENFORCE mode |
| Bedrock Guardrails | PII masking (address, name, email) and blocking (credit card numbers) via interceptor |
| Lambda-in-VPC proxy | Private subnet, NAT egress only |
| VPC Interface Endpoint | AgentCore traffic stays on AWS private network, never crosses public internet |
| ALB TLS termination | TLS 1.2+ on custom domain via ACM certificate |
| ALB `dropInvalidHeaderFields` | Rejects malformed headers (request-smuggling mitigation) |
| ALB Host-header gating | Every forwarding rule requires Host header match; raw `*.elb` DNS returns 404 |
| HTTP → HTTPS redirect | Permanent redirect on port 80 |
| WAF WebACL | IP rate limit (1,000 req/5 min), AWS IP Reputation list, Core Rule Set (OWASP Top 10), Known Bad Inputs |
| WAF Bot Control | COMMON level in COUNT mode (switch to BLOCK after traffic validation) |
| Reserved Lambda concurrency | Caps on all functions to limit DoS blast radius |
| Gateway resource policy | Restricts `InvokeGateway` to the VPC |
| Shield Standard | Automatic L3/L4 DDoS protection on public ALBs |
| ALB access logging | S3 bucket with SSE, public access blocked, SSL enforced, 90-day lifecycle expiration |
| Redirect URI allowlist | `handle_callback` validates `redirect_uri` against registered Cognito callback URLs before issuing 302 redirects (prevents open-redirect / auth code theft) |
| Per-Lambda IAM roles | Four dedicated least-privilege roles: `preTokenLambdaRole` (Cognito trigger), `proxyLambdaRole` (VPC + AgentCore invoke), `interceptorLambdaRole` (Bedrock Guardrails only), `toolLambdaRole` (CloudWatch Logs only) |
### Not Implemented Consider Before Production
| Feature | Details |
|---|---|
| Shield Advanced | L7 DDoS protection, SRT access, cost protection (subscription required) |
| Bot Control TARGETED | Higher inspection level for WAF Bot Control (additional cost) |
| CloudTrail / Security Hub | Centralized audit and security findings |
| ALB access-log Athena workgroup | Query access logs via Athena for forensic analysis |
| GuardDuty findings | Threat detection integration |
| MFA enforcement | Cognito User Pool is MFA-ready but not enforced (`mfa: cognito.Mfa.REQUIRED`) |
| Scoped IAM resources | Several policies use `Resource: "*"` — scope to specific ARNs |
| PKCE enforcement | Verify PKCE is enforced on the Cognito public client (no client secret) |
| Log encryption | Lambda CloudWatch logs use default settings (no KMS CMK encryption) |
| Log retention policy | Lambda CloudWatch log retention is indefinite by default |
## Project Structure
```
cdk/
├── bin/
│ └── enterprise-mcp-infra.ts # CDK app entry point (region pinned to us-east-1)
├── lib/
│ ├── enterprise-mcp-infra-stack.ts # Main infrastructure stack
│ └── agentcore-policy-engine.ts # Cedar policy engine construct
├── lambda/
│ ├── mcp_proxy_lambda.py # MCP OAuth proxy Lambda
│ ├── pre_token_generation_lambda.py # Cognito pre-token generation trigger
│ ├── interceptor/
│ │ └── interceptor.py # Guardrails interceptor Lambda
│ ├── mcp-servers/
│ │ ├── weather/ # Weather tool Lambda
│ │ ├── inventory/ # Inventory tool Lambda
│ │ └── user_details/ # User details tool Lambda
│ └── agentcore-policy-engine/ # Policy engine custom resource Lambda
├── test/
│ └── enterprise-mcp-infra.test.ts # Jest tests
├── cdk.json
├── cdk.context.json
├── tsconfig.json
└── package.json
```
@@ -4,17 +4,5 @@ import { EnterpriseMcpInfraStack } from '../lib/enterprise-mcp-infra-stack';
const app = new cdk.App();
new EnterpriseMcpInfraStack(app, 'EnterpriseMcpInfraStack', {
/* If you don't specify 'env', this stack will be environment-agnostic.
* Account/Region-dependent features and context lookups will not work,
* but a single synthesized template can be deployed anywhere. */
/* Uncomment the next line to specialize this stack for the AWS Account
* and Region that are implied by the current CLI configuration. */
// env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
/* Uncomment the next line if you know exactly what Account and Region you
* want to deploy the stack to. */
// env: { account: '123456789012', region: 'us-east-1' },
/* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: 'us-east-1' },
});
@@ -1,7 +1,8 @@
{
"deploymentType": "API_GATEWAY",
"domainName": "",
"domainName": "example.com",
"hostedZoneName": "",
"hostedZoneId": "",
"certificateArn": ""
"certificateArn": "",
"mcpMetadataKey": "com.example/target"
}
@@ -9,6 +9,7 @@ logger.setLevel(logging.INFO)
GUARDRAIL_ID = os.getenv("GUARDRAIL_ID", None)
GUARDRAIL_VERSION = os.getenv("GUARDRAIL_VERSION", "1.0")
MCP_METADATA_KEY = os.getenv("MCP_METADATA_KEY", "com.example/target")
client = boto3.client("bedrock-runtime")
@@ -46,6 +47,95 @@ def lambda_handler(event, context):
logger.info(f"Processing RESPONSE interceptor - MCP method: {mcp_method}")
# === HANDLE TOOLS/LIST FILTERING BASED ON _meta ===
if mcp_method == "tools/list" and response_body:
logger.info("tools/list response detected in RESPONSE interceptor")
# Extract target filter from MCP _meta (spec-compliant)
target_filter = None
meta = request_body.get("_meta", {})
if isinstance(meta, dict):
target_filter = meta.get(MCP_METADATA_KEY)
if target_filter:
logger.info(
f"Target filter from _meta: {MCP_METADATA_KEY} = '{target_filter}'"
)
logger.info(
f"Will filter tools to only those starting with '{target_filter}___'"
)
else:
logger.info(
"No target filter in _meta - returning ALL tools (no filtering)"
)
# Filter tools if target filter is specified
if "result" in response_body and "tools" in response_body.get(
"result", {}
):
result = response_body["result"]
original_tools = result.get("tools", [])
logger.info(f"Original tools count: {len(original_tools)}")
if target_filter:
# Filter by gateway target name prefix (format: "target___tool")
filtered_tools = [
tool
for tool in original_tools
if tool.get("name", "").startswith(f"{target_filter}___")
]
logger.info(
f"Filtered to {len(filtered_tools)} tools for target '{target_filter}'"
)
# Log matched tools
if filtered_tools:
logger.info("Matched tools:")
for tool in filtered_tools:
logger.info(f" - {tool.get('name')}")
else:
logger.warning(f"No tools matched target '{target_filter}'")
# Log filtering summary
removed = len(original_tools) - len(filtered_tools)
if removed > 0:
logger.info(
f"Filtered out {removed} tools not matching target"
)
# Create filtered response
filtered_body = {
"jsonrpc": response_body.get("jsonrpc", "2.0"),
"id": response_body.get("id"),
"result": {"tools": filtered_tools},
}
# Preserve _meta from response if present
if "_meta" in response_body:
filtered_body["_meta"] = response_body["_meta"]
response = {
"interceptorOutputVersion": "1.0",
"mcp": {
"transformedGatewayResponse": {
"body": filtered_body,
"statusCode": 200,
}
},
}
logger.info("Returning filtered tools/list response")
return response
else:
# No filtering - log all tools and return unchanged
logger.info(
f"No filtering applied - returning all {len(original_tools)} tools"
)
logger.info("Available tools:")
for tool in original_tools:
logger.info(f" - {tool.get('name')}")
if mcp_method == "tools/call" and response_body:
logger.info("tools/call response detected in RESPONSE interceptor")
content = (
@@ -56,7 +146,7 @@ def lambda_handler(event, context):
else None
)
if GUARDRAIL_ID:
response = client.apply_guardrail(
gr_response = client.apply_guardrail(
guardrailIdentifier=GUARDRAIL_ID,
guardrailVersion=GUARDRAIL_VERSION,
source="INPUT",
@@ -70,13 +160,16 @@ def lambda_handler(event, context):
],
outputScope="FULL",
)
if response.get("action", None) == "GUARDRAIL_INTERVENED":
if gr_response.get("action", None) == "GUARDRAIL_INTERVENED":
logger.warning("Guardrail intervened on the content. Details:")
logger.warning(response.get("outputs", [{}])[0].get("text", {}))
guardrail_text = gr_response.get("outputs", [{}])[0].get(
"text", ""
)
logger.warning(guardrail_text)
body_transformed = response_body
body_transformed["result"]["content"][0] = {
"type": "text",
"text": response.get("outputs", [{}])[0].get("text", {}),
"text": guardrail_text,
}
statusCode = 403
response = {
@@ -135,7 +228,7 @@ def lambda_handler(event, context):
if mcp_method == "tools/call" and request_body:
# This is a REQUEST interceptor
if GUARDRAIL_ID:
response = client.apply_guardrail(
gr_response = client.apply_guardrail(
guardrailIdentifier=GUARDRAIL_ID,
guardrailVersion=GUARDRAIL_VERSION,
source="INPUT",
@@ -149,29 +242,38 @@ def lambda_handler(event, context):
],
outputScope="FULL",
)
logger.info(f"Guardrail response: {response}")
logger.info(f"Guardrail response: {gr_response}")
if response.get("action", None) == "GUARDRAIL_INTERVENED":
if gr_response.get("action", None) == "GUARDRAIL_INTERVENED":
logger.warning("Guardrail intervened on the content. Details:")
logger.warning(
json.dumps(
response.get("outputs", [{}])[0].get("text", {}),
indent=2,
guardrail_text = gr_response.get("outputs", [{}])[0].get(
"text", "{}"
)
logger.warning(guardrail_text)
# Parse the guardrail output back to a dict since the gateway
# expects body to be a JSON object, not a string
try:
transformed_body = json.loads(guardrail_text)
except (json.JSONDecodeError, TypeError):
# If guardrail output isn't valid JSON, pass through original request
logger.error(
"Guardrail output is not valid JSON, passing through original request"
)
)
logger.info(
f"Interceptor response after guardrail intervention: {response}"
)
return {
transformed_body = request_body
response = {
"interceptorOutputVersion": "1.0",
"mcp": {
"transformedGatewayRequest": {
"body": response.get("outputs", [{}])[0].get(
"text", {}
),
"body": transformed_body,
}
},
}
logger.info(
f"Interceptor response after guardrail intervention: {response}"
)
return response
else:
logger.info(
"Guardrail did not intervene. Passing through original request."
@@ -11,15 +11,28 @@ import base64
import urllib.request
import urllib.parse
import urllib.error
import logging
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
import boto3
# Configure logging
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
# Configuration from environment variables
GATEWAY_URL = os.environ.get("GATEWAY_URL", "")
COGNITO_DOMAIN = os.environ.get("COGNITO_DOMAIN", "")
CLIENT_ID = os.environ.get("CLIENT_ID", "")
CLIENT_SECRET = os.environ.get("CLIENT_SECRET", "")
CALLBACK_LAMBDA_URL = os.environ.get("CALLBACK_LAMBDA_URL", "")
RESOURCE_SERVER_ID = os.environ.get("RESOURCE_SERVER_ID", "")
MCP_METADATA_KEY = os.environ.get("MCP_METADATA_KEY", "com.example/target")
# Allowed redirect URIs for the OAuth callback, passed from CDK as a
# JSON-encoded list. Must match the Cognito client's registered callbackUrls
# to prevent open-redirect attacks.
ALLOWED_REDIRECT_URIS = json.loads(os.environ.get("ALLOWED_REDIRECT_URIS", "[]"))
def sign_request(request):
@@ -43,7 +56,7 @@ def sign_request(request):
def lambda_handler(event, context):
"""Main Lambda handler - routes requests based on path."""
print(f"Event: {json.dumps(event)}")
logger.debug(f"Event: {json.dumps(event)}")
# Support both ALB and API Gateway v2 (HTTP API) events
# ALB uses: path, httpMethod
@@ -53,7 +66,7 @@ def lambda_handler(event, context):
"http", {}
).get("method", "GET")
print(f"Method: {method}, Path: {path}")
logger.debug(f"Method: {method}, Path: {path}")
if method == "OPTIONS":
return {
@@ -79,7 +92,7 @@ def lambda_handler(event, context):
return handle_token(event)
elif path == "/register" and method == "POST":
return handle_dcr(event)
elif path == "/mcp":
elif path == "/mcp" or path.endswith("/mcp"):
return proxy_to_gateway(event)
else:
return {"statusCode": 404, "body": json.dumps({"error": "Not found"})}
@@ -103,7 +116,13 @@ def handle_oauth_metadata(event):
"authorization_endpoint": f"{api_url}/authorize",
"token_endpoint": f"{api_url}/token",
"registration_endpoint": f"{api_url}/register",
"scopes_supported": ["openid", "profile", "email"],
"scopes_supported": [
"openid",
"profile",
"email",
f"{RESOURCE_SERVER_ID}/mcp.read",
f"{RESOURCE_SERVER_ID}/mcp.write",
],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
@@ -125,6 +144,13 @@ def handle_protected_resource_metadata(event):
"resource": f"{api_url}/mcp",
"authorization_servers": [api_url],
"bearer_methods_supported": ["header"],
"scopes_supported": [
"openid",
"profile",
"email",
f"{RESOURCE_SERVER_ID}/mcp.read",
f"{RESOURCE_SERVER_ID}/mcp.write",
],
},
)
@@ -135,40 +161,40 @@ def handle_authorize(event):
Since Lambda is stateless, we encode the original redirect_uri in the state parameter
so it survives across Lambda invocations.
"""
print("=== HANDLE_AUTHORIZE DEBUG ===")
logger.debug("=== HANDLE_AUTHORIZE DEBUG ===")
params = event.get("queryStringParameters", {}) or {}
print(f"Original params: {json.dumps(params)}")
logger.debug(f"Original params: {json.dumps(params)}")
# Remove unsupported parameters (Cognito doesn't support 'resource' parameter)
if "resource" in params:
print(f"Removing 'resource' parameter: {params['resource']}")
logger.debug(f"Removing 'resource' parameter: {params['resource']}")
params.pop("resource", None)
# Fix scope parameter: convert + to spaces (Cognito expects space-separated scopes)
# Fix scope parameter: URL-decode and normalize spaces
if "scope" in params:
# In URL encoding, + represents a space, so replace + with actual spaces
params["scope"] = params["scope"].replace("+", " ")
print(f"Fixed scope parameter: {params['scope']}")
# URL-decode first (handles %2F etc.), then normalize + to spaces
params["scope"] = urllib.parse.unquote(params["scope"]).replace("+", " ")
logger.debug(f"Fixed scope parameter: {params['scope']}")
# Override client_id
print(f"Original client_id: {params.get('client_id', 'N/A')}")
logger.debug(f"Original client_id: {params.get('client_id', 'N/A')}")
params["client_id"] = CLIENT_ID
print(f"Overridden client_id: {CLIENT_ID}")
logger.debug(f"Overridden client_id: {CLIENT_ID}")
# Encode original redirect_uri and state together in a new state parameter
original_redirect_uri = params.get("redirect_uri", "")
original_state = params.get("state", "")
print(f"Original redirect_uri (URL encoded): {original_redirect_uri}")
print(f"Original state (URL encoded): {original_state}")
logger.debug(f"Original redirect_uri (URL encoded): {original_redirect_uri}")
logger.debug(f"Original state (URL encoded): {original_state}")
if original_redirect_uri:
# URL-decode both state and redirect_uri before storing
decoded_state = urllib.parse.unquote(original_state)
decoded_redirect_uri = urllib.parse.unquote(original_redirect_uri)
print(f"Decoded state: {decoded_state}")
print(f"Decoded redirect_uri: {decoded_redirect_uri}")
logger.debug(f"Decoded state: {decoded_state}")
logger.debug(f"Decoded redirect_uri: {decoded_redirect_uri}")
# Create compound state: base64(json({original_state, original_redirect_uri}))
compound_state = {
@@ -180,18 +206,18 @@ def handle_authorize(event):
).decode()
params["state"] = encoded_state
print(f"Compound state created: {json.dumps(compound_state)}")
print(f"Encoded state: {encoded_state}")
logger.debug(f"Compound state created: {json.dumps(compound_state)}")
logger.debug(f"Encoded state: {encoded_state}")
# Replace redirect_uri with our callback
api_url = get_api_url(event)
params["redirect_uri"] = f"{api_url}/callback"
print(f"New redirect_uri: {params['redirect_uri']}")
logger.debug(f"New redirect_uri: {params['redirect_uri']}")
print(f"Final params being sent to Cognito: {json.dumps(params)}")
logger.debug(f"Final params being sent to Cognito: {json.dumps(params)}")
redirect_url = f"{COGNITO_DOMAIN.rstrip('/')}/oauth2/authorize?{urllib.parse.urlencode(params)}"
print(f"Redirect URL: {redirect_url}")
print("=== END HANDLE_AUTHORIZE DEBUG ===")
logger.debug(f"Redirect URL: {redirect_url}")
logger.debug("=== END HANDLE_AUTHORIZE DEBUG ===")
return {"statusCode": 302, "headers": {"Location": redirect_url}, "body": ""}
@@ -206,10 +232,10 @@ def handle_callback(event):
encoded_state = params.get("state", "")
error = params.get("error", "")
print("=== HANDLE_CALLBACK DEBUG ===")
print(f"Code: {code}")
print(f"State (URL encoded): {encoded_state}")
print(f"Error: {error}")
logger.debug("=== HANDLE_CALLBACK DEBUG ===")
logger.debug(f"Code: {code}")
logger.debug(f"State (URL encoded): {encoded_state}")
logger.debug(f"Error: {error}")
if error:
return json_response(400, {"error": error})
@@ -218,33 +244,55 @@ def handle_callback(event):
try:
# First, URL-decode the state parameter (Cognito sends it URL-encoded)
encoded_state_clean = urllib.parse.unquote(encoded_state)
print(f"State (URL decoded): {encoded_state_clean}")
logger.debug(f"State (URL decoded): {encoded_state_clean}")
# Handle any remaining URL encoding issues (spaces become + or %20)
encoded_state_clean = encoded_state_clean.replace(" ", "+")
# The state should now be proper base64, no padding needed
print(f"State (ready for base64 decode): {encoded_state_clean}")
print(f"State length: {len(encoded_state_clean)}")
logger.debug(f"State (ready for base64 decode): {encoded_state_clean}")
logger.debug(f"State length: {len(encoded_state_clean)}")
decoded = base64.urlsafe_b64decode(encoded_state_clean).decode()
print(f"Decoded JSON: {decoded}")
logger.debug(f"Decoded JSON: {decoded}")
compound_state = json.loads(decoded)
original_state = compound_state.get("state", "")
original_redirect_uri = compound_state.get("redirect_uri", "")
print(f"Original state: {original_state}")
print(f"Original redirect_uri: {original_redirect_uri}")
print("=== END HANDLE_CALLBACK DEBUG ===")
logger.debug(f"Original state: {original_state}")
logger.debug(f"Original redirect_uri: {original_redirect_uri}")
logger.debug("=== END HANDLE_CALLBACK DEBUG ===")
except Exception as e:
print(f"Error decoding state: {e}, state={encoded_state}")
print("=== END HANDLE_CALLBACK DEBUG (ERROR) ===")
logger.error(f"Error decoding state: {e}, state={encoded_state}")
logger.error("=== END HANDLE_CALLBACK DEBUG (ERROR) ===")
return json_response(400, {"error": "Invalid state parameter"})
if not original_redirect_uri:
return json_response(400, {"error": "Missing redirect_uri in state"})
# Validate redirect_uri against the allowlist to prevent open-redirect attacks.
# A crafted state blob could otherwise redirect the authorization code to an
# attacker-controlled URL.
#
# Localhost URIs with any port are allowed because IDE clients (VS Code, Kiro)
# spin up an ephemeral local server on a random port for the OAuth callback.
normalized = original_redirect_uri.rstrip("/")
parsed = urllib.parse.urlparse(normalized)
is_localhost = parsed.scheme == "http" and parsed.hostname in (
"localhost",
"127.0.0.1",
)
allowed_normalized = [u.rstrip("/") for u in ALLOWED_REDIRECT_URIS]
if not is_localhost and normalized not in allowed_normalized:
logger.warning(
f"Rejected redirect_uri not in allowlist: {original_redirect_uri}"
)
logger.debug(f"Normalized redirect_uri: {normalized}")
logger.debug(f"Allowed URIs (raw): {ALLOWED_REDIRECT_URIS}")
logger.debug(f"Allowed URIs (normalized): {allowed_normalized}")
return json_response(400, {"error": "invalid_redirect_uri"})
# Forward to VS Code's callback with original state
forward_params = urllib.parse.urlencode({"code": code, "state": original_state})
forward_url = f"{original_redirect_uri}?{forward_params}"
@@ -302,19 +350,81 @@ def handle_dcr(event):
def proxy_to_gateway(event):
"""Forward MCP requests to AgentCore Gateway."""
print("proxy_to_gateway")
"""Forward MCP requests to AgentCore Gateway with optional target filtering."""
logger.info("proxy_to_gateway")
path = event.get("path", "/")
method = event.get("httpMethod") or event.get("requestContext", {}).get(
"http", {}
).get("method", "GET")
headers = event.get("headers", {})
body = event.get("body", "")
print(f"Proxying to gateway - Method: {method}, Path: {path}")
print(f"Headers: {json.dumps(headers)}")
logger.info(f"Proxying to gateway - Method: {method}, Path: {path}")
logger.debug(f"Headers: {json.dumps(headers)}")
if event.get("isBase64Encoded") and body:
body = base64.b64decode(body)
# === EXTRACT TARGET FROM PATH ===
# /mcp → no filter (return all tools)
# /gitlab/mcp → filter = "gitlab"
# /weather/mcp → filter = "weather"
target_filter = None
if path and path != "/mcp":
# Remove leading/trailing slashes and split
parts = path.strip("/").split("/")
# Check if path has format: <target>/mcp
if len(parts) == 2 and parts[-1] == "mcp":
target_filter = parts[0]
logger.info(f"Target filter extracted from path: '{target_filter}'")
elif len(parts) > 2 and parts[-1] == "mcp":
# Handle nested paths like /api/v1/gitlab/mcp
target_filter = parts[-2]
logger.info(f"Target filter extracted from nested path: '{target_filter}'")
else:
logger.debug(f"Path '{path}' does not match target pattern, no filtering")
else:
logger.debug("Default path '/mcp' - returning all tools (no filtering)")
# === INJECT INTO MCP _meta ONLY IF TARGET FILTER EXISTS ===
if method == "POST" and body:
try:
# Parse MCP JSON-RPC request
mcp_request = json.loads(body if isinstance(body, str) else body.decode())
# Only inject _meta if we have a target filter AND it's a tool-related method
if target_filter and mcp_request.get("method") in [
"tools/list",
"tools/call",
]:
# Ensure _meta exists
if "_meta" not in mcp_request:
mcp_request["_meta"] = {}
# Inject target filter using reverse DNS notation
mcp_request["_meta"][MCP_METADATA_KEY] = target_filter
logger.info(f"Injected _meta: {MCP_METADATA_KEY} = '{target_filter}'")
logger.debug(
f"Modified MCP request: {json.dumps(mcp_request, indent=2)}"
)
else:
if not target_filter:
logger.debug(
"No target filter - NOT injecting _meta (will return all tools)"
)
else:
logger.debug(
f"Method '{mcp_request.get('method')}' - not injecting _meta"
)
# Re-serialize (possibly modified) request
body = json.dumps(mcp_request).encode()
except json.JSONDecodeError as e:
logger.error(f"Failed to parse MCP request: {e}")
# Continue with original body if parsing fails
# target_url = f"{GATEWAY_URL.rstrip('/mcp')}{path}" if path != "/" else GATEWAY_URL
target_url = GATEWAY_URL
# Build request headers
@@ -328,7 +438,7 @@ def proxy_to_gateway(event):
if headers.get(h):
req_headers[h.title()] = headers[h]
print(json.dumps(req_headers))
logger.debug(json.dumps(req_headers))
try:
if method == "POST" and body:
data = body.encode() if isinstance(body, str) else body
@@ -354,7 +464,7 @@ def proxy_to_gateway(event):
if auth:
req.add_header("Authorization", auth)
print(
logger.debug(
"{}\n{}\r\n{}\r\n\r\n{}".format(
"-----------START-----------",
(req.method or "GET") + " " + req.full_url,
@@ -365,8 +475,8 @@ def proxy_to_gateway(event):
with urllib.request.urlopen(req, timeout=60) as resp:
resp_body = resp.read().decode()
print(resp_body)
print(resp.headers)
logger.debug(resp_body)
logger.debug(resp.headers)
resp_headers = {
"Content-Type": resp.headers.get("Content-Type", "application/json")
}
@@ -387,7 +497,9 @@ def proxy_to_gateway(event):
)
www_auth_rewritten = www_auth.replace(gateway_base, api_url)
resp_headers["WWW-Authenticate"] = www_auth_rewritten
print(f"Rewrote WWW-Authenticate: {www_auth} -> {www_auth_rewritten}")
logger.debug(
f"Rewrote WWW-Authenticate: {www_auth} -> {www_auth_rewritten}"
)
return {
"statusCode": resp.status,
@@ -396,7 +508,7 @@ def proxy_to_gateway(event):
}
except urllib.error.HTTPError as e:
error = e.read().decode()
print(f"Gateway error response: {error}")
logger.error(f"Gateway error response: {error}")
# Rewrite any Gateway URLs in error response body
api_url = get_api_url(event)
@@ -404,7 +516,7 @@ def proxy_to_gateway(event):
gateway_base = GATEWAY_URL[:-4] if GATEWAY_URL.endswith("/mcp") else GATEWAY_URL
error_rewritten = error.replace(gateway_base, api_url)
if error != error_rewritten:
print("Rewrote Gateway URL in error body")
logger.debug("Rewrote Gateway URL in error body")
resp_headers = {"Content-Type": "application/json"}
@@ -413,7 +525,7 @@ def proxy_to_gateway(event):
if www_auth:
www_auth_rewritten = www_auth.replace(gateway_base, api_url)
resp_headers["WWW-Authenticate"] = www_auth_rewritten
print(
logger.debug(
f"Rewrote WWW-Authenticate in error: {www_auth} -> {www_auth_rewritten}"
)
@@ -1,9 +1,12 @@
import json
import logging
import os
logger = logging.getLogger()
logger.setLevel(logging.INFO)
RESOURCE_SERVER_ID = os.environ.get("RESOURCE_SERVER_ID", "")
def lambda_handler(event, context):
"""
@@ -27,6 +30,10 @@ def lambda_handler(event, context):
if email == "vscode-admin@example.com":
# Example: Set custom tag based on email
custom_tag = "admin_user"
elif email == "vscode-readonly@example.com":
# Test user with limited scopes — only mcp.read, no mcp.write
# Used to verify the gateway rejects requests with insufficient scopes
custom_tag = "readonly_user"
else:
custom_tag = "regular_user"
@@ -74,13 +81,34 @@ def lambda_handler(event, context):
"claimsToAddOrOverride"
] = {}
# Add email and user_tag to access token
# Add email, user_tag, and aud to access token
event["response"]["claimsAndScopeOverrideDetails"]["accessTokenGeneration"][
"claimsToAddOrOverride"
]["email"] = email
event["response"]["claimsAndScopeOverrideDetails"]["accessTokenGeneration"][
"claimsToAddOrOverride"
]["user_tag"] = custom_tag
# Inject the audience claim so the proxy Lambda and AgentCore Gateway
# can verify the token is scoped to this resource server.
# Cognito requires aud to match the current session's app client ID.
client_id = event.get("callerContext", {}).get("clientId", "")
if client_id:
event["response"]["claimsAndScopeOverrideDetails"]["accessTokenGeneration"][
"claimsToAddOrOverride"
]["aud"] = client_id
# For the readonly test user, suppress the mcp.write and mcp.read scopes so the
# gateway rejects write operations with insufficient_scope.
if custom_tag == "readonly_user":
event["response"]["claimsAndScopeOverrideDetails"]["accessTokenGeneration"][
"scopesToSuppress"
] = [
f"{RESOURCE_SERVER_ID}/mcp.write",
f"{RESOURCE_SERVER_ID}/mcp.read",
]
logger.info(
"Suppressed mcp.write and mcp.read scopes for readonly test user"
)
logger.info(
f"Added custom claims to ID token and Access token: "
@@ -73,6 +73,13 @@ export class AgentCorePolicyEngine extends Construct {
resources: ['*'],
}),
);
this.policyFunction.addToRolePolicy(
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['iam:GetRole', 'iam:GetRolePolicy', 'iam:ListAttachedRolePolicies', 'iam:ListRolePolicies'],
resources: ["arn:aws:iam::*:role/*"],
}),
);
// Grant permission to pass the gateway role if provided
if (props.gatewayRole) {