Przeglądaj źródła

优化暴露接口逻辑,新增订单接口,订单筛选列

jackson 1 tydzień temu
rodzic
commit
c000f89edb

+ 124 - 2
app.py

@@ -13,10 +13,27 @@ from services.auth_client import AuthClient
 from services.gateway_session_store import GatewaySessionStore
 from services.scoped_api_client import ScopedApiClient
 from services.token_store import FileTokenStore, RedisSocketClient, RedisTokenStore
+from tools.list_order_filter_options import ListOrderFilterOptionsTool
 from tools.query_order import QueryOrderTool
+from tools.query_order_exact import QueryOrderExactTool
 from tools.query_track import QueryTrackTool
 
 
+def parse_int_list(value):
+    if not value:
+        return []
+    return [int(item.strip()) for item in value.split(',') if item.strip()]
+
+
+def parse_string_list(value):
+    result = []
+    for item in (value or '').split(','):
+        item = item.strip()
+        if item and item not in result:
+            result.append(item)
+    return result
+
+
 class GatewayApp:
     def __init__(self, auth_client=None, api_client=None, token_store=None):
         self.auth_client = auth_client
@@ -25,6 +42,10 @@ class GatewayApp:
         self._tools = {
             'query_order': QueryOrderTool(api_client=api_client),
             'query_track': QueryTrackTool(api_client=api_client),
+            'query_order_exact': QueryOrderExactTool(api_client=api_client),
+            'list_order_filter_options': ListOrderFilterOptionsTool(
+                api_client=api_client
+            ),
         }
 
     @classmethod
@@ -65,8 +86,36 @@ class GatewayApp:
         )
         return cls(auth_client=auth_client, api_client=api_client, token_store=token_store)
 
+    def registered_tool_names(self):
+        return tuple(self._tools.keys())
+
+    def _enabled_tool_names(self, response):
+        if not isinstance(response, dict):
+            raise RuntimeError('invalid enabled tool response')
+        if response.get('code') != 'MCP_0000':
+            raise RuntimeError(response.get('msg') or 'list enabled tools failed')
+        data = response.get('data')
+        codes = data.get('tool_codes') if isinstance(data, dict) else None
+        if not isinstance(codes, list):
+            raise RuntimeError('invalid enabled tool response')
+        return {
+            code.strip().lower()
+            for code in codes
+            if isinstance(code, str) and code.strip()
+        }
+
+    def _load_enabled_tool_names(self):
+        if self.api_client is None or not hasattr(self.api_client, 'list_enabled_tools'):
+            raise RuntimeError('enabled tool client unavailable')
+        return self._enabled_tool_names(self.api_client.list_enabled_tools())
+
     def list_tools(self):
-        return [tool.metadata() for tool in self._tools.values()]
+        enabled = self._load_enabled_tool_names()
+        return [
+            tool.metadata()
+            for name, tool in self._tools.items()
+            if name in enabled
+        ]
 
     def build_request_id(self, request_id=''):
         request_id = str(request_id or '').strip()
@@ -91,6 +140,8 @@ class GatewayApp:
         tool = self._tools[name]
         if getattr(tool, 'requires_session', True):
             self.ensure_session()
+        if name not in self._load_enabled_tool_names():
+            raise RuntimeError('tool disabled: {0}'.format(name))
         arguments = arguments or {}
         request_id = self.build_request_id(request_id)
         return tool.call(request_id=request_id, **arguments)
@@ -116,7 +167,29 @@ class GatewayApp:
         call_parser.add_argument('--keyword', default='')
         call_parser.add_argument('--order-id', type=int, default=0)
         call_parser.add_argument('--order-number', default='')
+        call_parser.add_argument('--order-numbers', default='')
         call_parser.add_argument('--tracking-number', default='')
+        call_parser.add_argument('--tracking-numbers', default='')
+        call_parser.add_argument('--reference-number', default='')
+        call_parser.add_argument('--reference-numbers', default='')
+        call_parser.add_argument('--outbound-number', default='')
+        call_parser.add_argument('--outbound-numbers', default='')
+        call_parser.add_argument('--container-code', default='')
+        call_parser.add_argument('--container-codes', default='')
+        call_parser.add_argument('--so-number', default='')
+        call_parser.add_argument('--so-numbers', default='')
+        call_parser.add_argument('--shipment-id', default='')
+        call_parser.add_argument('--receiver-country', default='')
+        call_parser.add_argument('--product-ids', default='')
+        call_parser.add_argument('--customer-ids', default='')
+        call_parser.add_argument('--sales-id', type=int, default=0)
+        call_parser.add_argument('--warehouse-ids', default='')
+        call_parser.add_argument('--department-id', type=int, default=0)
+        call_parser.add_argument('--inbound-date-start', default='')
+        call_parser.add_argument('--inbound-date-end', default='')
+        call_parser.add_argument('--outbound-date-start', default='')
+        call_parser.add_argument('--outbound-date-end', default='')
+        call_parser.add_argument('--filter-type', default='')
         call_parser.add_argument('--page', type=int, default=1)
         call_parser.add_argument('--limit', type=int, default=20)
         call_parser.add_argument('--request-id', default='')
@@ -172,6 +245,55 @@ class GatewayApp:
                     tool_args['tracking_number'] = args.tracking_number
                 if args.order_id <= 0 and not args.order_number and not args.tracking_number:
                     raise ValueError('--order-id, --order-number or --tracking-number is required for query_track')
+            elif args.tool == 'query_order_exact':
+                exact_strings = {
+                    'order_number': args.order_number,
+                    'reference_number': args.reference_number,
+                    'tracking_number': args.tracking_number,
+                    'outbound_number': args.outbound_number,
+                    'container_code': args.container_code,
+                    'so_number': args.so_number,
+                    'shipment_id': args.shipment_id,
+                    'receiver_country': args.receiver_country,
+                    'inbound_date_start': args.inbound_date_start,
+                    'inbound_date_end': args.inbound_date_end,
+                    'outbound_date_start': args.outbound_date_start,
+                    'outbound_date_end': args.outbound_date_end,
+                }
+                for field, value in exact_strings.items():
+                    if value:
+                        tool_args[field] = value
+                exact_number_lists = {
+                    'order_numbers': args.order_numbers,
+                    'reference_numbers': args.reference_numbers,
+                    'tracking_numbers': args.tracking_numbers,
+                    'outbound_numbers': args.outbound_numbers,
+                    'container_codes': args.container_codes,
+                    'so_numbers': args.so_numbers,
+                }
+                for field, value in exact_number_lists.items():
+                    if value:
+                        tool_args[field] = parse_string_list(value)
+                exact_lists = {
+                    'product_ids': args.product_ids,
+                    'customer_ids': args.customer_ids,
+                    'warehouse_ids': args.warehouse_ids,
+                }
+                for field, value in exact_lists.items():
+                    if value:
+                        tool_args[field] = parse_int_list(value)
+                if args.sales_id > 0:
+                    tool_args['sales_id'] = args.sales_id
+                if args.department_id > 0:
+                    tool_args['department_id'] = args.department_id
+            elif args.tool == 'list_order_filter_options':
+                if not args.filter_type:
+                    raise ValueError(
+                        '--filter-type is required for '
+                        'list_order_filter_options'
+                    )
+                tool_args['filter_type'] = args.filter_type
+                tool_args['keyword'] = args.keyword
             else:
                 if args.keyword:
                     tool_args['keyword'] = args.keyword
@@ -195,4 +317,4 @@ def main(argv=None):
 
 
 if __name__ == '__main__':
-    raise SystemExit(main(sys.argv[1:]))
+    raise SystemExit(main(sys.argv[1:]))

+ 53 - 15
mcp_protocol.py

@@ -51,20 +51,7 @@ class McpProtocolHandler:
                 tool_name = params.get('name')
                 arguments = params.get('arguments') or {}
                 tool_result = self.gateway_app.call_tool(tool_name, arguments)
-                structured_content = tool_result.get('data') or {}
-                return self._success_response(
-                    request_id,
-                    {
-                        'content': [
-                            {
-                                'type': 'text',
-                                'text': self._render_text(structured_content),
-                            }
-                        ],
-                        'structuredContent': structured_content,
-                        'isError': False,
-                    },
-                )
+                return self._tool_call_response(request_id, tool_result)
             return self._error_response(request_id, -32601, 'Method not found: {0}'.format(method or '<empty>'))
         except Exception as exc:
             if method == 'tools/call':
@@ -108,6 +95,57 @@ class McpProtocolHandler:
             normalized['inputSchema'] = normalized.pop('input_schema')
         return normalized
 
