Kaynağa Gözat

防止在workbuddy给用户直接暴露字段

jackson 1 hafta önce
ebeveyn
işleme
c4d04d98f8

+ 2 - 1
COVERAGE_GUIDE.md

@@ -13,7 +13,7 @@ Manual form:
 
 ```powershell
 python -m coverage run -m unittest discover -s tests -p "test_*.py"
-python -m coverage report
+python -m coverage report -m --fail-under=100
 python -m coverage html
 ```
 
@@ -26,5 +26,6 @@ Coverage should focus on:
 - Request-scoped API forwarding.
 - Token refresh/revoke compatibility.
 - Query tool registration and JSON-RPC behavior.
+- OutputPresenter field hiding, fail-closed errors, cross-tool values, and legacy `query_order` behavior.
 
 The retired authorization-code binding files and tools should not appear in the runtime coverage list.

+ 27 - 3
README.md

@@ -19,9 +19,24 @@ Gateway 保持“薄网关”边界:
 - 不再支持授权码绑定工具;正式接入只使用后台生成的 `GWS_xxx` 设备配置。
 - 支持 `query_order` 订单查询工具。
 - 支持 `query_track` 轨迹查询工具。
+- 支持 `query_order_exact` 精准订单查询和 `list_order_filter_options` 权限筛选项。
+- 支持未排舱订单导出、导出筛选项和省外进港资料导出。
 - 支持 MCP `initialize`、`tools/list`、`tools/call`。
 - 公网模式支持 `gateway_session_id` 请求级隔离、Redis Gateway session、审计日志和基础限流。
 
+## 工具结果展示协议
+
+Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内部接口和工具入参:
+
+- `query_order` 保持原有 `columns + records` 结果和文本展示,不参与本次转换。
+- `query_order_exact`、`query_track` 对外使用中文 `headers + rows + pagination`,不返回内部字段键。
+- 两个筛选项工具保留“可传值、显示名称、业务编码”,确保返回值可继续传给查询或导出工具。
+- 两个导出工具返回 `files[].label + files[].url`,不暴露后端 `file_url` 键。
+- `request_id` 位于 MCP 结果 `_meta`;参数错误使用业务名称,未知异常不透传后端细节。
+- stdio 与 public 模式共用 `services/output_presenter.py`;未知工具或畸形响应关闭失败。
+
+详细设计见 `../base/project-docs/mcp-output-field-presentation-design.md`。
+
 ## 两种运行模式
 
 ### 本地 stdio 模式
@@ -188,7 +203,9 @@ python app.py serve-public --host 0.0.0.0 --port 8765
 Invoke-WebRequest http://127.0.0.1:8765/health -UseBasicParsing
 ```
 
-正常情况下会返回包含 `ok` 的 JSON。## 常用命令
+正常情况下会返回包含 `ok` 的 JSON。
+
+## 常用命令
 
 查看工具列表:
 
@@ -214,6 +231,13 @@ python app.py call --tool query_track --order-number USC26070371955
 python -m unittest discover -s tests -p "test_*.py"
 ```
 
+严格覆盖率验收:
+
+```powershell
+python -m coverage run -m unittest discover -s tests -p 'test_*.py'
+python -m coverage report -m --fail-under=100
+```
+
 ## 环境变量
 
 基础配置:
@@ -309,7 +333,7 @@ FMS_TOKEN_STORE_PATH=C:/fms-mcp/.mcp_token.json
 - 反向代理必须覆盖而不是透传用户伪造的转发头。
 - 当前 Python 内置限流默认使用真实 TCP 连接 IP;公网生产建议同时在 Nginx、负载均衡或 API 网关层配置限流。
 - 审计日志只记录 session hash 的短前缀、员工 ID、公司 ID、工具名和 request_id。
-- 第一版公网只开放查询型工具,不开放审批、费用修改、状态流转、批量写入。
+- 公网只注册经过安全评审的查询和导出工具;实际可见性继续由后端工具注册表动态控制,不开放审批、费用修改、状态流转或批量写入。
 
 ## ThinkPHP 路由口径
 
@@ -318,7 +342,7 @@ ThinkPHP MCP 路由位于各后端项目的 `route/mcp/mcp_route.php`。
 Gateway 会追加以下路径:
 
 - Auth:`/mcp/auth/exchange`、`/mcp/auth/refresh`、`/mcp/auth/revoke`
-- Tools:`/mcp/tools/queryOrder`、`/mcp/tools/queryTrack`
+- Tools:`/mcp/tools/listEnabledTools`、`queryOrder`、`queryTrack`、`queryOrderExact`、`listOrderFilterOptions`、`exportPendingOutboundOrders`、`listPendingOutboundExportFilterOptions`、`exportOutOfProvincePortData`,均位于 `/mcp/tools/` 下。
 
 `FMS_AUTH_BASE` 和 `FMS_TOOLS_BASE` 只配置域名或基础地址,不要包含 `/admin/mcp`。
 

+ 61 - 13
mcp_protocol.py

@@ -1,11 +1,14 @@
 import json
 import sys
 
+from services.output_presenter import OutputPresenter
+
 
 class McpProtocolHandler:
     protocol_version = '2025-06-18'
     server_name = 'fms-mcp-gateway'
     server_version = '0.1.0'
+    output_presenter = OutputPresenter()
 
     def __init__(self, gateway_app):
         self.gateway_app = gateway_app
@@ -25,6 +28,7 @@ class McpProtocolHandler:
     def handle_request(self, request):
         request_id = request.get('id')
         method = str(request.get('method') or '').strip()
+        tool_name = ''
         try:
             if method == 'initialize':
                 self.initialized = True
@@ -48,24 +52,26 @@ class McpProtocolHandler:
                 return self._success_response(request_id, {'tools': tools})
             if method == 'tools/call':
                 params = request.get('params') or {}
-                tool_name = params.get('name')
+                if not isinstance(params, dict):
+                    raise ValueError('tool parameters must be an object')
+                raw_tool_name = params.get('name')
+                if not isinstance(raw_tool_name, str) or not raw_tool_name.strip():
+                    raise ValueError('tool name must be a non-empty string')
+                tool_name = raw_tool_name.strip()
                 arguments = params.get('arguments') or {}
                 tool_result = self.gateway_app.call_tool(tool_name, arguments)
