mcp-web-engine 1.0.4__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """MCP Web Engine package."""
2
+
3
+ __version__ = "1.0.4"
@@ -0,0 +1,24 @@
1
+ """
2
+ Centralized Configuration Module (Pydantic Settings / Environment Variables)
3
+ """
4
+ from pydantic import Field
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+ class Settings(BaseSettings):
8
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
9
+
10
+ API_KEY: str = Field(default="sk_mcp_dev_key_12345", description="Bearer API key for authentication")
11
+ SEARXNG_URL: str = Field(default="http://127.0.0.1:8082/search", description="Internal SearXNG URL")
12
+ PORT: int = Field(default=5050, description="Service listening port")
13
+ HOST: str = Field(default="0.0.0.0", description="Service listening host")
14
+
15
+ # Rate Limiting & Limits
16
+ RATE_LIMIT_PER_MINUTE: int = Field(default=120, description="Max requests per minute per API key")
17
+ DEFAULT_TIMEOUT_SEC: float = Field(default=10.0, description="Default HTTP request timeout in seconds")
18
+ MAX_PAYLOAD_BYTES: int = Field(default=2 * 1024 * 1024, description="Max payload size limit (2 MB)")
19
+
20
+ # Environment & Logging
21
+ ENV: str = Field(default="production", description="Environment (development/production)")
22
+ LOG_LEVEL: str = Field(default="INFO", description="Log level")
23
+
24
+ settings = Settings()
@@ -0,0 +1,111 @@
1
+ """
2
+ Structured JSON Logging & Observability Metrics Module with Per-Beta-Key Telemetry Tracking
3
+ """
4
+ import logging
5
+ import json
6
+ import time
7
+ import os
8
+ from typing import Optional
9
+ from datetime import datetime
10
+ from .config import settings
11
+
12
+ BETA_KEYS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "beta_keys.json")
13
+
14
+ class JSONFormatter(logging.Formatter):
15
+ def format(self, record):
16
+ log_obj = {
17
+ "timestamp": datetime.utcnow().isoformat(),
18
+ "level": record.levelname,
19
+ "module": record.module,
20
+ "message": record.getMessage()
21
+ }
22
+ if hasattr(record, "extra_data"):
23
+ log_obj["extra"] = self.sanitize_data(record.extra_data)
24
+ return json.dumps(log_obj)
25
+
26
+ def sanitize_data(self, data):
27
+ if isinstance(data, dict):
28
+ clean = {}
29
+ for k, v in data.items():
30
+ if "key" in k.lower() or "token" in k.lower() or "secret" in k.lower() or "auth" in k.lower():
31
+ clean[k] = "REDACTED"
32
+ else:
33
+ clean[k] = self.sanitize_data(v)
34
+ return clean
35
+ elif isinstance(data, list):
36
+ return [self.sanitize_data(i) for i in data]
37
+ return data
38
+
39
+ logger = logging.getLogger("mcp_web_engine")
40
+ logger.setLevel(getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO))
41
+ handler = logging.StreamHandler()
42
+ handler.setFormatter(JSONFormatter())
43
+ logger.addHandler(handler)
44
+
45
+ # In-memory Metrics Store
46
+ class MetricsTracker:
47
+ def __init__(self):
48
+ self.total_requests = 0
49
+ self.total_errors = 0
50
+ self.tool_calls = {"web_search": 0, "fetch_url": 0, "extract_markdown": 0}
51
+ self.total_units_consumed = 0
52
+ self.latencies_ms = []
53
+
54
+ def record(self, tool_name: str, latency_ms: float, success: bool, units: int = 1, api_key: Optional[str] = None):
55
+ self.total_requests += 1
56
+ if tool_name in self.tool_calls:
57
+ self.tool_calls[tool_name] += 1
58
+ if not success:
59
+ self.total_errors += 1
60
+ self.total_units_consumed += units
61
+ self.latencies_ms.append(latency_ms)
62
+ if len(self.latencies_ms) > 1000:
63
+ self.latencies_ms = self.latencies_ms[-1000:]
64
+
65
+ if api_key and api_key.startswith("sk_mcp_beta_"):
66
+ self.update_beta_telemetry(api_key, tool_name, latency_ms, success)
67
+
68
+ def update_beta_telemetry(self, api_key: str, tool_name: str, latency_ms: float, success: bool):
69
+ if not os.path.exists(BETA_KEYS_FILE):
70
+ return
71
+ try:
72
+ with open(BETA_KEYS_FILE, "r", encoding="utf-8") as f:
73
+ keys = json.load(f)
74
+
75
+ if api_key in keys:
76
+ t = keys[api_key].setdefault("telemetry", {
77
+ "requests": 0,
78
+ "web_search": 0,
79
+ "fetch_url": 0,
80
+ "extract_markdown": 0,
81
+ "errors": 0,
82
+ "avg_latency_ms": 0.0,
83
+ "last_seen": None
84
+ })
85
+ t["requests"] += 1
86
+ if tool_name in t:
87
+ t[tool_name] += 1
88
+ if not success:
89
+ t["errors"] += 1
90
+
91
+ old_avg = t.get("avg_latency_ms", 0.0)
92
+ n = t["requests"]
93
+ t["avg_latency_ms"] = round(((old_avg * (n - 1)) + latency_ms) / n, 2)
94
+ t["last_seen"] = datetime.utcnow().isoformat() + "Z"
95
+
96
+ with open(BETA_KEYS_FILE, "w", encoding="utf-8") as f:
97
+ json.dump(keys, f, indent=2)
98
+ except Exception as e:
99
+ logger.error(f"Failed to update beta telemetry: {str(e)}")
100
+
101
+ def get_summary(self):
102
+ avg_lat = round(sum(self.latencies_ms) / len(self.latencies_ms), 2) if self.latencies_ms else 0.0
103
+ return {
104
+ "total_requests": self.total_requests,
105
+ "total_errors": self.total_errors,
106
+ "tool_calls": self.tool_calls,
107
+ "total_units_consumed": self.total_units_consumed,
108
+ "avg_latency_ms": avg_lat
109
+ }
110
+
111
+ metrics = MetricsTracker()
mcp_web_engine/main.py ADDED
@@ -0,0 +1,237 @@
1
+ """
2
+ Main FastAPI Application Gateway & MCP Protocol Handler (SSE & STDIO Modes)
3
+ """
4
+ import time
5
+ import json
6
+ import os
7
+ import sys
8
+ import asyncio
9
+ from fastapi import FastAPI, Depends, HTTPException, status, Response, Request, Header
10
+ from typing import Optional
11
+ from sse_starlette.sse import EventSourceResponse
12
+ from .config import settings
13
+ from .security import verify_api_key, check_rate_limit
14
+ from .logging_obs import logger, metrics
15
+ from .mcp_tools import MCP_TOOL_DEFINITIONS, handle_mcp_tool_call, WebSearchInput, FetchUrlInput, ExtractMarkdownInput
16
+ from .mcp_protocol import (
17
+ process_mcp_2026_stateless,
18
+ process_mcp_2025_legacy_stateful,
19
+ validate_2026_mcp_headers,
20
+ MCP_PROTOCOL_VERSION_2026,
21
+ MCP_PROTOCOL_VERSION_LEGACY
22
+ )
23
+ from .web_engine import execute_web_search, execute_fetch_url, execute_extract_markdown
24
+
25
+ BETA_KEYS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "beta_keys.json")
26
+
27
+ app = FastAPI(
28
+ title="MCP Web Engine & Search Gateway",
29
+ description="High-performance, SSRF-hardened MCP Server (Spec 2026-07-28 Pure Stateless Core & Legacy 2025-11-25)",
30
+ version="1.0.4"
31
+ )
32
+
33
+ @app.get("/health")
34
+ async def health_check():
35
+ return {
36
+ "status": "ok",
37
+ "environment": settings.ENV,
38
+ "searxng_url": settings.SEARXNG_URL,
39
+ "mcp_version": MCP_PROTOCOL_VERSION_2026
40
+ }
41
+
42
+ @app.get("/v1/metrics")
43
+ async def get_metrics(api_key: str = Depends(verify_api_key)):
44
+ return metrics.get_summary()
45
+
46
+ @app.get("/v1/beta/telemetry")
47
+ async def get_beta_telemetry(api_key: str = Depends(verify_api_key)):
48
+ if not os.path.exists(BETA_KEYS_FILE):
49
+ return {"users_count": 0, "telemetry": {}}
50
+ try:
51
+ with open(BETA_KEYS_FILE, "r", encoding="utf-8") as f:
52
+ keys = json.load(f)
53
+
54
+ summary = {}
55
+ for k, v in keys.items():
56
+ beta_id = v.get("id", "Unknown")
57
+ summary[beta_id] = {
58
+ "id": beta_id,
59
+ "status": v.get("status"),
60
+ "limit": v.get("limit"),
61
+ "telemetry": v.get("telemetry", {})
62
+ }
63
+ return {"users_count": len(summary), "telemetry": summary}
64
+ except Exception as e:
65
+ raise HTTPException(status_code=500, detail=str(e))
66
+
67
+ # OFFICIAL MCP 2026-07-28 STATELESS CORE ENDPOINT (POST /v1/mcp)
68
+ @app.post("/v1/mcp")
69
+ async def mcp_2026_stateless_endpoint(
70
+ request: Request,
71
+ api_key: str = Depends(verify_api_key),
72
+ mcp_protocol_version: Optional[str] = Header(None, alias="MCP-Protocol-Version"),
73
+ mcp_method: Optional[str] = Header(None, alias="Mcp-Method"),
74
+ mcp_name: Optional[str] = Header(None, alias="Mcp-Name")
75
+ ):
76
+ check_rate_limit(api_key)
77
+ try:
78
+ payload = await request.json()
79
+ except Exception:
80
+ raise HTTPException(
81
+ status_code=status.HTTP_400_BAD_REQUEST,
82
+ detail={"error": "INVALID_JSON", "message": "Request body must be valid JSON-RPC 2.0."}
83
+ )
84
+
85
+ validate_2026_mcp_headers(mcp_protocol_version, mcp_method, mcp_name, payload)
86
+
87
+ res_body, http_status = await process_mcp_2026_stateless(payload, api_key=api_key)
88
+
89
+ headers = {
90
+ "MCP-Protocol-Version": MCP_PROTOCOL_VERSION_2026,
91
+ "Mcp-Method": mcp_method,
92
+ "Content-Type": "application/json"
93
+ }
94
+ if mcp_name:
95
+ headers["Mcp-Name"] = mcp_name
96
+
97
+ return Response(
98
+ content=json.dumps(res_body, ensure_ascii=False) if res_body else "",
99
+ status_code=http_status,
100
+ headers=headers
101
+ )
102
+
103
+ # UNCHANGED LEGACY 2025-11-25 STATEFUL ENDPOINT (POST /v1/mcp/legacy)
104
+ @app.post("/v1/mcp/legacy")
105
+ async def mcp_2025_legacy_endpoint(
106
+ request: Request,
107
+ api_key: str = Depends(verify_api_key),
108
+ mcp_session_id: Optional[str] = Header(None, alias="Mcp-Session-Id")
109
+ ):
110
+ check_rate_limit(api_key)
111
+ try:
112
+ payload = await request.json()
113
+ except Exception:
114
+ raise HTTPException(
115
+ status_code=status.HTTP_400_BAD_REQUEST,
116
+ detail={"error": "INVALID_JSON", "message": "Request body must be valid JSON-RPC 2.0."}
117
+ )
118
+
119
+ res_body, http_status, out_session_id = await process_mcp_2025_legacy_stateful(payload, mcp_session_id)
120
+
121
+ headers = {
122
+ "MCP-Protocol-Version": MCP_PROTOCOL_VERSION_LEGACY,
123
+ "Mcp-Session-Id": out_session_id,
124
+ "Content-Type": "application/json"
125
+ }
126
+
127
+ return Response(
128
+ content=json.dumps(res_body, ensure_ascii=False) if res_body else "",
129
+ status_code=http_status,
130
+ headers=headers
131
+ )
132
+
133
+ # SSE Event Endpoint
134
+ @app.get("/v1/mcp")
135
+ async def mcp_sse_endpoint(
136
+ request: Request,
137
+ api_key: str = Depends(verify_api_key)
138
+ ):
139
+ check_rate_limit(api_key)
140
+
141
+ async def event_generator():
142
+ yield {
143
+ "event": "endpoint",
144
+ "data": "/v1/mcp"
145
+ }
146
+
147
+ return EventSourceResponse(event_generator())
148
+
149
+ # Legacy REST Tool Listing Endpoint (Compatibility)
150
+ @app.post("/v1/mcp/tools")
151
+ async def list_mcp_tools(api_key: str = Depends(verify_api_key)):
152
+ check_rate_limit(api_key)
153
+ return {"tools": MCP_TOOL_DEFINITIONS}
154
+
155
+ # Legacy REST Tool Invocation Endpoint (Compatibility)
156
+ @app.post("/v1/mcp/invoke")
157
+ async def invoke_mcp_tool(payload: dict, api_key: str = Depends(verify_api_key)):
158
+ check_rate_limit(api_key)
159
+ tool_name = payload.get("tool")
160
+ arguments = payload.get("arguments", {})
161
+
162
+ if not tool_name:
163
+ raise HTTPException(
164
+ status_code=status.HTTP_400_BAD_REQUEST,
165
+ detail={"error": "MISSING_TOOL", "message": "Payload must include 'tool' parameter."}
166
+ )
167
+
168
+ start_t = time.time()
169
+ try:
170
+ res = await handle_mcp_tool_call(tool_name, arguments)
171
+ lat = round((time.time() - start_t) * 1000, 2)
172
+ metrics.record(tool_name, lat, success=True, api_key=api_key)
173
+ return {"tool": tool_name, "status": "success", "result": res}
174
+ except HTTPException as e:
175
+ lat = round((time.time() - start_t) * 1000, 2)
176
+ metrics.record(tool_name, lat, success=False, api_key=api_key)
177
+ raise e
178
+ except Exception as e:
179
+ lat = round((time.time() - start_t) * 1000, 2)
180
+ metrics.record(tool_name, lat, success=False, api_key=api_key)
181
+ raise HTTPException(
182
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
183
+ detail={"error": "TOOL_EXECUTION_ERROR", "message": str(e)}
184
+ )
185
+
186
+ # Direct REST Endpoints
187
+ @app.post("/v1/search")
188
+ async def search_endpoint(body: WebSearchInput, api_key: str = Depends(verify_api_key)):
189
+ check_rate_limit(api_key)
190
+ return await execute_web_search(body.query, body.limit)
191
+
192
+ @app.post("/v1/extract")
193
+ async def extract_endpoint(body: ExtractMarkdownInput, api_key: str = Depends(verify_api_key)):
194
+ check_rate_limit(api_key)
195
+ return await execute_extract_markdown(body.url, body.max_bytes)
196
+
197
+ async def run_mcp_stdio_server():
198
+ """
199
+ Robust Stdio MCP protocol handler using asyncio.to_thread for cross-platform compatibility
200
+ (Works on Linux RPi5, x86_64, Windows, macOS without epoll PermissionError)
201
+ """
202
+ while True:
203
+ line_str = await asyncio.to_thread(sys.stdin.readline)
204
+ if not line_str:
205
+ break
206
+ line_str = line_str.strip()
207
+ if not line_str:
208
+ continue
209
+ try:
210
+ payload = json.loads(line_str)
211
+ res_body, _ = await process_mcp_2026_stateless(payload, api_key="local-stdio")
212
+ if res_body:
213
+ sys.stdout.write(json.dumps(res_body, ensure_ascii=False) + "\n")
214
+ sys.stdout.flush()
215
+ except Exception as e:
216
+ err_res = {
217
+ "jsonrpc": "2.0",
218
+ "id": None,
219
+ "error": {"code": -32603, "message": str(e)}
220
+ }
221
+ sys.stdout.write(json.dumps(err_res) + "\n")
222
+ sys.stdout.flush()
223
+
224
+ def cli_entrypoint():
225
+ """
226
+ Console script entrypoint for PyPI / uvx (mcp-web-engine).
227
+ Runs Stdio MCP mode by default; runs FastAPI HTTP server if --serve is passed.
228
+ """
229
+ is_serve = "--serve" in sys.argv or "--sse" in sys.argv
230
+ if is_serve:
231
+ import uvicorn
232
+ uvicorn.run("mcp_web_engine.main:app", host=settings.HOST, port=settings.PORT, reload=False)
233
+ else:
234
+ asyncio.run(run_mcp_stdio_server())
235
+
236
+ if __name__ == "__main__":
237
+ cli_entrypoint()
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Beta Keys & Telemetry Management CLI for MCP Web Engine
4
+ Genera 20 keys independientes (Beta_001 .. Beta_020), lista y gestiona telemetría por usuario en beta_keys.json.
5
+ """
6
+ import sys
7
+ import os
8
+ import json
9
+ import secrets
10
+ import time
11
+ import argparse
12
+
13
+ KEYS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "beta_keys.json")
14
+
15
+ def load_keys():
16
+ if not os.path.exists(KEYS_FILE):
17
+ return {}
18
+ try:
19
+ with open(KEYS_FILE, "r", encoding="utf-8") as f:
20
+ return json.load(f)
21
+ except Exception:
22
+ return {}
23
+
24
+ def save_keys(keys):
25
+ with open(KEYS_FILE, "w", encoding="utf-8") as f:
26
+ json.dump(keys, f, indent=2)
27
+
28
+ def generate_20_beta_keys():
29
+ keys = {}
30
+ generated_summary = []
31
+
32
+ for i in range(1, 21):
33
+ beta_id = f"Beta_{i:03d}"
34
+ raw_token = secrets.token_hex(16)
35
+ key_str = f"sk_mcp_beta_{raw_token}"
36
+
37
+ keys[key_str] = {
38
+ "id": beta_id,
39
+ "key": key_str,
40
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
41
+ "limit": 10000,
42
+ "rate_limit": 120,
43
+ "status": "active",
44
+ "telemetry": {
45
+ "requests": 0,
46
+ "web_search": 0,
47
+ "fetch_url": 0,
48
+ "extract_markdown": 0,
49
+ "errors": 0,
50
+ "avg_latency_ms": 0.0,
51
+ "last_seen": None
52
+ }
53
+ }
54
+ generated_summary.append((beta_id, key_str))
55
+
56
+ save_keys(keys)
57
+ print(f"✅ Generadas 20 Beta Keys independientes (Beta_001 .. Beta_020) en: {KEYS_FILE}")
58
+ for beta_id, key_str in generated_summary:
59
+ print(f" - {beta_id:10s}: {key_str}")
60
+ return keys
61
+
62
+ def list_beta_telemetry():
63
+ keys = load_keys()
64
+ if not keys:
65
+ print("ℹ️ No hay Beta Keys registradas.")
66
+ return
67
+
68
+ print("📊 DASHBOARD DE TELEMETRÍA POR USUARIO BETA:")
69
+ print("=" * 85)
70
+ print(f"{'ID Beta':10s} | {'Estado':8s} | {'Reqs':6s} | {'Search':6s} | {'Fetch':6s} | {'Extract':8s} | {'Errors':6s} | {'Last Seen'}")
71
+ print("=" * 85)
72
+
73
+ total_reqs = 0
74
+ total_errs = 0
75
+
76
+ for k, info in keys.items():
77
+ t = info.get("telemetry", {})
78
+ reqs = t.get("requests", 0)
79
+ errs = t.get("errors", 0)
80
+ total_reqs += reqs
81
+ total_errs += errs
82
+
83
+ last_seen = t.get("last_seen") or "Nunca"
84
+ print(f"{info['id']:10s} | {info['status']:8s} | {reqs:6d} | {t.get('web_search', 0):6d} | {t.get('fetch_url', 0):6d} | {t.get('extract_markdown', 0):8d} | {errs:6d} | {last_seen}")
85
+
86
+ print("=" * 85)
87
+ print(f"📈 TOTALES: {len(keys)} Usuarios Beta | {total_reqs} Peticiones Totales | {total_errs} Errores Totales")
88
+
89
+ def revoke_key_by_id(beta_id):
90
+ keys = load_keys()
91
+ found = False
92
+ for k, info in keys.items():
93
+ if info.get("id") == beta_id or k == beta_id:
94
+ info["status"] = "revoked"
95
+ found = True
96
+ print(f"🔴 Beta Key '{beta_id}' revocada con éxito.")
97
+ break
98
+ if found:
99
+ save_keys(keys)
100
+ else:
101
+ print(f"❌ Beta Key '{beta_id}' no encontrada.")
102
+
103
+ def main():
104
+ parser = argparse.ArgumentParser(description="Beta Keys & Telemetry Manager")
105
+ subparsers = parser.add_subparsers(dest="command")
106
+
107
+ subparsers.add_parser("init20", help="Genera las 20 keys de Beta_001 a Beta_020")
108
+ subparsers.add_parser("telemetry", help="Muestra la telemetría por usuario beta")
109
+
110
+ rev_parser = subparsers.add_parser("revoke", help="Revoca una beta key por ID o token")
111
+ rev_parser.add_argument("--id", type=str, required=True, help="ID Beta (ej: Beta_001)")
112
+
113
+ args = parser.parse_args()
114
+
115
+ if args.command == "init20":
116
+ generate_20_beta_keys()
117
+ elif args.command == "telemetry":
118
+ list_beta_telemetry()
119
+ elif args.command == "revoke":
120
+ revoke_key_by_id(args.id)
121
+ else:
122
+ parser.print_help()
123
+
124
+ if __name__ == "__main__":
125
+ main()
@@ -0,0 +1,210 @@
1
+ """
2
+ MCP Protocol Handler (Spec 2026-07-28 Pure Stateless Core with Header Validation & Per-User Telemetry)
3
+ """
4
+ import json
5
+ import time
6
+ import secrets
7
+ from typing import Optional
8
+ from fastapi import status, HTTPException
9
+ from .mcp_tools import MCP_TOOL_DEFINITIONS, handle_mcp_tool_call
10
+ from .logging_obs import logger, metrics
11
+
12
+ MCP_PROTOCOL_VERSION_2026 = "2026-07-28"
13
+ MCP_PROTOCOL_VERSION_LEGACY = "2025-11-25"
14
+
15
+ REGISTERED_TOOL_NAMES = {t["name"] for t in MCP_TOOL_DEFINITIONS}
16
+ VALID_2026_METHODS = {"server/discover", "tools/list", "tools/call"}
17
+
18
+ legacy_sessions = {}
19
+
20
+ # JSON-RPC 2.0 Error Codes
21
+ PARSE_ERROR = -32700
22
+ INVALID_REQUEST = -32600
23
+ METHOD_NOT_FOUND = -32601
24
+ INVALID_PARAMS = -32602
25
+ INTERNAL_ERROR = -32603
26
+
27
+ def make_jsonrpc_response(request_id, result):
28
+ return {
29
+ "jsonrpc": "2.0",
30
+ "id": request_id,
31
+ "result": result
32
+ }
33
+
34
+ def make_jsonrpc_error(request_id, code, message, data=None):
35
+ err_obj = {"code": code, "message": message}
36
+ if data:
37
+ err_obj["data"] = data
38
+ return {
39
+ "jsonrpc": "2.0",
40
+ "id": request_id,
41
+ "error": err_obj
42
+ }
43
+
44
+ def validate_2026_mcp_headers(
45
+ header_mcp_version: Optional[str],
46
+ header_mcp_method: Optional[str],
47
+ header_mcp_name: Optional[str],
48
+ payload: dict
49
+ ):
50
+ if header_mcp_version and header_mcp_version != MCP_PROTOCOL_VERSION_2026:
51
+ raise HTTPException(
52
+ status_code=status.HTTP_400_BAD_REQUEST,
53
+ detail={"error": "INVALID_PROTOCOL_VERSION", "message": f"Expected 'MCP-Protocol-Version: {MCP_PROTOCOL_VERSION_2026}'."}
54
+ )
55
+
56
+ if not header_mcp_method:
57
+ raise HTTPException(
58
+ status_code=status.HTTP_400_BAD_REQUEST,
59
+ detail={"error": "MISSING_MCP_METHOD", "message": "Header 'Mcp-Method' is required."}
60
+ )
61
+
62
+ clean_method = header_mcp_method.strip()
63
+ if clean_method not in VALID_2026_METHODS:
64
+ raise HTTPException(
65
+ status_code=status.HTTP_400_BAD_REQUEST,
66
+ detail={"error": "INVALID_MCP_METHOD", "message": f"Method '{clean_method}' is not a valid MCP 2026-07-28 method."}
67
+ )
68
+
69
+ body_method = payload.get("method")
70
+ if clean_method != body_method:
71
+ raise HTTPException(
72
+ status_code=status.HTTP_400_BAD_REQUEST,
73
+ detail={"error": "MISMATCHED_MCP_METHOD", "message": f"Header Mcp-Method '{clean_method}' does not match body method '{body_method}'."}
74
+ )
75
+
76
+ if clean_method == "tools/call":
77
+ if not header_mcp_name:
78
+ raise HTTPException(
79
+ status_code=status.HTTP_400_BAD_REQUEST,
80
+ detail={"error": "MISSING_MCP_NAME", "message": "Header 'Mcp-Name' is required for method 'tools/call'."}
81
+ )
82
+
83
+ clean_name = header_mcp_name.strip()
84
+ if clean_name not in REGISTERED_TOOL_NAMES:
85
+ raise HTTPException(
86
+ status_code=status.HTTP_400_BAD_REQUEST,
87
+ detail={"error": "INVALID_MCP_NAME", "message": f"Tool '{clean_name}' is not registered."}
88
+ )
89
+
90
+ body_tool_name = payload.get("params", {}).get("name")
91
+ if clean_name != body_tool_name:
92
+ raise HTTPException(
93
+ status_code=status.HTTP_400_BAD_REQUEST,
94
+ detail={"error": "MISMATCHED_MCP_NAME", "message": f"Header Mcp-Name '{clean_name}' does not match body params.name '{body_tool_name}'."}
95
+ )
96
+
97
+ async def process_mcp_2026_stateless(payload: dict, api_key: Optional[str] = None) -> tuple[dict, int]:
98
+ if not isinstance(payload, dict) or payload.get("jsonrpc") != "2.0":
99
+ return make_jsonrpc_error(payload.get("id") if isinstance(payload, dict) else None, INVALID_REQUEST, "Invalid JSON-RPC 2.0 request."), status.HTTP_400_BAD_REQUEST
100
+
101
+ request_id = payload.get("id")
102
+ method = payload.get("method")
103
+ params = payload.get("params", {})
104
+
105
+ if method == "server/discover":
106
+ res_data = {
107
+ "protocolVersion": MCP_PROTOCOL_VERSION_2026,
108
+ "server": {
109
+ "name": "mcp-web-engine",
110
+ "version": "1.0.4"
111
+ },
112
+ "capabilities": {
113
+ "tools": True
114
+ }
115
+ }
116
+ return make_jsonrpc_response(request_id, res_data), status.HTTP_200_OK
117
+
118
+ elif method == "tools/list":
119
+ res_data = {
120
+ "tools": MCP_TOOL_DEFINITIONS,
121
+ "cacheScope": "global",
122
+ "ttlMs": 3600000,
123
+ "listChanged": False
124
+ }
125
+ return make_jsonrpc_response(request_id, res_data), status.HTTP_200_OK
126
+
127
+ elif method == "tools/call":
128
+ tool_name = params.get("name")
129
+ arguments = params.get("arguments", {})
130
+
131
+ if not tool_name:
132
+ return make_jsonrpc_error(request_id, INVALID_PARAMS, "Missing tool 'name' in params."), status.HTTP_400_BAD_REQUEST
133
+
134
+ start_t = time.time()
135
+ try:
136
+ raw_result = await handle_mcp_tool_call(tool_name, arguments)
137
+ lat = round((time.time() - start_t) * 1000, 2)
138
+ metrics.record(tool_name, lat, success=True, api_key=api_key)
139
+
140
+ content_text = json.dumps(raw_result, indent=2, ensure_ascii=False)
141
+ mcp_result = {
142
+ "content": [
143
+ {
144
+ "type": "text",
145
+ "text": content_text
146
+ }
147
+ ],
148
+ "isError": False
149
+ }
150
+ return make_jsonrpc_response(request_id, mcp_result), status.HTTP_200_OK
151
+
152
+ except Exception as e:
153
+ lat = round((time.time() - start_t) * 1000, 2)
154
+ metrics.record(tool_name, lat, success=False, api_key=api_key)
155
+ logger.error(f"Error executing MCP tool '{tool_name}': {str(e)}")
156
+
157
+ error_content = {
158
+ "content": [
159
+ {
160
+ "type": "text",
161
+ "text": f"Error executing tool '{tool_name}': {str(e)}"
162
+ }
163
+ ],
164
+ "isError": True
165
+ }
166
+ return make_jsonrpc_response(request_id, error_content), status.HTTP_200_OK
167
+
168
+ else:
169
+ return make_jsonrpc_error(request_id, METHOD_NOT_FOUND, f"Method '{method}' not supported in MCP 2026-07-28 stateless core."), status.HTTP_404_NOT_FOUND
170
+
171
+ async def process_mcp_2025_legacy_stateful(payload: dict, session_id: Optional[str] = None) -> tuple[dict, int, str]:
172
+ if session_id and session_id in legacy_sessions:
173
+ current_sess_id = session_id
174
+ else:
175
+ current_sess_id = f"mcp_sess_legacy_{secrets.token_hex(8)}"
176
+ legacy_sessions[current_sess_id] = {"created": time.time()}
177
+
178
+ if not isinstance(payload, dict) or payload.get("jsonrpc") != "2.0":
179
+ return make_jsonrpc_error(payload.get("id") if isinstance(payload, dict) else None, INVALID_REQUEST, "Invalid JSON-RPC 2.0 request."), status.HTTP_400_BAD_REQUEST, current_sess_id
180
+
181
+ request_id = payload.get("id")
182
+ method = payload.get("method")
183
+ params = payload.get("params", {})
184
+
185
+ if method == "initialize":
186
+ res_data = {
187
+ "protocolVersion": MCP_PROTOCOL_VERSION_LEGACY,
188
+ "capabilities": {"tools": {"listChanged": False}},
189
+ "serverInfo": {"name": "mcp-web-engine-legacy", "version": "1.0.0"}
190
+ }
191
+ return make_jsonrpc_response(request_id, res_data), status.HTTP_200_OK, current_sess_id
192
+
193
+ elif method == "notifications/initialized":
194
+ return {}, status.HTTP_202_ACCEPTED, current_sess_id
195
+
196
+ elif method == "tools/list":
197
+ return make_jsonrpc_response(request_id, {"tools": MCP_TOOL_DEFINITIONS}), status.HTTP_200_OK, current_sess_id
198
+
199
+ elif method == "tools/call":
200
+ tool_name = params.get("name")
201
+ arguments = params.get("arguments", {})
202
+ try:
203
+ raw_result = await handle_mcp_tool_call(tool_name, arguments)
204
+ mcp_result = {"content": [{"type": "text", "text": json.dumps(raw_result)}], "isError": False}
205
+ return make_jsonrpc_response(request_id, mcp_result), status.HTTP_200_OK, current_sess_id
206
+ except Exception as e:
207
+ return make_jsonrpc_response(request_id, {"content": [{"type": "text", "text": str(e)}], "isError": True}), status.HTTP_200_OK, current_sess_id
208
+
209
+ else:
210
+ return make_jsonrpc_error(request_id, METHOD_NOT_FOUND, f"Method '{method}' not found."), status.HTTP_404_NOT_FOUND, current_sess_id
@@ -0,0 +1,50 @@
1
+ """
2
+ MCP Tools Definition & Schema Handlers (web_search, fetch_url, extract_markdown)
3
+ """
4
+ from typing import Optional
5
+ from pydantic import BaseModel, Field
6
+ from .web_engine import execute_web_search, execute_fetch_url, execute_extract_markdown
7
+
8
+ class WebSearchInput(BaseModel):
9
+ query: str = Field(..., description="The search query keywords", min_length=2)
10
+ limit: Optional[int] = Field(default=10, description="Max results to return (1-25)", ge=1, le=25)
11
+
12
+ class FetchUrlInput(BaseModel):
13
+ url: str = Field(..., description="Target URL to fetch content from")
14
+ max_bytes: Optional[int] = Field(default=None, description="Max bytes payload limit")
15
+
16
+ class ExtractMarkdownInput(BaseModel):
17
+ url: str = Field(..., description="Target URL to convert to clean Markdown")
18
+ max_bytes: Optional[int] = Field(default=None, description="Max bytes payload limit")
19
+
20
+ # MCP Protocol Tool Definitions
21
+ MCP_TOOL_DEFINITIONS = [
22
+ {
23
+ "name": "web_search",
24
+ "description": "Performs aggregate multi-engine web search returning structured search results.",
25
+ "inputSchema": WebSearchInput.model_json_schema()
26
+ },
27
+ {
28
+ "name": "fetch_url",
29
+ "description": "Fetches raw text content of a web page after SSRF security checks.",
30
+ "inputSchema": FetchUrlInput.model_json_schema()
31
+ },
32
+ {
33
+ "name": "extract_markdown",
34
+ "description": "Scrapes a web page and converts HTML into clean, structured Markdown for LLMs.",
35
+ "inputSchema": ExtractMarkdownInput.model_json_schema()
36
+ }
37
+ ]
38
+
39
+ async def handle_mcp_tool_call(tool_name: str, arguments: dict):
40
+ if tool_name == "web_search":
41
+ parsed = WebSearchInput(**arguments)
42
+ return await execute_web_search(parsed.query, parsed.limit)
43
+ elif tool_name == "fetch_url":
44
+ parsed = FetchUrlInput(**arguments)
45
+ return await execute_fetch_url(parsed.url, parsed.max_bytes)
46
+ elif tool_name == "extract_markdown":
47
+ parsed = ExtractMarkdownInput(**arguments)
48
+ return await execute_extract_markdown(parsed.url, parsed.max_bytes)
49
+ else:
50
+ raise ValueError(f"Unknown tool name '{tool_name}'")
@@ -0,0 +1,169 @@
1
+ """
2
+ Hardened Security Module: SSRF Protection, IP/DNS Validation, Multi-Key Auth (Master & Beta Keys) & Rate Limiting
3
+ """
4
+ import ipaddress
5
+ import socket
6
+ import time
7
+ import os
8
+ import json
9
+ import urllib.parse
10
+ from fastapi import HTTPException, Security, status
11
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
12
+ from .config import settings
13
+
14
+ security_bearer = HTTPBearer(auto_error=False)
15
+
16
+ rate_limit_records = {}
17
+ BETA_KEYS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "beta_keys.json")
18
+
19
+ def is_beta_key_valid(token: str) -> bool:
20
+ """
21
+ Checks if a token is an active Beta Key in beta_keys.json.
22
+ """
23
+ if not os.path.exists(BETA_KEYS_FILE):
24
+ return False
25
+ try:
26
+ with open(BETA_KEYS_FILE, "r", encoding="utf-8") as f:
27
+ keys = json.load(f)
28
+ if token in keys and keys[token].get("status") == "active":
29
+ return True
30
+ except Exception:
31
+ pass
32
+ return False
33
+
34
+ # Reserved Subnets for SSRF Protection
35
+ PRIVATE_NETWORKS = [
36
+ ipaddress.ip_network("0.0.0.0/8"),
37
+ ipaddress.ip_network("10.0.0.0/8"),
38
+ ipaddress.ip_network("100.64.0.0/10"),
39
+ ipaddress.ip_network("127.0.0.0/8"),
40
+ ipaddress.ip_network("169.254.0.0/16"),
41
+ ipaddress.ip_network("172.16.0.0/12"),
42
+ ipaddress.ip_network("192.0.0.0/24"),
43
+ ipaddress.ip_network("192.0.2.0/24"),
44
+ ipaddress.ip_network("192.88.99.0/24"),
45
+ ipaddress.ip_network("192.168.0.0/16"),
46
+ ipaddress.ip_network("198.18.0.0/15"),
47
+ ipaddress.ip_network("198.51.100.0/24"),
48
+ ipaddress.ip_network("203.0.113.0/24"),
49
+ ipaddress.ip_network("224.0.0.0/4"),
50
+ ipaddress.ip_network("240.0.0.0/4"),
51
+ ipaddress.ip_network("255.255.255.255/32"),
52
+ # IPv6
53
+ ipaddress.ip_network("::/128"),
54
+ ipaddress.ip_network("::1/128"),
55
+ ipaddress.ip_network("fc00::/7"),
56
+ ipaddress.ip_network("fe80::/10")
57
+ ]
58
+
59
+ FORBIDDEN_HOSTNAMES = {"localhost", "loopback", "broadcasthost", "local", "0.0.0.0", "127.0.0.1", "::1"}
60
+
61
+ def is_ip_private(ip_str: str) -> bool:
62
+ try:
63
+ if ip_str.isdigit():
64
+ ip_obj = ipaddress.ip_address(int(ip_str))
65
+ else:
66
+ ip_obj = ipaddress.ip_address(ip_str)
67
+
68
+ for net in PRIVATE_NETWORKS:
69
+ if ip_obj in net:
70
+ return True
71
+ return False
72
+ except ValueError:
73
+ return False
74
+
75
+ def validate_ssrf_url(url_str: str) -> str:
76
+ if not url_str or not isinstance(url_str, str):
77
+ raise HTTPException(
78
+ status_code=status.HTTP_400_BAD_REQUEST,
79
+ detail={"error": "INVALID_URL", "message": "URL must be a non-empty string."}
80
+ )
81
+
82
+ unquoted_url = urllib.parse.unquote(url_str)
83
+
84
+ try:
85
+ parsed = urllib.parse.urlparse(unquoted_url)
86
+ except Exception:
87
+ raise HTTPException(
88
+ status_code=status.HTTP_400_BAD_REQUEST,
89
+ detail={"error": "INVALID_URL", "message": f"URL '{url_str}' is unparseable."}
90
+ )
91
+
92
+ if parsed.scheme not in ["http", "https"]:
93
+ raise HTTPException(
94
+ status_code=status.HTTP_400_BAD_REQUEST,
95
+ detail={"error": "INVALID_SCHEME", "message": f"Scheme '{parsed.scheme}' not allowed. Only http/https supported."}
96
+ )
97
+
98
+ hostname = parsed.hostname
99
+ if not hostname:
100
+ raise HTTPException(
101
+ status_code=status.HTTP_400_BAD_REQUEST,
102
+ detail={"error": "MISSING_HOSTNAME", "message": "URL must contain a valid hostname."}
103
+ )
104
+
105
+ clean_host = hostname.lower().strip(".")
106
+
107
+ if clean_host in FORBIDDEN_HOSTNAMES or clean_host.endswith(".local"):
108
+ raise HTTPException(
109
+ status_code=status.HTTP_403_FORBIDDEN,
110
+ detail={"error": "SSRF_BLOCKED", "message": f"Access to hostname '{clean_host}' is blocked (SSRF protection)."}
111
+ )
112
+
113
+ if is_ip_private(clean_host):
114
+ raise HTTPException(
115
+ status_code=status.HTTP_403_FORBIDDEN,
116
+ detail={"error": "SSRF_BLOCKED", "message": f"Access to private IP '{clean_host}' is blocked."}
117
+ )
118
+
119
+ try:
120
+ ip_addresses = socket.getaddrinfo(clean_host, None)
121
+ except socket.gaierror:
122
+ raise HTTPException(
123
+ status_code=status.HTTP_400_BAD_REQUEST,
124
+ detail={"error": "DNS_RESOLUTION_FAILED", "message": f"Could not resolve hostname '{clean_host}'."}
125
+ )
126
+
127
+ for item in ip_addresses:
128
+ resolved_ip = item[4][0]
129
+ if is_ip_private(resolved_ip):
130
+ raise HTTPException(
131
+ status_code=status.HTTP_403_FORBIDDEN,
132
+ detail={"error": "SSRF_BLOCKED", "message": f"URL resolves to private/loopback IP address '{resolved_ip}' which is blocked."}
133
+ )
134
+
135
+ return url_str
136
+
137
+ def verify_api_key(auth: HTTPAuthorizationCredentials = Security(security_bearer)):
138
+ if not auth or not auth.credentials:
139
+ raise HTTPException(
140
+ status_code=status.HTTP_401_UNAUTHORIZED,
141
+ detail={"error": "UNAUTHORIZED", "message": "Invalid or missing API key."}
142
+ )
143
+
144
+ token = auth.credentials
145
+ # Valid if matches Master API_KEY or any active Beta Key
146
+ if token == settings.API_KEY or is_beta_key_valid(token):
147
+ return token
148
+
149
+ raise HTTPException(
150
+ status_code=status.HTTP_401_UNAUTHORIZED,
151
+ detail={"error": "UNAUTHORIZED", "message": "Invalid or revoked API key."}
152
+ )
153
+
154
+ def check_rate_limit(api_key: str):
155
+ now = time.time()
156
+ window_start = now - 60.0
157
+
158
+ if api_key not in rate_limit_records:
159
+ rate_limit_records[api_key] = []
160
+
161
+ rate_limit_records[api_key] = [t for t in rate_limit_records[api_key] if t > window_start]
162
+
163
+ if len(rate_limit_records[api_key]) >= settings.RATE_LIMIT_PER_MINUTE:
164
+ raise HTTPException(
165
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
166
+ detail={"error": "RATE_LIMIT_EXCEEDED", "message": f"Rate limit of {settings.RATE_LIMIT_PER_MINUTE} req/min exceeded."}
167
+ )
168
+
169
+ rate_limit_records[api_key].append(now)
@@ -0,0 +1,181 @@
1
+ """
2
+ Core Web Engine Module: SearXNG Connector with DuckDuckGo Direct Fallback, SSRF-Safe HTTP Fetcher & HTML/Markdown Parser
3
+ """
4
+ import time
5
+ import urllib.parse
6
+ import re
7
+ from curl_cffi.requests import AsyncSession
8
+ from bs4 import BeautifulSoup
9
+ import html2text
10
+ from fastapi import HTTPException, status
11
+ from .config import settings
12
+ from .security import validate_ssrf_url
13
+
14
+ USER_AGENTS = [
15
+ "Mozilla/5.0 (X11; Linux aarch64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
16
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
17
+ ]
18
+
19
+ def get_headers():
20
+ return {
21
+ "User-Agent": USER_AGENTS[0],
22
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
23
+ "Accept-Language": "es-ES,es;q=0.9,en-US;q=0.8,en;q=0.7",
24
+ "Sec-Ch-Ua": '"Not A(Brand";v="99", "Google Chrome";v="120"',
25
+ "Sec-Fetch-Dest": "document"
26
+ }
27
+
28
+ async def execute_ddg_fallback(query: str, limit: int = 10):
29
+ """
30
+ Direct DuckDuckGo HTML Search Fallback if SearXNG is unavailable.
31
+ """
32
+ start_t = time.time()
33
+ ddg_url = f"https://html.duckduckgo.com/html/?q={urllib.parse.quote(query)}"
34
+ headers = get_headers()
35
+
36
+ async with AsyncSession() as session:
37
+ try:
38
+ r = await session.get(ddg_url, headers=headers, impersonate="chrome120", timeout=8)
39
+ if r.status_code == 200:
40
+ soup = BeautifulSoup(r.text, "html.parser")
41
+ results = []
42
+ for idx, a in enumerate(soup.find_all("a", class_="result__a")[:limit]):
43
+ title = a.get_text(strip=True)
44
+ raw_href = a.get("href", "")
45
+ # Extract target URL from DDG redirect
46
+ match = re.search(r"uddg=(https?%3A%2F%2F[^&]+)", raw_href)
47
+ final_url = urllib.parse.unquote(match.group(1)) if match else raw_href
48
+ results.append({
49
+ "rank": idx + 1,
50
+ "title": title,
51
+ "url": final_url,
52
+ "snippet": f"Search result for '{query}'",
53
+ "engine": "duckduckgo_fallback"
54
+ })
55
+ return {
56
+ "query": query,
57
+ "count": len(results),
58
+ "latency_ms": round((time.time() - start_t) * 1000, 2),
59
+ "results": results
60
+ }
61
+ except Exception:
62
+ pass
63
+
64
+ return {"query": query, "count": 0, "latency_ms": 0, "results": []}
65
+
66
+ async def execute_web_search(query: str, limit: int = 10):
67
+ start_t = time.time()
68
+ url = f"{settings.SEARXNG_URL}?q={query}&format=json"
69
+
70
+ async with AsyncSession() as session:
71
+ try:
72
+ r = await session.get(url, timeout=4.0)
73
+ if r.status_code == 200:
74
+ data = r.json()
75
+ results = data.get("results", [])[:limit]
76
+ items = []
77
+ for idx, item in enumerate(results):
78
+ items.append({
79
+ "rank": idx + 1,
80
+ "title": item.get("title"),
81
+ "url": item.get("url"),
82
+ "snippet": item.get("content"),
83
+ "engine": item.get("engine")
84
+ })
85
+ return {
86
+ "query": query,
87
+ "count": len(items),
88
+ "latency_ms": round((time.time() - start_t) * 1000, 2),
89
+ "results": items
90
+ }
91
+ except Exception:
92
+ pass
93
+
94
+ # SearXNG failed or timed out -> Fallback to direct DuckDuckGo HTML parser
95
+ return await execute_ddg_fallback(query, limit)
96
+
97
+ async def execute_fetch_url(url: str, max_bytes: int = None):
98
+ current_url = validate_ssrf_url(url)
99
+ byte_limit = max_bytes or settings.MAX_PAYLOAD_BYTES
100
+
101
+ start_t = time.time()
102
+ headers = get_headers()
103
+ max_redirects = 5
104
+ redirect_count = 0
105
+
106
+ async with AsyncSession() as session:
107
+ while redirect_count < max_redirects:
108
+ try:
109
+ r = await session.get(
110
+ current_url,
111
+ headers=headers,
112
+ impersonate="chrome120",
113
+ allow_redirects=False,
114
+ timeout=settings.DEFAULT_TIMEOUT_SEC
115
+ )
116
+ except Exception as e:
117
+ raise HTTPException(
118
+ status_code=status.HTTP_504_GATEWAY_TIMEOUT if "timeout" in str(e).lower() else status.HTTP_502_BAD_GATEWAY,
119
+ detail={"error": "FETCH_ERROR", "message": f"Failed to fetch target URL: {str(e)}"}
120
+ )
121
+
122
+ if r.status_code in [301, 302, 303, 307, 308]:
123
+ location = r.headers.get("Location") or r.headers.get("location")
124
+ if not location:
125
+ break
126
+ next_url = urllib.parse.urljoin(current_url, location)
127
+ current_url = validate_ssrf_url(next_url)
128
+ redirect_count += 1
129
+ continue
130
+ else:
131
+ break
132
+
133
+ if r.status_code != 200:
134
+ raise HTTPException(
135
+ status_code=status.HTTP_502_BAD_GATEWAY,
136
+ detail={"error": "HTTP_FETCH_FAILED", "message": f"Target server returned HTTP status {r.status_code}."}
137
+ )
138
+
139
+ content = r.text[:byte_limit]
140
+ if not content or len(content.strip()) < 10:
141
+ raise HTTPException(
142
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
143
+ detail={"error": "EMPTY_RESPONSE", "message": "Target URL returned an empty or truncated payload."}
144
+ )
145
+
146
+ return {
147
+ "url": current_url,
148
+ "status_code": r.status_code,
149
+ "content_length": len(content),
150
+ "latency_ms": round((time.time() - start_t) * 1000, 2),
151
+ "content": content
152
+ }
153
+
154
+ async def execute_extract_markdown(url: str, max_bytes: int = None):
155
+ fetch_res = await execute_fetch_url(url, max_bytes)
156
+ html_content = fetch_res["content"]
157
+
158
+ try:
159
+ soup = BeautifulSoup(html_content, "html.parser")
160
+ for tag in soup(["script", "style", "nav", "footer", "iframe"]):
161
+ tag.decompose()
162
+
163
+ h = html2text.HTML2Text()
164
+ h.ignore_links = False
165
+ h.ignore_images = True
166
+ h.body_width = 0
167
+
168
+ markdown_text = h.handle(str(soup))
169
+
170
+ return {
171
+ "url": fetch_res["url"],
172
+ "status_code": 200,
173
+ "markdown_length": len(markdown_text),
174
+ "latency_ms": fetch_res["latency_ms"],
175
+ "markdown": markdown_text
176
+ }
177
+ except Exception as e:
178
+ raise HTTPException(
179
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
180
+ detail={"error": "EXTRACTION_FAILED", "message": f"Failed to convert HTML to Markdown: {str(e)}"}
181
+ )
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcp-web-engine
3
+ Version: 1.0.4
4
+ Summary: Privacy-First, Self-Hostable & SSRF-Hardened MCP Server & Web Engine for AI Agents
5
+ Project-URL: Homepage, https://github.com/Arbolencio/mcp-web-engine
6
+ Project-URL: Repository, https://github.com/Arbolencio/mcp-web-engine.git
7
+ Author: Arbolencio
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai-agent,mcp,mcp-server,searxng,ssrf-protection,turbollm,web-scraper,web-search
11
+ Requires-Python: >=3.9
12
+ Requires-Dist: beautifulsoup4>=4.12.0
13
+ Requires-Dist: curl-cffi>=0.6.0
14
+ Requires-Dist: fastapi>=0.109.0
15
+ Requires-Dist: html2text>=2024.1.14
16
+ Requires-Dist: pydantic-settings>=2.1.0
17
+ Requires-Dist: pydantic>=2.5.0
18
+ Requires-Dist: sse-starlette>=2.0.0
19
+ Requires-Dist: uvicorn>=0.27.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # 🌐 MCP Web Engine & Search Gateway
23
+
24
+ > **Public HTTPS Specification 2026-07-28 Pure Stateless Core MCP Server for AI Agents**
25
+
26
+ ---
27
+
28
+ ## 🌐 Public Production HTTPS Endpoint (Cloudflare Managed Tunnel)
29
+
30
+ - **Public MCP 2026-07-28 Endpoint:** `https://ultimate-ignored-over-light.trycloudflare.com/v1/mcp`
31
+ - **Health Check:** `https://ultimate-ignored-over-light.trycloudflare.com/health`
32
+
33
+ ---
34
+
35
+ ## 📌 What it does & Why it exists
36
+
37
+ `MCP Web Engine` is a high-performance Model Context Protocol (MCP) server implementing the **MCP Specification 2026-07-28 Pure Stateless Core** (JSON-RPC 2.0 over HTTPS). It provides AI Agents (Claude Desktop, Cursor, Windsurf, MCP Inspector, Hermes Agent) with secure, private access to web search, raw content fetching, and clean HTML-to-Markdown extraction.
38
+
39
+ ### Key Capabilities & Architecture
40
+ - **Pure Stateless Core (Spec 2026-07-28):** Zero sessions, zero state, zero initialize handshake required. Every request is completely independent.
41
+ - **Protocol Methods:** Implements `server/discover`, `tools/list`, and `tools/call` with standard JSON-RPC 2.0 payloads.
42
+ - **Zero Third-Party Tracking:** Powered by an internal SearXNG meta-search engine aggregating 70+ sources with DuckDuckGo fallback.
43
+ - **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.
44
+ - **TLS Fingerprinting:** Powered by `curl_cffi` (Chrome 120+ TLS impersonation) for clean research scraping without heavy browser overhead.
45
+
46
+ ---
47
+
48
+ ## 🏗️ Architecture
49
+
50
+ ```
51
+ [ MCP Client / Claude / Cursor / Windsurf / Inspector ]
52
+
53
+ (HTTPS Header: Authorization: Bearer sk_mcp_...)
54
+ (HTTPS Header: MCP-Protocol-Version: 2026-07-28)
55
+
56
+ [ Cloudflare Managed HTTPS Tunnel ]
57
+
58
+
59
+ [ FastAPI Gateway (Port 5050) ]
60
+ ├── Endpoint: POST /v1/mcp (Pure Stateless Core)
61
+ ├── Security & Auth (Bearer Token + Sliding Window Rate Limiter)
62
+ ├── SSRF Validator (Pre-request DNS + IP Subnet Filtering)
63
+ ├── Method 1: server/discover (Server Metadata Discovery)
64
+ ├── Method 2: tools/list (Tools Discovery)
65
+ └── Method 3: tools/call (Tool Execution Engine)
66
+ ```
67
+
68
+ ---
69
+
70
+ ## 🚀 Quickstart & Docker Installation (< 2 Minutes)
71
+
72
+ ```bash
73
+ # 1. Clone Repository
74
+ git clone https://github.com/Arbolencio/mcp-web-engine.git
75
+ cd mcp-web-engine
76
+
77
+ # 2. Copy Environment Example
78
+ cp .env.example .env
79
+
80
+ # 3. Launch with Docker Compose
81
+ docker compose up -d
82
+
83
+ # 4. Verify Health Check
84
+ curl -s https://ultimate-ignored-over-light.trycloudflare.com/health
85
+ # {"status":"ok","environment":"production","searxng_url":"http://host.docker.internal:8082/search","mcp_version":"2026-07-28"}
86
+ ```
87
+
88
+ ---
89
+
90
+ ## 🔌 Connecting from MCP Clients (Cursor, Windsurf, Inspector, Claude Desktop)
91
+
92
+ ### 1. Cursor / Windsurf / MCP Inspector (Direct HTTPS):
93
+ - **Server Endpoint:** `https://ultimate-ignored-over-light.trycloudflare.com/v1/mcp`
94
+ - **Headers:**
95
+ - `Authorization: Bearer YOUR_BETA_KEY_HERE`
96
+ - `MCP-Protocol-Version: 2026-07-28`
97
+
98
+ ### 2. Claude Desktop (via `mcp-remote` bridge):
99
+ ```json
100
+ {
101
+ "mcpServers": {
102
+ "mcp-web-engine": {
103
+ "command": "npx",
104
+ "args": [
105
+ "-y",
106
+ "mcp-remote",
107
+ "https://ultimate-ignored-over-light.trycloudflare.com/v1/mcp",
108
+ "--header",
109
+ "Authorization: Bearer YOUR_BETA_KEY_HERE"
110
+ ]
111
+ }
112
+ }
113
+ }
114
+ ```
115
+
116
+ ---
117
+
118
+ ## 🛠️ MCP 2026-07-28 Stateless Protocol Lifecycle
119
+
120
+ ### 1. `server/discover` Discovery
121
+ ```json
122
+ {
123
+ "jsonrpc": "2.0",
124
+ "id": 1,
125
+ "method": "server/discover",
126
+ "params": {}
127
+ }
128
+ ```
129
+
130
+ ### 2. `tools/list` Discovery
131
+ ```json
132
+ {
133
+ "jsonrpc": "2.0",
134
+ "id": 2,
135
+ "method": "tools/list",
136
+ "params": {}
137
+ }
138
+ ```
139
+
140
+ ### 3. `tools/call` Execution
141
+ ```json
142
+ {
143
+ "jsonrpc": "2.0",
144
+ "id": 3,
145
+ "method": "tools/call",
146
+ "params": {
147
+ "name": "web_search",
148
+ "arguments": {
149
+ "query": "Model Context Protocol 2026 specification",
150
+ "limit": 5
151
+ }
152
+ }
153
+ }
154
+ ```
155
+
156
+ ---
157
+
158
+ ## 🔑 Managing Beta Keys
159
+
160
+ Manage API access keys using the CLI helper:
161
+
162
+ ```bash
163
+ # Generate 20 Beta Keys
164
+ python manage_beta_keys.py init20
165
+
166
+ # View Beta User Telemetry
167
+ python manage_beta_keys.py telemetry
168
+
169
+ # Revoke a key by ID
170
+ python manage_beta_keys.py revoke --id Beta_001
171
+ ```
172
+
173
+ ---
174
+
175
+ ## 🛡️ Security & Honest Technical Limits
176
+
177
+ - **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.
178
+ - **Rate Limiting:** Default limit of 120 req/min per key.
179
+ - **Honest Positioning:** Designed for research, documentation retrieval, and web search. Auth-walled sites requiring login (e.g. private social feeds) are not supported.
180
+
181
+ ---
182
+
183
+ ## 📄 License
184
+
185
+ MIT License © 2026 MCP Web Engine Contributors.
@@ -0,0 +1,14 @@
1
+ mcp_web_engine/__init__.py,sha256=JRZV4tpld-6i_-J5WceGWJxoK8r8qDs8rOh7g6QfHIw,53
2
+ mcp_web_engine/config.py,sha256=IUGW5AP1R65CHZjzPCmkr3W32NlCpUT4KKH3ppxri_0,1222
3
+ mcp_web_engine/logging_obs.py,sha256=jbFLB2bQzbx133amXAJQap3DE388twsRlryVxBLyy50,4121
4
+ mcp_web_engine/main.py,sha256=CYX8dschYnMC_b6Lsd0ehjYNW5_JLZXiOQqwqUGNd5s,8308
5
+ mcp_web_engine/manage_beta_keys.py,sha256=aB_-G0QiXKt8qcvXw_m_McP6x9ZR2hrRuVAHHLQc6KE,3982
6
+ mcp_web_engine/mcp_protocol.py,sha256=KjD-bsYexht8r8pcx4QvBF8X_9fAVdIh8gS7q1wnHfA,8390
7
+ mcp_web_engine/mcp_tools.py,sha256=wzKH9C-FBcEKloEsVJaQJKpBmiXGyEHJfng4VrbVvIs,2147
8
+ mcp_web_engine/security.py,sha256=DhH7T3LH4ys3amDX_XmxgGIRsW5s3x5hYQ70KxELoW4,5952
9
+ mcp_web_engine/web_engine.py,sha256=E2VKiJUfJK2qC8spTojCuwFKTny_8sXiKXxZwaCwmTc,6914
10
+ mcp_web_engine-1.0.4.dist-info/METADATA,sha256=FpG7BdXBrRxpUsNb0YaOjAcaiZ_TdJJvv09MAqzTkqY,5824
11
+ mcp_web_engine-1.0.4.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
12
+ mcp_web_engine-1.0.4.dist-info/entry_points.txt,sha256=H9QHINaUpzqZXFxmFBbIhI9AJ7LkrMk5wDqFcKG5QEs,70
13
+ mcp_web_engine-1.0.4.dist-info/licenses/LICENSE,sha256=U5YuXrvsrnK8Jwt7Hc82K5jMwwI_9tcKI2Wfo6QVzyU,1084
14
+ mcp_web_engine-1.0.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-web-engine = mcp_web_engine.main:cli_entrypoint
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MCP Web Engine 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.