webget-cli 0.7.3__tar.gz → 0.8.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 (34) hide show
  1. {webget_cli-0.7.3 → webget_cli-0.8.0}/PKG-INFO +26 -4
  2. {webget_cli-0.7.3 → webget_cli-0.8.0}/README.md +25 -3
  3. {webget_cli-0.7.3 → webget_cli-0.8.0}/pyproject.toml +1 -1
  4. webget_cli-0.8.0/tests/test_mcp_profile.py +115 -0
  5. webget_cli-0.8.0/tests/test_mcp_server.py +199 -0
  6. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_webget.py +41 -0
  7. {webget_cli-0.7.3 → webget_cli-0.8.0}/webget_cli.egg-info/PKG-INFO +26 -4
  8. {webget_cli-0.7.3 → webget_cli-0.8.0}/webget_cli.egg-info/SOURCES.txt +1 -0
  9. {webget_cli-0.7.3 → webget_cli-0.8.0}/webget_cli.py +44 -13
  10. {webget_cli-0.7.3 → webget_cli-0.8.0}/webget_mcp.py +46 -0
  11. webget_cli-0.7.3/tests/test_mcp_server.py +0 -102
  12. {webget_cli-0.7.3 → webget_cli-0.8.0}/LICENSE +0 -0
  13. {webget_cli-0.7.3 → webget_cli-0.8.0}/setup.cfg +0 -0
  14. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_adversarial_auth.py +0 -0
  15. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_adversarial_cache.py +0 -0
  16. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_adversarial_concurrency.py +0 -0
  17. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_adversarial_http.py +0 -0
  18. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_adversarial_mcp.py +0 -0
  19. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_adversarial_ssrf.py +0 -0
  20. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_auth_review.py +0 -0
  21. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_browser_ssrf.py +0 -0
  22. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_cache_review.py +0 -0
  23. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_concurrency_review.py +0 -0
  24. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_extraction_markdown.py +0 -0
  25. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_firecrawl_policy.py +0 -0
  26. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_integration_ladder.py +0 -0
  27. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_mcp_leak_review.py +0 -0
  28. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_mcp_smoke.py +0 -0
  29. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_security_review.py +0 -0
  30. {webget_cli-0.7.3 → webget_cli-0.8.0}/tests/test_size_review.py +0 -0
  31. {webget_cli-0.7.3 → webget_cli-0.8.0}/webget_cli.egg-info/dependency_links.txt +0 -0
  32. {webget_cli-0.7.3 → webget_cli-0.8.0}/webget_cli.egg-info/entry_points.txt +0 -0
  33. {webget_cli-0.7.3 → webget_cli-0.8.0}/webget_cli.egg-info/requires.txt +0 -0
  34. {webget_cli-0.7.3 → webget_cli-0.8.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.8.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,31 @@ 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:
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
+ - `fetch(..., profile="portal")` / `search_fetch(..., profile="portal")` -
261
+ scrape using that session.
262
+
263
+ ```text
264
+ agent: "check my portal for new announcements"
265
+ 1. list_profiles -> portal (authenticated)
266
+ 2. fetch(url, profile="portal")
267
+ ```
268
+
269
+ Invalid profile names and unknown profiles are hard errors (no silent
270
+ anonymous fallback). Sessions are local to the machine running the MCP
271
+ server.
250
272
 
251
273
  ## Development
252
274
 
@@ -210,9 +210,31 @@ 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:
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
+ - `fetch(..., profile="portal")` / `search_fetch(..., profile="portal")` -
227
+ scrape using that session.
228
+
229
+ ```text
230
+ agent: "check my portal for new announcements"
231
+ 1. list_profiles -> portal (authenticated)
232
+ 2. fetch(url, profile="portal")
233
+ ```
234
+
235
+ Invalid profile names and unknown profiles are hard errors (no silent
236
+ anonymous fallback). Sessions are local to the machine running the MCP
237
+ server.
216
238
 
217
239
  ## Development
218
240
 
@@ -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.8.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"
@@ -0,0 +1,115 @@
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": "s",
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.isError, 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.isError
109
+ assert "profile 'ghost' not found" in res.content[0].text
110
+
111
+
112
+ if __name__ == "__main__":
113
+ import pytest
114
+
115
+ sys.exit(pytest.main([__file__, "-v"]))
@@ -0,0 +1,199 @@
1
+ """MCP server regression tests.
2
+
3
+ Spawns webget_mcp.py as a stdio subprocess and drives it like an MCP
4
+ client would. Requires the mcp client SDK (installed via the [mcp] extra).
5
+ """
6
+
7
+ import asyncio
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from mcp import ClientSession, StdioServerParameters
12
+ from mcp.client.stdio import stdio_client
13
+
14
+ ROOT = Path(__file__).resolve().parent.parent
15
+ MCP = ROOT / "webget_mcp.py"
16
+
17
+
18
+ def _run(coro, timeout=60):
19
+ """Run a coroutine with a hard wall-clock cap so a dead server
20
+ (the bug this guards against) fails the test instead of hanging CI."""
21
+ return asyncio.run(asyncio.wait_for(coro, timeout=timeout))
22
+
23
+
24
+ def test_tools_listed():
25
+ async def run():
26
+ params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
27
+ async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
28
+ await session.initialize()
29
+ tools = await session.list_tools()
30
+ return [t.name for t in tools.tools]
31
+
32
+ # order is not a contract; membership is
33
+ assert sorted(_run(run())) == sorted(["search", "fetch", "search_fetch", "list_profiles"])
34
+
35
+
36
+ def test_invalid_strategy_returns_error_not_crash():
37
+ """The server must survive a client sending strategy='bogus'.
38
+
39
+ Before the guard, _ladder raised SystemExit (a BaseException) which
40
+ killed the whole server process and hung the client.
41
+ """
42
+
43
+ async def run():
44
+ params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
45
+ async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
46
+ await session.initialize()
47
+ res = await session.call_tool(
48
+ "fetch",
49
+ {"url": "https://example.com", "strategy": "bogus", "no_cache": True},
50
+ )
51
+ return res
52
+
53
+ res = _run(run())
54
+ assert not res.isError
55
+ assert "error" in res.content[0].text
56
+
57
+
58
+ def test_firecrawl_without_key_returns_error_not_crash():
59
+ """strategy='firecrawl' without WEBGET_FIRECRAWL_KEY must not kill the
60
+ server either (SystemExit from _ladder)."""
61
+
62
+ async def run():
63
+ params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
64
+ async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
65
+ await session.initialize()
66
+ res = await session.call_tool(
67
+ "fetch",
68
+ {"url": "https://example.com", "strategy": "firecrawl", "no_cache": True},
69
+ )
70
+ return res
71
+
72
+ res = _run(run())
73
+ assert not res.isError
74
+ assert "error" in res.content[0].text
75
+
76
+
77
+ def test_server_stays_alive_after_bad_calls():
78
+ """The same server process that handled a bad strategy call must still
79
+ answer a normal fetch afterwards."""
80
+
81
+ async def run():
82
+ params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
83
+ async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
84
+ await session.initialize()
85
+ await session.call_tool(
86
+ "fetch",
87
+ {"url": "https://example.com", "strategy": "bogus", "no_cache": True},
88
+ )
89
+ res = await session.call_tool(
90
+ "fetch",
91
+ {"url": "https://example.com", "strategy": "http", "no_cache": True},
92
+ )
93
+ return res
94
+
95
+ res = _run(run())
96
+ assert not res.isError
97
+ assert "success" in res.content[0].text
98
+
99
+
100
+ def test_fetch_invalid_profile_returns_error_not_crash():
101
+ """profile name with path traversal must return a clean error and the
102
+ server must stay alive (SystemExit from profile_dir must not escape)."""
103
+
104
+ async def run():
105
+ params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
106
+ async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
107
+ await session.initialize()
108
+ res = await session.call_tool(
109
+ "fetch",
110
+ {"url": "https://example.com", "profile": "../etc", "no_cache": True},
111
+ )
112
+ ok = await session.call_tool(
113
+ "fetch",
114
+ {"url": "https://example.com", "strategy": "http", "no_cache": True},
115
+ )
116
+ return res, ok
117
+
118
+ res, ok = _run(run())
119
+ assert not res.isError
120
+ assert "invalid profile name" in res.content[0].text
121
+ assert "success" in ok.content[0].text # server alive after the bad call
122
+
123
+
124
+ def test_fetch_nonexistent_profile_returns_error():
125
+ """A valid-looking profile that does not exist is a hard error, never a
126
+ silent anonymous fallback (which would return login_required while the
127
+ agent believes the session was used)."""
128
+
129
+ async def run():
130
+ params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
131
+ async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
132
+ await session.initialize()
133
+ res = await session.call_tool(
134
+ "fetch",
135
+ {"url": "https://example.com", "profile": "ghost", "no_cache": True},
136
+ )
137
+ return res
138
+
139
+ res = _run(run())
140
+ assert not res.isError
141
+ assert "profile 'ghost' not found" in res.content[0].text
142
+
143
+
144
+ def test_search_fetch_invalid_profile_returns_error_not_crash():
145
+ """search_fetch must validate profile the same way and stay alive."""
146
+
147
+ async def run():
148
+ params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
149
+ async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
150
+ await session.initialize()
151
+ res = await session.call_tool(
152
+ "search_fetch",
153
+ {"query": "test", "n": 1, "profile": "../etc"},
154
+ )
155
+ ok = await session.call_tool(
156
+ "search_fetch",
157
+ {"query": "example.com", "n": 1},
158
+ )
159
+ return res, ok
160
+
161
+ res, ok = _run(run())
162
+ assert not res.isError
163
+ assert "invalid profile name" in res.content[0].text
164
+ assert not ok.isError # server alive after the bad call
165
+
166
+
167
+ def test_list_profiles_tool_metadata_only(tmp_path):
168
+ """list_profiles must list the session and NEVER leak cookie values."""
169
+ import json
170
+ import os
171
+
172
+ root = tmp_path / "profiles"
173
+ d = root / "sion"
174
+ (d / "Default").mkdir(parents=True)
175
+ (d / "storage_state.json").write_text(
176
+ json.dumps(
177
+ {"cookies": [{"name": "s", "value": "SUPERSECRET", "domain": ".x.com", "expires": -1}]}
178
+ )
179
+ )
180
+
181
+ async def run():
182
+ env = {**os.environ, "WEBGET_PROFILE_DIR": str(root)}
183
+ params = StdioServerParameters(command=sys.executable, args=[str(MCP)], env=env)
184
+ async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
185
+ await session.initialize()
186
+ res = await session.call_tool("list_profiles", {})
187
+ return res
188
+
189
+ res = _run(run())
190
+ assert not res.isError
191
+ text = res.content[0].text
192
+ assert "sion" in text
193
+ assert "SUPERSECRET" not in text # cookie values never exposed
194
+
195
+
196
+ if __name__ == "__main__":
197
+ import pytest
198
+
199
+ sys.exit(pytest.main([__file__, "-v"]))
@@ -85,6 +85,47 @@ class TestProfileSafety:
85
85
  p = webget.profile_state_path("campus")
86
86
  assert p.endswith(os.path.join("campus", "storage_state.json"))
87
87
 
88
+ def test_env_override(self, monkeypatch):
89
+ monkeypatch.setenv("WEBGET_PROFILE_DIR", "/tmp/override-profiles")
90
+ assert webget.profile_dir("campus").startswith("/tmp/override-profiles")
91
+ assert webget.profile_state_path("campus").startswith("/tmp/override-profiles")
92
+
93
+
94
+ # ---------- list_profiles / profile_exists (MCP reuse) ----------
95
+
96
+
97
+ class TestListProfiles:
98
+ def test_empty_root(self, tmp_path, monkeypatch):
99
+ monkeypatch.setattr(webget, "PROFILE_DIR", str(tmp_path / "profiles"))
100
+ assert webget.list_profiles() == []
101
+
102
+ def test_returns_metadata_only(self, tmp_path, monkeypatch):
103
+ import json
104
+
105
+ monkeypatch.setattr(webget, "PROFILE_DIR", str(tmp_path / "profiles"))
106
+ d = tmp_path / "profiles" / "sion"
107
+ (d / "Default").mkdir(parents=True)
108
+ state = {
109
+ "cookies": [
110
+ {"name": "secret", "value": "SUPERSECRET", "domain": ".x.com", "expires": -1}
111
+ ]
112
+ }
113
+ (d / "storage_state.json").write_text(json.dumps(state))
114
+ meta = webget.list_profiles()
115
+ assert len(meta) == 1
116
+ assert meta[0]["profile"] == "sion"
117
+ assert "status" in meta[0]
118
+ # cookie values must NEVER appear in the listing
119
+ assert "SUPERSECRET" not in json.dumps(meta)
120
+
121
+ def test_profile_exists(self, tmp_path, monkeypatch):
122
+ monkeypatch.setattr(webget, "PROFILE_DIR", str(tmp_path / "profiles"))
123
+ (tmp_path / "profiles" / "sion").mkdir(parents=True)
124
+ assert webget.profile_exists("sion") is True
125
+ assert webget.profile_exists("nope") is False
126
+ assert webget.profile_exists("../evil") is False # invalid = not exists
127
+ assert webget.profile_exists("") is False
128
+
88
129
 
89
130
  # ---------- auth state classifier ----------
90
131
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webget-cli
3
- Version: 0.7.3
3
+ Version: 0.8.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,31 @@ 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:
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
+ - `fetch(..., profile="portal")` / `search_fetch(..., profile="portal")` -
261
+ scrape using that session.
262
+
263
+ ```text
264
+ agent: "check my portal for new announcements"
265
+ 1. list_profiles -> portal (authenticated)
266
+ 2. fetch(url, profile="portal")
267
+ ```
268
+
269
+ Invalid profile names and unknown profiles are hard errors (no silent
270
+ anonymous fallback). Sessions are local to the machine running the MCP
271
+ server.
250
272
 
251
273
  ## Development
252
274
 
@@ -17,6 +17,7 @@ tests/test_extraction_markdown.py
17
17
  tests/test_firecrawl_policy.py
18
18
  tests/test_integration_ladder.py
19
19
  tests/test_mcp_leak_review.py
20
+ tests/test_mcp_profile.py
20
21
  tests/test_mcp_server.py
21
22
  tests/test_mcp_smoke.py
22
23
  tests/test_security_review.py
@@ -517,7 +517,22 @@ async def _guard_browser_routes(crawler_ctx):
517
517
  browser.on("context", lambda ctx: asyncio.create_task(register(ctx)))
518
518
 
519
519
 
520
- PROFILE_DIR = os.path.expanduser("~/.local/share/webget/profiles")
520
+ def _profile_root():
521
+ """Profile root dir; WEBGET_PROFILE_DIR env override for tests/ops.
522
+
523
+ Order: env override first, then a monkeypatched/overridden PROFILE_DIR
524
+ module attribute (existing tests patch it directly), then the default.
525
+ """
526
+ env = os.environ.get("WEBGET_PROFILE_DIR")
527
+ if env:
528
+ return env
529
+ patched = globals().get("PROFILE_DIR")
530
+ if patched:
531
+ return patched
532
+ return os.path.expanduser("~/.local/share/webget/profiles")
533
+
534
+
535
+ PROFILE_DIR = _profile_root()
521
536
  _PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
522
537
 
523
538
 
@@ -525,7 +540,7 @@ def profile_dir(name):
525
540
  """Return profile dir, rejecting path traversal / weird names."""
526
541
  if not name or not _PROFILE_NAME_RE.match(name) or name in (".", ".."):
527
542
  raise SystemExit(f"invalid profile name: {name!r}")
528
- return os.path.join(PROFILE_DIR, name)
543
+ return os.path.join(_profile_root(), name)
529
544
 
530
545
 
531
546
  def profile_state_path(profile):
@@ -1046,25 +1061,41 @@ def _fmt_age(ts):
1046
1061
  return f"{int(diff // 86400)}d ago"
1047
1062
 
1048
1063
 
1049
- def cmd_profiles(json_out):
1050
- if not os.path.isdir(PROFILE_DIR):
1051
- if json_out:
1052
- print("{}")
1053
- else:
1054
- print("PROFILE LAST USED SIZE STATUS\n(no profiles yet)")
1055
- return
1056
- profiles = []
1057
- for name in sorted(os.listdir(PROFILE_DIR)):
1064
+ def list_profiles():
1065
+ """Non-sensitive metadata for every profile dir. Never cookie values."""
1066
+ if not os.path.isdir(_profile_root()):
1067
+ return []
1068
+ out = []
1069
+ for name in sorted(os.listdir(_profile_root())):
1058
1070
  if not os.path.isdir(profile_dir(name)):
1059
1071
  continue
1060
1072
  try:
1061
- profile_dir(name) # validates name; raises SystemExit on bad names
1073
+ profile_dir(name) # validates; raises SystemExit on bad names
1062
1074
  meta = _profile_meta(name)
1063
1075
  if meta["size"] > 0 or meta["status"] != "unknown":
1064
- profiles.append(meta)
1076
+ out.append(meta)
1065
1077
  except SystemExit:
1066
1078
  # Skip malformed dir names that fail validation (e.g. "..").
1067
1079
  continue
1080
+ return out
1081
+
1082
+
1083
+ def profile_exists(name):
1084
+ """True if name is a valid profile name AND its dir exists."""
1085
+ try:
1086
+ return os.path.isdir(profile_dir(name))
1087
+ except SystemExit:
1088
+ return False
1089
+
1090
+
1091
+ def cmd_profiles(json_out):
1092
+ if not os.path.isdir(_profile_root()):
1093
+ if json_out:
1094
+ print("{}")
1095
+ else:
1096
+ print("PROFILE LAST USED SIZE STATUS\n(no profiles yet)")
1097
+ return
1098
+ profiles = list_profiles()
1068
1099
  if json_out:
1069
1100
  print(json.dumps({p["profile"]: p for p in profiles}, indent=2))
1070
1101
  return
@@ -22,6 +22,7 @@ Dependencies: mcp (pip install mcp). webget_cli.py must live next to this file.
22
22
  from __future__ import annotations
23
23
 
24
24
  import asyncio
25
+ import os
25
26
  import sys
26
27
  from pathlib import Path
27
28
 
@@ -52,6 +53,23 @@ def _clamp(name, value, lo, hi):
52
53
  return None
53
54
 
54
55
 
56
+ def _validate_profile(profile):
57
+ """Validate a profile name for authenticated fetches.
58
+
59
+ Returns an error string, or None when the profile exists and is usable.
60
+ Invalid names (path traversal etc.) and unknown profiles are HARD errors:
61
+ a silent anonymous fallback would return login_required while the agent
62
+ believes its session was used.
63
+ """
64
+ try:
65
+ pdir = wg.profile_dir(profile) # raises SystemExit on invalid names
66
+ except SystemExit:
67
+ return f"invalid profile name: {profile!r}"
68
+ if not os.path.isdir(pdir):
69
+ return f"profile '{profile}' not found"
70
+ return None
71
+
72
+
55
73
  @mcp.tool()
56
74
  async def search(query: str, n: int = 5) -> list[dict]:
57
75
  """Search the web (DuckDuckGo). Returns up to n results with title/url/snippet."""
@@ -61,6 +79,14 @@ async def search(query: str, n: int = 5) -> list[dict]:
61
79
  return await asyncio.to_thread(wg.search, query, n)
62
80
 
63
81
 
82
+ @mcp.tool()
83
+ def list_profiles() -> list[dict]:
84
+ """List locally stored login sessions (profiles) with non-sensitive
85
+ metadata: name, last used, size, status. Cookie values are never
86
+ returned. Use with fetch(profile=...) to scrape authenticated pages."""
87
+ return wg.list_profiles()
88
+
89
+
64
90
  @mcp.tool()
65
91
  async def fetch(
66
92
  url: str,
@@ -68,11 +94,16 @@ async def fetch(
68
94
  timeout: int = 20,
69
95
  strategy: str = "auto",
70
96
  no_cache: bool = False,
97
+ profile: str | None = None,
71
98
  ) -> dict:
72
99
  """Scrape a single URL to markdown, with metadata.
73
100
 
74
101
  strategy: auto|http|crawl4ai|firecrawl. Status values:
75
102
  success|login_required|challenge|blocked|error.
103
+
104
+ profile: name of a locally stored login session (created with
105
+ 'webget login URL --profile NAME'). Invalid names and unknown
106
+ profiles are hard errors (never a silent anonymous fallback).
76
107
  """
77
108
  if strategy not in _VALID_STRATEGIES:
78
109
  return {"status": "error", "error": f"unknown strategy: {strategy}"}
@@ -85,12 +116,17 @@ async def fetch(
85
116
  err = _clamp(name, value, lo, hi)
86
117
  if err:
87
118
  return {"status": "error", "error": err}
119
+ if profile is not None:
120
+ err = _validate_profile(profile)
121
+ if err:
122
+ return {"status": "error", "error": err}
88
123
  res = await wg.scrape_many(
89
124
  [url],
90
125
  max_chars=max_chars,
91
126
  per_url_timeout=timeout,
92
127
  strategy=strategy,
93
128
  no_cache=no_cache,
129
+ profile=profile,
94
130
  )
95
131
  return res.get(url, {})
96
132
 
@@ -102,11 +138,16 @@ async def search_fetch(
102
138
  max_chars: int = 4000,
103
139
  timeout: int = 20,
104
140
  no_cache: bool = False,
141
+ profile: str | None = None,
105
142
  ) -> list[dict]:
106
143
  """Search the web, then scrape the top n results in parallel.
107
144
 
108
145
  Returns one entry per URL with rank, search snippet, and scrape result
109
146
  (status/method/markdown).
147
+
148
+ profile: name of a locally stored login session (created with
149
+ 'webget login URL --profile NAME'). Invalid names and unknown
150
+ profiles are hard errors (never a silent anonymous fallback).
110
151
  """
111
152
  for name, value, lo, hi in (
112
153
  ("n", n, 1, _MAX_SEARCH_N),
@@ -116,6 +157,10 @@ async def search_fetch(
116
157
  err = _clamp(name, value, lo, hi)
117
158
  if err:
118
159
  return [{"error": err}]
160
+ if profile is not None:
161
+ err = _validate_profile(profile)
162
+ if err:
163
+ return [{"error": err}]
119
164
  results = await asyncio.to_thread(wg.search, query, n)
120
165
  urls = [r["url"] for r in results]
121
166
  scraped = await wg.scrape_many(
@@ -123,6 +168,7 @@ async def search_fetch(
123
168
  max_chars=max_chars,
124
169
  per_url_timeout=timeout,
125
170
  no_cache=no_cache,
171
+ profile=profile,
126
172
  )
127
173
  out = []
128
174
  for i, r in enumerate(results):
@@ -1,102 +0,0 @@
1
- """MCP server regression tests.
2
-
3
- Spawns webget_mcp.py as a stdio subprocess and drives it like an MCP
4
- client would. Requires the mcp client SDK (installed via the [mcp] extra).
5
- """
6
-
7
- import asyncio
8
- import sys
9
- from pathlib import Path
10
-
11
- from mcp import ClientSession, StdioServerParameters
12
- from mcp.client.stdio import stdio_client
13
-
14
- ROOT = Path(__file__).resolve().parent.parent
15
- MCP = ROOT / "webget_mcp.py"
16
-
17
-
18
- def _run(coro, timeout=60):
19
- """Run a coroutine with a hard wall-clock cap so a dead server
20
- (the bug this guards against) fails the test instead of hanging CI."""
21
- return asyncio.run(asyncio.wait_for(coro, timeout=timeout))
22
-
23
-
24
- def test_tools_listed():
25
- async def run():
26
- params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
27
- async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
28
- await session.initialize()
29
- tools = await session.list_tools()
30
- return [t.name for t in tools.tools]
31
-
32
- assert _run(run()) == ["search", "fetch", "search_fetch"]
33
-
34
-
35
- def test_invalid_strategy_returns_error_not_crash():
36
- """The server must survive a client sending strategy='bogus'.
37
-
38
- Before the guard, _ladder raised SystemExit (a BaseException) which
39
- killed the whole server process and hung the client.
40
- """
41
-
42
- async def run():
43
- params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
44
- async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
45
- await session.initialize()
46
- res = await session.call_tool(
47
- "fetch",
48
- {"url": "https://example.com", "strategy": "bogus", "no_cache": True},
49
- )
50
- return res
51
-
52
- res = _run(run())
53
- assert not res.isError
54
- assert "error" in res.content[0].text
55
-
56
-
57
- def test_firecrawl_without_key_returns_error_not_crash():
58
- """strategy='firecrawl' without WEBGET_FIRECRAWL_KEY must not kill the
59
- server either (SystemExit from _ladder)."""
60
-
61
- async def run():
62
- params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
63
- async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
64
- await session.initialize()
65
- res = await session.call_tool(
66
- "fetch",
67
- {"url": "https://example.com", "strategy": "firecrawl", "no_cache": True},
68
- )
69
- return res
70
-
71
- res = _run(run())
72
- assert not res.isError
73
- assert "error" in res.content[0].text
74
-
75
-
76
- def test_server_stays_alive_after_bad_calls():
77
- """The same server process that handled a bad strategy call must still
78
- answer a normal fetch afterwards."""
79
-
80
- async def run():
81
- params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
82
- async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
83
- await session.initialize()
84
- await session.call_tool(
85
- "fetch",
86
- {"url": "https://example.com", "strategy": "bogus", "no_cache": True},
87
- )
88
- res = await session.call_tool(
89
- "fetch",
90
- {"url": "https://example.com", "strategy": "http", "no_cache": True},
91
- )
92
- return res
93
-
94
- res = _run(run())
95
- assert not res.isError
96
- assert "success" in res.content[0].text
97
-
98
-
99
- if __name__ == "__main__":
100
- import pytest
101
-
102
- sys.exit(pytest.main([__file__, "-v"]))
File without changes
File without changes