Skip to content

Commit 332cd43

Browse files
committed
feat: Streamable HTTP transport client with SigV4 - Python implementation
1 parent 0e7b66e commit 332cd43

File tree

11 files changed

+953
-0
lines changed

11 files changed

+953
-0
lines changed

e2e_tests/python/main.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
from llm_client import LLMClient
1111
from server_clients.stdio_server import StdioServer
1212
from server_clients.lambda_function import LambdaFunctionClient
13+
from server_clients.lambda_function_url import (
14+
LambdaFunctionUrlClient,
15+
LambdaFunctionUrlConfig,
16+
)
1317

1418
# Configure logging
1519
logging.basicConfig(
@@ -83,6 +87,17 @@ async def main() -> None:
8387
for name, srv_config in server_config["lambdaFunctionServers"].items()
8488
]
8589
)
90+
91+
# Add Lambda function URL servers if they exist in config
92+
if "lambdaFunctionUrlServers" in server_config:
93+
servers.extend(
94+
[
95+
LambdaFunctionUrlClient(name, LambdaFunctionUrlConfig(**srv_config))
96+
for name, srv_config in server_config[
97+
"lambdaFunctionUrlServers"
98+
].items()
99+
]
100+
)
86101
llm_client = LLMClient(config.bedrock_client, config.model_id)
87102
user_utterances = [
88103
"Hello!",

e2e_tests/python/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
aiobotocore[boto3]==2.23.0
2+
boto3==1.38.27
23
mcp==1.10.1
34
uvicorn==0.35.0
45

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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+
)

e2e_tests/servers_config.integ.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@
1515
"region": "us-east-2"
1616
}
1717
},
18+
"lambdaFunctionUrls": {
19+
"mcpdoc": {
20+
"stackName": "LambdaMcpServer-Mcpdoc-INTEG_TEST_ID"
21+
},
22+
"cat-facts": {
23+
"stackName": "LambdaMcpServer-CatFacts-INTEG_TEST_ID"
24+
}
25+
},
1826
"oAuthServers": {
1927
"dog-facts": {
2028
"serverStackName": "LambdaMcpServer-DogFacts-INTEG_TEST_ID"

examples/chatbots/python/main.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
from llm_client import LLMClient
1111
from server_clients.stdio_server import StdioServer
1212
from server_clients.lambda_function import LambdaFunctionClient
13+
from server_clients.lambda_function_url import (
14+
LambdaFunctionUrlClient,
15+
LambdaFunctionUrlConfig,
16+
)
1317

1418
# Configure logging
1519
logging.basicConfig(
@@ -83,6 +87,17 @@ async def main() -> None:
8387
for name, srv_config in server_config["lambdaFunctionServers"].items()
8488
]
8589
)
90+
91+
# Initialize lambda function URL servers
92+
servers.extend(
93+
[
94+
LambdaFunctionUrlClient(name, LambdaFunctionUrlConfig(**srv_config))
95+
for name, srv_config in server_config.get(
96+
"lambdaFunctionUrls", {}
97+
).items()
98+
]
99+
)
100+
86101
llm_client = LLMClient(config.bedrock_client, config.model_id)
87102
chat_session = ChatSession(servers, llm_client)
88103
await chat_session.start()

0 commit comments

Comments
 (0)