webscout-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.
- webscout_mcp-0.1.0/LICENSE +21 -0
- webscout_mcp-0.1.0/PKG-INFO +184 -0
- webscout_mcp-0.1.0/README.md +155 -0
- webscout_mcp-0.1.0/pyproject.toml +47 -0
- webscout_mcp-0.1.0/setup.cfg +4 -0
- webscout_mcp-0.1.0/tests/test_basic.py +185 -0
- webscout_mcp-0.1.0/webscout_mcp/__init__.py +11 -0
- webscout_mcp-0.1.0/webscout_mcp/__main__.py +79 -0
- webscout_mcp-0.1.0/webscout_mcp/cache.py +133 -0
- webscout_mcp-0.1.0/webscout_mcp/config.py +86 -0
- webscout_mcp-0.1.0/webscout_mcp/crawler.py +133 -0
- webscout_mcp-0.1.0/webscout_mcp/extractor.py +141 -0
- webscout_mcp-0.1.0/webscout_mcp/fetcher.py +242 -0
- webscout_mcp-0.1.0/webscout_mcp/search.py +200 -0
- webscout_mcp-0.1.0/webscout_mcp/server.py +206 -0
- webscout_mcp-0.1.0/webscout_mcp/utils.py +92 -0
- webscout_mcp-0.1.0/webscout_mcp.egg-info/PKG-INFO +184 -0
- webscout_mcp-0.1.0/webscout_mcp.egg-info/SOURCES.txt +20 -0
- webscout_mcp-0.1.0/webscout_mcp.egg-info/dependency_links.txt +1 -0
- webscout_mcp-0.1.0/webscout_mcp.egg-info/entry_points.txt +2 -0
- webscout_mcp-0.1.0/webscout_mcp.egg-info/requires.txt +9 -0
- webscout_mcp-0.1.0/webscout_mcp.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 webscout-mcp contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: webscout-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A smart web search & fetch MCP server with built-in caching, rate-limiting, and content extraction
|
|
5
|
+
Author: webscout-mcp contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: mcp,model-context-protocol,web-search,web-scraping,ai-agent,caching
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: mcp>=1.0.0
|
|
21
|
+
Requires-Dist: httpx>=0.25.0
|
|
22
|
+
Requires-Dist: beautifulsoup4>=4.12.0
|
|
23
|
+
Requires-Dist: lxml>=5.0.0
|
|
24
|
+
Requires-Dist: trafilatura>=1.6.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# webscout-mcp
|
|
31
|
+
|
|
32
|
+
Web search and fetch tools for AI agents, as an MCP server. Search, fetch, crawl, and extract structured data from the web — no API keys, no per-request billing, everything stays on your machine.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install webscout-mcp
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Requires Python 3.10+.
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
Add to your MCP client config (Claude Code, Cursor, Codex, etc.):
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"webscout": {
|
|
50
|
+
"command": "webscout-mcp",
|
|
51
|
+
"args": []
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That's it. Your agent gets six tools:
|
|
58
|
+
|
|
59
|
+
- `web_search` — search via Bing, no key needed
|
|
60
|
+
- `web_fetch` — fetch a page and extract the main article (markdown/text/html)
|
|
61
|
+
- `web_crawl` — bounded BFS crawl with depth and page limits
|
|
62
|
+
- `web_extract` — pull structured data with CSS selectors, attributes, regex
|
|
63
|
+
- `cache_stats` — inspect the local cache
|
|
64
|
+
- `cache_clear` — wipe the cache
|
|
65
|
+
|
|
66
|
+
## Usage examples
|
|
67
|
+
|
|
68
|
+
### Search
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
web_search(query="best python async libraries", max_results=5)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Returns structured results with title, URL, and snippet.
|
|
75
|
+
|
|
76
|
+
### Fetch a page
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
web_fetch(url="https://example.com", extract=true, output_format="markdown")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`extract=true` runs trafilatura to strip nav, ads, and sidebars — you get clean article content, not raw HTML.
|
|
83
|
+
|
|
84
|
+
### Extract structured data
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
web_extract(
|
|
88
|
+
url="https://example.com/products",
|
|
89
|
+
rules='[
|
|
90
|
+
{"name": "titles", "selector": ".product h2", "multiple": true},
|
|
91
|
+
{"name": "prices", "selector": ".price", "regex": "\\$([\\d.]+)", "multiple": true},
|
|
92
|
+
{"name": "links", "selector": "a.product", "attribute": "href", "multiple": true}
|
|
93
|
+
]'
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Each rule supports `selector`, `attribute`, `multiple`, `regex`, and `default`.
|
|
98
|
+
|
|
99
|
+
### Crawl a site
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
web_crawl(seed_url="https://example.com", max_depth=2, max_pages=10)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Respects same-domain by default. All fetched pages go through the same cache and rate limiter.
|
|
106
|
+
|
|
107
|
+
## Use as a Python library
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
import asyncio
|
|
111
|
+
from webscout_mcp import Config, Fetcher, SearchEngine
|
|
112
|
+
|
|
113
|
+
async def main():
|
|
114
|
+
config = Config.from_env()
|
|
115
|
+
config.ensure_dirs()
|
|
116
|
+
|
|
117
|
+
fetcher = Fetcher(config)
|
|
118
|
+
result = await fetcher.fetch("https://example.com", extract=True)
|
|
119
|
+
print(result.title)
|
|
120
|
+
print(result.content[:500])
|
|
121
|
+
await fetcher.close()
|
|
122
|
+
|
|
123
|
+
search = SearchEngine(config)
|
|
124
|
+
results = await search.search("python async", max_results=5)
|
|
125
|
+
for r in results:
|
|
126
|
+
print(f"{r.position}. {r.title} — {r.url}")
|
|
127
|
+
|
|
128
|
+
asyncio.run(main())
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## How it works
|
|
132
|
+
|
|
133
|
+
- **Search** uses Bing via direct HTTP (no API key). Results are cached by query.
|
|
134
|
+
- **Fetching** uses httpx with exponential-backoff retries, per-domain token-bucket rate limiting, and a 5 MB content cap.
|
|
135
|
+
- **Content extraction** uses trafilatura — the same library behind many read-it-later services.
|
|
136
|
+
- **Caching** is SQLite with TTL and a size cap; old entries are evicted automatically. Repeat fetches and searches cost nothing.
|
|
137
|
+
- **Crawling** is BFS with configurable depth, page count, and same-domain restriction.
|
|
138
|
+
|
|
139
|
+
Everything runs locally. No data leaves your machine.
|
|
140
|
+
|
|
141
|
+
## Configuration
|
|
142
|
+
|
|
143
|
+
All settings have sensible defaults. Override via environment variables (`WEBSCOUT_` prefix) or CLI flags:
|
|
144
|
+
|
|
145
|
+
| Variable | Default | What it does |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `WEBSCOUT_CACHE_DIR` | `~/.cache/webscout` | Where the SQLite cache lives |
|
|
148
|
+
| `WEBSCOUT_CACHE_TTL` | `7200` | Cache entry lifetime in seconds |
|
|
149
|
+
| `WEBSCOUT_CACHE_MAX_SIZE_MB` | `512` | Max cache size before eviction |
|
|
150
|
+
| `WEBSCOUT_REQUEST_TIMEOUT` | `15.0` | HTTP timeout in seconds |
|
|
151
|
+
| `WEBSCOUT_MAX_RETRIES` | `3` | Retry attempts per request |
|
|
152
|
+
| `WEBSCOUT_RATE_LIMIT_PER_SECOND` | `2.0` | Max requests per second per domain |
|
|
153
|
+
| `WEBSCOUT_SEARCH_MAX_RESULTS` | `10` | Default search result count |
|
|
154
|
+
| `WEBSCOUT_CRAWLER_MAX_DEPTH` | `2` | Default crawl depth |
|
|
155
|
+
| `WEBSCOUT_CRAWLER_MAX_PAGES` | `20` | Default max pages per crawl |
|
|
156
|
+
|
|
157
|
+
CLI flags override env vars:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
webscout-mcp --cache-ttl 3600 --cache-dir /tmp/webscout
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Transports
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
# stdio (default — works with Claude Code, Cursor, etc.)
|
|
167
|
+
webscout-mcp
|
|
168
|
+
|
|
169
|
+
# SSE (for remote or browser-based clients)
|
|
170
|
+
webscout-mcp --transport sse --host 0.0.0.0 --port 8000
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Development
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
git clone https://github.com/wxs-lang/webscout-mcp.git
|
|
177
|
+
cd webscout-mcp
|
|
178
|
+
pip install -e ".[dev]"
|
|
179
|
+
pytest
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# webscout-mcp
|
|
2
|
+
|
|
3
|
+
Web search and fetch tools for AI agents, as an MCP server. Search, fetch, crawl, and extract structured data from the web — no API keys, no per-request billing, everything stays on your machine.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install webscout-mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Python 3.10+.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
Add to your MCP client config (Claude Code, Cursor, Codex, etc.):
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"mcpServers": {
|
|
20
|
+
"webscout": {
|
|
21
|
+
"command": "webscout-mcp",
|
|
22
|
+
"args": []
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
That's it. Your agent gets six tools:
|
|
29
|
+
|
|
30
|
+
- `web_search` — search via Bing, no key needed
|
|
31
|
+
- `web_fetch` — fetch a page and extract the main article (markdown/text/html)
|
|
32
|
+
- `web_crawl` — bounded BFS crawl with depth and page limits
|
|
33
|
+
- `web_extract` — pull structured data with CSS selectors, attributes, regex
|
|
34
|
+
- `cache_stats` — inspect the local cache
|
|
35
|
+
- `cache_clear` — wipe the cache
|
|
36
|
+
|
|
37
|
+
## Usage examples
|
|
38
|
+
|
|
39
|
+
### Search
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
web_search(query="best python async libraries", max_results=5)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Returns structured results with title, URL, and snippet.
|
|
46
|
+
|
|
47
|
+
### Fetch a page
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
web_fetch(url="https://example.com", extract=true, output_format="markdown")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`extract=true` runs trafilatura to strip nav, ads, and sidebars — you get clean article content, not raw HTML.
|
|
54
|
+
|
|
55
|
+
### Extract structured data
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
web_extract(
|
|
59
|
+
url="https://example.com/products",
|
|
60
|
+
rules='[
|
|
61
|
+
{"name": "titles", "selector": ".product h2", "multiple": true},
|
|
62
|
+
{"name": "prices", "selector": ".price", "regex": "\\$([\\d.]+)", "multiple": true},
|
|
63
|
+
{"name": "links", "selector": "a.product", "attribute": "href", "multiple": true}
|
|
64
|
+
]'
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Each rule supports `selector`, `attribute`, `multiple`, `regex`, and `default`.
|
|
69
|
+
|
|
70
|
+
### Crawl a site
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
web_crawl(seed_url="https://example.com", max_depth=2, max_pages=10)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Respects same-domain by default. All fetched pages go through the same cache and rate limiter.
|
|
77
|
+
|
|
78
|
+
## Use as a Python library
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import asyncio
|
|
82
|
+
from webscout_mcp import Config, Fetcher, SearchEngine
|
|
83
|
+
|
|
84
|
+
async def main():
|
|
85
|
+
config = Config.from_env()
|
|
86
|
+
config.ensure_dirs()
|
|
87
|
+
|
|
88
|
+
fetcher = Fetcher(config)
|
|
89
|
+
result = await fetcher.fetch("https://example.com", extract=True)
|
|
90
|
+
print(result.title)
|
|
91
|
+
print(result.content[:500])
|
|
92
|
+
await fetcher.close()
|
|
93
|
+
|
|
94
|
+
search = SearchEngine(config)
|
|
95
|
+
results = await search.search("python async", max_results=5)
|
|
96
|
+
for r in results:
|
|
97
|
+
print(f"{r.position}. {r.title} — {r.url}")
|
|
98
|
+
|
|
99
|
+
asyncio.run(main())
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## How it works
|
|
103
|
+
|
|
104
|
+
- **Search** uses Bing via direct HTTP (no API key). Results are cached by query.
|
|
105
|
+
- **Fetching** uses httpx with exponential-backoff retries, per-domain token-bucket rate limiting, and a 5 MB content cap.
|
|
106
|
+
- **Content extraction** uses trafilatura — the same library behind many read-it-later services.
|
|
107
|
+
- **Caching** is SQLite with TTL and a size cap; old entries are evicted automatically. Repeat fetches and searches cost nothing.
|
|
108
|
+
- **Crawling** is BFS with configurable depth, page count, and same-domain restriction.
|
|
109
|
+
|
|
110
|
+
Everything runs locally. No data leaves your machine.
|
|
111
|
+
|
|
112
|
+
## Configuration
|
|
113
|
+
|
|
114
|
+
All settings have sensible defaults. Override via environment variables (`WEBSCOUT_` prefix) or CLI flags:
|
|
115
|
+
|
|
116
|
+
| Variable | Default | What it does |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| `WEBSCOUT_CACHE_DIR` | `~/.cache/webscout` | Where the SQLite cache lives |
|
|
119
|
+
| `WEBSCOUT_CACHE_TTL` | `7200` | Cache entry lifetime in seconds |
|
|
120
|
+
| `WEBSCOUT_CACHE_MAX_SIZE_MB` | `512` | Max cache size before eviction |
|
|
121
|
+
| `WEBSCOUT_REQUEST_TIMEOUT` | `15.0` | HTTP timeout in seconds |
|
|
122
|
+
| `WEBSCOUT_MAX_RETRIES` | `3` | Retry attempts per request |
|
|
123
|
+
| `WEBSCOUT_RATE_LIMIT_PER_SECOND` | `2.0` | Max requests per second per domain |
|
|
124
|
+
| `WEBSCOUT_SEARCH_MAX_RESULTS` | `10` | Default search result count |
|
|
125
|
+
| `WEBSCOUT_CRAWLER_MAX_DEPTH` | `2` | Default crawl depth |
|
|
126
|
+
| `WEBSCOUT_CRAWLER_MAX_PAGES` | `20` | Default max pages per crawl |
|
|
127
|
+
|
|
128
|
+
CLI flags override env vars:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
webscout-mcp --cache-ttl 3600 --cache-dir /tmp/webscout
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Transports
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
# stdio (default — works with Claude Code, Cursor, etc.)
|
|
138
|
+
webscout-mcp
|
|
139
|
+
|
|
140
|
+
# SSE (for remote or browser-based clients)
|
|
141
|
+
webscout-mcp --transport sse --host 0.0.0.0 --port 8000
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Development
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
git clone https://github.com/wxs-lang/webscout-mcp.git
|
|
148
|
+
cd webscout-mcp
|
|
149
|
+
pip install -e ".[dev]"
|
|
150
|
+
pytest
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "webscout-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A smart web search & fetch MCP server with built-in caching, rate-limiting, and content extraction"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "webscout-mcp contributors"}]
|
|
13
|
+
keywords = ["mcp", "model-context-protocol", "web-search", "web-scraping", "ai-agent", "caching"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Internet :: WWW/HTTP :: Indexing/Search",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"mcp>=1.0.0",
|
|
27
|
+
"httpx>=0.25.0",
|
|
28
|
+
"beautifulsoup4>=4.12.0",
|
|
29
|
+
"lxml>=5.0.0",
|
|
30
|
+
"trafilatura>=1.6.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=7.0",
|
|
36
|
+
"pytest-asyncio>=0.21",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
webscout-mcp = "webscout_mcp.__main__:main"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
include = ["webscout_mcp*"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
asyncio_mode = "auto"
|
|
47
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Basic tests for webscout-mcp core modules.
|
|
2
|
+
|
|
3
|
+
These tests focus on pure-logic components that don't require network access.
|
|
4
|
+
Network-dependent tests are marked and can be run with --run-network.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from webscout_mcp.cache import Cache
|
|
17
|
+
from webscout_mcp.config import Config
|
|
18
|
+
from webscout_mcp.extractor import DataExtractor, ExtractionRule
|
|
19
|
+
from webscout_mcp.utils import TokenBucket, is_valid_url, normalize_url, truncate_text
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --- Config ---
|
|
23
|
+
|
|
24
|
+
class TestConfig:
|
|
25
|
+
def test_defaults(self):
|
|
26
|
+
cfg = Config()
|
|
27
|
+
assert cfg.cache_ttl == 7200
|
|
28
|
+
assert cfg.request_timeout == 15.0
|
|
29
|
+
assert cfg.search_max_results == 10
|
|
30
|
+
|
|
31
|
+
def test_from_env(self, monkeypatch):
|
|
32
|
+
monkeypatch.setenv("WEBSCOUT_CACHE_TTL", "3600")
|
|
33
|
+
monkeypatch.setenv("WEBSCOUT_SEARCH_MAX_RESULTS", "5")
|
|
34
|
+
cfg = Config.from_env()
|
|
35
|
+
assert cfg.cache_ttl == 3600
|
|
36
|
+
assert cfg.search_max_results == 5
|
|
37
|
+
|
|
38
|
+
def test_ensure_dirs(self):
|
|
39
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
40
|
+
cfg = Config(cache_dir=Path(tmpdir) / "test_cache")
|
|
41
|
+
cfg.ensure_dirs()
|
|
42
|
+
assert (Path(tmpdir) / "test_cache").exists()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --- Cache ---
|
|
46
|
+
|
|
47
|
+
class TestCache:
|
|
48
|
+
@pytest.fixture
|
|
49
|
+
def cache(self):
|
|
50
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
51
|
+
c = Cache(Path(tmpdir) / "test.db", ttl=3600, max_size_mb=10)
|
|
52
|
+
yield c
|
|
53
|
+
|
|
54
|
+
def test_set_and_get(self, cache):
|
|
55
|
+
cache.set("https://example.com", "hello world", "text/plain")
|
|
56
|
+
result = cache.get("https://example.com")
|
|
57
|
+
assert result is not None
|
|
58
|
+
assert result["value"] == "hello world"
|
|
59
|
+
assert result["cached"] is True
|
|
60
|
+
|
|
61
|
+
def test_get_missing(self, cache):
|
|
62
|
+
assert cache.get("https://nonexistent.com") is None
|
|
63
|
+
|
|
64
|
+
def test_expiry(self, cache):
|
|
65
|
+
cache.set("https://example.com", "data", ttl=0)
|
|
66
|
+
# ttl=0 means expires immediately
|
|
67
|
+
import time
|
|
68
|
+
time.sleep(0.1)
|
|
69
|
+
assert cache.get("https://example.com") is None
|
|
70
|
+
|
|
71
|
+
def test_delete(self, cache):
|
|
72
|
+
cache.set("https://example.com", "data")
|
|
73
|
+
cache.delete("https://example.com")
|
|
74
|
+
assert cache.get("https://example.com") is None
|
|
75
|
+
|
|
76
|
+
def test_clear(self, cache):
|
|
77
|
+
cache.set("https://a.com", "a")
|
|
78
|
+
cache.set("https://b.com", "b")
|
|
79
|
+
deleted = cache.clear()
|
|
80
|
+
assert deleted == 2
|
|
81
|
+
assert cache.get("https://a.com") is None
|
|
82
|
+
|
|
83
|
+
def test_stats(self, cache):
|
|
84
|
+
cache.set("https://example.com", "hello")
|
|
85
|
+
stats = cache.stats()
|
|
86
|
+
assert stats["entries"] == 1
|
|
87
|
+
assert stats["total_size_bytes"] > 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --- Utils ---
|
|
91
|
+
|
|
92
|
+
class TestUtils:
|
|
93
|
+
def test_normalize_url(self):
|
|
94
|
+
assert normalize_url("HTTPS://Example.COM/path//to//page/") == "https://example.com/path/to/page/"
|
|
95
|
+
assert normalize_url("http://example.com:80/path") == "http://example.com/path"
|
|
96
|
+
assert normalize_url("https://example.com:443/path") == "https://example.com/path"
|
|
97
|
+
|
|
98
|
+
def test_normalize_url_strips_fragment(self):
|
|
99
|
+
result = normalize_url("https://example.com/page#section")
|
|
100
|
+
assert "#" not in result
|
|
101
|
+
|
|
102
|
+
def test_is_valid_url(self):
|
|
103
|
+
assert is_valid_url("https://example.com")
|
|
104
|
+
assert is_valid_url("http://example.com/path?q=1")
|
|
105
|
+
assert not is_valid_url("not a url")
|
|
106
|
+
assert not is_valid_url("ftp://example.com")
|
|
107
|
+
assert not is_valid_url("")
|
|
108
|
+
|
|
109
|
+
def test_truncate_text(self):
|
|
110
|
+
short = "hello"
|
|
111
|
+
assert truncate_text(short, 100) == short
|
|
112
|
+
|
|
113
|
+
long_text = "a" * 1000
|
|
114
|
+
result = truncate_text(long_text, 100)
|
|
115
|
+
assert len(result) < 1000
|
|
116
|
+
assert "truncated" in result
|
|
117
|
+
|
|
118
|
+
@pytest.mark.asyncio
|
|
119
|
+
async def test_token_bucket(self):
|
|
120
|
+
bucket = TokenBucket(rate=10.0, burst=2)
|
|
121
|
+
# Should be able to acquire burst tokens quickly
|
|
122
|
+
await bucket.acquire("https://example.com")
|
|
123
|
+
await bucket.acquire("https://example.com")
|
|
124
|
+
# Third should require a short wait
|
|
125
|
+
await bucket.acquire("https://example.com")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --- Extractor ---
|
|
129
|
+
|
|
130
|
+
class TestExtractor:
|
|
131
|
+
SAMPLE_HTML = """
|
|
132
|
+
<html>
|
|
133
|
+
<head><title>Test Page</title></head>
|
|
134
|
+
<body>
|
|
135
|
+
<h1 class="title">Hello World</h1>
|
|
136
|
+
<p class="price">$29.99</p>
|
|
137
|
+
<a href="https://example.com/1" class="item">Item 1</a>
|
|
138
|
+
<a href="https://example.com/2" class="item">Item 2</a>
|
|
139
|
+
<a href="https://example.com/3" class="item">Item 3</a>
|
|
140
|
+
<div class="description">This is a <b>great</b> product.</div>
|
|
141
|
+
</body>
|
|
142
|
+
</html>
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def test_extract_single_text(self):
|
|
146
|
+
extractor = DataExtractor(Config(), None) # fetcher not needed for HTML extraction
|
|
147
|
+
rules = [ExtractionRule(name="title", selector="h1.title")]
|
|
148
|
+
result = extractor.extract_from_html(self.SAMPLE_HTML, rules)
|
|
149
|
+
assert result["title"] == "Hello World"
|
|
150
|
+
|
|
151
|
+
def test_extract_with_regex(self):
|
|
152
|
+
extractor = DataExtractor(Config(), None)
|
|
153
|
+
rules = [ExtractionRule(name="price", selector=".price", regex=r"\$([\d.]+)")]
|
|
154
|
+
result = extractor.extract_from_html(self.SAMPLE_HTML, rules)
|
|
155
|
+
assert result["price"] == "29.99"
|
|
156
|
+
|
|
157
|
+
def test_extract_attribute_multiple(self):
|
|
158
|
+
extractor = DataExtractor(Config(), None)
|
|
159
|
+
rules = [ExtractionRule(name="links", selector="a.item", attribute="href", multiple=True)]
|
|
160
|
+
result = extractor.extract_from_html(self.SAMPLE_HTML, rules)
|
|
161
|
+
assert result["links"] == [
|
|
162
|
+
"https://example.com/1",
|
|
163
|
+
"https://example.com/2",
|
|
164
|
+
"https://example.com/3",
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
def test_extract_default_on_missing(self):
|
|
168
|
+
extractor = DataExtractor(Config(), None)
|
|
169
|
+
rules = [ExtractionRule(name="missing", selector=".nonexistent", default="N/A")]
|
|
170
|
+
result = extractor.extract_from_html(self.SAMPLE_HTML, rules)
|
|
171
|
+
assert result["missing"] == "N/A"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --- MCP Server (import test) ---
|
|
175
|
+
|
|
176
|
+
class TestServer:
|
|
177
|
+
def test_create_server(self):
|
|
178
|
+
"""Verify the server can be created without errors."""
|
|
179
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
180
|
+
cfg = Config(cache_dir=Path(tmpdir))
|
|
181
|
+
from webscout_mcp.server import create_server
|
|
182
|
+
mcp = create_server(cfg)
|
|
183
|
+
assert mcp is not None
|
|
184
|
+
# FastMCP stores tools internally
|
|
185
|
+
assert hasattr(mcp, "tool")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""webscout-mcp: A smart web search & fetch MCP server."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
__all__ = ["__version__", "create_server", "Config", "Fetcher", "SearchEngine", "Crawler", "DataExtractor"]
|
|
5
|
+
|
|
6
|
+
from .config import Config
|
|
7
|
+
from .crawler import Crawler
|
|
8
|
+
from .extractor import DataExtractor
|
|
9
|
+
from .fetcher import Fetcher
|
|
10
|
+
from .search import SearchEngine
|
|
11
|
+
from .server import create_server
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Command-line entry point for webscout-mcp.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
webscout-mcp # Start MCP server over stdio (default)
|
|
5
|
+
webscout-mcp --transport sse # Start MCP server over SSE
|
|
6
|
+
webscout-mcp --version # Print version
|
|
7
|
+
webscout-mcp --help # Show help
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .config import Config
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> None:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="webscout-mcp",
|
|
22
|
+
description="A smart web search & fetch MCP server with caching and content extraction.",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--version",
|
|
26
|
+
action="version",
|
|
27
|
+
version=f"webscout-mcp {__version__}",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--transport",
|
|
31
|
+
choices=["stdio", "sse"],
|
|
32
|
+
default="stdio",
|
|
33
|
+
help="MCP transport protocol (default: stdio)",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--host",
|
|
37
|
+
default="127.0.0.1",
|
|
38
|
+
help="Host for SSE transport (default: 127.0.0.1)",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--port",
|
|
42
|
+
type=int,
|
|
43
|
+
default=8000,
|
|
44
|
+
help="Port for SSE transport (default: 8000)",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--cache-dir",
|
|
48
|
+
default=None,
|
|
49
|
+
help="Override cache directory",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--cache-ttl",
|
|
53
|
+
type=int,
|
|
54
|
+
default=None,
|
|
55
|
+
help="Override cache TTL in seconds",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
args = parser.parse_args()
|
|
59
|
+
|
|
60
|
+
# Build config
|
|
61
|
+
config = Config.from_env()
|
|
62
|
+
if args.cache_dir:
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
config.cache_dir = Path(args.cache_dir)
|
|
65
|
+
if args.cache_ttl is not None:
|
|
66
|
+
config.cache_ttl = args.cache_ttl
|
|
67
|
+
|
|
68
|
+
# Create and run server
|
|
69
|
+
from .server import create_server
|
|
70
|
+
mcp = create_server(config)
|
|
71
|
+
|
|
72
|
+
if args.transport == "sse":
|
|
73
|
+
mcp.run(transport="sse", host=args.host, port=args.port)
|
|
74
|
+
else:
|
|
75
|
+
mcp.run(transport="stdio")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
main()
|