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