diff --git a/.gitignore b/.gitignore index 49219fd..4071b9f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,12 @@ node_modules/ *.log *.swp +# Shell configurations +.zshrc + # Amazon Q Developer settings and rules .amazonq + +# Kiro settings and rules +.kiro + diff --git a/README.md b/README.md index 5df8014..26bb799 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,8 @@ To fully utilize the Generative AI Toolkit, it’s essential to understand the f 2.6 [Generating Traces: Running Cases in Bulk](#26-generating-traces-running-cases-in-bulk) 2.7 [CloudWatch Custom Metrics](#27-cloudwatch-custom-metrics) 2.8 [Deploying and Invoking the BedrockConverseAgent](#28-deploying-and-invoking-the-bedrockconverseagent) + 2.8.1 [General Deployment Patterns](#281-general-deployment-patterns) + 2.8.2 [Amazon Bedrock AgentCore Integration](#282-amazon-bedrock-agentcore-integration) 2.9 [Web UI for Conversation Debugging](#29-web-ui-for-conversation-debugging) 2.10 [Mocking and Testing](#210-mocking-and-testing) 2.11 [Model Context Protocol (MCP) Client](#211-model-context-protocol-mcp-client) @@ -1703,6 +1705,8 @@ In your Lambda function definition, if the above file is stored as `index.py`, y ### 2.8 Deploying and Invoking the `BedrockConverseAgent` +#### 2.8.1 General Deployment Patterns + > Also see our **sample notebook [deploying_on_aws.ipynb](/examples/deploying_on_aws.ipynb)**. A typical deployment of an agent using the Generative AI Toolkit would be, per the [reference architecture](#reference-architecture) mentioned above: @@ -1711,7 +1715,7 @@ A typical deployment of an agent using the Generative AI Toolkit would be, per t 2. An Amazon DynamoDB table to store conversation history and traces. This table has a stream enabled. The AWS Lambda function, your agent, would write its traces to this table. Additionally (using the `TeeTracer` and the `OtlpTracer`) the agent would send the traces to AWS X-Ray for developer inspection. 3. An AWS Lambda Function, that is attached to the DynamoDB table stream, to run `GenerativeAIToolkit.eval()` on the collected traces. This Lambda function would write the collected measurements to stdout in EMF format (see above), to make the measurements available in Amazon CloudWatch Metrics. -#### Using the `Runner` to run your agent as Lambda function +##### Using the `Runner` to run your agent as Lambda function The following code shows how you can implement your Generative AI Toolkit based agent as Lambda function, per the description above. @@ -1747,7 +1751,7 @@ In your Lambda function definition, if the above file is stored as `index.py`, y Note that you must use the [AWS Lambda Web Adapter](https://github.com/awslabs/aws-lambda-web-adapter) to run the `Runner` on AWS Lambda. -#### Invoking the AWS Lambda Function URL with the `IamAuthInvoker` +##### Invoking the AWS Lambda Function URL with the `IamAuthInvoker` If you use the `Runner` just explained, you would deploy your agent as an AWS Lambda Function that is exposed as Function URL. You should enable IAM Auth, in which case you must [sign all requests with AWS Signature V4 as explained here](https://docs.aws.amazon.com/lambda/latest/dg/urls-invocation.html). @@ -1778,7 +1782,7 @@ for tokens in response2: print(tokens, end="", flush=True) ``` -#### Invoking the AWS Lambda Function URL with `curl` +##### Invoking the AWS Lambda Function URL with `curl` Using `curl` works too because `curl` supports SigV4 out of the box: @@ -1794,7 +1798,7 @@ curl -v \ --aws-sigv4 "aws:amz:$AWS_REGION:lambda" ``` -#### Deployments outside AWS Lambda e.g. containerized as a pod on EKS +##### Deployments outside AWS Lambda e.g. containerized as a pod on EKS The `Runner` is a WSGI application and can be run with any compatible server, such as `gunicorn`. @@ -1837,7 +1841,7 @@ Make sure to tune concurrency. By default `gunicorn` runs with 1 worker (process gunicorn --workers 4 --threads 5 "path.to.agent:Runner()" ``` -#### Security: ensure users access their own conversation history only +##### Security: ensure users access their own conversation history only You must make sure that users can only set the conversation ID to an ID of one of their own conversations, or they would be able to read conversations from other users (unless you want that of course). To make this work securely with the out-of-the-box `DynamoDbConversationHistory`, you need to set the right auth context on the agent for each conversation with a user. @@ -1881,7 +1885,7 @@ Runner.configure(agent=my_agent, auth_context_fn=extract_x_user_id_from_request) > The `Runner` uses, by default, the AWS IAM `userId` as auth context. The actual value of this `userId` depends on how you've acquired AWS credentials to sign the AWS Lambda Function URL request with. For example, if you've assumed an AWS IAM Role it will simply be the concatenation of your assumed role ID with your chosen session ID. You'll likely want to customize the auth context as explained in this paragraph! -#### Security: ensure your tools operate with the right privileges +##### Security: ensure your tools operate with the right privileges Where relevant, your tools should use the `auth_context` within the `AgentContext` to determine the identity of the user (e.g. for authorization): @@ -2002,6 +2006,22 @@ INSERT INTO customer_orders (customer_id, order_details, amount) VALUES ('user123', 'Order for mouse', 25.99); ``` +#### 2.8.2 Amazon Bedrock AgentCore Integration + +The Generative AI Toolkit integrates with Amazon Bedrock AgentCore Runtime for agent deployments. AgentCore provides managed infrastructure, automatic scaling, and enterprise security features. + +The `examples/agentcore/` directory contains a complete weather agent implementation that demonstrates: + +- **Containerized Architecture**: Separate containers for agent and MCP server components +- **Model Context Protocol (MCP)**: Tool communication between agent and server containers +- **Pydantic-Based Tools**: Type-safe tool definitions with automatic schema generation +- **CDK Infrastructure**: Automated deployment templates +- **Comprehensive Testing**: Test suite with evaluation framework integration + +This example showcases building scalable, maintainable AI agents with proper separation of concerns and automated deployment patterns. + +> **See the complete example**: [examples/agentcore/README.md](/examples/agentcore/README.md) + ### 2.9 Web UI for Conversation Debugging The Generative AI Toolkit provides a local, web-based user interface (UI) to help you inspect and debug conversations, view evaluation results, and analyze agent behavior. This UI is particularly useful during development and testing phases, allowing you to quickly identify issues, review traces, and understand how your agent processes user queries and responds. diff --git a/examples/agentcore/README.md b/examples/agentcore/README.md new file mode 100644 index 0000000..869a2a2 --- /dev/null +++ b/examples/agentcore/README.md @@ -0,0 +1,149 @@ +# AgentCore Integration Example + +This example demonstrates how to build a weather agent with the Generative AI Toolkit and deploy it on Amazon Bedrock AgentCore Runtime. It showcases AI agent architecture with separate containerized components communicating via the Model Context Protocol (MCP). + +## What is AgentCore? + +Amazon Bedrock AgentCore Runtime provides a managed, serverless environment for deploying AI agents and their supporting services. Key advantages for this example: + +- **Containerized Deployment**: Both agent and MCP server run in separate, scalable containers +- **Managed Infrastructure**: No server management, automatic scaling, and built-in monitoring +- **Enterprise Security**: IAM-based access control, Cognito authentication, and JWT token validation +- **Service Isolation**: Agent and tools run independently, enabling better reliability and scaling +- **Observability**: Built-in logging, tracing, and health monitoring + +## Architecture + +### Separated Components + +**Agent Container** (`agent/`): +- Weather assistant using `BedrockConverseAgent` +- Connects to MCP server for tool access +- Handles user interactions and conversation flow + +**MCP Server Container** (`mcp-server/`): +- Provides weather tools via Model Context Protocol +- Modular tool architecture with separate modules per tool +- Independent scaling and deployment lifecycle + +### Pydantic-Based Tools + +This example demonstrates best practices for tool development: + +- **Type Safety**: Pydantic models ensure robust input validation +- **Rich Documentation**: Model docstrings become tool descriptions for the LLM +- **JSON Schema Generation**: Automatic schema creation for tool parameters +- **Modular Design**: Each tool (`get_weather_tool.py`, `get_forecast_tool.py`) is self-contained +- **Maintainable Code**: Clear separation between models, business logic, and MCP registration + +## Directory Structure + +``` +examples/agentcore/ +├── agent/ # Weather agent implementation +│ ├── agent.py # Main agent with BedrockConverseAgent +│ ├── mcp_tool_manager.py # MCP client integration +│ └── simple_mcp_client.py # AgentCore MCP client +├── mcp-server/ # Modular MCP tools server +│ ├── get_weather_tool.py # Current weather tool + models +│ ├── get_forecast_tool.py # Forecast tool + models +│ └── mcp_server.py # FastMCP server setup +├── infrastructure/ # CDK deployment stack +├── tests/ # Comprehensive test suite +└── docker-compose.yml # Local development environment +``` + +## Deployment Instructions + +### Prerequisites + +1. **AWS CLI configured** with appropriate permissions +2. **Node.js and npm** installed for CDK +3. **Docker** installed for container builds +4. **Python 3.13+** for local development + +### Step-by-Step Deployment + +#### 1. Install Dependencies + +```bash +# Install CDK dependencies +cd infrastructure +npm install + +# Install Python dependencies (optional, for local testing) +cd ../ +pip install -r requirements.txt +``` + +#### 2. Configure Environment + +```bash +# Set your AWS region +export AWS_REGION=us-east-1 + +# Choose a Bedrock model (ensure it's available in your region) +export BEDROCK_MODEL_ID=anthropic.claude-3-5-sonnet-20241022-v2:0 +``` + +#### 3. Deploy Infrastructure + +```bash +cd infrastructure + +# Bootstrap CDK (first time only) +npx cdk bootstrap + +# Optional: Set custom stack name (defaults to "{username}-agentcore-stack") +export CDK_STACK_NAME=MyWeatherAgent + +# Deploy the stack +npx cdk deploy --all +``` + +This will create: +- Two AgentCore Runtime environments (agent and MCP server) +- ECR repositories for container images +- IAM roles and policies +- Cognito User Pool for authentication + +#### 4. Build and Push Container Images + +The CDK deployment automatically builds and pushes the Docker images to ECR. Monitor the deployment output for any build failures. + +#### 5. Test the Deployment + +```bash +# Run the test suite to verify deployment +cd ../tests + +# Test agent deployment +python -m pytest agent/test_agent_deployment.py -v + +# Test MCP server deployment +python -m pytest mcp_server/test_mcp_server_deployment.py -v +``` + +### Local Development + +For local testing without AgentCore: + +```bash +# Start MCP server +cd mcp-server +python mcp_server.py + +# In another terminal, test the agent locally +cd ../tests +python -m pytest test_agent_local.py -v +``` + +## Getting Started + +See `examples/agentcore/tests/` for comprehensive examples of: +- Local development and testing +- AgentCore deployment validation +- Tool schema verification +- End-to-end agent evaluation + +The test suite demonstrates both local development workflows and deployment patterns, making it the best starting point for understanding how to use this example. \ No newline at end of file diff --git a/examples/agentcore/agent/.dockerignore b/examples/agentcore/agent/.dockerignore new file mode 100644 index 0000000..3dc4da4 --- /dev/null +++ b/examples/agentcore/agent/.dockerignore @@ -0,0 +1,73 @@ +# Docker ignore file for weather agent + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +*.log +logs/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# Documentation +docs/ +*.md +!README.md + +# Git +.git/ +.gitignore + +# Docker +Dockerfile* +.dockerignore +docker-compose*.yml \ No newline at end of file diff --git a/examples/agentcore/agent/Dockerfile b/examples/agentcore/agent/Dockerfile new file mode 100644 index 0000000..dc55abd --- /dev/null +++ b/examples/agentcore/agent/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1 +FROM python:3.13-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Create non-root user +RUN useradd -m -u 1000 agentcore + +# Copy application files (copy all Python files for the module) +COPY --chown=agentcore:agentcore *.py ./ + +# Switch to non-root user +USER agentcore + +EXPOSE 8080 + +# Health check endpoint (AgentCore provides /ping and /health endpoints) +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8080/ping || exit 1 + +CMD ["python", "agent.py"] diff --git a/examples/agentcore/agent/agent.py b/examples/agentcore/agent/agent.py new file mode 100644 index 0000000..efd8622 --- /dev/null +++ b/examples/agentcore/agent/agent.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Weather Agent for AgentCore Runtime using Generative AI Toolkit with MCP integration.""" + +import asyncio +import base64 +import json +import logging +import os +import sys + +import boto3 +from bedrock_agentcore.runtime import BedrockAgentCoreApp +from bedrock_agentcore.runtime.context import RequestContext +from mcp_tool_manager import McpToolManager + +from generative_ai_toolkit.agent import BedrockConverseAgent +from generative_ai_toolkit.tracer import InMemoryTracer + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +logger.info("Agent starting - logger configured") + + +def validate_environment_variables(): + """Validate required environment variables are present.""" + required_vars = { + "AWS_REGION": "AWS region for Bedrock service", + "BEDROCK_MODEL_ID": "Bedrock model identifier", + "MCP_SERVER_RUNTIME_ARN": "MCP server runtime ARN for tool integration", + } + + missing_vars = [] + for var, description in required_vars.items(): + if not os.environ.get(var): + missing_vars.append(f"{var} ({description})") + + if missing_vars: + logger.error("Missing required environment variables:") + for var in missing_vars: + logger.error(f" - {var}") + logger.error("Please set these environment variables before running the agent.") + sys.exit(1) + + # Log key configuration + logger.info("Key agent configuration:") + logger.info(f" AWS_REGION: {os.environ.get('AWS_REGION')}") + logger.info(f" BEDROCK_MODEL_ID: {os.environ.get('BEDROCK_MODEL_ID')}") + logger.info(f" MCP_SERVER_RUNTIME_ARN: {os.environ.get('MCP_SERVER_RUNTIME_ARN')}") + logger.info(" Authentication: JWT Bearer token (passed from request headers)") + + +# Validate environment on startup +validate_environment_variables() + +app = BedrockAgentCoreApp() + +# Global MCP tool manager instance +mcp_manager = McpToolManager() + + +def extract_session_info(payload: dict[str, object]) -> tuple[str, str]: + """Extract session ID and user message from AgentCore payload.""" + logger.info(f"Received invocation: {payload}") + + # Extract session ID for correlation (AgentCore observability best practice) + session_id = payload.get("sessionId") or payload.get("session_id", "unknown") + logger.info(f"Processing request for session: {session_id}") + + # Extract prompt from AgentCore format + if "input" not in payload or "prompt" not in payload["input"]: + raise ValueError( + "Invalid payload format. Expected: {'input': {'prompt': 'message'}}" + ) + + user_message = str(payload["input"]["prompt"]) + logger.info(f"Processing message: {user_message[:100]}...") + + return session_id, user_message + + +def create_bedrock_agent(tracer=None) -> BedrockConverseAgent: + """Create and configure the Bedrock Converse Agent.""" + region_name = os.environ["AWS_REGION"] # Required env var, validated at startup + model_id = os.environ["BEDROCK_MODEL_ID"] # Required env var, validated at startup + + session = boto3.Session(region_name=region_name) + + agent_kwargs = { + "model_id": model_id, + "session": session, + "system_prompt": "You are a helpful weather assistant. You have access to weather tools to provide accurate, real-time weather information. Use the available tools when users ask about weather conditions, forecasts, or related information.", + } + + if tracer: + agent_kwargs["tracer"] = tracer + + return BedrockConverseAgent(**agent_kwargs) + + +def register_mcp_tools_safely(agent: BedrockConverseAgent) -> bool: + """Register MCP tools with the agent, handling errors gracefully.""" + try: + # Use a simple approach - just use asyncio.run and let the MCP manager handle connection state + tools_registered = asyncio.run(mcp_manager.register_mcp_tools(agent)) + + if tools_registered: + logger.info("MCP tools available for this request") + return True + else: + logger.warning("MCP tools not available - using agent without tools") + return False + except Exception as mcp_error: + logger.error(f"MCP tool registration failed: {mcp_error}") + logger.info("Continuing without MCP tools") + return False + + +def handle_bedrock_validation_error(error_msg: str) -> dict[str, str]: + """Handle specific Bedrock ValidationException errors.""" + if "model identifier is invalid" in error_msg: + logger.error( + f"Model '{os.environ.get('BEDROCK_MODEL_ID')}' is not available in region '{os.environ.get('AWS_REGION')}'" + ) + logger.error( + "Please check available models with: aws bedrock list-foundation-models --region " + ) + return { + "result": f"Model configuration error: {os.environ.get('BEDROCK_MODEL_ID')} is not available in {os.environ.get('AWS_REGION')}" + } + elif "on-demand throughput isn't supported" in error_msg: + logger.error( + f"Model '{os.environ.get('BEDROCK_MODEL_ID')}' requires an inference profile for access" + ) + logger.error( + "Please check available inference profiles with: aws bedrock list-inference-profiles --region " + ) + logger.error("Use an inference profile ID instead of the direct model ID") + return { + "result": f"Model access error: {os.environ.get('BEDROCK_MODEL_ID')} requires an inference profile. Use an inference profile ID instead." + } + return None + + +def handle_error(e: Exception) -> dict[str, str]: + """Handle and categorize different types of errors.""" + error_msg = str(e) + + # Handle Bedrock ValidationException errors + if "ValidationException" in error_msg: + bedrock_error_response = handle_bedrock_validation_error(error_msg) + if bedrock_error_response: + return bedrock_error_response + + logger.error(f"Error processing invocation: {e}", exc_info=True) + + # Check if this is an MCP-related error + if "mcp" in error_msg.lower() or "tool" in error_msg.lower(): + return { + "result": "I'm sorry, but I'm currently unable to access weather tools. Please try again later or contact support if the issue persists." + } + + return {"result": f"Error: {error_msg}"} + + +# Module-level agent instance - created once and reused +bedrock_agent: BedrockConverseAgent = create_bedrock_agent(tracer=InMemoryTracer()) + +# MCP tools will be registered lazily when JWT tokens are available + + +@app.entrypoint +def invoke(payload: dict[str, object], context: RequestContext) -> dict[str, str]: + """Process agent invocation from AgentCore Runtime.""" + + logger.info("Agent invoked.") + + try: + jwt_token = None + + # Check for Authorization header and extract JWT token + if context.request_headers and "Authorization" in context.request_headers: + auth_header = context.request_headers["Authorization"] + + # Extract JWT token (remove "Bearer " prefix) + if auth_header.startswith("Bearer "): + jwt_token = auth_header[7:] # Remove "Bearer " prefix + + # Pass JWT token to MCP manager for tool authentication + mcp_manager.set_jwt_token(jwt_token) + + # Register MCP tools now that we have a JWT token (lazy registration) + register_mcp_tools_safely(bedrock_agent) + + # Decode JWT token to extract user information (without verification for info extraction) + try: + # Decode JWT payload (second part after splitting by '.') + parts = jwt_token.split(".") + if len(parts) >= 2: + # Add padding if needed for base64 decoding + payload_b64 = parts[1] + payload_b64 += "=" * (4 - len(payload_b64) % 4) + payload_bytes = base64.urlsafe_b64decode(payload_b64) + jwt_claims = json.loads(payload_bytes) + + # Extract user information + user_id = jwt_claims.get("sub") + username = jwt_claims.get("username") + + if user_id: + logger.info( + f"Processing request for user: {user_id} (username: {username})" + ) + else: + logger.info("No user ID found in JWT claims") + else: + logger.warning("Invalid JWT token format") + + except Exception as e: + logger.error(f"Error decoding JWT token: {e}") + else: + logger.warning("Authorization header doesn't start with 'Bearer '") + else: + logger.warning( + "No Authorization header found - MCP tools may not be available" + ) + if context.request_headers: + logger.info( + f"Available headers: {list(context.request_headers.keys())}" + ) + else: + logger.info("request_headers is None") + + # If no JWT token is available, warn about limited functionality + if not jwt_token: + logger.warning("No JWT token available - MCP tools will not be accessible") + + # Extract session information and user message + session_id, user_message = extract_session_info(payload) + + # Get response from agent (with or without tools) + logger.info("Calling Bedrock Converse API") + response = bedrock_agent.converse(user_message) + + logger.info(f"Sending response: {response[:100]}...") + return {"result": response} + + except Exception as e: + return handle_error(e) + + +if __name__ == "__main__": + app.run() diff --git a/examples/agentcore/agent/mcp_tool_manager.py b/examples/agentcore/agent/mcp_tool_manager.py new file mode 100644 index 0000000..6b6e7ca --- /dev/null +++ b/examples/agentcore/agent/mcp_tool_manager.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""MCP Tool Manager for integrating MCP servers with Generative AI Toolkit agents.""" + +import asyncio +import logging +import os + +from simple_mcp_client import SimpleMcpClient + +from generative_ai_toolkit.agent import BedrockConverseAgent +from generative_ai_toolkit.agent.tool import Tool + +logger = logging.getLogger(__name__) + + +class McpTool(Tool): + """A Tool implementation that wraps MCP tool calls.""" + + def __init__(self, mcp_tool, tool_manager: "McpToolManager"): + self.mcp_tool = mcp_tool + self.tool_manager = tool_manager + self._tool_spec = { + "name": mcp_tool.name, + "description": mcp_tool.description, + "inputSchema": {"json": mcp_tool.inputSchema}, + } + + @property + def tool_spec(self): + return self._tool_spec + + def invoke(self, **kwargs): + """Invoke the MCP tool synchronously.""" + try: + # Run the async operation synchronously to match Tool protocol + return asyncio.run(self._async_invoke(**kwargs)) + except Exception as e: + logger.error( + f"Error calling MCP tool {self.mcp_tool.name}: {e}", exc_info=True + ) + return f"Error calling {self.mcp_tool.name}: {str(e)}" + + async def _async_invoke(self, **kwargs): + """Internal async implementation of MCP tool invocation.""" + try: + # Create a dedicated client for this tool call to avoid concurrency issues + client = await self.tool_manager.get_dedicated_client() + + logger.info(f"Calling MCP tool '{self.mcp_tool.name}' with args: {kwargs}") + + # Use the client in a context manager to ensure proper cleanup + async with client: + result = await client.call_tool(self.mcp_tool.name, kwargs) + logger.info(f"MCP tool '{self.mcp_tool.name}' completed successfully") + + # Extract text content from MCP result + if hasattr(result, "content") and result.content: + if hasattr(result.content[0], "text"): + return result.content[0].text + else: + return str(result.content[0]) + else: + return str(result) + except Exception as e: + logger.error( + f"Error in _async_invoke for {self.mcp_tool.name}: {e}", exc_info=True + ) + raise + + +class McpToolManager: + """Manages MCP client and tool registration.""" + + def __init__(self): + self.mcp_client: SimpleMcpClient | None = None + self.tools_registered = False + self.current_jwt_token: str | None = None + + def set_jwt_token(self, jwt_token: str) -> None: + """Set the JWT token for MCP authentication.""" + # If JWT token changed, reset registration state to allow re-registration + if self.current_jwt_token != jwt_token: + self.tools_registered = False + + self.current_jwt_token = jwt_token + # If we have an existing client, update its token + if self.mcp_client: + self.mcp_client.set_jwt_token(jwt_token) + + def _get_or_create_client(self) -> SimpleMcpClient: + """Get or create MCP client instance (without connecting).""" + if self.mcp_client is None: + mcp_arn = os.environ[ + "MCP_SERVER_RUNTIME_ARN" + ] # Required env var, validated at startup + + logger.info(f"Creating MCP client for runtime: {mcp_arn}") + self.mcp_client = SimpleMcpClient( + runtime_arn=mcp_arn, jwt_token=self.current_jwt_token + ) + + # Ensure the client has the current JWT token + if self.current_jwt_token: + self.mcp_client.set_jwt_token(self.current_jwt_token) + + return self.mcp_client + + async def get_connected_client(self) -> SimpleMcpClient: + """Get MCP client and ensure it's connected (for tool registration only).""" + client = self._get_or_create_client() + + # Simple connection check without testing - avoid connection interference + try: + if not client.is_connected(): + logger.info("Connecting to MCP server") + await client.connect() + logger.info("MCP client connected successfully") + else: + logger.debug("Reusing existing MCP connection") + except Exception as e: + logger.info(f"Connection invalid ({e}), reconnecting...") + # Force reconnection + try: + await client.disconnect() + except Exception: # nosec B110 + pass # Ignore disconnect errors during reconnection + + await client.connect() + logger.info("MCP client reconnected successfully") + + return client + + async def get_dedicated_client(self) -> SimpleMcpClient: + """Get a dedicated MCP client for a single tool call to avoid concurrency issues.""" + mcp_arn = os.environ["MCP_SERVER_RUNTIME_ARN"] + + # Create a new client instance for this specific call with current JWT token + logger.debug("Creating dedicated MCP client for tool call") + return SimpleMcpClient(runtime_arn=mcp_arn, jwt_token=self.current_jwt_token) + + async def register_mcp_tools(self, agent: BedrockConverseAgent) -> bool: + """Register MCP tools with the Generative AI Toolkit agent.""" + if self.tools_registered: + logger.info("MCP tools already registered, skipping registration") + return True + + try: + # Get connected MCP client + mcp_client = await self.get_connected_client() + + # List available tools from MCP server + tools_result = await mcp_client.list_tools() + + if not tools_result.tools: + logger.warning("No tools available from MCP server") + return False + + logger.info(f"Registering {len(tools_result.tools)} MCP tools with agent") + + # Register each MCP tool with the Generative AI Toolkit + for mcp_tool in tools_result.tools: + logger.info(f"Registering tool: {mcp_tool.name}") + + # Create MCP tool wrapper + tool = McpTool(mcp_tool, self) + + # Register with the agent + agent.register_tool(tool) + + self.tools_registered = True + logger.info("MCP tools registered successfully") + return True + + except Exception as e: + logger.error(f"Failed to register MCP tools: {e}") + return False + + async def cleanup(self): + """Clean up MCP client resources.""" + if self.mcp_client: + try: + await self.mcp_client.disconnect() + except Exception as e: + logger.warning(f"Error during MCP client cleanup: {e}") + finally: + self.mcp_client = None + self.tools_registered = False diff --git a/examples/agentcore/agent/requirements.txt b/examples/agentcore/agent/requirements.txt new file mode 100644 index 0000000..ca95124 --- /dev/null +++ b/examples/agentcore/agent/requirements.txt @@ -0,0 +1,5 @@ +bedrock-agentcore>=0.1.7 +generative-ai-toolkit +boto3>=1.34.0 +mcp>=1.0.0 +pytest-asyncio>=0.21.0 diff --git a/examples/agentcore/agent/simple_mcp_client.py b/examples/agentcore/agent/simple_mcp_client.py new file mode 100644 index 0000000..31ef97c --- /dev/null +++ b/examples/agentcore/agent/simple_mcp_client.py @@ -0,0 +1,190 @@ +""" +Simple MCP client for AgentCore Runtime with OAuth Bearer token authentication. + +Provides basic MCP client functionality for short-running examples. +No complex session management or token refresh needed. +""" + +import asyncio +import logging +from typing import Any +from urllib.parse import quote + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +logger = logging.getLogger(__name__) + +# Suppress expected warnings during cleanup +logging.getLogger("httpx").setLevel(logging.WARNING) # HTTP 404s during cleanup +logging.getLogger("asyncio").setLevel( + logging.CRITICAL +) # Library-level async cleanup issues + + +class SimpleMcpClient: + """Simple MCP client for AgentCore Runtime with JWT token authentication.""" + + def __init__(self, runtime_arn: str, jwt_token: str = None): + """ + Initialize simple MCP client. + + Args: + runtime_arn: AgentCore Runtime ARN for the MCP server + jwt_token: JWT Bearer token for authentication (optional, can be set later) + """ + self.runtime_arn = runtime_arn + self.region = runtime_arn.split(":")[3] # Extract region from ARN + self.mcp_url = self._construct_mcp_url() + self.jwt_token = jwt_token + + # Connection state + self._session = None + self._transport_context = None + self._session_context = None + self._connected = False + + def _construct_mcp_url(self) -> str: + """Construct the MCP endpoint URL from the runtime ARN.""" + encoded_arn = quote(self.runtime_arn, safe="") + return f"https://bedrock-agentcore.{self.region}.amazonaws.com/runtimes/{encoded_arn}/invocations?qualifier=DEFAULT" + + def set_jwt_token(self, jwt_token: str) -> None: + """ + Set the JWT token for authentication. + + Args: + jwt_token: JWT Bearer token for authentication + """ + self.jwt_token = jwt_token + + async def connect(self) -> None: + """ + Connect to AgentCore Runtime MCP server with JWT token authentication. + + Raises: + Exception: When authentication or connection fails + """ + try: + if not self.jwt_token: + raise Exception( + "JWT token is required for authentication. Call set_jwt_token() first." + ) + + logger.info( + f"Connecting to AgentCore Runtime MCP server: {self.runtime_arn}" + ) + + # Create authenticated headers using the provided JWT token + headers = { + "Authorization": f"Bearer {self.jwt_token}", + "Content-Type": "application/json", + } + + # Connect using streamable HTTP client + self._transport_context = streamablehttp_client( + self.mcp_url, headers, timeout=30 + ) + read_stream, write_stream, _ = await self._transport_context.__aenter__() + + # Create MCP session + self._session_context = ClientSession(read_stream, write_stream) + self._session = await self._session_context.__aenter__() + + # Initialize the MCP session + await asyncio.wait_for(self._session.initialize(), timeout=10.0) + + self._connected = True + logger.info("Successfully connected to AgentCore Runtime MCP server") + + except Exception as e: + logger.error(f"Failed to connect: {e}") + await self._cleanup() + raise + + async def disconnect(self) -> None: + """Disconnect from AgentCore Runtime MCP server.""" + logger.info("Disconnecting from AgentCore Runtime MCP server") + await self._cleanup() + + async def _cleanup(self) -> None: + """Clean up connection resources.""" + self._connected = False + + # Close MCP session + if self._session_context: + try: + await self._session_context.__aexit__(None, None, None) + except Exception as e: + # Only log unexpected errors (404s and cancel scope errors are expected during cleanup) + error_msg = str(e).lower() + if "404" not in error_msg and "cancel scope" not in error_msg: + logger.warning(f"Error closing MCP session: {e}") + finally: + self._session = None + self._session_context = None + + # Close transport + if self._transport_context: + try: + await self._transport_context.__aexit__(None, None, None) + except Exception as e: + # Only log unexpected errors (404s and cancel scope errors are expected during cleanup) + error_msg = str(e).lower() + if "404" not in error_msg and "cancel scope" not in error_msg: + logger.warning(f"Error closing transport: {e}") + finally: + self._transport_context = None + + async def list_tools(self): + """ + List available tools from the MCP server. + + Returns: + MCP tools list result + + Raises: + Exception: When not connected or request fails + """ + if not self._connected or not self._session: + raise Exception("Not connected to MCP server. Call connect() first.") + + logger.info("Listing tools from MCP server") + result = await self._session.list_tools() + logger.info(f"Found {len(result.tools)} tools") + return result + + async def call_tool(self, name: str, arguments: dict[str, Any]): + """ + Call a tool on the MCP server. + + Args: + name: Tool name + arguments: Tool arguments + + Returns: + Tool execution result + + Raises: + Exception: When not connected or tool call fails + """ + if not self._connected or not self._session: + raise Exception("Not connected to MCP server. Call connect() first.") + + logger.info(f"Calling tool '{name}' with arguments: {arguments}") + result = await self._session.call_tool(name, arguments=arguments) + logger.info(f"Tool '{name}' executed successfully") + return result + + def is_connected(self) -> bool: + """Check if connected to MCP server.""" + return self._connected and self._session is not None + + async def __aenter__(self): + """Async context manager entry.""" + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + await self.disconnect() diff --git a/examples/agentcore/infrastructure/.gitignore b/examples/agentcore/infrastructure/.gitignore new file mode 100644 index 0000000..f0a913e --- /dev/null +++ b/examples/agentcore/infrastructure/.gitignore @@ -0,0 +1,11 @@ +*.js +!jest.config.js +*.d.ts +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out + +# Parcel default cache directory +.parcel-cache \ No newline at end of file diff --git a/examples/agentcore/infrastructure/bin/app.ts b/examples/agentcore/infrastructure/bin/app.ts new file mode 100644 index 0000000..8e48663 --- /dev/null +++ b/examples/agentcore/infrastructure/bin/app.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env node +import "source-map-support/register"; +import * as cdk from "aws-cdk-lib"; +import * as fs from "fs"; +import * as path from "path"; +import { AwsSolutionsChecks } from "cdk-nag"; +import { AgentCoreIntegrationStack } from "../lib/agentcore-stack"; +import { CdkNagSuppressions } from "../lib/cdk-nag-suppressions"; + +function getStackName(): string { + // Priority order: + // 1. Explicit environment variable + if (process.env.CDK_STACK_NAME) { + return process.env.CDK_STACK_NAME; + } + + // 2. Auto-generate from username + const username = process.env.USER || process.env.USERNAME || "dev"; + return `${username}-agentcore-stack`; +} + +function writeStackNameToEnv(stackName: string): void { + // Path to .env file in the workspace root (parent of infrastructure directory) + const envPath = path.join(__dirname, "..", "..", ".env"); + + let envContent = ""; + + // Read existing .env file if it exists + if (fs.existsSync(envPath)) { + envContent = fs.readFileSync(envPath, "utf8"); + } + + // Check if CDK_STACK_NAME already exists in the file + const lines = envContent.split("\n"); + let stackNameExists = false; + + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith("CDK_STACK_NAME=")) { + lines[i] = `CDK_STACK_NAME=${stackName}`; + stackNameExists = true; + break; + } + } + + // If CDK_STACK_NAME doesn't exist, add it + if (!stackNameExists) { + // Add a comment and the variable + if (envContent && !envContent.endsWith("\n")) { + lines.push(""); + } + lines.push("# CDK Stack Name (auto-generated)"); + lines.push(`CDK_STACK_NAME=${stackName}`); + } + + // Write back to .env file + fs.writeFileSync(envPath, lines.join("\n")); + console.log(`Updated .env file with CDK_STACK_NAME=${stackName}`); +} + +const app = new cdk.App(); +const stackName = getStackName(); + +// Write the stack name to .env file for persistence and VS Code integration +writeStackNameToEnv(stackName); + +const stack = new AgentCoreIntegrationStack(app, stackName, { + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION, + }, +}); + +// Apply CDK Nag AwsSolutionsChecks +// CDK Nag is disabled by default. Set CDK_NAG_ENABLED=true to enable +const cdkNagEnabled = process.env.CDK_NAG_ENABLED === "true"; +if (cdkNagEnabled) { + // Apply CDK Nag suppressions before running checks + CdkNagSuppressions.applySuppressions(stack); + + cdk.Aspects.of(app).add(new AwsSolutionsChecks({ verbose: true })); + console.log( + "CDK Nag AwsSolutionsChecks applied to the stack with suppressions" + ); +} else { + console.log("CDK Nag disabled (set CDK_NAG_ENABLED=true to enable)"); +} + +// Output the stack name for reference +console.log(`Deploying stack: ${stackName}`); diff --git a/examples/agentcore/infrastructure/cdk.json b/examples/agentcore/infrastructure/cdk.json new file mode 100644 index 0000000..2414867 --- /dev/null +++ b/examples/agentcore/infrastructure/cdk.json @@ -0,0 +1,65 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/app.ts", + "watch": { + "include": ["**"], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "yarn.lock", + "node_modules", + "test" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": ["aws", "aws-cn"], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-iam:standardizedServicePrincipals": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableLogging": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableLogging": true, + "@aws-cdk/aws-nordicapis-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, + "@aws-cdk/aws-ecs-patterns:removeDefaultDesiredCount": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForSourceAction": true, + "@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true, + "@aws-cdk/core:explicitStackTags": true, + "@aws-cdk/core:enableAdditionalMetadataCollection": true, + "@aws-cdk/aws-lambda:useCdkManagedLogGroup": true, + "@aws-cdk/aws-s3:publicAccessBlockedByDefault": true, + "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true, + "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true + } +} diff --git a/examples/agentcore/infrastructure/lambda/set-password/index.py b/examples/agentcore/infrastructure/lambda/set-password/index.py new file mode 100644 index 0000000..e08b746 --- /dev/null +++ b/examples/agentcore/infrastructure/lambda/set-password/index.py @@ -0,0 +1,72 @@ +import json +import logging + +import boto3 + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def on_event(event, context): + """ + Custom resource handler for setting Cognito user password + """ + logger.info(f"Event: {json.dumps(event, default=str)}") + + request_type = event["RequestType"] + if request_type in {"Create", "Update"}: + return on_create_or_update(event, context) + elif request_type == "Delete": + return on_delete(event, context) + else: + raise Exception(f"Invalid request type: {request_type}") + + +def on_create_or_update(event, context): + """ + Handle Create and Update events + """ + props = event["ResourceProperties"] + user_pool_id = props["UserPoolId"] + username = props["Username"] + password = props["Password"] + + # Convert string 'true'/'false' to boolean + permanent_str = props.get("Permanent", "true") + permanent = ( + permanent_str.lower() == "true" + if isinstance(permanent_str, str) + else bool(permanent_str) + ) + + cognito = boto3.client("cognito-idp") + + try: + # Set the user password as permanent + cognito.admin_set_user_password( + UserPoolId=user_pool_id, + Username=username, + Password=password, + Permanent=permanent, + ) + + logger.info( + f"Successfully set password for user {username} in pool {user_pool_id}" + ) + + return { + "PhysicalResourceId": f"{user_pool_id}-{username}", + "Data": {"UserPoolId": user_pool_id, "Username": username}, + } + except Exception as e: + logger.error(f"Error setting password: {str(e)}") + raise e + + +def on_delete(event, context): + """ + Handle Delete events - nothing to do for password setting + """ + logger.info("Delete event - no action required for password setting") + return {"PhysicalResourceId": event["PhysicalResourceId"]} diff --git a/examples/agentcore/infrastructure/lib/agentcore-stack.ts b/examples/agentcore/infrastructure/lib/agentcore-stack.ts new file mode 100644 index 0000000..3b80b95 --- /dev/null +++ b/examples/agentcore/infrastructure/lib/agentcore-stack.ts @@ -0,0 +1,75 @@ +import * as cdk from "aws-cdk-lib"; +import { Construct } from "constructs"; +import { Agent } from "./constructs/agent"; +import { ClientUser } from "./constructs/client-user"; +import { CognitoAuth } from "./constructs/cognito-auth"; +import { McpServer } from "./constructs/mcp-server"; + +export class AgentCoreIntegrationStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // Claude Sonnet 4 model lookup table by region + // Prefers Global inference profile where available for better performance and availability + const claudeSonnet4ModelLookup: Record = { + // Global inference profile supported regions (preferred) + "us-west-2": "global.anthropic.claude-sonnet-4-20250514-v1:0", + "us-east-1": "global.anthropic.claude-sonnet-4-20250514-v1:0", + "us-east-2": "global.anthropic.claude-sonnet-4-20250514-v1:0", + "eu-west-1": "global.anthropic.claude-sonnet-4-20250514-v1:0", + "ap-northeast-1": "global.anthropic.claude-sonnet-4-20250514-v1:0", + + // Regional models for other popular regions + "eu-central-1": "eu.anthropic.claude-sonnet-4-20250514-v1:0", + "eu-west-2": "eu.anthropic.claude-sonnet-4-20250514-v1:0", + "ap-southeast-1": "anthropic.claude-sonnet-4-20250514-v1:0", + "ap-southeast-2": "anthropic.claude-sonnet-4-20250514-v1:0", + "ca-central-1": "anthropic.claude-sonnet-4-20250514-v1:0", + }; + + // Get the model ID based on the deployment region + const deploymentRegion = cdk.Stack.of(this).region; + const bedrockModelId = claudeSonnet4ModelLookup[deploymentRegion]; + + if (!bedrockModelId) { + throw new Error( + `❌ Claude Sonnet 4 is not supported in region: ${deploymentRegion}\n` + + `Supported regions: ${Object.keys(claudeSonnet4ModelLookup).join( + ", " + )}\n` + + `Please deploy to one of the supported regions or update the model lookup table.` + ); + } + + console.log( + `Using Claude Sonnet 4 model: ${bedrockModelId} (region: ${deploymentRegion})` + ); + + // Cognito Authentication Infrastructure + const cognitoAuth = new CognitoAuth(this, "CognitoAuth", { + namePrefix: this.stackName, + }); + + // Client User for invoking agent runtime with JWT tokens + // This user is used by external clients to invoke the agent runtime with OAuth JWT bearer tokens + const clientUser = new ClientUser(this, "ClientUser", { + userPool: cognitoAuth.userPool, + namePrefix: this.stackName, + }); + + // MCP Server with Cognito authentication (JWT passthrough) + const mcpServer = new McpServer(this, "McpServer", { + cognitoAuth: cognitoAuth, + namePrefix: this.stackName, + }); + + // Agent with MCP Server integration and JWT passthrough authentication + const agent = new Agent(this, "Agent", { + namePrefix: this.stackName, + mcpServerRuntimeArn: mcpServer.runtime.attrAgentRuntimeArn, + cognitoAuth: cognitoAuth, + enableJwtAuth: true, // Enable JWT bearer token authentication + bedrockModelId: bedrockModelId, // Required model ID + }); + } +} diff --git a/examples/agentcore/infrastructure/lib/cdk-nag-suppressions.ts b/examples/agentcore/infrastructure/lib/cdk-nag-suppressions.ts new file mode 100644 index 0000000..6c666a1 --- /dev/null +++ b/examples/agentcore/infrastructure/lib/cdk-nag-suppressions.ts @@ -0,0 +1,310 @@ +import { NagSuppressions } from "cdk-nag"; +import { Stack } from "aws-cdk-lib"; +import * as cdk from "aws-cdk-lib"; + +/** + * Centralized CDK Nag suppressions for the AgentCore integration example. + * + * This is an example implementation for AgentCore agent and MCP server demonstration. + * All suppressions in this module are justified for example/demonstration purposes + * and prioritize simplicity and ease of testing over production-grade security. + * + * In production environments, these security features should be properly implemented + * rather than suppressed. Each suppression includes specific justifications for why + * the suppression is acceptable in this example context. + */ +export class CdkNagSuppressions { + /** + * Apply all CDK Nag suppressions to the given stack. + * @param stack The stack to apply suppressions to + */ + static applySuppressions(stack: Stack): void { + this.suppressCognitoMfaRequirement(stack); + this.suppressCognitoAdvancedSecurity(stack); + this.suppressAwsManagedPolicies(stack); + this.suppressCustomResourceWildcardPermissions(stack); + this.suppressSecretsManagerRotation(stack); + this.suppressNecessaryWildcardPermissions(stack); + } + + /** + * Suppress AwsSolutions-COG2: Cognito user pool doesn't require MFA + */ + private static suppressCognitoMfaRequirement(stack: Stack): void { + NagSuppressions.addResourceSuppressionsByPath( + stack, + `/${stack.stackName}/CognitoAuth/UserPool/Resource`, + [ + { + id: "AwsSolutions-COG2", + reason: + "MFA is disabled for simplicity and ease of testing. In production environments, " + + "MFA should be enabled for enhanced security.", + }, + ] + ); + } + + /** + * Suppress AwsSolutions-COG3: Cognito user pool doesn't have AdvancedSecurityMode set to ENFORCED + */ + private static suppressCognitoAdvancedSecurity(stack: Stack): void { + NagSuppressions.addResourceSuppressionsByPath( + stack, + `/${stack.stackName}/CognitoAuth/UserPool/Resource`, + [ + { + id: "AwsSolutions-COG3", + reason: + "Advanced Security Mode requires the Cognito Plus tier (additional cost) and complex " + + "configuration of threat protection features including adaptive authentication and " + + "compromised credentials detection. We prioritize simplicity and cost-effectiveness. " + + "In production environments, AdvancedSecurityMode should be set to ENFORCED for " + + "enhanced security against credential stuffing, brute force attacks, and suspicious sign-in attempts.", + }, + ] + ); + } + + /** + * Suppress AwsSolutions-IAM4: Usage of AWS managed policies + */ + private static suppressAwsManagedPolicies(stack: Stack): void { + // Suppress AWS managed policies for Lambda execution roles + const lambdaRolePaths = [ + `/${stack.stackName}/ClientUser/SetClientUserPasswordProviderHandler/ServiceRole/Resource`, + `/${stack.stackName}/ClientUser/SetClientUserPasswordProvider/framework-onEvent/ServiceRole/Resource`, + `/${stack.stackName}/McpServer/ExecutionRole/Resource`, + `/${stack.stackName}/Agent/ExecutionRole/Resource`, + ]; + + lambdaRolePaths.forEach((path) => { + NagSuppressions.addResourceSuppressionsByPath(stack, path, [ + { + id: "AwsSolutions-IAM4", + reason: + "AWS managed policies like AWSLambdaBasicExecutionRole and AmazonECSTaskExecutionRolePolicy " + + "are used for simplicity and are standard for Lambda and ECS execution roles. In production " + + "environments, consider creating custom policies with minimal required permissions.", + appliesTo: [ + "Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + "Policy::arn::iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", + "Policy::arn::iam::aws:policy/CloudWatchLambdaApplicationSignalsExecutionRolePolicy", + ], + }, + ]); + }); + } + + /** + * Suppress AwsSolutions-IAM5: Wildcard permissions in custom resource policies + */ + private static suppressCustomResourceWildcardPermissions(stack: Stack): void { + // Suppress for ClientUser custom resource + NagSuppressions.addResourceSuppressionsByPath( + stack, + `/${stack.stackName}/ClientUser/SetClientUserPasswordProvider/framework-onEvent/ServiceRole/DefaultPolicy/Resource`, + [ + { + id: "AwsSolutions-IAM5", + reason: + "The wildcard permission is automatically generated by CDK's Custom Resource framework " + + "to allow the provider to invoke the Lambda function with any qualifier (version/alias). " + + "This is a standard CDK pattern for custom resources and is scoped to a specific Lambda " + + "function ARN, making it secure for this use case.", + appliesTo: [ + "Resource:::*", + ], + }, + ] + ); + } + + /** + * Suppress AwsSolutions-SMG4: Secrets Manager automatic rotation not configured + */ + private static suppressSecretsManagerRotation(stack: Stack): void { + // Suppress for ClientUser credentials + NagSuppressions.addResourceSuppressionsByPath( + stack, + `/${stack.stackName}/ClientUser/ClientUserCredentials/Resource`, + [ + { + id: "AwsSolutions-SMG4", + reason: + "The secret contains static client user credentials for invoking agent runtime with " + + "JWT bearer tokens. Automatic rotation is not required for these demonstration " + + "credentials and would add unnecessary complexity and cost. In production environments, " + + "consider enabling automatic rotation for secrets containing sensitive production credentials.", + }, + ] + ); + } + + /** + * Suppress AwsSolutions-IAM5: Necessary wildcard permissions for AWS service requirements + */ + private static suppressNecessaryWildcardPermissions(stack: Stack): void { + // Suppress ECR GetAuthorizationToken wildcard (AWS service requirement) + const executionRolePaths = [ + `/${stack.stackName}/Agent/ExecutionRole/Resource`, + `/${stack.stackName}/McpServer/ExecutionRole/Resource`, + ]; + + executionRolePaths.forEach((path) => { + NagSuppressions.addResourceSuppressionsByPath(stack, path, [ + { + id: "AwsSolutions-IAM5", + reason: + "ECR GetAuthorizationToken requires wildcard resource as per AWS service requirements. " + + "Reference: https://docs.aws.amazon.com/AmazonECR/latest/userguide/security_iam_id-based-policy-examples.html#security_iam_id-based-policy-examples-access-one-bucket", + appliesTo: ["Resource::*"], + }, + ]); + }); + + // Suppress Foundation Models wildcard (global resources) + NagSuppressions.addResourceSuppressionsByPath( + stack, + `/${stack.stackName}/Agent/ExecutionRole/Resource`, + [ + { + id: "AwsSolutions-IAM5", + reason: + "Bedrock foundation models are global AWS resources that cannot be scoped to specific regions. " + + "The wildcard is required to access foundation models across all regions.", + appliesTo: ["Resource::arn:aws:bedrock:*::foundation-model/*"], + }, + ] + ); + + // Suppress CloudWatch with namespace condition (already well-scoped) + NagSuppressions.addResourceSuppressionsByPath( + stack, + `/${stack.stackName}/Agent/ExecutionRole/Resource`, + [ + { + id: "AwsSolutions-IAM5", + reason: + "CloudWatch PutMetricData permission is already well-scoped with a condition that restricts " + + "access to only the 'bedrock-agentcore' namespace. The resource wildcard is necessary for " + + "CloudWatch metrics but is effectively limited by the namespace condition. " + + "Reference: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/permissions-reference-cw.html", + appliesTo: [ + `Resource::arn:aws:cloudwatch:${cdk.Stack.of(stack).region}:${ + cdk.Stack.of(stack).account + }:*`, + ], + }, + ] + ); + + // Suppress remaining runtime endpoint wildcards (necessary for AgentCore functionality) + NagSuppressions.addResourceSuppressionsByPath( + stack, + `/${stack.stackName}/Agent/ExecutionRole/Resource`, + [ + { + id: "AwsSolutions-IAM5", + reason: + "Runtime endpoint wildcard is necessary for AgentCore to invoke other runtime endpoints. " + + "The permission is scoped to the specific account and region, and the wildcard allows " + + "for dynamic runtime endpoint discovery and invocation.", + appliesTo: [ + `Resource::arn:aws:bedrock-agentcore:${ + cdk.Stack.of(stack).region + }:${cdk.Stack.of(stack).account}:runtime-endpoint/*`, + ], + }, + ] + ); + + // Suppress inference profile wildcards (necessary for Bedrock model access) + NagSuppressions.addResourceSuppressionsByPath( + stack, + `/${stack.stackName}/Agent/ExecutionRole/Resource`, + [ + { + id: "AwsSolutions-IAM5", + reason: + "Inference profile wildcard is necessary for Bedrock model invocation. " + + "Inference profiles are dynamically created and managed by AWS Bedrock, " + + "requiring wildcard access within the account and region scope.", + appliesTo: [ + `Resource::arn:aws:bedrock:${cdk.Stack.of(stack).region}:${ + cdk.Stack.of(stack).account + }:inference-profile/*`, + ], + }, + ] + ); + + // Suppress tightened but still necessary wildcards for both Agent and MCP Server + const agentMcpRolePaths = [ + `/${stack.stackName}/Agent/ExecutionRole/Resource`, + `/${stack.stackName}/McpServer/ExecutionRole/Resource`, + ]; + + agentMcpRolePaths.forEach((path) => { + NagSuppressions.addResourceSuppressionsByPath(stack, path, [ + // Note: Workload identity permissions are automatically handled by the + // AWSServiceRoleForBedrockAgentCoreRuntimeIdentity Service-Linked Role + // for agents created on or after October 13, 2025 + { + id: "AwsSolutions-IAM5", + reason: + "CloudWatch Logs permissions are scoped to AgentCore-specific log groups, representing a " + + "significant tightening from the previous broad log group wildcard. These patterns are " + + "necessary for AgentCore runtime logging functionality.", + appliesTo: [ + `Resource::arn:aws:logs:${cdk.Stack.of(stack).region}:${ + cdk.Stack.of(stack).account + }:log-group:/aws/bedrock-agentcore/runtimes/*`, + `Resource::arn:aws:logs:${cdk.Stack.of(stack).region}:${ + cdk.Stack.of(stack).account + }:log-group:/aws/bedrock-agentcore/*`, + `Resource::arn:aws:logs:${cdk.Stack.of(stack).region}:${ + cdk.Stack.of(stack).account + }:log-group:/aws/bedrock-agentcore/*:log-stream:*`, + `Resource::arn:aws:logs:${cdk.Stack.of(stack).region}:${ + cdk.Stack.of(stack).account + }:log-group:*`, + ], + }, + ]); + }); + + // Suppress X-Ray tightened permissions (Agent only) + NagSuppressions.addResourceSuppressionsByPath( + stack, + `/${stack.stackName}/Agent/ExecutionRole/Resource`, + [ + { + id: "AwsSolutions-IAM5", + reason: + "X-Ray permissions are scoped to trace and sampling-rule resources, representing a " + + "significant tightening from the previous broad X-Ray wildcard. These specific resource " + + "types are necessary for distributed tracing functionality.", + appliesTo: [ + `Resource::arn:aws:xray:${cdk.Stack.of(stack).region}:${ + cdk.Stack.of(stack).account + }:trace/*`, + `Resource::arn:aws:xray:${cdk.Stack.of(stack).region}:${ + cdk.Stack.of(stack).account + }:sampling-rule/*`, + ], + }, + { + id: "AwsSolutions-IAM5", + reason: + "Service-Linked Role creation permission is required for AgentCore Identity service. " + + "This is a specific AWS service requirement for agents created before October 13, 2025. " + + "The wildcard in the account portion is required by AWS IAM for service-linked role creation.", + appliesTo: [ + "Resource::arn:aws:iam::*:role/aws-service-role/runtime-identity.bedrock-agentcore.amazonaws.com/AWSServiceRoleForBedrockAgentCoreRuntimeIdentity", + ], + }, + ] + ); + } +} diff --git a/examples/agentcore/infrastructure/lib/constructs/agent.ts b/examples/agentcore/infrastructure/lib/constructs/agent.ts new file mode 100644 index 0000000..e9d8232 --- /dev/null +++ b/examples/agentcore/infrastructure/lib/constructs/agent.ts @@ -0,0 +1,278 @@ +import * as cdk from "aws-cdk-lib"; +import * as bedrockagentcore from "aws-cdk-lib/aws-bedrockagentcore"; +import * as iam from "aws-cdk-lib/aws-iam"; +import { DockerImageAsset, Platform } from "aws-cdk-lib/aws-ecr-assets"; +import { Construct } from "constructs"; +import * as path from "path"; +import { CognitoAuth } from "./cognito-auth"; +import { RequestHeaderConfig } from "./request-header-config"; + +export interface AgentProps { + /** + * The name prefix for Agent resources + */ + readonly namePrefix?: string; + /** + * The MCP Server runtime ARN for tool integration + */ + readonly mcpServerRuntimeArn: string; + /** + * Cognito authentication construct for OAuth infrastructure + */ + readonly cognitoAuth: CognitoAuth; + /** + * Whether to enable JWT bearer token authentication for the agent runtime + * When enabled, the agent can be invoked with OAuth JWT tokens from Cognito + * @default true + */ + readonly enableJwtAuth?: boolean; + /** + * The Bedrock model ID to use for the agent + * Must be available in the deployment region + * @example "anthropic.claude-3-5-sonnet-20241022-v2:0" + * @example "eu.anthropic.claude-3-5-sonnet-20241022-v2:0" (for EU regions) + */ + readonly bedrockModelId: string; +} + +export class Agent extends Construct { + public readonly runtime: bedrockagentcore.CfnRuntime; + public readonly runtimeEndpoint: bedrockagentcore.CfnRuntimeEndpoint; + public readonly executionRole: iam.Role; + public readonly imageAsset: DockerImageAsset; + + constructor(scope: Construct, id: string, props: AgentProps) { + super(scope, id); + + const namePrefix = props.namePrefix || cdk.Stack.of(this).stackName; + + // Build agent container + this.imageAsset = new DockerImageAsset(this, "ImageAsset", { + directory: path.join(__dirname, "../../../agent"), + displayName: "AgentCore Agent", + assetName: "agentcore-agent", + platform: Platform.LINUX_ARM64, + }); + + const enableJwtAuth = props.enableJwtAuth ?? true; + + // IAM execution role + this.executionRole = new iam.Role(this, "ExecutionRole", { + assumedBy: new iam.ServicePrincipal("bedrock-agentcore.amazonaws.com"), + description: + "Execution role for Agent runtime with JWT passthrough authentication", + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AmazonECSTaskExecutionRolePolicy" + ), + iam.ManagedPolicy.fromAwsManagedPolicyName( + "CloudWatchLambdaApplicationSignalsExecutionRolePolicy" + ), + ], + inlinePolicies: { + AgentPermissions: new iam.PolicyDocument({ + statements: [ + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + ], + resources: [ + "arn:aws:bedrock:*::foundation-model/*", + `arn:aws:bedrock:${cdk.Stack.of(this).region}:${ + cdk.Stack.of(this).account + }:inference-profile/*`, + ], + }), + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + "logs:DescribeLogStreams", + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + resources: [ + `arn:aws:logs:${cdk.Stack.of(this).region}:${ + cdk.Stack.of(this).account + }:log-group:/aws/bedrock-agentcore/*`, + `arn:aws:logs:${cdk.Stack.of(this).region}:${ + cdk.Stack.of(this).account + }:log-group:/aws/bedrock-agentcore/*:log-stream:*`, + ], + }), + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ["logs:DescribeLogGroups"], + resources: [ + `arn:aws:logs:${cdk.Stack.of(this).region}:${ + cdk.Stack.of(this).account + }:log-group:*`, + ], + }), + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords", + "xray:GetSamplingRules", + "xray:GetSamplingTargets", + ], + resources: [ + `arn:aws:xray:${cdk.Stack.of(this).region}:${ + cdk.Stack.of(this).account + }:trace/*`, + `arn:aws:xray:${cdk.Stack.of(this).region}:${ + cdk.Stack.of(this).account + }:sampling-rule/*`, + ], + }), + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ["cloudwatch:PutMetricData"], + resources: [ + `arn:aws:cloudwatch:${cdk.Stack.of(this).region}:${ + cdk.Stack.of(this).account + }:*`, + ], + conditions: { + StringEquals: { + "cloudwatch:namespace": "bedrock-agentcore", + }, + }, + }), + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ["ecr:GetAuthorizationToken"], + resources: ["*"], + }), + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"], + resources: [this.imageAsset.repository.repositoryArn], + }), + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ["bedrock-agentcore:InvokeAgentRuntime"], + resources: [ + `arn:aws:bedrock-agentcore:${cdk.Stack.of(this).region}:${ + cdk.Stack.of(this).account + }:runtime-endpoint/*`, + ], + }), + ], + }), + }, + }); + + // Common environment variables for the agent runtime + const environmentVariables = { + AWS_DEFAULT_REGION: cdk.Stack.of(this).region, + AWS_REGION: cdk.Stack.of(this).region, + BEDROCK_MODEL_ID: props.bedrockModelId, + MCP_SERVER_RUNTIME_ARN: props.mcpServerRuntimeArn, + }; + + // Build base runtime configuration + const baseRuntimeConfig = { + agentRuntimeName: `${namePrefix}_agent`.replace(/-/g, "_"), + description: + "AgentCore runtime for the agent (HTTP protocol) with JWT passthrough authentication", + roleArn: this.executionRole.roleArn, + agentRuntimeArtifact: { + containerConfiguration: { + containerUri: this.imageAsset.imageUri, + }, + }, + networkConfiguration: { + networkMode: "PUBLIC", + }, + protocolConfiguration: "HTTP", + environmentVariables, + }; + + // Create runtime configuration with JWT authorization if enabled + const runtimeConfig: bedrockagentcore.CfnRuntimeProps = enableJwtAuth + ? { + ...baseRuntimeConfig, + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: `https://cognito-idp.${ + cdk.Stack.of(this).region + }.amazonaws.com/${ + props.cognitoAuth.userPool.userPoolId + }/.well-known/openid-configuration`, + allowedClients: [ + props.cognitoAuth.userPoolClient.userPoolClientId, + ], + }, + }, + } + : baseRuntimeConfig; + + // Agent Runtime + this.runtime = new bedrockagentcore.CfnRuntime( + this, + "Runtime", + runtimeConfig + ); + + // Configure Authorization header passthrough using Custom Resource + // This works around the CloudFormation limitation for RequestHeaderConfiguration + new RequestHeaderConfig(this, "RequestHeaderConfig", { + runtimeId: this.runtime.attrAgentRuntimeId, + allowedHeaders: ["Authorization"], + runtimeConfig: { + containerUri: this.imageAsset.imageUri, + roleArn: this.executionRole.roleArn, + networkMode: "PUBLIC", + authorizerConfiguration: enableJwtAuth + ? { + customJWTAuthorizer: { + discoveryUrl: `https://cognito-idp.${ + cdk.Stack.of(this).region + }.amazonaws.com/${ + props.cognitoAuth.userPool.userPoolId + }/.well-known/openid-configuration`, + allowedClients: [ + props.cognitoAuth.userPoolClient.userPoolClientId, + ], + }, + } + : undefined, + environmentVariables, + protocolConfiguration: "HTTP", + description: + "AgentCore runtime for the agent (HTTP protocol) with JWT passthrough authentication", + }, + }); + + // Agent Runtime Endpoint + this.runtimeEndpoint = new bedrockagentcore.CfnRuntimeEndpoint( + this, + "RuntimeEndpoint", + { + name: `${namePrefix}_agent_endpoint`.replace(/-/g, "_"), + description: "Runtime endpoint for the agent", + agentRuntimeId: this.runtime.attrAgentRuntimeId, + } + ); + + // Outputs + new cdk.CfnOutput(this, "RuntimeArn", { + value: this.runtime.attrAgentRuntimeArn, + description: "ARN of the Agent runtime", + }); + + new cdk.CfnOutput(this, "RuntimeEndpointArn", { + value: this.runtimeEndpoint.attrAgentRuntimeEndpointArn, + description: "ARN of the Agent runtime endpoint", + }); + + new cdk.CfnOutput(this, "ImageUri", { + value: this.imageAsset.imageUri, + description: "URI of the built agent container image", + }); + } +} diff --git a/examples/agentcore/infrastructure/lib/constructs/client-user.ts b/examples/agentcore/infrastructure/lib/constructs/client-user.ts new file mode 100644 index 0000000..66f6331 --- /dev/null +++ b/examples/agentcore/infrastructure/lib/constructs/client-user.ts @@ -0,0 +1,103 @@ +import * as cdk from "aws-cdk-lib"; +import * as cognito from "aws-cdk-lib/aws-cognito"; +import * as iam from "aws-cdk-lib/aws-iam"; +import { Construct } from "constructs"; +import { + BaseCognitoUserProps, + CognitoUserUtils, + PasswordSalts, + UserCredentials, +} from "./cognito-user-utils"; + +export interface ClientUserProps extends BaseCognitoUserProps { + /** + * The username for the client user + * @default "agent-client-user" + */ + readonly username?: string; + + /** + * The email address for the client user + * @default "agent-client@example.com" + */ + readonly email?: string; +} + +/** + * Creates a client user in Cognito User Pool for invoking the agent runtime with OAuth tokens. + * + * This construct creates a user account that can invoke the agent runtime "with an OAuth + * compliant access token using JWT format" as described in the AWS documentation. + * The credentials are securely stored in AWS Secrets Manager. + * + * This user is used by external clients to invoke the agent runtime using JWT bearer tokens. + * The JWT token is passed through to the MCP server for authentication. + */ +export class ClientUser extends Construct { + public readonly user: cognito.CfnUserPoolUser; + public readonly credentials: UserCredentials; + + constructor(scope: Construct, id: string, props: ClientUserProps) { + super(scope, id); + + const namePrefix = props.namePrefix || cdk.Stack.of(this).stackName; + const username = props.username || "agent-client-user"; + const email = props.email || "agent-client@example.com"; + + // Generate secure random password for client user + const clientPassword = CognitoUserUtils.generateSecurePassword( + cdk.Stack.of(this).stackId, + PasswordSalts.CLIENT_USER + ); + + // Create client user with secure generated credentials + this.user = CognitoUserUtils.createCognitoUser(this, "ClientUser", { + userPool: props.userPool, + username: username, + email: email, + password: clientPassword, + }); + + // Set the user password using a custom resource + CognitoUserUtils.createSetPasswordCustomResource( + this, + "SetClientUserPassword", + props.userPool, + this.user, + clientPassword + ); + + // Store client user credentials in AWS Secrets Manager (encrypted by default) + this.credentials = CognitoUserUtils.createUserCredentials( + this, + "ClientUserCredentials", + { + namePrefix: namePrefix, + secretName: "client-user", + description: + "Client user credentials for invoking agent runtime with OAuth JWT bearer tokens", + username: username, + password: clientPassword, + } + ); + + // Add CDK outputs for client user credentials + CognitoUserUtils.createUserCredentialsOutputs( + this, + "ClientUser", + this.credentials, + username, + "client user credentials used to invoke agent runtime with JWT tokens" + ); + } + + /** + * Create IAM policy for Secrets Manager access to client user credentials + */ + public createSecretsManagerAccessPolicy(): iam.PolicyDocument { + return CognitoUserUtils.createSecretsManagerAccessPolicy( + this, + this.credentials.secret.secretArn + ); + } +} diff --git a/examples/agentcore/infrastructure/lib/constructs/cognito-auth.ts b/examples/agentcore/infrastructure/lib/constructs/cognito-auth.ts new file mode 100644 index 0000000..6fe4462 --- /dev/null +++ b/examples/agentcore/infrastructure/lib/constructs/cognito-auth.ts @@ -0,0 +1,84 @@ +import * as cdk from "aws-cdk-lib"; +import * as cognito from "aws-cdk-lib/aws-cognito"; +import { Construct } from "constructs"; + +export interface CognitoAuthProps { + /** + * The name prefix for Cognito resources + */ + readonly namePrefix?: string; +} + +/** + * Cognito authentication infrastructure for OAuth flows. + * + * This construct creates the core Cognito infrastructure including: + * - Cognito User Pool with secure password policies + * - User Pool Client configured for OAuth flows + * + * Client users should be created separately using the ClientUser construct + * and passed to this construct's user pool. + */ +export class CognitoAuth extends Construct { + public readonly userPool: cognito.UserPool; + public readonly userPoolClient: cognito.UserPoolClient; + + constructor(scope: Construct, id: string, props?: CognitoAuthProps) { + super(scope, id); + + const namePrefix = props?.namePrefix || cdk.Stack.of(this).stackName; + + // Create Cognito User Pool with appropriate password policies + this.userPool = new cognito.UserPool(this, "UserPool", { + userPoolName: `${namePrefix}-user-pool`, + selfSignUpEnabled: false, // Only admin-created users + signInAliases: { + username: true, + }, + passwordPolicy: { + minLength: 12, + requireLowercase: true, + requireUppercase: true, + requireDigits: true, + requireSymbols: true, + }, + accountRecovery: cognito.AccountRecovery.NONE, // Disable recovery for agent users + removalPolicy: cdk.RemovalPolicy.DESTROY, // For development/testing + }); + + // Create Cognito App Client configured for public client OAuth flows + this.userPoolClient = new cognito.UserPoolClient(this, "UserPoolClient", { + userPool: this.userPool, + userPoolClientName: `${namePrefix}-client`, + generateSecret: false, // Public client for OAuth flows + authFlows: { + userPassword: true, // Enable USER_PASSWORD_AUTH flow + userSrp: true, // Enable SRP authentication + }, + accessTokenValidity: cdk.Duration.hours(1), // 1 hour access token + refreshTokenValidity: cdk.Duration.days(30), // 30 days refresh token + preventUserExistenceErrors: true, // Security best practice + }); + + // Add CDK outputs for User Pool ID, Client ID, and Discovery URL + new cdk.CfnOutput(this, "UserPoolId", { + value: this.userPool.userPoolId, + description: "Cognito User Pool ID for OAuth authentication", + exportName: `${cdk.Stack.of(this).stackName}-UserPoolId`, + }); + + new cdk.CfnOutput(this, "UserPoolClientId", { + value: this.userPoolClient.userPoolClientId, + description: "Cognito User Pool Client ID for OAuth authentication", + exportName: `${cdk.Stack.of(this).stackName}-UserPoolClientId`, + }); + + new cdk.CfnOutput(this, "UserPoolDiscoveryUrl", { + value: `https://cognito-idp.${cdk.Stack.of(this).region}.amazonaws.com/${ + this.userPool.userPoolId + }/.well-known/openid_configuration`, + description: "OAuth Discovery URL for the Cognito User Pool", + exportName: `${cdk.Stack.of(this).stackName}-UserPoolDiscoveryUrl`, + }); + } +} diff --git a/examples/agentcore/infrastructure/lib/constructs/cognito-user-utils.ts b/examples/agentcore/infrastructure/lib/constructs/cognito-user-utils.ts new file mode 100644 index 0000000..31ebf83 --- /dev/null +++ b/examples/agentcore/infrastructure/lib/constructs/cognito-user-utils.ts @@ -0,0 +1,286 @@ +import * as cdk from "aws-cdk-lib"; +import * as cognito from "aws-cdk-lib/aws-cognito"; +import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; +import * as iam from "aws-cdk-lib/aws-iam"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import * as cr from "aws-cdk-lib/custom-resources"; +import { Construct } from "constructs"; +import * as path from "path"; + +/** + * Base properties for Cognito user constructs + */ +export interface BaseCognitoUserProps { + /** + * The Cognito User Pool where the user will be created + */ + readonly userPool: cognito.UserPool; + + /** + * The name prefix for user resources + */ + readonly namePrefix?: string; + + /** + * The username for the user + */ + readonly username?: string; + + /** + * The email address for the user + */ + readonly email?: string; +} + +/** + * Configuration for creating a Cognito user + */ +export interface CognitoUserConfig { + readonly userPool: cognito.UserPool; + readonly username: string; + readonly email: string; + readonly password: string; +} + +/** + * Configuration for creating user credentials secret + */ +export interface UserCredentialsConfig { + readonly namePrefix: string; + readonly secretName: string; + readonly description: string; + readonly username: string; + readonly password: string; +} + +/** + * Result of creating user credentials + */ +export interface UserCredentials { + readonly secret: secretsmanager.Secret; +} + +/** + * Utility class for common Cognito user operations + */ +export class CognitoUserUtils { + /** + * Generate a cryptographically secure random password for a user + */ + static generateSecurePassword(stackId: string, passwordSalt: string): string { + // Generate a secure password with mixed case, numbers, and symbols + // This is a deterministic approach for CDK deployment consistency + const hash = require("crypto") + .createHash("sha256") + .update(stackId + passwordSalt) + .digest("hex"); + + // Create a password from the hash with required character types + const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const lowercase = "abcdefghijklmnopqrstuvwxyz"; + const digits = "0123456789"; + const symbols = "!@#$%^&*"; + + let password = ""; + password += + uppercase[parseInt(hash.substring(0, 2), 16) % uppercase.length]; + password += + lowercase[parseInt(hash.substring(2, 4), 16) % lowercase.length]; + password += digits[parseInt(hash.substring(4, 6), 16) % digits.length]; + password += symbols[parseInt(hash.substring(6, 8), 16) % symbols.length]; + + // Add more characters to reach 24 characters total + const allChars = uppercase + lowercase + digits + symbols; + for (let i = 8; i < 48; i += 2) { + password += + allChars[ + parseInt(hash.substring(i % 64, (i % 64) + 2), 16) % allChars.length + ]; + } + + return password; + } + + /** + * Create a Cognito user with the specified configuration + */ + static createCognitoUser( + scope: Construct, + id: string, + config: CognitoUserConfig + ): cognito.CfnUserPoolUser { + return new cognito.CfnUserPoolUser(scope, id, { + userPoolId: config.userPool.userPoolId, + username: config.username, + messageAction: "SUPPRESS", // Don't send welcome email + userAttributes: [ + { + name: "email", + value: config.email, + }, + { + name: "email_verified", + value: "true", + }, + ], + }); + } + + /** + * Create a custom resource to set user password + */ + static createSetPasswordCustomResource( + scope: Construct, + id: string, + userPool: cognito.UserPool, + user: cognito.CfnUserPoolUser, + password: string + ): cdk.CustomResource { + const setPasswordProvider = CognitoUserUtils.createSetPasswordProvider( + scope, + `${id}Provider`, + userPool + ); + + const customResource = new cdk.CustomResource(scope, id, { + serviceToken: setPasswordProvider.serviceToken, + properties: { + UserPoolId: userPool.userPoolId, + Username: user.username, + Password: password, + Permanent: "true", // Pass as string to avoid type conversion issues + }, + }); + + customResource.node.addDependency(user); + return customResource; + } + + /** + * Save user credentials in AWS Secrets Manager + */ + static createUserCredentials( + scope: Construct, + id: string, + config: UserCredentialsConfig + ): UserCredentials { + const secret = new secretsmanager.Secret(scope, id, { + secretName: `${config.namePrefix}/${config.secretName}/credentials`, + description: config.description, + secretObjectValue: { + username: cdk.SecretValue.unsafePlainText(config.username), + password: cdk.SecretValue.unsafePlainText(config.password), + }, + removalPolicy: cdk.RemovalPolicy.DESTROY, // For development/testing + }); + + return { secret }; + } + + /** + * Create IAM policy for Secrets Manager access to user credentials + */ + static createSecretsManagerAccessPolicy( + scope: Construct, + secretArn: string + ): iam.PolicyDocument { + return new iam.PolicyDocument({ + statements: [ + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret", + ], + resources: [secretArn], + }), + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ["kms:Decrypt"], + resources: [ + `arn:aws:kms:${cdk.Stack.of(scope).region}:${ + cdk.Stack.of(scope).account + }:alias/aws/secretsmanager`, + ], + conditions: { + StringEquals: { + "kms:ViaService": `secretsmanager.${ + cdk.Stack.of(scope).region + }.amazonaws.com`, + }, + }, + }), + ], + }); + } + + /** + * Create CDK outputs for user credentials + */ + static createUserCredentialsOutputs( + scope: Construct, + outputPrefix: string, + credentials: UserCredentials, + username: string, + description: string + ): void { + const stackName = cdk.Stack.of(scope).stackName; + + new cdk.CfnOutput(scope, `${outputPrefix}CredentialsSecretArn`, { + value: credentials.secret.secretArn, + description: `Secrets Manager ARN for ${description}`, + exportName: `${stackName}-${outputPrefix}CredentialsSecretArn`, + }); + + new cdk.CfnOutput(scope, `${outputPrefix}CredentialsSecretName`, { + value: credentials.secret.secretName, + description: `Secrets Manager secret name for ${description}`, + exportName: `${stackName}-${outputPrefix}CredentialsSecretName`, + }); + + new cdk.CfnOutput(scope, `${outputPrefix}Username`, { + value: username, + description: `Username for ${description}`, + exportName: `${stackName}-${outputPrefix}Username`, + }); + } + + /** + * Create a custom resource provider to set user password + */ + private static createSetPasswordProvider( + scope: Construct, + id: string, + userPool: cognito.UserPool + ): cr.Provider { + const onEventHandler = new lambda.Function(scope, `${id}Handler`, { + runtime: lambda.Runtime.PYTHON_3_13, + handler: "index.on_event", + code: lambda.Code.fromAsset( + path.join(__dirname, "../../lambda/set-password") + ), + timeout: cdk.Duration.minutes(5), + }); + + // Grant permissions to the Lambda function + onEventHandler.addToRolePolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ["cognito-idp:AdminSetUserPassword"], + resources: [userPool.userPoolArn], + }) + ); + + return new cr.Provider(scope, id, { + onEventHandler: onEventHandler, + }); + } +} + +/** + * Constants for password generation salts + */ +export const PasswordSalts = { + AGENT_USER: "mcp-agent-oauth-password", + CLIENT_USER: "client-user-oauth-password", +} as const; diff --git a/examples/agentcore/infrastructure/lib/constructs/mcp-server.ts b/examples/agentcore/infrastructure/lib/constructs/mcp-server.ts new file mode 100644 index 0000000..6a4f594 --- /dev/null +++ b/examples/agentcore/infrastructure/lib/constructs/mcp-server.ts @@ -0,0 +1,175 @@ +import * as cdk from "aws-cdk-lib"; +import * as bedrockagentcore from "aws-cdk-lib/aws-bedrockagentcore"; +import * as iam from "aws-cdk-lib/aws-iam"; +import { DockerImageAsset, Platform } from "aws-cdk-lib/aws-ecr-assets"; +import { Construct } from "constructs"; +import * as path from "path"; +import { CognitoAuth } from "./cognito-auth"; + +export interface McpServerProps { + /** + * Cognito authentication construct for MCP server authorization + */ + readonly cognitoAuth?: CognitoAuth; + /** + * The name prefix for MCP Server resources + */ + readonly namePrefix?: string; +} + +export class McpServer extends Construct { + public readonly runtime: bedrockagentcore.CfnRuntime; + public readonly runtimeEndpoint: bedrockagentcore.CfnRuntimeEndpoint; + public readonly executionRole: iam.Role; + public readonly imageAsset: DockerImageAsset; + public readonly cognitoAuth?: CognitoAuth; + + constructor(scope: Construct, id: string, props?: McpServerProps) { + super(scope, id); + + this.cognitoAuth = props?.cognitoAuth; + const namePrefix = props?.namePrefix || cdk.Stack.of(this).stackName; + + // Build MCP server container + this.imageAsset = new DockerImageAsset(this, "ImageAsset", { + directory: path.join(__dirname, "../../../mcp-server"), + displayName: "AgentCore MCP Server", + assetName: "agentcore-mcp-server", + platform: Platform.LINUX_ARM64, + }); + + // Build base IAM policy statements + // Note: Workload identity permissions (GetWorkloadAccessTokenForJWT) are automatically + // handled by the AWSServiceRoleForBedrockAgentCoreRuntimeIdentity Service-Linked Role + // for agents created on or after October 13, 2025 + const baseStatements = [ + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + "logs:DescribeLogStreams", + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + resources: [ + `arn:aws:logs:${cdk.Stack.of(this).region}:${ + cdk.Stack.of(this).account + }:log-group:/aws/bedrock-agentcore/runtimes/*`, + ], + }), + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ["logs:DescribeLogGroups"], + resources: [ + `arn:aws:logs:${cdk.Stack.of(this).region}:${ + cdk.Stack.of(this).account + }:log-group:/aws/bedrock-agentcore/*`, + ], + }), + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ["ecr:GetAuthorizationToken"], + resources: ["*"], + }), + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"], + resources: [this.imageAsset.repository.repositoryArn], + }), + ]; + + // Build inline policies + const inlinePolicies: { [name: string]: iam.PolicyDocument } = { + McpServerPermissions: new iam.PolicyDocument({ + statements: baseStatements, + }), + }; + + // IAM execution role + this.executionRole = new iam.Role(this, "ExecutionRole", { + assumedBy: new iam.ServicePrincipal("bedrock-agentcore.amazonaws.com"), + description: + "Execution role for MCP Server runtime with JWT passthrough authentication", + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AmazonECSTaskExecutionRolePolicy" + ), + iam.ManagedPolicy.fromAwsManagedPolicyName( + "CloudWatchLambdaApplicationSignalsExecutionRolePolicy" + ), + ], + inlinePolicies, + }); + + // Build base runtime configuration + const baseRuntimeConfig = { + agentRuntimeName: `${namePrefix}_mcp_server`.replace(/-/g, "_"), + description: + "AgentCore runtime for the MCP server (MCP protocol) with JWT passthrough authentication", + roleArn: this.executionRole.roleArn, + agentRuntimeArtifact: { + containerConfiguration: { + containerUri: this.imageAsset.imageUri, + }, + }, + networkConfiguration: { + networkMode: "PUBLIC", + }, + protocolConfiguration: "MCP", + }; + + // Create runtime configuration with JWT authorization if Cognito is enabled + const runtimeConfig: bedrockagentcore.CfnRuntimeProps = this.cognitoAuth + ? { + ...baseRuntimeConfig, + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: `https://cognito-idp.${ + cdk.Stack.of(this).region + }.amazonaws.com/${ + this.cognitoAuth.userPool.userPoolId + }/.well-known/openid-configuration`, + allowedClients: [ + this.cognitoAuth.userPoolClient.userPoolClientId, + ], + }, + }, + } + : baseRuntimeConfig; + + // MCP Server Runtime + this.runtime = new bedrockagentcore.CfnRuntime( + this, + "Runtime", + runtimeConfig + ); + + // MCP Server Runtime Endpoint + this.runtimeEndpoint = new bedrockagentcore.CfnRuntimeEndpoint( + this, + "RuntimeEndpoint", + { + name: `${namePrefix}_mcp_server_endpoint`.replace(/-/g, "_"), + description: + "Runtime endpoint for the MCP server with JWT passthrough authentication", + agentRuntimeId: this.runtime.attrAgentRuntimeId, + } + ); + + // Outputs + new cdk.CfnOutput(this, "RuntimeArn", { + value: this.runtime.attrAgentRuntimeArn, + description: "ARN of the MCP Server runtime", + }); + + new cdk.CfnOutput(this, "RuntimeEndpointArn", { + value: this.runtimeEndpoint.attrAgentRuntimeEndpointArn, + description: "ARN of the MCP Server runtime endpoint", + }); + + new cdk.CfnOutput(this, "ImageUri", { + value: this.imageAsset.imageUri, + description: "URI of the built MCP server container image", + }); + } +} diff --git a/examples/agentcore/infrastructure/lib/constructs/request-header-config.ts b/examples/agentcore/infrastructure/lib/constructs/request-header-config.ts new file mode 100644 index 0000000..7340f5c --- /dev/null +++ b/examples/agentcore/infrastructure/lib/constructs/request-header-config.ts @@ -0,0 +1,117 @@ +import * as cdk from "aws-cdk-lib"; +import * as iam from "aws-cdk-lib/aws-iam"; +import * as custom from "aws-cdk-lib/custom-resources"; +import { Construct } from "constructs"; + +export interface RequestHeaderConfigProps { + /** + * The AgentCore Runtime ID to configure + */ + runtimeId: string; + + /** + * List of headers to allow through to the runtime + */ + allowedHeaders: string[]; + + /** + * The current runtime configuration (used to detect changes) + */ + runtimeConfig: { + containerUri: string; + roleArn: string; + networkMode: string; + authorizerConfiguration?: any; + environmentVariables?: { [key: string]: string }; + protocolConfiguration?: string; + description?: string; + }; +} + +/** + * Custom Resource to configure RequestHeaderAllowlist for AgentCore Runtime + * + * This works around the limitation that CloudFormation doesn't yet support + * the RequestHeaderConfiguration property for AWS::BedrockAgentCore::Runtime + * + * Uses AwsCustomResource for simplified implementation without Lambda functions + */ +export class RequestHeaderConfig extends Construct { + constructor(scope: Construct, id: string, props: RequestHeaderConfigProps) { + super(scope, id); + + // Prepare update parameters + const updateParams = { + agentRuntimeId: props.runtimeId, + agentRuntimeArtifact: { + containerConfiguration: { + containerUri: props.runtimeConfig.containerUri, + }, + }, + roleArn: props.runtimeConfig.roleArn, + networkConfiguration: { + networkMode: props.runtimeConfig.networkMode, + }, + requestHeaderConfiguration: { + requestHeaderAllowlist: props.allowedHeaders, + }, + // Add optional parameters + ...(props.runtimeConfig.authorizerConfiguration && { + authorizerConfiguration: props.runtimeConfig.authorizerConfiguration, + }), + ...(props.runtimeConfig.environmentVariables && { + environmentVariables: props.runtimeConfig.environmentVariables, + }), + ...(props.runtimeConfig.protocolConfiguration && { + protocolConfiguration: { + serverProtocol: props.runtimeConfig.protocolConfiguration, + }, + }), + ...(props.runtimeConfig.description && { + description: props.runtimeConfig.description, + }), + }; + + // Create AwsCustomResource for updating runtime configuration + new custom.AwsCustomResource(this, "Resource", { + onCreate: { + service: "bedrock-agentcore-control", + action: "UpdateAgentRuntime", + parameters: updateParams, + physicalResourceId: custom.PhysicalResourceId.of( + `request-header-config-${props.runtimeId}` + ), + }, + onUpdate: { + service: "bedrock-agentcore-control", + action: "UpdateAgentRuntime", + parameters: updateParams, + physicalResourceId: custom.PhysicalResourceId.of( + `request-header-config-${props.runtimeId}` + ), + }, + // No onDelete needed - we leave the configuration in place + policy: custom.AwsCustomResourcePolicy.fromStatements([ + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + "bedrock-agentcore:UpdateAgentRuntime", + "bedrock-agentcore:GetAgentRuntime", + ], + resources: [ + `arn:aws:bedrock-agentcore:${cdk.Stack.of(this).region}:${ + cdk.Stack.of(this).account + }:runtime/${props.runtimeId}`, + ], + }), + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ["iam:PassRole"], + resources: [props.runtimeConfig.roleArn], + }), + ]), + installLatestAwsSdk: true, + timeout: cdk.Duration.minutes(5), + }); + } +} diff --git a/examples/agentcore/infrastructure/package-lock.json b/examples/agentcore/infrastructure/package-lock.json new file mode 100644 index 0000000..45d06cf --- /dev/null +++ b/examples/agentcore/infrastructure/package-lock.json @@ -0,0 +1,498 @@ +{ + "name": "agentcore-integration-infrastructure", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agentcore-integration-infrastructure", + "version": "1.0.0", + "dependencies": { + "aws-cdk-lib": "^2.100.0", + "cdk-nag": "^2.37.55", + "constructs": "^10.4.2" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "aws-cdk": "^2.100.0", + "typescript": "~5.0.0" + } + }, + "node_modules/@aws-cdk/asset-awscli-v1": { + "version": "2.2.242", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.242.tgz", + "integrity": "sha512-4c1bAy2ISzcdKXYS1k4HYZsNrgiwbiDzj36ybwFVxEWZXVAP0dimQTCaB9fxu7sWzEjw3d+eaw6Fon+QTfTIpQ==", + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.0.tgz", + "integrity": "sha512-7bY3J8GCVxLupn/kNmpPc5VJz8grx+4RKfnnJiO1LG+uxkZfANZG3RMHhE+qQxxwkyQ9/MfPtTpf748UhR425A==", + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/cloud-assembly-schema": { + "version": "48.12.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-48.12.0.tgz", + "integrity": "sha512-/GNDW8+O5FZldDXVtNJSMnd6+hG8DmwLt02cqlYHqWjpqTap7XzFFv3pqGTl/lL7s97jlbgj/568pLjpUWh2Dw==", + "bundleDependencies": [ + "jsonschema", + "semver" + ], + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "~1.4.1", + "semver": "^7.7.2" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { + "version": "1.4.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { + "version": "7.7.2", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/node": { + "version": "20.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.19.tgz", + "integrity": "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/aws-cdk": { + "version": "2.1029.4", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1029.4.tgz", + "integrity": "sha512-rJa8QLd8WHaoTEjPLqVwmNpDMmyJycVaxdr/Evr/1MDLq+WCovP46IqPaXfH0q/jY0gCsga9or907tEayK5xcg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "cdk": "bin/cdk" + }, + "engines": { + "node": ">= 18.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/aws-cdk-lib": { + "version": "2.219.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.219.0.tgz", + "integrity": "sha512-Rq1/f3exfFEWee1znNq8yvR1TuRQ4xQZz3JNkliBW9dFwyrDe7l/dmlAf6DVvB3nuiZAaUS+vh4ua1LZ7Ec8kg==", + "bundleDependencies": [ + "@balena/dockerignore", + "case", + "fs-extra", + "ignore", + "jsonschema", + "minimatch", + "punycode", + "semver", + "table", + "yaml", + "mime-types" + ], + "license": "Apache-2.0", + "dependencies": { + "@aws-cdk/asset-awscli-v1": "2.2.242", + "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.0", + "@aws-cdk/cloud-assembly-schema": "^48.6.0", + "@balena/dockerignore": "^1.0.2", + "case": "1.6.3", + "fs-extra": "^11.3.1", + "ignore": "^5.3.2", + "jsonschema": "^1.5.0", + "mime-types": "^2.1.35", + "minimatch": "^3.1.2", + "punycode": "^2.3.1", + "semver": "^7.7.2", + "table": "^6.9.0", + "yaml": "1.10.2" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "constructs": "^10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { + "version": "1.0.2", + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/aws-cdk-lib/node_modules/ajv": { + "version": "8.17.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-regex": { + "version": "5.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-styles": { + "version": "4.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/astral-regex": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/balanced-match": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/brace-expansion": { + "version": "1.1.12", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/aws-cdk-lib/node_modules/case": { + "version": "1.6.3", + "inBundle": true, + "license": "(MIT OR GPL-3.0-or-later)", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-convert": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-name": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/concat-map": { + "version": "0.0.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/emoji-regex": { + "version": "8.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-deep-equal": { + "version": "3.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-uri": { + "version": "3.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "inBundle": true, + "license": "BSD-3-Clause" + }, + "node_modules/aws-cdk-lib/node_modules/fs-extra": { + "version": "11.3.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/aws-cdk-lib/node_modules/graceful-fs": { + "version": "4.2.11", + "inBundle": true, + "license": "ISC" + }, + "node_modules/aws-cdk-lib/node_modules/ignore": { + "version": "5.3.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/aws-cdk-lib/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/json-schema-traverse": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/jsonfile": { + "version": "6.2.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/jsonschema": { + "version": "1.5.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/lodash.truncate": { + "version": "4.4.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/mime-db": { + "version": "1.52.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/mime-types": { + "version": "2.1.35", + "inBundle": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/minimatch": { + "version": "3.1.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/punycode": { + "version": "2.3.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/aws-cdk-lib/node_modules/require-from-string": { + "version": "2.0.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/semver": { + "version": "7.7.2", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk-lib/node_modules/slice-ansi": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/string-width": { + "version": "4.2.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/strip-ansi": { + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/table": { + "version": "6.9.0", + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/universalify": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/yaml": { + "version": "1.10.2", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cdk-nag": { + "version": "2.37.55", + "resolved": "https://registry.npmjs.org/cdk-nag/-/cdk-nag-2.37.55.tgz", + "integrity": "sha512-xcAkygwbph3pp7N0UEzJBmXUH/MIsluV7DYJSeZ/V3yCr0Y0QaRGO298WyD6mi4K+Rmnpl+EJoWUxcOblOqLKA==", + "license": "Apache-2.0", + "peerDependencies": { + "aws-cdk-lib": "^2.176.0", + "constructs": "^10.0.5" + } + }, + "node_modules/constructs": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.4.2.tgz", + "integrity": "sha512-wsNxBlAott2qg8Zv87q3eYZYgheb9lchtBfjHzzLHtXbttwSrHPs1NNQbBrmbb1YZvYg2+Vh0Dor76w4mFxJkA==", + "license": "Apache-2.0" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/typescript": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=12.20" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/examples/agentcore/infrastructure/package.json b/examples/agentcore/infrastructure/package.json new file mode 100644 index 0000000..706039f --- /dev/null +++ b/examples/agentcore/infrastructure/package.json @@ -0,0 +1,30 @@ +{ + "name": "agentcore-integration-infrastructure", + "version": "1.0.0", + "description": "CDK infrastructure for AgentCore integration example", + "main": "lib/app.js", + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "cdk": "cdk", + "deploy": "cdk deploy", + "deploy-with-outputs": "cdk deploy --outputs-file cdk-outputs.json", + "destroy": "cdk destroy", + "synth": "cdk synth", + "diff": "cdk diff", + "check-stack-name": "node check-stack-name.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "aws-cdk": "^2.100.0", + "typescript": "~5.0.0" + }, + "dependencies": { + "aws-cdk-lib": "^2.100.0", + "constructs": "^10.4.2", + "cdk-nag": "^2.37.55" + }, + "overrides": { + "@types/node": "^20.0.0" + } +} diff --git a/examples/agentcore/infrastructure/tsconfig.json b/examples/agentcore/infrastructure/tsconfig.json new file mode 100644 index 0000000..dc0dd19 --- /dev/null +++ b/examples/agentcore/infrastructure/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "skipLibCheck": true, + "typeRoots": ["./node_modules/@types"] + }, + "exclude": ["node_modules", "cdk.out"] +} diff --git a/examples/agentcore/mcp-server/.dockerignore b/examples/agentcore/mcp-server/.dockerignore new file mode 100644 index 0000000..3dc4da4 --- /dev/null +++ b/examples/agentcore/mcp-server/.dockerignore @@ -0,0 +1,73 @@ +# Docker ignore file for weather agent + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +*.log +logs/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# Documentation +docs/ +*.md +!README.md + +# Git +.git/ +.gitignore + +# Docker +Dockerfile* +.dockerignore +docker-compose*.yml \ No newline at end of file diff --git a/examples/agentcore/mcp-server/Dockerfile b/examples/agentcore/mcp-server/Dockerfile new file mode 100644 index 0000000..aad7932 --- /dev/null +++ b/examples/agentcore/mcp-server/Dockerfile @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 +FROM python:3.13-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Create non-root user +RUN useradd -m -u 1000 agentcore +USER agentcore + +# Copy application files +COPY --chown=agentcore:agentcore *.py . + +EXPOSE 8000 + +CMD ["python", "mcp_server.py"] diff --git a/examples/agentcore/mcp-server/get_forecast_tool.py b/examples/agentcore/mcp-server/get_forecast_tool.py new file mode 100644 index 0000000..0fa700c --- /dev/null +++ b/examples/agentcore/mcp-server/get_forecast_tool.py @@ -0,0 +1,170 @@ +""" +Get Forecast Tool - Weather forecast information for cities. + +This module provides weather forecasts for a specified number of days via MCP protocol. +It demonstrates using Pydantic models for input/output validation and FastMCP tool registration. +""" + +import json +import logging +from typing import Any + +from mcp.server.fastmcp import FastMCP +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +class WeatherForecastRequest(BaseModel): + """ + Request parameters for getting weather forecast information. + + Use this tool when users ask about weather forecasts for multiple days. + + Examples: + - "What's the 5-day forecast for Paris?" + - "Give me the weather forecast for Seattle" + - "What will the weather be like in Miami this week?" + """ + + city: str = Field( + description="Name of the city to get weather forecast for", + min_length=1, + max_length=100, + ) + + days: int = Field( + default=3, description="Number of days to forecast (1-7 days)", ge=1, le=7 + ) + + +class WeatherForecastResponse(BaseModel): + """ + Response structure for weather forecast information. + + This model represents the structured response from weather forecast tools, + containing forecast information and metadata. + """ + + success: bool = Field( + description="Whether the weather forecast request completed successfully" + ) + + city: str = Field(description="Name of the city the forecast is for") + + days: int = Field(description="Number of days in the forecast") + + forecast_info: str = Field(description="Detailed forecast information") + + temperature_range: str | None = Field( + default=None, description="Temperature range for the forecast period" + ) + + conditions: str | None = Field( + default=None, description="General weather conditions for the forecast period" + ) + + message: str | None = Field( + default=None, description="Additional information about the forecast" + ) + + error: str | None = Field( + default=None, description="Error message if the request failed" + ) + + +class WeatherForecastTool: + """ + Tool for getting weather forecast information for a city. + + This tool provides weather forecasts for a specified number of days. + """ + + def invoke(self, **kwargs) -> dict[str, Any]: + """ + Invoke the weather forecast tool. + + Args: + **kwargs: Keyword arguments matching WeatherForecastRequest fields. + + Returns: + Dictionary containing the forecast results. + """ + try: + # Create request from kwargs + request = WeatherForecastRequest(**kwargs) + response = self._get_forecast(request) + return response.model_dump() + except Exception as e: + # Handle validation errors and other exceptions gracefully + error_message = f"Invalid request parameters: {str(e)}" + response = WeatherForecastResponse( + success=False, + city=kwargs.get("city", "Unknown"), + days=kwargs.get("days", 3), + forecast_info="", + error=error_message, + ) + return response.model_dump() + + def _get_forecast(self, request: WeatherForecastRequest) -> WeatherForecastResponse: + """ + Get weather forecast for a city. + + Args: + request: The validated weather forecast request. + + Returns: + A WeatherForecastResponse containing the forecast information. + """ + logger.info(f"Getting {request.days}-day forecast for {request.city}") + + # Mock forecast data - in a real implementation, this would call a weather API + forecast_info = ( + f"{request.days}-day forecast for {request.city}: Sunny, 22-25°C" + ) + + return WeatherForecastResponse( + success=True, + city=request.city, + days=request.days, + forecast_info=forecast_info, + temperature_range="22-25°C", + conditions="sunny", + message=f"Successfully retrieved {request.days}-day forecast for {request.city}", + ) + + +# Initialize tool instance +_forecast_tool = WeatherForecastTool() + + +def register_get_forecast_tool(mcp: FastMCP) -> None: + """ + Register the get_forecast tool with the FastMCP server. + + Note: The LLM learns about this tool from the WeatherForecastRequest Pydantic model: + - Model docstring provides tool description and usage examples + - Field descriptions define parameter types and validation rules + + FastMCP automatically transforms the Pydantic model into JSON schemas + that help the LLM understand when and how to use this tool. + + Args: + mcp: The FastMCP server instance to register the tool with. + """ + + @mcp.tool() + def get_forecast(request: WeatherForecastRequest) -> str: + """Get weather forecast for a city. + + Args: + request: Weather forecast request parameters + + Returns: + JSON string containing forecast information + """ + logger.info(f"get_forecast called with request: {request}") + result = _forecast_tool.invoke(city=request.city, days=request.days) + logger.info("get_forecast completed successfully") + return json.dumps(result, indent=2) diff --git a/examples/agentcore/mcp-server/get_weather_tool.py b/examples/agentcore/mcp-server/get_weather_tool.py new file mode 100644 index 0000000..99caf82 --- /dev/null +++ b/examples/agentcore/mcp-server/get_weather_tool.py @@ -0,0 +1,158 @@ +""" +Get Weather Tool - Current weather information for cities. + +This module provides current weather conditions for a specified city via MCP protocol. +It demonstrates using Pydantic models for input/output validation and FastMCP tool registration. +""" + +import json +import logging +from typing import Any + +from mcp.server.fastmcp import FastMCP +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +class WeatherRequest(BaseModel): + """ + Request parameters for getting current weather information. + + Use this tool when users ask about current weather conditions in a specific city. + + Examples: + - "What's the weather like in New York?" + - "How's the weather in London today?" + - "Tell me the current weather in Tokyo" + """ + + city: str = Field( + description="Name of the city to get weather information for", + min_length=1, + max_length=100, + ) + + +class WeatherResponse(BaseModel): + """ + Response structure for weather information. + + This model represents the structured response from weather tools, + containing weather information and metadata. + """ + + success: bool = Field( + description="Whether the weather request completed successfully" + ) + + city: str = Field(description="Name of the city the weather information is for") + + weather_info: str = Field(description="Detailed weather information") + + temperature: str | None = Field(default=None, description="Temperature information") + + conditions: str | None = Field( + default=None, description="Weather conditions (sunny, cloudy, rainy, etc.)" + ) + + message: str | None = Field( + default=None, description="Additional information about the request" + ) + + error: str | None = Field( + default=None, description="Error message if the request failed" + ) + + +class WeatherTool: + """ + Tool for getting current weather information for a city. + + This tool provides current weather conditions for a specified city. + """ + + def invoke(self, **kwargs) -> dict[str, Any]: + """ + Invoke the weather tool. + + Args: + **kwargs: Keyword arguments matching WeatherRequest fields. + + Returns: + Dictionary containing the weather results. + """ + try: + # Create request from kwargs + request = WeatherRequest(**kwargs) + response = self._get_weather(request) + return response.model_dump() + except Exception as e: + # Handle validation errors and other exceptions gracefully + error_message = f"Invalid request parameters: {str(e)}" + response = WeatherResponse( + success=False, + city=kwargs.get("city", "Unknown"), + weather_info="", + error=error_message, + ) + return response.model_dump() + + def _get_weather(self, request: WeatherRequest) -> WeatherResponse: + """ + Get current weather for a city. + + Args: + request: The validated weather request. + + Returns: + A WeatherResponse containing the weather information. + """ + logger.info(f"Getting weather for {request.city}") + + # Mock weather data - in a real implementation, this would call a weather API + weather_info = f"The weather in {request.city} is sunny with 22°C" + + return WeatherResponse( + success=True, + city=request.city, + weather_info=weather_info, + temperature="22°C", + conditions="sunny", + message=f"Successfully retrieved weather for {request.city}", + ) + + +# Initialize tool instance +_weather_tool = WeatherTool() + + +def register_get_weather_tool(mcp: FastMCP) -> None: + """ + Register the get_weather tool with the FastMCP server. + + Note: The LLM learns about this tool from the WeatherRequest Pydantic model: + - Model docstring provides tool description and usage examples + - Field descriptions define parameter types and validation rules + + FastMCP automatically transforms the Pydantic model into JSON schemas + that help the LLM understand when and how to use this tool. + + Args: + mcp: The FastMCP server instance to register the tool with. + """ + + @mcp.tool() + def get_weather(request: WeatherRequest) -> str: + """Get current weather for a city. + + Args: + request: Weather request parameters + + Returns: + JSON string containing weather information + """ + logger.info(f"get_weather called with request: {request}") + result = _weather_tool.invoke(city=request.city) + logger.info("get_weather completed successfully") + return json.dumps(result, indent=2) diff --git a/examples/agentcore/mcp-server/mcp_server.py b/examples/agentcore/mcp-server/mcp_server.py new file mode 100644 index 0000000..29f226e --- /dev/null +++ b/examples/agentcore/mcp-server/mcp_server.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""MCP Server for AgentCore Runtime using modular weather tools with FastMCP.""" + +import logging + +from get_forecast_tool import register_get_forecast_tool +from get_weather_tool import register_get_weather_tool +from mcp.server.fastmcp import FastMCP + +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(name)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) + +logger.info("MCP Server starting") + +# Create FastMCP server with correct configuration for AgentCore Runtime +mcp = FastMCP( + host="0.0.0.0", # nosec B104 - Binding to all interfaces required for AgentCore Runtime container networking + stateless_http=True, +) + +# Register weather tools from separate modules +register_get_weather_tool(mcp) +register_get_forecast_tool(mcp) + +if __name__ == "__main__": + logger.info("Starting Weather Tools MCP Server with FastMCP") + mcp.run(transport="streamable-http") diff --git a/examples/agentcore/mcp-server/requirements.txt b/examples/agentcore/mcp-server/requirements.txt new file mode 100644 index 0000000..cc299c6 --- /dev/null +++ b/examples/agentcore/mcp-server/requirements.txt @@ -0,0 +1,6 @@ +bedrock-agentcore>=0.1.7 +fastmcp>=2.12.4 +pydantic>=2.10,<3.0 +mcp>=1.8,<2.0 +starlette +uvicorn diff --git a/examples/agentcore/package-lock.json b/examples/agentcore/package-lock.json new file mode 100644 index 0000000..078cdc1 --- /dev/null +++ b/examples/agentcore/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "agentcore", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/examples/agentcore/tests/__init__.py b/examples/agentcore/tests/__init__.py new file mode 100644 index 0000000..d21e2f6 --- /dev/null +++ b/examples/agentcore/tests/__init__.py @@ -0,0 +1 @@ +"""AgentCore unified test suite.""" diff --git a/examples/agentcore/tests/agent/__init__.py b/examples/agentcore/tests/agent/__init__.py new file mode 100644 index 0000000..d9165fb --- /dev/null +++ b/examples/agentcore/tests/agent/__init__.py @@ -0,0 +1 @@ +"""Agent tests.""" diff --git a/examples/agentcore/tests/agent/test_agent_deployment.py b/examples/agentcore/tests/agent/test_agent_deployment.py new file mode 100644 index 0000000..bdcc844 --- /dev/null +++ b/examples/agentcore/tests/agent/test_agent_deployment.py @@ -0,0 +1,46 @@ +"""Basic deployment verification tests for the AgentCore agent.""" + +# nosec B101 - Assert statements are standard in pytest test files + +import os + +import pytest +from botocore.exceptions import ClientError + + +class TestAgentDeployment: + """Basic tests to verify the agent is deployed and functional.""" + + def test_agent_runtime_exists(self, bedrock_agentcore_control_client): + """Test that the agent runtime exists and is accessible.""" + # Extract runtime ID from ARN + agent_runtime_arn = os.environ["AGENT_RUNTIME_ARN"] + runtime_id = agent_runtime_arn.split("/")[-1] + + try: + response = bedrock_agentcore_control_client.get_agent_runtime( + agentRuntimeId=runtime_id + ) + assert response["agentRuntimeId"] == runtime_id # nosec B101 + assert response["status"] in ["READY", "CREATING", "UPDATING"] # nosec B101 + except ClientError as e: + pytest.fail(f"Failed to get agent runtime: {e}") + + def test_agent_runtime_endpoint_exists(self, bedrock_agentcore_control_client): + """Test that the agent runtime endpoint exists and is accessible.""" + # Extract runtime ID and endpoint name from ARN + # ARN format: arn:aws:bedrock-agentcore:region:account:runtime/runtime-id/runtime-endpoint/endpoint-name + agent_runtime_endpoint_arn = os.environ["AGENT_RUNTIME_ENDPOINT_ARN"] + arn_parts = agent_runtime_endpoint_arn.split("/") + runtime_id = arn_parts[-3] # runtime-id + endpoint_name = arn_parts[-1] # endpoint-name + + try: + response = bedrock_agentcore_control_client.get_agent_runtime_endpoint( + agentRuntimeId=runtime_id, endpointName=endpoint_name + ) + assert response["name"] == endpoint_name # nosec B101 + assert runtime_id in response["agentRuntimeArn"] # nosec B101 + assert response["status"] in ["READY", "CREATING", "UPDATING"] # nosec B101 + except ClientError as e: + pytest.fail(f"Failed to get agent runtime endpoint: {e}") diff --git a/examples/agentcore/tests/agent/test_agent_examples.py b/examples/agentcore/tests/agent/test_agent_examples.py new file mode 100644 index 0000000..63f63c3 --- /dev/null +++ b/examples/agentcore/tests/agent/test_agent_examples.py @@ -0,0 +1,329 @@ +""" +Example tests demonstrating how to interact with a deployed AgentCore agent using JWT authentication. + +This test suite serves as documentation and examples for: +- Basic agent invocation patterns with JWT OAuth +- Weather tool usage via MCP integration +- Common payload structures +- Response handling +- Session management + +Note: This file uses assert statements for test validation (B101 suppressed) # nosec B101 + +These tests demonstrate the agent's capabilities rather than exhaustively testing edge cases. + +Environment Variables Required: + - AWS_REGION: AWS region where resources are deployed + - CLIENT_USER_CREDENTIALS_SECRET_NAME: Secret name for client user credentials + - OAUTH_USER_POOL_ID: Cognito User Pool ID + - OAUTH_USER_POOL_CLIENT_ID: Cognito User Pool Client ID + - AGENT_RUNTIME_ARN: Agent runtime ARN to invoke +""" + +# nosec B101 + +import json +import os +import time +import urllib.parse +import uuid +from typing import Any + +import pytest +import requests + + +class TestAgentExamples: + """Example interactions with the deployed AgentCore weather agent using JWT authentication.""" + + def _invoke_agent_with_jwt( + self, access_token: str, prompt: str, session_id: str = None + ) -> dict[str, Any]: + """Helper method to invoke the agent runtime using JWT bearer token.""" + agent_runtime_arn = os.environ["AGENT_RUNTIME_ARN"] + region = os.environ["AWS_REGION"] + + # URL encode the agent ARN + escaped_agent_arn = urllib.parse.quote(agent_runtime_arn, safe="") + + # Construct the URL + url = f"https://bedrock-agentcore.{region}.amazonaws.com/runtimes/{escaped_agent_arn}/invocations?qualifier=DEFAULT" + + # Set up headers + if session_id is None: + session_id = f"test-session-{uuid.uuid4().hex}" + trace_id = f"test-trace-{uuid.uuid4().hex[:16]}" + + headers = { + "Authorization": f"Bearer {access_token}", + "X-Amzn-Trace-Id": trace_id, + "Content-Type": "application/json", + "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": session_id, + } + + # Prepare payload in the correct format expected by AgentCore + payload = {"input": {"prompt": prompt}} + + response = requests.post( + url, headers=headers, data=json.dumps(payload), timeout=60 + ) + + if response.status_code == 200: + return response.json() + else: + # Try to get error details + try: + error_data = response.json() + error_msg = f"Agent invocation failed with status {response.status_code}: {json.dumps(error_data)}" + except json.JSONDecodeError: + error_msg = f"Agent invocation failed with status {response.status_code}: {response.text}" + + raise Exception(error_msg) + + def test_basic_agent_invocation(self, jwt_token): + """Example: Basic agent invocation with simple greeting.""" + try: + # Invoke the agent + runtime_session_id = str(uuid.uuid4()) + response_data = self._invoke_agent_with_jwt( + jwt_token, + "Hello! Can you tell me what you can help with?", + runtime_session_id, + ) + + print(f"runtime_session_id: {runtime_session_id}") + + # Verify response structure + assert "result" in response_data # nosec B101 + result = response_data["result"] + assert len(result) > 0 # nosec B101 + + print(f"Agent response: {result}") + + except Exception as e: + pytest.fail(f"Failed basic invocation: {e}") + + def test_weather_query_example(self, jwt_token): + """Example: Getting current weather for a city using MCP tools.""" + try: + # Ask for weather in a specific city + response_data = self._invoke_agent_with_jwt( + jwt_token, "What's the current weather in Amsterdam?" + ) + + assert "result" in response_data # nosec B101 + result = response_data["result"] + assert len(result) > 0 # nosec B101 + + # Verify weather information is provided + result_lower = result.lower() + assert "amsterdam" in result_lower # nosec B101 + + # Should contain weather-related information + weather_indicators = ["temperature", "weather", "degrees", "conditions"] + assert any(indicator in result_lower for indicator in weather_indicators) # nosec B101 + + print(f"Weather response: {result}") + + except Exception as e: + pytest.fail(f"Failed weather query: {e}") + + def test_weather_forecast_example(self, jwt_token): + """Example: Getting weather forecast using MCP tools.""" + try: + response_data = self._invoke_agent_with_jwt( + jwt_token, "Can you give me a 3-day weather forecast for London?" + ) + + assert "result" in response_data # nosec B101 + result = response_data["result"] + assert len(result) > 0 # nosec B101 + + result_lower = result.lower() + assert "london" in result_lower # nosec B101 + + # Should contain forecast information + forecast_indicators = ["forecast", "days", "tomorrow", "weather"] + assert any(indicator in result_lower for indicator in forecast_indicators) # nosec B101 + + print(f"Forecast response: {result}") + + except Exception as e: + pytest.fail(f"Failed forecast query: {e}") + + def test_multiple_cities_comparison_example(self, jwt_token): + """Example: Comparing weather across multiple cities.""" + try: + response_data = self._invoke_agent_with_jwt( + jwt_token, + "Compare the current weather between Paris and Berlin. Which city has better weather today?", + ) + + assert "result" in response_data # nosec B101 + result = response_data["result"] + assert len(result) > 0 # nosec B101 + + result_lower = result.lower() + + # Should mention at least one of the cities (more flexible) + city_indicators = ["paris", "berlin", "france", "germany"] + assert any(city in result_lower for city in city_indicators) # nosec B101 + + # Should contain comparison or weather-related language (more flexible) + comparison_indicators = [ + "compare", + "comparison", + "better", + "warmer", + "cooler", + "both", + "weather", + "temperature", + "versus", + "vs", + "between", + "different", + ] + assert any(indicator in result_lower for indicator in comparison_indicators) # nosec B101 + + print(f"Comparison response: {result}") + + except Exception as e: + pytest.fail(f"Failed comparison query: {e}") + + def test_session_continuity_example(self, jwt_token): + """Example: Maintaining context within a session.""" + session_id = str(uuid.uuid4()) + + try: + # First message: Ask about weather in a city + response_data1 = self._invoke_agent_with_jwt( + jwt_token, "What's the weather like in Tokyo today?", session_id + ) + assert "result" in response_data1 # nosec B101 + + print(f"First response: {response_data1['result']}") + + # Second message: Follow-up question using the same session + response_data2 = self._invoke_agent_with_jwt( + jwt_token, + "What about tomorrow's forecast for the same city?", + session_id, + ) + assert "result" in response_data2 # nosec B101 + + result2 = response_data2["result"] + result2_lower = result2.lower() + + # Should handle the follow-up appropriately + context_indicators = ["tokyo", "forecast", "tomorrow", "same"] + assert any(indicator in result2_lower for indicator in context_indicators) # nosec B101 + + print(f"Follow-up response: {result2}") + + except Exception as e: + pytest.fail(f"Failed session continuity: {e}") + + def test_agentcore_payload_structure_example(self, jwt_token): + """Example: AgentCore payload structure with optional metadata.""" + test_prompts = [ + "What's the weather in Rome?", + "What's the weather in Madrid?", + "What's the weather in Vienna?", + ] + + try: + for i, prompt in enumerate(test_prompts): + response_data = self._invoke_agent_with_jwt(jwt_token, prompt) + + assert "result" in response_data # nosec B101 + result = response_data["result"] + assert len(result) > 0 # nosec B101 + + print(f"Payload structure {i + 1} response: {result}") + + except Exception as e: + pytest.fail(f"Failed payload structure test: {e}") + + def test_error_handling_example(self, jwt_token): + """Example: How the agent handles invalid city names.""" + try: + response_data = self._invoke_agent_with_jwt( + jwt_token, "What's the weather in Nonexistentcity12345?" + ) + + assert "result" in response_data # nosec B101 + result = response_data["result"] + assert len(result) > 0 # nosec B101 + + # Agent should handle the error gracefully + print(f"Error handling response: {result}") + + except Exception as e: + pytest.fail(f"Failed error handling test: {e}") + + def test_performance_example(self, jwt_token): + """Example: Measuring agent response time.""" + try: + start_time = time.time() + response_data = self._invoke_agent_with_jwt( + jwt_token, "What's the current weather in New York City?" + ) + end_time = time.time() + + assert "result" in response_data # nosec B101 + result = response_data["result"] + assert len(result) > 0 # nosec B101 + + response_time = end_time - start_time + print(f"Response time: {response_time:.2f} seconds") + print(f"Response: {result}") + + # Should respond within reasonable time + assert response_time < 30.0 # nosec B101 + + except Exception as e: + pytest.fail(f"Failed performance test: {e}") + + def test_non_weather_query_example(self, jwt_token): + """Example: How the agent handles non-weather queries.""" + try: + response_data = self._invoke_agent_with_jwt( + jwt_token, "Can you tell me about the history of computers?" + ) + + assert "result" in response_data # nosec B101 + result = response_data["result"] + assert len(result) > 0 # nosec B101 + + print(f"Non-weather query response: {result}") + + except Exception as e: + pytest.fail(f"Failed non-weather query: {e}") + + def test_tool_capabilities_inquiry_example(self, jwt_token): + """Example: Asking the agent about its capabilities.""" + try: + response_data = self._invoke_agent_with_jwt( + jwt_token, "What weather tools and capabilities do you have available?" + ) + + assert "result" in response_data # nosec B101 + result = response_data["result"] + assert len(result) > 0 # nosec B101 + + # Should mention weather capabilities + result_lower = result.lower() + capability_indicators = [ + "weather", + "tool", + "capability", + "provide", + "check", + ] + assert any(indicator in result_lower for indicator in capability_indicators) # nosec B101 + + print(f"Capabilities response: {result}") + + except Exception as e: + pytest.fail(f"Failed capabilities inquiry: {e}") diff --git a/examples/agentcore/tests/agent/test_agent_local.py b/examples/agentcore/tests/agent/test_agent_local.py new file mode 100644 index 0000000..fdbb3ad --- /dev/null +++ b/examples/agentcore/tests/agent/test_agent_local.py @@ -0,0 +1,274 @@ +""" +Local integration tests for the agent after CDK deployment. + +These tests run the actual agent code locally using pytest, testing: +- Agent functionality with real Bedrock calls +- MCP integration with deployed MCP server (requires JWT token) +- Different payload structures +- Error handling scenarios + +Prerequisites: +- CDK stack deployed (cdk deploy) +- AWS credentials configured +- Environment variables set (AWS_REGION, BEDROCK_MODEL_ID, MCP_SERVER_RUNTIME_ARN) +- For MCP tool tests: JWT token from conftest.py fixtures + +Run with: pytest examples/agentcore/tests/agent/test_agent_local.py -v +""" + +# nosec B101 + +import asyncio +import textwrap +from unittest.mock import Mock + +import agent +import pytest +from bedrock_agentcore.runtime.context import RequestContext + +from generative_ai_toolkit.evaluate.interactive import GenerativeAIToolkit +from generative_ai_toolkit.metrics.modules.conversation import ( + ConversationExpectationMetric, +) +from generative_ai_toolkit.test import Case + + +class TestAgentLocalIntegration: + """Integration tests that run the agent with real AWS calls after CDK deployment.""" + + def create_mock_context( + self, session_id: str = "test-session", jwt_token: str = None + ) -> RequestContext: + """Create a mock RequestContext for local testing.""" + mock_context = Mock(spec=RequestContext) + mock_context.session_id = session_id + + # Add JWT token to request headers if provided + if jwt_token: + mock_context.request_headers = {"Authorization": f"Bearer {jwt_token}"} + else: + mock_context.request_headers = {} + + return mock_context + + @pytest.mark.integration + def test_agent_basic_functionality(self): + """Test basic agent functionality with real Bedrock calls (without JWT token).""" + + payload = { + "input": {"prompt": "Hello! Please introduce yourself briefly."}, + "sessionId": "integration-test-basic", + } + + try: + # Test without JWT token - agent should still work for basic functionality + context = self.create_mock_context("integration-test-basic") + result = agent.invoke(payload, context) + + assert "result" in result # nosec B101 + assert len(result["result"]) > 0 # nosec B101 + assert isinstance(result["result"], str) # nosec B101 + + # Should contain some indication it's a weather assistant + result_lower = result["result"].lower() + weather_indicators = ["weather", "assistant", "help", "information"] + assert any( + indicator in result_lower for indicator in weather_indicators + ) # nosec B101 + + print("✅ Basic agent functionality works without JWT token") + + except Exception as e: + pytest.fail(f"Agent invocation failed: {e}") + + @pytest.mark.integration + def test_agent_basic_functionality_with_jwt(self, jwt_token): + """Test basic agent functionality with JWT token (tools available but not used).""" + + payload = { + "input": {"prompt": "Hello! Please introduce yourself briefly."}, + "sessionId": "integration-test-jwt", + } + + try: + # Test with JWT token - agent should work and have tools available + context = self.create_mock_context("integration-test-jwt", jwt_token) + result = agent.invoke(payload, context) + + assert "result" in result # nosec B101 + assert len(result["result"]) > 0 # nosec B101 + assert isinstance(result["result"], str) # nosec B101 + + # Should contain some indication it's a weather assistant + result_lower = result["result"].lower() + weather_indicators = ["weather", "assistant", "help", "information"] + assert any( + indicator in result_lower for indicator in weather_indicators + ) # nosec B101 + + print("✅ Basic agent functionality works with JWT token") + + except Exception as e: + pytest.fail(f"Agent invocation failed: {e}") + + @pytest.mark.integration + def test_agent_with_mcp_weather_tools(self, jwt_token): + """Test agent with MCP weather tools using Case with Overall Conversation Expectations.""" + + # Create a Case with overall conversation expectations + weather_case = Case( + name="Weather query with MCP tools", + user_inputs=[ + "What's the current weather in Amsterdam? Please use your weather tools." + ], + overall_expectations=textwrap.dedent( + """ + The agent should: + 1. Recognize the user's request for weather information about Amsterdam + 2. Use the available MCP weather tools (get_weather) to retrieve current weather data + 3. Provide a helpful response that includes: + - Mention of Amsterdam as the requested city + - Weather information (temperature, conditions, etc.) obtained from the tools + - A natural, conversational response format + 4. If tools are unavailable, handle the error gracefully with an appropriate message + + The conversation should demonstrate successful integration between the agent and MCP server. + """ + ), + ) + + try: + # Set JWT token in MCP manager and re-register tools for local testing + agent.mcp_manager.set_jwt_token(jwt_token) + + # Re-register MCP tools with JWT token (needed for local testing) + asyncio.run(agent.mcp_manager.register_mcp_tools(agent.bedrock_agent)) + + # Run the case directly against the BedrockConverseAgent + traces = weather_case.run(agent.bedrock_agent) + + # Evaluate using ConversationExpectationMetric + results = GenerativeAIToolkit.eval( + metrics=[ConversationExpectationMetric()], traces=[traces] + ) + + # Check evaluation results + evaluation_results = list(results) + assert ( + len(evaluation_results) > 0 + ), "Expected evaluation results from ConversationExpectationMetric" # nosec B101 + conversation_result = evaluation_results[0] + + # Verify we have measurements + assert ( + len(conversation_result.measurements) > 0 + ), "Expected conversation measurements" # nosec B101 + + # Check if the evaluation passed (score >= 7 is generally good) + correctness_measurements = [ + m for m in conversation_result.measurements if "Correctness" in m.name + ] + + if correctness_measurements: + score = correctness_measurements[0].value + print(f"\n✅ Correctness Score: {score}/10") + assert ( + score >= 7 + ), f"Expected correctness score >= 7, got {score}" # nosec B101 + + # Verify the agent actually used weather tools by checking traces + tool_traces = [ + trace + for trace in conversation_result.traces + if hasattr(trace, "trace") + and "get_weather" in str(trace.trace.attributes.get("ai.tool.name", "")) + ] + + assert ( + len(tool_traces) > 0 + ), "Expected agent to use weather tools" # nosec B101 + print( + f"✅ Weather tool was used successfully ({len(tool_traces)} tool calls)" + ) + + except Exception as e: + pytest.fail(f"Case-based evaluation failed: {e}") + + @pytest.mark.integration + def test_agent_error_handling(self): + """Test agent error handling with invalid payload format. + + Verifies that the agent gracefully handles invalid input payloads + and returns a user-friendly error message instead of crashing. + """ + + # Test with invalid payload format (missing required fields) + invalid_payloads = [ + {}, # Empty payload + {"sessionId": "error-test"}, # Missing input + {"input": {}}, # Missing prompt in input + ] + + for i, payload in enumerate(invalid_payloads): + try: + context = self.create_mock_context(f"error-test-{i}") + result = agent.invoke(payload, context) + + # Should handle error gracefully and return error message + assert ( + "result" in result + ), f"Payload {i}: Missing result field" # nosec B101 + result_text = result["result"] + + # Check for the specific error message format + assert result_text.startswith( + "Error: Invalid payload format" + ), f"Payload {i}: Expected 'Error: Invalid payload format' message, got: {result['result']}" # nosec B101 + assert ( + "Expected: {'input': {'prompt': 'message'}}" in result_text + ), f"Payload {i}: Expected format specification in error message, got: {result['result']}" # nosec B101 + + except Exception as e: + pytest.fail( + f"Agent should handle invalid payload {i} gracefully, but raised: {e}" + ) + + # Test with valid payload to ensure normal operation still works + valid_payload = {"input": {"prompt": "Hello"}, "sessionId": "error-test"} + context = self.create_mock_context("error-test-valid") + result = agent.invoke(valid_payload, context) + assert "result" in result # nosec B101 + assert not result["result"].startswith( + "Error:" + ), f"Valid payload should not return error, got: {result['result']}" # nosec B101 + print("✅ Valid payload works correctly") + + @pytest.mark.integration + def test_agent_session_handling(self): + """Test agent with multiple calls in different sessions (without JWT tokens).""" + + # Test multiple sessions + sessions = [ + {"input": {"prompt": "Hello, I'm user 1"}, "sessionId": "session-1"}, + {"input": {"prompt": "Hello, I'm user 2"}, "sessionId": "session-2"}, + {"input": {"prompt": "Hello, I'm user 3"}, "sessionId": "session-3"}, + ] + + results = [] + for payload in sessions: + try: + context = self.create_mock_context(payload["sessionId"]) + result = agent.invoke(payload, context) + results.append(result) + except Exception as e: + pytest.fail(f"Agent invocation failed for session: {e}") + + # All calls should succeed + assert len(results) == len(sessions) # nosec B101 + for i, result in enumerate(results): + assert "result" in result, f"Session {i + 1} missing result" # nosec B101 + assert ( + len(result["result"]) > 0 + ), f"Session {i + 1} empty result" # nosec B101 + + print("✅ Session handling works correctly") diff --git a/examples/agentcore/tests/agent/test_jwt_authentication.py b/examples/agentcore/tests/agent/test_jwt_authentication.py new file mode 100644 index 0000000..67a5e0b --- /dev/null +++ b/examples/agentcore/tests/agent/test_jwt_authentication.py @@ -0,0 +1,138 @@ +""" +Test JWT authentication for AgentCore runtime. + +This test module demonstrates and validates JWT bearer token authentication +for invoking the AgentCore runtime using Cognito User Pool credentials. + +Test Flow: +1. Retrieve client user credentials from AWS Secrets Manager +2. Authenticate with Cognito to get a JWT access token +3. Invoke the agent runtime using the JWT bearer token +4. Validate the response + +Environment Variables Required: + - AWS_REGION: AWS region where resources are deployed + - CLIENT_USER_CREDENTIALS_SECRET_NAME: Secret name for client user credentials + - OAUTH_USER_POOL_ID: Cognito User Pool ID + - OAUTH_USER_POOL_CLIENT_ID: Cognito User Pool Client ID + - AGENT_RUNTIME_ARN: Agent runtime ARN to invoke +""" + +import base64 +import json +import os +import urllib.parse +import uuid +from typing import Any + +import pytest +import requests + + +class TestJWTAuthentication: + """Test class for JWT authentication with AgentCore runtime.""" + + def test_client_credentials_retrieval(self, client_credentials: dict[str, str]): + """Test that client credentials can be retrieved from Secrets Manager.""" + assert "username" in client_credentials + assert "password" in client_credentials + assert client_credentials["username"], "Username should not be empty" + assert client_credentials["password"], "Password should not be empty" + + def test_jwt_token_generation(self, jwt_token: str): + """Test that JWT token can be generated from Cognito.""" + assert jwt_token, "JWT token should not be empty" + assert len(jwt_token) > 100, "JWT token should be a reasonable length" + + # JWT tokens should have 3 parts separated by dots + parts = jwt_token.split(".") + assert len(parts) == 3, ( + "JWT token should have 3 parts (header.payload.signature)" + ) + + # Decode and inspect JWT claims (for debugging) + + # Decode payload (add padding if needed) + payload_b64 = parts[1] + # Add padding if needed + payload_b64 += "=" * (4 - len(payload_b64) % 4) + payload_bytes = base64.urlsafe_b64decode(payload_b64) + claims = json.loads(payload_bytes) + + print(f"JWT Claims: {json.dumps(claims, indent=2)}") + + # Verify expected claims are present + assert "sub" in claims, "JWT should contain 'sub' (subject) claim for user ID" + assert "client_id" in claims, "JWT should contain 'client_id' claim" + assert "username" in claims, "JWT should contain 'username' claim" + + def test_agent_invocation_with_jwt_weather_prompt(self, jwt_token: str): + """Test agent invocation with JWT token using a weather-related prompt.""" + prompt = "What is the weather like in Seattle?" + response = self._invoke_agent_with_jwt(jwt_token, prompt) + + assert response is not None + assert isinstance(response, dict) + # Since this is a weather agent, it should be able to handle weather queries + + def test_agent_invocation_with_invalid_token(self): + """Test that agent invocation fails with an invalid JWT token.""" + invalid_token = "invalid.jwt.token" + + with pytest.raises(Exception) as exc_info: + self._invoke_agent_with_jwt(invalid_token, "Hello!") + + # Should fail with authentication error + assert "401" in str(exc_info.value) or "403" in str(exc_info.value) + + def test_agent_invocation_with_expired_token(self): + """Test that agent invocation fails with an expired JWT token.""" + # This is a known expired JWT token (expired in the past) + expired_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkwMjJ9.invalid" + + with pytest.raises(Exception) as exc_info: + self._invoke_agent_with_jwt(expired_token, "Hello!") + + # Should fail with authentication error + assert "401" in str(exc_info.value) or "403" in str(exc_info.value) + + def _invoke_agent_with_jwt(self, access_token: str, prompt: str) -> dict[str, Any]: + """Helper method to invoke the agent runtime using JWT bearer token.""" + agent_runtime_arn = os.environ["AGENT_RUNTIME_ARN"] + region = os.environ["AWS_REGION"] + + # URL encode the agent ARN + escaped_agent_arn = urllib.parse.quote(agent_runtime_arn, safe="") + + # Construct the URL + url = f"https://bedrock-agentcore.{region}.amazonaws.com/runtimes/{escaped_agent_arn}/invocations?qualifier=DEFAULT" + + # Set up headers + session_id = f"test-session-{uuid.uuid4().hex}" # Generates 44 character string + trace_id = f"test-trace-{uuid.uuid4().hex[:16]}" # Shorter trace ID + + headers = { + "Authorization": f"Bearer {access_token}", + "X-Amzn-Trace-Id": trace_id, + "Content-Type": "application/json", + "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": session_id, + } + + # Prepare payload in the correct format expected by AgentCore + payload = {"input": {"prompt": prompt}} + + response = requests.post( + url, headers=headers, data=json.dumps(payload), timeout=60 + ) + + if response.status_code == 200: + return response.json() + else: + # Try to get error details + try: + error_data = response.json() + error_msg = f"Agent invocation failed with status {response.status_code}: {json.dumps(error_data)}" + except json.JSONDecodeError: + error_msg = f"Agent invocation failed with status {response.status_code}: {response.text}" + + raise Exception(error_msg) diff --git a/examples/agentcore/tests/agent/test_simple_mcp_client.py b/examples/agentcore/tests/agent/test_simple_mcp_client.py new file mode 100644 index 0000000..4109ae4 --- /dev/null +++ b/examples/agentcore/tests/agent/test_simple_mcp_client.py @@ -0,0 +1,218 @@ +""" +Integration tests for SimpleMcpClient with AgentCore Runtime. + +This module tests the simplified MCP client with JWT passthrough authentication +using real AgentCore Runtime MCP servers and deployed infrastructure. +""" + +# nosec B101 + +import os + +import pytest +from simple_mcp_client import SimpleMcpClient + + +class TestSimpleMcpClientIntegration: + """Integration tests with real AgentCore Runtime MCP server.""" + + @pytest.fixture(scope="class") + def simple_client(self, jwt_token): + """Create simple MCP client with real configuration.""" + return SimpleMcpClient( + runtime_arn=os.environ["MCP_SERVER_RUNTIME_ARN"], jwt_token=jwt_token + ) + + def test_client_initialization_with_real_arn(self, simple_client, jwt_token): + """Test client initialization with real AgentCore Runtime ARN.""" + mcp_server_runtime_arn = os.environ["MCP_SERVER_RUNTIME_ARN"] + assert simple_client.runtime_arn == mcp_server_runtime_arn # nosec B101 + assert simple_client.region in mcp_server_runtime_arn # nosec B101 + assert not simple_client.is_connected() # nosec B101 + assert simple_client.jwt_token == jwt_token # nosec B101 + + # Verify MCP URL construction + assert "bedrock-agentcore" in simple_client.mcp_url # nosec B101 + assert simple_client.region in simple_client.mcp_url # nosec B101 + assert "invocations?qualifier=DEFAULT" in simple_client.mcp_url # nosec B101 + + @pytest.mark.asyncio + async def test_full_connection_to_agentcore_mcp_server( + self, simple_client, jwt_token + ): + """Test full connection to deployed AgentCore Runtime MCP server.""" + try: + # Connect to the MCP server + await simple_client.connect() + + # Verify connection + assert simple_client.is_connected() # nosec B101 + + except Exception as e: + # Log the error details for debugging + print(f"\nConnection failed with error: {e}") + print(f"Error type: {type(e)}") + print(f"MCP URL: {simple_client.mcp_url}") + + # Don't fail the test immediately - let's see what the error is + pytest.fail(f"Failed to connect to AgentCore Runtime MCP server: {e}") + + finally: + # Always disconnect + try: + await simple_client.disconnect() + except Exception as disconnect_error: + print(f"Disconnect error: {disconnect_error}") + + @pytest.mark.asyncio + async def test_list_tools_from_deployed_mcp_server(self, simple_client, jwt_token): + """Test listing tools from deployed AgentCore Runtime MCP server.""" + try: + # Connect and list tools + await simple_client.connect() + + tools_result = await simple_client.list_tools() + + # Verify we got tools + assert hasattr(tools_result, "tools"), "Expected tools in response" # nosec B101 + assert len(tools_result.tools) > 0, "Expected at least one tool" # nosec B101 + + # Log available tools for debugging + print(f"\nFound {len(tools_result.tools)} tools:") + for tool in tools_result.tools: + print(f" - {tool.name}: {tool.description}") + + # Verify tool structure + first_tool = tools_result.tools[0] + assert hasattr(first_tool, "name"), "Tool should have name" # nosec B101 + assert hasattr(first_tool, "description"), "Tool should have description" # nosec B101 + assert hasattr(first_tool, "inputSchema"), "Tool should have input schema" # nosec B101 + + except Exception as e: + pytest.fail(f"Failed to list tools from MCP server: {e}") + + finally: + await simple_client.disconnect() + + @pytest.mark.asyncio + async def test_call_tool_on_deployed_mcp_server(self, simple_client, jwt_token): + """Test calling weather tools on deployed AgentCore Runtime MCP server.""" + try: + # Connect and get tools + await simple_client.connect() + tools_result = await simple_client.list_tools() + + # Test must fail if no tools are available + assert tools_result.tools, "MCP server must have tools available" # nosec B101 + assert len(tools_result.tools) > 0, ( + "Expected at least one tool from MCP server" + ) # nosec B101 + + # Find and test get_weather tool + weather_tool = None + forecast_tool = None + + for tool in tools_result.tools: + if tool.name == "get_weather": + weather_tool = tool + elif tool.name == "get_forecast": + forecast_tool = tool + + # Test get_weather tool + if weather_tool: + print("\nTesting get_weather tool:") + print(f"Description: {weather_tool.description}") + + result = await simple_client.call_tool( + "get_weather", {"city": "Amsterdam"} + ) + + assert result is not None, "get_weather tool should return a result" # nosec B101 + print(f"Weather result: {result}") + + # Verify the result contains expected weather information + result_str = str(result).lower() + assert "amsterdam" in result_str, "Result should mention Amsterdam" # nosec B101 + assert any( + indicator in result_str + for indicator in ["weather", "temperature", "sunny", "°c"] + ), "Result should contain weather information" # nosec B101 + + # Test get_forecast tool + if forecast_tool: + print("\nTesting get_forecast tool:") + print(f"Description: {forecast_tool.description}") + + result = await simple_client.call_tool( + "get_forecast", {"city": "London", "days": 5} + ) + + assert result is not None, "get_forecast tool should return a result" # nosec B101 + print(f"Forecast result: {result}") + + # Verify the result contains expected forecast information + result_str = str(result).lower() + assert "london" in result_str, "Result should mention London" # nosec B101 + assert any( + indicator in result_str + for indicator in ["forecast", "days", "weather", "°c"] + ), "Result should contain forecast information" # nosec B101 + + # Ensure we tested at least one tool + if not weather_tool and not forecast_tool: + pytest.fail( + "Expected to find get_weather or get_forecast tools in MCP server" + ) + + print( + f"\nSuccessfully tested {len([t for t in [weather_tool, forecast_tool] if t])} weather tools" + ) + + except Exception as e: + pytest.fail(f"Failed to call tools on MCP server: {e}") + + finally: + await simple_client.disconnect() + + @pytest.mark.asyncio + async def test_context_manager_with_real_server(self, simple_client, jwt_token): + """Test async context manager with real AgentCore Runtime MCP server.""" + try: + # Use as context manager + async with simple_client as client: + # Verify connection + assert client.is_connected() # nosec B101 + + # List tools to verify functionality + tools_result = await client.list_tools() + assert len(tools_result.tools) > 0 # nosec B101 + + # Verify disconnection after context exit + assert not simple_client.is_connected() # nosec B101 + + except Exception as e: + pytest.fail(f"Context manager test failed: {e}") + + @pytest.mark.asyncio + async def test_automatic_configuration_loading(self, simple_client, jwt_token): + """Test that configuration is loaded automatically from CDK stack.""" + try: + # The client should be able to load configuration automatically from environment variables + await simple_client.connect() + + # If we get here, configuration was loaded successfully + assert simple_client.jwt_token is not None # nosec B101 + + # Verify we can list tools (proves authentication worked) + tools_result = await simple_client.list_tools() + assert len(tools_result.tools) > 0 # nosec B101 + + except Exception as e: + pytest.fail(f"Automatic configuration loading failed: {e}") + + finally: + await simple_client.disconnect() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/agentcore/tests/conftest.py b/examples/agentcore/tests/conftest.py new file mode 100644 index 0000000..0adc767 --- /dev/null +++ b/examples/agentcore/tests/conftest.py @@ -0,0 +1,168 @@ +"""Unified pytest configuration for AgentCore tests.""" + +import json +import os +import sys +from pathlib import Path +from typing import Any + +import boto3 +import pytest + + +def get_cdk_stack_name() -> str: + """Get CDK stack name from .env file.""" + + # First try environment variable + if stack_name := os.getenv("CDK_STACK_NAME"): + return stack_name + + # Read from .env file relative to this script's location + script_dir = Path(__file__).parent + env_path = script_dir / "../.env" + if env_path.exists(): + with open(env_path) as f: + for line in f: + if line.startswith("CDK_STACK_NAME="): + return line.split("=", 1)[1].strip() + + raise RuntimeError("CDK_STACK_NAME not found in environment or .env file") + + +# Set required environment variables to prevent SystemExit during test collection +# This must be done before any agent module imports +def _setup_required_environment_variables(): + """Set up required environment variables from CDK outputs or defaults.""" + + # Set defaults for basic configuration + os.environ["AWS_REGION"] = boto3.Session().region_name or "eu-central-1" + os.environ["BEDROCK_MODEL_ID"] = "eu.anthropic.claude-sonnet-4-20250514-v1:0" + + # Get CDK stack name + stack_name = get_cdk_stack_name() + + # Get CDK outputs + cf_client = boto3.client("cloudformation") + response = cf_client.describe_stacks(StackName=stack_name) + stack_outputs = response["Stacks"][0]["Outputs"] + + # Set environment variables from CDK outputs + for output in stack_outputs: + key = output["OutputKey"] + value = output["OutputValue"] + + # MCP Server Runtime ARN + if "McpServerRuntimeArn" in key and "Endpoint" not in key: + os.environ["MCP_SERVER_RUNTIME_ARN"] = value + # Cognito User Pool ID + elif "CognitoAuthUserPoolId" in key: + os.environ["OAUTH_USER_POOL_ID"] = value + # Cognito User Pool Client ID + elif "CognitoAuthUserPoolClientId" in key: + os.environ["OAUTH_USER_POOL_CLIENT_ID"] = value + # Client User Credentials Secret Name + elif "ClientUserCredentialsSecretName" in key: + os.environ["CLIENT_USER_CREDENTIALS_SECRET_NAME"] = value + # Agent Runtime ARN (not endpoint) + elif "AgentRuntimeArn" in key and "Endpoint" not in key: + os.environ["AGENT_RUNTIME_ARN"] = value + # Agent Runtime Endpoint ARN + elif "AgentRuntimeEndpointArn" in key: + os.environ["AGENT_RUNTIME_ENDPOINT_ARN"] = value + # MCP Server Runtime Endpoint ARN + elif "McpServerRuntimeEndpointArn" in key: + os.environ["MCP_SERVER_RUNTIME_ENDPOINT_ARN"] = value + + +# Set up environment variables before any imports +_setup_required_environment_variables() + +# Add agent directory to Python path for imports +agent_dir = Path(__file__).parent.parent / "agent" +sys.path.insert(0, str(agent_dir)) + + +@pytest.fixture(scope="session") +def cdk_outputs() -> dict[str, Any]: + """Get CDK stack outputs for testing deployed resources.""" + try: + stack_name = get_cdk_stack_name() + + # Create CloudFormation client (uses environment variables for region) + cf_client = boto3.client("cloudformation") + + # Get stack outputs using AWS SDK + response = cf_client.describe_stacks(StackName=stack_name) + + outputs = {} + if "Stacks" in response and len(response["Stacks"]) > 0: + stack_outputs = response["Stacks"][0].get("Outputs", []) + for output in stack_outputs: + outputs[output["OutputKey"]] = output["OutputValue"] + + return outputs + + except Exception as e: + raise RuntimeError( + f"Failed to get CDK outputs for stack '{stack_name}': {e}. " + f"Ensure CDK stack is deployed with 'cdk deploy'." + ) from e + + +@pytest.fixture(scope="session") +def bedrock_agentcore_control_client(): + """Create Bedrock AgentCore Control client for testing.""" + return boto3.client("bedrock-agentcore-control") + + +@pytest.fixture(scope="session") +def bedrock_agentcore_client(): + """Create Bedrock AgentCore client for testing runtime invocation.""" + return boto3.client("bedrock-agentcore") + + +# JWT Authentication fixtures + + +@pytest.fixture(scope="session") +def client_credentials() -> dict[str, str]: + """Retrieve client user credentials from AWS Secrets Manager.""" + secret_name = os.environ["CLIENT_USER_CREDENTIALS_SECRET_NAME"] + region = os.environ["AWS_REGION"] + + # Create a Secrets Manager client + session = boto3.session.Session() + client = session.client(service_name="secretsmanager", region_name=region) + + get_secret_value_response = client.get_secret_value(SecretId=secret_name) + secret = json.loads(get_secret_value_response["SecretString"]) + return {"username": secret["username"], "password": secret["password"]} + + +@pytest.fixture(scope="session") +def jwt_token(client_credentials: dict[str, str]) -> str: + """Authenticate with Cognito and get JWT access token.""" + client_id = os.environ["OAUTH_USER_POOL_CLIENT_ID"] + region = os.environ["AWS_REGION"] + + # Create Cognito Identity Provider client + cognito_client = boto3.client("cognito-idp", region_name=region) + + response = cognito_client.initiate_auth( + ClientId=client_id, + AuthFlow="USER_PASSWORD_AUTH", + AuthParameters={ + "USERNAME": client_credentials["username"], + "PASSWORD": client_credentials["password"], + }, + ) + + access_token = response["AuthenticationResult"]["AccessToken"] + assert access_token, "JWT access token should not be empty" + return access_token + + +# Agent-specific fixtures - now using environment variables + + +# MCP Server-specific fixtures - now using environment variables diff --git a/examples/agentcore/tests/mcp_server/__init__.py b/examples/agentcore/tests/mcp_server/__init__.py new file mode 100644 index 0000000..1e791d7 --- /dev/null +++ b/examples/agentcore/tests/mcp_server/__init__.py @@ -0,0 +1 @@ +"""MCP server tests.""" diff --git a/examples/agentcore/tests/mcp_server/test_mcp_server_deployment.py b/examples/agentcore/tests/mcp_server/test_mcp_server_deployment.py new file mode 100644 index 0000000..d1b3030 --- /dev/null +++ b/examples/agentcore/tests/mcp_server/test_mcp_server_deployment.py @@ -0,0 +1,213 @@ +"""Test the deployed AgentCore MCP server deployment and basic protocol functionality.""" + +# nosec B101 + +import asyncio +import os +from urllib.parse import quote + +import pytest +from botocore.exceptions import ClientError +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +def _construct_mcp_endpoint_url(mcp_server_runtime_arn: str) -> str: + """Construct the MCP endpoint URL from the runtime ARN.""" + region = mcp_server_runtime_arn.split(":")[3] + encoded_arn = quote(mcp_server_runtime_arn, safe="") + return f"https://bedrock-agentcore.{region}.amazonaws.com/runtimes/{encoded_arn}/invocations?qualifier=DEFAULT" + + +class TestMcpServerDeployment: + """Test suite for MCP server deployment verification.""" + + def test_mcp_server_endpoint_url_construction(self): + """Test that MCP endpoint URL can be constructed from runtime ARN.""" + mcp_server_runtime_arn = os.environ["MCP_SERVER_RUNTIME_ARN"] + mcp_url = _construct_mcp_endpoint_url(mcp_server_runtime_arn) + + # Verify URL structure + assert "https://bedrock-agentcore." in mcp_url # nosec B101 + assert ".amazonaws.com/runtimes/" in mcp_url # nosec B101 + assert "invocations?qualifier=DEFAULT" in mcp_url # nosec B101 + + # Verify region is extracted correctly + region = mcp_server_runtime_arn.split(":")[3] + assert region in mcp_url # nosec B101 + + print(f"✅ MCP endpoint URL constructed: {mcp_url}") + + def test_mcp_server_runtime_exists(self, bedrock_agentcore_control_client): + """Test that the MCP server runtime exists and is accessible.""" + mcp_server_runtime_arn = os.environ["MCP_SERVER_RUNTIME_ARN"] + runtime_id = mcp_server_runtime_arn.split("/")[-1] + + try: + response = bedrock_agentcore_control_client.get_agent_runtime( + agentRuntimeId=runtime_id + ) + assert response["agentRuntimeId"] == runtime_id # nosec B101 + assert response["status"] in ["READY", "CREATING", "UPDATING"] # nosec B101 + assert response["protocolConfiguration"]["serverProtocol"] == "MCP" # nosec B101 + except ClientError as e: + pytest.fail(f"Failed to get MCP server runtime: {e}") + + def test_mcp_server_runtime_endpoint_exists(self, bedrock_agentcore_control_client): + """Test that the MCP server runtime endpoint exists and is accessible.""" + mcp_server_runtime_endpoint_arn = os.environ["MCP_SERVER_RUNTIME_ENDPOINT_ARN"] + arn_parts = mcp_server_runtime_endpoint_arn.split("/") + runtime_id = arn_parts[-3] + endpoint_name = arn_parts[-1] + + try: + response = bedrock_agentcore_control_client.get_agent_runtime_endpoint( + agentRuntimeId=runtime_id, endpointName=endpoint_name + ) + assert response["name"] == endpoint_name # nosec B101 + assert runtime_id in response["agentRuntimeArn"] # nosec B101 + assert response["status"] in ["READY", "CREATING", "UPDATING"] # nosec B101 + except ClientError as e: + pytest.fail(f"Failed to get MCP server runtime endpoint: {e}") + + def test_mcp_server_security_and_protocol_compliance( + self, bedrock_agentcore_client + ): + """Test MCP server security and protocol compliance. + + This test verifies: + 1. The MCP endpoint is reachable and responds to protocol requests + 2. Authentication is properly enforced (rejects unauthenticated requests) + 3. MCP protocol standards are followed (proper error handling) + """ + + async def run_mcp_test(): + mcp_server_runtime_arn = os.environ["MCP_SERVER_RUNTIME_ARN"] + mcp_url = _construct_mcp_endpoint_url(mcp_server_runtime_arn) + auth_error_received = False + + try: + headers = {} + async with streamablehttp_client(mcp_url, headers, timeout=10) as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: + # Test MCP session initialization + await asyncio.wait_for(session.initialize(), timeout=10.0) + + # Test 1: Try to list tools without authentication + try: + tools_result = await session.list_tools() + # This should not happen with a properly secured server + pytest.fail( + "MCP server allowed unauthenticated access - this is a security issue! " + f"Got {len(tools_result.tools) if hasattr(tools_result, 'tools') else 0} tools" + ) + except Exception as list_error: + error_msg = str(list_error).lower() + if any( + auth_error in error_msg + for auth_error in [ + "unauthorized", + "401", + "403", + "authentication", + "forbidden", + "access denied", + "httpstatuserror", + ] + ): + auth_error_received = True + print( + "✅ MCP server properly rejects unauthenticated tool listing" + ) + else: + raise list_error + + # Test 2: Try to call an invalid tool (if we got past list_tools) + if not auth_error_received: + try: + await session.call_tool( + name="invalid_tool_name", arguments={} + ) + pytest.fail("Expected error for invalid tool name") + except Exception as tool_error: + error_msg = str(tool_error).lower() + if any( + auth_error in error_msg + for auth_error in [ + "unauthorized", + "401", + "403", + "authentication", + "forbidden", + "access denied", + ] + ): + auth_error_received = True + print( + "✅ MCP server requires authentication for tool calls" + ) + elif "not found" in error_msg or "invalid" in error_msg: + print( + "✅ MCP server properly handles invalid tool requests" + ) + else: + raise tool_error + + except Exception as e: + error_msg = str(e).lower() + if any( + auth_error in error_msg + for auth_error in [ + "unauthorized", + "401", + "403", + "authentication", + "forbidden", + "access denied", + "httpstatuserror", + ] + ): + auth_error_received = True + print(f"✅ MCP server is properly secured at protocol level: {e}") + else: + # Unexpected error - could be network, protocol, or other issue + raise + + # Verify we got the expected authentication error + assert auth_error_received, ( # nosec B101 + "Expected authentication error when connecting without credentials. " + "MCP server should reject unauthenticated requests." + ) + + try: + asyncio.run(run_mcp_test()) + except (BaseExceptionGroup, Exception) as e: + # Handle both ExceptionGroup and regular exceptions + exceptions = e.exceptions if hasattr(e, "exceptions") else [e] + auth_error_found = False + + for exc in exceptions: + error_msg = str(exc).lower() + if any( + auth_error in error_msg + for auth_error in [ + "unauthorized", + "401", + "403", + "authentication", + "forbidden", + "access denied", + "httpstatuserror", + ] + ): + auth_error_found = True + print(f"✅ MCP server is properly secured: {exc}") + break + + if not auth_error_found: + # Re-raise if it's not an auth error + raise diff --git a/examples/agentcore/tests/mcp_server/test_mcp_server_health.py b/examples/agentcore/tests/mcp_server/test_mcp_server_health.py new file mode 100644 index 0000000..ab7981d --- /dev/null +++ b/examples/agentcore/tests/mcp_server/test_mcp_server_health.py @@ -0,0 +1,51 @@ +"""Health check tests for AgentCore MCP server monitoring and observability.""" + +# nosec B101 + +import os +from datetime import UTC, datetime, timedelta + +import boto3 + + +class TestMcpServerHealth: + """Health check tests for MCP server monitoring and observability.""" + + def test_mcp_server_runtime_logs_exist(self, bedrock_agentcore_control_client): + """Test that MCP server runtime has associated CloudWatch logs.""" + mcp_server_runtime_arn = os.environ["MCP_SERVER_RUNTIME_ARN"] + runtime_id = mcp_server_runtime_arn.split("/")[-1] + logs_client = boto3.client("logs") + + log_group_name = f"/aws/bedrock-agentcore/runtimes/{runtime_id}-DEFAULT" + + response = logs_client.describe_log_groups( + logGroupNamePrefix=log_group_name, limit=1 + ) + + log_groups = response.get("logGroups", []) + assert len(log_groups) == 1, f"Expected log group {log_group_name} to exist" # nosec B101 + assert log_groups[0]["logGroupName"] == log_group_name # nosec B101 + + def test_mcp_server_runtime_metrics_queryable(self): + """Test that CloudWatch metrics can be queried for the MCP server runtime.""" + mcp_server_runtime_arn = os.environ["MCP_SERVER_RUNTIME_ARN"] + runtime_id = mcp_server_runtime_arn.split("/")[-1] + cloudwatch = boto3.client("cloudwatch") + + end_time = datetime.now(UTC) + start_time = end_time - timedelta(hours=1) + + response = cloudwatch.get_metric_statistics( + Namespace="bedrock-agentcore", + MetricName="Invocations", + Dimensions=[{"Name": "RuntimeId", "Value": runtime_id}], + StartTime=start_time, + EndTime=end_time, + Period=300, # 5 minutes + Statistics=["Sum"], + ) + + # The query should succeed and return a list (may be empty for new deployments) + datapoints = response.get("Datapoints", []) + assert isinstance(datapoints, list) # nosec B101 diff --git a/examples/agentcore/tests/mcp_server/test_mcp_server_tools.py b/examples/agentcore/tests/mcp_server/test_mcp_server_tools.py new file mode 100644 index 0000000..be99469 --- /dev/null +++ b/examples/agentcore/tests/mcp_server/test_mcp_server_tools.py @@ -0,0 +1,345 @@ +"""Tests for deployed MCP server tool schema validation.""" + +# nosec B101 + +import json +import os +import sys +from pathlib import Path + +import pytest + +# Add agent directory to Python path for imports +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "agent")) + +from simple_mcp_client import SimpleMcpClient + + +class TestMcpServerDeployment: + """Test suite for deployed MCP server tool schema validation.""" + + async def _get_tools_via_mcp_client(self, jwt_token): + """Helper method to get tools using SimpleMcpClient.""" + mcp_server_runtime_arn = os.environ["MCP_SERVER_RUNTIME_ARN"] + + async with SimpleMcpClient( + runtime_arn=mcp_server_runtime_arn, jwt_token=jwt_token + ) as mcp_client: + tools_result = await mcp_client.list_tools() + return tools_result.tools + + @pytest.mark.asyncio + async def test_mcp_server_tools_list_endpoint(self, cdk_outputs, jwt_token): + """Test that the deployed MCP server responds to tools/list requests using MCP client.""" + mcp_server_runtime_arn = os.environ["MCP_SERVER_RUNTIME_ARN"] + + # Use SimpleMcpClient to connect and list tools + async with SimpleMcpClient( + runtime_arn=mcp_server_runtime_arn, jwt_token=jwt_token + ) as mcp_client: + # List available tools from MCP server + tools_result = await mcp_client.list_tools() + + # Verify we got a valid response + assert tools_result is not None # nosec B101 + assert hasattr(tools_result, "tools") # nosec B101 + assert isinstance(tools_result.tools, list) # nosec B101 + + # Verify we have the expected weather tools + tool_names = [tool.name for tool in tools_result.tools] + assert "get_weather" in tool_names # nosec B101 + assert "get_forecast" in tool_names # nosec B101 + assert len(tools_result.tools) == 2 # nosec B101 + + @pytest.mark.asyncio + async def test_mcp_server_returns_weather_tools(self, cdk_outputs, jwt_token): + """Test that the MCP server returns the expected weather tools.""" + tools = await self._get_tools_via_mcp_client(jwt_token) + + # Verify we have the expected tools + tool_names = [tool.name for tool in tools] + assert "get_weather" in tool_names # nosec B101 + assert "get_forecast" in tool_names # nosec B101 + + # Verify we have exactly 2 tools + assert len(tools) == 2 # nosec B101 + + @pytest.mark.asyncio + async def test_weather_tool_json_schema_structure(self, cdk_outputs, jwt_token): + """Test that the get_weather tool returns proper JSON schema with Pydantic model structure.""" + tools = await self._get_tools_via_mcp_client(jwt_token) + + # Find the get_weather tool + weather_tool = None + for tool in tools: + if tool.name == "get_weather": + weather_tool = tool + break + + assert weather_tool is not None, "get_weather tool not found" # nosec B101 + + # Verify tool structure + assert hasattr(weather_tool, "name") # nosec B101 + assert hasattr(weather_tool, "description") # nosec B101 + assert hasattr(weather_tool, "inputSchema") # nosec B101 + + # Verify the inputSchema is a proper JSON schema + schema = weather_tool.inputSchema + assert isinstance(schema, dict) # nosec B101 + assert "type" in schema # nosec B101 + assert schema["type"] == "object" # nosec B101 + assert "properties" in schema # nosec B101 + assert "required" in schema # nosec B101 + + # Verify request parameter exists (Pydantic model parameter) + assert "request" in schema["properties"] # nosec B101 + request_prop = schema["properties"]["request"] + + # The request should reference a Pydantic model definition + if "$ref" in request_prop: + # Schema uses $ref to reference model definition + assert "$defs" in schema or "definitions" in schema # nosec B101 + # Find the referenced model in definitions + ref_path = request_prop["$ref"] + if ref_path.startswith("#/$defs/"): + model_name = ref_path.split("/")[-1] + model_def = schema["$defs"][model_name] + elif ref_path.startswith("#/definitions/"): + model_name = ref_path.split("/")[-1] + model_def = schema["definitions"][model_name] + else: + model_def = request_prop + else: + # Schema has model definition inline + model_def = request_prop + + assert model_def["type"] == "object" # nosec B101 + assert "properties" in model_def # nosec B101 + assert "required" in model_def # nosec B101 + + # Verify city property exists within the model + assert "city" in model_def["properties"] # nosec B101 + city_prop = model_def["properties"]["city"] + assert city_prop["type"] == "string" # nosec B101 + assert "description" in city_prop # nosec B101 + assert "minLength" in city_prop # nosec B101 + assert "maxLength" in city_prop # nosec B101 + + # Verify required fields + assert "request" in schema["required"] # nosec B101 + assert "city" in model_def["required"] # nosec B101 + + @pytest.mark.asyncio + async def test_forecast_tool_json_schema_structure(self, cdk_outputs, jwt_token): + """Test that the get_forecast tool returns proper JSON schema with Pydantic model structure.""" + tools = await self._get_tools_via_mcp_client(jwt_token) + + # Find the get_forecast tool + forecast_tool = None + for tool in tools: + if tool.name == "get_forecast": + forecast_tool = tool + break + + assert forecast_tool is not None, "get_forecast tool not found" # nosec B101 + + # Verify tool structure + assert hasattr(forecast_tool, "name") # nosec B101 + assert hasattr(forecast_tool, "description") # nosec B101 + assert hasattr(forecast_tool, "inputSchema") # nosec B101 + + # Verify the inputSchema is a proper JSON schema + schema = forecast_tool.inputSchema + assert isinstance(schema, dict) # nosec B101 + assert "type" in schema # nosec B101 + assert schema["type"] == "object" # nosec B101 + assert "properties" in schema # nosec B101 + assert "required" in schema # nosec B101 + + # Verify request parameter exists (Pydantic model parameter) + assert "request" in schema["properties"] # nosec B101 + request_prop = schema["properties"]["request"] + + # The request should reference a Pydantic model definition + if "$ref" in request_prop: + # Schema uses $ref to reference model definition + assert "$defs" in schema or "definitions" in schema # nosec B101 + # Find the referenced model in definitions + ref_path = request_prop["$ref"] + if ref_path.startswith("#/$defs/"): + model_name = ref_path.split("/")[-1] + model_def = schema["$defs"][model_name] + elif ref_path.startswith("#/definitions/"): + model_name = ref_path.split("/")[-1] + model_def = schema["definitions"][model_name] + else: + model_def = request_prop + else: + # Schema has model definition inline + model_def = request_prop + + assert model_def["type"] == "object" # nosec B101 + assert "properties" in model_def # nosec B101 + assert "required" in model_def # nosec B101 + + # Verify city property within the model + assert "city" in model_def["properties"] # nosec B101 + city_prop = model_def["properties"]["city"] + assert city_prop["type"] == "string" # nosec B101 + assert "description" in city_prop # nosec B101 + + # Verify days property within the model + assert "days" in model_def["properties"] # nosec B101 + days_prop = model_def["properties"]["days"] + assert days_prop["type"] == "integer" # nosec B101 + assert "default" in days_prop # nosec B101 + assert days_prop["default"] == 3 # nosec B101 + assert "minimum" in days_prop # nosec B101 + assert "maximum" in days_prop # nosec B101 + + # Verify required fields (request is required, within model only city is required) + assert "request" in schema["required"] # nosec B101 + assert "city" in model_def["required"] # nosec B101 + assert "days" not in model_def["required"] # nosec B101 + + @pytest.mark.asyncio + async def test_tool_schemas_are_valid_json(self, cdk_outputs, jwt_token): + """Test that all tool schemas are valid JSON and can be serialized.""" + tools = await self._get_tools_via_mcp_client(jwt_token) + + # Test that each tool's schema can be serialized and deserialized + for tool in tools: + schema = tool.inputSchema + + # Test JSON serialization/deserialization + schema_json = json.dumps(schema) + parsed_schema = json.loads(schema_json) + assert parsed_schema == schema # nosec B101 + + # Verify schema has required JSON Schema fields + assert "type" in schema # nosec B101 + assert ( # nosec B101 + "properties" in schema + or "items" in schema + or schema["type"] in ["string", "number", "boolean"] + ) + + @pytest.mark.asyncio + async def test_tool_descriptions_are_present(self, cdk_outputs, jwt_token): + """Test that all tools have meaningful descriptions.""" + tools = await self._get_tools_via_mcp_client(jwt_token) + + # Verify each tool has a meaningful description + for tool in tools: + assert hasattr(tool, "description") # nosec B101 + description = tool.description + assert isinstance(description, str) # nosec B101 + assert len(description.strip()) > 10 # Meaningful description # nosec B101 + + # Verify descriptions contain usage guidance + description_lower = description.lower() + assert any( # nosec B101 + keyword in description_lower + for keyword in ["use", "tool", "when", "example", "get"] + ) + + @pytest.mark.asyncio + async def test_pydantic_model_validation_rules_preserved( + self, cdk_outputs, jwt_token + ): + """Test that Pydantic model validation rules are preserved in the JSON schema.""" + tools = await self._get_tools_via_mcp_client(jwt_token) + + # Test weather tool validation rules + weather_tool = next( + (tool for tool in tools if tool.name == "get_weather"), None + ) + assert weather_tool is not None # nosec B101 + + # Get the model definition (handle $ref or inline) + weather_request_schema = weather_tool.inputSchema["properties"]["request"] + if "$ref" in weather_request_schema: + ref_path = weather_request_schema["$ref"] + if ref_path.startswith("#/$defs/"): + model_name = ref_path.split("/")[-1] + weather_model_def = weather_tool.inputSchema["$defs"][model_name] + elif ref_path.startswith("#/definitions/"): + model_name = ref_path.split("/")[-1] + weather_model_def = weather_tool.inputSchema["definitions"][model_name] + else: + weather_model_def = weather_request_schema + + city_prop = weather_model_def["properties"]["city"] + + # Verify Pydantic Field validation rules are preserved + assert city_prop["minLength"] == 1 # nosec B101 + assert city_prop["maxLength"] == 100 # nosec B101 + assert "description" in city_prop # nosec B101 + assert len(city_prop["description"]) > 0 # nosec B101 + + # Test forecast tool validation rules + forecast_tool = next( + (tool for tool in tools if tool.name == "get_forecast"), None + ) + assert forecast_tool is not None # nosec B101 + + # Get the model definition (handle $ref or inline) + forecast_request_schema = forecast_tool.inputSchema["properties"]["request"] + if "$ref" in forecast_request_schema: + ref_path = forecast_request_schema["$ref"] + if ref_path.startswith("#/$defs/"): + model_name = ref_path.split("/")[-1] + forecast_model_def = forecast_tool.inputSchema["$defs"][model_name] + elif ref_path.startswith("#/definitions/"): + model_name = ref_path.split("/")[-1] + forecast_model_def = forecast_tool.inputSchema["definitions"][ + model_name + ] + else: + forecast_model_def = forecast_request_schema + + days_prop = forecast_model_def["properties"]["days"] + + # Verify Pydantic Field validation rules for days + assert days_prop["minimum"] == 1 # nosec B101 + assert days_prop["maximum"] == 7 # nosec B101 + assert days_prop["default"] == 3 # nosec B101 + assert "description" in days_prop # nosec B101 + assert len(days_prop["description"]) > 0 # nosec B101 + + @pytest.mark.asyncio + async def test_pydantic_model_titles_and_descriptions(self, cdk_outputs, jwt_token): + """Test that Pydantic model titles and descriptions are included in schemas.""" + tools = await self._get_tools_via_mcp_client(jwt_token) + + for tool in tools: + request_schema = tool.inputSchema["properties"]["request"] + + # Get the model definition (handle $ref or inline) + if "$ref" in request_schema: + ref_path = request_schema["$ref"] + if ref_path.startswith("#/$defs/"): + model_name = ref_path.split("/")[-1] + model_def = tool.inputSchema["$defs"][model_name] + elif ref_path.startswith("#/definitions/"): + model_name = ref_path.split("/")[-1] + model_def = tool.inputSchema["definitions"][model_name] + else: + model_def = request_schema + else: + model_def = request_schema + + # Verify Pydantic model has title + assert "title" in model_def # nosec B101 + assert isinstance(model_def["title"], str) # nosec B101 + assert len(model_def["title"]) > 0 # nosec B101 + + # Verify Pydantic model has description with examples + if "description" in model_def: + description = model_def["description"] + assert isinstance(description, str) # nosec B101 + # Should contain usage examples from our Pydantic model docstrings + assert any( # nosec B101 + keyword in description.lower() + for keyword in ["example", "use", "when"] + ) diff --git a/examples/agentcore/tests/pytest.ini b/examples/agentcore/tests/pytest.ini new file mode 100644 index 0000000..b8326ff --- /dev/null +++ b/examples/agentcore/tests/pytest.ini @@ -0,0 +1,16 @@ +[pytest] +testpaths = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --tb=short + --strict-markers +asyncio_mode = auto +markers = + slow: marks tests as slow (deselect with '-m "not slow"') + integration: marks tests as integration tests + deployment: marks tests that require deployed infrastructure + health: marks tests as health check tests + asyncio: marks tests as async tests \ No newline at end of file diff --git a/examples/agentcore/tools/README.md b/examples/agentcore/tools/README.md new file mode 100644 index 0000000..0973019 --- /dev/null +++ b/examples/agentcore/tools/README.md @@ -0,0 +1,83 @@ +# AgentCore Tools + +This directory contains utility tools for working with AgentCore agents. + +## view-agent-logs.py + +Cross-platform Python tool to view CloudWatch logs for AgentCore agents. + +### Usage + +```bash +# View logs for default agent (agentcore_integration_agent) in eu-central-1, last 1 hour +python view-agent-logs.py + +# Specify agent name +python view-agent-logs.py my_agent_name + +# Specify agent name and region +python view-agent-logs.py my_agent_name us-east-1 + +# Specify agent name, region, and hours back +python view-agent-logs.py my_agent_name us-east-1 24 + +# Show help +python view-agent-logs.py --help +``` + +### Features + +- **Automatic Agent Discovery**: Finds the latest agent runtime matching the provided name +- **Error Handling**: Shows helpful error messages and lists available agents/log groups +- **Color Coding**: Highlights ERROR (red), WARN (yellow), and INFO (green) messages +- **Cross-platform**: Works on Windows, macOS, and Linux +- **Flexible Time Range**: Specify how many hours back to search for logs +- **Stream Identification**: Shows session IDs from log stream names + +### Prerequisites + +- AWS CLI configured with appropriate permissions +- For AgentCore logs, you need: + - `bedrock-agentcore:ListAgentRuntimes` + - `logs:DescribeLogGroups` + - `logs:FilterLogEvents` + +### Examples + +#### Investigating a 500 Error +```bash +# Check recent logs for errors +python view-agent-logs.py agentcore_integration_agent eu-central-1 2 + +# Look for specific error patterns +python view-agent-logs.py my_agent | grep -i error +``` + +#### Monitoring Agent Activity +```bash +# Check logs over a longer period +python view-agent-logs.py my_agent us-east-1 24 + +# Monitor recent activity +python view-agent-logs.py my_agent eu-central-1 1 +``` + +### Troubleshooting + +#### No Agent Found +If you get "No agent runtimes found", the script will list all available agents. Make sure: +- The agent name prefix is correct +- You're using the right AWS region +- Your AWS credentials have the necessary permissions + +#### No Log Group Found +If the log group doesn't exist: +- The agent may not have been deployed yet +- The agent may not have been invoked (logs are created on first invocation) +- Check the agent runtime status with `aws bedrock-agentcore-control get-agent-runtime` + +#### No Logs in Time Range +If no logs appear: +- Increase the hours back parameter +- Check if the agent has been invoked recently +- Verify the agent runtime is in READY status \ No newline at end of file diff --git a/examples/agentcore/tools/view-agent-logs.py b/examples/agentcore/tools/view-agent-logs.py new file mode 100755 index 0000000..73f91e7 --- /dev/null +++ b/examples/agentcore/tools/view-agent-logs.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +""" +Script to view CloudWatch logs for an AgentCore agent. +Usage: python view-agent-logs.py [AGENT_NAME] [REGION] [TIME_BACK] +""" + +import argparse +import re +import sys +from datetime import datetime, timedelta + +import boto3 + + +def find_agent_runtime(agent_name: str, region: str) -> str | None: + """Find the latest agent runtime matching the name.""" + try: + client = boto3.client("bedrock-agentcore-control", region_name=region) + response = client.list_agent_runtimes() + + # Filter runtimes that start with the agent name + matching_runtimes = [ + runtime + for runtime in response.get("agentRuntimes", []) + if runtime["agentRuntimeName"].startswith(agent_name) + ] + + if not matching_runtimes: + return None + + # Sort by lastUpdatedAt and get the latest + latest_runtime = sorted(matching_runtimes, key=lambda x: x["lastUpdatedAt"])[-1] + + return latest_runtime["agentRuntimeId"] + + except Exception as e: + print(f"❌ Error finding agent runtime: {e}") + return None + + +def list_available_runtimes(region: str): + """List all available agent runtimes.""" + try: + client = boto3.client("bedrock-agentcore-control", region_name=region) + response = client.list_agent_runtimes() + + print("\nAvailable agent runtimes:") + print(f"{'Name':<30} {'ID':<40} {'Status':<15}") + print("-" * 85) + + for runtime in response.get("agentRuntimes", []): + print( + f"{runtime['agentRuntimeName']:<30} {runtime['agentRuntimeId']:<40} {runtime['status']:<15}" + ) + + except Exception as e: + print(f"❌ Error listing runtimes: {e}") + + +def check_log_group_exists(log_group: str, region: str) -> bool: + """Check if the log group exists.""" + try: + client = boto3.client("logs", region_name=region) + response = client.describe_log_groups(logGroupNamePrefix=log_group) + return len(response.get("logGroups", [])) > 0 + except Exception: + return False + + +def list_available_log_groups(region: str): + """List available AgentCore log groups.""" + try: + client = boto3.client("logs", region_name=region) + response = client.describe_log_groups( + logGroupNamePrefix="/aws/bedrock-agentcore" + ) + + print("\nAvailable AgentCore log groups:") + for log_group in response.get("logGroups", []): + print(f" {log_group['logGroupName']}") + + except Exception as e: + print(f"❌ Error listing log groups: {e}") + + +def colorize_log_message(message: str) -> str: + """Add color coding to log messages based on level.""" + if any(level in message.upper() for level in ["ERROR", "FATAL"]): + return f"\033[31m{message}\033[0m" # Red + elif "WARN" in message.upper(): + return f"\033[33m{message}\033[0m" # Yellow + elif "INFO" in message.upper(): + return f"\033[32m{message}\033[0m" # Green + return message + + +def parse_time_back(time_str: str) -> timedelta: + """Parse time string like '30m', '2h', '1.5h' into timedelta.""" + if isinstance(time_str, int): + # Backward compatibility - treat as hours + return timedelta(hours=time_str) + + time_str = str(time_str).lower().strip() + + # Extract number and unit + if time_str.endswith("m"): + minutes = float(time_str[:-1]) + return timedelta(minutes=minutes) + elif time_str.endswith("h"): + hours = float(time_str[:-1]) + return timedelta(hours=hours) + else: + # Default to hours if no unit specified + hours = float(time_str) + return timedelta(hours=hours) + + +def view_logs(agent_name: str, region: str, time_back: str): + """View CloudWatch logs for the agent.""" + time_delta = parse_time_back(time_back) + + print(f"Looking for agent: {agent_name}") + print(f"Region: {region}") + print(f"Time back: {time_back} ({time_delta})") + print("=" * 40) + + # Find the agent runtime + runtime_id = find_agent_runtime(agent_name, region) + if not runtime_id: + print(f"❌ No agent runtimes found starting with '{agent_name}'") + list_available_runtimes(region) + return False + + log_group = f"/aws/bedrock-agentcore/runtimes/{runtime_id}-DEFAULT" + + print(f"✅ Found agent runtime: {runtime_id}") + print(f"📋 Log Group: {log_group}") + print("=" * 40) + + # Check if log group exists + if not check_log_group_exists(log_group, region): + print(f"❌ Log group '{log_group}' not found") + list_available_log_groups(region) + return False + + # Calculate start time + start_time = datetime.now() - time_delta + start_timestamp = int(start_time.timestamp() * 1000) + + print(f"🔍 Fetching logs from the last {time_back}...") + print() + + try: + client = boto3.client("logs", region_name=region) + + # Get all log streams in the log group + print("🔍 Discovering log streams...") + log_streams = [] + paginator = client.get_paginator("describe_log_streams") + + for page in paginator.paginate(logGroupName=log_group): + log_streams.extend(page.get("logStreams", [])) + + print(f"📋 Found {len(log_streams)} log streams") + + # Filter log events from ALL log streams with pagination + all_events = [] + next_token = None + + while True: + filter_params = {"logGroupName": log_group, "startTime": start_timestamp} + + if next_token: + filter_params["nextToken"] = next_token + + response = client.filter_log_events(**filter_params) + + events = response.get("events", []) + all_events.extend(events) + + next_token = response.get("nextToken") + if not next_token: + break + + print(f"📄 Retrieved {len(events)} events (total: {len(all_events)})") + + if not all_events: + print("📝 No logs found in the specified time range.") + print( + "💡 Tip: Try increasing time back or check if the agent has been invoked recently" + ) + return True + + # Sort events by timestamp + all_events.sort(key=lambda x: x["timestamp"]) + print( + f"📊 Processing {len(all_events)} total log entries from {len(log_streams)} streams" + ) + print() + + for event in all_events: + timestamp = event["timestamp"] + log_stream = event.get("logStreamName", "unknown") + message = event["message"].strip() + + # Convert timestamp to readable format + readable_time = datetime.fromtimestamp(timestamp / 1000).strftime( + "%Y-%m-%d %H:%M:%S" + ) + + # Extract UUID from stream name if present + uuid_match = re.search(r"[a-f0-9-]{36}", log_stream) + stream_id = uuid_match.group(0) if uuid_match else "unknown" + + # Apply color coding + colored_message = colorize_log_message(message) + + print(f"[{readable_time}] [{stream_id}] {colored_message}") + + print() + print(f"📊 Displayed {len(all_events)} log entries") + return True + + except Exception as e: + print(f"❌ Error fetching logs: {e}") + return False + + +def main(): + parser = argparse.ArgumentParser( + description="View CloudWatch logs for an AgentCore agent", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python view-agent-logs.py # Use defaults (1h) + python view-agent-logs.py my_agent # Specific agent + python view-agent-logs.py my_agent us-east-1 # Different region + python view-agent-logs.py my_agent us-east-1 30m # Last 30 minutes + python view-agent-logs.py my_agent us-east-1 2h # Last 2 hours + python view-agent-logs.py my_agent us-east-1 1.5h # Last 1.5 hours + """, + ) + + parser.add_argument( + "agent_name", + nargs="?", + default="agentcore_integration_agent", + help="Agent name to search for (default: agentcore_integration_agent)", + ) + + parser.add_argument( + "region", + nargs="?", + default="eu-central-1", + help="AWS region (default: eu-central-1)", + ) + + parser.add_argument( + "time_back", + nargs="?", + default="1h", + help="Time back to search - use 'm' for minutes, 'h' for hours (e.g., '30m', '2h', '1.5h'). Default: 1h", + ) + + args = parser.parse_args() + + success = view_logs(args.agent_name, args.region, args.time_back) + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main()