Skip to content

Commit 88ab7c3

Browse files
committed
test: add integration tests for AWS MCP
1 parent aa601be commit 88ab7c3

File tree

7 files changed

+286
-10
lines changed

7 files changed

+286
-10
lines changed

examples/mcp-client/langchain/main.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,6 @@
2626
- MCP_REGION: AWS region where the MCP server is hosted (e.g., "us-west-2")
2727
3. Run: `uv run main.py`
2828
29-
Example .env file:
30-
==================
31-
MCP_SERVER_URL=https://example.gateway.bedrock-agentcore.us-west-2.amazonaws.com/mcp
32-
MCP_SERVER_AWS_SERVICE=bedrock-agentcore
33-
MCP_SERVER_REGION=us-west-2
34-
3529
Example .env file:
3630
==================
3731
MCP_SERVER_URL=https://example.gateway.bedrock-agentcore.us-west-2.amazonaws.com/mcp

mcp_proxy_for_aws/middleware/initialize_middleware.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010

1111
class InitializeMiddleware(Middleware):
12-
"""Intecept MCP initialize request and initialize the proxy client."""
12+
"""Intercept MCP initialize request and initialize the proxy client."""
1313

1414
def __init__(self, client_factory: AWSMCPProxyClientFactory) -> None:
1515
"""Create a middleware with client factory."""

tests/integ/conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,15 @@ def _build_endpoint_environment_remote_configuration():
9090
endpoint=remote_endpoint_url,
9191
region_name=region_name,
9292
)
93+
94+
95+
@pytest_asyncio.fixture(loop_scope="module", scope="module")
96+
async def aws_mcp_client():
97+
"""Create MCP Client for AWS MCP Server."""
98+
client = build_mcp_client(
99+
endpoint="https://aws-mcp.us-east-1.api.aws/mcp",
100+
region_name="us-east-1",
101+
)
102+
103+
async with client:
104+
yield client

tests/integ/mcp/simple_mcp_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def build_mcp_client(
2727
**_build_mcp_config(endpoint=endpoint, region_name=region_name, metadata=metadata)
2828
),
2929
elicitation_handler=_basic_elicitation_handler,
30-
timeout=10.0, # seconds
30+
timeout=20.0, # seconds
3131
)
3232

3333

