mcp-web-engine 1.0.5 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -104
- package/bin/cli.js +20 -88
- package/docs/operations.md +15 -0
- package/package.json +5 -3
- package/pyproject.toml +58 -0
- package/requirements.txt +1 -9
- package/src/mcp_web_engine/__init__.py +3 -0
- package/src/mcp_web_engine/config.py +43 -0
- package/src/mcp_web_engine/logging_obs.py +142 -0
- package/src/mcp_web_engine/main.py +281 -0
- package/{manage_beta_keys.py → src/mcp_web_engine/manage_beta_keys.py} +30 -9
- package/src/mcp_web_engine/mcp_protocol.py +166 -0
- package/{mcp_tools.py → src/mcp_web_engine/mcp_tools.py} +10 -7
- package/src/mcp_web_engine/resource_limits.py +45 -0
- package/src/mcp_web_engine/security.py +339 -0
- package/src/mcp_web_engine/state_store.py +133 -0
- package/src/mcp_web_engine/web_engine.py +315 -0
- package/config.py +0 -24
- package/logging_obs.py +0 -111
- package/main.py +0 -230
- package/mcp_protocol.py +0 -210
- package/security.py +0 -169
- package/web_engine.py +0 -181
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main FastAPI Application Gateway & MCP Protocol Handler (SSE & STDIO Modes)
|
|
3
|
+
"""
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from fastapi import Depends, FastAPI, Header, HTTPException, Request, Response, status
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
from sse_starlette.sse import EventSourceResponse
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from . import security as security_module
|
|
17
|
+
from .config import settings
|
|
18
|
+
from .logging_obs import metrics
|
|
19
|
+
from .mcp_protocol import (
|
|
20
|
+
INTERNAL_ERROR,
|
|
21
|
+
INVALID_REQUEST,
|
|
22
|
+
MCP_PROTOCOL_VERSION_2026,
|
|
23
|
+
MCP_PROTOCOL_VERSION_LEGACY,
|
|
24
|
+
PARSE_ERROR,
|
|
25
|
+
is_rpc_notification,
|
|
26
|
+
make_jsonrpc_error,
|
|
27
|
+
process_mcp_2025_legacy_stateful,
|
|
28
|
+
process_mcp_2026_stateless,
|
|
29
|
+
validate_2026_mcp_headers,
|
|
30
|
+
validate_rpc_envelope,
|
|
31
|
+
)
|
|
32
|
+
from .mcp_tools import (
|
|
33
|
+
MCP_TOOL_DEFINITIONS,
|
|
34
|
+
ExtractMarkdownInput,
|
|
35
|
+
WebSearchInput,
|
|
36
|
+
handle_mcp_tool_call,
|
|
37
|
+
)
|
|
38
|
+
from .resource_limits import RequestSizeLimitMiddleware
|
|
39
|
+
from .security import check_rate_limit, verify_api_key, verify_master_api_key
|
|
40
|
+
from .state_store import StateUnavailable
|
|
41
|
+
|
|
42
|
+
app = FastAPI(
|
|
43
|
+
title="MCP Web Engine & Search Gateway",
|
|
44
|
+
description="Self-hostable search, public-page fetch and Markdown extraction. Stdio and HTTP transports.",
|
|
45
|
+
version=__version__
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
app.add_middleware(RequestSizeLimitMiddleware)
|
|
49
|
+
|
|
50
|
+
def _reject_non_finite(value):
|
|
51
|
+
raise ValueError("Non-finite JSON number")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load_json(value):
|
|
55
|
+
return json.loads(value, parse_constant=_reject_non_finite)
|
|
56
|
+
|
|
57
|
+
@app.get("/health")
|
|
58
|
+
async def health_check():
|
|
59
|
+
return {
|
|
60
|
+
"status": "ok",
|
|
61
|
+
"environment": settings.ENV,
|
|
62
|
+
|
|
63
|
+
"mcp_version": MCP_PROTOCOL_VERSION_2026
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@app.get("/v1/metrics")
|
|
67
|
+
async def get_metrics(api_key: str = Depends(verify_master_api_key)):
|
|
68
|
+
check_rate_limit(api_key)
|
|
69
|
+
return metrics.get_summary()
|
|
70
|
+
|
|
71
|
+
@app.get("/v1/beta/telemetry")
|
|
72
|
+
async def get_beta_telemetry(api_key: str = Depends(verify_master_api_key)):
|
|
73
|
+
check_rate_limit(api_key)
|
|
74
|
+
if not os.path.exists(security_module.BETA_KEYS_FILE):
|
|
75
|
+
return {"users_count": 0, "telemetry": {}}
|
|
76
|
+
try:
|
|
77
|
+
with open(security_module.BETA_KEYS_FILE, "r", encoding="utf-8") as f:
|
|
78
|
+
keys = json.load(f)
|
|
79
|
+
|
|
80
|
+
summary = {}
|
|
81
|
+
for k, v in keys.items():
|
|
82
|
+
beta_id = v.get("id", "Unknown")
|
|
83
|
+
usage = metrics.get_beta_telemetry(k)
|
|
84
|
+
baseline = v.get("telemetry", {}).get("requests", 0)
|
|
85
|
+
if type(baseline) is not int or baseline < 0:
|
|
86
|
+
raise ValueError("Invalid legacy usage baseline")
|
|
87
|
+
usage["admissions"] = usage["requests"] = max(baseline, usage["admissions"])
|
|
88
|
+
summary[beta_id] = {
|
|
89
|
+
"id": beta_id,
|
|
90
|
+
"status": v.get("status"),
|
|
91
|
+
"limit": v.get("limit"),
|
|
92
|
+
"telemetry": usage
|
|
93
|
+
}
|
|
94
|
+
return {"users_count": len(summary), "telemetry": summary}
|
|
95
|
+
except (OSError, ValueError, TypeError, AttributeError, StateUnavailable):
|
|
96
|
+
raise HTTPException(status_code=503, detail={"error": "TELEMETRY_UNAVAILABLE"}) from None
|
|
97
|
+
|
|
98
|
+
# Custom stateless HTTP compatibility endpoint (POST /v1/mcp).
|
|
99
|
+
@app.post("/v1/mcp")
|
|
100
|
+
async def mcp_2026_stateless_endpoint(
|
|
101
|
+
request: Request,
|
|
102
|
+
api_key: str = Depends(verify_api_key),
|
|
103
|
+
mcp_protocol_version: Optional[str] = Header(None, alias="MCP-Protocol-Version"),
|
|
104
|
+
mcp_method: Optional[str] = Header(None, alias="Mcp-Method"),
|
|
105
|
+
mcp_name: Optional[str] = Header(None, alias="Mcp-Name")
|
|
106
|
+
):
|
|
107
|
+
check_rate_limit(api_key)
|
|
108
|
+
try:
|
|
109
|
+
payload = _load_json(await request.body())
|
|
110
|
+
except Exception:
|
|
111
|
+
return Response(content=json.dumps(make_jsonrpc_error(None, PARSE_ERROR, "Invalid JSON.")), status_code=400, media_type="application/json")
|
|
112
|
+
|
|
113
|
+
if is_rpc_notification(payload):
|
|
114
|
+
return Response(status_code=202)
|
|
115
|
+
validate_2026_mcp_headers(mcp_protocol_version, mcp_method, mcp_name, payload)
|
|
116
|
+
|
|
117
|
+
res_body, http_status = await process_mcp_2026_stateless(payload, api_key=api_key)
|
|
118
|
+
|
|
119
|
+
headers = {
|
|
120
|
+
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION_2026,
|
|
121
|
+
"Mcp-Method": mcp_method,
|
|
122
|
+
"Content-Type": "application/json"
|
|
123
|
+
}
|
|
124
|
+
if mcp_name:
|
|
125
|
+
headers["Mcp-Name"] = mcp_name
|
|
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
|
+
# UNCHANGED LEGACY 2025-11-25 STATEFUL ENDPOINT (POST /v1/mcp/legacy)
|
|
134
|
+
@app.post("/v1/mcp/legacy")
|
|
135
|
+
async def mcp_2025_legacy_endpoint(
|
|
136
|
+
request: Request,
|
|
137
|
+
api_key: str = Depends(verify_api_key),
|
|
138
|
+
mcp_session_id: Optional[str] = Header(None, alias="Mcp-Session-Id")
|
|
139
|
+
):
|
|
140
|
+
check_rate_limit(api_key)
|
|
141
|
+
try:
|
|
142
|
+
payload = _load_json(await request.body())
|
|
143
|
+
except Exception:
|
|
144
|
+
return Response(content=json.dumps(make_jsonrpc_error(None, PARSE_ERROR, "Invalid JSON.")), status_code=400, media_type="application/json")
|
|
145
|
+
|
|
146
|
+
res_body, http_status, out_session_id = await process_mcp_2025_legacy_stateful(payload, mcp_session_id, api_key=api_key)
|
|
147
|
+
|
|
148
|
+
headers = {
|
|
149
|
+
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION_LEGACY,
|
|
150
|
+
"Mcp-Session-Id": out_session_id,
|
|
151
|
+
"Content-Type": "application/json"
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return Response(
|
|
155
|
+
content=json.dumps(res_body, ensure_ascii=False) if res_body else "",
|
|
156
|
+
status_code=http_status,
|
|
157
|
+
headers=headers
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# SSE Event Endpoint
|
|
161
|
+
@app.get("/v1/mcp")
|
|
162
|
+
async def mcp_sse_endpoint(
|
|
163
|
+
request: Request,
|
|
164
|
+
api_key: str = Depends(verify_api_key)
|
|
165
|
+
):
|
|
166
|
+
check_rate_limit(api_key)
|
|
167
|
+
|
|
168
|
+
async def event_generator():
|
|
169
|
+
yield {
|
|
170
|
+
"event": "endpoint",
|
|
171
|
+
"data": "/v1/mcp"
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return EventSourceResponse(event_generator())
|
|
175
|
+
|
|
176
|
+
# Legacy REST Tool Listing Endpoint (Compatibility)
|
|
177
|
+
@app.post("/v1/mcp/tools")
|
|
178
|
+
async def list_mcp_tools(api_key: str = Depends(verify_api_key)):
|
|
179
|
+
check_rate_limit(api_key)
|
|
180
|
+
return {"tools": MCP_TOOL_DEFINITIONS}
|
|
181
|
+
|
|
182
|
+
# Legacy REST Tool Invocation Endpoint (Compatibility)
|
|
183
|
+
@app.post("/v1/mcp/invoke")
|
|
184
|
+
async def invoke_mcp_tool(payload: dict, api_key: str = Depends(verify_api_key)):
|
|
185
|
+
check_rate_limit(api_key)
|
|
186
|
+
tool_name = payload.get("tool")
|
|
187
|
+
arguments = payload.get("arguments", {})
|
|
188
|
+
|
|
189
|
+
if not isinstance(tool_name, str) or tool_name not in {tool['name'] for tool in MCP_TOOL_DEFINITIONS} or not isinstance(arguments, dict):
|
|
190
|
+
raise HTTPException(
|
|
191
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
192
|
+
detail={"error": "MISSING_TOOL", "message": "Payload must include 'tool' parameter."}
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
start_t = time.time()
|
|
196
|
+
try:
|
|
197
|
+
res = await handle_mcp_tool_call(tool_name, arguments)
|
|
198
|
+
lat = round((time.time() - start_t) * 1000, 2)
|
|
199
|
+
metrics.record(tool_name, lat, success=True, api_key=api_key)
|
|
200
|
+
return {"tool": tool_name, "status": "success", "result": res}
|
|
201
|
+
except ValidationError:
|
|
202
|
+
lat = round((time.time() - start_t) * 1000, 2)
|
|
203
|
+
metrics.record(tool_name, lat, success=False, api_key=api_key)
|
|
204
|
+
raise HTTPException(400, detail={"error": "INVALID_TOOL_ARGUMENTS"}) from None
|
|
205
|
+
except HTTPException as e:
|
|
206
|
+
lat = round((time.time() - start_t) * 1000, 2)
|
|
207
|
+
metrics.record(tool_name, lat, success=False, api_key=api_key)
|
|
208
|
+
raise e
|
|
209
|
+
except Exception:
|
|
210
|
+
lat = round((time.time() - start_t) * 1000, 2)
|
|
211
|
+
metrics.record(tool_name, lat, success=False, api_key=api_key)
|
|
212
|
+
raise HTTPException(
|
|
213
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
214
|
+
detail={"error": "TOOL_EXECUTION_ERROR", "message": "Tool execution failed."}
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Direct REST Endpoints
|
|
218
|
+
@app.post("/v1/search")
|
|
219
|
+
async def search_endpoint(body: WebSearchInput, api_key: str = Depends(verify_api_key)):
|
|
220
|
+
result = await invoke_mcp_tool({"tool": "web_search", "arguments": body.model_dump()}, api_key)
|
|
221
|
+
return result["result"]
|
|
222
|
+
|
|
223
|
+
@app.post("/v1/extract")
|
|
224
|
+
async def extract_endpoint(body: ExtractMarkdownInput, api_key: str = Depends(verify_api_key)):
|
|
225
|
+
result = await invoke_mcp_tool({"tool": "extract_markdown", "arguments": body.model_dump()}, api_key)
|
|
226
|
+
return result["result"]
|
|
227
|
+
|
|
228
|
+
async def run_mcp_stdio_server():
|
|
229
|
+
"""Bounded JSON-lines transport. Stdout contains protocol messages only."""
|
|
230
|
+
session_id = None
|
|
231
|
+
while True:
|
|
232
|
+
line = await asyncio.to_thread(sys.stdin.readline, settings.MAX_PAYLOAD_BYTES + 1)
|
|
233
|
+
if not line:
|
|
234
|
+
break
|
|
235
|
+
if len(line.encode("utf-8", errors="surrogatepass")) > settings.MAX_PAYLOAD_BYTES:
|
|
236
|
+
while line and not line.endswith("\n"):
|
|
237
|
+
line = await asyncio.to_thread(sys.stdin.readline, settings.MAX_PAYLOAD_BYTES + 1)
|
|
238
|
+
result = make_jsonrpc_error(None, INVALID_REQUEST, "Message exceeds MAX_PAYLOAD_BYTES.")
|
|
239
|
+
elif not line.strip():
|
|
240
|
+
continue
|
|
241
|
+
else:
|
|
242
|
+
try:
|
|
243
|
+
payload = _load_json(line)
|
|
244
|
+
except (ValueError, RecursionError):
|
|
245
|
+
result = make_jsonrpc_error(None, PARSE_ERROR, "Invalid JSON.")
|
|
246
|
+
else:
|
|
247
|
+
if is_rpc_notification(payload):
|
|
248
|
+
continue
|
|
249
|
+
result = validate_rpc_envelope(payload)
|
|
250
|
+
if result is None:
|
|
251
|
+
# Tools are requests, never notifications. Ignore notifications
|
|
252
|
+
# before dispatch so they cannot cause unacknowledged I/O.
|
|
253
|
+
if "id" not in payload:
|
|
254
|
+
continue
|
|
255
|
+
try:
|
|
256
|
+
if payload["method"] == "server/discover":
|
|
257
|
+
result, _ = await process_mcp_2026_stateless(payload, api_key="local-stdio")
|
|
258
|
+
else:
|
|
259
|
+
result, _, session_id = await process_mcp_2025_legacy_stateful(payload, session_id, api_key="local-stdio")
|
|
260
|
+
except Exception:
|
|
261
|
+
result = make_jsonrpc_error(payload.get("id"), INTERNAL_ERROR, "Internal error.")
|
|
262
|
+
if result:
|
|
263
|
+
sys.stdout.write(json.dumps(result, ensure_ascii=True) + "\n")
|
|
264
|
+
sys.stdout.flush()
|
|
265
|
+
|
|
266
|
+
def cli_entrypoint():
|
|
267
|
+
"""
|
|
268
|
+
Console script entrypoint for PyPI / uvx (mcp-web-engine).
|
|
269
|
+
Runs Stdio MCP mode by default; runs FastAPI HTTP server if --serve is passed.
|
|
270
|
+
"""
|
|
271
|
+
is_serve = "--serve" in sys.argv or "--sse" in sys.argv
|
|
272
|
+
if is_serve:
|
|
273
|
+
if len(settings.API_KEY.strip()) < 32:
|
|
274
|
+
raise SystemExit("HTTP mode requires an operator-generated API_KEY of at least 32 characters.")
|
|
275
|
+
import uvicorn
|
|
276
|
+
uvicorn.run("mcp_web_engine.main:app", host=settings.HOST, port=settings.PORT, reload=False)
|
|
277
|
+
else:
|
|
278
|
+
asyncio.run(run_mcp_stdio_server())
|
|
279
|
+
|
|
280
|
+
if __name__ == "__main__":
|
|
281
|
+
cli_entrypoint()
|
|
@@ -3,14 +3,21 @@
|
|
|
3
3
|
Beta Keys & Telemetry Management CLI for MCP Web Engine
|
|
4
4
|
Genera 20 keys independientes (Beta_001 .. Beta_020), lista y gestiona telemetría por usuario en beta_keys.json.
|
|
5
5
|
"""
|
|
6
|
-
import
|
|
7
|
-
import
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
8
|
import json
|
|
9
|
+
import os
|
|
9
10
|
import secrets
|
|
11
|
+
import tempfile
|
|
10
12
|
import time
|
|
11
|
-
import
|
|
13
|
+
from pathlib import Path
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
try:
|
|
16
|
+
from .state_store import beta_keys_path, read_usage
|
|
17
|
+
except ImportError: # Direct script execution
|
|
18
|
+
from state_store import beta_keys_path, read_usage
|
|
19
|
+
|
|
20
|
+
KEYS_FILE = str(beta_keys_path())
|
|
14
21
|
|
|
15
22
|
def load_keys():
|
|
16
23
|
if not os.path.exists(KEYS_FILE):
|
|
@@ -18,12 +25,24 @@ def load_keys():
|
|
|
18
25
|
try:
|
|
19
26
|
with open(KEYS_FILE, "r", encoding="utf-8") as f:
|
|
20
27
|
return json.load(f)
|
|
21
|
-
except
|
|
22
|
-
|
|
28
|
+
except (OSError, ValueError) as exc:
|
|
29
|
+
raise RuntimeError("Cannot read operator key store") from exc
|
|
23
30
|
|
|
24
31
|
def save_keys(keys):
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
target = Path(KEYS_FILE)
|
|
33
|
+
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
34
|
+
temporary = None
|
|
35
|
+
try:
|
|
36
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=target.parent,
|
|
37
|
+
prefix=".beta-keys-", delete=False) as stream:
|
|
38
|
+
temporary = stream.name
|
|
39
|
+
json.dump(keys, stream, indent=2)
|
|
40
|
+
stream.flush()
|
|
41
|
+
os.fsync(stream.fileno())
|
|
42
|
+
os.replace(temporary, target)
|
|
43
|
+
finally:
|
|
44
|
+
if temporary and os.path.exists(temporary):
|
|
45
|
+
os.unlink(temporary)
|
|
27
46
|
|
|
28
47
|
def generate_20_beta_keys():
|
|
29
48
|
keys = {}
|
|
@@ -74,7 +93,9 @@ def list_beta_telemetry():
|
|
|
74
93
|
total_errs = 0
|
|
75
94
|
|
|
76
95
|
for k, info in keys.items():
|
|
77
|
-
|
|
96
|
+
usage = read_usage(hashlib.sha256(k.encode("utf-8")).hexdigest())
|
|
97
|
+
t = {**info.get("telemetry", {}), **usage}
|
|
98
|
+
t["requests"] = max(info.get("telemetry", {}).get("requests", 0), usage.get("admissions", 0))
|
|
78
99
|
reqs = t.get("requests", 0)
|
|
79
100
|
errs = t.get("errors", 0)
|
|
80
101
|
total_reqs += reqs
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""JSON-RPC handlers. The custom stateless API is retained for compatibility;
|
|
2
|
+
its extensions are not claimed to be a certified official MCP implementation.
|
|
3
|
+
"""
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
from typing import NoReturn, Optional
|
|
9
|
+
|
|
10
|
+
from fastapi import HTTPException
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .logging_obs import logger, metrics
|
|
15
|
+
from .mcp_tools import MCP_TOOL_DEFINITIONS, handle_mcp_tool_call
|
|
16
|
+
|
|
17
|
+
MCP_PROTOCOL_VERSION_2026 = '2026-07-28'
|
|
18
|
+
MCP_PROTOCOL_VERSION_LEGACY = '2025-11-25'
|
|
19
|
+
REGISTERED_TOOL_NAMES = {t['name'] for t in MCP_TOOL_DEFINITIONS}
|
|
20
|
+
VALID_2026_METHODS = {'server/discover', 'tools/list', 'tools/call', 'ping'}
|
|
21
|
+
legacy_sessions = {}
|
|
22
|
+
PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR = -32700, -32600, -32601, -32602, -32603
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def make_jsonrpc_response(request_id, result):
|
|
26
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'result': result}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def make_jsonrpc_error(request_id, code, message, data=None):
|
|
30
|
+
error = {'code': code, 'message': message}
|
|
31
|
+
if data is not None:
|
|
32
|
+
error['data'] = data
|
|
33
|
+
return {'jsonrpc': '2.0', 'id': request_id, 'error': error}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_rpc_envelope(payload):
|
|
37
|
+
if not isinstance(payload, dict) or payload.get('jsonrpc') != '2.0':
|
|
38
|
+
return make_jsonrpc_error(None, INVALID_REQUEST, 'Invalid JSON-RPC 2.0 request.')
|
|
39
|
+
request_id = payload.get('id')
|
|
40
|
+
if request_id is not None and (isinstance(request_id, bool) or not isinstance(request_id, (str, int))):
|
|
41
|
+
return make_jsonrpc_error(None, INVALID_REQUEST, 'Invalid request id.')
|
|
42
|
+
if not isinstance(payload.get('method'), str) or not payload['method']:
|
|
43
|
+
return make_jsonrpc_error(None, INVALID_REQUEST, 'Missing or invalid method.')
|
|
44
|
+
if not isinstance(payload.get('params', {}), dict):
|
|
45
|
+
return make_jsonrpc_error(request_id, INVALID_PARAMS, 'Params must be an object.')
|
|
46
|
+
if payload['method'] == 'tools/call':
|
|
47
|
+
params = payload.get('params', {})
|
|
48
|
+
if not isinstance(params.get('name'), str) or not params['name'] or not isinstance(params.get('arguments', {}), dict):
|
|
49
|
+
return make_jsonrpc_error(request_id, INVALID_PARAMS, 'A tool name and object arguments are required.')
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def validate_2026_mcp_headers(header_mcp_version: Optional[str], header_mcp_method: Optional[str], header_mcp_name: Optional[str], payload):
|
|
54
|
+
error = validate_rpc_envelope(payload)
|
|
55
|
+
if error:
|
|
56
|
+
raise HTTPException(400, detail=error['error'])
|
|
57
|
+
|
|
58
|
+
def fail(code, message) -> NoReturn:
|
|
59
|
+
raise HTTPException(400, detail={'error': code, 'message': message})
|
|
60
|
+
|
|
61
|
+
if header_mcp_version and header_mcp_version != MCP_PROTOCOL_VERSION_2026:
|
|
62
|
+
fail('INVALID_PROTOCOL_VERSION', 'Unsupported protocol version.')
|
|
63
|
+
if not header_mcp_method:
|
|
64
|
+
fail('MISSING_MCP_METHOD', "Header 'Mcp-Method' is required.")
|
|
65
|
+
method = header_mcp_method.strip()
|
|
66
|
+
if method not in VALID_2026_METHODS:
|
|
67
|
+
fail('INVALID_MCP_METHOD', 'Unsupported method header.')
|
|
68
|
+
if method != payload['method']:
|
|
69
|
+
fail('MISMATCHED_MCP_METHOD', 'Method header does not match body.')
|
|
70
|
+
if method == 'tools/call':
|
|
71
|
+
if not header_mcp_name:
|
|
72
|
+
fail('MISSING_MCP_NAME', "Header 'Mcp-Name' is required for tools/call.")
|
|
73
|
+
if header_mcp_name.strip() not in REGISTERED_TOOL_NAMES:
|
|
74
|
+
fail('INVALID_MCP_NAME', 'Tool is not registered.')
|
|
75
|
+
if header_mcp_name.strip() != payload['params']['name']:
|
|
76
|
+
fail('MISMATCHED_MCP_NAME', 'Tool header does not match body.')
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def _tool_result(request_id, params, api_key):
|
|
80
|
+
name = params['name']
|
|
81
|
+
if name not in REGISTERED_TOOL_NAMES:
|
|
82
|
+
return make_jsonrpc_error(request_id, INVALID_PARAMS, 'Unknown tool.'), 400
|
|
83
|
+
start = time.monotonic()
|
|
84
|
+
success = False
|
|
85
|
+
try:
|
|
86
|
+
raw = await handle_mcp_tool_call(name, params.get('arguments', {}))
|
|
87
|
+
success = not (isinstance(raw, dict) and raw.get('error'))
|
|
88
|
+
result = {'content': [{'type': 'text', 'text': json.dumps(raw, ensure_ascii=False)}], 'isError': not success}
|
|
89
|
+
return make_jsonrpc_response(request_id, result), 200
|
|
90
|
+
except ValidationError:
|
|
91
|
+
return make_jsonrpc_error(request_id, INVALID_PARAMS, 'Invalid tool arguments.'), 400
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
text = 'Tool execution failed.'
|
|
94
|
+
if isinstance(exc, HTTPException) and isinstance(exc.detail, dict):
|
|
95
|
+
code = exc.detail.get('error')
|
|
96
|
+
if isinstance(code, str) and code.isupper() and code.replace('_', '').isalpha() and len(code) <= 64:
|
|
97
|
+
text += ' ' + code
|
|
98
|
+
logger.error('MCP tool execution failed', extra={'extra_data': {'tool': name, 'exception_type': type(exc).__name__}})
|
|
99
|
+
return make_jsonrpc_response(request_id, {'content': [{'type': 'text', 'text': text}], 'isError': True}), 200
|
|
100
|
+
finally:
|
|
101
|
+
metrics.record(name, round((time.monotonic() - start) * 1000, 2), success=success, api_key=api_key)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_rpc_notification(payload):
|
|
105
|
+
return (isinstance(payload, dict) and payload.get('jsonrpc') == '2.0'
|
|
106
|
+
and isinstance(payload.get('method'), str) and bool(payload['method'])
|
|
107
|
+
and 'id' not in payload)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def process_mcp_2026_stateless(payload, api_key: Optional[str] = None):
|
|
111
|
+
if is_rpc_notification(payload):
|
|
112
|
+
return {}, 202
|
|
113
|
+
error = validate_rpc_envelope(payload)
|
|
114
|
+
if error:
|
|
115
|
+
return error, 400
|
|
116
|
+
if 'id' not in payload:
|
|
117
|
+
return {}, 202
|
|
118
|
+
request_id, method = payload.get('id'), payload['method']
|
|
119
|
+
if method == 'ping':
|
|
120
|
+
return make_jsonrpc_response(request_id, {}), 200
|
|
121
|
+
if method == 'server/discover':
|
|
122
|
+
return make_jsonrpc_response(request_id, {'protocolVersion': MCP_PROTOCOL_VERSION_2026, 'server': {'name': 'mcp-web-engine', 'version': __version__}, 'capabilities': {'tools': True}}), 200
|
|
123
|
+
if method == 'tools/list':
|
|
124
|
+
return make_jsonrpc_response(request_id, {'tools': MCP_TOOL_DEFINITIONS, 'cacheScope': 'global', 'ttlMs': 3600000, 'listChanged': False}), 200
|
|
125
|
+
if method == 'tools/call':
|
|
126
|
+
return await _tool_result(request_id, payload['params'], api_key)
|
|
127
|
+
return make_jsonrpc_error(request_id, METHOD_NOT_FOUND, 'Method not found.'), 404
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def process_mcp_2025_legacy_stateful(payload, session_id: Optional[str] = None, api_key: Optional[str] = None):
|
|
131
|
+
if is_rpc_notification(payload):
|
|
132
|
+
return {}, 202, ''
|
|
133
|
+
error = validate_rpc_envelope(payload)
|
|
134
|
+
if error:
|
|
135
|
+
return error, 400, ''
|
|
136
|
+
if 'id' not in payload:
|
|
137
|
+
return {}, 202, ''
|
|
138
|
+
now = time.monotonic()
|
|
139
|
+
owner = hashlib.sha256((api_key or 'local-stdio').encode()).hexdigest()
|
|
140
|
+
for key in list(legacy_sessions):
|
|
141
|
+
if now - legacy_sessions[key]['created'] >= 3600:
|
|
142
|
+
del legacy_sessions[key]
|
|
143
|
+
session = legacy_sessions.get(session_id)
|
|
144
|
+
if session is None or session['owner'] != owner:
|
|
145
|
+
session_id = ''
|
|
146
|
+
request_id, method = payload.get('id'), payload['method']
|
|
147
|
+
params = payload.get('params', {})
|
|
148
|
+
if method == 'initialize':
|
|
149
|
+
if not session_id:
|
|
150
|
+
if len(legacy_sessions) >= 1024:
|
|
151
|
+
del legacy_sessions[next(iter(legacy_sessions))]
|
|
152
|
+
session_id = 'mcp_sess_legacy_' + secrets.token_hex(16)
|
|
153
|
+
legacy_sessions[session_id] = {'created': now, 'owner': owner}
|
|
154
|
+
supported = {'2024-11-05', '2025-03-26', '2025-06-18', MCP_PROTOCOL_VERSION_LEGACY}
|
|
155
|
+
version = params.get('protocolVersion')
|
|
156
|
+
version = version if isinstance(version, str) and version in supported else MCP_PROTOCOL_VERSION_LEGACY
|
|
157
|
+
result = {'protocolVersion': version, 'capabilities': {'tools': {'listChanged': False}}, 'serverInfo': {'name': 'mcp-web-engine', 'version': __version__}}
|
|
158
|
+
return make_jsonrpc_response(request_id, result), 200, session_id
|
|
159
|
+
if method == 'ping':
|
|
160
|
+
return make_jsonrpc_response(request_id, {}), 200, session_id
|
|
161
|
+
if method == 'tools/list':
|
|
162
|
+
return make_jsonrpc_response(request_id, {'tools': MCP_TOOL_DEFINITIONS}), 200, session_id
|
|
163
|
+
if method == 'tools/call':
|
|
164
|
+
result, http_status = await _tool_result(request_id, params, api_key)
|
|
165
|
+
return result, http_status, session_id
|
|
166
|
+
return make_jsonrpc_error(request_id, METHOD_NOT_FOUND, 'Method not found.'), 404, session_id
|
|
@@ -2,20 +2,23 @@
|
|
|
2
2
|
MCP Tools Definition & Schema Handlers (web_search, fetch_url, extract_markdown)
|
|
3
3
|
"""
|
|
4
4
|
from typing import Optional
|
|
5
|
+
|
|
5
6
|
from pydantic import BaseModel, Field
|
|
6
|
-
|
|
7
|
+
|
|
8
|
+
from .web_engine import execute_extract_markdown, execute_fetch_url, execute_web_search
|
|
9
|
+
|
|
7
10
|
|
|
8
11
|
class WebSearchInput(BaseModel):
|
|
9
|
-
query: str = Field(..., description="The search query keywords", min_length=2)
|
|
10
|
-
limit:
|
|
12
|
+
query: str = Field(..., description="The search query keywords", min_length=2, max_length=4096)
|
|
13
|
+
limit: int = Field(default=10, description="Max results to return (1-25)", ge=1, le=25)
|
|
11
14
|
|
|
12
15
|
class FetchUrlInput(BaseModel):
|
|
13
|
-
url: str = Field(..., description="Target
|
|
14
|
-
max_bytes: Optional[int] = Field(default=None, description="
|
|
16
|
+
url: str = Field(..., description="Target public HTTP(S) URL", min_length=1, max_length=8192)
|
|
17
|
+
max_bytes: Optional[int] = Field(default=None, description="Response byte limit, capped by operator MAX_PAYLOAD_BYTES", ge=1, le=16 * 1024 * 1024)
|
|
15
18
|
|
|
16
19
|
class ExtractMarkdownInput(BaseModel):
|
|
17
|
-
url: str = Field(..., description="Target
|
|
18
|
-
max_bytes: Optional[int] = Field(default=None, description="
|
|
20
|
+
url: str = Field(..., description="Target public HTTP(S) URL", min_length=1, max_length=8192)
|
|
21
|
+
max_bytes: Optional[int] = Field(default=None, description="Response byte limit, capped by operator MAX_PAYLOAD_BYTES", ge=1, le=16 * 1024 * 1024)
|
|
19
22
|
|
|
20
23
|
# MCP Protocol Tool Definitions
|
|
21
24
|
MCP_TOOL_DEFINITIONS = [
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Bound HTTP request buffering independently of endpoint/auth parsing."""
|
|
2
|
+
from starlette.responses import JSONResponse
|
|
3
|
+
|
|
4
|
+
from .config import settings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RequestSizeLimitMiddleware:
|
|
8
|
+
def __init__(self, app):
|
|
9
|
+
self.app = app
|
|
10
|
+
|
|
11
|
+
async def __call__(self, scope, receive, send):
|
|
12
|
+
if scope['type'] != 'http':
|
|
13
|
+
return await self.app(scope, receive, send)
|
|
14
|
+
limit = settings.MAX_PAYLOAD_BYTES
|
|
15
|
+
length = next((value for name, value in scope.get('headers', []) if name.lower() == b'content-length'), None)
|
|
16
|
+
if length is not None:
|
|
17
|
+
try:
|
|
18
|
+
size = int(length)
|
|
19
|
+
if size < 0:
|
|
20
|
+
raise ValueError
|
|
21
|
+
except ValueError:
|
|
22
|
+
return await JSONResponse({'error': 'INVALID_CONTENT_LENGTH'}, 400)(scope, receive, send)
|
|
23
|
+
if size > limit:
|
|
24
|
+
return await JSONResponse({'error': 'PAYLOAD_TOO_LARGE'}, 413)(scope, receive, send)
|
|
25
|
+
body = bytearray()
|
|
26
|
+
while True:
|
|
27
|
+
message = await receive()
|
|
28
|
+
if message['type'] == 'http.disconnect':
|
|
29
|
+
return
|
|
30
|
+
chunk = message.get('body', b'')
|
|
31
|
+
if len(body) + len(chunk) > limit:
|
|
32
|
+
return await JSONResponse({'error': 'PAYLOAD_TOO_LARGE'}, 413)(scope, receive, send)
|
|
33
|
+
body.extend(chunk)
|
|
34
|
+
if not message.get('more_body', False):
|
|
35
|
+
break
|
|
36
|
+
delivered = False
|
|
37
|
+
|
|
38
|
+
async def bounded_receive():
|
|
39
|
+
nonlocal delivered
|
|
40
|
+
if not delivered:
|
|
41
|
+
delivered = True
|
|
42
|
+
return {'type': 'http.request', 'body': bytes(body), 'more_body': False}
|
|
43
|
+
return await receive()
|
|
44
|
+
|
|
45
|
+
return await self.app(scope, bounded_receive, send)
|