-                return self._tool_call_response(request_id, tool_result)
+                return self._tool_call_response(
+                    request_id,
+                    tool_name,
+                    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':
-                return self._success_response(
+                return self._tool_exception_response(
                     request_id,
-                    {
-                        'content': [
-                            {
-                                'type': 'text',
-                                'text': str(exc),
-                            }
-                        ],
-                        'isError': True,
-                    },
+                    tool_name,
+                    exc,
                 )
             return self._error_response(request_id, -32000, str(exc))
 
@@ -96,7 +102,49 @@ class McpProtocolHandler:
         return normalized
 
     @classmethod
-    def _tool_call_response(cls, request_id, tool_result):
+    def _tool_call_response(cls, request_id, tool_name, tool_result):
+        if tool_name == 'query_order':
+            return cls._legacy_tool_call_response(request_id, tool_result)
+
+        presented = cls.output_presenter.present(tool_name, tool_result)
+        return cls._presented_response(request_id, presented)
+
+    @classmethod
+    def _tool_exception_response(cls, request_id, tool_name, exception):
+        if tool_name == 'query_order':
+            return cls._success_response(
+                request_id,
+                {
+                    'content': [{
+                        'type': 'text',
+                        'text': str(exception),
+                    }],
+                    'isError': True,
+                },
+            )
+
+        presented = cls.output_presenter.present_exception(
+            tool_name,
+            exception,
+        )
+        return cls._presented_response(request_id, presented)
+
+    @classmethod
+    def _presented_response(cls, request_id, presented):
+        result = {
+            'content': [{
+                'type': 'text',
+                'text': presented['text'],
+            }],
+            'structuredContent': presented['structured_content'],
+            'isError': presented['is_error'],
+        }
+        if presented['meta']:
+            result['_meta'] = presented['meta']
+        return cls._success_response(request_id, result)
+
+    @classmethod
+    def _legacy_tool_call_response(cls, request_id, tool_result):
         if not isinstance(tool_result, dict):
             raise RuntimeError('invalid tool response')
 

+ 12 - 6
public_server.py

@@ -45,6 +45,9 @@ class PublicMcpHttpHandler:
     def handle_json_rpc(self, headers, message, client_ip=''):
         request_id = message.get('id') if isinstance(message, dict) else None
         method = str((message or {}).get('method') or '').strip()
+        message_params = (message or {}).get('params') or {}
+        tool_name = str(message_params.get('name') or '').strip() \
+            if isinstance(message_params, dict) else ''
 
         try:
             if method == 'initialize':
@@ -78,6 +81,8 @@ class PublicMcpHttpHandler:
                     tools.append(normalized)
                 return McpProtocolHandler._success_response(request_id, {'tools': tools})
             if method == 'tools/call':
+                if not isinstance(message_params, dict):
+                    raise ValueError('tool parameters must be an object')
                 # Parse context first — tools/call always requires a valid session
                 context = self.context_parser.parse(headers or {})
                 if not context.has_session():
@@ -85,12 +90,11 @@ class PublicMcpHttpHandler:
                 # Session-based rate limiting: each employee gets an independent quota per tool.
                 # Unknown tool names fall back to the bare session bucket to prevent key explosion.
                 session_id = context.gateway_session_id
-                tool_name = ((message.get('params') or {}).get('name') or '').strip()
                 rate_key = '{0}:{1}'.format(session_id, tool_name) if tool_name in self._known_tools else session_id
                 blocked = self._check_rate_limit(rate_key, method, request_id, log_identity=client_ip)
                 if blocked:
                     return blocked
-                params = message.get('params') or {}
+                params = message_params
                 result = self.gateway_app.call_tool(
                     session_id,
                     params.get('name'),
@@ -100,15 +104,17 @@ class PublicMcpHttpHandler:
                 )
                 return McpProtocolHandler._tool_call_response(
                     request_id,
+                    tool_name,
                     result,
                 )
             return McpProtocolHandler._error_response(request_id, -32601, 'Method not found: {0}'.format(method))
         except Exception as exc:
             if method == 'tools/call':
-                return McpProtocolHandler._success_response(request_id, {
-                    'content': [{'type': 'text', 'text': str(exc)}],
-                    'isError': True,
-                })
+                return McpProtocolHandler._tool_exception_response(
+                    request_id,
+                    tool_name,
+                    exc,
+                )
             return McpProtocolHandler._error_response(request_id, -32000, str(exc))
 
 

+ 352 - 0
services/output_presenter.py

@@ -0,0 +1,352 @@
+import json
+
+from constants import DEVICE_INVALID_MESSAGE
+
+
+# Builds AI-safe MCP results for the explicitly supported tool set.
+class OutputPresenter:
+    TABLE_TOOLS = frozenset((
+        'query_order_exact',
+        'query_track',
+    ))
+    OPTION_TOOLS = frozenset((
+        'list_order_filter_options',
+        'list_pending_outbound_export_filter_options',
+    ))
+    EXPORT_TOOLS = frozenset((
+        'export_pending_outbound_orders',
+        'export_out_of_province_port_data',
+    ))
+    SAFE_TOOLS = TABLE_TOOLS | OPTION_TOOLS | EXPORT_TOOLS
+
+    FIELD_LABELS = {
+        'query_order_exact': {
+            'order_number': '订单号',
+            'order_numbers': '订单号',
+            'reference_number': '客户参考号',
+            'reference_numbers': '客户参考号',
+            'tracking_number': '快递单号',
+            'tracking_numbers': '快递单号',
+            'outbound_number': '排舱单号',
+            'outbound_numbers': '排舱单号',
+            'container_code': '柜号',
+            'container_codes': '柜号',
+            'so_number': 'SO号',
+            'so_numbers': 'SO号',
+            'shipment_id': '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': '每页数量',
+        },
+        'query_track': {
+            'order_id': '订单ID',
+            'order_number': '订单号',
+            'tracking_number': '快递单号',
+            'page': '页码',
+            'limit': '每页数量',
+        },
+        'list_order_filter_options': {
+            'filter_type': '筛选项类型',
+            'keyword': '关键词',
+            'page': '页码',
+            'limit': '每页数量',
+        },
+        'list_pending_outbound_export_filter_options': {
+            'filter_type': '筛选项类型',
+            'product_type_id': '产品分类',
+            'keyword': '关键词',
+            'page': '页码',
+            'limit': '每页数量',
+        },
+        'export_pending_outbound_orders': {
+            'number': '单号',
+            'product_type_id': '产品分类',
+            'product_id': '物流产品',
+            'order_warehouse_id': '集货仓库',
+            'receiver_country': '目的国',
+            'is_remove': '是否装柜剔除',
+            'address': '派送地址',
+            'inbound_date': '入库时间',
+            'status': '订单状态',
+            'merge_declare_number': '合并报关单号',
+            'importer_id': '进口商',
+            'packing_type': '货物类型',
+            'is_battery': '带电属性',
+            'is_magnetic': '带磁属性',
+            'is_wood': '带木属性',
+            'is_other': '其他商品属性',
+            'is_fda': 'FDA产品属性',
+            'is_toy': '玩具属性',
+            'is_ultra_limit': '超长超重属性',
+            'is_sensitive': '敏感货属性',
+            'is_food': '食品属性',
+            'no_property': '无属性',
+        },
+        'export_out_of_province_port_data': {
+            'outbound_numbers': '排舱单号',
+            'file_type': '资料类型',
+        },
+    }
+
+    ERROR_MESSAGES = {
+        'MCP_1101': '设备配置已失效,请重新生成配置',
+        'MCP_1102': '设备会话已过期,请重新连接',
+        'MCP_1201': '工具当前不可用',
+        'MCP_1202': '工具当前不可用',
+        'MCP_1301': '没有权限使用此工具',
+        'MCP_9001': '工具调用失败,请稍后重试',
+    }
+
+    def handles(self, tool_name):
+        return isinstance(tool_name, str) and tool_name in self.SAFE_TOOLS
+
+    # Convert a backend envelope into text and structured display data.
+    def present(self, tool_name, tool_result):
+        if not self.handles(tool_name) or not isinstance(tool_result, dict):
+            return self._format_error()
+
+        code = str(tool_result.get('code') or '').strip()
+        meta = self._build_meta(tool_result.get('meta'))
+        if code not in ('MCP_0000', '0'):
+            return self._business_error(
+                tool_name,
+                code or 'MCP_9001',
+                tool_result.get('msg'),
+                meta,
+            )
+
+        data = tool_result.get('data')
+        if not isinstance(data, dict):
+            return self._format_error(meta)
+
+        if tool_name in self.TABLE_TOOLS:
+            return self._present_table(data, tool_result.get('meta'), meta)
+        if tool_name in self.OPTION_TOOLS:
+            return self._present_options(data, tool_result.get('meta'), meta)
+        return self._present_export(data, meta)
+
+    # Map local tool exceptions without exposing internal error details.
+    def present_exception(self, tool_name, exception):
+        if not self.handles(tool_name):
+            return self._format_error()
+
+        message = str(exception or '').strip()
+        if isinstance(exception, ValueError):
+            label = self._find_field_label(tool_name, message)
+            return self._error_result(
+                'MCP_1401',
+                self._parameter_message(label),
+                False,
+            )
+        if message == DEVICE_INVALID_MESSAGE:
+            return self._error_result('MCP_1101', DEVICE_INVALID_MESSAGE, False)
+        if message.lower().startswith('tool disabled'):
+            return self._error_result('MCP_1202', '工具当前不可用', False)
+        return self._error_result(
+            'MCP_9001',
+            '工具调用失败,请稍后重试',
+            True,
+        )
+
+    def _present_table(self, data, raw_meta, meta):
+        columns = data.get('columns')
+        records = data.get('records')
+        if not isinstance(columns, list) or not columns or not isinstance(records, list):
+            return self._format_error(meta)
+
+        headers = []
+        keys = []
+        for column in columns:
+            if not isinstance(column, dict):
+                return self._format_error(meta)
+            key = column.get('key')
+            label = column.get('name')
+            if not isinstance(key, str) or not key.strip():
+                return self._format_error(meta)
+            if not isinstance(label, str) or not label.strip():
+                return self._format_error(meta)
+            keys.append(key.strip())
+            header = {'label': label.strip()}
+            description = column.get('description')
+            if isinstance(description, str) and description.strip():
+                header['description'] = description.strip()
+            headers.append(header)
+
+        rows = []
+        for record in records:
+            if not isinstance(record, dict):
+                return self._format_error(meta)
+            rows.append([
+                '' if record.get(key) is None else record.get(key, '')
+                for key in keys
+            ])
+
+        content = {
+            'summary': self._safe_text(data.get('summary')),
+            'headers': headers,
+            'rows': rows,
+        }
+        tips = self._safe_tips(data.get('tips'))
+        if tips:
+            content['tips'] = tips
+        pagination = self._build_pagination(raw_meta)
+        if pagination:
+            content['pagination'] = pagination
+        return self._success_result(content, self._render_table(content), meta)
+
+    def _present_options(self, data, raw_meta, meta):
+        records = data.get('records')
+        if not isinstance(records, list):
+            return self._format_error(meta)
+
+        rows = []
+        for record in records:
+            if not isinstance(record, dict):
+                return self._format_error(meta)
+            rows.append([
+                record.get('value', ''),
+                self._safe_text(record.get('label')),
+                self._safe_text(record.get('code')),
+            ])
+
+        content = {
+            'headers': [
+                {'label': '可传值'},
+                {'label': '显示名称'},
+                {'label': '业务编码'},
+            ],
+            'rows': rows,
+        }
+        pagination = self._build_pagination(raw_meta)
+        if pagination:
+            content['pagination'] = pagination
+        return self._success_result(content, self._render_table(content), meta)
+
+    def _present_export(self, data, meta):
+        url = data.get('file_url')
+        if not isinstance(url, str) or not url.strip():
+            return self._format_error(meta)
+        content = {
+            'message': '文件已生成',
+            'files': [{'label': '导出文件', 'url': url.strip()}],
+        }
+        text = '文件已生成\n- 导出文件: {0}'.format(url.strip())
+        return self._success_result(content, text, meta)
+
+    def _business_error(self, tool_name, code, raw_message, meta):
+        if code == 'MCP_1401':
+            label = self._find_field_label(tool_name, raw_message)
+            message = self._parameter_message(label)
+            retryable = False
+        else:
+            message = self.ERROR_MESSAGES.get(
+                code,
+                '工具调用失败,请稍后重试',
+            )
+            retryable = code not in ('MCP_1101', 'MCP_1102', 'MCP_1201', 'MCP_1202', 'MCP_1301')
+        return self._error_result(code, message, retryable, meta)
+
+    def _find_field_label(self, tool_name, raw_message):
+        message = str(raw_message or '')
+        fields = self.FIELD_LABELS.get(tool_name, {})
+        for field in sorted(fields, key=len, reverse=True):
+            if field in message:
+                return fields[field]
+        return ''
+
+    @staticmethod
+    def _parameter_message(label):
+        return '{0}参数不正确'.format(label) if label else '工具参数不正确,请检查后重试'
+
+    @staticmethod
+    def _build_meta(raw_meta):
+        if not isinstance(raw_meta, dict):
+            return {}
+        request_id = raw_meta.get('request_id')
+        if not isinstance(request_id, str) or not request_id.strip():
+            return {}
+        return {'request_id': request_id.strip()}
+
+    @staticmethod
+    def _build_pagination(raw_meta):
+        if not isinstance(raw_meta, dict):
+            return {}
+        return {
+            key: raw_meta[key]
+            for key in ('page', 'limit', 'has_more', 'total')
+            if key in raw_meta
+        }
+
+    @staticmethod
+    def _safe_text(value):
+        if value is None:
+            return ''
+        return str(value).strip()
+
+    @classmethod
+    def _safe_tips(cls, value):
+        if not isinstance(value, list):
+            return []
+        return [cls._safe_text(item) for item in value if cls._safe_text(item)]
+
+    @classmethod
+    def _render_table(cls, content):
+        headers = content.get('headers') or []
+        rows = content.get('rows') or []
+        lines = []
+        summary = cls._safe_text(content.get('summary'))
+        if summary:
+            lines.append(summary)
+        lines.append('表头共 {0} 列:'.format(len(headers)))
+        for index, header in enumerate(headers, start=1):
+            lines.append('{0}. {1}'.format(index, header['label']))
+        for index, row in enumerate(rows, start=1):
+            lines.append('记录 {0}:'.format(index))
+            for header, value in zip(headers, row):
+                if isinstance(value, (dict, list)):
+                    value = json.dumps(value, ensure_ascii=False)
+                lines.append('- {0}: {1}'.format(header['label'], value))
+        tips = content.get('tips') or []
+        if tips:
+            lines.append('提示:{0}'.format(';'.join(tips)))
+        return '\n'.join(lines)
+
+    @staticmethod
+    def _success_result(content, text, meta):
+        return {
+            'structured_content': content,
+            'text': text,
+            'is_error': False,
+            'meta': meta,
+        }
+
+    @staticmethod
+    def _error_result(code, message, retryable, meta=None):
+        return {
+            'structured_content': {
+                'code': code,
+                'message': message,
+                'retryable': retryable,
+            },
+            'text': '{0}: {1}'.format(code, message),
+            'is_error': True,
+            'meta': meta or {},
+        }
+
+    @classmethod
+    def _format_error(cls, meta=None):
+        return cls._error_result(
+            'MCP_9001',
+            '工具返回格式异常',
+            True,
+            meta,
+        )

+ 3 - 0
tests/TEST_README.md

@@ -27,3 +27,6 @@ python -m unittest discover -s tests -p "test_*.py" -v
 - `test_request_context.py`: session extraction from headers, bearer token, and cookies.
 - `test_auth_client.py`: token refresh/revoke only.
 - `test_gateway_runtime.py`: local runtime tool registration and token refresh.
+- `test_output_presenter.py`: safe table/filter/export DTOs, error hiding, and cross-tool value reuse.
+- `test_mcp_protocol.py`: stdio response shape plus unchanged `query_order` compatibility.
+- `test_public_server.py`: public response parity and malformed-input safety.

+ 11 - 3
tests/TEST_REPORT.md

@@ -1,15 +1,22 @@
 # MCP Gateway Test Report
 
-Updated: 2026-07-08
+Updated: 2026-07-15
 
 ## Result
 
-The current MCP Gateway test suite targets the direct Gateway session flow.
+The current MCP Gateway test suite covers direct Gateway sessions, dynamic tool visibility, safe output presentation, and local/public protocol parity.
 
 - Public Workbuddy access uses `GWS_xxx` Gateway sessions.
 - The legacy authorization-code binding path has been retired.
-- Public and stdio tool lists expose only query tools such as `query_order` and `query_track`.
+- Public and stdio dynamically expose the 7 locally registered tools only when the backend registry enables them.
 - `AuthClient` keeps only token refresh/revoke behavior.
+- `query_order` retains its legacy response; the other 6 tools use `OutputPresenter` and do not expose internal result keys.
+
+## Latest Result
+
+- Full suite: `369 tests OK`.
+- Coverage: `1423 statements / 538 branches / 100.00%`.
+- Strict command: `python -m coverage report -m --fail-under=100`.
 
 ## Recommended Verification
 
@@ -24,3 +31,4 @@ python -m unittest discover -s tests -p "test_*.py" -v
 - Scoped API forwarding with request-level MCP token.
 - Local token refresh/revoke compatibility.
 - Query tool registration and JSON-RPC routing.
+- Safe `headers/rows` presentation, cross-tool value forwarding, fail-closed errors, and `query_order` compatibility.

+ 5 - 1
tests/test_bind_auth_code_tool.py

@@ -83,7 +83,11 @@ class NoBindAuthCodeToolTest(unittest.TestCase):
         )
 
         self.assertTrue(response['result']['isError'])
-        self.assertIn('tool not registered', response['result']['content'][0]['text'])
+        self.assertNotIn('bind_auth_code', response['result']['content'][0]['text'])
+        self.assertEqual(
+            'MCP_9001',
+            response['result']['structuredContent']['code'],
+        )
 
     def test_query_order_without_token_returns_device_invalid_message(self):
         handler, _ = self.build_handler(with_token=False)

+ 15 - 0
tests/test_mcp_protocol.py

@@ -302,6 +302,12 @@ class McpProtocolTest(unittest.TestCase):
                 response = super().call_tool(tool_code, route_path, payload, request_id)
                 response['data'] = {
                     'summary': '共查询到 1 条轨迹记录',
+                    'columns': [
+                        {'key': 'status', 'name': '轨迹节点'},
+                        {'key': 'location', 'name': '轨迹地点'},
+                        {'key': 'time', 'name': '时间'},
+                        {'key': 'content', 'name': '轨迹内容'},
+                    ],
                     'records': [
                         {
                             'status': '清关放行',
@@ -341,6 +347,15 @@ class McpProtocolTest(unittest.TestCase):
         self.assertIn('\\u6e05\\u5173\\u653e\\u884c', line)
         response = json.loads(line)
         self.assertIn('清关放行', response['result']['content'][0]['text'])
+        self.assertEqual(
+            ['轨迹节点', '轨迹地点', '时间', '轨迹内容'],
+            [
+                header['label']
+                for header in response['result']['structuredContent']['headers']
+            ],
+        )
+        self.assertNotIn('status', json.dumps(response['result'], ensure_ascii=False))
+        self.assertTrue(response['result']['_meta']['request_id'].startswith('rq_'))
 
     def test_run_stdio_writes_only_request_responses(self):
         handler = self.build_handler()

+ 40 - 1
tests/test_mcp_protocol_coverage.py

@@ -257,11 +257,12 @@ class ProtocolResponseBoundaryTest(unittest.TestCase):
 
     def test_tool_call_response_rejects_non_dict(self):
         with self.assertRaisesRegex(RuntimeError, 'invalid tool response'):
-            McpProtocolHandler._tool_call_response('1', 'invalid')
+            McpProtocolHandler._tool_call_response('1', 'query_order', 'invalid')
 
     def test_success_response_wraps_non_dict_data(self):
         response = McpProtocolHandler._tool_call_response(
             '1',
+            'query_order',
             {'code': 'MCP_0000', 'data': ['A']},
         )
 
@@ -273,6 +274,7 @@ class ProtocolResponseBoundaryTest(unittest.TestCase):
     def test_success_response_uses_empty_structured_content_without_data(self):
         response = McpProtocolHandler._tool_call_response(
             '1',
+            'query_order',
             {'code': '0', 'data': None},
         )
 
@@ -282,6 +284,7 @@ class ProtocolResponseBoundaryTest(unittest.TestCase):
     def test_error_response_includes_data_without_meta(self):
         response = McpProtocolHandler._tool_call_response(
             '1',
+            'query_order',
             {'code': 'MCP_9001', 'msg': 'failed', 'data': ['detail']},
         )
 
@@ -360,6 +363,42 @@ class HandleMessageEdgeCasesTest(unittest.TestCase):
         self.assertTrue(response['result']['isError'])
         self.assertIn('tool failed', response['result']['content'][0]['text'])
 
+    def test_tools_call_with_non_object_params_fails_safely(self):
+        handler = McpProtocolHandler(None)
+
+        response = handler.handle_request({
+            'id': 7,
+            'method': 'tools/call',
+            'params': 'not-an-object',
+        })
+
+        self.assertTrue(response['result']['isError'])
+        self.assertEqual(
+            '工具返回格式异常',
+            response['result']['structuredContent']['message'],
+        )
+
+    def test_tools_call_with_non_string_tool_name_fails_safely(self):
+        from unittest.mock import MagicMock
+
+        handler = McpProtocolHandler(MagicMock())
+
+        for tool_name in ([], {}):
+            with self.subTest(tool_name=tool_name):
+                response = handler.handle_request({
+                    'id': 8,
+                    'method': 'tools/call',
+                    'params': {'name': tool_name, 'arguments': {}},
+                })
+
+                self.assertTrue(response['result']['isError'])
+                self.assertEqual(
+                    '工具返回格式异常',
+                    response['result']['structuredContent']['message'],
+                )
+
+        handler.gateway_app.call_tool.assert_not_called()
+
 
 if __name__ == '__main__':
     unittest.main()

+ 457 - 0
tests/test_output_presenter.py

@@ -0,0 +1,457 @@
+import json
+import unittest
+
+from services.output_presenter import OutputPresenter
+from tools.export_out_of_province_port_data import ExportOutOfProvincePortDataTool
+from tools.export_pending_outbound_orders import ExportPendingOutboundOrdersTool
+from tools.list_order_filter_options import ListOrderFilterOptionsTool
+from tools.list_pending_outbound_export_filter_options import (
+    ListPendingOutboundExportFilterOptionsTool,
+)
+from tools.query_order import QueryOrderTool
+from tools.query_order_exact import QueryOrderExactTool
+from tools.query_track import QueryTrackTool
+
+
+class OutputPresenterTest(unittest.TestCase):
+    def setUp(self):
+        self.presenter = OutputPresenter()
+
+    def test_exact_order_uses_labels_and_drops_internal_fields(self):
+        result = self.presenter.present(
+            'query_order_exact',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'summary': '当前页返回 1 条订单',
+                    'columns': [
+                        {'key': 'order_number', 'name': '订单号'},
+                        {
+                            'key': 'tracking_number',
+                            'name': '快递单号',
+                            'description': '承运商跟踪号码',
+                        },
+                    ],
+                    'records': [{
+                        'order_number': 'SO001',
+                        'tracking_number': 'TN001',
+                        'order_id': 99,
+                        'time_zone': '8.00',
+                    }],
+                    'tips': [],
+                },
+                'meta': {
+                    'page': 1,
+                    'limit': 20,
+                    'has_more': False,
+                    'request_id': 'rq_exact',
+                },
+            },
+        )
+
+        self.assertFalse(result['is_error'])
+        self.assertEqual(
+            [
+                {'label': '订单号'},
+                {'label': '快递单号', 'description': '承运商跟踪号码'},
+            ],
+            result['structured_content']['headers'],
+        )
+        self.assertEqual(
+            [['SO001', 'TN001']],
+            result['structured_content']['rows'],
+        )
+        self.assertEqual(
+            {'page': 1, 'limit': 20, 'has_more': False},
+            result['structured_content']['pagination'],
+        )
+        self.assertEqual({'request_id': 'rq_exact'}, result['meta'])
+        serialized = json.dumps(result, ensure_ascii=False)
+        self.assertNotIn('order_number', serialized)
+        self.assertNotIn('tracking_number', serialized)
+        self.assertNotIn('order_id', serialized)
+        self.assertNotIn('time_zone', serialized)
+        self.assertIn('订单号', result['text'])
+        self.assertIn('SO001', result['text'])
+
+    def test_table_missing_value_is_empty_and_duplicate_labels_are_preserved(self):
+        result = self.presenter.present(
+            'query_track',
+            {
+                'code': '0',
+                'data': {
+                    'columns': [
+                        {'key': 'status', 'name': '状态'},
+                        {'key': 'content', 'name': '状态'},
+                    ],
+                    'records': [{'status': '已发货'}],
+                    'tips': ['仅展示已有轨迹'],
+                },
+                'meta': {'request_id': 'rq_track'},
+            },
+        )
+
+        self.assertEqual(
+            [{'label': '状态'}, {'label': '状态'}],
+            result['structured_content']['headers'],
+        )
+        self.assertEqual([['已发货', '']], result['structured_content']['rows'])
+        self.assertEqual(['仅展示已有轨迹'], result['structured_content']['tips'])
+
+    def test_empty_table_keeps_safe_headers_and_tips(self):
+        result = self.presenter.present(
+            'query_track',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'columns': [{'key': 'status', 'name': '轨迹节点'}],
+                    'records': [],
+                    'tips': ['未查询到轨迹信息'],
+                },
+                'meta': {'page': 1, 'limit': 5, 'total': 0},
+            },
+        )
+
+        self.assertEqual([{'label': '轨迹节点'}], result['structured_content']['headers'])
+        self.assertEqual([], result['structured_content']['rows'])
+        self.assertIn('未查询到轨迹信息', result['text'])
+
+        without_tips = self.presenter.present(
+            'query_track',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'columns': [{'key': 'status', 'name': '轨迹节点'}],
+                    'records': [],
+                    'tips': 'not-a-list',
+                },
+            },
+        )
+        self.assertNotIn('tips', without_tips['structured_content'])
+
+    def test_filter_options_preserve_values_for_follow_up_calls(self):
+        result = self.presenter.present(
+            'list_order_filter_options',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'records': [
+                        {'value': 101, 'label': '客户甲', 'code': 'CUS-A'},
+                        {'value': -1, 'label': '客户仓', 'code': '-1'},
+                    ],
+                },
+                'meta': {'page': 2, 'limit': 20, 'has_more': True},
+            },
+        )
+
+        self.assertEqual(
+            [[101, '客户甲', 'CUS-A'], [-1, '客户仓', '-1']],
+            result['structured_content']['rows'],
+        )
+        self.assertEqual(
+            ['可传值', '显示名称', '业务编码'],
+            [item['label'] for item in result['structured_content']['headers']],
+        )
+        self.assertNotIn('"value"', json.dumps(result, ensure_ascii=False))
+
+    def test_pending_filter_options_use_same_safe_shape(self):
+        result = self.presenter.present(
+            'list_pending_outbound_export_filter_options',
+            {
+                'code': 'MCP_0000',
+                'data': {'records': [{'value': 'US', 'label': '美国', 'code': 'US'}]},
+                'meta': {},
+            },
+        )
+
+        self.assertEqual([['US', '美国', 'US']], result['structured_content']['rows'])
+
+    def test_export_result_preserves_url_without_file_url_key(self):
+        for tool_name in (
+            'export_pending_outbound_orders',
+            'export_out_of_province_port_data',
+        ):
+            with self.subTest(tool_name=tool_name):
+                result = self.presenter.present(
+                    tool_name,
+                    {
+                        'code': 'MCP_0000',
+                        'data': {'file_url': 'https://files.test/result.xlsx'},
+                        'meta': {'request_id': 'rq_export'},
+                    },
+                )
+
+                self.assertEqual(
+                    [{
+                        'label': '导出文件',
+                        'url': 'https://files.test/result.xlsx',
+                    }],
+                    result['structured_content']['files'],
+                )
+                self.assertNotIn('file_url', json.dumps(result, ensure_ascii=False))
+
+    def test_known_parameter_error_uses_business_label(self):
+        result = self.presenter.present(
+            'query_track',
+            {
+                'code': 'MCP_1401',
+                'msg': 'tracking_number is required',
+                'data': {'tracking_number': 'bad'},
+                'meta': {'request_id': 'rq_error'},
+            },
+        )
+
+        self.assertTrue(result['is_error'])
+        self.assertEqual('快递单号参数不正确', result['structured_content']['message'])
+        self.assertNotIn('tracking_number', json.dumps(result, ensure_ascii=False))
+        self.assertEqual({'request_id': 'rq_error'}, result['meta'])
+
+    def test_system_error_hides_backend_message_and_data(self):
+        result = self.presenter.present(
+            'query_order_exact',
+            {
+                'code': 'MCP_9001',
+                'msg': 'SQLSTATE table st_order order_number failed',
+                'data': {'sql': 'select * from st_order'},
+            },
+        )
+
+        serialized = json.dumps(result, ensure_ascii=False)
+        self.assertIn('工具调用失败,请稍后重试', serialized)
+        self.assertNotIn('SQLSTATE', serialized)
+        self.assertNotIn('st_order', serialized)
+        self.assertNotIn('order_number', serialized)
+
+    def test_unknown_tool_and_malformed_success_fail_closed(self):
+        unknown = self.presenter.present(
+            'future_tool',
+            {'code': 'MCP_0000', 'data': {'secret_field': 'secret'}},
+        )
+        malformed = self.presenter.present(
+            'query_track',
+            {'code': 'MCP_0000', 'data': {'records': []}},
+        )
+
+        for result in (unknown, malformed):
+            self.assertTrue(result['is_error'])
+            self.assertEqual('工具返回格式异常', result['structured_content']['message'])
+            self.assertNotIn('secret', json.dumps(result, ensure_ascii=False))
+
+    def test_present_exception_maps_value_error_and_hides_unknown_exception(self):
+        parameter = self.presenter.present_exception(
+            'query_track',
+            ValueError('order_number is required'),
+        )
+        system = self.presenter.present_exception(
+            'query_track',
+            RuntimeError('SQL password leaked'),
+        )
+
+        self.assertEqual('订单号参数不正确', parameter['structured_content']['message'])
+        self.assertEqual('工具调用失败,请稍后重试', system['structured_content']['message'])
+        self.assertNotIn('password', json.dumps(system, ensure_ascii=False))
+
+    def test_present_exception_handles_device_disabled_and_unknown_tool(self):
+        device = self.presenter.present_exception(
+            'query_track',
+            RuntimeError('这台设备的 Workbuddy 配置已失效,请重新生成配置。'),
+        )
+        disabled = self.presenter.present_exception(
+            'query_track',
+            RuntimeError('tool disabled: query_track'),
+        )
+        unknown = self.presenter.present_exception(
+            'future_tool',
+            RuntimeError('secret error'),
+        )
+
+        self.assertIn('Workbuddy', device['structured_content']['message'])
+        self.assertEqual('MCP_1202', disabled['structured_content']['code'])
+        self.assertEqual('工具返回格式异常', unknown['structured_content']['message'])
+
+    def test_invalid_payload_types_and_generic_errors_fail_safely(self):
+        cases = (
+            self.presenter.present('query_track', 'invalid'),
+            self.presenter.present('query_track', {'code': 'MCP_0000', 'data': []}),
+            self.presenter.present('query_track', {'code': '', 'msg': 'secret'}),
+            self.presenter.present(
+                'query_track',
+                {'code': 'MCP_7777', 'msg': 'secret backend error'},
+            ),
+            self.presenter.present(
+                'query_track',
+                {'code': 'MCP_1301', 'msg': 'secret permission detail'},
+            ),
+        )
+
+        for result in cases:
+            self.assertTrue(result['is_error'])
+            self.assertNotIn('secret', json.dumps(result, ensure_ascii=False))
+
+    def test_malformed_table_parts_fail_closed(self):
+        base = {
+            'code': 'MCP_0000',
+            'data': {
+                'columns': [{'key': 'status', 'name': '状态'}],
+                'records': [],
+            },
+        }
+        bad_data = (
+            {'columns': [], 'records': []},
+            {'columns': [{'key': 'status', 'name': '状态'}], 'records': None},
+            {'columns': ['status'], 'records': []},
+            {'columns': [{'key': None, 'name': '状态'}], 'records': []},
+            {'columns': [{'key': ' ', 'name': '状态'}], 'records': []},
+            {'columns': [{'key': 'status', 'name': None}], 'records': []},
+            {'columns': [{'key': 'status', 'name': ' '}], 'records': []},
+            {'columns': [{'key': 'status', 'name': '状态'}], 'records': ['bad']},
+        )
+
+        for data in bad_data:
+            with self.subTest(data=data):
+                payload = dict(base)
+                payload['data'] = data
+                self.assertTrue(self.presenter.present('query_track', payload)['is_error'])
+
+    def test_malformed_options_and_exports_fail_closed(self):
+        cases = (
+            self.presenter.present(
+                'list_order_filter_options',
+                {'code': 'MCP_0000', 'data': {'records': None}},
+            ),
+            self.presenter.present(
+                'list_order_filter_options',
+                {'code': 'MCP_0000', 'data': {'records': ['bad']}},
+            ),
+            self.presenter.present(
+                'export_pending_outbound_orders',
+                {'code': 'MCP_0000', 'data': {'file_url': None}},
+            ),
+            self.presenter.present(
+                'export_pending_outbound_orders',
+                {'code': 'MCP_0000', 'data': {'file_url': '  '}},
+            ),
+        )
+
+        self.assertTrue(all(result['is_error'] for result in cases))
+
+    def test_optional_text_meta_and_nested_values_cover_safe_boundaries(self):
+        result = self.presenter.present(
+            'query_track',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'summary': None,
+                    'columns': [
+                        {'key': 'status', 'name': '状态', 'description': ''},
+                        {'key': 'detail', 'name': '详情', 'description': 123},
+                    ],
+                    'records': [{'status': {'name': '已发货'}, 'detail': ['A']}],
+                    'tips': ['', None, '有效提示'],
+                },
+                'meta': {'request_id': ' ', 'page': 1},
+            },
+        )
+
+        self.assertFalse(result['is_error'])
+        self.assertEqual({}, result['meta'])
+        self.assertIn('{"name": "已发货"}', result['text'])
+        self.assertIn('["A"]', result['text'])
+        self.assertEqual(['有效提示'], result['structured_content']['tips'])
+
+        no_meta = self.presenter.present(
+            'list_order_filter_options',
+            {'code': 'MCP_0000', 'data': {'records': []}, 'meta': None},
+        )
+        self.assertNotIn('pagination', no_meta['structured_content'])
+
+    def test_parameter_error_without_known_field_is_generic(self):
+        result = self.presenter.present(
+            'query_track',
+            {'code': 'MCP_1401', 'msg': 'invalid request'},
+        )
+
+        self.assertEqual(
+            '工具参数不正确,请检查后重试',
+            result['structured_content']['message'],
+        )
+
+    def test_query_order_is_explicitly_not_presented(self):
+        self.assertFalse(self.presenter.handles('query_order'))
+        self.assertTrue(self.presenter.handles('query_order_exact'))
+
+    def test_unhashable_tool_names_fail_closed(self):
+        for tool_name in ([], {}):
+            with self.subTest(tool_name=tool_name):
+                self.assertFalse(self.presenter.handles(tool_name))
+                result = self.presenter.present_exception(
+                    tool_name,
+                    RuntimeError('secret backend error'),
+                )
+                self.assertTrue(result['is_error'])
+                self.assertEqual(
+                    '工具返回格式异常',
+                    result['structured_content']['message'],
+                )
+
+    def test_safe_tool_descriptions_require_business_labels_only(self):
+        safe_tools = (
+            QueryOrderExactTool(),
+            QueryTrackTool(),
+            ListOrderFilterOptionsTool(),
+            ListPendingOutboundExportFilterOptionsTool(),
+            ExportPendingOutboundOrdersTool(),
+            ExportOutOfProvincePortDataTool(),
+        )
+
+        for tool in safe_tools:
+            with self.subTest(tool=tool.name):
+                self.assertIn('不得展示内部参数名', tool.metadata()['description'])
+        self.assertNotIn('不得展示内部参数名', QueryOrderTool().metadata()['description'])
+
+    def test_presented_order_and_option_values_can_feed_follow_up_tools(self):
+        class RecordingClient:
+            def __init__(self):
+                self.calls = []
+
+            def call_tool(self, tool_code, route_path, payload, request_id):
+                self.calls.append((tool_code, payload))
+                return {'code': 'MCP_0000'}
+
+        order_result = self.presenter.present(
+            'query_order_exact',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'columns': [{'key': 'order_number', 'name': '订单号'}],
+                    'records': [{'order_number': 'SO-FOLLOW-UP'}],
+                },
+            },
+        )
+        option_result = self.presenter.present(
+            'list_order_filter_options',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'records': [{'value': 901, 'label': '客户甲', 'code': 'C901'}],
+                },
+            },
+        )
+
+        client = RecordingClient()
+        QueryTrackTool(client).call(
+            order_number=order_result['structured_content']['rows'][0][0],
+        )
+        QueryOrderExactTool(client).call(
+            customer_ids=[option_result['structured_content']['rows'][0][0]],
+        )
+
+        self.assertEqual(
+            ('query_track', {'page': 1, 'limit': 5, 'order_number': 'SO-FOLLOW-UP'}),
+            client.calls[0],
+        )
+        self.assertEqual([901], client.calls[1][1]['customer_ids'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 69 - 1
tests/test_public_server.py

@@ -1,3 +1,4 @@
+import json
 import unittest
 
 from public_server import PublicMcpHttpHandler, extract_client_ip
@@ -142,6 +143,48 @@ class PublicMcpHttpHandlerTest(unittest.TestCase):
             response['result']['structuredContent'],
         )
 
+    def test_query_track_success_uses_safe_public_output(self):
+        gateway = FakeGateway()
+        gateway.tool_result = {
+            'code': 'MCP_0000',
+            'data': {
+                'summary': '共 1 条轨迹',
+                'columns': [
+                    {'key': 'status', 'name': '轨迹节点'},
+                    {'key': 'tracking_number', 'name': '快递单号'},
+                ],
+                'records': [{
+                    'status': '已发货',
+                    'tracking_number': 'TN-PUBLIC',
+                    'time_zone': '8.00',
+                }],
+            },
+            'meta': {'request_id': 'rq_public', 'page': 1, 'limit': 5},
+        }
+        handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
+
+        response = handler.handle_json_rpc(
+            headers={'X-Gateway-Session': 'GWS_A'},
+            message={
+                'jsonrpc': '2.0',
+                'id': 8,
+                'method': 'tools/call',
+                'params': {
+                    'name': 'query_track',
+                    'arguments': {'tracking_number': 'TN-PUBLIC'},
+                },
+            },
+            client_ip='10.0.0.5',
+        )
+
+        serialized = json.dumps(response['result'], ensure_ascii=False)
+        self.assertFalse(response['result']['isError'])
+        self.assertNotIn('tracking_number', serialized)
+        self.assertNotIn('time_zone', serialized)
+        self.assertIn('快递单号', serialized)
+        self.assertIn('TN-PUBLIC', serialized)
+        self.assertEqual('rq_public', response['result']['_meta']['request_id'])
+
     def test_missing_session_on_tool_call_returns_error_content(self):
         gateway = FakeGateway()
         handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
@@ -156,7 +199,32 @@ class PublicMcpHttpHandlerTest(unittest.TestCase):
         )
 
         self.assertTrue(response['result']['isError'])