tests/integ/test_aws_mcp_server.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Integration tests for AWS MCP Server at https://aws-mcp.us-east-1.api.aws/mcp."""
2+
3+
import fastmcp
4+
import logging
5+
import pytest
6+
from fastmcp.client.client import CallToolResult
7+
8+
from tests.integ.test_proxy_simple_mcp_server import get_text_content
9+
import json
10+
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
@pytest.mark.asyncio(loop_scope="module")
16+
async def test_aws_mcp_ping(aws_mcp_client: fastmcp.Client):
17+
"""Test ping to AWS MCP Server."""
18+
await aws_mcp_client.ping()
19+
20+
@pytest.mark.asyncio(loop_scope="module")
21+
async def test_aws_mcp_list_tools(aws_mcp_client: fastmcp.Client):
22+
"""Test list tools from AWS MCP Server."""
23+
tools = await aws_mcp_client.list_tools()
24+
25+
assert len(tools) > 0, f"AWS MCP Server should have tools (got {len(tools)})"
26+
27+
28+
def verify_response_content(response: CallToolResult):
29+
"""Verify that a tool call response is successful and contains text content.
30+
31+
Args:
32+
response: The CallToolResult from an MCP tool call
33+
34+
Returns:
35+
str: The extracted text content from the response
36+
37+
Raises:
38+
AssertionError: If response indicates an error or has empty content
39+
"""
40+
assert (
41+
response.is_error is False
42+
), f"is_error returned true. Returned response body: {response}."
43+
assert len(response.content) > 0, f"Empty result list in response. Response: {response}"
44+
45+
response_text = get_text_content(response)
46+
assert len(response_text) > 0, f"Empty response text. Response: {response}"
47+
48+
return response_text
49+
50+
def verify_json_response(response: CallToolResult):
51+
"""Verify that a tool call response is successful and contains valid JSON data.
52+
53+
Args:
54+
response: The CallToolResult from an MCP tool call
55+
56+
Raises:
57+
AssertionError: If response indicates an error, has empty content,
58+
contains invalid JSON, or JSON data is empty
59+
"""
60+
response_text = verify_response_content(response)
61+
62+
# Verify response_text is valid JSON
63+
try:
64+
response_data = json.loads(response_text)
65+
except json.JSONDecodeError:
66+
raise AssertionError(f"Response text is not valid JSON. Response text: {response_text}")
67+
68+
assert len(response_data) > 0, f"Empty JSON content in response. Response: {response}"
69+
70+
71+
@pytest.mark.parametrize(
72+
"tool_name,params",
73+
[
74+
("aws___list_regions", {}),
75+
("aws___suggest_aws_commands", {"query": "how to list my lambda functions"}),
76+
("aws___search_documentation", {"search_phrase": "S3 bucket versioning"}),
77+
(
78+
"aws___recommend",
79+
{"url": "bad_url"},
80+
),
81+
(
82+
"aws___read_documentation",
83+
{"url": "https://docs.aws.amazon.com/lambda/latest/dg/lambda-invocation.html"},
84+
),
85+
(
86+
"aws___get_regional_availability",
87+
{"resource_type": "cfn", "region": "us-east-1"},
88+
),
89+
("aws___call_aws", {"cli_command": "aws s3 ls", "max_results": 50}),
90+
],
91+
ids=[
92+
"list_regions",
93+
"suggest_aws_commands",
94+
"search_documentation",
95+
"recommend",
96+
"read_documentation",
97+
"get_regional_availability",
98+
"call_aws",
99+
],
100+
)
101+
@pytest.mark.asyncio(loop_scope="module")
102+
async def test_aws_mcp_tools(aws_mcp_client: fastmcp.Client, tool_name: str, params: dict):
103+
"""Test AWS MCP tools with minimal valid params."""
104+
response = await aws_mcp_client.call_tool(tool_name, params)
105+
verify_json_response(response)
106+
107+
108+
@pytest.mark.asyncio(loop_scope="module")
109+
async def test_aws_mcp_tools_retrieve_agent_sop(aws_mcp_client: fastmcp.Client):
110+
"""Test aws___retrieve_agent_sop by retrieving the list of available SOPs."""
111+
112+
# Step 1: Call retrieve_agent_sop with empty params to get list of available SOPs
113+
list_sops_response = await aws_mcp_client.call_tool("aws___retrieve_agent_sop")
114+
115+
list_sops_response_text = verify_response_content(list_sops_response)
116+
117+
# Parse SOP names from text (format: "* sop_name : description")
118+
sop_names = []
119+
for line in list_sops_response_text.split("\n"):
120+
line = line.strip()
121+
if line.startswith("*") and ":" in line:
122+
# Extract the SOP name between '*' and ':'
123+
sop_name = line.split("*", 1)[1].split(":", 1)[0].strip()
124+
if sop_name:
125+
sop_names.append(sop_name)
126+
127+
assert (
128+
len(sop_names) > 0
129+
), f"No SOPs found in response. Response: {list_sops_response_text[:200]}..."
130+
logger.info(f"Found {len(sop_names)} SOPs: {sop_names}")
131+
132+
# Step 2: Test retrieving the first SOP
133+
test_script = sop_names[0]
134+
logger.info(f"Testing with SOP: {test_script}")
135+
136+
response = await aws_mcp_client.call_tool("aws___retrieve_agent_sop", {"sop_name": test_script})
137+
138+
verify_response_content(response)
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Negative integration tests for AWS MCP Server at https://aws-mcp.us-east-1.api.aws/mcp."""
2+
3+
import fastmcp
4+
import logging
5+
import pytest
6+
import boto3
7+
from fastmcp.client import StdioTransport
8+
from datetime import datetime, timedelta
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
@pytest.mark.asyncio(loop_scope="module")
14+
async def test_nonexistent_tool(aws_mcp_client: fastmcp.Client):
15+
"""Test that calling a nonexistent tool raises an exception.
16+
17+
This test will:
18+
- PASS when calling a nonexistent tool raises an exception
19+
- FAIL if the nonexistent tool somehow succeeds
20+
"""
21+
exception_raised = False
22+
exception_message = None
23+
24+
try:
25+
response = await aws_mcp_client.call_tool("aws___nonexistent_tool_12345", {})
26+
logger.info(f"Unexpected success, response: {response}")
27+
except Exception as e:
28+
exception_raised = True
29+
exception_message = str(e)
30+
logger.info(f"Exception raised as expected: {type(e).__name__}: {exception_message}")
31+
32+
# Assert that an exception was raised
33+
assert exception_raised, (
34+
f"Expected exception when calling nonexistent tool 'aws___nonexistent_tool_12345', "
35+
f"but call succeeded unexpectedly."
36+
)
37+
38+
# Verify the exception mentions the tool not being found
39+
error_message_lower = exception_message.lower()
40+
tool_error_patterns = [
41+
"not found",
42+
"unknown",
43+
"does not exist",
44+
"invalid tool",
45+
"tool",
46+
"unknown tool",
47+
]
48+
49+
assert any(pattern in error_message_lower for pattern in tool_error_patterns), (
50+
f"Exception was raised but doesn't appear to be about a missing tool. "
51+
f"Expected one of {tool_error_patterns}, but got: {exception_message[:200]}"
52+
)
53+
54+
logger.info(f"Test passed: Nonexistent tool correctly raised exception")
55+
56+
57+
@pytest.mark.asyncio(loop_scope="module")
58+
async def test_expired_credentials():
59+
"""Test that expired credentials are properly rejected.
60+
61+
This test uses real AWS credentials but modifies the session token to simulate
62+
an expired token, which should result in an 'expired token' error message.
63+
64+
This test will:
65+
- PASS when expired credentials are rejected with appropriate error
66+
- FAIL if the modified credentials somehow work
67+
"""
68+
69+
# Get real credentials from boto3
70+
session = boto3.Session()
71+
creds = session.get_credentials()
72+
73+
# Use real access key and secret, but modify the token to simulate expiration by changing a few characters
74+
expired_token = creds.token[:-20] + "EXPIRED_TOKEN_12345"
75+
76+
expired_client = fastmcp.Client(
77+
StdioTransport(
78+
command="mcp-proxy-for-aws",
79+
args=[
80+
"https://aws-mcp.us-east-1.api.aws/mcp",
81+
"--log-level",
82+
"DEBUG",
83+
"--region",
84+
"us-east-1",
85+
],
86+
env={
87+
"AWS_REGION": "us-east-1",
88+
"AWS_ACCESS_KEY_ID": creds.access_key,
89+
"AWS_SECRET_ACCESS_KEY": creds.secret_key,
90+
"AWS_SESSION_TOKEN": expired_token,
91+
},
92+
),
93+
timeout=30.0,
94+
)
95+
96+
exception_raised = False
97+
exception_message = None
98+
99+
try:
100+
async with expired_client:
101+
response = await expired_client.call_tool("aws___list_regions")
102+
logger.info(f"Tool call completed without exception: is_error={response.is_error}")
103+
except Exception as e:
104+
exception_raised = True
105+
exception_message = str(e)
106+
logger.info(f"Exception raised as expected: {type(e).__name__}: {exception_message}")
107+
108+
# Assert that an exception was raised (credentials are invalid)
109+
assert exception_raised, (
110+
f"Expected authentication exception with invalid credentials, " f"but tool call succeeded."
111+
)
112+
113+
# Verify the exception is related to authentication/credentials
114+
error_message_lower = exception_message.lower()
115+
auth_error_patterns = [
116+
"credential",
117+
"authentication",
118+
"authorization",
119+
"access denied",
120+
"unauthorized",
121+
"invalid",
122+
"expired",
123+
"signature",
124+
"401",
125+
]
126+
127+
assert any(pattern in error_message_lower for pattern in auth_error_patterns), (
128+
f"Exception was raised but doesn't appear to be authentication-related. "
129+
f"Expected one of {auth_error_patterns}, but got: {exception_message[:200]}"
130+
)
131+
132+
logger.info(f"Test passed: Invalid credentials correctly rejected")

uv.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)