From 2c1ed1cf79551f6b536a2d0f41e42d1561e0627c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abraham=20P=C3=A9rez?= Date: Sat, 10 Oct 2026 09:34:48 -0600 Subject: [PATCH] feat(mcp): clear retryable errors + cold-start note for hosted clients A plain exception from a tool handler reached MCP clients as an opaque "error executing tool" (what a cold-start / slow-target failure looked like). Now: - _call() wraps every hosted tool: non-ToolError exceptions are re-raised as ToolError with the reason + a retry hint; ToolErrors pass through. - Server instructions note that the first call after idle may need a retry while the function cold-starts. - Tests cover both _call branches. Co-Authored-By: Claude Opus 4.8 --- src/bytecrawl/mcp_http.py | 42 +++++++++++++++++++++++++++++++-------- tests/test_mcp_http.py | 26 ++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/bytecrawl/mcp_http.py b/src/bytecrawl/mcp_http.py index 77d0897..79875bb 100644 --- a/src/bytecrawl/mcp_http.py +++ b/src/bytecrawl/mcp_http.py @@ -61,6 +61,25 @@ def _guarded(url: str) -> None: raise ToolError(str(e)) from e +def _call(fn, *args, **kwargs): + """Run a scrape/crawl call, surfacing failures as a clear, retryable + ToolError instead of the framework's generic 'error executing tool' mask. + + A plain exception (a slow/unreachable target, or the server cold-starting + after idle) otherwise reaches the client as an opaque error. We re-raise it + with the reason and a retry hint; ToolErrors (SSRF, seed errors) pass through + untouched.""" + try: + return fn(*args, **kwargs) + except ToolError: + raise + except Exception as e: # noqa: BLE001 — translate so the client sees why + raise ToolError( + f"{type(e).__name__}: {e}. If this was the first call after a quiet " + "period, the server may have been cold-starting — retry once." + ) from e + + # --- 3. rate limit ---------------------------------------------------------- class RateLimiter: """Sliding-window counter per key. In-memory: per-instance on serverless.""" @@ -122,7 +141,9 @@ async def __call__(self, scope, receive, send): "Hosted ByteCrawl: web scraping and focused crawling. Static HTML only " "(no JS rendering here — run bytecrawl-mcp locally for that). " f"Crawls are capped at {MAX_PAGES} pages per call, and " - f"compare_strategies at {MAX_COMPARE_PAGES} pages per strategy." + f"compare_strategies at {MAX_COMPARE_PAGES} pages per strategy. " + "The first call after a period of inactivity may be slow or fail once " + "while the server cold-starts — just retry it." ), ) @@ -136,7 +157,7 @@ async def __call__(self, scope, receive, send): ) def fetch_markdown(url: str) -> dict: _guarded(url) - return local.fetch_markdown(url) + return _call(local.fetch_markdown, url) @server.tool( @@ -154,7 +175,7 @@ def extract( url: str, item: str = "", fields: dict[str, str] | None = None, select: str = "" ) -> dict: _guarded(url) - return local.extract(url, item=item, fields=fields, select=select) + return _call(local.extract, url, item=item, fields=fields, select=select) @server.tool( @@ -166,7 +187,7 @@ def extract( ) def list_links(url: str, raw: bool = False) -> dict: _guarded(url) - return local.list_links(url, raw=raw) + return _call(local.list_links, url, raw=raw) @server.tool( @@ -180,7 +201,8 @@ def focused_crawl( url: str, query: str = "", strategy: str = "shark", max_pages: int = MAX_PAGES ) -> dict: _guarded(url) - return local.focused_crawl( + return _call( + local.focused_crawl, url, query=query, strategy=strategy, @@ -199,15 +221,19 @@ def focused_crawl( ) def compare_strategies(url: str, query: str, max_pages: int = MAX_COMPARE_PAGES) -> dict: _guarded(url) - return local.compare_strategies( - url, query=query, max_pages=min(max_pages, MAX_COMPARE_PAGES), timeout=HOSTED_TIMEOUT + return _call( + local.compare_strategies, + url, + query=query, + max_pages=min(max_pages, MAX_COMPARE_PAGES), + timeout=HOSTED_TIMEOUT, ) @server.tool(description="Fetch a public JSON API endpoint.") def fetch_json_api(url: str, params: dict | None = None) -> dict: _guarded(url) - return local.fetch_json_api(url, params=params) + return _call(local.fetch_json_api, url, params=params) def _transport_security() -> TransportSecuritySettings: diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index 2b2e142..aedc977 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -77,6 +77,32 @@ def test_every_tool_guards_before_fetching(self, monkeypatch, tool, kwargs): assert called == [] +class TestErrorTranslation: + def test_plain_error_becomes_retryable_toolerror(self, monkeypatch): + """A plain exception from the underlying call is otherwise masked as a + generic 'error executing tool'; the hosted tools re-raise it as a + ToolError with the reason and a retry hint (cold-start friendly).""" + from mcp.server.mcpserver.exceptions import ToolError + + def boom(*a, **kw): + raise ConnectionError("connection reset") + + monkeypatch.setattr(mcp_http.local, "fetch_markdown", boom) + with pytest.raises(ToolError, match="retry"): + mcp_http.fetch_markdown("http://8.8.8.8/") + + def test_toolerror_passes_through_unwrapped(self, monkeypatch): + """An already-meaningful ToolError (SSRF, seed error) is not rewrapped.""" + from mcp.server.mcpserver.exceptions import ToolError + + def boom(*a, **kw): + raise ToolError("seed is unreachable") + + monkeypatch.setattr(mcp_http.local, "focused_crawl", boom) + with pytest.raises(ToolError, match="seed is unreachable"): + mcp_http.focused_crawl("http://8.8.8.8/") + + class TestRateLimiter: def test_allows_up_to_limit(self): rl = RateLimiter(limit=3, window=60)