Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
18 changes: 5 additions & 13 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -142,24 +142,16 @@ jobs:
working-directory: src/${{ matrix.package }}
run: uv sync --locked --all-extras --dev

# fetch's tests pin readabilipy's Readability.js path, which needs Node
# and readabilipy's JavaScript dependencies. readabilipy >= 0.3.0 ships
# only their package.json and runs `npm install` on first use, but on
# Windows its subprocess call cannot find npm (npm.cmd), so it falls back
# to the pure-Python extractor. Install them here, as an operator would.
# fetch's tests pin the Readability.js path, which needs Node and npm.
# readabilipy >= 0.3.0 ships only its JavaScript's package.json, and the
# server installs the dependencies on first use, resolving npm through
# PATH so Windows' npm.cmd is found (#5313). Nothing is pre-installed
# here, so this leg proves that install works on Windows.
- uses: actions/setup-node@v7
if: matrix.package == 'fetch'
with:
node-version: 24

- name: Install readabilipy's JavaScript dependencies
if: matrix.package == 'fetch'
working-directory: src/fetch
shell: bash
run: |
jsdir=$(uv run --frozen python -c "import os, readabilipy; print(os.path.join(os.path.dirname(readabilipy.__file__), 'javascript'))")
cd "$jsdir" && npm install --no-audit --no-fund

- name: Run pytest
working-directory: src/${{ matrix.package }}
run: uv run --frozen pytest
Expand Down
43 changes: 36 additions & 7 deletions src/fetch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ The server speaks both eras of the Model Context Protocol over stdio, with nothi
## Installation

Optionally: Install node.js, this will cause the fetch server to use a different HTML simplifier that is more robust.
Its JavaScript dependencies are then installed with npm on first use, which contacts the npm registry.
To keep the server from ever starting Node.js, see [Customization - HTML extraction](#customization---html-extraction).

### Using uv (recommended)
Expand Down Expand Up @@ -213,11 +214,34 @@ installed with the server). A `socks://` proxy URL, as many desktop proxy settin

### Customization - HTML extraction

