public_server.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976
  1. import hashlib
  2. import json
  3. import logging
  4. import socket
  5. import threading
  6. import time
  7. import uuid
  8. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  9. from constants import DEVICE_INVALID_MESSAGE
  10. from mcp_protocol import McpProtocolHandler
  11. from services.diagnostic_event import RequestDiagnosticEmitter
  12. from services.diagnostic_reporter import NullDiagnosticReporter
  13. from services.request_context import RequestContextParser
  14. from utils.rate_limiter import SimpleRateLimiter
  15. logger = logging.getLogger(__name__)
  16. UPLOAD_CORS_HEADERS = (
  17. ('Access-Control-Allow-Origin', '*'),
  18. ('Access-Control-Allow-Methods', 'POST, OPTIONS'),
  19. ('Access-Control-Allow-Headers', 'Content-Type'),
  20. ('Access-Control-Max-Age', '600'),
  21. )
  22. UPLOAD_BODY_LIMIT = 102 * 1024 * 1024
  23. UPLOAD_LIMIT_NAME = 'upload-headhaul-document'
  24. UPLOAD_DISCARD_CHUNK = 64 * 1024
  25. UPLOAD_DISCARD_IDLE_SECONDS = 0.5
  26. def discard_request_body(rfile, connection, length, chunk=UPLOAD_DISCARD_CHUNK,
  27. idle_timeout=UPLOAD_DISCARD_IDLE_SECONDS):
  28. if length <= 0 or rfile is None:
  29. return
  30. remaining = length
  31. old_timeout = None
  32. if connection is not None:
  33. try:
  34. old_timeout = connection.gettimeout()
  35. connection.settimeout(idle_timeout)
  36. except OSError:
  37. old_timeout = None
  38. try:
  39. while remaining > 0:
  40. data = rfile.read(min(chunk, remaining))
  41. if not data:
  42. break
  43. remaining -= len(data)
  44. except (OSError, socket.timeout):
  45. pass
  46. finally:
  47. if connection is not None and old_timeout is not None:
  48. try:
  49. connection.settimeout(old_timeout)
  50. except OSError:
  51. pass
  52. def extract_client_ip(headers, client_address):
  53. if client_address and len(client_address) > 0:
  54. return str(client_address[0])
  55. return ''
  56. class PublicMcpHttpHandler:
  57. def __init__(
  58. self,
  59. gateway_app,
  60. context_parser=None,
  61. rate_limiter=None,
  62. reporter=None,
  63. ):
  64. self.gateway_app = gateway_app
  65. self.context_parser = context_parser or RequestContextParser()
  66. self.rate_limiter = rate_limiter
  67. self.reporter = reporter or NullDiagnosticReporter()
  68. # Cache registered tool names at startup for rate-key validation;
  69. # unknown names fall back to the bare IP bucket, preventing bucket explosion
  70. self._known_tools = frozenset(gateway_app.registered_tool_names())
  71. @staticmethod
  72. def _build_trace_request_id(headers):
  73. return 'rq_http_{0}'.format(uuid.uuid4().hex[:16])
  74. @staticmethod
  75. def _client_request_id(headers):
  76. incoming = ''
  77. for key, value in (headers or {}).items():
  78. if str(key).lower() == 'x-request-id':
  79. incoming = str(value or '').strip()
  80. break
  81. if not incoming:
  82. return ''
  83. return hashlib.sha256(incoming.encode('utf-8')).hexdigest()[:16]
  84. def _check_rate_limit(
  85. self,
  86. rate_key,
  87. method,
  88. trace_request_id,
  89. jsonrpc_id=None,
  90. tool_name='',
  91. *,
  92. log_identity=None,
  93. diagnostic_emitter=None,
  94. ):
  95. """Returns an error response if rate limit exceeded, else None.
  96. rate_key — the session-based bucket key used for tools/call
  97. log_identity — optional string shown in warning logs (e.g. client_ip)
  98. """
  99. if self.rate_limiter and rate_key and not self.rate_limiter.is_allowed(rate_key):
  100. if diagnostic_emitter is not None:
  101. diagnostic_emitter.emit(
  102. stage='rate_limit',
  103. status='failed',
  104. event_code='RATE_LIMIT_EXCEEDED',
  105. tool_code=tool_name or None,
  106. context={
  107. 'limit_type': 'request_window',
  108. 'transport': 'http',
  109. },
  110. )
  111. logger.warning(
  112. "MCP public rate limit exceeded",
  113. extra={
  114. 'request_id': trace_request_id,
  115. 'jsonrpc_id': jsonrpc_id,
  116. 'protocol_method': method,
  117. 'tool_code': tool_name,
  118. 'client_identity': log_identity or rate_key,
  119. 'protocol_code': -32029,
  120. 'diagnostic_reason': 'RATE_LIMIT_EXCEEDED',
  121. },
  122. )
  123. return McpProtocolHandler._error_response(
  124. jsonrpc_id,
  125. -32029,
  126. 'Rate limit exceeded. Please try again later.',
  127. trace_request_id,
  128. )
  129. return None
  130. @staticmethod
  131. def _upload_error(code, message, trace_request_id):
  132. return {
  133. 'code': code,
  134. 'msg': message,
  135. 'data': {},
  136. 'meta': {'request_id': trace_request_id},
  137. }
  138. def gate_upload(self, headers, content_length, client_ip='', trace_request_id=''):
  139. if (
  140. isinstance(content_length, bool)
  141. or not isinstance(content_length, int)
  142. or content_length < 0
  143. ):
  144. return 400, self._upload_error(
  145. 'MCP_1401',
  146. '工具参数不正确,请检查后重试',
  147. trace_request_id,
  148. ), None
  149. if content_length > UPLOAD_BODY_LIMIT:
  150. return 413, self._upload_error(
  151. 'MCP_1401',
  152. '文件不可大于100M',
  153. trace_request_id,
  154. ), None
  155. context = self.context_parser.parse(headers or {})
  156. session_id = context.gateway_session_id if context.has_session() else ''
  157. identity = session_id or ('ip:' + str(client_ip or 'unknown'))
  158. rate_key = '{0}:{1}'.format(identity, UPLOAD_LIMIT_NAME)
  159. if self.rate_limiter is not None:
  160. if not self.rate_limiter.is_allowed(rate_key):
  161. logger.warning(
  162. "MCP public rate limit exceeded",
  163. extra={
  164. 'request_id': trace_request_id,
  165. 'protocol_method': 'upload',
  166. 'tool_code': UPLOAD_LIMIT_NAME,
  167. 'client_identity': identity,
  168. 'diagnostic_reason': 'RATE_LIMIT_EXCEEDED',
  169. },
  170. )
  171. return 429, self._upload_error(
  172. 'MCP_9001',
  173. '请求过于频繁,请稍后重试',
  174. trace_request_id,
  175. ), None
  176. if not self.rate_limiter.try_acquire(rate_key):
  177. logger.warning(
  178. 'MCP public concurrency limit exceeded',
  179. extra={
  180. 'request_id': trace_request_id,
  181. 'protocol_method': 'upload',
  182. 'tool_code': UPLOAD_LIMIT_NAME,
  183. 'client_identity': identity,
  184. 'diagnostic_reason': 'CONCURRENCY_LIMIT_EXCEEDED',
  185. },
  186. )
  187. return 429, self._upload_error(
  188. 'MCP_9001',
  189. '请求过于频繁,请稍后重试',
  190. trace_request_id,
  191. ), None
  192. return None, None, rate_key
  193. def handle_upload(
  194. self,
  195. headers,
  196. body,
  197. client_ip='',
  198. trace_request_id='',
  199. ):
  200. if len(body or b'') > UPLOAD_BODY_LIMIT:
  201. return 413, self._upload_error(
  202. 'MCP_1401',
  203. '文件不可大于100M',
  204. trace_request_id,
  205. )
  206. fields, files = self._parse_multipart(headers, body or b'')
  207. upload_token = str(fields.get('upload_token') or '').strip()
  208. file_spec = files.get('file')
  209. context = self.context_parser.parse(headers or {})
  210. session_id = context.gateway_session_id if context.has_session() else ''
  211. if (
  212. not session_id
  213. and upload_token
  214. and hasattr(self.gateway_app, 'session_for_headhaul_token')
  215. ):
  216. session_id = str(
  217. self.gateway_app.session_for_headhaul_token(upload_token) or ''
  218. ).strip()
  219. if not session_id:
  220. return 401, {
  221. 'code': 'MCP_1101',
  222. 'msg': DEVICE_INVALID_MESSAGE,
  223. 'data': {},
  224. 'meta': {'request_id': trace_request_id},
  225. }
  226. if not upload_token or not file_spec:
  227. return 400, {
  228. 'code': 'MCP_1401',
  229. 'msg': '工具参数不正确,请检查后重试',
  230. 'data': {},
  231. 'meta': {'request_id': trace_request_id},
  232. }
  233. filename, file_bytes, content_type = file_spec
  234. try:
  235. payload = self.gateway_app.upload_headhaul_document(
  236. session_id,
  237. upload_token,
  238. filename,
  239. file_bytes,
  240. content_type,
  241. request_id=trace_request_id,
  242. client_ip=client_ip,
  243. )
  244. except RuntimeError as exc:
  245. message = str(exc)
  246. if message == DEVICE_INVALID_MESSAGE:
  247. return 401, {
  248. 'code': 'MCP_1101',
  249. 'msg': DEVICE_INVALID_MESSAGE,
  250. 'data': {},
  251. 'meta': {'request_id': trace_request_id},
  252. }
  253. if message.lower().startswith('tool disabled'):
  254. return 403, {
  255. 'code': 'MCP_1202',
  256. 'msg': '工具当前不可用',
  257. 'data': {},
  258. 'meta': {'request_id': trace_request_id},
  259. }
  260. return 500, {
  261. 'code': 'MCP_9001',
  262. 'msg': '系统繁忙,请稍后重试',
  263. 'data': {},
  264. 'meta': {'request_id': trace_request_id},
  265. }
  266. if not isinstance(payload, dict):
  267. return 500, {
  268. 'code': 'MCP_9001',
  269. 'msg': '系统繁忙,请稍后重试',
  270. 'data': {},
  271. 'meta': {'request_id': trace_request_id},
  272. }
  273. return 200, payload
  274. @staticmethod
  275. def _parse_multipart(headers, body):
  276. content_type = ''
  277. for key, value in (headers or {}).items():
  278. if str(key).lower() == 'content-type':
  279. content_type = str(value or '')
  280. break
  281. if 'multipart/form-data' not in content_type or 'boundary=' not in content_type:
  282. return {}, {}
  283. boundary = content_type.split('boundary=', 1)[1].strip().strip('"')
  284. delimiter = b'--' + boundary.encode('utf-8')
  285. fields = {}
  286. files = {}
  287. for raw_part in body.split(delimiter):
  288. part = raw_part.strip()
  289. if not part or part == b'--':
  290. continue
  291. header_blob, separator, content = part.partition(b'\r\n\r\n')
  292. if separator == b'':
  293. continue
  294. header_text = header_blob.decode('utf-8', 'replace')
  295. disposition = ''
  296. part_type = 'application/octet-stream'
  297. for line in header_text.split('\r\n'):
  298. lower = line.lower()
  299. if lower.startswith('content-disposition:'):
  300. disposition = line.split(':', 1)[1].strip()
  301. if lower.startswith('content-type:'):
  302. part_type = line.split(':', 1)[1].strip()
  303. name = ''
  304. filename = ''
  305. for item in disposition.split(';'):
  306. item = item.strip()
  307. if item.startswith('name='):
  308. name = item.split('=', 1)[1].strip().strip('"')
  309. elif item.startswith('filename='):
  310. filename = item.split('=', 1)[1].strip().strip('"')
  311. if not name:
  312. continue
  313. if filename:
  314. files[name] = (filename, content, part_type)
  315. else:
  316. fields[name] = content.decode('utf-8', 'replace')
  317. return fields, files
  318. def handle_json_rpc(
  319. self,
  320. headers,
  321. message,
  322. client_ip='',
  323. trace_request_id='',
  324. diagnostic_emitter=None,
  325. ):
  326. request_id = message.get('id') if isinstance(message, dict) else None
  327. trace_request_id = str(trace_request_id or '').strip() \
  328. or self._build_trace_request_id(headers)
  329. method = str((message or {}).get('method') or '').strip()
  330. message_params = (message or {}).get('params') or {}
  331. tool_name = str(message_params.get('name') or '').strip() \
  332. if isinstance(message_params, dict) else ''
  333. emitter = diagnostic_emitter or RequestDiagnosticEmitter(
  334. self.reporter,
  335. trace_request_id,
  336. defer_until_identity=True,
  337. )
  338. emitter.emit(
  339. stage='request_ingress',
  340. status='started',
  341. event_code='REQUEST_RECEIVED',
  342. context={'jsonrpc_method': method or 'unknown', 'transport': 'http'},
  343. )
  344. protocol_validated = False
  345. try:
  346. if method == 'initialize':
  347. emitter.emit(
  348. stage='protocol_validation',
  349. status='succeeded',
  350. event_code='PROTOCOL_VALIDATION_COMPLETED',
  351. context={
  352. 'jsonrpc_method': method,
  353. 'transport': 'http',
  354. },
  355. )
  356. protocol_validated = True
  357. # initialize is a stateless handshake that only returns server metadata;
  358. # rate-limiting it would block clients from connecting at all, so we skip it.
  359. return McpProtocolHandler._success_response(request_id, {
  360. 'protocolVersion': McpProtocolHandler.protocol_version,
  361. 'capabilities': McpProtocolHandler.server_capabilities(),
  362. 'serverInfo': {
  363. 'name': McpProtocolHandler.server_name,
  364. 'version': McpProtocolHandler.server_version,
  365. },
  366. })
  367. if method == 'tools/list':
  368. emitter.emit(
  369. stage='protocol_validation',
  370. status='succeeded',
  371. event_code='PROTOCOL_VALIDATION_COMPLETED',
  372. context={
  373. 'jsonrpc_method': method,
  374. 'transport': 'http',
  375. },
  376. )
  377. protocol_validated = True
  378. context = self.context_parser.parse(headers or {})
  379. if not context.has_session():
  380. emitter.emit(
  381. stage='gateway_session',
  382. status='failed',
  383. event_code='GATEWAY_SESSION_NOT_FOUND',
  384. context={'transport': 'http'},
  385. )
  386. logger.warning(
  387. 'MCP public device session unavailable',
  388. extra={
  389. 'request_id': trace_request_id,
  390. 'jsonrpc_id': request_id,
  391. 'protocol_method': method,
  392. 'tool_code': '',
  393. 'protocol_code': -32001,
  394. 'diagnostic_reason': 'GATEWAY_SESSION_NOT_FOUND',
  395. },
  396. )
  397. return McpProtocolHandler._error_response(
  398. request_id,
  399. -32001,
  400. DEVICE_INVALID_MESSAGE,
  401. trace_request_id,
  402. )
  403. tools = []
  404. for tool in self.gateway_app.list_tools(
  405. context.gateway_session_id,
  406. request_id=trace_request_id,
  407. ):
  408. normalized = dict(tool)
  409. if 'input_schema' in normalized:
  410. normalized['inputSchema'] = normalized.pop('input_schema')
  411. tools.append(normalized)
  412. return McpProtocolHandler._success_response(request_id, {'tools': tools})
  413. if method in ('resources/list', 'resources/read'):
  414. emitter.emit(
  415. stage='protocol_validation',
  416. status='succeeded',
  417. event_code='PROTOCOL_VALIDATION_COMPLETED',
  418. context={
  419. 'jsonrpc_method': method,
  420. 'transport': 'http',
  421. },
  422. )
  423. protocol_validated = True
  424. context = self.context_parser.parse(headers or {})
  425. if not context.has_session():
  426. emitter.emit(
  427. stage='gateway_session',
  428. status='failed',
  429. event_code='GATEWAY_SESSION_NOT_FOUND',
  430. context={'transport': 'http'},
  431. )
  432. return McpProtocolHandler._error_response(
  433. request_id,
  434. -32001,
  435. DEVICE_INVALID_MESSAGE,
  436. trace_request_id,
  437. )
  438. if method == 'resources/list':
  439. resources = []
  440. if hasattr(self.gateway_app, 'list_resources'):
  441. resources = list(self.gateway_app.list_resources() or [])
  442. return McpProtocolHandler._success_response(
  443. request_id,
  444. {'resources': resources},
  445. )
  446. uri = ''
  447. if isinstance(message_params, dict):
  448. uri = str(message_params.get('uri') or '').strip()
  449. resource = None
  450. if uri and hasattr(self.gateway_app, 'read_resource'):
  451. resource = self.gateway_app.read_resource(
  452. uri,
  453. context.gateway_session_id,
  454. )
  455. if not isinstance(resource, dict):
  456. return McpProtocolHandler._error_response(
  457. request_id,
  458. -32602,
  459. 'Invalid params',
  460. trace_request_id,
  461. )
  462. return McpProtocolHandler._success_response(
  463. request_id,
  464. {'contents': [resource]},
  465. )
  466. if method == 'tools/call':
  467. if not isinstance(message_params, dict):
  468. raise ValueError('tool parameters must be an object')
  469. if not tool_name:
  470. emitter.emit(
  471. stage='protocol_validation',
  472. status='failed',
  473. event_code='PARAM_VALIDATION_FAILED',
  474. context={
  475. 'jsonrpc_method': method,
  476. 'jsonrpc_code': -32602,
  477. 'transport': 'http',
  478. },
  479. )
  480. return McpProtocolHandler._error_response(
  481. request_id,
  482. -32602,
  483. 'Invalid params',
  484. trace_request_id,
  485. )
  486. emitter.emit(
  487. stage='protocol_validation',
  488. status='succeeded',
  489. event_code='PROTOCOL_VALIDATION_COMPLETED',
  490. tool_code=tool_name,
  491. context={
  492. 'jsonrpc_method': method,
  493. 'transport': 'http',
  494. },
  495. )
  496. protocol_validated = True
  497. # Parse context first — tools/call always requires a valid session
  498. context = self.context_parser.parse(headers or {})
  499. if not context.has_session():
  500. emitter.emit(
  501. stage='gateway_session',
  502. status='failed',
  503. event_code='GATEWAY_SESSION_NOT_FOUND',
  504. context={'transport': 'http'},
  505. )
  506. logger.warning(
  507. 'MCP public device session unavailable',
  508. extra={
  509. 'request_id': trace_request_id,
  510. 'jsonrpc_id': request_id,
  511. 'protocol_method': method,
  512. 'tool_code': tool_name,
  513. 'protocol_code': -32001,
  514. 'diagnostic_reason': 'GATEWAY_SESSION_NOT_FOUND',
  515. },
  516. )
  517. return McpProtocolHandler._error_response(
  518. request_id,
  519. -32001,
  520. DEVICE_INVALID_MESSAGE,
  521. trace_request_id,
  522. )
  523. # Session-based rate limiting: each employee gets an independent quota per tool.
  524. # Unknown tool names fall back to the bare session bucket to prevent key explosion.
  525. session_id = context.gateway_session_id
  526. rate_key = '{0}:{1}'.format(session_id, tool_name) if tool_name in self._known_tools else session_id
  527. blocked = self._check_rate_limit(
  528. rate_key,
  529. method,
  530. trace_request_id,
  531. request_id,
  532. tool_name,
  533. log_identity=client_ip,
  534. diagnostic_emitter=emitter,
  535. )
  536. if blocked:
  537. return blocked
  538. params = message_params
  539. acquired = self.rate_limiter is None \
  540. or self.rate_limiter.try_acquire(rate_key)
  541. if not acquired:
  542. emitter.emit(
  543. stage='rate_limit',
  544. status='failed',
  545. event_code='CONCURRENCY_LIMIT_EXCEEDED',
  546. tool_code=tool_name or None,
  547. context={
  548. 'limit_type': 'concurrency',
  549. 'transport': 'http',
  550. },
  551. )
  552. logger.warning(
  553. 'MCP public concurrency limit exceeded',
  554. extra={
  555. 'request_id': trace_request_id,
  556. 'jsonrpc_id': request_id,
  557. 'protocol_method': method,
  558. 'tool_code': tool_name,
  559. 'client_identity': client_ip,
  560. 'protocol_code': -32029,
  561. 'diagnostic_reason': 'CONCURRENCY_LIMIT_EXCEEDED',
  562. },
  563. )
  564. return McpProtocolHandler._error_response(
  565. request_id,
  566. -32029,
  567. 'Too many requests in progress. Please try again later.',
  568. trace_request_id,
  569. )
  570. emitter.emit(
  571. stage='rate_limit',
  572. status='succeeded',
  573. event_code='RATE_LIMIT_ALLOWED',
  574. tool_code=tool_name or None,
  575. context={
  576. 'limit_type': 'tools_call',
  577. 'transport': 'http',
  578. },
  579. )
  580. try:
  581. result = self.gateway_app.call_tool(
  582. session_id,
  583. params.get('name'),
  584. params.get('arguments') or {},
  585. request_id=trace_request_id,
  586. client_ip=client_ip,
  587. diagnostic_emitter=emitter,
  588. )
  589. finally:
  590. if self.rate_limiter is not None:
  591. self.rate_limiter.release(rate_key)
  592. try:
  593. response = McpProtocolHandler._tool_call_response(
  594. request_id,
  595. tool_name,
  596. result,
  597. )
  598. except Exception:
  599. emitter.emit(
  600. stage='response_safety',
  601. status='failed',
  602. event_code='RESPONSE_SAFETY_REJECTED',
  603. context={'transport': 'http'},
  604. )
  605. raise
  606. emitter.emit(
  607. stage='response_safety',
  608. status='succeeded',
  609. event_code='RESPONSE_SAFETY_COMPLETED',
  610. context={'transport': 'http'},
  611. )
  612. return response
  613. emitter.emit(
  614. stage='protocol_validation',
  615. status='failed',
  616. event_code='METHOD_NOT_FOUND',
  617. context={
  618. 'jsonrpc_method': method or 'unknown',
  619. 'jsonrpc_code': -32601,
  620. 'transport': 'http',
  621. },
  622. )
  623. return McpProtocolHandler._error_response(
  624. request_id,
  625. -32601,
  626. 'Method not found: {0}'.format(method),
  627. trace_request_id,
  628. )
  629. except Exception as exc:
  630. if not protocol_validated:
  631. emitter.emit(
  632. stage='protocol_validation',
  633. status='failed',
  634. event_code='PARAM_VALIDATION_FAILED',
  635. tool_code=tool_name or None,
  636. context={
  637. 'jsonrpc_method': method or 'unknown',
  638. 'jsonrpc_code': -32602,
  639. 'transport': 'http',
  640. },
  641. )
  642. if method == 'tools/call':
  643. diagnostic_reason = (
  644. 'PARAM_VALIDATION_FAILED'
  645. if isinstance(exc, ValueError)
  646. else 'UNEXPECTED_EXCEPTION'
  647. )
  648. logger.error(
  649. 'MCP public tool request failed',
  650. extra={
  651. 'request_id': trace_request_id,
  652. 'jsonrpc_id': request_id,
  653. 'protocol_method': method,
  654. 'tool_code': tool_name,
  655. 'response_code': 'MCP_9001',
  656. 'diagnostic_reason': diagnostic_reason,
  657. 'exception_class': exc.__class__.__name__,
  658. },
  659. )
  660. return McpProtocolHandler._tool_exception_response(
  661. request_id,
  662. tool_name,
  663. exc,
  664. trace_request_id,
  665. )
  666. if str(exc) == DEVICE_INVALID_MESSAGE:
  667. parsed = self.context_parser.parse(headers or {})
  668. emitter.emit(
  669. stage='gateway_session',
  670. status='failed',
  671. event_code='GATEWAY_SESSION_NOT_FOUND',
  672. session_credential=(
  673. parsed.gateway_session_id if parsed.has_session() else None
  674. ),
  675. context={'transport': 'http'},
  676. )
  677. logger.warning(
  678. 'MCP public device session unavailable',
  679. extra={
  680. 'request_id': trace_request_id,
  681. 'jsonrpc_id': request_id,
  682. 'protocol_method': method,
  683. 'tool_code': tool_name,
  684. 'protocol_code': -32001,
  685. 'diagnostic_reason': 'GATEWAY_SESSION_NOT_FOUND',
  686. },
  687. )
  688. return McpProtocolHandler._error_response(
  689. request_id,
  690. -32001,
  691. DEVICE_INVALID_MESSAGE,
  692. trace_request_id,
  693. )
  694. logger.error(
  695. "MCP public request failed",
  696. extra={
  697. 'request_id': trace_request_id,
  698. 'jsonrpc_id': request_id,
  699. 'protocol_method': method,
  700. 'tool_code': tool_name,
  701. 'protocol_code': -32000,
  702. 'diagnostic_reason': 'UNEXPECTED_EXCEPTION',
  703. 'exception_class': exc.__class__.__name__,
  704. },
  705. )
  706. return McpProtocolHandler._error_response(
  707. request_id,
  708. -32000,
  709. 'Gateway request failed. Please try again later.',
  710. trace_request_id,
  711. )
  712. finally:
  713. emitter.flush()
  714. def create_http_handler(gateway_app, rate_limiter=None, reporter=None):
  715. rpc_handler = PublicMcpHttpHandler(
  716. gateway_app,
  717. rate_limiter=rate_limiter,
  718. reporter=reporter,
  719. )
  720. class Handler(BaseHTTPRequestHandler):
  721. def log_message(self, format, *args):
  722. # Override to use Python logging instead of stderr
  723. logger.info(f"{self.address_string()} - {format % args}")
  724. def do_GET(self):
  725. if self.path == '/health':
  726. self._write_json({'ok': True})
  727. return
  728. self.send_response(404)
  729. self.end_headers()
  730. def do_OPTIONS(self):
  731. if self.path == '/mcp/upload-headhaul-document':
  732. self.send_response(204)
  733. for key, value in UPLOAD_CORS_HEADERS:
  734. self.send_header(key, value)
  735. self.end_headers()
  736. return
  737. self.send_response(404)
  738. self.end_headers()
  739. def do_POST(self):
  740. request_headers = dict(self.headers.items())
  741. client_ip = extract_client_ip(request_headers, self.client_address)
  742. trace_request_id = rpc_handler._build_trace_request_id(request_headers)
  743. diagnostic_emitter = RequestDiagnosticEmitter(
  744. rpc_handler.reporter,
  745. trace_request_id,
  746. defer_until_identity=True,
  747. )
  748. client_request_id_hash = rpc_handler._client_request_id(
  749. request_headers
  750. )
  751. if self.path == '/mcp/upload-headhaul-document':
  752. try:
  753. length = int(self.headers.get('Content-Length') or '0')
  754. except (TypeError, ValueError):
  755. length = -1
  756. status, payload, rate_key = rpc_handler.gate_upload(
  757. request_headers,
  758. length,
  759. client_ip=client_ip,
  760. trace_request_id=trace_request_id,
  761. )
  762. if status is not None:
  763. if length > 0:
  764. discard_request_body(self.rfile, self.connection, length)
  765. self._write_upload_json(status, payload, close=True)
  766. return
  767. try:
  768. body = self.rfile.read(length) if length > 0 else b''
  769. status, payload = rpc_handler.handle_upload(
  770. request_headers,
  771. body,
  772. client_ip=client_ip,
  773. trace_request_id=trace_request_id,
  774. )
  775. self._write_upload_json(status, payload)
  776. finally:
  777. if rate_key and rpc_handler.rate_limiter is not None:
  778. rpc_handler.rate_limiter.release(rate_key)
  779. return
  780. length = int(self.headers.get('Content-Length') or '0')
  781. if self.path != '/mcp':
  782. logger.warning(f"[HTTP] 404: ip={client_ip}, path={self.path}")
  783. if length > 0:
  784. self.rfile.read(length)
  785. self.send_response(404)
  786. self.end_headers()
  787. return
  788. body = self.rfile.read(length).decode('utf-8-sig')
  789. try:
  790. message = json.loads(body)
  791. except json.JSONDecodeError as exc:
  792. diagnostic_emitter.emit(
  793. stage='request_ingress',
  794. status='started',
  795. event_code='REQUEST_RECEIVED',
  796. context={
  797. 'jsonrpc_method': 'unknown',
  798. 'transport': 'http',
  799. },
  800. )
  801. diagnostic_emitter.emit(
  802. stage='protocol_validation',
  803. status='failed',
  804. event_code='PARAM_VALIDATION_FAILED',
  805. context={
  806. 'jsonrpc_code': -32700,
  807. 'transport': 'http',
  808. },
  809. )
  810. logger.warning(
  811. 'MCP public invalid JSON',
  812. extra={
  813. 'request_id': trace_request_id,
  814. 'jsonrpc_id': None,
  815. 'protocol_method': '',
  816. 'tool_code': '',
  817. 'protocol_code': -32700,
  818. 'diagnostic_reason': 'PARAM_VALIDATION_FAILED',
  819. 'exception_class': exc.__class__.__name__,
  820. 'client_identity': client_ip,
  821. },
  822. )
  823. self._write_json(
  824. McpProtocolHandler._error_response(
  825. None,
  826. -32700,
  827. 'Parse error',
  828. trace_request_id,
  829. ),
  830. diagnostic_emitter=diagnostic_emitter,
  831. )
  832. return
  833. method = message.get('method', '')
  834. request_id = message.get('id') if message.get('id') is not None else ''
  835. logger.info(
  836. 'MCP public request',
  837. extra={
  838. 'request_id': trace_request_id,
  839. 'jsonrpc_id': request_id,
  840. 'protocol_method': method,
  841. 'client_identity': client_ip,
  842. 'client_request_id_hash': client_request_id_hash,
  843. },
  844. )
  845. response = rpc_handler.handle_json_rpc(
  846. request_headers,
  847. message,
  848. client_ip,
  849. trace_request_id=trace_request_id,
  850. diagnostic_emitter=diagnostic_emitter,
  851. )
  852. self._write_json(
  853. response,
  854. diagnostic_emitter=diagnostic_emitter,
  855. )
  856. def _write_upload_json(self, status, payload, close=False):
  857. encoded = json.dumps(payload, ensure_ascii=False).encode('utf-8')
  858. self.send_response(status)
  859. self.send_header('Content-Type', 'application/json; charset=utf-8')
  860. for key, value in UPLOAD_CORS_HEADERS:
  861. self.send_header(key, value)
  862. if close:
  863. self.send_header('Connection', 'close')
  864. self.close_connection = True
  865. self.send_header('Content-Length', str(len(encoded)))
  866. self.end_headers()
  867. self.wfile.write(encoded)
  868. def _write_json(self, payload, diagnostic_emitter=None):
  869. raw = json.dumps(payload, ensure_ascii=False).encode('utf-8')
  870. try:
  871. self.send_response(200)
  872. self.send_header('Content-Type', 'application/json; charset=utf-8')
  873. self.send_header('Content-Length', str(len(raw)))
  874. self.end_headers()
  875. self.wfile.write(raw)
  876. if diagnostic_emitter is not None:
  877. diagnostic_emitter.emit(
  878. stage='response_write',
  879. status='succeeded',
  880. event_code='RESPONSE_WRITE_COMPLETED',
  881. context={
  882. 'http_status': 200,
  883. 'client_disconnected': False,
  884. 'transport': 'http',
  885. },
  886. )
  887. except (BrokenPipeError, ConnectionResetError):
  888. self.close_connection = True
  889. if diagnostic_emitter is not None:
  890. diagnostic_emitter.emit(
  891. stage='response_write',
  892. status='failed',
  893. event_code='CLIENT_DISCONNECTED',
  894. context={
  895. 'http_status': 200,
  896. 'client_disconnected': True,
  897. 'transport': 'http',
  898. },
  899. )
  900. logger.info('MCP client disconnected before response was written')
  901. finally:
  902. if diagnostic_emitter is not None:
  903. diagnostic_emitter.flush()
  904. return Handler
  905. def serve_public(gateway_app, host='0.0.0.0', port=8765, enable_rate_limit=True,
  906. rate_limit_max_requests=60, rate_limit_window_seconds=60,
  907. max_in_flight_per_tool=2, reporter=None):
  908. # Configure logging
  909. logging.basicConfig(
  910. level=logging.INFO,
  911. format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
  912. datefmt='%Y-%m-%d %H:%M:%S'
  913. )
  914. # Configure rate limiting
  915. rate_limiter = None
  916. if enable_rate_limit:
  917. rate_limiter = SimpleRateLimiter(
  918. max_requests=rate_limit_max_requests,
  919. window_seconds=rate_limit_window_seconds,
  920. max_in_flight=max_in_flight_per_tool,
  921. )
  922. 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 and upload HTTP)")
  923. logger.info(f"Starting public MCP Gateway on {host}:{port}")
  924. server = ThreadingHTTPServer(
  925. (host, int(port)),
  926. create_http_handler(gateway_app, rate_limiter, reporter=reporter),
  927. )
  928. # Schedule periodic cleanup to prevent unbounded memory growth in the rate limiter
  929. if rate_limiter is not None:
  930. def _cleanup_loop():
  931. while True:
  932. time.sleep(300)
  933. # Use the actual window as max_age to avoid deleting entries still within the window
  934. rate_limiter.cleanup(max_age_seconds=rate_limit_window_seconds)
  935. t = threading.Thread(target=_cleanup_loop, daemon=True)
  936. t.start()
  937. try:
  938. server.serve_forever()
  939. except KeyboardInterrupt:
  940. logger.info("Shutting down public MCP Gateway")
  941. server.shutdown()