Skip to main content
Glama

Groundhog

Conformance Glama

Web search, read and research for AI agents, through a real, stealth-patched Chrome. Groundhog is an MCP server that returns clean Markdown a model can trust: text no human could see is stripped before the model reads it and reported, every source comes with a provenance receipt, and a real browser reads pages that block plain fetchers.

agent / crawler  ──MCP──▶  Groundhog (search, read_url, research)  ──CDP──▶  stealth Chrome  ──▶  the web

Measured against other fetch layers

Twenty-eight pages carry text a human can't see, and three controls carry none. A case passes when the payload doesn't reach the model and the article text still does.

contained

kept the article

reported it

false positives

requests + trafilatura

25/28

28/28

0/28

0/3

Scrapling (HTTP)

16/28

28/28

0/28

0/3

Jina Reader

15/28

28/28

0/28

0/3

Firecrawl

7/28

28/28

0/28

0/3

Groundhog

28/28

28/28

25/28

0/3

Other extractors often drop hidden text by accident, but none of them tells the caller anything was removed. The three cases Groundhog doesn't report (a hidden input value, a meta description, an iframe srcdoc) never reach any extractor. The corpus is public at dmytrome.github.io/groundhog; the harness and its limits are in benchmark/.

Related MCP server: firecrawl-mcp-server

Quick start

Claude Code:

claude mcp add groundhog -- uvx groundhog-mcp

Claude Desktop, Cursor, Windsurf:

{
  "mcpServers": {
    "groundhog": {
      "command": "uvx",
      "args": ["groundhog-mcp"]
    }
  }
}

On the first fetch Groundhog starts the Chrome you already have, headless, in its own profile at ~/.groundhog/chrome. Your own browsing, tabs and logins are never touched. If Chrome is missing, status and the error say what to install.

GROUNDHOG_BROWSER=stealth runs the hardened browser container instead (Docker or Podman; the first pull takes a few minutes). To run it yourself:

docker run -d --rm --name groundhog-browser --shm-size 512m \
  -p 127.0.0.1:9222:9222 -- ghcr.io/dmytrome/groundhog:latest

CDP_URL points Groundhog at any browser and is used as given: http:// is probed for /json/version, ws:// or wss:// (what hosted browsers hand out) is connected to directly. Auto-start only runs for a local address that isn't answering, and GROUNDHOG_AUTO_START_BROWSER=false turns it off. From source: cd mcp && uv sync && uv run groundhog-mcp.

All four tools are annotated readOnlyHint: they write nothing and change no remote state. The one side effect is auto-start, which may launch Chrome or, with stealth, run the container after removing an unreachable one named groundhog-browser.

What makes it different

  • Hidden text is stripped and reported. A rendered DOM shows what a human would see; everything else goes, and each removal is listed in threats. It's a strong heuristic, not a proof: see the limits.

  • Every source carries a receipt: a SHA-256 of the extracted content, canonical URL, language, word count, and author and date when the page declares them.

  • Safe by default. An SSRF guard checks every document request, redirects and frames included, with per-domain rate limiting. See Security.

  • No automation tell. Groundhog drives Chrome over raw CDP and never enables the Runtime domain that anti-bots detect in Puppeteer, Playwright and Selenium.

  • A real fingerprint. Real Chrome means real TLS, HTTP/2, WebGL and canvas. In headless mode the HeadlessChrome token is replaced and client hints are copied from the browser; shared and service workers aren't covered. The stealth image runs headful under Xvfb.

  • No model, no API key. research returns extracts and your agent does the synthesis.

Tools

read_url(url, format="markdown", max_tokens=None, query=None, include_hidden=False)

Key

Meaning

markdown

Extracted content, article first, falling back to full text; format may be markdown or text

title

Page title

url

The URL you asked for

final_url

The URL after redirects, re-checked by the SSRF guard. If unusable, the requested URL is reported with a final_url_suppressed threat

fetched_at

UTC ISO-8601 timestamp

status

ok, or challenge / blocked / rate_limited / not_found / server_error / unsupported_content when what came back isn't the page you asked for. blocked covers 401, 403 and other 4xx error pages. unknown means no usable status was observed. Judged on the document the text came from, after client-side redirects

http_status

The top-level HTTP status, or null

truncated

Whether the token budget cut the content

threats

