Skip to content

Commit ffa7740

Browse files
JonLimJonathan Lim
andauthored
fix(aws-documentation-mcp-server): Fix get_available_services endpoint (#1796)
* fix(aws-documentation-mcp-server): Fix get_available_services endpoint A change was recently made to the AWS CN page for available services and completely changed its format. This change is to update the tool endpoint function to continue surfacing the available services in AWS CN. * fix: Updating tests to cover new functionality introduced in change * Updating object/dict access patterns to use safer approach * Using existing approach for returning/logging error messages --------- Co-authored-by: Jonathan Lim <[email protected]>
1 parent d6344f8 commit ffa7740

File tree

3 files changed

+148
-9
lines changed

3 files changed

+148
-9
lines changed

src/aws-documentation-mcp-server/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,12 @@ Gets a list of available AWS services in China regions.
189189
```python
190190
get_available_services() -> str
191191
```
192+
193+
## Development
194+
195+
For getting started with development on the AWS Documentation MCP server, please refer to the awslabs/mcp DEVELOPER_GUIDE first. Everything below this is specific to AWS Documentation MCP Server development.
196+
197+
### Running tests
198+
199+
Unit tests: `uv run --frozen pytest --cov --cov-branch --cov-report=term-missing`
200+
Unit tests with integration tests: `uv run --frozen pytest --cov --cov-branch --cov-report=term-missing --run-live`

src/aws-documentation-mcp-server/awslabs/aws_documentation_mcp_server/server_aws_cn.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ async def get_available_services(
160160
"""
161161
url_str = 'https://docs.amazonaws.cn/en_us/aws/latest/userguide/services.html'
162162
url_with_session = f'{url_str}?session={SESSION_UUID}'
163+
164+
toc_url_str = 'https://docs.amazonaws.cn/en_us/aws/latest/userguide/toc-contents.json'
165+
toc_url_with_session = f'{toc_url_str}?session={SESSION_UUID}'
163166
async with httpx.AsyncClient() as client:
164167
try:
165168
response = await client.get(
@@ -168,21 +171,60 @@ async def get_available_services(
168171
headers={'User-Agent': DEFAULT_USER_AGENT},
169172
timeout=30,
170173
)
174+
# Fetch the Table of Contents in the Services page, which contains the list of supported services
175+
toc_response = await client.get(
176+
toc_url_with_session,
177+
follow_redirects=True,
178+
headers={'User-Agent': DEFAULT_USER_AGENT, 'Content-Type': 'application/json'},
179+
timeout=30,
180+
)
171181
except httpx.HTTPError as e:
172-
error_msg = f'Failed to fetch {url_str}: {str(e)}'
182+
error_msg = f'Failed to fetch AWS-CN services page: {str(e)}'
173183
logger.error(error_msg)
174184
await ctx.error(error_msg)
175185
return error_msg
176186

177187
if response.status_code >= 400:
178-
error_msg = f'Failed to fetch {url_str} - status code {response.status_code}'
188+
error_msg = (
189+
f'Failed to fetch AWS-CN services page - status code {response.status_code}'
190+
)
179191
logger.error(error_msg)
180192
await ctx.error(error_msg)
181193
return error_msg
182194

183195
page_raw = response.text
184196
content_type = response.headers.get('content-type', '')
185197

198+
page_toc_json = toc_response.json()
199+
# Expecting a toc JSON object that has a href of 'services.html', which contains all of the AWS Services supported in China
200+
# toc_response = { 'contents' : [ { 'title: '', 'href': '', 'contents: [] } ] }
201+
services_json = [
202+
toc_item.get('contents', [])
203+
for toc_item in page_toc_json.get('contents', [])
204+
if toc_item.get('href') == 'services.html'
205+
]
206+
207+
# If toc_response does not have `href: services.html`, and services_json is empty, raise an error so
208+
# users can self-solve.
209+
if len(services_json) == 0:
210+
error_msg = (
211+
f'Failed fetching list of available AWS Services, please go to {url_str} directly'
212+
)
213+
logger.error(error_msg)
214+
await ctx.error(error_msg)
215+
return error_msg
216+
217+
# Filtering out 'Services Unsupported in Amazon Web Services in China'
218+
formatted_service_titles = ''
219+
service_doc_links = [
220+
f'[{service.get("title")}](https://docs.amazonaws.cn/en_us/aws/latest/userguide/{service.get("href")})'
221+
for service in services_json[0]
222+
if 'Services Unsupported' not in service.get('title')
223+
]
224+
formatted_service_titles = '\n\n## Services in Amazon Web Services China\n\n' + '\n'.join(
225+
[f'- {service_doc_link}' for service_doc_link in service_doc_links]
226+
)
227+
186228
if is_html_content(page_raw, content_type):
187229
content = extract_content_from_html(page_raw)
188230
else:
@@ -194,7 +236,7 @@ async def get_available_services(
194236
url_str, content, start_index=0, max_length=MAX_DOCUMENTATION_LENGTH
195237
)
196238

197-
return result
239+
return result + formatted_service_titles
198240

199241

200242
def main():

src/aws-documentation-mcp-server/tests/test_server_aws_cn.py

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,21 +117,46 @@ async def test_get_available_services(self):
117117
mock_response.text = '<html><body><h1>AWS Services in China</h1><p>Available services list.</p></body></html>'
118118
mock_response.headers = {'content-type': 'text/html'}
119119

120+
mock_toc_response = MagicMock()
121+
mock_toc_response.status_code = 200
122+
mock_toc_response.json = lambda: {
123+
'contents': [
124+
{
125+
'title': 'Documentation by Service',
126+
'href': 'services.html',
127+
'contents': [
128+
{'title': 'Amazon Simple Storage Service', 'href': 's3.html'},
129+
{'title': 'Amazon Simple Queue Service', 'href': 'sqs.html'},
130+
],
131+
}
132+
]
133+
}
134+
mock_toc_response.headers = {'content-type': 'application/json'}
135+
120136
with patch('httpx.AsyncClient.get', new_callable=AsyncMock) as mock_get:
121-
mock_get.return_value = mock_response
137+
# Set the response for successive calls, first to service.html, second to toc.json
138+
mock_get.side_effect = [
139+
mock_response,
140+
mock_toc_response,
141+
]
142+
122143
with patch(
123144
'awslabs.aws_documentation_mcp_server.server_aws_cn.extract_content_from_html'
124145
) as mock_extract:
125146
mock_extract.return_value = '# AWS Services in China\n\nAvailable services list.'
126-
127147
result = await get_available_services(ctx)
128148

129149
assert 'AWS Documentation from' in result
130150
assert (
131151
'https://docs.amazonaws.cn/en_us/aws/latest/userguide/services.html' in result
132152
)
133153
assert '# AWS Services in China\n\nAvailable services list.' in result
134-
mock_get.assert_called_once()
154+
assert 'Amazon Simple Storage Service' in result
155+
assert 's3.html' in result
156+
assert 'Amazon Simple Queue Service' in result
157+
assert 'sqs.html' in result
158+
159+
assert mock_get.call_count == 2
135160
mock_extract.assert_called_once()
136161
called_url = mock_get.call_args[0][0]
137162
assert '?session=' in called_url
@@ -165,7 +190,7 @@ async def test_get_available_services_status_error(self):
165190

166191
assert 'Failed to fetch' in result
167192
assert 'status code 404' in result
168-
mock_get.assert_called_once()
193+
assert mock_get.call_count == 2
169194

170195
@pytest.mark.asyncio
171196
async def test_get_available_services_non_html(self):
@@ -177,8 +202,29 @@ async def test_get_available_services_non_html(self):
177202
mock_response.text = 'Plain text content'
178203
mock_response.headers = {'content-type': 'text/plain'}
179204

205+
mock_toc_response = MagicMock()
206+
mock_toc_response.status_code = 200
207+
mock_toc_response.json = lambda: {
208+
'contents': [
209+
{
210+
'title': 'Documentation by Service',
211+
'href': 'services.html',
212+
'contents': [
213+
{'title': 'Amazon Simple Storage Service', 'href': 's3.html'},
214+
{'title': 'Amazon Simple Queue Service', 'href': 'sqs.html'},
215+
],
216+
}
217+
]
218+
}
219+
mock_toc_response.headers = {'content-type': 'application/json'}
220+
180221
with patch('httpx.AsyncClient.get', new_callable=AsyncMock) as mock_get:
181-
mock_get.return_value = mock_response
222+
# Set the response for successive calls, first to service.html, second to toc.json
223+
mock_get.side_effect = [
224+
mock_response,
225+
mock_toc_response,
226+
]
227+
182228
with patch(
183229
'awslabs.aws_documentation_mcp_server.server_aws_cn.is_html_content'
184230
) as mock_is_html:
@@ -188,9 +234,51 @@ async def test_get_available_services_non_html(self):
188234

189235
assert 'AWS Documentation from' in result
190236
assert 'Plain text content' in result
191-
mock_get.assert_called_once()
237+
assert mock_get.call_count == 2
192238
mock_is_html.assert_called_once()
193239

240+
@pytest.mark.asyncio
241+
async def test_get_available_services_key_error(self):
242+
"""Test getting available services in AWS China."""
243+
ctx = MockContext()
244+
245+
mock_response = MagicMock()
246+
mock_response.status_code = 200
247+
mock_response.text = '<html><body><h1>AWS Services in China</h1><p>Available services list.</p></body></html>'
248+
mock_response.headers = {'content-type': 'text/html'}
249+
250+
mock_toc_response = MagicMock()
251+
mock_toc_response.status_code = 200
252+
mock_toc_response.json = lambda: {
253+
'contents': [
254+
{
255+
'title': 'Welcome',
256+
'href': 'introduction.html',
257+
}
258+
]
259+
}
260+
mock_toc_response.headers = {'content-type': 'application/json'}
261+
262+
with patch('httpx.AsyncClient.get', new_callable=AsyncMock) as mock_get:
263+
# Set the response for successive calls, first to service.html, second to toc.json
264+
mock_get.side_effect = [
265+
mock_response,
266+
mock_toc_response,
267+
]
268+
269+
with patch(
270+
'awslabs.aws_documentation_mcp_server.server_aws_cn.extract_content_from_html'
271+
) as mock_extract:
272+
mock_extract.return_value = '# AWS Services in China\n\nAvailable services list.'
273+
result = await get_available_services(ctx)
274+
275+
assert 'Failed fetching list of available AWS Services, please go to' in result
276+
277+
assert mock_get.call_count == 2
278+
mock_extract.assert_not_called()
279+
called_url = mock_get.call_args[0][0]
280+
assert '?session=' in called_url
281+
194282

195283
class TestMain:
196284
"""Tests for the main function."""

0 commit comments

Comments
 (0)