webget-cli 0.7.3__tar.gz → 0.9.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.
Files changed (36) hide show
  1. {webget_cli-0.7.3/webget_cli.egg-info → webget_cli-0.9.0}/PKG-INFO +32 -4
  2. {webget_cli-0.7.3 → webget_cli-0.9.0}/README.md +31 -3
  3. {webget_cli-0.7.3 → webget_cli-0.9.0}/pyproject.toml +1 -1
  4. {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_adversarial_http.py +21 -0
  5. {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_adversarial_mcp.py +5 -5
  6. {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_adversarial_ssrf.py +3 -3
  7. webget_cli-0.9.0/tests/test_login_flow.py +180 -0
  8. {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_mcp_leak_review.py +2 -2
  9. webget_cli-0.9.0/tests/test_mcp_profile.py +177 -0
  10. webget_cli-0.9.0/tests/test_mcp_server.py +201 -0
  11. {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_security_review.py +6 -6
  12. webget_cli-0.9.0/tests/test_size_review.py +109 -0
  13. {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_webget.py +172 -7
  14. {webget_cli-0.7.3 → webget_cli-0.9.0/webget_cli.egg-info}/PKG-INFO +32 -4
  15. {webget_cli-0.7.3 → webget_cli-0.9.0}/webget_cli.egg-info/SOURCES.txt +2 -0
  16. {webget_cli-0.7.3 → webget_cli-0.9.0}/webget_cli.py +438 -64
  17. {webget_cli-0.7.3 → webget_cli-0.9.0}/webget_mcp.py +99 -0
  18. webget_cli-0.7.3/tests/test_mcp_server.py +0 -102
  19. webget_cli-0.7.3/tests/test_size_review.py +0 -80
  20. {webget_cli-0.7.3 → webget_cli-0.9.0}/LICENSE +0 -0
  21. {webget_cli-0.7.3 → webget_cli-0.9.0}/setup.cfg +0 -0
  22. {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_adversarial_auth.py +0 -0
  23. {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_adversarial_cache.py +0 -0
  24. {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_adversarial_concurrency.py +0 -0
  25. {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_auth_review.py +0 -0
  26. {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_browser_ssrf.py +0 -0
  27. {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_cache_review.py +0 -0
  28. {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_concurrency_review.py +0 -0
  29. {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_extraction_markdown.py +0 -0
  30. {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_firecrawl_policy.py +0 -0
  31. {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_integration_ladder.py +0 -0
  32. {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_mcp_smoke.py +0 -0
  33. {webget_cli-0.7.3 → webget_cli-0.9.0}/webget_cli.egg-info/dependency_links.txt +0 -0
  34. {webget_cli-0.7.3 → webget_cli-0.9.0}/webget_cli.egg-info/entry_points.txt +0 -0
  35. {webget_cli-0.7.3 → webget_cli-0.9.0}/webget_cli.egg-info/requires.txt +0 -0
  36. {webget_cli-0.7.3 → webget_cli-0.9.0}/webget_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webget-cli
3
- Version: 0.7.3
3
+ Version: 0.9.0
4
4
  Summary: Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys.
5
5
  Author-email: David Tarigan <tarigansdavid@gmail.com>
6
6
  License: Apache-2.0
@@ -244,9 +244,37 @@ Register as a local MCP server in opencode:
244
244
  Then prompt with `use webget` for search and scrape tasks. Run the server
245
245
  standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
246
246
 
247
- > **Limitation:** MCP tools do not expose `--profile`/`--cookies`, so
248
- > authenticated pages are out of scope for the MCP server. Use the CLI
249
- > (`webget login`, `webget u --profile ...`) for session-based fetching.
247
+ ### Authenticated sessions (profiles)
248
+
249
+ MCP tools can use locally stored login sessions. Create one first with the
250
+ CLI, or let the agent create it via the MCP `login` tool:
251
+
252
+ ```bash
253
+ webget login https://portal.example.com --profile portal
254
+ ```
255
+
256
+ Then the agent can discover sessions and fetch authenticated pages:
257
+
258
+ - `list_profiles` - lists available sessions (name, last used, size,
259
+ status). Cookie values are never returned.
260
+ - `login(url, profile)` - opens a browser session (headful by default so a
261
+ human can log in), navigates to `url`, and persists the session once the
262
+ login handshake's cookies appear (or after `wait_seconds`, whichever
263
+ comes first). MCP stdin is the JSON-RPC stream, so there is no Enter
264
+ keypress; the flow polls for cookies instead.
265
+ - `fetch(..., profile="portal")` / `search_fetch(..., profile="portal")` -
266
+ scrape using that session.
267
+
268
+ ```text
269
+ agent: "check my portal for new announcements"
270
+ 1. list_profiles -> portal (authenticated)
271
+ 2. login(https://portal.example.com, profile="portal") # if not listed
272
+ 3. fetch(url, profile="portal")
273
+ ```
274
+
275
+ Invalid profile names and unknown profiles are hard errors (no silent
276
+ anonymous fallback). Sessions are local to the machine running the MCP
277
+ server.
250
278
 
251
279
  ## Development
252
280
 
@@ -210,9 +210,37 @@ Register as a local MCP server in opencode:
210
210
  Then prompt with `use webget` for search and scrape tasks. Run the server
211
211
  standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
212
212
 
213
- > **Limitation:** MCP tools do not expose `--profile`/`--cookies`, so
214
- > authenticated pages are out of scope for the MCP server. Use the CLI
215
- > (`webget login`, `webget u --profile ...`) for session-based fetching.
213
+ ### Authenticated sessions (profiles)
214
+
215
+ MCP tools can use locally stored login sessions. Create one first with the
216
+ CLI, or let the agent create it via the MCP `login` tool:
217
+
218
+ ```bash
219
+ webget login https://portal.example.com --profile portal
220
+ ```
221
+
222
+ Then the agent can discover sessions and fetch authenticated pages:
223
+
224
+ - `list_profiles` - lists available sessions (name, last used, size,
225
+ status). Cookie values are never returned.
226
+ - `login(url, profile)` - opens a browser session (headful by default so a
227
+ human can log in), navigates to `url`, and persists the session once the
228
+ login handshake's cookies appear (or after `wait_seconds`, whichever
229
+ comes first). MCP stdin is the JSON-RPC stream, so there is no Enter
230
+ keypress; the flow polls for cookies instead.
231
+ - `fetch(..., profile="portal")` / `search_fetch(..., profile="portal")` -
232
+ scrape using that session.
233
+
234
+ ```text
235
+ agent: "check my portal for new announcements"
236
+ 1. list_profiles -> portal (authenticated)
237
+ 2. login(https://portal.example.com, profile="portal") # if not listed
238
+ 3. fetch(url, profile="portal")
239
+ ```
240
+
241
+ Invalid profile names and unknown profiles are hard errors (no silent
242
+ anonymous fallback). Sessions are local to the machine running the MCP
243
+ server.
216
244
 
217
245
  ## Development
218
246
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "webget-cli"
7
- version = "0.7.3"
7
+ version = "0.9.0"
8
8
  description = "Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,7 @@
1
1
  """Adversarial HTTP tests: URL handling, status codes, redirects, bodies."""
2
2
 
3
3
  import asyncio
4
+ import time
4
5
 
5
6
  import pytest
6
7
 
@@ -63,6 +64,26 @@ class TestConnectionFailures:
63
64
  res = asyncio.run(_fetch(server.url("/timeout?sec=5"), per_url_timeout=1))
64
65
  assert _one(res)["status"] == "error"
65
66
 
67
+ def test_slow_drip_hits_wall_clock_deadline(self, fresh_cache):
68
+ """A server that slow-drips the body must not exceed per_url_timeout.
69
+
70
+ httpx's own timeout bounds a single socket operation only, so a
71
+ drip of small chunks every 150ms (total ~6s for 40 chunks) would
72
+ keep the request alive far past a 2s budget. fetch_http enforces
73
+ an absolute deadline while streaming; this proves the URL errors
74
+ out in roughly the budget, not the full drip duration.
75
+ """
76
+ server = fresh_cache
77
+ t0 = time.monotonic()
78
+ res = asyncio.run(
79
+ _fetch(server.url("/drip?n=40&delay=0.15"), per_url_timeout=2, strategy="http")
80
+ )
81
+ elapsed = time.monotonic() - t0
82
+ out = _one(res)
83
+ assert out["status"] == "error"
84
+ # Drip would take ~6s uncapped; deadline must cut it near 2s.
85
+ assert elapsed < 4.0, f"deadline not enforced: took {elapsed:.1f}s"
86
+
66
87
 
67
88
  # ---------- status codes ----------
68
89
 
@@ -58,7 +58,7 @@ class TestMalformedArguments:
58
58
  return res
59
59
 
60
60
  res = _run(run())
61
- assert res.isError
61
+ assert res.is_error
62
62
 
63
63
 
64
64
  class TestInvalidURLs:
@@ -72,7 +72,7 @@ class TestInvalidURLs:
72
72
  return res
73
73
 
74
74
  res = _run(run())
75
- assert "error" in res.content[0].text or res.isError
75
+ assert "error" in res.content[0].text or res.is_error
76
76
 
77
77
 
78
78
  class TestRepeatedCalls:
@@ -86,7 +86,7 @@ class TestRepeatedCalls:
86
86
  "fetch",
87
87
  {"url": "https://example.com", "strategy": "http", "no_cache": True},
88
88
  )
89
- if res.isError:
89
+ if res.is_error:
90
90
  return "ERROR"
91
91
  return "OK"
92
92
 
@@ -106,7 +106,7 @@ class TestRepeatedCalls:
106
106
  for _ in range(5)
107
107
  ]
108
108
  )
109
- return [r.isError for r in results]
109
+ return [r.is_error for r in results]
110
110
 
111
111
  assert _run(run()) == [False] * 5
112
112
 
@@ -133,7 +133,7 @@ class TestInputCaps:
133
133
  return res
134
134
 
135
135
  res = _run(run())
136
- assert "must be between" in res.content[0].text or res.isError
136
+ assert "must be between" in res.content[0].text or res.is_error
137
137
 
138
138
 
139
139
  class TestToolFailureIsolation:
@@ -98,14 +98,14 @@ class TestSSRFBlocking:
98
98
  the initial URL passed the guard (e.g. it was public)."""
99
99
  import pytest
100
100
 
101
- real = webget._is_private_target
101
+ real = webget._private_ip_for
102
102
  initial = server.url("/redirect-private")
103
103
 
104
104
  def guarded(url):
105
105
  # initial URL passes (as if public); every other URL checked for real
106
- return real(url) if url != initial else False
106
+ return real(url) if url != initial else None
107
107
 
108
- monkeypatch.setattr(webget, "_is_private_target", guarded)
108
+ monkeypatch.setattr(webget, "_private_ip_for", guarded)
109
109
  with pytest.raises(webget.SSRFError):
110
110
  asyncio.run(webget.fetch_http(initial, 2000, timeout=5))
111
111
 
@@ -0,0 +1,180 @@
1
+ """MCP login tool + non-interactive login flow tests.
2
+
3
+ Covers:
4
+ - _login_flow(interactive=False): polls browser context until a Set-Cookie
5
+ arrives (here: /set-cookie on the local test server), then persists a
6
+ storage_state.json the HTTP fast path can reuse.
7
+ - MCP login tool: validates URL/profile, runs the flow headless against
8
+ the local server, then fetch(profile=...) uses the stored session.
9
+ - login tool rejects bad URLs and unknown/invalid profiles.
10
+
11
+ The test server lives on 127.0.0.1, so WEBGET_ALLOW_PRIVATE=1 is required
12
+ for the subsequent authenticated fetch through the SSRF-guarded HTTP path.
13
+ """
14
+
15
+ import asyncio
16
+ import json
17
+ import os
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ import pytest
22
+
23
+ ROOT = Path(__file__).resolve().parent.parent
24
+ sys.path.insert(0, str(ROOT))
25
+
26
+ import webget_cli as webget
27
+
28
+ playwright = pytest.importorskip("playwright", reason="playwright not installed")
29
+
30
+
31
+ def _profile_root(tmp_path):
32
+ return tmp_path / "profiles"
33
+
34
+
35
+ def test_login_flow_noninteractive_persists_session(server, tmp_path, monkeypatch):
36
+ """Browser-less-enough path: headless Chromium navigates /set-cookie,
37
+ cookie polling sees the Set-Cookie side effect, storage_state persists,
38
+ and the HTTP fast path can reuse it on /cookie-gated."""
39
+ root = _profile_root(tmp_path)
40
+ monkeypatch.setattr(webget, "PROFILE_DIR", str(root))
41
+ monkeypatch.setenv("WEBGET_ALLOW_PRIVATE", "1")
42
+
43
+ asyncio.run(
44
+ webget._login_flow(
45
+ server.url("/set-cookie"),
46
+ "flowp",
47
+ headless=True,
48
+ wait_seconds=30,
49
+ interactive=False,
50
+ quiet=True,
51
+ )
52
+ )
53
+
54
+ state_p = webget.profile_state_path("flowp")
55
+ assert os.path.exists(state_p)
56
+ state = json.loads(Path(state_p).read_text())
57
+ cookies = {c["name"]: c["value"] for c in state["cookies"]}
58
+ assert cookies.get("webget_session") == "1"
59
+
60
+ # HTTP fast path must now authenticate on the cookie-gated page.
61
+ res = asyncio.run(
62
+ webget.scrape_many(
63
+ [server.url("/cookie-gated")],
64
+ strategy="http",
65
+ no_cache=True,
66
+ profile="flowp",
67
+ )
68
+ )
69
+ out = res[server.url("/cookie-gated")]
70
+ assert out["status"] == "success"
71
+ assert out["auth"]["authenticated"] is True
72
+ assert "you are authenticated" in out["markdown"]
73
+
74
+
75
+ def test_login_flow_waits_until_cookie(server, tmp_path, monkeypatch):
76
+ """A page that does NOT set a cookie still persists (best-effort), and a
77
+ slow Set-Cookie is picked up by polling before the deadline."""
78
+ root = _profile_root(tmp_path)
79
+ monkeypatch.setattr(webget, "PROFILE_DIR", str(root))
80
+
81
+ asyncio.run(
82
+ webget._login_flow(
83
+ server.url("/set-cookie"),
84
+ "slowp",
85
+ headless=True,
86
+ wait_seconds=30,
87
+ interactive=False,
88
+ quiet=True,
89
+ )
90
+ )
91
+ state_p = webget.profile_state_path("slowp")
92
+ assert os.path.exists(state_p)
93
+ cookies = {c["name"] for c in json.loads(Path(state_p).read_text())["cookies"]}
94
+ assert "webget_session" in cookies
95
+
96
+
97
+ def _spawn_mcp(profile_root):
98
+ from mcp import StdioServerParameters
99
+
100
+ env = {
101
+ **os.environ,
102
+ "WEBGET_PROFILE_DIR": str(profile_root),
103
+ "WEBGET_ALLOW_PRIVATE": "1",
104
+ }
105
+ return StdioServerParameters(command=sys.executable, args=[str(ROOT / "webget_mcp.py")], env=env)
106
+
107
+
108
+ def test_mcp_login_tool_persists_and_fetch_uses_it(server, tmp_path):
109
+ """End-to-end MCP: login(url, profile, headless=True) against the local
110
+ /set-cookie page persists a session, then fetch(profile=...) on the
111
+ cookie-gated page succeeds while anonymous fetch does not."""
112
+ from mcp import ClientSession
113
+ from mcp.client.stdio import stdio_client
114
+
115
+ root = _profile_root(tmp_path)
116
+ login_url = server.url("/set-cookie")
117
+ gated_url = server.url("/cookie-gated")
118
+
119
+ async def run():
120
+ async with stdio_client(_spawn_mcp(root)) as (read, write), ClientSession(read, write) as session:
121
+ await session.initialize()
122
+ res = await session.call_tool(
123
+ "login",
124
+ {"url": login_url, "profile": "mcplogin", "headless": True, "wait_seconds": 30},
125
+ )
126
+ authed = await session.call_tool(
127
+ "fetch", {"url": gated_url, "strategy": "http", "no_cache": True, "profile": "mcplogin"}
128
+ )
129
+ anon = await session.call_tool(
130
+ "fetch", {"url": gated_url, "strategy": "http", "no_cache": True}
131
+ )
132
+ return res, authed, anon
133
+
134
+ res, authed, anon = asyncio.run(asyncio.wait_for(run(), timeout=90))
135
+ res_text = res.content[0].text if res.content else ""
136
+ assert not res.is_error, res_text
137
+ assert '"status":"success"' in res_text, res_text
138
+ assert '"profile":"mcplogin"' in res_text
139
+
140
+ authed_text = authed.content[0].text if authed.content else ""
141
+ assert '"status":"success"' in authed_text, authed_text
142
+ assert "you are authenticated" in authed_text
143
+ assert '"authenticated":true' in authed_text
144
+
145
+ anon_text = anon.content[0].text if anon.content else ""
146
+ assert '"status":"success"' not in anon_text, f"anonymous fetch leaked: {anon_text}"
147
+
148
+
149
+ def test_mcp_login_rejects_bad_url_and_profile(server, tmp_path):
150
+ """login tool validates its inputs: bad URL and bad profile name are
151
+ clean errors, never a crash."""
152
+ from mcp import ClientSession
153
+ from mcp.client.stdio import stdio_client
154
+
155
+ root = _profile_root(tmp_path)
156
+
157
+ async def run():
158
+ async with stdio_client(_spawn_mcp(root)) as (read, write), ClientSession(read, write) as session:
159
+ await session.initialize()
160
+ bad_url = await session.call_tool(
161
+ "login", {"url": "not a url", "profile": "x", "headless": True, "wait_seconds": 5}
162
+ )
163
+ bad_name = await session.call_tool(
164
+ "login", {"url": server.url("/"), "profile": "../evil", "headless": True, "wait_seconds": 5}
165
+ )
166
+ bad_secs = await session.call_tool(
167
+ "login", {"url": server.url("/"), "profile": "x", "headless": True, "wait_seconds": 1}
168
+ )
169
+ return bad_url, bad_name, bad_secs
170
+
171
+ bad_url, bad_name, bad_secs = asyncio.run(asyncio.wait_for(run(), timeout=60))
172
+ assert "invalid site URL" in bad_url.content[0].text
173
+ assert "invalid profile name" in bad_name.content[0].text
174
+ assert "wait_seconds must be between 5 and 600" in bad_secs.content[0].text
175
+
176
+
177
+ if __name__ == "__main__":
178
+ import pytest
179
+
180
+ sys.exit(pytest.main([__file__, "-v"]))
@@ -64,7 +64,7 @@ class TestNoSecretLeakage:
64
64
  return res
65
65
 
66
66
  res = _run(run())
67
- if res.isError:
67
+ if res.is_error:
68
68
  return # error path: no payload to leak, still fine
69
69
  text = res.content[0].text
70
70
  hits = _scan(text)
@@ -121,7 +121,7 @@ class TestServerRecovery:
121
121
  bad = await session.call_tool(
122
122
  "fetch", {"url": "https://example.com", "strategy": "bogus"}
123
123
  )
124
- assert not bad.isError or "error" in bad.content[0].text
124
+ assert not bad.is_error or "error" in bad.content[0].text
125
125
  good = await session.call_tool(
126
126
  "fetch", {"url": "https://example.com", "strategy": "http", "no_cache": True}
127
127
  )
@@ -0,0 +1,177 @@
1
+ """Integration: MCP fetch with a real profile uses the stored session.
2
+
3
+ A cookie-gated local server returns 200 only when the request carries the
4
+ session cookie from a profile's storage_state.json:
5
+ - fetch(profile="testp") -> success (session used)
6
+ - fetch() (anonymous) -> blocked/login_required (no session)
7
+
8
+ Spawns webget_mcp.py as a stdio subprocess with WEBGET_PROFILE_DIR pointing
9
+ at a temp profile root and WEBGET_ALLOW_PRIVATE=1 (the test server lives on
10
+ 127.0.0.1, which the SSRF guard blocks by default).
11
+ """
12
+
13
+ import asyncio
14
+ import json
15
+ import os
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from mcp import ClientSession, StdioServerParameters
20
+ from mcp.client.stdio import stdio_client
21
+
22
+ ROOT = Path(__file__).resolve().parent.parent
23
+ MCP = ROOT / "webget_mcp.py"
24
+ sys.path.insert(0, str(ROOT))
25
+
26
+ from tests.http_server import TestServer
27
+
28
+
29
+ def _spawn(profile_root):
30
+ env = {
31
+ **os.environ,
32
+ "WEBGET_PROFILE_DIR": str(profile_root),
33
+ "WEBGET_ALLOW_PRIVATE": "1",
34
+ }
35
+ return StdioServerParameters(command=sys.executable, args=[str(MCP)], env=env)
36
+
37
+
38
+ def _make_profile(root, name):
39
+ d = root / name
40
+ (d / "Default").mkdir(parents=True)
41
+ (d / "storage_state.json").write_text(
42
+ json.dumps(
43
+ {
44
+ "cookies": [
45
+ {
46
+ "name": "session",
47
+ "value": "1",
48
+ "domain": "127.0.0.1",
49
+ "path": "/",
50
+ "expires": 9999999999,
51
+ }
52
+ ]
53
+ }
54
+ )
55
+ )
56
+
57
+
58
+ def test_mcp_fetch_with_profile_uses_session(tmp_path):
59
+ server = TestServer().start()
60
+ try:
61
+ root = tmp_path / "profiles"
62
+ _make_profile(root, "testp")
63
+ url = server.url("/cookie-gated")
64
+
65
+ async def run():
66
+ async with (
67
+ stdio_client(_spawn(root)) as (read, write),
68
+ ClientSession(read, write) as session,
69
+ ):
70
+ await session.initialize()
71
+ ok = await session.call_tool(
72
+ "fetch", {"url": url, "strategy": "http", "no_cache": True, "profile": "testp"}
73
+ )
74
+ anon = await session.call_tool(
75
+ "fetch", {"url": url, "strategy": "http", "no_cache": True}
76
+ )
77
+ return ok, anon
78
+
79
+ ok, anon = asyncio.run(asyncio.wait_for(run(), timeout=60))
80
+ ok_text = ok.content[0].text if ok.content else ""
81
+ anon_text = anon.content[0].text if anon.content else ""
82
+ assert not ok.is_error, ok_text
83
+ assert '"status":"success"' in ok_text, ok_text
84
+ assert "you are authenticated" in ok_text, ok_text
85
+ assert '"authenticated":true' in ok_text, ok_text # session was used
86
+ # anonymous fetch must NOT succeed on the gated page
87
+ assert ok_text != anon_text
88
+ assert '"status":"success"' not in anon_text, f"anonymous fetch leaked: {anon_text}"
89
+ finally:
90
+ server.stop()
91
+
92
+
93
+ def test_mcp_fetch_unknown_profile_is_hard_error(tmp_path):
94
+ """Even with a profile root, a name that does not exist is an error
95
+ (never a silent anonymous fallback)."""
96
+
97
+ async def run():
98
+ env = {**os.environ, "WEBGET_PROFILE_DIR": str(tmp_path / "profiles")}
99
+ params = StdioServerParameters(command=sys.executable, args=[str(MCP)], env=env)
100
+ async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
101
+ await session.initialize()
102
+ res = await session.call_tool(
103
+ "fetch", {"url": "https://example.com", "profile": "ghost", "no_cache": True}
104
+ )
105
+ return res
106
+
107
+ res = asyncio.run(asyncio.wait_for(run(), timeout=60))
108
+ assert not res.is_error
109
+ assert "profile 'ghost' not found" in res.content[0].text
110
+
111
+
112
+ def test_mcp_fetch_enriched_output_present(tmp_path):
113
+ """MCP fetch must include auth, attempts, and reasons in the response.
114
+
115
+ These fields give agents full provenance: whether the fetch used a
116
+ session, how many ladder steps were tried, and the full chain of
117
+ failures. Added 2026-08-31.
118
+ """
119
+ server = TestServer().start()
120
+ try:
121
+ root = tmp_path / "profiles"
122
+ _make_profile(root, "testp")
123
+ gated = server.url("/cookie-gated")
124
+
125
+ async def run():
126
+ async with (
127
+ stdio_client(_spawn(root)) as (read, write),
128
+ ClientSession(read, write) as session,
129
+ ):
130
+ await session.initialize()
131
+ # Authenticated fetch
132
+ ok = await session.call_tool(
133
+ "fetch",
134
+ {
135
+ "url": gated,
136
+ "strategy": "http",
137
+ "no_cache": True,
138
+ "profile": "testp",
139
+ },
140
+ )
141
+ # Anonymous fetch (blocked)
142
+ anon = await session.call_tool(
143
+ "fetch",
144
+ {"url": gated, "strategy": "http", "no_cache": True},
145
+ )
146
+ return ok, anon
147
+
148
+ ok, anon = asyncio.run(asyncio.wait_for(run(), timeout=60))
149
+ ok_text = ok.content[0].text if ok.content else ""
150
+ anon_text = anon.content[0].text if anon.content else ""
151
+
152
+ # Authenticated: standard assertions
153
+ assert not ok.is_error, ok_text
154
+ assert '"status":"success"' in ok_text
155
+ # auth
156
+ assert '"auth"' in ok_text
157
+ assert '"authenticated":true' in ok_text
158
+ assert '"attempts"' in ok_text
159
+ # Success carries an empty reasons list (consistent shape: consumers
160
+ # can always iterate reasons without a None check).
161
+ assert '"reasons":[]' in ok_text, "success should have empty reasons"
162
+
163
+ # Anonymous (blocked): must have provenance fields
164
+ assert '"status":"blocked"' in anon_text or '"status":"login_required"' in anon_text
165
+ assert '"auth"' in anon_text
166
+ assert '"authenticated":false' in anon_text or '"authenticated":null' in anon_text
167
+ assert '"attempts"' in anon_text
168
+ assert '"reasons"' in anon_text
169
+ assert '"state":"blocked"' in anon_text or '"state":"login_required"' in anon_text
170
+ finally:
171
+ server.stop()
172
+
173
+
174
+ if __name__ == "__main__":
175
+ import pytest
176
+
177
+ sys.exit(pytest.main([__file__, "-v"]))