mcp_protocol.py 11 KB

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