Переглянути джерело

Merge remote-tracking branch 'remotes/origin/master_task_7124556399' into master

# Conflicts:
#	README.md
#	tests/test_container_timeliness_tools.py
#	tests/test_customer_payment_followup_tool.py
#	tests/test_customer_payment_records_tool.py
#	tests/test_customer_query_tools.py
#	tests/test_customer_unverified_bill_details_tool.py
#	tests/test_destination_trailer_tools.py
#	tests/test_export_pallet_data_tool.py
#	tests/test_export_receivable_cost_list_tool.py
#	tests/test_order_abnormal_tools.py
#	tests/test_order_receivable_cost_details_tool.py
#	tests/test_output_presenter.py
#	tests/test_payable_cost_tools.py
#	tests/test_query_export_task_tool.py
#	tests/test_receivable_cost_list_tools.py
#	tests/test_receive_volume_tools.py
#	tests/test_tool_description_boundaries.py
zhongjr 2 тижнів тому
батько
коміт
38f5ec7276

+ 6 - 4
README.md

@@ -17,7 +17,7 @@ Gateway 保持“薄网关”边界:
 - 支持 Redis token/session 存储。
 - 支持文件 token store 作为开发排障兜底。
 - 不再支持授权码绑定工具;正式接入只使用后台生成的 `GWS_xxx` 设备配置。
-- 本地 stdio 与公网 HTTP 注册同一组 35 个查询、筛选和导出工具。
+- 本地 stdio 与公网 HTTP 注册同一组 36 个查询、筛选和导出工具。
 - 支持客户、订单、订单详情、轨迹、报关资料、排舱列表与详情查询。
 - 支持客户、订单与排舱筛选项,以及未排舱订单和省外进港资料导出。
 - 支持 MCP `initialize`、`tools/list`、`tools/call`。
@@ -25,7 +25,7 @@ Gateway 保持“薄网关”边界:
 
 ## 工具目录
 
-`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 35 个候选工具:
+`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 36 个候选工具:
 
 | MCP 工具 | 用途 | ThinkPHP 路由 | 最终展示 |
 |---|---|---|---|
@@ -49,6 +49,8 @@ Gateway 保持“薄网关”边界:
 | `query_order_abnormal_list` | 按六个页签查询头程问题件列表 | `/mcp/tools/queryOrderAbnormalList` | 固定 9 列安全表格 |
 | `list_order_abnormal_filter_options` | 查询头程问题件六个中文页签 | `/mcp/tools/listOrderAbnormalFilterOptions` | 安全筛选项 |
 | `query_receive_volume_list` | 按入库时间查询收货量已入库订单明细 | `/mcp/tools/queryReceiveVolumeList` | 固定 9 列安全表格 |
+| `query_goods_info` | 按 SKU 或中文品名查询已审核商品资料 | `/mcp/tools/queryGoodsInfo` | 固定 24 列安全表格 |
+| `trial_goods_tax` | 按 SKU、起运国代码和目的国代码试算已审核商品的预计关税 | `/mcp/tools/trialGoodsTax` | 预计关税金额 |
 | `list_receive_volume_filter_options` | 查询收货量五类筛选项 | `/mcp/tools/listReceiveVolumeFilterOptions` | 安全筛选项 |
 | `query_container_timeliness_list` | 按柜号、提单号或干线实际出发时间查询柜子时效 | `/mcp/tools/queryContainerTimelinessList` | 固定 54 列安全表格 |
 | `export_container_timeliness_report` | 提交柜子时效统计报表异步导出 | `/mcp/tools/exportContainerTimelinessReport` | 签名任务引用 |
@@ -79,7 +81,7 @@ Gateway 保持“薄网关”边界:
 
 `export_container_timeliness_report` 的筛选与查询相同:柜号、提单号可同时传入并按 AND 收窄,合计最多 200 个;没有号码时必须提供最多 31 个起运港当地日历日的干线实际出发时间闭区间。Excel 对齐后台 `OPERATE_REPORT_OUTBOUND`(序号 + 54 列)。看表格改走 `query_container_timeliness_list`。
 
-这 35 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
+这 36 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
 
 MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明或注册集合变化后,必须重启对应 Gateway 进程并让客户端重新连接,客户端才会重新获取工具列表。
 
