Skip to content

Commit 9aa02ea

Browse files
refactor: rename aws_profile to proxy_aws_profile and add AWS_MCP_PROXY_PROFILES env var
- Renamed injected tool parameter from aws_profile to proxy_aws_profile to avoid potential collision with backend tool parameters (EKS MCP, ECS MCP, customer-hosted servers use the same proxy) - Added AWS_MCP_PROXY_PROFILES env var support as alternative to --profile flag. Enables plugin integration where CLI args cannot be modified. Format: space-separated profile names, first is default. - --profile flag takes precedence over env var when both are set - Documented env var in README - Added unit tests for env var parsing and precedence
1 parent f724c67 commit 9aa02ea

6 files changed

Lines changed: 174 additions & 36 deletions

File tree

README.md

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ docker build -t mcp-proxy-for-aws .
9797
| `endpoint` | MCP endpoint URL (e.g., `https://your-service.us-east-1.amazonaws.com/mcp`) | N/A |Yes |
9898
| --- | --- | --- |--- |
9999
| `--service` | AWS service name for SigV4 signing, if omitted we try to infer this from the url | Inferred from endpoint if not provided |No |
100-
| `--profile` | AWS profile(s) to use. First profile is the default. Additional profiles enable per-call switching via `aws_profile` tool parameter (e.g., `--profile default dev staging`) | Uses `AWS_PROFILE` environment variable if not set |No |
100+
| `--profile` | AWS profile(s) to use. First profile is the default. Additional profiles enable per-call switching across accounts (e.g., `--profile default dev staging`) | Falls back to `AWS_MCP_PROXY_PROFILES` env var, then default credential chain |No |
101101
| `--region` | AWS region to use | Uses `AWS_REGION` environment variable if not set |No |
102102
| `--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 |
103103
| `--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 |
@@ -125,6 +125,10 @@ export AWS_SESSION_TOKEN=<session_token>
125125

