From 4ba37a7a84d318b733c7d7dde61447316ff2a3f2 Mon Sep 17 00:00:00 2001 From: Serhii Vasylenko Date: Mon, 6 Jul 2026 01:23:33 +0200 Subject: [PATCH 01/15] fix: default to HTTP/1.1 transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit undici's h2 path hands a pre-connected socket to node:http2, whose first-flight frame pattern some CDNs (Cloudflare, observed on openai.com) score as a bot and 403 — even against a valid Chrome header set, while the identical request over HTTP/1.1 is let through. h2 buys nothing for single-shot GETs and every h2 server speaks h1.1, so pin allowH2:false. --- src/core.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/core.ts b/src/core.ts index f2a6d4b..481b042 100644 --- a/src/core.ts +++ b/src/core.ts @@ -86,13 +86,14 @@ 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 })); +// Force HTTP/1.1 (undici's default, pinned here so it isn't silently switched +// to h2). HTTP/2 buys nothing for single-shot GETs — no multiplexing to exploit, +// negligible header compression — and undici's h2 path hands a pre-connected +// socket to node:http2, whose first-flight frame pattern some CDNs (Cloudflare, +// observed on openai.com) score as a bot and answer with 403 even against a +// valid Chrome header set. HTTP/1.1 sidesteps that, and every h2 server also +// speaks it, so nothing is lost. +setGlobalDispatcher(new Agent({ allowH2: false })); const TURNDOWN = new TurndownService({ headingStyle: "atx", From 1f3d0620a037e146f3296a76a8c787670ca8bb3e Mon Sep 17 00:00:00 2001 From: Serhii Vasylenko Date: Mon, 6 Jul 2026 01:23:33 +0200 Subject: [PATCH 02/15] docs: describe HTTP/1.1-primary transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match CHANGELOG, README, and SPEC to the transport default. Correct the SPEC decision that called Chrome-over-h1.1 more suspicious than curl — observed evidence is the opposite for these edges. Record impit (browser-grade impersonation) as the escalation path for stricter CDN tiers. --- CHANGELOG.md | 3 +++ README.md | 4 ++-- docs/SPEC.md | 5 +++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a62ee5..ae9fb58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- 403 from CDNs that fingerprint the HTTP/2 connection (Cloudflare, observed on `openai.com/products/release-notes/`): a valid Chrome header set was rejected over h2 but let through over HTTP/1.1. markfetch now defaults to HTTP/1.1. undici's h2 path hands a pre-connected socket to `node:http2`, whose first-flight frame pattern trips that scoring; h2 also offers nothing for single-shot GETs and every h2 server speaks HTTP/1.1, so nothing is lost. + ## [0.6.0] - 2026-05-14 ### Added diff --git a/README.md b/README.md index 4fb3ae3..58b4676 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![node](https://img.shields.io/node/v/markfetch.svg?color=10b981)](https://nodejs.org/) [![license](https://img.shields.io/npm/l/markfetch.svg?color=10b981)](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,7 +61,7 @@ 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, and h2 buys nothing for single-shot fetches. - **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`.
 
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 1e53c92..d5c564c 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -6,7 +6,7 @@ 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
@@ -23,7 +23,7 @@ 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. Wire protocol is deliberately HTTP/1.1, not h2: undici's h2 path hands a pre-connected socket to `node:http2`, whose first-flight frame pattern some CDNs (Cloudflare, observed on `openai.com`) score as a bot and 403 — the identical request over h1.1 is let through. h2 also buys nothing for single-shot GETs, and every h2 server speaks h1.1, so nothing is lost. (Earlier revisions assumed Chrome-over-h1.1 was *more* suspicious than curl; the observed evidence is the opposite for these edges.)
 
 - **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 +40,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 key on Node's TLS JA3/JA4 + UA mismatch and 403 every protocol — neither h1.1 nor `node:http2` beats them (both carry Node's OpenSSL fingerprint under a Chrome UA). A BoringSSL-based impersonating client (Apify's `impit`) delivers genuine HTTP/2 with a Chrome-matching TLS + h2 fingerprint; near-drop-in for `undici.fetch`, one native addon (~6 MB, prebuilt binaries). 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.

From a52ea5cb9635c24902c0130a2c6feeb19b0c8ef2 Mon Sep 17 00:00:00 2001
From: Serhii Vasylenko 
Date: Mon, 6 Jul 2026 01:56:51 +0200
Subject: [PATCH 03/15] feat: add --raw / raw passthrough
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Return the fetched body verbatim, skipping Readability and the content-type gate while keeping the fetch layer and the MARKFETCH_MAX_BYTES cap. Non-HTML responses (JSON, XML, plain text, source) come back as-is instead of unsupported_content_type. Threaded as a `raw` flag through fetchMarkdown (fetchHtml → fetchBody, content-type gate now conditional); CLI `--raw`, MCP `raw` boolean.
---
 src/cli.ts           |  9 +++--
 src/core.ts          | 82 +++++++++++++++++++++++++++-----------------
 src/mcp.ts           | 11 ++++--
 tests/cli.test.ts    | 17 +++++++++
 tests/server.test.ts | 68 ++++++++++++++++++++++++++++++++++++
 5 files changed, 151 insertions(+), 36 deletions(-)

diff --git a/src/cli.ts b/src/cli.ts
index 20af7f2..c5174d4 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -29,7 +29,11 @@ program
     "-o, --output ",
     "save markdown to 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 481b042..3ec559a 100644
--- a/src/core.ts
+++ b/src/core.ts
@@ -229,7 +229,10 @@ function chromeHeaders(): Record {
   };
 }
 
