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 - **Policy-Based Access Control** with custom Cedar policies
- **Request/Response Interception** for logging and transformation - **Request/Response Interception** for logging and transformation
- **PII Protection** using Bedrock Guardrails - **PII Protection** using Bedrock Guardrails
- **Flexible Deployment Options** (ALB or API Gateway)
- **Custom Domain Support** with SSL/TLS - **Custom Domain Support** with SSL/TLS
- **ALB Access Logging** to S3 with encryption and lifecycle management
## Architecture ## Architecture
@@ -25,10 +25,8 @@ This CDK infrastructure deploys a complete MCP gateway solution that enables VS
- **OAuth Clients**: - **OAuth Clients**:
- VS Code Client (Authorization Code Grant with PKCE) - VS Code Client (Authorization Code Grant with PKCE)
#### 2. **API Gateway Layer** #### 2. **Application Load Balancer**
Choose between two deployment options: Production-grade internet-facing ALB with custom domain, SSL/TLS, WAF, and access logging.
- **Application Load Balancer (ALB)**: Production-grade with custom domains and SSL/TLS
- **API Gateway HTTP API**: Serverless, cost-effective for development/testing
#### 3. **MCP Proxy Lambda** #### 3. **MCP Proxy Lambda**
Central component that handles: Central component that handles:
@@ -90,12 +88,12 @@ Example Lambda functions that implement MCP tools:
| Amazon Cognito | OAuth 2.0 authentication and user management | | Amazon Cognito | OAuth 2.0 authentication and user management |
| AWS Lambda | Serverless compute for proxy, MCP servers, and policy engine | | AWS Lambda | Serverless compute for proxy, MCP servers, and policy engine |
| Amazon Bedrock AgentCore | MCP gateway and protocol handling | | Amazon Bedrock AgentCore | MCP gateway and protocol handling |
| Application Load Balancer | Production routing with custom domains (optional) | | Application Load Balancer | Internet-facing ALB with TLS, WAF, and access logging |
| API Gateway HTTP API | Serverless API endpoint (optional) | | Amazon VPC | Network isolation with private subnets and VPC endpoints |
| Amazon VPC | Network isolation for ALB deployment |
| AWS IAM | Identity and access management | | AWS IAM | Identity and access management |
| Amazon Route53 | DNS management for custom domains | | Amazon Route53 | DNS management for custom domains |
| AWS Certificate Manager | SSL/TLS certificates | | AWS Certificate Manager | SSL/TLS certificates |
| Amazon S3 | ALB access log storage (encrypted, 90-day lifecycle) |
| Bedrock Guardrails | Content filtering and PII protection | | Bedrock Guardrails | Content filtering and PII protection |
## Prerequisites ## Prerequisites
@@ -116,7 +114,7 @@ Example Lambda functions that implement MCP tools:
### 1. Install Dependencies ### 1. Install Dependencies
```bash ```bash
cd enterprise-mcp-infra/cdk cd cdk
npm install npm install
``` ```
@@ -130,11 +128,8 @@ cdk bootstrap aws://ACCOUNT-ID/REGION
Edit `cdk/cdk.context.json` to configure your deployment: Edit `cdk/cdk.context.json` to configure your deployment:
#### Option A: ALB Deployment with Custom Domain
```json ```json
{ {
"deploymentType": "ALB",
"domainName": "enterprise-mcp", "domainName": "enterprise-mcp",
"hostedZoneName": "example.com", "hostedZoneName": "example.com",
"hostedZoneId": "Z1234567890ABC", "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:** **Configuration Parameters:**
| Parameter | Description | Required | Default | | Parameter | Description | Required | Default |
|-----------|-------------|----------|---------| |-----------|-------------|----------|---------|
| `deploymentType` | Deployment type: `ALB` or `API_GATEWAY` | Yes | `ALB` | | `domainName` | Custom domain name (e.g., `enterprise-mcp`) | Yes | `""` |
| `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`) | Yes | `""` |
| `hostedZoneName` | Route53 hosted zone name (e.g., `example.com`) | Only with custom domain | `""` | | `hostedZoneId` | Route53 hosted zone ID (e.g., `Z1234567890ABC`) | Yes | `""` |
| `hostedZoneId` | Route53 hosted zone ID (e.g., `Z1234567890ABC`) | Only with custom domain | `""` | | `certificateArn` | ACM certificate ARN for HTTPS | Yes | `""` |
| `certificateArn` | ACM certificate ARN for HTTPS | Only with custom domain | `""` |
### 4. Deploy the Stack ### 4. Deploy the Stack
@@ -182,11 +152,7 @@ Edit `cdk/cdk.context.json` to configure your deployment:
cdk deploy cdk deploy
``` ```
You can also override context values via command line: > **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.
```bash
cdk deploy -c deploymentType=API_GATEWAY
```
### 5. Save CDK Outputs ### 5. Save CDK Outputs
@@ -208,25 +174,26 @@ EnterpriseMcpInfraStack.Gateway = agentcore-mcp-gateway-xxxxx
#### Using the Automated Script (Recommended) #### 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) - Replace the `output` variable content with your actual CDK outputs (from step 4)
- Customize the users list with your desired email addresses and passwords - Customize the users list with your desired email addresses and passwords
2. **Run the script** to create users: 2. **Run the script** to create users:
```bash ```bash
cd enterprise-mcp-infra/scripts cd scripts
python script.py python script.py
``` ```
The script will: The script will:
- Parse the CDK outputs to extract the User Pool ID - 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) - Set permanent passwords (no need for password reset on first login)
- Skip users that already exist - Skip users that already exist
**Default Users Created:** **Default Users Created:**
- `vscode-admin@example.com` / `TempPassword123!` - `vscode-admin@example.com` / `TempPassword123!`
- `vscode-user@example.com` / `TempPassword1234!` - `vscode-user@example.com` / `TempPassword1234!`
- `vscode-readonly@example.com` / `TempPassword1235!`
#### Manual User Creation (Alternative) #### Manual User Creation (Alternative)
@@ -374,15 +341,26 @@ cdk deploy
### Lambda Logs ### 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 ```bash
# MCP Proxy Lambda # MCP Proxy Lambda name from CDK output: EnterpriseMcpInfraStack.ProxyLambdaName
aws logs tail /aws/lambda/<ProxyLambdaName> --follow 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 # Pre-Token Generation Lambda name from CDK output: EnterpriseMcpInfraStack.PreTokenGenerationLambdaName
aws logs tail /aws/lambda/<PolicyEngineLambdaName> --follow aws logs tail /aws/lambda/$(aws cloudformation describe-stacks \
--stack-name EnterpriseMcpInfraStack \
--query "Stacks[0].Outputs[?OutputKey=='PreTokenGenerationLambdaName'].OutputValue" \
--output text) --follow
# Interceptor Lambda # Interceptor Lambda look up name in AWS Console (filter by stack: EnterpriseMcpInfraStack)
aws logs tail /aws/lambda/<InterceptorLambdaName> --follow # 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 ### CloudWatch Insights Queries
@@ -401,24 +379,66 @@ fields @timestamp, method, path, statusCode
| sort @timestamp desc | 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) - OAuth 2.0 with PKCE (Proof Key for Code Exchange)
- JWT tokens with custom claims - JWT tokens with custom claims
- Secure token storage in VS Code - Secure token storage in VS Code
### Authorization #### Authorization
- Policy-based access control using Cedar - Policy-based access control using Cedar
- User attribute injection via Lambda triggers - User attribute injection via Lambda triggers
- Gateway-level authorization enforcement - Gateway-level authorization enforcement
### Data Protection #### Data Protection
- SSL/TLS encryption in transit - SSL/TLS encryption in transit
- PII anonymization via Bedrock Guardrails - 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 - Client secrets stored in environment variables
- OAuth tokens never exposed to logs - OAuth tokens never exposed to logs
- IAM role-based access for Lambda functions - IAM role-based access for Lambda functions
@@ -452,10 +472,12 @@ cdk destroy
## Architecture Decisions ## Architecture Decisions
### Why Two Deployment Options? ### Why ALB?
- **ALB**: Production environments requiring custom domains, SSL/TLS, and fine-grained routing - Production-grade with custom domains, SSL/TLS, and fine-grained routing
- **API Gateway**: Development/testing, serverless preference, cost optimization - 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? ### 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 - **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.
* `npm run watch` watch for changes and compile - **AgentCore Gateway** with Cognito authorizer, Cedar policy engine (ENFORCE mode), and Bedrock Guardrails (PII masking/blocking).
* `npm run test` perform the jest unit tests - **Lambda functions**: MCP proxy, weather tool, inventory tool, user details tool, interceptor, and pre-token generation.
* `npx cdk deploy` deploy this stack to your default AWS account/region - **VPC** with private subnets, NAT gateway, and VPC Interface Endpoint for AgentCore.
* `npx cdk diff` compare deployed stack with current state - **Internet-facing ALB** with TLS termination, WAF WebACL, and access logging.
* `npx cdk synth` emits the synthesized CloudFormation template
## 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(); const app = new cdk.App();
new EnterpriseMcpInfraStack(app, 'EnterpriseMcpInfraStack', { new EnterpriseMcpInfraStack(app, 'EnterpriseMcpInfraStack', {
/* If you don't specify 'env', this stack will be environment-agnostic. env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: 'us-east-1' },
* 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 */
}); });
@@ -1,7 +1,8 @@
{ {
"deploymentType": "API_GATEWAY", "domainName": "example.com",
"domainName": "",
"hostedZoneName": "", "hostedZoneName": "",
"hostedZoneId": "", "hostedZoneId": "",
"certificateArn": "" "certificateArn": "",
"mcpMetadataKey": "com.example/target"
} }
@@ -9,6 +9,7 @@ logger.setLevel(logging.INFO)
GUARDRAIL_ID = os.getenv("GUARDRAIL_ID", None) GUARDRAIL_ID = os.getenv("GUARDRAIL_ID", None)
GUARDRAIL_VERSION = os.getenv("GUARDRAIL_VERSION", "1.0") GUARDRAIL_VERSION = os.getenv("GUARDRAIL_VERSION", "1.0")
MCP_METADATA_KEY = os.getenv("MCP_METADATA_KEY", "com.example/target")
client = boto3.client("bedrock-runtime") client = boto3.client("bedrock-runtime")
@@ -46,6 +47,95 @@ def lambda_handler(event, context):
logger.info(f"Processing RESPONSE interceptor - MCP method: {mcp_method}") 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: if mcp_method == "tools/call" and response_body:
logger.info("tools/call response detected in RESPONSE interceptor") logger.info("tools/call response detected in RESPONSE interceptor")
content = ( content = (
@@ -56,7 +146,7 @@ def lambda_handler(event, context):
else None else None
) )
if GUARDRAIL_ID: if GUARDRAIL_ID:
response = client.apply_guardrail( gr_response = client.apply_guardrail(
guardrailIdentifier=GUARDRAIL_ID, guardrailIdentifier=GUARDRAIL_ID,
guardrailVersion=GUARDRAIL_VERSION, guardrailVersion=GUARDRAIL_VERSION,
source="INPUT", source="INPUT",
@@ -70,13 +160,16 @@ def lambda_handler(event, context):
], ],
outputScope="FULL", 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("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 = response_body
body_transformed["result"]["content"][0] = { body_transformed["result"]["content"][0] = {
"type": "text", "type": "text",
"text": response.get("outputs", [{}])[0].get("text", {}), "text": guardrail_text,
} }
statusCode = 403 statusCode = 403
response = { response = {
@@ -135,7 +228,7 @@ def lambda_handler(event, context):
if mcp_method == "tools/call" and request_body: if mcp_method == "tools/call" and request_body:
# This is a REQUEST interceptor # This is a REQUEST interceptor
if GUARDRAIL_ID: if GUARDRAIL_ID:
response = client.apply_guardrail( gr_response = client.apply_guardrail(
guardrailIdentifier=GUARDRAIL_ID, guardrailIdentifier=GUARDRAIL_ID,
guardrailVersion=GUARDRAIL_VERSION, guardrailVersion=GUARDRAIL_VERSION,
source="INPUT", source="INPUT",
@@ -149,29 +242,38 @@ def lambda_handler(event, context):
], ],
outputScope="FULL", 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("Guardrail intervened on the content. Details:")
logger.warning( guardrail_text = gr_response.get("outputs", [{}])[0].get(
json.dumps( "text", "{}"
response.get("outputs", [{}])[0].get("text", {}), )
indent=2, 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"
) )
) transformed_body = request_body
logger.info(
f"Interceptor response after guardrail intervention: {response}" response = {
)
return {
"interceptorOutputVersion": "1.0", "interceptorOutputVersion": "1.0",
"mcp": { "mcp": {
"transformedGatewayRequest": { "transformedGatewayRequest": {
"body": response.get("outputs", [{}])[0].get( "body": transformed_body,
"text", {}
),
} }
}, },
} }
logger.info(
f"Interceptor response after guardrail intervention: {response}"
)
return response
else: else:
logger.info( logger.info(
"Guardrail did not intervene. Passing through original request." "Guardrail did not intervene. Passing through original request."
@@ -11,15 +11,28 @@ import base64
import urllib.request import urllib.request
import urllib.parse import urllib.parse
import urllib.error import urllib.error
import logging
from botocore.auth import SigV4Auth from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest from botocore.awsrequest import AWSRequest
import boto3 import boto3
# Configure logging
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
# Configuration from environment variables # Configuration from environment variables
GATEWAY_URL = os.environ.get("GATEWAY_URL", "") GATEWAY_URL = os.environ.get("GATEWAY_URL", "")
COGNITO_DOMAIN = os.environ.get("COGNITO_DOMAIN", "") COGNITO_DOMAIN = os.environ.get("COGNITO_DOMAIN", "")
CLIENT_ID = os.environ.get("CLIENT_ID", "") CLIENT_ID = os.environ.get("CLIENT_ID", "")
CLIENT_SECRET = os.environ.get("CLIENT_SECRET", "") 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): def sign_request(request):
@@ -43,7 +56,7 @@ def sign_request(request):
def lambda_handler(event, context): def lambda_handler(event, context):
"""Main Lambda handler - routes requests based on path.""" """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 # Support both ALB and API Gateway v2 (HTTP API) events
# ALB uses: path, httpMethod # ALB uses: path, httpMethod
@@ -53,7 +66,7 @@ def lambda_handler(event, context):
"http", {} "http", {}
).get("method", "GET") ).get("method", "GET")
print(f"Method: {method}, Path: {path}") logger.debug(f"Method: {method}, Path: {path}")
if method == "OPTIONS": if method == "OPTIONS":
return { return {
@@ -79,7 +92,7 @@ def lambda_handler(event, context):
return handle_token(event) return handle_token(event)
elif path == "/register" and method == "POST": elif path == "/register" and method == "POST":
return handle_dcr(event) return handle_dcr(event)
elif path == "/mcp": elif path == "/mcp" or path.endswith("/mcp"):
return proxy_to_gateway(event) return proxy_to_gateway(event)
else: else:
return {"statusCode": 404, "body": json.dumps({"error": "Not found"})} return {"statusCode": 404, "body": json.dumps({"error": "Not found"})}
@@ -103,7 +116,13 @@ def handle_oauth_metadata(event):
"authorization_endpoint": f"{api_url}/authorize", "authorization_endpoint": f"{api_url}/authorize",
"token_endpoint": f"{api_url}/token", "token_endpoint": f"{api_url}/token",
"registration_endpoint": f"{api_url}/register", "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"], "response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"], "grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"], "token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
@@ -125,6 +144,13 @@ def handle_protected_resource_metadata(event):
"resource": f"{api_url}/mcp", "resource": f"{api_url}/mcp",
"authorization_servers": [api_url], "authorization_servers": [api_url],
"bearer_methods_supported": ["header"], "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 Since Lambda is stateless, we encode the original redirect_uri in the state parameter
so it survives across Lambda invocations. so it survives across Lambda invocations.
""" """
print("=== HANDLE_AUTHORIZE DEBUG ===") logger.debug("=== HANDLE_AUTHORIZE DEBUG ===")
params = event.get("queryStringParameters", {}) or {} 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) # Remove unsupported parameters (Cognito doesn't support 'resource' parameter)
if "resource" in params: if "resource" in params:
print(f"Removing 'resource' parameter: {params['resource']}") logger.debug(f"Removing 'resource' parameter: {params['resource']}")
params.pop("resource", None) 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: if "scope" in params:
# In URL encoding, + represents a space, so replace + with actual spaces # URL-decode first (handles %2F etc.), then normalize + to spaces
params["scope"] = params["scope"].replace("+", " ") params["scope"] = urllib.parse.unquote(params["scope"]).replace("+", " ")
print(f"Fixed scope parameter: {params['scope']}") logger.debug(f"Fixed scope parameter: {params['scope']}")
# Override client_id # 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 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 # Encode original redirect_uri and state together in a new state parameter
original_redirect_uri = params.get("redirect_uri", "") original_redirect_uri = params.get("redirect_uri", "")
original_state = params.get("state", "") original_state = params.get("state", "")
print(f"Original redirect_uri (URL encoded): {original_redirect_uri}") logger.debug(f"Original redirect_uri (URL encoded): {original_redirect_uri}")
print(f"Original state (URL encoded): {original_state}") logger.debug(f"Original state (URL encoded): {original_state}")
if original_redirect_uri: if original_redirect_uri:
# URL-decode both state and redirect_uri before storing # URL-decode both state and redirect_uri before storing
decoded_state = urllib.parse.unquote(original_state) decoded_state = urllib.parse.unquote(original_state)
decoded_redirect_uri = urllib.parse.unquote(original_redirect_uri) decoded_redirect_uri = urllib.parse.unquote(original_redirect_uri)
print(f"Decoded state: {decoded_state}") logger.debug(f"Decoded state: {decoded_state}")
print(f"Decoded redirect_uri: {decoded_redirect_uri}") logger.debug(f"Decoded redirect_uri: {decoded_redirect_uri}")
# Create compound state: base64(json({original_state, original_redirect_uri})) # Create compound state: base64(json({original_state, original_redirect_uri}))
compound_state = { compound_state = {
@@ -180,18 +206,18 @@ def handle_authorize(event):
).decode() ).decode()
params["state"] = encoded_state params["state"] = encoded_state
print(f"Compound state created: {json.dumps(compound_state)}") logger.debug(f"Compound state created: {json.dumps(compound_state)}")
print(f"Encoded state: {encoded_state}") logger.debug(f"Encoded state: {encoded_state}")
# Replace redirect_uri with our callback # Replace redirect_uri with our callback
api_url = get_api_url(event) api_url = get_api_url(event)
params["redirect_uri"] = f"{api_url}/callback" 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)}" redirect_url = f"{COGNITO_DOMAIN.rstrip('/')}/oauth2/authorize?{urllib.parse.urlencode(params)}"
print(f"Redirect URL: {redirect_url}") logger.debug(f"Redirect URL: {redirect_url}")
print("=== END HANDLE_AUTHORIZE DEBUG ===") logger.debug("=== END HANDLE_AUTHORIZE DEBUG ===")
return {"statusCode": 302, "headers": {"Location": redirect_url}, "body": ""} return {"statusCode": 302, "headers": {"Location": redirect_url}, "body": ""}
@@ -206,10 +232,10 @@ def handle_callback(event):
encoded_state = params.get("state", "") encoded_state = params.get("state", "")
error = params.get("error", "") error = params.get("error", "")
print("=== HANDLE_CALLBACK DEBUG ===") logger.debug("=== HANDLE_CALLBACK DEBUG ===")
print(f"Code: {code}") logger.debug(f"Code: {code}")
print(f"State (URL encoded): {encoded_state}") logger.debug(f"State (URL encoded): {encoded_state}")
print(f"Error: {error}") logger.debug(f"Error: {error}")
if error: if error:
return json_response(400, {"error": error}) return json_response(400, {"error": error})
@@ -218,33 +244,55 @@ def handle_callback(event):
try: try:
# First, URL-decode the state parameter (Cognito sends it URL-encoded) # First, URL-decode the state parameter (Cognito sends it URL-encoded)
encoded_state_clean = urllib.parse.unquote(encoded_state) 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) # Handle any remaining URL encoding issues (spaces become + or %20)
encoded_state_clean = encoded_state_clean.replace(" ", "+") encoded_state_clean = encoded_state_clean.replace(" ", "+")
# The state should now be proper base64, no padding needed # The state should now be proper base64, no padding needed
print(f"State (ready for base64 decode): {encoded_state_clean}") logger.debug(f"State (ready for base64 decode): {encoded_state_clean}")
print(f"State length: {len(encoded_state_clean)}") logger.debug(f"State length: {len(encoded_state_clean)}")
decoded = base64.urlsafe_b64decode(encoded_state_clean).decode() decoded = base64.urlsafe_b64decode(encoded_state_clean).decode()
print(f"Decoded JSON: {decoded}") logger.debug(f"Decoded JSON: {decoded}")
compound_state = json.loads(decoded) compound_state = json.loads(decoded)
original_state = compound_state.get("state", "") original_state = compound_state.get("state", "")
original_redirect_uri = compound_state.get("redirect_uri", "") original_redirect_uri = compound_state.get("redirect_uri", "")
print(f"Original state: {original_state}") logger.debug(f"Original state: {original_state}")
print(f"Original redirect_uri: {original_redirect_uri}") logger.debug(f"Original redirect_uri: {original_redirect_uri}")
print("=== END HANDLE_CALLBACK DEBUG ===") logger.debug("=== END HANDLE_CALLBACK DEBUG ===")
except Exception as e: except Exception as e:
print(f"Error decoding state: {e}, state={encoded_state}") logger.error(f"Error decoding state: {e}, state={encoded_state}")
print("=== END HANDLE_CALLBACK DEBUG (ERROR) ===") logger.error("=== END HANDLE_CALLBACK DEBUG (ERROR) ===")
return json_response(400, {"error": "Invalid state parameter"}) return json_response(400, {"error": "Invalid state parameter"})
if not original_redirect_uri: if not original_redirect_uri:
return json_response(400, {"error": "Missing redirect_uri in state"}) 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 to VS Code's callback with original state
forward_params = urllib.parse.urlencode({"code": code, "state": original_state}) forward_params = urllib.parse.urlencode({"code": code, "state": original_state})
forward_url = f"{original_redirect_uri}?{forward_params}" forward_url = f"{original_redirect_uri}?{forward_params}"
@@ -302,19 +350,81 @@ def handle_dcr(event):
def proxy_to_gateway(event): def proxy_to_gateway(event):
"""Forward MCP requests to AgentCore Gateway.""" """Forward MCP requests to AgentCore Gateway with optional target filtering."""
print("proxy_to_gateway") logger.info("proxy_to_gateway")
path = event.get("path", "/") path = event.get("path", "/")
method = event.get("httpMethod") or event.get("requestContext", {}).get( method = event.get("httpMethod") or event.get("requestContext", {}).get(
"http", {} "http", {}
).get("method", "GET") ).get("method", "GET")
headers = event.get("headers", {}) headers = event.get("headers", {})
body = event.get("body", "") body = event.get("body", "")
print(f"Proxying to gateway - Method: {method}, Path: {path}") logger.info(f"Proxying to gateway - Method: {method}, Path: {path}")
print(f"Headers: {json.dumps(headers)}") logger.debug(f"Headers: {json.dumps(headers)}")
if event.get("isBase64Encoded") and body: if event.get("isBase64Encoded") and body:
body = base64.b64decode(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 = f"{GATEWAY_URL.rstrip('/mcp')}{path}" if path != "/" else GATEWAY_URL
target_url = GATEWAY_URL target_url = GATEWAY_URL
# Build request headers # Build request headers
@@ -328,7 +438,7 @@ def proxy_to_gateway(event):
if headers.get(h): if headers.get(h):
req_headers[h.title()] = headers[h] req_headers[h.title()] = headers[h]
print(json.dumps(req_headers)) logger.debug(json.dumps(req_headers))
try: try:
if method == "POST" and body: if method == "POST" and body:
data = body.encode() if isinstance(body, str) else body data = body.encode() if isinstance(body, str) else body
@@ -354,7 +464,7 @@ def proxy_to_gateway(event):
if auth: if auth:
req.add_header("Authorization", auth) req.add_header("Authorization", auth)
print( logger.debug(
"{}\n{}\r\n{}\r\n\r\n{}".format( "{}\n{}\r\n{}\r\n\r\n{}".format(
"-----------START-----------", "-----------START-----------",
(req.method or "GET") + " " + req.full_url, (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: with urllib.request.urlopen(req, timeout=60) as resp:
resp_body = resp.read().decode() resp_body = resp.read().decode()
print(resp_body) logger.debug(resp_body)
print(resp.headers) logger.debug(resp.headers)
resp_headers = { resp_headers = {
"Content-Type": resp.headers.get("Content-Type", "application/json") "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) www_auth_rewritten = www_auth.replace(gateway_base, api_url)
resp_headers["WWW-Authenticate"] = www_auth_rewritten 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 { return {
"statusCode": resp.status, "statusCode": resp.status,
@@ -396,7 +508,7 @@ def proxy_to_gateway(event):
} }
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
error = e.read().decode() 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 # Rewrite any Gateway URLs in error response body
api_url = get_api_url(event) 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 gateway_base = GATEWAY_URL[:-4] if GATEWAY_URL.endswith("/mcp") else GATEWAY_URL
error_rewritten = error.replace(gateway_base, api_url) error_rewritten = error.replace(gateway_base, api_url)
if error != error_rewritten: 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"} resp_headers = {"Content-Type": "application/json"}
@@ -413,7 +525,7 @@ def proxy_to_gateway(event):
if www_auth: if www_auth:
www_auth_rewritten = www_auth.replace(gateway_base, api_url) www_auth_rewritten = www_auth.replace(gateway_base, api_url)
resp_headers["WWW-Authenticate"] = www_auth_rewritten resp_headers["WWW-Authenticate"] = www_auth_rewritten
print( logger.debug(
f"Rewrote WWW-Authenticate in error: {www_auth} -> {www_auth_rewritten}" f"Rewrote WWW-Authenticate in error: {www_auth} -> {www_auth_rewritten}"
) )
@@ -1,9 +1,12 @@
import json import json
import logging import logging
import os
logger = logging.getLogger() logger = logging.getLogger()
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
RESOURCE_SERVER_ID = os.environ.get("RESOURCE_SERVER_ID", "")
def lambda_handler(event, context): def lambda_handler(event, context):
""" """
@@ -27,6 +30,10 @@ def lambda_handler(event, context):
if email == "vscode-admin@example.com": if email == "vscode-admin@example.com":
# Example: Set custom tag based on email # Example: Set custom tag based on email
custom_tag = "admin_user" 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: else:
custom_tag = "regular_user" custom_tag = "regular_user"
@@ -74,13 +81,34 @@ def lambda_handler(event, context):
"claimsToAddOrOverride" "claimsToAddOrOverride"
] = {} ] = {}
# Add email and user_tag to access token # Add email, user_tag, and aud to access token
event["response"]["claimsAndScopeOverrideDetails"]["accessTokenGeneration"][ event["response"]["claimsAndScopeOverrideDetails"]["accessTokenGeneration"][
"claimsToAddOrOverride" "claimsToAddOrOverride"
]["email"] = email ]["email"] = email
event["response"]["claimsAndScopeOverrideDetails"]["accessTokenGeneration"][ event["response"]["claimsAndScopeOverrideDetails"]["accessTokenGeneration"][
"claimsToAddOrOverride" "claimsToAddOrOverride"
]["user_tag"] = custom_tag ]["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( logger.info(
f"Added custom claims to ID token and Access token: " f"Added custom claims to ID token and Access token: "
@@ -73,6 +73,13 @@ export class AgentCorePolicyEngine extends Construct {
resources: ['*'], 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 // Grant permission to pass the gateway role if provided
if (props.gatewayRole) { if (props.gatewayRole) {