Skip to content
Open
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
42 changes: 34 additions & 8 deletions src/bytecrawl/mcp_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."
),
)

Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions tests/test_mcp_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading