Skip to content

strad fetch

Fetch a web page from an agent and get it back as Markdown. strad-fetch is strad’s own MCP server — source in servers/strad-fetch/, mounted at /strad-fetch inside the bundle image.

It is a fork of PulseMCP’s @pulsemcp/pulse-fetch, and the fork is not cosmetic. Every delta comes from one difference: upstream runs as an npx subprocess inside a single client session, and strad runs it as one long-lived mount that many sessions reach through the gateway. servers/strad-fetch/FORK.md lists them with the evidence for each.

One tool, scrape. It takes a URL and returns the page, cleaned to semantic Markdown by default.

ParameterDefaultWhat it does
urlrequiredThe page. A bare hostname is accepted and gets https://.
resultHandlingsaveAndReturnreturnOnly, saveAndReturn, or saveOnly — see below.
cleanScrapetrueHTML → semantic Markdown, dropping nav and boilerplate. false for raw bytes.
maxChars100000Truncate the returned text.
startIndex0Read from an offset — paginate a long page with maxChars.
timeout60000Page-load budget in milliseconds.
forceRescrapefalseSkip the cache.

saveAndReturn stores the page as an MCP resource and returns its text; saveOnly returns only a resource link; returnOnly returns text and stores nothing. strad’s gateway proxies resources as well as tools, so a link a saveOnly call returns is readable through the same /mcp endpoint.

The surface does not change with the container’s environment — one tool, seven parameters, always. That is deliberate, and it is one of the fork’s deltas: upstream grows an eighth parameter when LLM credentials are present, which would mean the image advertising one tool in CI and a different one in production.

Three strategies, and why a missing key is not a failure

Section titled “Three strategies, and why a missing key is not a failure”
StrategyNeedsGood for
nativenothingmost public pages
firecrawlFIRECRAWL_API_KEYpages that need JavaScript rendered
brightdataBRIGHTDATA_API_KEYpages behind anti-bot measures

The chain runs in order until one returns content that can actually be read, and the strategy that worked is remembered per URL prefix, in a table under the container’s temp directory. A subsequent request to that prefix starts with the strategy that worked last time and falls through the rest if it does not.

native is always in the chain. Upstream’s speed mode omits it, which turns a deployment with no working paid key into a deployment that cannot fetch anything — including a plain public page. Here speed demotes native to last rather than dropping it, so the paid credentials buy coverage on sites that block a bare fetch and never stand between the tool and the open web.

A rejected key does not stop the chain either. Upstream returns as soon as a strategy reports an authentication failure. Here it is recorded and the next strategy runs; if everything fails, the error names the strategies whose credentials were rejected — Credentials REJECTED by: brightdata — separately from what the site itself said, because those are different things to fix.

Both deltas come from one incident. On 2026-08-16 the deployment ran OPTIMIZE_FOR=speed with an empty Firecrawl key and an invalid BrightData key. Upstream’s chain had nothing that could succeed, so every fetch failed — and because upstream’s stdio entrypoint also probes both credentials at boot and exits 1 when one is rejected, the subprocess never started at all, which killed the agent session hosting it.

A strategy has to be able to READ the page, not just fetch it

Section titled “A strategy has to be able to READ the page, not just fetch it”

“The response had a body” and “the page had content” are different questions, and the chain answers the first while reporting the second unless something makes it look. A page that draws its content client-side answers 200 with a full document whose rendered markup is empty — so the bytes are there, the clean finds nothing, and the fetch looks like a success against a page with nothing on it.

So a strategy is accepted on what can be extracted from what it fetched. The page is cleaned before the strategy is accepted; if the extraction comes back with under 8 characters of readable content, that is treated as this strategy could not read this page and the chain escalates to one that renders JavaScript, which is what firecrawl and brightdata are in the chain for.

Three consequences, and they are the point:

  • A strategy that read nothing is never learned for the prefix. Otherwise one unreadable answer becomes every later answer for every URL under it.
  • When nothing can read the page, the result says the content is UNKNOWN, with the per-strategy diagnostics — how many bytes each one fetched and how much came out. It is never an empty body under a Scraped using: native footer, because that states an absence nothing observed.
  • cleanScrape: false still returns the raw bytes, with a note that nothing could be extracted from them. It is the way to look at what was actually served, and it is exactly the tool for this situation.

The floor is 8 characters and applies only to content the cleaner is lossy over — that is, HTML. It is low on purpose: a false escalation spends a paid credential and turns a genuinely short page into “unknown”, which is the same over-claiming in the other direction. JSON, XML and plain text are exempt; they come back byte-identical from the cleaner, so holding {"ok":true} to a character floor would escalate it to a strategy that returns the same eleven bytes.