@@ -88,7 +90,7 @@ MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明
 Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内部接口和工具入参:
 
 - `query_order` 保持原有 `columns + records` 结果和文本展示,不参与本次转换。
-- `services/output_presenter.py` 对其余 34 个安全工具执行显式白名单展示。
+- `services/output_presenter.py` 对其余 35 个安全工具执行显式白名单展示。
 - `query_order_exact`、`query_customer_list`、`query_customer_payment_followup`、`query_customer_unverified_bill_details`、`query_customer_payment_records`、`query_receivable_cost_list`、`query_track`、`query_customs_declaration_files`、`query_outbound_list` 对外使用中文 `headers + rows + pagination`,不返回内部字段键。
 - 订单应收费用明细对外使用四项 CNY `summary`(订单总应收、订单总结算、特批金额、结算毛利)加固定 13 列 `headers + rows + pagination`,不返回内部字段键。
 - 客户回款跟进严格校验三字段月份汇总;单个客户的六字段账单明细由独立分页工具返回。未知、缺失、额外字段或畸形分页均返回安全错误,不静默丢弃。

+ 29 - 0
app.py

@@ -65,6 +65,8 @@ from tools.query_receive_volume_list import QueryReceiveVolumeListTool
 from tools.list_receive_volume_filter_options import (
     ListReceiveVolumeFilterOptionsTool,
 )
