diff --git a/strands-agentcore-mcp/.gitignore b/strands-agentcore-mcp/.gitignore new file mode 100644 index 000000000..1fa18eea8 --- /dev/null +++ b/strands-agentcore-mcp/.gitignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ + +# Testing +.pytest_cache/ +.hypothesis/ +.coverage +htmlcov/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Lambda build artifacts +/tmp/ + +# Generated scripts +scripts/test.sh + +# macOS +.DS_Store + +# IDE +.idea/ +.vscode/ +*.swp + +# Kiro +.kiro/ + +# SAM build artifacts +.aws-sam/ + +# SAM config (environment-specific) +samconfig.toml diff --git a/strands-agentcore-mcp/Makefile b/strands-agentcore-mcp/Makefile new file mode 100644 index 000000000..00e9c866d --- /dev/null +++ b/strands-agentcore-mcp/Makefile @@ -0,0 +1,49 @@ +# Build Makefile — Docker-free Lambda packaging for AWS SAM (BuildMethod: makefile) +# +# SAM's BuildMethod: makefile invokes a target named build- +# for each function, passing $(ARTIFACTS_DIR) (provided by SAM per function) as +# the destination for the finished build artifact (dependencies + source). +# +# Target names MUST match the function logical IDs in infrastructure/template.yaml +# EXACTLY (build-AgentLambdaFunction, build-McpServerLambda) or SAM cannot resolve +# them. +# +# The recipes reuse the project's proven two-step pip3 manylinux install so wheels +# are Linux/manylinux2014_x86_64-correct even when `sam build` runs on macOS — +# without Docker. +# +# Conventions (project-conventions.md): +# - Use pip3, never bare pip +# - Two steps: --only-binary=:all: against requirements.txt, then --no-deps for +# the pure-Python set (--only-binary silently skips pure-Python packages) +# - Do NOT delete .dist-info directories (OpenTelemetry reads them at runtime) +# - Preserve the src/ prefix so src.agent.handler.lambda_handler and +# src.mcp_server.handler.lambda_handler resolve + +build-AgentLambdaFunction: + # Step A: binary packages from requirements.txt (manylinux wheels) + pip3 install --target "$(ARTIFACTS_DIR)" \ + --platform manylinux2014_x86_64 --python-version 3.12 \ + --only-binary=:all: -r requirements.txt + # Step B: pure-Python packages skipped by --only-binary in Step A + pip3 install --target "$(ARTIFACTS_DIR)" \ + --platform manylinux2014_x86_64 --python-version 3.12 \ + --only-binary=:all: --no-deps \ + requests urllib3 charset-normalizer idna certifi PyJWT cryptography cffi mcp + # Copy the src/ tree into the artifact, preserving the src/ prefix so + # src.agent.handler.lambda_handler resolves + cp -r src "$(ARTIFACTS_DIR)/src" + +build-McpServerLambda: + # Step A: binary packages from requirements.txt (manylinux wheels) + pip3 install --target "$(ARTIFACTS_DIR)" \ + --platform manylinux2014_x86_64 --python-version 3.12 \ + --only-binary=:all: -r requirements.txt + # Step B: pure-Python packages skipped by --only-binary in Step A + pip3 install --target "$(ARTIFACTS_DIR)" \ + --platform manylinux2014_x86_64 --python-version 3.12 \ + --only-binary=:all: --no-deps \ + requests urllib3 charset-normalizer idna certifi PyJWT cryptography cffi mcp + # Copy the src/ tree into the artifact, preserving the src/ prefix so + # src.mcp_server.handler.lambda_handler resolves + cp -r src "$(ARTIFACTS_DIR)/src" diff --git a/strands-agentcore-mcp/README.md b/strands-agentcore-mcp/README.md new file mode 100644 index 000000000..9a316b5dd --- /dev/null +++ b/strands-agentcore-mcp/README.md @@ -0,0 +1,161 @@ +# strands-agentcore-mcp + +A serverless AI agent that answers natural-language prompts about products by invoking tools through AWS Bedrock AgentCore Gateway using the Model Context Protocol (MCP). + +## Architecture + +![Architecture Diagram](architecture/mcp-target.png) + +``` +User → Agent Lambda → AgentCore Gateway (MCP) → API Gateway → MCP Server Lambda → DynamoDB + │ │ │ + Strands Agent CUSTOM_JWT Auth JSON-RPC 2.0 + + BedrockModel + MCP Routing Tool Execution + + MCPClient + Tool Discovery (list/get/put) + │ + Cognito JWT + Validated +``` + +The agent is model-driven: Claude decides which tool to call based on the user's prompt and the tool schemas discovered at runtime via `tools/list`. + +> **Why API Gateway?** AgentCore Gateway's MCP target type requires an HTTPS endpoint to forward MCP requests to. Lambda functions don't have a public HTTPS URL on their own, so API Gateway sits in front of the MCP Server Lambda to provide one. This is different from Smithy or OpenAPI targets where the Gateway handles protocol translation — with an MCP target, the Lambda itself speaks MCP (JSON-RPC 2.0 over HTTP) directly. + +## Prerequisites + +- [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) installed +- AWS CLI v2 configured with credentials for `us-east-1` +- Python 3.12 and `pip3` +- **No Docker required.** The build is Docker-free: `sam build` runs the root `Makefile`'s `BuildMethod: makefile` targets, which perform the two-step `pip3` manylinux install directly on your machine. There is no `--use-container` step and no Docker daemon to run. +- Bedrock model access enabled in your account: + - Open the [Bedrock console](https://console.aws.amazon.com/bedrock) → Model Access + - Enable **Claude Sonnet 4.5** (cross-region inference profile) + +## Deploy + +### Step 1: Open a Terminal + +Open a terminal on your machine and navigate to where you want to clone the project. + +### Step 2: Clone the Repository + +```bash +git clone https://github.com/aws-samples/serverless-patterns +cd serverless-patterns/strands-agentcore-mcp +``` + +### Step 3: Deploy + +```bash +./scripts/deploy.sh +``` + +To use a different model, edit the `BedrockModelId` value in the `parameter_overrides` line of `samconfig.toml` before running (or pass `sam deploy --parameter-overrides 'BedrockModelId=""'`). + +The deploy script is a thin wrapper around AWS SAM. It runs `sam build` (Docker-free — no `--use-container`) followed by `sam deploy` (which reads `samconfig.toml`), then performs the post-deploy steps. In order it will: + +1. Build the SAM application with `sam build` — the native `BuildMethod: makefile` build (driven by the root `Makefile`) installs Lambda dependencies via the two-step `pip3` manylinux install and packages the real `src/` tree, no Docker needed +2. Deploy the stack with `sam deploy` — Cognito, DynamoDB, API Gateway, Lambda x2, AgentCore Gateway, and IAM roles are created (or updated) in one shot with the real Lambda code, no inline placeholders +3. Read the stack outputs +4. Create and synchronize the MCP Gateway Target +5. Seed DynamoDB with 3 sample products +6. Create a Cognito test user (`testuser` / `TestPass123!`) +7. Generate `scripts/test.sh` with deployment values baked in + +Deploy takes approximately 5–10 minutes on first run. + +> **Packaging convention.** The native SAM `BuildMethod: makefile` build replaces the old in-script two-step `pip3` packaging and the separate `aws lambda update-function-code` flow. The proven two-step `pip3 --platform manylinux2014_x86_64 --only-binary=:all:` install (binary packages, then pure-Python packages `--no-deps`) now lives in the root `Makefile`'s `build-AgentLambdaFunction` / `build-McpServerLambda` targets and runs during `sam build`. `sam deploy` ships the real code directly, so there is no manual zip-and-update step. + +## Test + +Run the default prompt (lists all products): + +```bash +./scripts/test.sh +``` + +Or pass a custom prompt: + +```bash +./scripts/test.sh 'List all products in Electronics' +./scripts/test.sh 'Get product ELEC-001 details' +./scripts/test.sh 'Add a new product called Widget Pro in Electronics for $49.99' +./scripts/test.sh 'Update the price of ELEC-001 to $149.99' +``` + +## Terminate + +Removes all AWS resources created by the deploy: + +```bash +./scripts/terminate.sh +``` + +This deletes: +- AgentCore Gateway Target (created outside CloudFormation) +- CloudFormation stack (Lambda x2, API Gateway, DynamoDB, Cognito User Pool, IAM roles, AgentCore Gateway) +- CloudWatch Log Groups +- Legacy S3 deploy bucket, if one is still present from an older deploy (SAM now uses its own managed artifact bucket via `resolve_s3`, which is not removed here) +- Generated `scripts/test.sh` + +## MCP Tools + +The agent has access to three product management tools: + +| Tool | Description | +|------|-------------| +| `list_products` | List all products, optionally filtered by category | +| `get_product` | Get a specific product by `category` and `productId` | +| `put_product` | Create or update a product | + +## Project Structure + +``` +├── infrastructure/ +│ └── template.yaml # SAM template — all AWS resources +├── Makefile # Docker-free SAM build targets (BuildMethod: makefile) +├── samconfig.toml # sam deploy parameters (stack, region, capabilities) +├── scripts/ +│ ├── deploy.sh # One-command deploy (wraps sam build + sam deploy) +│ ├── terminate.sh # One-command teardown +│ └── test.sh # Generated by deploy.sh +├── src/ +│ ├── agent/ # Agent Lambda (Strands SDK) +│ │ ├── handler.py +│ │ ├── agent_processor.py +│ │ └── strands_client.py +│ ├── mcp_server/ # MCP Server Lambda +│ │ ├── handler.py # JSON-RPC 2.0 handler +│ │ ├── tools.py # Tool registry +│ │ └── dynamodb_client.py # DynamoDB operations +│ └── shared/ # Shared utilities +│ ├── jwt_utils.py +│ ├── models.py +│ ├── error_utils.py +│ └── logging_utils.py +├── tests/ +│ ├── unit/ # Template and deploy script assertions +│ └── property/ # Hypothesis property-based tests +├── requirements.txt # Lambda runtime dependencies +└── requirements-dev.txt # Local dev/test dependencies +``` + +## Running Tests + +```bash +pip3 install -r requirements-dev.txt +python3 -m pytest tests/unit/ tests/property/ -v +``` + +## Notes + +- Region: `us-east-1` +- Build: Docker-free. `sam build` uses each function's `Metadata.BuildMethod: makefile` to run the root `Makefile`'s two-step `pip3` manylinux install, producing Linux-correct wheels on macOS without a Docker daemon. +- Model: Claude Sonnet 4.5 (cross-region inference profile `us.anthropic.claude-sonnet-4-5-20250929-v1:0`) +- To swap models without code changes, edit the `BedrockModelId` value in the `parameter_overrides` line of `samconfig.toml` and re-run `./scripts/deploy.sh`. +- The MCP Gateway Target is created via boto3 in `deploy.sh` (not the SAM template) because AgentCore probes `tools/list` during target creation — keeping it as an ordered post-deploy step preserves the proven create-vs-update behavior even though `sam deploy` ships the real Lambda code. + +--- + +Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 diff --git a/strands-agentcore-mcp/architecture/mcp-target.png b/strands-agentcore-mcp/architecture/mcp-target.png new file mode 100644 index 000000000..a1b0f1842 Binary files /dev/null and b/strands-agentcore-mcp/architecture/mcp-target.png differ diff --git a/strands-agentcore-mcp/example-pattern.json b/strands-agentcore-mcp/example-pattern.json new file mode 100644 index 000000000..79f4a56af --- /dev/null +++ b/strands-agentcore-mcp/example-pattern.json @@ -0,0 +1,86 @@ +{ + "title": "Serverless AI Agent with AgentCore MCP Gateway on Lambda", + "description": "Strands SDK agent on Lambda routes tool calls via AgentCore Gateway (MCP) to a Lambda MCP Server backed by DynamoDB, with Cognito JWT auth.", + "language": "Python", + "level": "300", + "framework": "SAM", + "introBox": { + "headline": "How it works", + "text": [ + "The user authenticates with Amazon Cognito and receives a JWT token.", + "The JWT is passed to an Agent Lambda which uses the Strands Agents SDK to create an AI agent backed by Amazon Bedrock (Claude Sonnet 4.5 cross-region inference profile).", + "The Strands Agent connects to an AgentCore Gateway MCP endpoint, dynamically discovering available product management tools via the MCP tools/list protocol.", + "The AgentCore Gateway validates the JWT token using a CUSTOM_JWT authorizer backed by Cognito, then routes MCP tool calls to the MCP Server Lambda via API Gateway.", + "The MCP Server Lambda implements the Model Context Protocol (JSON-RPC 2.0 over HTTP) and exposes three tools: list_products, get_product, and put_product, backed by a DynamoDB table.", + "The Strands SDK handles the full agentic loop: tool discovery, Claude tool selection, MCP tool execution, and response formatting — all in a single agent() call.", + "The model used by the agent is configurable via the BEDROCK_MODEL_ID environment variable on the Agent Lambda, set from the BedrockModelId SAM template parameter." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/strands-agentcore-mcp", + "templateURL": "serverless-patterns/strands-agentcore-mcp", + "projectFolder": "strands-agentcore-mcp", + "templateFile": "infrastructure/template.yaml" + } + }, + "resources": { + "bullets": [ + { + "text": "Strands Agents SDK", + "link": "https://github.com/strands-agents/sdk-python" + }, + { + "text": "Amazon Bedrock AgentCore Gateway", + "link": "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html" + }, + { + "text": "AgentCore Gateway MCP Target", + "link": "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-target-MCPservers.html" + }, + { + "text": "Model Context Protocol (MCP)", + "link": "https://modelcontextprotocol.io/" + }, + { + "text": "Amazon Cognito JWT Authentication", + "link": "https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html" + }, + { + "text": "Amazon Bedrock Cross-Region Inference", + "link": "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html" + }, + { + "text": "Amazon DynamoDB", + "link": "https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Introduction.html" + } + ] + }, + "deploy": { + "text": [ + "./scripts/deploy.sh" + ] + }, + "testing": { + "text": [ + "./scripts/test.sh", + "./scripts/test.sh 'List all products in Electronics'", + "./scripts/test.sh 'Get product ELEC-001 details'", + "./scripts/test.sh 'Add a new product called Widget Pro in Electronics for $49.99'" + ] + }, + "cleanup": { + "text": [ + "./scripts/terminate.sh" + ] + }, + "authors": [ + { + "name": "Mike Hume", + "image": "https://serverlessland.com/assets/images/contributors/mike-hume.jpg", + "bio": "AWS Senior Solutions Architect & UKPS Serverless Lead.", + "linkedin": "michael-hume-4663bb64", + "twitter": "" + } + ] +} diff --git a/strands-agentcore-mcp/infrastructure/.gitkeep b/strands-agentcore-mcp/infrastructure/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/strands-agentcore-mcp/infrastructure/.gitkeep @@ -0,0 +1 @@ + diff --git a/strands-agentcore-mcp/infrastructure/template.yaml b/strands-agentcore-mcp/infrastructure/template.yaml new file mode 100644 index 000000000..a4450fa1f --- /dev/null +++ b/strands-agentcore-mcp/infrastructure/template.yaml @@ -0,0 +1,450 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + strands-agentcore-mcp — Serverless AI agent using Strands SDK + Claude Sonnet 4.5, + backed by AgentCore Gateway (MCP target), API Gateway, MCP Server Lambda, and DynamoDB. + Migrated to AWS SAM (BuildMethod: makefile, Docker-free build). + +# --------------------------------------------------------------------------- +# Parameters +# --------------------------------------------------------------------------- +Parameters: + GatewayName: + Type: String + Default: agentcore-mcp-gateway + Description: Name for the AgentCore Gateway resource. + + CognitoUserPoolName: + Type: String + Default: agentcore-mcp-user-pool + Description: Name for the Cognito User Pool. + + BedrockModelId: + Type: String + Default: us.anthropic.claude-sonnet-4-5-20250929-v1:0 + Description: > + Bedrock model ID for the Agent Lambda. Override to swap models without + code changes (e.g. a newer Claude Sonnet version). + + ProductTableName: + Type: String + Default: Product_Table + Description: Name for the DynamoDB product table. + +# --------------------------------------------------------------------------- +# Resources +# --------------------------------------------------------------------------- +# NOTE: This section is authored incrementally across subtasks 1.1–1.4. +# 1.1 (this subtask): Cognito User Pool + Client, DynamoDB Product Table +# 1.2: IAM roles (AgentLambdaRole, McpServerRole, GatewayExecutionRole) +# 1.3: Serverless::Function definitions (AgentLambdaFunction, McpServerLambda) +# 1.4: API Gateway resources + AgentCoreGateway +# --------------------------------------------------------------------------- +Resources: + + # ========================================================================= + # Identity layer — Cognito User Pool + Client + # ========================================================================= + + CognitoUserPool: + Type: AWS::Cognito::UserPool + Properties: + UserPoolName: !Ref CognitoUserPoolName + # Standard username + password sign-in + UsernameAttributes: [] + Policies: + PasswordPolicy: + MinimumLength: 8 + RequireUppercase: true + RequireLowercase: true + RequireNumbers: true + RequireSymbols: false + AutoVerifiedAttributes: [] + Schema: + - Name: email + AttributeDataType: String + Mutable: true + Required: false + + CognitoUserPoolClient: + Type: AWS::Cognito::UserPoolClient + Properties: + ClientName: agentcore-mcp-client + UserPoolId: !Ref CognitoUserPool + GenerateSecret: false + # USER_PASSWORD_AUTH is required for the test script to obtain tokens + ExplicitAuthFlows: + - ALLOW_USER_PASSWORD_AUTH + - ALLOW_REFRESH_TOKEN_AUTH + + # ========================================================================= + # Data layer — DynamoDB Product Table + # ========================================================================= + + ProductTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Ref ProductTableName + # Partition key: category (String) + # Sort key: productId (String) + AttributeDefinitions: + - AttributeName: category + AttributeType: S + - AttributeName: productId + AttributeType: S + KeySchema: + - AttributeName: category + KeyType: HASH + - AttributeName: productId + KeyType: RANGE + BillingMode: PAY_PER_REQUEST + # ========================================================================= + # Agent Lambda — IAM role + # ========================================================================= + + AgentLambdaRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub '${AWS::StackName}-agent-lambda-role' + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: BedrockInvoke + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + # Cross-region inference profiles route dynamically to us-east-1, + # us-east-2, us-west-2, etc. Use a wildcard scoped to Claude models + # only to avoid enumerating every possible region. + Resource: 'arn:aws:bedrock:*::foundation-model/anthropic.claude-*' + - Effect: Allow + Action: + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + Resource: !Sub 'arn:aws:bedrock:${AWS::Region}:${AWS::AccountId}:inference-profile/us.anthropic.claude-*' + - PolicyName: AgentCoreInvoke + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: bedrock-agentcore:InvokeGateway + Resource: !GetAtt AgentCoreGateway.GatewayArn + - PolicyName: CloudWatchLogs + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*' + + # ========================================================================= + # MCP Server Lambda — IAM role + # ========================================================================= + + McpServerRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub '${AWS::StackName}-mcp-server-role' + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: DynamoDBAccess + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - dynamodb:GetItem + - dynamodb:PutItem + - dynamodb:Query + - dynamodb:Scan + # Scoped to ProductTable ARN ONLY — no wildcard (Requirement 9.2, 9.4) + Resource: !GetAtt ProductTable.Arn + - PolicyName: CloudWatchLogs + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*' + + # ========================================================================= + # Gateway Execution Role + # ========================================================================= + # + # CASING TRAPS (from project-conventions.md): + # - AgentCoreGateway uses RoleArn, NOT ExecutionRoleArn + # - AuthorizerConfiguration uses CustomJWTAuthorizer (JWT all-caps) + # - AllowedAudience, NOT Audience + # - CredentialProviderConfigurations is an ARRAY + # - DiscoveryUrl must end with /.well-known/openid-configuration + # ========================================================================= + + GatewayExecutionRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub '${AWS::StackName}-gateway-execution-role' + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: bedrock-agentcore.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: AgentCoreAccess + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: bedrock-agentcore:* + # Four required ARN patterns — NO wildcard * on Resource (Requirement 5.2, 5.4) + Resource: + - !Sub 'arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:token-vault/default' + - !Sub 'arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:token-vault/default/apikeycredentialprovider/*' + - !Sub 'arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:workload-identity-directory/default' + - !Sub 'arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:workload-identity-directory/default/workload-identity/${GatewayName}-*' + - PolicyName: InvokeMcpApi + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: execute-api:Invoke + # Scoped to all APIs in this account/region — the API Gateway resource + # policy on McpApi further restricts this to the specific API. + # We cannot reference McpApi here directly because McpApi's resource + # policy references this role (circular dependency). + Resource: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:*/*/*/*' + + # ========================================================================= + # Agent Lambda — function (AWS::Serverless::Function, Docker-free makefile build) + # ========================================================================= + + AgentLambdaFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub '${AWS::StackName}-agent' + Runtime: python3.12 + # src/ prefix preserved so src.agent.handler.lambda_handler resolves (Req 2.3) + Handler: src.agent.handler.lambda_handler + # Project root → full src/ tree + requirements.txt + Makefile (Req 2.2, 2.5, 9.3) + CodeUri: ../ + # Explicit least-privilege role kept as source of truth (Req 6) + Role: !GetAtt AgentLambdaRole.Arn + Timeout: 120 + MemorySize: 512 + Environment: + Variables: + # AgentCore Gateway endpoint URL for MCP client connection + # (forward reference to AgentCoreGateway, added in subtask 1.4) + GATEWAY_URL: !GetAtt AgentCoreGateway.GatewayUrl + # Cognito issuer URL for JWT signature verification via JWKS + COGNITO_ISSUER: !Sub 'https://cognito-idp.${AWS::Region}.amazonaws.com/${CognitoUserPool}' + # Cognito User Pool ID (available to handler if needed) + COGNITO_USER_POOL_ID: !Ref CognitoUserPool + # Cognito App Client ID (available to handler if needed) + COGNITO_CLIENT_ID: !Ref CognitoUserPoolClient + # Bedrock model ID — change via stack parameter to swap models without code changes + BEDROCK_MODEL_ID: !Ref BedrockModelId + # NO Code.ZipFile (Req 3.1); NO Layers (Req 2.6); NO Events + Metadata: + # Docker-free build via Makefile target build-AgentLambdaFunction (Req 1.2, 9.x) + BuildMethod: makefile + + # ========================================================================= + # MCP Server Lambda — function (AWS::Serverless::Function, Docker-free makefile build) + # ========================================================================= + + McpServerLambda: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub '${AWS::StackName}-mcp-server' + Runtime: python3.12 + # src/ prefix preserved so src.mcp_server.handler.lambda_handler resolves (Req 2.4) + Handler: src.mcp_server.handler.lambda_handler + # Project root → full src/ tree + requirements.txt + Makefile (Req 2.2, 2.5, 9.3) + CodeUri: ../ + # Explicit least-privilege role kept as source of truth (Req 6) + Role: !GetAtt McpServerRole.Arn + Timeout: 30 + MemorySize: 256 + Environment: + Variables: + PRODUCT_TABLE: !Ref ProductTable + # NO Code.ZipFile (Req 3.1); NO Layers (Req 2.6); NO Events + Metadata: + # Docker-free build via Makefile target build-McpServerLambda (Req 1.2, 9.x) + BuildMethod: makefile + + # ========================================================================= + # API Gateway — REST API, resource, method, deployment, stage, permission + # ========================================================================= + + McpApi: + Type: AWS::ApiGateway::RestApi + Properties: + Name: mcp-api + Description: HTTPS endpoint for the MCP Server Lambda (AgentCore MCP target) + + McpResource: + Type: AWS::ApiGateway::Resource + Properties: + RestApiId: !Ref McpApi + ParentId: !GetAtt McpApi.RootResourceId + PathPart: mcp + + McpMethod: + Type: AWS::ApiGateway::Method + Properties: + RestApiId: !Ref McpApi + ResourceId: !Ref McpResource + HttpMethod: POST + # NONE auth — AgentCore Gateway signs requests with SigV4 (execute-api service) + # using the GatewayExecutionRole. The gateway's IAM policy + the role's + # execute-api:Invoke permission provide the access control layer. + # AWS_IAM auth on the method causes 403 because API GW rejects cross-service + # assumed-role SigV4 calls without an explicit resource policy allowing the + # assumed-role session ARN, which cannot be expressed cleanly in CloudFormation. + AuthorizationType: NONE + Integration: + Type: AWS_PROXY + IntegrationHttpMethod: POST + Uri: !Sub + - 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaArn}/invocations' + - LambdaArn: !GetAtt McpServerLambda.Arn + + McpDeployment: + Type: AWS::ApiGateway::Deployment + DependsOn: McpMethod + Properties: + RestApiId: !Ref McpApi + + McpStage: + Type: AWS::ApiGateway::Stage + Properties: + RestApiId: !Ref McpApi + DeploymentId: !Ref McpDeployment + StageName: prod + + # Resource-based permission: API Gateway → McpServerLambda invoke + McpApiInvokePermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !GetAtt McpServerLambda.Arn + Action: lambda:InvokeFunction + Principal: apigateway.amazonaws.com + # Scoped to POST /mcp on this specific API (Requirement 8.3) + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${McpApi}/*/POST/mcp' + + # ========================================================================= + # AgentCore Gateway + # ========================================================================= + # + # CASING TRAP: CustomJWTAuthorizer — JWT must be ALL CAPS (not the mixed-case variant) + # CASING TRAP: RoleArn — NOT ExecutionRoleArn + # CASING TRAP: AllowedAudience — NOT Audience + # CASING TRAP: DiscoveryUrl must end with /.well-known/openid-configuration + + AgentCoreGateway: + Type: AWS::BedrockAgentCore::Gateway + Properties: + Name: !Ref GatewayName + Description: AgentCore Gateway with MCP target for product management tools + # CASING TRAP: RoleArn (NOT ExecutionRoleArn) — Requirement 12.2 + RoleArn: !GetAtt GatewayExecutionRole.Arn + # Required: authorizer type enum — CUSTOM_JWT | AWS_IAM | NONE + AuthorizerType: CUSTOM_JWT + # Required: protocol type enum — only MCP is currently supported + ProtocolType: MCP + AuthorizerConfiguration: + # CASING TRAP: CustomJWTAuthorizer — JWT all-caps (NOT the mixed-case variant) — Requirement 12.1 + CustomJWTAuthorizer: + # CASING TRAP: DiscoveryUrl must end with /.well-known/openid-configuration — Requirement 12.5 + DiscoveryUrl: !Sub 'https://cognito-idp.${AWS::Region}.amazonaws.com/${CognitoUserPool}/.well-known/openid-configuration' + # CASING TRAP: AllowedAudience (NOT Audience) — Requirement 12.3 + AllowedAudience: + - !Ref CognitoUserPoolClient + + # ========================================================================= + # MCP Target — NOTE: Created via AWS CLI / boto3 in deploy.sh AFTER Lambda code + # is deployed, because AgentCore probes tools/list during target creation. + # Kept as an explicit, ordered post-deploy step — NOT declared in this template. + # ========================================================================= + +# --------------------------------------------------------------------------- +# Outputs — consumed by deploy.sh to generate scripts/test.sh +# --------------------------------------------------------------------------- +Outputs: + AgentLambdaName: + Description: Agent Lambda function name + Value: !Ref AgentLambdaFunction + Export: + Name: !Sub '${AWS::StackName}-AgentLambdaName' + + McpServerLambdaName: + Description: MCP Server Lambda function name + Value: !Ref McpServerLambda + Export: + Name: !Sub '${AWS::StackName}-McpServerLambdaName' + + CognitoUserPoolId: + Description: Cognito User Pool ID + Value: !Ref CognitoUserPool + Export: + Name: !Sub '${AWS::StackName}-CognitoUserPoolId' + + CognitoClientId: + Description: Cognito App Client ID + Value: !Ref CognitoUserPoolClient + Export: + Name: !Sub '${AWS::StackName}-CognitoClientId' + + McpApiInvokeUrl: + Description: HTTPS invoke URL for the MCP API (POST /prod/mcp) + Value: !Sub 'https://${McpApi}.execute-api.${AWS::Region}.amazonaws.com/prod/mcp' + Export: + Name: !Sub '${AWS::StackName}-McpApiInvokeUrl' + + GatewayUrl: + Description: AgentCore Gateway endpoint URL + Value: !GetAtt AgentCoreGateway.GatewayUrl + Export: + Name: !Sub '${AWS::StackName}-GatewayUrl' + + ProductTableName: + Description: DynamoDB Product Table name + Value: !Ref ProductTable + Export: + Name: !Sub '${AWS::StackName}-ProductTableName' + + # NEW (subtask 1.5): first-class GatewayId output — replaces the old deploy.sh + # URL-parsing fallback that derived the gateway ID from GatewayUrl (Req 12.2) + GatewayId: + Description: AgentCore Gateway ID + # The AWS::BedrockAgentCore::Gateway resource exposes the gateway ID via the + # GatewayIdentifier attribute (there is no GatewayId attribute). Output key + # stays GatewayId — the downstream deploy.sh contract — while resolving from + # the actual attribute (Req 12.2). + Value: !GetAtt AgentCoreGateway.GatewayIdentifier + Export: + Name: !Sub '${AWS::StackName}-GatewayId' diff --git a/strands-agentcore-mcp/requirements-dev.txt b/strands-agentcore-mcp/requirements-dev.txt new file mode 100644 index 000000000..62197b6ea --- /dev/null +++ b/strands-agentcore-mcp/requirements-dev.txt @@ -0,0 +1,5 @@ +pytest +hypothesis +moto[dynamodb] +pyyaml +cfn-lint diff --git a/strands-agentcore-mcp/requirements.txt b/strands-agentcore-mcp/requirements.txt new file mode 100644 index 000000000..98a726750 --- /dev/null +++ b/strands-agentcore-mcp/requirements.txt @@ -0,0 +1,6 @@ +strands-agents>=1.0.0 +mcp>=1.0.0 +requests>=2.31.0 +PyJWT[crypto]>=2.8.0 +boto3 +jsonschema diff --git a/strands-agentcore-mcp/scripts/.gitkeep b/strands-agentcore-mcp/scripts/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/strands-agentcore-mcp/scripts/.gitkeep @@ -0,0 +1 @@ + diff --git a/strands-agentcore-mcp/scripts/deploy.sh b/strands-agentcore-mcp/scripts/deploy.sh new file mode 100755 index 000000000..c728124a8 --- /dev/null +++ b/strands-agentcore-mcp/scripts/deploy.sh @@ -0,0 +1,501 @@ +#!/usr/bin/env bash +# ============================================================================= +# deploy.sh — One-command deploy for strands-agentcore-mcp (AWS SAM) +# +# Usage: +# ./scripts/deploy.sh +# +# Prerequisites: AWS SAM CLI + pip3 (Python 3.12). No Docker required — +# the build is Docker-free via the root Makefile's BuildMethod: makefile +# targets, which run the two-step pip3 manylinux install during `sam build`. +# +# What it does (in order): +# 1. Build the SAM application (sam build — Docker-free, Makefile-driven) +# 2. Deploy the SAM application (sam deploy — reads samconfig.toml) +# 3. Read stack outputs +# 4. Create or update the AgentCore MCP Target (boto3) + synchronize +# 5. Seed DynamoDB with sample products +# 6. Create Cognito test user +# 7. Generate scripts/test.sh with baked-in deployment values +# ============================================================================= +set -euo pipefail + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +REGION="us-east-1" +STACK_NAME="agentcore-mcp" +TEMPLATE_FILE="infrastructure/template.yaml" + +# Test user credentials (baked into generated test.sh) +TEST_USERNAME="testuser" +TEST_PASSWORD="TestPass123!" +TEST_EMAIL="testuser@example.com" + +# PID-based temp files — macOS-compatible (no mktemp suffix templates) +TMP_STACK_OUTPUT="/tmp/agentcore-mcp.$$.stack-output.json" + +# --------------------------------------------------------------------------- +# Cleanup trap — runs on exit (success or failure) +# --------------------------------------------------------------------------- +cleanup() { + rm -rf \ + "$TMP_STACK_OUTPUT" \ + 2>/dev/null || true +} +trap cleanup EXIT + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +log() { echo "[deploy] $*"; } +die() { echo "[deploy] ERROR: $*" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# STEP 1: Build the SAM application (Requirement 2.1, 10.1) +# +# Docker-free: each function declares Metadata.BuildMethod: makefile, so +# `sam build` invokes the root Makefile's build- targets, +# which run the proven two-step pip3 manylinux install. No --use-container, +# no Docker daemon required. +# --------------------------------------------------------------------------- +log "Step 1: Building SAM application..." +sam build --template "$TEMPLATE_FILE" +log "Build complete." + +# --------------------------------------------------------------------------- +# STEP 2: Deploy the SAM application (Requirement 10.1, 10.2) +# +# All deploy parameters (stack name, region, capabilities, resolve_s3, +# parameter_overrides, fail_on_empty_changeset=false) live in samconfig.toml, +# so no flags are passed here. SAM handles create-vs-update automatically and +# tolerates no-op changesets. +# --------------------------------------------------------------------------- +log "Step 2: Deploying SAM application (stack '$STACK_NAME')..." +sam deploy +log "Deploy complete." + +# --------------------------------------------------------------------------- +# Read stack outputs +# +# Prefer `sam list stack-outputs` (Requirement 12.2); fall back to +# `aws cloudformation describe-stacks` if `sam list` fails or is unavailable. +# The two sources produce different JSON shapes: +# - sam list stack-outputs: a flat array of {OutputKey, OutputValue} +# - describe-stacks: an object with Stacks[0].Outputs (same item shape) +# The parser below detects which shape it received and extracts accordingly. +# --------------------------------------------------------------------------- +log "Reading stack outputs..." +if sam list stack-outputs \ + --stack-name "$STACK_NAME" \ + --region "$REGION" \ + --output json \ + > "$TMP_STACK_OUTPUT" 2>/dev/null; then + log " Read outputs via 'sam list stack-outputs'." +else + log " 'sam list stack-outputs' unavailable — falling back to describe-stacks." + aws cloudformation describe-stacks \ + --stack-name "$STACK_NAME" \ + --region "$REGION" \ + > "$TMP_STACK_OUTPUT" +fi + +# Parse outputs into shell variables. +# Write the Python script to a temp file to avoid shell brace-expansion issues +# with dict comprehensions inside eval "$(...)". +TMP_PARSE_PY="/tmp/agentcore-mcp.$$.parse.py" +cat > "$TMP_PARSE_PY" << 'PYEOF' +import json, sys + +data = json.load(open(sys.argv[1])) + +# Detect source shape: +# - sam list stack-outputs -> a list of {OutputKey, OutputValue} +# - describe-stacks -> {'Stacks': [{'Outputs': [...]}]} +if isinstance(data, list): + raw_outputs = data +elif isinstance(data, dict) and 'Stacks' in data: + raw_outputs = data['Stacks'][0].get('Outputs', []) +else: + raw_outputs = data.get('Outputs', []) if isinstance(data, dict) else [] + +outputs = {} +for o in raw_outputs: + outputs[o['OutputKey']] = o['OutputValue'] + +keys = [ + 'AgentLambdaName', + 'McpServerLambdaName', + 'CognitoUserPoolId', + 'CognitoClientId', + 'McpApiInvokeUrl', + 'GatewayUrl', + 'ProductTableName', + 'GatewayId', +] +for k in keys: + v = outputs.get(k, '') + v_escaped = v.replace("'", "'\\''") + print(f"{k}='{v_escaped}'") +PYEOF + +eval "$(python3 "$TMP_PARSE_PY" "$TMP_STACK_OUTPUT")" +rm -f "$TMP_PARSE_PY" + +log " AgentLambdaName: $AgentLambdaName" +log " McpServerLambdaName: $McpServerLambdaName" +log " CognitoUserPoolId: $CognitoUserPoolId" +log " CognitoClientId: $CognitoClientId" +log " McpApiInvokeUrl: $McpApiInvokeUrl" +log " GatewayUrl: $GatewayUrl" +log " ProductTableName: $ProductTableName" +log " GatewayId: $GatewayId" + +# --------------------------------------------------------------------------- +# STEP 5b: Create or update the AgentCore MCP Target +# +# McpTarget is NOT in the CloudFormation template because AgentCore probes +# tools/list during target creation — the placeholder Lambda would return a +# non-MCP response causing a Forbidden error. We create it here after the +# real Lambda code is deployed. +# +# We use the AWS CLI (bedrock-agentcore-control) to create/update the target. +# --------------------------------------------------------------------------- +log "Step 5b: Creating/updating AgentCore MCP Target..." + +# GatewayId is read from the stack outputs above (first-class output). +GATEWAY_ID="$GatewayId" + +log " Gateway ID: $GATEWAY_ID" +log " MCP Endpoint: $McpApiInvokeUrl" + +MCP_ENDPOINT="$McpApiInvokeUrl" +TMP_TARGET_OUTPUT="/tmp/agentcore-mcp.$$.target-output.json" + +# Check if target already exists +EXISTING_TARGET_ID=$(aws bedrock-agentcore-control list-gateway-targets \ + --gateway-identifier "$GATEWAY_ID" \ + --region "$REGION" \ + --query "items[?name=='mcp-server-target'].targetId" \ + --output text 2>/dev/null || echo "") + +if [ -z "$EXISTING_TARGET_ID" ] || [ "$EXISTING_TARGET_ID" = "None" ]; then + log " Creating new MCP target..." + # Use boto3 directly — the AWS CLI's local schema validation rejects + # iamCredentialProvider even though the service requires it for mcpServer targets. + TMP_TARGET_PY="/tmp/agentcore-mcp.$$.target.py" + cat > "$TMP_TARGET_PY" << PYEOF +import boto3, sys, json + +client = boto3.client('bedrock-agentcore-control', region_name=sys.argv[1]) +resp = client.create_gateway_target( + gatewayIdentifier=sys.argv[2], + name='mcp-server-target', + description='MCP target pointing at the API Gateway HTTPS endpoint', + credentialProviderConfigurations=[{ + 'credentialProviderType': 'GATEWAY_IAM_ROLE', + 'credentialProvider': { + 'iamCredentialProvider': { + 'service': 'execute-api' + } + } + }], + targetConfiguration={ + 'mcp': { + 'mcpServer': { + 'endpoint': sys.argv[3] + } + } + } +) +print(json.dumps({'targetId': resp.get('targetId', '')})) +PYEOF + python3 "$TMP_TARGET_PY" "$REGION" "$GATEWAY_ID" "$MCP_ENDPOINT" > "$TMP_TARGET_OUTPUT" + rm -f "$TMP_TARGET_PY" + TARGET_ID=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('targetId',''))" "$TMP_TARGET_OUTPUT") + log " MCP target created (targetId: $TARGET_ID)." +else + log " Updating existing MCP target ($EXISTING_TARGET_ID)..." + TMP_TARGET_PY="/tmp/agentcore-mcp.$$.target.py" + cat > "$TMP_TARGET_PY" << PYEOF +import boto3, sys, json + +client = boto3.client('bedrock-agentcore-control', region_name=sys.argv[1]) +resp = client.update_gateway_target( + gatewayIdentifier=sys.argv[2], + targetId=sys.argv[3], + name='mcp-server-target', + description='MCP target pointing at the API Gateway HTTPS endpoint', + credentialProviderConfigurations=[{ + 'credentialProviderType': 'GATEWAY_IAM_ROLE', + 'credentialProvider': { + 'iamCredentialProvider': { + 'service': 'execute-api' + } + } + }], + targetConfiguration={ + 'mcp': { + 'mcpServer': { + 'endpoint': sys.argv[4] + } + } + } +) +print(json.dumps({'targetId': resp.get('targetId', '')})) +PYEOF + python3 "$TMP_TARGET_PY" "$REGION" "$GATEWAY_ID" "$EXISTING_TARGET_ID" "$MCP_ENDPOINT" > "$TMP_TARGET_OUTPUT" + rm -f "$TMP_TARGET_PY" + TARGET_ID="$EXISTING_TARGET_ID" + log " MCP target updated." +fi + +# Synchronize the target so the gateway indexes the tools from the MCP server. +# Without this, tools/list returns 0 tools and the agent has nothing to call. +log " Synchronizing MCP target to index tools..." +TMP_SYNC_PY="/tmp/agentcore-mcp.$$.sync.py" +cat > "$TMP_SYNC_PY" << PYEOF +import boto3, sys, json, time + +client = boto3.client('bedrock-agentcore-control', region_name=sys.argv[1]) +gateway_id = sys.argv[2] +target_id = sys.argv[3] + +# Wait for target to reach a stable state before synchronizing +print(" Waiting for target to reach stable state...", flush=True) +for _ in range(36): # up to 3 minutes + resp = client.get_gateway_target( + gatewayIdentifier=gateway_id, + targetId=target_id + ) + status = resp.get('status', '') + print(f" Target status: {status}", flush=True) + if status not in ('CREATING', 'UPDATING', 'SYNCHRONIZING'): + break + time.sleep(5) + +# Trigger synchronization +print(" Triggering synchronization...", flush=True) +client.synchronize_gateway_targets( + gatewayIdentifier=gateway_id, + targetIdList=[target_id] +) + +# Poll until sync completes +for _ in range(36): # up to 3 minutes + resp = client.get_gateway_target( + gatewayIdentifier=gateway_id, + targetId=target_id + ) + status = resp.get('status', '') + reasons = resp.get('statusReasons', []) + print(f" Target status: {status}", flush=True) + if status not in ('SYNCHRONIZING', 'CREATING', 'UPDATING'): + if reasons: + print(f" Status reasons: {reasons}", flush=True) + break + time.sleep(5) + +print(f"Final status: {status}") +PYEOF +python3 "$TMP_SYNC_PY" "$REGION" "$GATEWAY_ID" "$TARGET_ID" +rm -f "$TMP_SYNC_PY" +log " MCP target synchronized." + +rm -f "$TMP_TARGET_OUTPUT" + +# --------------------------------------------------------------------------- +# STEP 6: Seed DynamoDB with sample products (Requirement 10.4) +# +# At least three items across at least two categories. +# DynamoDB attribute-value JSON format. +# --------------------------------------------------------------------------- +log "Step 6: Seeding DynamoDB table '$ProductTableName'..." + +aws dynamodb put-item \ + --table-name "$ProductTableName" \ + --item '{ + "category": {"S": "Electronics"}, + "productId": {"S": "ELEC-001"}, + "name": {"S": "Noise-cancelling Headphones"}, + "price": {"N": "199.99"} + }' \ + --region "$REGION" + +aws dynamodb put-item \ + --table-name "$ProductTableName" \ + --item '{ + "category": {"S": "Electronics"}, + "productId": {"S": "ELEC-002"}, + "name": {"S": "Wireless Keyboard"}, + "price": {"N": "79.99"} + }' \ + --region "$REGION" + +aws dynamodb put-item \ + --table-name "$ProductTableName" \ + --item '{ + "category": {"S": "Books"}, + "productId": {"S": "BOOK-001"}, + "name": {"S": "Clean Code"}, + "price": {"N": "34.99"} + }' \ + --region "$REGION" + +log " Seeded 3 products (Electronics x2, Books x1)." + +# --------------------------------------------------------------------------- +# STEP 7: Create Cognito test user (Requirement 10.5) +# --------------------------------------------------------------------------- +log "Step 7: Creating Cognito test user '$TEST_USERNAME'..." + +# Create user (suppress welcome email) +aws cognito-idp admin-create-user \ + --user-pool-id "$CognitoUserPoolId" \ + --username "$TEST_USERNAME" \ + --user-attributes "Name=email,Value=${TEST_EMAIL}" \ + --message-action SUPPRESS \ + --region "$REGION" \ + > /dev/null 2>&1 || log " User '$TEST_USERNAME' already exists — skipping create." + +# Set permanent password (confirms the user) +aws cognito-idp admin-set-user-password \ + --user-pool-id "$CognitoUserPoolId" \ + --username "$TEST_USERNAME" \ + --password "$TEST_PASSWORD" \ + --permanent \ + --region "$REGION" + +log " Test user '$TEST_USERNAME' is CONFIRMED." + +# --------------------------------------------------------------------------- +# STEP 8: Generate scripts/test.sh with baked-in literal values +# +# Rules (Requirements 11.3, 11.4, 11.5): +# - Use a heredoc + sed substitution (NOT nested echo emitting JSON) +# - Bake in USER_POOL_ID, CLIENT_ID, AGENT_LAMBDA_NAME, USERNAME, PASSWORD +# - Accept $1 as optional prompt, fall back to DEFAULT_PROMPT +# - Obtain ID token via initiate-auth USER_PASSWORD_AUTH +# - Invoke Agent Lambda with {"jwt": "", "prompt": ""} +# - Print the model's final answer +# --------------------------------------------------------------------------- +log "Step 8: Generating scripts/test.sh..." + +# Write the template with placeholder tokens +cat > scripts/test.sh <<'EOF' +#!/usr/bin/env bash +# ============================================================================= +# test.sh — End-to-end smoke test for strands-agentcore-mcp +# +# Generated by deploy.sh — do not edit manually. +# +# Usage: +# ./scripts/test.sh # default prompt +# ./scripts/test.sh 'List all products in Electronics' +# ./scripts/test.sh 'Get product ELEC-001 details' +# ./scripts/test.sh 'Add a new product called Widget' +# ============================================================================= +set -euo pipefail + +# --------------------------------------------------------------------------- +# Baked-in deployment values (substituted by deploy.sh) +# --------------------------------------------------------------------------- +USER_POOL_ID="__USER_POOL_ID__" +CLIENT_ID="__CLIENT_ID__" +AGENT_LAMBDA_NAME="__AGENT_LAMBDA_NAME__" +USERNAME="__USERNAME__" +PASSWORD="__PASSWORD__" +DEFAULT_PROMPT="List all products." +REGION="us-east-1" + +# --------------------------------------------------------------------------- +# Accept optional prompt argument +# --------------------------------------------------------------------------- +PROMPT="${1:-$DEFAULT_PROMPT}" + +echo "[test] Authenticating as '$USERNAME'..." + +# --------------------------------------------------------------------------- +# Obtain ID token via USER_PASSWORD_AUTH +# --------------------------------------------------------------------------- +AUTH_RESULT=$(aws cognito-idp initiate-auth \ + --auth-flow USER_PASSWORD_AUTH \ + --client-id "$CLIENT_ID" \ + --auth-parameters "USERNAME=${USERNAME},PASSWORD=${PASSWORD}" \ + --region "$REGION" \ + --output json) + +ID_TOKEN=$(echo "$AUTH_RESULT" | python3 -c " +import json, sys +data = json.load(sys.stdin) +print(data['AuthenticationResult']['IdToken']) +") + +echo "[test] Authenticated. Invoking agent with prompt: $PROMPT" + +# --------------------------------------------------------------------------- +# Invoke Agent Lambda +# Payload written to a temp file to avoid shell quoting / JSON injection issues +# --------------------------------------------------------------------------- +TMP_PAYLOAD="/tmp/test-payload.$$.json" +TMP_RESPONSE="/tmp/test-response.$$.json" +trap 'rm -f "$TMP_PAYLOAD" "$TMP_RESPONSE"' EXIT + +python3 -c " +import json, sys +payload = {'jwt': sys.argv[1], 'prompt': sys.argv[2]} +print(json.dumps(payload)) +" "$ID_TOKEN" "$PROMPT" > "$TMP_PAYLOAD" + +aws lambda invoke \ + --function-name "$AGENT_LAMBDA_NAME" \ + --payload "fileb://${TMP_PAYLOAD}" \ + --region "$REGION" \ + --cli-binary-format raw-in-base64-out \ + "$TMP_RESPONSE" \ + > /dev/null + +echo "" +echo "=== Agent Response ===" +python3 -c " +import json, sys +data = json.load(open(sys.argv[1])) +# AgentResponse dataclass: {success, response, error} +if isinstance(data, dict) and 'response' in data: + print(data['response']) +else: + print(json.dumps(data, indent=2)) +" "$TMP_RESPONSE" +EOF + +# Substitute placeholder tokens with actual deployment values using sed +# (literal string substitution — no nested echo emitting JSON) +sed -i.bak \ + -e "s|__USER_POOL_ID__|${CognitoUserPoolId}|g" \ + -e "s|__CLIENT_ID__|${CognitoClientId}|g" \ + -e "s|__AGENT_LAMBDA_NAME__|${AgentLambdaName}|g" \ + -e "s|__USERNAME__|${TEST_USERNAME}|g" \ + -e "s|__PASSWORD__|${TEST_PASSWORD}|g" \ + scripts/test.sh + +# Remove sed backup file +rm -f scripts/test.sh.bak + +chmod +x scripts/test.sh +log " Generated scripts/test.sh" + +# --------------------------------------------------------------------------- +# Done +# --------------------------------------------------------------------------- +log "" +log "============================================================" +log "Deployment complete!" +log "============================================================" +log "" +log "Run the smoke test:" +log " ./scripts/test.sh" +log "" +log "Or with a custom prompt:" +log " ./scripts/test.sh 'List all products in Electronics'" +log " ./scripts/test.sh 'Get product ELEC-001 details'" +log "" diff --git a/strands-agentcore-mcp/scripts/terminate.sh b/strands-agentcore-mcp/scripts/terminate.sh new file mode 100755 index 000000000..08524fe8e --- /dev/null +++ b/strands-agentcore-mcp/scripts/terminate.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# ============================================================================= +# terminate.sh — Tear down all resources for strands-agentcore-mcp +# +# Usage: +# ./scripts/terminate.sh +# +# What it does (in order): +# 1. Delete the MCP Gateway Target (created outside CloudFormation) +# 2. Delete the CloudFormation stack (all other resources) +# 3. Wait for stack deletion to complete +# 4. Clean up the generated test.sh +# ============================================================================= +set -euo pipefail + +REGION="us-east-1" +STACK_NAME="agentcore-mcp" + +log() { echo "[terminate] $*"; } +die() { echo "[terminate] ERROR: $*" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# STEP 1: Delete the MCP Gateway Target +# +# The target was created outside CloudFormation (via boto3 in deploy.sh) +# because AgentCore probes tools/list during CFN creation — the placeholder +# Lambda would fail that probe. So we must delete it manually here. +# --------------------------------------------------------------------------- +log "Step 1: Deleting AgentCore MCP Gateway Target..." + +python3 - <<'PYEOF' +import boto3, sys + +client = boto3.client('bedrock-agentcore-control', region_name='us-east-1') + +# Find the gateway by name pattern from stack outputs +try: + # Get gateway ID from CloudFormation stack output + cfn = boto3.client('cloudformation', region_name='us-east-1') + resp = cfn.describe_stacks(StackName='agentcore-mcp') + outputs = {o['OutputKey']: o['OutputValue'] for o in resp['Stacks'][0].get('Outputs', [])} + gateway_url = outputs.get('GatewayUrl', '') + # Extract gateway ID from URL: https://.gateway.bedrock-agentcore... + gateway_id = gateway_url.replace('https://', '').split('.')[0] + if not gateway_id: + print(' No GatewayUrl output found — skipping target deletion') + sys.exit(0) + print(f' Gateway ID: {gateway_id}') +except Exception as e: + print(f' Could not get stack outputs: {e} — skipping target deletion') + sys.exit(0) + +# List and delete all targets on this gateway +try: + targets = client.list_gateway_targets(gatewayIdentifier=gateway_id) + items = targets.get('items', []) + if not items: + print(' No targets found') + for target in items: + target_id = target['targetId'] + name = target.get('name', target_id) + print(f' Deleting target: {name} ({target_id})') + client.delete_gateway_target( + gatewayIdentifier=gateway_id, + targetId=target_id + ) + print(f' Deleted: {name}') +except Exception as e: + print(f' Warning: could not delete targets: {e}') +PYEOF + +log " Gateway targets deleted." + +# --------------------------------------------------------------------------- +# STEP 2: Delete the CloudFormation stack +# --------------------------------------------------------------------------- +log "Step 2: Deleting CloudFormation stack '$STACK_NAME'..." + +# Check if stack exists first +TMP_ERR="/tmp/agentcore-mcp.$$.terminate-err.txt" +if ! aws cloudformation describe-stacks \ + --stack-name "$STACK_NAME" \ + --region "$REGION" \ + > /dev/null \ + 2> "$TMP_ERR"; then + if grep -qi "does not exist\|DOES_NOT_EXIST" "$TMP_ERR"; then + log " Stack '$STACK_NAME' does not exist — nothing to delete." + rm -f "$TMP_ERR" + else + cat "$TMP_ERR" >&2 + rm -f "$TMP_ERR" + die "Unexpected error checking stack '$STACK_NAME'" + fi +else + rm -f "$TMP_ERR" + aws cloudformation delete-stack \ + --stack-name "$STACK_NAME" \ + --region "$REGION" + + log " Waiting for stack deletion to complete (this may take a few minutes)..." + aws cloudformation wait stack-delete-complete \ + --stack-name "$STACK_NAME" \ + --region "$REGION" + log " Stack '$STACK_NAME' deleted." +fi + +# --------------------------------------------------------------------------- +# STEP 3: Clean up CloudWatch Log Groups +# --------------------------------------------------------------------------- +log "Step 3: Cleaning up CloudWatch Log Groups..." +for LOG_GROUP in \ + "/aws/lambda/${STACK_NAME}-agent" \ + "/aws/lambda/${STACK_NAME}-mcp-server"; do + if aws logs describe-log-groups \ + --log-group-name-prefix "$LOG_GROUP" \ + --region "$REGION" \ + --query 'logGroups[0].logGroupName' \ + --output text 2>/dev/null | grep -q "$LOG_GROUP"; then + aws logs delete-log-group \ + --log-group-name "$LOG_GROUP" \ + --region "$REGION" 2>/dev/null || true + log " Deleted log group: $LOG_GROUP" + else + log " Log group not found: $LOG_GROUP — skipping." + fi +done + +# --------------------------------------------------------------------------- +# STEP 4: Clean up S3 deploy bucket (if it exists) +# --------------------------------------------------------------------------- +log "Step 4: Cleaning up S3 deploy bucket (if exists)..." +ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text --region "$REGION" 2>/dev/null || echo "") +if [ -n "$ACCOUNT_ID" ]; then + S3_BUCKET="${ACCOUNT_ID}-${STACK_NAME}-deploy" + if aws s3api head-bucket --bucket "$S3_BUCKET" --region "$REGION" 2>/dev/null; then + log " Emptying and deleting s3://$S3_BUCKET..." + aws s3 rm "s3://${S3_BUCKET}" --recursive --region "$REGION" 2>/dev/null || true + aws s3api delete-bucket --bucket "$S3_BUCKET" --region "$REGION" 2>/dev/null || true + log " Deleted s3://$S3_BUCKET" + else + log " S3 bucket $S3_BUCKET does not exist — skipping." + fi +else + log " Could not determine account ID — skipping S3 cleanup." +fi + +# --------------------------------------------------------------------------- +# STEP 4: Clean up generated scripts +# --------------------------------------------------------------------------- +log "Step 5: Cleaning up generated files..." +rm -f scripts/test.sh +log " Removed scripts/test.sh" + +# --------------------------------------------------------------------------- +# Done +# --------------------------------------------------------------------------- +log "" +log "============================================================" +log "Termination complete!" +log "============================================================" +log "" +log "All resources have been deleted:" +log " - AgentCore Gateway Target" +log " - CloudFormation stack (Lambda, API Gateway, DynamoDB," +log " Cognito, IAM roles, AgentCore Gateway)" +log " - CloudWatch Log Groups" +log " - S3 deploy bucket (if it existed)" +log " - Generated scripts/test.sh" +log "" +log "To redeploy: ./scripts/deploy.sh" +log "" diff --git a/strands-agentcore-mcp/src/__init__.py b/strands-agentcore-mcp/src/__init__.py new file mode 100644 index 000000000..521670b4d --- /dev/null +++ b/strands-agentcore-mcp/src/__init__.py @@ -0,0 +1 @@ +# src package diff --git a/strands-agentcore-mcp/src/agent/__init__.py b/strands-agentcore-mcp/src/agent/__init__.py new file mode 100644 index 000000000..c5e998fcc --- /dev/null +++ b/strands-agentcore-mcp/src/agent/__init__.py @@ -0,0 +1 @@ +# agent package diff --git a/strands-agentcore-mcp/src/agent/agent_processor.py b/strands-agentcore-mcp/src/agent/agent_processor.py new file mode 100644 index 000000000..f044b4f64 --- /dev/null +++ b/strands-agentcore-mcp/src/agent/agent_processor.py @@ -0,0 +1,75 @@ +"""Agent request processor with MCP session lifecycle management. + +Target-type agnostic processor that creates a Strands Agent with an MCP +client connected to AgentCore Gateway. Manages the MCP session in a +try/finally block to ensure cleanup on both success and failure paths. +""" + +import os + +from strands import Agent + +from src.agent.strands_client import create_bedrock_model, create_mcp_client +from src.shared.error_utils import format_internal_error_response +from src.shared.logging_utils import get_logger +from src.shared.models import AgentRequest, AgentResponse + +logger = get_logger(__name__) + +# Environment variable for the gateway URL +GATEWAY_URL_ENV = "GATEWAY_URL" + +SYSTEM_PROMPT = """You have access to product management tools via MCP. +- Use list_products to see available products (optionally filter by category) +- Use get_product with category and productId to retrieve a specific product +- Use put_product to create or update a product + +Respond conversationally based on tool results.""" + + +def process_request(request: AgentRequest, gateway_url: str | None = None) -> AgentResponse: + """Process an agent request by invoking the Strands Agent with MCP tools. + + Creates an MCP client connected to the AgentCore Gateway, builds a Strands + Agent with a Bedrock model, and invokes the agent with the user's prompt. + The MCP session lifecycle is managed in a try/finally block — NOT a with + context manager — to ensure cleanup occurs even on exceptions. + + Args: + request: The agent request containing the prompt and user context. + gateway_url: Optional gateway URL override. If not provided, reads + from the GATEWAY_URL environment variable. + + Returns: + An AgentResponse with the agent's response on success, or an error + response on failure. + """ + url = gateway_url or os.environ.get(GATEWAY_URL_ENV) + if not url: + logger.error("Gateway URL not configured") + return format_internal_error_response("Gateway URL not configured") + + token = request.user_context.token + prompt = request.prompt + + logger.info("Processing request for user: %s", request.user_context.username) + + mcp_client = create_mcp_client(url, token) + try: + bedrock_model = create_bedrock_model() + agent = Agent(model=bedrock_model, tools=[mcp_client], system_prompt=SYSTEM_PROMPT) + result = agent(prompt) + logger.info("Agent completed successfully for user: %s", request.user_context.username) + return AgentResponse(success=True, response=str(result)) + except Exception as e: + logger.error("Agent processing failed: %s", e) + return format_internal_error_response("Agent processing error", exception=e) + finally: + logger.info("Cleaning up MCP client") + try: + mcp_client.cleanup() + except AttributeError: + try: + mcp_client.close() + except AttributeError: + pass diff --git a/strands-agentcore-mcp/src/agent/handler.py b/strands-agentcore-mcp/src/agent/handler.py new file mode 100644 index 000000000..9a239145a --- /dev/null +++ b/strands-agentcore-mcp/src/agent/handler.py @@ -0,0 +1,117 @@ +"""Lambda entry point for the agent function. + +Target-type agnostic handler that extracts JWT tokens from the Lambda event, +validates them, creates a UserContext, and delegates processing to the +agent_processor module. Returns AgentResponse as a dict suitable for +Lambda response serialization. +""" + +import os +from dataclasses import asdict + +from src.agent.agent_processor import process_request +from src.shared.error_utils import ( + format_internal_error_response, + format_unauthorized_response, +) +from src.shared.jwt_utils import JWTValidationError, extract_username, validate_token +from src.shared.logging_utils import configure_logging, get_correlation_id +from src.shared.models import AgentRequest, UserContext + +# Environment variable for the Cognito User Pool issuer URL used for JWKS +# signature verification. Format: +# https://cognito-idp..amazonaws.com/ +JWT_ISSUER_ENV = "COGNITO_ISSUER" + + +def _extract_token(event: dict) -> str | None: + """Extract JWT token from the Lambda event. + + Checks for the token in three locations: + 1. event["jwt"] — direct token field (used by scripts/test.sh payloads) + 2. event["token"] — alternate direct token field + 3. event["headers"]["Authorization"] — Bearer token from headers + + Args: + event: The Lambda event dictionary. + + Returns: + The JWT token string, or None if not found. + """ + token = event.get("jwt") or event.get("token") + if token: + return token + + headers = event.get("headers", {}) + auth_header = headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + return auth_header[len("Bearer "):] + + # Return raw Authorization header if present (non-Bearer) + return auth_header or None + + +def lambda_handler(event: dict, context) -> dict: + """Lambda entry point for agent invocations. + + Extracts and validates the JWT token from the event, builds an + AgentRequest with the user's prompt and context, and delegates + to the agent processor. Returns the AgentResponse as a dict. + + Args: + event: Lambda event containing token/headers and prompt. + context: Lambda context object (unused). + + Returns: + A dict representation of AgentResponse. + """ + correlation_id = get_correlation_id(event) + logger = configure_logging(correlation_id=correlation_id) + + logger.info("Agent handler invoked") + + # Extract JWT token + token = _extract_token(event) + if not token: + logger.warning("Missing JWT token in request") + return asdict(format_unauthorized_response("Missing authentication token")) + + # Validate JWT and extract username. Use the Cognito issuer from the + # environment when available so signatures are verified via JWKS. + issuer = os.environ.get(JWT_ISSUER_ENV) + try: + claims = validate_token(token, issuer=issuer) + username = extract_username(claims) + except JWTValidationError as e: + logger.warning("JWT validation failed: %s", e) + return asdict(format_unauthorized_response(str(e))) + + # Build request and process + user_context = UserContext(username=username, token=token) + prompt = event.get("prompt", "") + + # Also check nested body.prompt (from API Gateway-shaped payloads) + if not prompt: + body = event.get("body", {}) + if isinstance(body, dict): + prompt = body.get("prompt", "") + elif isinstance(body, str): + import json + try: + body = json.loads(body) + prompt = body.get("prompt", "") + except (json.JSONDecodeError, AttributeError): + pass + + if not prompt: + logger.warning("Empty prompt received from user: %s", username) + return asdict(format_unauthorized_response("Missing prompt")) + + request = AgentRequest(prompt=prompt, user_context=user_context) + + try: + response = process_request(request) + return asdict(response) + except Exception as e: + logger.error("Unexpected error processing request: %s", e) + return asdict(format_internal_error_response("Unexpected error", exception=e)) diff --git a/strands-agentcore-mcp/src/agent/strands_client.py b/strands-agentcore-mcp/src/agent/strands_client.py new file mode 100644 index 000000000..bfb1e6fc9 --- /dev/null +++ b/strands-agentcore-mcp/src/agent/strands_client.py @@ -0,0 +1,79 @@ +"""Factory functions for Strands SDK client configuration. + +Target-type agnostic factories for creating MCP clients and Bedrock model +instances. These functions connect to AgentCore Gateway via MCP protocol +and are unaware of the target type behind the gateway. +""" + +import logging +import os + +from mcp.client.streamable_http import streamablehttp_client +from strands.models.bedrock import BedrockModel +from strands.tools.mcp import MCPClient + +logger = logging.getLogger(__name__) + +# Bedrock model ID — override via BEDROCK_MODEL_ID environment variable. +# Defaults to Claude Sonnet 4.5 cross-region inference profile. +DEFAULT_MODEL_ID = os.environ.get( + "BEDROCK_MODEL_ID", + "us.anthropic.claude-sonnet-4-5-20250929-v1:0", +) + +# Default AWS region for Bedrock +DEFAULT_REGION = "us-east-1" + + +def create_mcp_client(gateway_url: str, token: str) -> MCPClient: + """Create an MCP client configured to connect to AgentCore Gateway. + + Uses streamable HTTP transport with a Bearer token for authentication. + The returned client is NOT used as a context manager — the Agent manages + the MCP session lifecycle, and cleanup should occur in a finally block. + + Args: + gateway_url: The AgentCore Gateway endpoint URL. + token: JWT token for gateway authentication. + + Returns: + An MCPClient instance configured for the gateway. + """ + logger.info("Creating MCP client for gateway: %s", gateway_url) + + mcp_client = MCPClient( + lambda: streamablehttp_client( + gateway_url, + headers={"Authorization": f"Bearer {token}"}, + ) + ) + + return mcp_client + + +def create_bedrock_model( + model_id: str = DEFAULT_MODEL_ID, + region_name: str = DEFAULT_REGION, +) -> BedrockModel: + """Create a Bedrock model instance for the Strands Agent. + + Configures Claude Sonnet 4.5 as the LLM provider via AWS Bedrock. + The model can be overridden at deploy time via the BEDROCK_MODEL_ID + environment variable without any code changes. + + Args: + model_id: Bedrock model identifier. Defaults to the value of the + BEDROCK_MODEL_ID env var, falling back to Claude Sonnet 4.5. + region_name: AWS region for Bedrock API calls. Defaults to us-east-1. + + Returns: + A BedrockModel instance ready for use with a Strands Agent. + """ + logger.info("Creating Bedrock model: %s in %s", model_id, region_name) + + model = BedrockModel( + model_id=model_id, + region_name=region_name, + ) + + return model diff --git a/strands-agentcore-mcp/src/mcp_server/__init__.py b/strands-agentcore-mcp/src/mcp_server/__init__.py new file mode 100644 index 000000000..9fb2531c1 --- /dev/null +++ b/strands-agentcore-mcp/src/mcp_server/__init__.py @@ -0,0 +1 @@ +# mcp_server package diff --git a/strands-agentcore-mcp/src/mcp_server/dynamodb_client.py b/strands-agentcore-mcp/src/mcp_server/dynamodb_client.py new file mode 100644 index 000000000..bdb66949f --- /dev/null +++ b/strands-agentcore-mcp/src/mcp_server/dynamodb_client.py @@ -0,0 +1,82 @@ +"""Thin DynamoDB wrapper for the Product_Table. + +Provides the data-access primitives used by the MCP tool implementations +in `src/mcp_server/tools.py`. Exceptions from boto3/botocore are allowed +to propagate; the JSON-RPC handler maps them to a `-32603` internal error. +""" + +import os +from decimal import Decimal + +import boto3 +from boto3.dynamodb.conditions import Key + +# Module-level resource/table handles. The Lambda runtime reuses them +# across invocations within the same execution environment. +TABLE = boto3.resource("dynamodb").Table(os.environ["PRODUCT_TABLE"]) + + +def _to_dynamodb(obj): + """Recursively convert floats to Decimal for DynamoDB compatibility.""" + if isinstance(obj, float): + return Decimal(str(obj)) + if isinstance(obj, dict): + return {k: _to_dynamodb(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_to_dynamodb(v) for v in obj] + return obj + + +def list_products(category: str | None = None) -> list[dict]: + """Return products from Product_Table, optionally filtered by category. + + Uses `Query` on the partition key when a category is provided, and + falls back to a full `Scan` otherwise. + + Args: + category: Optional partition-key value to filter by. + + Returns: + The `Items` list from the DynamoDB response (may be empty). + """ + if category is not None: + response = TABLE.query( + KeyConditionExpression=Key("category").eq(category), + ) + else: + response = TABLE.scan() + return response.get("Items", []) + + +def get_product(category: str, productId: str) -> dict: + """Fetch a single product by composite key. + + Args: + category: Partition key. + productId: Sort key. + + Returns: + `{"found": False}` when the item does not exist, otherwise + `{"found": True, "item": }`. + """ + response = TABLE.get_item(Key={"category": category, "productId": productId}) + item = response.get("Item") + if item is None: + return {"found": False} + return {"found": True, "item": item} + + +def put_product(item: dict) -> dict: + """Write (or overwrite) a product item in Product_Table. + + Args: + item: The full item to persist. Must include `category` and + `productId` at minimum. + + Returns: + `{"written": True, "item": item}`. + """ + # Convert floats to Decimal — DynamoDB rejects Python float types + dynamo_item = _to_dynamodb(item) + TABLE.put_item(Item=dynamo_item) + return {"written": True, "item": item} diff --git a/strands-agentcore-mcp/src/mcp_server/handler.py b/strands-agentcore-mcp/src/mcp_server/handler.py new file mode 100644 index 000000000..a35e17e6c --- /dev/null +++ b/strands-agentcore-mcp/src/mcp_server/handler.py @@ -0,0 +1,171 @@ +"""API Gateway entry point for the MCP Server Lambda. + +Parses incoming JSON-RPC 2.0 requests forwarded by AgentCore Gateway, +dispatches `tools/list` and `tools/call` methods to the tool registry in +`src.mcp_server.tools`, and shapes every response as the API Gateway +proxy envelope expected by a Lambda `AWS_PROXY` integration. + +Protocol rules (see design.md and project-conventions.md): + +- HTTP status is always `200`; JSON-RPC errors are conveyed in the body. +- `Content-Type: application/json` on every response. +- Error codes: `-32700` parse error, `-32601` method not found, + `-32602` invalid params, `-32603` internal error. +- Every request logs the JSON-RPC `method`; every `tools/call` also + logs the tool `name`. +""" + +import json +from decimal import Decimal +from typing import Any + +from src.mcp_server import tools +from src.shared.logging_utils import get_logger + +logger = get_logger(__name__) + + +class _DecimalEncoder(json.JSONEncoder): + """Convert DynamoDB Decimal values to float for JSON serialization.""" + def default(self, obj): + if isinstance(obj, Decimal): + return float(obj) + return super().default(obj) + + +def json_rpc_response( + id_: Any, + result: Any | None = None, + error: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build the API Gateway proxy envelope for a JSON-RPC 2.0 response. + + Exactly one of `result` or `error` is populated in the body. The HTTP + envelope is always `200` with `Content-Type: application/json` so the + JSON-RPC error semantics are preserved end-to-end. + + Args: + id_: The JSON-RPC request id echoed back to the caller. May be + `None` when the incoming body could not be parsed. + result: The JSON-RPC `result` payload for success responses. + error: The JSON-RPC `error` object (`{"code": int, "message": str, ...}`) + for failure responses. + + Returns: + An API Gateway proxy response dict with `statusCode`, `headers`, + and a JSON-encoded `body`. + """ + body: dict[str, Any] = {"jsonrpc": "2.0", "id": id_} + if error is not None: + body["error"] = error + else: + body["result"] = result + return { + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": json.dumps(body, cls=_DecimalEncoder), + } + + +def _parse_error(id_: Any = None) -> dict[str, Any]: + """Return a `-32700` JSON-RPC parse-error envelope.""" + return json_rpc_response( + id_, + error={"code": -32700, "message": "parse error"}, + ) + + +def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]: + """Handle an API Gateway proxy invocation carrying a JSON-RPC request. + + Parses `event["body"]` as a JSON-RPC 2.0 message, validates the + envelope (`jsonrpc == "2.0"` and a `method` field), and dispatches + to the tool registry. Unknown methods and unknown tools both map to + `-32601`; argument-schema violations map to `-32602`; any other + exception from a tool handler is logged and mapped to `-32603`. + + Args: + event: The API Gateway proxy event. The JSON-RPC request is in + the `body` field as a (possibly empty) string. + context: The Lambda context (unused). + + Returns: + An API Gateway proxy response dict. `statusCode` is always `200`; + JSON-RPC errors are returned in the body. + """ + raw_body = event.get("body") + + # Parse the JSON-RPC envelope. Anything non-parseable or structurally + # invalid short-circuits to a -32700 parse error with id=None, since + # we can't reliably recover the request id from an unparseable body. + try: + if not isinstance(raw_body, str): + raise json.JSONDecodeError("body is not a string", "", 0) + parsed = json.loads(raw_body) + except json.JSONDecodeError: + logger.info("mcp_request method= id=None") + return _parse_error() + + if not isinstance(parsed, dict): + logger.info("mcp_request method= id=None") + return _parse_error() + + request_id = parsed.get("id") + method = parsed.get("method") + + if parsed.get("jsonrpc") != "2.0" or not isinstance(method, str): + logger.info("mcp_request method=%r id=%r (invalid envelope)", method, request_id) + return _parse_error(request_id) + + logger.info("mcp_request method=%s id=%r", method, request_id) + + # MCP protocol handshake — AgentCore sends initialize before tools/list. + # Respond with the server's capabilities so the session can proceed. + if method == "initialize": + return json_rpc_response(request_id, result={ + "protocolVersion": parsed.get("params", {}).get("protocolVersion", "2025-03-26"), + "capabilities": {"tools": {}}, + "serverInfo": {"name": "agentcore-mcp-server", "version": "1.0.0"}, + }) + + # MCP notification — no response needed, return empty 200 + if method == "notifications/initialized": + return {"statusCode": 200, "headers": {"Content-Type": "application/json"}, "body": ""} + + if method == "tools/list": + return json_rpc_response(request_id, result=tools.list_tool_catalog()) + + if method == "tools/call": + params = parsed.get("params") or {} + tool_name = params.get("name") if isinstance(params, dict) else None + arguments = params.get("arguments") if isinstance(params, dict) else None + if not isinstance(arguments, dict): + arguments = {} + + logger.info("mcp_tools_call name=%r id=%r", tool_name, request_id) + + try: + result = tools.dispatch(tool_name, arguments) + except tools.UnknownToolError as exc: + return json_rpc_response( + request_id, + error={"code": -32601, "message": str(exc)}, + ) + except tools.InvalidParamsError as exc: + return json_rpc_response( + request_id, + error={"code": -32602, "message": str(exc)}, + ) + except Exception as exc: # noqa: BLE001 - surface as internal error + logger.exception("mcp_tool_handler_error name=%r id=%r", tool_name, request_id) + return json_rpc_response( + request_id, + error={"code": -32603, "message": f"internal error: {exc}"}, + ) + + return json_rpc_response(request_id, result=result) + + return json_rpc_response( + request_id, + error={"code": -32601, "message": f"method not found: {method}"}, + ) diff --git a/strands-agentcore-mcp/src/mcp_server/tools.py b/strands-agentcore-mcp/src/mcp_server/tools.py new file mode 100644 index 000000000..e422d0a6e --- /dev/null +++ b/strands-agentcore-mcp/src/mcp_server/tools.py @@ -0,0 +1,151 @@ +"""MCP tool registry for the MCP Server Lambda. + +Declares the three product-management tools exposed to AgentCore Gateway +via MCP JSON-RPC, along with a dispatcher that validates arguments against +each tool's JSON Schema before invoking the handler. + +Tool handlers delegate to `dynamodb_client` for all DynamoDB I/O. Errors +from handlers (e.g. boto3 `ClientError`) propagate out of `dispatch` so +the JSON-RPC handler can map them to `-32603` internal errors. +""" + +from dataclasses import dataclass +from typing import Any, Callable + +import jsonschema + +from src.mcp_server import dynamodb_client + + +class UnknownToolError(Exception): + """Raised when a `tools/call` request names a tool not in the registry. + + The JSON-RPC handler maps this to error code `-32601` (method not found). + """ + + +class InvalidParamsError(Exception): + """Raised when `tools/call` arguments fail JSON Schema validation. + + The JSON-RPC handler maps this to error code `-32602` (invalid params). + """ + + +@dataclass(frozen=True) +class Tool: + """Declarative record for a single MCP tool. + + Attributes: + name: Tool identifier exposed to the model via `tools/list`. + description: Human-readable description shown to the model. + input_schema: JSON Schema describing the shape of `arguments`. + handler: Callable invoked with the validated arguments dict. + """ + + name: str + description: str + input_schema: dict[str, Any] + handler: Callable[[dict[str, Any]], Any] + + +LIST_PRODUCTS = Tool( + name="list_products", + description="List products, optionally filtered by category.", + input_schema={ + "type": "object", + "properties": {"category": {"type": "string"}}, + "additionalProperties": False, + }, + handler=lambda args: dynamodb_client.list_products(args.get("category")), +) + + +GET_PRODUCT = Tool( + name="get_product", + description="Get a product by category and productId.", + input_schema={ + "type": "object", + "properties": { + "category": {"type": "string"}, + "productId": {"type": "string"}, + }, + "required": ["category", "productId"], + "additionalProperties": False, + }, + handler=lambda args: dynamodb_client.get_product(args["category"], args["productId"]), +) + + +PUT_PRODUCT = Tool( + name="put_product", + description="Create or update a product.", + input_schema={ + "type": "object", + "properties": { + "category": {"type": "string"}, + "productId": {"type": "string"}, + "name": {"type": "string"}, + "price": {"type": "number"}, + }, + "required": ["category", "productId", "name", "price"], + "additionalProperties": False, + }, + handler=lambda args: dynamodb_client.put_product(args), +) + + +TOOLS: dict[str, Tool] = {t.name: t for t in (LIST_PRODUCTS, GET_PRODUCT, PUT_PRODUCT)} + + +def list_tool_catalog() -> dict[str, list[dict[str, Any]]]: + """Return the `tools/list` response payload. + + Shape matches the MCP spec: a `tools` array where each entry exposes + `name`, `description`, and `inputSchema`. + """ + return { + "tools": [ + { + "name": t.name, + "description": t.description, + "inputSchema": t.input_schema, + } + for t in TOOLS.values() + ] + } + + +def dispatch(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + """Validate arguments and invoke the named tool. + + Args: + name: Tool name from `params.name` of a `tools/call` request. + arguments: Tool arguments from `params.arguments`. + + Returns: + `{"content": }` on success. + + Raises: + UnknownToolError: `name` is not a registered tool. + InvalidParamsError: `arguments` fails `inputSchema` validation. + """ + tool = TOOLS.get(name) + if tool is None: + raise UnknownToolError(f"unknown tool: {name}") + + try: + jsonschema.validate(instance=arguments, schema=tool.input_schema) + except jsonschema.ValidationError as exc: + raise InvalidParamsError(f"invalid params: {exc.message}") from exc + + result = tool.handler(arguments) + # MCP spec requires content to be an array of {type, text} objects + import json + from decimal import Decimal + + def _default(obj): + if isinstance(obj, Decimal): + return float(obj) + raise TypeError(f"Object of type {type(obj)} is not JSON serializable") + + return {"content": [{"type": "text", "text": json.dumps(result, default=_default)}]} diff --git a/strands-agentcore-mcp/src/shared/__init__.py b/strands-agentcore-mcp/src/shared/__init__.py new file mode 100644 index 000000000..ad5da11e2 --- /dev/null +++ b/strands-agentcore-mcp/src/shared/__init__.py @@ -0,0 +1 @@ +# shared package diff --git a/strands-agentcore-mcp/src/shared/error_utils.py b/strands-agentcore-mcp/src/shared/error_utils.py new file mode 100644 index 000000000..ed4521027 --- /dev/null +++ b/strands-agentcore-mcp/src/shared/error_utils.py @@ -0,0 +1,75 @@ +"""Standardized error response formatting. + +Target-type agnostic error handling utilities that produce consistent +AgentResponse objects for error conditions across the agent application. +""" + +import logging +from typing import Optional + +from src.shared.models import AgentResponse + +logger = logging.getLogger(__name__) + + +def format_error_response( + error_message: str, + status_code: int = 500, + log_message: Optional[str] = None, +) -> AgentResponse: + """Create a standardized error AgentResponse. + + Logs the error and returns a consistent AgentResponse with success=False. + + Args: + error_message: User-facing error description. + status_code: HTTP-style status code for categorization (default 500). + log_message: Optional detailed message for logging. If not provided, + the error_message is logged. + + Returns: + An AgentResponse with success=False and the error details. + """ + log_msg = log_message or error_message + if status_code >= 500: + logger.error("Error [%d]: %s", status_code, log_msg) + else: + logger.warning("Error [%d]: %s", status_code, log_msg) + + return AgentResponse( + success=False, + response="", + error=error_message, + ) + + +def format_unauthorized_response(detail: str = "Unauthorized") -> AgentResponse: + """Create a 401 Unauthorized error response. + + Args: + detail: Specific unauthorized reason (default "Unauthorized"). + + Returns: + An AgentResponse for authentication failures. + """ + return format_error_response(detail, status_code=401) + + +def format_internal_error_response( + detail: str = "Internal server error", + exception: Optional[Exception] = None, +) -> AgentResponse: + """Create a 500 Internal Server Error response. + + Optionally logs the full exception for debugging while returning + a safe user-facing message. + + Args: + detail: User-facing error description. + exception: Optional exception to log for debugging. + + Returns: + An AgentResponse for internal server errors. + """ + log_msg = f"{detail}: {exception}" if exception else detail + return format_error_response(detail, status_code=500, log_message=log_msg) diff --git a/strands-agentcore-mcp/src/shared/jwt_utils.py b/strands-agentcore-mcp/src/shared/jwt_utils.py new file mode 100644 index 000000000..4ad930560 --- /dev/null +++ b/strands-agentcore-mcp/src/shared/jwt_utils.py @@ -0,0 +1,151 @@ +"""JWT validation utilities for Cognito token handling. + +Target-type agnostic JWT validation that works with any AgentCore Gateway +target type. Verifies signatures via JWKS fetched from the Cognito User Pool +issuer, accepts both access and ID tokens, disables audience verification +(gateway handles it via AllowedAudience), and extracts the cognito:username +claim. + +Mandatory rules (see .kiro/steering/project-conventions.md): + - Accept both token_use: access AND token_use: id + - Pass verify_aud=False to jwt.decode (Gateway enforces audience) + - Extract username from the cognito:username claim (NOT username, NOT sub) +""" + +import logging +from typing import Any, Dict, Optional + +import jwt +from jwt import PyJWKClient + +logger = logging.getLogger(__name__) + +# Valid token_use claim values accepted by the validator +VALID_TOKEN_USE_VALUES = {"access", "id"} + +# Module-level JWKS client cache keyed by issuer URL so we avoid re-fetching +# the JWKS document on every invocation within a warm Lambda container. +_JWKS_CLIENTS: Dict[str, PyJWKClient] = {} + + +class JWTValidationError(Exception): + """Raised when JWT validation fails.""" + pass + + +def _get_jwks_client(issuer: str) -> PyJWKClient: + """Return a cached PyJWKClient for the given issuer. + + The JWKS URL is derived from the Cognito User Pool issuer as + ``{issuer}/.well-known/jwks.json``. + """ + client = _JWKS_CLIENTS.get(issuer) + if client is None: + jwks_url = f"{issuer.rstrip('/')}/.well-known/jwks.json" + logger.info("Creating PyJWKClient for JWKS URL: %s", jwks_url) + client = PyJWKClient(jwks_url) + _JWKS_CLIENTS[issuer] = client + return client + + +def validate_token( + token: str, + issuer: Optional[str] = None, + options: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Decode and validate a JWT token. + + Verifies the token's signature against the Cognito User Pool JWKS when an + issuer is provided. Always disables audience verification — the AgentCore + Gateway's CUSTOM_JWT authorizer enforces audience via AllowedAudience. + Requires the token_use claim to be either 'access' or 'id'. + + Args: + token: The JWT token string to validate. + issuer: Optional Cognito User Pool issuer URL (e.g. + ``https://cognito-idp.us-east-1.amazonaws.com/``). When + provided, the signature is verified via JWKS. When omitted, + signature verification is skipped (useful for unit tests). + options: Optional PyJWT decode options override. + + Returns: + The decoded JWT claims dictionary. + + Raises: + JWTValidationError: If the token is invalid, expired, signature + verification fails, or the token_use claim is unsupported. + """ + if not token: + raise JWTValidationError("Token is empty or missing") + + try: + if issuer: + jwks_client = _get_jwks_client(issuer) + signing_key = jwks_client.get_signing_key_from_jwt(token).key + decode_options = options or {"verify_aud": False} + claims = jwt.decode( + token, + signing_key, + algorithms=["RS256"], + options=decode_options, + ) + else: + # No issuer: decode without signature verification. Audience + # verification is still disabled — the Gateway handles audience + # via AllowedAudience. + decode_options = options or { + "verify_signature": False, + "verify_aud": False, + "verify_exp": True, + } + claims = jwt.decode( + token, + algorithms=["RS256"], + options=decode_options, + ) + except jwt.ExpiredSignatureError: + logger.warning("JWT token has expired") + raise JWTValidationError("Token has expired") + except jwt.InvalidSignatureError as e: + logger.warning("JWT signature invalid: %s", e) + raise JWTValidationError(f"Invalid token signature: {e}") + except jwt.DecodeError as e: + logger.warning("JWT decode error: %s", str(e)) + raise JWTValidationError(f"Invalid token: {e}") + except jwt.InvalidTokenError as e: + logger.warning("JWT validation error: %s", str(e)) + raise JWTValidationError(f"Token validation failed: {e}") + except Exception as e: # PyJWKClient can raise its own errors + logger.warning("JWT validation failed: %s", e) + raise JWTValidationError(f"Token validation failed: {e}") + + # Validate token_use claim — accept both 'access' and 'id' + token_use = claims.get("token_use") + if token_use is not None and token_use not in VALID_TOKEN_USE_VALUES: + raise JWTValidationError( + f"Unsupported token_use: {token_use}. " + f"Must be one of: {VALID_TOKEN_USE_VALUES}" + ) + + return claims + + +def extract_username(claims: Dict[str, Any]) -> str: + """Extract the username from JWT claims. + + Uses the 'cognito:username' claim, which is the correct claim for + Cognito ID tokens. Does NOT fall back to 'username' or 'sub' claims. + + Args: + claims: Decoded JWT claims dictionary. + + Returns: + The username string from the cognito:username claim. + + Raises: + JWTValidationError: If the cognito:username claim is missing. + """ + username = claims.get("cognito:username") + if not username: + raise JWTValidationError("Missing 'cognito:username' claim in token") + return username diff --git a/strands-agentcore-mcp/src/shared/logging_utils.py b/strands-agentcore-mcp/src/shared/logging_utils.py new file mode 100644 index 000000000..73199b4e7 --- /dev/null +++ b/strands-agentcore-mcp/src/shared/logging_utils.py @@ -0,0 +1,74 @@ +"""Structured logging with correlation IDs. + +Target-type agnostic logging utilities that provide consistent structured +logging across the agent application. Supports correlation IDs for request +tracing through Lambda invocations. +""" + +import logging +import uuid +from typing import Any, Dict, Optional + + +def get_correlation_id(event: Optional[Dict[str, Any]] = None) -> str: + """Extract or generate a correlation ID for request tracing. + + Attempts to extract a correlation ID from the Lambda event's request + context. Falls back to generating a new UUID if none is found. + + Args: + event: Optional Lambda event dictionary. + + Returns: + A correlation ID string. + """ + if event: + # Check for AWS request ID in the event context + request_context = event.get("requestContext", {}) + request_id = request_context.get("requestId") + if request_id: + return request_id + + return str(uuid.uuid4()) + + +def configure_logging( + level: int = logging.INFO, + correlation_id: Optional[str] = None, +) -> logging.Logger: + """Configure structured logging for the application. + + Sets up the root logger with a structured format that includes + the correlation ID for request tracing. + + Args: + level: Logging level (default INFO). + correlation_id: Optional correlation ID to include in log format. + + Returns: + The configured root logger. + """ + log_format = "[%(levelname)s]" + if correlation_id: + log_format += f" [{correlation_id}]" + log_format += " %(name)s - %(message)s" + + logging.basicConfig(level=level, format=log_format, force=True) + # Enable debug logging for strands to see tool call details + logging.getLogger("strands").setLevel(logging.DEBUG) + return logging.getLogger() + + +def get_logger(name: str) -> logging.Logger: + """Get a named logger instance. + + Convenience wrapper around logging.getLogger for consistent + logger creation across modules. + + Args: + name: Logger name, typically __name__ of the calling module. + + Returns: + A named Logger instance. + """ + return logging.getLogger(name) diff --git a/strands-agentcore-mcp/src/shared/models.py b/strands-agentcore-mcp/src/shared/models.py new file mode 100644 index 000000000..5f02724b5 --- /dev/null +++ b/strands-agentcore-mcp/src/shared/models.py @@ -0,0 +1,29 @@ +"""Shared data models for the agent application. + +Target-type agnostic data classes used across agent and shared modules. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class UserContext: + """Represents an authenticated user's context.""" + username: str + token: str + + +@dataclass +class AgentRequest: + """Represents a request to the agent.""" + prompt: str + user_context: UserContext + + +@dataclass +class AgentResponse: + """Represents a response from the agent.""" + success: bool + response: str + error: Optional[str] = None diff --git a/strands-agentcore-mcp/tests/__init__.py b/strands-agentcore-mcp/tests/__init__.py new file mode 100644 index 000000000..65140f2e3 --- /dev/null +++ b/strands-agentcore-mcp/tests/__init__.py @@ -0,0 +1 @@ +# tests package diff --git a/strands-agentcore-mcp/tests/integration/__init__.py b/strands-agentcore-mcp/tests/integration/__init__.py new file mode 100644 index 000000000..252d0f0d3 --- /dev/null +++ b/strands-agentcore-mcp/tests/integration/__init__.py @@ -0,0 +1 @@ +# integration tests diff --git a/strands-agentcore-mcp/tests/integration/test_end_to_end.py b/strands-agentcore-mcp/tests/integration/test_end_to_end.py new file mode 100644 index 000000000..8728808e1 --- /dev/null +++ b/strands-agentcore-mcp/tests/integration/test_end_to_end.py @@ -0,0 +1,364 @@ +"""End-to-end integration tests for the strands-agentcore-mcp stack. + +These tests exercise the full deployed system: + - Test A (happy path): Cognito auth → Agent Lambda → AgentCore Gateway → + MCP Server Lambda → DynamoDB, asserting a conversational answer that + references seeded product data. + - Test B (unauthorized): Agent Lambda invoked without a JWT, asserting + an auth-error response shape (no CloudWatch assertion needed). + - Test C (MCP discovery): Agent Lambda invoked with a product-detail + prompt; CloudWatch Logs Insights confirms both ``tools/list`` and at + least one ``tools/call`` appear in the MCP Server Lambda's log stream + during the invocation window. + +Requirements: a deployed CloudFormation stack named ``agentcore-mcp`` in +``us-east-1``. All tests are skipped (not failed) when the stack is absent +or its outputs are unavailable. +""" + +import json +import subprocess +import time +from datetime import datetime, timedelta, timezone + +import boto3 +import pytest + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +STACK_NAME = "agentcore-mcp" +REGION = "us-east-1" + +TEST_USERNAME = "testuser" +TEST_PASSWORD = "TestPassword123!" + +# Seeded product identifiers — at least one must appear in a happy-path answer. +SEEDED_PRODUCT_TOKENS = [ + "ELEC-001", + "ELEC-002", + "BOOK-001", + "Headphones", + "Electronics", + "Books", +] + + +# --------------------------------------------------------------------------- +# Session-scoped fixture: stack outputs +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def stack_outputs(): + """Return a dict mapping CloudFormation output key → value. + + Calls ``aws cloudformation describe-stacks`` once per test session. + Skips the entire session if the stack is not deployed or the command + fails for any reason. + """ + result = subprocess.run( + [ + "aws", + "cloudformation", + "describe-stacks", + "--stack-name", + STACK_NAME, + "--region", + REGION, + "--output", + "json", + ], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + pytest.skip( + f"Stack {STACK_NAME!r} not deployed — skipping integration tests " + f"(aws cli error: {result.stderr.strip()!r})" + ) + + try: + data = json.loads(result.stdout) + raw_outputs = data["Stacks"][0].get("Outputs", []) + except (json.JSONDecodeError, KeyError, IndexError) as exc: + pytest.skip( + f"Stack {STACK_NAME!r} outputs unavailable — skipping integration tests " + f"(parse error: {exc})" + ) + + outputs = {item["OutputKey"]: item["OutputValue"] for item in raw_outputs} + return outputs + + +# --------------------------------------------------------------------------- +# Helper: resolve MCP Server Lambda physical name from stack resources +# --------------------------------------------------------------------------- + + +def get_mcp_lambda_name(stack_name: str = STACK_NAME) -> str: + """Return the physical resource ID for the ``McpServerLambda`` logical ID. + + Falls back to the conventional name ``agentcore-mcp-McpServerLambda`` + when the describe-stack-resources call fails. + """ + result = subprocess.run( + [ + "aws", + "cloudformation", + "describe-stack-resources", + "--stack-name", + stack_name, + "--region", + REGION, + "--output", + "json", + ], + capture_output=True, + text=True, + ) + + if result.returncode == 0: + try: + data = json.loads(result.stdout) + for resource in data.get("StackResources", []): + if resource.get("LogicalResourceId") == "McpServerLambda": + return resource["PhysicalResourceId"] + except (json.JSONDecodeError, KeyError): + pass + + # Fallback: derive from stack name + logical ID + return f"{stack_name}-McpServerLambda" + + +# --------------------------------------------------------------------------- +# Helper: Cognito authentication +# --------------------------------------------------------------------------- + + +def get_id_token(client_id: str, username: str, password: str) -> str: + """Authenticate against Cognito and return the ID token. + + Args: + client_id: The Cognito App Client ID. + username: The Cognito username. + password: The user's password. + + Returns: + The ``IdToken`` string from the authentication result. + """ + cognito = boto3.client("cognito-idp", region_name=REGION) + response = cognito.initiate_auth( + AuthFlow="USER_PASSWORD_AUTH", + AuthParameters={"USERNAME": username, "PASSWORD": password}, + ClientId=client_id, + ) + return response["AuthenticationResult"]["IdToken"] + + +# --------------------------------------------------------------------------- +# Helper: Lambda invocation +# --------------------------------------------------------------------------- + + +def invoke_lambda(function_name: str, payload: dict) -> dict: + """Invoke a Lambda function synchronously and return the parsed response. + + Args: + function_name: The Lambda function name or ARN. + payload: The event payload dict to send. + + Returns: + The parsed JSON response body returned by the Lambda function. + """ + client = boto3.client("lambda", region_name=REGION) + response = client.invoke( + FunctionName=function_name, + InvocationType="RequestResponse", + Payload=json.dumps(payload).encode(), + ) + raw = response["Payload"].read() + return json.loads(raw) + + +# --------------------------------------------------------------------------- +# Test A — happy path +# --------------------------------------------------------------------------- + + +def test_happy_path(stack_outputs): + """Authenticate via Cognito, invoke the Agent Lambda, assert a product answer. + + Validates: Requirements 1.2, 2.2, 2.3, 2.5, 4.4, 8.4, 14.1 + """ + agent_lambda_name = stack_outputs.get("AgentLambdaName") + client_id = stack_outputs.get("CognitoClientId") + + if not agent_lambda_name or not client_id: + pytest.skip("Required stack outputs (AgentLambdaName, CognitoClientId) not found") + + id_token = get_id_token(client_id, TEST_USERNAME, TEST_PASSWORD) + + result = invoke_lambda( + agent_lambda_name, + {"jwt": id_token, "prompt": "List all products."}, + ) + + # The Agent Lambda returns either {"answer": "..."} or {"body": "..."} + answer = result.get("answer") or result.get("body") or result.get("response", "") + + assert isinstance(answer, str) and len(answer) > 0, ( + f"Expected a non-empty string answer, got: {result!r}" + ) + + answer_lower = answer.lower() + matched = any(token.lower() in answer_lower for token in SEEDED_PRODUCT_TOKENS) + assert matched, ( + f"Answer does not reference any seeded product token " + f"({SEEDED_PRODUCT_TOKENS!r}).\nAnswer: {answer!r}" + ) + + +# --------------------------------------------------------------------------- +# Test B — unauthorized (no JWT) +# --------------------------------------------------------------------------- + + +def test_unauthorized(stack_outputs): + """Invoke the Agent Lambda without a JWT; assert an auth-error response. + + Only the return-payload shape is checked — no CloudWatch assertion. + + Validates: Requirements 1.7, 14.1 + """ + agent_lambda_name = stack_outputs.get("AgentLambdaName") + + if not agent_lambda_name: + pytest.skip("Required stack output AgentLambdaName not found") + + # Omit the 'jwt' key entirely + result = invoke_lambda(agent_lambda_name, {"prompt": "List all products."}) + + result_str = json.dumps(result).lower() + + auth_error_tokens = ["error", "unauthorized", "401", "403", "missing", "invalid"] + matched = any(token in result_str for token in auth_error_tokens) + assert matched, ( + f"Expected an auth-error indicator in the response, but got: {result!r}" + ) + + +# --------------------------------------------------------------------------- +# Test C — MCP discovery via CloudWatch Logs Insights +# --------------------------------------------------------------------------- + + +def test_mcp_discovery(stack_outputs): + """Invoke a product-detail prompt and verify MCP protocol traffic in logs. + + Confirms that both ``tools/list`` and at least one ``tools/call`` appear + in the MCP Server Lambda's CloudWatch log stream during the invocation + window. + + Validates: Requirements 2.2, 2.3, 2.5, 4.4, 14.2 + """ + agent_lambda_name = stack_outputs.get("AgentLambdaName") + client_id = stack_outputs.get("CognitoClientId") + + if not agent_lambda_name or not client_id: + pytest.skip("Required stack outputs (AgentLambdaName, CognitoClientId) not found") + + mcp_lambda_name = get_mcp_lambda_name() + log_group = f"/aws/lambda/{mcp_lambda_name}" + + id_token = get_id_token(client_id, TEST_USERNAME, TEST_PASSWORD) + + # Record the window around the invocation + start_time = datetime.now(tz=timezone.utc) + + invoke_lambda( + agent_lambda_name, + {"jwt": id_token, "prompt": "Get product ELEC-001 details"}, + ) + + end_time = datetime.now(tz=timezone.utc) + + # Query CloudWatch Logs Insights for MCP protocol messages + logs_client = boto3.client("logs", region_name=REGION) + + query_start = int((start_time - timedelta(seconds=30)).timestamp()) + query_end = int((end_time + timedelta(seconds=60)).timestamp()) + + query_string = ( + "fields @message " + "| filter @message like /tools\\/list/ or @message like /tools\\/call/" + ) + + query_response = logs_client.start_query( + logGroupName=log_group, + startTime=query_start, + endTime=query_end, + queryString=query_string, + ) + query_id = query_response["queryId"] + + # Poll until the query completes (up to 60 seconds total) + deadline = time.monotonic() + 60 + results = [] + while time.monotonic() < deadline: + time.sleep(2) + status_response = logs_client.get_query_results(queryId=query_id) + status = status_response.get("status", "") + if status == "Complete": + results = status_response.get("results", []) + break + if status in ("Failed", "Cancelled", "Timeout"): + pytest.fail( + f"CloudWatch Logs Insights query ended with status {status!r}" + ) + + # Flatten results to a list of message strings + messages = [] + for row in results: + for field in row: + if field.get("field") == "@message": + messages.append(field.get("value", "")) + + # If no results yet, wait a bit more for log propagation (up to 60 s extra) + if not messages: + extra_deadline = time.monotonic() + 60 + while time.monotonic() < extra_deadline: + time.sleep(5) + status_response = logs_client.get_query_results(queryId=query_id) + if status_response.get("status") == "Complete": + results = status_response.get("results", []) + for row in results: + for field in row: + if field.get("field") == "@message": + messages.append(field.get("value", "")) + if messages: + break + # Re-issue the query with a wider window if still empty + if not messages and time.monotonic() > extra_deadline - 30: + wider_end = int((end_time + timedelta(seconds=120)).timestamp()) + retry_response = logs_client.start_query( + logGroupName=log_group, + startTime=query_start, + endTime=wider_end, + queryString=query_string, + ) + query_id = retry_response["queryId"] + + has_tools_list = any("tools/list" in msg for msg in messages) + has_tools_call = any("tools/call" in msg for msg in messages) + + assert has_tools_list, ( + "Expected at least one 'tools/list' entry in MCP Server Lambda logs, " + f"but found none. Messages captured: {messages!r}" + ) + assert has_tools_call, ( + "Expected at least one 'tools/call' entry in MCP Server Lambda logs, " + f"but found none. Messages captured: {messages!r}" + ) diff --git a/strands-agentcore-mcp/tests/property/__init__.py b/strands-agentcore-mcp/tests/property/__init__.py new file mode 100644 index 000000000..a36510a24 --- /dev/null +++ b/strands-agentcore-mcp/tests/property/__init__.py @@ -0,0 +1 @@ +# property-based tests diff --git a/strands-agentcore-mcp/tests/property/conftest.py b/strands-agentcore-mcp/tests/property/conftest.py new file mode 100644 index 000000000..3f632cb66 --- /dev/null +++ b/strands-agentcore-mcp/tests/property/conftest.py @@ -0,0 +1,157 @@ +"""Shared fixtures and helpers for JWT property-based tests. + +Provides a session-scoped RSA keypair, a JWKS mock fixture, and a +make_jwt() helper that signs tokens with the test key. +""" + +import time +from typing import Any, Dict, Optional + +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + + +# --------------------------------------------------------------------------- +# Session-scoped RSA keypair +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def rsa_private_key(): + """Generate a 2048-bit RSA private key once per test session.""" + return rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + + +@pytest.fixture(scope="session") +def rsa_public_key(rsa_private_key): + """Return the public key counterpart of the session RSA key.""" + return rsa_private_key.public_key() + + +# --------------------------------------------------------------------------- +# Alternative RSA key (for bad-signature tests) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def rsa_other_private_key(): + """A second RSA key used to produce tokens with invalid signatures.""" + return rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + + +# --------------------------------------------------------------------------- +# JWKS mock document +# --------------------------------------------------------------------------- + +_FIXED_KID = "test-key-id" + + +@pytest.fixture(scope="session") +def mock_jwks(rsa_public_key): + """Return a minimal JWKS document exposing the test public key.""" + from jwt.algorithms import RSAAlgorithm + import json + + jwk_dict = json.loads(RSAAlgorithm.to_jwk(rsa_public_key)) + jwk_dict["kid"] = _FIXED_KID + jwk_dict["use"] = "sig" + return {"keys": [jwk_dict]} + + +# --------------------------------------------------------------------------- +# make_jwt helper +# --------------------------------------------------------------------------- + +def make_jwt( + claims: Dict[str, Any], + *, + key, + kid: str = _FIXED_KID, + expired: bool = False, + bad_signature: bool = False, + alg: str = "RS256", +) -> str: + """Encode a JWT with the given claims and signing key. + + Args: + claims: Base claims dict (will be shallow-copied before modification). + key: RSA private key to sign with. + kid: Key ID to embed in the JWT header. + expired: If True, force ``exp`` to a past timestamp. + bad_signature: If True, sign with a freshly generated throwaway key + so the signature will not match the test JWKS. + alg: JWT algorithm (default RS256). + + Returns: + Encoded JWT string. + """ + payload = dict(claims) + + if expired: + payload["exp"] = int(time.time()) - 3600 # 1 hour in the past + + signing_key = key + if bad_signature: + signing_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + + private_pem = signing_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + + return jwt.encode( + payload, + private_pem, + algorithm=alg, + headers={"kid": kid}, + ) + + +# --------------------------------------------------------------------------- +# Fixture that patches jwt_utils._JWKS_CLIENTS to use the mock JWKS +# --------------------------------------------------------------------------- + +TEST_ISSUER = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_TESTPOOL" + + +@pytest.fixture() +def patched_jwks_client(mock_jwks, monkeypatch): + """Patch src.shared.jwt_utils._JWKS_CLIENTS with a mock PyJWKClient. + + The mock client's get_signing_key_from_jwt() returns the test public key + for any token whose header contains the fixed kid. + """ + from unittest.mock import MagicMock + import src.shared.jwt_utils as jwt_utils_module + + mock_client = MagicMock() + + def _get_signing_key(token): + # Parse the kid from the token header + header = jwt.get_unverified_header(token) + kid = header.get("kid") + # Find the matching key in the mock JWKS + for key_data in mock_jwks["keys"]: + if key_data.get("kid") == kid: + from jwt.algorithms import RSAAlgorithm + import json + public_key = RSAAlgorithm.from_jwk(json.dumps(key_data)) + result = MagicMock() + result.key = public_key + return result + raise jwt.exceptions.PyJWKClientError(f"No key found for kid={kid}") + + mock_client.get_signing_key_from_jwt.side_effect = _get_signing_key + + monkeypatch.setitem(jwt_utils_module._JWKS_CLIENTS, TEST_ISSUER, mock_client) + return mock_client diff --git a/strands-agentcore-mcp/tests/property/test_jwt_properties.py b/strands-agentcore-mcp/tests/property/test_jwt_properties.py new file mode 100644 index 000000000..dd95a0963 --- /dev/null +++ b/strands-agentcore-mcp/tests/property/test_jwt_properties.py @@ -0,0 +1,290 @@ +"""Property-based tests for src/shared/jwt_utils.py. + +# Feature: strands-agentcore-mcp, Property 1: JWT validation positive path +# Feature: strands-agentcore-mcp, Property 2: JWT validation negative path +""" + +import time +from typing import Any, Dict +from unittest.mock import MagicMock + +import jwt +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +import src.shared.jwt_utils as jwt_utils_module +import src.agent.strands_client as strands_client_module +from src.shared.jwt_utils import JWTValidationError, extract_username, validate_token +from tests.property.conftest import TEST_ISSUER, _FIXED_KID, make_jwt + + +# --------------------------------------------------------------------------- +# Strategies +# --------------------------------------------------------------------------- + +# Non-empty printable strings for cognito:username +username_strategy = st.text( + alphabet=st.characters( + whitelist_categories=("Lu", "Ll", "Nd"), + whitelist_characters="-_.@", + ), + min_size=1, + max_size=64, +) + +# Valid token_use values +valid_token_use_strategy = st.sampled_from(["access", "id"]) + +# Invalid token_use values — anything not in {"access", "id"} +invalid_token_use_strategy = st.text(min_size=1, max_size=32).filter( + lambda s: s not in ("access", "id") +) + + +def _future_exp() -> int: + """Return an exp timestamp 1 hour in the future.""" + return int(time.time()) + 3600 + + +# --------------------------------------------------------------------------- +# Shared helper: install the mock JWKS client into jwt_utils._JWKS_CLIENTS +# --------------------------------------------------------------------------- + +def _install_mock_jwks_client(mock_jwks: dict) -> MagicMock: + """Create and install a mock PyJWKClient for TEST_ISSUER. + + Returns the mock so callers can inspect call counts if needed. + """ + mock_client = MagicMock() + + def _get_signing_key(token): + header = jwt.get_unverified_header(token) + kid = header.get("kid") + for key_data in mock_jwks["keys"]: + if key_data.get("kid") == kid: + from jwt.algorithms import RSAAlgorithm + import json + public_key = RSAAlgorithm.from_jwk(json.dumps(key_data)) + result = MagicMock() + result.key = public_key + return result + raise jwt.exceptions.PyJWKClientError(f"No key found for kid={kid}") + + mock_client.get_signing_key_from_jwt.side_effect = _get_signing_key + jwt_utils_module._JWKS_CLIENTS[TEST_ISSUER] = mock_client + return mock_client + + +# --------------------------------------------------------------------------- +# Property 1: JWT validation positive path +# Validates: Requirements 1.3, 1.4, 1.6 +# --------------------------------------------------------------------------- + +@settings( + max_examples=100, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +@given( + username=username_strategy, + token_use=valid_token_use_strategy, +) +def test_jwt_positive_path(username, token_use, rsa_private_key, mock_jwks): + """Property 1: JWT validation positive path. + + For any valid claim set (token_use ∈ {access, id}, future exp, + non-empty cognito:username), validate_token succeeds and the returned + username matches the cognito:username claim. + + Validates: Requirements 1.3, 1.4, 1.6 + """ + _install_mock_jwks_client(mock_jwks) + + claims: Dict[str, Any] = { + "cognito:username": username, + "token_use": token_use, + "exp": _future_exp(), + "iss": TEST_ISSUER, + "sub": "test-sub-id", + } + + token = make_jwt(claims, key=rsa_private_key) + + # validate_token should succeed + returned_claims = validate_token(token, issuer=TEST_ISSUER) + + # The returned username must equal the cognito:username claim + extracted = extract_username(returned_claims) + assert extracted == username, ( + f"Expected username={username!r}, got {extracted!r}" + ) + + # token_use must be preserved + assert returned_claims.get("token_use") == token_use + + +# --------------------------------------------------------------------------- +# Property 2: JWT validation negative path +# Validates: Requirements 1.3, 1.4, 1.7 +# --------------------------------------------------------------------------- + +# --- 2a: Missing / empty token --- + +@settings( + max_examples=50, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +@given(token=st.one_of(st.just(""), st.just(" "))) +def test_jwt_negative_missing_token(token, mock_jwks): + """Property 2 (missing token): validate_token raises JWTValidationError. + + Validates: Requirements 1.3, 1.7 + """ + _install_mock_jwks_client(mock_jwks) + + with pytest.raises(JWTValidationError): + validate_token(token.strip(), issuer=TEST_ISSUER) + + +# --- 2b: Expired token --- + +@settings( + max_examples=100, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +@given( + username=username_strategy, + token_use=valid_token_use_strategy, +) +def test_jwt_negative_expired(username, token_use, rsa_private_key, mock_jwks): + """Property 2 (expired exp): validate_token raises JWTValidationError. + + Validates: Requirements 1.3, 1.7 + """ + _install_mock_jwks_client(mock_jwks) + + claims: Dict[str, Any] = { + "cognito:username": username, + "token_use": token_use, + "exp": int(time.time()) - 3600, # 1 hour in the past + "iss": TEST_ISSUER, + "sub": "test-sub-id", + } + + token = make_jwt(claims, key=rsa_private_key) + + with pytest.raises(JWTValidationError): + validate_token(token, issuer=TEST_ISSUER) + + +# --- 2c: Bad signature (signed by a different key) --- + +@settings( + max_examples=100, + suppress_health_check=[HealthCheck.function_scoped_fixture], + deadline=None, # RSA key generation per example can exceed the 200ms default +) +@given( + username=username_strategy, + token_use=valid_token_use_strategy, +) +def test_jwt_negative_bad_signature(username, token_use, rsa_private_key, mock_jwks): + """Property 2 (bad signature): validate_token raises JWTValidationError. + + Validates: Requirements 1.3, 1.7 + """ + _install_mock_jwks_client(mock_jwks) + + claims: Dict[str, Any] = { + "cognito:username": username, + "token_use": token_use, + "exp": _future_exp(), + "iss": TEST_ISSUER, + "sub": "test-sub-id", + } + + # Sign with a different key — signature will not match the mock JWKS + token = make_jwt(claims, key=rsa_private_key, bad_signature=True) + + with pytest.raises(JWTValidationError): + validate_token(token, issuer=TEST_ISSUER) + + +# --- 2d: Invalid token_use --- + +@settings( + max_examples=100, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +@given( + username=username_strategy, + token_use=invalid_token_use_strategy, +) +def test_jwt_negative_invalid_token_use( + username, token_use, rsa_private_key, mock_jwks +): + """Property 2 (invalid token_use): validate_token raises JWTValidationError. + + Validates: Requirements 1.4, 1.7 + """ + _install_mock_jwks_client(mock_jwks) + + claims: Dict[str, Any] = { + "cognito:username": username, + "token_use": token_use, + "exp": _future_exp(), + "iss": TEST_ISSUER, + "sub": "test-sub-id", + } + + token = make_jwt(claims, key=rsa_private_key) + + with pytest.raises(JWTValidationError): + validate_token(token, issuer=TEST_ISSUER) + + +# --------------------------------------------------------------------------- +# Property 2 — no gateway call on auth failure +# Validates: Requirements 1.7 +# --------------------------------------------------------------------------- + +@settings( + max_examples=50, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +@given(token_use=valid_token_use_strategy) +def test_jwt_negative_no_gateway_call_on_auth_failure( + token_use, rsa_private_key, mock_jwks, monkeypatch +): + """Property 2 (no gateway call): when JWT validation fails, create_mcp_client + is never called. + + Validates: Requirements 1.7 + """ + _install_mock_jwks_client(mock_jwks) + + mock_create = MagicMock() + monkeypatch.setattr(strands_client_module, "create_mcp_client", mock_create) + monkeypatch.setenv("COGNITO_ISSUER", TEST_ISSUER) + + # Use an expired token to trigger auth failure + claims: Dict[str, Any] = { + "cognito:username": "testuser", + "token_use": token_use, + "exp": int(time.time()) - 3600, + "iss": TEST_ISSUER, + "sub": "test-sub-id", + } + token = make_jwt(claims, key=rsa_private_key) + + from src.agent.handler import lambda_handler + + event = {"jwt": token, "prompt": "List all products."} + response = lambda_handler(event, None) + + # Should be an auth error response + assert response.get("success") is False + assert response.get("error") is not None + + # create_mcp_client must never have been called + mock_create.assert_not_called() diff --git a/strands-agentcore-mcp/tests/unit/__init__.py b/strands-agentcore-mcp/tests/unit/__init__.py new file mode 100644 index 000000000..35411ac0d --- /dev/null +++ b/strands-agentcore-mcp/tests/unit/__init__.py @@ -0,0 +1 @@ +# unit tests diff --git a/strands-agentcore-mcp/tests/unit/test_deploy_script.py b/strands-agentcore-mcp/tests/unit/test_deploy_script.py new file mode 100644 index 000000000..a1514c78c --- /dev/null +++ b/strands-agentcore-mcp/tests/unit/test_deploy_script.py @@ -0,0 +1,291 @@ +"""Deploy-script lint tests. + +Reads scripts/deploy.sh as text and asserts the NEW SAM-based deploy flow and +the conventions from .kiro/steering/project-conventions.md are followed. + +The deploy script no longer packages Lambdas by hand or runs a separate +update-function-code step. It now wraps `sam build` + `sam deploy` and runs the +ordered post-deploy steps (MCP target create/update, synchronize, DynamoDB +seed, Cognito test user, test.sh generation). + +These tests guard the deploy script mechanically so regressions are caught +before a real deployment attempt. + +Requirements covered: 3.2, 3.3, 10.1, 10.3, 10.4, 10.5, 11.1, 11.2, 11.3, 13.2 +""" + +import os +import re + +import pytest + +DEPLOY_SCRIPT_PATH = os.path.join( + os.path.dirname(__file__), "..", "..", "scripts", "deploy.sh" +) + + +@pytest.fixture(scope="session") +def deploy_script() -> str: + """Read the deploy script once per session.""" + with open(DEPLOY_SCRIPT_PATH, "r") as fh: + return fh.read() + + +# --------------------------------------------------------------------------- +# Helper: extract non-comment lines from the deploy script +# --------------------------------------------------------------------------- + +def _non_comment_lines(script: str) -> str: + """Return the script with comment-only lines removed.""" + lines = [] + for line in script.splitlines(): + stripped = line.strip() + if not stripped.startswith("#"): + lines.append(line) + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# 1. SAM build + deploy flow (Requirement 10.1) +# --------------------------------------------------------------------------- + +def test_sam_build_present(deploy_script): + """Script must invoke 'sam build'.""" + code_only = _non_comment_lines(deploy_script) + assert re.search(r'\bsam\s+build\b', code_only), ( + "deploy.sh must invoke 'sam build'" + ) + + +def test_sam_build_without_use_container(deploy_script): + """The build is Docker-free — 'sam build' must NOT use --use-container.""" + code_only = _non_comment_lines(deploy_script) + assert "--use-container" not in code_only, ( + "deploy.sh must invoke 'sam build' WITHOUT --use-container " + "(Docker-free build via the Makefile's BuildMethod: makefile targets)" + ) + + +def test_sam_deploy_present(deploy_script): + """Script must invoke 'sam deploy'.""" + code_only = _non_comment_lines(deploy_script) + assert re.search(r'\bsam\s+deploy\b', code_only), ( + "deploy.sh must invoke 'sam deploy'" + ) + + +def test_sam_build_before_sam_deploy(deploy_script): + """'sam build' must appear before 'sam deploy'.""" + code_only = _non_comment_lines(deploy_script) + build_match = re.search(r'\bsam\s+build\b', code_only) + deploy_match = re.search(r'\bsam\s+deploy\b', code_only) + assert build_match and deploy_match, "Expected both 'sam build' and 'sam deploy'" + assert build_match.start() < deploy_match.start(), ( + "'sam build' must appear before 'sam deploy' in deploy.sh" + ) + + +# --------------------------------------------------------------------------- +# 2. No separate update-function-code step (Requirement 3.2) +# --------------------------------------------------------------------------- + +def test_no_update_function_code(deploy_script): + """Script must NOT contain a separate 'aws lambda update-function-code' step.""" + assert "update-function-code" not in deploy_script, ( + "deploy.sh must not include a separate 'aws lambda update-function-code' " + "step — SAM deploys real code in one shot" + ) + + +# --------------------------------------------------------------------------- +# 3. No manual two-step pip3 packaging in the deploy script (Requirement 3.3) +# +# The two-step pip3 manylinux install now lives in the root Makefile's +# build-* targets, NOT in deploy.sh. +# --------------------------------------------------------------------------- + +def test_no_manual_pip_packaging(deploy_script): + """Script must NOT contain the two-step pip3 manylinux packaging logic.""" + assert "--only-binary=:all:" not in deploy_script, ( + "deploy.sh must not contain '--only-binary=:all:' packaging — " + "it now lives in the Makefile" + ) + assert "--platform manylinux2014_x86_64" not in deploy_script, ( + "deploy.sh must not contain '--platform manylinux2014_x86_64' packaging — " + "it now lives in the Makefile" + ) + + +def test_no_package_lambda_function(deploy_script): + """Script must NOT define a package_lambda() function.""" + assert not re.search(r'\bpackage_lambda\b', deploy_script), ( + "deploy.sh must not define or call a package_lambda() function — " + "packaging moved to the Makefile" + ) + + +# --------------------------------------------------------------------------- +# 4. No bare pip (only pip3) (Requirement 11.1) +# --------------------------------------------------------------------------- + +def test_no_bare_pip_invocations(deploy_script): + """Script must use pip3, never bare pip.""" + non_comment_matches = [] + for line in deploy_script.splitlines(): + stripped = line.strip() + if stripped.startswith("#"): + continue + found = re.findall(r'\bpip\b(?!3)', stripped) + non_comment_matches.extend(found) + + assert not non_comment_matches, ( + f"deploy.sh must use 'pip3', not bare 'pip'. " + f"Found {len(non_comment_matches)} bare 'pip' occurrence(s)" + ) + + +# --------------------------------------------------------------------------- +# 5. PID-based temp file names (Requirement 11.2) +# --------------------------------------------------------------------------- + +def test_pid_based_temp_files(deploy_script): + """Script must use $$-based PID temp file names.""" + assert re.search(r'\.\$\$\.', deploy_script), ( + "deploy.sh must use PID-based temp file names " + "(e.g. /tmp/agentcore-mcp.$$.stack-output.json)" + ) + + +def test_no_mktemp_suffix_templates(deploy_script): + """Script must NOT use mktemp suffix templates (macOS incompatible).""" + assert not re.search(r'mktemp\s+.*-t\s+\S*X{3,}', deploy_script), ( + "deploy.sh must not use 'mktemp -t XXXXX' suffix templates — " + "use $$-based PID names instead" + ) + assert not re.search(r'mktemp\s+.*--suffix', deploy_script), ( + "deploy.sh must not use 'mktemp --suffix' — " + "use $$-based PID names instead" + ) + + +# --------------------------------------------------------------------------- +# 6. Generated test.sh uses heredoc + sed substitution (Requirement 11.3) +# --------------------------------------------------------------------------- + +def test_test_sh_generated_via_heredoc(deploy_script): + """Generated test.sh must be produced via a heredoc, not nested echo JSON.""" + assert re.search(r'cat\s+>\s*scripts/test\.sh', deploy_script), ( + "deploy.sh must use 'cat > scripts/test.sh <<...' to generate the test script" + ) + + +def test_test_sh_uses_sed_substitution(deploy_script): + """Generated test.sh placeholders must be substituted via sed -i.bak.""" + assert "sed -i.bak" in deploy_script, ( + "deploy.sh must use 'sed -i.bak' to substitute baked-in values into test.sh" + ) + + +# --------------------------------------------------------------------------- +# 7. Ordered post-deploy steps (Requirement 10.3) +# +# Order: MCP target create/update -> synchronize -> DynamoDB seed +# -> Cognito user -> test.sh generation +# --------------------------------------------------------------------------- + +def test_post_deploy_steps_in_order(deploy_script): + """Post-deploy steps must appear in the required order.""" + markers = [ + ("MCP target create/update", "list-gateway-targets"), + ("MCP target synchronize", "synchronize_gateway_targets"), + ("DynamoDB seed", "aws dynamodb put-item"), + ("Cognito user", "admin-create-user"), + ("test.sh generation", "cat > scripts/test.sh"), + ] + + positions = [] + for label, marker in markers: + pos = deploy_script.find(marker) + assert pos != -1, f"deploy.sh is missing the {label} step (marker: {marker!r})" + positions.append((label, pos)) + + for (prev_label, prev_pos), (next_label, next_pos) in zip(positions, positions[1:]): + assert prev_pos < next_pos, ( + f"Post-deploy step '{prev_label}' must appear before '{next_label}' " + f"in deploy.sh" + ) + + +# --------------------------------------------------------------------------- +# 8. DynamoDB seeding: >=3 put-item calls spanning >=2 categories (Req 10.4) +# --------------------------------------------------------------------------- + +def test_dynamodb_seeding_at_least_three_products(deploy_script): + """Script must seed at least three products via put-item.""" + put_item_calls = re.findall(r'aws\s+dynamodb\s+put-item', deploy_script) + assert len(put_item_calls) >= 3, ( + f"deploy.sh must seed at least 3 products with 'aws dynamodb put-item', " + f"found {len(put_item_calls)}" + ) + + +def test_dynamodb_seeding_at_least_two_categories(deploy_script): + """Seeded products must span at least two categories.""" + categories = set(re.findall(r'"category":\s*\{"S":\s*"([^"]+)"\}', deploy_script)) + assert len(categories) >= 2, ( + f"deploy.sh must seed products across at least 2 categories, " + f"found: {sorted(categories)}" + ) + + +# --------------------------------------------------------------------------- +# 9. Cognito test user with permanent password (Requirement 10.5) +# --------------------------------------------------------------------------- + +def test_cognito_test_user_creation(deploy_script): + """Script must create a Cognito test user.""" + assert "admin-create-user" in deploy_script, ( + "deploy.sh must call 'aws cognito-idp admin-create-user'" + ) + + +def test_cognito_permanent_password(deploy_script): + """Script must set a permanent password so the user is confirmed.""" + assert "admin-set-user-password" in deploy_script, ( + "deploy.sh must call 'aws cognito-idp admin-set-user-password'" + ) + assert "--permanent" in deploy_script, ( + "deploy.sh must use the --permanent flag to confirm the test user" + ) + + +# --------------------------------------------------------------------------- +# 10. Old removed flow must be gone (Requirements 3.2, 3.3) +# --------------------------------------------------------------------------- + +def test_no_validate_template(deploy_script): + """Script must NOT call 'aws cloudformation validate-template' (SAM validates).""" + assert "validate-template" not in deploy_script, ( + "deploy.sh must not call 'aws cloudformation validate-template' — " + "SAM validates during build/deploy" + ) + + +def test_no_create_or_update_stack_decision_tree(deploy_script): + """Script must NOT use the create-stack/update-stack decision tree.""" + assert "aws cloudformation create-stack" not in deploy_script, ( + "deploy.sh must not call 'aws cloudformation create-stack' — " + "SAM handles create-vs-update" + ) + assert "aws cloudformation update-stack" not in deploy_script, ( + "deploy.sh must not call 'aws cloudformation update-stack' — " + "SAM handles create-vs-update" + ) + + +def test_no_rollback_complete_handling(deploy_script): + """Script must NOT contain ROLLBACK_COMPLETE handling (SAM manages rollback).""" + assert "ROLLBACK_COMPLETE" not in deploy_script, ( + "deploy.sh must not contain ROLLBACK_COMPLETE handling — " + "SAM manages rollback" + ) diff --git a/strands-agentcore-mcp/tests/unit/test_makefile.py b/strands-agentcore-mcp/tests/unit/test_makefile.py new file mode 100644 index 000000000..47c314796 --- /dev/null +++ b/strands-agentcore-mcp/tests/unit/test_makefile.py @@ -0,0 +1,199 @@ +"""Makefile assertion tests for the Docker-free SAM build. + +Parses the root ``Makefile`` and asserts the ``build-AgentLambdaFunction`` and +``build-McpServerLambda`` targets exist and each runs the project's proven +two-step ``pip3`` manylinux install plus the ``src`` copy. + +This is a deterministic lint guarding the Docker-free build approach +(BuildMethod: makefile). See .kiro/steering/project-conventions.md. + +Validates: Requirements 2.1, 2.5, 2.6, 9.3, 11.1, 13.1 +""" + +import os +import re +from typing import Dict, List + +import pytest + +# --------------------------------------------------------------------------- +# Fixture: locate and read the project-root Makefile +# --------------------------------------------------------------------------- + +MAKEFILE_PATH = os.path.join( + os.path.dirname(__file__), "..", "..", "Makefile" +) + +# The pure-Python package set installed in Step B (--no-deps), in order. +PURE_PYTHON_PACKAGES = [ + "requests", "urllib3", "charset-normalizer", "idna", "certifi", + "PyJWT", "cryptography", "cffi", "mcp", +] + +REQUIRED_TARGETS = ["build-AgentLambdaFunction", "build-McpServerLambda"] + + +@pytest.fixture(scope="session") +def makefile_text() -> str: + """Read the raw Makefile text once per session.""" + assert os.path.exists(MAKEFILE_PATH), ( + f"Expected a Makefile at the project root: {MAKEFILE_PATH}" + ) + with open(MAKEFILE_PATH, "r") as fh: + return fh.read() + + +@pytest.fixture(scope="session") +def target_recipes(makefile_text) -> Dict[str, List[str]]: + """Split the Makefile into {target_name: [recipe_lines]}. + + A target starts at a line matching ``^:`` and owns the following + tab-indented recipe lines until the next non-indented line. + """ + recipes: Dict[str, List[str]] = {} + current: str = None + for line in makefile_text.splitlines(): + # A target header: starts at column 0, ends with ':' before any recipe. + header = re.match(r"^([A-Za-z0-9_.\-]+):\s*(.*)$", line) + if header and not line.startswith("\t"): + current = header.group(1) + recipes[current] = [] + continue + if current is not None and line.startswith("\t"): + recipes[current].append(line[1:]) # strip the leading tab + continue + # Blank line inside a recipe is allowed by make; keep collecting. + if current is not None and line.strip() == "": + continue + # Any other non-indented line ends the current target block. + current = None + return recipes + + +def _recipe_text(recipes: Dict[str, List[str]], target: str) -> str: + """Join a target's recipe lines into a single searchable string.""" + return "\n".join(recipes[target]) + + +def _command_lines(recipe_lines: List[str]) -> List[str]: + """Return recipe lines that are commands (strip comment-only lines).""" + cmds = [] + for ln in recipe_lines: + stripped = ln.strip() + if not stripped or stripped.startswith("#"): + continue + cmds.append(ln) + return cmds + + +# --------------------------------------------------------------------------- +# 1. Both build targets exist (Requirement 2.1) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("target", REQUIRED_TARGETS) +def test_build_target_exists(target_recipes, target): + """SAM BuildMethod: makefile requires build- targets.""" + assert target in target_recipes, ( + f"Makefile must declare a '{target}:' target so SAM's " + f"BuildMethod: makefile can resolve it" + ) + assert target_recipes[target], ( + f"'{target}:' target must have a non-empty recipe" + ) + + +# --------------------------------------------------------------------------- +# 2. Step A — binary install from requirements.txt (Requirements 2.5, 13.1) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("target", REQUIRED_TARGETS) +def test_step_a_only_binary_requirements(target_recipes, target): + """Step A must install with --only-binary=:all: against requirements.txt.""" + text = _recipe_text(target_recipes, target) + assert "--only-binary=:all:" in text, ( + f"'{target}' must use --only-binary=:all: (Step A binary wheels)" + ) + assert "-r requirements.txt" in text, ( + f"'{target}' Step A must install -r requirements.txt" + ) + + +# --------------------------------------------------------------------------- +# 3. Step B — pure-Python package set with --no-deps (Requirements 2.5, 13.1) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("target", REQUIRED_TARGETS) +def test_step_b_no_deps_pure_python_set(target_recipes, target): + """Step B must use --no-deps and install the pure-Python package set.""" + text = _recipe_text(target_recipes, target) + assert "--no-deps" in text, ( + f"'{target}' must use --no-deps for the pure-Python set (Step B); " + f"--only-binary silently skips pure-Python packages" + ) + for pkg in PURE_PYTHON_PACKAGES: + assert re.search(rf"(?= 2, ( + f"'{target}' must use --platform manylinux2014_x86_64 in BOTH pip3 " + f"steps, found {platform_count}" + ) + assert pyversion_count >= 2, ( + f"'{target}' must use --python-version 3.12 in BOTH pip3 steps, " + f"found {pyversion_count}" + ) + + +# --------------------------------------------------------------------------- +# 5. src copied into $(ARTIFACTS_DIR) preserving the src/ prefix (Req 9.3) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("target", REQUIRED_TARGETS) +def test_copies_src_into_artifacts_dir(target_recipes, target): + """Each target must copy src into $(ARTIFACTS_DIR), preserving src/ prefix.""" + text = _recipe_text(target_recipes, target) + assert re.search(r'cp\s+-r\s+src\s+"?\$\(ARTIFACTS_DIR\)/src"?', text), ( + f"'{target}' must copy src into $(ARTIFACTS_DIR)/src " + f'(e.g. cp -r src "$(ARTIFACTS_DIR)/src")' + ) + + +# --------------------------------------------------------------------------- +# 6. Uses pip3, never a bare pip invocation (Requirement 11.1) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("target", REQUIRED_TARGETS) +def test_uses_pip3_not_bare_pip(target_recipes, target): + """Recipe command lines must invoke pip3, never bare 'pip'.""" + text = _recipe_text(target_recipes, target) + assert "pip3 install" in text, ( + f"'{target}' must use 'pip3 install'" + ) + for cmd in _command_lines(target_recipes[target]): + # A bare pip invocation is 'pip' followed by space/end, not 'pip3'. + assert not re.search(r"(? Dict[str, Any]: + """Load and parse the SAM template.""" + with open(TEMPLATE_PATH, "r") as fh: + return yaml.load(fh, Loader=CloudFormationLoader) + + +@pytest.fixture(scope="session") +def resources(template) -> Dict[str, Any]: + return template["Resources"] + + +@pytest.fixture(scope="session") +def template_text() -> str: + with open(TEMPLATE_PATH, "r") as fh: + return fh.read() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _collect_all_statements(policies: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Flatten all policy statements from a list of inline policies.""" + stmts: List[Dict[str, Any]] = [] + for policy in policies: + doc = policy.get("PolicyDocument", {}) + statement = doc.get("Statement", []) + if isinstance(statement, dict): + statement = [statement] + stmts.extend(statement) + return stmts + + +def _resource_strings(resource_value) -> List[str]: + """Return a list of resource strings from a Resource field (str/list/dict).""" + if isinstance(resource_value, str): + return [resource_value] + if isinstance(resource_value, list): + result = [] + for r in resource_value: + if isinstance(r, str): + result.append(r) + elif isinstance(r, dict): + # !Sub / !GetAtt dict — get the template string + result.append(list(r.values())[0]) + return result + if isinstance(resource_value, dict): + return [list(resource_value.values())[0]] + return [] + + +def _actions_list(stmt: Dict[str, Any]) -> List[str]: + actions = stmt.get("Action", []) + if isinstance(actions, str): + return [actions] + return list(actions) + + +def _all_role_logical_ids() -> List[str]: + return ["AgentLambdaRole", "McpServerRole", "GatewayExecutionRole"] + + +def _sub_value(value): + """Extract the underlying string from a scalar or a !Sub/!GetAtt dict. + + For !Sub with a [template, vars] list form, returns the template string. + """ + if isinstance(value, dict): + inner = list(value.values())[0] + if isinstance(inner, list): + return inner[0] + return inner + return value + + +# --------------------------------------------------------------------------- +# 1. SAM Transform directive (Requirement 1.1) +# --------------------------------------------------------------------------- + +def test_transform_directive_present(template): + """Template must declare the SAM transform AWS::Serverless-2016-10-31.""" + transform = template.get("Transform") + transforms = transform if isinstance(transform, list) else [transform] + assert "AWS::Serverless-2016-10-31" in transforms, ( + f"Template must declare Transform: AWS::Serverless-2016-10-31, " + f"got {transform!r}" + ) + + +# --------------------------------------------------------------------------- +# 2. Both Lambda functions are AWS::Serverless::Function (Requirement 1.2) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("logical_id", ["AgentLambdaFunction", "McpServerLambda"]) +def test_function_is_serverless_function(resources, logical_id): + """Both Lambda functions must be AWS::Serverless::Function resources.""" + assert resources[logical_id]["Type"] == "AWS::Serverless::Function", ( + f"{logical_id} must be Type AWS::Serverless::Function, " + f"got {resources[logical_id]['Type']!r}" + ) + + +# --------------------------------------------------------------------------- +# 3. Metadata.BuildMethod: makefile on both functions (Requirement 1.2, 9.x) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("logical_id", ["AgentLambdaFunction", "McpServerLambda"]) +def test_function_build_method_makefile(resources, logical_id): + """Each function must declare Metadata.BuildMethod: makefile (Docker-free build).""" + metadata = resources[logical_id].get("Metadata", {}) + assert metadata.get("BuildMethod") == "makefile", ( + f"{logical_id} must declare Metadata.BuildMethod: makefile, " + f"got {metadata.get('BuildMethod')!r}" + ) + + +# --------------------------------------------------------------------------- +# 4. Handler paths (Requirement 2.3, 2.4) +# --------------------------------------------------------------------------- + +def test_agent_lambda_handler_path(resources): + """AgentLambdaFunction must use handler src.agent.handler.lambda_handler.""" + fn_props = resources["AgentLambdaFunction"]["Properties"] + assert fn_props.get("Handler") == "src.agent.handler.lambda_handler", ( + f"AgentLambdaFunction handler must be 'src.agent.handler.lambda_handler', " + f"got {fn_props.get('Handler')!r}" + ) + + +def test_mcp_server_lambda_handler_path(resources): + """McpServerLambda must use handler src.mcp_server.handler.lambda_handler.""" + fn_props = resources["McpServerLambda"]["Properties"] + assert fn_props.get("Handler") == "src.mcp_server.handler.lambda_handler", ( + f"McpServerLambda handler must be 'src.mcp_server.handler.lambda_handler', " + f"got {fn_props.get('Handler')!r}" + ) + + +# --------------------------------------------------------------------------- +# 5. CodeUri points at project root (Requirement 2.2, 9.3) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("logical_id", ["AgentLambdaFunction", "McpServerLambda"]) +def test_function_codeuri_is_project_root(resources, logical_id): + """Each function CodeUri must point at the project root ('../').""" + fn_props = resources[logical_id]["Properties"] + code_uri = fn_props.get("CodeUri") + assert code_uri == "../", ( + f"{logical_id} CodeUri must be '../' (project root), got {code_uri!r}" + ) + + +# --------------------------------------------------------------------------- +# 6. Timeout / MemorySize (Requirement 9.1, 9.2) +# --------------------------------------------------------------------------- + +def test_agent_lambda_sizing(resources): + """AgentLambdaFunction must declare Timeout 120 and MemorySize 512.""" + fn_props = resources["AgentLambdaFunction"]["Properties"] + assert fn_props.get("Timeout") == 120, ( + f"AgentLambdaFunction Timeout must be 120, got {fn_props.get('Timeout')!r}" + ) + assert fn_props.get("MemorySize") == 512, ( + f"AgentLambdaFunction MemorySize must be 512, got {fn_props.get('MemorySize')!r}" + ) + + +def test_mcp_server_lambda_sizing(resources): + """McpServerLambda must declare Timeout 30 and MemorySize 256.""" + fn_props = resources["McpServerLambda"]["Properties"] + assert fn_props.get("Timeout") == 30, ( + f"McpServerLambda Timeout must be 30, got {fn_props.get('Timeout')!r}" + ) + assert fn_props.get("MemorySize") == 256, ( + f"McpServerLambda MemorySize must be 256, got {fn_props.get('MemorySize')!r}" + ) + + +# --------------------------------------------------------------------------- +# 7. Environment variables (Requirement 9.4, 9.5) +# --------------------------------------------------------------------------- + +def test_agent_lambda_env_vars(resources): + """AgentLambdaFunction must receive the five required env vars.""" + fn_props = resources["AgentLambdaFunction"]["Properties"] + env_vars = fn_props.get("Environment", {}).get("Variables", {}) + required = { + "GATEWAY_URL", + "COGNITO_ISSUER", + "COGNITO_USER_POOL_ID", + "COGNITO_CLIENT_ID", + "BEDROCK_MODEL_ID", + } + missing = required - set(env_vars.keys()) + assert not missing, ( + f"AgentLambdaFunction missing env vars: {sorted(missing)}" + ) + + +def test_mcp_server_lambda_env_vars(resources): + """McpServerLambda must receive the product table name via PRODUCT_TABLE.""" + fn_props = resources["McpServerLambda"]["Properties"] + env_vars = fn_props.get("Environment", {}).get("Variables", {}) + assert "PRODUCT_TABLE" in env_vars, ( + "McpServerLambda must declare a PRODUCT_TABLE environment variable" + ) + + +# --------------------------------------------------------------------------- +# 8. No inline ZipFile code on either function (Requirement 3.1) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("logical_id", ["AgentLambdaFunction", "McpServerLambda"]) +def test_function_no_inline_zipfile(resources, logical_id): + """No function may declare inline Code.ZipFile (real source via SAM build).""" + fn_props = resources[logical_id]["Properties"] + code = fn_props.get("Code") + if isinstance(code, dict): + assert "ZipFile" not in code, ( + f"{logical_id} must NOT declare inline Code.ZipFile" + ) + + +# --------------------------------------------------------------------------- +# 9. No Lambda Layer (Requirement 2.6) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("logical_id", ["AgentLambdaFunction", "McpServerLambda"]) +def test_function_no_layers(resources, logical_id): + """No function may declare Layers (dependencies built via makefile, Req 2.6).""" + fn_props = resources[logical_id]["Properties"] + assert "Layers" not in fn_props, ( + f"{logical_id} must NOT declare Layers — no shared Lambda Layer" + ) + + +def test_no_lambda_layer_resources(resources): + """No AWS::Serverless::LayerVersion or AWS::Lambda::LayerVersion resources.""" + layer_types = {"AWS::Serverless::LayerVersion", "AWS::Lambda::LayerVersion"} + layers = [lid for lid, r in resources.items() if r.get("Type") in layer_types] + assert not layers, f"Template must not declare Lambda Layer resources: {layers}" + + +# --------------------------------------------------------------------------- +# 10. Each function references its explicit role via Role: !GetAtt .Arn +# and the three named roles exist (Requirement 6, design decision) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "logical_id,role_id", + [ + ("AgentLambdaFunction", "AgentLambdaRole"), + ("McpServerLambda", "McpServerRole"), + ], +) +def test_function_references_explicit_role(resources, logical_id, role_id): + """Each function must reference its explicit role via Role: !GetAtt .Arn.""" + fn_props = resources[logical_id]["Properties"] + role = fn_props.get("Role") + assert isinstance(role, dict), ( + f"{logical_id} must declare an explicit Role via !GetAtt, got {role!r}" + ) + getatt_val = role.get("!GetAtt") + assert getatt_val is not None, ( + f"{logical_id} Role must use !GetAtt .Arn, got {role!r}" + ) + if isinstance(getatt_val, list): + assert getatt_val == [role_id, "Arn"], ( + f"{logical_id} Role must be !GetAtt {role_id}.Arn, got {getatt_val!r}" + ) + else: + assert getatt_val == f"{role_id}.Arn", ( + f"{logical_id} Role must be !GetAtt {role_id}.Arn, got {getatt_val!r}" + ) + + +def test_named_roles_exist_with_policies(resources): + """The three named IAM roles must exist with scoped inline policies.""" + for role_id in _all_role_logical_ids(): + assert role_id in resources, f"Template must declare role {role_id}" + role = resources[role_id] + assert role["Type"] == "AWS::IAM::Role", ( + f"{role_id} must be Type AWS::IAM::Role" + ) + policies = role["Properties"].get("Policies", []) + assert len(policies) >= 1, ( + f"{role_id} must declare at least one scoped inline policy" + ) + + +# --------------------------------------------------------------------------- +# 11. AgentCore Gateway casing traps (Requirements 5.1-5.6) +# --------------------------------------------------------------------------- + +def test_custom_jwt_authorizer_key_exists(resources): + """AuthorizerConfiguration.CustomJWTAuthorizer must exist (JWT all-caps, Req 5.1).""" + gw = resources["AgentCoreGateway"]["Properties"] + auth_config = gw.get("AuthorizerConfiguration", {}) + assert "CustomJWTAuthorizer" in auth_config, ( + "Expected 'CustomJWTAuthorizer' key under AuthorizerConfiguration" + ) + + +def test_custom_jwt_authorizer_wrong_casing_absent(template_text): + """CustomJwtAuthorizer (wrong casing) must NOT appear anywhere (Req 5.1).""" + assert "CustomJwtAuthorizer" not in template_text, ( + "Found 'CustomJwtAuthorizer' (wrong casing) — must be 'CustomJWTAuthorizer'" + ) + + +def test_gateway_uses_role_arn(resources): + """AgentCoreGateway must use RoleArn, not ExecutionRoleArn (Req 5.2).""" + gw_props = resources["AgentCoreGateway"]["Properties"] + assert "RoleArn" in gw_props, "AgentCoreGateway must have a 'RoleArn' property" + assert "ExecutionRoleArn" not in gw_props, ( + "AgentCoreGateway must NOT use 'ExecutionRoleArn' — use 'RoleArn'" + ) + + +def test_allowed_audience_present(resources): + """CustomJWTAuthorizer must use AllowedAudience, not Audience (Req 5.3).""" + gw = resources["AgentCoreGateway"]["Properties"] + jwt_auth = gw["AuthorizerConfiguration"]["CustomJWTAuthorizer"] + assert "AllowedAudience" in jwt_auth, ( + "CustomJWTAuthorizer must have 'AllowedAudience'" + ) + assert "Audience" not in jwt_auth, ( + "CustomJWTAuthorizer must use 'AllowedAudience', not bare 'Audience'" + ) + + +def test_bare_audience_key_absent(template_text): + """The bare key 'Audience:' (wrong name) must not appear (Req 5.3).""" + matches = re.findall(r'(?