Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ Self-hosters should consult [`MIGRATION.md`](./MIGRATION.md) when upgrading acro

---

## [Unreleased]

### Changed

- **`query` is now described by when to use it, not by how it works.** The MCP `read_url` schema, the bundled Claude Code skill, the README and the in-app help page all described the parameter mechanically ("return only the sections relevant to this text", "BM25 over the converted Markdown"), which told an agent what the feature does but never that it should reach for it. In practice the parameter went unused: a model reading the old description had no trigger condition to match against. All four surfaces now lead with the trigger - when you need specific information from a page rather than the whole document, pass the question you are trying to answer, in natural language - name the payoff (typically 70-95% fewer tokens on long pages), and frame the full-page fetch as the case that needs a reason (summarizing, translating, archiving). `max_tokens` additionally states that it has no effect without `query`. Text only: no parameter, default, validation or response shape changed, and nothing changes for an existing integration except how likely an agent is to use the parameter at all.

---

## [3.8.0] - 2026-07-31

### Added
Expand Down
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,8 @@ Returns clean Markdown (text/markdown). Optional query params:
nocache=true bypass the 1h cache and refetch
render=force|skip override the auto Playwright fallback
pdf=ocr high-quality PDF conversion (tables)
query=<text> return only the sections relevant to <text>
query=<text> got a specific question? pass it and get back only
the matching sections (70-95% fewer tokens)
max_tokens=N budget for query= (default 600, 64-20000)
lang=de|en language for the comments section header

