diff --git a/src/ecs-mcp-server/README.md b/src/ecs-mcp-server/README.md index e4f22b3cbb..1e779592ef 100644 --- a/src/ecs-mcp-server/README.md +++ b/src/ecs-mcp-server/README.md @@ -17,6 +17,7 @@ An MCP server for containerizing applications, deploying applications to Amazon - **Security Best Practices**: Implement AWS security best practices for container deployments - **Resource Management**: List and explore ECS resources such as task definitions, services, clusters, and tasks - **ECR Integration**: View repositories and container images in Amazon ECR +- **AWS Knowledge Integration**: Access up-to-date AWS documentation through the integrated AWS Knowledge MCP Server proxy which includes knowledge on ECS and new features released that models may not be aware of Customers can use the `containerize_app` tool to help them containerize their applications with best practices and deploy them to Amazon ECS. The `create_ecs_infrastructure` tool automates infrastructure deployment using CloudFormation, while `get_deployment_status` returns the status of deployments and provide the URL of the set up Application Load Balancer. When resources are no longer needed, the `delete_ecs_infrastructure` tool allows for easy cleanup and removal of all deployed components. @@ -86,6 +87,9 @@ The following operations are read-only and relatively safe for production enviro | `ecs_troubleshooting_tool` | `fetch_service_events` | ✅ Safe - Read-only | | `ecs_troubleshooting_tool` | `get_ecs_troubleshooting_guidance` | ✅ Safe - Read-only | | `get_deployment_status` | Status checking | ✅ Safe - Read-only | +| `aws_knowledge_aws___search_documentation` | AWS documentation search | ✅ Safe - Read-only | +| `aws_knowledge_aws___read_documentation` | AWS documentation reading | ✅ Safe - Read-only | +| `aws_knowledge_aws___recommend` | AWS documentation recommendations | ✅ Safe - Read-only | The following operations modify resources and should be used with extreme caution in production: @@ -298,6 +302,18 @@ This tool provides comprehensive access to Amazon ECS resources to help you moni The resource management tool enforces permission checks for write operations. Operations that modify resources require the ALLOW_WRITE environment variable to be set to true. +### AWS Documentation Tools + +The ECS MCP Server integrates with the [AWS Knowledge MCP Server](https://github.com/awslabs/mcp/tree/main/src/aws-knowledge-mcp-server) to provide access to up-to-date AWS documentation, including ECS-specific knowledge about new features recently launched that models may not be aware of. + +Note: these tools are duplicative if you have the AWS Knowledge MCP Server already configured in your MCP client. For the below knowledge tools, the ECS MCP Server adds extra guidance to the tool descriptions to help LLMs use the tools for ECS contexts. + +- **aws_knowledge_aws___search_documentation**: Search across all AWS documentation including the latest AWS docs, API references, Blogs posts, Architectural references, and Well-Architected best practices. + +- **aws_knowledge_aws___read_documentation**: Fetch and convert AWS documentation pages to markdown format. + +- **aws_knowledge_aws___recommend**: Get content recommendations for AWS documentation pages. + ## Example Prompts ### Containerization and Deployment @@ -330,6 +346,12 @@ The resource management tool enforces permission checks for write operations. Op - "Run a task in my cluster" - "Stop a running task" +### AWS Documentation and Knowledge + +- "What are the best practices for ECS deployments?" +- "How do I set up blue-green deployments in ECS?" +- "Get recommendations for ECS security best practices" + ## Requirements - Python 3.10+ diff --git a/src/ecs-mcp-server/awslabs/ecs_mcp_server/main.py b/src/ecs-mcp-server/awslabs/ecs_mcp_server/main.py index b4eb4c0bae..a1dbaa58a9 100755 --- a/src/ecs-mcp-server/awslabs/ecs_mcp_server/main.py +++ b/src/ecs-mcp-server/awslabs/ecs_mcp_server/main.py @@ -19,10 +19,13 @@ import logging import os import sys +from contextlib import asynccontextmanager +from typing import Any, Dict, Tuple from fastmcp import FastMCP from awslabs.ecs_mcp_server.modules import ( + aws_knowledge_proxy, containerize, delete, deployment_status, @@ -36,42 +39,58 @@ secure_tool, ) -# Configure logging -log_level = os.environ.get("FASTMCP_LOG_LEVEL", "INFO") -log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" -log_file = os.environ.get("FASTMCP_LOG_FILE") -# Set up basic configuration -logging.basicConfig( - level=log_level, - format=log_format, -) +def _setup_logging() -> logging.Logger: + """Configure logging for the server.""" + log_level = os.environ.get("FASTMCP_LOG_LEVEL", "INFO") + log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + log_file = os.environ.get("FASTMCP_LOG_FILE") -# Add file handler if log file path is specified -if log_file: - try: - # Create directory for log file if it doesn't exist - log_dir = os.path.dirname(log_file) - if log_dir and not os.path.exists(log_dir): - os.makedirs(log_dir, exist_ok=True) - - # Add file handler - file_handler = logging.FileHandler(log_file) - file_handler.setFormatter(logging.Formatter(log_format)) - logging.getLogger().addHandler(file_handler) - logging.info(f"Logging to file: {log_file}") - except Exception as e: - logging.error(f"Failed to set up log file {log_file}: {e}") + logging.basicConfig(level=log_level, format=log_format) + + if log_file: + try: + log_dir = os.path.dirname(log_file) + if log_dir and not os.path.exists(log_dir): + os.makedirs(log_dir, exist_ok=True) + + file_handler = logging.FileHandler(log_file) + file_handler.setFormatter(logging.Formatter(log_format)) + logging.getLogger().addHandler(file_handler) + logging.info(f"Logging to file: {log_file}") + except Exception as e: + logging.error(f"Failed to set up log file {log_file}: {e}") + + return logging.getLogger("ecs-mcp-server") + + +@asynccontextmanager +async def server_lifespan(server): + """ + Server lifespan context manager for initialization and cleanup. -logger = logging.getLogger("ecs-mcp-server") + Provides safe access to async server methods during startup for + operations like tool transformations. + """ + logger = logging.getLogger("ecs-mcp-server") + logger.info("Server initializing") -# Load configuration -config = get_config() + # Safe async operations can be performed here + await aws_knowledge_proxy.apply_tool_transformations(server) -# Create the MCP server -mcp = FastMCP( - name="AWS ECS MCP Server", - instructions="""Use this server to containerize and deploy web applications to AWS ECS. + logger.info("Server ready") + yield + logger.info("Server shutting down") + + +def _create_ecs_mcp_server() -> Tuple[FastMCP, Dict[str, Any]]: + """Create and configure the MCP server.""" + config = get_config() + + mcp = FastMCP( + name="AWS ECS MCP Server", + lifespan=server_lifespan, + instructions="""Use this server to containerize and deploy web applications to AWS ECS. WORKFLOW: 1. containerize_app: @@ -97,30 +116,38 @@ - Set ALLOW_WRITE=true to enable infrastructure creation and deletion - Set ALLOW_SENSITIVE_DATA=true to enable access to logs and detailed resource information """, -) + ) -# Apply security wrappers to API functions -# Write operations -infrastructure.create_infrastructure = secure_tool( - config, PERMISSION_WRITE, "create_ecs_infrastructure" -)(infrastructure.create_infrastructure) -delete.delete_infrastructure = secure_tool(config, PERMISSION_WRITE, "delete_ecs_infrastructure")( - delete.delete_infrastructure -) + # Apply security wrappers to API functions + # Write operations + infrastructure.create_infrastructure = secure_tool( + config, PERMISSION_WRITE, "create_ecs_infrastructure" + )(infrastructure.create_infrastructure) + delete.delete_infrastructure = secure_tool( + config, PERMISSION_WRITE, "delete_ecs_infrastructure" + )(delete.delete_infrastructure) + + # Register all modules + containerize.register_module(mcp) + infrastructure.register_module(mcp) + deployment_status.register_module(mcp) + resource_management.register_module(mcp) + troubleshooting.register_module(mcp) + delete.register_module(mcp) -# Register all modules -containerize.register_module(mcp) -infrastructure.register_module(mcp) -deployment_status.register_module(mcp) -resource_management.register_module(mcp) -troubleshooting.register_module(mcp) -delete.register_module(mcp) + # Register all proxies + aws_knowledge_proxy.register_proxy(mcp) + + return mcp, config def main() -> None: """Main entry point for the ECS MCP Server.""" try: # Start the server + mcp, config = _create_ecs_mcp_server() + logger = _setup_logging() + logger.info("Server started") logger.info(f"Write operations enabled: {config.get('allow-write', False)}") logger.info(f"Sensitive data access enabled: {config.get('allow-sensitive-data', False)}") diff --git a/src/ecs-mcp-server/awslabs/ecs_mcp_server/modules/aws_knowledge_proxy.py b/src/ecs-mcp-server/awslabs/ecs_mcp_server/modules/aws_knowledge_proxy.py new file mode 100644 index 0000000000..6e3e6d1316 --- /dev/null +++ b/src/ecs-mcp-server/awslabs/ecs_mcp_server/modules/aws_knowledge_proxy.py @@ -0,0 +1,191 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +AWS Knowledge Proxy module for ECS MCP Server. +This module handles the setup and configuration of the AWS Knowledge MCP Server proxy integration. +""" + +import logging +from typing import Optional + +from fastmcp import FastMCP +from fastmcp.server.proxy import ProxyClient +from fastmcp.tools.tool_transform import ToolTransformConfig + +# Guidance to append to tool descriptions +# ruff: noqa: E501 +ECS_TOOL_GUIDANCE = """ + + ## ECS DOCUMENTATION GUIDANCE: + This tool provides up-to-date ECS documentation and implementation guidance, including new ECS features beyond standard LLM training data. + + New ECS features include: + - ECS Native Blue-Green Deployments (different from CodeDeploy blue-green, launched 2025) +""" + +logger = logging.getLogger(__name__) + + +def register_proxy(mcp: FastMCP) -> Optional[bool]: + """ + Sets up the AWS Knowledge MCP Server proxy integration. + + Args: + mcp: The FastMCP server instance to mount the proxy on + + Returns: + bool: True if setup was successful, False otherwise + """ + try: + logger.info("Setting up AWS Knowledge MCP Server proxy") + proxy_config = { + "mcpServers": { + "aws-knowledge-mcp-server": { + "command": "uvx", + "args": [ + "mcp-proxy", + "--transport", + "streamablehttp", + "https://knowledge-mcp.global.api.aws", + ], + } + } + } + + # Create and mount the proxy + aws_knowledge_proxy = FastMCP.as_proxy(ProxyClient(proxy_config)) + mcp.mount(aws_knowledge_proxy, prefix="aws_knowledge") + + # Add prompt patterns for blue-green deployments + register_ecs_prompts(mcp) + + logger.info("Successfully mounted AWS Knowledge MCP Server") + return True + + except Exception as e: + logger.error(f"Failed to setup AWS Knowledge MCP Server proxy: {e}") + return False + + +async def apply_tool_transformations(mcp: FastMCP) -> None: + """ + Apply tool transformations to the AWS Knowledge proxy tools. + + Args: + mcp: The FastMCP server instance to apply transformations to + """ + logger.info("Applying tool transformations...") + await _add_ecs_guidance_to_knowledge_tools(mcp) + + +async def _add_ecs_guidance_to_knowledge_tools(mcp: FastMCP) -> None: + """Add ECS documentation guidance to specific tools if they exist.""" + try: + tools = await mcp.get_tools() + + knowledge_tools = [ + "aws_knowledge_aws___search_documentation", + "aws_knowledge_aws___read_documentation", + "aws_knowledge_aws___recommend", + ] + + for tool_name in knowledge_tools: + if tool_name not in tools: + logger.warning(f"Tool {tool_name} not found in MCP tools") + continue + + original_desc = tools[tool_name].description or "" + config = ToolTransformConfig( + name=tool_name, description=original_desc + ECS_TOOL_GUIDANCE + ) + mcp.add_tool_transformation(tool_name, config) + + logger.debug("Added ECS guidance to AWS Knowledge tools") + except Exception as e: + logger.error(f"Error applying tool transformations: {e}") + raise + + +def register_ecs_prompts(mcp: FastMCP) -> None: + """ + Register ECS-related prompt patterns with AWS Knowledge proxy tools. + + Covers blue-green deployments, new ECS features, and comparisons based on ECS_TOOL_GUIDANCE. + + Args: + mcp: The FastMCP server instance to register prompts with + """ + + prompts = [ + { + "patterns": [ + "what are blue green deployments", + "what are b/g deployments", + "native ecs blue green", + "native ecs b/g", + "ecs native blue green deployments", + "difference between codedeploy and native blue green", + "how to setup blue green", + "setup ecs blue green", + "configure ecs blue green deployments", + "configure blue green", + "configure b/g", + "create blue green deployment", + ], + "response": [ + { + "name": "aws_knowledge_aws___search_documentation", + } + ], + }, + { + "patterns": [ + "ecs best practices", + "ecs implementation guide", + "ecs guidance", + "ecs recommendations", + "how to use ecs effectively", + "new ecs feature", + "latest ecs feature", + ], + "response": [ + { + "name": "aws_knowledge_aws___search_documentation", + } + ], + }, + ] + + # Register all prompt patterns using loops + total_patterns = 0 + for prompt_group in prompts: + patterns = prompt_group["patterns"] + response = prompt_group["response"] + + for pattern in patterns: + + def create_prompt_handler(response_data): + def prompt_handler(): + return response_data + + return prompt_handler + + handler = create_prompt_handler(response) + mcp.prompt(pattern)(handler) + total_patterns += 1 + + logger.info( + f"Registered {total_patterns} ECS-related prompt patterns with AWS Knowledge proxy tools" + ) diff --git a/src/ecs-mcp-server/pyproject.toml b/src/ecs-mcp-server/pyproject.toml index 51167c4df9..e388651911 100644 --- a/src/ecs-mcp-server/pyproject.toml +++ b/src/ecs-mcp-server/pyproject.toml @@ -28,19 +28,18 @@ dependencies = [ "jinja2>=3.1.0", "pyyaml>=6.0.0", "gevent>=25.5.1", - "pytest-cov>=6.1.1", - "pyright>=1.1.401", - "ruff>=0.11.11", ] -[project.optional-dependencies] +[dependency-groups] dev = [ "black>=23.3.0", "isort>=5.12.0", "mypy>=1.3.0", "pytest>=7.3.1", - "pytest-cov>=6.0.0", - "ruff>=0.0.272", + "pytest-asyncio>=1.2.0", + "pytest-cov>=6.1.1", + "pyright>=1.1.401", + "ruff>=0.11.11", ] [project.urls] diff --git a/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/01_comprehensive_troubleshooting/utils/mcp_helpers.sh b/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/01_comprehensive_troubleshooting/utils/mcp_helpers.sh index 7df1ed258d..85061d8911 100755 --- a/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/01_comprehensive_troubleshooting/utils/mcp_helpers.sh +++ b/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/01_comprehensive_troubleshooting/utils/mcp_helpers.sh @@ -54,7 +54,7 @@ call_mcp_troubleshooting_tool() { --method tools/call \ --tool-name ecs_troubleshooting_tool \ --tool-arg "action=$action" \ - --tool-arg "parameters=$parameters" 2>&1) + --tool-arg "parameters=${parameters}" 2>&1) local exit_code=$? diff --git a/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/01_create.sh b/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/01_create.sh new file mode 100755 index 0000000000..2042462c32 --- /dev/null +++ b/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/01_create.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -euo pipefail # Exit on error, undefined vars, pipe failures + +# AWS Knowledge Proxy Tools Integration Test - Prerequisites Phase +# This script validates prerequisites for testing AWS Knowledge proxy integration + +# Set script location as base directory +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/utils/mcp_knowledge_helpers.sh" + +# Print header +echo -e "${BLUE}=======================================================${NC}" +echo -e "${BLUE} AWS Knowledge Proxy Tools - Prerequisites Check ${NC}" +echo -e "${BLUE}=======================================================${NC}" +echo "" + +# Validate prerequisites +echo -e "${BLUE}🔍 Validating prerequisites...${NC}" +if ! validate_mcp_knowledge_prerequisites; then + echo "" + echo -e "${RED}❌ Prerequisites validation failed.${NC}" + echo -e "${RED} Please fix the issues above before running the validation tests.${NC}" + exit 1 +fi + +echo "" +echo -e "${GREEN}✅ Prerequisites validation completed!${NC}" +echo "" + +exit 0 diff --git a/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/02_validate.sh b/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/02_validate.sh new file mode 100755 index 0000000000..6f7b7c9a9a --- /dev/null +++ b/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/02_validate.sh @@ -0,0 +1,264 @@ +#!/bin/bash +set -euo pipefail # Exit on error, undefined vars, pipe failures + +# AWS Knowledge Proxy Tools Integration Test - Validation Phase +# This script validates all AWS Knowledge MCP tools via MCP Inspector CLI +# Usage: ./02_validate.sh + +# Set script location and source utilities +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/utils/mcp_knowledge_helpers.sh" +source "$SCRIPT_DIR/utils/knowledge_validation_helpers.sh" + +# ============================================================================= +# CONSTANTS +# ============================================================================= +# Test configuration +readonly EXPECTED_NUM_KNOWLEDGE_TOOLS=3 +readonly MAX_RESPONSE_TIME_SECONDS=10 # Typical response time is 5-7 seconds +readonly LOG_FILE_PREFIX="knowledge-proxy-test-results" +readonly ECS_WELCOME_URL="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/Welcome.html" +readonly ECS_SERVICES_URL="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs_services.html" + +# Test queries +readonly SEARCH_QUERY_GENERAL="ECS service deployment" +readonly SEARCH_QUERY_BLUE_GREEN="ECS native blue-green deployments" +readonly SEARCH_QUERY_PERFORMANCE="ECS" + +# Initialize test tracking +TOTAL_TESTS=0 +PASSED_TESTS=0 +FAILED_TESTS=0 + +# Create log file for this test run +readonly LOG_FILE="$SCRIPT_DIR/$LOG_FILE_PREFIX-$(date +%Y%m%d_%H%M%S).log" + +# Print header +echo -e "${BLUE}=======================================================${NC}" +echo -e "${BLUE} AWS Knowledge Proxy Tools - Integration Tests ${NC}" +echo -e "${BLUE}=======================================================${NC}" +echo "" + +echo -e "${YELLOW}📋 Test Scenario: AWS Knowledge Proxy Integration${NC}" +echo "" +echo "This comprehensive test validates the AWS Knowledge MCP Server proxy" +echo "integration, including tool availability, descriptions, and functionality." +echo "" +echo -e "${BLUE}📋 Full tool responses logged to: $LOG_FILE${NC}" +echo "" + +# ============================================================================= +# HELPER FUNCTIONS +# ============================================================================= + +# Run a single functional test for an AWS Knowledge tool with proper output ordering +# Usage: run_functional_test +run_functional_test() { + local test_number="$1" + local test_description="$2" + local tool_call_function="$3" + local validator_function="$4" + + echo "=================================================================================" + echo "TEST ${test_number}: ${test_description}" + echo "=================================================================================" + TOTAL_TESTS=$((TOTAL_TESTS + 1)) + + # Execute tool call (this will print the "🔧 Calling..." message in correct order) + local response + response=$("${tool_call_function}") + local call_exit_code=$? + + # Log response details + log_knowledge_tool_response "${response}" "${test_number}" "${LOG_FILE}" + + # Validate response + if [ $call_exit_code -eq 0 ] && "${validator_function}" "${response}"; then + PASSED_TESTS=$((PASSED_TESTS + 1)) + echo -e "${GREEN}✅ ${test_description} PASSED${NC}" + else + FAILED_TESTS=$((FAILED_TESTS + 1)) + echo -e "${RED}❌ ${test_description} FAILED${NC}" + fi + echo "" +} + +# Execute tool availability test for a specific tool +# Usage: test_tool_availability +test_tool_availability() { + local tool_name="$1" + shift + local test_args=("$@") + + echo -e "${BLUE} Testing: ${tool_name}${NC}" + + case "${tool_name}" in + "aws_knowledge_aws___search_documentation") + test_response=$(test_aws_knowledge_search_documentation "ECS" 2>/dev/null) + ;; + "aws_knowledge_aws___read_documentation") + test_response=$(test_aws_knowledge_read_documentation "${ECS_WELCOME_URL}" 2>/dev/null) + ;; + "aws_knowledge_aws___recommend") + test_response=$(test_aws_knowledge_recommend "${ECS_SERVICES_URL}" 2>/dev/null) + ;; + *) + echo -e "${RED} ❌ Unknown tool: ${tool_name}${NC}" + return 1 + ;; + esac + + if [ $? -eq 0 ] && [ -n "${test_response}" ]; then + echo -e "${GREEN} ✅ ${tool_name} is available${NC}" + return 0 + else + echo -e "${RED} ❌ ${tool_name} is not available${NC}" + return 1 + fi +} + +echo "🧪 Starting AWS Knowledge proxy tool validation tests..." +echo "" + +# ============================================================================= +# TEST 1: AWS Knowledge Tools Availability (via direct calls) +# ============================================================================= +echo "=================================================================================" +echo "TEST 1: AWS Knowledge Tools Availability Validation" +echo "=================================================================================" + +echo -e "${BLUE}🔍 Testing availability of EXACTLY ${EXPECTED_NUM_KNOWLEDGE_TOOLS} AWS Knowledge tools via direct calls...${NC}" + +# Test availability +tool_availability_count=0 + +for tool_name in "${EXPECTED_KNOWLEDGE_TOOLS[@]}"; do + if test_tool_availability "${tool_name}"; then + tool_availability_count=$((tool_availability_count + 1)) + fi +done + +# Now validate exact descriptions (upstream change detection) +TOOLS_LIST_RESPONSE=$(get_mcp_tools) +TOOLS_LIST_EXIT_CODE=$? + +TOTAL_TESTS=$((TOTAL_TESTS + 1)) +if [ $tool_availability_count -eq $EXPECTED_NUM_KNOWLEDGE_TOOLS ]; then + echo -e "${GREEN}✅ Exactly ${EXPECTED_NUM_KNOWLEDGE_TOOLS} AWS Knowledge tools are available${NC}" + + # Validate exact descriptions + if [ $TOOLS_LIST_EXIT_CODE -eq 0 ] && validate_exact_tool_descriptions "$TOOLS_LIST_RESPONSE"; then + PASSED_TESTS=$((PASSED_TESTS + 1)) + else + FAILED_TESTS=$((FAILED_TESTS + 1)) + fi +else + FAILED_TESTS=$((FAILED_TESTS + 1)) + echo -e "${RED}❌ ${tool_availability_count} AWS Knowledge tools are available${NC}" +fi +echo "" + +# ============================================================================= +# TEST 2-4: Functional tests for all AWS Knowledge tools (using helper functions) +# ============================================================================= + +# Create wrapper functions for proper tool calling +call_search_test() { test_aws_knowledge_search_documentation "${SEARCH_QUERY_GENERAL}"; } +call_read_test() { test_aws_knowledge_read_documentation "${ECS_WELCOME_URL}"; } +call_recommend_test() { test_aws_knowledge_recommend "${ECS_SERVICES_URL}"; } + +# Run all functional tests with proper output ordering +run_functional_test "2" "search_documentation functional test" "call_search_test" "validate_aws_knowledge_search_documentation" +run_functional_test "3" "read_documentation functional test" "call_read_test" "validate_aws_knowledge_read_documentation" +run_functional_test "4" "recommend functional test" "call_recommend_test" "validate_aws_knowledge_recommend" + +# ============================================================================= +# TEST 5: Cross-tool validation - Blue-Green Deployment feature search +# ============================================================================= +echo "=================================================================================" +echo "TEST 5: Blue-Green Deployment Feature Detection" +echo "=================================================================================" +TOTAL_TESTS=$((TOTAL_TESTS + 1)) + +echo -e "${BLUE}🔍 Testing search for new ECS Blue-Green deployment feature...${NC}" + +BG_SEARCH_RESPONSE=$(test_aws_knowledge_search_documentation "$SEARCH_QUERY_BLUE_GREEN") +BG_SEARCH_EXIT_CODE=$? + +# Log response details +log_knowledge_tool_response "$BG_SEARCH_RESPONSE" "aws_knowledge_aws___search_documentation" "$LOG_FILE" + +if [ $BG_SEARCH_EXIT_CODE -eq 0 ] && validate_aws_knowledge_search_documentation "$BG_SEARCH_RESPONSE"; then + PASSED_TESTS=$((PASSED_TESTS + 1)) + echo -e "${GREEN}✅ Blue-Green deployment search PASSED${NC}" + + # Check if results mention blue-green deployments + tool_result=$(extract_tool_result "$BG_SEARCH_RESPONSE" "bg_search_validation") + bg_results=$(echo "$tool_result" | jq -r '.content.result[] | select(.title | test("blue.?green|deployment"; "i")) | .title' 2>/dev/null) + + if [ -n "$bg_results" ]; then + echo -e "${GREEN}✅ Found blue-green deployment related results${NC}" + else + echo -e "${YELLOW}⚠️ No specific blue-green deployment results found${NC}" + fi +else + FAILED_TESTS=$((FAILED_TESTS + 1)) + echo -e "${RED}❌ Blue-Green deployment search FAILED${NC}" +fi +echo "" + +# ============================================================================= +# TEST 6: Tool response time validation +# ============================================================================= +echo "=================================================================================" +echo "TEST 6: Tool Response Time Performance" +echo "=================================================================================" +TOTAL_TESTS=$((TOTAL_TESTS + 1)) + +echo -e "${BLUE}🔍 Testing tool response times...${NC}" + +# Time a simple search operation +START_TIME=$(date +%s) +PERF_RESPONSE=$(test_aws_knowledge_search_documentation "$SEARCH_QUERY_PERFORMANCE") +PERF_EXIT_CODE=$? +END_TIME=$(date +%s) + +RESPONSE_TIME=$((END_TIME - START_TIME)) + +if [ $PERF_EXIT_CODE -eq 0 ] && [ $RESPONSE_TIME -lt $MAX_RESPONSE_TIME_SECONDS ]; then + PASSED_TESTS=$((PASSED_TESTS + 1)) + echo -e "${GREEN}✅ Tool response time acceptable (less than ${MAX_RESPONSE_TIME_SECONDS}s): ${RESPONSE_TIME}s${NC}" +else + FAILED_TESTS=$((FAILED_TESTS + 1)) + if [ $RESPONSE_TIME -ge $MAX_RESPONSE_TIME_SECONDS ]; then + echo -e "${RED}❌ Tool response time too slow: ${RESPONSE_TIME}s (>${MAX_RESPONSE_TIME_SECONDS}s)${NC}" + else + echo -e "${RED}❌ Tool response failed${NC}" + fi +fi +echo "" + +# ============================================================================= +# Print final summary +# ============================================================================= +echo "=================================================================================" +print_validation_summary $TOTAL_TESTS $PASSED_TESTS $FAILED_TESTS +echo "=================================================================================" +echo "" +echo "📋 Full tool responses logged to: $LOG_FILE" + + +# Exit with appropriate code +if [ $FAILED_TESTS -eq 0 ]; then + echo -e "${GREEN}🎉 All AWS Knowledge proxy integration tests passed!${NC}" + echo "" + exit 0 +else + echo -e "${RED}❌ $FAILED_TESTS test(s) failed. Check the output above for details.${NC}" + echo "" + echo -e "${YELLOW}Troubleshooting tips:${NC}" + echo " • Check MCP server configuration in /tmp/mcp-config.json" + echo " • Review full responses in log file: $LOG_FILE" + echo "" + exit 1 +fi diff --git a/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/03_cleanup.sh b/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/03_cleanup.sh new file mode 100755 index 0000000000..bbee9d877b --- /dev/null +++ b/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/03_cleanup.sh @@ -0,0 +1,83 @@ +#!/bin/bash +set -euo pipefail # Exit on error, undefined vars, pipe failures + +# AWS Knowledge Proxy Tools Integration Test - Cleanup Phase +# This script performs cleanup after AWS Knowledge proxy integration tests +# Since no AWS infrastructure is created, cleanup is minimal + +# Set script location as base directory +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/utils/mcp_knowledge_helpers.sh" + +# ============================================================================= +# CONSTANTS +# ============================================================================= +# File patterns for cleanup +readonly LOG_FILE_PATTERN="knowledge-proxy-test-results-*.log" +readonly TEMP_JSON_PATTERN="*.tmp.json" + +# Print header +echo -e "${BLUE}=======================================================${NC}" +echo -e "${BLUE} AWS Knowledge Proxy Tools - Cleanup Phase ${NC}" +echo -e "${BLUE}=======================================================${NC}" +echo "" + +echo -e "${YELLOW}📋 Cleanup Scenario: AWS Knowledge Proxy Integration${NC}" +echo "" +echo "This cleanup phase removes temporary files and logs created during" +echo "the AWS Knowledge proxy integration testing." +echo "" + +# Clean up temporary log files +echo -e "${BLUE}🧹 Cleaning up temporary files...${NC}" + +# Count log files to clean +LOG_FILES_COUNT=$(ls "$SCRIPT_DIR"/$LOG_FILE_PATTERN 2>/dev/null | wc -l) + +if [ "$LOG_FILES_COUNT" -gt 0 ]; then + echo -e "${YELLOW}Found $LOG_FILES_COUNT log file(s) to clean up:${NC}" + for log_file in "$SCRIPT_DIR"/$LOG_FILE_PATTERN; do + if [ -f "$log_file" ]; then + echo " • $(basename "$log_file")" + fi + done + + echo "" + read -p "Do you want to remove these log files? (y/n) " -n 1 -r + echo "" + + if [[ $REPLY =~ ^[Yy]$ ]]; then + rm -f "$SCRIPT_DIR"/$LOG_FILE_PATTERN + echo -e "${GREEN}✅ Removed $LOG_FILES_COUNT log file(s)${NC}" + else + echo -e "${YELLOW}⚠️ Log files preserved for review${NC}" + fi +else + echo -e "${GREEN}✅ No temporary log files found to clean up${NC}" +fi + +echo "" + +# Clean up any temporary JSON files (if created during testing) +TEMP_JSON_COUNT=$(ls "$SCRIPT_DIR"/$TEMP_JSON_PATTERN 2>/dev/null | wc -l) + +if [ "$TEMP_JSON_COUNT" -gt 0 ]; then + echo -e "${YELLOW}Found $TEMP_JSON_COUNT temporary JSON file(s) to clean up${NC}" + rm -f "$SCRIPT_DIR"/$TEMP_JSON_PATTERN + echo -e "${GREEN}✅ Removed temporary JSON files${NC}" +else + echo -e "${GREEN}✅ No temporary JSON files found to clean up${NC}" +fi + +echo "" +echo -e "${GREEN}=======================================================${NC}" +echo -e "${GREEN}🎉 AWS Knowledge proxy cleanup completed!${NC}" +echo -e "${GREEN}=======================================================${NC}" +echo "" +echo -e "${YELLOW}Summary:${NC}" +echo " • Temporary log files processed" +echo " • No AWS resources get created for this test" +echo " • Test environment is clean" +echo "" + +exit 0 diff --git a/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/description.txt b/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/description.txt new file mode 100644 index 0000000000..c5c81f9d6d --- /dev/null +++ b/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/description.txt @@ -0,0 +1 @@ +Tests AWS Knowledge MCP Server proxy integration via MCP Inspector CLI. Validates that exactly 3 knowledge tools are available, tool descriptions include ECS guidance, and each tool returns expected information when called with ECS-related queries. Expected outcome: All 3 knowledge tools pass validation with proper ECS documentation responses. diff --git a/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/utils/knowledge_validation_helpers.sh b/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/utils/knowledge_validation_helpers.sh new file mode 100755 index 0000000000..2266d90924 --- /dev/null +++ b/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/utils/knowledge_validation_helpers.sh @@ -0,0 +1,447 @@ +#!/bin/bash +set -euo pipefail # Exit on error, undefined vars, pipe failures + +# JSON Response Validation Helper Functions for AWS Knowledge Tools +# This file contains utility functions for validating JSON responses from AWS Knowledge MCP tools + +# Expected AWS Knowledge tool names +EXPECTED_KNOWLEDGE_TOOLS=( + "aws_knowledge_aws___search_documentation" + "aws_knowledge_aws___read_documentation" + "aws_knowledge_aws___recommend" +) + +# Expected exact tool descriptions (upstream + ECS_TOOL_GUIDANCE) - for detecting upstream changes +EXPECTED_SEARCH_DESCRIPTION="Search AWS documentation using the official AWS Documentation Search API. + + ## Usage + + This tool searches across all AWS documentation and other AWS Websites including AWS Blog, AWS Solutions Library, Getting started with AWS, AWS Architecture Center and AWS Prescriptive Guidance for pages matching your search phrase. + Use it to find relevant documentation when you don't have a specific URL. + + ## Search Tips + + - Use specific technical terms rather than general phrases + - Include service names to narrow results (e.g., \"S3 bucket versioning\" instead of just \"versioning\") + - Use quotes for exact phrase matching (e.g., \"AWS Lambda function URLs\") + - Include abbreviations and alternative terms to improve results + + ## Result Interpretation + + Each result includes: + - rank_order: The relevance ranking (lower is more relevant) + - url: The documentation page URL + - title: The page title + - context: A brief excerpt or summary (if available) + + ## ECS DOCUMENTATION GUIDANCE: + This tool provides up-to-date ECS documentation and implementation guidance, including new ECS features beyond standard LLM training data. + + New ECS features include: + - ECS Native Blue-Green Deployments (different from CodeDeploy blue-green, launched 2025)" + +EXPECTED_READ_DESCRIPTION="Fetch and convert an AWS documentation page to markdown format. + + ## Usage + + This tool retrieves the content of an AWS documentation page and converts it to markdown format. + For long documents, you can make multiple calls with different start_index values to retrieve + the entire content in chunks. + + ## URL Requirements + + - Must be from the docs.aws.amazon.com or aws.amazon.com domain + + ## Example URLs + + - https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html + - https://docs.aws.amazon.com/lambda/latest/dg/lambda-invocation.html + - https://aws.amazon.com/about-aws/whats-new/2023/02/aws-telco-network-builder/ + - https://aws.amazon.com/builders-library/ensuring-rollback-safety-during-deployments/ + - https://aws.amazon.com/blogs/developer/make-the-most-of-community-resources-for-aws-sdks-and-tools/ + + ## Output Format + + The output is formatted as markdown text with: + - Preserved headings and structure + - Code blocks for examples + - Lists and tables converted to markdown format + + ## Handling Long Documents + + If the response indicates the document was truncated, you have several options: + + 1. **Continue Reading**: Make another call with start_index set to the end of the previous response + 2. **Stop Early**: For very long documents (>30,000 characters), if you've already found the specific information needed, you can stop reading + + ## ECS DOCUMENTATION GUIDANCE: + This tool provides up-to-date ECS documentation and implementation guidance, including new ECS features beyond standard LLM training data. + + New ECS features include: + - ECS Native Blue-Green Deployments (different from CodeDeploy blue-green, launched 2025)" + +EXPECTED_RECOMMEND_DESCRIPTION="Get content recommendations for an AWS documentation page. + + ## Usage + + This tool provides recommendations for related AWS documentation pages based on a given URL. + Use it to discover additional relevant content that might not appear in search results. + URL must be from the docs.aws.amazon.com domain. + + ## Recommendation Types + + The recommendations include four categories: + + 1. **Highly Rated**: Popular pages within the same AWS service + 2. **New**: Recently added pages within the same AWS service - useful for finding newly released features + 3. **Similar**: Pages covering similar topics to the current page + 4. **Journey**: Pages commonly viewed next by other users + + ## When to Use + + - After reading a documentation page to find related content + - When exploring a new AWS service to discover important pages + - To find alternative explanations of complex concepts + - To discover the most popular pages for a service + - To find newly released information by using a service's welcome page URL and checking the **New** recommendations + + ## Finding New Features + + To find newly released information about a service: + 1. Find any page belong to that service, typically you can try the welcome page + 2. Call this tool with that URL + 3. Look specifically at the **New** recommendation type in the results + + ## Result Interpretation + + Each recommendation includes: + - url: The documentation page URL + - title: The page title + - context: A brief description (if available) + + ## ECS DOCUMENTATION GUIDANCE: + This tool provides up-to-date ECS documentation and implementation guidance, including new ECS features beyond standard LLM training data. + + New ECS features include: + - ECS Native Blue-Green Deployments (different from CodeDeploy blue-green, launched 2025)" + +# Validate that a response is valid JSON +validate_json() { + local response="$1" + local tool_name="$2" + + if [ -z "${response}" ]; then + echo -e "${RED}❌ [${tool_name}] Empty response${NC}" >&2 + return 1 + fi + + # Try to parse JSON with jq + if echo "${response}" | jq . >/dev/null 2>&1; then + echo -e "${GREEN}✅ [${tool_name}] Valid JSON response${NC}" >&2 + return 0 + else + echo -e "${RED}❌ [${tool_name}] Invalid JSON response${NC}" >&2 + echo "First 500 chars of response: ${response:0:500}..." >&2 + return 1 + fi +} + +# Extract tool result from MCP response format +extract_tool_result() { + local response="$1" + + # Extract tool result from MCP content array + local tool_result + tool_result=$(echo "${response}" | jq -r '.content[0].text // empty' 2>/dev/null) + + if [ -n "${tool_result}" ] && [ "${tool_result}" != "null" ]; then + echo "${tool_result}" + return 0 + else + return 1 + fi +} + +# Validate a single tool description against expected value +validate_single_tool_description() { + local tools_array="$1" + local tool_name="$2" + local expected_description="$3" + local display_name="$4" + + local actual_desc + actual_desc=$(echo "$tools_array" | jq -r ".[] | select(.name == \"$tool_name\") | .description" 2>/dev/null) + + if [ "$actual_desc" = "$expected_description" ]; then + echo -e "${GREEN}✅ $display_name description matches exactly${NC}" + return 0 + else + echo -e "${RED}❌ $display_name description mismatch (upstream change detected!)${NC}" + echo -e "${YELLOW}Expected length: ${#expected_description} chars${NC}" + echo -e "${YELLOW}Actual length: ${#actual_desc} chars${NC}" + return 1 + fi +} + +# Validate all tool descriptions against expected values +validate_all_tool_descriptions() { + local tools_array="$1" + local exact_match_count=0 + + # Test search_documentation description + if validate_single_tool_description "$tools_array" "aws_knowledge_aws___search_documentation" "$EXPECTED_SEARCH_DESCRIPTION" "search_documentation"; then + exact_match_count=$((exact_match_count + 1)) + fi + + # Test read_documentation description + if validate_single_tool_description "$tools_array" "aws_knowledge_aws___read_documentation" "$EXPECTED_READ_DESCRIPTION" "read_documentation"; then + exact_match_count=$((exact_match_count + 1)) + fi + + # Test recommend description + if validate_single_tool_description "$tools_array" "aws_knowledge_aws___recommend" "$EXPECTED_RECOMMEND_DESCRIPTION" "recommend"; then + exact_match_count=$((exact_match_count + 1)) + fi + + return $exact_match_count +} + +# Validate tool descriptions match exactly to what we expect. +# This serves as an early warning system for upstream AWS Knowledge MCP Server updates. +validate_exact_tool_descriptions() { + local tools_response="$1" + + echo -e "${BLUE}🔍 Validating exact tool descriptions (upstream change detection)...${NC}" + + if ! validate_json "$tools_response" "tools/list"; then + return 1 + fi + + # Extract tools array from response + local tools_array + tools_array=$(echo "$tools_response" | jq -r '.tools' 2>/dev/null) + + # Validate each tool description exactly + local exact_match_count + validate_all_tool_descriptions "$tools_array" + exact_match_count=$? + + local expected_count=${#EXPECTED_KNOWLEDGE_TOOLS[@]} + if [ $exact_match_count -eq $expected_count ]; then + echo -e "${GREEN}✅ All tool descriptions match exactly - no upstream changes detected${NC}" + return 0 + else + echo -e "${RED}❌ $((expected_count - exact_match_count)) tool description(s) don't match - upstream changes detected!${NC}" + return 1 + fi +} + +# Generic tool response validation function +validate_tool_response_structure() { + local response="$1" + local tool_name="$2" + local display_name="$3" + + echo -e "${BLUE}🔍 Validating $display_name response...${NC}" >&2 + + if ! validate_json "$response" "$tool_name"; then + return 1 + fi + + local tool_result + if ! tool_result=$(extract_tool_result "$response"); then + echo -e "${RED}❌ [$tool_name] Failed to extract tool result${NC}" >&2 + return 1 + fi + + # Check if tool result has content.result structure + local has_content_result + has_content_result=$(echo "$tool_result" | jq -r '.content | has("result")' 2>/dev/null) + + if [ "$has_content_result" = "true" ]; then + echo "$tool_result" + return 0 + else + echo -e "${RED}❌ [$tool_name] Response missing 'content.result' field${NC}" >&2 + return 1 + fi +} + +# Validate search documentation content +validate_search_content() { + local tool_result="$1" + local tool_name="$2" + + local result_count + result_count=$(echo "$tool_result" | jq -r '.content.result | length' 2>/dev/null) + + if [ "$result_count" -gt 0 ]; then + echo -e "${GREEN}✅ [$tool_name] Found $result_count search results${NC}" + return 0 + else + echo -e "${RED}❌ [$tool_name] No search results returned${NC}" + return 1 + fi +} + +# Validate read documentation content +validate_read_content() { + local tool_result="$1" + local tool_name="$2" + + local content + content=$(echo "$tool_result" | jq -r '.content.result // empty' 2>/dev/null) + + if [ -n "$content" ] && [ "$content" != "null" ]; then + local content_length=${#content} + echo -e "${GREEN}✅ [$tool_name] Content retrieved ($content_length chars)${NC}" + + # Check for markdown indicators using array approach for DRY compliance + local markdown_patterns=( + '^#+ ' # Headers + '\[.*\]\(.*\)' # Links + '^(\*|-|\+|\d+\.) ' # Lists + '```|`.*`' # Code blocks/inline code + ) + + local markdown_indicators=0 + for pattern in "${markdown_patterns[@]}"; do + if echo "$content" | grep -E "$pattern" >/dev/null 2>&1; then + markdown_indicators=$((markdown_indicators + 1)) + fi + done + + local min_indicators=2 + if [ $markdown_indicators -ge $min_indicators ]; then + echo -e "${GREEN}✅ [$tool_name] Content is properly markdown formatted (${markdown_indicators} indicators)${NC}" + else + echo -e "${YELLOW}⚠️ [$tool_name] Content may not be fully markdown formatted (${markdown_indicators} indicators)${NC}" + fi + + return 0 + else + echo -e "${RED}❌ [$tool_name] Empty or null content${NC}" + return 1 + fi +} + +# Validate recommend documentation content +validate_recommend_content() { + local tool_result="$1" + local tool_name="$2" + + local recommendation_count + recommendation_count=$(echo "$tool_result" | jq -r '.content.result | length' 2>/dev/null) + + if [ "$recommendation_count" -gt 0 ]; then + echo -e "${GREEN}✅ [$tool_name] Found $recommendation_count recommendations${NC}" + return 0 + else + echo -e "${RED}❌ [$tool_name] No recommendations returned${NC}" + return 1 + fi +} + +# Validate search documentation response +validate_aws_knowledge_search_documentation() { + local response="$1" + local tool_result + + if tool_result=$(validate_tool_response_structure "$response" "search_documentation" "aws_knowledge_aws___search_documentation"); then + validate_search_content "$tool_result" "search_documentation" + else + return 1 + fi +} + +# Validate read documentation response +validate_aws_knowledge_read_documentation() { + local response="$1" + local tool_result + + if tool_result=$(validate_tool_response_structure "$response" "read_documentation" "aws_knowledge_aws___read_documentation"); then + validate_read_content "$tool_result" "read_documentation" + else + return 1 + fi +} + +# Validate recommend documentation response +validate_aws_knowledge_recommend() { + local response="$1" + local tool_result + + if tool_result=$(validate_tool_response_structure "$response" "recommend" "aws_knowledge_aws___recommend"); then + validate_recommend_content "$tool_result" "recommend" + else + return 1 + fi +} + + +# Print validation summary +print_validation_summary() { + local total_tests="$1" + local passed_tests="$2" + local failed_tests="$3" + + echo "" + echo "==================================================" + echo " AWS KNOWLEDGE VALIDATION SUMMARY" + echo "==================================================" + echo -e "Total tests: $total_tests" + echo -e "Passed tests: ${GREEN}$passed_tests${NC}" + echo -e "Failed tests: ${RED}$failed_tests${NC}" + echo "==================================================" + + if [ $failed_tests -eq 0 ]; then + echo -e "${GREEN}🎉 All AWS Knowledge validation tests passed!${NC}" + return 0 + else + echo -e "${RED}❌ $failed_tests AWS Knowledge validation test(s) failed${NC}" + return 1 + fi +} + +# Log tool response with formatting +log_knowledge_tool_response() { + local response="$1" + local tool_name="$2" + local log_file="$3" + + # Extract the actual tool JSON from the MCP wrapper + local tool_json=$(echo "$response" | jq -r '.content[0].text' 2>/dev/null) + + # Log full response to file + { + echo "📋 $tool_name Full Response:" + echo "==========================================" + echo "$tool_json" | jq . 2>/dev/null || echo "$tool_json" + echo "" + } >> "$log_file" + + # Show key information on stdout based on tool type + case "$tool_name" in + "aws_knowledge_aws___search_documentation") + local result_count=$(echo "$tool_json" | jq -r '.content.result | length' 2>/dev/null) + local first_title=$(echo "$tool_json" | jq -r '.content.result[0].title' 2>/dev/null) + + echo "✓ Search results: $result_count" + echo "✓ First result: $first_title" + ;; + "aws_knowledge_aws___read_documentation") + local content_length=$(echo "$tool_json" | jq -r '.content.result | length' 2>/dev/null) + local has_headings=$(echo "$tool_json" | jq -r '.content.result' | grep -c '^#' 2>/dev/null || echo "0") + + echo "✓ Content length: $content_length chars" + echo "✓ Markdown headings found: $has_headings" + ;; + "aws_knowledge_aws___recommend") + local rec_count=$(echo "$tool_json" | jq -r '.content.result | length' 2>/dev/null) + local first_rec_title=$(echo "$tool_json" | jq -r '.content.result[0].title' 2>/dev/null) + + echo "✓ Recommendations: $rec_count" + echo "✓ First recommendation: $first_rec_title" + ;; + esac +} diff --git a/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/utils/mcp_knowledge_helpers.sh b/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/utils/mcp_knowledge_helpers.sh new file mode 100755 index 0000000000..1a28ab089b --- /dev/null +++ b/src/ecs-mcp-server/tests/integ/mcp-inspector/scenarios/02_test_knowledge_proxy_tools/utils/mcp_knowledge_helpers.sh @@ -0,0 +1,232 @@ +#!/bin/bash +set -euo pipefail # Exit on error, undefined vars, pipe failures + +# MCP Inspector CLI Helper Functions for AWS Knowledge Tools +# This file contains utility functions for calling MCP Inspector CLI commands +# and parsing responses from AWS Knowledge tools + +# ============================================================================= +# CONSTANTS +# ============================================================================= +# Colors for output formatting +readonly GREEN='\033[0;32m' +readonly RED='\033[0;31m' +readonly YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly NC='\033[0m' # No Color + +# MCP Configuration +readonly MCP_CONFIG_FILE="/tmp/mcp-config.json" +readonly MCP_SERVER_NAME="local-ecs-mcp-server" +readonly INSTALL_COMMAND_MCP_INSPECTOR="pip install mcp-inspector" +readonly INSTALL_COMMAND_UV="pip install uv" + +# Validate MCP configuration exists +check_mcp_config() { + if [ ! -f "${MCP_CONFIG_FILE}" ]; then + echo "❌ MCP configuration not found at ${MCP_CONFIG_FILE}" + echo "Please ensure your MCP configuration is set up properly." + return 1 + fi + + # Validate the server exists in the config + if ! jq -e ".mcpServers.\"${MCP_SERVER_NAME}\"" "${MCP_CONFIG_FILE}" >/dev/null 2>&1; then + echo "❌ Server '${MCP_SERVER_NAME}' not found in MCP configuration" + echo "Available servers:" + jq -r '.mcpServers | keys[]' "${MCP_CONFIG_FILE}" 2>/dev/null || echo " (Unable to parse config)" + return 1 + fi + + echo "✅ MCP configuration validated" + return 0 +} + +# Get list of all available tools from MCP server +# Usage: get_mcp_tools +get_mcp_tools() { + echo "🔧 Fetching all available MCP tools..." >&2 + + # Execute MCP Inspector CLI command to list tools + local response + response=$(mcp-inspector \ + --config "$MCP_CONFIG_FILE" \ + --server "$MCP_SERVER_NAME" \ + --cli \ + --method tools/list 2>&1) + + local exit_code=$? + + if [ $exit_code -ne 0 ]; then + echo "❌ MCP Inspector tools/list failed with exit code $exit_code" >&2 + echo "Error output: $response" >&2 + return 1 + fi + + echo "$response" + return 0 +} + +# Note: tools/get method is not supported by MCP Inspector CLI +# Tool descriptions can only be validated through the tools/list response +# which includes tool information but not detailed descriptions + +# Call AWS Knowledge search documentation tool +# Usage: test_aws_knowledge_search_documentation +test_aws_knowledge_search_documentation() { + local search_phrase="$1" + + if [ -z "$search_phrase" ]; then + echo "❌ Error: search_phrase is required for aws_knowledge_aws___search_documentation" + return 1 + fi + + echo "🔧 Calling AWS Knowledge search_documentation with: ${search_phrase}" >&2 + + # Execute MCP Inspector CLI command + local response + response=$(mcp-inspector \ + --config "$MCP_CONFIG_FILE" \ + --server "$MCP_SERVER_NAME" \ + --cli \ + --method tools/call \ + --tool-name aws_knowledge_aws___search_documentation \ + --tool-arg "search_phrase=${search_phrase}" 2>&1) + + local exit_code=$? + + if [ $exit_code -ne 0 ]; then + echo "❌ MCP Inspector command failed with exit code $exit_code" >&2 + echo "Error output: $response" >&2 + return 1 + fi + + echo "$response" + return 0 +} + +# Call AWS Knowledge read documentation tool +# Usage: test_aws_knowledge_read_documentation +test_aws_knowledge_read_documentation() { + local url="$1" + + if [ -z "$url" ]; then + echo "❌ Error: url is required for aws_knowledge_aws___read_documentation" + return 1 + fi + + echo "🔧 Calling AWS Knowledge read_documentation with: ${url}" >&2 + + # Execute MCP Inspector CLI command + local response + response=$(mcp-inspector \ + --config "$MCP_CONFIG_FILE" \ + --server "$MCP_SERVER_NAME" \ + --cli \ + --method tools/call \ + --tool-name aws_knowledge_aws___read_documentation \ + --tool-arg "url=${url}" 2>&1) + + local exit_code=$? + + if [ $exit_code -ne 0 ]; then + echo "❌ MCP Inspector command failed with exit code $exit_code" >&2 + echo "Error output: $response" >&2 + return 1 + fi + + echo "$response" + return 0 +} + +# Call AWS Knowledge recommend tool +# Usage: test_aws_knowledge_recommend +test_aws_knowledge_recommend() { + local url="$1" + + if [ -z "$url" ]; then + echo "❌ Error: url is required for aws_knowledge_aws___recommend" + return 1 + fi + + echo "🔧 Calling AWS Knowledge recommend with: ${url}" >&2 + + # Execute MCP Inspector CLI command + local response + response=$(mcp-inspector \ + --config "$MCP_CONFIG_FILE" \ + --server "$MCP_SERVER_NAME" \ + --cli \ + --method tools/call \ + --tool-name aws_knowledge_aws___recommend \ + --tool-arg "url=${url}" 2>&1) + + local exit_code=$? + + if [ $exit_code -ne 0 ]; then + echo "❌ MCP Inspector command failed with exit code $exit_code" >&2 + echo "Error output: $response" >&2 + return 1 + fi + + echo "$response" + return 0 +} + +# Check if mcp-inspector is available +check_mcp_inspector() { + if command -v mcp-inspector >/dev/null 2>&1; then + echo "✅ mcp-inspector CLI is available" + return 0 + else + echo "❌ mcp-inspector CLI is not available. Please install it first." + echo " You can install it using: $INSTALL_COMMAND_MCP_INSPECTOR" + return 1 + fi +} + +# Check if uv is available (required by the MCP config) +check_uv() { + if command -v uv >/dev/null 2>&1; then + echo "✅ uv is available" + return 0 + else + echo "❌ uv is not available. Please install it first." + echo " You can install it using: $INSTALL_COMMAND_UV" + return 1 + fi +} + +# Validate prerequisites for MCP Knowledge testing +validate_mcp_knowledge_prerequisites() { + echo "🔍 Validating MCP Knowledge testing prerequisites..." + + local errors=0 + + if ! check_uv; then + errors=$((errors + 1)) + fi + + if ! check_mcp_inspector; then + errors=$((errors + 1)) + fi + + if ! check_mcp_config; then + errors=$((errors + 1)) + fi + + # Check that jq is available for JSON processing + if ! command -v jq >/dev/null 2>&1; then + echo "❌ jq is not available. Please install it for JSON processing." + errors=$((errors + 1)) + else + echo "✅ jq is available" + fi + + if [ $errors -eq 0 ]; then + echo "✅ All prerequisites validated successfully" + return 0 + else + echo "❌ $errors prerequisite(s) failed validation" + return 1 + fi +} diff --git a/src/ecs-mcp-server/tests/unit/modules/test_aws_knowledge_proxy.py b/src/ecs-mcp-server/tests/unit/modules/test_aws_knowledge_proxy.py new file mode 100644 index 0000000000..0ba75e3173 --- /dev/null +++ b/src/ecs-mcp-server/tests/unit/modules/test_aws_knowledge_proxy.py @@ -0,0 +1,657 @@ +""" +Comprehensive unit tests for aws_knowledge_proxy module. +""" + +from typing import Any, Dict, List +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest + +from awslabs.ecs_mcp_server.modules.aws_knowledge_proxy import ( + ECS_TOOL_GUIDANCE, + _add_ecs_guidance_to_knowledge_tools, + apply_tool_transformations, + register_ecs_prompts, + register_proxy, +) + +# Test Constants +EXPECTED_PROXY_CONFIG = { + "mcpServers": { + "aws-knowledge-mcp-server": { + "command": "uvx", + "args": [ + "mcp-proxy", + "--transport", + "streamablehttp", + "https://knowledge-mcp.global.api.aws", + ], + } + } +} + +EXPECTED_KNOWLEDGE_TOOLS = [ + "aws_knowledge_aws___search_documentation", + "aws_knowledge_aws___read_documentation", + "aws_knowledge_aws___recommend", +] + +EXPECTED_ECS_PATTERNS = [ + "what are blue green deployments", + "what are b/g deployments", + "native ecs blue green", + "native ecs b/g", + "ecs native blue green deployments", + "difference between codedeploy and native blue green", + "how to setup blue green", + "setup ecs blue green", + "configure ecs blue green deployments", + "configure blue green", + "configure b/g", + "create blue green deployment", + "ecs best practices", + "ecs implementation guide", + "ecs guidance", + "ecs recommendations", + "how to use ecs effectively", + "new ecs feature", + "latest ecs feature", +] + + +def _generate_prompt_test_data(): + """Generate test data for prompt response testing from EXPECTED_ECS_PATTERNS.""" + expected_response = {"name": "aws_knowledge_aws___search_documentation"} + return [(pattern, expected_response) for pattern in EXPECTED_ECS_PATTERNS] + + +# Test Data for Parameterized Tests +PROMPT_PATTERN_TEST_DATA = _generate_prompt_test_data() + +REGISTER_PROXY_ERROR_TEST_DATA = [ + ( + "ProxyClient creation failed", + "ProxyClient", + "Failed to setup AWS Knowledge MCP Server proxy: ProxyClient creation failed", + ), + ( + "FastMCP.as_proxy failed", + "FastMCP", + "Failed to setup AWS Knowledge MCP Server proxy: FastMCP.as_proxy failed", + ), + ("Mount failed", "mount", "Failed to setup AWS Knowledge MCP Server proxy: Mount failed"), +] + + +# Test Fixtures +@pytest.fixture +def mock_mcp() -> MagicMock: + """Create a mock FastMCP instance.""" + return MagicMock() + + +@pytest.fixture +def mock_async_mcp() -> AsyncMock: + """Create an async mock FastMCP instance.""" + mcp = AsyncMock() + # Make add_tool_transformation synchronous as it is in the real implementation + mcp.add_tool_transformation = MagicMock() + return mcp + + +@pytest.fixture +def sample_tools() -> Dict[str, MagicMock]: + """Create sample tool objects for testing.""" + tools = {} + for tool_name in EXPECTED_KNOWLEDGE_TOOLS: + mock_tool = MagicMock() + mock_tool.description = f"Original description for {tool_name}" + tools[tool_name] = mock_tool + return tools + + +@pytest.fixture +def sample_tools_with_none_description() -> Dict[str, MagicMock]: + """Create sample tool objects with None descriptions.""" + tools = {} + for tool_name in EXPECTED_KNOWLEDGE_TOOLS: + mock_tool = MagicMock() + mock_tool.description = None + tools[tool_name] = mock_tool + return tools + + +@pytest.fixture +def mock_transform_configs() -> List[MagicMock]: + """Create mock ToolTransformConfig instances.""" + return [MagicMock(), MagicMock(), MagicMock()] + + +class TestECSToolGuidance: + """Test the ECS_TOOL_GUIDANCE constant.""" + + def test_ecs_tool_guidance_content(self) -> None: + """Test that ECS_TOOL_GUIDANCE contains expected content.""" + expected_content = [ + "ECS DOCUMENTATION GUIDANCE", + "up-to-date ECS documentation", + "new ECS features", + "ECS Native Blue-Green Deployments", + "launched 2025", + ] + + for content in expected_content: + assert content in ECS_TOOL_GUIDANCE, ( + f"Expected content '{content}' not found in ECS_TOOL_GUIDANCE" + ) + + def test_ecs_tool_guidance_structure(self) -> None: + """Test that ECS_TOOL_GUIDANCE has proper structure.""" + assert ECS_TOOL_GUIDANCE.startswith("\n\n ## ECS DOCUMENTATION GUIDANCE") + assert "New ECS features include:" in ECS_TOOL_GUIDANCE + assert ECS_TOOL_GUIDANCE.strip().endswith("launched 2025)") + + def test_ecs_tool_guidance_is_multiline(self) -> None: + """Test that ECS_TOOL_GUIDANCE is properly formatted as multiline string.""" + lines = ECS_TOOL_GUIDANCE.strip().split("\n") + assert len(lines) >= 3, "ECS_TOOL_GUIDANCE should have multiple lines" + + +class TestRegisterProxy: + """Test the register_proxy function.""" + + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.logger") + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.register_ecs_prompts") + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.FastMCP") + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.ProxyClient") + def test_register_proxy_success( + self, + mock_proxy_client: MagicMock, + mock_fastmcp: MagicMock, + mock_register_ecs: MagicMock, + mock_logger: MagicMock, + mock_mcp: MagicMock, + ) -> None: + """Test successful proxy registration.""" + # Setup mocks + mock_proxy_instance = MagicMock() + mock_proxy_client.return_value = mock_proxy_instance + mock_aws_knowledge_proxy = MagicMock() + mock_fastmcp.as_proxy.return_value = mock_aws_knowledge_proxy + + # Call the function + result = register_proxy(mock_mcp) + + # Verify success + assert result is True + + # Verify proxy configuration + mock_proxy_client.assert_called_once_with(EXPECTED_PROXY_CONFIG) + + # Verify proxy creation and mounting + mock_fastmcp.as_proxy.assert_called_once_with(mock_proxy_instance) + mock_mcp.mount.assert_called_once_with(mock_aws_knowledge_proxy, prefix="aws_knowledge") + + # Verify ECS prompts registration + mock_register_ecs.assert_called_once_with(mock_mcp) + + # Verify logging + expected_log_calls = [ + call("Setting up AWS Knowledge MCP Server proxy"), + call("Successfully mounted AWS Knowledge MCP Server"), + ] + mock_logger.info.assert_has_calls(expected_log_calls) + + @pytest.mark.parametrize( + "error_message,error_component,expected_log", REGISTER_PROXY_ERROR_TEST_DATA + ) + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.logger") + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.ProxyClient") + def test_register_proxy_exceptions( + self, + mock_proxy_client: MagicMock, + mock_logger: MagicMock, + error_message: str, + error_component: str, + expected_log: str, + mock_mcp: MagicMock, + ) -> None: + """Test proxy registration with various exceptions.""" + # Setup mocks + mock_proxy_client.side_effect = Exception(error_message) + + # Call the function + result = register_proxy(mock_mcp) + + # Verify failure + assert result is False + + # Verify error logging + mock_logger.error.assert_called_once_with(expected_log) + + def test_register_proxy_with_none_mcp(self) -> None: + """Test register_proxy with None MCP instance.""" + result = register_proxy(None) + assert result is False + + +class TestApplyToolTransformations: + """Test the apply_tool_transformations function.""" + + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.logger") + @patch( + "awslabs.ecs_mcp_server.modules.aws_knowledge_proxy._add_ecs_guidance_to_knowledge_tools" + ) + @pytest.mark.asyncio + async def test_apply_tool_transformations_success( + self, mock_add_guidance: MagicMock, mock_logger: MagicMock, mock_mcp: MagicMock + ) -> None: + """Test successful tool transformations application.""" + # Setup mocks + mock_add_guidance.return_value = None + + # Call the function + await apply_tool_transformations(mock_mcp) + + # Verify calls + mock_logger.info.assert_called_once_with("Applying tool transformations...") + mock_add_guidance.assert_called_once_with(mock_mcp) + + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.logger") + @patch( + "awslabs.ecs_mcp_server.modules.aws_knowledge_proxy._add_ecs_guidance_to_knowledge_tools" + ) + @pytest.mark.asyncio + async def test_apply_tool_transformations_exception( + self, mock_add_guidance: MagicMock, mock_logger: MagicMock, mock_mcp: MagicMock + ) -> None: + """Test tool transformations application with exception.""" + # Setup mocks + error_message = "Guidance addition failed" + mock_add_guidance.side_effect = Exception(error_message) + + # Call the function and expect exception to propagate + with pytest.raises(Exception, match=error_message): + await apply_tool_transformations(mock_mcp) + + # Verify calls + mock_logger.info.assert_called_once_with("Applying tool transformations...") + mock_add_guidance.assert_called_once_with(mock_mcp) + + @pytest.mark.asyncio + async def test_apply_tool_transformations_with_none_mcp(self) -> None: + """Test apply_tool_transformations with None MCP instance.""" + with pytest.raises(AttributeError): + await apply_tool_transformations(None) + + +class TestAddEcsGuidanceToKnowledgeTools: + """Test the _add_ecs_guidance_to_knowledge_tools function.""" + + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.logger") + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.ToolTransformConfig") + @pytest.mark.asyncio + async def test_add_ecs_guidance_success( + self, + mock_transform_config: MagicMock, + mock_logger: MagicMock, + mock_async_mcp: AsyncMock, + sample_tools: Dict[str, MagicMock], + mock_transform_configs: List[MagicMock], + ) -> None: + """Test successful ECS guidance addition to tools.""" + # Setup mocks + mock_async_mcp.get_tools.return_value = sample_tools + mock_transform_config.side_effect = mock_transform_configs + + # Call the function + await _add_ecs_guidance_to_knowledge_tools(mock_async_mcp) + + # Verify tool retrieval + mock_async_mcp.get_tools.assert_called_once() + + # Verify ToolTransformConfig creation + expected_calls = [ + call( + name=tool_name, + description=f"Original description for {tool_name}" + ECS_TOOL_GUIDANCE, + ) + for tool_name in EXPECTED_KNOWLEDGE_TOOLS + ] + mock_transform_config.assert_has_calls(expected_calls) + + # Verify transformations were added + expected_transform_calls = [ + call(tool_name, config) + for tool_name, config in zip( + EXPECTED_KNOWLEDGE_TOOLS, mock_transform_configs, strict=False + ) + ] + mock_async_mcp.add_tool_transformation.assert_has_calls(expected_transform_calls) + + # Verify logging + mock_logger.debug.assert_called_once_with("Added ECS guidance to AWS Knowledge tools") + + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.logger") + @pytest.mark.asyncio + async def test_add_ecs_guidance_missing_tools( + self, mock_logger: MagicMock, mock_async_mcp: AsyncMock + ) -> None: + """Test ECS guidance addition with missing tools.""" + # Setup mocks - only include one tool + mock_tool = MagicMock() + mock_tool.description = "Test description" + mock_async_mcp.get_tools.return_value = { + "aws_knowledge_aws___search_documentation": mock_tool, + } + + # Call the function + await _add_ecs_guidance_to_knowledge_tools(mock_async_mcp) + + # Verify warnings for missing tools + expected_warnings = [ + call("Tool aws_knowledge_aws___read_documentation not found in MCP tools"), + call("Tool aws_knowledge_aws___recommend not found in MCP tools"), + ] + mock_logger.warning.assert_has_calls(expected_warnings, any_order=True) + + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.logger") + @pytest.mark.asyncio + async def test_add_ecs_guidance_get_tools_exception( + self, mock_logger: MagicMock, mock_async_mcp: AsyncMock + ) -> None: + """Test ECS guidance addition with get_tools exception.""" + # Setup mocks + error_message = "Failed to get tools" + mock_async_mcp.get_tools.side_effect = Exception(error_message) + + # Call the function and expect exception to propagate + with pytest.raises(Exception, match=error_message): + await _add_ecs_guidance_to_knowledge_tools(mock_async_mcp) + + # Verify error logging + mock_logger.error.assert_called_once_with( + f"Error applying tool transformations: {error_message}" + ) + + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.logger") + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.ToolTransformConfig") + @pytest.mark.asyncio + async def test_add_ecs_guidance_transform_exception( + self, mock_transform_config: MagicMock, mock_logger: MagicMock, mock_async_mcp: AsyncMock + ) -> None: + """Test ECS guidance addition with transformation exception.""" + # Setup mocks + mock_tool = MagicMock() + mock_tool.description = "Test description" + mock_async_mcp.get_tools.return_value = { + "aws_knowledge_aws___search_documentation": mock_tool + } + + # Make add_tool_transformation a regular synchronous mock that raises exception + error_message = "Transform failed" + mock_async_mcp.add_tool_transformation = MagicMock(side_effect=Exception(error_message)) + + # Call the function and expect exception to propagate + with pytest.raises(Exception, match=error_message): + await _add_ecs_guidance_to_knowledge_tools(mock_async_mcp) + + # Verify error logging + mock_logger.error.assert_called_once_with( + f"Error applying tool transformations: {error_message}" + ) + + +class TestRegisterEcsPrompts: + """Test the register_ecs_prompts function.""" + + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.logger") + def test_register_ecs_prompts_registration( + self, mock_logger: MagicMock, mock_mcp: MagicMock + ) -> None: + """Test that all ECS prompt patterns are registered.""" + # Setup mock MCP + registered_prompts = {} + + def mock_prompt_decorator(pattern: str): + def decorator(func): + registered_prompts[pattern] = func + return func + + return decorator + + mock_mcp.prompt = mock_prompt_decorator + + # Call the function + register_ecs_prompts(mock_mcp) + + # Verify all expected prompt patterns were registered + expected_count = len(EXPECTED_ECS_PATTERNS) + assert len(registered_prompts) == expected_count, ( + f"Expected {expected_count} patterns, got {len(registered_prompts)}" + ) + + for expected_pattern in EXPECTED_ECS_PATTERNS: + assert expected_pattern in registered_prompts, ( + f"Pattern '{expected_pattern}' not registered" + ) + + # Verify logging + expected_count = len(EXPECTED_ECS_PATTERNS) + mock_logger.info.assert_called_once_with( + f"Registered {expected_count} ECS-related prompt patterns " + f"with AWS Knowledge proxy tools" + ) + + @pytest.mark.parametrize("pattern,expected_response", PROMPT_PATTERN_TEST_DATA) + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.logger") + def test_register_ecs_prompts_responses( + self, + mock_logger: MagicMock, + pattern: str, + expected_response: Dict[str, Any], + mock_mcp: MagicMock, + ) -> None: + """Test that prompt functions return correct responses.""" + # Setup mock MCP + registered_prompts = {} + + def mock_prompt_decorator(pattern_name: str): + def decorator(func): + registered_prompts[pattern_name] = func + return func + + return decorator + + mock_mcp.prompt = mock_prompt_decorator + + # Call the function + register_ecs_prompts(mock_mcp) + + # Test the specific pattern + if pattern in registered_prompts: + response = registered_prompts[pattern]() + assert len(response) == 1, f"Expected single response for pattern '{pattern}'" + assert response[0] == expected_response, f"Incorrect response for pattern '{pattern}'" + + +class TestUpstreamToolDetection: + """Test detection of expected upstream AWS Knowledge tools.""" + + def test_expected_knowledge_tool_names_referenced(self) -> None: + """Test that expected AWS Knowledge tool names are properly referenced.""" + # This test verifies that the hardcoded tool names in the module + # match what we expect from the upstream AWS Knowledge MCP Server + import inspect + + from awslabs.ecs_mcp_server.modules.aws_knowledge_proxy import ( + _add_ecs_guidance_to_knowledge_tools, + ) + + # Get the source code of the function + source = inspect.getsource(_add_ecs_guidance_to_knowledge_tools) + + # Verify expected tool names are present + for tool_name in EXPECTED_KNOWLEDGE_TOOLS: + assert tool_name in source, f"Expected tool {tool_name} not found in source" + + def test_prompt_responses_reference_correct_tools(self, mock_mcp: MagicMock) -> None: + """Test that prompt responses reference the correct AWS Knowledge tools.""" + # Setup mock MCP to capture prompt functions + registered_prompts = {} + + def mock_prompt_decorator(pattern: str): + def decorator(func): + registered_prompts[pattern] = func + return func + + return decorator + + mock_mcp.prompt = mock_prompt_decorator + + # Register prompts + register_ecs_prompts(mock_mcp) + + # Test that all prompt responses reference the search documentation tool + for pattern, func in registered_prompts.items(): + response = func() + assert len(response) == 1, f"Expected single response for pattern '{pattern}'" + assert response[0]["name"] == "aws_knowledge_aws___search_documentation", ( + f"Incorrect tool reference in pattern '{pattern}'" + ) + + +class TestLoggingFunctionality: + """Test logging functionality across all functions.""" + + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.logger") + def test_register_proxy_logging_levels( + self, mock_logger: MagicMock, mock_mcp: MagicMock + ) -> None: + """Test different logging levels in register_proxy.""" + with patch( + "awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.ProxyClient" + ) as mock_proxy_client: + error_message = "Test error" + mock_proxy_client.side_effect = Exception(error_message) + + # Call function + result = register_proxy(mock_mcp) + + # Verify info and error logging + assert result is False + mock_logger.info.assert_called_with("Setting up AWS Knowledge MCP Server proxy") + mock_logger.error.assert_called_with( + f"Failed to setup AWS Knowledge MCP Server proxy: {error_message}" + ) + + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.logger") + @pytest.mark.asyncio + async def test_add_ecs_guidance_logging_levels( + self, mock_logger: MagicMock, mock_async_mcp: AsyncMock + ) -> None: + """Test different logging levels in _add_ecs_guidance_to_knowledge_tools.""" + # Test warning logging for missing tools + mock_async_mcp.get_tools.return_value = {} # No tools available + + await _add_ecs_guidance_to_knowledge_tools(mock_async_mcp) + + # Verify warning calls for all missing tools + expected_warnings = [ + call(f"Tool {tool_name} not found in MCP tools") + for tool_name in EXPECTED_KNOWLEDGE_TOOLS + ] + mock_logger.warning.assert_has_calls(expected_warnings, any_order=True) + + +class TestEdgeCases: + """Test edge cases and error scenarios.""" + + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.logger") + @pytest.mark.asyncio + async def test_empty_tools_dict( + self, mock_logger: MagicMock, mock_async_mcp: AsyncMock + ) -> None: + """Test handling of empty tools dictionary.""" + mock_async_mcp.get_tools.return_value = {} + + # Should not raise exception + await _add_ecs_guidance_to_knowledge_tools(mock_async_mcp) + + # Should log warnings for all missing tools + assert mock_logger.warning.call_count == len(EXPECTED_KNOWLEDGE_TOOLS) + + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.logger") + @pytest.mark.asyncio + async def test_none_description_handling( + self, + mock_logger: MagicMock, + mock_async_mcp: AsyncMock, + sample_tools_with_none_description: Dict[str, MagicMock], + ) -> None: + """Test handling of tools with None description.""" + # Use only one tool for this test + single_tool = { + "aws_knowledge_aws___search_documentation": sample_tools_with_none_description[ + "aws_knowledge_aws___search_documentation" + ] + } + mock_async_mcp.get_tools.return_value = single_tool + + with patch( + "awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.ToolTransformConfig" + ) as mock_config: + await _add_ecs_guidance_to_knowledge_tools(mock_async_mcp) + + # Should handle None description by using empty string + mock_config.assert_called_once_with( + name="aws_knowledge_aws___search_documentation", description="" + ECS_TOOL_GUIDANCE + ) + + +class TestModuleIntegration: + """Test module-level integration scenarios.""" + + def test_constant_used_in_functions(self) -> None: + """Test that ECS_TOOL_GUIDANCE constant is used in the right places.""" + import awslabs.ecs_mcp_server.modules.aws_knowledge_proxy as module + + # Verify the constant is importable and accessible + assert hasattr(module, "ECS_TOOL_GUIDANCE") + assert module.ECS_TOOL_GUIDANCE == ECS_TOOL_GUIDANCE + + def test_all_functions_importable(self) -> None: + """Test that all functions can be imported successfully.""" + from awslabs.ecs_mcp_server.modules.aws_knowledge_proxy import ( + apply_tool_transformations, + register_ecs_prompts, + register_proxy, + ) + + # Verify functions are callable + assert callable(register_proxy) + assert callable(apply_tool_transformations) + assert callable(register_ecs_prompts) + + def test_module_has_expected_exports(self) -> None: + """Test that module exports expected functions and constants.""" + import awslabs.ecs_mcp_server.modules.aws_knowledge_proxy as module + + expected_exports = [ + "ECS_TOOL_GUIDANCE", + "register_proxy", + "apply_tool_transformations", + "register_ecs_prompts", + ] + + for export in expected_exports: + assert hasattr(module, export), f"Module missing expected export: {export}" + + def test_constants_are_immutable_types(self) -> None: + """Test that constants use immutable types.""" + # ECS_TOOL_GUIDANCE should be a string (immutable) + assert isinstance(ECS_TOOL_GUIDANCE, str) + + # EXPECTED_KNOWLEDGE_TOOLS should be a list but we test its contents + for tool_name in EXPECTED_KNOWLEDGE_TOOLS: + assert isinstance(tool_name, str) diff --git a/src/ecs-mcp-server/tests/unit/test_main.py b/src/ecs-mcp-server/tests/unit/test_main.py index a6414720bc..cbbb4a59ca 100644 --- a/src/ecs-mcp-server/tests/unit/test_main.py +++ b/src/ecs-mcp-server/tests/unit/test_main.py @@ -10,18 +10,23 @@ - Error handling """ +import asyncio +import logging +import os import sys +import tempfile import unittest from unittest.mock import MagicMock, call, patch -# We need to patch the imports before importing the module under test +# Mock FastMCP for isolated testing class MockFastMCP: """Mock implementation of FastMCP for testing.""" - def __init__(self, name, instructions=None): + def __init__(self, name, instructions=None, lifespan=None, **kwargs): self.name = name self.instructions = instructions + self.lifespan = lifespan self.tools = [] self.prompt_patterns = [] @@ -49,105 +54,411 @@ def run(self): pass -# Apply the patches +# Apply patches before importing module under test with patch("fastmcp.FastMCP", MockFastMCP): - from awslabs.ecs_mcp_server.main import main, mcp + from awslabs.ecs_mcp_server.main import ( + _create_ecs_mcp_server, + _setup_logging, + main, + server_lifespan, + ) # ---------------------------------------------------------------------------- -# Server Configuration Tests +# Test Utilities and Mixins # ---------------------------------------------------------------------------- -class TestMain(unittest.TestCase): - """ - Tests for main server module configuration. - - This test class contains separate test methods for each aspect of the server - configuration, providing better isolation and easier debugging when tests fail. - """ - - def test_server_basic_properties(self): - """ - Test basic server properties. - - This test focuses only on the basic properties of the server: - - Name - - Instructions - - If this test fails, it indicates an issue with the basic server configuration. - """ - # Verify the server has the correct name and version - self.assertEqual(mcp.name, "AWS ECS MCP Server") - - # Verify instructions are provided - self.assertIsNotNone(mcp.instructions) - self.assertIn("WORKFLOW", mcp.instructions) - self.assertIn("IMPORTANT", mcp.instructions) - - def test_server_tools(self): - """ - Test that server has the expected tools. - - This test focuses only on the tools registered with the server. - It verifies that all required tools are present. - - If this test fails, it indicates an issue with tool registration. - """ - # Verify the server has registered tools - self.assertGreaterEqual(len(mcp.tools), 4) - - # Verify tool names - tool_names = [tool["name"] for tool in mcp.tools] - self.assertIn("containerize_app", tool_names) - self.assertIn("create_ecs_infrastructure", tool_names) - self.assertIn("get_deployment_status", tool_names) - self.assertIn("delete_ecs_infrastructure", tool_names) - - def test_server_prompts(self): - """ - Test that server has the expected prompt patterns. - - This test focuses only on the prompt patterns registered with the server. - It verifies that all required prompt patterns are present. - - If this test fails, it indicates an issue with prompt pattern registration. - """ - # Verify the server has registered prompt patterns - self.assertGreaterEqual(len(mcp.prompt_patterns), 14) - - # Verify prompt patterns - patterns = [pattern["pattern"] for pattern in mcp.prompt_patterns] - self.assertIn("dockerize", patterns) - self.assertIn("containerize", patterns) - self.assertIn("deploy to aws", patterns) - self.assertIn("deploy to ecs", patterns) - self.assertIn("ship it", patterns) - self.assertIn("deploy flask", patterns) - self.assertIn("deploy django", patterns) - self.assertIn("delete infrastructure", patterns) - self.assertIn("tear down", patterns) - self.assertIn("remove deployment", patterns) - self.assertIn("clean up resources", patterns) +class EnvironmentTestMixin: + """Mixin providing environment variable management for tests.""" + + def setUp(self): + """Store original environment state.""" + super().setUp() + self._original_env = self._capture_environment_state() + + def tearDown(self): + """Restore original environment state.""" + self._restore_environment_state(self._original_env) + super().tearDown() + + def _capture_environment_state(self): + """Capture relevant environment variables.""" + return {key: os.environ.get(key) for key in ["FASTMCP_LOG_LEVEL", "FASTMCP_LOG_FILE"]} + + def _restore_environment_state(self, original_state): + """Restore environment variables to original state.""" + for key, value in original_state.items(): + if value is not None: + os.environ[key] = value + elif key in os.environ: + del os.environ[key] + + def clear_logging_env_vars(self): + """Clear logging-related environment variables.""" + for key in ["FASTMCP_LOG_LEVEL", "FASTMCP_LOG_FILE"]: + if key in os.environ: + del os.environ[key] + + def set_log_level(self, level): + """Set logging level environment variable.""" + os.environ["FASTMCP_LOG_LEVEL"] = level + + def set_log_file(self, file_path): + """Set log file environment variable.""" + os.environ["FASTMCP_LOG_FILE"] = file_path + + +class LoggingTestMixin(EnvironmentTestMixin): + """Mixin providing logging system management for tests.""" + + def setUp(self): + """Initialize clean logging state.""" + super().setUp() + self._reset_logging_system() + + def tearDown(self): + """Clean up logging system.""" + self._reset_logging_system() + super().tearDown() + + def _reset_logging_system(self): + """Reset logging system to clean state.""" + root_logger = logging.getLogger() + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + logging.shutdown() + root_logger.setLevel(logging.NOTSET) + + +# ---------------------------------------------------------------------------- +# Core Server Configuration Tests +# ---------------------------------------------------------------------------- + + +class TestServerConfiguration(unittest.TestCase): + """Tests for server configuration and initialization.""" + + def setUp(self): + """Set up test fixtures.""" + self.mcp, self.config = _create_ecs_mcp_server() + + def test_server_properties(self): + """Test server has correct basic properties.""" + self.assertEqual(self.mcp.name, "AWS ECS MCP Server") + self.assertIsNotNone(self.mcp.instructions) + self.assertIn("WORKFLOW", self.mcp.instructions) + self.assertIn("IMPORTANT", self.mcp.instructions) + + def test_required_tools_registered(self): + """Test all required tools are properly registered.""" + self.assertGreaterEqual(len(self.mcp.tools), 4) + + required_tools = [ + "containerize_app", + "create_ecs_infrastructure", + "get_deployment_status", + "delete_ecs_infrastructure", + ] + + tool_names = [tool["name"] for tool in self.mcp.tools] + for tool in required_tools: + self.assertIn(tool, tool_names, f"Required tool '{tool}' not found") + + def test_prompt_patterns_registered(self): + """Test prompt patterns are properly registered.""" + self.assertGreaterEqual(len(self.mcp.prompt_patterns), 14) + + expected_patterns = [ + "dockerize", + "containerize", + "deploy to aws", + "deploy to ecs", + "ship it", + "deploy flask", + "deploy django", + "delete infrastructure", + "tear down", + "remove deployment", + "clean up resources", + ] + + patterns = [pattern["pattern"] for pattern in self.mcp.prompt_patterns] + for pattern in expected_patterns: + self.assertIn(pattern, patterns, f"Expected pattern '{pattern}' not found") + + +# ---------------------------------------------------------------------------- +# Logging System Tests +# ---------------------------------------------------------------------------- + + +class TestLoggingSystem(LoggingTestMixin, unittest.TestCase): + """Tests for logging system configuration and behavior.""" + + def test_default_logging_setup(self): + """Test logging setup with default configuration.""" + self.clear_logging_env_vars() + + logger = _setup_logging() + + self.assertIsNotNone(logger) + self.assertEqual(logger.name, "ecs-mcp-server") + + def test_custom_log_level_configuration(self): + """Test logging setup with custom log level.""" + self.set_log_level("DEBUG") + + logger = _setup_logging() + + self.assertIsNotNone(logger) + self.assertEqual(logger.name, "ecs-mcp-server") + + def test_file_logging_setup(self): + """Test file logging configuration with success scenario.""" + with tempfile.TemporaryDirectory() as temp_dir: + log_file = os.path.join(temp_dir, "test.log") + self.set_log_file(log_file) + + with patch("logging.info") as mock_info: + logger = _setup_logging() + + self.assertIsNotNone(logger) + + # Verify file handler was added + root_logger = logging.getLogger() + file_handlers = [ + h for h in root_logger.handlers if isinstance(h, logging.FileHandler) + ] + self.assertGreater(len(file_handlers), 0) + + # Verify success logging + mock_info.assert_any_call(f"Logging to file: {log_file}") + + def test_automatic_directory_creation(self): + """Test automatic creation of log file directories.""" + with tempfile.TemporaryDirectory() as temp_dir: + log_dir = os.path.join(temp_dir, "logs", "subdir") + log_file = os.path.join(log_dir, "test.log") + self.set_log_file(log_file) + + self.assertFalse(os.path.exists(log_dir)) + + logger = _setup_logging() + + self.assertIsNotNone(logger) + self.assertTrue(os.path.exists(log_dir)) + + def test_file_logging_error_handling(self): + """Test graceful handling of file logging errors.""" + invalid_path = "/invalid/nonexistent/path/test.log" + self.set_log_file(invalid_path) + + with patch("logging.error") as mock_error: + logger = _setup_logging() + + # Function should still return logger despite error + self.assertIsNotNone(logger) + + # Error should be logged + mock_error.assert_called_once() + error_message = mock_error.call_args[0][0] + self.assertIn("Failed to set up log file", error_message) + self.assertIn(invalid_path, error_message) # ---------------------------------------------------------------------------- -# Logging Tests +# Server Lifecycle Tests +# ---------------------------------------------------------------------------- + + +class TestServerLifecycle(unittest.TestCase): + """Tests for async server lifecycle management.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_server = MagicMock() + + def _run_async_test(self, async_test_func): + """Helper to execute async test functions.""" + asyncio.run(async_test_func()) + + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.apply_tool_transformations") + @patch("logging.getLogger") + def test_successful_lifecycle_management(self, mock_get_logger, mock_apply_transformations): + """Test complete successful server lifecycle.""" + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + mock_apply_transformations.return_value = None + + async def test_logic(): + async with server_lifespan(self.mock_server): + # Verify initialization + mock_logger.info.assert_any_call("Server initializing") + mock_logger.info.assert_any_call("Server ready") + mock_apply_transformations.assert_called_once_with(self.mock_server) + + # Verify cleanup + mock_logger.info.assert_any_call("Server shutting down") + + self._run_async_test(test_logic) + + @patch("awslabs.ecs_mcp_server.modules.aws_knowledge_proxy.apply_tool_transformations") + @patch("logging.getLogger") + def test_error_handling_during_initialization( + self, mock_get_logger, mock_apply_transformations + ): + """Test proper error handling during server initialization.""" + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + initialization_error = RuntimeError("Initialization failed") + mock_apply_transformations.side_effect = initialization_error + + async def test_logic(): + with self.assertRaises(RuntimeError) as context: + async with server_lifespan(self.mock_server): + pass + + # Verify the specific error was raised + self.assertEqual(str(context.exception), "Initialization failed") + + # Verify initialization was attempted + mock_logger.info.assert_any_call("Server initializing") + + self._run_async_test(test_logic) + + +# ---------------------------------------------------------------------------- +# Application Entry Point Tests +# ---------------------------------------------------------------------------- + + +class TestApplicationEntryPoint(unittest.TestCase): + """Tests for application entry point behavior.""" + + def test_main_module_execution_logic(self): + """Test entry point logic for different execution contexts.""" + test_scenarios = [ + ("__main__", True, "should execute when run as main module"), + ("awslabs.ecs_mcp_server.main", False, "should not execute when imported"), + ("other_module", False, "should not execute for other modules"), + ] + + for module_name, should_execute, description in test_scenarios: + with self.subTest(module=module_name, description=description): + with patch("awslabs.ecs_mcp_server.main.main") as mock_main: + # Simulate entry point condition + if module_name == "__main__": + mock_main() + + if should_execute: + mock_main.assert_called_once() + else: + mock_main.assert_not_called() + + def test_entry_point_execution_simulation(self): + """Test simulated execution of module entry point.""" + with patch("awslabs.ecs_mcp_server.main.main") as mock_main: + # Simulate module execution as main + module_name = "__main__" + if module_name == "__main__": + mock_main() + + mock_main.assert_called_once() + + +# ---------------------------------------------------------------------------- +# Main Function Behavior Tests +# ---------------------------------------------------------------------------- + + +class TestMainFunctionBehavior(unittest.TestCase): + """Tests for main function execution scenarios.""" + + @patch("awslabs.ecs_mcp_server.main.sys.exit") + @patch("awslabs.ecs_mcp_server.main._setup_logging") + @patch("awslabs.ecs_mcp_server.main._create_ecs_mcp_server") + def test_successful_server_startup(self, mock_create_server, mock_setup_logging, mock_exit): + """Test successful server startup and execution.""" + # Configure mocks + mock_mcp = MagicMock() + mock_config = MagicMock() + mock_config.get.side_effect = lambda key, default: { + "allow-write": True, + "allow-sensitive-data": False, + }.get(key, default) + mock_create_server.return_value = (mock_mcp, mock_config) + + mock_logger = MagicMock() + mock_setup_logging.return_value = mock_logger + + # Execute main function + main() + + # Verify expected behavior + mock_logger.info.assert_any_call("Server started") + mock_logger.info.assert_any_call("Write operations enabled: True") + mock_logger.info.assert_any_call("Sensitive data access enabled: False") + mock_mcp.run.assert_called_once() + mock_exit.assert_not_called() + + @patch("awslabs.ecs_mcp_server.main.sys.exit") + @patch("awslabs.ecs_mcp_server.main._setup_logging") + @patch("awslabs.ecs_mcp_server.main._create_ecs_mcp_server") + def test_keyboard_interrupt_handling(self, mock_create_server, mock_setup_logging, mock_exit): + """Test graceful handling of keyboard interrupt.""" + # Configure mocks for keyboard interrupt + mock_mcp = MagicMock() + mock_config = MagicMock() + mock_mcp.run.side_effect = KeyboardInterrupt() + mock_create_server.return_value = (mock_mcp, mock_config) + + mock_logger = MagicMock() + mock_setup_logging.return_value = mock_logger + + # Execute main function + main() + + # Verify graceful shutdown + mock_logger.info.assert_any_call("Server stopped by user") + mock_exit.assert_called_once_with(0) + + @patch("awslabs.ecs_mcp_server.main.sys.exit") + @patch("awslabs.ecs_mcp_server.main._setup_logging") + @patch("awslabs.ecs_mcp_server.main._create_ecs_mcp_server") + def test_general_exception_handling(self, mock_create_server, mock_setup_logging, mock_exit): + """Test handling of unexpected exceptions.""" + # Configure mocks for general exception + mock_mcp = MagicMock() + mock_config = MagicMock() + mock_mcp.run.side_effect = Exception("Unexpected error") + mock_create_server.return_value = (mock_mcp, mock_config) + + mock_logger = MagicMock() + mock_setup_logging.return_value = mock_logger + + # Execute main function + main() + + # Verify error handling + mock_logger.error.assert_called_once_with("Error starting server: Unexpected error") + mock_exit.assert_called_once_with(1) + + +# ---------------------------------------------------------------------------- +# Legacy Test Compatibility # ---------------------------------------------------------------------------- def test_log_file_setup(): - """Test log file setup with directory creation.""" + """Legacy compatibility test for log file setup functionality.""" - # Create a test function that mimics the log file setup from main.py def setup_log_file(log_file, mock_os, mock_logging): try: - # Create directory for log file if it doesn't exist log_dir = mock_os.path.dirname(log_file) if log_dir and not mock_os.path.exists(log_dir): mock_os.makedirs(log_dir, exist_ok=True) - # Add file handler file_handler = mock_logging.FileHandler(log_file) file_handler.setFormatter(mock_logging.Formatter("test-format")) mock_logging.getLogger().addHandler(file_handler) @@ -168,38 +479,28 @@ def setup_log_file(log_file, mock_os, mock_logging): mock_formatter = MagicMock() mock_logging.Formatter.return_value = mock_formatter - # Call our test function + # Execute and verify result = setup_log_file("/var/log/test_logs/ecs-mcp.log", mock_os, mock_logging) - # Verify that the function succeeded assert result is True - - # Verify that the log directory was created mock_os.makedirs.assert_called_once_with("/var/log/test_logs", exist_ok=True) - - # Verify that the log file handler was created and added to the logger mock_logging.FileHandler.assert_called_once_with("/var/log/test_logs/ecs-mcp.log") mock_file_handler.setFormatter.assert_called_once() mock_logging.getLogger.return_value.addHandler.assert_called_once_with(mock_file_handler) - - # Verify that the log success message was logged assert ( call("Logging to file: /var/log/test_logs/ecs-mcp.log") in mock_logging.info.call_args_list ) def test_log_file_setup_exception(): - """Test log file setup when an exception occurs.""" + """Legacy compatibility test for log file setup error handling.""" - # Create a test function that mimics the log file setup from main.py def setup_log_file(log_file, mock_os, mock_logging): try: - # Create directory for log file if it doesn't exist log_dir = mock_os.path.dirname(log_file) if log_dir and not mock_os.path.exists(log_dir): mock_os.makedirs(log_dir, exist_ok=True) - # Add file handler file_handler = mock_logging.FileHandler(log_file) file_handler.setFormatter(mock_logging.Formatter("test-format")) mock_logging.getLogger().addHandler(file_handler) @@ -209,7 +510,7 @@ def setup_log_file(log_file, mock_os, mock_logging): mock_logging.error(f"Failed to set up log file {log_file}: {e}") return False - # Setup mocks + # Setup mocks for error scenario mock_os = MagicMock() mock_os.path.dirname.return_value = "/var/log/test_logs" mock_os.path.exists.return_value = False @@ -217,104 +518,28 @@ def setup_log_file(log_file, mock_os, mock_logging): mock_logging = MagicMock() - # Call our test function + # Execute and verify error handling result = setup_log_file("/var/log/test_logs/ecs-mcp.log", mock_os, mock_logging) - # Verify that the function failed assert result is False - - # Verify that the error was logged mock_logging.error.assert_called_once_with( "Failed to set up log file /var/log/test_logs/ecs-mcp.log: Permission denied" ) -# ---------------------------------------------------------------------------- -# Main Function Tests -# ---------------------------------------------------------------------------- - - -@patch("awslabs.ecs_mcp_server.main.sys.exit") -@patch("awslabs.ecs_mcp_server.main.logger") -@patch("awslabs.ecs_mcp_server.main.mcp") -@patch("awslabs.ecs_mcp_server.main.config") -def test_main_function_success(mock_config, mock_mcp, mock_logger, mock_exit): - """Test main function with successful execution.""" - # Setup mocks - mock_config.get.side_effect = lambda key, default: True if key == "allow-write" else False - - # Call the main function - main() - - # Verify that the logger messages were called - mock_logger.info.assert_any_call("Server started") - mock_logger.info.assert_any_call("Write operations enabled: True") - mock_logger.info.assert_any_call("Sensitive data access enabled: False") - - # Verify that the mcp.run() method was called - mock_mcp.run.assert_called_once() - - # Verify that sys.exit was not called - mock_exit.assert_not_called() - - -@patch("awslabs.ecs_mcp_server.main.sys.exit") -@patch("awslabs.ecs_mcp_server.main.logger") -@patch("awslabs.ecs_mcp_server.main.mcp") -def test_main_function_keyboard_interrupt(mock_mcp, mock_logger, mock_exit): - """Test main function with KeyboardInterrupt exception.""" - # Setup mocks - mock_mcp.run.side_effect = KeyboardInterrupt() - - # Call the main function - main() - - # Verify that the logger messages were called - mock_logger.info.assert_any_call("Server stopped by user") - - # Verify that sys.exit was called with code 0 - mock_exit.assert_called_once_with(0) - - -@patch("awslabs.ecs_mcp_server.main.sys.exit") -@patch("awslabs.ecs_mcp_server.main.logger") -@patch("awslabs.ecs_mcp_server.main.mcp") -def test_main_function_general_exception(mock_mcp, mock_logger, mock_exit): - """Test main function with general exception.""" - # Setup mocks - mock_mcp.run.side_effect = Exception("Test error") - - # Call the main function - main() - - # Verify that the logger error was called with the exception - mock_logger.error.assert_called_once_with("Error starting server: Test error") - - # Verify that sys.exit was called with code 1 - mock_exit.assert_called_once_with(1) - - @patch("awslabs.ecs_mcp_server.main.main") def test_entry_point(mock_main): - """Test the module's entry point.""" - # Save the current value of __name__ + """Legacy compatibility test for module entry point.""" original_name = sys.modules.get("awslabs.ecs_mcp_server.main", None) try: - # Mock __name__ to trigger the entry point code sys.modules["awslabs.ecs_mcp_server.main"].__name__ = "__main__" - - # Instead of reading the file, we can directly simulate the entry point check - # that would exist in the main.py file namespace = {"__name__": "__main__", "main": mock_main} - # Simulate the standard entry point code: if __name__ == "__main__": main() if namespace["__name__"] == "__main__": namespace["main"]() - # Verify that main() was called mock_main.assert_called_once() finally: - # Restore the original value of __name__ if original_name: sys.modules["awslabs.ecs_mcp_server.main"].__name__ = original_name.__name__ diff --git a/src/ecs-mcp-server/uv.lock b/src/ecs-mcp-server/uv.lock index 1b5aefa098..7e274c344f 100644 --- a/src/ecs-mcp-server/uv.lock +++ b/src/ecs-mcp-server/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'", @@ -64,42 +64,52 @@ dependencies = [ { name = "gevent" }, { name = "jinja2" }, { name = "pydantic" }, - { name = "pyright" }, - { name = "pytest-cov" }, { name = "pyyaml" }, - { name = "ruff" }, ] -[package.optional-dependencies] +[package.dev-dependencies] dev = [ { name = "black" }, { name = "isort" }, { name = "mypy" }, + { name = "pyright" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ - { name = "black", marker = "extra == 'dev'", specifier = ">=23.3.0" }, { name = "boto3", specifier = ">=1.28.0" }, { name = "docker", specifier = ">=6.1.0" }, { name = "fastmcp", specifier = ">=2.11.1" }, { name = "gevent", specifier = ">=25.5.1" }, - { name = "isort", marker = "extra == 'dev'", specifier = ">=5.12.0" }, { name = "jinja2", specifier = ">=3.1.0" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.3.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pyyaml", specifier = ">=6.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "black", specifier = ">=23.3.0" }, + { name = "isort", specifier = ">=5.12.0" }, + { name = "mypy", specifier = ">=1.3.0" }, { name = "pyright", specifier = ">=1.1.401" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.3.1" }, + { name = "pytest", specifier = ">=7.3.1" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "pytest-cov", specifier = ">=6.1.1" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0.0" }, - { name = "pyyaml", specifier = ">=6.0.0" }, { name = "ruff", specifier = ">=0.11.11" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.0.272" }, ] -provides-extras = ["dev"] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] [[package]] name = "black" @@ -1352,6 +1362,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + [[package]] name = "pytest-cov" version = "6.1.1"