mcp-web-engine 1.0.5 → 1.2.0
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.
- package/README.md +62 -104
- package/bin/cli.js +20 -88
- package/docs/operations.md +15 -0
- package/package.json +5 -3
- package/pyproject.toml +58 -0
- package/requirements.txt +1 -9
- package/src/mcp_web_engine/__init__.py +3 -0
- package/src/mcp_web_engine/config.py +43 -0
- package/src/mcp_web_engine/logging_obs.py +142 -0
- package/src/mcp_web_engine/main.py +281 -0
- package/{manage_beta_keys.py → src/mcp_web_engine/manage_beta_keys.py} +30 -9
- package/src/mcp_web_engine/mcp_protocol.py +166 -0
- package/{mcp_tools.py → src/mcp_web_engine/mcp_tools.py} +10 -7
- package/src/mcp_web_engine/resource_limits.py +45 -0
- package/src/mcp_web_engine/security.py +339 -0
- package/src/mcp_web_engine/state_store.py +133 -0
- package/src/mcp_web_engine/web_engine.py +315 -0
- package/config.py +0 -24
- package/logging_obs.py +0 -111
- package/main.py +0 -230
- package/mcp_protocol.py +0 -210
- package/security.py +0 -169
- package/web_engine.py +0 -181
package/README.md
CHANGED
|
@@ -1,141 +1,99 @@
|
|
|
1
|
-
#
|
|
1
|
+
# MCP Web Engine
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
> **MCP Specification 2026-07-28 Pure Stateless Core & Stdio / HTTP Dual Mode**
|
|
3
|
+
Self-hostable MCP search, public-page fetch and Markdown extraction. Source version: **1.2.0**; examples below run the checkout, not an assumed published release.
|
|
5
4
|
|
|
6
|
-
[
|
|
7
|
-
[](https://www.npmjs.com/package/mcp-web-engine)
|
|
8
|
-
[](LICENSE)
|
|
5
|
+
Requires Python >=3.11. The optional npm launcher requires Node >=18 and [uv](https://docs.astral.sh/uv/getting-started/installation/) on PATH. Installation resolves dependencies from `pyproject.toml` and may access package indexes. uv isolates Python dependencies; it is not a security sandbox.
|
|
9
6
|
|
|
10
|
-
|
|
7
|
+
## Local stdio
|
|
11
8
|
|
|
12
|
-
## ⚡ Instant Quickstart with `uvx` (Recommended for AI Agents)
|
|
13
|
-
|
|
14
|
-
AI agents (Claude Desktop, Cursor, Windsurf, TurboLLM, Hermes Agent) can run `mcp-web-engine` in an isolated ephemeral environment with **zero configuration**:
|
|
15
|
-
|
|
16
|
-
```bash
|
|
17
|
-
# Run stdio MCP server in an isolated environment (10ms startup, 0% system contamination)
|
|
18
|
-
uvx mcp-web-engine
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
Or run via Git repository:
|
|
22
9
|
```bash
|
|
23
|
-
|
|
10
|
+
git clone https://github.com/Arbolencio/mcp-web-engine.git
|
|
11
|
+
cd mcp-web-engine
|
|
12
|
+
uv run mcp-web-engine
|
|
24
13
|
```
|
|
25
14
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
## 📌 What it does & Why it exists
|
|
29
|
-
|
|
30
|
-
`mcp-web-engine` is a high-performance Model Context Protocol (MCP) server implementing the **MCP Specification 2026-07-28 Pure Stateless Core**. It provides AI Agents with secure, private, and SSRF-hardened access to multi-engine web search, raw content fetching, and clean HTML-to-Markdown extraction.
|
|
15
|
+
Default transport is newline-delimited JSON-RPC over stdin/stdout. Diagnostics and installer output belong on stderr. Clients should initialize, send `notifications/initialized`, then use `ping`, `tools/list`, and `tools/call`. No search service is required for initialization or listing tools.
|
|
31
16
|
|
|
32
|
-
|
|
33
|
-
- **Dual Execution Mode:**
|
|
34
|
-
- **Stdio Mode (Default):** Runs JSON-RPC over `stdin`/`stdout` for direct LLM client integration (`uvx mcp-web-engine`).
|
|
35
|
-
- **HTTP/SSE Server Mode:** Runs FastAPI/Uvicorn server (`mcp-web-engine --serve`).
|
|
36
|
-
- **Pure Stateless Core (Spec 2026-07-28):** Zero sessions, zero state, zero initialize handshake required. Every request is completely independent.
|
|
37
|
-
- **Protocol Methods:** Implements `server/discover`, `tools/list`, and `tools/call` with standard JSON-RPC 2.0.
|
|
38
|
-
- **Zero Third-Party Tracking:** Powered by an internal SearXNG meta-search engine aggregating 70+ sources with DuckDuckGo fallback.
|
|
39
|
-
- **Automatic URL Normalization:** Handled transparently by `normalize_searxng_url()` — any provided `SEARXNG_URL` variation (`http://127.0.0.1:8082`, `http://127.0.0.1:8082/`, `http://127.0.0.1:8082/search`) automatically appends `/search` if missing.
|
|
40
|
-
- **SSRF Hardened:** Pre-request DNS resolution checks prevent agents from accessing internal networks (`localhost`, `127.0.0.1`, `192.168.x.x`, `169.254.169.254`). Step-by-step HTTP 301/302 redirect re-validation.
|
|
41
|
-
- **Zero `--break-system-packages`:** Executed 100% in sandboxed virtual environments created on-the-fly by `uv` or `venv`.
|
|
17
|
+
Client configuration (replace the absolute checkout path):
|
|
42
18
|
|
|
43
|
-
---
|
|
44
|
-
|
|
45
|
-
## 🔌 Integration Guides
|
|
46
|
-
|
|
47
|
-
### 1. TurboLLM / Local MCP Catalog
|
|
48
|
-
```typescript
|
|
49
|
-
{
|
|
50
|
-
id: 'mcp-web-engine',
|
|
51
|
-
name: 'MCP Web Engine',
|
|
52
|
-
cat: 'Search',
|
|
53
|
-
desc: 'Privacy-first, self-hostable & SSRF-hardened web search engine and scraper for AI agents.',
|
|
54
|
-
cmd: 'uvx mcp-web-engine',
|
|
55
|
-
uvx: true,
|
|
56
|
-
argNote: 'Requires a running SearXNG instance.',
|
|
57
|
-
envs: [
|
|
58
|
-
{ key: 'SEARXNG_URL', desc: 'SearXNG meta-search endpoint (default: http://127.0.0.1:8082/search)', required: true },
|
|
59
|
-
],
|
|
60
|
-
}
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
### 2. Claude Desktop (`claude_desktop_config.json`)
|
|
64
19
|
```json
|
|
65
20
|
{
|
|
66
21
|
"mcpServers": {
|
|
67
22
|
"mcp-web-engine": {
|
|
68
|
-
"command": "
|
|
69
|
-
"args": ["mcp-web-engine"],
|
|
70
|
-
"env": {
|
|
71
|
-
"SEARXNG_URL": "http://127.0.0.1:8082/search"
|
|
72
|
-
}
|
|
23
|
+
"command": "uv",
|
|
24
|
+
"args": ["tool", "run", "--from", "/absolute/path/mcp-web-engine", "mcp-web-engine"],
|
|
25
|
+
"env": {"SEARXNG_URL": "http://127.0.0.1:8082/search", "ALLOW_DDG_FALLBACK": "false"}
|
|
73
26
|
}
|
|
74
27
|
}
|
|
75
28
|
}
|
|
76
29
|
```
|
|
77
30
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
}
|
|
31
|
+
The npm tarball includes the canonical Python src package and build metadata. `npx --package /absolute/path/mcp-web-engine-1.2.0.tgz mcp-web-engine` delegates to uv; Python/uv remain prerequisites, and first launch may need network access for dependencies.
|
|
32
|
+
|
|
33
|
+
## HTTP
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
export API_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
|
37
|
+
export SEARXNG_URL=http://127.0.0.1:8082/search
|
|
38
|
+
uv run mcp-web-engine --serve
|
|
88
39
|
```
|
|
89
40
|
|
|
90
|
-
|
|
41
|
+
HTTP defaults to `127.0.0.1:5050`; `API_KEY` must contain at least 32 non-whitespace characters. Send `Authorization: Bearer <your-key>` to protected endpoints. Keep the key out of source control and URLs. `/health` is a liveness endpoint, not a SearXNG connectivity check.
|
|
91
42
|
|
|
92
|
-
|
|
43
|
+
`POST /v1/mcp/legacy` provides the initialize-based compatibility path. `/v1/mcp` is a project-specific stateless extension requiring `Mcp-Method` (and `Mcp-Name` for tool calls). Its `2026-07-28` label is an internal extension identifier, **not a claim of official MCP conformance**. SSE support is limited; this is not a complete implementation of every MCP transport or optional feature.
|
|
93
44
|
|
|
94
|
-
|
|
95
|
-
|---|---|---|
|
|
96
|
-
| `web_search` | `query` (str), `limit` (int, default: 10) | Multi-engine meta-search via SearXNG returning structured JSON results |
|
|
97
|
-
| `fetch_url` | `url` (str), `max_bytes` (int, optional) | Fetches raw web content after strict SSRF security validation |
|
|
98
|
-
| `extract_markdown` | `url` (str), `max_bytes` (int, optional) | Scrapes a webpage and converts HTML into clean, LLM-optimized Markdown |
|
|
45
|
+
## Tools
|
|
99
46
|
|
|
100
|
-
|
|
47
|
+
| Tool | Arguments | Purpose |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `web_search` | `query`, optional `limit` | Search through configured SearXNG |
|
|
50
|
+
| `fetch_url` | `url`, optional `max_bytes` | Fetch a public HTTP(S) page |
|
|
51
|
+
| `extract_markdown` | `url`, optional `max_bytes` | Convert fetched HTML to Markdown |
|
|
101
52
|
|
|
102
|
-
|
|
53
|
+
Use `tools/list` for authoritative schemas and bounds.
|
|
103
54
|
|
|
104
|
-
|
|
105
|
-
|---|---|---|---|
|
|
106
|
-
| `SEARXNG_URL` | **Yes** | `http://127.0.0.1:8082/search` | SearXNG meta-search endpoint (Auto-normalizes trailing slashes and missing `/search`) |
|
|
107
|
-
| `PORT` | No | `5050` | HTTP/SSE server port (Used only when running in `--serve` mode) |
|
|
108
|
-
| `HOST` | No | `0.0.0.0` | HTTP/SSE server binding host |
|
|
109
|
-
| `API_KEY` | No | `""` | Optional authentication key for HTTP endpoints |
|
|
55
|
+
## Configuration
|
|
110
56
|
|
|
111
|
-
|
|
57
|
+
Process environment is authoritative. CWD `.env` files are **not** loaded automatically. To explicitly load one, set `MCP_WEB_ENGINE_ENV_FILE` to an existing **absolute** file path; process variables override file values.
|
|
112
58
|
|
|
113
|
-
|
|
59
|
+
| Variable | Default | Meaning |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `SEARXNG_URL` | `http://127.0.0.1:8082/search` | Trusted operator-controlled search backend; search needs it running |
|
|
62
|
+
| `ALLOW_DDG_FALLBACK` | `false` | Opt in to sending queries to DuckDuckGo if SearXNG fails |
|
|
63
|
+
| `API_KEY` | empty | Operator-generated HTTP bearer key; mandatory for CLI HTTP mode |
|
|
64
|
+
| `HOST` / `PORT` | `127.0.0.1` / `5050` | HTTP bind address and port |
|
|
65
|
+
| `RATE_LIMIT_PER_MINUTE` | `120` | Per-key HTTP admission limit, persisted in local SQLite |
|
|
66
|
+
| `DEFAULT_TIMEOUT_SEC` | `10` | Outbound timeout, up to 120 seconds |
|
|
67
|
+
| `MAX_PAYLOAD_BYTES` | `2097152` | Payload ceiling, up to 16 MiB |
|
|
68
|
+
| `MCP_WEB_ENGINE_STATE_DB` | `$XDG_STATE_HOME/mcp-web-engine/usage.sqlite3` (or `~/.local/state/...`) | Local SQLite admission quotas and per-key tool telemetry; share one local file across workers |
|
|
69
|
+
| `MCP_WEB_ENGINE_BETA_KEYS_FILE` | Same state directory, `beta_keys.json` | Operator-managed credentials, separate from telemetry; use an absolute path |
|
|
70
|
+
| `LOG_LEVEL` / `ENV` | `INFO` / `production` | Logging and environment settings |
|
|
71
|
+
|
|
72
|
+
## Docker
|
|
114
73
|
|
|
115
74
|
```bash
|
|
116
|
-
#
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
# Build Wheel for PyPI
|
|
121
|
-
python3 -m build --wheel
|
|
75
|
+
# With API_KEY already exported as above:
|
|
76
|
+
docker compose up --build -d
|
|
77
|
+
```
|
|
122
78
|
|
|
123
|
-
|
|
124
|
-
uv run python -m mcp_web_engine.main
|
|
79
|
+
The image installs the Python package, runs as a non-root user and explicitly starts `mcp-web-engine --serve` on port 5050. Compose publishes **only host loopback**. Container `HOST=0.0.0.0` is necessary for that port mapping; do not confuse it with the host binding. Set `SEARXNG_URL` to a reachable backend: compose defaults to `http://host.docker.internal:8082/search`, using the host gateway. An instance bound only to host loopback may not be reachable from Docker. No SearXNG instance or credentials are bundled. See [operations and limitations](docs/operations.md).
|
|
125
80
|
|
|
126
|
-
|
|
127
|
-
uv run python -m mcp_web_engine.main --serve
|
|
128
|
-
```
|
|
81
|
+
## Security controls and limitations
|
|
129
82
|
|
|
130
|
-
|
|
83
|
+
Public fetches validate schemes, resolved addresses and redirects, rejecting loopback/private/reserved destinations. Bounded payloads, timeouts and tool argument validation reduce resource exposure. These are defense-in-depth controls, not a guarantee against all SSRF, DNS, routing, proxy or upstream failures; enforce outbound network policy independently.
|
|
131
84
|
|
|
132
|
-
|
|
85
|
+
SearXNG is an intentional trusted-backend exception to public-fetch restrictions and can use a private address. Its operator and upstream engines receive queries; self-hosting does not keep searches entirely on-host. DuckDuckGo fallback is off by default, but ordinary SearXNG searches still involve upstream services. Fetched pages are untrusted input and may contain prompt injection. Dependency isolation does not isolate filesystem/network access. HTTP authentication is not TLS or a multi-host distributed rate limiter; use a restricted network and a TLS reverse proxy when needed. Do not expose the service directly to the Internet.
|
|
133
86
|
|
|
134
|
-
|
|
135
|
-
- **Zero Package Pollution:** Execution via `uvx` or `bin/cli.js` never modifies global site-packages or uses dangerous flags like `--break-system-packages`.
|
|
87
|
+
## Development and artifact verification
|
|
136
88
|
|
|
137
|
-
|
|
89
|
+
```bash
|
|
90
|
+
uv sync --extra test
|
|
91
|
+
uv run pytest -q
|
|
92
|
+
uv run --with build python -m build
|
|
93
|
+
npm pack
|
|
94
|
+
python3 tests/artifact_smoke.py -- uv tool run --from /absolute/path/to/package.whl mcp-web-engine
|
|
95
|
+
```
|
|
138
96
|
|
|
139
|
-
|
|
97
|
+
Tests are offline by default. The artifact smoke sends no search query: initialize, silent notification, ping, tool listing, and a rejected loopback fetch. CI tests Python 3.11–3.13 and builds/installs wheel and npm artifacts outside the checkout. Package dependency installation still requires package-index access unless cached.
|
|
140
98
|
|
|
141
|
-
MIT License
|
|
99
|
+
MIT License. See [LICENSE](LICENSE).
|
package/bin/cli.js
CHANGED
|
@@ -1,90 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
child.on('error', (err) => {
|
|
20
|
-
console.error('[mcp-web-engine] Failed to start process:', err.message);
|
|
21
|
-
process.exit(1);
|
|
22
|
-
});
|
|
23
|
-
child.on('exit', (code, signal) => {
|
|
24
|
-
if (signal) {
|
|
25
|
-
process.exit(128 + (signal === 'SIGINT' ? 2 : 15));
|
|
26
|
-
}
|
|
27
|
-
process.exit(code ?? 0);
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// 1. Try running with 'uv' if available (fastest, cleanest, isolated)
|
|
32
|
-
function tryUv() {
|
|
33
|
-
try {
|
|
34
|
-
execSync('uv --version', { stdio: 'ignore' });
|
|
35
|
-
const repoRoot = path.join(__dirname, '..');
|
|
36
|
-
const child = spawn('uv', ['run', '--directory', repoRoot, 'python', '-m', 'mcp_web_engine.main', ...process.argv.slice(2)], {
|
|
37
|
-
stdio: 'inherit',
|
|
38
|
-
env: process.env
|
|
39
|
-
});
|
|
40
|
-
forwardChild(child);
|
|
41
|
-
return true;
|
|
42
|
-
} catch (e) {
|
|
43
|
-
return false;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// 2. Fallback to dedicated isolated virtualenv in user cache dir (~/.cache/mcp-web-engine/venv)
|
|
48
|
-
function runInIsolatedVenv() {
|
|
49
|
-
const cacheDir = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
50
|
-
const venvDir = path.join(cacheDir, 'mcp-web-engine', 'venv');
|
|
51
|
-
const venvPython = process.platform === 'win32'
|
|
52
|
-
? path.join(venvDir, 'Scripts', 'python.exe')
|
|
53
|
-
: path.join(venvDir, 'bin', 'python');
|
|
54
|
-
const venvPip = process.platform === 'win32'
|
|
55
|
-
? path.join(venvDir, 'Scripts', 'pip.exe')
|
|
56
|
-
: path.join(venvDir, 'bin', 'pip');
|
|
57
|
-
|
|
58
|
-
function findSystemPython() {
|
|
59
|
-
for (const cmd of ['python3', 'python']) {
|
|
60
|
-
try {
|
|
61
|
-
execSync(`${cmd} --version`, { stdio: 'ignore' });
|
|
62
|
-
return cmd;
|
|
63
|
-
} catch (e) {}
|
|
64
|
-
}
|
|
65
|
-
throw new Error('Python 3 is required but was not found on PATH.');
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Ensure isolated venv exists and has requirements installed
|
|
69
|
-
if (!fs.existsSync(venvPython)) {
|
|
70
|
-
console.log('[mcp-web-engine] Creating isolated Python venv at:', venvDir);
|
|
71
|
-
const sysPython = findSystemPython();
|
|
72
|
-
fs.mkdirSync(path.dirname(venvDir), { recursive: true });
|
|
73
|
-
execSync(`${sysPython} -m venv "${venvDir}"`, { stdio: 'inherit' });
|
|
74
|
-
console.log('[mcp-web-engine] Installing dependencies into isolated venv...');
|
|
75
|
-
execSync(`"${venvPip}" install -r "${reqTxt}"`, { stdio: 'inherit' });
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const repoRoot = path.join(__dirname, '..');
|
|
79
|
-
const env = { ...process.env, PYTHONPATH: path.join(repoRoot, 'src') };
|
|
80
|
-
|
|
81
|
-
const child = spawn(venvPython, ['-m', 'mcp_web_engine.main', ...process.argv.slice(2)], {
|
|
82
|
-
stdio: 'inherit',
|
|
83
|
-
env: env
|
|
84
|
-
});
|
|
85
|
-
forwardChild(child);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (!tryUv()) {
|
|
89
|
-
runInIsolatedVenv();
|
|
2
|
+
// Ship the Python src package in the npm tarball and let uv isolate it.
|
|
3
|
+
// No pip/bootstrap chatter can ever enter MCP stdout.
|
|
4
|
+
const { spawn } = require('node:child_process');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const root = path.resolve(__dirname, '..');
|
|
8
|
+
const child = spawn('uv', ['tool', 'run', '--from', root, 'mcp-web-engine', ...process.argv.slice(2)], {
|
|
9
|
+
stdio: 'inherit',
|
|
10
|
+
env: process.env,
|
|
11
|
+
});
|
|
12
|
+
child.on('error', (error) => {
|
|
13
|
+
console.error(`[mcp-web-engine] Cannot launch uv: ${error.message}. Install uv: https://docs.astral.sh/uv/getting-started/installation/`);
|
|
14
|
+
process.exitCode = 1;
|
|
15
|
+
});
|
|
16
|
+
const signals = ['SIGINT', 'SIGTERM'];
|
|
17
|
+
for (const signal of signals) {
|
|
18
|
+
process.on(signal, () => { if (!child.killed) child.kill(signal); });
|
|
90
19
|
}
|
|
20
|
+
child.on('exit', (code, signal) => {
|
|
21
|
+
process.exitCode = code ?? (signal === 'SIGINT' ? 130 : 143);
|
|
22
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Operating boundaries
|
|
2
|
+
|
|
3
|
+
- Configure an operator-owned SearXNG backend. Queries go to that backend and its search engines even when DuckDuckGo fallback is disabled. Never accept arbitrary clients' backend URLs as trusted configuration.
|
|
4
|
+
- Keep HTTP on loopback or a restricted network. Generate a unique API_KEY (at least 32 characters); terminate TLS at a reverse proxy for remote access. Health is liveness only. HTTP admission limits, beta quotas and per-key tool telemetry persist in SQLite, serializing workers that share the same local file. Set MCP_WEB_ENGINE_STATE_DB to an absolute writable path. This is not multi-host coordination: do not put SQLite on a distributed network mount. Process summary metrics remain process-local. Set MCP_WEB_ENGINE_BETA_KEYS_FILE to the separate operator-managed credential file; telemetry never rewrites that file. Preserve state across container replacement to preserve quotas; deleting database rows resets usage.
|
|
5
|
+
- Explicit env files require MCP_WEB_ENGINE_ENV_FILE with an absolute path. Compose itself may interpolate a local .env file; that is Docker Compose behavior, not automatic application env loading. Do not bake secrets into images or npm tarballs.
|
|
6
|
+
- The Docker context uses a deny-by-default allowlist for Python source and build metadata. Keep credentials outside src/; review actual artifacts before distribution. Do not put secrets in Python source or README metadata.
|
|
7
|
+
- Public fetch validation and redirect checks do not replace firewall egress policy. Restrict cloud metadata, internal services and proxy configuration at the network layer. Treat fetched content as untrusted, including prompt injection.
|
|
8
|
+
- uv/npm installation can access package registries and write dependency caches. It is not an execution sandbox. Pin and audit dependencies for reproducible deployments.
|
|
9
|
+
- The stateless protocol label is project-specific, not an official specification certification. SSE is limited; validate your client's transport requirements. Stdio uses JSON lines rather than Content-Length framing.
|
|
10
|
+
|
|
11
|
+
## Verify a local Docker image without touching deployed services
|
|
12
|
+
|
|
13
|
+
Compose mounts the named `usage-state` volume for SQLite and operator-managed beta credentials. Container replacement preserves it; `docker compose down -v` explicitly destroys it and resets usage. Back up the volume and never use that command as routine deployment cleanup.
|
|
14
|
+
|
|
15
|
+
Build a separately tagged image, run it with an ephemeral loopback port and a disposable key, probe /health and protected routes, then remove only that container. Never use compose down against an existing deployment as a test cleanup step.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-web-engine",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Self-hostable MCP search, public-page fetch and Markdown extraction",
|
|
5
5
|
"main": "bin/cli.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"mcp-web-engine": "bin/cli.js"
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"bin",
|
|
14
|
-
"
|
|
14
|
+
"src/mcp_web_engine/*.py",
|
|
15
|
+
"pyproject.toml",
|
|
16
|
+
"docs/operations.md",
|
|
15
17
|
"requirements.txt",
|
|
16
18
|
"README.md",
|
|
17
19
|
"LICENSE"
|
package/pyproject.toml
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mcp-web-engine"
|
|
7
|
+
version = "1.2.0"
|
|
8
|
+
description = "Self-hostable MCP search, public-page fetch and Markdown extraction"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Arbolencio" }
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"mcp",
|
|
17
|
+
"mcp-server",
|
|
18
|
+
"searxng",
|
|
19
|
+
"web-search",
|
|
20
|
+
"ai-agent",
|
|
21
|
+
"turbollm",
|
|
22
|
+
"web-scraper",
|
|
23
|
+
"ssrf-protection"
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"fastapi>=0.162.0,<1",
|
|
27
|
+
"uvicorn>=0.54.0,<1",
|
|
28
|
+
"pydantic>=2.14.3,<3",
|
|
29
|
+
"pydantic-settings>=2.16.2,<3",
|
|
30
|
+
"curl-cffi>=0.17.0,<0.18",
|
|
31
|
+
"beautifulsoup4>=4.15.0,<5",
|
|
32
|
+
"html2text>=2025.4.15,<2027",
|
|
33
|
+
"sse-starlette>=3.6.0,<4"
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
mcp-web-engine = "mcp_web_engine.main:cli_entrypoint"
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/Arbolencio/mcp-web-engine"
|
|
41
|
+
Repository = "https://github.com/Arbolencio/mcp-web-engine.git"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/mcp_web_engine"]
|
|
45
|
+
|
|
46
|
+
[project.optional-dependencies]
|
|
47
|
+
test = ["pytest>=8,<10", "pytest-asyncio>=0.26,<2", "httpx>=0.28,<1"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
target-version = "py311"
|
|
51
|
+
line-length = 100
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = ["E4", "E7", "E9", "F", "I"]
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
asyncio_mode = "auto"
|
|
58
|
+
asyncio_default_fixture_loop_scope = "function"
|
package/requirements.txt
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized Configuration Module (Pydantic Settings / Environment Variables)
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Settings(BaseSettings):
|
|
12
|
+
model_config = SettingsConfigDict(env_file=None, extra="ignore")
|
|
13
|
+
|
|
14
|
+
API_KEY: str = Field(default="", description="Operator-generated HTTP bearer key; required for --serve")
|
|
15
|
+
SEARXNG_URL: str = Field(default="http://127.0.0.1:8082/search", description="Internal SearXNG URL")
|
|
16
|
+
PORT: int = Field(default=5050, ge=1, le=65535, description="Service listening port")
|
|
17
|
+
HOST: str = Field(default="127.0.0.1", min_length=1, description="Service listening host")
|
|
18
|
+
|
|
19
|
+
# Rate Limiting & Limits
|
|
20
|
+
RATE_LIMIT_PER_MINUTE: int = Field(default=120, ge=1, le=100000, description="Max requests per minute per API key")
|
|
21
|
+
DEFAULT_TIMEOUT_SEC: float = Field(default=10.0, gt=0, le=120, allow_inf_nan=False, description="Default HTTP request timeout in seconds")
|
|
22
|
+
MAX_PAYLOAD_BYTES: int = Field(default=2 * 1024 * 1024, ge=1, le=16 * 1024 * 1024, description="Max payload size limit (2 MB)")
|
|
23
|
+
|
|
24
|
+
# Privacy: the DuckDuckGo HTML fallback is deliberately opt-in, so that a query
|
|
25
|
+
# never leaves the host unless the operator explicitly allows it.
|
|
26
|
+
ALLOW_DDG_FALLBACK: bool = Field(default=False, description="Fall back to html.duckduckgo.com when SearXNG is unavailable (privacy trade-off, off by default)")
|
|
27
|
+
|
|
28
|
+
# Environment & Logging
|
|
29
|
+
ENV: str = Field(default="production", description="Environment (development/production)")
|
|
30
|
+
LOG_LEVEL: str = Field(default="INFO", description="Log level")
|
|
31
|
+
|
|
32
|
+
def load_settings():
|
|
33
|
+
"""Only an explicitly selected absolute env file may supplement process env."""
|
|
34
|
+
selected = os.environ.get("MCP_WEB_ENGINE_ENV_FILE")
|
|
35
|
+
if selected:
|
|
36
|
+
path = Path(selected).expanduser()
|
|
37
|
+
if not path.is_absolute() or not path.is_file():
|
|
38
|
+
raise ValueError("MCP_WEB_ENGINE_ENV_FILE must name an existing absolute file")
|
|
39
|
+
return Settings(_env_file=path, _env_file_encoding="utf-8")
|
|
40
|
+
return Settings()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
settings = load_settings()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Secret-safe JSON logs and separate durable tool telemetry."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from . import state_store
|
|
11
|
+
from .config import settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JSONFormatter(logging.Formatter):
|
|
15
|
+
_sensitive_key = re.compile(r"key|token|secret|auth|password|cookie|credential", re.I)
|
|
16
|
+
_url = re.compile(r"(?:[a-z][a-z0-9+.-]*://|www\.)[^\s<>\"']+", re.I)
|
|
17
|
+
_credentials = re.compile(
|
|
18
|
+
r"\b(?:Bearer\s+\S+|sk_mcp_beta_[A-Za-z0-9_-]+|"
|
|
19
|
+
r"(?:password|token|secret|api[_-]?key|authorization|cookie)\s*[:=]\s*\S+)", re.I)
|
|
20
|
+
|
|
21
|
+
def _text(self, value):
|
|
22
|
+
value = str(value)
|
|
23
|
+
master = settings.API_KEY
|
|
24
|
+
if isinstance(master, str) and master:
|
|
25
|
+
value = value.replace(master, "[REDACTED]")
|
|
26
|
+
value = self._url.sub("[REDACTED_URL]", value)
|
|
27
|
+
value = self._credentials.sub("[REDACTED]", value)
|
|
28
|
+
# Bound log size and remove terminal/control injection.
|
|
29
|
+
return "".join(c if ord(c) >= 32 and ord(c) != 127 else " " for c in value[:4096])
|
|
30
|
+
|
|
31
|
+
def format(self, record):
|
|
32
|
+
try:
|
|
33
|
+
message = "Operation failed" if record.exc_info else self._text(record.getMessage())
|
|
34
|
+
except Exception:
|
|
35
|
+
message = "Unformattable log message"
|
|
36
|
+
log_obj: dict[str, Any] = {
|
|
37
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
38
|
+
"level": record.levelname,
|
|
39
|
+
"module": self._text(record.module),
|
|
40
|
+
"message": message,
|
|
41
|
+
}
|
|
42
|
+
if hasattr(record, "extra_data"):
|
|
43
|
+
log_obj["extra"] = self.sanitize_data(record.extra_data)
|
|
44
|
+
return json.dumps(log_obj, allow_nan=False)
|
|
45
|
+
|
|
46
|
+
def sanitize_data(self, data, _depth=0):
|
|
47
|
+
if _depth >= 8:
|
|
48
|
+
return "[TRUNCATED]"
|
|
49
|
+
if isinstance(data, dict):
|
|
50
|
+
return {self._text(k): "REDACTED" if self._sensitive_key.search(str(k))
|
|
51
|
+
else self.sanitize_data(v, _depth + 1) for k, v in list(data.items())[:100]}
|
|
52
|
+
if isinstance(data, (list, tuple)):
|
|
53
|
+
return [self.sanitize_data(v, _depth + 1) for v in data[:100]]
|
|
54
|
+
if data is None or type(data) in (bool, int):
|
|
55
|
+
return data
|
|
56
|
+
if isinstance(data, str):
|
|
57
|
+
return self._text(data)
|
|
58
|
+
# No repr/str of arbitrary exceptions or objects: may contain credentials.
|
|
59
|
+
if isinstance(data, float):
|
|
60
|
+
import math
|
|
61
|
+
return data if math.isfinite(data) else None
|
|
62
|
+
return "[REDACTED_OBJECT]"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
logger = logging.getLogger("mcp_web_engine")
|
|
66
|
+
logger.setLevel(getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO))
|
|
67
|
+
handler = logging.StreamHandler()
|
|
68
|
+
handler.setFormatter(JSONFormatter())
|
|
69
|
+
if not any(isinstance(h.formatter, JSONFormatter) for h in logger.handlers):
|
|
70
|
+
logger.addHandler(handler)
|
|
71
|
+
logger.propagate = False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class MetricsTracker:
|
|
75
|
+
"""Bounded process summary; per-key tool counters persist independently.
|
|
76
|
+
|
|
77
|
+
Call record once per tool completion, and check_rate_limit once per HTTP
|
|
78
|
+
admission. Telemetry requests are tool calls, never the admission quota.
|
|
79
|
+
"""
|
|
80
|
+
def __init__(self):
|
|
81
|
+
self.total_requests = 0
|
|
82
|
+
self.total_errors = 0
|
|
83
|
+
self.tool_calls = {"web_search": 0, "fetch_url": 0, "extract_markdown": 0}
|
|
84
|
+
self.total_units_consumed = 0
|
|
85
|
+
self.latencies_ms = []
|
|
86
|
+
self._lock = threading.Lock()
|
|
87
|
+
|
|
88
|
+
def record(self, tool_name: str, latency_ms: float, success: bool, units: int = 1, api_key: Optional[str] = None):
|
|
89
|
+
with self._lock:
|
|
90
|
+
self.total_requests += 1
|
|
91
|
+
if tool_name in self.tool_calls:
|
|
92
|
+
self.tool_calls[tool_name] += 1
|
|
93
|
+
self.total_errors += int(not success)
|
|
94
|
+
self.total_units_consumed += units
|
|
95
|
+
self.latencies_ms.append(latency_ms)
|
|
96
|
+
if len(self.latencies_ms) > 1000:
|
|
97
|
+
del self.latencies_ms[:-1000]
|
|
98
|
+
if api_key:
|
|
99
|
+
self.update_beta_telemetry(api_key, tool_name, latency_ms, success, units)
|
|
100
|
+
|
|
101
|
+
def update_beta_telemetry(self, api_key: str, tool_name: str, latency_ms: float, success: bool, units: int = 1):
|
|
102
|
+
"""Compatibility name; now records any authenticated key without JSON IO."""
|
|
103
|
+
try:
|
|
104
|
+
state_store.record_telemetry(hashlib.sha256(api_key.encode("utf-8")).hexdigest(),
|
|
105
|
+
tool_name, latency_ms, success, units)
|
|
106
|
+
except (state_store.StateUnavailable, state_store.AdmissionDenied, ValueError):
|
|
107
|
+
logger.error("Telemetry persistence failed")
|
|
108
|
+
|
|
109
|
+
def get_beta_telemetry(self, api_key: str):
|
|
110
|
+
"""Durable per-key dashboard, no credential IO.
|
|
111
|
+
|
|
112
|
+
requests/admissions are HTTP admissions; tool_requests counts completed
|
|
113
|
+
tool calls (possibly several per HTTP batch). Raises StateUnavailable
|
|
114
|
+
rather than falsely returning zero if the state database cannot be read.
|
|
115
|
+
"""
|
|
116
|
+
usage = state_store.read_usage(hashlib.sha256(api_key.encode("utf-8")).hexdigest())
|
|
117
|
+
tools = usage.get("requests", 0)
|
|
118
|
+
return {
|
|
119
|
+
"requests": usage.get("admissions", 0),
|
|
120
|
+
"admissions": usage.get("admissions", 0),
|
|
121
|
+
"tool_requests": tools,
|
|
122
|
+
"errors": usage.get("errors", 0),
|
|
123
|
+
"web_search": usage.get("web_search", 0),
|
|
124
|
+
"fetch_url": usage.get("fetch_url", 0),
|
|
125
|
+
"extract_markdown": usage.get("extract_markdown", 0),
|
|
126
|
+
"total_units_consumed": usage.get("units", 0),
|
|
127
|
+
"avg_latency_ms": round(usage.get("latency_total", 0) / tools, 2) if tools else 0.0,
|
|
128
|
+
"last_seen": datetime.fromtimestamp(usage["last_seen"], timezone.utc).isoformat() if usage.get("last_seen") else None,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
def get_summary(self):
|
|
132
|
+
with self._lock:
|
|
133
|
+
return {
|
|
134
|
+
"total_requests": self.total_requests,
|
|
135
|
+
"total_errors": self.total_errors,
|
|
136
|
+
"tool_calls": dict(self.tool_calls),
|
|
137
|
+
"total_units_consumed": self.total_units_consumed,
|
|
138
|
+
"avg_latency_ms": round(sum(self.latencies_ms) / len(self.latencies_ms), 2) if self.latencies_ms else 0.0,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
metrics = MetricsTracker()
|