HTML is converted to Markdown by [readabilipy](https://github.com/alan-turing-institute/ReadabiliPy). When Node.js is
on the `PATH`, readabilipy runs Mozilla's Readability.js in a Node.js subprocess; otherwise it uses its own
pure-Python extractor, which keeps headings and text but drops link targets. The Node.js subprocess runs with no
timeout, so a Node.js that hangs, or fails in a way readabilipy does not detect, stalls the tool call until the client
gives up.
HTML is converted to Markdown with [readabilipy](https://github.com/alan-turing-institute/ReadabiliPy). When Node.js 14
or newer is on the `PATH`, the server runs Mozilla's Readability.js (readabilipy's `ExtractArticle.js`) in a Node.js
subprocess; otherwise it uses readabilipy's pure-Python extractor, which keeps headings and text but drops link
targets. The subprocess runs off the server's event loop, so other requests are answered while it works, and under a
30 second timeout: a Node.js that hangs is killed, together with anything it started, and that page is extracted with
the pure-Python extractor instead. If Readability.js fails in any other way, the same fallback is used. Every reason
for a fallback is written to stderr (the server's log), never to stdout, which carries the protocol.

**Readability.js's dependencies are installed with npm on first use, which reaches the npm registry.** readabilipy
ships only the `package.json` for its JavaScript (`@mozilla/readability`, `jsdom`, `minimist`). The first time the
server extracts HTML with Node.js on the `PATH` and those packages missing, it runs `npm install` in readabilipy's
`javascript` directory, inside that tool call, so:

- `registry.npmjs.org` belongs in the egress allowlist of a deployment that restricts outbound traffic, unless the
packages are installed ahead of time (below) or Readability.js is turned off. Without it the install fails and the
server uses the pure-Python extractor.
- The JavaScript that runs is resolved from readabilipy's unpinned version ranges when it is installed, so pinning
`mcp-server-fetch` does not pin it.
- The install runs at most once per server process, under a 120 second timeout. Its output goes to stderr. If it
fails, or Node.js is missing or older than 14, the server uses the pure-Python extractor until it is restarted.

To install the packages ahead of time (in a container image, say), run `npm install` in readabilipy's `javascript`
directory from the Python environment the server runs in; the server then finds them and installs nothing:

```bash
cd "$(python -c "import os, readabilipy; print(os.path.join(os.path.dirname(readabilipy.__file__), 'javascript'))")"
npm install --no-audit --no-fund
```

Readability.js discards hidden elements. Pages rendered progressively on the server often send a visible loading
shell, then the real content in a container hidden (`display: none`, `visibility: hidden`, `hidden`) until scripts
Expand All @@ -226,7 +250,8 @@ Readability.js extracted, the server unhides it and runs Readability.js again, k
text. Other pages are extracted once, exactly as before.

To always use the pure-Python extractor, add the argument `--no-readability-js` to the `args` list in the
configuration. The server then never starts Node.js (or npm), whether or not it is installed.
configuration. The server then never starts Node.js (or npm), whether or not it is installed, and never contacts the
npm registry.

## Windows Configuration

Expand Down Expand Up @@ -270,6 +295,10 @@ If you're experiencing timeout issues on Windows, you may need to set the `PYTHO

This addresses character encoding issues that can cause the server to timeout on Windows systems.

Readability.js works on Windows as it does elsewhere: the server finds Node.js and npm (`npm.cmd`) on the `PATH`, and
installs Readability.js's dependencies on first use as described in
[Customization - HTML extraction](#customization---html-extraction).

## Debugging

You can use the MCP inspector to debug the server. For uvx installations:
Expand All @@ -294,7 +323,7 @@ uv sync --frozen --all-extras --dev
uv run --frozen pytest
```

The tests drive `serve()` through an MCP `ClientSession` in-process (and, in `tests/test_eras.py`, through a `Client` as a 2026-07-28 and as a 2025-11-25 client), with HTTP mocked, so they need no network. The HTML extraction tests need Node.js on `PATH` (readabilipy uses Readability.js when Node is present and a pure-Python fallback when it is not).
The tests drive `serve()` through an MCP `ClientSession` in-process (and, in `tests/test_eras.py`, through a `Client` as a 2026-07-28 and as a 2025-11-25 client), with HTTP mocked, so they need no network. The HTML extraction tests need Node.js and npm on `PATH` (the server uses Readability.js when Node is present and a pure-Python fallback when it is not), and the first run installs Readability.js's dependencies from the npm registry.

Coverage, measured per file with branch coverage:

Expand Down
259 changes: 259 additions & 0 deletions src/fetch/src/mcp_server_fetch/readability_js.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
# Runs Mozilla's Readability.js (readabilipy's `ExtractArticle.js`) under
# Node.js in a subprocess the server controls, instead of through readabilipy's
# own wrapper (`simple_json_from_html_string(use_readability=True)`).
#
# readabilipy's wrapper had four defects for a stdio MCP server, each fixed
# here rather than worked around at the call site:
#
# - No timeout (#5306). Its `subprocess.run` calls wait forever, so a Node.js
# or npm that hangs stalled the tool call. Every process here runs under a
# timeout, and on timeout its whole process tree is killed (a process group
# on POSIX, `taskkill /T` on Windows), so nothing is left running.
# - Child output on fd 1 (#5310). Its `npm install` inherits the server's
# stdout, which over stdio is the JSON-RPC channel, and it `print`s Node's
# stderr to stdout on a failure. Here every child gets stdin from the null
# device (it must never read the server's JSON-RPC input either) and pipes
# for stdout and stderr; whatever a child prints is forwarded to stderr.
# - npm not found on Windows (#5313). It runs a bare `npm`, which Windows'
# `CreateProcess` does not resolve to `npm.cmd`. Here `node` and `npm` are
# resolved with `shutil.which`, which honours `PATHEXT`.
# - A failed Readability.js run raised out of the tool call. Here any failure
# (no Node.js, an old one, no npm, a failed install, a timeout, a crash, bad
# output) returns None, and the caller falls back to readabilipy's
# pure-Python extractor, with the reason written to stderr.
#
# readabilipy >= 0.3.0 ships only `package.json` for its JavaScript and runs
# `npm install` on first use, which reaches the npm registry during a tool call
# (#4830). That stays the behaviour (the README states it), but the install now
# runs at most once per process, under a lock and a timeout. It installs into
# readabilipy's own `javascript` directory, so dependencies an operator
# installed there ahead of time (the README says how) are found and nothing is
# fetched.
#
# Everything here is synchronous: the caller runs extraction in a worker
# thread (`asyncio.to_thread`), so the server's event loop never waits on it.

from __future__ import annotations

import json
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import threading
from pathlib import Path

import readabilipy

# How long one Readability.js run may take before it is killed.
EXTRACT_TIMEOUT_SECONDS = 30.0
# How long the one-time `npm install` of readabilipy's JavaScript may take.
INSTALL_TIMEOUT_SECONDS = 120.0
# How long `node -v` may take.
VERSION_TIMEOUT_SECONDS = 10.0
# readabilipy's `package.json` declares `"engines": {"node": ">=14.0.0"}`.
MIN_NODE_MAJOR = 14
# What `ExtractArticle.js` requires; all three present means installed.
JS_DEPENDENCIES = ("@mozilla/readability", "jsdom", "minimist")

_probe_lock = threading.Lock()
_probed = False
_node: str | None = None


def javascript_dir() -> Path:
"""readabilipy's `javascript` directory: `ExtractArticle.js` and `node_modules`."""
return Path(readabilipy.__file__).parent / "javascript"


def _log(message: str) -> None:
# stderr only: stdout is the JSON-RPC channel.
print(f"mcp-server-fetch: {message}", file=sys.stderr, flush=True)


def _forward(output: str) -> None:
"""Copy a child's captured output to stderr, never to stdout."""
if output:
sys.stderr.write(output if output.endswith("\n") else output + "\n")
sys.stderr.flush()


def _kill_tree(proc: subprocess.Popen[str]) -> None:
"""Kill ``proc`` and everything it started (npm runs node, `npm.cmd` runs cmd)."""
if sys.platform == "win32":
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
else:
try:
# start_new_session made the child a process-group leader.
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
# Whatever the tree kill did, the direct child is certainly gone after this.
proc.kill()


def _run(
argv: list[str], timeout: float, cwd: Path | None = None
) -> subprocess.CompletedProcess[str] | None:
"""Run ``argv`` detached from the server's stdio, or None if it could not
start or did not finish within ``timeout`` seconds (it is killed then)."""
try:
proc = subprocess.Popen(
argv,
cwd=cwd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
# POSIX only (ignored on Windows): its own process group, so a
# timeout can kill the processes it starts as well.
start_new_session=True,
)
except OSError as e:
_log(f"could not start {argv[0]}: {e}")
return None
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
_kill_tree(proc)
proc.communicate()
Comment thread
Copilot marked this conversation as resolved.
Resolved
_log(f"{Path(argv[0]).name} did not finish within {timeout:g} s and was killed")
return None
return subprocess.CompletedProcess(argv, proc.returncode, stdout, stderr)


def _node_major(version: str) -> int | None:
"""The major version in `node -v` output (``v24.1.0``), or None."""
head = version.strip().lstrip("v").split(".", 1)[0]
return int(head) if head.isdigit() else None


def _dependencies_installed(jsdir: Path) -> bool:
modules = jsdir / "node_modules"
return all((modules / name / "package.json").is_file() for name in JS_DEPENDENCIES)


def _install_dependencies(jsdir: Path) -> bool:
"""`npm install` readabilipy's JavaScript dependencies; whether it worked."""
npm = shutil.which("npm")
if npm is None:
_log(
"npm was not found on PATH, so Readability.js's dependencies cannot be "
f"installed in {jsdir}; using the pure-Python extractor"
)
return False
_log(
f"installing Readability.js's dependencies with npm in {jsdir} (first use; "
"this contacts the npm registry)"
)
done = _run(
[npm, "install", "--no-audit", "--no-fund"],
timeout=INSTALL_TIMEOUT_SECONDS,
cwd=jsdir,
)
if done is None:
return False
_forward(done.stdout)
_forward(done.stderr)
if done.returncode != 0 or not _dependencies_installed(jsdir):
_log(
f"npm install failed (exit {done.returncode}); using the pure-Python extractor"
)
return False
return True


def _find_node() -> str | None:
"""A Node.js ready to run `ExtractArticle.js`, installing its dependencies
if they are missing, or None (the reason is logged)."""
node = shutil.which("node")
if node is None:
_log("Node.js was not found on PATH; using the pure-Python extractor")
return None
done = _run([node, "-v"], timeout=VERSION_TIMEOUT_SECONDS)
major = _node_major(done.stdout) if done and done.returncode == 0 else None
if major is None or major < MIN_NODE_MAJOR:
_log(
f"Node.js >= {MIN_NODE_MAJOR} is needed for Readability.js "
f"({node} did not report one); using the pure-Python extractor"
)
return None
jsdir = javascript_dir()
if not _dependencies_installed(jsdir) and not _install_dependencies(jsdir):
return None
return node


def node_path() -> str | None:
"""The Node.js to run Readability.js with, or None to use pure Python.

Probed once per process, under a lock, so concurrent first calls run one
`npm install` between them. A failed probe is not retried until the server
restarts, so a missing or broken Node.js costs one probe, not one per call.
"""
global _probed, _node
with _probe_lock:
if not _probed:
_node = _find_node()
_probed = True
return _node


def extract_article(html: str) -> str | None:
"""Readability.js's article HTML for ``html`` ("" when it finds none), or
None when Readability.js is unavailable or failed, to fall back on."""
node = node_path()
if node is None:
return None
jsdir = javascript_dir()
with tempfile.TemporaryDirectory(
prefix="mcp-server-fetch-", ignore_cleanup_errors=True
) as tmp:
page = Path(tmp, "page.html")
result = Path(tmp, "article.json")
page.write_text(html, encoding="utf-8")
done = _run(
[
node,
str(jsdir / "ExtractArticle.js"),
"-i",
str(page),
"-o",
str(result),
],
timeout=EXTRACT_TIMEOUT_SECONDS,
cwd=jsdir,
)
if done is None:
_log(
"Readability.js was stopped; using the pure-Python extractor for this "
"page. Start the server with --no-readability-js to never use it."
)
return None
if done.returncode != 0 or not result.is_file():
_forward(done.stderr)
_log(
f"Readability.js failed (exit {done.returncode}); using the "
"pure-Python extractor for this page"
)
return None
try:
article = json.loads(result.read_text(encoding="utf-8"))
except ValueError:
_log(
"Readability.js wrote unreadable output; using the pure-Python extractor"
)
return None
# `Readability.parse()` returns null for a page with no article.
content = article.get("content") if isinstance(article, dict) else None
return content if isinstance(content, str) else ""
Loading
Loading