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,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 sys
7
- import os
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 argparse
13
+ from pathlib import Path
12
14
 
13
- KEYS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "beta_keys.json")
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 Exception:
22
- return {}
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
- with open(KEYS_FILE, "w", encoding="utf-8") as f:
26
- json.dump(keys, f, indent=2)
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
- t = info.get("telemetry", {})
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
- from web_engine import execute_web_search, execute_fetch_url, execute_extract_markdown
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: Optional[int] = Field(default=10, description="Max results to return (1-25)", ge=1, le=25)
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 URL to fetch content from")
14
- max_bytes: Optional[int] = Field(default=None, description="Max bytes payload limit")
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 URL to convert to clean Markdown")
18
- max_bytes: Optional[int] = Field(default=None, description="Max bytes payload limit")
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)