Skip to content

Commit 3c736c0

Browse files
committed
fix(fetch): add python-only readability fallback
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
1 parent 64b1cb0 commit 3c736c0

4 files changed

Lines changed: 200 additions & 66 deletions

File tree

src/fetch/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ The fetch tool will truncate the response, but by using the `start_index` argume
2626

2727
## Installation
2828

29-
Optionally: Install node.js, this will cause the fetch server to use a different HTML simplifier that is more robust.
29+
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.
3030

3131
### Using uv (recommended)
3232

@@ -170,6 +170,10 @@ This can be customized by adding the argument `--user-agent=YourUserAgent` to th
170170

171171
The server can be configured to use a proxy by using the `--proxy-url` argument.
172172

173+
### Customization - HTML simplifier
174+
175+
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.
176+
173177
## Windows Configuration
174178

175179
If you're experiencing timeout issues on Windows, you may need to set the `PYTHONIOENCODING` environment variable to ensure proper character encoding:

src/fetch/src/mcp_server_fetch/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,21 @@ def main():
1616
help="Ignore robots.txt restrictions",
1717
)
1818
parser.add_argument("--proxy-url", type=str, help="Proxy URL to use for requests")
19+
parser.add_argument(
20+
"--no-readability-js",
21+
action="store_true",
22+
help="Use readabilipy's Python-only HTML simplifier instead of the optional Node.js readability path",
23+
)
1924

2025
args = parser.parse_args()
21-
asyncio.run(serve(args.user_agent, args.ignore_robots_txt, args.proxy_url))
26+
asyncio.run(
27+
serve(
28+
args.user_agent,
29+
args.ignore_robots_txt,
30+
args.proxy_url,
31+
use_readability_js=False if args.no_readability_js else None,
32+
)
33+
)
2234

2335

2436
if __name__ == "__main__":

src/fetch/src/mcp_server_fetch/server.py

Lines changed: 70 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import shutil
12
from typing import Annotated, Tuple
23
from urllib.parse import urlparse, urlunparse
34

@@ -24,7 +25,7 @@
2425
DEFAULT_USER_AGENT_MANUAL = "ModelContextProtocol/1.0 (User-Specified; +https://github.com/modelcontextprotocol/servers)"
2526

2627

27-
def extract_content_from_html(html: str) -> str:
28+
def extract_content_from_html(html: str, use_readability_js: bool | None = None) -> str:
2829
"""Extract and convert HTML content to Markdown format.
2930
3031
Args:
@@ -33,8 +34,11 @@ def extract_content_from_html(html: str) -> str:
3334
Returns:
3435
Simplified markdown version of the content
3536
"""
37+
if use_readability_js is None:
38+
use_readability_js = shutil.which("node") is not None
39+
3640
ret = readabilipy.simple_json.simple_json_from_html_string(
37-
html, use_readability=True
41+
html, use_readability=use_readability_js
3842
)
3943
if not ret["content"]:
4044
return "<error>Page failed to be simplified from HTML</error>"
@@ -63,7 +67,9 @@ def get_robots_txt_url(url: str) -> str:
6367
return robots_url
6468

6569

