diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1a62ee5..4225e18 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.7.0] - 2026-07-06
+
+### Added
+- `--raw` (CLI) / `raw` (MCP): return the unprocessed body verbatim — no Readability, no content-type gate; `MARKFETCH_MAX_BYTES` still applies.
+
+### Fixed
+- Cloudflare-class CDNs 403 valid Chrome headers over HTTP/2 but pass them over HTTP/1.1 — markfetch now defaults to HTTP/1.1.
+- Empty-heading pruning truncated headings containing an inline `#` (`## Step 1 # download` became `# download`).
+
## [0.6.0] - 2026-05-14
### Added
diff --git a/README.md b/README.md
index 4fb3ae3..845ecce 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
[](https://nodejs.org/)
[](https://github.com/vasylenko/markfetch/blob/main/LICENSE)
-The built-in fetch tools that ship with AI coding agents return raw HTML, broken markdown, or `403` from Cloudflare more often than you'd like. `markfetch` sends **HTTP/2 with a coherent Chrome header set** so bot-detection systems see a real browser, then runs the response through the **same Reader View pipeline your browser uses** (Mozilla's Readability → turndown). The output is markdown indistinguishable from a human running "Save as Markdown" — on sites that would block a naive curl.
+The built-in fetch tools that ship with AI coding agents return raw HTML, broken markdown, or `403` from Cloudflare more often than you'd like. `markfetch` sends **a coherent Chrome header set** so bot-detection systems see a real browser, then runs the response through the **same Reader View pipeline your browser uses** (Mozilla's Readability → turndown). The output is markdown indistinguishable from a human running "Save as Markdown" — on sites that would block a naive curl.
One command, two surfaces:
@@ -61,13 +61,13 @@ gemini mcp add -s user markfetch npx -y markfetch
| CloudFlare `/markdown` | ✓ | ✓ | – | paid |
| **`markfetch`** | **✓** | **✓** | **✓ (8 codes)** | **✓** |
-- **Real-browser HTTP/2 + Chrome fingerprint.** ALPN-negotiated h2, `User-Agent`, `Sec-CH-UA-*`, `Sec-Fetch-*`, `Accept-*`. A Chrome UA with no client hints is a *stronger* automation signal than curl — `markfetch` sends the full coherent set, derived from the UA at startup so an override stays internally consistent.
+- **Real-browser request fingerprint.** `User-Agent`, `Sec-CH-UA-*`, `Sec-Fetch-*`, `Accept-*` — a coherent Chrome header set. A Chrome UA with no client hints is a *stronger* automation signal than curl, so `markfetch` sends the full set, derived from the UA at startup so an override stays internally consistent. HTTP/1.1 over TLS: some CDNs fingerprint undici's HTTP/2 connection and 403 it.
- **Reader-View-quality extraction.** [linkedom](https://github.com/WebReflection/linkedom) → [@mozilla/readability](https://github.com/mozilla/readability) → [turndown](https://github.com/mixmark-io/turndown) with GFM tables, strikethrough, and task lists. Code fences preserve `language-X` hints. Sphinx-style bare `
` blocks render as code, not escaped prose. Intraword underscores stay un-escaped — no more `list\_tools`.
-- **One tool, one shape (MCP).** `fetch_markdown(url, savePath?)` returns markdown in `content[0].text`. No `structuredContent`, no frontmatter, no metadata fields. Several major MCP clients (Claude Code CLI, VS Code/Copilot) forward only `structuredContent` to the model and drop `content[]` when both are present — `markfetch` deliberately stays on the channel your LLM can actually read.
+- **One tool, one shape (MCP).** `fetch_markdown(url, savePath?, raw?)` returns markdown in `content[0].text`. No `structuredContent`, no frontmatter, no metadata fields. Several major MCP clients (Claude Code CLI, VS Code/Copilot) forward only `structuredContent` to the model and drop `content[]` when both are present — `markfetch` deliberately stays on the channel your LLM can actually read.
-- **`savePath` / `-o` escape valve.** Pass an absolute path (MCP `savePath`) or `-o ` (CLI) and the markdown lands on disk instead of the response channel. Use it when your client's inline tool-result cap would truncate large responses, or to redirect output from a shell pipeline. The file is only ever the markdown of the URL — fetch errors return a `[code]` string and never touch the disk.
+- **`savePath` / `-o` escape valve.** Pass an absolute path (MCP `savePath`) or `-o ` (CLI) and the output lands on disk instead of the response channel. Use it when your client's inline tool-result cap would truncate large responses, or to redirect output from a shell pipeline. The file is only ever the fetched output (extracted markdown, or the raw body with `--raw`) — fetch errors return a `[code]` string and never touch the disk.
- **Whole document or honest failure.** No pagination, no truncation. If the document doesn't fit in `MARKFETCH_MAX_BYTES`, you get `too_large` — never a half-truth.
@@ -88,6 +88,9 @@ npx -y markfetch https://example.com/article -o article.md
# Pipe into another tool
npx -y markfetch https://example.com/article | pandoc -o article.pdf
+
+# Fetch JSON / APIs / page source verbatim
+npx -y markfetch --raw https://api.github.com/repos/vasylenko/markfetch
```
For repeat use, install once:
@@ -102,7 +105,8 @@ Flags:
| Flag | Purpose |
|---|---|
-| `-o, --output ` | Save markdown to a file (absolute or relative path). Default is stdout. |
+| `-o, --output ` | Save the output to a file (absolute or relative path). Default is stdout. |
+| `--raw` | Return the unprocessed response body as UTF-8 text — skips Readability and the content-type gate. For JSON, XML, plain text, or page source (binary is not byte-preserved). |
| `-V, --version` | Print version and exit. |
| `-h, --help` | Print usage and exit. |
@@ -115,8 +119,8 @@ Errors carry one of eight deterministic codes:
| `network_error` | DNS / TCP / TLS failure, or an unexpected internal error from the fetcher. |
| `http_error` | Upstream returned a non-2xx status. |
| `timeout` | Per-request budget `MARKFETCH_TIMEOUT_MS` exceeded. |
-| `unsupported_content_type` | Response was not `text/html` or `application/xhtml+xml`. |
-| `extraction_failed` | Readability returned no article content (typical for pure client-rendered SPAs). |
+| `unsupported_content_type` | Response was not `text/html` or `application/xhtml+xml` (not raised with `--raw` / `raw`). |
+| `extraction_failed` | Readability returned no article content (typical for pure client-rendered SPAs). Not raised with `--raw` / `raw`. |
| `too_large` | Response body or extracted markdown exceeded `MARKFETCH_MAX_BYTES`. |
| `save_failed` | `savePath` was given but `writeFile` failed (parent directory missing, permission denied, etc.). |
| `save_forbidden` | `savePath` resolves outside the allowed write roots — see [Write sandbox](#write-sandbox). MCP-only; the CLI has no sandbox. |
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 1e53c92..4999a79 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -6,13 +6,15 @@ Text processing pipeline:
```
URL
- → undici.fetch HTTP/2 via ALPN; full Chrome header set; Sec-CH-UA-* derived from UA
+ → undici.fetch HTTP/1.1; full Chrome header set; Sec-CH-UA-* derived from UA
→ linkedom.parseHTML
→ @mozilla/readability
→ turndown + GFM HTML → markdown
→ caller markdown body, or "Saved N bytes to /path" confirmation
```
+`raw` mode (`--raw` / MCP `raw`) returns the body straight from `undici.fetch`, skipping the parse → extract → convert steps.
+
Errors throw `MarkfetchError` uniformly from core; adapters catch once. Codes: `network_error`, `http_error`, `timeout`, `unsupported_content_type`, `extraction_failed`, `too_large`, `save_failed`; plus `save_forbidden`, emitted by the MCP adapter only (before `fetchMarkdown` runs — see "Asymmetric write sandbox" under Core Decisions). CLI emits `[code] message` to stderr and exits 1; MCP emits `{ isError: true, content: [{ text: "[code] message" }] }`.
## Core Decisions
@@ -23,7 +25,9 @@ Errors throw `MarkfetchError` uniformly from core; adapters catch once. Codes: `
- **Core throws, adapters translate.** Seven of the eight error codes surface from `core.ts` — five are thrown explicitly as `MarkfetchError`; `network_error`, `timeout`, and (sometimes) `http_error` are translated by `classifyError` from underlying-API errors (undici TypeErrors, AbortSignal timeouts). The eighth code, `save_forbidden`, is the exception — it's emitted by the MCP adapter before `fetchMarkdown` is invoked (see "Asymmetric write sandbox" below). New core codes need an `ErrorCode` union member + a throw site; adapters don't change.
-- **HTTP/2 + coherent Chrome fingerprint.** Wire protocol, headers, and UA must agree — a Chrome UA over HTTP/1.1 or without `Sec-CH-UA-*` is *more* suspicious than curl. `Sec-CH-UA-*` is derived from `MARKFETCH_USER_AGENT` at startup so override-coherence is mechanical.
+- **HTTP/1.1 + coherent Chrome header fingerprint.** Headers and UA must agree — a Chrome UA without `Sec-CH-UA-*` is a stronger bot signal than curl, so `Sec-CH-UA-*` is derived from `MARKFETCH_USER_AGENT` at startup and override-coherence is mechanical. HTTP/1.1 is deliberate: undici's h2 path hands `node:http2` a pre-connected socket whose first-flight frames some CDNs (Cloudflare, seen on `openai.com`) score as a bot and 403; the identical request over h1.1 passes. h2 buys nothing for single-shot GETs.
+
+- **`raw` passthrough.** Returns the fetched body verbatim — no Readability, no content-type gate; the fetch layer and `MARKFETCH_MAX_BYTES` cap stay. Same `fetchMarkdown` code path via a `raw` flag — no second entry point. Body is UTF-8 text; binary is not byte-preserved.
- **Single-channel MCP response.** `content[0].text` only. Several major MCP clients (Claude Code CLI, VS Code/Copilot) forward only `structuredContent` to the model and drop `content[]` when both are present — a single-channel response keeps the markdown reachable from those clients.
@@ -40,6 +44,7 @@ Errors throw `MarkfetchError` uniformly from core; adapters catch once. Codes: `
- **Authentication.** `MARKFETCH_AUTH_HEADER` env var (simple), or Chrome-cookie import for sites where the user is already logged in (frictionless, platform-specific, security-sensitive). Trigger: first useful internal / paywalled doc.
- **JS rendering fallback for SPAs.** Playwright / headless Chrome as a companion package (`markfetch-heavy`) so the lean package stays lean. Trigger: enough useful sites returning `extraction_failed`.
- **CloudFlare `/markdown` fallback.** Gated by `CF_AUTH_TOKEN`; fall back when Readability fails. Trigger: extraction failure rate stays high after Readability tuning.
+- **Browser-grade TLS + HTTP/2 impersonation.** Stricter CDN tiers fingerprint Node's TLS (JA3/JA4) and 403 every protocol. Apify's `impit` (BoringSSL-based) ships a Chrome-matching TLS + h2 fingerprint, near-drop-in for `undici.fetch`. Trigger: target sites 403 the current h1.1 + Chrome-header approach.
- **Cookie reuse across redirects within a single fetch.** Currently none. Trigger: a target serves content only after a session-cookie redirect.
- **Proxy support** (`MARKFETCH_PROXY_URL`) and **`Accept-Language` control** (`MARKFETCH_ACCEPT_LANGUAGE`). Trigger: corporate proxy / locale-specific content.
- **Single-binary distribution.** Bun's `build --compile`, Node SEA, or similar. Trigger: `npx` first-run latency feedback, or an offline / airgapped need.
diff --git a/package-lock.json b/package-lock.json
index 3a1e30b..7d4aa69 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "markfetch",
- "version": "0.6.0",
+ "version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "markfetch",
- "version": "0.6.0",
+ "version": "0.7.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
diff --git a/package.json b/package.json
index 9522e90..fb91208 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "markfetch",
- "version": "0.6.0",
+ "version": "0.7.0",
"description": "Fetch a URL, return clean markdown. MCP server and CLI for AI agents.",
"license": "MIT",
"author": {
diff --git a/src/cli.ts b/src/cli.ts
index 20af7f2..6c9fbb6 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -27,9 +27,13 @@ program
.argument("", "absolute http(s) URL to fetch")
.option(
"-o, --output ",
- "save markdown to file (absolute or relative path); default is stdout",
+ "save output to a file (absolute or relative path); default is stdout",
)
- .action(async (url: string, options: { output?: string }) => {
+ .option(
+ "--raw",
+ "return the unprocessed response body (skip Readability and the content-type gate)",
+ )
+ .action(async (url: string, options: { output?: string; raw?: boolean }) => {
// CLI resolves relative output paths against cwd before calling core;
// core requires an absolute path so the contract is unambiguous regardless
// of which adapter invokes it. Tilde expansion is intentionally NOT done
@@ -43,9 +47,10 @@ program
const { markdown, bytes, savedTo } = await fetchMarkdown({
url,
savePath,
+ raw: options.raw,
});
if (savedTo === undefined) {
- // Raw markdown body — no added newline, matches MCP content[0].text.
+ // Verbatim output — no added newline, matches MCP content[0].text.
process.stdout.write(markdown);
} else {
// Confirmation message — the only stdout newline the CLI ever adds.
diff --git a/src/core.ts b/src/core.ts
index f2a6d4b..d8a5d89 100644
--- a/src/core.ts
+++ b/src/core.ts
@@ -86,13 +86,8 @@ function deriveClientHints(ua: string): {
const clientHints = deriveClientHints(config.userAgent);
-// Enable HTTP/2 via TLS ALPN. Modern bot-detection systems and CDNs consider
-// wire protocol alongside header fingerprint; HTTP/2 over TLS pairs cleanly
-// with a Chrome header set. Servers that don't advertise h2 in ALPN fall back
-// to HTTP/1.1 transparently during the TLS handshake — no manual retry needed.
-// Plain-HTTP connections (port 80) skip ALPN entirely and use HTTP/1.1,
-// accepting the protocol/fingerprint mismatch in that case.
-setGlobalDispatcher(new Agent({ allowH2: true }));
+// HTTP/1.1 because undici's h2 handshake trips CDN bot-scoring (Cloudflare 403s Chrome headers over h2 but not h1.1).
+setGlobalDispatcher(new Agent({ allowH2: false }));
const TURNDOWN = new TurndownService({
headingStyle: "atx",
@@ -140,8 +135,11 @@ TURNDOWN.escape = (s: string): string =>
TURNDOWN.addRule("barePre", {
filter: (node) => node.nodeName === "PRE" && !node.querySelector("code"),
replacement: (_content, node) => {
- const text = (node.textContent ?? "").replace(/\n+$/, "");
- return "\n\n```\n" + text + "\n```\n\n";
+ // index-based trailing-newline trim — /\n+$/ backtracks super-linearly
+ const text = node.textContent ?? "";
+ let end = text.length;
+ while (end > 0 && text[end - 1] === "\n") end--;
+ return "\n\n```\n" + text.slice(0, end) + "\n```\n\n";
},
});
@@ -228,7 +226,8 @@ function chromeHeaders(): Record {
};
}
-async function fetchHtml(url: string): Promise {
+// raw = skip the HTML content-type gate and pass any body through verbatim.
+async function fetchBody(url: string, raw: boolean): Promise {
const response = await fetch(url, {
signal: AbortSignal.timeout(config.timeoutMs),
headers: chromeHeaders(),
@@ -241,15 +240,17 @@ async function fetchHtml(url: string): Promise {
);
}
- const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
- if (
- !contentType.startsWith("text/html") &&
- !contentType.startsWith("application/xhtml+xml")
- ) {
- throw new MarkfetchError(
- "unsupported_content_type",
- `Content-Type: ${contentType || "(missing)"}`,
- );
+ if (!raw) {
+ const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
+ if (
+ !contentType.startsWith("text/html") &&
+ !contentType.startsWith("application/xhtml+xml")
+ ) {
+ throw new MarkfetchError(
+ "unsupported_content_type",
+ `Content-Type: ${contentType || "(missing)"}`,
+ );
+ }
}
const declaredLength = Number(response.headers.get("content-length") ?? 0);
@@ -257,12 +258,13 @@ async function fetchHtml(url: string): Promise {
throw enforceTooLarge("Content-Length", declaredLength);
}
- const html = await response.text();
- if (Buffer.byteLength(html, "utf8") > config.maxBytes) {
- throw enforceTooLarge("Body", Buffer.byteLength(html, "utf8"));
+ const body = await response.text();
+ const size = Buffer.byteLength(body, "utf8");
+ if (size > config.maxBytes) {
+ throw enforceTooLarge("Body", size);
}
- return html;
+ return body;
}
function enforceTooLarge(stage: string, actual: number): MarkfetchError {
@@ -379,24 +381,39 @@ function convertToMarkdown(article: {
// an interactive widget (MDN browser-compat tables, MCP spec diagrams,
// etc.) but leaves the orphan heading. Iterate until stable so a parent
// section that becomes empty after its last child heading is pruned also
- // gets removed.
+ // gets removed. Blank spans match line-by-line — \s* backtracks super-linearly
+ // and lets a mid-line "#" pass as the next heading.
let prev: string;
do {
prev = result;
- result = result.replaceAll(/^(#{1,6}) [^\n]+\s*(?=#{1,6} )/gm, "");
+ result = result.replaceAll(/^#{1,6} [^\n]+\n(?:[ \t]*\n)*[ \t]*(?=#{1,6} )/gm, "");
} while (result !== prev);
// The lookahead-based iteration above can't catch a trailing empty
// heading at EOF (no following heading to anchor on). One-shot pass.
- result = result.replace(/(?:^|\n)#{1,6} [^\n]+\s*$/, "");
+ result = result.replace(/(?:^|\n)#{1,6} [^\n]+(?:\n[ \t]*)*$/, "");
return result;
}
+// Throws extraction_failed when Readability finds nothing (pure client-rendered SPAs).
+function extractMarkdown(html: string, url: string): string {
+ const article = extractArticle(html, url);
+ if (!article) {
+ throw new MarkfetchError(
+ "extraction_failed",
+ "Readability returned no article content.",
+ );
+ }
+ return convertToMarkdown(article);
+}
+
// --- Unified entry point ---
// Adapters call this with already-validated inputs (URL syntax checked by the
// adapter's schema; savePath, if present, is an absolute path — adapters
// resolve any relative-vs-absolute concerns before calling).
//
+// With raw, unsupported_content_type and extraction_failed cannot arise.
+//
// Errors are thrown uniformly as MarkfetchError. Adapters catch and translate:
// - mcp.ts catches → errorResult(code, message) → MCP {isError, content}
// - cli.ts catches → console.error("[code] message") → sets process.exitCode = 1
@@ -412,31 +429,18 @@ function convertToMarkdown(article: {
export async function fetchMarkdown(input: {
url: string;
savePath?: string;
+ raw?: boolean;
}): Promise<{ markdown: string; bytes: number; savedTo?: string }> {
- const { url, savePath } = input;
- const html = await fetchHtml(url);
- const article = extractArticle(html, url);
- if (!article) {
- throw new MarkfetchError(
- "extraction_failed",
- "Readability returned no article content.",
- );
- }
- const markdown = convertToMarkdown(article);
- const bytes = Buffer.byteLength(markdown, "utf8");
- if (bytes > config.maxBytes) {
- throw new MarkfetchError(
- "too_large",
- `Markdown ${bytes} bytes > MARKFETCH_MAX_BYTES (${config.maxBytes})`,
- );
- }
- // The file at savePath is only ever the markdown of the URL. Fetch /
- // extraction / size-cap failures all throw above and never reach this
- // branch, so the file is never written for them. save_failed is its own
- // phase, handled here.
+ const { url, savePath, raw = false } = input;
+ const body = await fetchBody(url, raw);
+ const content = raw ? body : extractMarkdown(body, url);
+ const bytes = Buffer.byteLength(content, "utf8");
+ if (bytes > config.maxBytes) throw enforceTooLarge("Output", bytes);
+ // The file only ever holds the fetched output: failures throw above and
+ // never reach this branch. save_failed is its own phase, handled here.
if (savePath !== undefined) {
try {
- await writeFile(savePath, markdown, "utf8");
+ await writeFile(savePath, content, "utf8");
} catch (err) {
// Node's fs errors prefix the message with the errno already
// (e.g. "ENOENT: no such file or directory, open '/path'"), so
@@ -448,7 +452,7 @@ export async function fetchMarkdown(input: {
e.message || (e.code ?? "unknown error"),
);
}
- return { markdown, bytes, savedTo: savePath };
+ return { markdown: content, bytes, savedTo: savePath };
}
- return { markdown, bytes };
+ return { markdown: content, bytes };
}
diff --git a/src/mcp.ts b/src/mcp.ts
index 94648f4..049a226 100644
--- a/src/mcp.ts
+++ b/src/mcp.ts
@@ -33,7 +33,7 @@ server.registerTool(
"fetch_markdown",
{
description:
- "Fetch a single public HTTP/S URL and return its main article content as clean markdown. Best for articles, documentation, blog posts, news, and reference pages. Non-HTML responses return `unsupported_content_type`. Pure client-rendered SPAs with no extractable static HTML return `extraction_failed`; SPAs that ship server-rendered or SEO-prerendered HTML will extract whatever static content they expose. Also supports saving the markdown to a file, e.g., to bypass client tool-result size limits or to reuse later. Saved files must land inside the allowed write roots (defaults: system temp dir and the server's working directory; configurable via `MARKFETCH_ALLOWED_WRITE_ROOTS`); paths outside return `save_forbidden`.",
+ "Fetch a public HTTP/S URL and return its main article content as clean markdown. Best for articles, documentation, blog posts, and reference pages. Non-HTML responses return `unsupported_content_type` unless `raw` is set; pure client-rendered SPAs return `extraction_failed`. Set `savePath` to write the output to a file instead of returning it inline.",
inputSchema: {
url: z
.string()
@@ -46,11 +46,17 @@ server.registerTool(
.refine(isAbsolute, "savePath must be an absolute filesystem path")
.optional()
.describe(
- "Optional. When provided, the fetched markdown is written to this absolute filesystem path and the response becomes a small confirmation. Use this when the markdown might exceed your client's tool-result inline cap. Must be an absolute path on the host platform (e.g., `/foo/bar.md` on POSIX; `C:\\foo\\bar.md` or `\\\\server\\share\\bar.md` on Windows); relative paths and tilde paths (`~/...`) are rejected by the schema. Writes are confined to an allow-listed sandbox — defaults are the system temp dir (`os.tmpdir()`) and the server's working directory; operators can override with `MARKFETCH_ALLOWED_WRITE_ROOTS` (path-delimiter-separated). A `savePath` outside the allowed roots returns `save_forbidden` and no file is created. Existing files are overwritten; the parent directory must exist (caller's responsibility). The file is written only on fetch success — fetch / extraction / size-cap errors return a `[code]` string and never touch the file.",
+ "Absolute path to write the output to instead of returning it inline; the response becomes a short confirmation. Use when the output might exceed your client's tool-result cap. Relative and `~` paths are rejected. Writes are sandboxed to allowed roots (defaults: system temp dir and the server's working directory; override with `MARKFETCH_ALLOWED_WRITE_ROOTS`) — paths outside return `save_forbidden`. Existing files are overwritten; the parent directory must exist. Fetch errors never touch the file.",
+ ),
+ raw: z
+ .boolean()
+ .optional()
+ .describe(
+ "Return the response body verbatim as UTF-8 text (binary is not byte-preserved), skipping Readability and the HTML content-type gate — for JSON, APIs, or raw page source. `MARKFETCH_MAX_BYTES` still applies.",
),
},
},
- async ({ url, savePath }) => {
+ async ({ url, savePath, raw }) => {
// Sandbox gate (MCP-only; CLI is intentionally unbounded). Runs before
// fetchMarkdown so a forbidden path short-circuits the fetch. The
// canonicalized check.resolved — not the caller's savePath — is what
@@ -68,6 +74,7 @@ server.registerTool(
const { markdown, bytes, savedTo } = await fetchMarkdown({
url,
savePath: resolvedSavePath,
+ raw,
});
if (savedTo !== undefined) {
// Echo the caller's original savePath in the confirmation. The bytes
diff --git a/tests/cli.test.ts b/tests/cli.test.ts
index ebdcdd5..83c9bb1 100644
--- a/tests/cli.test.ts
+++ b/tests/cli.test.ts
@@ -76,6 +76,23 @@ test("CLI: happy path → markdown to stdout, stderr empty, exit 0", async () =>
}
});
+test("CLI: --raw returns the unprocessed body verbatim to stdout", async () => {
+ const RAW = '{"hello":"world","n":42}';
+ const mock = await startMock((_req, res) => {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(RAW);
+ });
+ try {
+ const { code, stdout, stderr } = await runCli([mock.url, "--raw"]);
+ assert.equal(code, 0, `stderr was: ${stderr}`);
+ assert.equal(stderr, "");
+ // Byte-for-byte: content-type gate and Readability skipped, no added newline.
+ assert.equal(stdout, RAW);
+ } finally {
+ await mock.close();
+ }
+});
+
test("CLI: -o writes file, prints confirmation to stdout", async () => {
const mock = await startMock((_req, res) => {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
diff --git a/tests/fixtures/10-inline-hash-heading.expected.md b/tests/fixtures/10-inline-hash-heading.expected.md
new file mode 100644
index 0000000..b8c1d64
--- /dev/null
+++ b/tests/fixtures/10-inline-hash-heading.expected.md
@@ -0,0 +1,11 @@
+# Runner setup guide
+
+This guide walks through installing and registering a self-hosted runner, from download to the first green build. Each step is safe to re-run if something goes wrong halfway.
+
+## Step 1 # download the agent
+
+Grab the latest release tarball for your platform and unpack it into an empty directory owned by the service user. Keep the directory outside the repository checkout.
+
+## Step 2 # register with the server
+
+Run the config script with the registration token from the project settings page. The token is single-use and expires after one hour, so generate it right before registering.
\ No newline at end of file
diff --git a/tests/fixtures/10-inline-hash-heading.html b/tests/fixtures/10-inline-hash-heading.html
new file mode 100644
index 0000000..a9ecdb5
--- /dev/null
+++ b/tests/fixtures/10-inline-hash-heading.html
@@ -0,0 +1,14 @@
+
+
+Runner setup guide
+
+
+
Runner setup guide
+
This guide walks through installing and registering a self-hosted runner, from download to the first green build. Each step is safe to re-run if something goes wrong halfway.
+
Step 1 # download the agent
+
Grab the latest release tarball for your platform and unpack it into an empty directory owned by the service user. Keep the directory outside the repository checkout.
+
Step 2 # register with the server
+
Run the config script with the registration token from the project settings page. The token is single-use and expires after one hour, so generate it right before registering.
+
+
+
diff --git a/tests/server.test.ts b/tests/server.test.ts
index 77f05a0..cacdae8 100644
--- a/tests/server.test.ts
+++ b/tests/server.test.ts
@@ -219,6 +219,73 @@ test("error: extraction_failed when Readability finds nothing", async () => {
}
});
+test("raw: non-HTML body is returned verbatim (content-type gate and Readability bypassed)", async () => {
+ const RAW = '{"hello":"world","items":[1,2,3]}';
+ const mock = await startMock((_req, res) => {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(RAW);
+ });
+ const client = await spawnClient();
+ try {
+ const result = await client.callTool({
+ name: "fetch_markdown",
+ arguments: { url: mock.url, raw: true },
+ });
+ // A JSON body would be unsupported_content_type without raw; here it comes
+ // back byte-for-byte, proving both the gate and extraction were skipped.
+ assert.equal(result.isError, false);
+ assert.equal(textOf(result), RAW);
+ } finally {
+ await client.close();
+ await mock.close();
+ }
+});
+
+test("raw: HTML is returned unprocessed, not run through Readability", async () => {
+ const HTML =
+ "