What was stripped; empty when nothing was found

matches

With query: ranked passages with id, heading, offset, end and score

set_aside

With query: scoring passages the relevance floor dropped

provenance

Content hash, canonical URL, language, word count, author and date

Stripped by default: display:none, visibility:hidden, content-visibility:hidden, opacity ≤ 0.05, font-size < 4px, zero-size and display:contents boxes that render nothing, .sr-only-style sub-pixel boxes, clip: rect(...), transparent or background-coloured text, off-page positioning, collapsed <details>, <template> content, long alt / aria-label / aria-description / title text, and zero-width, bidi and Unicode Tag characters. HTML comments are reported too. include_hidden=True keeps the text but still reports it.

Treat threats as untrusted: its excerpts are page-authored text, cleaned and length-capped.

type

Carries

hidden_css

The hiding reason, an 80-character excerpt and the DOM location

hidden_attribute

The same, for text in an alt, aria-label, aria-description or title attribute

hidden_template

The same, for text inside a <template>

zero_width / bidi / tag

A codepoint and count in reason, detected on the served text

report_truncated

How many entries the cap dropped

final_url_suppressed

The page's final URL was unusable and not returned

detection_degraded

Detection ran in the page's own JavaScript world, which the page can tamper with

strip_incomplete

The text was rebuilt from markup rather than layout, usually because of open shadow roots

At most 50 findings per page are returned (10 per source in research), with the two classes capped separately so decoys can't bury a real payload. Notices are appended after the cap.

query swaps head-truncation for BM25 passage ranking on the sanitized content, keeping passages within half the best score up to the budget.

search(query, limit=10)

Returns hits (title, url, snippet, engine, score, published) and the backend that answered. Nothing is fetched. With SEARXNG_URL set it uses your SearXNG instance, which needs formats: [html, json]; otherwise it renders a search page through the browser. Hit text is cleaned and capped, and a hit whose URL would change under cleaning, isn't http(s), carries credentials or exceeds 2048 characters is dropped. A backend that can't answer raises an error instead of returning an empty web.

research(query, max_sources=5, max_tokens=None, include_domains=None, exclude_domains=None, published_after=None, aim=None)

Searches, reads the top sources and ranks passages from all of them in one pass.

  • passages: id, text, source_url, heading, score, start, end, clamped, fence. text is exactly markdown[start:end] unless clamped. A passage cut from a long code block carries the block's opening line in fence: reopen with it when text doesn't start with it, and close the block when text doesn't end with a closing fence. id stays stable while the page doesn't change.

  • sources: url, title, status (ok / blocked / timeout / error), page_status (as in read_url), threats, provenance, filtered. A failed source doesn't fail the call. A challenge, an error page or a page with no text (whose page_status stays ok) counts as not_content and contributes no passages.

  • One page per registrable domain unless include_domains is set, and then no more pages per site than GROUNDHOG_MAX_CONCURRENT_PAGES; max_sources goes up to 10.

include_domains reads only those domains and their subdomains, exclude_domains never reads them, and published_after (YYYY-MM-DD) drops pages dated earlier. Up to 8 domains can be included and 50 excluded. A page that redirects into an excluded domain or out of the included ones, is dated too early, or repeats the URL or text of a page already used is listed with filtered and contributes no passages. The query sent to the engine names at most eight domains; further excluded ones are filtered after the search. aim (up to 200 characters) says what the evidence must show and is echoed back without changing the search or the ranking.

receipt holds the executed_query, the distinct hits and hits_by_domain, the hits not fetched (hits_excluded, hits_outside_domains, hits_before_date, hits_same_domain, hits_unread past the source limit), each source counted once as failed, not_content, filtered or usable, outside_domains, unknown_dates, and the passages omitted, set_aside or clamped. Read it before concluding the web has no answer.

status()

Returns browser_reachable, cdp_url (scheme, host and port only) and a hint when the browser can't be reached.

Configuration

MCP server:

Env var

Default

Purpose

CDP_URL

http://127.0.0.1:9222

CDP endpoint, local or remote. Unauthenticated, so keep it private

GROUNDHOG_BROWSER

chrome

What auto-start launches: chrome or stealth. Defaults to stealth when GROUNDHOG_COMPOSE_FILE or GROUNDHOG_BROWSER_IMAGE is set

