Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## v1.2.0 (2026-01-08)

### Added

- New `aws_iam_streamable_http_client` function to replace deprecated `aws_iam_streamablehttp_client`

### Changed

- Updated minimum `fastmcp` version to 2.14.2 to support `streamable_http_client` function from mcp>=1.25.0

### Deprecated

- `aws_iam_streamablehttp_client` is now deprecated in favor of `aws_iam_streamable_http_client`
to align with upstream MCP package naming conventions. The old function will be removed in version 2.0.0.
- `sse_read_timeout` parameter in `aws_iam_streamable_http_client` is deprecated and will be removed in version 2.0.0

## v1.1.5 (2025-12-15)

### Fix
Expand Down
82 changes: 76 additions & 6 deletions mcp_proxy_for_aws/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@
# limitations under the License.

import boto3
import httpx
import logging
import warnings
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from botocore.credentials import Credentials
from contextlib import _AsyncGeneratorContextManager
from datetime import timedelta
from mcp.client.streamable_http import GetSessionIdCallback, streamablehttp_client
from mcp.client.streamable_http import GetSessionIdCallback, streamable_http_client
from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
from mcp.shared.message import SessionMessage
from mcp_proxy_for_aws.sigv4_helper import SigV4HTTPXAuth
Expand All @@ -28,7 +30,7 @@
logger = logging.getLogger(__name__)


