public_server.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  1. import hashlib
  2. import json
  3. import logging
  4. import threading
  5. import time
  6. import uuid
  7. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  8. from constants import DEVICE_INVALID_MESSAGE
  9. from mcp_protocol import McpProtocolHandler
  10. from services.diagnostic_event import RequestDiagnosticEmitter
  11. from services.diagnostic_reporter import NullDiagnosticReporter
  12. from services.request_context import RequestContextParser
  13. from utils.rate_limiter import SimpleRateLimiter
  14. logger = logging.getLogger(__name__)
  15. def extract_client_ip(headers, client_address):
  16. if client_address and len(client_address) > 0:
  17. return str(client_address[0])
  18. return ''
  19. class PublicMcpHttpHandler:
  20. def __init__(
  21. self,
  22. gateway_app,
  23. context_parser=None,
  24. rate_limiter=None,
  25. reporter=None,
  26. ):
  27. self.gateway_app = gateway_app
  28. self.context_parser = context_parser or RequestContextParser()
  29. self.rate_limiter = rate_limiter
  30. self.reporter = reporter or NullDiagnosticReporter()
  31. # Cache registered tool names at startup for rate-key validation;
  32. # unknown names fall back to the bare IP bucket, preventing bucket explosion
  33. self._known_tools = frozenset(gateway_app.registered_tool_names())
  34. @staticmethod
  35. def _build_trace_request_id(headers):
  36. return 'rq_http_{0}'.format(uuid.uuid4().hex[:16])
  37. @staticmethod
  38. def _client_request_id(headers):
  39. incoming = ''
  40. for key, value in (headers or {}).items():
  41. if str(key).lower() == 'x-request-id':
  42. incoming = str(value or '').strip()
  43. break
  44. if not incoming:
  45. return ''
  46. return hashlib.sha256(incoming.encode('utf-8')).hexdigest()[:16]
  47. def _check_rate_limit(
  48. self,
  49. rate_key,
  50. method,
  51. trace_request_id,
  52. jsonrpc_id=None,
  53. tool_name='',
  54. *,
  55. log_identity=None,
  56. diagnostic_emitter=None,
  57. ):
  58. """Returns an error response if rate limit exceeded, else None.
  59. rate_key — the session-based bucket key used for tools/call
  60. log_identity — optional string shown in warning logs (e.g. client_ip)
  61. """
  62. if self.rate_limiter and rate_key and not self.rate_limiter.is_allowed(rate_key):
  63. if diagnostic_emitter is not None:
  64. diagnostic_emitter.emit(
  65. stage='rate_limit',
  66. status='failed',
  67. event_code='RATE_LIMIT_EXCEEDED',
  68. tool_code=tool_name or None,
  69. context={
  70. 'limit_type': 'request_window',
  71. 'transport': 'http',
  72. },
  73. )
  74. logger.warning(
  75. "MCP public rate limit exceeded",
  76. extra={
  77. 'request_id': trace_request_id,
  78. 'jsonrpc_id': jsonrpc_id,
  79. 'protocol_method': method,
  80. 'tool_code': tool_name,
  81. 'client_identity': log_identity or rate_key,
  82. 'protocol_code': -32029,
  83. 'diagnostic_reason': 'RATE_LIMIT_EXCEEDED',
  84. },
  85. )
  86. return McpProtocolHandler._error_response(
  87. jsonrpc_id,
  88. -32029,
  89. 'Rate limit exceeded. Please try again later.',
  90. trace_request_id,
  91. )
  92. return None
  93. def handle_json_rpc(
  94. self,
  95. headers,
  96. message,
  97. client_ip='',
  98. trace_request_id='',
  99. diagnostic_emitter=None,
  100. ):
  101. request_id = message.get('id') if isinstance(message, dict) else None
  102. trace_request_id = str(trace_request_id or '').strip() \
  103. or self._build_trace_request_id(headers)
  104. method = str((message or {}).get('method') or '').strip()
  105. message_params = (message or {}).get('params') or {}
  106. tool_name = str(message_params.get('name') or '').strip() \
  107. if isinstance(message_params, dict) else ''
  108. emitter = diagnostic_emitter or RequestDiagnosticEmitter(
  109. self.reporter,
  110. trace_request_id,
  111. defer_until_identity=True,
  112. )
  113. emitter.emit(
  114. stage='request_ingress',
  115. status='started',
  116. event_code='REQUEST_RECEIVED',
  117. context={'jsonrpc_method': method or 'unknown', 'transport': 'http'},
  118. )
  119. protocol_validated = False
  120. try:
  121. if method == 'initialize':
  122. emitter.emit(
  123. stage='protocol_validation',
  124. status='succeeded',
  125. event_code='PROTOCOL_VALIDATION_COMPLETED',
  126. context={
  127. 'jsonrpc_method': method,
  128. 'transport': 'http',
  129. },
  130. )
  131. protocol_validated = True
  132. # initialize is a stateless handshake that only returns server metadata;
  133. # rate-limiting it would block clients from connecting at all, so we skip it.
  134. return McpProtocolHandler._success_response(request_id, {
  135. 'protocolVersion': McpProtocolHandler.protocol_version,
  136. 'capabilities': {'tools': {'listChanged': False}},
  137. 'serverInfo': {
  138. 'name': McpProtocolHandler.server_name,
  139. 'version': McpProtocolHandler.server_version,
  140. },
  141. })
  142. if method == 'tools/list':
  143. emitter.emit(
  144. stage='protocol_validation',
  145. status='succeeded',
  146. event_code='PROTOCOL_VALIDATION_COMPLETED',
  147. context={
  148. 'jsonrpc_method': method,
  149. 'transport': 'http',
  150. },
  151. )
  152. protocol_validated = True
  153. context = self.context_parser.parse(headers or {})
  154. if not context.has_session():
  155. emitter.emit(
  156. stage='gateway_session',
  157. status='failed',
  158. event_code='GATEWAY_SESSION_NOT_FOUND',
  159. context={'transport': 'http'},
  160. )
  161. logger.warning(
  162. 'MCP public device session unavailable',
  163. extra={
  164. 'request_id': trace_request_id,
  165. 'jsonrpc_id': request_id,
  166. 'protocol_method': method,
  167. 'tool_code': '',
  168. 'protocol_code': -32001,
  169. 'diagnostic_reason': 'GATEWAY_SESSION_NOT_FOUND',
  170. },
  171. )
  172. return McpProtocolHandler._error_response(
  173. request_id,
  174. -32001,
  175. DEVICE_INVALID_MESSAGE,
  176. trace_request_id,
  177. )
  178. tools = []
  179. for tool in self.gateway_app.list_tools(
  180. context.gateway_session_id,
  181. request_id=trace_request_id,
  182. ):
  183. normalized = dict(tool)
  184. if 'input_schema' in normalized:
  185. normalized['inputSchema'] = normalized.pop('input_schema')
  186. tools.append(normalized)
  187. return McpProtocolHandler._success_response(request_id, {'tools': tools})
  188. if method == 'tools/call':
  189. if not isinstance(message_params, dict):
  190. raise ValueError('tool parameters must be an object')
  191. if not tool_name:
  192. emitter.emit(
  193. stage='protocol_validation',
  194. status='failed',
  195. event_code='PARAM_VALIDATION_FAILED',
  196. context={
  197. 'jsonrpc_method': method,
  198. 'jsonrpc_code': -32602,
  199. 'transport': 'http',
  200. },
  201. )
  202. return McpProtocolHandler._error_response(
  203. request_id,
  204. -32602,
  205. 'Invalid params',
  206. trace_request_id,
  207. )
  208. emitter.emit(
  209. stage='protocol_validation',
  210. status='succeeded',
  211. event_code='PROTOCOL_VALIDATION_COMPLETED',
  212. tool_code=tool_name,
  213. context={
  214. 'jsonrpc_method': method,
  215. 'transport': 'http',
  216. },
  217. )
  218. protocol_validated = True
  219. # Parse context first — tools/call always requires a valid session
  220. context = self.context_parser.parse(headers or {})
  221. if not context.has_session():
  222. emitter.emit(
  223. stage='gateway_session',
  224. status='failed',
  225. event_code='GATEWAY_SESSION_NOT_FOUND',
  226. context={'transport': 'http'},
  227. )
  228. logger.warning(
  229. 'MCP public device session unavailable',
  230. extra={
  231. 'request_id': trace_request_id,
  232. 'jsonrpc_id': request_id,
  233. 'protocol_method': method,
  234. 'tool_code': tool_name,
  235. 'protocol_code': -32001,
  236. 'diagnostic_reason': 'GATEWAY_SESSION_NOT_FOUND',
  237. },
  238. )
  239. return McpProtocolHandler._error_response(
  240. request_id,
  241. -32001,
  242. DEVICE_INVALID_MESSAGE,
  243. trace_request_id,
  244. )
  245. # Session-based rate limiting: each employee gets an independent quota per tool.
  246. # Unknown tool names fall back to the bare session bucket to prevent key explosion.
  247. session_id = context.gateway_session_id
  248. rate_key = '{0}:{1}'.format(session_id, tool_name) if tool_name in self._known_tools else session_id
  249. blocked = self._check_rate_limit(
  250. rate_key,
  251. method,
  252. trace_request_id,
  253. request_id,
  254. tool_name,
  255. log_identity=client_ip,
  256. diagnostic_emitter=emitter,
  257. )
  258. if blocked:
  259. return blocked
  260. params = message_params
  261. acquired = self.rate_limiter is None \
  262. or self.rate_limiter.try_acquire(rate_key)
  263. if not acquired:
  264. emitter.emit(
  265. stage='rate_limit',
  266. status='failed',
  267. event_code='CONCURRENCY_LIMIT_EXCEEDED',
  268. tool_code=tool_name or None,
  269. context={
  270. 'limit_type': 'concurrency',
  271. 'transport': 'http',
  272. },
  273. )
  274. logger.warning(
  275. 'MCP public concurrency limit exceeded',
  276. extra={
  277. 'request_id': trace_request_id,
  278. 'jsonrpc_id': request_id,
  279. 'protocol_method': method,
  280. 'tool_code': tool_name,
  281. 'client_identity': client_ip,
  282. 'protocol_code': -32029,
  283. 'diagnostic_reason': 'CONCURRENCY_LIMIT_EXCEEDED',
  284. },
  285. )
  286. return McpProtocolHandler._error_response(
  287. request_id,
  288. -32029,
  289. 'Too many requests in progress. Please try again later.',
  290. trace_request_id,
  291. )
  292. emitter.emit(
  293. stage='rate_limit',
  294. status='succeeded',
  295. event_code='RATE_LIMIT_ALLOWED',
  296. tool_code=tool_name or None,
  297. context={
  298. 'limit_type': 'tools_call',
  299. 'transport': 'http',
  300. },
  301. )
  302. try:
  303. result = self.gateway_app.call_tool(
  304. session_id,
  305. params.get('name'),
  306. params.get('arguments') or {},
  307. request_id=trace_request_id,
  308. client_ip=client_ip,
  309. diagnostic_emitter=emitter,
  310. )
  311. finally:
  312. if self.rate_limiter is not None:
  313. self.rate_limiter.release(rate_key)
  314. try:
  315. response = McpProtocolHandler._tool_call_response(
  316. request_id,
  317. tool_name,
  318. result,
  319. )
  320. except Exception:
  321. emitter.emit(
  322. stage='response_safety',
  323. status='failed',
  324. event_code='RESPONSE_SAFETY_REJECTED',
  325. context={'transport': 'http'},
  326. )
  327. raise
  328. emitter.emit(
  329. stage='response_safety',
  330. status='succeeded',
  331. event_code='RESPONSE_SAFETY_COMPLETED',
  332. context={'transport': 'http'},
  333. )
  334. return response
  335. emitter.emit(
  336. stage='protocol_validation',
  337. status='failed',
  338. event_code='METHOD_NOT_FOUND',
  339. context={
  340. 'jsonrpc_method': method or 'unknown',
  341. 'jsonrpc_code': -32601,
  342. 'transport': 'http',
  343. },
  344. )
  345. return McpProtocolHandler._error_response(
  346. request_id,
  347. -32601,
  348. 'Method not found: {0}'.format(method),
  349. trace_request_id,
  350. )
  351. except Exception as exc:
  352. if not protocol_validated:
  353. emitter.emit(
  354. stage='protocol_validation',
  355. status='failed',
  356. event_code='PARAM_VALIDATION_FAILED',
  357. tool_code=tool_name or None,
  358. context={
  359. 'jsonrpc_method': method or 'unknown',
  360. 'jsonrpc_code': -32602,
  361. 'transport': 'http',
  362. },
  363. )
  364. if method == 'tools/call':
  365. diagnostic_reason = (
  366. 'PARAM_VALIDATION_FAILED'
  367. if isinstance(exc, ValueError)
  368. else 'UNEXPECTED_EXCEPTION'
  369. )
  370. logger.error(
  371. 'MCP public tool request failed',
  372. extra={
  373. 'request_id': trace_request_id,
  374. 'jsonrpc_id': request_id,
  375. 'protocol_method': method,
  376. 'tool_code': tool_name,
  377. 'response_code': 'MCP_9001',
  378. 'diagnostic_reason': diagnostic_reason,
  379. 'exception_class': exc.__class__.__name__,
  380. },
  381. )
  382. return McpProtocolHandler._tool_exception_response(
  383. request_id,
  384. tool_name,
  385. exc,
  386. trace_request_id,
  387. )
  388. logger.error(
  389. "MCP public request failed",
  390. extra={
  391. 'request_id': trace_request_id,
  392. 'jsonrpc_id': request_id,
  393. 'protocol_method': method,
  394. 'tool_code': tool_name,
  395. 'protocol_code': -32000,
  396. 'diagnostic_reason': 'UNEXPECTED_EXCEPTION',
  397. 'exception_class': exc.__class__.__name__,
  398. },
  399. )
  400. return McpProtocolHandler._error_response(
  401. request_id,
  402. -32000,
  403. 'Gateway request failed. Please try again later.',
  404. trace_request_id,
  405. )
  406. finally:
  407. emitter.flush()
  408. def create_http_handler(gateway_app, rate_limiter=None, reporter=None):
  409. rpc_handler = PublicMcpHttpHandler(
  410. gateway_app,
  411. rate_limiter=rate_limiter,
  412. reporter=reporter,
  413. )
  414. class Handler(BaseHTTPRequestHandler):
  415. def log_message(self, format, *args):
  416. # Override to use Python logging instead of stderr
  417. logger.info(f"{self.address_string()} - {format % args}")
  418. def do_GET(self):
  419. if self.path == '/health':
  420. self._write_json({'ok': True})
  421. return
  422. self.send_response(404)
  423. self.end_headers()
  424. def do_POST(self):
  425. request_headers = dict(self.headers.items())
  426. client_ip = extract_client_ip(request_headers, self.client_address)
  427. trace_request_id = rpc_handler._build_trace_request_id(request_headers)
  428. diagnostic_emitter = RequestDiagnosticEmitter(
  429. rpc_handler.reporter,
  430. trace_request_id,
  431. defer_until_identity=True,
  432. )
  433. client_request_id_hash = rpc_handler._client_request_id(
  434. request_headers
  435. )
  436. length = int(self.headers.get('Content-Length') or '0')
  437. if self.path != '/mcp':
  438. logger.warning(f"[HTTP] 404: ip={client_ip}, path={self.path}")
  439. if length > 0:
  440. self.rfile.read(length)
  441. self.send_response(404)
  442. self.end_headers()
  443. return
  444. body = self.rfile.read(length).decode('utf-8-sig')
  445. try:
  446. message = json.loads(body)
  447. except json.JSONDecodeError as exc:
  448. diagnostic_emitter.emit(
  449. stage='request_ingress',
  450. status='started',
  451. event_code='REQUEST_RECEIVED',
  452. context={
  453. 'jsonrpc_method': 'unknown',
  454. 'transport': 'http',
  455. },
  456. )
  457. diagnostic_emitter.emit(
  458. stage='protocol_validation',
  459. status='failed',
  460. event_code='PARAM_VALIDATION_FAILED',
  461. context={
  462. 'jsonrpc_code': -32700,
  463. 'transport': 'http',
  464. },
  465. )
  466. logger.warning(
  467. 'MCP public invalid JSON',
  468. extra={
  469. 'request_id': trace_request_id,
  470. 'jsonrpc_id': None,
  471. 'protocol_method': '',
  472. 'tool_code': '',
  473. 'protocol_code': -32700,
  474. 'diagnostic_reason': 'PARAM_VALIDATION_FAILED',
  475. 'exception_class': exc.__class__.__name__,
  476. 'client_identity': client_ip,
  477. },
  478. )
  479. self._write_json(
  480. McpProtocolHandler._error_response(
  481. None,
  482. -32700,
  483. 'Parse error',
  484. trace_request_id,
  485. ),
  486. diagnostic_emitter=diagnostic_emitter,
  487. )
  488. return
  489. method = message.get('method', '')
  490. request_id = message.get('id') if message.get('id') is not None else ''
  491. logger.info(
  492. 'MCP public request',
  493. extra={
  494. 'request_id': trace_request_id,
  495. 'jsonrpc_id': request_id,
  496. 'protocol_method': method,
  497. 'client_identity': client_ip,
  498. 'client_request_id_hash': client_request_id_hash,
  499. },
  500. )
  501. response = rpc_handler.handle_json_rpc(
  502. request_headers,
  503. message,
  504. client_ip,
  505. trace_request_id=trace_request_id,
  506. diagnostic_emitter=diagnostic_emitter,
  507. )
  508. self._write_json(
  509. response,
  510. diagnostic_emitter=diagnostic_emitter,
  511. )
  512. def _write_json(self, payload, diagnostic_emitter=None):
  513. raw = json.dumps(payload, ensure_ascii=False).encode('utf-8')
  514. try:
  515. self.send_response(200)
  516. self.send_header('Content-Type', 'application/json; charset=utf-8')
  517. self.send_header('Content-Length', str(len(raw)))
  518. self.end_headers()
  519. self.wfile.write(raw)
  520. if diagnostic_emitter is not None:
  521. diagnostic_emitter.emit(
  522. stage='response_write',
  523. status='succeeded',
  524. event_code='RESPONSE_WRITE_COMPLETED',
  525. context={
  526. 'http_status': 200,
  527. 'client_disconnected': False,
  528. 'transport': 'http',
  529. },
  530. )
  531. except (BrokenPipeError, ConnectionResetError):
  532. self.close_connection = True
  533. if diagnostic_emitter is not None:
  534. diagnostic_emitter.emit(
  535. stage='response_write',
  536. status='failed',
  537. event_code='CLIENT_DISCONNECTED',
  538. context={
  539. 'http_status': 200,
  540. 'client_disconnected': True,
  541. 'transport': 'http',
  542. },
  543. )
  544. logger.info('MCP client disconnected before response was written')
  545. finally:
  546. if diagnostic_emitter is not None:
  547. diagnostic_emitter.flush()
  548. return Handler
  549. def serve_public(gateway_app, host='0.0.0.0', port=8765, enable_rate_limit=True,
  550. rate_limit_max_requests=60, rate_limit_window_seconds=60,
  551. max_in_flight_per_tool=2, reporter=None):
  552. # Configure logging
  553. logging.basicConfig(
  554. level=logging.INFO,
  555. format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
  556. datefmt='%Y-%m-%d %H:%M:%S'
  557. )
  558. # Configure rate limiting
  559. rate_limiter = None
  560. if enable_rate_limit:
  561. rate_limiter = SimpleRateLimiter(
  562. max_requests=rate_limit_max_requests,
  563. window_seconds=rate_limit_window_seconds,
  564. max_in_flight=max_in_flight_per_tool,
  565. )
  566. logger.info(f"Rate limiting enabled: {rate_limit_max_requests} requests/{rate_limit_window_seconds}s and {max_in_flight_per_tool} in-flight per session and tool (tools/call only)")
  567. logger.info(f"Starting public MCP Gateway on {host}:{port}")
  568. server = ThreadingHTTPServer(
  569. (host, int(port)),
  570. create_http_handler(gateway_app, rate_limiter, reporter=reporter),
  571. )
  572. # Schedule periodic cleanup to prevent unbounded memory growth in the rate limiter
  573. if rate_limiter is not None:
  574. def _cleanup_loop():
  575. while True:
  576. time.sleep(300)
  577. # Use the actual window as max_age to avoid deleting entries still within the window
  578. rate_limiter.cleanup(max_age_seconds=rate_limit_window_seconds)
  579. t = threading.Thread(target=_cleanup_loop, daemon=True)
  580. t.start()
  581. try:
  582. server.serve_forever()
  583. except KeyboardInterrupt:
  584. logger.info("Shutting down public MCP Gateway")
  585. server.shutdown()