Ver código fonte

7119725758将飞驼的轨迹查询包装成接口用于MCP查询

zhongjr 1 semana atrás
pai
commit
6a41121baa

+ 6 - 5
README.md

@@ -17,7 +17,7 @@ Gateway 保持“薄网关”边界:
 - 支持 Redis token/session 存储。
 - 支持文件 token store 作为开发排障兜底。
 - 不再支持授权码绑定工具;正式接入只使用后台生成的 `GWS_xxx` 设备配置。
-- 本地 stdio 与公网 HTTP 注册同一组 34 个查询、筛选和导出工具。
+- 本地 stdio 与公网 HTTP 注册同一组 35 个查询、筛选和导出工具。
 - 支持客户、订单、订单详情、轨迹、报关资料、排舱列表与详情查询。
 - 支持客户、订单与排舱筛选项,以及未排舱订单和省外进港资料导出。
 - 支持 MCP `initialize`、`tools/list`、`tools/call`。
@@ -25,12 +25,13 @@ Gateway 保持“薄网关”边界:
 
 ## 工具目录
 
-`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 34 个候选工具:
+`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 35 个候选工具:
 
 | MCP 工具 | 用途 | ThinkPHP 路由 | 最终展示 |
 |---|---|---|---|
 | `query_order` | 普通订单列表查询 | `/mcp/tools/queryOrder` | 旧协议兼容 |
 | `query_track` | 按订单号/排舱单号或跟踪号/柜号/快递单号查询轨迹 | `/mcp/tools/queryTrack` | 安全表格 |
+| `query_freightower_track` | 按本公司排舱单的提单号或柜号查询飞驼现场船期和已发生节点 | `/mcp/tools/queryFreightowerTrack` | 船期、港口、轨迹 |
 | `query_order_exact` | 按明确号码类型精准查询订单 | `/mcp/tools/queryOrderExact` | 安全表格 |
 | `query_order_detail` | 按明确订单号查询订单详情,支持“全部”聚合 | `/mcp/tools/queryOrderDetail` | 安全中文详情 |
 | `query_customer_list` | 查询当前员工有权查看的启用主客户 | `/mcp/tools/queryCustomerList` | 固定 17 列安全表格 |
@@ -78,7 +79,7 @@ Gateway 保持“薄网关”边界:
 
 `export_container_timeliness_report` 的筛选与查询相同:柜号、提单号可同时传入并按 AND 收窄,合计最多 200 个;没有号码时必须提供最多 31 个起运港当地日历日的干线实际出发时间闭区间。Excel 对齐后台 `OPERATE_REPORT_OUTBOUND`(序号 + 54 列)。看表格改走 `query_container_timeliness_list`。
 
-这 34 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
+这 35 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
 
 MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明或注册集合变化后,必须重启对应 Gateway 进程并让客户端重新连接,客户端才会重新获取工具列表。
 
@@ -87,7 +88,7 @@ MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明
 Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内部接口和工具入参:
 
 - `query_order` 保持原有 `columns + records` 结果和文本展示,不参与本次转换。
-- `services/output_presenter.py` 对其余 33 个安全工具执行显式白名单展示。
+- `services/output_presenter.py` 对其余 34 个安全工具执行显式白名单展示。
 - `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`,不返回内部字段键。
 - 客户回款跟进严格校验三字段月份汇总;单个客户的六字段账单明细由独立分页工具返回。未知、缺失、额外字段或畸形分页均返回安全错误,不静默丢弃。
@@ -486,7 +487,7 @@ Gateway 会调用以下路径:
 
 - Auth:本地兼容会话只使用 `/mcp/auth/refresh`、`/mcp/auth/revoke`;正式公网设备配置由 base 的登录态设备接口创建,不调用已退役的 `/mcp/auth/exchange`。
 - 动态工具列表:`/mcp/tools/listEnabledTools`。
