test_mcp_protocol.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import io
  2. import json
  3. import unittest
  4. from app import GatewayApp
  5. from services.token_store import InMemoryTokenStore
  6. from mcp_protocol import McpProtocolHandler
  7. class DummyApiClient:
  8. def __init__(self):
  9. self.calls = []
  10. def call_tool(self, tool_code, route_path, payload, request_id):
  11. self.calls.append(
  12. {
  13. 'tool_code': tool_code,
  14. 'route_path': route_path,
  15. 'payload': payload,
  16. 'request_id': request_id,
  17. }
  18. )
  19. return {
  20. 'code': 'MCP_0000',
  21. 'msg': 'success',
  22. 'data': {
  23. 'summary': 'matched 1 order',
  24. 'records': [
  25. {
  26. 'order_no': 'SO20260706001',
  27. }
  28. ],
  29. 'tips': ['scoped by employee permissions'],
  30. },
  31. 'meta': {
  32. 'request_id': request_id,
  33. },
  34. }
  35. class FullColumnsApiClient(DummyApiClient):
  36. def call_tool(self, tool_code, route_path, payload, request_id):
  37. response = super().call_tool(tool_code, route_path, payload, request_id)
  38. columns = [
  39. ('order_number', '订单号'),
  40. ('reference_number', '客户参考号'),
  41. ('status_txt_name', '状态'),
  42. ('check_status_txt_name', '是否已查验'),
  43. ('customer_name', '客户名称'),
  44. ('customer_account_type_name', '客户属性'),
  45. ('inbound_date', '入库时间'),
  46. ('wo_num', '未完成工单'),
  47. ('product_name', '物流产品'),
  48. ('inbound_pieces', '件数'),
  49. ('inbound_volume', '体积(CBM)'),
  50. ('inbound_weight', '重量(KG)'),
  51. ('pro_cn_name', '品名'),
  52. ('export_declaration_type', '报关方式'),
  53. ('merge_declare_number', '合并报关单号'),
  54. ('delivery_address', '派送地址'),
  55. ('container_code', '柜号'),
  56. ('out_status_txt', '排舱单状态'),
  57. ('hinge_of_destination', '目的港'),
  58. ('etd', 'ETD'),
  59. ('atd', 'ATD'),
  60. ('eta', 'ETA'),
  61. ('ata', 'ATA'),
  62. ('release_time', '清关放行时间'),
  63. ('oversea_inbound_date', '海外入库时间'),
  64. ('appt_time', 'APPT时间'),
  65. ('est_loading_time', '预计装柜时间'),
  66. ('pickup_time', '海外提柜时间'),
  67. ('delivery_way_title', '派送方式'),
  68. ('tracking_number', '快递单号'),
  69. ('shipment_id', 'SHIPMENT ID'),
  70. ('goods_attribute', '商品属性'),
  71. ('sku', 'SKU'),
  72. ('sales_user', '商务经理'),
  73. ('service_user', '客户经理'),
  74. ('department_name', '事业部'),
  75. ('remark', '订单备注'),
  76. ('importer_name', '进口商'),
  77. ('warehouse_name', '交货仓库'),
  78. ('paid_status_name', '付款状态'),
  79. ]
  80. response['data']['columns'] = [
  81. {'key': key, 'name': name, 'check': True} for key, name in columns
  82. ]
  83. response['data']['records'] = [
  84. {key: '{0}-value'.format(key) for key, _name in columns}
  85. ]
  86. return response
  87. class McpProtocolTest(unittest.TestCase):
  88. def build_handler(self):
  89. token_store = InMemoryTokenStore(refresh_skew_seconds=60)
  90. token_store.save('MT_demo', '2099-01-01T00:00:00')
  91. app = GatewayApp(
  92. auth_client=None,
  93. api_client=DummyApiClient(),
  94. token_store=token_store,
  95. )
  96. return McpProtocolHandler(app)
  97. def test_initialize_returns_server_capabilities(self):
  98. handler = self.build_handler()
  99. response = handler.handle_request(
  100. {
  101. 'jsonrpc': '2.0',
  102. 'id': 1,
  103. 'method': 'initialize',
  104. 'params': {
  105. 'protocolVersion': '2025-06-18',
  106. 'capabilities': {},
  107. 'clientInfo': {'name': 'workbuddy', 'version': '1.0.0'},
  108. },
  109. }
  110. )
  111. self.assertEqual('2.0', response['jsonrpc'])
  112. self.assertEqual(1, response['id'])
  113. self.assertEqual('2025-06-18', response['result']['protocolVersion'])
  114. self.assertIn('tools', response['result']['capabilities'])
  115. self.assertEqual('fms-mcp-gateway', response['result']['serverInfo']['name'])
  116. def test_initialized_notification_does_not_emit_response(self):
  117. handler = self.build_handler()
  118. response = handler.handle_message(
  119. {
  120. 'jsonrpc': '2.0',
  121. 'method': 'notifications/initialized',
  122. }
  123. )
  124. self.assertIsNone(response)
  125. def test_tools_list_returns_registered_tools(self):
  126. handler = self.build_handler()
  127. response = handler.handle_request(
  128. {
  129. 'jsonrpc': '2.0',
  130. 'id': 2,
  131. 'method': 'tools/list',
  132. 'params': {},
  133. }
  134. )
  135. self.assertEqual('2.0', response['jsonrpc'])
  136. self.assertEqual(2, response['id'])
  137. by_name = {tool['name']: tool for tool in response['result']['tools']}
  138. self.assertIn('query_order', by_name)
  139. self.assertIn('bind_auth_code', by_name)
  140. self.assertIn('inputSchema', by_name['query_order'])
  141. def test_tools_call_wraps_gateway_result_as_structured_content(self):
  142. handler = self.build_handler()
  143. response = handler.handle_request(
  144. {
  145. 'jsonrpc': '2.0',
  146. 'id': 3,
  147. 'method': 'tools/call',
  148. 'params': {
  149. 'name': 'query_order',
  150. 'arguments': {
  151. 'keyword': 'SO20260706001',
  152. 'page': 1,
  153. 'limit': 20,
  154. },
  155. },
  156. }
  157. )
  158. self.assertEqual('2.0', response['jsonrpc'])
  159. self.assertEqual(3, response['id'])
  160. self.assertFalse(response['result']['isError'])
  161. self.assertEqual('matched 1 order', response['result']['structuredContent']['summary'])
  162. self.assertEqual('SO20260706001', response['result']['structuredContent']['records'][0]['order_no'])
  163. self.assertEqual('text', response['result']['content'][0]['type'])
  164. self.assertIn('matched 1 order', response['result']['content'][0]['text'])
  165. def test_tools_call_renders_all_query_order_columns_in_text_content(self):
  166. token_store = InMemoryTokenStore(refresh_skew_seconds=60)
  167. token_store.save('MT_demo', '2099-01-01T00:00:00')
  168. app = GatewayApp(
  169. auth_client=None,
  170. api_client=FullColumnsApiClient(),
  171. token_store=token_store,
  172. )
  173. handler = McpProtocolHandler(app)
  174. response = handler.handle_request(
  175. {
  176. 'jsonrpc': '2.0',
  177. 'id': 4,
  178. 'method': 'tools/call',
  179. 'params': {
  180. 'name': 'query_order',
  181. 'arguments': {
  182. 'keyword': 'SO20260706001',
  183. },
  184. },
  185. }
  186. )
  187. text = response['result']['content'][0]['text']
  188. self.assertIn('表头共 40 列', text)
  189. self.assertIn('1. 订单号 (order_number)', text)
  190. self.assertIn('40. 付款状态 (paid_status_name)', text)
  191. self.assertIn('- 付款状态: paid_status_name-value', text)
  192. self.assertEqual(40, len(response['result']['structuredContent']['columns']))
  193. def test_run_stdio_writes_only_request_responses(self):
  194. handler = self.build_handler()
  195. stdin = io.StringIO(
  196. json.dumps(
  197. {
  198. 'jsonrpc': '2.0',
  199. 'id': 1,
  200. 'method': 'initialize',
  201. 'params': {
  202. 'protocolVersion': '2025-06-18',
  203. 'capabilities': {},
  204. 'clientInfo': {'name': 'workbuddy', 'version': '1.0.0'},
  205. },
  206. }
  207. )
  208. + '\n'
  209. + json.dumps(
  210. {
  211. 'jsonrpc': '2.0',
  212. 'method': 'notifications/initialized',
  213. }
  214. )
  215. + '\n'
  216. )
  217. stdout = io.StringIO()
  218. handler.run_stdio(stdin=stdin, stdout=stdout)
  219. lines = [line for line in stdout.getvalue().splitlines() if line.strip()]
  220. self.assertEqual(1, len(lines))
  221. response = json.loads(lines[0])
  222. self.assertEqual(1, response['id'])
  223. self.assertEqual('2025-06-18', response['result']['protocolVersion'])
  224. if __name__ == '__main__':
  225. unittest.main()