Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/aws-documentation-mcp-server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Fixed

- Recoverable read-path failures now name a next step. A 4xx, and a redirect that landed on a page with nothing to read, both add "Use search_documentation.", in the same spirit as the existing missing-subsections message. A page that returned content at the URL asked for but could not be parsed, and a transport failure, do not - the page answered, so a search is not the remedy.
- Read-path failures now reach the client with their message. Anticipated failures raise `DocumentationToolError`, a `ToolError` subclass, because MCP SDK 2.1.0 and later forward only a `ToolError`'s text and replace everything else with a bare `Error executing tool <name>`. This affected `read_documentation`, `read_sections` and `search_table`: a moved page, a 4xx response, an unreadable index shell or a rejected URL all arrived at the model with no reason and no suggested next step. Unexpected exceptions are deliberately left unconverted, so crash details stay off the wire.

### Changed

- 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.

### Added

- Add environment variable `AWS_DOCUMENTATION_PARTITION` to select AWS documentation partition.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@

# Import utility functions
from awslabs.aws_documentation_mcp_server.util import (
DocumentationToolError,
add_search_intent_to_search_request,
parse_recommendation_results,
)
Expand Down Expand Up @@ -187,10 +188,10 @@ async def read_documentation(

if not any(re.match(domain_regex, url_str) for domain_regex in supported_domains_regex):
await ctx.error(f'Invalid URL: {url_str}. URL must be from list of supported domains')
raise ValueError('URL must be from list of supported domains')
raise DocumentationToolError('URL must be from list of supported domains')
if not url_str.endswith('.html'):
await ctx.error(f'Invalid URL: {url_str}. URL must end with .html')
raise ValueError('URL must end with .html')
raise DocumentationToolError('URL must end with .html')

return await read_documentation_impl(ctx, url_str, max_length, start_index, SESSION_UUID)

Expand Down Expand Up @@ -263,14 +264,14 @@ async def read_sections(

if not any(re.match(domain_regex, url_str) for domain_regex in supported_domains_regex):
await ctx.error(f'Invalid URL: {url_str}. URL must be from list of supported domains')
raise ValueError('URL must be from list of supported domains')
raise DocumentationToolError('URL must be from list of supported domains')
if not url_str.endswith('.html'):
await ctx.error(f'Invalid URL: {url_str}. URL must end with .html')
raise ValueError('URL must end with .html')
raise DocumentationToolError('URL must end with .html')

if not section_titles:
await ctx.error('section_titles parameter cannot be empty')
raise ValueError('section_titles parameter cannot be empty')
raise DocumentationToolError('section_titles parameter cannot be empty')

return await read_sections_impl(ctx, url_str, section_titles, SESSION_UUID)

Expand Down Expand Up @@ -367,14 +368,14 @@ async def search_table(

if not any(re.match(domain_regex, url_str) for domain_regex in supported_domains_regex):
await ctx.error(f'Invalid URL: {url_str}. URL must be from list of supported domains')
raise ValueError('URL must be from list of supported domains')
raise DocumentationToolError('URL must be from list of supported domains')
if not url_str.endswith('.html'):
await ctx.error(f'Invalid URL: {url_str}. URL must end with .html')
raise ValueError('URL must end with .html')
raise DocumentationToolError('URL must end with .html')

if not query or not query.strip():
await ctx.error('query parameter cannot be empty')
raise ValueError('query parameter cannot be empty')
raise DocumentationToolError('query parameter cannot be empty')

return await search_table_impl(ctx, url_str, section_title, query, max_rows, SESSION_UUID)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
TableResult,
)
from awslabs.aws_documentation_mcp_server.util import (
DocumentationToolError,
UnreadablePageError,
enforce_redirect_allowlist,
extract_content_from_html,
Expand Down Expand Up @@ -67,6 +68,9 @@ def _docs_client(allowed_domain_regexes: Sequence[str]) -> httpx.AsyncClient:
)


_USE_SEARCH = 'Use search_documentation.'


# - '/a/index.html' 301s to '/a/' everywhere on the site
# - a missing page gets a 302 to '/a/' instead
_DIRECTORY_INDEX_FILENAME = 'index.html'
Expand Down Expand Up @@ -104,13 +108,14 @@ def of(cls, url_str: str, response: httpx.Response) -> 'Page':
"""Build from a completed response, stripping query parameters from both URLs."""
return cls(_without_query(url_str), _without_query(str(response.url)))

@property
def substituted(self) -> bool:
"""Whether the page that answered is a different page from the one asked for."""
return _page_identity(self.served) != _page_identity(self.requested)

def message(self, *parts: str) -> str:
"""Join a substitution note and any reasons into one sentence run."""
note = (
f'Requested {self.requested}; served {self.served}.'
if _page_identity(self.served) != _page_identity(self.requested)
else ''
)
note = f'Requested {self.requested}; served {self.served}.' if self.substituted else ''
return ' '.join(part for part in (note, *parts) if part)


Expand Down Expand Up @@ -147,17 +152,18 @@ async def read_documentation_impl(
error_msg = f'Failed to fetch {url_str}: {str(e)}'
logger.error(error_msg)
await ctx.error(error_msg)
raise ValueError(error_msg) from e
raise DocumentationToolError(error_msg) from e

page = Page.of(url_str, response)

if response.status_code >= 400:
error_msg = page.message(
f'Failed to fetch {page.served} - status code {response.status_code}'
f'Failed to fetch {page.served} - status code {response.status_code}.',
_USE_SEARCH,
)
logger.error(error_msg)
await ctx.error(error_msg)
raise ValueError(error_msg)
raise DocumentationToolError(error_msg)

page_raw = response.text
content_type = response.headers.get('content-type', '')
Expand All @@ -166,10 +172,13 @@ async def read_documentation_impl(
try:
content = extract_content_from_html(page_raw)
except UnreadablePageError as e:
error_msg = page.message(f'{page.served} could not be read: {e}')
error_msg = page.message(
f'{page.served} could not be read: {e}',
_USE_SEARCH if page.substituted else '',
)
logger.error(error_msg)
await ctx.error(error_msg)
raise ValueError(error_msg) from e
raise DocumentationToolError(error_msg) from e
content = truncate_large_tables(content, url=page.served)
else:
content = page_raw
Expand Down Expand Up @@ -263,17 +272,18 @@ async def read_sections_impl(
error_msg = f'Failed to fetch {url_str}: {str(e)}'
logger.error(error_msg)
await ctx.error(error_msg)
raise ValueError(error_msg) from e
raise DocumentationToolError(error_msg) from e

page = Page.of(url_str, response)

if response.status_code >= 400:
error_msg = page.message(
f'Failed to fetch {page.served} - status code {response.status_code}'
f'Failed to fetch {page.served} - status code {response.status_code}.',
_USE_SEARCH,
)
logger.error(error_msg)
await ctx.error(error_msg)
raise ValueError(error_msg)
raise DocumentationToolError(error_msg)

page_raw = response.text
content_type = response.headers.get('content-type', '')
Expand All @@ -283,29 +293,35 @@ async def read_sections_impl(
error_msg = page.message(non_html_msg)
logger.error(error_msg)
await ctx.error(error_msg)
raise ValueError(error_msg)
raise DocumentationToolError(error_msg)

try:
filtered_content = extract_sections_from_html(page_raw, section_titles)
except UnreadablePageError as e:
error_msg = page.message(f'{page.served} could not be read: {e}')
error_msg = page.message(
f'{page.served} could not be read: {e}',
_USE_SEARCH if page.substituted else '',
)
logger.error(error_msg)
await ctx.error(error_msg)
raise ValueError(error_msg) from e
raise DocumentationToolError(error_msg) from e
except ValueError as e:
error_msg = page.message(str(e))
logger.error(error_msg)
await ctx.error(error_msg)
raise ValueError(error_msg) from e
raise DocumentationToolError(error_msg) from e

try:
markdown = extract_content_from_html(filtered_content)
markdown = truncate_large_tables(markdown, url=page.served)
except UnreadablePageError as e:
error_msg = page.message(f'{page.served} could not be read: {e}')
error_msg = page.message(
f'{page.served} could not be read: {e}',
_USE_SEARCH if page.substituted else '',
)
logger.error(error_msg)
await ctx.error(error_msg)
raise ValueError(error_msg) from e
raise DocumentationToolError(error_msg) from e
except Exception as e:
error_msg = str(e)
logger.error(error_msg)
Expand Down Expand Up @@ -361,17 +377,18 @@ async def search_table_impl(
error_msg = f'Failed to fetch {url_str}: {str(e)}'
logger.error(error_msg)
await ctx.error(error_msg)
raise ValueError(error_msg) from e
raise DocumentationToolError(error_msg) from e

page = Page.of(url_str, response)

if response.status_code >= 400:
error_msg = page.message(
f'Failed to fetch {page.served} - status code {response.status_code}'
f'Failed to fetch {page.served} - status code {response.status_code}.',
_USE_SEARCH,
)
logger.error(error_msg)
await ctx.error(error_msg)
raise ValueError(error_msg)
raise DocumentationToolError(error_msg)

page_raw = response.text
content_type = response.headers.get('content-type', '')
Expand All @@ -381,15 +398,18 @@ async def search_table_impl(
error_msg = page.message(non_html_msg)
logger.error(error_msg)
await ctx.error(error_msg)
raise ValueError(error_msg)
raise DocumentationToolError(error_msg)

try:
table_data = parse_html_tables(page_raw, section_title if section_title else None)
except UnreadablePageError as e:
error_msg = page.message(f'{page.served} could not be read: {e}')
error_msg = page.message(
f'{page.served} could not be read: {e}',
_USE_SEARCH if page.substituted else '',
)
logger.error(error_msg)
await ctx.error(error_msg)
raise ValueError(error_msg) from e
raise DocumentationToolError(error_msg) from e

if table_data is None:
return SearchTableResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import markdownify
import re
from awslabs.aws_documentation_mcp_server.models import RecommendationResult
from mcp.server.mcpserver.exceptions import ToolError
from typing import Any, Dict, List, Sequence
from urllib.parse import quote_plus, urljoin

Expand All @@ -41,7 +42,20 @@ def _unwrap_broken_links(root) -> None:
anchor.unwrap()


class UnreadablePageError(ValueError):
class DocumentationToolError(ToolError, ValueError):
"""An anticipated failure whose message is meant for the caller.
The MCP SDK forwards a ``ToolError``'s message to the client and withholds the text of any
other exception, keeping crash details off the wire (python-sdk#3314). Every failure the read
path raises deliberately already builds a message for the caller, so those messages have to
travel as a ``ToolError`` to arrive at all.
``ValueError`` stays in the bases because that is what these paths raised before, and callers
and tests catch it.
"""


class UnreadablePageError(DocumentationToolError):
"""Raised when a page carries no extractable content, only markup."""


Expand Down Expand Up @@ -72,7 +86,7 @@ def extract_content_from_html(html: str) -> str:
UnreadablePageError: the page carries no extractable content
"""
if not html:
raise UnreadablePageError('Empty HTML content')
raise UnreadablePageError('Empty HTML content.')

try:
# First use BeautifulSoup to clean up the HTML
Expand Down Expand Up @@ -185,13 +199,13 @@ def extract_content_from_html(html: str) -> str:
)

if not content.strip():
raise UnreadablePageError('Page failed to be simplified from HTML')
raise UnreadablePageError('Page failed to be simplified from HTML.')

return content
except UnreadablePageError:
raise
except Exception as e:
raise UnreadablePageError(f'Error converting HTML to Markdown: {str(e)}') from e
raise UnreadablePageError(f'Error converting HTML to Markdown: {str(e)}.') from e


def is_html_content(page_raw: str, content_type: str) -> bool:
Expand Down Expand Up @@ -327,10 +341,10 @@ def extract_sections_from_html(html: str, section_titles: List[str]) -> str:
if available_level2_sections:
available_list = ', '.join(f'"{section}"' for section in available_level2_sections)
error_msg = f'No matching sections were found: {section_list}. Available sections: {available_list}. Please retry with one or more of these sections or use the read_documentation tool instead to get the full document content.'
raise ValueError(error_msg)
raise DocumentationToolError(error_msg)
else:
error_msg = 'This document does not contain subsections. Please use the read_documentation tool instead to get the full document content.'
raise ValueError(error_msg)
raise DocumentationToolError(error_msg)

result_html = ''.join(matched_sections_html)

Expand Down
2 changes: 1 addition & 1 deletion src/aws-documentation-mcp-server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"markdownify>=1.1.0",
"mcp[cli]>=2.0.0,<3.0.0",
"mcp[cli]>=2.1.0,<3.0.0",
"pydantic>=2.10.6",
"httpx>=0.27.0",
"loguru>=0.7.0",
Expand Down
Loading
Loading