|
| 1 | +""" |
| 2 | +Lambda Function URL client for MCP servers running behind Lambda function URLs. |
| 3 | +
|
| 4 | +This client uses AWS SigV4 authentication to communicate with MCP servers |
| 5 | +deployed as Lambda functions with function URLs enabled. |
| 6 | +""" |
| 7 | + |
| 8 | +import logging |
| 9 | +from datetime import timedelta |
| 10 | +from typing import Optional |
| 11 | + |
| 12 | +import boto3 |
| 13 | +from botocore.exceptions import ClientError |
| 14 | + |
| 15 | +from mcp.client.session import ClientSession |
| 16 | +from mcp_lambda.client.streamable_http_sigv4 import streamablehttp_client_with_sigv4 |
| 17 | + |
| 18 | +from server_clients.server import Server |
| 19 | + |
| 20 | + |
| 21 | +class LambdaFunctionUrlConfig: |
| 22 | + """Configuration for LambdaFunctionUrlClient.""" |
| 23 | + |
| 24 | + def __init__( |
| 25 | + self, |
| 26 | + function_url: Optional[str] = None, |
| 27 | + stack_name: Optional[str] = None, |
| 28 | + stack_url_output_key: str = "FunctionUrl", |
| 29 | + region: str = "us-east-2", |
| 30 | + **kwargs, |
| 31 | + ): |
| 32 | + # Handle camelCase parameter names from JSON config |
| 33 | + self.function_url = kwargs.get("functionUrl", function_url) |
| 34 | + self.stack_name = kwargs.get("stackName", stack_name) |
| 35 | + self.stack_url_output_key = kwargs.get( |
| 36 | + "stackUrlOutputKey", stack_url_output_key |
| 37 | + ) |
| 38 | + self.region = kwargs.get("region", region) |
| 39 | + |
| 40 | + |
| 41 | +class LambdaFunctionUrlClient(Server): |
| 42 | + """ |
| 43 | + Manages MCP server connections and tool execution for servers running behind |
| 44 | + Lambda function URLs with AWS SigV4 authentication. |
| 45 | +
|
| 46 | + This client can lookup the function URL from a CloudFormation stack output |
| 47 | + instead of requiring the user to statically configure the URL. |
| 48 | + """ |
| 49 | + |
| 50 | + def __init__(self, name: str, config: LambdaFunctionUrlConfig): |
| 51 | + # Convert config to dict for base class |
| 52 | + config_dict = { |
| 53 | + "function_url": config.function_url, |
| 54 | + "stack_name": config.stack_name, |
| 55 | + "stack_url_output_key": config.stack_url_output_key, |
| 56 | + "region": config.region, |
| 57 | + } |
| 58 | + super().__init__(name, config_dict) |
| 59 | + |
| 60 | + if not config.function_url and not config.stack_name: |
| 61 | + raise ValueError( |
| 62 | + "Either function_url must be provided or stack_name must be provided for CloudFormation lookup" |
| 63 | + ) |
| 64 | + |
| 65 | + if config.function_url and config.stack_name: |
| 66 | + raise ValueError("Only one of function_url or stack_name can be provided") |
| 67 | + |
| 68 | + self.lambda_config = config |
| 69 | + self._transport_context = None |
| 70 | + self._session_context = None |
| 71 | + self._streams = None |
| 72 | + |
| 73 | + async def initialize(self) -> None: |
| 74 | + """Initialize the server connection with AWS SigV4 authentication.""" |
| 75 | + try: |
| 76 | + # Determine the function URL |
| 77 | + function_url = self.lambda_config.function_url |
| 78 | + if self.lambda_config.stack_name: |
| 79 | + logging.debug("Retrieving function URL from CloudFormation...") |
| 80 | + function_url = await self._get_function_url_from_cloudformation() |
| 81 | + # Update the config with the resolved URL |
| 82 | + self.config["function_url"] = function_url |
| 83 | + self.lambda_config.function_url = function_url |
| 84 | + |
| 85 | + if not function_url: |
| 86 | + raise ValueError( |
| 87 | + "The function_url must be a valid string and cannot be undefined." |
| 88 | + ) |
| 89 | + |
| 90 | + logging.debug(f"Connecting to Lambda function URL: {function_url}") |
| 91 | + |
| 92 | + session = boto3.Session() |
| 93 | + credentials = session.get_credentials() |
| 94 | + if not credentials: |
| 95 | + raise ValueError( |
| 96 | + "AWS credentials not found. Please configure your AWS credentials." |
| 97 | + ) |
| 98 | + |
| 99 | + logging.debug("Creating transport with SigV4 authentication...") |
| 100 | + self._transport_context = streamablehttp_client_with_sigv4( |
| 101 | + url=function_url, |
| 102 | + credentials=credentials, |
| 103 | + service="lambda", |
| 104 | + region=self.lambda_config.region, |
| 105 | + timeout=timedelta(seconds=60), |
| 106 | + ) |
| 107 | + |
| 108 | + # Enter the transport context |
| 109 | + self._streams = await self._transport_context.__aenter__() |
| 110 | + read_stream, write_stream, get_session_id = self._streams |
| 111 | + |
| 112 | + logging.debug("Creating MCP session...") |
| 113 | + self._session_context = ClientSession(read_stream, write_stream) |
| 114 | + self.session = await self._session_context.__aenter__() |
| 115 | + |
| 116 | + logging.debug("Initializing MCP session...") |
| 117 | + await self.session.initialize() |
| 118 | + logging.debug("MCP session initialized successfully") |
| 119 | + |
| 120 | + except Exception as error: |
| 121 | + logging.error( |
| 122 | + f"Error initializing Lambda function URL client {self.name}: {error}" |
| 123 | + ) |
| 124 | + raise error |
| 125 | + |
| 126 | + async def __aexit__(self, exc_type, exc_val, exc_tb): |
| 127 | + """Async context manager exit with proper cleanup.""" |
| 128 | + try: |
| 129 | + if self._session_context: |
| 130 | + await self._session_context.__aexit__(exc_type, exc_val, exc_tb) |
| 131 | + self._session_context = None |
| 132 | + self.session = None |
| 133 | + |
| 134 | + if self._transport_context: |
| 135 | + await self._transport_context.__aexit__(exc_type, exc_val, exc_tb) |
| 136 | + self._transport_context = None |
| 137 | + self._streams = None |
| 138 | + except Exception as e: |
| 139 | + logging.error(f"Error during cleanup: {e}") |
| 140 | + |
| 141 | + # Call parent cleanup |
| 142 | + await super().__aexit__(exc_type, exc_val, exc_tb) |
| 143 | + |
| 144 | + async def _get_function_url_from_cloudformation(self) -> str: |
| 145 | + """Retrieve the Lambda function URL from CloudFormation stack outputs.""" |
| 146 | + try: |
| 147 | + logging.debug( |
| 148 | + f"Retrieving function URL from CloudFormation stack: {self.lambda_config.stack_name}" |
| 149 | + ) |
| 150 | + |
| 151 | + # Create CloudFormation client |
| 152 | + session = boto3.Session() |
| 153 | + cf_client = session.client( |
| 154 | + "cloudformation", region_name=self.lambda_config.region |
| 155 | + ) |
| 156 | + |
| 157 | + response = cf_client.describe_stacks( |
| 158 | + StackName=self.lambda_config.stack_name |
| 159 | + ) |
| 160 | + |
| 161 | + if not response.get("Stacks"): |
| 162 | + raise ValueError( |
| 163 | + f"CloudFormation stack '{self.lambda_config.stack_name}' not found" |
| 164 | + ) |
| 165 | + |
| 166 | + stack = response["Stacks"][0] |
| 167 | + if not stack.get("Outputs"): |
| 168 | + raise ValueError( |
| 169 | + f"No outputs found in CloudFormation stack '{self.lambda_config.stack_name}'" |
| 170 | + ) |
| 171 | + |
| 172 | + function_url_output = next( |
| 173 | + ( |
| 174 | + output |
| 175 | + for output in stack["Outputs"] |
| 176 | + if output["OutputKey"] == self.lambda_config.stack_url_output_key |
| 177 | + ), |
| 178 | + None, |
| 179 | + ) |
| 180 | + |
| 181 | + if not function_url_output or not function_url_output.get("OutputValue"): |
| 182 | + raise ValueError( |
| 183 | + f"Function URL output not found in CloudFormation stack. Output key: {self.lambda_config.stack_url_output_key}" |
| 184 | + ) |
| 185 | + |
| 186 | + function_url = function_url_output["OutputValue"] |
| 187 | + logging.debug(f"Retrieved function URL: {function_url}") |
| 188 | + return function_url |
| 189 | + |
| 190 | + except ClientError as error: |
| 191 | + error_code = error.response["Error"]["Code"] |
| 192 | + if error_code == "ValidationException": |
| 193 | + raise ValueError( |
| 194 | + f"CloudFormation stack '{self.lambda_config.stack_name}' does not exist or is not accessible" |
| 195 | + ) |
| 196 | + elif error_code in ["AccessDenied", "UnauthorizedOperation"]: |
| 197 | + raise ValueError( |
| 198 | + f"Insufficient permissions to access CloudFormation stack '{self.lambda_config.stack_name}'. " |
| 199 | + "Ensure your AWS credentials have cloudformation:DescribeStacks permission." |
| 200 | + ) |
| 201 | + else: |
| 202 | + raise ValueError( |
| 203 | + f"Could not retrieve function URL from CloudFormation stack {self.lambda_config.stack_name}: {error}" |
| 204 | + ) |
| 205 | + |
| 206 | + except Exception as error: |
| 207 | + logging.error( |
| 208 | + f"Failed to retrieve function URL from CloudFormation:", error |
| 209 | + ) |
| 210 | + raise ValueError( |
| 211 | + f"Could not retrieve function URL from CloudFormation stack {self.lambda_config.stack_name}: {error}" |
| 212 | + ) |
0 commit comments