Skip to content

Commit 589ac55

Browse files
mwaisaida-13mwaisaida-13
andauthored
feat(elasticache-mcp-server): connection_type for serverless public endpoints (#4867)
* feat(elasticache-mcp-server): connection_type for serverless public endpoints Amazon ElastiCache Serverless for Valkey 9.0+ supports a 'public' connection type: an internet-reachable endpoint with IAM authentication over TLS 1.3 and no VPC, subnets, or security groups. - create-serverless-cache: new optional connection_type ('vpc' default or 'public'), validated by the request model and forwarded as ConnectionType - Jump-host tools (create/connect/get-ssh-tunnel-command) return a clear error for caches with a public endpoint and point to the Valkey MCP server - boto3 floor raised to 1.43.105, the first release with ConnectionType - README tool list and prerequisites, CHANGELOG, unit tests * fix(elasticache-mcp-server): address review feedback - README: document the elasticache:ConnectionType condition key for denying public cache creation instead of restating the boto3 floor - Jump-host guard: point at the Valkey MCP server connection guide instead of a single env var that is not sufficient on its own - create docstring: major_engine_version '9' or later --------- Co-authored-by: mwaisaida-13 <aidams@amazon.com>
1 parent c4e3f59 commit 589ac55

9 files changed

Lines changed: 302 additions & 15 deletions

File tree

‎src/elasticache-mcp-server/CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- `connection_type` parameter on `create-serverless-cache` to create ElastiCache Serverless caches with a public endpoint (`'public'`) or a VPC endpoint (`'vpc'`, default).
13+
- Jump-host tools now return a clear error for caches with a public endpoint, since those are reached directly over the internet with IAM authentication.
1214
- Initial project setup

‎src/elasticache-mcp-server/README.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ The official MCP Server for interacting with AWS ElastiCache control plane. In o
55
## Available MCP Tools
66

77
### Serverless Cache Operations
8-
- `create-serverless-cache` - Create a new ElastiCache serverless cache
8+
- `create-serverless-cache` - Create a new ElastiCache serverless cache. Pass `connection_type='public'` to create a Valkey 9+ cache with a public endpoint that is reachable over the internet without a VPC (IAM authentication over TLS 1.3, IAM user group required); the default `'vpc'` creates a VPC endpoint
99
- `delete-serverless-cache` - Delete a serverless cache
1010
- `describe-serverless-caches` - Get information about serverless caches
1111
- `modify-serverless-cache` - Modify settings of a serverless cache
12-
- `connect-jump-host-serverless-cache` - Configure an EC2 instance as a jump host for serverless cache access
13-
- `create-jump-host-serverless-cache` - Create an EC2 jump host to access a serverless cache via SSH tunnel
14-
- `get-ssh-tunnel-command-serverless-cache` - Generate SSH tunnel command for serverless cache access
12+
- `connect-jump-host-serverless-cache` - Configure an EC2 instance as a jump host for access to a serverless cache with a VPC endpoint
13+
- `create-jump-host-serverless-cache` - Create an EC2 jump host to access a serverless cache with a VPC endpoint via SSH tunnel
14+
- `get-ssh-tunnel-command-serverless-cache` - Generate SSH tunnel command for a serverless cache with a VPC endpoint
15+
16+
The jump-host tools apply only to caches with a VPC endpoint. For a cache with a public endpoint they return an error, because the cache is reached directly over the internet with IAM authentication.
1517

1618
### Replication Group Operations
1719
- `create-replication-group` - Create an Amazon ElastiCache replication group with specified configuration
@@ -73,6 +75,7 @@ All tools support an optional `region_name` parameter to specify which AWS regio
7375
2. Install Python using `uv python install 3.10`
7476
3. Set up AWS credentials with access to AWS services
7577
- Consider setting up Read-only permission if you don't want the LLM to modify any resources
78+
- To keep the LLM from creating caches with a public endpoint, deny `elasticache:CreateServerlessCache` when the `elasticache:ConnectionType` condition key is `public`. See [Using condition keys](https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/IAM.ConditionKeys.html#IAM.ExamplePolicies).
7679

7780
## Installation
7881

‎src/elasticache-mcp-server/awslabs/elasticache_mcp_server/tools/serverless/connect.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,29 @@
2222
from typing import Any, Dict, Optional, Tuple, Union
2323

2424

25+
def _ensure_vpc_cache(serverless_cache: Dict[str, Any], serverless_cache_name: str) -> None:
26+
"""Reject jump-host operations for serverless caches with ConnectionType 'public'.
27+
28+
Public-endpoint caches have no VPC endpoint, subnets, or security groups, so jump hosts
29+
and SSH tunnels do not apply. A missing ConnectionType field is treated as 'vpc'.
30+
31+
Args:
32+
serverless_cache (Dict[str, Any]): A ServerlessCache entry from describe_serverless_caches
33+
serverless_cache_name (str): Name of the cache, used in the error message
34+
35+
Raises:
36+
ValueError: If the cache has a public endpoint
37+
"""
38+
if serverless_cache.get('ConnectionType') == 'public':
39+
endpoint = serverless_cache.get('Endpoint', {}).get('Address', '<endpoint>')
40+
raise ValueError(
41+
f'Serverless cache {serverless_cache_name} has a public endpoint '
42+
f'(ConnectionType=public), so a jump host or SSH tunnel is not needed and cannot '
43+
f'be configured. Connect directly to {endpoint} over TLS 1.3 using IAM '
44+
'authentication; see the ElastiCache connection guide of the Valkey MCP server.'
45+
)
46+
47+
2548
async def _configure_security_groups(
2649
serverless_cache_name: str,
2750
instance_id: str,
@@ -51,6 +74,7 @@ async def _configure_security_groups(
5174
serverless_cache = elasticache_client.describe_serverless_caches(
5275
ServerlessCacheName=serverless_cache_name
5376
)['ServerlessCaches'][0]
77+
_ensure_vpc_cache(serverless_cache, serverless_cache_name)
5478

5579
# Get cache security groups
5680
cache_security_groups = serverless_cache['SecurityGroupIds']
@@ -148,6 +172,8 @@ async def connect_jump_host_serverless(
148172
) -> Dict[str, Any]:
149173
"""Configures an existing EC2 instance as a jump host to access an ElastiCache serverless cache.
150174
175+
Not applicable to serverless caches with a public endpoint; returns an error for them.
176+
151177
Args:
152178
serverless_cache_name (str): Name of the ElastiCache serverless cache to connect to
153179
instance_id (str): ID of the EC2 instance to use as jump host
@@ -192,6 +218,8 @@ async def get_ssh_tunnel_command_serverless(
192218
) -> Dict[str, Union[str, int]]:
193219
"""Generates an SSH tunnel command to connect to an ElastiCache serverless cache through an EC2 jump host.
194220
221+
Not applicable to serverless caches with a public endpoint; returns an error for them.
222+
195223
Args:
196224
serverless_cache_name (str): Name of the ElastiCache serverless cache to connect to
197225
instance_id (str): ID of the EC2 instance to use as jump host
@@ -235,6 +263,7 @@ async def get_ssh_tunnel_command_serverless(
235263
serverless_cache = elasticache_client.describe_serverless_caches(
236264
ServerlessCacheName=serverless_cache_name
237265
)['ServerlessCaches'][0]
266+
_ensure_vpc_cache(serverless_cache, serverless_cache_name)
238267

239268
# Get cache endpoint and port
240269
cache_endpoint = serverless_cache['Endpoint']['Address']
@@ -281,6 +310,8 @@ async def create_jump_host_serverless(
281310
) -> Dict[str, Any]:
282311
"""Creates an EC2 jump host instance to access an ElastiCache serverless cache via SSH tunnel.
283312
313+
Not applicable to serverless caches with a public endpoint; returns an error for them.
314+
284315
Args:
285316
serverless_cache_name (str): Name of the ElastiCache serverless cache to connect to
286317
key_name (str): Name of the EC2 key pair to use for SSH access
@@ -324,6 +355,7 @@ async def create_jump_host_serverless(
324355
serverless_cache = elasticache_client.describe_serverless_caches(
325356
ServerlessCacheName=serverless_cache_name
326357
)['ServerlessCaches'][0]
358+
_ensure_vpc_cache(serverless_cache, serverless_cache_name)
327359

328360
# Get cache security groups
329361
cache_security_groups = serverless_cache['SecurityGroupIds']

‎src/elasticache-mcp-server/awslabs/elasticache_mcp_server/tools/serverless/create.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ async def create_serverless_cache(request: CreateServerlessCacheRequest) -> Dict
7070
user_group_id (Optional[str]): ID of the user group to associate with the cache.
7171
snapshot_retention_limit (Optional[int]): Number of days for which ElastiCache retains automatic snapshots.
7272
daily_snapshot_time (Optional[str]): Time range (in UTC) when daily snapshots are taken (e.g., '04:00-05:00').
73+
connection_type (Optional[str]): 'vpc' (default) or 'public'. For 'public', pass engine='valkey',
74+
major_engine_version='9' or later, and user_group_id; omit subnet_ids and security_group_ids.
7375
7476
Returns:
7577
Dict containing information about the created serverless cache.
@@ -113,6 +115,7 @@ async def create_serverless_cache(request: CreateServerlessCacheRequest) -> Dict
113115
('MajorEngineVersion', request.major_engine_version),
114116
('UserGroupId', request.user_group_id),
115117
('DailySnapshotTime', request.daily_snapshot_time),
118+
('ConnectionType', request.connection_type),
116119
]:
117120
if value:
118121
create_request[param_name] = str(value)

‎src/elasticache-mcp-server/awslabs/elasticache_mcp_server/tools/serverless/models.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,18 @@ class CreateServerlessCacheRequest(BaseModel):
103103
None,
104104
description="Time range (in UTC) when daily snapshots are taken (e.g., '04:00-05:00')",
105105
)
106+
connection_type: Optional[str] = Field(
107+
None,
108+
description=(
109+
'The connection type for the serverless cache. Must be either vpc | public. Use vpc '
110+
'to access the cache through a VPC endpoint, or public to access the cache over the '
111+
'internet. If not specified, defaults to vpc. This value cannot be changed after the '
112+
'serverless cache is created. Setting this to public requires Valkey 9 or above, a '
113+
'user group in which every user uses IAM authentication (e.g. '
114+
'default.iam-user-group), and clients that connect with IAM authentication over '
115+
'TLS 1.3.'
116+
),
117+
)
106118

107119
@field_validator('daily_snapshot_time')
108120
def validate_snapshot_time(cls, v):
@@ -114,6 +126,13 @@ def validate_snapshot_time(cls, v):
114126
raise ValueError('Invalid time range format. Must be in format HH:MM-HH:MM')
115127
return v
116128

129+
@field_validator('connection_type')
130+
def validate_connection_type(cls, v):
131+
"""Validate that connection_type is 'vpc' or 'public'."""
132+
if v is not None and v not in ('vpc', 'public'):
133+
raise ValueError("connection_type must be 'vpc' or 'public'")
134+
return v
135+
117136
model_config = ConfigDict(validate_by_name=True, arbitrary_types_allowed=True)
118137

119138

‎src/elasticache-mcp-server/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ dependencies = [
88
"loguru>=0.7.0",
99
"mcp[cli]>=2.0.0,<3.0.0",
1010
"pydantic>=2.10.6",
11-
"boto3>=1.34.0",
11+
"boto3>=1.43.105",
1212
]
1313
license = {text = "Apache-2.0"}
1414
license-files = ["LICENSE", "NOTICE" ]

‎src/elasticache-mcp-server/tests/tools/serverless/test_connect.py‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,3 +794,145 @@ async def test_create_jump_host_serverless_default_vpc_map_public_ip():
794794
assert result['SubnetId'] == 'subnet-1234'
795795
assert result['SecurityGroupId'] == 'sg-1234'
796796
assert result['ServerlessCacheName'] == 'cache-1'
797+
798+
799+
PUBLIC_CACHE = {
800+
'ServerlessCacheName': 'public-cache',
801+
'ConnectionType': 'public',
802+
'Engine': 'valkey',
803+
'MajorEngineVersion': '9',
804+
'Endpoint': {
805+
'Address': 'public-cache-x2e9hv.public.serverless.use1.cache.amazonaws.com',
806+
'Port': 6379,
807+
},
808+
}
809+
810+
811+
@pytest.mark.asyncio
812+
async def test_configure_security_groups_rejects_public_cache():
813+
"""A public-endpoint cache is rejected before any security group work."""
814+
mock_ec2 = MagicMock()
815+
mock_elasticache = MagicMock()
816+
mock_elasticache.describe_serverless_caches.return_value = {'ServerlessCaches': [PUBLIC_CACHE]}
817+
818+
with pytest.raises(ValueError) as exc_info:
819+
await _configure_security_groups('public-cache', 'i-1234', mock_ec2, mock_elasticache)
820+
821+
assert 'public endpoint' in str(exc_info.value)
822+
assert PUBLIC_CACHE['Endpoint']['Address'] in str(exc_info.value)
823+
mock_ec2.describe_subnets.assert_not_called()
824+
mock_ec2.authorize_security_group_ingress.assert_not_called()
825+
826+
827+
@pytest.mark.asyncio
828+
async def test_connect_jump_host_serverless_rejects_public_cache():
829+
"""connect-jump-host returns an error for a public-endpoint cache."""
830+
mock_ec2 = MagicMock()
831+
mock_elasticache = MagicMock()
832+
mock_elasticache.describe_serverless_caches.return_value = {'ServerlessCaches': [PUBLIC_CACHE]}
833+
834+
with (
835+
patch(
836+
'awslabs.elasticache_mcp_server.common.connection.EC2ConnectionManager.get_connection',
837+
return_value=mock_ec2,
838+
),
839+
patch(
840+
'awslabs.elasticache_mcp_server.common.connection.ElastiCacheConnectionManager.get_connection',
841+
return_value=mock_elasticache,
842+
),
843+
):
844+
result = await connect_jump_host_serverless('public-cache', 'i-1234')
845+
846+
assert 'error' in result
847+
assert 'public endpoint' in result['error']
848+
mock_ec2.authorize_security_group_ingress.assert_not_called()
849+
850+
851+
@pytest.mark.asyncio
852+
async def test_get_ssh_tunnel_command_serverless_rejects_public_cache():
853+
"""get-ssh-tunnel-command returns an error for a public-endpoint cache."""
854+
mock_ec2 = MagicMock()
855+
mock_elasticache = MagicMock()
856+
mock_ec2.describe_instances.return_value = {
857+
'Reservations': [
858+
{
859+
'Instances': [
860+
{
861+
'KeyName': 'my-key',
862+
'PublicDnsName': 'ec2-1-2-3-4.compute-1.amazonaws.com',
863+
'Platform': '',
864+
}
865+
]
866+
}
867+
]
868+
}
869+
mock_elasticache.describe_serverless_caches.return_value = {'ServerlessCaches': [PUBLIC_CACHE]}
870+
871+
with (
872+
patch(
873+
'awslabs.elasticache_mcp_server.common.connection.EC2ConnectionManager.get_connection',
874+
return_value=mock_ec2,
875+
),
876+
patch(
877+
'awslabs.elasticache_mcp_server.common.connection.ElastiCacheConnectionManager.get_connection',
878+
return_value=mock_elasticache,
879+
),
880+
):
881+
result = await get_ssh_tunnel_command_serverless('public-cache', 'i-1234')
882+
883+
assert 'error' in result
884+
assert 'public endpoint' in result['error']
885+
assert 'command' not in result
886+
887+
888+
@pytest.mark.asyncio
889+
async def test_create_jump_host_serverless_rejects_public_cache_before_launch():
890+
"""create-jump-host returns an error for a public-endpoint cache and launches nothing."""
891+
mock_ec2 = MagicMock()
892+
mock_elasticache = MagicMock()
893+
mock_ec2.describe_key_pairs.return_value = {'KeyPairs': [{'KeyName': 'my-key'}]}
894+
mock_elasticache.describe_serverless_caches.return_value = {'ServerlessCaches': [PUBLIC_CACHE]}
895+
896+
with (
897+
patch(
898+
'awslabs.elasticache_mcp_server.common.connection.EC2ConnectionManager.get_connection',
899+
return_value=mock_ec2,
900+
),
901+
patch(
902+
'awslabs.elasticache_mcp_server.common.connection.ElastiCacheConnectionManager.get_connection',
903+
return_value=mock_elasticache,
904+
),
905+
):
906+
result = await create_jump_host_serverless('public-cache', 'my-key')
907+
908+
assert 'error' in result
909+
assert 'public endpoint' in result['error']
910+
mock_ec2.run_instances.assert_not_called()
911+
mock_ec2.describe_subnets.assert_not_called()
912+
913+
914+
@pytest.mark.asyncio
915+
async def test_configure_security_groups_vpc_cache_without_connection_type_field():
916+
"""Caches created before ConnectionType existed are treated as VPC caches."""
917+
mock_ec2 = MagicMock()
918+
mock_elasticache = MagicMock()
919+
mock_elasticache.describe_serverless_caches.return_value = {
920+
'ServerlessCaches': [
921+
{'SecurityGroupIds': ['sg-cache'], 'SubnetIds': ['subnet-1234'], 'Engine': 'valkey'}
922+
]
923+
}
924+
mock_ec2.describe_subnets.return_value = {'Subnets': [{'VpcId': 'vpc-1234'}]}
925+
mock_ec2.describe_instances.return_value = {
926+
'Reservations': [
927+
{'Instances': [{'VpcId': 'vpc-1234', 'SecurityGroups': [{'GroupId': 'sg-instance'}]}]}
928+
]
929+
}
930+
mock_ec2.describe_security_groups.return_value = {'SecurityGroups': [{'IpPermissions': []}]}
931+
932+
success, vpc_id, port = await _configure_security_groups(
933+
'cache-1', 'i-1234', mock_ec2, mock_elasticache
934+
)
935+
936+
assert success is True
937+
assert vpc_id == 'vpc-1234'
938+
assert port == 6379

0 commit comments

Comments
 (0)