public_server.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  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.request_context import RequestContextParser
  11. from utils.rate_limiter import SimpleRateLimiter
  12. logger = logging.getLogger(__name__)
  13. def extract_client_ip(headers, client_address):
  14. if client_address and len(client_address) > 0:
  15. return str(client_address[0])
  16. return ''
  17. class PublicMcpHttpHandler:
  18. def __init__(self, gateway_app, context_parser=None, rate_limiter=None):
  19. self.gateway_app = gateway_app
  20. self.context_parser = context_parser or RequestContextParser()
  21. self.rate_limiter = rate_limiter
  22. # Cache registered tool names at startup for rate-key validation;
  23. # unknown names fall back to the bare IP bucket, preventing bucket explosion
  24. self._known_tools = frozenset(gateway_app.registered_tool_names())
  25. @staticmethod
  26. def _build_trace_request_id(headers):
  27. return 'rq_http_{0}'.format(uuid.uuid4().hex[:16])
  28. @staticmethod
  29. def _client_request_id(headers):
  30. incoming = ''
  31. for key, value in (headers or {}).items():
  32. if str(key).lower() == 'x-request-id':
  33. incoming = str(value or '').strip()
  34. break
  35. if not incoming:
  36. return ''
  37. return hashlib.sha256(incoming.encode('utf-8')).hexdigest()[:16]
  38. def _check_rate_limit(
  39. self,
  40. rate_key,
  41. method,
  42. trace_request_id,
  43. jsonrpc_id=None,
  44. tool_name='',
  45. *,
  46. log_identity=None
  47. ):
  48. """Returns an error response if rate limit exceeded, else None.
  49. rate_key — the session-based bucket key used for tools/call
  50. log_identity — optional string shown in warning logs (e.g. client_ip)
  51. """
  52. if self.rate_limiter and rate_key and not self.rate_limiter.is_allowed(rate_key):
  53. logger.warning(
  54. "MCP public rate limit exceeded",
  55. extra={
  56. 'request_id': trace_request_id,
  57. 'jsonrpc_id': jsonrpc_id,
  58. 'protocol_method': method,
  59. 'tool_code': tool_name,
  60. 'client_identity': log_identity or rate_key,
  61. 'protocol_code': -32029,
  62. 'diagnostic_reason': 'RATE_LIMIT_EXCEEDED',
  63. },
  64. )
  65. return McpProtocolHandler._error_response(
  66. jsonrpc_id,
  67. -32029,
  68. 'Rate limit exceeded. Please try again later.',
  69. trace_request_id,
  70. )
  71. return None
  72. def handle_json_rpc(
  73. self,
  74. headers,
  75. message,
  76. client_ip='',
  77. trace_request_id='',
  78. ):
  79. request_id = message.get('id') if isinstance(message, dict) else None
  80. trace_request_id = str(trace_request_id or '').strip() \
  81. or self._build_trace_request_id(headers)
  82. method = str((message or {}).get('method') or '').strip()
  83. message_params = (message or {}).get('params') or {}
  84. tool_name = str(message_params.get('name') or '').strip() \
  85. if isinstance(message_params, dict) else ''
  86. try:
  87. if method == 'initialize':
  88. # initialize is a stateless handshake that only returns server metadata;
  89. # rate-limiting it would block clients from connecting at all, so we skip it.
  90. return McpProtocolHandler._success_response(request_id, {
  91. 'protocolVersion': McpProtocolHandler.protocol_version,
  92. 'capabilities': {'tools': {'listChanged': False}},
  93. 'serverInfo': {
  94. 'name': McpProtocolHandler.server_name,
  95. 'version': McpProtocolHandler.server_version,
  96. },
  97. })
  98. if method == 'tools/list':
  99. context = self.context_parser.parse(headers or {})
  100. if not context.has_session():
  101. logger.warning(
  102. 'MCP public device session unavailable',
  103. extra={
  104. 'request_id': trace_request_id,
  105. 'jsonrpc_id': request_id,
  106. 'protocol_method': method,
  107. 'tool_code': '',
  108. 'protocol_code': -32001,
  109. 'diagnostic_reason': 'GATEWAY_SESSION_NOT_FOUND',
  110. },
  111. )
  112. return McpProtocolHandler._error_response(
  113. request_id,
  114. -32001,
  115. DEVICE_INVALID_MESSAGE,
  116. trace_request_id,
  117. )
  118. tools = []
  119. for tool in self.gateway_app.list_tools(
  120. context.gateway_session_id,
  121. request_id=trace_request_id,
  122. ):
  123. normalized = dict(tool)
  124. if 'input_schema' in normalized:
  125. normalized['inputSchema'] = normalized.pop('input_schema')
  126. tools.append(normalized)
  127. return McpProtocolHandler._success_response(request_id, {'tools': tools})
  128. if method == 'tools/call':
  129. if not isinstance(message_params, dict):
  130. raise ValueError('tool parameters must be an object')
  131. # Parse context first — tools/call always requires a valid session
  132. context = self.context_parser.parse(headers or {})
  133. if not context.has_session():
  134. logger.warning(
  135. 'MCP public device session unavailable',
  136. extra={
  137. 'request_id': trace_request_id,
  138. 'jsonrpc_id': request_id,
  139. 'protocol_method': method,
  140. 'tool_code': tool_name,
  141. 'protocol_code': -32001,
  142. 'diagnostic_reason': 'GATEWAY_SESSION_NOT_FOUND',
  143. },
  144. )
  145. return McpProtocolHandler._error_response(
  146. request_id,
  147. -32001,
  148. DEVICE_INVALID_MESSAGE,
  149. trace_request_id,
  150. )
  151. # Session-based rate limiting: each employee gets an independent quota per tool.
  152. # Unknown tool names fall back to the bare session bucket to prevent key explosion.
  153. session_id = context.gateway_session_id
  154. rate_key = '{0}:{1}'.format(session_id, tool_name) if tool_name in self._known_tools else session_id
  155. blocked = self._check_rate_limit(
  156. rate_key,
  157. method,
  158. trace_request_id,
  159. request_id,
  160. tool_name,
  161. log_identity=client_ip,
  162. )
  163. if blocked:
  164. return blocked
  165. params = message_params
  166. acquired = self.rate_limiter is None \
  167. or self.rate_limiter.try_acquire(rate_key)
  168. if not acquired:
  169. logger.warning(
  170. 'MCP public concurrency limit exceeded',
  171. extra={
  172. 'request_id': trace_request_id,
  173. 'jsonrpc_id': request_id,
  174. 'protocol_method': method,
  175. 'tool_code': tool_name,
  176. 'client_identity': client_ip,
  177. 'protocol_code': -32029,
  178. 'diagnostic_reason': 'CONCURRENCY_LIMIT_EXCEEDED',
  179. },
  180. )
  181. return McpProtocolHandler._error_response(
  182. request_id,
  183. -32029,
  184. 'Too many requests in progress. Please try again later.',
  185. trace_request_id,
  186. )
  187. try:
  188. result = self.gateway_app.call_tool(
  189. session_id,
  190. params.get('name'),
  191. params.get('arguments') or {},
  192. request_id=trace_request_id,
  193. client_ip=client_ip,
  194. )
  195. finally:
  196. if self.rate_limiter is not None:
  197. self.rate_limiter.release(rate_key)
  198. return McpProtocolHandler._tool_call_response(
  199. request_id,
  200. tool_name,
  201. result,
  202. )
  203. return McpProtocolHandler._error_response(
  204. request_id,
  205. -32601,
  206. 'Method not found: {0}'.format(method),
  207. trace_request_id,
  208. )
  209. except Exception as exc:
  210. if method == 'tools/call':
  211. diagnostic_reason = (
  212. 'PARAM_VALIDATION_FAILED'
  213. if isinstance(exc, ValueError)
  214. else 'UNEXPECTED_EXCEPTION'
  215. )
  216. logger.error(
  217. 'MCP public tool request failed',
  218. extra={
  219. 'request_id': trace_request_id,
  220. 'jsonrpc_id': request_id,
  221. 'protocol_method': method,
  222. 'tool_code': tool_name,
  223. 'response_code': 'MCP_9001',
  224. 'diagnostic_reason': diagnostic_reason,
  225. 'exception_class': exc.__class__.__name__,
  226. },
  227. )
  228. return McpProtocolHandler._tool_exception_response(
  229. request_id,
  230. tool_name,
  231. exc,
  232. trace_request_id,
  233. )
  234. logger.error(
  235. "MCP public request failed",
  236. extra={
  237. 'request_id': trace_request_id,
  238. 'jsonrpc_id': request_id,
  239. 'protocol_method': method,
  240. 'tool_code': tool_name,
  241. 'protocol_code': -32000,
  242. 'diagnostic_reason': 'UNEXPECTED_EXCEPTION',
  243. 'exception_class': exc.__class__.__name__,
  244. },
  245. )
  246. return McpProtocolHandler._error_response(
  247. request_id,
  248. -32000,
  249. 'Gateway request failed. Please try again later.',
  250. trace_request_id,
  251. )
  252. def create_http_handler(gateway_app, rate_limiter=None):
  253. rpc_handler = PublicMcpHttpHandler(gateway_app, rate_limiter=rate_limiter)
  254. class Handler(BaseHTTPRequestHandler):
  255. def log_message(self, format, *args):
  256. # Override to use Python logging instead of stderr
  257. logger.info(f"{self.address_string()} - {format % args}")
  258. def do_GET(self):
  259. if self.path == '/health':
  260. self._write_json({'ok': True})
  261. return
  262. self.send_response(404)
  263. self.end_headers()
  264. def do_POST(self):
  265. request_headers = dict(self.headers.items())
  266. client_ip = extract_client_ip(request_headers, self.client_address)
  267. trace_request_id = rpc_handler._build_trace_request_id(request_headers)
  268. client_request_id_hash = rpc_handler._client_request_id(
  269. request_headers
  270. )
  271. length = int(self.headers.get('Content-Length') or '0')
  272. if self.path != '/mcp':
  273. logger.warning(f"[HTTP] 404: ip={client_ip}, path={self.path}")
  274. if length > 0:
  275. self.rfile.read(length)
  276. self.send_response(404)
  277. self.end_headers()
  278. return
  279. body = self.rfile.read(length).decode('utf-8-sig')
  280. try:
  281. message = json.loads(body)
  282. except json.JSONDecodeError as exc:
  283. logger.warning(
  284. 'MCP public invalid JSON',
  285. extra={
  286. 'request_id': trace_request_id,
  287. 'jsonrpc_id': None,
  288. 'protocol_method': '',
  289. 'tool_code': '',
  290. 'protocol_code': -32700,
  291. 'diagnostic_reason': 'PARAM_VALIDATION_FAILED',
  292. 'exception_class': exc.__class__.__name__,
  293. 'client_identity': client_ip,
  294. },
  295. )
  296. self._write_json(McpProtocolHandler._error_response(
  297. None,
  298. -32700,
  299. 'Parse error',
  300. trace_request_id,
  301. ))
  302. return
  303. method = message.get('method', '')
  304. request_id = message.get('id') if message.get('id') is not None else ''
  305. logger.info(
  306. 'MCP public request',
  307. extra={
  308. 'request_id': trace_request_id,
  309. 'jsonrpc_id': request_id,
  310. 'protocol_method': method,
  311. 'client_identity': client_ip,
  312. 'client_request_id_hash': client_request_id_hash,
  313. },
  314. )
  315. response = rpc_handler.handle_json_rpc(
  316. request_headers,
  317. message,
  318. client_ip,
  319. trace_request_id=trace_request_id,
  320. )
  321. self._write_json(response)
  322. def _write_json(self, payload):
  323. raw = json.dumps(payload, ensure_ascii=False).encode('utf-8')
  324. try:
  325. self.send_response(200)
  326. self.send_header('Content-Type', 'application/json; charset=utf-8')
  327. self.send_header('Content-Length', str(len(raw)))
  328. self.end_headers()
  329. self.wfile.write(raw)
  330. except (BrokenPipeError, ConnectionResetError):
  331. self.close_connection = True
  332. logger.info('MCP client disconnected before response was written')
  333. return Handler
  334. def serve_public(gateway_app, host='0.0.0.0', port=8765, enable_rate_limit=True,
  335. rate_limit_max_requests=60, rate_limit_window_seconds=60,
  336. max_in_flight_per_tool=2):
  337. # Configure logging
  338. logging.basicConfig(
  339. level=logging.INFO,
  340. format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
  341. datefmt='%Y-%m-%d %H:%M:%S'
  342. )
  343. # Configure rate limiting
  344. rate_limiter = None
  345. if enable_rate_limit:
  346. rate_limiter = SimpleRateLimiter(
  347. max_requests=rate_limit_max_requests,
  348. window_seconds=rate_limit_window_seconds,
  349. max_in_flight=max_in_flight_per_tool,
  350. )
  351. 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)")
  352. logger.info(f"Starting public MCP Gateway on {host}:{port}")
  353. server = ThreadingHTTPServer((host, int(port)), create_http_handler(gateway_app, rate_limiter))
  354. # Schedule periodic cleanup to prevent unbounded memory growth in the rate limiter
  355. if rate_limiter is not None:
  356. def _cleanup_loop():
  357. while True:
  358. time.sleep(300)
  359. # Use the actual window as max_age to avoid deleting entries still within the window
  360. rate_limiter.cleanup(max_age_seconds=rate_limit_window_seconds)
  361. t = threading.Thread(target=_cleanup_loop, daemon=True)
  362. t.start()
  363. try:
  364. server.serve_forever()
  365. except KeyboardInterrupt:
  366. logger.info("Shutting down public MCP Gateway")
  367. server.shutdown()