-- 查询:`/mcp/tools/queryOrder`、`/mcp/tools/queryTrack`、`/mcp/tools/queryOrderExact`、`/mcp/tools/queryOrderDetail`、`/mcp/tools/queryCustomsDeclarationFiles`、`/mcp/tools/queryOutboundList`、`/mcp/tools/queryOutboundDetail`。
+- 查询:`/mcp/tools/queryOrder`、`/mcp/tools/queryTrack`、`/mcp/tools/queryFreightowerTrack`、`/mcp/tools/queryOrderExact`、`/mcp/tools/queryOrderDetail`、`/mcp/tools/queryCustomsDeclarationFiles`、`/mcp/tools/queryOutboundList`、`/mcp/tools/queryOutboundDetail`。
 - 筛选项:`/mcp/tools/listOutboundFilterOptions`、`/mcp/tools/listOrderFilterOptions`、`/mcp/tools/listPendingOutboundExportFilterOptions`。
 - 导出:`/mcp/tools/exportPendingOutboundOrders`、`/mcp/tools/exportOutOfProvincePortData`、`/mcp/tools/queryExportTask`。
 

+ 17 - 0
app.py

@@ -69,6 +69,7 @@ from tools.query_container_timeliness_list import QueryContainerTimelinessListTo
 from tools.export_container_timeliness_report import (
     ExportContainerTimelinessReportTool,
 )
+from tools.query_freightower_track import QueryFreightowerTrackTool
 
 
 def parse_int_list(value):
@@ -172,6 +173,9 @@ class GatewayApp:
             ),
             'export_container_timeliness_report':
                 ExportContainerTimelinessReportTool(api_client=api_client),
+            'query_freightower_track': QueryFreightowerTrackTool(
+                api_client=api_client
+            ),
         }
 
     @classmethod
@@ -310,6 +314,7 @@ class GatewayApp:
         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('--bl-number', default='')
         call_parser.add_argument('--bl-numbers', default='')
         call_parser.add_argument('--container-code', default='')
         call_parser.add_argument('--container-codes', default='')
@@ -839,6 +844,18 @@ class GatewayApp:
                 if args.tool == 'export_container_timeliness_report':
                     tool_args.pop('page', None)
                     tool_args.pop('limit', None)
+            elif args.tool == 'query_freightower_track':
+                tool_args.pop('page', None)
+                tool_args.pop('limit', None)
+                if args.bl_number:
+                    tool_args['bl_number'] = args.bl_number
+                if args.container_code:
+                    tool_args['container_code'] = args.container_code
+                if not tool_args.get('bl_number') and not tool_args.get('container_code'):
+                    raise ValueError(
+                        '--bl-number or --container-code is required for '
+                        'query_freightower_track'
+                    )
             elif args.tool in (
                 'list_order_filter_options', 'list_outbound_filter_options',
                 'list_customer_filter_options',

+ 2 - 0
public_gateway.py

@@ -55,6 +55,7 @@ from tools.query_container_timeliness_list import QueryContainerTimelinessListTo
 from tools.export_container_timeliness_report import (
     ExportContainerTimelinessReportTool,
 )
+from tools.query_freightower_track import QueryFreightowerTrackTool
 from utils.security import hash_gateway_session_id
 
 
@@ -139,6 +140,7 @@ class PublicGatewayApp:
             ),
             'export_container_timeliness_report':
                 ExportContainerTimelinessReportTool(api_client=None),
+            'query_freightower_track': QueryFreightowerTrackTool(api_client=None),
         }
 
     def registered_tool_names(self):

+ 113 - 0
services/output_presenter.py

@@ -37,6 +37,7 @@ class OutputPresenter:
     ORDER_ABNORMAL_TOOLS = frozenset(('query_order_abnormal_list',))
     RECEIVE_VOLUME_TOOLS = frozenset(('query_receive_volume_list',))
     CONTAINER_TIMELINESS_TOOLS = frozenset(('query_container_timeliness_list',))