-        self.assertIn('这台设备的 Workbuddy 配置已失效,请重新生成配置', response['result']['content'][0]['text'])
+        self.assertIn(
+            '这台设备的 Workbuddy 配置已失效,请重新生成配置',
+            response['result']['content'][0]['text'],
+        )
+
+    def test_non_object_tool_params_fail_safely(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': 9,
+                'method': 'tools/call',
+                'params': 'not-an-object',
+            },
+            client_ip='10.0.0.5',
+        )
+
+        self.assertTrue(response['result']['isError'])
+        self.assertEqual(
+            '工具返回格式异常',
+            response['result']['structuredContent']['message'],
+        )
+        self.assertEqual([], gateway.calls)
 
     def test_extract_client_ip_ignores_spoofable_forwarded_for_header(self):
         client_ip = extract_client_ip(

+ 5 - 1
tests/test_public_server_integration.py

@@ -157,7 +157,11 @@ class TestPublicServerIntegration(unittest.TestCase):
 
         self.assertIn('result', result)
         self.assertTrue(result['result']['isError'])
-        self.assertIn('tool not registered', result['result']['content'][0]['text'])
+        self.assertNotIn('bind_auth_code', result['result']['content'][0]['text'])
+        self.assertEqual(
+            'MCP_9001',
+            result['result']['structuredContent']['code'],
+        )
         self.mock_auth_client.exchange.assert_not_called()
 
     def test_tools_call_with_valid_session(self):

+ 2 - 0
tools/export_out_of_province_port_data.py

@@ -15,6 +15,8 @@ class ExportOutOfProvincePortDataTool:
                 '客户参考号、快递单号、柜号、SO 号或 Shipment ID;不得根据'
                 '号码格式猜测。号码类型或资料类型不明确时先询问用户。'
                 '一次只能选择一种资料类型;用户要求多个类型时应分别调用。'
+                '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,'
+                '不得展示内部参数名。'
             ),
             'input_schema': {
                 'type': 'object',

+ 1 - 0
tools/export_pending_outbound_orders.py

@@ -66,6 +66,7 @@ class ExportPendingOutboundOrdersTool:
             'description': (
                 '按 outbound/add.html 的筛选条件导出未排舱订单。筛选名称必须先通过 '
                 'list_pending_outbound_export_filter_options 获取当前员工有权限的 value。'
+                '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,不得展示内部参数名。'
             ),
             'input_schema': {'type': 'object', 'properties': properties},
         }

