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/mcp_protocol.py
DELETED
|
@@ -1,210 +0,0 @@
|
|
|
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.0"
|
|
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
|
package/security.py
DELETED
|
@@ -1,169 +0,0 @@
|
|
|
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)
|
package/web_engine.py
DELETED
|
@@ -1,181 +0,0 @@
|
|
|
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
|
-
)
|