Skip to content

Commit 9e554ef

Browse files
fix(iam-mcp-server): inject ctx instead of requiring it
list_users, get_user, and create_user annotated ctx as CallToolResult, so the SDK treated it as a required argument and every call failed with "ctx Field required". Annotate ctx as the MCP Context so the server injects it.
1 parent 8abaec6 commit 9e554ef

2 files changed

Lines changed: 60 additions & 4 deletions

File tree

‎src/iam-mcp-server/awslabs/iam_mcp_server/server.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@
3939
UsersListResponse,
4040
)
4141
from loguru import logger
42+
from mcp.server.mcpserver import Context as McpContext
4243
from mcp.server.mcpserver import MCPServer
43-
from mcp.types import CallToolResult
4444
from pydantic import Field
4545
from typing import Any, Dict, List, Optional, Union
4646

@@ -271,7 +271,7 @@ def _require_confirmation(operation: str, details: str) -> None:
271271

272272
@mcp.tool()
273273
async def list_users(
274-
ctx: CallToolResult,
274+
ctx: McpContext,
275275
path_prefix: Optional[str] = Field(
276276
description='Path prefix to filter users (e.g., "/division_abc/")', default=None
277277
),
@@ -339,7 +339,7 @@ async def list_users(
339339

340340
@mcp.tool()
341341
async def get_user(
342-
ctx: CallToolResult, user_name: str = Field(description='The name of the IAM user to retrieve')
342+
ctx: McpContext, user_name: str = Field(description='The name of the IAM user to retrieve')
343343
) -> UserDetailsResponse:
344344
"""Get detailed information about a specific IAM user.
345345
@@ -427,7 +427,7 @@ async def get_user(
427427

428428
@mcp.tool()
429429
async def create_user(
430-
ctx: CallToolResult,
430+
ctx: McpContext,
431431
user_name: str = Field(description='The name of the new IAM user'),
432432
path: str = Field(description='The path for the user', default='/'),
433433
permissions_boundary: Optional[str] = Field(

‎src/iam-mcp-server/tests/test_server.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,62 @@ async def test_list_users_mock():
138138
assert result.is_truncated is False
139139

140140

141+
@pytest.mark.asyncio
142+
async def test_user_tools_do_not_require_ctx():
143+
"""Context is injected for list_users, get_user, and create_user, not a client argument."""
144+
from awslabs.iam_mcp_server.server import mcp
145+
from mcp.types import CallToolResult, TextContent
146+
147+
tools = {tool.name: tool for tool in await mcp.list_tools()}
148+
for name in ('list_users', 'get_user', 'create_user'):
149+
schema = tools[name].input_schema
150+
assert 'ctx' not in schema.get('properties', {})
151+
assert 'ctx' not in (schema.get('required') or [])
152+
153+
assert not tools['list_users'].input_schema.get('required')
154+
assert tools['get_user'].input_schema.get('required') == ['user_name']
155+
assert tools['create_user'].input_schema.get('required') == ['user_name']
156+
157+
created = datetime(2023, 1, 1)
158+
user = {
159+
'UserName': 'test-user',
160+
'UserId': 'AIDACKCEVSQ6C2EXAMPLE',
161+
'Arn': 'arn:aws:iam::123456789012:user/test-user',
162+
'Path': '/',
163+
'CreateDate': created,
164+
}
165+
mock_client = Mock()
166+
mock_client.list_users.return_value = {'Users': [user], 'IsTruncated': False}
167+
mock_client.get_user.return_value = {'User': user}
168+
mock_client.list_attached_user_policies.return_value = {'AttachedPolicies': []}
169+
mock_client.list_user_policies.return_value = {'PolicyNames': []}
170+
mock_client.list_groups_for_user.return_value = {'Groups': []}
171+
mock_client.list_access_keys.return_value = {'AccessKeyMetadata': []}
172+
mock_client.create_user.return_value = {'User': user}
173+
174+
Context.initialize(readonly=False, require_confirmation=False)
175+
try:
176+
with patch('awslabs.iam_mcp_server.server.get_iam_client', return_value=mock_client):
177+
listed = await mcp.call_tool('list_users', {})
178+
fetched = await mcp.call_tool('get_user', {'user_name': 'test-user'})
179+
created_user = await mcp.call_tool(
180+
'create_user', {'user_name': 'test-user', 'confirmed': True}
181+
)
182+
finally:
183+
Context.initialize()
184+
185+
for result in (listed, fetched, created_user):
186+
assert isinstance(result, CallToolResult)
187+
assert result.is_error is False
188+
text = result.content[0]
189+
assert isinstance(text, TextContent)
190+
assert 'Field required' not in text.text
191+
192+
mock_client.list_users.assert_called_once()
193+
mock_client.get_user.assert_called_once_with(UserName='test-user')
194+
mock_client.create_user.assert_called_once()
195+
196+
141197
@pytest.mark.asyncio
142198
async def test_create_user_readonly_mode():
143199
"""Test create_user function in readonly mode."""

0 commit comments

Comments
 (0)