GROUNDHOG_CHROME_PATH

(found)

Chrome executable

GROUNDHOG_CHROME_PROFILE

~/.groundhog/chrome

Profile directory; a login made here is reused

GROUNDHOG_BLOCK_PRIVATE_IPS

true

The SSRF guard

GROUNDHOG_MIN_DELAY_MS

5000

Delay between requests to one domain

GROUNDHOG_MAX_TOKENS

20000

Token budget

GROUNDHOG_MAX_CONCURRENT_PAGES

4

Concurrent tabs

SEARXNG_URL

(unset)

SearXNG instance for search

GROUNDHOG_SEARCH_BACKEND

auto

auto, searxng or serp

GROUNDHOG_AUTO_START_BROWSER

true

Start the browser when a local CDP_URL isn't answering

GROUNDHOG_BROWSER_IMAGE

ghcr.io/dmytrome/groundhog:latest

Image for stealth

GROUNDHOG_COMPOSE_FILE

(none)

Start with docker compose -f <file> up -d instead

Browser container:

Env var

Default

Purpose

USER_AGENT

derived from installed Chrome

UA set at launch, clean in every scope

PROXY

(none)

Upstream proxy http://user:pass@host:port; timezone and locale follow the exit IP

TZ

UTC

Fallback timezone

WINDOW_SIZE

1920,1080

Chrome window size

XVFB_WHD

1920x1080x24

Virtual display

The stealth Chrome container

Headful Chrome under Xvfb with a CDP endpoint, usable by any CDP client. It reports Chrome rather than HeadlessChrome, sets navigator.webdriver to false, and sets the UA at launch. With PROXY, timezone and locale match the exit IP, credentials go through a local tinyproxy, and WebRTC is pinned to the proxy. WebGL uses a detected GPU, else Mesa llvmpipe.

Against a fresh container (Chrome 151, no proxy), over raw CDP:

Detector

Result

deviceandbrowserinfo

not a bot (isBot: false, zero flags)

browserscan

Normal

bot.sannysoft.com

31 / 31 checks pass

iphey flags any datacenter IP, so it's tracked but not scored. The live table is in RESULTS.md. Puppeteer, Playwright and Selenium are still flagged because they enable Runtime; examples/ has clients for each, and OTHER_TOOLS.md covers more.

Security

The CDP endpoint is unauthenticated: anyone who reaches the port controls the browser. Keep it on localhost or a private network. The stealth image runs --no-sandbox, so keep the container isolated. Report vulnerabilities via SECURITY.md.

The SSRF guard resolves each host and refuses loopback, private, link-local (including 169.254.169.254), reserved, multicast, unspecified, CGNAT and IPv4-mapped IPv6 addresses, non-http(s) schemes, and URLs with credentials. It checks every document request Chrome is about to send, redirects and cross-site frames included, and final_url once more. Sub-resources are left to Chrome's Local Network Access rule, and the Chrome that Groundhog launches has preloading turned off.

Limits of the guard:

  • Only documents are checked. An older Chrome, or a site granted local-network access in the profile, isn't covered for sub-resources.

  • Preloading stays on in the stealth container and in a browser you supply.

  • Groundhog and Chrome resolve DNS separately, so a short-TTL rebinding window remains.

  • Fetches share one profile, so cookies set by one page persist into later fetches, and page JavaScript can make its own requests from it.

GROUNDHOG_BLOCK_PRIVATE_IPS=false turns the guard off; use it only where reaching internal addresses is intended.

Limits of hidden-text detection

An empty threats list isn't a clean bill of health. Detection runs in an isolated world the page can't tamper with, and the live page is never modified. But:

  • Thresholds can be sat just inside (opacity: 0.06), and unmodelled techniques such as clip-path, text-indent or transform: scale(0) pass.

  • The invisible-character set is a denylist.

  • Closed shadow roots can't be read, so their content is left out entirely.

  • When the page wins the cascade against the hiding stylesheet or hides its own <body>, text is rebuilt from markup, line breaks are guessed, and strip_incomplete says so.

Limits of block detection

  • A challenge is called with certainty from vendor headers (cf-mitigated, x-vercel-mitigated, x-amzn-waf-action, x-dd-b, x-datadome-cid) or challenge assets (Cloudflare, DataDome, PerimeterX, Imperva). Other vendors fall back to English wording on a near-empty page.

  • A soft block, a 200 with the data quietly withheld, looks like content.

  • unknown isn't ok, but it isn't treated as a block either.

  • Vendor presence (server: cloudflare, cf-ray) is deliberately ignored.

