groundhog-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ # Dependencies
2
+ node_modules/
3
+ **/node_modules/
4
+ .venv/
5
+ __pycache__/
6
+ *.py[cod]
7
+
8
+ # Build / runtime
9
+ chrome-user-data/
10
+ downloads/
11
+ screenshots/
12
+ *.log
13
+
14
+ # Example/test run artifacts
15
+ examples/**/*.png
16
+ package-lock.json
17
+ go.sum
18
+
19
+ # Env / secrets
20
+ .env
21
+ .env.*
22
+ *.pem
23
+ *.key
24
+
25
+ # OS / editor
26
+ .DS_Store
27
+ .idea/
28
+ .vscode/
29
+ *.swp
30
+
31
+ # Internal (never publish)
32
+ .superpowers/
33
+ docs/superpowers/
34
+ .internal/
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: groundhog-mcp
3
+ Version: 0.1.0
4
+ Summary: Stealth-browser grounding tools for AI agents, over MCP
5
+ Project-URL: Homepage, https://github.com/dmytrome/groundhog
6
+ Project-URL: Repository, https://github.com/dmytrome/groundhog
7
+ Project-URL: Issues, https://github.com/dmytrome/groundhog/issues
8
+ Author: dmytrome
9
+ License-Expression: MIT
10
+ Keywords: agents,browser,cdp,mcp,scraping,ssrf,stealth
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
15
+ Requires-Python: >=3.11
16
+ Requires-Dist: mcp<2,>=1.28
17
+ Requires-Dist: playwright>=1.49
18
+ Requires-Dist: tldextract>=5.1
19
+ Requires-Dist: trafilatura>=2.0
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "groundhog-mcp"
3
+ version = "0.1.0"
4
+ description = "Stealth-browser grounding tools for AI agents, over MCP"
5
+ requires-python = ">=3.11"
6
+ license = "MIT"
7
+ authors = [{ name = "dmytrome" }]
8
+ keywords = ["mcp", "stealth", "browser", "cdp", "scraping", "ssrf", "agents"]
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Intended Audience :: Developers",
12
+ "Programming Language :: Python :: 3",
13
+ "Topic :: Internet :: WWW/HTTP :: Browsers",
14
+ ]
15
+ dependencies = [
16
+ "mcp>=1.28,<2",
17
+ "playwright>=1.49",
18
+ "trafilatura>=2.0",
19
+ "tldextract>=5.1",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/dmytrome/groundhog"
24
+ Repository = "https://github.com/dmytrome/groundhog"
25
+ Issues = "https://github.com/dmytrome/groundhog/issues"
26
+
27
+ [project.scripts]
28
+ groundhog-mcp = "groundhog_mcp.__main__:main"
29
+
30
+ [dependency-groups]
31
+ dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.14"]
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.pytest.ini_options]
38
+ asyncio_mode = "auto"
39
+ testpaths = ["tests"]
40
+
41
+ [tool.ruff]
42
+ line-length = 100
43
+
44
+ [tool.ruff.lint]
45
+ select = ["E", "F", "I", "B", "UP"]
46
+
47
+ [tool.ruff.lint.isort]
48
+ combine-as-imports = true
File without changes
@@ -0,0 +1,11 @@
1
+ from .server import build_server
2
+
3
+
4
+ def main() -> None:
5
+ # stdio is the only transport for now; HTTP support ships with its own
6
+ # host/DNS-rebinding hardening.
7
+ build_server().run(transport="stdio")
8
+
9
+
10
+ if __name__ == "__main__":
11
+ main()
@@ -0,0 +1,36 @@
1
+ import os
2
+ from dataclasses import dataclass
3
+
4
+ DEFAULT_USER_AGENT = (
5
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
6
+ "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
7
+ )
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Config:
12
+ cdp_url: str
13
+ min_delay_ms: int
14
+ block_private_ips: bool
15
+ max_tokens: int
16
+ user_agent: str
17
+ auto_start_browser: bool
18
+ compose_file: str | None
19
+
20
+
21
+ def _bool(value: str | None, default: bool) -> bool:
22
+ if value is None:
23
+ return default
24
+ return value.strip().lower() in ("1", "true", "yes", "on")
25
+
26
+
27
+ def load_config() -> Config:
28
+ return Config(
29
+ cdp_url=os.environ.get("CDP_URL", "http://127.0.0.1:9222"),
30
+ min_delay_ms=int(os.environ.get("GROUNDHOG_MIN_DELAY_MS", "5000")),
31
+ block_private_ips=_bool(os.environ.get("GROUNDHOG_BLOCK_PRIVATE_IPS"), True),
32
+ max_tokens=int(os.environ.get("GROUNDHOG_MAX_TOKENS", "20000")),
33
+ user_agent=os.environ.get("GROUNDHOG_USER_AGENT", DEFAULT_USER_AGENT),
34
+ auto_start_browser=_bool(os.environ.get("GROUNDHOG_AUTO_START_BROWSER"), False),
35
+ compose_file=os.environ.get("GROUNDHOG_COMPOSE_FILE") or None,
36
+ )
@@ -0,0 +1,156 @@
1
+ import asyncio
2
+ import urllib.request
3
+ from dataclasses import dataclass
4
+
5
+ import tldextract
6
+ from playwright.async_api import (
7
+ Browser,
8
+ BrowserContext,
9
+ Error as PlaywrightError,
10
+ Playwright,
11
+ async_playwright,
12
+ )
13
+
14
+ from . import safety
15
+ from .config import Config, load_config
16
+ from .ratelimit import RateLimiter
17
+
18
+ _GOTO_TIMEOUT_MS = 60_000
19
+ _PROBE_TIMEOUT_S = 2.0
20
+ _AUTOSTART_READY_TRIES = 30
21
+ _ERR_DETAIL_CHARS = 300
22
+
23
+
24
+ class BrowserUnavailableError(Exception):
25
+ """The stealth browser's CDP endpoint could not be reached."""
26
+
27
+
28
+ def remediation(cfg: Config) -> str:
29
+ return (
30
+ f"Cannot reach the stealth browser at {cfg.cdp_url}. "
31
+ "Start it with `docker compose up -d` (see the Groundhog README), "
32
+ "or set GROUNDHOG_AUTO_START_BROWSER=true to let Groundhog start it for you."
33
+ )
34
+
35
+
36
+ async def check_browser(cdp_url: str, timeout: float = _PROBE_TIMEOUT_S) -> bool:
37
+ """Return whether the CDP endpoint answers, via its `/json/version` probe."""
38
+ probe_url = cdp_url.rstrip("/") + "/json/version"
39
+
40
+ def _probe() -> bool:
41
+ try:
42
+ with urllib.request.urlopen(probe_url, timeout=timeout) as resp:
43
+ return resp.status == 200
44
+ except OSError:
45
+ return False # refused / DNS / timeout all mean "not reachable"
46
+
47
+ return await asyncio.get_running_loop().run_in_executor(None, _probe)
48
+
49
+
50
+ async def _start_browser_container(cfg: Config) -> None:
51
+ cmd = ["docker", "compose"]
52
+ if cfg.compose_file:
53
+ cmd += ["-f", cfg.compose_file]
54
+ cmd += ["up", "-d"]
55
+ try:
56
+ proc = await asyncio.create_subprocess_exec(
57
+ *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
58
+ )
59
+ except FileNotFoundError as exc:
60
+ raise BrowserUnavailableError(
61
+ "GROUNDHOG_AUTO_START_BROWSER is set but Docker was not found on PATH. "
62
+ + remediation(cfg)
63
+ ) from exc
64
+ _, stderr = await proc.communicate()
65
+ if proc.returncode != 0:
66
+ detail = stderr.decode(errors="replace").strip()[:_ERR_DETAIL_CHARS]
67
+ raise BrowserUnavailableError(f"`docker compose up -d` failed: {detail}")
68
+ for _ in range(_AUTOSTART_READY_TRIES):
69
+ if await check_browser(cfg.cdp_url):
70
+ return
71
+ await asyncio.sleep(1)
72
+ raise BrowserUnavailableError(
73
+ f"Browser container started but {cfg.cdp_url} did not become ready in time."
74
+ )
75
+
76
+
77
+ @dataclass
78
+ class RenderedPage:
79
+ html: str
80
+ text: str
81
+ final_url: str
82
+ title: str
83
+
84
+
85
+ def registrable_domain(url: str) -> str:
86
+ ext = tldextract.extract(url)
87
+ return ext.registered_domain or ext.fqdn or url
88
+
89
+
90
+ class EngineProvider:
91
+ def __init__(self, cfg: Config):
92
+ self._cfg = cfg
93
+ self._pw: Playwright | None = None
94
+ self._browser: Browser | None = None
95
+ self._context: BrowserContext | None = None
96
+ self._rl = RateLimiter(cfg.min_delay_ms / 1000)
97
+
98
+ async def start(self) -> None:
99
+ self._pw = await async_playwright().start()
100
+ self._browser = await self._connect()
101
+ self._context = await self._browser.new_context(
102
+ user_agent=self._cfg.user_agent,
103
+ viewport={"width": 1920, "height": 1080},
104
+ )
105
+
106
+ async def _connect(self) -> Browser:
107
+ try:
108
+ return await self._pw.chromium.connect_over_cdp(self._cfg.cdp_url)
109
+ except PlaywrightError as exc:
110
+ if not self._cfg.auto_start_browser:
111
+ raise BrowserUnavailableError(remediation(self._cfg)) from exc
112
+ await _start_browser_container(self._cfg)
113
+ try:
114
+ return await self._pw.chromium.connect_over_cdp(self._cfg.cdp_url)
115
+ except PlaywrightError as exc:
116
+ raise BrowserUnavailableError(remediation(self._cfg)) from exc
117
+
118
+ async def fetch(self, url: str) -> RenderedPage:
119
+ await safety.check_url(url, self._cfg)
120
+ await self._rl.acquire(registrable_domain(url))
121
+ page = await self._context.new_page()
122
+ try:
123
+ await page.goto(url, wait_until="domcontentloaded", timeout=_GOTO_TIMEOUT_MS)
124
+ # A page can redirect to an internal address the initial check never
125
+ # saw; re-check the final URL so its content is never returned.
126
+ await safety.check_url(page.url, self._cfg)
127
+ return RenderedPage(
128
+ html=await page.content(),
129
+ text=await page.inner_text("body"),
130
+ final_url=page.url,
131
+ title=await page.title(),
132
+ )
133
+ finally:
134
+ await page.close()
135
+
136
+ async def aclose(self) -> None:
137
+ if self._context is not None:
138
+ await self._context.close()
139
+ if self._browser is not None:
140
+ await self._browser.close()
141
+ if self._pw is not None:
142
+ await self._pw.stop()
143
+
144
+
145
+ _provider: EngineProvider | None = None
146
+ _provider_lock = asyncio.Lock()
147
+
148
+
149
+ async def get_provider() -> EngineProvider:
150
+ global _provider
151
+ async with _provider_lock:
152
+ if _provider is None:
153
+ provider = EngineProvider(load_config())
154
+ await provider.start()
155
+ _provider = provider
156
+ return _provider
@@ -0,0 +1,27 @@
1
+ import trafilatura
2
+
3
+ _CHARS_PER_TOKEN = 4
4
+ _TRUNCATION_MARKER = "\n\n[... truncated]"
5
+
6
+
7
+ def truncate(text: str, max_tokens: int) -> tuple[str, bool]:
8
+ limit = max_tokens * _CHARS_PER_TOKEN
9
+ if len(text) <= limit:
10
+ return text, False
11
+ cut = text.rfind("\n\n", 0, limit)
12
+ if cut <= 0:
13
+ cut = limit
14
+ return text[:cut].rstrip() + _TRUNCATION_MARKER, True
15
+
16
+
17
+ def to_markdown(html: str, text_fallback: str, url: str, max_tokens: int) -> tuple[str, bool]:
18
+ markdown = trafilatura.extract(
19
+ html,
20
+ url=url,
21
+ output_format="markdown",
22
+ include_links=True,
23
+ include_tables=True,
24
+ )
25
+ if not markdown:
26
+ markdown = text_fallback or ""
27
+ return truncate(markdown, max_tokens)
@@ -0,0 +1,28 @@
1
+ import asyncio
2
+ import time
3
+
4
+
5
+ class RateLimiter:
6
+ """Enforces a minimum delay between acquisitions sharing the same key."""
7
+
8
+ def __init__(self, min_delay: float, *, clock=time.monotonic, sleep=asyncio.sleep):
9
+ self._min_delay = min_delay
10
+ self._clock = clock
11
+ self._sleep = sleep
12
+ self._last: dict[str, float] = {}
13
+ self._locks: dict[str, asyncio.Lock] = {}
14
+
15
+ def _lock(self, key: str) -> asyncio.Lock:
16
+ lock = self._locks.get(key)
17
+ if lock is None:
18
+ lock = self._locks[key] = asyncio.Lock()
19
+ return lock
20
+
21
+ async def acquire(self, key: str) -> None:
22
+ async with self._lock(key):
23
+ last = self._last.get(key)
24
+ if last is not None:
25
+ wait = self._min_delay - (self._clock() - last)
26
+ if wait > 0:
27
+ await self._sleep(wait)
28
+ self._last[key] = self._clock()
@@ -0,0 +1,40 @@
1
+ import asyncio
2
+ import ipaddress
3
+ from urllib.parse import urlparse
4
+
5
+ from .config import Config
6
+
7
+ _CGNAT = ipaddress.ip_network("100.64.0.0/10")
8
+
9
+
10
+ class BlockedURLError(Exception):
11
+ """Raised when a URL is disallowed by the SSRF guard."""
12
+
13
+
14
+ def is_blocked_ip(ip: str) -> bool:
15
+ addr = ipaddress.ip_address(ip)
16
+ if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped:
17
+ addr = addr.ipv4_mapped
18
+ if (addr.is_private or addr.is_loopback or addr.is_link_local
19
+ or addr.is_reserved or addr.is_multicast or addr.is_unspecified):
20
+ return True
21
+ return isinstance(addr, ipaddress.IPv4Address) and addr in _CGNAT
22
+
23
+
24
+ async def check_url(url: str, cfg: Config) -> None:
25
+ parsed = urlparse(url)
26
+ if parsed.scheme not in ("http", "https"):
27
+ raise BlockedURLError(f"scheme not allowed: {parsed.scheme!r}")
28
+ if parsed.username or parsed.password:
29
+ raise BlockedURLError("credentials in URL are not allowed")
30
+ host = parsed.hostname
31
+ if not host:
32
+ raise BlockedURLError("URL has no host")
33
+ if not cfg.block_private_ips:
34
+ return
35
+ loop = asyncio.get_running_loop()
36
+ infos = await loop.getaddrinfo(host, None)
37
+ for info in infos:
38
+ ip = info[4][0]
39
+ if is_blocked_ip(ip):
40
+ raise BlockedURLError(f"blocked address: {host} -> {ip}")
@@ -0,0 +1,11 @@
1
+ from mcp.server.fastmcp import FastMCP
2
+
3
+ from .tools.read_url import read_url
4
+ from .tools.status import status
5
+
6
+
7
+ def build_server() -> FastMCP:
8
+ mcp = FastMCP("groundhog")
9
+ mcp.tool()(read_url)
10
+ mcp.tool()(status)
11
+ return mcp
@@ -0,0 +1,42 @@
1
+ from datetime import UTC, datetime
2
+ from typing import TypedDict
3
+
4
+ from .. import extract
5
+ from ..config import load_config
6
+ from ..engine import get_provider
7
+
8
+ _FORMATS = ("markdown", "text")
9
+
10
+
11
+ class ReadResult(TypedDict):
12
+ markdown: str
13
+ title: str
14
+ url: str
15
+ final_url: str
16
+ fetched_at: str
17
+ truncated: bool
18
+
19
+
20
+ async def read_url(url: str, format: str = "markdown", max_tokens: int | None = None) -> ReadResult:
21
+ """Fetch a web page through the stealth browser and return clean Markdown
22
+ plus provenance (source URL, final URL, title, fetch time). Use this to
23
+ ground answers in live web content, including sites that block plain
24
+ fetchers. `format` may be "markdown" (default) or "text"."""
25
+ if format not in _FORMATS:
26
+ raise ValueError(f"format must be one of {_FORMATS}, got {format!r}")
27
+ cfg = load_config()
28
+ provider = await get_provider()
29
+ page = await provider.fetch(url)
30
+ limit = max_tokens or cfg.max_tokens
31
+ if format == "text":
32
+ body, truncated = extract.truncate(page.text, limit)
33
+ else:
34
+ body, truncated = extract.to_markdown(page.html, page.text, page.final_url, limit)
35
+ return {
36
+ "markdown": body,
37
+ "title": page.title,
38
+ "url": url,
39
+ "final_url": page.final_url,
40
+ "fetched_at": datetime.now(UTC).isoformat(),
41
+ "truncated": truncated,
42
+ }
@@ -0,0 +1,20 @@
1
+ from typing import TypedDict
2
+
3
+ from ..config import load_config
4
+ from ..engine import check_browser, remediation
5
+
6
+
7
+ class StatusResult(TypedDict):
8
+ browser_reachable: bool
9
+ cdp_url: str
10
+ hint: str | None
11
+
12
+
13
+ async def status() -> StatusResult:
14
+ """Check whether Groundhog can reach the stealth browser. Call this to
15
+ diagnose setup before fetching: if `browser_reachable` is false, follow
16
+ `hint` to start the browser, then retry."""
17
+ cfg = load_config()
18
+ reachable = await check_browser(cfg.cdp_url)
19
+ hint = None if reachable else remediation(cfg)
20
+ return {"browser_reachable": reachable, "cdp_url": cfg.cdp_url, "hint": hint}
File without changes
@@ -0,0 +1,23 @@
1
+ from groundhog_mcp.config import load_config
2
+
3
+
4
+ def test_defaults(monkeypatch):
5
+ for key in ("CDP_URL", "GROUNDHOG_MIN_DELAY_MS", "GROUNDHOG_BLOCK_PRIVATE_IPS",
6
+ "GROUNDHOG_MAX_TOKENS", "GROUNDHOG_USER_AGENT"):
7
+ monkeypatch.delenv(key, raising=False)
8
+ cfg = load_config()
9
+ assert cfg.cdp_url == "http://127.0.0.1:9222"
10
+ assert cfg.min_delay_ms == 5000
11
+ assert cfg.block_private_ips is True
12
+ assert cfg.max_tokens == 20000
13
+ assert "Chrome/149" in cfg.user_agent
14
+
15
+
16
+ def test_env_overrides(monkeypatch):
17
+ monkeypatch.setenv("CDP_URL", "http://127.0.0.1:9333")
18
+ monkeypatch.setenv("GROUNDHOG_MIN_DELAY_MS", "0")
19
+ monkeypatch.setenv("GROUNDHOG_BLOCK_PRIVATE_IPS", "false")
20
+ cfg = load_config()
21
+ assert cfg.cdp_url == "http://127.0.0.1:9333"
22
+ assert cfg.min_delay_ms == 0
23
+ assert cfg.block_private_ips is False
@@ -0,0 +1,35 @@
1
+ import os
2
+
3
+ import pytest
4
+
5
+ from groundhog_mcp.config import load_config
6
+ from groundhog_mcp.engine import EngineProvider
7
+ from groundhog_mcp.safety import BlockedURLError
8
+
9
+ pytestmark = pytest.mark.skipif(
10
+ os.environ.get("RUN_LIVE") != "1",
11
+ reason="requires the engine running; set RUN_LIVE=1 and CDP_URL",
12
+ )
13
+
14
+
15
+ async def test_fetch_example_com():
16
+ provider = EngineProvider(load_config())
17
+ await provider.start()
18
+ try:
19
+ page = await provider.fetch("https://example.com/")
20
+ assert "Example Domain" in page.title
21
+ assert "Example Domain" in page.html
22
+ assert page.final_url.startswith("https://example.com")
23
+ assert "Example Domain" in page.text
24
+ finally:
25
+ await provider.aclose()
26
+
27
+
28
+ async def test_fetch_blocks_internal():
29
+ provider = EngineProvider(load_config())
30
+ await provider.start()
31
+ try:
32
+ with pytest.raises(BlockedURLError):
33
+ await provider.fetch("http://169.254.169.254/")
34
+ finally:
35
+ await provider.aclose()
@@ -0,0 +1,40 @@
1
+ from groundhog_mcp.extract import to_markdown, truncate
2
+
3
+ ARTICLE = """
4
+ <html><head><title>T</title></head><body>
5
+ <article><h1>Hello World</h1>
6
+ <p>This is the first paragraph of a real article with enough words to be
7
+ extracted by the content extractor. It mentions grounding for agents.</p>
8
+ <p>This is a second substantial paragraph so the extractor crosses its
9
+ content threshold and returns the main text rather than nothing at all.</p>
10
+ </article></body></html>
11
+ """
12
+
13
+
14
+ def test_truncate_under_limit():
15
+ text, cut = truncate("hello", 1000)
16
+ assert text == "hello"
17
+ assert cut is False
18
+
19
+
20
+ def test_truncate_over_limit_marks_and_cuts():
21
+ text, cut = truncate("para one\n\npara two\n\npara three", 2) # limit=8, no \n\n below it
22
+ assert cut is True
23
+ assert text.endswith("\n\n[... truncated]")
24
+
25
+
26
+ def test_truncate_cuts_at_paragraph_boundary():
27
+ text, cut = truncate("para one\n\npara two\n\npara three", 3) # limit=12, \n\n at index 8
28
+ assert cut is True
29
+ assert text == "para one\n\n[... truncated]"
30
+
31
+
32
+ def test_to_markdown_extracts_article():
33
+ md, cut = to_markdown(ARTICLE, "", "https://example.com/a", 10000)
34
+ assert "Hello World" in md
35
+ assert cut is False
36
+
37
+
38
+ def test_to_markdown_falls_back_to_text():
39
+ md, cut = to_markdown("<html><body></body></html>", "FALLBACK TEXT", "https://x.com", 10000)
40
+ assert "FALLBACK TEXT" in md
@@ -0,0 +1,48 @@
1
+ import asyncio
2
+
3
+ from groundhog_mcp.ratelimit import RateLimiter
4
+
5
+
6
+ def make_clock(start=100.0):
7
+ state = {"t": start}
8
+ return state
9
+
10
+ async def test_same_key_waits_once():
11
+ clock = make_clock()
12
+ sleeps = []
13
+
14
+ async def fake_sleep(d):
15
+ sleeps.append(d)
16
+ clock["t"] += d
17
+
18
+ rl = RateLimiter(5.0, clock=lambda: clock["t"], sleep=fake_sleep)
19
+ await rl.acquire("example.com") # first call: no wait
20
+ await rl.acquire("example.com") # second call: must wait 5s
21
+ assert sleeps == [5.0]
22
+
23
+
24
+ async def test_different_keys_do_not_wait():
25
+ clock = make_clock()
26
+ sleeps = []
27
+
28
+ async def fake_sleep(d):
29
+ sleeps.append(d)
30
+ clock["t"] += d
31
+
32
+ rl = RateLimiter(5.0, clock=lambda: clock["t"], sleep=fake_sleep)
33
+ await rl.acquire("a.com")
34
+ await rl.acquire("b.com")
35
+ assert sleeps == []
36
+
37
+
38
+ async def test_concurrent_same_key_serialized():
39
+ clock = make_clock()
40
+ sleeps = []
41
+
42
+ async def fake_sleep(d):
43
+ sleeps.append(d)
44
+ clock["t"] += d
45
+
46
+ rl = RateLimiter(5.0, clock=lambda: clock["t"], sleep=fake_sleep)
47
+ await asyncio.gather(rl.acquire("x.com"), rl.acquire("x.com"))
48
+ assert len(sleeps) == 1
@@ -0,0 +1,9 @@
1
+ import pytest
2
+
3
+ from groundhog_mcp.tools.read_url import read_url
4
+
5
+
6
+ async def test_read_url_rejects_unknown_format():
7
+ # Validation happens at the boundary, before any engine call.
8
+ with pytest.raises(ValueError):
9
+ await read_url("https://example.com/", format="json")