Reaching a verdict costs at most one clean of the first 256KB. The cleaner’s cost tracks the content it extracts, not the size of the document — a 906KB client-rendered shell cleans in under a second because there is nothing in it, while 2MB of prose takes forty — so capping the input bounds the expensive case without exempting the shells the check is for. A page over the cap is judged on its prefix and cleaned in full by the tool afterwards, exactly as it was before.

The check runs whatever cleanScrape says, because cleanScrape chooses what is displayed and must not change which strategy is chosen or remembered — otherwise two callers asking for the same page in different formats teach the deployment different things about it.

The native strategy fetches the public internet only

Section titled “The native strategy fetches the public internet only”

url is a free-form string and native is a bare fetch from the bundle container — the container that hosts twenty-one mounts, not the agent’s own sandbox. Without a guard, an entitled caller could point scrape at http://127.0.0.1:<port>, at http://169.254.169.254/…, or at whatever the private component network answers, and read the response back as tool output.

So the native strategy checks three things:

  • The scheme. http and https. file:, data:, ftp: and the rest are refused before anything is dispatched.
  • The address. Loopback, link-local, RFC1918, carrier-grade NAT, multicast and reserved space, as IPv4 and IPv6 literals — including the IPv6 spellings that carry an IPv4 address, ::ffff:127.0.0.1 and 2002:7f00:1:: among them — and as resolved addresses, so a name that points into one of those ranges is refused too.
  • The name. A single-label host, and the special-use suffixes (.internal, .local, .localdomain, .home.arpa, .alt, .localhost). This is the rule that covers strad’s own private network rather than the range check: a bundle is reached at ${bundle.PRIVATE_URL}, which is http://bundle-name:8080, so every component is one bare label. Public DNS serves no single-label name, so refusing them costs no legitimate fetch.
  • Every connection the fetch opens, not just the URL the caller passed. The resolved-address check runs on the resolution that decides the socket, so there is no second lookup to race, and a redirect into a private address is refused at the hop: a public page that 302s to http://127.0.0.1:8080/ opens a new connection through the same guarded dispatcher.

A refusal is the native strategy’s failure, in the shape every other failure has — Refusing to fetch …: 169.254.169.254 is a link-local address (169.254.0.0/16) — the cloud metadata range — so it reaches the caller rather than a log, and the paid strategies are still tried behind it.

STRAD_FETCH_ALLOW_PRIVATE_HOSTS is the escape hatch: a comma-separated list of bare hosts — no port; an entry exempts every port on that host — that may be reached anyway, empty by default. It is for pointing a development gateway at a service on the same box, and it is a list rather than an off switch on purpose.

strad vendors only upstream’s shared/ package. The entrypoint that ran the health check is not here, and neither is the health check. A rejected credential surfaces in the result of a tool call that needed it, which is the only place it can be acted on.

That is also why this mount is never reported degraded on the bundle’s health route, unlike pulse-subregistry: there is no credential whose absence stops it serving.

Nothing is required. The image is credential-free; strad injects what it has through the server’s env: map.

VariableDefaultWhat it does
BRIGHTDATA_API_KEYunsetEnables the brightdata strategy.
FIRECRAWL_API_KEYunsetEnables the firecrawl strategy.
STRAD_FETCH_OPTIMIZE_FORcostcost (native first) or speed (native last).
STRAD_FETCH_CACHE_MAX_ENTRIES512Resource-cache entry cap.
STRAD_FETCH_CACHE_MAX_BYTES64MBResource-cache byte cap.
STRAD_FETCH_STRATEGY_CONFIG_PATHtempWhere the learned strategy table lives.
STRAD_FETCH_ALLOW_PRIVATE_HOSTSemptyBare hosts (no port) the native strategy may reach anyway.

The two credential names are bare because each names one vendor and nothing else in the bundle image reads them — the same reasoning that leaves SLACK_BOT_TOKEN bare. Everything else is namespaced, because the bundle is one container and a generic name there is a claim on twenty other mounts.

A scraped page is cached in the process, so a repeated fetch of a URL is free. The cache is LRU-bounded on entry count and total bytes — upstream’s is unbounded, which is correct for a subprocess the OS reclaims and an out-of-memory risk in a container hosting twenty-one mounts.

It is also one cache for every caller of the slug, which is a limitation rather than a feature: see limitation #65.