+    @classmethod
+    def _tool_call_response(cls, request_id, tool_result):
+        if not isinstance(tool_result, dict):
+            raise RuntimeError('invalid tool response')
+
+        raw_code = tool_result.get('code')
+        code = str(raw_code).strip() if raw_code is not None else ''
+        message = str(tool_result.get('msg') or '').strip()
+        data = tool_result.get('data')
+        meta = tool_result.get('meta')
+
+        if code in ('MCP_0000', '0'):
+            if isinstance(data, dict):
+                structured_content = dict(data)
+            elif data:
+                structured_content = {'data': data}
+            else:
+                structured_content = {}
+            if isinstance(meta, dict) and meta:
+                structured_content['meta'] = dict(meta)
+
+            return cls._success_response(request_id, {
+                'content': [{
+                    'type': 'text',
+                    'text': cls._render_text(structured_content),
+                }],
+                'structuredContent': structured_content,
+                'isError': False,
+            })
+
+        error_content = {
+            'code': code or 'MCP_9001',
+            'msg': message or 'tool call failed',
+        }
+        if data not in (None, [], {}):
+            error_content['data'] = data
+        if isinstance(meta, dict) and meta:
+            error_content['meta'] = dict(meta)
+
+        return cls._success_response(request_id, {
+            'content': [{
+                'type': 'text',
+                'text': '{0}: {1}'.format(
+                    error_content['code'],
+                    error_content['msg'],
+                ),
+            }],
+            'structuredContent': error_content,
+            'isError': True,
+        })
+
     @staticmethod
     def _render_text(structured_content):
         if not structured_content:
@@ -213,4 +251,4 @@ class McpProtocolHandler:
                 'code': code,
                 'message': message,
             },
-        }
+        }

+ 46 - 6
public_gateway.py

@@ -2,7 +2,9 @@ import logging
 import uuid
 
 from constants import DEVICE_INVALID_MESSAGE
+from tools.list_order_filter_options import ListOrderFilterOptionsTool
 from tools.query_order import QueryOrderTool
+from tools.query_order_exact import QueryOrderExactTool
 from tools.query_track import QueryTrackTool
 from utils.security import hash_gateway_session_id
 
@@ -17,10 +19,48 @@ class PublicGatewayApp:
         self._tools = {
             'query_order': QueryOrderTool(api_client=None),
             'query_track': QueryTrackTool(api_client=None),
+            'query_order_exact': QueryOrderExactTool(api_client=None),
+            'list_order_filter_options': ListOrderFilterOptionsTool(
+                api_client=None
+            ),
         }
 
-    def list_tools(self):
-        return [tool.metadata() for tool in self._tools.values()]
+    def registered_tool_names(self):
+        return tuple(self._tools.keys())
+
+    def _require_session(self, gateway_session_id):
+        session = self.session_store.get(gateway_session_id)
+        if not session or not session.get('mcp_token'):
+            raise RuntimeError(DEVICE_INVALID_MESSAGE)
+        return session
+
+    def _enabled_tool_names(self, response):
+        if not isinstance(response, dict):
+            raise RuntimeError('invalid enabled tool response')
+        if response.get('code') != 'MCP_0000':
+            raise RuntimeError(response.get('msg') or 'list enabled tools failed')
+        data = response.get('data')
+        codes = data.get('tool_codes') if isinstance(data, dict) else None
+        if not isinstance(codes, list):
+            raise RuntimeError('invalid enabled tool response')
+        return {
+            code.strip().lower()
+            for code in codes
+            if isinstance(code, str) and code.strip()
+        }
+
+    def _load_enabled_tool_names(self, token):
+        response = self.api_client.list_enabled_tools(token)
+        return self._enabled_tool_names(response)
+
+    def list_tools(self, gateway_session_id):
+        session = self._require_session(gateway_session_id)
+        enabled = self._load_enabled_tool_names(session['mcp_token'])
+        return [
+            tool.metadata()
+            for name, tool in self._tools.items()
+            if name in enabled
+        ]
 
     def build_request_id(self, request_id=''):
         request_id = str(request_id or '').strip()
@@ -30,9 +70,9 @@ class PublicGatewayApp:
         if name not in self._tools:
             raise KeyError('tool not registered: {0}'.format(name))
 
-        session = self.session_store.get(gateway_session_id)
-        if not session or not session.get('mcp_token'):
-            raise RuntimeError(DEVICE_INVALID_MESSAGE)
+        session = self._require_session(gateway_session_id)
+        if name not in self._load_enabled_tool_names(session['mcp_token']):
+            raise RuntimeError('tool disabled: {0}'.format(name))
 
         tool = self._tools[name]
         request_id = self.build_request_id(request_id)
@@ -57,4 +97,4 @@ class PublicGatewayApp:
             return result
         except Exception as e:
             logger.error(f"[AUDIT] tool_error: session_hash={session_hash}, tool={name}, request_id={request_id}, error={str(e)}")
-            raise
+            raise

+ 14 - 13
public_server.py

@@ -26,7 +26,7 @@ class PublicMcpHttpHandler:
         self.rate_limiter = rate_limiter
         # Cache registered tool names at startup for rate-key validation;
         # unknown names fall back to the bare IP bucket, preventing bucket explosion
-        self._known_tools = frozenset(t.get('name', '') for t in gateway_app.list_tools())
+        self._known_tools = frozenset(gateway_app.registered_tool_names())
 
     def _check_rate_limit(self, rate_key, method, request_id=None, *, log_identity=None):
         """Returns an error response if rate limit exceeded, else None.
@@ -59,16 +59,19 @@ class PublicMcpHttpHandler:
                     },
                 })
             if method == 'tools/list':
-                # tools/list has no session context — use a dedicated IP-based bucket
-                # so it doesn't compete with the per-session tools/call quota
+                # Keep list traffic in a dedicated IP-based bucket so it does not
+                # compete with the per-session tools/call quota.
                 blocked = self._check_rate_limit(
                     'list:{0}'.format(client_ip), method, request_id,
                     log_identity=client_ip,
                 )
                 if blocked:
                     return blocked
+                context = self.context_parser.parse(headers or {})
+                if not context.has_session():
+                    raise RuntimeError(DEVICE_INVALID_MESSAGE)
                 tools = []
-                for tool in self.gateway_app.list_tools():
+                for tool in self.gateway_app.list_tools(context.gateway_session_id):
                     normalized = dict(tool)
                     if 'input_schema' in normalized:
                         normalized['inputSchema'] = normalized.pop('input_schema')
@@ -95,12 +98,10 @@ class PublicMcpHttpHandler:
                     request_id='rq_http_{0}_{1}'.format(request_id, uuid.uuid4().hex[:16]),
                     client_ip=client_ip,
                 )
-                structured_content = result.get('data') or {}
-                return McpProtocolHandler._success_response(request_id, {
-                    'content': [{'type': 'text', 'text': McpProtocolHandler._render_text(structured_content)}],
-                    'structuredContent': structured_content,
-                    'isError': False,
-                })
+                return McpProtocolHandler._tool_call_response(
+                    request_id,
+                    result,
+                )
             return McpProtocolHandler._error_response(request_id, -32601, 'Method not found: {0}'.format(method))
         except Exception as exc:
             if method == 'tools/call':
@@ -128,12 +129,14 @@ def create_http_handler(gateway_app, rate_limiter=None):
 
         def do_POST(self):
             client_ip = extract_client_ip(dict(self.headers.items()), self.client_address)
+            length = int(self.headers.get('Content-Length') or '0')
             if self.path != '/mcp':
                 logger.warning(f"[HTTP] 404: ip={client_ip}, path={self.path}")
+                if length > 0:
+                    self.rfile.read(length)
                 self.send_response(404)
                 self.end_headers()
                 return
-            length = int(self.headers.get('Content-Length') or '0')
             body = self.rfile.read(length).decode('utf-8-sig')
             try:
                 message = json.loads(body)
@@ -194,5 +197,3 @@ def serve_public(gateway_app, host='0.0.0.0', port=8765, enable_rate_limit=True,
     except KeyboardInterrupt:
         logger.info("Shutting down public MCP Gateway")
         server.shutdown()
-
-

+ 6 - 0
services/api_client.py

@@ -30,3 +30,9 @@ class ApiClient:
             'X-Request-Id': request_id,
         }
         return self.transport.post_json(url, payload, headers, self.timeout)
+
+    def list_enabled_tools(self):
+        token = self.token_store.require_token()
+        url = self.base_url + '/mcp/tools/listEnabledTools'
+        headers = {'Authorization': 'Bearer {0}'.format(token)}
+        return self.transport.post_json(url, {}, headers, self.timeout)

+ 8 - 0
services/scoped_api_client.py

@@ -21,3 +21,11 @@ class ScopedApiClient:
         if client_ip:
             headers['X-MCP-Client-IP'] = client_ip
         return self.transport.post_json(url, payload, headers, self.timeout)
+
+    def list_enabled_tools(self, token):
+        token = str(token or '').strip()
+        if not token:
+            raise RuntimeError('mcp token missing')
+        url = self.base_url + '/mcp/tools/listEnabledTools'
+        headers = {'Authorization': 'Bearer {0}'.format(token)}
+        return self.transport.post_json(url, {}, headers, self.timeout)

+ 31 - 2
tests/test_app_coverage.py

@@ -70,6 +70,21 @@ class FromConfigUnsupportedStoreTest(unittest.TestCase):
 # ---------------------------------------------------------------------------
 
 class DummyApiClient:
+    def list_enabled_tools(self):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_order',
+                    'query_track',
+                    'query_order_exact',
+                    'list_order_filter_options',
+                    'fake_tool',
+                    'no_kw_tool',
+                ],
+            },
+        }
+
     def call_tool(self, tool_code, route_path, payload, request_id):
         return {
             'code': 'MCP_0000',
@@ -192,15 +207,29 @@ class MainFunctionTest(unittest.TestCase):
                 session_key='test_machine:test_user',
             )
             stdout = io.StringIO()
+            enabled = {
+                'code': 'MCP_0000',
+                'data': {
+                    'tool_codes': [
+                        'query_order',
+                        'query_track',
+                        'query_order_exact',
+                        'list_order_filter_options',
+                    ],
+                },
+            }
             with patch.object(config_module.GatewayConfig, 'from_env', return_value=fake_config):
-                with patch('sys.stdout', stdout):
-                    code = main(['list-tools'])
+                with patch('services.api_client.ApiClient.list_enabled_tools', return_value=enabled):
+                    with patch('sys.stdout', stdout):
+                        code = main(['list-tools'])
 
             self.assertEqual(0, code)
             tools = json.loads(stdout.getvalue())
             names = [t['name'] for t in tools]
             self.assertIn('query_order', names)
             self.assertIn('query_track', names)
+            self.assertIn('query_order_exact', names)
+            self.assertIn('list_order_filter_options', names)
 
     def test_main_propagates_value_error_for_bad_store(self):
         """

