test_public_server_stream.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import json
  2. import unittest
  3. from http.client import HTTPConnection
  4. from threading import Thread
  5. from time import sleep
  6. from unittest.mock import MagicMock
  7. from constants import DEVICE_INVALID_MESSAGE
  8. from public_gateway import PublicGatewayApp
  9. from public_server import serve_public
  10. from utils.security import generate_gateway_session_id
  11. class TestPublicServerStream(unittest.TestCase):
  12. @classmethod
  13. def setUpClass(cls):
  14. cls.mock_session_store = MagicMock()
  15. cls.mock_api_client = MagicMock()
  16. cls.mock_auth_client = MagicMock()
  17. cls.gateway_app = PublicGatewayApp(
  18. session_store=cls.mock_session_store,
  19. api_client=cls.mock_api_client,
  20. auth_client=cls.mock_auth_client,
  21. )
  22. cls.server_thread = Thread(
  23. target=serve_public,
  24. args=(cls.gateway_app,),
  25. kwargs={'host': '127.0.0.1', 'port': 18766, 'enable_rate_limit': False},
  26. daemon=True,
  27. )
  28. cls.server_thread.start()
  29. sleep(0.5)
  30. def setUp(self):
  31. self.mock_session_store.reset_mock()
  32. self.mock_api_client.reset_mock()
  33. self.mock_auth_client.reset_mock()
  34. self.mock_session_store.get.return_value = None
  35. self.mock_api_client.list_enabled_tools.return_value = {
  36. 'code': 'MCP_0000',
  37. 'data': {'tool_codes': ['query_order']},
  38. }
  39. self.mock_api_client.call_tool.return_value = {
  40. 'code': '0',
  41. 'msg': 'success',
  42. 'data': {'order_no': 'ABC123'},
  43. }
  44. def _exchange(self, method, path, body=None, headers=None):
  45. conn = HTTPConnection('127.0.0.1', 18766, timeout=5)
  46. try:
  47. request_headers = {}
  48. if headers:
  49. request_headers.update(headers)
  50. conn.request(method, path, body, request_headers)
  51. response = conn.getresponse()
  52. return response.status, response.getheader('Allow'), response.read()
  53. finally:
  54. conn.close()
  55. def _post_json(self, path, payload, headers=None):
  56. raw = json.dumps(payload).encode('utf-8')
  57. status, allow, body = self._exchange('POST', path, raw, headers)
  58. parsed = json.loads(body.decode('utf-8')) if body else None
  59. return status, allow, parsed
  60. def test_existing_mcp_initialize_keeps_protocol_version(self):
  61. status, _allow, body = self._post_json('/mcp', {
  62. 'jsonrpc': '2.0',
  63. 'id': 1,
  64. 'method': 'initialize',
  65. 'params': {'protocolVersion': '2025-03-26'},
  66. })
  67. self.assertEqual(200, status)
  68. self.assertEqual('2025-06-18', body['result']['protocolVersion'])
  69. def test_existing_mcp_notification_stays_method_not_found(self):
  70. status, _allow, body = self._post_json('/mcp', {
  71. 'jsonrpc': '2.0',
  72. 'method': 'notifications/initialized',
  73. })
  74. self.assertEqual(200, status)
  75. self.assertEqual(-32601, body['error']['code'])
  76. self.assertIn('Method not found', body['error']['message'])
  77. def test_existing_get_mcp_stays_404(self):
  78. status, _allow, body = self._exchange('GET', '/mcp')
  79. self.assertEqual(404, status)
  80. self.assertEqual(b'', body)
  81. def test_health_stays_200(self):
  82. status, _allow, body = self._exchange('GET', '/health')
  83. self.assertEqual(200, status)
  84. self.assertEqual({'ok': True}, json.loads(body.decode('utf-8')))
  85. def test_delete_mcp_stays_unimplemented(self):
  86. status, _allow, _body = self._exchange('DELETE', '/mcp')
  87. self.assertEqual(501, status)
  88. def test_stream_lookalike_paths_stay_404(self):
  89. status, _allow, _body = self._post_json('/mcp/stream-extra', {
  90. 'jsonrpc': '2.0',
  91. 'id': 1,
  92. 'method': 'ping',
  93. })
  94. self.assertEqual(404, status)
  95. status, _allow, _body = self._post_json('/mcp/stream?x=1', {
  96. 'jsonrpc': '2.0',
  97. 'id': 1,
  98. 'method': 'ping',
  99. })
  100. self.assertEqual(404, status)
  101. def test_get_stream_returns_405(self):
  102. status, allow, body = self._exchange('GET', '/mcp/stream')
  103. self.assertEqual(405, status)
  104. self.assertEqual('POST', allow)
  105. self.assertEqual(b'', body)
  106. def test_delete_stream_returns_405(self):
  107. status, allow, body = self._exchange('DELETE', '/mcp/stream')
  108. self.assertEqual(405, status)
  109. self.assertEqual('POST', allow)
  110. self.assertEqual(b'', body)
  111. def test_stream_notification_returns_202(self):
  112. status, allow, body = self._post_json('/mcp/stream', {
  113. 'jsonrpc': '2.0',
  114. 'method': 'notifications/initialized',
  115. })
  116. self.assertEqual(202, status)
  117. self.assertIsNone(allow)
  118. self.assertIsNone(body)
  119. def test_stream_invalid_json_returns_400(self):
  120. status, _allow, body = self._exchange(
  121. 'POST',
  122. '/mcp/stream',
  123. b'this is not json',
  124. )
  125. self.assertEqual(400, status)
  126. parsed = json.loads(body.decode('utf-8'))
  127. self.assertEqual(-32700, parsed['error']['code'])
  128. self.assertEqual('Parse error', parsed['error']['message'])
  129. def test_stream_empty_body_returns_parse_error(self):
  130. status, _allow, body = self._exchange('POST', '/mcp/stream', b'')
  131. self.assertEqual(400, status)
  132. parsed = json.loads(body.decode('utf-8'))
  133. self.assertEqual(-32700, parsed['error']['code'])
  134. def test_stream_non_object_returns_invalid_request(self):
  135. status, _allow, body = self._exchange('POST', '/mcp/stream', b'[1]')
  136. self.assertEqual(400, status)
  137. parsed = json.loads(body.decode('utf-8'))
  138. self.assertEqual(-32600, parsed['error']['code'])
  139. self.assertEqual('Invalid Request', parsed['error']['message'])
  140. def test_stream_ping(self):
  141. status, _allow, body = self._post_json('/mcp/stream', {
  142. 'jsonrpc': '2.0',
  143. 'id': 7,
  144. 'method': 'ping',
  145. })
  146. self.assertEqual(200, status)
  147. self.assertEqual({'jsonrpc': '2.0', 'id': 7, 'result': {}}, body)
  148. def test_stream_initialize_echoes_known_versions(self):
  149. for version in ('2025-03-26', '2025-06-18'):
  150. status, _allow, body = self._post_json('/mcp/stream', {
  151. 'jsonrpc': '2.0',
  152. 'id': 1,
  153. 'method': 'initialize',
  154. 'params': {'protocolVersion': version},
  155. })
  156. self.assertEqual(200, status)
  157. self.assertEqual(version, body['result']['protocolVersion'])
  158. self.assertEqual('fms-mcp-gateway', body['result']['serverInfo']['name'])
  159. self.assertEqual(False, body['result']['capabilities']['tools']['listChanged'])
  160. def test_stream_initialize_unknown_version_falls_back(self):
  161. status, _allow, body = self._post_json('/mcp/stream', {
  162. 'jsonrpc': '2.0',
  163. 'id': 1,
  164. 'method': 'initialize',
  165. 'params': {'protocolVersion': '2099-01-01'},
  166. })
  167. self.assertEqual(200, status)
  168. self.assertEqual('2025-03-26', body['result']['protocolVersion'])
  169. def test_stream_initialize_without_params_falls_back(self):
  170. status, _allow, body = self._post_json('/mcp/stream', {
  171. 'jsonrpc': '2.0',
  172. 'id': 1,
  173. 'method': 'initialize',
  174. })
  175. self.assertEqual(200, status)
  176. self.assertEqual('2025-03-26', body['result']['protocolVersion'])
  177. def test_stream_initialize_with_empty_params_falls_back(self):
  178. status, _allow, body = self._post_json('/mcp/stream', {
  179. 'jsonrpc': '2.0',
  180. 'id': 1,
  181. 'method': 'initialize',
  182. 'params': {},
  183. })
  184. self.assertEqual(200, status)
  185. self.assertEqual('2025-03-26', body['result']['protocolVersion'])
  186. def test_stream_initialize_with_non_object_params_falls_back(self):
  187. status, _allow, body = self._post_json('/mcp/stream', {
  188. 'jsonrpc': '2.0',
  189. 'id': 1,
  190. 'method': 'initialize',
  191. 'params': ['2025-06-18'],
  192. })
  193. self.assertEqual(200, status)
  194. self.assertEqual('2025-03-26', body['result']['protocolVersion'])
  195. def test_stream_request_without_method_uses_existing_error(self):
  196. status, _allow, body = self._post_json('/mcp/stream', {
  197. 'jsonrpc': '2.0',
  198. 'id': 3,
  199. })
  200. self.assertEqual(200, status)
  201. self.assertEqual(-32601, body['error']['code'])
  202. def test_stream_tools_list_without_session_matches_existing_error(self):
  203. status, _allow, body = self._post_json('/mcp/stream', {
  204. 'jsonrpc': '2.0',
  205. 'id': 1,
  206. 'method': 'tools/list',
  207. })
  208. self.assertEqual(200, status)
  209. self.assertEqual(-32001, body['error']['code'])
  210. self.assertIn(DEVICE_INVALID_MESSAGE, body['error']['message'])
  211. def test_stream_tools_call_uses_existing_session(self):
  212. session_id = generate_gateway_session_id()
  213. self.mock_session_store.get.return_value = {
  214. 'mcp_token': 'MT_valid_token',
  215. 'admin_id': 456,
  216. 'company_id': 200,
  217. }
  218. status, _allow, body = self._post_json(
  219. '/mcp/stream',
  220. {
  221. 'jsonrpc': '2.0',
  222. 'id': 1,
  223. 'method': 'tools/call',
  224. 'params': {
  225. 'name': 'query_order',
  226. 'arguments': {'order_no': 'ABC123'},
  227. },
  228. },
  229. {'X-Gateway-Session': session_id},
  230. )
  231. self.assertEqual(200, status)
  232. self.assertIn('result', body)
  233. self.assertFalse(body['result']['isError'])
  234. self.mock_api_client.call_tool.assert_called_once()
  235. if __name__ == '__main__':
  236. unittest.main()