public_gateway.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import logging
  2. import time
  3. import uuid
  4. from constants import DEVICE_INVALID_MESSAGE
  5. from tools.list_order_filter_options import ListOrderFilterOptionsTool
  6. from tools.list_outbound_filter_options import ListOutboundFilterOptionsTool
  7. from tools.export_pending_outbound_orders import ExportPendingOutboundOrdersTool
  8. from tools.export_out_of_province_port_data import (
  9. ExportOutOfProvincePortDataTool,
  10. )
  11. from tools.list_pending_outbound_export_filter_options import (
  12. ListPendingOutboundExportFilterOptionsTool,
  13. )
  14. from tools.query_order import QueryOrderTool
  15. from tools.query_customs_declaration_files import (
  16. QueryCustomsDeclarationFilesTool,
  17. )
  18. from tools.query_order_exact import QueryOrderExactTool
  19. from tools.query_order_detail import QueryOrderDetailTool
  20. from tools.query_export_task import QueryExportTaskTool
  21. from tools.query_outbound_detail import QueryOutboundDetailTool
  22. from tools.query_outbound_list import QueryOutboundListTool
  23. from tools.query_track import QueryTrackTool
  24. from utils.security import hash_gateway_session_id
  25. logger = logging.getLogger(__name__)
  26. class PublicGatewayApp:
  27. def __init__(self, session_store, api_client, auth_client=None):
  28. self.session_store = session_store
  29. self.api_client = api_client
  30. self._tools = {
  31. 'query_order': QueryOrderTool(api_client=None),
  32. 'query_track': QueryTrackTool(api_client=None),
  33. 'query_order_exact': QueryOrderExactTool(api_client=None),
  34. 'query_order_detail': QueryOrderDetailTool(api_client=None),
  35. 'query_customs_declaration_files':
  36. QueryCustomsDeclarationFilesTool(api_client=None),
  37. 'query_outbound_list': QueryOutboundListTool(api_client=None),
  38. 'query_outbound_detail': QueryOutboundDetailTool(api_client=None),
  39. 'list_outbound_filter_options': ListOutboundFilterOptionsTool(
  40. api_client=None
  41. ),
  42. 'list_order_filter_options': ListOrderFilterOptionsTool(
  43. api_client=None
  44. ),
  45. 'export_pending_outbound_orders': ExportPendingOutboundOrdersTool(
  46. api_client=None
  47. ),
  48. 'export_out_of_province_port_data':
  49. ExportOutOfProvincePortDataTool(api_client=None),
  50. 'query_export_task': QueryExportTaskTool(api_client=None),
  51. 'list_pending_outbound_export_filter_options':
  52. ListPendingOutboundExportFilterOptionsTool(api_client=None),
  53. }
  54. def registered_tool_names(self):
  55. return tuple(self._tools.keys())
  56. def _require_session(self, gateway_session_id, diagnostic_emitter=None):
  57. session = self.session_store.get(gateway_session_id)
  58. if not session or not session.get('mcp_token'):
  59. if diagnostic_emitter is not None:
  60. diagnostic_emitter.emit(
  61. stage='gateway_session',
  62. status='failed',
  63. event_code='GATEWAY_SESSION_NOT_FOUND',
  64. session_credential=gateway_session_id,
  65. context={'transport': 'http'},
  66. )
  67. raise RuntimeError(DEVICE_INVALID_MESSAGE)
  68. if diagnostic_emitter is not None:
  69. diagnostic_emitter.set_defaults(
  70. session_credential=gateway_session_id,
  71. admin_id=session.get('admin_id'),
  72. company_id=session.get('company_id'),
  73. context={'transport': 'http'},
  74. )
  75. diagnostic_emitter.emit(
  76. stage='gateway_session',
  77. status='succeeded',
  78. event_code='GATEWAY_SESSION_RESOLVED',
  79. )
  80. return session
  81. def _enabled_tool_names(self, response):
  82. if not isinstance(response, dict):
  83. raise RuntimeError('invalid enabled tool response')
  84. if response.get('code') != 'MCP_0000':
  85. raise RuntimeError(response.get('msg') or 'list enabled tools failed')
  86. data = response.get('data')
  87. codes = data.get('tool_codes') if isinstance(data, dict) else None
  88. if not isinstance(codes, list):
  89. raise RuntimeError('invalid enabled tool response')
  90. return {
  91. code.strip().lower()
  92. for code in codes
  93. if isinstance(code, str) and code.strip()
  94. }
  95. def _load_enabled_tool_names(self, token, request_id=''):
  96. response = self.api_client.list_enabled_tools(
  97. token,
  98. request_id=request_id,
  99. )
  100. return self._enabled_tool_names(response)
  101. def list_tools(self, gateway_session_id, request_id=''):
  102. session = self._require_session(gateway_session_id)
  103. request_id = self.build_request_id(request_id)
  104. enabled = self._load_enabled_tool_names(
  105. session['mcp_token'],
  106. request_id,
  107. )
  108. return [
  109. tool.metadata()
  110. for name, tool in self._tools.items()
  111. if name in enabled
  112. ]
  113. def build_request_id(self, request_id=''):
  114. request_id = str(request_id or '').strip()
  115. return request_id or 'rq_{0}'.format(uuid.uuid4().hex[:16])
  116. def call_tool(
  117. self,
  118. gateway_session_id,
  119. name,
  120. arguments=None,
  121. request_id='',
  122. client_ip='',
  123. diagnostic_emitter=None,
  124. ):
  125. session = self._require_session(gateway_session_id, diagnostic_emitter)
  126. request_id = self.build_request_id(request_id)
  127. if name not in self._tools:
  128. if diagnostic_emitter is not None:
  129. diagnostic_emitter.emit(
  130. stage='backend_call',
  131. status='failed',
  132. event_code='TOOL_NOT_REGISTERED',
  133. context={'transport': 'http'},
  134. )
  135. raise KeyError('tool not registered: {0}'.format(name))
  136. try:
  137. enabled_tools = self._load_enabled_tool_names(
  138. session['mcp_token'],
  139. request_id,
  140. )
  141. except Exception:
  142. if diagnostic_emitter is not None:
  143. diagnostic_emitter.emit(
  144. stage='backend_call',
  145. status='failed',
  146. event_code='ENABLED_TOOL_LOOKUP_FAILED',
  147. tool_code=name,
  148. context={'transport': 'http'},
  149. )
  150. raise
  151. if name not in enabled_tools:
  152. if diagnostic_emitter is not None:
  153. diagnostic_emitter.emit(
  154. stage='backend_call',
  155. status='failed',
  156. event_code='TOOL_DISABLED',
  157. tool_code=name,
  158. context={'transport': 'http'},
  159. )
  160. raise RuntimeError('tool disabled: {0}'.format(name))
  161. tool = self._tools[name]
  162. if diagnostic_emitter is not None:
  163. diagnostic_emitter.set_defaults(tool_code=name)
  164. session_hash = hash_gateway_session_id(gateway_session_id)[:12]
  165. admin_id = session.get('admin_id')
  166. company_id = session.get('company_id')
  167. logger.info(
  168. 'MCP public tool call',
  169. extra={
  170. 'request_id': request_id,
  171. 'tool_code': name,
  172. 'session_hash': session_hash,
  173. 'admin_id': admin_id,
  174. 'company_id': company_id,
  175. },
  176. )
  177. try:
  178. started_at = time.monotonic()
  179. if diagnostic_emitter is not None:
  180. diagnostic_emitter.emit(
  181. stage='backend_call',
  182. status='started',
  183. event_code='BACKEND_CALL_STARTED',
  184. )
  185. result = self.api_client.call_tool(
  186. token=session['mcp_token'],
  187. tool_code=tool.name,
  188. route_path=tool.route_path,
  189. payload=arguments or {},
  190. request_id=request_id,
  191. client_ip=client_ip,
  192. )
  193. if hasattr(self.session_store, 'touch_session'):
  194. self.session_store.touch_session(gateway_session_id)
  195. logger.info(
  196. 'MCP public tool success',
  197. extra={
  198. 'request_id': request_id,
  199. 'tool_code': name,
  200. 'session_hash': session_hash,
  201. 'response_code': result.get('code'),
  202. },
  203. )
  204. if diagnostic_emitter is not None:
  205. diagnostic_emitter.emit(
  206. stage='backend_call',
  207. status='succeeded',
  208. event_code='BACKEND_CALL_COMPLETED',
  209. response_code=(
  210. result.get('code') if isinstance(result, dict) else None
  211. ),
  212. cost_ms=max(0, int((time.monotonic() - started_at) * 1000)),
  213. )
  214. return result
  215. except Exception as e:
  216. logger.error(
  217. 'MCP public tool failed',
  218. extra={
  219. 'request_id': request_id,
  220. 'tool_code': name,
  221. 'session_hash': session_hash,
  222. 'response_code': 'MCP_9001',
  223. 'diagnostic_reason': 'UNEXPECTED_EXCEPTION',
  224. 'exception_class': e.__class__.__name__,
  225. },
  226. )
  227. if diagnostic_emitter is not None:
  228. diagnostic_emitter.emit(
  229. stage='backend_call',
  230. status='failed',
  231. event_code='UNEXPECTED_EXCEPTION',
  232. response_code='MCP_9001',
  233. )
  234. raise