+ 16 - 1
tests/test_bind_auth_code_tool.py

@@ -6,6 +6,19 @@ from services.token_store import InMemoryTokenStore
 
 
 class DummyApiClient:
+    def list_enabled_tools(self):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_order',
+                    'query_track',
+                    'query_order_exact',
+                    'list_order_filter_options',
+                ],
+            },
+        }
+
     def call_tool(self, tool_code, route_path, payload, request_id):
         return {
             'code': 'MCP_0000',
@@ -48,6 +61,8 @@ class NoBindAuthCodeToolTest(unittest.TestCase):
         names = [tool['name'] for tool in response['result']['tools']]
         self.assertIn('query_order', names)
         self.assertIn('query_track', names)
+        self.assertIn('query_order_exact', names)
+        self.assertIn('list_order_filter_options', names)
         self.assertNotIn('bind_auth_code', names)
 
     def test_bind_auth_code_call_is_not_registered(self):
@@ -93,4 +108,4 @@ class NoBindAuthCodeToolTest(unittest.TestCase):
 
 
 if __name__ == '__main__':
-    unittest.main()
+    unittest.main()

+ 7 - 1
tests/test_cli_and_file_store.py

@@ -30,6 +30,12 @@ class DummyApiClient:
     def __init__(self):
         self.calls = []
 
+    def list_enabled_tools(self):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_order', 'query_track']},
+        }
+
     def call_tool(self, tool_code, route_path, payload, request_id):
         self.calls.append(
             {
@@ -174,4 +180,4 @@ class CliAndFileStoreTest(unittest.TestCase):
 
 
 if __name__ == '__main__':
-    unittest.main()
+    unittest.main()

+ 47 - 2
tests/test_gateway_query_order.py

@@ -20,6 +20,19 @@ class DummyTransport:
                 'timeout': timeout,
             }
         )
+        if url.endswith('/mcp/tools/listEnabledTools'):
+            return {
+                'code': 'MCP_0000',
+                'msg': 'success',
+                'data': {
+                    'tool_codes': [
+                        'query_order',
+                        'query_track',
+                        'query_order_exact',
+                        'list_order_filter_options',
+                    ],
+                },
+            }
         return {
             'code': 'MCP_0000',
             'msg': 'success',
@@ -29,7 +42,7 @@ class DummyTransport:
                 'tips': [],
             },
             'meta': {
-                'request_id': headers['X-Request-Id'],
+                'request_id': headers.get('X-Request-Id', ''),
             },
         }
 
@@ -49,12 +62,20 @@ class BomHttpResponse:
         return self.body
 class GatewayQueryOrderTest(unittest.TestCase):
     def test_gateway_lists_query_order_tool(self):
-        app = GatewayApp()
+        store = InMemoryTokenStore(refresh_skew_seconds=60)
+        store.save('MT_demo', '2099-01-01T00:00:00')
+        app = GatewayApp(api_client=ApiClient(
+            base_url='http://tools.example.test',
+            token_store=store,
+            transport=DummyTransport(),
+        ))
 
         tools = app.list_tools()
 
         by_name = {tool['name']: tool for tool in tools}
         self.assertIn('query_order', by_name)
+        self.assertIn('query_order_exact', by_name)
+        self.assertIn('list_order_filter_options', by_name)
         self.assertIn('keyword', by_name['query_order']['input_schema']['required'])
 
     def test_json_transport_accepts_utf8_bom_response(self):
@@ -96,6 +117,30 @@ class GatewayQueryOrderTest(unittest.TestCase):
         self.assertEqual('rq_demo', transport.calls[0]['headers']['X-Request-Id'])
         self.assertEqual('http://tools.example.test/mcp/tools/queryOrder', transport.calls[0]['url'])
 
+    def test_api_client_lists_enabled_tools_without_tool_headers(self):
+        transport = DummyTransport()
+        store = InMemoryTokenStore(refresh_skew_seconds=60)
+        store.save('MT_demo', '2099-01-01T00:00:00')
+        client = ApiClient(
+            base_url='http://tools.example.test',
+            token_store=store,
+            transport=transport,
+            timeout=8,
+        )
+
+        response = client.list_enabled_tools()
+
+        self.assertEqual('MCP_0000', response['code'])
+        self.assertEqual(
+            'http://tools.example.test/mcp/tools/listEnabledTools',
+            transport.calls[0]['url'],
+        )
+        self.assertEqual({}, transport.calls[0]['payload'])
+        self.assertEqual(
+            {'Authorization': 'Bearer MT_demo'},
+            transport.calls[0]['headers'],
+        )
+
     def test_query_order_tool_normalizes_input_before_forwarding(self):
         transport = DummyTransport()
         store = InMemoryTokenStore(refresh_skew_seconds=60)

+ 61 - 1
tests/test_gateway_runtime.py

@@ -26,6 +26,22 @@ class DummyAuthClient:
 class DummyApiClient:
     def __init__(self):
         self.calls = []
+        self.enabled_calls = 0
+        self.enabled_response = {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_order',
+                    'query_track',
+                    'query_order_exact',
+                    'list_order_filter_options',
+                ],
+            },
+        }
+
+    def list_enabled_tools(self):
+        self.enabled_calls += 1
+        return self.enabled_response
 
     def call_tool(self, tool_code, route_path, payload, request_id):
         self.calls.append(
@@ -63,9 +79,53 @@ class GatewayRuntimeTest(unittest.TestCase):
 
         self.assertIn('query_order', names)
         self.assertIn('query_track', names)
+        self.assertIn('query_order_exact', names)
+        self.assertIn('list_order_filter_options', names)
         self.assertNotIn('bind_auth_code', names)
         self.assertFalse(hasattr(app, 'bind'))
 
+    def test_list_tools_intersects_registry_codes_with_local_tools(self):
+        api_client = DummyApiClient()
+        api_client.enabled_response = {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': ['query_order_exact', 'unknown_tool'],
+            },
+        }
+        app = GatewayApp(api_client=api_client)
+
+        names = [tool['name'] for tool in app.list_tools()]
+
+        self.assertEqual(['query_order_exact'], names)
+        self.assertEqual(1, api_client.enabled_calls)
+
+    def test_list_tools_fails_closed_on_registry_error(self):
+        api_client = DummyApiClient()
+        api_client.enabled_response = {
+            'code': 'MCP_9001',
+            'msg': 'registry unavailable',
+            'data': {},
+        }
+        app = GatewayApp(api_client=api_client)
+
+        with self.assertRaisesRegex(RuntimeError, 'registry unavailable'):
+            app.list_tools()
+
+    def test_call_tool_rejects_dynamically_disabled_tool_before_forwarding(self):
+        store = InMemoryTokenStore(refresh_skew_seconds=60)
+        store.save('MT_valid', '2099-01-01T00:00:00')
+        api_client = DummyApiClient()
+        api_client.enabled_response = {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_track']},
+        }
+        app = GatewayApp(api_client=api_client, token_store=store)
+
+        with self.assertRaisesRegex(RuntimeError, 'tool disabled: query_order'):
+            app.call_tool('query_order', {'keyword': 'ORDER-1'})
+
+        self.assertEqual([], api_client.calls)
+
     def test_call_tool_refreshes_expiring_token_and_generates_request_id(self):
         store = InMemoryTokenStore(refresh_skew_seconds=60)
         expiring_time = (datetime.now() + timedelta(seconds=10)).isoformat(timespec='seconds')
