diff --git a/src/fetch/README.md b/src/fetch/README.md index 65457d4501..6b91fe8f7d 100644 --- a/src/fetch/README.md +++ b/src/fetch/README.md @@ -33,7 +33,7 @@ The fetch tool will truncate the response, but by using the `start_index` argume ## Installation -Optionally: Install node.js, this will cause the fetch server to use a different HTML simplifier that is more robust. +Optionally: Install node.js, this will cause the fetch server to use a different HTML simplifier that is more robust. If `node` is not available, the server falls back to the Python-only simplifier. ### Using uv (recommended) @@ -193,6 +193,10 @@ environment variables (honoring `NO_PROXY`). Both `http(s)://` and SOCKS5 proxie installed with the server). A `socks://` proxy URL, as many desktop proxy settings export it, is treated as `socks5://`. A proxy setting the server cannot use makes each fetch fail with a tool error that says so. +### Customization - HTML simplifier + +By default, the server uses the Node.js readability simplifier when `node` is available on `PATH`, and otherwise falls back to readabilipy's Python-only simplifier. If a host has Node.js installed but the readability path is slow or misconfigured, add `--no-readability-js` to force the Python-only simplifier. + ## Windows Configuration If you're experiencing timeout issues on Windows, you may need to set the `PYTHONIOENCODING` environment variable to ensure proper character encoding: diff --git a/src/fetch/src/mcp_server_fetch/__init__.py b/src/fetch/src/mcp_server_fetch/__init__.py index aa0b11a3b7..febeb19f3e 100644 --- a/src/fetch/src/mcp_server_fetch/__init__.py +++ b/src/fetch/src/mcp_server_fetch/__init__.py @@ -21,6 +21,11 @@ def main(): action="store_true", help="Allow fetching private, loopback and link-local addresses (refused by default)", ) + parser.add_argument( + "--no-readability-js", + action="store_true", + help="Use readabilipy's Python-only HTML simplifier instead of the optional Node.js readability path", + ) args = parser.parse_args() asyncio.run( @@ -29,6 +34,7 @@ def main(): args.ignore_robots_txt, args.proxy_url, args.allow_private_ips, + use_readability_js=False if args.no_readability_js else None, ) ) diff --git a/src/fetch/src/mcp_server_fetch/server.py b/src/fetch/src/mcp_server_fetch/server.py index 94914f6b65..6490472528 100644 --- a/src/fetch/src/mcp_server_fetch/server.py +++ b/src/fetch/src/mcp_server_fetch/server.py @@ -1,6 +1,7 @@ import asyncio import ipaddress import os +import shutil import socket from typing import Annotated, Tuple from importlib.metadata import version @@ -143,7 +144,7 @@ async def _refuse_private_destination(request: httpx.Request) -> None: ) -def extract_content_from_html(html: str) -> str: +def extract_content_from_html(html: str, use_readability_js: bool | None = None) -> str: """Extract and convert HTML content to Markdown format. Args: @@ -152,8 +153,11 @@ def extract_content_from_html(html: str) -> str: Returns: Simplified markdown version of the content """ + if use_readability_js is None: + use_readability_js = shutil.which("node") is not None + ret = readabilipy.simple_json.simple_json_from_html_string( - html, use_readability=True + html, use_readability=use_readability_js ) failed = "Page failed to be simplified from HTML" if not ret["content"]: @@ -302,6 +306,7 @@ async def fetch_url( force_raw: bool = False, proxy_url: str | None = None, allow_private_ips: bool = False, + use_readability_js: bool | None = None, ) -> Tuple[str, str]: """ Fetch the URL and return the content in a form ready for the LLM, as well as a prefix string with status information. @@ -338,7 +343,9 @@ async def fetch_url( ) if is_page_html and not force_raw: - return extract_content_from_html(page_raw), "" + return extract_content_from_html( + page_raw, use_readability_js=use_readability_js + ), "" return ( page_raw, @@ -384,6 +391,7 @@ async def serve( ignore_robots_txt: bool = False, proxy_url: str | None = None, allow_private_ips: bool = False, + use_readability_js: bool | None = None, ) -> None: """Run the fetch MCP server. @@ -458,6 +466,7 @@ async def call_tool(name, arguments: dict) -> list[TextContent]: force_raw=args.raw, proxy_url=proxy_url, allow_private_ips=allow_private_ips, + use_readability_js=use_readability_js, ) original_length = len(content) if args.start_index >= original_length: @@ -476,6 +485,7 @@ async def call_tool(name, arguments: dict) -> list[TextContent]: if actual_content_length == args.max_length and remaining_content > 0: next_start = args.start_index + actual_content_length content += f"\n\nContent truncated. Call the fetch tool with a start_index of {next_start} to get more content." + return [TextContent(type="text", text=f"{prefix}Contents of {url}:\n{content}")] @server.get_prompt() @@ -495,6 +505,7 @@ async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult: user_agent_manual, proxy_url=proxy_url, allow_private_ips=allow_private_ips, + use_readability_js=use_readability_js, ) # TODO: after SDK bug is addressed, don't catch the exception except McpError as e: diff --git a/src/fetch/tests/test_entrypoints.py b/src/fetch/tests/test_entrypoints.py index 12de04a781..fcad7964c4 100644 --- a/src/fetch/tests/test_entrypoints.py +++ b/src/fetch/tests/test_entrypoints.py @@ -31,7 +31,7 @@ def run_main(monkeypatch: pytest.MonkeyPatch, *argv: str) -> AsyncMock: def test_main_defaults(monkeypatch: pytest.MonkeyPatch) -> None: serve = run_main(monkeypatch) - serve.assert_awaited_once_with(None, False, None, False) + serve.assert_awaited_once_with(None, False, None, False, use_readability_js=None) def test_main_passes_every_flag(monkeypatch: pytest.MonkeyPatch) -> None: @@ -43,9 +43,14 @@ def test_main_passes_every_flag(monkeypatch: pytest.MonkeyPatch) -> None: "--proxy-url", "http://proxy.example.com:8080", "--allow-private-ips", + "--no-readability-js", ) serve.assert_awaited_once_with( - "MyBot/1.0", True, "http://proxy.example.com:8080", True + "MyBot/1.0", + True, + "http://proxy.example.com:8080", + True, + use_readability_js=False, ) @@ -62,6 +67,7 @@ def test_main_help_exits_zero( "--ignore-robots-txt", "--proxy-url", "--allow-private-ips", + "--no-readability-js", ): assert flag in out @@ -90,7 +96,7 @@ def test_package_init_run_as_main_calls_main(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setattr(mcp_server_fetch.server, "serve", serve) monkeypatch.setattr(sys, "argv", ["mcp-server-fetch"]) runpy.run_module("mcp_server_fetch.__init__", run_name="__main__") - serve.assert_awaited_once_with(None, False, None, False) + serve.assert_awaited_once_with(None, False, None, False, use_readability_js=None) def console_script() -> Path: diff --git a/src/fetch/tests/test_protocol.py b/src/fetch/tests/test_protocol.py index 01269c7002..e123c60f72 100644 --- a/src/fetch/tests/test_protocol.py +++ b/src/fetch/tests/test_protocol.py @@ -21,6 +21,8 @@ import httpx import pytest +import readabilipy.simple_json +from unittest.mock import patch from mcp import ClientSession from mcp.shared.exceptions import McpError from mcp.types import INVALID_PARAMS, CallToolResult @@ -467,6 +469,24 @@ async def test_html_without_node_falls_back_to_pure_python(web: FakeWeb) -> None assert "https://example.com/x" not in text +async def test_fetch_forwards_python_only_readability_choice(web: FakeWeb) -> None: + """serve(use_readability_js=False) reaches the extractor explicitly.""" + web.add(ROBOTS, plain("", status=404)) + web.add(PAGE, html(ARTICLE_HTML)) + with patch( + "readabilipy.simple_json.simple_json_from_html_string", + wraps=readabilipy.simple_json.simple_json_from_html_string, + ) as spy: + async with connect(ignore_robots_txt=True, use_readability_js=False) as ( + session, + _, + ): + result = await call(session, {"url": PAGE}) + assert wire(result)["isError"] is False + spy.assert_called_once() + assert spy.call_args.kwargs["use_readability"] is False + + @pytest.mark.usefixtures("python_readability") async def test_empty_html_without_node_cannot_be_simplified(web: FakeWeb) -> None: # The pure-Python extractor returns "
" for an empty page, which diff --git a/src/fetch/tests/test_server.py b/src/fetch/tests/test_server.py index 4d00250e44..bc950cfdf4 100644 --- a/src/fetch/tests/test_server.py +++ b/src/fetch/tests/test_server.py @@ -8,6 +8,7 @@ import httpx import pytest +from unittest.mock import patch from mcp_server_fetch.server import ( PROXY_ENV_VARS, @@ -93,6 +94,35 @@ def test_empty_content_returns_error(self): result = extract_content_from_html(html) assert "" in result + def test_can_disable_readability_js(self): + """Callers can force the Python-only extraction path.""" + html = "

Hello World

" + + with patch( + "readabilipy.simple_json.simple_json_from_html_string", + return_value={"content": "

Hello World

"}, + ) as mock_extract: + result = extract_content_from_html(html, use_readability_js=False) + + assert "Hello World" in result + mock_extract.assert_called_once_with(html, use_readability=False) + + def test_auto_disables_readability_js_without_node(self): + """Python-only environments do not try the Node.js path.""" + html = "

Hello World

" + + with ( + patch("shutil.which", return_value=None), + patch( + "readabilipy.simple_json.simple_json_from_html_string", + return_value={"content": "

Hello World

"}, + ) as mock_extract, + ): + result = extract_content_from_html(html) + + assert "Hello World" in result + mock_extract.assert_called_once_with(html, use_readability=False) + class TestNormalizeProxyUrl: """Tests for normalize_proxy_url and normalize_proxy_env (#767)."""