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 CHANGED
@@ -1,141 +1,99 @@
1
- # 🌐 MCP Web Engine & Search Gateway
1
+ # MCP Web Engine
2
2
 
3
- > **Privacy-First, Self-Hostable & SSRF-Hardened MCP Server & Web Engine for AI Agents**
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
- [![PyPI Version](https://img.shields.io/badge/pypi-v1.0.5-blue.svg)](https://pypi.org/project/mcp-web-engine/)
7
- [![npm Version](https://img.shields.io/badge/npm-v1.0.5-red.svg)](https://www.npmjs.com/package/mcp-web-engine)
8
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
- uvx --from git+https://github.com/Arbolencio/mcp-web-engine mcp-web-engine
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
- ### Key Capabilities & Architecture
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": "uvx",
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
- ### 3. Cursor / Windsurf / Hermes Agent
79
- ```json
80
- {
81
- "mcpServers": {
82
- "mcp-web-engine": {
83
- "command": "uvx",
84
- "args": ["mcp-web-engine"]
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
- ## 🛠️ Provided MCP Tools
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
- | Tool Name | Parameters | Description |
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
- ## ⚙️ Configuration & Environment Variables
53
+ Use `tools/list` for authoritative schemas and bounds.
103
54
 
104
- | Variable | Required | Default | Description |
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
- ## 🛠️ Development & Building
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
- # Clone Repository
117
- git clone https://github.com/Arbolencio/mcp-web-engine.git
118
- cd mcp-web-engine
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
- # Run locally in stdio mode via uv
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
- # Run locally in HTTP server mode via uv
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
- ## 🛡️ Security & Environment Isolation
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
- - **SSRF Hardening:** Blocks `localhost`, loopbacks, private subnets (`10.0.0.0/8`, `192.168.0.0/16`, `172.16.0.0/12`), percent-encoding bypasses (`%31%32%37...`), and re-validates `Location` headers on HTTP redirects step-by-step.
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
- ## 📄 License
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 © 2026 MCP Web Engine Contributors.
99
+ MIT License. See [LICENSE](LICENSE).
package/bin/cli.js CHANGED
@@ -1,90 +1,22 @@
1
1
  #!/usr/bin/env node
2
-
3
- /**
4
- * MCP Web Engine CLI Entry Point (npm / npx fallback)
5
- * Spawns MCP Web Engine in an ISOLATED Python environment without polluting global system packages.
6
- *
7
- * Recommended execution for AI agents:
8
- * uvx mcp-web-engine
9
- */
10
-
11
- const { spawn, execSync } = require('child_process');
12
- const path = require('path');
13
- const fs = require('fs');
14
- const os = require('os');
15
-
16
- const reqTxt = path.join(__dirname, '..', 'requirements.txt');
17
-
18
- function forwardChild(child) {
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.5",
4
- "description": "Privacy-First, Self-Hostable & SSRF-Hardened MCP Server & Web Engine for AI Agents",
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
- "*.py",
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
@@ -1,9 +1 @@
1
- fastapi>=0.109.0
2
- uvicorn>=0.27.0
3
- pydantic>=2.5.0
4
- pydantic-settings>=2.1.0
5
- curl-cffi>=0.6.0
6
- beautifulsoup4>=4.12.0
7
- html2text>=2024.1.14
8
- sse-starlette>=2.0.0
9
- pytest>=8.0.0
1
+ -e .
@@ -0,0 +1,3 @@
1
+ """MCP Web Engine package."""
2
+
3
+ __version__ = "1.2.0"
@@ -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()