@@ -88,4 +148,4 @@ class GatewayRuntimeTest(unittest.TestCase):
 
 
 if __name__ == '__main__':
-    unittest.main()
+    unittest.main()

+ 127 - 0
tests/test_list_order_filter_options_tool.py

@@ -0,0 +1,127 @@
+import unittest
+from io import StringIO
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from tools.list_order_filter_options import ListOrderFilterOptionsTool
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.last_call = None
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.last_call = {
+            'tool_code': tool_code,
+            'route_path': route_path,
+            'payload': payload,
+            'request_id': request_id,
+        }
+        return {'code': 'MCP_0000'}
+
+    def list_enabled_tools(self):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['list_order_filter_options']},
+        }
+
+
+class PublicSessionStore:
+    def get(self, gateway_session_id):
+        if gateway_session_id == 'GWS_test':
+            return {'mcp_token': 'MT_test'}
+        return None
+
+
+class PublicApiClient:
+    def list_enabled_tools(self, token):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['list_order_filter_options']},
+        }
+
+
+class ListOrderFilterOptionsToolTest(unittest.TestCase):
+    def test_local_and_public_gateways_register_tool(self):
+        local_names = {
+            tool['name']
+            for tool in GatewayApp(api_client=RecordingApiClient()).list_tools()
+        }
+        public_names = {
+            tool['name']
+            for tool in PublicGatewayApp(
+                PublicSessionStore(),
+                PublicApiClient(),
+            ).list_tools('GWS_test')
+        }
+
+        self.assertIn('list_order_filter_options', local_names)
+        self.assertIn('list_order_filter_options', public_names)
+
+    def test_metadata_requires_filter_type_and_describes_all_types(self):
+        schema = ListOrderFilterOptionsTool().metadata()['input_schema']
+
+        self.assertEqual(['filter_type'], schema['required'])
+        self.assertEqual(
+            ['country', 'product', 'customer', 'sales', 'warehouse', 'department'],
+            schema['properties']['filter_type']['enum'],
+        )
+        self.assertIn('keyword', schema['properties'])
+
+    def test_call_normalizes_and_forwards_options_query(self):
+        client = RecordingApiClient()
+        tool = ListOrderFilterOptionsTool(api_client=client)
+
+        result = tool.call(
+            filter_type=' customer ',
+            keyword=' 客户A ',
+            page=0,
+            limit=200,
+            request_id='rq_options',
+        )
+
+        self.assertEqual({'code': 'MCP_0000'}, result)
+        self.assertEqual('list_order_filter_options', client.last_call['tool_code'])
+        self.assertEqual(
+            '/mcp/tools/listOrderFilterOptions',
+            client.last_call['route_path'],
+        )
+        self.assertEqual({
+            'filter_type': 'customer',
+            'keyword': '客户A',
+            'page': 1,
+            'limit': 100,
+        }, client.last_call['payload'])
+
+    def test_call_rejects_unknown_filter_type(self):
+        tool = ListOrderFilterOptionsTool(api_client=RecordingApiClient())
+
+        with self.assertRaisesRegex(ValueError, 'unsupported filter_type'):
+            tool.call('carrier')
+
+    def test_call_requires_api_client(self):
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            ListOrderFilterOptionsTool().call('country')
+
+    def test_cli_forwards_filter_type_and_optional_keyword(self):
+        client = RecordingApiClient()
+        output = StringIO()
+
+        exit_code = GatewayApp(api_client=client).run_cli([
+            'call',
+            '--tool', 'list_order_filter_options',
+            '--filter-type', 'customer',
+            '--keyword', '客户A',
+        ], stdout=output)
+
+        self.assertEqual(0, exit_code)
+        self.assertEqual({
+            'filter_type': 'customer',
+            'keyword': '客户A',
+            'page': 1,
+            'limit': 20,
+        }, client.last_call['payload'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 85 - 2
tests/test_mcp_protocol.py

@@ -11,6 +11,19 @@ class DummyApiClient:
     def __init__(self):
         self.calls = []
 
+    def list_enabled_tools(self):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_order',
+                    'query_track',
+                    'query_order_exact',
+                    'list_order_filter_options',
+                ],
+            },
+        }
+
     def call_tool(self, tool_code, route_path, payload, request_id):
         self.calls.append(
             {
@@ -37,6 +50,20 @@ class DummyApiClient:
             },
         }
 
+
+class BusinessErrorApiClient(DummyApiClient):
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        super().call_tool(tool_code, route_path, payload, request_id)
+        return {
+            'code': 'MCP_1301',
+            'msg': 'no order query permission',
+            'data': [],
+            'meta': {
+                'request_id': request_id,
+            },
+        }
+
+
 class FullColumnsApiClient(DummyApiClient):
     def call_tool(self, tool_code, route_path, payload, request_id):
         response = super().call_tool(tool_code, route_path, payload, request_id)
@@ -92,12 +119,12 @@ class FullColumnsApiClient(DummyApiClient):
 
 
 class McpProtocolTest(unittest.TestCase):
-    def build_handler(self):
+    def build_handler(self, api_client=None):
         token_store = InMemoryTokenStore(refresh_skew_seconds=60)
         token_store.save('MT_demo', '2099-01-01T00:00:00')
         app = GatewayApp(
             auth_client=None,
-            api_client=DummyApiClient(),
+            api_client=api_client or DummyApiClient(),
             token_store=token_store,
         )
         return McpProtocolHandler(app)
@@ -154,6 +181,15 @@ class McpProtocolTest(unittest.TestCase):
         self.assertIn('query_order', by_name)
         self.assertNotIn('bind_auth_code', by_name)
         self.assertIn('inputSchema', by_name['query_order'])
+        exact_schema = by_name['query_order_exact']['inputSchema']
+        self.assertIn(
+            '系统订单号',
+            exact_schema['properties']['order_number']['description'],
+        )
+        self.assertIsInstance(
+            exact_schema['properties']['order_numbers']['examples'][0],
+            list,
+        )
 
     def test_tools_call_wraps_gateway_result_as_structured_content(self):
         handler = self.build_handler()
@@ -181,6 +217,53 @@ class McpProtocolTest(unittest.TestCase):
         self.assertEqual('SO20260706001', response['result']['structuredContent']['records'][0]['order_no'])
         self.assertEqual('text', response['result']['content'][0]['type'])
         self.assertIn('matched 1 order', response['result']['content'][0]['text'])
+        self.assertTrue(
+            response['result']['structuredContent']['meta']['request_id'].startswith('rq_')
+        )
+
+    def test_business_error_is_tool_error_and_stdio_continues(self):
+        handler = self.build_handler(BusinessErrorApiClient())
+        stdin = io.StringIO(
+            json.dumps({
+                'jsonrpc': '2.0',
+                'id': 5,
+                'method': 'tools/call',
+                'params': {
+                    'name': 'query_order',
+                    'arguments': {'keyword': 'SO20260706001'},
+                },
+            })
+            + '\n'
+            + json.dumps({
+                'jsonrpc': '2.0',
+                'id': 6,
+                'method': 'initialize',
+                'params': {},
+            })
+            + '\n'
+        )
+        stdout = io.StringIO()
+
+        exit_code = handler.run_stdio(stdin=stdin, stdout=stdout)
+        responses = [json.loads(line) for line in stdout.getvalue().splitlines()]
+
+        self.assertEqual(0, exit_code)
+        self.assertEqual(2, len(responses))
+        self.assertNotIn('error', responses[0])
+        self.assertTrue(responses[0]['result']['isError'])
+        self.assertIn('MCP_1301', responses[0]['result']['content'][0]['text'])
+        self.assertEqual(
+            'MCP_1301',
+            responses[0]['result']['structuredContent']['code'],
+        )
+        self.assertEqual(
+            'no order query permission',
+            responses[0]['result']['structuredContent']['msg'],
+        )
+        self.assertTrue(
+            responses[0]['result']['structuredContent']['meta']['request_id'].startswith('rq_')
+        )
+        self.assertEqual('2025-06-18', responses[1]['result']['protocolVersion'])
 
     def test_tools_call_renders_all_query_order_columns_in_text_content(self):
         token_store = InMemoryTokenStore(refresh_skew_seconds=60)

+ 19 - 2
tests/test_public_gateway.py

@@ -23,6 +23,19 @@ class FakeApiClient:
         self.calls.append((token, tool_code, route_path, payload, request_id, client_ip))
         return {'code': 'MCP_0000', 'data': {'token_used': token}}
 
+    def list_enabled_tools(self, token):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_order',
+                    'query_track',
+                    'query_order_exact',
+                    'list_order_filter_options',
+                ],
+            },
+        }
+
 
 class PublicGatewayAppTest(unittest.TestCase):
     def test_two_employees_use_isolated_tokens(self):