+from tools.query_goods_info import QueryGoodsInfoTool
+from tools.trial_goods_tax import TrialGoodsTaxTool
 from tools.query_container_timeliness_list import QueryContainerTimelinessListTool
 from tools.export_container_timeliness_report import (
     ExportContainerTimelinessReportTool,
@@ -166,6 +168,8 @@ class GatewayApp:
             'query_receive_volume_list': QueryReceiveVolumeListTool(
                 api_client=api_client
             ),
+            'query_goods_info': QueryGoodsInfoTool(api_client=api_client),
+            'trial_goods_tax': TrialGoodsTaxTool(api_client=api_client),
             'list_receive_volume_filter_options':
                 ListReceiveVolumeFilterOptionsTool(api_client=api_client),
             'query_container_timeliness_list': QueryContainerTimelinessListTool(
@@ -303,6 +307,14 @@ class GatewayApp:
         call_parser = subparsers.add_parser('call')
         call_parser.add_argument('--tool', required=True)
         call_parser.add_argument('--keyword', default='')
+        call_parser.add_argument('--sku', default='')
+        call_parser.add_argument('--cn-name', default='')
+        call_parser.add_argument('--departure-country', default='')
+        call_parser.add_argument('--destination-country', default='')
+        call_parser.add_argument('--price', type=float, default=0)
+        call_parser.add_argument('--qty', type=float, default=0)
+        call_parser.add_argument('--gross-weight', type=float, default=0)
+        call_parser.add_argument('--box', type=float, default=0)
         call_parser.add_argument('--order-id', type=int, default=0)
         call_parser.add_argument('--order-number', default='')
         call_parser.add_argument('--business-type', type=int, default=0)
@@ -805,6 +817,23 @@ class GatewayApp:
                     tool_args['order_numbers'] = parse_string_list(
                         args.order_numbers
                     )
+            elif args.tool == 'trial_goods_tax':
+                tool_args['sku'] = args.sku
+                tool_args['departure_country'] = args.departure_country
+                tool_args['destination_country'] = args.destination_country
+                tool_args['price'] = args.price
+                tool_args['qty'] = args.qty
+                tool_args['gross_weight'] = args.gross_weight
+                tool_args['box'] = args.box
+            elif args.tool == 'query_goods_info':
+                if args.sku:
+                    tool_args['sku'] = args.sku
+                if args.cn_name:
+                    tool_args['cn_name'] = args.cn_name
+                if not args.sku and not args.cn_name:
+                    raise ValueError(
+                        '--sku or --cn-name is required for query_goods_info'
+                    )
             elif args.tool == 'query_receive_volume_list':
                 if not args.inbound_date_start or not args.inbound_date_end:
                     raise ValueError(

+ 4 - 0
public_gateway.py

@@ -47,6 +47,8 @@ from tools.query_order_abnormal_list import QueryOrderAbnormalListTool
 from tools.list_order_abnormal_filter_options import (
     ListOrderAbnormalFilterOptionsTool,
 )
+from tools.query_goods_info import QueryGoodsInfoTool
+from tools.trial_goods_tax import TrialGoodsTaxTool
 from tools.query_receive_volume_list import QueryReceiveVolumeListTool
 from tools.list_receive_volume_filter_options import (
     ListReceiveVolumeFilterOptionsTool,
@@ -133,6 +135,8 @@ class PublicGatewayApp:
             'query_receive_volume_list': QueryReceiveVolumeListTool(
                 api_client=None
             ),
+            'query_goods_info': QueryGoodsInfoTool(api_client=None),
+            'trial_goods_tax': TrialGoodsTaxTool(api_client=None),
             'list_receive_volume_filter_options':
                 ListReceiveVolumeFilterOptionsTool(api_client=None),
             'query_container_timeliness_list': QueryContainerTimelinessListTool(

+ 108 - 0
services/output_presenter.py

@@ -36,6 +36,8 @@ class OutputPresenter:
     DESTINATION_TRAILER_TOOLS = frozenset(('query_destination_trailer_list',))
     ORDER_ABNORMAL_TOOLS = frozenset(('query_order_abnormal_list',))
     RECEIVE_VOLUME_TOOLS = frozenset(('query_receive_volume_list',))
+    GOODS_INFO_TOOLS = frozenset(('query_goods_info',))
+    GOODS_TAX_TOOLS = frozenset(('trial_goods_tax',))
     CONTAINER_TIMELINESS_TOOLS = frozenset(('query_container_timeliness_list',))
     FREIGHTOWER_TRACK_TOOLS = frozenset(('query_freightower_track',))
     DETAIL_TOOLS = frozenset(('query_outbound_detail',))
@@ -65,6 +67,8 @@ class OutputPresenter:
         | DESTINATION_TRAILER_TOOLS
         | ORDER_ABNORMAL_TOOLS
         | RECEIVE_VOLUME_TOOLS
+        | GOODS_INFO_TOOLS
+        | GOODS_TAX_TOOLS
         | CONTAINER_TIMELINESS_TOOLS
         | FREIGHTOWER_TRACK_TOOLS
         | OPTION_TOOLS | EXPORT_TOOLS | TASK_TOOLS
@@ -568,6 +572,20 @@ class OutputPresenter:
     ORDER_ABNORMAL_STATUSES = frozenset((
         '待处理', '放行待审核', '包裹放行', '退件待审核', '退件驳回', '已退件',
     ))
+    GOODS_INFO_COLUMNS = (
+        ('sku', 'SKU'), ('departure_country', '起运国'),
+        ('destination_country', '目的国'), ('goods_attr', '商品属性'),
+        ('cn_name', '中文名称'), ('en_name', '英文名称'),
+        ('declaration_hscode', '报关编码'), ('clearance_hscode', '清关编码'),
+        ('tax_rate', '税率'), ('is_inspection_fee', '木制/竹制品商检费'),
+        ('is_fda_declaration', 'FDA申报'), ('product_surcharge', '产品附加费'),
+        ('tax_surcharge', '关税附加费'), ('is_epa_declaration', 'EPA申报'),
+        ('is_act_declaration', 'LACY ACT申报'), ('is_dot_declaration', 'DOT申报'),
+        ('is_cpsc_declaration', 'CPSC申报'), ('brand_type', '品牌类型'),
+        ('brand', '品牌'), ('goods_type', '型号'),
+        ('material_cn', '材质(CN)'), ('material_en', '材质(EN)'),
+        ('purpose_cn', '用途(CN)'), ('purpose_en', '用途(EN)'),
+    )
     RECEIVE_VOLUME_COLUMNS = (
         ('order_number', '订单号'), ('customer_name', '客户名称'),
         ('packing_type', '货物类型'), ('inbound_volume', '入库体积'),
@@ -820,6 +838,12 @@ class OutputPresenter:
         'MCP_1601': '没有可导出的数据',
         'MCP_9001': '工具调用失败,请稍后重试',
     }
+    TAX_TRIAL_MESSAGES = frozenset((
+        '未找到已审核商品',
+        '匹配到多条商品,请核对起运国和目的国',
+        '该商品没有应收清关编码,无法试算',
+        '关税试算失败,请稍后重试',
+    ))
 
     NON_RETRYABLE_CODES = frozenset((
         'MCP_1101',
@@ -910,6 +934,12 @@ class OutputPresenter:
             return self._present_receive_volume_list(
                 data, tool_result.get('meta'), meta
             )
+        if tool_name in self.GOODS_INFO_TOOLS:
+            return self._present_goods_info(
+                data, tool_result.get('meta'), meta
+            )
+        if tool_name in self.GOODS_TAX_TOOLS:
+            return self._present_goods_tax(data, meta)
         if tool_name in self.CONTAINER_TIMELINESS_TOOLS:
             return self._present_container_timeliness_list(
                 data, tool_result.get('meta'), meta
@@ -1779,6 +1809,79 @@ class OutputPresenter:
         }
         return self._success_result(content, self._render_table(content), meta)
 
+    def _present_goods_tax(self, data, meta):
+        expected = {
+            'estimated_tax', 'sku', 'departure_country', 'destination_country',
+        }
+        if set(data) != expected:
+            return self._format_error(meta)
+        amount = data.get('estimated_tax')
+        sku = data.get('sku')
+        departure = data.get('departure_country')
+        destination = data.get('destination_country')
+        if (
+            isinstance(amount, bool) or not isinstance(amount, (int, float))
+            or not math.isfinite(float(amount))
+            or not isinstance(sku, str) or not sku.strip()
+            or not self._country_code(departure)
+            or not self._country_code(destination)
+        ):
+            return self._format_error(meta)
+        content = {
+            'estimated_tax': amount,
+            'sku': sku,
+            'departure_country': departure,
+            'destination_country': destination,
+        }
+        text = '预计关税金额: {0}'.format(amount)
+        return self._success_result(content, text, meta)
+
+    @staticmethod
+    def _country_code(value):
+        return (
+            isinstance(value, str)
+            and value.isalpha()
+            and value.isupper()
+            and 2 <= len(value) <= 3
+        )
+
+    def _present_goods_info(self, data, raw_meta, meta):
+        if set(data) != {'columns', 'records'}:
+            return self._format_error(meta)
+        expected = list(self.GOODS_INFO_COLUMNS)
+        pagination = self._customer_pagination(raw_meta)
+        columns = data.get('columns')
+        records = data.get('records')
+        if (
+            not isinstance(columns, list)
+            or not isinstance(records, list)
+            or len(columns) != len(expected)
+            or pagination is None
+        ):
+            return self._format_error(meta)
+        for column, (key, name) in zip(columns, expected):
+            if column != {'key': key, 'name': name}:
+                return self._format_error(meta)
+        rows = []
+        expected_keys = [key for key, _ in expected]
+        for record in records:
+            if not isinstance(record, dict) or set(record) != set(expected_keys):
+                return self._format_error(meta)
+            row = []
+            for key in expected_keys:
+                value = record[key]
+                if not isinstance(value, str):
+                    return self._format_error(meta)
+                row.append(value)
+            rows.append(row)
+        headers = [{'label': name} for _, name in expected]
+        content = {
+            'headers': headers,
+            'rows': rows,
+            'pagination': pagination,
+        }
+        return self._success_result(content, self._render_table(content), meta)
+
     def _present_receive_volume_list(self, data, raw_meta, meta):
         if set(data) != {'columns', 'records'}:
             return self._format_error(meta)
@@ -2227,6 +2330,11 @@ class OutputPresenter:
             return False
 
     def _business_error(self, tool_name, code, raw_message, meta):
+        message = str(raw_message or '').strip()
+        if tool_name == 'trial_goods_tax' and message in self.TAX_TRIAL_MESSAGES:
+            safe_code = code if code in self.ERROR_MESSAGES else 'MCP_9001'
+            retryable = safe_code not in self.NON_RETRYABLE_CODES
+            return self._error_result(safe_code, message, retryable, meta)
         if code not in self.ERROR_MESSAGES and code != 'MCP_1401':
             logger.warning(
                 'Unknown MCP business error code',

+ 2 - 2
tests/test_container_timeliness_tools.py

@@ -296,8 +296,8 @@ class ContainerTimelinessToolContractTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(35, len(local))
-        self.assertEqual(34, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(36, len(local))
+        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
         self.assertIn('query_container_timeliness_list', local)
         self.assertIn('export_container_timeliness_report', local)
 

+ 1 - 1
tests/test_customer_payment_followup_tool.py

@@ -112,7 +112,7 @@ class CustomerPaymentFollowupToolTest(unittest.TestCase):
         local = GatewayApp(api_client=RecordingApiClient())
         public = PublicGatewayApp(None, None)
         self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
-        self.assertEqual(35, len(local.registered_tool_names()))
+        self.assertEqual(36, len(local.registered_tool_names()))
         self.assertIn('query_customer_payment_followup', local.registered_tool_names())
 
         stdout = io.StringIO()

+ 1 - 1
tests/test_customer_payment_records_tool.py

@@ -118,7 +118,7 @@ class CustomerPaymentRecordsToolTest(unittest.TestCase):
         local = GatewayApp(api_client=client)
         public = PublicGatewayApp(None, None)
         self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
-        self.assertEqual(35, len(local.registered_tool_names()))
+        self.assertEqual(36, len(local.registered_tool_names()))
         self.assertIn('query_customer_payment_records', local.registered_tool_names())
 
         stdout = io.StringIO()

+ 1 - 1
tests/test_customer_query_tools.py

@@ -68,7 +68,7 @@ class CustomerQueryToolTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(35, len(local))
+        self.assertEqual(36, len(local))
         self.assertIn('query_customer_list', local)
         self.assertIn('list_customer_filter_options', local)
 

+ 1 - 1
tests/test_customer_unverified_bill_details_tool.py

@@ -61,7 +61,7 @@ class CustomerUnverifiedBillDetailsToolTest(unittest.TestCase):
         local = GatewayApp(api_client=client)
         public = PublicGatewayApp(None, None)
         self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
-        self.assertEqual(35, len(local.registered_tool_names()))
+        self.assertEqual(36, len(local.registered_tool_names()))
         self.assertIn('query_customer_unverified_bill_details', local.registered_tool_names())
 
         output = StringIO()

+ 2 - 2
tests/test_destination_trailer_tools.py

@@ -387,8 +387,8 @@ class DestinationTrailerToolContractTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(35, len(local))
-        self.assertEqual(34, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(36, len(local))
+        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
         self.assertIn('query_destination_trailer_list', local)
         self.assertIn('list_destination_trailer_filter_options', local)
 

+ 2 - 2
tests/test_export_pallet_data_tool.py

@@ -296,9 +296,9 @@ class ExportPalletDataToolTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(35, len(local))
+        self.assertEqual(36, len(local))
         self.assertIn('export_pallet_data', local)
-        self.assertEqual(34, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
 
     def test_presenter_reuses_queued_export_contract(self):
         presented = OutputPresenter().present(

+ 2 - 2
tests/test_export_receivable_cost_list_tool.py

@@ -168,9 +168,9 @@ class ExportReceivableCostListToolTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(35, len(local))
+        self.assertEqual(36, len(local))
         self.assertIn('export_receivable_cost_list', local)
-        self.assertEqual(34, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
 
     def test_presenter_reuses_queued_export_contract(self):
         presented = OutputPresenter().present(

+ 2 - 2
tests/test_order_abnormal_tools.py

@@ -341,7 +341,7 @@ class OrderAbnormalToolContractTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(35, len(local))
-        self.assertEqual(34, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(36, len(local))
+        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
         self.assertIn('query_order_abnormal_list', local)
         self.assertIn('list_order_abnormal_filter_options', local)

+ 1 - 1
tests/test_order_receivable_cost_details_tool.py

@@ -86,7 +86,7 @@ class OrderReceivableCostDetailsToolTest(unittest.TestCase):
         local = GatewayApp(api_client=client)
         public = PublicGatewayApp(None, None)
         self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
-        self.assertEqual(35, len(local.registered_tool_names()))
+        self.assertEqual(36, len(local.registered_tool_names()))
         self.assertIn('query_order_receivable_cost_details', local.registered_tool_names())
         self.assertIn('query_customer_payment_records', local.registered_tool_names())
 

+ 1 - 1
tests/test_output_presenter.py

@@ -203,7 +203,7 @@ class OutputPresenterTest(unittest.TestCase):
     def test_customer_tools_are_safe_and_total_is_24(self):
         self.assertTrue(self.presenter.handles(QueryCustomerListTool.name))
         self.assertTrue(self.presenter.handles(ListCustomerFilterOptionsTool.name))
-        self.assertEqual(34, len(self.presenter.SAFE_TOOLS))
+        self.assertEqual(35, len(self.presenter.SAFE_TOOLS))
 
     def test_exact_order_uses_labels_and_drops_internal_fields(self):
         result = self.presenter.present(

+ 2 - 2
tests/test_payable_cost_tools.py

@@ -231,8 +231,8 @@ class PayableCostToolContractTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(35, len(local))
-        self.assertEqual(34, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(36, len(local))
+        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
         self.assertEqual(
             (
                 'query_payable_cost_list',

+ 1 - 1
tests/test_query_export_task_tool.py

@@ -63,7 +63,7 @@ class QueryExportTaskToolTest(unittest.TestCase):
         public = PublicGatewayApp(None, None).registered_tool_names()
 
         self.assertEqual(local, public)
-        self.assertEqual(35, len(local))
+        self.assertEqual(36, len(local))
         self.assertIn('query_export_task', local)
 
     def test_cli_forwards_only_task_reference(self):

+ 2 - 2
tests/test_receivable_cost_list_tools.py

@@ -274,8 +274,8 @@ class ReceivableCostIntegrationTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(35, len(local))
-        self.assertEqual(34, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(36, len(local))
+        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
         self.assertEqual(
             (
                 'query_receivable_cost_list',

+ 2 - 2
tests/test_receive_volume_tools.py

@@ -406,7 +406,7 @@ class ReceiveVolumeToolContractTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(35, len(local))
-        self.assertEqual(34, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(36, len(local))
+        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
         self.assertIn('query_receive_volume_list', local)
         self.assertIn('list_receive_volume_filter_options', local)

+ 5 - 0
tests/test_tool_description_boundaries.py

@@ -23,6 +23,8 @@ from tools.query_order_abnormal_list import QueryOrderAbnormalListTool
 from tools.list_order_abnormal_filter_options import (
     ListOrderAbnormalFilterOptionsTool,
 )
+from tools.query_goods_info import QueryGoodsInfoTool
+from tools.trial_goods_tax import TrialGoodsTaxTool
 from tools.query_receive_volume_list import QueryReceiveVolumeListTool
 from tools.list_receive_volume_filter_options import (
     ListReceiveVolumeFilterOptionsTool,
@@ -54,6 +56,8 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             QueryOrderAbnormalListTool(),
             ListOrderAbnormalFilterOptionsTool(),
             QueryReceiveVolumeListTool(),
+            QueryGoodsInfoTool(),
+            TrialGoodsTaxTool(),
             ListReceiveVolumeFilterOptionsTool(),
             QueryContainerTimelinessListTool(),
             ExportContainerTimelinessReportTool(),
@@ -80,6 +84,7 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             QueryOrderAbnormalListTool(),
             QueryContainerTimelinessListTool(),
             ExportContainerTimelinessReportTool(),
+            TrialGoodsTaxTool(),
             QueryFreightowerTrackTool(),
         )
 

+ 85 - 0
tests/test_trial_goods_tax_tool.py

@@ -0,0 +1,85 @@
+import unittest
+from unittest.mock import Mock
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.trial_goods_tax import TrialGoodsTaxTool
+
+
+class TrialGoodsTaxToolTest(unittest.TestCase):
+    def test_call_forwards_required_fields(self):
+        client = Mock()
+        client.call_tool.return_value = {'code': 'MCP_0000'}
+        TrialGoodsTaxTool(client).call(
+            sku=' SKU-1 ',
+            departure_country=' CN ',
+            destination_country='us',
+            price=10,
+            qty=2,
+            gross_weight=1.5,
+            box=1,
+        )
+        client.call_tool.assert_called_once_with(
+            'trial_goods_tax',
+            '/mcp/tools/trialGoodsTax',
+            {
+                'sku': 'SKU-1',
+                'departure_country': 'CN',
+                'destination_country': 'US',
+                'price': 10,
+                'qty': 2,
+                'gross_weight': 1.5,
+                'box': 1,
+            },
+            'rq_trial_goods_tax',
+        )
+
+    def test_call_rejects_country_name(self):
+        tool = TrialGoodsTaxTool(api_client=Mock())
+        with self.assertRaises(ValueError):
+            tool.call(
+                sku='SKU-1', departure_country='中国', destination_country='US',
+                price=10, qty=1, gross_weight=1, box=1,
+            )
+
+    def test_call_rejects_zero_price(self):
+        tool = TrialGoodsTaxTool(api_client=Mock())
+        with self.assertRaises(ValueError):
+            tool.call(
+                sku='SKU-1', departure_country='CN', destination_country='US',
+                price=0, qty=1, gross_weight=1, box=1,
+            )
+
+    def test_registries_match(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertIn('trial_goods_tax', local)
+        self.assertEqual(36, len(local))
+        self.assertIn('trial_goods_tax', OutputPresenter.SAFE_TOOLS)
+
+    def test_presenter_renders_amount(self):
+        result = OutputPresenter().present('trial_goods_tax', {
+            'code': 'MCP_0000',
+            'data': {
+                'estimated_tax': 12.5,
+                'sku': 'SKU-1',
+                'departure_country': 'CN',
+                'destination_country': 'US',
+            },
+            'meta': {'request_id': 'rq1'},
+        })
+        self.assertFalse(result['is_error'])
+        self.assertEqual(12.5, result['structured_content']['estimated_tax'])
+        self.assertIn('预计关税金额', result['text'])
+
+    def test_presenter_keeps_safe_failure_message(self):
+        result = OutputPresenter().present('trial_goods_tax', {
+            'code': 'MCP_1501',
+            'msg': '未找到已审核商品',
+            'data': [],
+            'meta': {'request_id': 'rq1'},
+        })
+        self.assertTrue(result['is_error'])
+        self.assertEqual('未找到已审核商品', result['structured_content']['message'])

+ 95 - 0
tools/query_goods_info.py

@@ -0,0 +1,95 @@
+class QueryGoodsInfoTool:
+    name = 'query_goods_info'
+    route_path = '/mcp/tools/queryGoodsInfo'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户明确要查已审核商品资料时使用,可按 SKU 或中文品名查询,'
+                '返回与后台编码查询页相同的列表字段,包括起运国、目的国、属性、'
+                '报关编码、清关编码和税率。SKU 与中文品名至少提供一个;'
+                '用户没说明是 SKU 还是中文品名时必须先询问,确认前不得调用。'
+                '不得根据号码格式猜测,不得跨字段或跨工具试查。'
+                '同一 SKU 按起运国和目的国会返回多行。'
+                '禁止使用:不查未审核商品,不导出文件,不修改商品或清关资料。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'sku': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 2000,
+                        'description': (
+                            'SKU。仅当用户明确说明是 SKU 时使用。'
+                            '多个 SKU 用逗号或换行分隔,最多 50 个,每个最多 100 个字符。'
+                        ),
+                    },
+                    'cn_name': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 100,
+                        'description': (
+                            '中文品名。仅当用户明确说明是中文品名时使用,按包含关系查询。'
+                        ),
+                    },
+                    'page': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 20,
+                    },
+                },
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        sku='',
+        cn_name='',
+        page=1,
+        limit=20,
+        request_id='rq_query_goods_info',
+        **_ignored,
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for query_goods_info')
+        payload = {
+            'page': self._bounded_integer(page, 'page'),
+            'limit': self._bounded_integer(limit, 'limit'),
+        }
+        sku = self._text(sku, 'sku', 2000)
+        cn_name = self._text(cn_name, 'cn_name', 100)
+        if sku:
+            payload['sku'] = sku
+        if cn_name:
+            payload['cn_name'] = cn_name
+        if 'sku' not in payload and 'cn_name' not in payload:
+            raise ValueError('sku or cn_name is required')
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id
+        )
+
+    @staticmethod
+    def _text(value, field, max_length):
+        if value in (None, ''):
+            return ''
+        if not isinstance(value, str):
+            raise ValueError(field + ' must be a string')
+        value = value.strip()
+        if len(value) > max_length:
+            raise ValueError(field + ' is too long')
+        return value
+
+    @staticmethod
+    def _bounded_integer(value, field):
+        if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 100:
+            raise ValueError(field + ' is invalid')
+        return value

+ 109 - 0
tools/trial_goods_tax.py

@@ -0,0 +1,109 @@
+import math
+
+
+class TrialGoodsTaxTool:
+    name = 'trial_goods_tax'
+    route_path = '/mcp/tools/trialGoodsTax'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        number = {'type': 'number', 'exclusiveMinimum': 0, 'maximum': 1000000000}
+        country = {
+            'type': 'string',
+            'minLength': 2,
+            'maxLength': 3,
+            'pattern': '^[A-Za-z]{2,3}$',
+            'description': '国家代码,例如 CN、US。不得传中文国名或“国家名-代码”。',
+        }
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户明确要对某一条已审核商品做关税试算时使用。'
+                '必须同时提供 SKU、起运国国家代码、目的国国家代码、采购单价、SKU数量、总毛重和箱数。'
+                '起运国和目的国只能填国家代码,例如 CN、US。'
+                '同一 SKU 有多条时,号码类型不明确时必须先询问用户,国家不明确也必须先询问,确认前不得调用。'
+                '不得根据号码格式猜测,不得跨字段或跨工具试查。'
+                '返回预计关税金额,不保存试算结果。'
+                '禁止使用:不查询商品列表,不修改商品或清关资料,不创建订单,不导出文件。'
+                '查商品资料用 query_goods_info。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'sku': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 100,
+                        'description': 'SKU。仅当用户明确说明是 SKU 时使用。',
+                    },
+                    'departure_country': dict(country, description='起运国。'),
+                    'destination_country': dict(country, description='目的国。'),
+                    'price': dict(number, description='采购单价,必须大于 0。'),
+                    'qty': dict(number, description='SKU数量,必须大于 0。'),
+                    'gross_weight': dict(number, description='总毛重,必须大于 0。'),
+                    'box': dict(number, description='箱数,必须大于 0。'),
+                },
+                'required': [
+                    'sku', 'departure_country', 'destination_country',
+                    'price', 'qty', 'gross_weight', 'box',
+                ],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        sku='',
+        departure_country='',
+        destination_country='',
+        price=None,
+        qty=None,
+        gross_weight=None,
+        box=None,
+        request_id='rq_trial_goods_tax',
+        **_ignored,
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for trial_goods_tax')
+        payload = {
+            'sku': self._text(sku, 'sku', 100),
+            'departure_country': self._country(departure_country, 'departure_country'),
+            'destination_country': self._country(destination_country, 'destination_country'),
+            'price': self._positive(price, 'price'),
+            'qty': self._positive(qty, 'qty'),
+            'gross_weight': self._positive(gross_weight, 'gross_weight'),
+            'box': self._positive(box, 'box'),
+        }
+        if not payload['sku'] or not payload['departure_country'] or not payload['destination_country']:
+            raise ValueError('sku, departure_country and destination_country are required')
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id
+        )
+
+    @staticmethod
+    def _country(value, field):
+        if not isinstance(value, str):
+            raise ValueError(field + ' must be a country code')
+        value = value.strip().upper()
+        if len(value) < 2 or len(value) > 3 or not value.isalpha():
+            raise ValueError(field + ' must be a country code')
+        return value
+
+    @staticmethod
+    def _text(value, field, max_length):
+        if not isinstance(value, str):
+            raise ValueError(field + ' must be a string')
+        value = value.strip()
+        if not value or len(value) > max_length:
+            raise ValueError(field + ' is invalid')
+        return value
+
+    @staticmethod
+    def _positive(value, field):
+        if isinstance(value, bool) or not isinstance(value, (int, float)):
+            raise ValueError(field + ' is invalid')
+        if not math.isfinite(value) or value <= 0 or value > 1000000000:
+            raise ValueError(field + ' is invalid')
+        return value