Skip to content

Commit 7150db3

Browse files
kstichalexisareyn
andauthored
feat(aws-documentation-mcp-server): negotiate Markdown for read_sections (#4977)
`read_sections` and `search_table` each built their own request with no `Accept` header, and `read_sections` refused any response that was not HTML. Only `read_documentation` benefited from the published Markdown that docs.aws.amazon.com serves. A single `_fetch` now owns the request, the session and query_id parameters, and transport and status errors. `fetch_markdown` negotiates Markdown, converts HTML when that is what the page serves, and runs the shared link rewrite and table truncation. `read_documentation` and `read_sections` both use it, and `read_sections` now extracts level-2 sections from Markdown, so pages that only serve Markdown or plain text no longer fail. `fetch_html` keeps `search_table` on HTML, because published Markdown flattens the merged-cell structure its nested output depends on. Co-authored-by: alexisareyn <162268610+alexisareyn@users.noreply.github.com>
1 parent dc8221e commit 7150db3

7 files changed

Lines changed: 442 additions & 373 deletions

File tree

‎src/aws-documentation-mcp-server/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
### Changed
1616

1717
- `read_documentation` now requests `Accept: text/markdown, text/html;q=0.9`. Pages that publish a Markdown variant return it directly, which is smaller and skips the HTML-to-Markdown conversion. Pages without one, such as the CLI reference, still return HTML and are converted as before. The table-truncation pass now runs on both forms, all in a single request with no extra probe for pages that lack Markdown. Same-site links to `.md` pages are rewritten to their absolute `.html` URLs, so they can be passed straight back to the read tools.
18+
- `read_sections` negotiates Markdown the same way and extracts level-2 sections from it, converting HTML first when that is what the page serves. Pages that only serve Markdown or plain text no longer fail with "Cannot extract sections from non-HTML content", and section results get the same link rewriting as `read_documentation`. `search_table` keeps requesting HTML, because published Markdown flattens the merged-cell structure its nested output depends on.
1819
- Raised the `mcp` floor to `>=2.1.0`. 2.1.0 is where the SDK stopped forwarding non-`ToolError` messages, so an environment that resolved 2.0.0 hid this entire class of bug, CI included. The lock now resolves 2.3.0.
1920

2021
### Added

‎src/aws-documentation-mcp-server/awslabs/aws_documentation_mcp_server/server_utils.py‎

Lines changed: 93 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
UnreadablePageError,
2525
enforce_redirect_allowlist,
2626
extract_content_from_html,
27-
extract_sections_from_html,
27+
extract_sections_from_markdown,
2828
format_documentation_result,
2929
is_html_content,
3030
rewrite_markdown_links,
@@ -73,7 +73,9 @@ def _docs_client(allowed_domain_regexes: Sequence[str]) -> httpx.AsyncClient:
7373

7474

7575
# Pages with a published Markdown variant serve it directly; the rest fall back to HTML.
76-
_READ_DOCUMENTATION_ACCEPT = 'text/markdown, text/html;q=0.9'
76+
_ACCEPT_MARKDOWN = 'text/markdown, text/html;q=0.9'
77+
# Table search needs the HTML table structure, which published Markdown flattens.
78+
_ACCEPT_HTML = 'text/html'
7779

7880

7981
# - '/a/index.html' 301s to '/a/' everywhere on the site
@@ -124,18 +126,18 @@ def message(self, *parts: str) -> str:
124126
return ' '.join(part for part in (note, *parts) if part)
125127

126128

127-
async def read_documentation_impl(
129+
async def _fetch(
128130
ctx: Context,
129131
url_str: str,
130-
max_length: int,
131-
start_index: int,
132132
session_uuid: str,
133-
allowed_domain_regexes: Sequence[str] = COMMERCIAL_ALLOWED_DOMAIN_REGEXES,
134-
) -> str:
135-
"""The implementation of the read_documentation tool."""
136-
logger.debug(f'Fetching documentation from {url_str}')
137-
133+
query: str,
134+
accept: str,
135+
allowed_domain_regexes: Sequence[str],
136+
) -> tuple[Page, httpx.Response]:
137+
"""Request a documentation page, failing on transport errors and error statuses."""
138138
url_with_session = f'{url_str}?session={session_uuid}'
139+
if query:
140+
url_with_session += f'&{query}'
139141

140142
query_id = get_query_id_from_cache(url_str)
141143
if query_id:
@@ -150,7 +152,7 @@ async def read_documentation_impl(
150152
headers={
151153
'User-Agent': DEFAULT_USER_AGENT,
152154
'X-MCP-Session-Id': session_uuid,
153-
'Accept': _READ_DOCUMENTATION_ACCEPT,
155+
'Accept': accept,
154156
},
155157
timeout=30,
156158
)
@@ -160,24 +162,36 @@ async def read_documentation_impl(
160162
await ctx.error(error_msg)
161163
raise DocumentationToolError(error_msg) from e
162164

163-
page = Page.of(url_str, response)
165+
page = Page.of(url_str, response)
164166

165-
if response.status_code >= 400:
166-
error_msg = page.message(
167-
f'Failed to fetch {page.served} - status code {response.status_code}.',
168-
_USE_SEARCH,
169-
)
170-
logger.error(error_msg)
171-
await ctx.error(error_msg)
172-
raise DocumentationToolError(error_msg)
167+
if response.status_code >= 400:
168+
error_msg = page.message(
169+
f'Failed to fetch {page.served} - status code {response.status_code}.',
170+
_USE_SEARCH,
171+
)
172+
logger.error(error_msg)
173+
await ctx.error(error_msg)
174+
raise DocumentationToolError(error_msg)
173175

174-
page_raw = response.text
175-
content_type = response.headers.get('content-type', '')
176+
return page, response
176177

177-
# Normalize to Markdown, then clean up once regardless of which form the server sent.
178-
if is_html_content(page_raw, content_type):
178+
179+
async def fetch_markdown(
180+
ctx: Context,
181+
url_str: str,
182+
session_uuid: str,
183+
query: str,
184+
allowed_domain_regexes: Sequence[str],
185+
) -> tuple[Page, str]:
186+
"""Read a page as cleaned-up Markdown, converting it when the server only has HTML."""
187+
page, response = await _fetch(
188+
ctx, url_str, session_uuid, query, _ACCEPT_MARKDOWN, allowed_domain_regexes
189+
)
190+
content = response.text
191+
192+
if is_html_content(content, response.headers.get('content-type', '')):
179193
try:
180-
content = extract_content_from_html(page_raw)
194+
content = extract_content_from_html(content)
181195
except UnreadablePageError as e:
182196
error_msg = page.message(
183197
f'{page.served} could not be read: {e}',
@@ -186,11 +200,52 @@ async def read_documentation_impl(
186200
logger.error(error_msg)
187201
await ctx.error(error_msg)
188202
raise DocumentationToolError(error_msg) from e
189-
else:
190-
content = page_raw
191203

192-
content = rewrite_markdown_links(content, base_url=page.served)
193-
content = truncate_large_tables(content, url=page.served)
204+
try:
205+
content = rewrite_markdown_links(content, base_url=page.served)
206+
return page, truncate_large_tables(content, url=page.served)
207+
except Exception as e:
208+
error_msg = str(e)
209+
logger.error(error_msg)
210+
await ctx.error(error_msg)
211+
raise
212+
213+
214+
async def fetch_html(
215+
ctx: Context,
216+
url_str: str,
217+
session_uuid: str,
218+
query: str,
219+
allowed_domain_regexes: Sequence[str],
220+
) -> tuple[Page, str]:
221+
"""Fetch a page as HTML, failing when the server sends anything else."""
222+
page, response = await _fetch(
223+
ctx, url_str, session_uuid, query, _ACCEPT_HTML, allowed_domain_regexes
224+
)
225+
if not is_html_content(response.text, response.headers.get('content-type', '')):
226+
error_msg = page.message(
227+
'Page content is not HTML. Use read_documentation to view this page.'
228+
)
229+
logger.error(error_msg)
230+
await ctx.error(error_msg)
231+
raise DocumentationToolError(error_msg)
232+
233+
return page, response.text
234+
235+
236+
async def read_documentation_impl(
237+
ctx: Context,
238+
url_str: str,
239+
max_length: int,
240+
start_index: int,
241+
session_uuid: str,
242+
allowed_domain_regexes: Sequence[str] = COMMERCIAL_ALLOWED_DOMAIN_REGEXES,
243+
) -> str:
244+
"""The implementation of the read_documentation tool."""
245+
logger.debug(f'Fetching documentation from {url_str}')
246+
247+
page, content = await fetch_markdown(ctx, url_str, session_uuid, '', allowed_domain_regexes)
248+
194249
result = format_documentation_result(page.served, content, start_index, max_length)
195250
if note := page.message():
196251
result = f'<e>{note}</e>\n\n{result}'
@@ -256,55 +311,13 @@ async def read_sections_impl(
256311
"""The implementation of the read_sections tool."""
257312
logger.debug(f'Fetching sections {section_titles} from {url_str}')
258313

259-
url_with_session = f'{url_str}?session={session_uuid}'
260314
sections_param = ','.join(quote(title.strip(), safe='') for title in section_titles)
261-
url_with_session += f'&sections={sections_param}'
262-
263-
query_id = get_query_id_from_cache(url_str)
264-
if query_id:
265-
url_with_session += f'&query_id={query_id}'
266-
logger.debug(f'Using query_id {query_id}')
267-
268-
async with _docs_client(allowed_domain_regexes) as client:
269-
try:
270-
response = await client.get(
271-
url_with_session,
272-
follow_redirects=True,
273-
headers={
274-
'User-Agent': DEFAULT_USER_AGENT,
275-
'X-MCP-Session-Id': session_uuid,
276-
},
277-
timeout=30,
278-
)
279-
except httpx.HTTPError as e:
280-
error_msg = f'Failed to fetch {url_str}: {str(e)}'
281-
logger.error(error_msg)
282-
await ctx.error(error_msg)
283-
raise DocumentationToolError(error_msg) from e
284-
285-
page = Page.of(url_str, response)
286-
287-
if response.status_code >= 400:
288-
error_msg = page.message(
289-
f'Failed to fetch {page.served} - status code {response.status_code}.',
290-
_USE_SEARCH,
291-
)
292-
logger.error(error_msg)
293-
await ctx.error(error_msg)
294-
raise DocumentationToolError(error_msg)
295-
296-
page_raw = response.text
297-
content_type = response.headers.get('content-type', '')
298-
299-
if not is_html_content(page_raw, content_type):
300-
non_html_msg = 'Cannot extract sections from non-HTML content. Please use the read_documentation tool instead to get the full document content.'
301-
error_msg = page.message(non_html_msg)
302-
logger.error(error_msg)
303-
await ctx.error(error_msg)
304-
raise DocumentationToolError(error_msg)
315+
page, content = await fetch_markdown(
316+
ctx, url_str, session_uuid, f'sections={sections_param}', allowed_domain_regexes
317+
)
305318

306319
try:
307-
filtered_content = extract_sections_from_html(page_raw, section_titles)
320+
markdown = extract_sections_from_markdown(content, section_titles)
308321
except UnreadablePageError as e:
309322
error_msg = page.message(
310323
f'{page.served} could not be read: {e}',
@@ -319,23 +332,6 @@ async def read_sections_impl(
319332
await ctx.error(error_msg)
320333
raise DocumentationToolError(error_msg) from e
321334

322-
try:
323-
markdown = extract_content_from_html(filtered_content)
324-
markdown = truncate_large_tables(markdown, url=page.served)
325-
except UnreadablePageError as e:
326-
error_msg = page.message(
327-
f'{page.served} could not be read: {e}',
328-
_USE_SEARCH if page.substituted else '',
329-
)
330-
logger.error(error_msg)
331-
await ctx.error(error_msg)
332-
raise DocumentationToolError(error_msg) from e
333-
except Exception as e:
334-
error_msg = str(e)
335-
logger.error(error_msg)
336-
await ctx.error(error_msg)
337-
raise
338-
339335
markdown = f'AWS Documentation from {page.served}:\n\n{markdown}'
340336
if note := page.message():
341337
markdown = f'<e>{note}</e>\n\n{markdown}'
@@ -360,53 +356,12 @@ async def search_table_impl(
360356

361357
logger.debug(f'Searching tables in section "{section_title}" of {url_str} for "{query}"')
362358

363-
url_with_session = (
364-
f'{url_str}?session={session_uuid}&tool=search_table&query={quote(query, safe="")}'
365-
)
359+
query_param = f'tool=search_table&query={quote(query, safe="")}'
366360
if section_title:
367-
url_with_session += f'&section={quote(section_title, safe="")}'
368-
369-
query_id = get_query_id_from_cache(url_str)
370-
if query_id:
371-
url_with_session += f'&query_id={query_id}'
372-
373-
async with _docs_client(allowed_domain_regexes) as client:
374-
try:
375-
response = await client.get(
376-
url_with_session,
377-
follow_redirects=True,
378-
headers={
379-
'User-Agent': DEFAULT_USER_AGENT,
380-
'X-MCP-Session-Id': session_uuid,
381-
},
382-
timeout=30,
383-
)
384-
except httpx.HTTPError as e:
385-
error_msg = f'Failed to fetch {url_str}: {str(e)}'
386-
logger.error(error_msg)
387-
await ctx.error(error_msg)
388-
raise DocumentationToolError(error_msg) from e
389-
390-
page = Page.of(url_str, response)
391-
392-
if response.status_code >= 400:
393-
error_msg = page.message(
394-
f'Failed to fetch {page.served} - status code {response.status_code}.',
395-
_USE_SEARCH,
396-
)
397-
logger.error(error_msg)
398-
await ctx.error(error_msg)
399-
raise DocumentationToolError(error_msg)
400-
401-
page_raw = response.text
402-
content_type = response.headers.get('content-type', '')
403-
404-
if not is_html_content(page_raw, content_type):
405-
non_html_msg = 'Page content is not HTML. Use read_documentation to view this page.'
406-
error_msg = page.message(non_html_msg)
407-
logger.error(error_msg)
408-
await ctx.error(error_msg)
409-
raise DocumentationToolError(error_msg)
361+
query_param += f'&section={quote(section_title, safe="")}'
362+
page, page_raw = await fetch_html(
363+
ctx, url_str, session_uuid, query_param, allowed_domain_regexes
364+
)
410365

411366
try:
412367
table_data = parse_html_tables(page_raw, section_title if section_title else None)

0 commit comments

Comments
 (0)