@@ -41,13 +54,17 @@ class PublicGatewayAppTest(unittest.TestCase):
         self.assertEqual('MT_B', api_client.calls[1][0])
 
     def test_public_tools_do_not_include_bind_auth_code(self):
-        app = PublicGatewayApp(session_store=FakeSessionStore(), api_client=FakeApiClient(), auth_client=None)
+        store = FakeSessionStore()
+        store.sessions['GWS_A'] = {'mcp_token': 'MT_A'}
+        app = PublicGatewayApp(session_store=store, api_client=FakeApiClient(), auth_client=None)
 
-        tool_names = [tool['name'] for tool in app.list_tools()]
+        tool_names = [tool['name'] for tool in app.list_tools('GWS_A')]
 
         self.assertNotIn('bind_auth_code', tool_names)
         self.assertIn('query_order', tool_names)
         self.assertIn('query_track', tool_names)
+        self.assertIn('query_order_exact', tool_names)
+        self.assertIn('list_order_filter_options', tool_names)
 
     def test_missing_session_returns_human_device_message(self):
         app = PublicGatewayApp(session_store=FakeSessionStore(), api_client=FakeApiClient(), auth_client=None)

+ 93 - 2
tests/test_public_gateway_unit.py

@@ -9,6 +9,17 @@ class TestPublicGatewayApp(unittest.TestCase):
     def setUp(self):
         self.mock_session_store = MagicMock()
         self.mock_api_client = MagicMock()
+        self.mock_api_client.list_enabled_tools.return_value = {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_order',
+                    'query_track',
+                    'query_order_exact',
+                    'list_order_filter_options',
+                ],
+            },
+        }
 
         self.app = PublicGatewayApp(
             session_store=self.mock_session_store,
@@ -16,13 +27,30 @@ class TestPublicGatewayApp(unittest.TestCase):
         )
 
     def test_list_tools_returns_metadata(self):
-        tools = self.app.list_tools()
+        self.mock_session_store.get.return_value = {
+            'mcp_token': 'MT_token',
+        }
+        self.mock_api_client.list_enabled_tools.return_value = {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_order',
+                    'query_track',
+                    'query_order_exact',
+                    'list_order_filter_options',
+                ],
+            },
+        }
+
+        tools = self.app.list_tools('GWS_test')
 
         self.assertIsInstance(tools, list)
         self.assertGreater(len(tools), 0)
         tool_names = [tool['name'] for tool in tools]
         self.assertIn('query_order', tool_names)
         self.assertIn('query_track', tool_names)
+        self.assertIn('query_order_exact', tool_names)
+        self.assertIn('list_order_filter_options', tool_names)
         self.assertNotIn('bind_auth_code', tool_names)
 
         for tool in tools:
@@ -30,6 +58,48 @@ class TestPublicGatewayApp(unittest.TestCase):
             self.assertIn('description', tool)
             self.assertIn('input_schema', tool)
 
+    def test_list_tools_intersects_enabled_codes_and_preserves_local_order(self):
+        self.mock_session_store.get.return_value = {
+            'mcp_token': 'MT_token',
+        }
+        self.mock_api_client.list_enabled_tools.return_value = {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_track',
+                    'query_order_exact',
+                    'unknown_tool',
+                ],
+            },
+        }
+
+        tools = self.app.list_tools('GWS_test')
+
+        self.assertEqual(
+            ['query_track', 'query_order_exact'],
+            [tool['name'] for tool in tools],
+        )
+        self.mock_api_client.list_enabled_tools.assert_called_once_with('MT_token')
+
+    def test_list_tools_rejects_missing_session_without_querying_registry(self):
+        self.mock_session_store.get.return_value = None
+
+        with self.assertRaisesRegex(RuntimeError, DEVICE_INVALID_MESSAGE):
+            self.app.list_tools('GWS_missing')
+
+        self.mock_api_client.list_enabled_tools.assert_not_called()
+
+    def test_list_tools_fails_closed_on_registry_error(self):
+        self.mock_session_store.get.return_value = {'mcp_token': 'MT_token'}
+        self.mock_api_client.list_enabled_tools.return_value = {
+            'code': 'MCP_9001',
+            'msg': 'registry unavailable',
+            'data': {},
+        }
+
+        with self.assertRaisesRegex(RuntimeError, 'registry unavailable'):
+            self.app.list_tools('GWS_test')
+
     def test_build_request_id_generates_id_when_empty(self):
         request_id = self.app.build_request_id('')
 
@@ -89,6 +159,10 @@ class TestPublicGatewayApp(unittest.TestCase):
             'msg': 'success',
             'data': {'order': 'details'}
         }
+        self.mock_api_client.list_enabled_tools.return_value = {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_order']},
+        }
 
         result = self.app.call_tool(gateway_session_id, tool_name, arguments, 'rq_test')
 
@@ -100,6 +174,23 @@ class TestPublicGatewayApp(unittest.TestCase):
         self.assertEqual(call_args['request_id'], 'rq_test')
         self.assertEqual(result['code'], '0')
 
+    def test_call_tool_rejects_dynamically_disabled_tool_before_forwarding(self):
+        self.mock_session_store.get.return_value = {
+            'mcp_token': 'MT_token',
+            'admin_id': 1,
+            'company_id': 1,
+        }
+        self.mock_api_client.list_enabled_tools.return_value = {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_track']},
+        }
+
+        with self.assertRaisesRegex(RuntimeError, 'tool disabled: query_order'):
+            self.app.call_tool('GWS_test', 'query_order', {})
+
+        self.mock_session_store.get.assert_called_once_with('GWS_test')
+        self.mock_api_client.call_tool.assert_not_called()
+
     def test_call_tool_generates_request_id_when_not_provided(self):
         self.mock_session_store.get.return_value = {
             'mcp_token': 'MT_token',
@@ -162,4 +253,4 @@ class TestPublicGatewayApp(unittest.TestCase):
 
 
 if __name__ == '__main__':
-    unittest.main()
+    unittest.main()

+ 97 - 7
tests/test_public_server.py

@@ -20,16 +20,70 @@ class FakeParser:
 class FakeGateway:
     def __init__(self):
         self.calls = []
+        self.list_calls = []
+        self.tool_result = {'code': 'MCP_0000', 'data': {'ok': True}}
 
-    def list_tools(self):
-        return [{'name': 'query_order', 'description': 'query order', 'input_schema': {'type': 'object'}}]
+    def registered_tool_names(self):
+        return ('query_order', 'query_track')
+
+    def list_tools(self, gateway_session_id):
+        self.list_calls.append(gateway_session_id)
+        return [{'name': 'query_track', 'description': 'query track', 'input_schema': {'type': 'object'}}]
 
     def call_tool(self, gateway_session_id, name, arguments=None, request_id='', client_ip=''):
         self.calls.append((gateway_session_id, name, arguments, request_id, client_ip))
-        return {'code': 'MCP_0000', 'data': {'ok': True}}
+        return self.tool_result
 
 
 class PublicMcpHttpHandlerTest(unittest.TestCase):
+    def test_constructor_uses_registered_names_without_loading_dynamic_list(self):
+        gateway = FakeGateway()
+
+        PublicMcpHttpHandler(gateway, context_parser=FakeParser())
+
+        self.assertEqual([], gateway.list_calls)
+
+    def test_handle_tools_list_passes_session_and_returns_dynamic_tools(self):
+        gateway = FakeGateway()
+        handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
+
+        response = handler.handle_json_rpc(
+            headers={'X-Gateway-Session': 'GWS_A'},
+            message={
+                'jsonrpc': '2.0',
+                'id': 2,
+                'method': 'tools/list',
+                'params': {},
+            },
+            client_ip='10.0.0.5',
+        )
+
+        self.assertEqual(['GWS_A'], gateway.list_calls)
+        self.assertEqual(
+            ['query_track'],
+            [tool['name'] for tool in response['result']['tools']],
+        )
+        self.assertIn('inputSchema', response['result']['tools'][0])
+
+    def test_missing_session_on_tools_list_returns_protocol_error(self):
+        gateway = FakeGateway()
+        handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
+
+        response = handler.handle_json_rpc(
+            headers={},
+            message={
+                'jsonrpc': '2.0',
+                'id': 3,
+                'method': 'tools/list',
+                'params': {},
+            },
+            client_ip='10.0.0.5',
+        )
+
+        self.assertEqual(-32000, response['error']['code'])
+        self.assertIn('Workbuddy', response['error']['message'])
+        self.assertEqual([], gateway.list_calls)
+
     def test_handle_tools_call_passes_gateway_session_to_public_gateway(self):
         gateway = FakeGateway()
         handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
@@ -52,6 +106,42 @@ class PublicMcpHttpHandlerTest(unittest.TestCase):
         self.assertEqual('query_order', gateway.calls[0][1])
         self.assertEqual('10.0.0.5', gateway.calls[0][4])
 
+    def test_backend_business_error_returns_tool_error_result(self):
+        gateway = FakeGateway()
+        gateway.tool_result = {
+            'code': 'MCP_1401',
+            'msg': 'invalid exact order query conditions',
+            'data': [],
+            'meta': {'request_id': 'rq_backend'},
+        }
+        handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
+
+        response = handler.handle_json_rpc(
+            headers={'X-Gateway-Session': 'GWS_A'},
+            message={
+                'jsonrpc': '2.0',
+                'id': 7,
+                'method': 'tools/call',
+                'params': {
+                    'name': 'query_order',
+                    'arguments': {'keyword': 'USC'},
+                },
+            },
+            client_ip='10.0.0.5',
+        )
+
+        self.assertNotIn('error', response)
+        self.assertTrue(response['result']['isError'])
+        self.assertIn('MCP_1401', response['result']['content'][0]['text'])
+        self.assertEqual(
+            {
+                'code': 'MCP_1401',
+                'msg': 'invalid exact order query conditions',
+                'meta': {'request_id': 'rq_backend'},
+            },
+            response['result']['structuredContent'],
+        )
+
     def test_missing_session_on_tool_call_returns_error_content(self):
         gateway = FakeGateway()
         handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
@@ -146,8 +236,9 @@ class RateLimitTest(unittest.TestCase):
 
     def test_tools_list_is_rate_limited(self):
         handler = self._make_handler(max_requests=1)
-        handler.handle_json_rpc({}, {'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list'}, client_ip='1.2.3.4')
-        response = handler.handle_json_rpc({}, {'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'}, client_ip='1.2.3.4')
+        headers = {'X-Gateway-Session': 'GWS_A'}
+        handler.handle_json_rpc(headers, {'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list'}, client_ip='1.2.3.4')
+        response = handler.handle_json_rpc(headers, {'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'}, client_ip='1.2.3.4')
         self.assertIn('error', response)
         self.assertIn('Rate limit', response['error']['message'])
 
@@ -155,7 +246,7 @@ class RateLimitTest(unittest.TestCase):
         # tools/list 和 tools/call 使用不同 bucket,互不影响
         handler = self._make_handler(max_requests=1)
         # 用掉 tools/list 的1个 slot
-        handler.handle_json_rpc({}, {'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list'}, client_ip='1.2.3.4')
+        handler.handle_json_rpc({'X-Gateway-Session': 'GWS_A'}, {'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list'}, client_ip='1.2.3.4')
         # tools/call 应仍可正常执行(使用独立的 client_ip:tool_name bucket)
         response = handler.handle_json_rpc(
             {'X-Gateway-Session': 'GWS_A'},
@@ -167,4 +258,3 @@ class RateLimitTest(unittest.TestCase):
 
 if __name__ == '__main__':
     unittest.main()
-

+ 4 - 1
tests/test_public_server_coverage.py

@@ -39,7 +39,10 @@ class ExtractClientIpTest(unittest.TestCase):
 # ---------------------------------------------------------------------------
 
 class FakeGateway:
-    def list_tools(self):
+    def registered_tool_names(self):
+        return ('query_order',)
+
+    def list_tools(self, gateway_session_id):
         return [{'name': 'query_order', 'description': '', 'input_schema': {'type': 'object'}}]
 
     def call_tool(self, *args, **kwargs):

+ 33 - 1
tests/test_public_server_integration.py

@@ -43,6 +43,18 @@ class TestPublicServerIntegration(unittest.TestCase):
         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',
+                    'query_track',
+                    'query_order_exact',
+                    'list_order_filter_options',
+                ],
+            },
+        }
 
     def _make_request(self, method, params=None, headers=None):
         """Helper to make JSON-RPC requests"""