66-
async def check_may_autonomously_fetch_url(url: str, user_agent: str, proxy_url: str | None = None) -> None:
70+
async def check_may_autonomously_fetch_url(
71+
url: str, user_agent: str, proxy_url: str | None = None
72+
) -> None:
6773
"""
6874
Check if the URL can be fetched by the user agent according to the robots.txt file.
6975
Raises a McpError if not.
@@ -80,15 +86,19 @@ async def check_may_autonomously_fetch_url(url: str, user_agent: str, proxy_url:
8086
headers={"User-Agent": user_agent},
8187
)
8288
except HTTPError:
83-
raise McpError(ErrorData(
84-
code=INTERNAL_ERROR,
85-
message=f"Failed to fetch robots.txt {robot_txt_url} due to a connection issue",
86-
))
89+
raise McpError(
90+
ErrorData(
91+
code=INTERNAL_ERROR,
92+
message=f"Failed to fetch robots.txt {robot_txt_url} due to a connection issue",
93+
)
94+
)
8795
if response.status_code in (401, 403):
88-
raise McpError(ErrorData(
89-
code=INTERNAL_ERROR,
90-
message=f"When fetching robots.txt ({robot_txt_url}), received status {response.status_code} so assuming that autonomous fetching is not allowed, the user can try manually fetching by using the fetch prompt",
91-
))
96+
raise McpError(
97+
ErrorData(
98+
code=INTERNAL_ERROR,
99+
message=f"When fetching robots.txt ({robot_txt_url}), received status {response.status_code} so assuming that autonomous fetching is not allowed, the user can try manually fetching by using the fetch prompt",
100+
)
101+
)
92102
elif 400 <= response.status_code < 500:
93103
return
94104
robot_txt = response.text
@@ -97,19 +107,25 @@ async def check_may_autonomously_fetch_url(url: str, user_agent: str, proxy_url:
97107
)
98108
robot_parser = Protego.parse(processed_robot_txt)
99109
if not robot_parser.can_fetch(str(url), user_agent):
100-
raise McpError(ErrorData(
101-
code=INTERNAL_ERROR,
102-
message=f"The sites robots.txt ({robot_txt_url}), specifies that autonomous fetching of this page is not allowed, "
103-
f"<useragent>{user_agent}</useragent>\n"
104-
f"<url>{url}</url>"
105-
f"<robots>\n{robot_txt}\n</robots>\n"
106-
f"The assistant must let the user know that it failed to view the page. The assistant may provide further guidance based on the above information.\n"
107-
f"The assistant can tell the user that they can try manually fetching the page by using the fetch prompt within their UI.",
108-
))
110+
raise McpError(
111+
ErrorData(
112+
code=INTERNAL_ERROR,
113+
message=f"The sites robots.txt ({robot_txt_url}), specifies that autonomous fetching of this page is not allowed, "
114+
f"<useragent>{user_agent}</useragent>\n"
115+
f"<url>{url}</url>"
116+
f"<robots>\n{robot_txt}\n</robots>\n"
117+
f"The assistant must let the user know that it failed to view the page. The assistant may provide further guidance based on the above information.\n"
118+
f"The assistant can tell the user that they can try manually fetching the page by using the fetch prompt within their UI.",
119+
)
120+
)
109121

110122

111123
async def fetch_url(
112-
url: str, user_agent: str, force_raw: bool = False, proxy_url: str | None = None
124+
url: str,
125+
user_agent: str,
126+
force_raw: bool = False,
127+
proxy_url: str | None = None,
128+
use_readability_js: bool | None = None,
113129
) -> Tuple[str, str]:
114130
"""
115131
Fetch the URL and return the content in a form ready for the LLM, as well as a prefix string with status information.
@@ -125,12 +141,16 @@ async def fetch_url(
125141
timeout=30,
126142
)
127143
except HTTPError as e:
128-
raise McpError(ErrorData(code=INTERNAL_ERROR, message=f"Failed to fetch {url}: {e!r}"))
144+
raise McpError(
145+
ErrorData(code=INTERNAL_ERROR, message=f"Failed to fetch {url}: {e!r}")
146+
)
129147
if response.status_code >= 400:
130-
raise McpError(ErrorData(
131-
code=INTERNAL_ERROR,
132-
message=f"Failed to fetch {url} - status code {response.status_code}",
133-
))
148+
raise McpError(
149+
ErrorData(
150+
code=INTERNAL_ERROR,
151+
message=f"Failed to fetch {url} - status code {response.status_code}",
152+
)
153+
)
134154

135155
page_raw = response.text
136156

@@ -140,7 +160,9 @@ async def fetch_url(
140160
)
141161

142162
if is_page_html and not force_raw:
143-
return extract_content_from_html(page_raw), ""
163+
return extract_content_from_html(
164+
page_raw, use_readability_js=use_readability_js
165+
), ""
144166

145167
return (
146168
page_raw,
@@ -182,6 +204,7 @@ async def serve(
182204
custom_user_agent: str | None = None,
183205
ignore_robots_txt: bool = False,
184206
proxy_url: str | None = None,
207+
use_readability_js: bool | None = None,
185208
) -> None:
186209
"""Run the fetch MCP server.
187210
@@ -232,22 +255,32 @@ async def call_tool(name, arguments: dict) -> list[TextContent]:
232255
raise McpError(ErrorData(code=INVALID_PARAMS, message="URL is required"))
233256

234257
if not ignore_robots_txt:
235-
await check_may_autonomously_fetch_url(url, user_agent_autonomous, proxy_url)
258+
await check_may_autonomously_fetch_url(
259+
url, user_agent_autonomous, proxy_url
260+
)
236261

237262
content, prefix = await fetch_url(
238-
url, user_agent_autonomous, force_raw=args.raw, proxy_url=proxy_url
263+
url,
264+
user_agent_autonomous,
265+
force_raw=args.raw,
266+
proxy_url=proxy_url,
267+
use_readability_js=use_readability_js,
239268
)
240269
original_length = len(content)
241270
if args.start_index >= original_length:
242271
content = "<error>No more content available.</error>"
243272
else:
244-
truncated_content = content[args.start_index : args.start_index + args.max_length]
273+
truncated_content = content[
274+
args.start_index : args.start_index + args.max_length
275+
]
245276
if not truncated_content:
246277
content = "<error>No more content available.</error>"
247278
else:
248279
content = truncated_content
249280
actual_content_length = len(truncated_content)
250-
remaining_content = original_length - (args.start_index + actual_content_length)
281+
remaining_content = original_length - (
282+
args.start_index + actual_content_length
283+
)
251284
# Only add the prompt to continue fetching if there is still remaining content
252285
if actual_content_length == args.max_length and remaining_content > 0:
253286
next_start = args.start_index + actual_content_length
@@ -262,7 +295,12 @@ async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult:
262295
url = arguments["url"]
263296

264297
try:
265-
content, prefix = await fetch_url(url, user_agent_manual, proxy_url=proxy_url)
298+
content, prefix = await fetch_url(
299+
url,
300+
user_agent_manual,
301+
proxy_url=proxy_url,
302+
use_readability_js=use_readability_js,
303+
)
266304
# TODO: after SDK bug is addressed, don't catch the exception
267305
except McpError as e:
268306
return GetPromptResult(

0 commit comments

Comments
 (0)