A note on "stealth"

Best effort. It beats common open-source detectors, not commercial anti-bot systems that gate on IP reputation and behaviour. Use it for legitimate, authorized automation.

Privacy Policy

Groundhog is self-hosted software and talks to no servers the project runs. It collects nothing: no telemetry, analytics, crash reports or accounts.

Pages are fetched by a browser on your machine and returned to your MCP client. The server keeps no database and writes no logs. The browser profile does persist cookies and storage: ~/.groundhog/chrome for local Chrome (delete the folder to discard it), or the container for stealth (docker rm -f groundhog-browser).

Your traffic reaches the sites you fetch, your search backend (your SearXNG, or a public search engine without one), an IP-geolocation service for the proxy's exit IP only when PROXY is set, and PyPI and GHCR at install time.

Questions: SECURITY.md or https://github.com/dmytrome/groundhog/issues.

License

MIT

Available Tools

4 tools
read_urlRead a URL
Read-only

Fetch one web page through the stealth browser and return clean, grounded content with provenance.

Hidden text injected for models but invisible to humans is stripped by default and reported in threats. Use this to ground answers in live web content, including sites that block plain fetchers.

Reads a URL you already have: use search to find URLs, or research to search and read in one call. Fetches are rate limited per domain (5s apart by default), so several pages from one site are not instant.

ParametersJSON Schema
NameRequiredDescriptionDefault
urlYesAbsolute http(s) URL. Private and loopback addresses are refused.
queryNoWhen set, the content is built from the passages most relevant to it, and `matches` lists each one's heading, offset and score for citation; it carries no text. If nothing matches, `matches` is empty and the whole page is returned. On a long page the relevant passages can still be most of it, so set `max_tokens` to bound the read.
formatNo'markdown' extracts the article; 'text' returns the page's rendered text.markdown
max_tokensNoToken budget for the content. Omit to use the server's GROUNDHOG_MAX_TOKENS (20000 by default). Must be positive.
include_hiddenNoKeep text that is invisible to a human reader. It is reported in `threats` either way; this only controls whether it stays in the content.

Output Schema

ParametersJSON Schema
NameRequiredDescription
urlYes
titleYes
statusYes
matchesYes
threatsYes
markdownYes
final_urlYes
set_asideYes
truncatedYes
fetched_atYes
provenanceYes
http_statusYes
researchResearch the web
Read-only

Search the web and return ranked passages drawn from several sources.

One call does what search + repeated read_url would: finds pages, reads them through the stealth browser, and returns the passages most relevant to query — each attributed to its source, with that source's provenance receipt and any stripped injection payloads. A source that fails is reported in sources rather than failing the whole call.

Prefer read_url when you already have the URL, and search when you only want links. This reads max_sources pages, rate limited per domain, so it is the slowest of the three and the one to avoid for a single known page.

ParametersJSON Schema
NameRequiredDescriptionDefault
aimNoWhat the evidence must show, in at most 200 characters: the authority, artifact and version sought. Echoed in the receipt; it does not change the search or the ranking.
queryYesThe question to research. Passages are ranked against it.
max_tokensNoToken budget for the returned passages. Omit to use the server's GROUNDHOG_MAX_TOKENS (20000 by default). Must be positive.
max_sourcesNoHow many pages to read. Values outside 1-10 are clamped rather than rejected. Each source is a full page fetch, so this is the main cost and latency control.
exclude_domainsNoNever read pages on these domains (at most 50).
include_domainsNoRead only pages on these domains or their subdomains (at most 8), e.g. a standards body or the vendor's docs. Several pages from one domain may be read.
published_afterNoYYYY-MM-DD. Leave out pages dated before this day. A page with no date is kept and counted in the receipt's `unknown_dates`.

Output Schema

ParametersJSON Schema
NameRequiredDescription
queryYes
backendYes
receiptYes
sourcesYes
passagesYes
truncatedYes
statusBrowser statusA
Read-only

Check whether Groundhog can reach the stealth browser. Call this to diagnose setup before fetching: if browser_reachable is false, follow hint to start the browser, then retry.