@@ -86,7 +98,15 @@ class TestPublicServerIntegration(unittest.TestCase):
         self.assertIn('serverInfo', result['result'])
 
     def test_tools_list(self):
-        result = self._make_request('tools/list')
+        session_id = generate_gateway_session_id()
+        self.mock_session_store.get.return_value = {
+            'mcp_token': 'MT_valid_token',
+        }
+
+        result = self._make_request(
+            'tools/list',
+            headers={'X-Gateway-Session': session_id},
+        )
 
         self.assertIn('result', result)
         self.assertIn('tools', result['result'])
@@ -95,6 +115,8 @@ class TestPublicServerIntegration(unittest.TestCase):
         tool_names = [tool['name'] for tool in tools]
         self.assertIn('query_order', tool_names)
         self.assertIn('query_track', tool_names)
+        self.assertIn('query_order_exact', tool_names)
+        self.assertIn('list_order_filter_options', tool_names)
         self.assertNotIn('bind_auth_code', tool_names)
 
         # Check tool structure
@@ -103,6 +125,16 @@ class TestPublicServerIntegration(unittest.TestCase):
             self.assertIn('description', tool)
             self.assertIn('inputSchema', tool)
 
+        by_name = {tool['name']: tool for tool in tools}
+        exact_tool = by_name['query_order_exact']
+        self.assertIn('单号类型不明确', exact_tool['description'])
+        self.assertIn(
+            '系统订单号',
+            exact_tool['inputSchema']['properties']['order_number'][
+                'description'
+            ],
+        )
+
     def test_tools_call_without_session(self):
         result = self._make_request('tools/call', {'name': 'query_order', 'arguments': {}})
 

+ 257 - 0
tests/test_query_order_exact_tool.py

