|
@@ -0,0 +1,265 @@
|
|
|
|
|
+import json
|
|
|
|
|
+import unittest
|
|
|
|
|
+from http.client import HTTPConnection
|
|
|
|
|
+from threading import Thread
|
|
|
|
|
+from time import sleep
|
|
|
|
|
+from unittest.mock import MagicMock
|
|
|
|
|
+
|
|
|
|
|
+from constants import DEVICE_INVALID_MESSAGE
|
|
|
|
|
+from public_gateway import PublicGatewayApp
|
|
|
|
|
+from public_server import serve_public
|
|
|
|
|
+from utils.security import generate_gateway_session_id
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestPublicServerStream(unittest.TestCase):
|
|
|
|
|
+ @classmethod
|
|
|
|
|
+ def setUpClass(cls):
|
|
|
|
|
+ cls.mock_session_store = MagicMock()
|
|
|
|
|
+ cls.mock_api_client = MagicMock()
|
|
|
|
|
+ cls.mock_auth_client = MagicMock()
|
|
|
|
|
+ cls.gateway_app = PublicGatewayApp(
|
|
|
|
|
+ session_store=cls.mock_session_store,
|
|
|
|
|
+ api_client=cls.mock_api_client,
|
|
|
|
|
+ auth_client=cls.mock_auth_client,
|
|
|
|
|
+ )
|
|
|
|
|
+ cls.server_thread = Thread(
|
|
|
|
|
+ target=serve_public,
|
|
|
|
|
+ args=(cls.gateway_app,),
|
|
|
|
|
+ kwargs={'host': '127.0.0.1', 'port': 18766, 'enable_rate_limit': False},
|
|
|
|
|
+ daemon=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ cls.server_thread.start()
|
|
|
|
|
+ sleep(0.5)
|
|
|
|
|
+
|
|
|
|
|
+ def setUp(self):
|
|
|
|
|
+ self.mock_session_store.reset_mock()
|
|
|
|
|
+ self.mock_api_client.reset_mock()
|
|
|
|
|
+ self.mock_auth_client.reset_mock()
|
|
|
|
|
+ self.mock_session_store.get.return_value = None
|
|
|
|
|
+ self.mock_api_client.list_enabled_tools.return_value = {
|
|
|
|
|
+ 'code': 'MCP_0000',
|
|
|
|
|
+ 'data': {'tool_codes': ['query_order']},
|
|
|
|
|
+ }
|
|
|
|
|
+ self.mock_api_client.call_tool.return_value = {
|
|
|
|
|
+ 'code': '0',
|
|
|
|
|
+ 'msg': 'success',
|
|
|
|
|
+ 'data': {'order_no': 'ABC123'},
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ def _exchange(self, method, path, body=None, headers=None):
|
|
|
|
|
+ conn = HTTPConnection('127.0.0.1', 18766, timeout=5)
|
|
|
|
|
+ try:
|
|
|
|
|
+ request_headers = {}
|
|
|
|
|
+ if headers:
|
|
|
|
|
+ request_headers.update(headers)
|
|
|
|
|
+ conn.request(method, path, body, request_headers)
|
|
|
|
|
+ response = conn.getresponse()
|
|
|
|
|
+ return response.status, response.getheader('Allow'), response.read()
|
|
|
|
|
+ finally:
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+
|
|
|
|
|
+ def _post_json(self, path, payload, headers=None):
|
|
|
|
|
+ raw = json.dumps(payload).encode('utf-8')
|
|
|
|
|
+ status, allow, body = self._exchange('POST', path, raw, headers)
|
|
|
|
|
+ parsed = json.loads(body.decode('utf-8')) if body else None
|
|
|
|
|
+ return status, allow, parsed
|
|
|
|
|
+
|
|
|
|
|
+ def test_existing_mcp_initialize_keeps_protocol_version(self):
|
|
|
|
|
+ status, _allow, body = self._post_json('/mcp', {
|
|
|
|
|
+ 'jsonrpc': '2.0',
|
|
|
|
|
+ 'id': 1,
|
|
|
|
|
+ 'method': 'initialize',
|
|
|
|
|
+ 'params': {'protocolVersion': '2025-03-26'},
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertEqual(200, status)
|
|
|
|
|
+ self.assertEqual('2025-06-18', body['result']['protocolVersion'])
|
|
|
|
|
+
|
|
|
|
|
+ def test_existing_mcp_notification_stays_method_not_found(self):
|
|
|
|
|
+ status, _allow, body = self._post_json('/mcp', {
|
|
|
|
|
+ 'jsonrpc': '2.0',
|
|
|
|
|
+ 'method': 'notifications/initialized',
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertEqual(200, status)
|
|
|
|
|
+ self.assertEqual(-32601, body['error']['code'])
|
|
|
|
|
+ self.assertIn('Method not found', body['error']['message'])
|
|
|
|
|
+
|
|
|
|
|
+ def test_existing_get_mcp_stays_404(self):
|
|
|
|
|
+ status, _allow, body = self._exchange('GET', '/mcp')
|
|
|
|
|
+ self.assertEqual(404, status)
|
|
|
|
|
+ self.assertEqual(b'', body)
|
|
|
|
|
+
|
|
|
|
|
+ def test_health_stays_200(self):
|
|
|
|
|
+ status, _allow, body = self._exchange('GET', '/health')
|
|
|
|
|
+ self.assertEqual(200, status)
|
|
|
|
|
+ self.assertEqual({'ok': True}, json.loads(body.decode('utf-8')))
|
|
|
|
|
+
|
|
|
|
|
+ def test_delete_mcp_stays_unimplemented(self):
|
|
|
|
|
+ status, _allow, _body = self._exchange('DELETE', '/mcp')
|
|
|
|
|
+ self.assertEqual(501, status)
|
|
|
|
|
+
|
|
|
|
|
+ def test_stream_lookalike_paths_stay_404(self):
|
|
|
|
|
+ status, _allow, _body = self._post_json('/mcp/stream-extra', {
|
|
|
|
|
+ 'jsonrpc': '2.0',
|
|
|
|
|
+ 'id': 1,
|
|
|
|
|
+ 'method': 'ping',
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertEqual(404, status)
|
|
|
|
|
+ status, _allow, _body = self._post_json('/mcp/stream?x=1', {
|
|
|
|
|
+ 'jsonrpc': '2.0',
|
|
|
|
|
+ 'id': 1,
|
|
|
|
|
+ 'method': 'ping',
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertEqual(404, status)
|
|
|
|
|
+
|
|
|
|
|
+ def test_get_stream_returns_405(self):
|
|
|
|
|
+ status, allow, body = self._exchange('GET', '/mcp/stream')
|
|
|
|
|
+ self.assertEqual(405, status)
|
|
|
|
|
+ self.assertEqual('POST', allow)
|
|
|
|
|
+ self.assertEqual(b'', body)
|
|
|
|
|
+
|
|
|
|
|
+ def test_delete_stream_returns_405(self):
|
|
|
|
|
+ status, allow, body = self._exchange('DELETE', '/mcp/stream')
|
|
|
|
|
+ self.assertEqual(405, status)
|
|
|
|
|
+ self.assertEqual('POST', allow)
|
|
|
|
|
+ self.assertEqual(b'', body)
|
|
|
|
|
+
|
|
|
|
|
+ def test_stream_notification_returns_202(self):
|
|
|
|
|
+ status, allow, body = self._post_json('/mcp/stream', {
|
|
|
|
|
+ 'jsonrpc': '2.0',
|
|
|
|
|
+ 'method': 'notifications/initialized',
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertEqual(202, status)
|
|
|
|
|
+ self.assertIsNone(allow)
|
|
|
|
|
+ self.assertIsNone(body)
|
|
|
|
|
+
|
|
|
|
|
+ def test_stream_invalid_json_returns_400(self):
|
|
|
|
|
+ status, _allow, body = self._exchange(
|
|
|
|
|
+ 'POST',
|
|
|
|
|
+ '/mcp/stream',
|
|
|
|
|
+ b'this is not json',
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(400, status)
|
|
|
|
|
+ parsed = json.loads(body.decode('utf-8'))
|
|
|
|
|
+ self.assertEqual(-32700, parsed['error']['code'])
|
|
|
|
|
+ self.assertEqual('Parse error', parsed['error']['message'])
|
|
|
|
|
+
|
|
|
|
|
+ def test_stream_empty_body_returns_parse_error(self):
|
|
|
|
|
+ status, _allow, body = self._exchange('POST', '/mcp/stream', b'')
|
|
|
|
|
+ self.assertEqual(400, status)
|
|
|
|
|
+ parsed = json.loads(body.decode('utf-8'))
|
|
|
|
|
+ self.assertEqual(-32700, parsed['error']['code'])
|
|
|
|
|
+
|
|
|
|
|
+ def test_stream_non_object_returns_invalid_request(self):
|
|
|
|
|
+ status, _allow, body = self._exchange('POST', '/mcp/stream', b'[1]')
|
|
|
|
|
+ self.assertEqual(400, status)
|
|
|
|
|
+ parsed = json.loads(body.decode('utf-8'))
|
|
|
|
|
+ self.assertEqual(-32600, parsed['error']['code'])
|
|
|
|
|
+ self.assertEqual('Invalid Request', parsed['error']['message'])
|
|
|
|
|
+
|
|
|
|
|
+ def test_stream_ping(self):
|
|
|
|
|
+ status, _allow, body = self._post_json('/mcp/stream', {
|
|
|
|
|
+ 'jsonrpc': '2.0',
|
|
|
|
|
+ 'id': 7,
|
|
|
|
|
+ 'method': 'ping',
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertEqual(200, status)
|
|
|
|
|
+ self.assertEqual({'jsonrpc': '2.0', 'id': 7, 'result': {}}, body)
|
|
|
|
|
+
|
|
|
|
|
+ def test_stream_initialize_echoes_known_versions(self):
|
|
|
|
|
+ for version in ('2025-03-26', '2025-06-18'):
|
|
|
|
|
+ status, _allow, body = self._post_json('/mcp/stream', {
|
|
|
|
|
+ 'jsonrpc': '2.0',
|
|
|
|
|
+ 'id': 1,
|
|
|
|
|
+ 'method': 'initialize',
|
|
|
|
|
+ 'params': {'protocolVersion': version},
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertEqual(200, status)
|
|
|
|
|
+ self.assertEqual(version, body['result']['protocolVersion'])
|
|
|
|
|
+ self.assertEqual('fms-mcp-gateway', body['result']['serverInfo']['name'])
|
|
|
|
|
+ self.assertEqual(False, body['result']['capabilities']['tools']['listChanged'])
|
|
|
|
|
+
|
|
|
|
|
+ def test_stream_initialize_unknown_version_falls_back(self):
|
|
|
|
|
+ status, _allow, body = self._post_json('/mcp/stream', {
|
|
|
|
|
+ 'jsonrpc': '2.0',
|
|
|
|
|
+ 'id': 1,
|
|
|
|
|
+ 'method': 'initialize',
|
|
|
|
|
+ 'params': {'protocolVersion': '2099-01-01'},
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertEqual(200, status)
|
|
|
|
|
+ self.assertEqual('2025-03-26', body['result']['protocolVersion'])
|
|
|
|
|
+
|
|
|
|
|
+ def test_stream_initialize_without_params_falls_back(self):
|
|
|
|
|
+ status, _allow, body = self._post_json('/mcp/stream', {
|
|
|
|
|
+ 'jsonrpc': '2.0',
|
|
|
|
|
+ 'id': 1,
|
|
|
|
|
+ 'method': 'initialize',
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertEqual(200, status)
|
|
|
|
|
+ self.assertEqual('2025-03-26', body['result']['protocolVersion'])
|
|
|
|
|
+
|
|
|
|
|
+ def test_stream_initialize_with_empty_params_falls_back(self):
|
|
|
|
|
+ status, _allow, body = self._post_json('/mcp/stream', {
|
|
|
|
|
+ 'jsonrpc': '2.0',
|
|
|
|
|
+ 'id': 1,
|
|
|
|
|
+ 'method': 'initialize',
|
|
|
|
|
+ 'params': {},
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertEqual(200, status)
|
|
|
|
|
+ self.assertEqual('2025-03-26', body['result']['protocolVersion'])
|
|
|
|
|
+
|
|
|
|
|
+ def test_stream_initialize_with_non_object_params_falls_back(self):
|
|
|
|
|
+ status, _allow, body = self._post_json('/mcp/stream', {
|
|
|
|
|
+ 'jsonrpc': '2.0',
|
|
|
|
|
+ 'id': 1,
|
|
|
|
|
+ 'method': 'initialize',
|
|
|
|
|
+ 'params': ['2025-06-18'],
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertEqual(200, status)
|
|
|
|
|
+ self.assertEqual('2025-03-26', body['result']['protocolVersion'])
|
|
|
|
|
+
|
|
|
|
|
+ def test_stream_request_without_method_uses_existing_error(self):
|
|
|
|
|
+ status, _allow, body = self._post_json('/mcp/stream', {
|
|
|
|
|
+ 'jsonrpc': '2.0',
|
|
|
|
|
+ 'id': 3,
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertEqual(200, status)
|
|
|
|
|
+ self.assertEqual(-32601, body['error']['code'])
|
|
|
|
|
+
|
|
|
|
|
+ def test_stream_tools_list_without_session_matches_existing_error(self):
|
|
|
|
|
+ status, _allow, body = self._post_json('/mcp/stream', {
|
|
|
|
|
+ 'jsonrpc': '2.0',
|
|
|
|
|
+ 'id': 1,
|
|
|
|
|
+ 'method': 'tools/list',
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertEqual(200, status)
|
|
|
|
|
+ self.assertEqual(-32001, body['error']['code'])
|
|
|
|
|
+ self.assertIn(DEVICE_INVALID_MESSAGE, body['error']['message'])
|
|
|
|
|
+
|
|
|
|
|
+ def test_stream_tools_call_uses_existing_session(self):
|
|
|
|
|
+ session_id = generate_gateway_session_id()
|
|
|
|
|
+ self.mock_session_store.get.return_value = {
|
|
|
|
|
+ 'mcp_token': 'MT_valid_token',
|
|
|
|
|
+ 'admin_id': 456,
|
|
|
|
|
+ 'company_id': 200,
|
|
|
|
|
+ }
|
|
|
|
|
+ status, _allow, body = self._post_json(
|
|
|
|
|
+ '/mcp/stream',
|
|
|
|
|
+ {
|
|
|
|
|
+ 'jsonrpc': '2.0',
|
|
|
|
|
+ 'id': 1,
|
|
|
|
|
+ 'method': 'tools/call',
|
|
|
|
|
+ 'params': {
|
|
|
|
|
+ 'name': 'query_order',
|
|
|
|
|
+ 'arguments': {'order_no': 'ABC123'},
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ {'X-Gateway-Session': session_id},
|
|
|
|
|
+ )
|
|
|
|
|
+ self.assertEqual(200, status)
|
|
|
|
|
+ self.assertIn('result', body)
|
|
|
|
|
+ self.assertFalse(body['result']['isError'])
|
|
|
|
|
+ self.mock_api_client.call_tool.assert_called_once()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == '__main__':
|
|
|
|
|
+ unittest.main()
|