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.
@@ -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)