@@ -0,0 +1,257 @@
+import inspect
+import unittest
+from io import StringIO
+
+import app as gateway_app_module
+from app import GatewayApp, parse_int_list
+from public_gateway import PublicGatewayApp
+from tools.query_order_exact import QueryOrderExactTool
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.last_call = None
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.last_call = {
+            'tool_code': tool_code,
+            'route_path': route_path,
+            'payload': payload,
+            'request_id': request_id,
+        }
+        return {'code': 'MCP_0000'}
+
+    def list_enabled_tools(self):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_order_exact']},
+        }
+
+
+class PublicSessionStore:
+    def get(self, gateway_session_id):
+        if gateway_session_id == 'GWS_test':
+            return {'mcp_token': 'MT_test'}
+        return None
+
+
+class PublicApiClient:
+    def list_enabled_tools(self, token):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_order_exact']},
+        }
+
+
+class QueryOrderExactToolTest(unittest.TestCase):
+    def test_local_and_public_gateways_register_tool(self):
+        local_names = {
+            tool['name']
+            for tool in GatewayApp(api_client=RecordingApiClient()).list_tools()
+        }
+        public_names = {
+            tool['name']
+            for tool in PublicGatewayApp(
+                PublicSessionStore(),
+                PublicApiClient(),
+            ).list_tools('GWS_test')
+        }
+
+        self.assertIn('query_order_exact', local_names)
+        self.assertIn('query_order_exact', public_names)
+
+    def test_parse_int_list_accepts_comma_separated_ids(self):
+        self.assertEqual([9, 12], parse_int_list('9, 12'))
+        self.assertEqual([], parse_int_list(''))
+
+    def test_metadata_exposes_all_exact_filters(self):
+        schema = QueryOrderExactTool().metadata()['input_schema']
+
+        for field in (
+            'order_number', 'reference_number', 'tracking_number',
+            'outbound_number', 'container_code', 'so_number', 'shipment_id',
+            'receiver_country', 'product_ids', 'customer_ids', 'sales_id',
+            'warehouse_ids', 'department_id', 'inbound_date_start',
+            'inbound_date_end', 'outbound_date_start', 'outbound_date_end',
+            'page', 'limit',
+        ):
+            self.assertIn(field, schema['properties'])
+        for field in (
+            'order_numbers', 'reference_numbers', 'tracking_numbers',
+            'outbound_numbers', 'container_codes', 'so_numbers',
+        ):
+            self.assertIn(field, schema['properties'])
+            self.assertEqual('array', schema['properties'][field]['type'])
+            self.assertEqual(
+                'string',
+                schema['properties'][field]['items']['type'],
+            )
+        self.assertEqual(100, schema['properties']['limit']['maximum'])
+
+    def test_metadata_guides_ai_to_explicit_fields_without_fallback(self):
+        metadata = QueryOrderExactTool().metadata()
+        description = metadata['description']
+        properties = metadata['input_schema']['properties']
+
+        for phrase in (
+            '单号类型不明确', '先询问用户', '不得改用其他字段',
+            '同一字段使用 IN', '不同字段使用 AND',
+        ):
+            self.assertIn(phrase, description)
+
+        single_fields = (
+            'order_number', 'reference_number', 'tracking_number',
+            'outbound_number', 'container_code', 'so_number',
+        )
+        batch_fields = (
+            'order_numbers', 'reference_numbers', 'tracking_numbers',
+            'outbound_numbers', 'container_codes', 'so_numbers',
+        )
+        for field in single_fields:
+            self.assertTrue(properties[field]['description'])
+            self.assertIsInstance(properties[field]['examples'][0], str)
+        for field in batch_fields:
+            self.assertTrue(properties[field]['description'])
+            self.assertIsInstance(properties[field]['examples'][0], list)
+            self.assertGreater(len(properties[field]['examples'][0]), 1)
+
+        self.assertIn('系统订单号', properties['order_number']['description'])
+        self.assertIn(
+            '客户参考号',
+            properties['reference_number']['description'],
+        )
+        self.assertIn('承运商', properties['tracking_number']['description'])
+        self.assertIn('出库单号', properties['outbound_number']['description'])
+        self.assertIn('柜号', properties['container_code']['description'])
+        self.assertIn('Shipping Order', properties['so_number']['description'])
+
+        for field in (
+            'shipment_id', 'receiver_country', 'product_ids', 'customer_ids',
+            'sales_id', 'warehouse_ids', 'department_id',
+            'inbound_date_start', 'inbound_date_end',
+            'outbound_date_start', 'outbound_date_end', 'page', 'limit',
+        ):
+            self.assertTrue(properties[field]['description'])
+
+    def test_call_forwards_normalized_number_arrays(self):
+        tool = QueryOrderExactTool(api_client=RecordingApiClient())
+        self.assertIn('order_numbers', inspect.signature(tool.call).parameters)
+
+        tool.call(
+            order_number=' A ',
+            order_numbers=[' B ', 'A'],
+            tracking_numbers=['T1', 'T2'],
+        )
+
+        self.assertEqual('A', tool.api_client.last_call['payload']['order_number'])
+        self.assertEqual(
+            ['B', 'A'],
+            tool.api_client.last_call['payload']['order_numbers'],
+        )
+        self.assertEqual(
+            ['T1', 'T2'],
+            tool.api_client.last_call['payload']['tracking_numbers'],
+        )
+
+    def test_call_rejects_invalid_number_arrays(self):
+        tool = QueryOrderExactTool(api_client=RecordingApiClient())
+        self.assertIn('order_numbers', inspect.signature(tool.call).parameters)
+
+        invalid_values = ('A,B', [1], [''], [' '], ['X' * 101])
+        for values in invalid_values:
+            with self.subTest(values=values):
+                with self.assertRaises(ValueError):
+                    tool.call(order_numbers=values)
+
+        with self.assertRaisesRegex(ValueError, 'must not exceed 200'):
+            tool.call(
+                order_numbers=['O{0}'.format(i) for i in range(200)],
+                so_numbers=['S1'],
+            )
+
+    def test_call_normalizes_exact_filters_and_id_arrays(self):
+        client = RecordingApiClient()
+        tool = QueryOrderExactTool(api_client=client)
+
+        result = tool.call(
+            order_number=' USC001 ',
+            customer_ids=['9', 9, 0, -1, '12'],
+            warehouse_ids=['-1', '5'],
+            sales_id='7',
+            page=0,
+            limit=200,
+            request_id='rq_exact',
+        )
+
+        self.assertEqual({'code': 'MCP_0000'}, result)
+        self.assertEqual('query_order_exact', client.last_call['tool_code'])
+        self.assertEqual('/mcp/tools/queryOrderExact', client.last_call['route_path'])
+        self.assertEqual('rq_exact', client.last_call['request_id'])
+        self.assertEqual({
+            'order_number': 'USC001',
+            'customer_ids': [9, 12],
+            'sales_id': 7,
+            'warehouse_ids': [-1, 5],
+            'page': 1,
+            'limit': 100,
+        }, client.last_call['payload'])
+
+    def test_call_requires_one_business_filter(self):
+        tool = QueryOrderExactTool(api_client=RecordingApiClient())
+
+        with self.assertRaisesRegex(ValueError, 'at least one exact order filter'):
+            tool.call(page=1, limit=20)
+
+    def test_call_requires_api_client(self):
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            QueryOrderExactTool().call(order_number='USC001')
+
+    def test_cli_forwards_exact_fields_and_id_lists(self):
+        client = RecordingApiClient()
+        output = StringIO()
+
+        exit_code = GatewayApp(api_client=client).run_cli([
+            'call',
+            '--tool', 'query_order_exact',
+            '--order-number', 'USC001',
+            '--customer-ids', '9,12',
+            '--warehouse-ids=-1,5',
+            '--inbound-date-start', '2026-07-01',
+        ], stdout=output)
+
+        self.assertEqual(0, exit_code)
+        self.assertEqual({
+            'order_number': 'USC001',
+            'customer_ids': [9, 12],
+            'warehouse_ids': [-1, 5],
+            'inbound_date_start': '2026-07-01',
+            'page': 1,
+            'limit': 20,
+        }, client.last_call['payload'])
+
+    def test_cli_forwards_batch_number_lists(self):
+        self.assertTrue(hasattr(gateway_app_module, 'parse_string_list'))
+        self.assertEqual(
+            ['A', 'B'],
+            gateway_app_module.parse_string_list(' A, B, A '),
+        )
+
+        client = RecordingApiClient()
+        output = StringIO()
+        exit_code = GatewayApp(api_client=client).run_cli([
+            'call',
+            '--tool', 'query_order_exact',
+            '--order-numbers', 'A,B,A',
+            '--tracking-numbers', 'T1,T2',
+        ], stdout=output)
+
+        self.assertEqual(0, exit_code)
+        self.assertEqual(['A', 'B'], client.last_call['payload']['order_numbers'])
+        self.assertEqual(
+            ['T1', 'T2'],
+            client.last_call['payload']['tracking_numbers'],
+        )
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 23 - 0
tests/test_scoped_api_client.py

@@ -135,6 +135,29 @@ class TestScopedApiClient(unittest.TestCase):
         self.assertEqual(call_headers['X-MCP-Tool-Code'], 'query_track')
         self.assertEqual(call_headers['X-Request-Id'], 'rq_xyz')
 
+    def test_list_enabled_tools_uses_only_explicit_token(self):
+        expected = {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_order_exact']},
+        }
+        self.mock_transport.post_json.return_value = expected
+
+        result = self.client.list_enabled_tools(' MT_scoped ')
+
+        self.assertIs(expected, result)
+        self.mock_transport.post_json.assert_called_once_with(
+            'http://api.example.com/mcp/tools/listEnabledTools',
+            {},
+            {'Authorization': 'Bearer MT_scoped'},
+            15,
+        )
+
+    def test_list_enabled_tools_rejects_empty_token(self):
+        with self.assertRaisesRegex(RuntimeError, 'mcp token missing'):
+            self.client.list_enabled_tools('')
+
+        self.mock_transport.post_json.assert_not_called()
+
 
 if __name__ == '__main__':
     unittest.main()

+ 69 - 0
tools/list_order_filter_options.py

@@ -0,0 +1,69 @@
+class ListOrderFilterOptionsTool:
+    name = 'list_order_filter_options'
+    route_path = '/mcp/tools/listOrderFilterOptions'
+    FILTER_TYPES = (
+        'country',
+        'product',
+        'customer',
+        'sales',
+        'warehouse',
+        'department',
+    )
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                'List authorized values for query_order_exact filters. '
+                'Use the returned value directly in the corresponding filter.'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'filter_type': {
+                        'type': 'string',
+                        'enum': list(self.FILTER_TYPES),
+                    },
+                    'keyword': {'type': 'string'},
+                    'page': {'type': 'integer', 'minimum': 1},
+                    'limit': {
+                        'type': 'integer',
+                        'minimum': 1,
+                        'maximum': 100,
+                    },
+                },
+                'required': ['filter_type'],
+            },
+        }
+
+    def call(
+        self,
+        filter_type,
+        keyword='',
+        page=1,
+        limit=20,
+        request_id='rq_list_order_filter_options',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for list_order_filter_options'
+            )
+        filter_type = str(filter_type).strip()
+        if filter_type not in self.FILTER_TYPES:
+            raise ValueError('unsupported filter_type')
+
+        payload = {
+            'filter_type': filter_type,
+            'keyword': str(keyword or '').strip(),
+            'page': max(1, int(page)),
+            'limit': max(1, min(100, int(limit))),
+        }
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            payload,
+            request_id,
+        )

+ 305 - 0
tools/query_order_exact.py

