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:
@@ -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?
|
||||
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 189 KiB |
+119
-10
@@ -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
|
||||
```
|
||||
|
||||
+1
-13
@@ -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' },
|
||||
});
|
||||
|
||||
+4
-3
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"deploymentType": "API_GATEWAY",
|
||||
"domainName": "",
|
||||
"domainName": "example.com",
|
||||
"hostedZoneName": "",
|
||||
"hostedZoneId": "",
|
||||
"certificateArn": ""
|
||||
"certificateArn": "",
|
||||
"mcpMetadataKey": "com.example/target"
|
||||
|
||||
}
|
||||
|
||||
+121
-19
@@ -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."
|
||||
|
||||
+160
-48
@@ -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}"
|
||||
)
|
||||
|
||||
|
||||
+29
-1
@@ -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: "
|
||||
|
||||
+7
@@ -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) {
|
||||
|
||||
+666
-218
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user