mcp-web-engine 1.0.4 → 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/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
- )