+ 3 - 1
tools/list_order_filter_options.py

@@ -18,7 +18,9 @@ class ListOrderFilterOptionsTool:
             'name': self.name,
             'description': (
                 'List authorized values for query_order_exact filters. '
-                'Use the returned value directly in the corresponding filter.'
+                'Use the returned value directly in the corresponding filter. '
+                '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,'
+                '不得展示内部参数名。'
             ),
             'input_schema': {
                 'type': 'object',

+ 2 - 1
tools/list_pending_outbound_export_filter_options.py

@@ -14,7 +14,8 @@ class ListPendingOutboundExportFilterOptionsTool:
             'name': self.name,
             'description': (
                 '列出当前员工有权限使用的未排舱订单导出筛选项。用户提供名称时,必须先查到 value,'
-                '不得猜测 ID;关键字搜索也只在授权结果内执行。'
+                '不得猜测 ID;关键字搜索也只在授权结果内执行。参数名仅用于工具调用;'
+                '向用户回答时只能使用中文业务名称,不得展示内部参数名。'
             ),
             'input_schema': {
                 'type': 'object',

+ 2 - 0
tools/query_order_exact.py

@@ -188,6 +188,8 @@ class QueryOrderExactTool:
                 '查询无结果时不得改用其他字段重试。'
                 '产品、客户、销售、仓库、事业部和国家条件先调用 '
                 'list_order_filter_options。'
+                '参数名仅用于工具调用;向用户回答时只能使用对应的中文业务名称,'
+                '不得展示内部参数名。'
             ),
             'input_schema': {
                 'type': 'object',

+ 7 - 2
tools/query_track.py

@@ -8,7 +8,12 @@ class QueryTrackTool:
     def metadata(self):
         return {
             'name': self.name,
-            'description': 'Query tracking information with the current employee permissions. Provide order_id, order_number, or tracking_number.',
+            'description': (
+                'Query tracking information with the current employee permissions. '
+                'Provide order_id, order_number, or tracking_number. '
+                '参数名仅用于工具调用;向用户回答时只能使用“订单号”、'
+                '“快递单号”等业务名称,不得展示内部参数名。'
+            ),
             'input_schema': {
                 'type': 'object',
                 'properties': {
@@ -45,4 +50,4 @@ class QueryTrackTool:
         if tracking_number:
             payload['tracking_number'] = str(tracking_number).strip()
 
-        return self.api_client.call_tool(self.name, self.route_path, payload, request_id)
+        return self.api_client.call_tool(self.name, self.route_path, payload, request_id)