mcp_protocol.py 14 KB

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