@@ -0,0 +1,305 @@
+class QueryOrderExactTool:
+    name = 'query_order_exact'
+    route_path = '/mcp/tools/queryOrderExact'
+
+    STRING_FILTERS = (
+        'order_number',
+        'reference_number',
+        'tracking_number',
+        'outbound_number',
+        'container_code',
+        'so_number',
+        'shipment_id',
+        'receiver_country',
+        'inbound_date_start',
+        'inbound_date_end',
+        'outbound_date_start',
+        'outbound_date_end',
+    )
+    ARRAY_FILTERS = ('product_ids', 'customer_ids', 'warehouse_ids')
+    NUMBER_ARRAY_FILTERS = (
+        'order_numbers',
+        'reference_numbers',
+        'tracking_numbers',
+        'outbound_numbers',
+        'container_codes',
+        'so_numbers',
+    )
+    NUMBER_FILTER_MAP = {
+        'order_numbers': 'order_number',
+        'reference_numbers': 'reference_number',
+        'tracking_numbers': 'tracking_number',
+        'outbound_numbers': 'outbound_number',
+        'container_codes': 'container_code',
+        'so_numbers': 'so_number',
+    }
+    SCALAR_ID_FILTERS = ('sales_id', 'department_id')
+    MAX_NUMBER_FILTER_COUNT = 200
+    FIELD_GUIDANCE = {
+        'order_number': (
+            '系统订单号,用户明确说“订单号”且只有一个值时使用;'
+            '不是客户参考号或快递单号。',
+            ['USC26070917207'],
+        ),
+        'order_numbers': (
+            '多个系统订单号的批量精准查询;用户给出多个订单号时使用。',
+            [['USC26070917207', 'USC26070917208']],
+        ),
+        'reference_number': (
+            '客户参考号,用户明确说“客户参考号”且只有一个值时使用;'
+            '不是系统订单号。',
+            ['REF-20260713-001'],
+        ),
+        'reference_numbers': (
+            '多个客户参考号的批量精准查询。',
+            [['REF-001', 'REF-002']],
+        ),
+        'tracking_number': (
+            '快递单号、物流跟踪号或承运商跟踪号码;不是系统订单号。',
+            ['1Z999AA10123456784'],
+        ),
+        'tracking_numbers': (
+            '多个快递单号或承运商跟踪号码的批量精准查询。',
+            [['TRACK-001', 'TRACK-002']],
+        ),
+        'outbound_number': (
+            '出库单号,用户明确说“出库单号”且只有一个值时使用。',
+            ['OUT-20260713-001'],
+        ),
+        'outbound_numbers': (
+            '多个出库单号的批量精准查询。',
+            [['OUT-001', 'OUT-002']],
+        ),
+        'container_code': (
+            '柜号或集装箱号,只有一个值时使用。',
+            ['MSCU1234567'],
+        ),
+        'container_codes': (
+            '多个柜号或集装箱号的批量精准查询。',
+            [['MSCU1234567', 'TGHU7654321']],
+        ),
+        'so_number': (
+            'SO号,即 Shipping Order 编号,只有一个值时使用。',
+            ['SO-20260713-001'],
+        ),
+        'so_numbers': (
+            '多个 Shipping Order 编号的批量精准查询。',
+            [['SO-001', 'SO-002']],
+        ),
+        'shipment_id': (
+            'Shipment ID 的单值精准匹配。',
+            ['FBA123456789'],
+        ),
+        'receiver_country': (
+            '收货国家代码;先调用 list_order_filter_options 的 country 类型'
+            '取得可用值。',
+            ['US'],
+        ),
+        'product_ids': (
+            '物流产品 ID 数组;必须先调用 list_order_filter_options 的 '
+            'product 类型取得 ID。',
+            [[101, 102]],
+        ),
+        'customer_ids': (
+            '客户 ID 数组;必须先调用 list_order_filter_options 的 customer '
+            '类型取得 ID。',
+            [[201, 202]],
+        ),
+        'sales_id': (
+            '销售人员 ID;必须先调用 list_order_filter_options 的 sales 类型'
+            '取得 ID。',
+            [301],
+        ),
+        'warehouse_ids': (
+            '仓库 ID 数组;必须先调用 list_order_filter_options 的 warehouse '
+            '类型取得 ID,-1 表示客户仓。',
+            [[401, -1]],
+        ),
+        'department_id': (
+            '事业部 ID;必须先调用 list_order_filter_options 的 department '
+            '类型取得 ID。',
+            [501],
+        ),
+        'inbound_date_start': (
+            '入库日期范围开始日期,格式 YYYY-MM-DD。',
+            ['2026-07-01'],
+        ),
+        'inbound_date_end': (
+            '入库日期范围结束日期,格式 YYYY-MM-DD。',
+            ['2026-07-13'],
+        ),
+        'outbound_date_start': (
+            '出库日期范围开始日期,格式 YYYY-MM-DD。',
+            ['2026-07-01'],
+        ),
+        'outbound_date_end': (
+            '出库日期范围结束日期,格式 YYYY-MM-DD。',
+            ['2026-07-13'],
+        ),
+        'page': ('结果页码,从 1 开始。', [1]),
+        'limit': ('每页记录数,范围 1 到 100。', [20]),
+    }
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        properties = {
+            field: {'type': 'string'}
+            for field in self.STRING_FILTERS
+        }
+        for field in self.ARRAY_FILTERS:
+            properties[field] = {
+                'type': 'array',
+                'items': {'type': 'integer'},
+            }
+        for field in self.NUMBER_ARRAY_FILTERS:
+            properties[field] = {
+                'type': 'array',
+                'items': {'type': 'string'},
+            }
+        for field in self.SCALAR_ID_FILTERS:
+            properties[field] = {'type': 'integer', 'minimum': 1}
+        properties.update({
+            'page': {'type': 'integer', 'minimum': 1, 'maximum': 100},
+            'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100},
+        })
+        for field, guidance in self.FIELD_GUIDANCE.items():
+            properties[field]['description'] = guidance[0]
+            properties[field]['examples'] = guidance[1]
+
+        return {
+            'name': self.name,
+            'description': (
+                '按用户明确指定的字段精准查询订单,并应用当前员工权限。'
+                '订单号、客户参考号、快递单号、出库单号、柜号和 SO 号必须'
+                '使用各自对应字段。'
+                '单号类型不明确时先询问用户,不要调用工具猜测。'
+                '一个值使用单数字段,多个同类值使用复数字段数组;'
+                '同一字段使用 IN,不同字段使用 AND。'
+                '查询无结果时不得改用其他字段重试。'
+                '产品、客户、销售、仓库、事业部和国家条件先调用 '
+                'list_order_filter_options。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': properties,
+            },
+        }
+
+    def call(
+        self,
+        order_number='',
+        reference_number='',
+        tracking_number='',
+        outbound_number='',
+        container_code='',
+        so_number='',
+        shipment_id='',
+        receiver_country='',
+        product_ids=None,
+        customer_ids=None,
+        sales_id=None,
+        warehouse_ids=None,
+        department_id=None,
+        inbound_date_start='',
+        inbound_date_end='',
+        outbound_date_start='',
+        outbound_date_end='',
+        page=1,
+        limit=20,
+        request_id='rq_query_order_exact',
+        order_numbers=None,
+        reference_numbers=None,
+        tracking_numbers=None,
+        outbound_numbers=None,
+        container_codes=None,
+        so_numbers=None,
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for query_order_exact')
+
+        values = locals()
+        payload = {}
+        for field in self.STRING_FILTERS:
+            value = str(values[field] or '').strip()
+            if value:
+                payload[field] = value
+
+        number_count = 0
+        for field in self.NUMBER_ARRAY_FILTERS:
+            value = self._normalize_string_list(values[field], field)
+            if value:
+                payload[field] = value
+            singular = self.NUMBER_FILTER_MAP[field]
+            merged = list(value)
+            singular_value = payload.get(singular)
+            if singular_value and singular_value not in merged:
+                merged.insert(0, singular_value)
+            number_count += len(merged)
+        if number_count > self.MAX_NUMBER_FILTER_COUNT:
+            raise ValueError('number filters must not exceed 200 items')
+
+        for field in self.ARRAY_FILTERS:
+            allow_customer_warehouse = field == 'warehouse_ids'
+            value = self._normalize_int_list(
+                values[field],
+                allow_customer_warehouse=allow_customer_warehouse,
+            )
+            if value:
+                payload[field] = value
+
+        for field in self.SCALAR_ID_FILTERS:
+            value = values[field]
+            if value is None or value == '':
+                continue
+            value = int(value)
+            if value <= 0:
+                raise ValueError('{0} must be greater than 0'.format(field))
+            payload[field] = value
+
+        if not payload:
+            raise ValueError('at least one exact order filter is required')
+
+        payload['page'] = max(1, min(100, int(page)))
+        payload['limit'] = max(1, min(100, int(limit)))
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            payload,
+            request_id,
+        )
+
+    @staticmethod
+    def _normalize_int_list(value, allow_customer_warehouse=False):
+        if value is None or value == '':
+            return []
+        if isinstance(value, str):
+            value = [item.strip() for item in value.split(',') if item.strip()]
+
+        result = []
+        for item in value:
+            item = int(item)
+            if item <= 0 and not (allow_customer_warehouse and item == -1):
+                continue
+            if item not in result:
+                result.append(item)
+        return result
+
+    @staticmethod
+    def _normalize_string_list(value, field):
+        if value is None or value == '':
+            return []
+        if not isinstance(value, (list, tuple)):
+            raise ValueError('{0} must be an array'.format(field))
+
+        result = []
+        for item in value:
+            if not isinstance(item, str):
+                raise ValueError('{0} items must be strings'.format(field))
+            item = item.strip()
+            if not item or len(item) > 100:
+                raise ValueError('{0} contains an invalid number'.format(field))
+            if item not in result:
+                result.append(item)
+        return result