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/README.md +62 -103
- 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
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core Web Engine Module: SearXNG Connector with DuckDuckGo Direct Fallback, SSRF-Safe HTTP Fetcher & HTML/Markdown Parser
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import urllib.parse
|
|
11
|
+
|
|
12
|
+
import html2text
|
|
13
|
+
from bs4 import BeautifulSoup
|
|
14
|
+
from curl_cffi import CurlError, CurlOpt
|
|
15
|
+
from curl_cffi.curl import CURL_WRITEFUNC_ERROR
|
|
16
|
+
from curl_cffi.requests import AsyncSession
|
|
17
|
+
from fastapi import HTTPException, status
|
|
18
|
+
|
|
19
|
+
from .config import settings
|
|
20
|
+
from .security import _parse_http_url, resolve_ssrf_target
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
USER_AGENTS = [
|
|
25
|
+
"Mozilla/5.0 (X11; Linux aarch64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
26
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_headers():
|
|
31
|
+
return {
|
|
32
|
+
"User-Agent": USER_AGENTS[0],
|
|
33
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
34
|
+
"Accept-Language": "es-ES,es;q=0.9,en-US;q=0.8,en;q=0.7",
|
|
35
|
+
"Sec-Ch-Ua": '"Not A(Brand";v="99", "Google Chrome";v="120"',
|
|
36
|
+
"Sec-Fetch-Dest": "document",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def execute_ddg_fallback(query: str, limit: int = 10):
|
|
41
|
+
"""
|
|
42
|
+
Direct DuckDuckGo HTML Search Fallback if SearXNG is unavailable.
|
|
43
|
+
"""
|
|
44
|
+
start_t = time.time()
|
|
45
|
+
ddg_url = f"https://html.duckduckgo.com/html/?q={urllib.parse.quote(query)}"
|
|
46
|
+
try:
|
|
47
|
+
r, body, _ = await _bounded_request(ddg_url, _payload_limit(), timeout=8)
|
|
48
|
+
if r.status_code == 200:
|
|
49
|
+
soup = BeautifulSoup(_decode_body(r, body), "html.parser")
|
|
50
|
+
results = []
|
|
51
|
+
for idx, a in enumerate(soup.find_all("a", class_="result__a")[:limit]):
|
|
52
|
+
title = a.get_text(strip=True)
|
|
53
|
+
raw_href = a.get("href", "")
|
|
54
|
+
# Extract target URL from DDG redirect
|
|
55
|
+
match = re.search(r"uddg=(https?%3A%2F%2F[^&]+)", raw_href)
|
|
56
|
+
final_url = urllib.parse.unquote(match.group(1)) if match else raw_href
|
|
57
|
+
results.append(
|
|
58
|
+
{
|
|
59
|
+
"rank": idx + 1,
|
|
60
|
+
"title": title,
|
|
61
|
+
"url": final_url,
|
|
62
|
+
"snippet": f"Search result for '{query}'",
|
|
63
|
+
"engine": "duckduckgo_fallback",
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
return {
|
|
67
|
+
"query": query,
|
|
68
|
+
"count": len(results),
|
|
69
|
+
"latency_ms": round((time.time() - start_t) * 1000, 2),
|
|
70
|
+
"results": results,
|
|
71
|
+
}
|
|
72
|
+
except (HTTPException, ValueError, TypeError, AttributeError) as exc:
|
|
73
|
+
logger.warning("DuckDuckGo fallback unavailable (%s)", type(exc).__name__)
|
|
74
|
+
|
|
75
|
+
return {"query": query, "count": 0, "latency_ms": 0, "results": []}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def normalize_searxng_url(base_url: str) -> str:
|
|
79
|
+
url = base_url.rstrip("/")
|
|
80
|
+
if not url.endswith("/search"):
|
|
81
|
+
url = f"{url}/search"
|
|
82
|
+
return url
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def execute_web_search(query: str, limit: int = 10):
|
|
86
|
+
start_t = time.time()
|
|
87
|
+
base_searxng = normalize_searxng_url(settings.SEARXNG_URL)
|
|
88
|
+
query_params = urllib.parse.urlencode({"q": query, "format": "json"})
|
|
89
|
+
url = f"{base_searxng}?{query_params}"
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
r, body, _ = await _bounded_request(
|
|
93
|
+
url, _payload_limit(), trusted_search=True, timeout=4.0
|
|
94
|
+
)
|
|
95
|
+
if r.status_code == 200:
|
|
96
|
+
data = json.loads(_decode_body(r, body))
|
|
97
|
+
results = data.get("results", [])[:limit]
|
|
98
|
+
items = []
|
|
99
|
+
for idx, item in enumerate(results):
|
|
100
|
+
items.append(
|
|
101
|
+
{
|
|
102
|
+
"rank": idx + 1,
|
|
103
|
+
"title": item.get("title"),
|
|
104
|
+
"url": item.get("url"),
|
|
105
|
+
"snippet": item.get("content"),
|
|
106
|
+
"engine": item.get("engine"),
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
return {
|
|
110
|
+
"query": query,
|
|
111
|
+
"count": len(items),
|
|
112
|
+
"latency_ms": round((time.time() - start_t) * 1000, 2),
|
|
113
|
+
"results": items,
|
|
114
|
+
}
|
|
115
|
+
except (HTTPException, ValueError, TypeError, AttributeError) as exc:
|
|
116
|
+
logger.warning("SearXNG unavailable (%s)", type(exc).__name__)
|
|
117
|
+
|
|
118
|
+
# SearXNG failed or timed out -> only fall back when the operator opted in,
|
|
119
|
+
# so that no query ever leaves the host without explicit consent.
|
|
120
|
+
if settings.ALLOW_DDG_FALLBACK:
|
|
121
|
+
return await execute_ddg_fallback(query, limit)
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
"query": query,
|
|
125
|
+
"count": 0,
|
|
126
|
+
"latency_ms": round((time.time() - start_t) * 1000, 2),
|
|
127
|
+
"results": [],
|
|
128
|
+
"error": "SEARXNG_UNAVAILABLE",
|
|
129
|
+
"detail": "SearXNG did not answer. The DuckDuckGo fallback is disabled by default; set ALLOW_DDG_FALLBACK=true to enable it.",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _payload_limit(max_bytes=None):
|
|
134
|
+
ceiling = settings.MAX_PAYLOAD_BYTES
|
|
135
|
+
if max_bytes is None:
|
|
136
|
+
return ceiling
|
|
137
|
+
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
|
|
138
|
+
raise HTTPException(
|
|
139
|
+
400,
|
|
140
|
+
detail={"error": "INVALID_LIMIT", "message": "max_bytes must be positive."},
|
|
141
|
+
)
|
|
142
|
+
return min(max_bytes, ceiling)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def _bounded_request(url, byte_limit, *, trusted_search=False, timeout=None):
|
|
146
|
+
"""Private transport. Only the configured operator search URL may bypass SSRF.
|
|
147
|
+
|
|
148
|
+
Each hop gets a fresh session/connection: no stale DNS cache, pooled HTTP/2
|
|
149
|
+
coalescing, cross-origin cookies, or environment proxies. curl's write callback
|
|
150
|
+
receives decompressed data and aborts before retaining more than byte_limit.
|
|
151
|
+
"""
|
|
152
|
+
deadline = time.monotonic() + (timeout or settings.DEFAULT_TIMEOUT_SEC)
|
|
153
|
+
current_url = url
|
|
154
|
+
for redirect_count in range(6):
|
|
155
|
+
remaining = deadline - time.monotonic()
|
|
156
|
+
if remaining <= 0:
|
|
157
|
+
raise HTTPException(504, detail={"error": "FETCH_TIMEOUT"})
|
|
158
|
+
if trusted_search:
|
|
159
|
+
current_url, _, _ = _parse_http_url(current_url)
|
|
160
|
+
target = None
|
|
161
|
+
else:
|
|
162
|
+
# Offload blocking resolver; outer wait bounds the caller's DNS wait.
|
|
163
|
+
try:
|
|
164
|
+
target = await asyncio.wait_for(
|
|
165
|
+
asyncio.to_thread(resolve_ssrf_target, current_url), remaining
|
|
166
|
+
)
|
|
167
|
+
except asyncio.TimeoutError:
|
|
168
|
+
raise HTTPException(504, detail={"error": "FETCH_TIMEOUT"}) from None
|
|
169
|
+
current_url = target.url
|
|
170
|
+
options = {
|
|
171
|
+
# Set the exact canonical URL last, after requests library requoting.
|
|
172
|
+
CurlOpt.URL: current_url,
|
|
173
|
+
CurlOpt.PROXY: "", # trust_env alone is insufficient in older releases.
|
|
174
|
+
CurlOpt.NOPROXY: "*",
|
|
175
|
+
CurlOpt.PROTOCOLS: 3, # CURLPROTO_HTTP | CURLPROTO_HTTPS
|
|
176
|
+
CurlOpt.REDIR_PROTOCOLS: 3,
|
|
177
|
+
CurlOpt.FRESH_CONNECT: 1,
|
|
178
|
+
CurlOpt.FORBID_REUSE: 1,
|
|
179
|
+
}
|
|
180
|
+
if target is not None:
|
|
181
|
+
options[CurlOpt.RESOLVE] = [target.resolve_entry]
|
|
182
|
+
body = bytearray()
|
|
183
|
+
oversized = False
|
|
184
|
+
|
|
185
|
+
def receive(chunk, body=body):
|
|
186
|
+
nonlocal oversized
|
|
187
|
+
if len(body) + len(chunk) > byte_limit:
|
|
188
|
+
oversized = True
|
|
189
|
+
# Older curl_cffi ignores a short byte count: use the explicit
|
|
190
|
+
# libcurl abort sentinel instead of 0 (verified on 0.6 and 0.17).
|
|
191
|
+
return CURL_WRITEFUNC_ERROR
|
|
192
|
+
body.extend(chunk)
|
|
193
|
+
return len(chunk)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
async with AsyncSession(trust_env=False, curl_options=options) as session:
|
|
197
|
+
remaining = deadline - time.monotonic()
|
|
198
|
+
if remaining <= 0:
|
|
199
|
+
raise asyncio.TimeoutError()
|
|
200
|
+
r = await asyncio.wait_for(
|
|
201
|
+
session.get(
|
|
202
|
+
current_url,
|
|
203
|
+
headers=get_headers(),
|
|
204
|
+
impersonate="chrome120",
|
|
205
|
+
allow_redirects=False,
|
|
206
|
+
timeout=remaining,
|
|
207
|
+
content_callback=receive,
|
|
208
|
+
accept_encoding="gzip, deflate, br",
|
|
209
|
+
),
|
|
210
|
+
remaining,
|
|
211
|
+
)
|
|
212
|
+
except (CurlError, asyncio.TimeoutError, OSError, RuntimeError) as exc:
|
|
213
|
+
if oversized:
|
|
214
|
+
raise HTTPException(
|
|
215
|
+
413,
|
|
216
|
+
detail={
|
|
217
|
+
"error": "PAYLOAD_TOO_LARGE",
|
|
218
|
+
"message": "Decoded response exceeds byte limit.",
|
|
219
|
+
},
|
|
220
|
+
) from None
|
|
221
|
+
timed_out = (
|
|
222
|
+
isinstance(exc, asyncio.TimeoutError) or "timeout" in str(exc).lower()
|
|
223
|
+
)
|
|
224
|
+
# Never echo transport exceptions (may include sensitive URL data).
|
|
225
|
+
raise HTTPException(
|
|
226
|
+
504 if timed_out else 502,
|
|
227
|
+
detail={"error": "FETCH_ERROR", "message": "Target request failed."},
|
|
228
|
+
) from None
|
|
229
|
+
if oversized:
|
|
230
|
+
raise HTTPException(413, detail={"error": "PAYLOAD_TOO_LARGE"})
|
|
231
|
+
if r.status_code not in (301, 302, 303, 307, 308) or trusted_search:
|
|
232
|
+
return r, bytes(body), current_url
|
|
233
|
+
location = r.headers.get("Location") or r.headers.get("location")
|
|
234
|
+
if not location:
|
|
235
|
+
return r, bytes(body), current_url
|
|
236
|
+
if redirect_count == 5:
|
|
237
|
+
raise HTTPException(502, detail={"error": "TOO_MANY_REDIRECTS"})
|
|
238
|
+
# Validate raw Location BEFORE urljoin, which also strips some controls.
|
|
239
|
+
if (
|
|
240
|
+
not isinstance(location, str)
|
|
241
|
+
or "\\" in location
|
|
242
|
+
or any(ord(c) <= 32 or ord(c) == 127 or c.isspace() for c in location)
|
|
243
|
+
):
|
|
244
|
+
raise HTTPException(400, detail={"error": "INVALID_REDIRECT"})
|
|
245
|
+
current_url = urllib.parse.urljoin(current_url, location)
|
|
246
|
+
raise HTTPException(502, detail={"error": "TOO_MANY_REDIRECTS"})
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _decode_body(response, body):
|
|
250
|
+
try:
|
|
251
|
+
return body.decode(response.encoding or "utf-8", errors="replace")
|
|
252
|
+
except (LookupError, AttributeError):
|
|
253
|
+
return body.decode("utf-8", errors="replace")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
async def execute_fetch_url(url: str, max_bytes: int | None = None):
|
|
257
|
+
start_t = time.monotonic()
|
|
258
|
+
r, body, current_url = await _bounded_request(url, _payload_limit(max_bytes))
|
|
259
|
+
if r.status_code != 200:
|
|
260
|
+
raise HTTPException(
|
|
261
|
+
502,
|
|
262
|
+
detail={
|
|
263
|
+
"error": "HTTP_FETCH_FAILED",
|
|
264
|
+
"message": f"Target server returned HTTP status {r.status_code}.",
|
|
265
|
+
},
|
|
266
|
+
)
|
|
267
|
+
content = _decode_body(r, body)
|
|
268
|
+
if not content or len(content.strip()) < 10:
|
|
269
|
+
raise HTTPException(
|
|
270
|
+
422,
|
|
271
|
+
detail={
|
|
272
|
+
"error": "EMPTY_RESPONSE",
|
|
273
|
+
"message": "Target returned an empty payload.",
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
return {
|
|
277
|
+
"url": current_url,
|
|
278
|
+
"status_code": r.status_code,
|
|
279
|
+
"content_length": len(body),
|
|
280
|
+
"latency_ms": round((time.monotonic() - start_t) * 1000, 2),
|
|
281
|
+
"content": content,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def execute_extract_markdown(url: str, max_bytes: int | None = None):
|
|
286
|
+
fetch_res = await execute_fetch_url(url, max_bytes)
|
|
287
|
+
html_content = fetch_res["content"]
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
soup = BeautifulSoup(html_content, "html.parser")
|
|
291
|
+
for tag in soup(["script", "style", "nav", "footer", "iframe"]):
|
|
292
|
+
tag.decompose()
|
|
293
|
+
|
|
294
|
+
h = html2text.HTML2Text()
|
|
295
|
+
h.ignore_links = False
|
|
296
|
+
h.ignore_images = True
|
|
297
|
+
h.body_width = 0
|
|
298
|
+
|
|
299
|
+
markdown_text = h.handle(str(soup))
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
"url": fetch_res["url"],
|
|
303
|
+
"status_code": 200,
|
|
304
|
+
"markdown_length": len(markdown_text),
|
|
305
|
+
"latency_ms": fetch_res["latency_ms"],
|
|
306
|
+
"markdown": markdown_text,
|
|
307
|
+
}
|
|
308
|
+
except (ValueError, TypeError, RuntimeError):
|
|
309
|
+
raise HTTPException(
|
|
310
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
311
|
+
detail={
|
|
312
|
+
"error": "EXTRACTION_FAILED",
|
|
313
|
+
"message": "Failed to convert HTML to Markdown.",
|
|
314
|
+
},
|
|
315
|
+
)
|
package/config.py
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
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()
|
package/logging_obs.py
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
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()
|
package/main.py
DELETED
|
@@ -1,230 +0,0 @@
|
|
|
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.0"
|
|
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
|
-
if __name__ == "__main__":
|
|
225
|
-
is_stdio = "--stdio" in sys.argv or not sys.stdin.isatty()
|
|
226
|
-
if is_stdio:
|
|
227
|
-
asyncio.run(run_mcp_stdio_server())
|
|
228
|
-
else:
|
|
229
|
-
import uvicorn
|
|
230
|
-
uvicorn.run("main:app", host=settings.HOST, port=settings.PORT, reload=False)
|