mcp_protocol.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. import json
  2. import logging
  3. import sys
  4. import uuid
  5. from services.output_presenter import OutputPresenter
  6. from services.diagnostic_event import RequestDiagnosticEmitter
  7. from services.diagnostic_reporter import NullDiagnosticReporter
  8. logger = logging.getLogger(__name__)
  9. class McpProtocolHandler:
  10. protocol_version = '2025-06-18'
  11. server_name = 'fms-mcp-gateway'
  12. server_version = '0.1.0'
  13. output_presenter = OutputPresenter()
  14. def __init__(self, gateway_app, reporter=None):
  15. self.gateway_app = gateway_app
  16. self.reporter = reporter or NullDiagnosticReporter()
  17. self.initialized = False
  18. def handle_message(self, message):
  19. if not isinstance(message, dict):
  20. return self._error_response(
  21. None,
  22. -32600,
  23. 'Invalid Request',
  24. self._build_trace_request_id(),
  25. )
  26. if 'id' in message:
  27. return self.handle_request(message)
  28. method = str(message.get('method') or '').strip()
  29. if method == 'notifications/initialized':
  30. self.initialized = True
  31. return None
  32. return None
  33. def handle_request(self, request):
  34. request_id = request.get('id')
  35. method = str(request.get('method') or '').strip()
  36. tool_name = ''
  37. trace_request_id = self._build_trace_request_id()
  38. emitter = RequestDiagnosticEmitter(self.reporter, trace_request_id)
  39. emitter.emit(
  40. stage='request_ingress',
  41. status='started',
  42. event_code='REQUEST_RECEIVED',
  43. context={
  44. 'jsonrpc_method': method or 'unknown',
  45. 'transport': 'stdio',
  46. },
  47. )
  48. backend_started = False
  49. try:
  50. if method == 'initialize':
  51. emitter.emit(
  52. stage='protocol_validation',
  53. status='succeeded',
  54. event_code='PROTOCOL_VALIDATION_COMPLETED',
  55. context={
  56. 'jsonrpc_method': method,
  57. 'transport': 'stdio',
  58. },
  59. )
  60. self.initialized = True
  61. return self._success_response(
  62. request_id,
  63. {
  64. 'protocolVersion': self.protocol_version,
  65. 'capabilities': {
  66. 'tools': {
  67. 'listChanged': False,
  68. }
  69. },
  70. 'serverInfo': {
  71. 'name': self.server_name,
  72. 'version': self.server_version,
  73. },
  74. },
  75. )
  76. if method == 'tools/list':
  77. emitter.emit(
  78. stage='protocol_validation',
  79. status='succeeded',
  80. event_code='PROTOCOL_VALIDATION_COMPLETED',
  81. context={
  82. 'jsonrpc_method': method,
  83. 'transport': 'stdio',
  84. },
  85. )
  86. tools = [
  87. self._normalize_tool(tool)
  88. for tool in self.gateway_app.list_tools(
  89. request_id=trace_request_id,
  90. )
  91. ]
  92. return self._success_response(request_id, {'tools': tools})
  93. if method == 'tools/call':
  94. params = request.get('params') or {}
  95. if not isinstance(params, dict):
  96. raise ValueError('tool parameters must be an object')
  97. raw_tool_name = params.get('name')
  98. if not isinstance(raw_tool_name, str) or not raw_tool_name.strip():
  99. emitter.emit(
  100. stage='protocol_validation',
  101. status='failed',
  102. event_code='PARAM_VALIDATION_FAILED',
  103. context={
  104. 'jsonrpc_method': method,
  105. 'jsonrpc_code': -32602,
  106. 'transport': 'stdio',
  107. },
  108. )
  109. return self._error_response(
  110. request_id,
  111. -32602,
  112. 'Invalid params',
  113. trace_request_id,
  114. )
  115. tool_name = raw_tool_name.strip()
  116. arguments = params.get('arguments') or {}
  117. emitter.emit(
  118. stage='protocol_validation',
  119. status='succeeded',
  120. event_code='PROTOCOL_VALIDATION_COMPLETED',
  121. tool_code=tool_name,
  122. context={
  123. 'jsonrpc_method': method,
  124. 'transport': 'stdio',
  125. },
  126. )
  127. backend_started = True
  128. emitter.emit(
  129. stage='backend_call',
  130. status='started',
  131. event_code='BACKEND_CALL_STARTED',
  132. tool_code=tool_name,
  133. context={'transport': 'stdio'},
  134. )
  135. tool_result = self.gateway_app.call_tool(
  136. tool_name,
  137. arguments,
  138. request_id=trace_request_id,
  139. )
  140. backend_started = False
  141. emitter.emit(
  142. stage='backend_call',
  143. status='succeeded',
  144. event_code='BACKEND_CALL_COMPLETED',
  145. tool_code=tool_name,
  146. response_code=(
  147. tool_result.get('code')
  148. if isinstance(tool_result, dict)
  149. else None
  150. ),
  151. context={'transport': 'stdio'},
  152. )
  153. try:
  154. response = self._tool_call_response(
  155. request_id,
  156. tool_name,
  157. tool_result,
  158. )
  159. except Exception:
  160. emitter.emit(
  161. stage='response_safety',
  162. status='failed',
  163. event_code='RESPONSE_SAFETY_REJECTED',
  164. tool_code=tool_name,
  165. context={'transport': 'stdio'},
  166. )
  167. raise
  168. emitter.emit(
  169. stage='response_safety',
  170. status='succeeded',
  171. event_code='RESPONSE_SAFETY_COMPLETED',
  172. tool_code=tool_name,
  173. context={'transport': 'stdio'},
  174. )
  175. return response
  176. return self._error_response(
  177. request_id,
  178. -32601,
  179. 'Method not found: {0}'.format(method or '<empty>'),
  180. trace_request_id,
  181. )
  182. except Exception as exc:
  183. if method == 'tools/call':
  184. diagnostic_reason = (
  185. 'PARAM_VALIDATION_FAILED'
  186. if isinstance(exc, ValueError)
  187. else 'UNEXPECTED_EXCEPTION'
  188. )
  189. logger.error(
  190. 'MCP stdio tool request failed',
  191. extra={
  192. 'request_id': trace_request_id,
  193. 'jsonrpc_id': request_id,
  194. 'protocol_method': method,
  195. 'tool_code': tool_name,
  196. 'response_code': 'MCP_9001',
  197. 'diagnostic_reason': diagnostic_reason,
  198. 'exception_class': exc.__class__.__name__,
  199. },
  200. )
  201. if backend_started:
  202. emitter.emit(
  203. stage='backend_call',
  204. status='failed',
  205. event_code=diagnostic_reason,
  206. tool_code=tool_name or None,
  207. response_code='MCP_9001',
  208. context={'transport': 'stdio'},
  209. )
  210. return self._tool_exception_response(
  211. request_id,
  212. tool_name,
  213. exc,
  214. trace_request_id,
  215. )
  216. logger.error(
  217. "MCP stdio 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. 'protocol_code': -32000,
  224. 'diagnostic_reason': 'UNEXPECTED_EXCEPTION',
  225. 'exception_class': exc.__class__.__name__,
  226. },
  227. )
  228. return self._error_response(
  229. request_id,
  230. -32000,
  231. 'Gateway request failed. Please try again later.',
  232. trace_request_id,
  233. )
  234. def run_stdio(self, stdin=None, stdout=None):
  235. stdin = stdin or sys.stdin
  236. stdout = stdout or sys.stdout
  237. for raw_line in stdin:
  238. line = str(raw_line).strip()
  239. if not line:
  240. continue
  241. try:
  242. message = json.loads(line)
  243. except ValueError:
  244. response = self._error_response(
  245. None,
  246. -32700,
  247. 'Parse error',
  248. self._build_trace_request_id(),
  249. )
  250. else:
  251. response = self.handle_message(message)
  252. if response is None:
  253. continue
  254. stdout.write(json.dumps(response, ensure_ascii=True) + '\n')
  255. if hasattr(stdout, 'flush'):
  256. stdout.flush()
  257. return 0
  258. def _normalize_tool(self, tool):
  259. normalized = dict(tool)
  260. if 'input_schema' in normalized:
  261. normalized['inputSchema'] = normalized.pop('input_schema')
  262. return normalized
  263. @classmethod
  264. def _tool_call_response(cls, request_id, tool_name, tool_result):
  265. if tool_name == 'query_order':
  266. return cls._legacy_tool_call_response(request_id, tool_result)
  267. presented = cls.output_presenter.present(tool_name, tool_result)
  268. return cls._presented_response(request_id, presented)
  269. @classmethod
  270. def _tool_exception_response(
  271. cls,
  272. request_id,
  273. tool_name,
  274. exception,
  275. trace_request_id='',
  276. ):
  277. if tool_name == 'query_order':
  278. return cls._success_response(
  279. request_id,
  280. {
  281. 'content': [{
  282. 'type': 'text',
  283. 'text': str(exception),
  284. }],
  285. 'isError': True,
  286. },
  287. )
  288. presented = cls.output_presenter.present_exception(
  289. tool_name,
  290. exception,
  291. )
  292. if trace_request_id:
  293. presented['meta'] = {'request_id': trace_request_id}
  294. return cls._presented_response(request_id, presented)
  295. @classmethod
  296. def _presented_response(cls, request_id, presented):
  297. result = {
  298. 'content': [{
  299. 'type': 'text',
  300. 'text': presented['text'],
  301. }],
  302. 'structuredContent': presented['structured_content'],
  303. 'isError': presented['is_error'],
  304. }
  305. if presented['meta']:
  306. result['_meta'] = presented['meta']
  307. return cls._success_response(request_id, result)
  308. @classmethod
  309. def _legacy_tool_call_response(cls, request_id, tool_result):
  310. if not isinstance(tool_result, dict):
  311. raise RuntimeError('invalid tool response')
  312. raw_code = tool_result.get('code')
  313. code = str(raw_code).strip() if raw_code is not None else ''
  314. message = str(tool_result.get('msg') or '').strip()
  315. data = tool_result.get('data')
  316. meta = tool_result.get('meta')
  317. if code in ('MCP_0000', '0'):
  318. if isinstance(data, dict):
  319. structured_content = dict(data)
  320. elif data:
  321. structured_content = {'data': data}
  322. else:
  323. structured_content = {}
  324. if isinstance(meta, dict) and meta:
  325. structured_content['meta'] = dict(meta)
  326. return cls._success_response(request_id, {
  327. 'content': [{
  328. 'type': 'text',
  329. 'text': cls._render_text(structured_content),
  330. }],
  331. 'structuredContent': structured_content,
  332. 'isError': False,
  333. })
  334. error_content = {
  335. 'code': code or 'MCP_9001',
  336. 'msg': message or 'tool call failed',
  337. }
  338. if data not in (None, [], {}):
  339. error_content['data'] = data
  340. if isinstance(meta, dict) and meta:
  341. error_content['meta'] = dict(meta)
  342. return cls._success_response(request_id, {
  343. 'content': [{
  344. 'type': 'text',
  345. 'text': '{0}: {1}'.format(
  346. error_content['code'],
  347. error_content['msg'],
  348. ),
  349. }],
  350. 'structuredContent': error_content,
  351. 'isError': True,
  352. })
  353. @staticmethod
  354. def _render_text(structured_content):
  355. if not structured_content:
  356. return 'ok'
  357. columns = structured_content.get('columns') if isinstance(structured_content, dict) else None
  358. records = structured_content.get('records') if isinstance(structured_content, dict) else None
  359. if isinstance(columns, list) and isinstance(records, list):
  360. return McpProtocolHandler._render_table_like_text(structured_content, columns, records)
  361. if isinstance(records, list) and records:
  362. # Check if this is a track-like structure (no columns, but has records)
  363. first_record = records[0] if records else {}
  364. if 'status' in first_record and 'content' in first_record and 'time' in first_record:
  365. return McpProtocolHandler._render_track_like_text(structured_content, records)
  366. return json.dumps(structured_content, ensure_ascii=False)
  367. @staticmethod
  368. def _render_table_like_text(structured_content, columns, records):
  369. lines = []
  370. summary = str(structured_content.get('summary') or '').strip()
  371. if summary:
  372. lines.append(summary)
  373. lines.append('表头共 {0} 列:'.format(len(columns)))
  374. for index, column in enumerate(columns, start=1):
  375. key = str(column.get('key') or '').strip()
  376. name = str(column.get('name') or key).strip()
  377. lines.append('{0}. {1} ({2})'.format(index, name, key))
  378. if not records:
  379. tips = structured_content.get('tips') or []
  380. if tips:
  381. lines.append('提示:{0}'.format(';'.join(str(tip) for tip in tips)))
  382. return '\n'.join(lines)
  383. for record_index, record in enumerate(records, start=1):
  384. lines.append('订单 {0}:'.format(record_index))
  385. for column in columns:
  386. key = str(column.get('key') or '').strip()
  387. name = str(column.get('name') or key).strip()
  388. value = record.get(key, '') if isinstance(record, dict) else ''
  389. if value is None:
  390. value = ''
  391. lines.append('- {0}: {1}'.format(name, value))
  392. tips = structured_content.get('tips') or []
  393. if tips:
  394. lines.append('提示:{0}'.format(';'.join(str(tip) for tip in tips)))
  395. return '\n'.join(lines)
  396. @staticmethod
  397. def _render_track_like_text(structured_content, records):
  398. lines = []
  399. summary = str(structured_content.get('summary') or '').strip()
  400. if summary:
  401. lines.append(summary)
  402. if not records:
  403. tips = structured_content.get('tips') or []
  404. if tips:
  405. lines.append('提示:{0}'.format(';'.join(str(tip) for tip in tips)))
  406. return '\n'.join(lines)
  407. for record_index, record in enumerate(records, start=1):
  408. status = str(record.get('status') or '').strip()
  409. content = str(record.get('content') or '').strip()
  410. location = str(record.get('location') or '').strip()
  411. time = str(record.get('time') or '').strip()
  412. tracking_number = str(record.get('tracking_number') or '').strip()
  413. shipment_id = str(record.get('shipment_id') or '').strip()
  414. sub_track = int(record.get('sub_track') or 0)
  415. lines.append('轨迹 {0}:'.format(record_index))
  416. lines.append('- 状态: {0}'.format(status or '无'))
  417. lines.append('- 内容: {0}'.format(content or '无'))
  418. if location:
  419. lines.append('- 地点: {0}'.format(location))
  420. lines.append('- 时间: {0}'.format(time or '无'))
  421. if tracking_number:
  422. lines.append('- 快递单号: {0}'.format(tracking_number))
  423. if shipment_id:
  424. lines.append('- Shipment ID: {0}'.format(shipment_id))
  425. if sub_track:
  426. lines.append('- 子单轨迹: 是')
  427. tips = structured_content.get('tips') or []
  428. if tips:
  429. lines.append('提示:{0}'.format(';'.join(str(tip) for tip in tips)))
  430. return '\n'.join(lines)
  431. @staticmethod
  432. def _success_response(request_id, result):
  433. return {
  434. 'jsonrpc': '2.0',
  435. 'id': request_id,
  436. 'result': result,
  437. }
  438. @staticmethod
  439. def _build_trace_request_id():
  440. return 'rq_stdio_{0}'.format(uuid.uuid4().hex[:16])
  441. @staticmethod
  442. def _error_response(request_id, code, message, trace_request_id=''):
  443. error = {
  444. 'code': code,
  445. 'message': message,
  446. }
  447. if trace_request_id:
  448. error['data'] = {'request_id': trace_request_id}
  449. return {
  450. 'jsonrpc': '2.0',
  451. 'id': request_id,
  452. 'error': error,
  453. }