diff --git a/strands-agentcore-openapi/.gitignore b/strands-agentcore-openapi/.gitignore new file mode 100644 index 0000000000..a1c1d26ff3 --- /dev/null +++ b/strands-agentcore-openapi/.gitignore @@ -0,0 +1,71 @@ +# Python +__pycache__/ +*.py[cod] +*.pyc +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +env/ +ENV/ +.venv + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.hypothesis/ + +# Type checking +.mypy_cache/ +.dmypy.json +dmypy.json + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# AWS / Deployment build artifacts +*.zip +*-deps/ +*-package/ +lambda-packages/ +.aws-sam/ +samconfig.toml + +# Deployment artifacts (generated by scripts/deploy.sh) +deployment/stack_outputs.json +deployment/test_credentials.json +test_credentials.json +scripts/test.sh + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Environment variables +.env +.env.local diff --git a/strands-agentcore-openapi/README.md b/strands-agentcore-openapi/README.md new file mode 100644 index 0000000000..a05d5d8a99 --- /dev/null +++ b/strands-agentcore-openapi/README.md @@ -0,0 +1,201 @@ +# Serverless AI Agent with AgentCore MCP Gateway and OpenAPI Target + +A serverless AI agent system that enables natural language interaction with OpenAPI-compliant REST APIs using AWS Bedrock AgentCore Gateway and Claude Sonnet 4.5. + +## Overview + +The OpenAPI Agent Gateway dynamically discovers and invokes REST API operations from OpenAPI 3.x specifications. Users authenticate via Cognito JWT, submit natural language prompts to an Agent Lambda powered by Claude/Bedrock, which discovers and invokes tools dynamically generated from OpenAPI specifications through the AgentCore Gateway. + +## Architecture + +![Architecture Diagram](architecture/target-openapi.png) + +``` +User → Agent Lambda → AgentCore Gateway (MCP) → WeatherAPI.com + │ │ │ + ─────────────── ───────────────────── ───────────────────── + Strands Agent CUSTOM_JWT Auth API Key Auth + + BedrockModel + MCP Tool Routing (Credential Provider + + MCPClient + Tool Discovery → Secrets Manager) + │ │ + Cognito JWT OpenAPI Target + Validated (auto-discovered) +``` + +- **Agent Lambda** (512MB, 120s timeout): Processes natural language prompts using Claude Sonnet 4.5, discovers tools from OpenAPI specifications via the Gateway, and orchestrates tool execution +- **AgentCore Gateway**: Handles CUSTOM_JWT auth via Cognito, exposes tools via MCP, and calls WeatherAPI.com directly using API key credential injection + +> **Why WeatherAPI.com (and not a key-less API like Open-Meteo)?** +> AgentCore Gateway **OpenAPI targets only support `API_KEY` or `OAuth` outbound authorization** — IAM is not supported, and the service rejects a target created without a credential provider (`"CredentialProviderConfigurations is required for OpenAPI targets"`). That rules out genuinely key-less public APIs for this pattern. WeatherAPI.com fits naturally because it authenticates with an API key, which the Gateway injects from an API Key Credential Provider backed by Secrets Manager. +> +> If you need to call a key-less or IAM-authenticated backend, front it with **Amazon API Gateway** and use an API Gateway target instead (API Gateway targets support IAM / `GATEWAY_IAM_ROLE`). + +## Project Structure + +``` +. +├── src/ +│ ├── agent/ # Agent Lambda implementation +│ ├── shared/ # Shared utilities and data models +│ ├── openapi_parser/ # OpenAPI specification parser +│ └── requirements.txt # Runtime dependencies (used by `sam build`) +├── template.yaml # AWS SAM template +├── tests/ # Unit, property, and integration tests +├── deployment/ # Test-user setup and generated outputs +├── scripts/ # deploy.sh and generated test.sh +└── README.md +``` + +## Prerequisites + +- Python 3.12+ — [python.org/downloads](https://www.python.org/downloads/) +- AWS CLI v2 (2.28+ recommended) — [docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) +- AWS SAM CLI — [docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) +- `make` (used by the SAM Makefile build; preinstalled on macOS via Xcode CLT and most Linux distros). No Docker required. +- AWS account with access to: Bedrock, Lambda, AgentCore Gateway, Cognito, Secrets Manager, S3, CloudWatch +- Bedrock model access enabled for Claude Sonnet 4.5 (or your chosen model) in `us-east-1` +- A free WeatherAPI.com API key — [weatherapi.com/signup.aspx](https://www.weatherapi.com/signup.aspx) + +## Deployment + +### Step 1: Clone the repository + +Open a terminal and run: + +```bash +git clone https://github.com/aws-samples/serverless-patterns +cd serverless-patterns/strands-agentcore-openapi +``` + +### Step 2: Set up your Python environment + +```bash +python3 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +pip install -r requirements.txt +``` + +### Step 3: Configure AWS credentials + +If you haven't already configured the AWS CLI: + +```bash +aws configure +# AWS Access Key ID: +# AWS Secret Access Key: +# Default region name: us-east-1 +# Default output format: json +``` + +Verify it's working: + +```bash +aws sts get-caller-identity +``` + +### Step 4: Get your own WeatherAPI.com API key + +Each person deploying this project uses **their own** WeatherAPI.com key — no key is bundled in the repo, and the deploy script requires one. + +1. Sign up at [weatherapi.com/signup.aspx](https://www.weatherapi.com/signup.aspx) (free, no credit card) +2. Verify your email and log in to [weatherapi.com/my](https://www.weatherapi.com/my/) +3. Copy your API key from the dashboard + +> Your key is passed only via the `--weather-api-key` flag and stored in your own AWS account (Secrets Manager + the credential provider). Never commit it to the repo. + +### Step 5: Deploy + +```bash +./scripts/deploy.sh \ + --environment-name dev \ + --weather-api-key YOUR_WEATHERAPI_KEY +``` + +The script creates the WeatherAPI secret and API Key credential provider, then runs `sam build` and `sam deploy`, and finally creates a Cognito test user. When it finishes, run a quick test: + +```bash +./scripts/test.sh 'What is the weather in Liverpool, United Kingdom?' +``` + +#### Deploy script options + +| Flag | Default | Description | +|------|---------|-------------| +| `--environment-name` | required | Prefix for all resources (e.g. dev, prod) | +| `--weather-api-key` | required | Your WeatherAPI.com API key | +| `--model-id` | `us.anthropic.claude-sonnet-4-5-20250929-v1:0` | Bedrock model ID to use | +| `--region` | `us-east-1` | AWS region | + +SAM manages the deployment artifact bucket automatically (`--resolve-s3`), so there's no bucket to configure. + +### Step 6: Changing the model (optional) + +The agent defaults to Claude Sonnet 4.5. Pass `--model-id` to use a different Bedrock model: + +```bash +./scripts/deploy.sh \ + --environment-name dev \ + --weather-api-key YOUR_WEATHERAPI_KEY \ + --model-id anthropic.claude-3-5-sonnet-20241022-v2:0 +``` + +To update a live deployment without redeploying the full stack: + +```bash +aws lambda update-function-configuration \ + --function-name \ + --environment "Variables={BEDROCK_MODEL_ID=anthropic.claude-3-5-sonnet-20241022-v2:0,GATEWAY_ID=,COGNITO_JWKS_URL=}" \ + --region us-east-1 +``` + +> When updating via CLI, the entire `Variables` map is replaced — include all existing variables. + +**Available Bedrock model IDs:** + +| Model | ID | +|---|---| +| Claude Sonnet 4.5 (default) | `us.anthropic.claude-sonnet-4-5-20250929-v1:0` | +| Claude 3 Sonnet | `anthropic.claude-3-sonnet-20240229-v1:0` | +| Claude 3.5 Sonnet v2 | `anthropic.claude-3-5-sonnet-20241022-v2:0` | +| Claude 3.5 Haiku | `anthropic.claude-3-5-haiku-20241022-v1:0` | +| Claude 3 Opus | `anthropic.claude-3-opus-20240229-v1:0` | +| Amazon Nova Pro | `amazon.nova-pro-v1:0` | +| Amazon Nova Lite | `amazon.nova-lite-v1:0` | + +Make sure the model has access enabled in your account under **Bedrock → Model access**. + +## Teardown + +```bash +sam delete --stack-name dev-openapi-agent-gateway --region us-east-1 +``` + +To fully clean up the resources created outside the stack: + +1. Delete the credential provider: + ```bash + aws bedrock-agentcore-control delete-api-key-credential-provider --name dev-weatherapi-key --region us-east-1 + ``` +2. Delete the secret: + ```bash + aws secretsmanager delete-secret --secret-id dev/weatherapi-key --force-delete-without-recovery --region us-east-1 + ``` + > `--force-delete-without-recovery` purges the secret immediately. Without it, Secrets Manager keeps the secret in a 30-day recovery window, and a redeploy within that window fails with `"You can't perform this operation on the secret because it was marked for deletion."` (recover with `aws secretsmanager restore-secret --secret-id dev/weatherapi-key`). + +## Troubleshooting + +**`sam build` fails or the Lambda errors on import (e.g. cryptography)** — The build installs Linux manylinux wheels via `src/Makefile` (pip `--platform manylinux2014_x86_64`). Make sure `make` is installed. If a dependency has no manylinux wheel, pip's `--only-binary=:all:` will fail — pin a version that publishes wheels. + +**"Internal Error" on tools/call** — The Gateway execution role needs the `GetResourceApiKey` permissions for the token vault and workload identity (all present in the SAM template's `GatewayExecutionRole`). + +**MCPClientInitializationError ("client session is currently running")** — Do not use `with mcp_client:`. The Strands Agent calls `start()` internally via `load_tools()`. Use `try/finally` with `mcp_client.stop(None, None, None)` instead. + +**AccessDeniedException on bedrock:InvokeModelWithResponseStream** — The Lambda role needs both `bedrock:ConverseStream` and `bedrock:InvokeModelWithResponseStream` (both are in the SAM template). + +**Lambda timeout** — The agentic loop involves multiple model calls and tool executions. Lambda is configured for 120s timeout and 1024MB memory. + +## License + +Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/strands-agentcore-openapi/architecture/target-openapi.png b/strands-agentcore-openapi/architecture/target-openapi.png new file mode 100644 index 0000000000..b538d794d5 Binary files /dev/null and b/strands-agentcore-openapi/architecture/target-openapi.png differ diff --git a/strands-agentcore-openapi/deployment/setup_test_user.py b/strands-agentcore-openapi/deployment/setup_test_user.py new file mode 100755 index 0000000000..8ae8847f07 --- /dev/null +++ b/strands-agentcore-openapi/deployment/setup_test_user.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +""" +Create a test user in Cognito User Pool and obtain JWT token. + +This script creates a test user with a permanent password, authenticates, +and saves the credentials and JWT token for testing. +""" + +import boto3 +import json +import sys +from pathlib import Path +from typing import Dict, Optional +from botocore.exceptions import ClientError + + +def load_stack_outputs() -> Optional[Dict[str, str]]: + """Load CloudFormation stack outputs.""" + outputs_file = Path("deployment/stack_outputs.json") + + if not outputs_file.exists(): + print(f"✗ Stack outputs not found: {outputs_file}") + print(" Run: python deployment/deploy_stack.py") + return None + + try: + with open(outputs_file, 'r') as f: + outputs = json.load(f) + return outputs + except Exception as e: + print(f"✗ Failed to load stack outputs: {e}") + return None + + +def get_user_pool_client_id(cognito_client, user_pool_id: str) -> Optional[str]: + """Get the first user pool client ID.""" + try: + response = cognito_client.list_user_pool_clients( + UserPoolId=user_pool_id, + MaxResults=10 + ) + + if not response.get('UserPoolClients'): + print(" ✗ No user pool clients found") + return None + + client_id = response['UserPoolClients'][0]['ClientId'] + client_name = response['UserPoolClients'][0]['ClientName'] + + print(f" User Pool Client: {client_name}") + print(f" Client ID: {client_id}") + + return client_id + + except ClientError as e: + print(f" ✗ Failed to get client ID: {e}") + return None + + +def create_or_update_user( + cognito_client, + user_pool_id: str, + username: str, + email: str, + password: str +) -> bool: + """Create or update a Cognito user.""" + try: + # Try to create user + print(f"\nCreating user: {username}") + try: + cognito_client.admin_create_user( + UserPoolId=user_pool_id, + Username=username, + UserAttributes=[ + {'Name': 'email', 'Value': email}, + {'Name': 'email_verified', 'Value': 'true'} + ], + TemporaryPassword=password, + MessageAction='SUPPRESS' + ) + print(" ✓ User created") + except ClientError as e: + if e.response['Error']['Code'] == 'UsernameExistsException': + print(" ℹ User already exists - deleting and recreating...") + + # Delete existing user + cognito_client.admin_delete_user( + UserPoolId=user_pool_id, + Username=username + ) + + # Recreate user + cognito_client.admin_create_user( + UserPoolId=user_pool_id, + Username=username, + UserAttributes=[ + {'Name': 'email', 'Value': email}, + {'Name': 'email_verified', 'Value': 'true'} + ], + TemporaryPassword=password, + MessageAction='SUPPRESS' + ) + print(" ✓ User recreated") + else: + raise + + # Set permanent password + print(" Setting permanent password...") + cognito_client.admin_set_user_password( + UserPoolId=user_pool_id, + Username=username, + Password=password, + Permanent=True + ) + print(" ✓ Password set") + + return True + + except ClientError as e: + print(f" ✗ Failed to create user: {e}") + return False + + +def authenticate_user( + cognito_client, + client_id: str, + username: str, + password: str +) -> Optional[Dict[str, str]]: + """Authenticate user and get JWT tokens.""" + try: + print("\nAuthenticating user...") + + response = cognito_client.initiate_auth( + ClientId=client_id, + AuthFlow='USER_PASSWORD_AUTH', + AuthParameters={ + 'USERNAME': username, + 'PASSWORD': password + } + ) + + auth_result = response['AuthenticationResult'] + + tokens = { + 'access_token': auth_result['AccessToken'], + 'id_token': auth_result['IdToken'], + 'refresh_token': auth_result.get('RefreshToken', ''), + 'expires_in': auth_result.get('ExpiresIn', 3600) + } + + print(" ✓ Authentication successful") + print(f" Access Token: {tokens['access_token'][:50]}...") + print(f" ID Token: {tokens['id_token'][:50]}...") + print(f" Expires in: {tokens['expires_in']} seconds") + + return tokens + + except ClientError as e: + print(f" ✗ Authentication failed: {e}") + return None + + +def decode_jwt_token(token: str) -> Optional[Dict]: + """Decode JWT token to show claims (without verification).""" + try: + import base64 + + # Split token into parts + parts = token.split('.') + if len(parts) != 3: + return None + + # Decode payload (add padding if needed) + payload = parts[1] + padding = 4 - len(payload) % 4 + if padding != 4: + payload += '=' * padding + + decoded = base64.urlsafe_b64decode(payload) + claims = json.loads(decoded) + + return claims + + except Exception: + return None + + +def setup_test_user( + username: str = "testuser@example.com", + password: str = "TestPassword123!" +) -> bool: + """Set up test user and save credentials.""" + print("=" * 60) + print("COGNITO TEST USER SETUP") + print("=" * 60) + + # Load stack outputs + print("\nLoading stack outputs...") + outputs = load_stack_outputs() + if not outputs: + return False + + user_pool_id = outputs.get('CognitoUserPoolId') + if not user_pool_id: + print("✗ CognitoUserPoolId not found in stack outputs") + return False + + print(f" User Pool ID: {user_pool_id}") + + # Initialize Cognito client + cognito_client = boto3.client('cognito-idp', region_name='us-east-1') + + # Get client ID + print("\nGetting User Pool Client ID...") + client_id = get_user_pool_client_id(cognito_client, user_pool_id) + if not client_id: + return False + + # Create or update user + email = username # Username is email for this user pool + if not create_or_update_user(cognito_client, user_pool_id, username, email, password): + return False + + # Authenticate and get tokens + tokens = authenticate_user(cognito_client, client_id, username, password) + if not tokens: + return False + + # Decode ID token to show claims + print("\nID Token Claims:") + claims = decode_jwt_token(tokens['id_token']) + if claims: + print(f" sub (user_id): {claims.get('sub', 'N/A')}") + print(f" email: {claims.get('email', 'N/A')}") + print(f" cognito:username: {claims.get('cognito:username', 'N/A')}") + print(f" iss (issuer): {claims.get('iss', 'N/A')}") + print(f" aud (audience): {claims.get('aud', 'N/A')}") + print(f" exp (expires): {claims.get('exp', 'N/A')}") + + # Save credentials + credentials = { + 'user_pool_id': user_pool_id, + 'client_id': client_id, + 'username': username, + 'email': email, + 'password': password, + 'access_token': tokens['access_token'], + 'id_token': tokens['id_token'], + 'refresh_token': tokens['refresh_token'], + 'expires_in': tokens['expires_in'] + } + + creds_file = Path("deployment/test_credentials.json") + with open(creds_file, 'w') as f: + json.dump(credentials, f, indent=2) + + print(f"\n ✓ Credentials saved to: {creds_file}") + + # Also save to root for backward compatibility + root_creds_file = Path("test_credentials.json") + with open(root_creds_file, 'w') as f: + json.dump(credentials, f, indent=2) + + print(f" ✓ Credentials also saved to: {root_creds_file}") + + print("\n" + "=" * 60) + print("✓ TEST USER READY") + print("=" * 60) + print(f"\nUsername: {username}") + print(f"Password: {password}") + print(f"Email: {email}") + print(f"\nCredentials saved to:") + print(f" - {creds_file}") + print(f" - {root_creds_file}") + print("\nYou can now use these credentials to test the Agent Lambda:") + print(" - Use the 'id_token' as the Bearer token in Authorization header") + print(" - The token is valid for ~1 hour") + print("\nNext steps:") + print(" 1. Run integration tests: pytest tests/integration/") + print(" 2. Test manually with the saved credentials") + + return True + + +def main(): + """Main function.""" + import argparse + + parser = argparse.ArgumentParser( + description='Create Cognito test user and obtain JWT token' + ) + parser.add_argument( + '--username', + default='testuser@example.com', + help='Username (email) for test user' + ) + parser.add_argument( + '--password', + default='TestPassword123!', + help='Password for test user' + ) + + args = parser.parse_args() + + success = setup_test_user(args.username, args.password) + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/strands-agentcore-openapi/example-pattern.json b/strands-agentcore-openapi/example-pattern.json new file mode 100644 index 0000000000..af08462c7a --- /dev/null +++ b/strands-agentcore-openapi/example-pattern.json @@ -0,0 +1,90 @@ +{ + "title": "Serverless AI Agent with AgentCore MCP Gateway and OpenAPI Target", + "description": "Strands SDK agent on Lambda uses AgentCore Gateway (MCP) to call WeatherAPI.com via an OpenAPI target with Cognito JWT auth and API key injection.", + "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 weather 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 directly to WeatherAPI.com via an OpenAPI target.", + "The OpenAPI target is defined inline in the SAM template using the WeatherAPI.com OpenAPI schema. The Gateway injects the API key automatically using an API Key Credential Provider backed by AWS Secrets Manager — no Lambda intermediary required.", + "The Strands SDK handles the full agentic loop: tool discovery, Claude tool selection, tool execution via the Gateway, and response formatting — all in a single agent() call.", + "The stack is built and deployed with AWS SAM (sam build + sam deploy). The model used by the agent is configurable via the --model-id flag on the deploy script, which sets the BedrockModelId template parameter." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/strands-agentcore-openapi", + "templateURL": "serverless-patterns/strands-agentcore-openapi", + "projectFolder": "strands-agentcore-openapi", + "templateFile": "template.yaml" + } + }, + "resources": { + "bullets": [ + { + "text": "AWS Serverless Application Model (SAM)", + "link": "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html" + }, + { + "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 OpenAPI Target", + "link": "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-target-openapi.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": "WeatherAPI.com", + "link": "https://www.weatherapi.com/docs/" + } + ] + }, + "deploy": { + "text": [ + "./scripts/deploy.sh --environment-name dev --weather-api-key YOUR_WEATHERAPI_KEY" + ] + }, + "testing": { + "text": [ + "./scripts/test.sh", + "./scripts/test.sh 'What is the weather in London?'", + "./scripts/test.sh 'What is the weather in Paris, France?'", + "./scripts/test.sh 'Is it raining in New York right now?'" + ] + }, + "cleanup": { + "text": [ + "sam delete --stack-name dev-openapi-agent-gateway --region us-east-1" + ] + }, + "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-openapi/mypy.ini b/strands-agentcore-openapi/mypy.ini new file mode 100644 index 0000000000..e59fa66b1f --- /dev/null +++ b/strands-agentcore-openapi/mypy.ini @@ -0,0 +1,27 @@ +[mypy] +python_version = 3.12 +warn_return_any = True +warn_unused_configs = True +disallow_untyped_defs = True +disallow_incomplete_defs = True +check_untyped_defs = True +disallow_untyped_calls = True +disallow_untyped_decorators = False +no_implicit_optional = True +warn_redundant_casts = True +warn_unused_ignores = True +warn_no_return = True +warn_unreachable = True +strict_equality = True + +[mypy-boto3.*] +ignore_missing_imports = True + +[mypy-botocore.*] +ignore_missing_imports = True + +[mypy-moto.*] +ignore_missing_imports = True + +[mypy-hypothesis.*] +ignore_missing_imports = True diff --git a/strands-agentcore-openapi/pytest.ini b/strands-agentcore-openapi/pytest.ini new file mode 100644 index 0000000000..ff7a79a8cf --- /dev/null +++ b/strands-agentcore-openapi/pytest.ini @@ -0,0 +1,18 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --strict-markers + --tb=short + --cov=src + --cov-report=term-missing + --cov-report=html + --cov-fail-under=80 +markers = + unit: Unit tests + property: Property-based tests + integration: Integration tests + slow: Slow running tests diff --git a/strands-agentcore-openapi/requirements.txt b/strands-agentcore-openapi/requirements.txt new file mode 100644 index 0000000000..f423a35d9b --- /dev/null +++ b/strands-agentcore-openapi/requirements.txt @@ -0,0 +1,37 @@ +# Core dependencies +boto3>=1.34.0 +botocore>=1.34.0 + +# Strands Agents SDK (AI agent orchestration) +strands-agents>=1.0.0 + +# MCP client (used by Strands MCPClient for Gateway connection) +mcp>=1.0.0 + +# JWT handling +PyJWT>=2.8.0 +cryptography>=41.0.0 + +# HTTP requests +requests>=2.31.0 + +# Testing +pytest>=7.4.0 +pytest-cov>=4.1.0 +pytest-asyncio>=0.21.0 +hypothesis>=6.92.0 +moto>=4.2.0 + +# Type checking +mypy>=1.7.0 +boto3-stubs[bedrock,bedrock-agentcore,cognito-idp,lambda,logs,cloudwatch]>=1.34.0 + +# Linting +ruff>=0.1.0 + +# OpenAPI +pyyaml>=6.0.1 +jsonschema>=4.20.0 + +# Development +ipython>=8.18.0 diff --git a/strands-agentcore-openapi/scripts/deploy.sh b/strands-agentcore-openapi/scripts/deploy.sh new file mode 100755 index 0000000000..6ae8ce8666 --- /dev/null +++ b/strands-agentcore-openapi/scripts/deploy.sh @@ -0,0 +1,374 @@ +#!/usr/bin/env bash +# ============================================================================= +# OpenAPI Agent Gateway — Deployment Script (AWS SAM) +# +# Creates the API Key credential provider (which cannot be defined in the +# template), then builds and deploys the stack with AWS SAM in a single pass. +# +# Usage: +# ./scripts/deploy.sh \ +# --environment-name dev \ +# --weather-api-key YOUR_WEATHERAPI_KEY \ +# --region us-east-1 \ +# --model-id us.anthropic.claude-sonnet-4-5-20250929-v1:0 +# ============================================================================= +set -e + +# ------------------------------------------------------- +# Defaults +# ------------------------------------------------------- +REGION="us-east-1" +ENVIRONMENT_NAME="" +WEATHER_API_KEY="" +MODEL_ID="us.anthropic.claude-sonnet-4-5-20250929-v1:0" +TEMPLATE_FILE="template.yaml" +CAPABILITIES="CAPABILITY_IAM CAPABILITY_NAMED_IAM CAPABILITY_AUTO_EXPAND" + +# ------------------------------------------------------- +# Parse arguments +# ------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --environment-name) + ENVIRONMENT_NAME="$2" + shift 2 + ;; + --weather-api-key) + WEATHER_API_KEY="$2" + shift 2 + ;; + --region) + REGION="$2" + shift 2 + ;; + --model-id) + MODEL_ID="$2" + shift 2 + ;; + *) + echo "Unknown parameter: $1" + echo "Usage: $0 --environment-name NAME --weather-api-key KEY [--region REGION] [--model-id MODEL_ID]" + exit 1 + ;; + esac +done + +# ------------------------------------------------------- +# Validate required parameters +# ------------------------------------------------------- +if [[ -z "$ENVIRONMENT_NAME" ]]; then + echo "ERROR: --environment-name is required" + exit 1 +fi + +if [[ -z "$WEATHER_API_KEY" ]]; then + echo "ERROR: --weather-api-key is required" + exit 1 +fi + +if ! command -v sam > /dev/null 2>&1; then + echo "ERROR: AWS SAM CLI is not installed." + echo " Install it: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html" + exit 1 +fi + +STACK_NAME="${ENVIRONMENT_NAME}-openapi-agent-gateway" +WEATHER_SECRET_NAME="${ENVIRONMENT_NAME}/weatherapi-key" +LAMBDA_FUNCTION_NAME="${ENVIRONMENT_NAME}-agent-lambda" + +echo "=============================================" +echo " OpenAPI Agent Gateway Deployment (AWS SAM)" +echo "=============================================" +echo " Environment : ${ENVIRONMENT_NAME}" +echo " Region : ${REGION}" +echo " Stack : ${STACK_NAME}" +echo " Model : ${MODEL_ID}" +echo "=============================================" + +get_output() { + aws cloudformation describe-stacks \ + --stack-name "${STACK_NAME}" \ + --region "${REGION}" \ + --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \ + --output text +} + +# ============================================================================= +# Step 1: Create/update Secrets Manager secret for WeatherAPI key +# ============================================================================= +echo "" +echo ">>> Step 1: Creating/updating WeatherAPI key secret..." + +if aws secretsmanager describe-secret --secret-id "${WEATHER_SECRET_NAME}" --region "${REGION}" > /dev/null 2>&1; then + echo " Updating existing secret..." + aws secretsmanager put-secret-value \ + --secret-id "${WEATHER_SECRET_NAME}" \ + --secret-string "${WEATHER_API_KEY}" \ + --region "${REGION}" > /dev/null +else + echo " Creating secret..." + aws secretsmanager create-secret \ + --name "${WEATHER_SECRET_NAME}" \ + --description "WeatherAPI.com API key for ${ENVIRONMENT_NAME}" \ + --secret-string "${WEATHER_API_KEY}" \ + --region "${REGION}" > /dev/null +fi + +WEATHER_SECRET_ARN=$(aws secretsmanager describe-secret \ + --secret-id "${WEATHER_SECRET_NAME}" \ + --region "${REGION}" \ + --query 'ARN' --output text) + +echo " Secret ARN: ${WEATHER_SECRET_ARN}" + +# ============================================================================= +# Step 2: Create/update the API Key credential provider +# +# This is independent of the CloudFormation stack, so we create it first and +# feed its ARN into a single SAM deploy. Requires AWS CLI 2.28+. +# ============================================================================= +echo "" +echo ">>> Step 2: Creating/updating credential provider..." + +CRED_PROVIDER_NAME="${ENVIRONMENT_NAME}-weatherapi-key" +CRED_PROVIDER_ARN="" + +if aws bedrock-agentcore-control list-api-key-credential-providers --region "${REGION}" > /dev/null 2>&1; then + EXISTING_ARN=$(aws bedrock-agentcore-control list-api-key-credential-providers \ + --region "${REGION}" \ + --query "credentialProviders[?name=='${CRED_PROVIDER_NAME}'].credentialProviderArn" \ + --output text 2>/dev/null) + + if [[ -n "${EXISTING_ARN}" && "${EXISTING_ARN}" != "None" ]]; then + echo " Updating existing credential provider..." + if aws bedrock-agentcore-control update-api-key-credential-provider \ + --name "${CRED_PROVIDER_NAME}" \ + --api-key "${WEATHER_API_KEY}" \ + --region "${REGION}" > /dev/null 2>&1; then + CRED_PROVIDER_ARN="${EXISTING_ARN}" + echo " Credential provider updated." + else + echo " WARNING: update failed. Deleting and recreating..." + aws bedrock-agentcore-control delete-api-key-credential-provider \ + --name "${CRED_PROVIDER_NAME}" \ + --region "${REGION}" > /dev/null 2>&1 || true + sleep 3 + fi + fi + + if [[ -z "${CRED_PROVIDER_ARN}" || "${CRED_PROVIDER_ARN}" == "None" ]]; then + echo " Creating credential provider via CLI..." + CRED_PROVIDER_ARN=$(aws bedrock-agentcore-control create-api-key-credential-provider \ + --name "${CRED_PROVIDER_NAME}" \ + --api-key "${WEATHER_API_KEY}" \ + --region "${REGION}" \ + --query 'credentialProviderArn' --output text 2>&1) || { + echo " ERROR: Failed to create credential provider: ${CRED_PROVIDER_ARN}" + CRED_PROVIDER_ARN="" + } + + if [[ -n "${CRED_PROVIDER_ARN}" && "${CRED_PROVIDER_ARN}" != "None" ]]; then + echo " Credential provider created: ${CRED_PROVIDER_ARN}" + fi + fi +fi + +if [[ -z "${CRED_PROVIDER_ARN}" || "${CRED_PROVIDER_ARN}" == "None" ]]; then + CRED_PROVIDER_ARN="" + echo "" + echo " WARNING: Could not create the credential provider automatically." + echo " The stack will deploy WITHOUT the WeatherAPI Gateway Target." + echo " Create it via the Console (Bedrock -> AgentCore -> Identity -> Outbound Auth," + echo " type 'API Key', name '${CRED_PROVIDER_NAME}'), then re-run this script." +fi + +# ============================================================================= +# Step 3: Build with SAM +# ============================================================================= +echo "" +echo ">>> Step 3: Building with SAM..." + +# The Agent function uses a Makefile build (see src/Makefile) that installs +# Linux manylinux wheels via pip's --platform flag — no Docker required. +sam build --template-file "${TEMPLATE_FILE}" + +echo " Build complete." + +# ============================================================================= +# Step 4: Deploy with SAM (single pass) +# ============================================================================= +echo "" +echo ">>> Step 4: Deploying stack '${STACK_NAME}'..." + +PARAM_OVERRIDES=( + "EnvironmentName=${ENVIRONMENT_NAME}" + "WeatherApiKeySecretArn=${WEATHER_SECRET_ARN}" + "BedrockModelId=${MODEL_ID}" +) +# Only pass CredentialProviderArn when we actually have one — SAM rejects an +# empty parameter value, and omitting it lets the template default ('') apply, +# which skips the Gateway Target via the HasCredentialProvider condition. +if [[ -n "${CRED_PROVIDER_ARN}" ]]; then + PARAM_OVERRIDES+=("CredentialProviderArn=${CRED_PROVIDER_ARN}") +fi + +sam deploy \ + --stack-name "${STACK_NAME}" \ + --region "${REGION}" \ + --capabilities ${CAPABILITIES} \ + --resolve-s3 \ + --no-confirm-changeset \ + --no-fail-on-empty-changeset \ + --parameter-overrides "${PARAM_OVERRIDES[@]}" + +echo " Deployment complete." + +GATEWAY_ID=$(get_output "GatewayId") +USER_POOL_ID=$(get_output "CognitoUserPoolId") +USER_POOL_CLIENT_ID=$(get_output "CognitoClientId") +LAMBDA_ARN=$(get_output "AgentLambdaArn") + +echo "" +echo " Stack Outputs:" +echo " Gateway ID : ${GATEWAY_ID}" +echo " User Pool ID : ${USER_POOL_ID}" +echo " Client ID : ${USER_POOL_CLIENT_ID}" +echo " Lambda ARN : ${LAMBDA_ARN}" + +# Save stack outputs for other scripts +mkdir -p deployment +cat > deployment/stack_outputs.json << EOF +{ + "GatewayId": "${GATEWAY_ID}", + "CognitoUserPoolId": "${USER_POOL_ID}", + "CognitoClientId": "${USER_POOL_CLIENT_ID}", + "AgentLambdaArn": "${LAMBDA_ARN}" +} +EOF +echo " Outputs saved to deployment/stack_outputs.json" + +# ============================================================================= +# Step 5: Create test user +# ============================================================================= +echo "" +echo ">>> Step 5: Creating test user..." + +TEST_USERNAME="testuser@example.com" +TEST_PASSWORD="TestPassword123!" + +if aws cognito-idp admin-get-user \ + --user-pool-id "${USER_POOL_ID}" \ + --username "${TEST_USERNAME}" \ + --region "${REGION}" > /dev/null 2>&1; then + echo " Test user '${TEST_USERNAME}' already exists." +else + echo " Creating test user '${TEST_USERNAME}'..." + aws cognito-idp admin-create-user \ + --user-pool-id "${USER_POOL_ID}" \ + --username "${TEST_USERNAME}" \ + --user-attributes Name=email,Value="${TEST_USERNAME}" Name=email_verified,Value=true \ + --temporary-password "TempPass123!" \ + --message-action SUPPRESS \ + --region "${REGION}" > /dev/null + + echo " Setting permanent password..." + aws cognito-idp admin-set-user-password \ + --user-pool-id "${USER_POOL_ID}" \ + --username "${TEST_USERNAME}" \ + --password "${TEST_PASSWORD}" \ + --permanent \ + --region "${REGION}" > /dev/null + + echo " Test user created." +fi + +# ============================================================================= +# Step 6: Generate test script +# ============================================================================= +echo "" +echo ">>> Step 6: Generating test script..." + +TEST_SCRIPT="scripts/test.sh" +mkdir -p scripts + +cat > "${TEST_SCRIPT}" << 'TESTEOF' +#!/usr/bin/env bash +set -e +TESTEOF + +cat >> "${TEST_SCRIPT}" << EOF +CLIENT_ID="${USER_POOL_CLIENT_ID}" +FUNCTION_NAME="${LAMBDA_FUNCTION_NAME}" +REGION="${REGION}" +USERNAME="${TEST_USERNAME}" +PASSWORD="${TEST_PASSWORD}" +EOF + +cat >> "${TEST_SCRIPT}" << 'TESTEOF' + +echo "Getting access token..." +ACCESS_TOKEN=$(aws cognito-idp initiate-auth \ + --auth-flow USER_PASSWORD_AUTH \ + --client-id "${CLIENT_ID}" \ + --auth-parameters USERNAME="${USERNAME}",PASSWORD="${PASSWORD}" \ + --region "${REGION}" \ + --query 'AuthenticationResult.AccessToken' --output text) + +if [[ -z "${ACCESS_TOKEN}" || "${ACCESS_TOKEN}" == "None" ]]; then + echo "ERROR: Failed to get access token" + exit 1 +fi + +echo "Token obtained (${#ACCESS_TOKEN} chars)" + +PROMPT="${1:-What is the weather in London, UK?}" +echo "Invoking agent with: ${PROMPT}" + +PAYLOAD=$(python3 -c " +import json +inner = json.dumps({'prompt': '${PROMPT}'}) +outer = json.dumps({'body': inner, 'headers': {'Authorization': 'Bearer ${ACCESS_TOKEN}'}}) +print(outer) +") + +aws lambda invoke \ + --function-name "${FUNCTION_NAME}" \ + --region "${REGION}" \ + --cli-binary-format raw-in-base64-out \ + --payload "${PAYLOAD}" \ + /tmp/response.json > /dev/null + +echo "" +echo "=== Response ===" +python3 -c " +import json +with open('/tmp/response.json') as f: + resp = json.load(f) +if 'body' in resp: + body = json.loads(resp['body']) + if 'response' in body: + print(body['response']) + elif 'error' in body: + print(f\"ERROR: {body['error']}\") + else: + print(json.dumps(body, indent=2)) +else: + print(json.dumps(resp, indent=2)) +" +TESTEOF + +chmod +x "${TEST_SCRIPT}" + +# ============================================================================= +# Done +# ============================================================================= +echo "" +echo "=============================================" +echo " Deployment Complete!" +echo "=============================================" +echo "" +echo " Test the agent:" +echo "" +echo " ./scripts/test.sh" +echo " ./scripts/test.sh 'What is the weather in Paris, France?'" +echo "" diff --git a/strands-agentcore-openapi/src/Makefile b/strands-agentcore-openapi/src/Makefile new file mode 100644 index 0000000000..4a84096a04 --- /dev/null +++ b/strands-agentcore-openapi/src/Makefile @@ -0,0 +1,19 @@ +# SAM custom build for the Agent Lambda. +# +# The target name must be build- (build-AgentFunction). +# SAM invokes this from the CodeUri directory (src/) and passes ARTIFACTS_DIR, +# the folder it will zip and upload. +# +# We install dependencies as Linux manylinux wheels via pip's --platform flag, +# so the build works on macOS/Windows without Docker. + +build-AgentFunction: + python3 -m pip install \ + -r requirements.txt \ + --platform manylinux2014_x86_64 \ + --python-version 3.12 \ + --implementation cp \ + --only-binary=:all: \ + --target "$(ARTIFACTS_DIR)" + cp -r agent "$(ARTIFACTS_DIR)/agent" + cp -r shared "$(ARTIFACTS_DIR)/shared" diff --git a/strands-agentcore-openapi/src/__init__.py b/strands-agentcore-openapi/src/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/strands-agentcore-openapi/src/agent/__init__.py b/strands-agentcore-openapi/src/agent/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/strands-agentcore-openapi/src/agent/agent_processor.py b/strands-agentcore-openapi/src/agent/agent_processor.py new file mode 100644 index 0000000000..08843ba44e --- /dev/null +++ b/strands-agentcore-openapi/src/agent/agent_processor.py @@ -0,0 +1,127 @@ +"""Agent processor orchestrating AI pipeline via Strands Agents SDK. + +Uses the official strands-agents SDK. The SDK's Agent class handles the +full agentic loop automatically — multi-turn tool use, parallel tool +calls, and response generation are all managed by the SDK. +""" + +import uuid +from typing import Optional, Tuple + +import boto3 + +from shared.logging_utils import StructuredLogger +from .strands_client import create_mcp_client, create_agent + + +class AgentProcessor: + """Orchestrates AI agent processing via Strands Agents SDK + AgentCore Gateway.""" + + def __init__( + self, + gateway_id: str, + model_id: str, + region: str, + logger: StructuredLogger, + ): + self.gateway_id = gateway_id + self.model_id = model_id + self.region = region + self.logger = logger + + # Resolve Gateway MCP URL once + self._gateway_url: Optional[str] = None + + logger.info("Agent processor initialized") + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def process_request( + self, + prompt: str, + jwt_token: str, + session_id: Optional[str] = None, + ) -> Tuple[str, str]: + """Process a user prompt end-to-end. + + 1. Resolve session + 2. Connect to Gateway via MCPClient + 3. Create Strands Agent with MCP tools + 4. Run the agent (SDK handles tool loop) + 5. Return response text + session_id + + Args: + prompt: User's natural language prompt + jwt_token: Cognito JWT for Gateway auth + session_id: Optional conversation session ID + + Returns: + Tuple of (response_text, session_id) + """ + if not session_id: + session_id = str(uuid.uuid4()) + self.logger.info("New conversation started", session_id=session_id) + else: + self.logger.info("Continuing conversation", session_id=session_id) + + gateway_url = self._get_gateway_url() + + # Create MCPClient for this invocation (per Lambda best practice). + # Do NOT use `with mcp_client:` — the Agent's tool registry calls + # load_tools() → start() internally. Starting it beforehand causes + # "the client session is currently running" error. + mcp_client = create_mcp_client(gateway_url, jwt_token) + + try: + agent = create_agent( + model_id=self.model_id, + region=self.region, + mcp_client=mcp_client, + ) + + self.logger.info("Invoking Strands agent", prompt_length=len(prompt)) + + # The SDK handles the full agentic loop: + # prompt → model reasoning → tool selection → tool execution → repeat → final response + result = agent(prompt) + response_text = str(result) + + self.logger.info( + "Agent request processed successfully", + session_id=session_id, + response_length=len(response_text), + ) + + return response_text, session_id + + except Exception as e: + self.logger.error("Agent processing failed", error=str(e)) + raise + finally: + # Clean up MCP connection + try: + mcp_client.stop(None, None, None) + except Exception: + pass + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _get_gateway_url(self) -> str: + """Resolve and cache the Gateway MCP endpoint URL.""" + if self._gateway_url: + return self._gateway_url + + client = boto3.client("bedrock-agentcore-control", region_name=self.region) + response = client.get_gateway(gatewayIdentifier=self.gateway_id) + gateway_url = response.get("gatewayUrl") + + if not gateway_url: + raise RuntimeError("Gateway URL not found in get_gateway response") + + self._gateway_url = gateway_url + self.logger.info("Gateway URL resolved", gateway_url=gateway_url) + return gateway_url diff --git a/strands-agentcore-openapi/src/agent/handler.py b/strands-agentcore-openapi/src/agent/handler.py new file mode 100644 index 0000000000..e63652cee5 --- /dev/null +++ b/strands-agentcore-openapi/src/agent/handler.py @@ -0,0 +1,143 @@ +"""Agent Lambda handler — entry point for AI agent requests. + +Validates Cognito JWT, extracts user context, and delegates to +AgentProcessor which uses the Strands Agents SDK for orchestration. +""" + +import os +import json +import uuid +from typing import Dict, Any + +from shared.models import AgentRequest, AgentResponse, UserContext +from shared.jwt_utils import validate_jwt, extract_user_context +from shared.logging_utils import get_logger, StructuredLogger +from shared.error_utils import ErrorHandler + +from .agent_processor import AgentProcessor + + +# Environment variables +COGNITO_JWKS_URL = os.environ.get("COGNITO_JWKS_URL", "") +GATEWAY_ID = os.environ.get("GATEWAY_ID", "") +BEDROCK_MODEL_ID = os.environ.get( + "BEDROCK_MODEL_ID", "anthropic.claude-3-sonnet-20240229-v1:0" +) +AWS_REGION = os.environ.get("AWS_REGION", "us-east-1") +LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO") + +base_logger = get_logger(__name__, LOG_LEVEL) + + +def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: + """Lambda entry point. + + 1. Extract + validate JWT + 2. Parse prompt / session_id from body + 3. Run AgentProcessor (Strands SDK handles the agentic loop) + 4. Return AgentResponse + """ + request_id = str(uuid.uuid4()) + logger = StructuredLogger(base_logger, request_id) + + try: + # --- JWT extraction --- + headers = event.get("headers", {}) + auth_header = headers.get("Authorization", "") + + if not auth_header or not auth_header.startswith("Bearer "): + logger.warning("Missing or malformed Authorization header") + return ErrorHandler.handle_authentication_error( + ValueError("Missing or invalid Authorization header") + ) + + jwt_token = auth_header[len("Bearer "):].strip() + if not jwt_token: + logger.warning("Empty JWT token") + return ErrorHandler.handle_authentication_error( + ValueError("Empty JWT token") + ) + + # --- JWT validation --- + try: + claims = validate_jwt(jwt_token, COGNITO_JWKS_URL) + logger.info("JWT validated") + except ValueError as e: + logger.warning("JWT validation failed", error=str(e)) + return ErrorHandler.handle_authentication_error(e) + + # --- User context --- + try: + user_context = extract_user_context(claims) + logger.info("User context extracted", user_id=user_context.user_id) + except ValueError as e: + logger.error("User context extraction failed", error=str(e)) + return ErrorHandler.handle_validation_error(e) + + logger.user_context = user_context + + # --- Request body --- + body_str = event.get("body", "{}") + try: + body = json.loads(body_str) if isinstance(body_str, str) else body_str + except json.JSONDecodeError: + return ErrorHandler.handle_validation_error( + ValueError("Invalid JSON in request body") + ) + + prompt = body.get("prompt") + if not prompt: + return ErrorHandler.handle_missing_parameter_error("prompt") + + session_id = body.get("session_id") + + logger.info( + "Agent request received", + prompt_length=len(prompt), + has_session_id=bool(session_id), + ) + + # --- Agent processing (Strands SDK) --- + processor = AgentProcessor( + gateway_id=GATEWAY_ID, + model_id=BEDROCK_MODEL_ID, + region=AWS_REGION, + logger=logger, + ) + + try: + response_text, final_session_id = processor.process_request( + prompt=prompt, + jwt_token=jwt_token, + session_id=session_id, + ) + except Exception as e: + logger.error( + "Agent processing failed", + error_type=type(e).__name__, + error_message=str(e), + ) + return ErrorHandler.handle_generic_error(e) + + # --- Response --- + agent_response = AgentResponse( + response=response_text, + session_id=final_session_id, + user_context=user_context, + ) + + logger.info( + "Agent request completed", + session_id=final_session_id, + response_length=len(response_text), + ) + + return agent_response.to_lambda_response() + + except Exception as e: + logger.error( + "Unexpected error", + error_type=type(e).__name__, + error_message=str(e), + ) + return ErrorHandler.handle_generic_error(e) diff --git a/strands-agentcore-openapi/src/agent/strands_client.py b/strands-agentcore-openapi/src/agent/strands_client.py new file mode 100644 index 0000000000..669c29d0f6 --- /dev/null +++ b/strands-agentcore-openapi/src/agent/strands_client.py @@ -0,0 +1,77 @@ +"""Strands Agents SDK client for AI agent orchestration. + +Uses the official strands-agents SDK (pip install strands-agents) with +MCPClient to connect to the AgentCore Gateway MCP endpoint. The SDK +handles the full agentic loop automatically: reasoning, tool selection, +tool execution, and response generation. + +References: +- https://strandsagents.com/docs/user-guide/concepts/agents/agent-loop/ +- https://strandsagents.com/docs/user-guide/concepts/tools/mcp-tools/ +- https://strandsagents.com/docs/user-guide/deploy/deploy_to_aws_lambda/ +""" + +from typing import Optional + +from strands import Agent +from strands.models.bedrock import BedrockModel +from strands.tools.mcp import MCPClient +from mcp.client.streamable_http import streamablehttp_client + +from shared.logging_utils import StructuredLogger + + +SYSTEM_PROMPT = """You are a helpful AI assistant that can interact with APIs to help users. +When retrieving information, use the available tools to fetch real data. +Format responses in a clear, human-readable way. +If a tool call fails, explain the error and suggest alternatives.""" + + +def create_mcp_client(gateway_url: str, jwt_token: str) -> MCPClient: + """Create an MCPClient connected to the AgentCore Gateway MCP endpoint. + + Args: + gateway_url: Full Gateway MCP endpoint URL + jwt_token: JWT token for authorization + + Returns: + MCPClient configured for the Gateway + """ + return MCPClient( + lambda: streamablehttp_client( + url=gateway_url, + headers={"Authorization": f"Bearer {jwt_token}"} + ) + ) + + +def create_agent( + model_id: str, + region: str, + mcp_client: MCPClient, + system_prompt: Optional[str] = None, + max_tokens: int = 4096, +) -> Agent: + """Create a Strands Agent with Bedrock model and MCP tools. + + Args: + model_id: Bedrock model ID + region: AWS region + mcp_client: MCPClient connected to Gateway + system_prompt: Optional system prompt override + max_tokens: Maximum tokens in response + + Returns: + Configured Strands Agent + """ + model = BedrockModel( + model_id=model_id, + region_name=region, + max_tokens=max_tokens, + ) + + return Agent( + model=model, + tools=[mcp_client], + system_prompt=system_prompt or SYSTEM_PROMPT, + ) diff --git a/strands-agentcore-openapi/src/openapi_parser/__init__.py b/strands-agentcore-openapi/src/openapi_parser/__init__.py new file mode 100644 index 0000000000..0a229531dd --- /dev/null +++ b/strands-agentcore-openapi/src/openapi_parser/__init__.py @@ -0,0 +1,15 @@ +"""OpenAPI parser module for converting OpenAPI specifications to tool definitions.""" + +from src.openapi_parser.parser import ( + parse_openapi_spec, + extract_operation_tool, + convert_to_json_schema, + OpenAPIParseError +) + +__all__ = [ + 'parse_openapi_spec', + 'extract_operation_tool', + 'convert_to_json_schema', + 'OpenAPIParseError' +] diff --git a/strands-agentcore-openapi/src/openapi_parser/parser.py b/strands-agentcore-openapi/src/openapi_parser/parser.py new file mode 100644 index 0000000000..62a03231ac --- /dev/null +++ b/strands-agentcore-openapi/src/openapi_parser/parser.py @@ -0,0 +1,325 @@ +"""OpenAPI 3.x specification parser for tool definition generation.""" + +from typing import List, Dict, Any, Optional +from src.shared.models import ToolDefinition + + +class OpenAPIParseError(Exception): + """Exception raised when OpenAPI specification parsing fails.""" + pass + + +def parse_openapi_spec(spec_dict: dict) -> List[ToolDefinition]: + """Parse OpenAPI 3.x specification and extract tool definitions. + + Args: + spec_dict: OpenAPI specification as dictionary + + Returns: + List of ToolDefinition objects, one per operation + + Raises: + OpenAPIParseError: If specification is invalid or unsupported + """ + # Validate OpenAPI version + openapi_version = spec_dict.get('openapi', '') + if not openapi_version: + raise OpenAPIParseError("Missing 'openapi' field in specification") + + # Check version is 3.0.x or 3.1.x + if not (openapi_version.startswith('3.0.') or openapi_version.startswith('3.1.')): + raise OpenAPIParseError( + f"Unsupported OpenAPI version: {openapi_version}. " + "Only OpenAPI 3.0.x and 3.1.x are supported." + ) + + # Extract paths + paths = spec_dict.get('paths', {}) + if not isinstance(paths, dict): + raise OpenAPIParseError("Missing or empty 'paths' field in specification") + + # Extract components for schema references + components = spec_dict.get('components', {}) + + # Extract global security requirements + global_security = spec_dict.get('security', []) + + # Parse all operations + tools = [] + for path, path_item in paths.items(): + if not isinstance(path_item, dict): + continue + + # Iterate through HTTP methods + for method in ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']: + operation = path_item.get(method) + if operation and isinstance(operation, dict): + try: + tool = extract_operation_tool( + path=path, + method=method, + operation=operation, + components=components, + global_security=global_security + ) + tools.append(tool) + except Exception as e: + raise OpenAPIParseError( + f"Error parsing operation {method.upper()} {path}: {str(e)}" + ) + + if not tools: + raise OpenAPIParseError("No valid operations found in specification") + + return tools + + +def extract_operation_tool( + path: str, + method: str, + operation: dict, + components: dict = None, + global_security: List[dict] = None +) -> ToolDefinition: + """Convert OpenAPI operation to ToolDefinition. + + Args: + path: API path (e.g., "/weather") + method: HTTP method (e.g., "get") + operation: OpenAPI operation object + components: OpenAPI components for schema references + global_security: Global security requirements + + Returns: + ToolDefinition for the operation + """ + components = components or {} + global_security = global_security or [] + + # Extract or generate tool name + operation_id = operation.get('operationId') + if operation_id: + tool_name = operation_id + else: + # Generate from method + path + # Convert /weather/{location} to get_weather_location + path_parts = path.strip('/').replace('{', '').replace('}', '').replace('/', '_') + tool_name = f"{method}_{path_parts}" if path_parts else method + + # Extract description from summary or description + description = operation.get('summary', operation.get('description', f'{method.upper()} {path}')) + + # Convert parameters to input schema + parameters = operation.get('parameters', []) + request_body = operation.get('requestBody') + input_schema = _build_input_schema(parameters, request_body, components) + + # Convert responses to output schema + responses = operation.get('responses', {}) + output_schema = _build_output_schema(responses, components) + + # Extract security requirements (operation-level overrides global) + security = operation.get('security', global_security) + + return ToolDefinition( + name=tool_name, + description=description, + input_schema=input_schema, + output_schema=output_schema, + security=security + ) + + +def _build_input_schema( + parameters: List[dict], + request_body: Optional[dict], + components: dict +) -> dict: + """Build JSON Schema for tool input from parameters and requestBody. + + Args: + parameters: List of OpenAPI parameter objects + request_body: OpenAPI requestBody object + components: OpenAPI components for schema references + + Returns: + JSON Schema for input + """ + properties = {} + required = [] + + # Process parameters + for param in parameters: + param_name = param.get('name') + if not param_name: + continue + + param_schema = param.get('schema', {}) + param_description = param.get('description', '') + + # Convert OpenAPI schema to JSON Schema + properties[param_name] = convert_to_json_schema(param_schema, components) + if param_description: + properties[param_name]['description'] = param_description + + # Track required parameters + if param.get('required', False): + required.append(param_name) + + # Process requestBody + if request_body: + content = request_body.get('content', {}) + # Try to get JSON content type + for content_type in ['application/json', '*/*']: + if content_type in content: + media_type = content[content_type] + body_schema = media_type.get('schema', {}) + + # Convert schema + converted_schema = convert_to_json_schema(body_schema, components) + + # If schema has properties, merge them + if 'properties' in converted_schema: + properties.update(converted_schema['properties']) + if 'required' in converted_schema: + required.extend(converted_schema['required']) + else: + # Otherwise, add as 'body' property + properties['body'] = converted_schema + if request_body.get('required', False): + required.append('body') + break + + # Build final schema + input_schema = { + 'type': 'object', + 'properties': properties + } + + if required: + input_schema['required'] = required + + return input_schema + + +def _build_output_schema(responses: dict, components: dict) -> dict: + """Build JSON Schema for tool output from responses. + + Args: + responses: OpenAPI responses object + components: OpenAPI components for schema references + + Returns: + JSON Schema for output + """ + # Try to get successful response (200, 201, or default) + for status_code in ['200', '201', 'default']: + if status_code in responses: + response = responses[status_code] + content = response.get('content', {}) + + # Try to get JSON content type + for content_type in ['application/json', '*/*']: + if content_type in content: + media_type = content[content_type] + schema = media_type.get('schema', {}) + return convert_to_json_schema(schema, components) + + # No schema found, return generic object + return {'type': 'object'} + + +def convert_to_json_schema(openapi_schema: dict, components: dict = None) -> dict: + """Convert OpenAPI schema to JSON Schema format. + + Handles OpenAPI-specific keywords like nullable, discriminator, and $ref. + + Args: + openapi_schema: OpenAPI schema object + components: OpenAPI components for resolving $ref + + Returns: + JSON Schema compatible with Claude + """ + components = components or {} + + # Handle $ref + if '$ref' in openapi_schema: + ref_path = openapi_schema['$ref'] + resolved_schema = _resolve_ref(ref_path, components) + return convert_to_json_schema(resolved_schema, components) + + # Start with a copy of the schema + json_schema = {} + + # Copy basic fields + for field in ['type', 'format', 'description', 'default', 'example', + 'minimum', 'maximum', 'minLength', 'maxLength', + 'pattern', 'enum', 'items', 'additionalProperties']: + if field in openapi_schema: + json_schema[field] = openapi_schema[field] + + # Handle properties (for object types) + if 'properties' in openapi_schema: + json_schema['properties'] = {} + for prop_name, prop_schema in openapi_schema['properties'].items(): + json_schema['properties'][prop_name] = convert_to_json_schema(prop_schema, components) + + # Handle required + if 'required' in openapi_schema: + json_schema['required'] = openapi_schema['required'] + + # Handle allOf, oneOf, anyOf + for keyword in ['allOf', 'oneOf', 'anyOf']: + if keyword in openapi_schema: + json_schema[keyword] = [ + convert_to_json_schema(schema, components) + for schema in openapi_schema[keyword] + ] + + # Handle nullable (OpenAPI 3.0.x specific) + if openapi_schema.get('nullable', False): + # Convert to oneOf with null type + if 'type' in json_schema: + original_type = json_schema.pop('type') + json_schema['oneOf'] = [ + {'type': original_type}, + {'type': 'null'} + ] + + # Handle items for arrays + if 'items' in json_schema and isinstance(json_schema['items'], dict): + json_schema['items'] = convert_to_json_schema(json_schema['items'], components) + + return json_schema + + +def _resolve_ref(ref_path: str, components: dict) -> dict: + """Resolve $ref to actual schema from components. + + Args: + ref_path: Reference path (e.g., "#/components/schemas/Weather") + components: OpenAPI components object + + Returns: + Resolved schema + + Raises: + OpenAPIParseError: If reference cannot be resolved + """ + if not ref_path.startswith('#/'): + raise OpenAPIParseError(f"External references not supported: {ref_path}") + + # Parse path like "#/components/schemas/Weather" + parts = ref_path[2:].split('/') # Remove "#/" and split + + # Start from the root spec, not just components + # The components dict passed in is actually the full components section + current = {'components': components} if components else {} + + for part in parts: + if not isinstance(current, dict) or part not in current: + raise OpenAPIParseError(f"Cannot resolve reference: {ref_path}") + current = current[part] + + return current diff --git a/strands-agentcore-openapi/src/requirements.txt b/strands-agentcore-openapi/src/requirements.txt new file mode 100644 index 0000000000..173620d9a6 --- /dev/null +++ b/strands-agentcore-openapi/src/requirements.txt @@ -0,0 +1,22 @@ +# Runtime dependencies for the Agent Lambda (used by `sam build`). +# Keep this lean — only what the function needs at runtime. Dev/test tooling +# lives in the root requirements.txt. + +# AWS SDK — include explicitly so Strands has a recent enough boto3/botocore +boto3>=1.34.0 +botocore>=1.34.0 + +# Strands Agents SDK (AI agent orchestration) +strands-agents>=1.0.0 + +# MCP client (used by Strands MCPClient for the Gateway connection) +mcp>=1.0.0 + +# JWT validation of Cognito tokens +PyJWT>=2.8.0 +cryptography>=41.0.0 + +# HTTP + schema handling +requests>=2.31.0 +pyyaml>=6.0.1 +jsonschema>=4.20.0 diff --git a/strands-agentcore-openapi/src/shared/__init__.py b/strands-agentcore-openapi/src/shared/__init__.py new file mode 100644 index 0000000000..5682663553 --- /dev/null +++ b/strands-agentcore-openapi/src/shared/__init__.py @@ -0,0 +1,74 @@ +"""Shared utilities and data models for OpenAPI Agent Gateway.""" + +from .models import ( + UserContext, + AgentRequest, + AgentResponse, + ToolDefinition, + WeatherData, + DailyForecast, + ForecastData, + ToolRequest, + ToolResponse, + ConversationTurn, + ConversationContext, +) + +from .logging_utils import ( + get_logger, + log_with_user_context, + sanitize_log_data, + StructuredLogger, +) + +from .error_utils import ( + format_error_response, + get_user_friendly_message, + is_transient_error, + retry_with_backoff, + timeout_wrapper, + ErrorHandler, + TimeoutError, + TransientError, +) + +from .jwt_utils import ( + validate_jwt, + extract_user_context, + decode_jwt_payload, + get_jwks, +) + +__all__ = [ + # Models + 'UserContext', + 'AgentRequest', + 'AgentResponse', + 'ToolDefinition', + 'WeatherData', + 'DailyForecast', + 'ForecastData', + 'ToolRequest', + 'ToolResponse', + 'ConversationTurn', + 'ConversationContext', + # Logging + 'get_logger', + 'log_with_user_context', + 'sanitize_log_data', + 'StructuredLogger', + # Error handling + 'format_error_response', + 'get_user_friendly_message', + 'is_transient_error', + 'retry_with_backoff', + 'timeout_wrapper', + 'ErrorHandler', + 'TimeoutError', + 'TransientError', + # JWT + 'validate_jwt', + 'extract_user_context', + 'decode_jwt_payload', + 'get_jwks', +] diff --git a/strands-agentcore-openapi/src/shared/error_utils.py b/strands-agentcore-openapi/src/shared/error_utils.py new file mode 100644 index 0000000000..6ce2f906ad --- /dev/null +++ b/strands-agentcore-openapi/src/shared/error_utils.py @@ -0,0 +1,303 @@ +"""Error handling utilities with retry logic, timeout management, and HTTP status codes.""" + +import time +import json +from typing import Callable, Any, Optional, Dict +from functools import wraps +import signal + + +class TimeoutError(Exception): + """Raised when an operation times out.""" + pass + + +class TransientError(Exception): + """Raised for transient errors that should be retried.""" + pass + + +def format_error_response( + status_code: int, + error_message: str, + error_code: Optional[str] = None +) -> dict: + """Format error response for Lambda with appropriate HTTP status codes. + + This function ensures appropriate HTTP status codes are returned: + - 400 for client errors (invalid input, missing parameters) + - 401 for authentication errors (invalid JWT, missing JWT) + - 500 for server errors (internal failures, service unavailable) + + Validates Requirement 8.10. + + Args: + status_code: HTTP status code (400, 401, 500, etc.) + error_message: User-friendly error message + error_code: Optional error code for categorization + + Returns: + Lambda response dictionary + """ + body = {'error': error_message} + if error_code: + body['error_code'] = error_code + + return { + 'statusCode': status_code, + 'body': json.dumps(body) + } + + +def get_user_friendly_message(error_code: str) -> str: + """Get user-friendly error message for AWS error codes. + + Args: + error_code: AWS error code + + Returns: + User-friendly error message + """ + error_messages = { + 'AccessDenied': 'You do not have permission to perform this operation.', + 'AccessDeniedException': 'You do not have permission to perform this operation.', + 'Throttling': 'The service is temporarily busy. Please try again.', + 'ThrottlingException': 'The service is temporarily busy. Please try again.', + 'ServiceUnavailable': 'The service is temporarily unavailable. Please try again.', + 'InternalError': 'An internal error occurred. Please try again.', + 'InvalidParameterValue': 'Invalid parameter provided.', + 'ResourceNotFoundException': 'The requested resource was not found.', + 'ValidationException': 'Invalid request parameters.', + 'RequestTimeout': 'The request timed out. Please try again.', + 'NetworkingError': 'Network connection error. Please try again.', + } + + return error_messages.get( + error_code, + 'An error occurred while processing your request. Please try again.' + ) + + +def is_transient_error(error_code: str) -> bool: + """Check if an error code represents a transient error. + + Args: + error_code: AWS error code + + Returns: + True if error is transient and should be retried + """ + transient_codes = { + 'Throttling', + 'ThrottlingException', + 'ServiceUnavailable', + 'InternalError', + 'RequestTimeout', + 'NetworkingError', + 'TooManyRequestsException', + 'ProvisionedThroughputExceededException', + } + + return error_code in transient_codes + + +def retry_with_backoff( + func: Callable, + max_attempts: int = 3, + initial_delay: float = 1.0, + backoff_factor: float = 2.0, + max_delay: float = 10.0 +) -> Any: + """Retry function with exponential backoff. + + Args: + func: Function to retry + max_attempts: Maximum number of retry attempts + initial_delay: Initial delay in seconds + backoff_factor: Multiplier for delay after each attempt + max_delay: Maximum delay between retries + + Returns: + Function result + + Raises: + Exception: If all retry attempts fail + """ + delay = initial_delay + last_exception = None + + for attempt in range(max_attempts): + try: + return func() + except Exception as e: + last_exception = e + + # Check if error is transient + error_code = getattr(e, 'response', {}).get('Error', {}).get('Code', '') + if not is_transient_error(error_code) and attempt == 0: + # Non-transient error, don't retry + raise + + if attempt < max_attempts - 1: + # Calculate delay with exponential backoff + wait_time = min(delay, max_delay) + time.sleep(wait_time) + delay *= backoff_factor + else: + # Last attempt failed + raise last_exception + + raise last_exception + + +def timeout_wrapper(timeout_seconds: int): + """Decorator to add timeout to function execution. + + Args: + timeout_seconds: Timeout in seconds + + Returns: + Decorated function + + Raises: + TimeoutError: If function execution exceeds timeout + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + def timeout_handler(signum, frame): + raise TimeoutError(f"Function {func.__name__} timed out after {timeout_seconds} seconds") + + # Set up signal handler + old_handler = signal.signal(signal.SIGALRM, timeout_handler) + signal.alarm(timeout_seconds) + + try: + result = func(*args, **kwargs) + finally: + # Restore old handler and cancel alarm + signal.alarm(0) + signal.signal(signal.SIGALRM, old_handler) + + return result + + return wrapper + + return decorator + + +class ErrorHandler: + """Centralized error handler for Lambda functions with appropriate HTTP status codes. + + This class ensures appropriate HTTP status codes are returned (Requirement 8.10): + - 400 for client errors (invalid input, missing parameters) + - 401 for authentication errors (invalid JWT, missing JWT) + - 500 for server errors (internal failures, service unavailable) + """ + + @staticmethod + def handle_authentication_error(error: Exception) -> dict: + """Handle authentication errors with 401 status code. + + Returns 401 for authentication errors (Requirement 8.10). + + Args: + error: Authentication error + + Returns: + Lambda error response with 401 status + """ + # Return generic message to avoid exposing authentication details + return format_error_response( + 401, + 'Invalid credentials', + 'AuthenticationError' + ) + + @staticmethod + def handle_aws_error(error: Exception) -> dict: + """Handle AWS service errors with appropriate status codes. + + Maps AWS errors to appropriate HTTP status codes (Requirement 8.10): + - 400 for validation errors + - 401 for authentication errors + - 403 for authorization errors + - 404 for not found errors + - 429 for throttling errors + - 500 for internal/service errors + + Args: + error: AWS ClientError + + Returns: + Lambda error response with appropriate status code + """ + error_code = getattr(error, 'response', {}).get('Error', {}).get('Code', 'Unknown') + message = get_user_friendly_message(error_code) + + # Map AWS errors to appropriate HTTP status codes (Requirement 8.10) + status_code = 500 # Default to server error + if error_code in ['ValidationException', 'InvalidParameterValue']: + status_code = 400 # Client error + elif error_code in ['AccessDenied', 'AccessDeniedException']: + status_code = 403 # Forbidden + elif error_code in ['ResourceNotFoundException']: + status_code = 404 # Not found + elif error_code in ['Throttling', 'ThrottlingException', 'TooManyRequestsException']: + status_code = 429 # Too many requests + + return format_error_response(status_code, message, error_code) + + @staticmethod + def handle_validation_error(error: Exception) -> dict: + """Handle validation errors with 400 status code. + + Returns 400 for client errors (Requirement 8.10). + + Args: + error: Validation error + + Returns: + Lambda error response with 400 status + """ + return format_error_response( + 400, + str(error), + 'ValidationError' + ) + + @staticmethod + def handle_missing_parameter_error(parameter_name: str) -> dict: + """Handle missing parameter errors with 400 status code. + + Returns 400 for client errors (Requirement 8.10). + + Args: + parameter_name: Name of the missing parameter + + Returns: + Lambda error response with 400 status + """ + return format_error_response( + 400, + f'Missing required parameter: {parameter_name}', + 'MissingParameterError' + ) + + @staticmethod + def handle_generic_error(error: Exception) -> dict: + """Handle generic errors with 500 status code. + + Returns 500 for server errors (Requirement 8.10). + + Args: + error: Generic error + + Returns: + Lambda error response with 500 status + """ + return format_error_response( + 500, + 'An unexpected error occurred. Please try again.', + 'InternalError' + ) diff --git a/strands-agentcore-openapi/src/shared/jwt_utils.py b/strands-agentcore-openapi/src/shared/jwt_utils.py new file mode 100644 index 0000000000..10efc2ce55 --- /dev/null +++ b/strands-agentcore-openapi/src/shared/jwt_utils.py @@ -0,0 +1,150 @@ +"""JWT validation and user context extraction utilities.""" + +import json +import time +from typing import Dict, Optional +from functools import lru_cache + +import jwt +import requests +from jwt import PyJWK + +from .models import UserContext + + +@lru_cache(maxsize=1) +def _get_jwks_with_cache(jwks_url: str, cache_time: int) -> tuple: + """Fetch and cache JWKS keys. + + Args: + jwks_url: Cognito JWKS URL + cache_time: Cache timestamp for TTL management + + Returns: + Tuple of (jwks, cache_time) + """ + response = requests.get(jwks_url, timeout=5) + response.raise_for_status() + jwks = response.json() + return jwks, cache_time + + +def get_jwks(jwks_url: str, ttl: int = 3600) -> dict: + """Get JWKS with caching and TTL. + + Args: + jwks_url: Cognito JWKS URL + ttl: Time-to-live in seconds (default 1 hour) + + Returns: + JWKS dictionary + """ + current_time = int(time.time()) + cache_time = current_time - (current_time % ttl) + + jwks, _ = _get_jwks_with_cache(jwks_url, cache_time) + return jwks + + +def validate_jwt(token: str, jwks_url: str) -> dict: + """Validate JWT token using JWKS from Cognito. + + Args: + token: JWT access token + jwks_url: Cognito JWKS URL + + Returns: + Decoded JWT claims + + Raises: + ValueError: If token is invalid, expired, or malformed + """ + try: + # Fetch JWKS + jwks = get_jwks(jwks_url) + + # Get token header + unverified_header = jwt.get_unverified_header(token) + kid = unverified_header.get('kid') + + if not kid: + raise ValueError("Token missing 'kid' in header") + + # Find matching key + key = next((k for k in jwks['keys'] if k['kid'] == kid), None) + if not key: + raise ValueError("Key not found in JWKS") + + # Construct public key using PyJWK + public_key = PyJWK.from_dict(key).key + + # Validate token + claims = jwt.decode( + token, + public_key, + algorithms=['RS256'], + options={'verify_exp': True} + ) + + # Verify token type (must be access token) + if claims.get('token_use') != 'access': + raise ValueError("Must use access token, not ID token") + + return claims + + except jwt.ExpiredSignatureError: + raise ValueError("Token has expired") + except jwt.InvalidTokenError as e: + raise ValueError(f"Invalid token: {e}") + except requests.RequestException as e: + raise ValueError(f"Failed to fetch JWKS: {e}") + except Exception as e: + raise ValueError(f"Token validation failed: {e}") + + +def extract_user_context(claims: dict) -> UserContext: + """Extract user context from JWT claims. + + Args: + claims: Decoded JWT claims + + Returns: + UserContext object + + Raises: + ValueError: If required claims are missing + """ + required_claims = ['sub', 'username', 'client_id'] + missing = [c for c in required_claims if c not in claims] + + if missing: + raise ValueError(f"Missing required claims: {missing}") + + return UserContext( + user_id=claims['sub'], + username=claims['username'], + client_id=claims['client_id'] + ) + + +def decode_jwt_payload(token: str) -> dict: + """Decode JWT payload without verification (for Interceptor use). + + This is used by the Gateway Request Interceptor to extract user claims + without full validation, since the Gateway validates the token independently. + + Args: + token: JWT token string + + Returns: + Decoded JWT payload + + Raises: + ValueError: If token is malformed + """ + try: + # Decode without verification + claims = jwt.decode(token, options={"verify_signature": False}) + return claims + except Exception as e: + raise ValueError(f"Failed to decode JWT payload: {e}") diff --git a/strands-agentcore-openapi/src/shared/logging_utils.py b/strands-agentcore-openapi/src/shared/logging_utils.py new file mode 100644 index 0000000000..edb76ed3c8 --- /dev/null +++ b/strands-agentcore-openapi/src/shared/logging_utils.py @@ -0,0 +1,177 @@ +"""Logging utilities with user context, request ID correlation, and security.""" + +import logging +import json +import re +from typing import Optional, Dict, Any +from datetime import datetime + +from .models import UserContext + + +# Patterns for sensitive data that should not be logged +SENSITIVE_PATTERNS = [ + r'Bearer\s+[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+', # JWT tokens + r'password["\']?\s*[:=]\s*["\']?[^"\'}\s]+', # Passwords + r'secret["\']?\s*[:=]\s*["\']?[^"\'}\s]+', # Secrets + r'api[_-]?key["\']?\s*[:=]\s*["\']?[^"\'}\s]+', # API keys +] + + +def sanitize_log_data(data: Any) -> Any: + """Remove sensitive information from log data. + + Args: + data: Data to sanitize (string, dict, list, etc.) + + Returns: + Sanitized data + """ + if isinstance(data, str): + sanitized = data + for pattern in SENSITIVE_PATTERNS: + sanitized = re.sub(pattern, '[REDACTED]', sanitized, flags=re.IGNORECASE) + return sanitized + + elif isinstance(data, dict): + return {k: sanitize_log_data(v) for k, v in data.items()} + + elif isinstance(data, list): + return [sanitize_log_data(item) for item in data] + + return data + + +def get_logger(name: str, level: str = 'INFO') -> logging.Logger: + """Get configured logger with structured formatting. + + Args: + name: Logger name (typically __name__) + level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + + Returns: + Configured logger instance + """ + logger = logging.getLogger(name) + logger.setLevel(getattr(logging, level.upper())) + + # Only add handler if not already configured + if not logger.handlers: + handler = logging.StreamHandler() + handler.setLevel(getattr(logging, level.upper())) + + # Use JSON formatter for structured logging + formatter = logging.Formatter( + '{"timestamp": "%(asctime)s", "level": "%(levelname)s", ' + '"logger": "%(name)s", "message": "%(message)s"}' + ) + handler.setFormatter(formatter) + logger.addHandler(handler) + + return logger + + +def log_with_user_context( + logger: logging.Logger, + level: str, + message: str, + user_context: Optional[UserContext] = None, + request_id: Optional[str] = None, + **extra_fields +) -> None: + """Log message with user context, request ID, and additional fields. + + This function ensures request_id is always included in log messages for + correlation across services (Requirement 8.9). + + Args: + logger: Logger instance + level: Log level (info, warning, error, etc.) + message: Log message + user_context: User context for attribution + request_id: Request ID for tracing (REQUIRED for correlation) + **extra_fields: Additional fields to include in log + """ + log_data = { + 'timestamp': datetime.utcnow().isoformat(), + 'message': message + } + + # Always include request_id for correlation (Requirement 8.9) + if request_id: + log_data['request_id'] = request_id + else: + # Log warning if request_id is missing + log_data['request_id'] = 'MISSING' + log_data['warning'] = 'request_id not provided for log correlation' + + if user_context: + log_data['user_id'] = user_context.user_id + log_data['username'] = user_context.username + log_data['client_id'] = user_context.client_id + + # Add extra fields + log_data.update(extra_fields) + + # Sanitize before logging + sanitized_data = sanitize_log_data(log_data) + + # Log as JSON + log_method = getattr(logger, level.lower()) + log_method(json.dumps(sanitized_data)) + + +class StructuredLogger: + """Structured logger with automatic user context and request ID inclusion. + + This logger ensures request_id is included in all log messages for + correlation across services (Requirement 8.9). + """ + + def __init__( + self, + logger: logging.Logger, + request_id: str, + user_context: Optional[UserContext] = None + ): + """Initialize structured logger. + + Args: + logger: Base logger instance + request_id: Request ID to include in all logs (REQUIRED) + user_context: User context to include in all logs + """ + self.logger = logger + self.request_id = request_id + self.user_context = user_context + + def _log(self, level: str, message: str, **extra_fields) -> None: + """Internal log method that ensures request_id is always included.""" + log_with_user_context( + self.logger, + level, + message, + self.user_context, + self.request_id, + **extra_fields + ) + + def debug(self, message: str, **extra_fields) -> None: + """Log debug message with request_id.""" + self._log('debug', message, **extra_fields) + + def info(self, message: str, **extra_fields) -> None: + """Log info message with request_id.""" + self._log('info', message, **extra_fields) + + def warning(self, message: str, **extra_fields) -> None: + """Log warning message with request_id.""" + self._log('warning', message, **extra_fields) + + def error(self, message: str, **extra_fields) -> None: + """Log error message with request_id.""" + self._log('error', message, **extra_fields) + + def critical(self, message: str, **extra_fields) -> None: + """Log critical message with request_id.""" + self._log('critical', message, **extra_fields) diff --git a/strands-agentcore-openapi/src/shared/models.py b/strands-agentcore-openapi/src/shared/models.py new file mode 100644 index 0000000000..9e29c377b4 --- /dev/null +++ b/strands-agentcore-openapi/src/shared/models.py @@ -0,0 +1,367 @@ +"""Data models for OpenAPI Agent Gateway.""" + +import json +from dataclasses import dataclass, field +from typing import Optional, List, Dict, Any +from datetime import datetime + + +@dataclass +class UserContext: + """User identity information extracted from JWT token.""" + user_id: str + username: str + client_id: str + + def to_dict(self) -> dict: + """Convert to dictionary representation.""" + return { + 'user_id': self.user_id, + 'username': self.username, + 'client_id': self.client_id + } + + @classmethod + def from_jwt_claims(cls, claims: dict) -> 'UserContext': + """Create UserContext from JWT claims. + + Args: + claims: Decoded JWT claims containing sub, username, client_id + + Returns: + UserContext instance + """ + return cls( + user_id=claims['sub'], + username=claims['username'], + client_id=claims['client_id'] + ) + + @classmethod + def from_dict(cls, data: dict) -> 'UserContext': + """Create UserContext from dictionary. + + Args: + data: Dictionary with user_id, username, client_id + + Returns: + UserContext instance + """ + return cls( + user_id=data.get('user_id', 'unknown'), + username=data.get('username', 'unknown'), + client_id=data.get('client_id', 'unknown') + ) + + +@dataclass +class AgentRequest: + """Request to Agent Lambda with authentication.""" + prompt: str + jwt_token: str + session_id: Optional[str] = None + + @classmethod + def from_event(cls, event: dict) -> 'AgentRequest': + """Parse AgentRequest from Lambda event. + + Args: + event: Lambda event with headers and body + + Returns: + AgentRequest instance + """ + headers = event.get('headers', {}) + body_str = event.get('body', '{}') + body = json.loads(body_str) if isinstance(body_str, str) else body_str + + auth_header = headers.get('Authorization', '') + jwt_token = auth_header.replace('Bearer ', '') + + return cls( + prompt=body['prompt'], + jwt_token=jwt_token, + session_id=body.get('session_id') + ) + + +@dataclass +class AgentResponse: + """Response from Agent Lambda.""" + response: str + session_id: str + user_context: UserContext + + def to_lambda_response(self) -> dict: + """Convert to Lambda response format. + + Returns: + Lambda response dictionary + """ + return { + 'statusCode': 200, + 'body': json.dumps({ + 'response': self.response, + 'session_id': self.session_id, + 'user_context': self.user_context.to_dict() + }) + } + + +@dataclass +class ToolDefinition: + """Tool definition from OpenAPI specification.""" + name: str + description: str + input_schema: dict + output_schema: dict + security: List[dict] = field(default_factory=list) + + def to_dict(self) -> dict: + """Convert to dictionary format. + + Returns: + Tool definition dictionary + """ + return { + 'name': self.name, + 'description': self.description, + 'input_schema': self.input_schema, + 'output_schema': self.output_schema, + 'security': self.security + } + + def to_claude_format(self) -> dict: + """Convert to Claude-compatible tool format. + + Returns: + Claude tool definition dictionary + """ + return { + 'name': self.name, + 'description': self.description, + 'input_schema': self.input_schema + } + + +@dataclass +class WeatherData: + """Current weather data response.""" + location: str + temperature: float + conditions: str + humidity: int + wind_speed: float + user_context: UserContext + + def to_dict(self) -> dict: + """Convert to dictionary format. + + Returns: + Weather data dictionary + """ + return { + 'location': self.location, + 'temperature': self.temperature, + 'conditions': self.conditions, + 'humidity': self.humidity, + 'wind_speed': self.wind_speed, + 'user_context': { + 'user_id': self.user_context.user_id, + 'username': self.user_context.username + } + } + + +@dataclass +class DailyForecast: + """Single day forecast data.""" + date: str + high: float + low: float + conditions: str + + def to_dict(self) -> dict: + """Convert to dictionary format. + + Returns: + Daily forecast dictionary + """ + return { + 'date': self.date, + 'high': self.high, + 'low': self.low, + 'conditions': self.conditions + } + + +@dataclass +class ForecastData: + """Multi-day forecast data response.""" + location: str + days: int + forecast: List[DailyForecast] + user_context: UserContext + + def to_dict(self) -> dict: + """Convert to dictionary format. + + Returns: + Forecast data dictionary + """ + return { + 'location': self.location, + 'days': self.days, + 'forecast': [day.to_dict() for day in self.forecast], + 'user_context': { + 'user_id': self.user_context.user_id, + 'username': self.user_context.username + } + } + + +@dataclass +class ToolRequest: + """Tool execution request with user attribution.""" + tool_name: str + parameters: dict + user_context: UserContext + + @classmethod + def from_event(cls, event: dict) -> 'ToolRequest': + """Parse ToolRequest from Lambda event. + + When AgentCore Gateway invokes a Lambda target, it only passes the + arguments from the MCP request, not the tool name. The tool name + is configured via the TOOL_NAME environment variable. + + Args: + event: Lambda event from AgentCore Gateway + + Returns: + ToolRequest instance + + Raises: + ValueError: If TOOL_NAME environment variable is not set + """ + import os + + # Get tool name from environment variable + # This is set in CloudFormation for each Lambda function + tool_name = os.environ.get('TOOL_NAME', '') + + if not tool_name: + raise ValueError( + "TOOL_NAME environment variable must be set. " + "This Lambda function must be configured with the tool it handles." + ) + + # Extract parameters - Gateway passes arguments directly as event + parameters = event if isinstance(event, dict) else {} + + # Extract user context + user_context_dict = parameters.get('user_context', {}) + user_context = UserContext.from_dict(user_context_dict) + + return cls( + tool_name=tool_name, + parameters=parameters, + user_context=user_context + ) + + +@dataclass +class ToolResponse: + """Tool execution response with user attribution.""" + result: dict + user_context: UserContext + + def to_dict(self) -> dict: + """Convert to dictionary format. + + Returns: + Response dictionary with user context + """ + return { + 'result': { + **self.result, + 'user_context': { + 'user_id': self.user_context.user_id, + 'username': self.user_context.username + } + } + } + + +@dataclass +class ConversationTurn: + """Single turn in a conversation.""" + prompt: str + response: str + timestamp: str + tool_calls: List[dict] = field(default_factory=list) + + def to_dict(self) -> dict: + """Convert to dictionary format.""" + return { + 'prompt': self.prompt, + 'response': self.response, + 'timestamp': self.timestamp, + 'toolCalls': self.tool_calls + } + + +@dataclass +class ConversationContext: + """Complete conversation context for a session.""" + session_id: str + user_id: str + turns: List[ConversationTurn] + created_at: str + updated_at: str + + def to_memory_format(self) -> dict: + """Convert to AgentCore Memory format. + + Returns: + Memory format dictionary + """ + return { + 'sessionId': self.session_id, + 'userId': self.user_id, + 'turns': [turn.to_dict() for turn in self.turns], + 'createdAt': self.created_at, + 'updatedAt': self.updated_at + } + + @classmethod + def from_memory_format(cls, data: dict) -> 'ConversationContext': + """Create ConversationContext from memory format. + + Args: + data: Memory format dictionary + + Returns: + ConversationContext instance + """ + turns = [ + ConversationTurn( + prompt=turn['prompt'], + response=turn['response'], + timestamp=turn['timestamp'], + tool_calls=turn.get('toolCalls', []) + ) + for turn in data.get('turns', []) + ] + + return cls( + session_id=data['sessionId'], + user_id=data['userId'], + turns=turns, + created_at=data['createdAt'], + updated_at=data['updatedAt'] + ) + + + + diff --git a/strands-agentcore-openapi/template.yaml b/strands-agentcore-openapi/template.yaml new file mode 100644 index 0000000000..d5c1a2803d --- /dev/null +++ b/strands-agentcore-openapi/template.yaml @@ -0,0 +1,225 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: 'OpenAPI Agent Gateway - Serverless AI agent system with dynamic OpenAPI tool discovery (AWS SAM)' + +Parameters: + EnvironmentName: + Type: String + Description: Environment name prefix for all resources + Default: dev + AllowedValues: + - dev + - test + - prod + + WeatherApiKeySecretArn: + Type: String + Description: ARN of the Secrets Manager secret containing the WeatherAPI.com API key + Default: '' + + CredentialProviderArn: + Type: String + Description: ARN of the API Key credential provider for WeatherAPI (created outside CloudFormation) + Default: '' + + BedrockModelId: + Type: String + Description: Bedrock model ID for the Agent Lambda + Default: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0' + +Conditions: + HasCredentialProvider: !Not [!Equals [!Ref CredentialProviderArn, '']] + +Resources: + CognitoUserPool: + Type: AWS::Cognito::UserPool + Properties: + UserPoolName: !Sub '${EnvironmentName}-openapi-agent-user-pool' + AutoVerifiedAttributes: + - email + UsernameAttributes: + - email + + CognitoUserPoolClient: + Type: AWS::Cognito::UserPoolClient + Properties: + ClientName: !Sub '${EnvironmentName}-openapi-agent-client' + UserPoolId: !Ref CognitoUserPool + GenerateSecret: false + ExplicitAuthFlows: + - ALLOW_USER_PASSWORD_AUTH + - ALLOW_REFRESH_TOKEN_AUTH + + GatewayExecutionRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub '${EnvironmentName}-gateway-execution-role' + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: bedrock-agentcore.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/CloudWatchLogsFullAccess + Policies: + - PolicyName: GatewayOutboundAuth + PolicyDocument: + Version: '2012-10-17' + Statement: + - Sid: GetWorkloadAccessToken + Effect: Allow + Action: + - bedrock-agentcore:GetWorkloadAccessToken + Resource: + - !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/${EnvironmentName}-openapi-agent-gateway-*' + - Sid: GetResourceApiKey + Effect: Allow + Action: + - bedrock-agentcore:GetResourceApiKey + 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/${EnvironmentName}-openapi-agent-gateway-*' + - Sid: GetSecretValue + Effect: Allow + Action: + - secretsmanager:GetSecretValue + Resource: + - !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:bedrock-agentcore-identity!default/apikey/*' + + AgentCoreGateway: + Type: AWS::BedrockAgentCore::Gateway + Properties: + Name: !Sub '${EnvironmentName}-openapi-agent-gateway' + AuthorizerType: CUSTOM_JWT + AuthorizerConfiguration: + CustomJWTAuthorizer: + DiscoveryUrl: !Sub 'https://cognito-idp.${AWS::Region}.amazonaws.com/${CognitoUserPool}/.well-known/openid-configuration' + AllowedClients: + - !Ref CognitoUserPoolClient + ProtocolType: MCP + ProtocolConfiguration: + Mcp: + SupportedVersions: + - '2025-03-26' + RoleArn: !GetAtt GatewayExecutionRole.Arn + + AgentFunction: + Type: AWS::Serverless::Function + # Built via src/Makefile so binary deps (e.g. cryptography) are installed + # as Linux manylinux wheels without needing Docker. See BuildMethod below. + Metadata: + BuildMethod: makefile + Properties: + FunctionName: !Sub '${EnvironmentName}-agent-lambda' + CodeUri: src/ + Handler: agent.handler.lambda_handler + Runtime: python3.12 + Architectures: + - x86_64 + Timeout: 120 + MemorySize: 1024 + Environment: + Variables: + COGNITO_JWKS_URL: !Sub 'https://cognito-idp.${AWS::Region}.amazonaws.com/${CognitoUserPool}/.well-known/jwks.json' + GATEWAY_ID: !Ref AgentCoreGateway + BEDROCK_MODEL_ID: !Ref BedrockModelId + Policies: + - Statement: + - Sid: BedrockInvoke + Effect: Allow + Action: + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + - bedrock:Converse + - bedrock:ConverseStream + Resource: '*' + - Sid: AgentCoreGatewayRead + Effect: Allow + Action: + - bedrock-agentcore:ListGatewayTargets + - bedrock-agentcore:GetGatewayTarget + - bedrock-agentcore:GetGateway + Resource: '*' + + AgentFunctionLogGroup: + Type: AWS::Logs::LogGroup + DeletionPolicy: Delete + UpdateReplacePolicy: Delete + Properties: + LogGroupName: !Sub '/aws/lambda/${EnvironmentName}-agent-lambda' + RetentionInDays: 30 + + # Gateway Target for WeatherAPI.com — created once the API Key credential + # provider ARN is available (see scripts/deploy.sh). + WeatherAPITarget: + Type: AWS::BedrockAgentCore::GatewayTarget + Condition: HasCredentialProvider + Properties: + GatewayIdentifier: !Ref AgentCoreGateway + Name: weatherapi-current-weather + Description: WeatherAPI.com current weather data via OpenAPI target + CredentialProviderConfigurations: + - CredentialProviderType: API_KEY + CredentialProvider: + ApiKeyCredentialProvider: + ProviderArn: !Ref CredentialProviderArn + CredentialLocation: QUERY_PARAMETER + CredentialParameterName: key + TargetConfiguration: + Mcp: + OpenApiSchema: + InlinePayload: | + openapi: 3.0.0 + info: + title: WeatherAPI.com + version: 1.0.0 + description: Real-time weather data API + servers: + - url: https://api.weatherapi.com/v1 + paths: + /current.json: + get: + operationId: getCurrentWeather + summary: Get current weather + description: Get real-time weather data for a location including temperature, conditions, wind, and humidity + parameters: + - name: q + in: query + required: true + schema: + type: string + description: Location query (city name, coordinates, IP, etc) + - name: aqi + in: query + required: false + schema: + type: string + enum: + - "yes" + - "no" + description: Include air quality data + responses: + "200": + description: Current weather data + +Outputs: + GatewayId: + Description: AgentCore Gateway ID + Value: !Ref AgentCoreGateway + + CognitoUserPoolId: + Description: Cognito User Pool ID + Value: !Ref CognitoUserPool + + CognitoClientId: + Description: Cognito User Pool Client ID + Value: !Ref CognitoUserPoolClient + + AgentLambdaArn: + Description: Agent Lambda Function ARN + Value: !GetAtt AgentFunction.Arn diff --git a/strands-agentcore-openapi/tests/__init__.py b/strands-agentcore-openapi/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/strands-agentcore-openapi/tests/conftest.py b/strands-agentcore-openapi/tests/conftest.py new file mode 100644 index 0000000000..4bb886872b --- /dev/null +++ b/strands-agentcore-openapi/tests/conftest.py @@ -0,0 +1,8 @@ +"""Pytest configuration for OpenAPI Agent Gateway tests.""" + +import sys +from pathlib import Path + +# Add src directory to Python path for imports +src_path = Path(__file__).parent.parent / 'src' +sys.path.insert(0, str(src_path)) diff --git a/strands-agentcore-openapi/tests/integration/__init__.py b/strands-agentcore-openapi/tests/integration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/strands-agentcore-openapi/tests/integration/test_gateway_target.py b/strands-agentcore-openapi/tests/integration/test_gateway_target.py new file mode 100644 index 0000000000..22b5718302 --- /dev/null +++ b/strands-agentcore-openapi/tests/integration/test_gateway_target.py @@ -0,0 +1,112 @@ +""" +Integration tests for Gateway Target deployment. + +Tests that the WeatherAPI Gateway Target and supporting resources were +created correctly and are accessible via AWS API. Requires a live deployment +(reads deployment/stack_outputs.json). +""" + +import json +import boto3 +import pytest +from pathlib import Path + + +@pytest.fixture +def stack_outputs(): + """Load stack outputs from deployment.""" + outputs_file = Path('deployment/stack_outputs.json') + if not outputs_file.exists(): + pytest.skip("Stack outputs not found - run deployment first") + + with open(outputs_file) as f: + return json.load(f) + + +@pytest.fixture +def bedrock_client(): + """Create Bedrock AgentCore client.""" + return boto3.client('bedrock-agent', region_name='us-east-1') + + +def test_gateway_exists(stack_outputs, bedrock_client): + """Test that the Gateway was created.""" + gateway_id = stack_outputs['GatewayId'] + + # Note: There's no direct API to get gateway details in bedrock-agent + # This test verifies the gateway ID is in the outputs + assert gateway_id is not None + assert gateway_id.startswith('dev-openapi-agent-gateway-') + print(f"✓ Gateway ID: {gateway_id}") + + +def test_gateway_target_listed(stack_outputs): + """Test that Gateway Targets can be listed.""" + gateway_id = stack_outputs['GatewayId'] + + # Note: AWS SDK may not have direct support for listing gateway targets yet + # This is a placeholder for when the API becomes available + print(f"✓ Gateway ID available: {gateway_id}") + print(" Note: Direct Gateway Target listing API not yet available in boto3") + + +def test_cognito_user_pool_exists(stack_outputs): + """Test that Cognito User Pool was created.""" + user_pool_id = stack_outputs['CognitoUserPoolId'] + + cognito = boto3.client('cognito-idp', region_name='us-east-1') + + response = cognito.describe_user_pool(UserPoolId=user_pool_id) + + assert response['UserPool']['Id'] == user_pool_id + assert response['UserPool']['Name'] == 'dev-openapi-agent-user-pool' + print(f"✓ Cognito User Pool: {user_pool_id}") + + +def test_lambda_functions_exist(stack_outputs): + """Test that the Agent Lambda was created.""" + agent_lambda_arn = stack_outputs['AgentLambdaArn'] + + lambda_client = boto3.client('lambda', region_name='us-east-1') + + agent_response = lambda_client.get_function(FunctionName=agent_lambda_arn) + assert agent_response['Configuration']['FunctionName'] == 'dev-agent-lambda' + assert agent_response['Configuration']['Runtime'] == 'python3.12' + print(f"✓ Agent Lambda: {agent_lambda_arn}") + + +def test_lambda_environment_variables(stack_outputs): + """Test that Lambda environment variables are set correctly.""" + agent_lambda_arn = stack_outputs['AgentLambdaArn'] + gateway_id = stack_outputs['GatewayId'] + + lambda_client = boto3.client('lambda', region_name='us-east-1') + + response = lambda_client.get_function(FunctionName=agent_lambda_arn) + env_vars = response['Configuration']['Environment']['Variables'] + + assert 'GATEWAY_ID' in env_vars + assert env_vars['GATEWAY_ID'] == gateway_id + assert 'COGNITO_JWKS_URL' in env_vars + assert 'BEDROCK_MODEL_ID' in env_vars + print(f"✓ Lambda environment variables configured correctly") + + +def test_stack_outputs_complete(stack_outputs): + """Test that all expected stack outputs are present.""" + required_outputs = [ + 'GatewayId', + 'CognitoUserPoolId', + 'CognitoClientId', + 'AgentLambdaArn' + ] + + for output in required_outputs: + assert output in stack_outputs, f"Missing output: {output}" + assert stack_outputs[output], f"Empty output: {output}" + + print("✓ All stack outputs present") + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/strands-agentcore-openapi/tests/property/__init__.py b/strands-agentcore-openapi/tests/property/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/strands-agentcore-openapi/tests/unit/__init__.py b/strands-agentcore-openapi/tests/unit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/strands-agentcore-openapi/tests/unit/test_agent_handler.py b/strands-agentcore-openapi/tests/unit/test_agent_handler.py new file mode 100644 index 0000000000..3e6f3d0883 --- /dev/null +++ b/strands-agentcore-openapi/tests/unit/test_agent_handler.py @@ -0,0 +1,329 @@ +"""Unit tests for Agent Lambda handler.""" + +import pytest +import json +from unittest.mock import Mock, patch, MagicMock +import os + +from src.agent.handler import lambda_handler +from src.shared.models import UserContext + + +@pytest.fixture +def mock_env_vars(): + """Set up environment variables for testing.""" + with patch.dict(os.environ, { + 'COGNITO_JWKS_URL': 'https://cognito-idp.us-east-1.amazonaws.com/test-pool/.well-known/jwks.json', + 'GATEWAY_ID': 'test-gateway-id', + 'BEDROCK_MODEL_ID': 'anthropic.claude-3-sonnet-20240229-v1:0', + 'AWS_REGION': 'us-east-1', + 'LOG_LEVEL': 'INFO' + }): + yield + + +@pytest.fixture +def valid_jwt_token(): + """Return a mock valid JWT token.""" + return 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test.signature' + + +@pytest.fixture +def valid_jwt_claims(): + """Return mock JWT claims.""" + return { + 'sub': 'user-123', + 'username': 'testuser@example.com', + 'client_id': 'client-456', + 'token_use': 'access' + } + + +@pytest.fixture +def valid_event(valid_jwt_token): + """Create a valid Lambda event.""" + return { + 'headers': { + 'Authorization': f'Bearer {valid_jwt_token}' + }, + 'body': json.dumps({ + 'prompt': 'What is the weather in Seattle?', + 'session_id': 'test-session-123' + }) + } + + +@pytest.fixture +def mock_context(): + """Create mock Lambda context.""" + context = Mock() + context.request_id = 'test-request-id' + return context + + +def test_lambda_handler_success(mock_env_vars, valid_event, mock_context, valid_jwt_token, valid_jwt_claims): + """Test successful request processing.""" + with patch('src.agent.handler.validate_jwt') as mock_validate, \ + patch('src.agent.handler.extract_user_context') as mock_extract, \ + patch('src.agent.handler.AgentProcessor') as mock_processor_cls: + + # Setup mocks + mock_validate.return_value = valid_jwt_claims + mock_extract.return_value = UserContext( + user_id='user-123', + username='testuser@example.com', + client_id='client-456' + ) + + mock_processor = Mock() + mock_processor.process_request.return_value = ( + 'The weather in Seattle is 65°F and partly cloudy.', + 'test-session-123' + ) + mock_processor_cls.return_value = mock_processor + + # Execute + response = lambda_handler(valid_event, mock_context) + + # Verify + assert response['statusCode'] == 200 + + body = json.loads(response['body']) + assert body['response'] == 'The weather in Seattle is 65°F and partly cloudy.' + assert body['session_id'] == 'test-session-123' + assert body['user_context']['user_id'] == 'user-123' + assert body['user_context']['username'] == 'testuser@example.com' + assert body['user_context']['client_id'] == 'client-456' + + # Verify JWT was validated (with the JWKS URL from environment) + mock_validate.assert_called_once() + call_args = mock_validate.call_args + assert call_args[0][0] == valid_jwt_token + # JWKS URL comes from environment variable + + # Verify user context was extracted + mock_extract.assert_called_once_with(valid_jwt_claims) + + # Verify processor was called + mock_processor.process_request.assert_called_once_with( + prompt='What is the weather in Seattle?', + jwt_token=valid_jwt_token, + session_id='test-session-123' + ) + + +def test_lambda_handler_missing_authorization_header(mock_env_vars, mock_context): + """Test request with missing Authorization header.""" + event = { + 'headers': {}, + 'body': json.dumps({'prompt': 'Test'}) + } + + # Execute + response = lambda_handler(event, mock_context) + + # Verify 401 response + assert response['statusCode'] == 401 + body = json.loads(response['body']) + assert 'error' in body + + +def test_lambda_handler_invalid_authorization_format(mock_env_vars, mock_context): + """Test request with invalid Authorization header format.""" + event = { + 'headers': { + 'Authorization': 'InvalidFormat token123' + }, + 'body': json.dumps({'prompt': 'Test'}) + } + + # Execute + response = lambda_handler(event, mock_context) + + # Verify 401 response + assert response['statusCode'] == 401 + body = json.loads(response['body']) + assert 'error' in body + + +def test_lambda_handler_empty_jwt_token(mock_env_vars, mock_context): + """Test request with empty JWT token.""" + event = { + 'headers': { + 'Authorization': 'Bearer ' + }, + 'body': json.dumps({'prompt': 'Test'}) + } + + # Execute + response = lambda_handler(event, mock_context) + + # Verify 401 response + assert response['statusCode'] == 401 + body = json.loads(response['body']) + assert 'error' in body + + +def test_lambda_handler_jwt_validation_failure(mock_env_vars, valid_event, mock_context): + """Test request with invalid JWT token.""" + with patch('src.agent.handler.validate_jwt') as mock_validate: + # Setup mock to raise validation error + mock_validate.side_effect = ValueError('Token has expired') + + # Execute + response = lambda_handler(valid_event, mock_context) + + # Verify 401 response + assert response['statusCode'] == 401 + body = json.loads(response['body']) + assert 'error' in body + + +def test_lambda_handler_missing_prompt(mock_env_vars, valid_jwt_token, mock_context, valid_jwt_claims): + """Test request with missing prompt.""" + event = { + 'headers': { + 'Authorization': f'Bearer {valid_jwt_token}' + }, + 'body': json.dumps({ + 'session_id': 'test-session-123' + }) + } + + with patch('src.agent.handler.validate_jwt') as mock_validate, \ + patch('src.agent.handler.extract_user_context') as mock_extract: + + mock_validate.return_value = valid_jwt_claims + mock_extract.return_value = UserContext( + user_id='user-123', + username='testuser@example.com', + client_id='client-456' + ) + + # Execute + response = lambda_handler(event, mock_context) + + # Verify 400 response + assert response['statusCode'] == 400 + body = json.loads(response['body']) + assert 'error' in body + assert 'prompt' in body['error'].lower() + + +def test_lambda_handler_invalid_json_body(mock_env_vars, valid_jwt_token, mock_context, valid_jwt_claims): + """Test request with invalid JSON body.""" + event = { + 'headers': { + 'Authorization': f'Bearer {valid_jwt_token}' + }, + 'body': 'invalid json {' + } + + with patch('src.agent.handler.validate_jwt') as mock_validate, \ + patch('src.agent.handler.extract_user_context') as mock_extract: + + mock_validate.return_value = valid_jwt_claims + mock_extract.return_value = UserContext( + user_id='user-123', + username='testuser@example.com', + client_id='client-456' + ) + + # Execute + response = lambda_handler(event, mock_context) + + # Verify 400 response + assert response['statusCode'] == 400 + body = json.loads(response['body']) + assert 'error' in body + + +def test_lambda_handler_agent_processing_failure(mock_env_vars, valid_event, mock_context, valid_jwt_token, valid_jwt_claims): + """Test request when agent processing fails.""" + with patch('src.agent.handler.validate_jwt') as mock_validate, \ + patch('src.agent.handler.extract_user_context') as mock_extract, \ + patch('src.agent.handler.AgentProcessor') as mock_processor_cls: + + # Setup mocks + mock_validate.return_value = valid_jwt_claims + mock_extract.return_value = UserContext( + user_id='user-123', + username='testuser@example.com', + client_id='client-456' + ) + + mock_processor = Mock() + mock_processor.process_request.side_effect = Exception('Processing failed') + mock_processor_cls.return_value = mock_processor + + # Execute + response = lambda_handler(valid_event, mock_context) + + # Verify 500 response + assert response['statusCode'] == 500 + body = json.loads(response['body']) + assert 'error' in body + + +def test_lambda_handler_without_session_id(mock_env_vars, valid_jwt_token, mock_context, valid_jwt_claims): + """Test request without session_id (new conversation).""" + event = { + 'headers': { + 'Authorization': f'Bearer {valid_jwt_token}' + }, + 'body': json.dumps({ + 'prompt': 'Hello' + }) + } + + with patch('src.agent.handler.validate_jwt') as mock_validate, \ + patch('src.agent.handler.extract_user_context') as mock_extract, \ + patch('src.agent.handler.AgentProcessor') as mock_processor_cls: + + # Setup mocks + mock_validate.return_value = valid_jwt_claims + mock_extract.return_value = UserContext( + user_id='user-123', + username='testuser@example.com', + client_id='client-456' + ) + + mock_processor = Mock() + mock_processor.process_request.return_value = ( + 'Hi there!', + 'new-session-456' + ) + mock_processor_cls.return_value = mock_processor + + # Execute + response = lambda_handler(event, mock_context) + + # Verify + assert response['statusCode'] == 200 + body = json.loads(response['body']) + assert body['session_id'] == 'new-session-456' + + # Verify processor was called with None session_id + mock_processor.process_request.assert_called_once_with( + prompt='Hello', + jwt_token=valid_jwt_token, + session_id=None + ) + + +def test_lambda_handler_user_context_extraction_failure(mock_env_vars, valid_event, mock_context, valid_jwt_claims): + """Test request when user context extraction fails.""" + with patch('src.agent.handler.validate_jwt') as mock_validate, \ + patch('src.agent.handler.extract_user_context') as mock_extract: + + # Setup mocks + mock_validate.return_value = valid_jwt_claims + mock_extract.side_effect = ValueError('Missing required claims') + + # Execute + response = lambda_handler(valid_event, mock_context) + + # Verify 400 response + assert response['statusCode'] == 400 + body = json.loads(response['body']) + assert 'error' in body diff --git a/strands-agentcore-openapi/tests/unit/test_agent_processor.py b/strands-agentcore-openapi/tests/unit/test_agent_processor.py new file mode 100644 index 0000000000..b11f912323 --- /dev/null +++ b/strands-agentcore-openapi/tests/unit/test_agent_processor.py @@ -0,0 +1,350 @@ +"""Unit tests for Agent Processor.""" + +import pytest +from unittest.mock import Mock, MagicMock, patch +import uuid + +from src.agent.agent_processor import AgentProcessor +from src.shared.models import UserContext +from src.shared.logging_utils import StructuredLogger + + +@pytest.fixture +def mock_logger(): + """Create mock structured logger.""" + logger = Mock(spec=StructuredLogger) + return logger + + +@pytest.fixture +def mock_strands_agent(): + """Create mock Strands agent.""" + agent = Mock() + return agent + + +@pytest.fixture +def mock_gateway_client(): + """Create mock Gateway client.""" + client = Mock() + return client + + +@pytest.fixture +def agent_processor(mock_logger): + """Create AgentProcessor with mocked dependencies.""" + with patch('src.agent.agent_processor.StrandsAgent') as mock_strands_cls, \ + patch('src.agent.agent_processor.GatewayClient') as mock_gateway_cls: + + processor = AgentProcessor( + gateway_id='test-gateway-id', + model_id='anthropic.claude-3-sonnet-20240229-v1:0', + region='us-east-1', + logger=mock_logger + ) + + # Replace with mocks + processor.strands_agent = Mock() + processor.gateway_client = Mock() + + return processor + + +def test_agent_processor_initialization(mock_logger): + """Test agent processor initializes correctly.""" + with patch('src.agent.agent_processor.StrandsAgent') as mock_strands_cls, \ + patch('src.agent.agent_processor.GatewayClient') as mock_gateway_cls: + + processor = AgentProcessor( + gateway_id='test-gateway-id', + model_id='anthropic.claude-3-sonnet-20240229-v1:0', + region='us-east-1', + logger=mock_logger + ) + + # Verify clients were initialized + mock_strands_cls.assert_called_once_with( + model_id='anthropic.claude-3-sonnet-20240229-v1:0', + region='us-east-1', + logger=mock_logger + ) + + mock_gateway_cls.assert_called_once_with( + gateway_id='test-gateway-id', + region='us-east-1', + logger=mock_logger + ) + + mock_logger.info.assert_called_with('Agent processor initialized') + + +def test_process_request_text_response(agent_processor): + """Test processing request with text response (no tool use).""" + # Setup mocks + agent_processor.gateway_client.list_tools.return_value = [ + { + 'name': 'test-tool', + 'description': 'Test tool', + 'input_schema': {} + } + ] + + agent_processor.strands_agent.format_messages.return_value = [ + {'role': 'user', 'content': 'What is the weather?'} + ] + + agent_processor.strands_agent.invoke_with_tools.return_value = { + 'content': [ + {'type': 'text', 'text': 'The weather is sunny.'} + ], + 'stop_reason': 'end_turn' + } + + agent_processor.strands_agent.extract_tool_use.return_value = None + agent_processor.strands_agent.extract_text_response.return_value = 'The weather is sunny.' + + # Execute + response_text, session_id = agent_processor.process_request( + prompt='What is the weather?', + jwt_token='test-jwt-token', + session_id=None + ) + + # Verify + assert response_text == 'The weather is sunny.' + assert session_id is not None + assert len(session_id) > 0 + + # Verify tool discovery was called + agent_processor.gateway_client.list_tools.assert_called_once_with('test-jwt-token') + + # Verify Claude was invoked + agent_processor.strands_agent.invoke_with_tools.assert_called_once() + + # Verify no tool execution + agent_processor.gateway_client.invoke_tool.assert_not_called() + + +def test_process_request_with_tool_use(agent_processor): + """Test processing request with tool use.""" + # Setup mocks + agent_processor.gateway_client.list_tools.return_value = [ + { + 'name': 'weather-api___getCurrentWeather', + 'description': 'Get current weather', + 'input_schema': {} + } + ] + + agent_processor.strands_agent.format_messages.return_value = [ + {'role': 'user', 'content': 'What is the weather in Seattle?'} + ] + + # First Claude response with tool use + agent_processor.strands_agent.invoke_with_tools.side_effect = [ + { + 'content': [ + { + 'type': 'tool_use', + 'id': 'tool-use-123', + 'name': 'weather-api___getCurrentWeather', + 'input': {'location': 'Seattle'} + } + ], + 'stop_reason': 'tool_use' + }, + # Second Claude response with final text + { + 'content': [ + {'type': 'text', 'text': 'The weather in Seattle is 65°F and partly cloudy.'} + ], + 'stop_reason': 'end_turn' + } + ] + + agent_processor.strands_agent.extract_tool_use.return_value = { + 'id': 'tool-use-123', + 'name': 'weather-api___getCurrentWeather', + 'input': {'location': 'Seattle'} + } + + agent_processor.gateway_client.invoke_tool.return_value = { + 'location': 'Seattle', + 'temperature': 65, + 'conditions': 'Partly Cloudy' + } + + agent_processor.strands_agent.format_tool_result.return_value = { + 'role': 'user', + 'content': [ + { + 'type': 'tool_result', + 'tool_use_id': 'tool-use-123', + 'content': '{"location": "Seattle", "temperature": 65, "conditions": "Partly Cloudy"}' + } + ] + } + + agent_processor.strands_agent.extract_text_response.return_value = 'The weather in Seattle is 65°F and partly cloudy.' + + # Execute + response_text, session_id = agent_processor.process_request( + prompt='What is the weather in Seattle?', + jwt_token='test-jwt-token', + session_id=None + ) + + # Verify + assert response_text == 'The weather in Seattle is 65°F and partly cloudy.' + assert session_id is not None + + # Verify tool was executed + agent_processor.gateway_client.invoke_tool.assert_called_once_with( + tool_name='weather-api___getCurrentWeather', + tool_input={'location': 'Seattle'}, + jwt_token='test-jwt-token' + ) + + # Verify Claude was invoked twice (initial + after tool result) + assert agent_processor.strands_agent.invoke_with_tools.call_count == 2 + + +def test_process_request_with_existing_session_id(agent_processor): + """Test processing request with existing session ID.""" + # Setup mocks + agent_processor.gateway_client.list_tools.return_value = [] + agent_processor.strands_agent.format_messages.return_value = [ + {'role': 'user', 'content': 'Hello'} + ] + agent_processor.strands_agent.invoke_with_tools.return_value = { + 'content': [{'type': 'text', 'text': 'Hi there!'}], + 'stop_reason': 'end_turn' + } + agent_processor.strands_agent.extract_tool_use.return_value = None + agent_processor.strands_agent.extract_text_response.return_value = 'Hi there!' + + existing_session_id = 'existing-session-123' + + # Execute + response_text, session_id = agent_processor.process_request( + prompt='Hello', + jwt_token='test-jwt-token', + session_id=existing_session_id + ) + + # Verify session ID is preserved + assert session_id == existing_session_id + + +def test_process_request_tool_discovery_failure(agent_processor): + """Test processing request when tool discovery fails.""" + # Setup mocks - tool discovery fails + agent_processor.gateway_client.list_tools.side_effect = Exception('Gateway unavailable') + + agent_processor.strands_agent.format_messages.return_value = [ + {'role': 'user', 'content': 'Hello'} + ] + agent_processor.strands_agent.invoke_with_tools.return_value = { + 'content': [{'type': 'text', 'text': 'Hi there!'}], + 'stop_reason': 'end_turn' + } + agent_processor.strands_agent.extract_tool_use.return_value = None + agent_processor.strands_agent.extract_text_response.return_value = 'Hi there!' + + # Execute - should continue without tools + response_text, session_id = agent_processor.process_request( + prompt='Hello', + jwt_token='test-jwt-token', + session_id=None + ) + + # Verify response is still generated + assert response_text == 'Hi there!' + + # Verify Claude was invoked with empty tools list + call_args = agent_processor.strands_agent.invoke_with_tools.call_args + assert call_args[1]['tools'] == [] + + +def test_process_request_bedrock_failure(agent_processor): + """Test processing request when Bedrock invocation fails.""" + # Setup mocks + agent_processor.gateway_client.list_tools.return_value = [] + agent_processor.strands_agent.format_messages.return_value = [ + {'role': 'user', 'content': 'Hello'} + ] + agent_processor.strands_agent.invoke_with_tools.side_effect = Exception('Bedrock error') + + # Execute - should raise exception + with pytest.raises(Exception) as exc_info: + agent_processor.process_request( + prompt='Hello', + jwt_token='test-jwt-token', + session_id=None + ) + + assert 'AI service temporarily unavailable' in str(exc_info.value) + + +def test_process_request_tool_execution_failure(agent_processor): + """Test processing request when tool execution fails.""" + # Setup mocks + agent_processor.gateway_client.list_tools.return_value = [ + { + 'name': 'test-tool', + 'description': 'Test tool', + 'input_schema': {} + } + ] + + agent_processor.strands_agent.format_messages.return_value = [ + {'role': 'user', 'content': 'Test'} + ] + + agent_processor.strands_agent.invoke_with_tools.return_value = { + 'content': [ + { + 'type': 'tool_use', + 'id': 'tool-use-123', + 'name': 'test-tool', + 'input': {} + } + ], + 'stop_reason': 'tool_use' + } + + agent_processor.strands_agent.extract_tool_use.return_value = { + 'id': 'tool-use-123', + 'name': 'test-tool', + 'input': {} + } + + # Tool execution fails + agent_processor.gateway_client.invoke_tool.side_effect = Exception('Tool error') + + # Execute + response_text, session_id = agent_processor.process_request( + prompt='Test', + jwt_token='test-jwt-token', + session_id=None + ) + + # Verify error message is returned + assert 'encountered an error' in response_text.lower() + assert 'test tool' in response_text.lower() + + +def test_generate_session_id(agent_processor): + """Test session ID generation.""" + session_id = agent_processor._generate_session_id() + + # Verify it's a valid UUID + assert session_id is not None + assert len(session_id) > 0 + + # Verify it can be parsed as UUID + uuid.UUID(session_id) + + # Verify multiple calls generate different IDs + session_id2 = agent_processor._generate_session_id() + assert session_id != session_id2 diff --git a/strands-agentcore-openapi/tests/unit/test_openapi_parser.py b/strands-agentcore-openapi/tests/unit/test_openapi_parser.py new file mode 100644 index 0000000000..51a6a4140b --- /dev/null +++ b/strands-agentcore-openapi/tests/unit/test_openapi_parser.py @@ -0,0 +1,459 @@ +"""Unit tests for OpenAPI parser module.""" + +import pytest +from src.openapi_parser import ( + parse_openapi_spec, + extract_operation_tool, + convert_to_json_schema, + OpenAPIParseError +) +from src.shared.models import ToolDefinition + + +class TestParseOpenAPISpec: + """Tests for parse_openapi_spec function.""" + + def test_parse_minimal_valid_spec(self): + """Test parsing a minimal valid OpenAPI 3.0 specification.""" + spec = { + 'openapi': '3.0.0', + 'info': {'title': 'Test API', 'version': '1.0.0'}, + 'paths': { + '/test': { + 'get': { + 'operationId': 'getTest', + 'summary': 'Get test data', + 'responses': { + '200': { + 'description': 'Success', + 'content': { + 'application/json': { + 'schema': {'type': 'object'} + } + } + } + } + } + } + } + } + + tools = parse_openapi_spec(spec) + + assert len(tools) == 1 + assert tools[0].name == 'getTest' + assert tools[0].description == 'Get test data' + assert tools[0].input_schema['type'] == 'object' + assert tools[0].output_schema['type'] == 'object' + + def test_parse_openapi_3_1_spec(self): + """Test parsing OpenAPI 3.1.x specification.""" + spec = { + 'openapi': '3.1.0', + 'info': {'title': 'Test API', 'version': '1.0.0'}, + 'paths': { + '/test': { + 'get': { + 'operationId': 'getTest', + 'summary': 'Get test', + 'responses': {'200': {'description': 'Success'}} + } + } + } + } + + tools = parse_openapi_spec(spec) + assert len(tools) == 1 + + def test_missing_openapi_field(self): + """Test error when 'openapi' field is missing.""" + spec = { + 'info': {'title': 'Test API', 'version': '1.0.0'}, + 'paths': {} + } + + with pytest.raises(OpenAPIParseError, match="Missing 'openapi' field"): + parse_openapi_spec(spec) + + def test_unsupported_openapi_version(self): + """Test error for unsupported OpenAPI version.""" + spec = { + 'openapi': '2.0', + 'info': {'title': 'Test API', 'version': '1.0.0'}, + 'paths': {} + } + + with pytest.raises(OpenAPIParseError, match="Unsupported OpenAPI version"): + parse_openapi_spec(spec) + + def test_missing_paths_field(self): + """Test error when 'paths' field is missing.""" + spec = { + 'openapi': '3.0.0', + 'info': {'title': 'Test API', 'version': '1.0.0'} + } + + with pytest.raises(OpenAPIParseError, match="No valid operations found"): + parse_openapi_spec(spec) + + def test_empty_paths(self): + """Test error when paths is empty.""" + spec = { + 'openapi': '3.0.0', + 'info': {'title': 'Test API', 'version': '1.0.0'}, + 'paths': {} + } + + with pytest.raises(OpenAPIParseError, match="No valid operations found"): + parse_openapi_spec(spec) + + def test_multiple_operations(self): + """Test parsing spec with multiple operations.""" + spec = { + 'openapi': '3.0.0', + 'info': {'title': 'Test API', 'version': '1.0.0'}, + 'paths': { + '/users': { + 'get': { + 'operationId': 'listUsers', + 'summary': 'List users', + 'responses': {'200': {'description': 'Success'}} + }, + 'post': { + 'operationId': 'createUser', + 'summary': 'Create user', + 'responses': {'201': {'description': 'Created'}} + } + }, + '/users/{id}': { + 'get': { + 'operationId': 'getUser', + 'summary': 'Get user', + 'responses': {'200': {'description': 'Success'}} + } + } + } + } + + tools = parse_openapi_spec(spec) + + assert len(tools) == 3 + tool_names = [t.name for t in tools] + assert 'listUsers' in tool_names + assert 'createUser' in tool_names + assert 'getUser' in tool_names + + +class TestExtractOperationTool: + """Tests for extract_operation_tool function.""" + + def test_extract_with_operation_id(self): + """Test extraction when operationId is present.""" + operation = { + 'operationId': 'getCurrentWeather', + 'summary': 'Get current weather', + 'responses': {'200': {'description': 'Success'}} + } + + tool = extract_operation_tool('/weather', 'get', operation) + + assert tool.name == 'getCurrentWeather' + assert tool.description == 'Get current weather' + + def test_extract_without_operation_id(self): + """Test extraction generates name from method and path.""" + operation = { + 'summary': 'Get weather', + 'responses': {'200': {'description': 'Success'}} + } + + tool = extract_operation_tool('/weather', 'get', operation) + + assert tool.name == 'get_weather' + + def test_extract_with_path_parameters(self): + """Test extraction with path parameters in URL.""" + operation = { + 'summary': 'Get user', + 'responses': {'200': {'description': 'Success'}} + } + + tool = extract_operation_tool('/users/{id}', 'get', operation) + + assert tool.name == 'get_users_id' + + def test_extract_with_parameters(self): + """Test extraction with query parameters.""" + operation = { + 'operationId': 'searchUsers', + 'summary': 'Search users', + 'parameters': [ + { + 'name': 'query', + 'in': 'query', + 'required': True, + 'schema': {'type': 'string'} + }, + { + 'name': 'limit', + 'in': 'query', + 'required': False, + 'schema': {'type': 'integer'} + } + ], + 'responses': {'200': {'description': 'Success'}} + } + + tool = extract_operation_tool('/users/search', 'get', operation) + + assert 'query' in tool.input_schema['properties'] + assert 'limit' in tool.input_schema['properties'] + assert 'query' in tool.input_schema['required'] + assert 'limit' not in tool.input_schema['required'] + + def test_extract_with_request_body(self): + """Test extraction with requestBody.""" + operation = { + 'operationId': 'createUser', + 'summary': 'Create user', + 'requestBody': { + 'required': True, + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'email': {'type': 'string'} + }, + 'required': ['name', 'email'] + } + } + } + }, + 'responses': {'201': {'description': 'Created'}} + } + + tool = extract_operation_tool('/users', 'post', operation) + + assert 'name' in tool.input_schema['properties'] + assert 'email' in tool.input_schema['properties'] + assert 'name' in tool.input_schema['required'] + assert 'email' in tool.input_schema['required'] + + def test_extract_with_security(self): + """Test extraction preserves security requirements.""" + operation = { + 'operationId': 'getProtected', + 'summary': 'Get protected resource', + 'security': [{'bearerAuth': []}], + 'responses': {'200': {'description': 'Success'}} + } + + tool = extract_operation_tool('/protected', 'get', operation) + + assert tool.security == [{'bearerAuth': []}] + + def test_extract_uses_description_fallback(self): + """Test extraction uses description when summary is missing.""" + operation = { + 'operationId': 'getTest', + 'description': 'This is a test endpoint', + 'responses': {'200': {'description': 'Success'}} + } + + tool = extract_operation_tool('/test', 'get', operation) + + assert tool.description == 'This is a test endpoint' + + def test_extract_generates_description_fallback(self): + """Test extraction generates description when both summary and description are missing.""" + operation = { + 'operationId': 'getTest', + 'responses': {'200': {'description': 'Success'}} + } + + tool = extract_operation_tool('/test', 'get', operation) + + assert tool.description == 'GET /test' + + +class TestConvertToJsonSchema: + """Tests for convert_to_json_schema function.""" + + def test_convert_simple_type(self): + """Test conversion of simple type.""" + openapi_schema = {'type': 'string'} + + json_schema = convert_to_json_schema(openapi_schema) + + assert json_schema == {'type': 'string'} + + def test_convert_with_format(self): + """Test conversion preserves format.""" + openapi_schema = {'type': 'string', 'format': 'date-time'} + + json_schema = convert_to_json_schema(openapi_schema) + + assert json_schema['type'] == 'string' + assert json_schema['format'] == 'date-time' + + def test_convert_object_with_properties(self): + """Test conversion of object with properties.""" + openapi_schema = { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'age': {'type': 'integer'} + }, + 'required': ['name'] + } + + json_schema = convert_to_json_schema(openapi_schema) + + assert json_schema['type'] == 'object' + assert 'name' in json_schema['properties'] + assert 'age' in json_schema['properties'] + assert json_schema['required'] == ['name'] + + def test_convert_array_with_items(self): + """Test conversion of array with items.""" + openapi_schema = { + 'type': 'array', + 'items': {'type': 'string'} + } + + json_schema = convert_to_json_schema(openapi_schema) + + assert json_schema['type'] == 'array' + assert json_schema['items']['type'] == 'string' + + def test_convert_nullable(self): + """Test conversion of nullable type (OpenAPI 3.0.x).""" + openapi_schema = { + 'type': 'string', + 'nullable': True + } + + json_schema = convert_to_json_schema(openapi_schema) + + assert 'oneOf' in json_schema + assert {'type': 'string'} in json_schema['oneOf'] + assert {'type': 'null'} in json_schema['oneOf'] + + def test_convert_enum(self): + """Test conversion preserves enum.""" + openapi_schema = { + 'type': 'string', + 'enum': ['red', 'green', 'blue'] + } + + json_schema = convert_to_json_schema(openapi_schema) + + assert json_schema['enum'] == ['red', 'green', 'blue'] + + def test_convert_with_ref(self): + """Test conversion resolves $ref.""" + components = { + 'schemas': { + 'User': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'} + } + } + } + } + openapi_schema = {'$ref': '#/components/schemas/User'} + + json_schema = convert_to_json_schema(openapi_schema, components) + + assert json_schema['type'] == 'object' + assert 'name' in json_schema['properties'] + + def test_convert_nested_properties(self): + """Test conversion handles nested object properties.""" + openapi_schema = { + 'type': 'object', + 'properties': { + 'user': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'} + } + } + } + } + + json_schema = convert_to_json_schema(openapi_schema) + + assert json_schema['properties']['user']['type'] == 'object' + assert json_schema['properties']['user']['properties']['name']['type'] == 'string' + + def test_convert_invalid_ref(self): + """Test conversion raises error for invalid $ref.""" + openapi_schema = {'$ref': '#/components/schemas/NonExistent'} + + with pytest.raises(OpenAPIParseError, match="Cannot resolve reference"): + convert_to_json_schema(openapi_schema, {}) + + def test_convert_external_ref(self): + """Test conversion raises error for external $ref.""" + openapi_schema = {'$ref': 'http://example.com/schema.json'} + + with pytest.raises(OpenAPIParseError, match="External references not supported"): + convert_to_json_schema(openapi_schema, {}) + + +class TestEdgeCases: + """Tests for edge cases and error handling.""" + + def test_operation_with_no_parameters(self): + """Test operation with no parameters.""" + operation = { + 'operationId': 'getAll', + 'summary': 'Get all items', + 'responses': {'200': {'description': 'Success'}} + } + + tool = extract_operation_tool('/items', 'get', operation) + + assert tool.input_schema['type'] == 'object' + assert tool.input_schema['properties'] == {} + + def test_operation_with_no_responses(self): + """Test operation with no responses.""" + operation = { + 'operationId': 'deleteItem', + 'summary': 'Delete item' + } + + tool = extract_operation_tool('/items/{id}', 'delete', operation) + + assert tool.output_schema == {'type': 'object'} + + def test_root_path_operation(self): + """Test operation on root path.""" + operation = { + 'summary': 'Get root', + 'responses': {'200': {'description': 'Success'}} + } + + tool = extract_operation_tool('/', 'get', operation) + + assert tool.name == 'get' + + def test_parameter_without_name(self): + """Test parameter without name is skipped.""" + operation = { + 'operationId': 'test', + 'summary': 'Test', + 'parameters': [ + {'in': 'query', 'schema': {'type': 'string'}} + ], + 'responses': {'200': {'description': 'Success'}} + } + + tool = extract_operation_tool('/test', 'get', operation) + + assert tool.input_schema['properties'] == {} diff --git a/strands-agentcore-openapi/tests/unit/test_openapi_parser_integration.py b/strands-agentcore-openapi/tests/unit/test_openapi_parser_integration.py new file mode 100644 index 0000000000..23cc023807 --- /dev/null +++ b/strands-agentcore-openapi/tests/unit/test_openapi_parser_integration.py @@ -0,0 +1,274 @@ +"""Integration tests for OpenAPI parser with realistic specifications.""" + +import pytest +from src.openapi_parser import parse_openapi_spec, OpenAPIParseError + + +class TestWeatherAPISpec: + """Test parsing a realistic Weather API OpenAPI specification.""" + + def test_parse_weather_api_spec(self): + """Test parsing a complete Weather API specification.""" + spec = { + 'openapi': '3.0.3', + 'info': { + 'title': 'Weather API', + 'version': '1.0.0', + 'description': 'API for retrieving weather information' + }, + 'servers': [ + {'url': 'https://api.weather.example.com/v1'} + ], + 'components': { + 'schemas': { + 'WeatherData': { + 'type': 'object', + 'properties': { + 'location': {'type': 'string'}, + 'temperature': {'type': 'number'}, + 'conditions': {'type': 'string'}, + 'humidity': {'type': 'integer'}, + 'wind_speed': {'type': 'number'} + }, + 'required': ['location', 'temperature', 'conditions'] + }, + 'UserContext': { + 'type': 'object', + 'properties': { + 'user_id': {'type': 'string'}, + 'username': {'type': 'string'}, + 'client_id': {'type': 'string'} + }, + 'required': ['user_id', 'username', 'client_id'] + } + }, + 'securitySchemes': { + 'bearerAuth': { + 'type': 'http', + 'scheme': 'bearer', + 'bearerFormat': 'JWT' + } + } + }, + 'security': [ + {'bearerAuth': []} + ], + 'paths': { + '/weather': { + 'get': { + 'operationId': 'getCurrentWeather', + 'summary': 'Get current weather for a location', + 'description': 'Returns current weather conditions including temperature, humidity, and wind speed', + 'parameters': [ + { + 'name': 'location', + 'in': 'query', + 'required': True, + 'description': 'Location name (e.g., "Seattle")', + 'schema': {'type': 'string'} + }, + { + 'name': 'user_context', + 'in': 'query', + 'required': True, + 'description': 'User context for audit trail', + 'schema': {'$ref': '#/components/schemas/UserContext'} + } + ], + 'responses': { + '200': { + 'description': 'Successful response', + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/WeatherData'} + } + } + }, + '400': { + 'description': 'Invalid request' + }, + '401': { + 'description': 'Unauthorized' + } + }, + 'security': [ + {'bearerAuth': []} + ] + } + }, + '/forecast': { + 'get': { + 'operationId': 'getForecast', + 'summary': 'Get weather forecast for a location', + 'description': 'Returns multi-day weather forecast', + 'parameters': [ + { + 'name': 'location', + 'in': 'query', + 'required': True, + 'description': 'Location name', + 'schema': {'type': 'string'} + }, + { + 'name': 'days', + 'in': 'query', + 'required': False, + 'description': 'Number of days to forecast (1-7)', + 'schema': { + 'type': 'integer', + 'minimum': 1, + 'maximum': 7, + 'default': 3 + } + }, + { + 'name': 'user_context', + 'in': 'query', + 'required': True, + 'description': 'User context for audit trail', + 'schema': {'$ref': '#/components/schemas/UserContext'} + } + ], + 'responses': { + '200': { + 'description': 'Successful response', + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'location': {'type': 'string'}, + 'days': {'type': 'integer'}, + 'forecast': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'date': {'type': 'string', 'format': 'date'}, + 'high': {'type': 'number'}, + 'low': {'type': 'number'}, + 'conditions': {'type': 'string'} + } + } + } + } + } + } + } + } + } + } + } + } + } + + # Parse the specification + tools = parse_openapi_spec(spec) + + # Verify we got both operations + assert len(tools) == 2 + tool_names = [t.name for t in tools] + assert 'getCurrentWeather' in tool_names + assert 'getForecast' in tool_names + + # Verify getCurrentWeather tool + current_weather = next(t for t in tools if t.name == 'getCurrentWeather') + assert current_weather.description == 'Get current weather for a location' + assert 'location' in current_weather.input_schema['properties'] + assert 'user_context' in current_weather.input_schema['properties'] + assert 'location' in current_weather.input_schema['required'] + assert 'user_context' in current_weather.input_schema['required'] + + # Verify user_context was resolved from $ref + user_context_schema = current_weather.input_schema['properties']['user_context'] + assert user_context_schema['type'] == 'object' + assert 'user_id' in user_context_schema['properties'] + assert 'username' in user_context_schema['properties'] + assert 'client_id' in user_context_schema['properties'] + + # Verify output schema was resolved from $ref + assert current_weather.output_schema['type'] == 'object' + assert 'location' in current_weather.output_schema['properties'] + assert 'temperature' in current_weather.output_schema['properties'] + assert 'conditions' in current_weather.output_schema['properties'] + + # Verify security requirements + assert current_weather.security == [{'bearerAuth': []}] + + # Verify getForecast tool + forecast = next(t for t in tools if t.name == 'getForecast') + assert forecast.description == 'Get weather forecast for a location' + assert 'location' in forecast.input_schema['properties'] + assert 'days' in forecast.input_schema['properties'] + assert 'user_context' in forecast.input_schema['properties'] + + # Verify days parameter has constraints + days_schema = forecast.input_schema['properties']['days'] + assert days_schema['type'] == 'integer' + assert days_schema['minimum'] == 1 + assert days_schema['maximum'] == 7 + assert days_schema['default'] == 3 + + # Verify forecast output has array structure + assert forecast.output_schema['type'] == 'object' + assert 'forecast' in forecast.output_schema['properties'] + forecast_array = forecast.output_schema['properties']['forecast'] + assert forecast_array['type'] == 'array' + assert 'items' in forecast_array + assert forecast_array['items']['type'] == 'object' + assert 'date' in forecast_array['items']['properties'] + assert 'high' in forecast_array['items']['properties'] + assert 'low' in forecast_array['items']['properties'] + + def test_convert_to_claude_format(self): + """Test converting tool definitions to Claude format.""" + spec = { + 'openapi': '3.0.0', + 'info': {'title': 'Test API', 'version': '1.0.0'}, + 'paths': { + '/test': { + 'get': { + 'operationId': 'testOperation', + 'summary': 'Test operation', + 'parameters': [ + { + 'name': 'param1', + 'in': 'query', + 'required': True, + 'schema': {'type': 'string'} + } + ], + 'responses': { + '200': { + 'description': 'Success', + 'content': { + 'application/json': { + 'schema': {'type': 'object'} + } + } + } + } + } + } + } + } + + tools = parse_openapi_spec(spec) + tool = tools[0] + + # Convert to Claude format + claude_tool = tool.to_claude_format() + + # Verify Claude format has required fields + assert 'name' in claude_tool + assert 'description' in claude_tool + assert 'input_schema' in claude_tool + + # Verify output_schema is NOT in Claude format (Claude doesn't use it) + assert 'output_schema' not in claude_tool + + # Verify values + assert claude_tool['name'] == 'testOperation' + assert claude_tool['description'] == 'Test operation' + assert claude_tool['input_schema']['type'] == 'object' + assert 'param1' in claude_tool['input_schema']['properties'] diff --git a/strands-agentcore-openapi/tests/unit/test_sam_template.py b/strands-agentcore-openapi/tests/unit/test_sam_template.py new file mode 100644 index 0000000000..8e5a9ae038 --- /dev/null +++ b/strands-agentcore-openapi/tests/unit/test_sam_template.py @@ -0,0 +1,114 @@ +"""Unit tests for the AWS SAM template (template.yaml).""" + +import yaml +import pytest +from pathlib import Path + + +def cloudformation_constructor(loader, tag_suffix, node): + """Handle CloudFormation/SAM intrinsic functions like !Ref, !GetAtt, !Sub.""" + if isinstance(node, yaml.ScalarNode): + return loader.construct_scalar(node) + elif isinstance(node, yaml.SequenceNode): + return loader.construct_sequence(node) + elif isinstance(node, yaml.MappingNode): + return loader.construct_mapping(node) + return None + + +yaml.add_multi_constructor('!', cloudformation_constructor, Loader=yaml.SafeLoader) + + +@pytest.fixture +def sam_template(): + """Load the SAM template from the project root.""" + template_path = Path(__file__).parent.parent.parent / 'template.yaml' + with open(template_path, 'r') as f: + return yaml.safe_load(f) + + +class TestSamTemplate: + """Test SAM template structure and content.""" + + def test_template_has_required_sections(self, sam_template): + assert sam_template['AWSTemplateFormatVersion'] == '2010-09-09' + assert sam_template['Transform'] == 'AWS::Serverless-2016-10-31' + assert 'Parameters' in sam_template + assert 'Resources' in sam_template + assert 'Outputs' in sam_template + + def test_parameters_defined(self, sam_template): + params = sam_template['Parameters'] + assert 'EnvironmentName' in params + assert 'WeatherApiKeySecretArn' in params + assert 'CredentialProviderArn' in params + assert 'BedrockModelId' in params + + def test_default_model_id(self, sam_template): + default_model = sam_template['Parameters']['BedrockModelId']['Default'] + assert default_model == 'us.anthropic.claude-sonnet-4-5-20250929-v1:0' + + def test_agent_function_is_serverless(self, sam_template): + resources = sam_template['Resources'] + assert 'AgentFunction' in resources + assert resources['AgentFunction']['Type'] == 'AWS::Serverless::Function' + + def test_agent_function_configuration(self, sam_template): + props = sam_template['Resources']['AgentFunction']['Properties'] + + assert props['Runtime'] == 'python3.12' + assert props['Handler'] == 'agent.handler.lambda_handler' + assert props['CodeUri'] == 'src/' + assert props['MemorySize'] == 1024 + assert props['Timeout'] == 120 + + env_vars = props['Environment']['Variables'] + assert 'COGNITO_JWKS_URL' in env_vars + assert 'GATEWAY_ID' in env_vars + assert 'BEDROCK_MODEL_ID' in env_vars + + def test_log_group_retention(self, sam_template): + resources = sam_template['Resources'] + assert 'AgentFunctionLogGroup' in resources + assert resources['AgentFunctionLogGroup']['Properties']['RetentionInDays'] == 30 + + def test_cognito_configuration(self, sam_template): + resources = sam_template['Resources'] + assert resources['CognitoUserPool']['Type'] == 'AWS::Cognito::UserPool' + + client = resources['CognitoUserPoolClient'] + assert client['Type'] == 'AWS::Cognito::UserPoolClient' + assert 'ALLOW_USER_PASSWORD_AUTH' in client['Properties']['ExplicitAuthFlows'] + + def test_gateway_configuration(self, sam_template): + gateway = sam_template['Resources']['AgentCoreGateway'] + assert gateway['Type'] == 'AWS::BedrockAgentCore::Gateway' + assert gateway['Properties']['AuthorizerType'] == 'CUSTOM_JWT' + assert gateway['Properties']['ProtocolType'] == 'MCP' + + def test_gateway_target_is_openapi(self, sam_template): + resources = sam_template['Resources'] + assert 'WeatherAPITarget' in resources + + target = resources['WeatherAPITarget'] + assert target['Type'] == 'AWS::BedrockAgentCore::GatewayTarget' + # Only created once the credential provider ARN is supplied + assert target['Condition'] == 'HasCredentialProvider' + + target_config = target['Properties']['TargetConfiguration']['Mcp'] + assert 'OpenApiSchema' in target_config + inline_schema = target_config['OpenApiSchema']['InlinePayload'] + assert 'getCurrentWeather' in inline_schema + assert 'api.weatherapi.com' in inline_schema + + def test_gateway_target_uses_api_key_credential(self, sam_template): + target = sam_template['Resources']['WeatherAPITarget'] + cred_configs = target['Properties']['CredentialProviderConfigurations'] + assert cred_configs[0]['CredentialProviderType'] == 'API_KEY' + + def test_stack_outputs(self, sam_template): + outputs = sam_template['Outputs'] + assert 'GatewayId' in outputs + assert 'CognitoUserPoolId' in outputs + assert 'CognitoClientId' in outputs + assert 'AgentLambdaArn' in outputs