126126
# AWS Region
127127
export AWS_REGION=<aws_region>
128+
129+
# Multi-profile switching (alternative to --profile flag)
130+
# Space-separated list: first is default, rest are switchable
131+
export AWS_MCP_PROXY_PROFILES="default dev staging"
128132
```
129133

130134
### Setup Examples
@@ -167,12 +171,7 @@ Add the following configuration to your MCP client config file (e.g., for Kiro C
167171
168172
#### Multi-account access
169173

170-
When multiple profiles are passed to `--profile`, individual tool calls can route through different AWS profiles without restarting the proxy. This is useful when an AI agent needs to query resources across multiple AWS accounts in a single session.
171-
172-
**How it works:**
173-
- The first profile is the **default** identity used when a tool call does not specify a profile.
174-
- Additional profiles are available for per-call switching via the `aws_profile` tool parameter. Each profile gets its own dedicated connection to the backend.
175-
- If a tool call omits `aws_profile`, the default profile connection is used. If it includes `aws_profile`, the request is routed through the matching per-profile connection instead.
174+
When multiple profiles are passed to `--profile`, the agent can route individual tool calls through different AWS accounts without restarting the proxy.
176175

177176
```json
178177
{
@@ -194,7 +193,31 @@ When multiple profiles are passed to `--profile`, individual tool calls can rout
194193
}
195194
```
196195

197-
In the example above, tool calls without an `aws_profile` argument use the `default` profile. A tool call that includes `"aws_profile": "dev-profile"` is routed through a dedicated connection signed with `dev-profile` credentials.
196+
**How it works:**
197+
- The first profile (`default`) is used for all calls unless the agent specifies otherwise.
198+
- The agent can switch to any additional profile (`dev-profile`, `staging-profile`) on a per-call basis. Each profile gets its own dedicated connection.
199+
- Only profiles in the list are accessible — other profiles in `~/.aws/config` are not exposed.
200+
- With a single profile (e.g., `--profile default`), no switching is available and behavior is unchanged from previous versions.
201+
202+
**Using an environment variable:**
203+
204+
As an alternative to `--profile`, set the `AWS_MCP_PROXY_PROFILES` environment variable:
205+
206+
```bash
207+
export AWS_MCP_PROXY_PROFILES="default dev-profile staging-profile"
208+
```
209+
210+
Or in your MCP configuration's `env` block:
211+
212+
```json
213+
{
214+
"env": {
215+
"AWS_MCP_PROXY_PROFILES": "default dev-profile staging-profile"
216+
}
217+
}
218+
```
219+
220+
The `--profile` flag takes precedence over the environment variable if both are set.
198221

199222
#### Using Docker
200223

mcp_proxy_for_aws/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def parse_args():
9494
nargs='+',
9595
dest='profiles',
9696
help='AWS profile(s) to use. First profile is the default. '
97-
'Additional profiles enable per-call switching via aws_profile tool parameter '
97+
'Additional profiles enable per-call switching via proxy_aws_profile tool parameter '
9898
'(e.g., --profile default dev staging)',
9999
default=[os.getenv('AWS_PROFILE')] if os.getenv('AWS_PROFILE') else None,
100100
metavar='PROFILE',

mcp_proxy_for_aws/middleware/profile_switcher.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
"""Middleware that enables per-call AWS profile overrides via an ``aws_profile`` argument.
15+
"""Middleware that enables per-call AWS profile overrides via a ``proxy_aws_profile`` argument.
1616
17-
Pass ``aws_profile`` as an extra argument on any tool call to route that single request
17+
Pass ``proxy_aws_profile`` as an extra argument on any tool call to route that single request
1818
through a dedicated transport signed with the specified profile's credentials. The
1919
argument is stripped before forwarding to the backend.
2020
@@ -41,12 +41,12 @@
4141

4242

4343
class ProfileOverrideMiddleware(Middleware):
44-
"""Middleware that intercepts ``aws_profile`` on any tool call for per-request AWS identity switching.
44+
"""Middleware that intercepts ``proxy_aws_profile`` on any tool call for per-request AWS identity switching.
4545
46-
When a tool call includes an ``aws_profile`` argument, the middleware:
46+
When a tool call includes a ``proxy_aws_profile`` argument, the middleware:
4747
4848
1. Validates the profile against the allowed list
49-
2. Strips ``aws_profile`` from the arguments
49+
2. Strips ``proxy_aws_profile`` from the arguments
5050
3. Forwards the call through a dedicated per-profile MCP client
5151
5252
Each profile gets its own transport and session to the backend so that
@@ -81,7 +81,7 @@ async def on_list_tools(
8181
context: MiddlewareContext[mt.ListToolsRequest],
8282
call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]],
8383
) -> Sequence[Tool]:
84-
"""Inject ``aws_profile`` into every tool's schema."""
84+
"""Inject ``proxy_aws_profile`` into every tool's schema."""
8585
tools = await call_next(context)
8686

8787
for tool in tools:
@@ -91,13 +91,13 @@ async def on_list_tools(
9191
params = copy.deepcopy(tool.parameters)
9292
if 'properties' not in params:
9393
params['properties'] = {}
94-
if 'aws_profile' in params['properties']:
94+
if 'proxy_aws_profile' in params['properties']:
9595
logger.warning(
96-
'Tool %r already defines an "aws_profile" parameter; '
96+
'Tool %r already defines a "proxy_aws_profile" parameter; '
9797
'the middleware override is shadowing the backend definition.',
9898
tool.name,
9999
)
100-
params['properties']['aws_profile'] = {
100+
params['properties']['proxy_aws_profile'] = {
101101
'type': 'string',
102102
'description': (
103103
'AWS CLI profile to sign this request with. Omit to use the default profile.'
@@ -116,10 +116,10 @@ async def on_call_tool(
116116
context: MiddlewareContext[mt.CallToolRequestParams],
117117
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
118118
) -> ToolResult:
119-
"""Intercept ``aws_profile`` and route through a dedicated per-profile client."""
119+
"""Intercept ``proxy_aws_profile`` and route through a dedicated per-profile client."""
120120
arguments = context.message.arguments
121-
if isinstance(arguments, dict) and 'aws_profile' in arguments:
122-
profile = arguments['aws_profile']
121+
if isinstance(arguments, dict) and 'proxy_aws_profile' in arguments:
122+
profile = arguments['proxy_aws_profile']
123123
return await self._call_with_profile(profile, context, call_next)
124124

125125
return await call_next(context)
@@ -171,9 +171,9 @@ async def _call_with_profile(
171171
f'Profile {profile!r} is not in the allowed list. Allowed profiles: {allowed}'
172172
)
173173

174-
# Strip aws_profile before forwarding to the backend
174+
# Strip proxy_aws_profile before forwarding to the backend
175175
arguments: dict[str, Any] = dict(cast(dict[str, Any], context.message.arguments))
176-
arguments.pop('aws_profile', None)
176+
arguments.pop('proxy_aws_profile', None)
177177

178178
logger.info(
179179
'Per-call profile override: routing through dedicated connection for %s', profile

mcp_proxy_for_aws/server.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import asyncio
2626
import httpx
2727
import logging
28+
import os
2829
from fastmcp.server.middleware.error_handling import RetryMiddleware
2930
from fastmcp.server.middleware.logging import LoggingMiddleware
3031
from fastmcp.server.providers.proxy import FastMCPProxy
@@ -64,8 +65,12 @@ async def run_proxy(args) -> None:
6465
if args.metadata:
6566
metadata.update(args.metadata)
6667

67-
# Get profile(s)
68+
# Get profile(s) from CLI args or env var
6869
profiles: list[str] = args.profiles if args.profiles else []
70+
if not profiles:
71+
env_profiles = os.environ.get('AWS_MCP_PROXY_PROFILES')
72+
if env_profiles:
73+
profiles = env_profiles.split()
6974
default_profile = profiles[0] if profiles else None
7075
# Dedup switch profiles and exclude the default profile
7176
switch_profiles = list(dict.fromkeys(p for p in profiles[1:] if p != default_profile))

tests/unit/test_profile_switcher.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@ class TestOnListTools:
5252
"""Tests for the on_list_tools method."""
5353

5454
@pytest.mark.asyncio
55-
async def test_injects_aws_profile_property_into_tool_schemas(self, middleware, mock_context):
56-
"""Every proxied tool gets an aws_profile property in its schema."""
55+
async def test_injects_proxy_aws_profile_property_into_tool_schemas(
56+
self, middleware, mock_context
57+
):
58+
"""Every proxied tool gets an proxy_aws_profile property in its schema."""
5759
tool = Mock()
5860
tool.name = 'some_tool'
5961
tool.parameters = {'type': 'object', 'properties': {'arg': {'type': 'string'}}}
@@ -63,7 +65,7 @@ async def test_injects_aws_profile_property_into_tool_schemas(self, middleware,
6365

6466
assert len(result) == 1
6567
assert result[0].name == 'some_tool'
66-
profile_schema = result[0].parameters['properties']['aws_profile']
68+
profile_schema = result[0].parameters['properties']['proxy_aws_profile']
6769
assert profile_schema['type'] == 'string'
6870
assert 'AWS CLI profile' in profile_schema['description']
6971
assert profile_schema['enum'] == sorted(ALLOWED_PROFILES)
@@ -102,15 +104,15 @@ async def test_adds_properties_key_when_missing(self, middleware, mock_context):
102104
result = await middleware.on_list_tools(mock_context, call_next)
103105

104106
assert 'properties' in result[0].parameters
105-
assert 'aws_profile' in result[0].parameters['properties']
107+
assert 'proxy_aws_profile' in result[0].parameters['properties']
106108

107109

108110
class TestOnCallTool:
109111
"""Tests for the on_call_tool method."""
110112

111113
@pytest.mark.asyncio
112-
async def test_passes_through_calls_without_aws_profile(self, middleware, mock_context):
113-
"""Tool calls without aws_profile are forwarded unchanged."""
114+
async def test_passes_through_calls_without_proxy_aws_profile(self, middleware, mock_context):
115+
"""Tool calls without proxy_aws_profile are forwarded unchanged."""
114116
mock_context.message = Mock()
115117
mock_context.message.name = 'some_tool'
116118
mock_context.message.arguments = {'arg': 'value'}
@@ -145,7 +147,7 @@ async def test_profile_override_disallowed(self, middleware, mock_context):
145147
"""Disallowed profile raises ToolError."""
146148
mock_context.message = Mock()
147149
mock_context.message.name = 'some_tool'
148-
mock_context.message.arguments = {'arg': 'value', 'aws_profile': 'evil-profile'}
150+
mock_context.message.arguments = {'arg': 'value', 'proxy_aws_profile': 'evil-profile'}
149151
call_next = AsyncMock()
150152

151153
with pytest.raises(ToolError, match='not in the allowed list'):
@@ -154,8 +156,8 @@ async def test_profile_override_disallowed(self, middleware, mock_context):
154156
call_next.assert_not_called()
155157

156158
@pytest.mark.asyncio
157-
async def test_profile_override_strips_aws_profile_arg(self, middleware, mock_context):
158-
"""aws_profile is stripped before forwarding to the backend."""
159+
async def test_profile_override_strips_proxy_aws_profile_arg(self, middleware, mock_context):
160+
"""proxy_aws_profile is stripped before forwarding to the backend."""
159161
mock_client = AsyncMock()
160162
mock_call_result = MagicMock()
161163
mock_call_result.content = 'result'
@@ -165,7 +167,7 @@ async def test_profile_override_strips_aws_profile_arg(self, middleware, mock_co
165167

166168
mock_context.message = Mock()
167169
mock_context.message.name = 'some_tool'
168-
mock_context.message.arguments = {'arg': 'value', 'aws_profile': 'dev-profile'}
170+
mock_context.message.arguments = {'arg': 'value', 'proxy_aws_profile': 'dev-profile'}
169171
call_next = AsyncMock()
170172

171173
with patch.object(middleware, '_get_profile_client', return_value=mock_client):
@@ -179,7 +181,7 @@ async def test_profile_override_connection_failure(self, middleware, mock_contex
179181
"""Connection failure raises ToolError with sanitized message."""
180182
mock_context.message = Mock()
181183
mock_context.message.name = 'some_tool'
182-
mock_context.message.arguments = {'arg': 'value', 'aws_profile': 'dev-profile'}
184+
mock_context.message.arguments = {'arg': 'value', 'proxy_aws_profile': 'dev-profile'}
183185
call_next = AsyncMock()
184186

185187
with patch.object(
@@ -198,7 +200,7 @@ async def test_profile_override_tool_call_failure(self, middleware, mock_context
198200

199201
mock_context.message = Mock()
200202
mock_context.message.name = 'some_tool'
201-
mock_context.message.arguments = {'arg': 'value', 'aws_profile': 'dev-profile'}
203+
mock_context.message.arguments = {'arg': 'value', 'proxy_aws_profile': 'dev-profile'}
202204
call_next = AsyncMock()
203205

204206
with patch.object(middleware, '_get_profile_client', return_value=mock_client):

0 commit comments

Comments
 (0)