def aws_iam_streamablehttp_client(
def aws_iam_streamable_http_client(
Copy link
Contributor

@DennisTraub DennisTraub Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we change the API to match the upstream naming convention, we should also match the signature. It wouldn't make much sense to introduce a breaking change just to keep naming consistent while letting the signature drift.

async def aws_iam_streamable_http_client(
    # Specific to the IAM client, replacing upstream's `url: str`
    endpoint: str,
    aws_service: str,
    aws_region: str | None = None,
    aws_profile: str | None = None,
    credentials: Credentials | None = None,
    # Remaining upstream parameters following the new pattern
    *,
    http_client: httpx.AsyncClient | None = None,
    terminate_on_close: bool = True,
) -> AsyncGenerator[
    tuple[
        MemoryObjectReceiveStream[SessionMessage | Exception],
        MemoryObjectSendStream[SessionMessage],
        GetSessionIdCallback,
    ],
    None,
]:
    ...

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense. Thanks for point this out. i match the signature with the upstream in this commit 24f4bcc

endpoint: str,
aws_service: str,
aws_region: Optional[str] = None,
Expand Down Expand Up @@ -60,7 +62,7 @@ def aws_iam_streamablehttp_client(
credentials: Optional AWS credentials from boto3/botocore. If provided, takes precedence over aws_profile.
headers: Optional additional HTTP headers to include in requests.
timeout: Request timeout in seconds or timedelta object. Defaults to 30 seconds.
sse_read_timeout: Server-sent events read timeout in seconds or timedelta object.
sse_read_timeout: Deprecated. This parameter is no longer used and will be removed in version 2.0.0.
terminate_on_close: Whether to terminate the connection on close.
httpx_client_factory: Factory function for creating HTTPX clients.

Expand All @@ -71,7 +73,7 @@ def aws_iam_streamablehttp_client(
- get_session_id: Callback function to retrieve the current session ID

Example:
async with aws_iam_mcp_client(
async with aws_iam_streamable_http_client(
endpoint="https://example.com/mcp",
aws_service="bedrock-agentcore",
aws_region="us-west-2"
Expand All @@ -81,6 +83,13 @@ def aws_iam_streamablehttp_client(
"""
logger.debug('Preparing AWS IAM MCP client for endpoint: %s', endpoint)

# Warn if sse_read_timeout is set to a non-default value
if sse_read_timeout != 60 * 5:
logger.warning(
'sse_read_timeout parameter is deprecated and will be removed in version 2.0.0. '
'The value is ignored in the current implementation.'
)

if credentials is not None:
creds = credentials
region = aws_region
Expand Down Expand Up @@ -113,13 +122,74 @@ def aws_iam_streamablehttp_client(
# Create a SigV4 authentication handler with AWS credentials
auth = SigV4HTTPXAuth(creds, aws_service, region)

# Convert timeout to httpx.Timeout if it's a number or timedelta
httpx_timeout = None
if timeout is not None:
if isinstance(timeout, (int, float)):
httpx_timeout = httpx.Timeout(timeout)
elif isinstance(timeout, timedelta):
httpx_timeout = httpx.Timeout(timeout.total_seconds())
else:
httpx_timeout = timeout

# Create HTTP client using the factory with authentication and custom headers
http_client = httpx_client_factory(
auth=auth,
timeout=httpx_timeout,
headers=headers,
)

# Return the streamable HTTP client context manager with AWS IAM authentication
return streamablehttp_client(
return streamable_http_client(
url=endpoint,
http_client=http_client,
terminate_on_close=terminate_on_close,
)


def aws_iam_streamablehttp_client(
endpoint: str,
aws_service: str,
aws_region: Optional[str] = None,
aws_profile: Optional[str] = None,
credentials: Optional[Credentials] = None,
headers: Optional[dict[str, str]] = None,
timeout: float | timedelta = 30,
sse_read_timeout: float | timedelta = 60 * 5,
terminate_on_close: bool = True,
httpx_client_factory: McpHttpClientFactory = create_mcp_http_client,
) -> _AsyncGeneratorContextManager[
tuple[
MemoryObjectReceiveStream[SessionMessage | Exception],
MemoryObjectSendStream[SessionMessage],
GetSessionIdCallback,
],
None,
]:
"""Create an AWS IAM-authenticated MCP streamable HTTP client.

.. deprecated:: 1.2.0
Use :func:`aws_iam_streamable_http_client` instead.
This function will be removed in version 2.0.0.

This is a deprecated alias for aws_iam_streamable_http_client.
Please update your code to use aws_iam_streamable_http_client instead.
"""
warnings.warn(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest using the @deprecated decorator:

from typing_extensions import deprecated

...

@deprecated("Use `aws_iam_streamable_http_client` instead.")
def aws_iam_streamablehttp_client(
    ...

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree. updated in this commit ya 24f4bcc

"aws_iam_streamablehttp_client is deprecated and will be removed in version 2.0.0. "
"Use aws_iam_streamable_http_client instead.",
DeprecationWarning,
stacklevel=2,
)
return aws_iam_streamable_http_client(
endpoint=endpoint,
aws_service=aws_service,
aws_region=aws_region,
aws_profile=aws_profile,
credentials=credentials,
headers=headers,
timeout=timeout,
sse_read_timeout=sse_read_timeout,
terminate_on_close=terminate_on_close,
httpx_client_factory=httpx_client_factory,
auth=auth,
)
1 change: 1 addition & 0 deletions mcp_proxy_for_aws/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def client_factory(
headers: Optional[Dict[str, str]] = None,
timeout: Optional[httpx.Timeout] = None,
auth: Optional[httpx.Auth] = None,
**kwargs, # Accept additional parameters from fastmcp (e.g., follow_redirects)
) -> httpx.AsyncClient:
return create_sigv4_client(
service=service,
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ members = [
name = "mcp-proxy-for-aws"

# NOTE: "Patch"=9223372036854775807 bumps next release to zero.
version = "1.1.5"
version = "1.2.0"

description = "MCP Proxy for AWS"
readme = "README.md"
requires-python = ">=3.10,<3.14"
dependencies = [
"fastmcp (>=2.13.1,<2.14.1)",
"fastmcp>=2.14.2",
"boto3>=1.41.0",
"botocore[crt]>=1.41.0",
]
Expand Down
5 changes: 4 additions & 1 deletion tests/integ/mcp/simple_mcp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,11 @@ def _build_mcp_config(endpoint: str, region_name: str, metadata: Optional[Dict[s
'AWS_REGION': region_name,
'AWS_ACCESS_KEY_ID': credentials.access_key,
'AWS_SECRET_ACCESS_KEY': credentials.secret_key,
'AWS_SESSION_TOKEN': credentials.token,
}

# Only include AWS_SESSION_TOKEN if it's not None (e.g., for temporary credentials)
if credentials.token:
environment_variables['AWS_SESSION_TOKEN'] = credentials.token

args = _build_args(endpoint, region_name, metadata)

Expand Down
66 changes: 49 additions & 17 deletions tests/unit/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ async def test_boto3_session_parameters(
mock_read, mock_write, mock_get_session = mock_streams

with patch('boto3.Session', return_value=mock_session) as mock_boto:
with patch('mcp_proxy_for_aws.client.streamablehttp_client') as mock_stream_client:
with patch('mcp_proxy_for_aws.client.streamable_http_client') as mock_stream_client:
mock_stream_client.return_value.__aenter__ = AsyncMock(
return_value=(mock_read, mock_write, mock_get_session)
)
Expand Down Expand Up @@ -94,9 +94,14 @@ async def test_sigv4_auth_is_created_and_used(mock_session, mock_streams, servic

with patch('boto3.Session', return_value=mock_session):
with patch('mcp_proxy_for_aws.client.SigV4HTTPXAuth') as mock_auth_cls:
with patch('mcp_proxy_for_aws.client.streamablehttp_client') as mock_stream_client:
with patch('mcp_proxy_for_aws.client.streamable_http_client') as mock_stream_client:
mock_auth = Mock()
mock_auth_cls.return_value = mock_auth

# Mock the factory to capture its calls
mock_http_client = Mock()
mock_factory = Mock(return_value=mock_http_client)

mock_stream_client.return_value.__aenter__ = AsyncMock(
return_value=(mock_read, mock_write, mock_get_session)
)
Expand All @@ -106,17 +111,22 @@ async def test_sigv4_auth_is_created_and_used(mock_session, mock_streams, servic
endpoint='https://test.example.com/mcp',
aws_service=service_name,
aws_region=region,
httpx_client_factory=mock_factory,
):
pass

mock_auth_cls.assert_called_once_with(
# Auth should be constructed with the resolved credentials, service, and region,
# and passed into the streamable client.
# and passed to the httpx client factory.
mock_session.get_credentials.return_value,
service_name,
region,
)
assert mock_stream_client.call_args[1]['auth'] is mock_auth
# Check that factory was called with auth
assert mock_factory.called
assert mock_factory.call_args[1]['auth'] is mock_auth
# Check that http_client was passed to streamable_http_client
assert mock_stream_client.call_args[1]['http_client'] is mock_http_client


@pytest.mark.asyncio
Expand All @@ -137,7 +147,10 @@ async def test_streamable_client_parameters(
mock_read, mock_write, mock_get_session = mock_streams

with patch('boto3.Session', return_value=mock_session):
with patch('mcp_proxy_for_aws.client.streamablehttp_client') as mock_stream_client:
with patch('mcp_proxy_for_aws.client.streamable_http_client') as mock_stream_client:
mock_http_client = Mock()
mock_factory = Mock(return_value=mock_http_client)

mock_stream_client.return_value.__aenter__ = AsyncMock(
return_value=(mock_read, mock_write, mock_get_session)
)
Expand All @@ -150,16 +163,30 @@ async def test_streamable_client_parameters(
timeout=timeout_value,
sse_read_timeout=sse_value,
terminate_on_close=terminate_value,
httpx_client_factory=mock_factory,
):
pass

call_kwargs = mock_stream_client.call_args[1]
# Confirm each parameter is forwarded unchanged.
assert call_kwargs['url'] == 'https://test.example.com/mcp'
assert call_kwargs['headers'] == headers
assert call_kwargs['timeout'] == timeout_value
assert call_kwargs['sse_read_timeout'] == sse_value
assert call_kwargs['terminate_on_close'] == terminate_value
# Check that factory was called with headers and timeout
assert mock_factory.called
factory_kwargs = mock_factory.call_args[1]
assert factory_kwargs['headers'] == headers
# Check timeout conversion
if isinstance(timeout_value, timedelta):
expected_timeout = timeout_value.total_seconds()
else:
expected_timeout = timeout_value
# httpx.Timeout sets all timeout types (connect, read, write, pool) to the same value
assert factory_kwargs['timeout'].connect == expected_timeout
assert factory_kwargs['timeout'].read == expected_timeout
assert factory_kwargs['timeout'].write == expected_timeout
assert factory_kwargs['timeout'].pool == expected_timeout

# Check streamable_http_client was called correctly
stream_kwargs = mock_stream_client.call_args[1]
assert stream_kwargs['url'] == 'https://test.example.com/mcp'
assert stream_kwargs['http_client'] is mock_http_client
assert stream_kwargs['terminate_on_close'] == terminate_value


@pytest.mark.asyncio
Expand All @@ -170,7 +197,9 @@ async def test_custom_httpx_client_factory_is_passed(mock_session, mock_streams)
custom_factory = Mock()

with patch('boto3.Session', return_value=mock_session):
with patch('mcp_proxy_for_aws.client.streamablehttp_client') as mock_stream_client:
with patch('mcp_proxy_for_aws.client.streamable_http_client') as mock_stream_client:
mock_http_client = Mock()
custom_factory.return_value = mock_http_client
mock_stream_client.return_value.__aenter__ = AsyncMock(
return_value=(mock_read, mock_write, mock_get_session)
)
Expand All @@ -183,7 +212,10 @@ async def test_custom_httpx_client_factory_is_passed(mock_session, mock_streams)
):
pass

assert mock_stream_client.call_args[1]['httpx_client_factory'] is custom_factory
# Check that the custom factory was called
assert custom_factory.called
# Check that the http_client from custom factory was passed to streamable_http_client
assert mock_stream_client.call_args[1]['http_client'] is mock_http_client


@pytest.mark.asyncio
Expand All @@ -198,7 +230,7 @@ async def mock_aexit(*_):
cleanup_called = True

with patch('boto3.Session', return_value=mock_session):
with patch('mcp_proxy_for_aws.client.streamablehttp_client') as mock_stream_client:
with patch('mcp_proxy_for_aws.client.streamable_http_client') as mock_stream_client:
mock_stream_client.return_value.__aenter__ = AsyncMock(
return_value=(mock_read, mock_write, mock_get_session)
)
Expand All @@ -220,7 +252,7 @@ async def test_credentials_parameter_with_region(mock_streams):
creds = Credentials('test_key', 'test_secret', 'test_token')

with patch('mcp_proxy_for_aws.client.SigV4HTTPXAuth') as mock_auth_cls:
with patch('mcp_proxy_for_aws.client.streamablehttp_client') as mock_stream_client:
with patch('mcp_proxy_for_aws.client.streamable_http_client') as mock_stream_client:
mock_auth = Mock()
mock_auth_cls.return_value = mock_auth
mock_stream_client.return_value.__aenter__ = AsyncMock(
Expand Down Expand Up @@ -264,7 +296,7 @@ async def test_credentials_parameter_bypasses_boto3_session(mock_streams):

with patch('boto3.Session') as mock_boto:
with patch('mcp_proxy_for_aws.client.SigV4HTTPXAuth'):
with patch('mcp_proxy_for_aws.client.streamablehttp_client') as mock_stream_client:
with patch('mcp_proxy_for_aws.client.streamable_http_client') as mock_stream_client:
mock_stream_client.return_value.__aenter__ = AsyncMock(
return_value=(mock_read, mock_write, mock_get_session)
)
Expand Down
Loading