mcp_protocol.py 20 KB

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