Skip to content
Closed
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
6 changes: 5 additions & 1 deletion src/fetch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions src/fetch/src/mcp_server_fetch/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
)
)

Expand Down
17 changes: 14 additions & 3 deletions src/fetch/src/mcp_server_fetch/server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import ipaddress
import os
import shutil
import socket
from typing import Annotated, Tuple
from importlib.metadata import version
Expand Down Expand Up @@ -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:
Expand All @@ -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 = "<error>Page failed to be simplified from HTML</error>"
if not ret["content"]:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand All @@ -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\n<error>Content truncated. Call the fetch tool with a start_index of {next_start} to get more content.</error>"

return [TextContent(type="text", text=f"{prefix}Contents of {url}:\n{content}")]

@server.get_prompt()
Expand All @@ -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:
Expand Down
12 changes: 9 additions & 3 deletions src/fetch/tests/test_entrypoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
)


Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions src/fetch/tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 "<div></div>" for an empty page, which
Expand Down
30 changes: 30 additions & 0 deletions src/fetch/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import httpx
import pytest
from unittest.mock import patch

from mcp_server_fetch.server import (
PROXY_ENV_VARS,
Expand Down Expand Up @@ -93,6 +94,35 @@ def test_empty_content_returns_error(self):
result = extract_content_from_html(html)
assert "<error>" in result

def test_can_disable_readability_js(self):
"""Callers can force the Python-only extraction path."""
html = "<html><body><article><p>Hello World</p></article></body></html>"

with patch(
"readabilipy.simple_json.simple_json_from_html_string",
return_value={"content": "<p>Hello World</p>"},
) 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 = "<html><body><article><p>Hello World</p></article></body></html>"

with (
patch("shutil.which", return_value=None),
patch(
"readabilipy.simple_json.simple_json_from_html_string",
return_value={"content": "<p>Hello World</p>"},
) 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)."""
Expand Down
Loading