Expand All @@ -370,8 +371,9 @@ Reddit URLs are auto-detected (incl. redd.it short links and /s/ shares).
Hacker News URLs are auto-detected too — items, comment permalinks, and the
front/newest/ask/show/jobs listings.
Use this whenever you would otherwise fetch raw HTML — the markdown is
much cleaner and saves significant context window space. For a long page
where you only need one thing, add query= and get just that.
much cleaner and saves significant context window space. When you only
need specific information rather than the whole document, pass your
question as query= and get back just the relevant sections.
```

### 2. Claude Code skill
Expand Down Expand Up @@ -509,8 +511,8 @@ for it.
| `pdf` | — | `ocr` → route PDFs through the [OCR tier](#high-quality-pdf-ocr). Bypasses cache. |
| `yt_timecodes` / `yt_chunk` | see [YouTube](#youtube-transcripts) | Transcript format overrides. Bypass cache when set. |
| `lang` | `de` | Comments-section header language (`de` or `en`). |
| `query` | — | Return only the sections relevant to this text instead of the full page (see [Query-scoped extraction](#query-scoped-extraction)). Empty/whitespace-only is treated as absent — output is unchanged. |
| `max_tokens` | `600` | Token budget for `query` extraction (`64`–`20000`). Only validated when `query` is non-empty; an invalid value returns `400`. |
| `query` | — | Set this when you need specific information from a page rather than the whole document: pass the question, get back only the matching sections (see [Query-scoped extraction](#query-scoped-extraction)). Empty/whitespace-only is treated as absent — output is unchanged. |
| `max_tokens` | `600` | Token budget for `query` extraction (`64`–`20000`). No effect without `query`; raise it when the answer likely spans several sections. Only validated when `query` is non-empty; an invalid value returns `400`. |

Both `query` and `max_tokens` are also available on the MCP `read_url` tool.

Expand All @@ -527,9 +529,11 @@ Both `query` and `max_tokens` are also available on the MCP `read_url` tool.

### Query-scoped extraction

Pass `?query=<text>` on `GET /api` (or the `query` param on the MCP `read_url`
tool) to get back only the sections of the page relevant to that text instead
of the full Markdown body:
When you have a specific question about a long page, you rarely need the whole
document. Pass the question as `?query=<text>` on `GET /api` (or the `query`
param on the MCP `read_url` tool) to get back only the sections relevant to it
instead of the full Markdown body - typically 70-95% fewer tokens on long
pages:

```
GET /api?url=https://example.com/long-article&query=how+does+caching+work
Expand Down
6 changes: 3 additions & 3 deletions lib/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function createMcpServer({
'read_url',
{
description:
'Fetch a URL and return clean Markdown. Handles web pages (Cloudflare-Markdown, Readability+Trafilatura, headless-Chromium fallback for JS-heavy sites), Reddit threads with comment trees, documents (PDF, Word, PowerPoint, Excel, EPUB, CSV, …), YouTube videos (title, description, transcript with timecodes), and image/audio URLs (AI caption / transcription) — document, YouTube, and media support depend on server configuration. Use this whenever you would otherwise fetch raw HTML or download a document — it produces dramatically cleaner content and saves context window space.',
'Fetch a URL and return clean Markdown. Handles web pages (Cloudflare-Markdown, Readability+Trafilatura, headless-Chromium fallback for JS-heavy sites), Reddit threads with comment trees, documents (PDF, Word, PowerPoint, Excel, EPUB, CSV, …), YouTube videos (title, description, transcript with timecodes), and image/audio URLs (AI caption / transcription) — document, YouTube, and media support depend on server configuration. Use this whenever you would otherwise fetch raw HTML or download a document — it produces dramatically cleaner content and saves context window space. If you only need specific information from a page rather than the whole document, pass `query` - you get back just the relevant sections, typically 70-95% fewer tokens on long pages.',
inputSchema: {
url: z.string().url().describe('The URL to fetch'),
comments: z.boolean().optional().describe('Include Reddit comments. Default true (ignored for non-Reddit URLs).'),
Expand All @@ -86,8 +86,8 @@ export function createMcpServer({
yt_timecodes: z.enum(['links', 'plain', 'none']).optional().describe('YouTube transcript timecode style (links = clickable timestamps, default).'),
yt_chunk: z.number().int().min(0).optional().describe('YouTube transcript block size in seconds; 0 = per original snippet.'),
pdf_ocr: z.boolean().optional().describe('Use high-quality OCR for PDFs instead of the default text extraction (requires PULLMD_PDF_OCR_API_KEY). Default false.'),
query: z.string().min(1).optional().describe('Return only the sections relevant to this query (BM25 over the converted markdown). Omit for the full page.'),
max_tokens: z.number().int().min(64).max(20000).optional().describe('Token budget for query extraction (default 600).'),
query: z.string().min(1).optional().describe('Set this when you need specific information from a page rather than the whole document - pass the question you are trying to answer, in natural language. Returns only the matching sections, typically 70-95% fewer tokens on long pages. Prefer this over fetching the full page whenever the request is a specific question. Omit only when you genuinely need the complete document (summarizing, translating, archiving).'),
max_tokens: z.number().int().min(64).max(20000).optional().describe('Token budget for `query` extraction (default 600, range 64-20000). No effect without `query`. Raise it when the answer likely spans several sections; leave the default for single-fact lookups.'),
},
},
async ({
Expand Down
20 changes: 11 additions & 9 deletions public/help.html
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,8 @@ <h3 lang="en">Option 1 — Universal: paste a prompt</h3>
render=force|skip override the auto headless-Chromium fallback
extractor=readability|trafilatura|playwright force one extractor
pdf=ocr high-quality PDF conversion (tables)
query=<text> return only the sections relevant to <text>
query=&lt;text&gt; got a specific question? pass it and get back only
the matching sections (70-95% fewer tokens)
max_tokens=N budget for query= (default 600, 64-20000)
lang=de|en language for the comments section header

Expand All @@ -464,8 +465,9 @@ <h3 lang="en">Option 1 — Universal: paste a prompt</h3>
Hacker News URLs are auto-detected too — items, comment permalinks, and the
front/newest/ask/show/jobs listings.
Use this whenever you would otherwise fetch raw HTML — the markdown is
much cleaner and saves significant context window space. For a long page
where you only need one thing, add query= and get just that.</pre>
much cleaner and saves significant context window space. When you only
need specific information rather than the whole document, pass your
question as query= and get back just the relevant sections.</pre>
</div>

<h3 lang="de">Option 2 — Claude Code Skill</h3>
Expand Down Expand Up @@ -704,12 +706,12 @@ <h2><span lang="de">API-Parameter</span><span lang="en">API parameters</span><sp
<td lang="en">Language for the comments header (<code>de</code> or <code>en</code>).</td>
</tr>
<tr><td><code>query</code></td><td>—</td>
<td lang="de">Liefert nur die zur Anfrage passenden Abschnitte statt der ganzen Seite, siehe <a href="#query">Query-Extraktion</a>.</td>
<td lang="en">Returns only the sections relevant to this text instead of the whole page, see <a href="#query">Query extraction</a>.</td>
<td lang="de">Für eine konkrete Frage an eine lange Seite: Frage hier übergeben, zurück kommen nur die passenden Abschnitte statt der ganzen Seite, siehe <a href="#query">Query-Extraktion</a>.</td>
<td lang="en">For a specific question about a long page: pass the question here and get back only the matching sections instead of the whole page, see <a href="#query">Query extraction</a>.</td>
</tr>
<tr><td><code>max_tokens</code></td><td><code>600</code></td>
<td lang="de">Token-Budget für <code>query</code> (64–20000). Wird nur geprüft, wenn <code>query</code> gesetzt ist; ein ungültiger Wert liefert <code>400</code>.</td>
<td lang="en">Token budget for <code>query</code> (64–20000). Only validated when <code>query</code> is set; an invalid value returns <code>400</code>.</td>
<td lang="de">Token-Budget für <code>query</code> (64–20000). Ohne <code>query</code> wirkungslos; höher setzen, wenn die Antwort über mehrere Abschnitte verteilt liegt. Wird nur geprüft, wenn <code>query</code> gesetzt ist; ein ungültiger Wert liefert <code>400</code>.</td>
<td lang="en">Token budget for <code>query</code> (64–20000). No effect without <code>query</code>; raise it when the answer likely spans several sections. Only validated when <code>query</code> is set; an invalid value returns <code>400</code>.</td>
</tr>
</tbody>
</table>
Expand Down Expand Up @@ -772,8 +774,8 @@ <h2><span lang="de">Response-Header</span><span lang="en">Response headers</span
<!-- ===================================================== -->
<section id="query">
<h2><span lang="de">Query-Extraktion</span><span lang="en">Query extraction</span><span class="kicker">?query=</span></h2>
<p lang="de">Bei langen Seiten braucht ein Agent selten alles. <code>?query=</code> liefert nur die Abschnitte, die zur Frage passen:</p>
<p lang="en">On long pages an agent rarely needs everything. <code>?query=</code> returns just the sections that match:</p>
<p lang="de">Wer eine konkrete Frage an eine lange Seite hat, braucht selten die ganze Seite. <code>?query=</code> nimmt die Frage in natürlicher Sprache entgegen und liefert nur die passenden Abschnitte - auf langen Seiten typischerweise 70-95 % weniger Tokens. Die ganze Seite lohnt sich dann noch, wenn man sie wirklich komplett braucht: zusammenfassen, übersetzen, archivieren.</p>
<p lang="en">When you have a specific question about a long page, you rarely need the whole page. <code>?query=</code> takes that question in natural language and returns only the matching sections - typically 70-95% fewer tokens on long pages. Fetching the full page still makes sense when you genuinely need all of it: summarizing, translating, archiving.</p>
<div class="copybox">
<div class="copybox-header">
<span class="copybox-label">example</span>
Expand Down
6 changes: 3 additions & 3 deletions skill/pullmd/skills/pullmd/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ The response is `text/markdown` — ready to use as-is.
| `pdf` | — | `ocr` → high-quality OCR conversion for PDFs (table-grade output; needs a server-side OCR key). Bypasses cache. |
| `yt_timecodes` | `links` | YouTube transcripts: `links` (clickable timestamps), `plain` (`[MM:SS]`), `none`. |
| `yt_chunk` | `30` | YouTube transcript block size in seconds; `0` = per original snippet. |
| `query` | — | Return only the sections relevant to this text instead of the whole page (BM25 over the converted Markdown, no LLM). Empty/absent = full page, unchanged. |
| `max_tokens` | `600` | Token budget for `query` (64–20000). Only validated when `query` is set. |
| `query` | — | Set this when you need specific information from a page rather than the whole document: pass the question you are trying to answer, in natural language, and get back only the matching sections - typically 70-95% fewer tokens on long pages. No LLM involved. Empty/absent = full page, unchanged. |
| `max_tokens` | `600` | Token budget for `query` (64–20000). No effect without `query`. Raise it when the answer likely spans several sections; leave the default for single-fact lookups. Only validated when `query` is set. |
| `lang` | `de` | Language for the comments-section header (`de` or `en`). |

**Response headers worth checking:**
Expand Down Expand Up @@ -134,7 +134,7 @@ Need to read a URL?

- PullMD caches results for 1 hour. Use `nocache=true` if you need the latest version. `render=force|skip`, `extractor=`, `pdf=ocr`, and explicit `yt_*` params also bypass the cache.
- For pages with important comments or discussions (forums, HN, Reddit), add `comments=true` to include the discussion below the post. Reddit and Hacker News URLs are auto-detected and use dedicated pipelines; `comment_depth` controls how deep the tree goes.
- When the page is long and you only need one aspect of it, add `query=<what you are looking for>` instead of pulling the whole thing — it returns just the matching sections and reports the token saving in `X-Extract-*`. It falls back to the full page when nothing matches, so it is safe to try.
- When you need specific information from a page rather than the whole document, add `query=<the question you are trying to answer>`, phrased in natural language - it returns just the matching sections (typically 70-95% fewer tokens on long pages) and reports the saving in `X-Extract-*`. It falls back to the full page when nothing matches, so it is safe to try. Omit it only when you genuinely need the complete document - summarizing, translating, archiving.
- For JS-rendered apps where the auto-fallback didn't fire (e.g. content lives in a tab the heuristic didn't reach), `render=force` re-extracts via headless Chromium.
- Reddit URLs are automatically detected (incl. `redd.it` short links and `/r/<sub>/s/<id>` share links) and use a specialized extraction pipeline that handles posts, comments, galleries, and videos.
- Add `frontmatter=true` when you want metadata: extraction source and quality always; for Reddit posts also subreddit, author, upvotes, and publish date; for media/YouTube/OCR results duration, image size, and LLM token usage (cost tracking).
Expand Down
22 changes: 22 additions & 0 deletions test/mcp-query-extract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,28 @@ describe('MCP read_url - query-extract: tool schema', () => {
assert.ok(props.query, 'query param must be present');
assert.ok(props.max_tokens, 'max_tokens param must be present');
});

// Discoverability regression: agents only reach for `query` if the schema
// tells them WHEN to use it. Assert on content markers, not exact strings,
// so wording can be polished but a silent revert to a mechanism-only
// description ("BM25 over the converted markdown") fails here.
it('describes query by its trigger condition, not its mechanism', async () => {
const app = createApp({ cache: createCache(':memory:') });
const j = await listTools(app);
const readUrl = j.result.tools.find(t => t.name === 'read_url');
const props = readUrl.inputSchema.properties;

assert.match(readUrl.description, /query/i, 'tool description must mention query');

const q = props.query.description;
assert.match(q, /when you need|Set this when/i, 'query must state its trigger condition');
assert.ok(!/bm25/i.test(q), 'query description must not name the mechanism (BM25)');
assert.match(q, /70-95%/, 'query description must keep the token-saving figure');
assert.match(q, /natural language/i, 'query description must ask for natural language');

const mt = props.max_tokens.description;
assert.match(mt, /no effect without/i, 'max_tokens must state it is inert without query');
});
});

describe('MCP read_url - query-extract: byte-identical without query', () => {
Expand Down
Loading