test_mcp_protocol_coverage.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. """
  2. Coverage补充测试:mcp_protocol.py
  3. 目标路径:
  4. - run_stdio — ValueError 导致的 parse error 响应、空行跳过、非 dict message
  5. - handle_request — 未知 method、非 tools/call 的异常
  6. - _render_text — 无 columns/track 字段时的 json.dumps fallback
  7. - _render_text — records 存在但第一条无 status/content/time 时 fallback
  8. - _render_track_like_text — location / tracking_number / shipment_id / sub_track 可选字段
  9. - _render_track_like_text — 空 records + tips
  10. - _render_table_like_text — 空 records + tips
  11. """
  12. import io
  13. import json
  14. import unittest
  15. from mcp_protocol import McpProtocolHandler
  16. # ---------------------------------------------------------------------------
  17. # _render_text 的 fallback 路径
  18. # ---------------------------------------------------------------------------
  19. class RenderTextFallbackTest(unittest.TestCase):
  20. def test_empty_dict_returns_ok(self):
  21. self.assertEqual('ok', McpProtocolHandler._render_text({}))
  22. def test_none_returns_ok(self):
  23. self.assertEqual('ok', McpProtocolHandler._render_text(None))
  24. def test_empty_list_returns_ok(self):
  25. # non-dict → 走 "not structured_content" 分支
  26. self.assertEqual('ok', McpProtocolHandler._render_text([]))
  27. def test_dict_without_columns_or_track_fields_falls_back_to_json_dumps(self):
  28. """有 records 但第一条没有 status/content/time → 走 json.dumps fallback。"""
  29. content = {'records': [{'order_no': 'SO001', 'qty': 10}]}
  30. result = McpProtocolHandler._render_text(content)
  31. # 应该是 json.dumps 的输出,包含键名
  32. self.assertIn('order_no', result)
  33. self.assertIn('SO001', result)
  34. def test_dict_with_no_records_falls_back_to_json_dumps(self):
  35. """有 columns 但 records 是 None(不是 list)→ columns 是 list 但 records 不是 → fallback。"""
  36. content = {'columns': [{'key': 'no', 'name': '编号'}], 'records': None}
  37. result = McpProtocolHandler._render_text(content)
  38. self.assertIn('columns', result)
  39. def test_plain_dict_no_columns_no_records_falls_back_to_json(self):
  40. content = {'foo': 'bar', 'count': 42}
  41. result = McpProtocolHandler._render_text(content)
  42. self.assertIn('foo', result)
  43. self.assertIn('bar', result)
  44. def test_records_empty_list_no_columns_falls_back_to_json(self):
  45. """records 是空 list(falsy),columns 是 None → 第二个条件 `records` falsy → fallback。"""
  46. content = {'records': []}
  47. result = McpProtocolHandler._render_text(content)
  48. # json.dumps 输出
  49. self.assertIn('records', result)
  50. # ---------------------------------------------------------------------------
  51. # _render_table_like_text 的 tips / 空 records 路径
  52. # ---------------------------------------------------------------------------
  53. class RenderTableEmptyRecordsTest(unittest.TestCase):
  54. def test_empty_records_with_tips_shows_tips(self):
  55. content = {
  56. 'columns': [{'key': 'no', 'name': '编号'}, {'key': 'name', 'name': '名称'}],
  57. 'records': [],
  58. 'tips': ['暂无数据', '请调整关键词'],
  59. }
  60. result = McpProtocolHandler._render_text(content)
  61. self.assertIn('表头共 2 列:', result)
  62. self.assertIn('1. 编号 (no)', result)
  63. self.assertIn('提示:暂无数据;请调整关键词', result)
  64. def test_records_with_none_value_renders_empty_string(self):
  65. content = {
  66. 'columns': [{'key': 'k', 'name': '键'}],
  67. 'records': [{'k': None}],
  68. }
  69. result = McpProtocolHandler._render_text(content)
  70. self.assertIn('- 键:', result)
  71. # ---------------------------------------------------------------------------
  72. # _render_track_like_text 的可选字段
  73. # ---------------------------------------------------------------------------
  74. class RenderTrackOptionalFieldsTest(unittest.TestCase):
  75. """每个可选字段(location / tracking_number / shipment_id / sub_track)单独覆盖。"""
  76. def _call(self, **extra):
  77. record = {'status': '清关放行', 'content': '启运港放行', 'time': '2026-07-07 10:00:00'}
  78. record.update(extra)
  79. content = {'records': [record]}
  80. return McpProtocolHandler._render_text(content)
  81. def test_location_rendered_when_present(self):
  82. result = self._call(location='宁波港')
  83. self.assertIn('- 地点: 宁波港', result)
  84. def test_location_omitted_when_empty(self):
  85. result = self._call(location='')
  86. self.assertNotIn('地点', result)
  87. def test_location_omitted_when_absent(self):
  88. result = self._call()
  89. self.assertNotIn('地点', result)
  90. def test_tracking_number_rendered_when_present(self):
  91. result = self._call(tracking_number='TN1234567890')
  92. self.assertIn('- 快递单号: TN1234567890', result)
  93. def test_tracking_number_omitted_when_empty(self):
  94. result = self._call(tracking_number='')
  95. self.assertNotIn('快递单号', result)
  96. def test_shipment_id_rendered_when_present(self):
  97. result = self._call(shipment_id='SHP-9900')
  98. self.assertIn('- Shipment ID: SHP-9900', result)
  99. def test_shipment_id_omitted_when_empty(self):
  100. result = self._call(shipment_id='')
  101. self.assertNotIn('Shipment ID', result)
  102. def test_sub_track_rendered_when_truthy(self):
  103. result = self._call(sub_track=1)
  104. self.assertIn('- 子单轨迹: 是', result)
  105. def test_sub_track_omitted_when_zero(self):
  106. result = self._call(sub_track=0)
  107. self.assertNotIn('子单轨迹', result)
  108. def test_sub_track_omitted_when_absent(self):
  109. result = self._call()
  110. self.assertNotIn('子单轨迹', result)
  111. def test_all_optional_fields_together(self):
  112. result = self._call(
  113. location='上海港',
  114. tracking_number='YT123',
  115. shipment_id='SHP001',
  116. sub_track=2,
  117. )
  118. self.assertIn('地点: 上海港', result)
  119. self.assertIn('快递单号: YT123', result)
  120. self.assertIn('Shipment ID: SHP001', result)
  121. self.assertIn('子单轨迹: 是', result)
  122. def test_track_with_summary_and_tips(self):
  123. content = {
  124. 'summary': '共1条轨迹',
  125. 'records': [
  126. {'status': '已签收', 'content': '已签收', 'time': '2026-07-08 12:00:00'}
  127. ],
  128. 'tips': ['仅展示最新轨迹'],
  129. }
  130. result = McpProtocolHandler._render_text(content)
  131. self.assertIn('共1条轨迹', result)
  132. self.assertIn('已签收', result)
  133. self.assertIn('提示:仅展示最新轨迹', result)
  134. def test_multiple_track_records(self):
  135. content = {
  136. 'records': [
  137. {'status': '已发货', 'content': '揽件', 'time': '2026-07-06 09:00:00'},
  138. {'status': '已签收', 'content': '签收', 'time': '2026-07-08 12:00:00'},
  139. ],
  140. }
  141. result = McpProtocolHandler._render_text(content)
  142. self.assertIn('轨迹 1:', result)
  143. self.assertIn('轨迹 2:', result)
  144. self.assertIn('已发货', result)
  145. self.assertIn('已签收', result)
  146. # ---------------------------------------------------------------------------
  147. # run_stdio — parse error 分支
  148. # ---------------------------------------------------------------------------
  149. class RunStdioParseErrorTest(unittest.TestCase):
  150. def test_invalid_json_emits_parse_error_response(self):
  151. """无效 JSON 行 → 写出 -32700 Parse error 响应。"""
  152. handler = McpProtocolHandler(None)
  153. stdin = io.StringIO('not-valid-json\n')
  154. stdout = io.StringIO()
  155. handler.run_stdio(stdin=stdin, stdout=stdout)
  156. line = stdout.getvalue().strip()
  157. self.assertTrue(line, 'expected a response line on parse error')
  158. response = json.loads(line)
  159. self.assertIn('error', response)
  160. self.assertEqual(-32700, response['error']['code'])
  161. self.assertEqual('Parse error', response['error']['message'])
  162. self.assertIsNone(response['id'])
  163. def test_blank_lines_are_skipped(self):
  164. handler = McpProtocolHandler(None)
  165. stdin = io.StringIO('\n \n\t\n')
  166. stdout = io.StringIO()
  167. handler.run_stdio(stdin=stdin, stdout=stdout)
  168. self.assertEqual('', stdout.getvalue().strip())
  169. def test_mixed_valid_and_invalid_lines(self):
  170. """有效请求和无效行混合;两者都产生输出。"""
  171. handler = McpProtocolHandler(None)
  172. stdin = io.StringIO(
  173. 'bad-json\n'
  174. + json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'initialize', 'params': {}})
  175. + '\n'
  176. )
  177. stdout = io.StringIO()
  178. # initialize 需要 gateway_app,这里简单 patch
  179. from unittest.mock import MagicMock
  180. handler.gateway_app = MagicMock()
  181. handler.gateway_app.list_tools.return_value = []
  182. handler.run_stdio(stdin=stdin, stdout=stdout)
  183. lines = [l for l in stdout.getvalue().splitlines() if l.strip()]
  184. # parse error + initialize response = 2 lines
  185. self.assertEqual(2, len(lines))
  186. parse_err = json.loads(lines[0])
  187. self.assertEqual(-32700, parse_err['error']['code'])
  188. def test_stdout_without_flush_writes_all_responses(self):
  189. class WriteOnlyOutput:
  190. def __init__(self):
  191. self.values = []
  192. def write(self, value):
  193. self.values.append(value)
  194. stdout = WriteOnlyOutput()
  195. handler = McpProtocolHandler(None)
  196. result = handler.run_stdio(
  197. stdin=io.StringIO('bad-json\nstill-bad\n'),
  198. stdout=stdout,
  199. )
  200. self.assertEqual(0, result)
  201. self.assertEqual(2, len(stdout.values))
  202. class ProtocolResponseBoundaryTest(unittest.TestCase):
  203. def test_normalize_tool_preserves_metadata_without_input_schema(self):
  204. tool = {'name': 'plain_tool', 'description': 'plain'}
  205. self.assertEqual(tool, McpProtocolHandler(None)._normalize_tool(tool))
  206. def test_tool_call_response_rejects_non_dict(self):
  207. with self.assertRaisesRegex(RuntimeError, 'invalid tool response'):
  208. McpProtocolHandler._tool_call_response('1', 'query_order', 'invalid')
  209. def test_success_response_wraps_non_dict_data(self):
  210. response = McpProtocolHandler._tool_call_response(
  211. '1',
  212. 'query_order',
  213. {'code': 'MCP_0000', 'data': ['A']},
  214. )
  215. self.assertEqual(
  216. {'data': ['A']},
  217. response['result']['structuredContent'],
  218. )
  219. def test_success_response_uses_empty_structured_content_without_data(self):
  220. response = McpProtocolHandler._tool_call_response(
  221. '1',
  222. 'query_order',
  223. {'code': '0', 'data': None},
  224. )
  225. self.assertEqual({}, response['result']['structuredContent'])
  226. self.assertEqual('ok', response['result']['content'][0]['text'])
  227. def test_error_response_includes_data_without_meta(self):
  228. response = McpProtocolHandler._tool_call_response(
  229. '1',
  230. 'query_order',
  231. {'code': 'MCP_9001', 'msg': 'failed', 'data': ['detail']},
  232. )
  233. structured = response['result']['structuredContent']
  234. self.assertEqual(['detail'], structured['data'])
  235. self.assertNotIn('meta', structured)
  236. self.assertTrue(response['result']['isError'])
  237. def test_empty_table_without_tips_returns_headers(self):
  238. result = McpProtocolHandler._render_text({
  239. 'columns': [{'key': 'number'}],
  240. 'records': [],
  241. })
  242. self.assertIn('1. number (number)', result)
  243. self.assertNotIn('提示', result)
  244. def test_empty_track_with_tips_returns_tip(self):
  245. result = McpProtocolHandler._render_track_like_text(
  246. {'tips': ['暂无轨迹']},
  247. [],
  248. )
  249. self.assertEqual('提示:暂无轨迹', result)
  250. def test_empty_track_without_tips_returns_empty_text(self):
  251. result = McpProtocolHandler._render_track_like_text({}, [])
  252. self.assertEqual('', result)
  253. # ---------------------------------------------------------------------------
  254. # handle_message / handle_request 边界情况
  255. # ---------------------------------------------------------------------------
  256. class HandleMessageEdgeCasesTest(unittest.TestCase):
  257. def test_non_dict_message_returns_invalid_request_error(self):
  258. handler = McpProtocolHandler(None)
  259. response = handler.handle_message('not a dict')
  260. self.assertEqual(-32600, response['error']['code'])
  261. def test_notification_with_unknown_method_returns_none(self):
  262. handler = McpProtocolHandler(None)
  263. # 无 id → 通知,非 notifications/initialized → returns None
  264. response = handler.handle_message({'jsonrpc': '2.0', 'method': 'some/notification'})
  265. self.assertIsNone(response)
  266. def test_unknown_request_method_returns_method_not_found(self):
  267. handler = McpProtocolHandler(None)
  268. response = handler.handle_request({'id': 1, 'method': 'unknown/method'})
  269. self.assertEqual(-32601, response['error']['code'])
  270. self.assertIn('Method not found', response['error']['message'])
  271. def test_non_tools_call_exception_returns_error(self):
  272. """tools/list 抛异常 → 返回 error 响应(非 isError 内容)。"""
  273. from unittest.mock import MagicMock
  274. handler = McpProtocolHandler(MagicMock())
  275. handler.gateway_app.list_tools.side_effect = RuntimeError('backend error')
  276. response = handler.handle_request({'id': 5, 'method': 'tools/list'})
  277. self.assertIn('error', response)
  278. self.assertIn('backend error', response['error']['message'])
  279. def test_tools_call_exception_returns_is_error_content(self):
  280. """tools/call 抛异常 → 返回 isError: true 的 content。"""
  281. from unittest.mock import MagicMock
  282. handler = McpProtocolHandler(MagicMock())
  283. handler.gateway_app.call_tool.side_effect = RuntimeError('tool failed')
  284. response = handler.handle_request({
  285. 'id': 6,
  286. 'method': 'tools/call',
  287. 'params': {'name': 'query_order', 'arguments': {}},
  288. })
  289. self.assertEqual(False, 'error' in response)
  290. self.assertTrue(response['result']['isError'])
  291. self.assertIn('tool failed', response['result']['content'][0]['text'])
  292. def test_tools_call_with_non_object_params_fails_safely(self):
  293. handler = McpProtocolHandler(None)
  294. response = handler.handle_request({
  295. 'id': 7,
  296. 'method': 'tools/call',
  297. 'params': 'not-an-object',
  298. })
  299. self.assertTrue(response['result']['isError'])
  300. self.assertEqual(
  301. '工具返回格式异常',
  302. response['result']['structuredContent']['message'],
  303. )
  304. def test_tools_call_with_non_string_tool_name_fails_safely(self):
  305. from unittest.mock import MagicMock
  306. handler = McpProtocolHandler(MagicMock())
  307. for tool_name in ([], {}):
  308. with self.subTest(tool_name=tool_name):
  309. response = handler.handle_request({
  310. 'id': 8,
  311. 'method': 'tools/call',
  312. 'params': {'name': tool_name, 'arguments': {}},
  313. })
  314. self.assertTrue(response['result']['isError'])
  315. self.assertEqual(
  316. '工具返回格式异常',
  317. response['result']['structuredContent']['message'],
  318. )
  319. handler.gateway_app.call_tool.assert_not_called()
  320. if __name__ == '__main__':
  321. unittest.main()