+    FREIGHTOWER_TRACK_TOOLS = frozenset(('query_freightower_track',))
     DETAIL_TOOLS = frozenset(('query_outbound_detail',))
     ORDER_DETAIL_TOOLS = frozenset(('query_order_detail',))
     OPTION_TOOLS = frozenset((
@@ -65,6 +66,7 @@ class OutputPresenter:
         | ORDER_ABNORMAL_TOOLS
         | RECEIVE_VOLUME_TOOLS
         | CONTAINER_TIMELINESS_TOOLS
+        | FREIGHTOWER_TRACK_TOOLS
         | OPTION_TOOLS | EXPORT_TOOLS | TASK_TOOLS
     )
 
@@ -799,6 +801,10 @@ class OutputPresenter:
         'query_export_task': {
             'task_ref': '导出任务引用',
         },
+        'query_freightower_track': {
+            'bl_number': '提单号',
+            'container_code': '柜号',
+        },
     }
 
     ERROR_MESSAGES = {
@@ -908,6 +914,8 @@ class OutputPresenter:
             return self._present_container_timeliness_list(
                 data, tool_result.get('meta'), meta
             )
+        if tool_name in self.FREIGHTOWER_TRACK_TOOLS:
+            return self._present_freightower_track(data, meta)
         if tool_name in self.TABLE_TOOLS:
             return self._present_table(
                 tool_name,
@@ -2234,6 +2242,19 @@ class OutputPresenter:
         if code == 'MCP_1401':
             label = self._find_field_label(tool_name, raw_message)
             message = self._parameter_message(label)
+        elif tool_name in self.FREIGHTOWER_TRACK_TOOLS:
+            allowed = str(raw_message or '').strip()
+            if allowed not in (
+                '暂时没有飞驼轨迹',
+                '未找到本公司排舱单',
+                '匹配到多票排舱单,请同时提供提单号和柜号',
+                '未找到飞驼轨迹订单',
+                '飞驼查询超时',
+                '飞驼查询失败',
+            ):
+                message = self.ERROR_MESSAGES.get(code, self.ERROR_MESSAGES['MCP_9001'])
+            else:
+                message = allowed
         else:
             message = self.ERROR_MESSAGES[code]
         retryable = code not in self.NON_RETRYABLE_CODES
@@ -2317,6 +2338,98 @@ class OutputPresenter:
             )
         return '\n'.join(lines)
 
+        return self._success_result(content, '\n'.join(lines), meta)
+
+    def _present_freightower_track(self, data, meta):
+        if set(data) != {'schedule', 'ports', 'tracks'}:
+            return self._format_error(meta)
+        schedule = data.get('schedule')
+        ports = data.get('ports')
+        tracks = data.get('tracks')
+        schedule_keys = (
+            'bl_number', 'container_code', 'vessel', 'voyage', 'etd', 'eta',
+        )
+        if (
+            not isinstance(schedule, dict)
+            or set(schedule) != set(schedule_keys)
+            or not isinstance(ports, list)
+            or not isinstance(tracks, list)
+        ):
+            return self._format_error(meta)
+        labels = {
+            'bl_number': '提单号',
+            'container_code': '柜号',
+            'vessel': '船名',
+            'voyage': '航次',
+            'etd': '预计离港',
+            'eta': '预计到达',
+        }
+        safe_schedule = {}
+        for key in schedule_keys:
+            value = schedule.get(key)
+            if not isinstance(value, str):
+                return self._format_error(meta)
+            safe_schedule[labels[key]] = value
+        safe_ports = []
+        for port in ports:
+            if not isinstance(port, dict) or set(port) != {
+                'place_type', 'port_name', 'eta', 'ata',
+            }:
+                return self._format_error(meta)
+            if port['place_type'] not in ('中转港', '目的港', '交货地'):
+                return self._format_error(meta)
+            for key in ('port_name', 'eta', 'ata'):
+                if not isinstance(port[key], str):
+                    return self._format_error(meta)
+            safe_ports.append({
+                '地点类型': port['place_type'],
+                '港口': port['port_name'],
+                '预计到达': port['eta'],
+                '实际到达': port['ata'],
+            })
+        safe_tracks = []
+        for track in tracks:
+            if not isinstance(track, dict) or set(track) != {
+                'time', 'location', 'content', 'event_code',
+            }:
+                return self._format_error(meta)
+            for key in ('time', 'location', 'content', 'event_code'):
+                if not isinstance(track[key], str):
+                    return self._format_error(meta)
+            safe_tracks.append({
+                '时间': track['time'],
+                '地点': track['location'],
+                '内容': track['content'],
+                '状态代码': track['event_code'],
+            })
+        content = {
+            '船期': safe_schedule,
+            '港口': safe_ports,
+            '轨迹': safe_tracks,
+        }
+        lines = ['飞驼船期']
+        for label, value in safe_schedule.items():
+            lines.append('- {0}: {1}'.format(label, value))
+        lines.append('港口')
+        if not safe_ports:
+            lines.append('- 无')
+        for port in safe_ports:
+            lines.append(
+                '- {0} {1} 预计到达 {2} 实际到达 {3}'.format(
+                    port['地点类型'], port['港口'], port['预计到达'], port['实际到达'],
+                )
+            )
+        lines.append('轨迹')
+        if not safe_tracks:
+            lines.append('- 无')
+        for track in safe_tracks:
+            lines.append(
+                '- {0} {1} {2} {3}'.format(
+                    track['时间'], track['地点'], track['内容'], track['状态代码'],
+                )
+            )
+        return self._success_result(content, '\n'.join(lines), meta)
+
     @staticmethod
     def _success_result(content, text, meta):
         return {

+ 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(34, len(local))
-        self.assertEqual(33, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(35, len(local))
+        self.assertEqual(34, 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(34, len(local.registered_tool_names()))
+        self.assertEqual(35, 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(34, len(local.registered_tool_names()))
+        self.assertEqual(35, 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(34, len(local))
+        self.assertEqual(35, 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(34, len(local.registered_tool_names()))
+        self.assertEqual(35, 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(34, len(local))
-        self.assertEqual(33, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(35, len(local))
+        self.assertEqual(34, 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(34, len(local))
+        self.assertEqual(35, len(local))
         self.assertIn('export_pallet_data', local)
-        self.assertEqual(33, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(34, 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(34, len(local))
+        self.assertEqual(35, len(local))
         self.assertIn('export_receivable_cost_list', local)
-        self.assertEqual(33, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(34, 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(34, len(local))
-        self.assertEqual(33, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(35, len(local))
+        self.assertEqual(34, 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(34, len(local.registered_tool_names()))
+        self.assertEqual(35, 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(33, len(self.presenter.SAFE_TOOLS))
+        self.assertEqual(34, 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(34, len(local))
-        self.assertEqual(33, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(35, len(local))
+        self.assertEqual(34, 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(34, len(local))
+        self.assertEqual(35, len(local))
         self.assertIn('query_export_task', local)
 
     def test_cli_forwards_only_task_reference(self):

+ 194 - 0
tests/test_query_freightower_track_tool.py

@@ -0,0 +1,194 @@
+import io
+import unittest
+from unittest.mock import patch
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.query_freightower_track import QueryFreightowerTrackTool
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.call = None
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.call = (tool_code, route_path, payload, request_id)
+        return {'code': 'MCP_0000', 'data': {'ok': True}}
+
+
+def sample_data():
+    return {
+        'schedule': {
+            'bl_number': 'BL1',
+            'container_code': 'BOX1',
+            'vessel': 'VESSEL',
+            'voyage': '001E',
+            'etd': '2026-09-01 00:00:00',
+            'eta': '2026-09-20 00:00:00',
+        },
+        'ports': [{
+            'place_type': '目的港',
+            'port_name': '洛杉矶',
+            'eta': '2026-09-20',
+            'ata': '',
+        }],
+        'tracks': [{
+            'time': '2026-09-20 01:00:00',
+            'location': 'LOS ANGELES',
+            'content': '抵港',
+            'event_code': 'BDAR',
+        }],
+    }
+
+
+class QueryFreightowerTrackToolTest(unittest.TestCase):
+    def test_metadata_requires_explicit_number_type(self):
+        metadata = QueryFreightowerTrackTool().metadata()
+        description = metadata['description']
+        schema = metadata['input_schema']
+
+        self.assertEqual('query_freightower_track', metadata['name'])
+        self.assertNotIn('required', schema)
+        self.assertFalse(schema['additionalProperties'])
+        self.assertIn('使用场景:', description)
+        self.assertIn('禁止使用:', description)
+        self.assertIn('号码类型不明确时必须先询问用户', description)
+        self.assertIn('不得根据号码格式猜测', description)
+        self.assertIn('不得跨字段或跨工具试查', description)
+        self.assertIn('query_track', description)
+        self.assertIn('仅当用户明确', schema['properties']['bl_number']['description'])
+        self.assertIn('不得放入其他类型号码', schema['properties']['container_code']['description'])
+
+    def test_call_forwards_only_provided_numbers(self):
+        api = RecordingApiClient()
+        tool = QueryFreightowerTrackTool(api_client=api)
+
+        tool.call(bl_number=' BL1 ', container_code=' BOX1 ', request_id='rq_1')
+
+        self.assertEqual((
+            'query_freightower_track',
+            '/mcp/tools/queryFreightowerTrack',
+            {'bl_number': 'BL1', 'container_code': 'BOX1'},
+            'rq_1',
+        ), api.call)
+
+    def test_call_accepts_one_number(self):
+        api = RecordingApiClient()
+        QueryFreightowerTrackTool(api_client=api).call(container_code='BOX1')
+        self.assertEqual({'container_code': 'BOX1'}, api.call[2])
+
+    def test_invalid_numbers_are_rejected(self):
+        tool = QueryFreightowerTrackTool(api_client=RecordingApiClient())
+        with self.assertRaises(ValueError):
+            tool.call()
+        with self.assertRaises(ValueError):
+            tool.call(bl_number=123)
+        with self.assertRaises(ValueError):
+            tool.call(container_code='B' * 101)
+
+    def test_api_client_is_required(self):
+        with self.assertRaises(RuntimeError):
+            QueryFreightowerTrackTool().call(bl_number='BL1')
+
+    def test_registries_include_the_tool(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertIn('query_freightower_track', local)
+        self.assertEqual(35, len(local))
+        self.assertEqual(34, len(OutputPresenter.SAFE_TOOLS))
+
+    def test_cli_forwards_bill_and_container(self):
+        app = GatewayApp()
+        stdout = io.StringIO()
+        with patch.object(app, 'call_tool', return_value={'code': 'MCP_0000'}) as call_tool:
+            code = app.run_cli([
+                'call',
+                '--tool', 'query_freightower_track',
+                '--bl-number', 'BL1',
+                '--container-code', 'BOX1',
+            ], stdout=stdout)
+        self.assertEqual(0, code)
+        self.assertEqual('query_freightower_track', call_tool.call_args[0][0])
+        self.assertEqual(
+            {'bl_number': 'BL1', 'container_code': 'BOX1'},
+            call_tool.call_args[0][1],
+        )
+
+    def test_cli_requires_a_number(self):
+        app = GatewayApp()
+        with self.assertRaises(ValueError):
+            app.run_cli([
+                'call',
+                '--tool', 'query_freightower_track',
+            ], stdout=io.StringIO())
+
+    def test_presenter_renders_schedule_ports_and_arrival(self):
+        result = OutputPresenter().present('query_freightower_track', {
+            'code': 'MCP_0000',
+            'data': sample_data(),
+            'meta': {'request_id': 'rq_ft'},
+        })
+        self.assertFalse(result['is_error'])
+        self.assertEqual('洛杉矶', result['structured_content']['港口'][0]['港口'])
+        self.assertEqual('BDAR', result['structured_content']['轨迹'][0]['状态代码'])
+        self.assertIn('抵港', result['text'])
+        self.assertNotIn('bl_number', result['text'])
+        self.assertEqual({'request_id': 'rq_ft'}, result['meta'])
+
+    def test_presenter_accepts_empty_ports_and_tracks(self):
+        data = sample_data()
+        data['ports'] = []
+        data['tracks'] = []
+        result = OutputPresenter().present('query_freightower_track', {
+            'code': 'MCP_0000',
+            'data': data,
+        })
+        self.assertFalse(result['is_error'])
+        self.assertIn('港口\n- 无', result['text'])
+        self.assertIn('轨迹\n- 无', result['text'])
+
+    def test_presenter_rejects_unknown_shape(self):
+        cases = []
+        extra = sample_data()
+        extra['schedule']['secret'] = 'x'
+        cases.append(extra)
+        bad_type = sample_data()
+        bad_type['ports'][0]['place_type'] = '起运港'
+        cases.append(bad_type)
+        bad_value = sample_data()
+        bad_value['tracks'][0]['time'] = 1
+        cases.append(bad_value)
+        cases.append({'schedule': {}})
+        for data in cases:
+            with self.subTest(data=data):
+                result = OutputPresenter().present('query_freightower_track', {
+                    'code': 'MCP_0000',
+                    'data': data,
+                })
+                self.assertTrue(result['is_error'])
+                self.assertNotIn('secret', result['text'])
+
+    def test_presenter_keeps_allowlisted_business_message(self):
+        result = OutputPresenter().present('query_freightower_track', {
+            'code': 'MCP_1501',
+            'msg': '暂时没有飞驼轨迹',
+            'data': [],
+        })
+        self.assertTrue(result['is_error'])
+        self.assertIn('暂时没有飞驼轨迹', result['text'])
+
+    def test_presenter_hides_unknown_upstream_message(self):
+        result = OutputPresenter().present('query_freightower_track', {
+            'code': 'MCP_1501',
+            'msg': 'carrier token leaked',
+            'data': [],
+        })
+        self.assertTrue(result['is_error'])
+        self.assertNotIn('leaked', result['text'])
+        self.assertIn('目标数据不可用', result['text'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 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(34, len(local))
-        self.assertEqual(33, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(35, len(local))
+        self.assertEqual(34, 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(34, len(local))
-        self.assertEqual(33, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(35, len(local))
+        self.assertEqual(34, len(OutputPresenter.SAFE_TOOLS))
         self.assertIn('query_receive_volume_list', local)
         self.assertIn('list_receive_volume_filter_options', local)

+ 6 - 0
tests/test_tool_description_boundaries.py

@@ -28,6 +28,7 @@ from tools.list_receive_volume_filter_options import (
     ListReceiveVolumeFilterOptionsTool,
 )
 from tools.query_container_timeliness_list import QueryContainerTimelinessListTool
+from tools.query_freightower_track import QueryFreightowerTrackTool
 from tools.export_container_timeliness_report import (
     ExportContainerTimelinessReportTool,
 )
@@ -56,6 +57,7 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             ListReceiveVolumeFilterOptionsTool(),
             QueryContainerTimelinessListTool(),
             ExportContainerTimelinessReportTool(),
+            QueryFreightowerTrackTool(),
         )
 
         for tool in tools:
@@ -78,6 +80,7 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             QueryOrderAbnormalListTool(),
             QueryContainerTimelinessListTool(),
             ExportContainerTimelinessReportTool(),
+            QueryFreightowerTrackTool(),
         )
 
         for tool in tools:
@@ -127,6 +130,9 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             ExportContainerTimelinessReportTool(): (
                 '明确要求导出', '柜子时效', 'query_export_task',
             ),
+            QueryFreightowerTrackTool(): (
+                '飞驼', 'query_track', '提单号',
+            ),
         }
 
         for tool, phrases in expected.items():

+ 77 - 0
tools/query_freightower_track.py

@@ -0,0 +1,77 @@
+class QueryFreightowerTrackTool:
+    name = 'query_freightower_track'
+    route_path = '/mcp/tools/queryFreightowerTrack'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        number = {
+            'type': 'string',
+            'minLength': 1,
+            'maxLength': 100,
+        }
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户明确要查看飞驼现场船期和集装箱节点时使用,'
+                '返回船名、航次、预计离港、预计到达、中转港/目的港/交货地,'
+                '以及已经发生的轨迹节点。提单号、柜号至少提供一个;'
+                '号码类型不明确时必须先询问用户是提单号还是柜号,确认前不得调用。'
+                '不得根据号码格式猜测,不得跨字段或跨工具试查。'
+                '禁止使用:系统里已经保存的轨迹用 query_track;'
+                '本工具不导出文件,不修改干线到达或提柜状态。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'bl_number': dict(number, description=(
+                        '提单号。仅当用户明确说明是提单号时使用,'
+                        '不得放入其他类型号码。'
+                    )),
+                    'container_code': dict(number, description=(
+                        '柜号。仅当用户明确说明是柜号时使用,'
+                        '不得放入其他类型号码。'
+                    )),
+                },
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        bl_number='',
+        container_code='',
+        request_id='rq_query_freightower_track',
+        **_ignored,
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for query_freightower_track'
+            )
+        payload = {}
+        bl_number = self._number(bl_number, 'bl_number')
+        container_code = self._number(container_code, 'container_code')
+        if bl_number:
+            payload['bl_number'] = bl_number
+        if container_code:
+            payload['container_code'] = container_code
+        if not payload:
+            raise ValueError('bl_number or container_code is required')
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            payload,
+            request_id,
+        )
+
+    @staticmethod
+    def _number(value, field):
+        if value in (None, ''):
+            return ''
+        if not isinstance(value, str):
+            raise ValueError(field + ' must be a string')
+        value = value.strip()
+        if len(value) > 100:
+            raise ValueError(field + ' must contain at most 100 characters')
+        return value