Skip to content

Commit b52085f

Browse files
kyoncalKyon Calderaarangatang
authored andcommitted
Forward region via meta (aws#71)
* feat(sigv4_helper): inject AWS_REGION in _meta * Override the sigv4 signature when adding _meta. * feat(sigv4_helper): add region and service argument to _inject_metadata_hook to allow for proper resigning of sigv4 to work * feat(server.py): add forwarding region as optional argument * feat: replace forwarding region with metadata forwarding * refactor: move the hooks from sigv4_helper.py into a new folder and add tests * refactor(siv4_helper.py): move signing logic from client creation to an event hook * test(test_hooks.py): add assertions * refactor(sigv4_helper.py): remove hooks.py module and move hooks to sigv4_helper.py This refactor was needed in order to avoid a circular depdency, which resulted in a mid-module import. --------- Co-authored-by: Kyon Caldera <kyonc@amazon.com> Co-authored-by: Leonardo Araneda Freccero <araneda@amazon.com>
1 parent 5ac5998 commit b52085f

13 files changed

Lines changed: 1044 additions & 308 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ docker build -t mcp-proxy-for-aws .
8282
| `--service` | AWS service name for SigV4 signing | Inferred from endpoint if not provided |No |
8383
| `--profile` | AWS profile for AWS credentials to use | Uses `AWS_PROFILE` environment variable if not set |No |
8484
| `--region` | AWS region to use | Uses `AWS_REGION` environment variable if not set, defaults to `us-east-1` |No |
85+
| `--metadata` | Metadata to inject into MCP requests as key=value pairs (e.g., `--metadata KEY1=value1 KEY2=value2`) | `AWS_REGION` is automatically injected based on `--region` if not provided |No |
8586
| `--read-only` | Disable tools which may require write permissions (tools which DO NOT require write permissions are annotated with [`readOnlyHint=true`](https://modelcontextprotocol.io/specification/2025-06-18/schema#toolannotations-readonlyhint)) | `False` |No |
8687
| `--retries` | Configures number of retries done when calling upstream services, setting this to 0 disables retries. | 0 |No |
8788
| `--log-level` | Set the logging level (`DEBUG/INFO/WARNING/ERROR/CRITICAL`) | `INFO` |No |

mcp_proxy_for_aws/cli.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,43 @@
1818
import os
1919
from mcp_proxy_for_aws import __version__
2020
from mcp_proxy_for_aws.utils import within_range
21+
from typing import Any, Dict, Optional, Sequence
22+
23+
24+
class KeyValueAction(argparse.Action):
25+
"""Custom argparse action to parse key=value pairs into a dictionary."""
26+
27+
def __call__(
28+
self,
29+
parser: argparse.ArgumentParser,
30+
namespace: argparse.Namespace,
31+
values: str | Sequence[Any] | None,
32+
option_string: Optional[str] = None,
33+
) -> None:
34+
"""Parse key=value pairs into a dictionary.
35+
36+
Args:
37+
parser: The argument parser
38+
namespace: The namespace object to update
39+
values: The values to parse (list of key=value strings)
40+
option_string: The option string that triggered this action
41+
"""
42+
metadata: Dict[str, str] = {}
43+
# Ensure values is a sequence
44+
if values is None:
45+
# No values provided, set empty dict
46+
setattr(namespace, self.dest, metadata)
47+
return
48+
49+
if isinstance(values, str):
50+
values = [values]
51+
52+
for item in values:
53+
if '=' not in item:
54+
parser.error(f'Metadata must be in key=value format, got: {item}')
55+
key, value = item.split('=', 1)
56+
metadata[key] = value
57+
setattr(namespace, self.dest, metadata)
2158

2259

2360
def parse_args():
@@ -60,10 +97,18 @@ def parse_args():
6097

6198
parser.add_argument(
6299
'--region',
63-
help='AWS region to use (uses AWS_REGION environment variable if not provided, with final fallback to us-east-1)',
100+
help='AWS region to sign (uses AWS_REGION environment variable if not provided, with final fallback to us-east-1)',
64101
default=None,
65102
)
66103

104+
parser.add_argument(
105+
'--metadata',
106+
nargs='*',
107+
action=KeyValueAction,
108+
default=None,
109+
help='Metadata to inject into MCP requests as key=value pairs (e.g., --metadata AWS_REGION=us-west-2 KEY=VALUE)',
110+
)
111+
67112
parser.add_argument(
68113
'--read-only',
69114
action='store_true',

mcp_proxy_for_aws/server.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,22 @@ async def setup_mcp_mode(local_mcp: FastMCP, args) -> None:
5454
region = determine_aws_region(args.endpoint, args.region)
5555
logger.debug('Using region: %s', region)
5656

57+
# Build metadata dictionary - start with AWS_REGION, then merge user metadata
58+
metadata = {'AWS_REGION': region}
59+
if args.metadata:
60+
metadata.update(args.metadata)
61+
5762
# Get profile
5863
profile = args.profile
5964

6065
# Log server configuration
61-
logger.info('Using service: %s, region: %s, profile: %s', service, region, profile)
66+
logger.info(
67+
'Using service: %s, region: %s, metadata: %s, profile: %s',
68+
service,
69+
region,
70+
metadata,
71+
profile,
72+
)
6273
logger.info('Running in MCP mode')
6374

6475
timeout = httpx.Timeout(
@@ -69,7 +80,9 @@ async def setup_mcp_mode(local_mcp: FastMCP, args) -> None:
6980
)
7081

7182
# Create transport with SigV4 authentication
72-
transport = create_transport_with_sigv4(args.endpoint, service, region, timeout, profile)
83+
transport = create_transport_with_sigv4(
84+
args.endpoint, service, region, metadata, timeout, profile
85+
)
7386

7487
# Create proxy with the transport
7588
proxy = FastMCP.as_proxy(transport)

mcp_proxy_for_aws/sigv4_helper.py

Lines changed: 156 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@
1616

1717
import boto3
1818
import httpx
19+
import json
1920
import logging
2021
from botocore.auth import SigV4Auth
2122
from botocore.awsrequest import AWSRequest
2223
from botocore.credentials import Credentials
24+
from functools import partial
2325
from 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-
12476
def 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-
182105
def 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

Comments
 (0)