-async function fetchHtml(url: string): Promise {
+// raw=true skips the HTML content-type gate so any body (JSON, plain text,
+// XML, source) is returned verbatim. Transport and the size caps are identical
+// either way; only the gate is conditional.
+async function fetchBody(url: string, raw: boolean): Promise {
   const response = await fetch(url, {
     signal: AbortSignal.timeout(config.timeoutMs),
     headers: chromeHeaders(),
@@ -242,15 +245,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);
@@ -258,12 +263,12 @@ 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();
+  if (Buffer.byteLength(body, "utf8") > config.maxBytes) {
+    throw enforceTooLarge("Body", Buffer.byteLength(body, "utf8"));
   }
 
-  return html;
+  return body;
 }
 
 function enforceTooLarge(stage: string, actual: number): MarkfetchError {
@@ -398,6 +403,10 @@ function convertToMarkdown(article: {
 // adapter's schema; savePath, if present, is an absolute path — adapters
 // resolve any relative-vs-absolute concerns before calling).
 //
+// With `raw`, the fetched body is returned verbatim — the content-type gate
+// and Readability/markdown conversion are skipped, so `unsupported_content_type`
+// and `extraction_failed` cannot arise in that mode. Size caps still apply.
+//
 // 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
@@ -413,31 +422,40 @@ 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 { url, savePath, raw = false } = input;
+  const body = await fetchBody(url, raw);
+  // raw returns the fetched body untouched — no Readability, no markdown
+  // conversion — so the content channel (and any savePath file) carry it
+  // verbatim. Otherwise extract the main article and convert to markdown.
+  let content: string;
+  if (raw) {
+    content = body;
+  } else {
+    const article = extractArticle(body, url);
+    if (!article) {
+      throw new MarkfetchError(
+        "extraction_failed",
+        "Readability returned no article content.",
+      );
+    }
+    content = convertToMarkdown(article);
   }
-  const markdown = convertToMarkdown(article);
-  const bytes = Buffer.byteLength(markdown, "utf8");
+  const bytes = Buffer.byteLength(content, "utf8");
   if (bytes > config.maxBytes) {
     throw new MarkfetchError(
       "too_large",
-      `Markdown ${bytes} bytes > MARKFETCH_MAX_BYTES (${config.maxBytes})`,
+      `Output ${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.
+  // The file at savePath is only ever the fetched output (extracted markdown,
+  // or the raw body when `raw` is set). 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.
   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
@@ -449,7 +467,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..9f54700 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 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` unless `raw` is set. 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`.",
     inputSchema: {
       url: z
         .string()
@@ -48,9 +48,15 @@ server.registerTool(
         .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.",
         ),
+      raw: z
+        .boolean()
+        .optional()
+        .describe(
+          "Optional. When true, returns the response body verbatim and skips both Readability extraction and the HTML content-type gate — so non-HTML responses (JSON, XML, plain text, source) come back as-is instead of `unsupported_content_type`. The `MARKFETCH_MAX_BYTES` size cap still applies. Use for APIs, raw page source, or when you want the unprocessed document rather than extracted article markdown.",
+        ),
     },
   },
-  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/server.test.ts b/tests/server.test.ts
index 77f05a0..7238198 100644
--- a/tests/server.test.ts
+++ b/tests/server.test.ts
@@ -219,6 +219,74 @@ 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 =
+    "

Title

Body text.

"; + const mock = await startMock((_req, res) => { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(HTML); + }); + const client = await spawnClient(); + try { + const result = await client.callTool({ + name: "fetch_markdown", + arguments: { url: mock.url, raw: true }, + }); + assert.equal(result.isError, false); + // Verbatim HTML — the