ParametersJSON Schema
NameRequiredDescriptionDefault

No parameters

Output Schema

ParametersJSON Schema
NameRequiredDescription
hintYes
cdp_urlYes
browser_reachableYes

TDQS

A4.7/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

The description names the specific output field `browser_reachable` and the `hint` field, giving the agent concrete knowledge of what to expect. Annotations (readOnlyHint=true) already signal this is a safe read operation. The description adds value by explaining what the output means behaviorally (telling the agent what to do when unreachable), going beyond the annotation safety profile.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Two sentences, front-loaded with the core purpose first, followed by actionable diagnostics. No wasted words. Slight deduction could be argued for not being maximally compact, but the conditional instruction earns its place since it directly guides agent behavior. Efficient and purposeful.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

This is a simple, parameterless diagnostic tool with an output schema present, so the description doesn't need to explain return values extensively. The description covers the purpose, when to use it, and the conditional action to take based on the result. For a tool of this complexity, this is complete — nothing meaningful is missing.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The tool has 0 parameters, so there is no parameter meaning to convey — the schema itself documents this fully (100% coverage by definition since there are no params). The task is parameter-free, so a baseline of 4-5 applies, and the description fully compensates by explaining what the tool checks and what its output communicates.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool's purpose: checking whether Groundhog can reach the stealth browser. It uses a specific verb ('check') with a clear resource ('reach the stealth browser') and includes a diagnostic framing, distinguishing it from sibling tools (read_url, research, search) which are substantive data-gathering operations rather than setup diagnostics.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description gives explicit when-to-use guidance: 'Call this to diagnose setup before fetching.' It also provides conditional follow-up instructions: if `browser_reachable` is false, follow `hint` to start the browser, then retry. This is directly actionable and tells the agent exactly the workflow to follow.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 2 tool updatesv0.18.0
    • Changedread_url4 fields changed
      • changedInput schema / properties / query / description
        Previous value: -"When set, `matches` carries the passages most relevant to it, each with its heading and offset for citation."New value: +"When set, the content is built from the passages most relevant to it, and `matches` lists each one's heading, offset and score for citation; it carries no text. If nothing matches, `matches` is empty and the whole page is returned. On a long page the relevant passages can still be most of it, so set `max_tokens` to bound the read."
      • addedOutput schema / $defs / Match / properties / end
        Added value: +{
        +  "title": "End",
        +  "type": "integer"
        +}
      • addedOutput schema / $defs / Match / properties / id
        Added value: +{
        +  "title": "Id",
        +  "type": "string"
        +}
      • changedOutput schema / $defs / Match / required
        Previous value: -[
        -  "heading",
        -  "offset",
        -  "score"
        -]New value: +[
        +  "id",
        +  "heading",
        +  "offset",
        +  "end",
        +  "score"
        +]
    • Changedresearch15 fields changed
      • addedInput schema / properties / aim
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "description": "What the evidence must show, in at most 200 characters: the authority, artifact and version sought. Echoed in the receipt; it does not change the search or the ranking.",
        +  "title": "Aim"
        +}
      • addedInput schema / properties / exclude_domains
        Added value: +{
        +  "anyOf": [
        +    {
        +      "items": {
        +        "type": "string"
        +      },
        +      "type": "array"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "description": "Never read pages on these domains (at most 50).",
        +  "title": "Exclude Domains"
        +}
      • addedInput schema / properties / include_domains
        Added value: +{
        +  "anyOf": [
        +    {
        +      "items": {
        +        "type": "string"
        +      },
        +      "type": "array"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "description": "Read only pages on these domains or their subdomains (at most 8), e.g. a standards body or the vendor's docs. Several pages from one domain may be read.",
        +  "title": "Include Domains"
        +}
      • addedInput schema / properties / published_after
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "description": "YYYY-MM-DD. Leave out pages dated before this day. A page with no date is kept and counted in the receipt's `unknown_dates`.",
        +  "title": "Published After"
        +}
      • addedOutput schema / $defs / Passage / properties / clamped
        Added value: +{
        +  "title": "Clamped",
        +  "type": "boolean"
        +}
      • addedOutput schema / $defs / Passage / properties / end
        Added value: +{
        +  "title": "End",
        +  "type": "integer"
        +}
      • addedOutput schema / $defs / Passage / properties / fence
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "title": "Fence"
        +}
      • addedOutput schema / $defs / Passage / properties / id
        Added value: +{
        +  "title": "Id",
        +  "type": "string"
        +}
      • addedOutput schema / $defs / Passage / properties / start
        Added value: +{
        +  "title": "Start",
        +  "type": "integer"
        +}
      • changedOutput schema / $defs / Passage / required
        Previous value: -[
        -  "text",
        -  "source_url",
        -  "heading",
        -  "score"
        -]New value: +[
        +  "id",
        +  "text",
        +  "source_url",
        +  "heading",
        +  "score",
        +  "start",
        +  "end",
        +  "clamped",
        +  "fence"
        +]
      • addedOutput schema / $defs / Receipt
        Added value: +{
        +  "properties": {
        +    "aim": {
        +      "anyOf": [
        +        {
        +          "type": "string"
        +        },
        +        {
        +          "type": "null"
        +        }
        +      ],
        +      "title": "Aim"
        +    },
        +    "clamped": {
        +      "title": "Clamped",
        +      "type": "boolean"
        +    },
        +    "exclude_domains": {
        +      "items": {
        +        "type": "string"
        +      },
        +      "title": "Exclude Domains",
        +      "type": "array"
        +    },
        +    "executed_query": {
        +      "title": "Executed Query",
        +      "type": "string"
        +    },
        +    "failed": {
        +      "title": "Failed",
        +      "type": "integer"
        +    },
        +    "filtered": {
        +      "title": "Filtered",
        +      "type": "integer"
        +    },
        +    "hits": {
        +      "title": "Hits",
        +      "type": "integer"
        +    },
        +    "hits_before_date": {
        +      "title": "Hits Before Date",
        +      "type": "integer"
        +    },
        +    "hits_by_domain": {
        +      "additionalProperties": {
        +        "type": "integer"
        +      },
        +      "title": "Hits By Domain",
        +      "type": "object"
        +    },
        +    "hits_excluded": {
        +      "title": "Hits Excluded",
        +      "type": "integer"
        +    },
        +    "hits_outside_domains": {
        +      "title": "Hits Outside Domains",
        +      "type": "integer"
        +    },
        +    "hits_same_domain": {
        +      "title": "Hits Same Domain",
        +      "type": "integer"
        +    },
        +    "hits_unread": {
        +      "title": "Hits Unread",
        +      "type": "integer"
        +    },
        +    "include_domains": {
        +      "items": {
        +        "type": "string"
        +      },
        +      "title": "Include Domains",
        +      "type": "array"
        +    },
        +    "not_content": {
        +      "title": "Not Content",
        +      "type": "integer"
        +    },
        +    "omitted": {
        +      "title": "Omitted",
        +      "type": "integer"
        +    },
        +    "outside_domains": {
        +      "title": "Outside Domains",
        +      "type": "integer"
        +    },
        +    "published_after": {
        +      "anyOf": [
        +        {
        +          "type": "string"
        +        },
        +        {
        +          "type": "null"
        +        }
        +      ],
        +      "title": "Published After"
        +    },
        +    "set_aside": {
        +      "title": "Set Aside",
        +      "type": "integer"
        +    },
        +    "unknown_dates": {
        +      "anyOf": [
        +        {
        +          "type": "integer"
        +        },
        +        {
        +          "type": "null"
        +        }
        +      ],
        +      "title": "Unknown Dates"
        +    },
        +    "usable": {
        +      "title": "Usable",
        +      "type": "integer"
        +    }
        +  },
        +  "required": [
        +    "executed_query",
        +    "aim",
        +    "include_domains",
        +    "exclude_domains",
        +    "published_after",
        +    "hits",
        +    "hits_by_domain",
        +    "hits_excluded",
        +    "hits_outside_domains",
        +    "hits_before_date",
        +    "hits_same_domain",
        +    "hits_unread",
        +    "failed",
        +    "not_content",
        +    "filtered",
        +    "usable",
        +    "outside_domains",
        +    "unknown_dates",
        +    "omitted",
        +    "set_aside",
        +    "clamped"
        +  ],
        +  "title": "Receipt",
        +  "type": "object"
        +}
      • addedOutput schema / $defs / Source / properties / filtered
        Added value: +{
        +  "anyOf": [
        +    {
        +      "enum": [
        +        "excluded_domain",
        +        "outside_domains",
        +        "published_before",
        +        "duplicate"
        +      ],
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "title": "Filtered"
        +}
      • changedOutput schema / $defs / Source / required
        Previous value: -[
        -  "url",
        -  "title",
        -  "status",
        -  "page_status",
        -  "threats",
        -  "provenance",
        -  "error"
        -]New value: +[
        +  "url",
        +  "title",
        +  "status",
        +  "page_status",
        +  "threats",
        +  "provenance",
        +  "error",
        +  "filtered"
        +]
      • addedOutput schema / properties / receipt
        Added value: +{
        +  "$ref": "#/$defs/Receipt"
        +}
      • changedOutput schema / required
        Previous value: -[
        -  "query",
        -  "backend",
        -  "passages",
        -  "sources",
        -  "truncated"
        -]New value: +[
        +  "query",
        +  "backend",
        +  "passages",
        +  "sources",
        +  "truncated",
        +  "receipt"
        +]
  2. 1 tool updatev0.16.1
    • Changedread_url2 fields changed
      • addedOutput schema / properties / set_aside
        Added value: +{
        +  "title": "Set Aside",
        +  "type": "integer"
        +}
      • changedOutput schema / required
        Previous value: -[
        -  "markdown",
        -  "title",
        -  "url",
        -  "final_url",
        -  "fetched_at",
        -  "status",
        -  "http_status",
        -  "truncated",
        -  "threats",
        -  "matches",
        -  "provenance"
        -]New value: +[
        +  "markdown",
        +  "title",
        +  "url",
        +  "final_url",
        +  "fetched_at",
        +  "status",
        +  "http_status",
        +  "truncated",
        +  "threats",
        +  "matches",
        +  "set_aside",
        +  "provenance"
        +]
  3. 2 tool updatesv0.15.0
    • Changedread_url1 field changed
      • addedOutput schema / $defs / Threat / properties / seen
        Added value: +{
        +  "title": "Seen",
        +  "type": "integer"
        +}
    • Changedresearch1 field changed
      • addedOutput schema / $defs / Threat / properties / seen
        Added value: +{
        +  "title": "Seen",
        +  "type": "integer"
        +}
  4. 2 tool updatesv0.13.0
    • Changedread_url1 field changed
      • changedOutput schema / $defs / Threat / properties / type / enum
        Previous value: -[
        -  "hidden_css",
        -  "report_truncated",
        -  "final_url_suppressed",
        -  "detection_degraded",
        -  "strip_incomplete",
        -  "zero_width",
        -  "bidi",
        -  "tag"
        -]New value: +[
        +  "hidden_css",
        +  "hidden_attribute",
        +  "hidden_template",
        +  "report_truncated",
        +  "final_url_suppressed",
        +  "detection_degraded",
        +  "strip_incomplete",
        +  "zero_width",
        +  "bidi",
        +  "tag"
        +]
    • Changedresearch1 field changed
      • changedOutput schema / $defs / Threat / properties / type / enum
        Previous value: -[
        -  "hidden_css",
        -  "report_truncated",
        -  "final_url_suppressed",
        -  "detection_degraded",
        -  "strip_incomplete",
        -  "zero_width",
        -  "bidi",
        -  "tag"
        -]New value: +[
        +  "hidden_css",
        +  "hidden_attribute",
        +  "hidden_template",
        +  "report_truncated",
        +  "final_url_suppressed",
        +  "detection_degraded",
        +  "strip_incomplete",
        +  "zero_width",
        +  "bidi",
        +  "tag"
        +]
  5. 2 tool updatesv0.10.0
    • Changedread_url3 fields changed
      • addedOutput schema / properties / http_status
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "integer"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "title": "Http Status"
        +}
      • addedOutput schema / properties / status
        Added value: +{
        +  "enum": [
        +    "ok",
        +    "challenge",
        +    "blocked",
        +    "rate_limited",
        +    "not_found",
        +    "server_error",
        +    "unsupported_content",
        +    "unknown"
        +  ],
        +  "title": "Status",
        +  "type": "string"
        +}
      • changedOutput schema / required
        Previous value: -[
        -  "markdown",
        -  "title",
        -  "url",
        -  "final_url",
        -  "fetched_at",
        -  "truncated",
        -  "threats",
        -  "matches",
        -  "provenance"
        -]New value: +[
        +  "markdown",
        +  "title",
        +  "url",
        +  "final_url",
        +  "fetched_at",
        +  "status",
        +  "http_status",
        +  "truncated",
        +  "threats",
        +  "matches",
        +  "provenance"
        +]
    • Changedresearch2 fields changed
      • addedOutput schema / $defs / Source / properties / page_status
        Added value: +{
        +  "anyOf": [
        +    {
        +      "enum": [
        +        "ok",
        +        "challenge",
        +        "blocked",
        +        "rate_limited",
        +        "not_found",
        +        "server_error",
        +        "unsupported_content",
        +        "unknown"
        +      ],
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "title": "Page Status"
        +}
      • changedOutput schema / $defs / Source / required
        Previous value: -[
        -  "url",
        -  "title",
        -  "status",
        -  "threats",
        -  "provenance",
        -  "error"
        -]New value: +[
        +  "url",
        +  "title",
        +  "status",
        +  "page_status",
        +  "threats",
        +  "provenance",
        +  "error"
        +]
  6. 3 tool updatesv1.0.0
    • Changedread_url5 fields changed
      • addedInput schema / properties / format / description
        Added value: +"'markdown' extracts the article; 'text' returns the page's rendered text."
      • addedInput schema / properties / include_hidden / description
        Added value: +"Keep text that is invisible to a human reader. It is reported in `threats` either way; this only controls whether it stays in the content."
      • addedInput schema / properties / max_tokens / description
        Added value: +"Token budget for the content. Omit to use the server's GROUNDHOG_MAX_TOKENS (20000 by default). Must be positive."
      • addedInput schema / properties / query / description
        Added value: +"When set, `matches` carries the passages most relevant to it, each with its heading and offset for citation."
      • addedInput schema / properties / url / description
        Added value: +"Absolute http(s) URL. Private and loopback addresses are refused."
    • Changedresearch3 fields changed
      • addedInput schema / properties / max_sources / description
        Added value: +"How many pages to read. Values outside 1-10 are clamped rather than rejected. Each source is a full page fetch, so this is the main cost and latency control."
      • addedInput schema / properties / max_tokens / description
        Added value: +"Token budget for the returned passages. Omit to use the server's GROUNDHOG_MAX_TOKENS (20000 by default). Must be positive."
      • addedInput schema / properties / query / description
        Added value: +"The question to research. Passages are ranked against it."
    • Changedsearch2 fields changed
      • addedInput schema / properties / limit / description
        Added value: +"How many hits to return. Values outside 1-25 are clamped rather than rejected."
      • addedInput schema / properties / query / description
        Added value: +"What to search for. Must not be empty."
  7. 4 tool updates
    • First observedread_url
    • First observedresearch
    • First observedsearch
    • First observedstatus

TDQS

A4.6/5.0

Scored across 4 tools

Disambiguation5/5

Each tool has a distinct purpose: status checks browser connectivity, search returns links only, read_url fetches a known URL, and research combines search and reading into one call. The descriptions explicitly call out the boundaries between search, read_url, and research, so an agent should not confuse them.

Naming Consistency3/5

The names are readable and all lowercase, but they follow mixed conventions: read_url uses a verb_noun pattern, status is a noun, and search/research are bare verbs. There is no consistent structural pattern across the set, though the names themselves are not misleading.

Tool Count5/5

Four tools is well-scoped for the server's purpose: setup diagnostics, link-only search, single-page reading, and combined research. Each tool fills a necessary role without redundancy or bloat.

Completeness5/5

The tool surface covers the full workflow: check browser readiness, discover URLs via search, read a specific page via read_url, and get synthesized passages via research. There are no obvious dead ends or missing operations for the stated web-research purpose.

Maintenance

ActivityActive
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    A
    maintenance
    This server enables LLMs to retrieve and process content from web pages, converting HTML to markdown for easier consumption.
    20
    1
    228,373 PyPI
    91,381
    MIT
  • A
    license
    A
    quality
    C
    maintenance
    Enables web content extraction, screenshot capture, web search, arXiv paper search, and image search through Jina AI's APIs. Provides tools for reading URLs as markdown, searching the web for current information, and finding academic papers or images.
    19
    883
    Apache 2.0