1616
1717import boto3
1818import httpx
19+ import json
1920import logging
2021from botocore .auth import SigV4Auth
2122from botocore .awsrequest import AWSRequest
2223from botocore .credentials import Credentials
24+ from functools import partial
2325from typing import Any , Dict , Generator , Optional
2426
2527
@@ -71,56 +73,6 @@ def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Re
7173 yield request
7274
7375
74- async def _handle_error_response (response : httpx .Response ) -> None :
75- """Event hook to handle HTTP error responses and extract details.
76-
77- This function is called for every HTTP response to check for errors
78- and provide more detailed error information when requests fail.
79-
80- Args:
81- response: The HTTP response object
82-
83- Raises:
84- No raises. let the mcp http client handle the errors.
85- """
86- if response .is_error :
87- # warning only because the SDK logs error
88- log_level = logging .WARNING
89- if (
90- # The server MAY respond 405 to GET (SSE) and DELETE (session).
91- response .status_code == 405 and response .request .method in ('GET' , 'DELETE' )
92- ) or (
93- # The server MAY terminate the session at any time, after which it MUST
94- # respond to requests containing that session ID with HTTP 404 Not Found.
95- response .status_code == 404 and response .request .method == 'POST'
96- ):
97- log_level = logging .DEBUG
98-
99- try :
100- # read the content and settle the response content. required to get body (.json(), .text)
101- await response .aread ()
102- except Exception as e :
103- logger .debug ('Failed to read response: %s' , e )
104- # do nothing and let the client and SDK handle the error
105- return
106-
107- # Try to extract error details with fallbacks
108- try :
109- # Try to parse JSON error details
110- error_details = response .json ()
111- logger .log (log_level , 'HTTP %d Error Details: %s' , response .status_code , error_details )
112- except Exception :
113- # If JSON parsing fails, use response text or status code
114- try :
115- response_text = response .text
116- logger .log (log_level , 'HTTP %d Error: %s' , response .status_code , response_text )
117- except Exception :
118- # Fallback to just status code and URL
119- logger .log (
120- log_level , 'HTTP %d Error for url %s' , response .status_code , response .url
121- )
122-
123-
12476def create_aws_session (profile : Optional [str ] = None ) -> boto3 .Session :
12577 """Create an AWS session with optional profile.
12678
@@ -150,42 +102,13 @@ def create_aws_session(profile: Optional[str] = None) -> boto3.Session:
150102 return session
151103
152104
153- def create_sigv4_auth (service : str , region : str , profile : Optional [str ] = None ) -> SigV4HTTPXAuth :
154- """Create SigV4 authentication for AWS requests.
155-
156- Args:
157- service: AWS service name for SigV4 signing
158- profile: AWS profile to use (optional)
159- region: AWS region (defaults to AWS_REGION env var or us-east-1)
160-
161- Returns:
162- SigV4HTTPXAuth instance
163-
164- Raises:
165- ValueError: If credentials cannot be obtained
166- """
167- # Create session and get credentials
168- session = create_aws_session (profile )
169- credentials = session .get_credentials ()
170-
171- # Create SigV4Auth with explicit credentials
172- sigv4_auth = SigV4HTTPXAuth (
173- credentials = credentials ,
174- service = service ,
175- region = region ,
176- )
177-
178- logger .info ("Created SigV4 authentication for service '%s' in region '%s'" , service , region )
179- return sigv4_auth
180-
181-
182105def create_sigv4_client (
183106 service : str ,
184107 region : str ,
185108 timeout : Optional [httpx .Timeout ] = None ,
186109 profile : Optional [str ] = None ,
187110 headers : Optional [Dict [str , str ]] = None ,
188- auth : Optional [httpx . Auth ] = None ,
111+ metadata : Optional [Dict [ str , Any ] ] = None ,
189112 ** kwargs : Any ,
190113) -> httpx .AsyncClient :
191114 """Create an httpx.AsyncClient with SigV4 authentication.
@@ -196,7 +119,7 @@ def create_sigv4_client(
196119 region: AWS region (optional, defaults to AWS_REGION env var or us-east-1)
197120 timeout: Timeout configuration for the HTTP client
198121 headers: Headers to include in requests
199- auth: Auth parameter (ignored as we provide our own)
122+ metadata: Metadata to inject into MCP _meta field
200123 **kwargs: Additional arguments to pass to httpx.AsyncClient
201124
202125 Returns:
@@ -220,14 +143,159 @@ def create_sigv4_client(
220143 'Creating httpx.AsyncClient with custom headers: %s' , client_kwargs .get ('headers' , {})
221144 )
222145
223- # Create SigV4 auth
224- sigv4_auth = create_sigv4_auth (service , region , profile )
225-
226- # Create the client with SigV4 auth and error handling event hook
227- logger .info ("Creating httpx.AsyncClient with SigV4 authentication for service '%s'" , service )
146+ logger .info ("Creating httpx.AsyncClient with SigV4 request hooks for service '%s'" , service )
228147
229148 return httpx .AsyncClient (
230- auth = sigv4_auth ,
231149 ** client_kwargs ,
232- event_hooks = {'response' : [_handle_error_response ]},
150+ event_hooks = {
151+ 'response' : [_handle_error_response ],
152+ 'request' : [
153+ partial (_inject_metadata_hook , metadata or {}),
154+ partial (_sign_request_hook , region , service , profile ),
155+ ],
156+ },
233157 )
158+
159+
160+ async def _handle_error_response (response : httpx .Response ) -> None :
161+ """Event hook to handle HTTP error responses and extract details.
162+
163+ This function is called for every HTTP response to check for errors
164+ and provide more detailed error information when requests fail.
165+
166+ Args:
167+ response: The HTTP response object
168+
169+ Raises:
170+ No raises. let the mcp http client handle the errors.
171+ """
172+ if response .is_error :
173+ # warning only because the SDK logs error
174+ log_level = logging .WARNING
175+ if (
176+ # The server MAY respond 405 to GET (SSE) and DELETE (session).
177+ response .status_code == 405 and response .request .method in ('GET' , 'DELETE' )
178+ ) or (
179+ # The server MAY terminate the session at any time, after which it MUST
180+ # respond to requests containing that session ID with HTTP 404 Not Found.
181+ response .status_code == 404 and response .request .method == 'POST'
182+ ):
183+ log_level = logging .DEBUG
184+
185+ try :
186+ # read the content and settle the response content. required to get body (.json(), .text)
187+ await response .aread ()
188+ except Exception as e :
189+ logger .debug ('Failed to read response: %s' , e )
190+ # do nothing and let the client and SDK handle the error
191+ return
192+
193+ # Try to extract error details with fallbacks
194+ try :
195+ # Try to parse JSON error details
196+ error_details = response .json ()
197+ logger .log (log_level , 'HTTP %d Error Details: %s' , response .status_code , error_details )
198+ except Exception :
199+ # If JSON parsing fails, use response text or status code
200+ try :
201+ response_text = response .text
202+ logger .log (log_level , 'HTTP %d Error: %s' , response .status_code , response_text )
203+ except Exception :
204+ # Fallback to just status code and URL
205+ logger .log (
206+ log_level , 'HTTP %d Error for url %s' , response .status_code , response .url
207+ )
208+
209+
210+ async def _sign_request_hook (
211+ region : str ,
212+ service : str ,
213+ profile : Optional [str ],
214+ request : httpx .Request ,
215+ ) -> None :
216+ """Request hook to sign HTTP requests with AWS SigV4.
217+
218+ This hook signs the request with AWS SigV4 credentials and adds signature headers.
219+
220+ This should be the last hook called to ensure the signature includes any modifications.
221+
222+ Args:
223+ region: AWS region for SigV4 signing
224+ service: AWS service name for SigV4 signing
225+ profile: AWS profile to use (optional)
226+ request: The HTTP request object to sign (modified in-place)
227+ """
228+ # Set Content-Length for signing
229+ request .headers ['Content-Length' ] = str (len (request .content ))
230+
231+ # Get AWS credentials
232+ session = create_aws_session (profile )
233+ credentials = session .get_credentials ()
234+ logger .info ('Signing request with credentials for access key: %s' , credentials .access_key )
235+
236+ # Create SigV4 auth and use its signing logic
237+ auth = SigV4HTTPXAuth (credentials , service , region )
238+
239+ # Call auth_flow to sign the request (it modifies request in-place)
240+ auth_flow = auth .auth_flow (request )
241+ next (auth_flow ) # Execute the generator to perform signing
242+
243+ logger .debug ('Request headers after signing: %s' , request .headers )
244+
245+
246+ async def _inject_metadata_hook (metadata : Dict [str , Any ], request : httpx .Request ) -> None :
247+ """Request hook to inject metadata into MCP calls.
248+
249+ Args:
250+ metadata: Dictionary of metadata to inject into _meta field
251+ request: The HTTP request object
252+ """
253+ logger .info ('=== Outgoing Request ===' )
254+ logger .info ('URL: %s' , request .url )
255+ logger .info ('Method: %s' , request .method )
256+
257+ # Try to inject metadata if it's a JSON-RPC/MCP request
258+ if request .content and metadata :
259+ try :
260+ # Parse the request body
261+ body = json .loads (await request .aread ())
262+
263+ # Check if it's a JSON-RPC request
264+ if isinstance (body , dict ) and 'jsonrpc' in body :
265+ # Ensure _meta exists in params
266+ if '_meta' not in body ['params' ]:
267+ body ['params' ]['_meta' ] = {}
268+
269+ # Get existing metadata
270+ existing_meta = body ['params' ]['_meta' ]
271+
272+ # Merge metadata (existing takes precedence)
273+ if isinstance (existing_meta , dict ):
274+ # Check for conflicting keys before merge
275+ conflicting_keys = set (metadata .keys ()) & set (existing_meta .keys ())
276+ if conflicting_keys :
277+ for key in conflicting_keys :
278+ logger .warning (
279+ 'Metadata key "%s" already exists in _meta. '
280+ 'Keeping existing value "%s", ignoring injected value "%s"' ,
281+ key ,
282+ existing_meta [key ],
283+ metadata [key ],
284+ )
285+ body ['params' ]['_meta' ] = {** metadata , ** existing_meta }
286+ else :
287+ logger .info ('Replacing non-dict _meta value with injected metadata' )
288+ body ['params' ]['_meta' ] = metadata
289+
290+ # Create new content with updated metadata
291+ new_content = json .dumps (body ).encode ('utf-8' )
292+
293+ # Update the request with new content
294+ request .stream = httpx .ByteStream (new_content )
295+ request ._content = new_content
296+
297+ logger .info ('Injected metadata into _meta: %s' , body ['params' ]['_meta' ])
298+
299+ except (json .JSONDecodeError , KeyError , TypeError ) as e :
300+ # Not a JSON request or invalid format, skip metadata injection
301+ logger .error ('Skipping metadata injection: %s' , e )
0 commit comments