Просмотр исходного кода

mcp导出接口修改成异步

jackson дней назад: 3
Родитель
Сommit
4c175a62b2

+ 17 - 10
README.md

@@ -17,7 +17,7 @@ Gateway 保持“薄网关”边界:
 - 支持 Redis token/session 存储。
 - 支持文件 token store 作为开发排障兜底。
 - 不再支持授权码绑定工具;正式接入只使用后台生成的 `GWS_xxx` 设备配置。
-- 本地 stdio 与公网 HTTP 注册同一组 12 个查询、筛选和导出工具。
+- 本地 stdio 与公网 HTTP 注册同一组 13 个查询、筛选和导出工具。
 - 支持订单、订单详情、轨迹、报关资料、排舱列表与详情查询。
 - 支持订单与排舱筛选项,以及未排舱订单和省外进港资料导出。
 - 支持 MCP `initialize`、`tools/list`、`tools/call`。
@@ -25,7 +25,7 @@ Gateway 保持“薄网关”边界:
 
 ## 工具目录
 
-`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 12 个候选工具:
+`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 13 个候选工具:
 
 | MCP 工具 | 用途 | ThinkPHP 路由 | 最终展示 |
 |---|---|---|---|
@@ -38,11 +38,18 @@ Gateway 保持“薄网关”边界:
 | `query_outbound_detail` | 按排舱单号查询排舱汇总与订单明细 | `/mcp/tools/queryOutboundDetail` | 安全详情 |
 | `list_outbound_filter_options` | 查询七类排舱筛选项 | `/mcp/tools/listOutboundFilterOptions` | 安全筛选项 |
 | `list_order_filter_options` | 查询精准订单筛选项 | `/mcp/tools/listOrderFilterOptions` | 安全筛选项 |
-| `export_pending_outbound_orders` | 导出未排舱订单 | `/mcp/tools/exportPendingOutboundOrders` | 安全文件链接 |
-| `export_out_of_province_port_data` | 导出省外进港资料 | `/mcp/tools/exportOutOfProvincePortData` | 安全文件链接 |
+| `export_pending_outbound_orders` | 提交未排舱订单异步导出 | `/mcp/tools/exportPendingOutboundOrders` | 签名任务引用 |
+| `export_out_of_province_port_data` | 提交省外进港资料异步导出 | `/mcp/tools/exportOutOfProvincePortData` | 签名任务引用 |
+| `query_export_task` | 查询异步导出状态或文件 | `/mcp/tools/queryExportTask` | 安全任务状态/文件链接 |
 | `list_pending_outbound_export_filter_options` | 查询未排舱导出筛选项 | `/mcp/tools/listPendingOutboundExportFilterOptions` | 安全筛选项 |
 
-这 12 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
+**异步导出流程:**
+
+1. 调用 `export_pending_outbound_orders` 或 `export_out_of_province_port_data` 提交任务,返回 `task_ref`
+2. 等待建议时间(`retry_after_seconds`)后,使用 `query_export_task` 和 `task_ref` 查询状态
+3. 任务完成后从 `query_export_task` 响应获取下载链接(`files[].url`)
+
+这 13 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是”Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
 
 MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明或注册集合变化后,必须重启对应 Gateway 进程并让客户端重新连接,客户端才会重新获取工具列表。
 
@@ -51,12 +58,12 @@ MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明
 Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内部接口和工具入参:
 
 - `query_order` 保持原有 `columns + records` 结果和文本展示,不参与本次转换。
-- `services/output_presenter.py` 对其余 11 个安全工具执行显式白名单展示。
+- `services/output_presenter.py` 对其余 12 个安全工具执行显式白名单展示。
 - `query_order_exact`、`query_track`、`query_customs_declaration_files`、`query_outbound_list` 对外使用中文 `headers + rows + pagination`,不返回内部字段键。
 - `query_outbound_detail` 使用中文 `summary + details + pagination` 两层结构。
 - `query_order_detail` 按中文详情模块返回固定分组或明细;传入“全部”时一次返回概览和十个明细模块,各明细模块独立分页。附件保留文件名、预览与下载链接;后端机器字段和内部 ID 不进入最终展示。
 - 三个筛选项工具保留“可传值、显示名称、业务编码”,确保返回值可继续传给查询或导出工具。
-- 两个导出工具返回 `files[].label + files[].url`,不暴露后端 `file_url` 键
+- 两个导出工具只返回 `task_ref + queued + retry_after_seconds`。客户端稍后在新的调用中使用 `query_export_task`;完成后才返回 `files[].label + files[].url`。Gateway 不在一次调用内等待或循环轮询
 - `request_id` 位于 MCP 结果 `_meta`;参数错误使用业务名称,未知异常不透传后端细节。
 - stdio 与公网 `tools/call` 缺少有效工具名时返回 JSON-RPC `-32602 Invalid params`,不包装为业务 `isError`。
 - stdio 与 public 模式共用 `services/output_presenter.py`;未知工具或畸形响应关闭失败。
@@ -250,7 +257,7 @@ Invoke-WebRequest http://127.0.0.1:8765/health -UseBasicParsing
 
 ### 部署状态核对
 
-用户已确认 `Y:\fmsoperate\sql` 原有 10 个 MCP SQL 和 `Y:\base\sql` 中 5 个 MCP SQL 均已执行。订单详情的注册 SQL 仍待目标环境审核与执行,首次注册默认关闭;实际可见工具始终以 fmsoperate 动态注册表当前启用值为准。
+用户已确认 `Y:\fmsoperate\sql` 原有 10 个 MCP SQL 和 `Y:\base\sql` 中 5 个 MCP SQL 均已执行。订单详情与异步导出任务查询的注册 SQL 仍待目标环境审核与执行,首次注册默认关闭;实际可见工具始终以 fmsoperate 动态注册表当前启用值为准。
 
 仓库静态内容无法证明运行中的 Gateway 是否已加载当前代码,也无法证明 Workbuddy 是否已重新连接。发布工具或 Schema 变更后,运维交接必须分别确认:
 
@@ -438,7 +445,7 @@ Gateway 会调用以下路径:
 - 动态工具列表:`/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/listOutboundFilterOptions`、`/mcp/tools/listOrderFilterOptions`、`/mcp/tools/listPendingOutboundExportFilterOptions`。
-- 导出:`/mcp/tools/exportPendingOutboundOrders`、`/mcp/tools/exportOutOfProvincePortData`。
+- 导出:`/mcp/tools/exportPendingOutboundOrders`、`/mcp/tools/exportOutOfProvincePortData`、`/mcp/tools/queryExportTask`
 
 `FMS_AUTH_BASE` 和 `FMS_TOOLS_BASE` 只配置域名或基础地址,不要包含 `/admin/mcp`。
 
@@ -481,6 +488,6 @@ Windows 下 Python 标准输出可能使用本机控制台编码。当前 `mcp_p
 - Gateway 不是业务系统,不直接查 MySQL。
 - Gateway 不替代 ThinkPHP 权限体系。
 - 公网 Gateway 是否能生产放量,取决于 Workbuddy 远程 MCP 是否能稳定传递 `gateway_session_id`。
-- fmsoperate 原有 SQL 和 base SQL 已确认执行;订单详情注册 SQL 尚待执行,工具当前动态启用值仍必须从运行环境核验。
+- fmsoperate 原有 SQL 和 base SQL 已确认执行;订单详情与异步导出任务查询注册 SQL 尚待执行,工具当前动态启用值仍必须从运行环境核验。
 - 仓库无法证明运行中 Gateway 已重启或客户端已重连,发布交接必须显式确认这两项。
 - 写入型 MCP 工具需要单独安全评审后再开放。

+ 9 - 0
app.py

@@ -32,6 +32,7 @@ from tools.query_customs_declaration_files import (
 )
 from tools.query_order_exact import QueryOrderExactTool
 from tools.query_order_detail import QueryOrderDetailTool
+from tools.query_export_task import QueryExportTaskTool
 from tools.query_outbound_detail import QueryOutboundDetailTool
 from tools.query_outbound_list import QueryOutboundListTool
 from tools.query_track import QueryTrackTool
@@ -84,6 +85,7 @@ class GatewayApp:
             ),
             'export_out_of_province_port_data':
                 ExportOutOfProvincePortDataTool(api_client=api_client),
+            'query_export_task': QueryExportTaskTool(api_client=api_client),
             'list_pending_outbound_export_filter_options':
                 ListPendingOutboundExportFilterOptionsTool(api_client=api_client),
         }
@@ -255,6 +257,7 @@ class GatewayApp:
         call_parser.add_argument('--outbound-date-start', default='')
         call_parser.add_argument('--outbound-date-end', default='')
         call_parser.add_argument('--filter-type', default='')
+        call_parser.add_argument('--task-ref', default='')
         call_parser.add_argument('--page', type=int, default=1)
         call_parser.add_argument('--limit', type=int, default=20)
         call_parser.add_argument('--request-id', default='')
@@ -426,6 +429,12 @@ class GatewayApp:
                         'query_outbound_detail'
                     )
                 tool_args['outbound_number'] = args.outbound_number
+            elif args.tool == 'query_export_task':
+                if not args.task_ref:
+                    raise ValueError(
+                        '--task-ref is required for query_export_task'
+                    )
+                tool_args = {'task_ref': args.task_ref}
             elif args.tool in (
                 'list_order_filter_options', 'list_outbound_filter_options'
             ):

+ 2 - 0
public_gateway.py

@@ -18,6 +18,7 @@ from tools.query_customs_declaration_files import (
 )
 from tools.query_order_exact import QueryOrderExactTool
 from tools.query_order_detail import QueryOrderDetailTool
+from tools.query_export_task import QueryExportTaskTool
 from tools.query_outbound_detail import QueryOutboundDetailTool
 from tools.query_outbound_list import QueryOutboundListTool
 from tools.query_track import QueryTrackTool
@@ -51,6 +52,7 @@ class PublicGatewayApp:
             ),
             'export_out_of_province_port_data':
                 ExportOutOfProvincePortDataTool(api_client=None),
+            'query_export_task': QueryExportTaskTool(api_client=None),
             'list_pending_outbound_export_filter_options':
                 ListPendingOutboundExportFilterOptionsTool(api_client=None),
         }

+ 118 - 8
services/output_presenter.py

@@ -1,5 +1,6 @@
 import json
 import logging
+from urllib.parse import urlparse
 
 from constants import DEVICE_INVALID_MESSAGE
 
@@ -26,9 +27,10 @@ class OutputPresenter:
         'export_pending_outbound_orders',
         'export_out_of_province_port_data',
     ))
+    TASK_TOOLS = frozenset(('query_export_task',))
     SAFE_TOOLS = (
         TABLE_TOOLS | DETAIL_TOOLS | ORDER_DETAIL_TOOLS
-        | OPTION_TOOLS | EXPORT_TOOLS
+        | OPTION_TOOLS | EXPORT_TOOLS | TASK_TOOLS
     )
 
     ORDER_DETAIL_SECTIONS = {
@@ -417,6 +419,9 @@ class OutputPresenter:
             'so_numbers': 'SO号',
             'file_type': '资料类型',
         },
+        'query_export_task': {
+            'task_ref': '导出任务引用',
+        },
     }
 
     ERROR_MESSAGES = {
@@ -487,7 +492,9 @@ class OutputPresenter:
             )
         if tool_name in self.OPTION_TOOLS:
             return self._present_options(data, tool_result.get('meta'), meta)
-        return self._present_export(data, meta)
+        if tool_name in self.EXPORT_TOOLS:
+            return self._present_export_submission(data, meta)
+        return self._present_export_task(data, meta)
 
     def _present_order_detail(self, data, raw_meta, meta):
         if set(data) != {'section', 'order_number', 'payload'}:
@@ -910,17 +917,120 @@ class OutputPresenter:
             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():
+    def _present_export_submission(self, data, meta):
+        expected = {'task_ref', 'status', 'retry_after_seconds'}
+        if set(data) != expected:
+            return self._format_error(meta)
+        task_ref = data.get('task_ref')
+        retry_after = data.get('retry_after_seconds')
+        if (
+            not isinstance(task_ref, str) or not task_ref.strip()
+            or data.get('status') != 'queued'
+            or isinstance(retry_after, bool)
+            or not isinstance(retry_after, int)
+            or retry_after <= 0
+        ):
             return self._format_error(meta)
+        task = {
+            'task_ref': task_ref.strip(),
+            'status': 'queued',
+            'retry_after_seconds': retry_after,
+        }
         content = {
-            'message': '文件已生成',
-            'files': [{'label': '导出文件', 'url': url.strip()}],
+            'message': '导出任务已提交',
+            'task': task,
         }
-        text = '文件已生成\n- 导出文件: {0}'.format(url.strip())
+        text = (
+            '导出任务已提交\n'
+            '- 任务引用: {0}\n'
+            '- 建议 {1} 秒后单独查询任务状态'
+        ).format(task['task_ref'], retry_after)
         return self._success_result(content, text, meta)
 
+    def _present_export_task(self, data, meta):
+        task_ref = data.get('task_ref')
+        status = data.get('status')
+        if (
+            not isinstance(task_ref, str)
+            or not task_ref.strip()
+            or status not in ('queued', 'running', 'completed', 'failed')
+        ):
+            return self._format_error(meta)
+
+        task = {'task_ref': task_ref.strip(), 'status': status}
+        if status in ('queued', 'running'):
+            if set(data) != {'task_ref', 'status', 'retry_after_seconds'}:
+                return self._format_error(meta)
+            retry_after = data.get('retry_after_seconds')
+            if (
+                isinstance(retry_after, bool)
+                or not isinstance(retry_after, int)
+                or retry_after <= 0
+            ):
+                return self._format_error(meta)
+            task['retry_after_seconds'] = retry_after
+            label = '等待中' if status == 'queued' else '生成中'
+            content = {'message': '导出任务{0}'.format(label), 'task': task}
+            text = (
+                '导出任务{0}\n- 任务引用: {1}\n- 建议 {2} 秒后再次查询'
+            ).format(label, task['task_ref'], retry_after)
+            return self._success_result(content, text, meta)
+
+        if status == 'failed':
+            if set(data) != {'task_ref', 'status'}:
+                return self._format_error(meta)
+            content = {'message': '导出任务失败,请重新提交', 'task': task}
+            text = '导出任务失败,请重新提交'
+            return self._success_result(content, text, meta)
+
+        if set(data) != {'task_ref', 'status', 'files'}:
+            return self._format_error(meta)
+        files = data.get('files')
+        if not isinstance(files, list) or not files:
+            return self._format_error(meta)
+        safe_files = []
+        for item in files:
+            if not isinstance(item, dict) or set(item) != {'label', 'url'}:
+                return self._format_error(meta)
+            label = item.get('label')
+            url = item.get('url')
+            if (
+                not isinstance(label, str) or not label.strip()
+                or not self._valid_http_url(url)
+            ):
+                return self._format_error(meta)
+            safe_files.append({'label': label.strip(), 'url': url.strip()})
+        content = {
+            'message': '文件已生成',
+            'task': task,
+            'files': safe_files,
+        }
+        lines = ['文件已生成']
+        for item in safe_files:
+            lines.append('- {0}: {1}'.format(item['label'], item['url']))
+        return self._success_result(content, '\n'.join(lines), meta)
+
+    def _valid_http_url(self, value):
+        if not isinstance(value, str) or not value.strip():
+            return False
+        if value != value.strip() or '\\' in value or any(
+            character.isspace() or ord(character) < 32 or ord(character) == 127
+            for character in value
+        ):
+            return False
+        try:
+            parsed = urlparse(value)
+            port = parsed.port
+            return (
+                parsed.scheme in ('http', 'https')
+                and bool(parsed.hostname)
+                and parsed.username is None
+                and parsed.password is None
+                and (port is None or 0 < port <= 65535)
+            )
+        except ValueError:
+            return False
+
     def _business_error(self, tool_name, code, raw_message, meta):
         if code not in self.ERROR_MESSAGES and code != 'MCP_1401':
             logger.warning(

+ 180 - 9
tests/test_output_presenter.py

@@ -248,7 +248,7 @@ class OutputPresenterTest(unittest.TestCase):
 
         self.assertEqual([['US', '美国', 'US']], result['structured_content']['rows'])
 
-    def test_export_result_preserves_url_without_file_url_key(self):
+    def test_export_submission_returns_only_safe_queued_task(self):
         for tool_name in (
             'export_pending_outbound_orders',
             'export_out_of_province_port_data',
@@ -258,20 +258,165 @@ class OutputPresenterTest(unittest.TestCase):
                     tool_name,
                     {
                         'code': 'MCP_0000',
-                        'data': {'file_url': 'https://files.test/result.xlsx'},
+                        'data': {
+                            'task_ref': 'mexp_abc',
+                            'status': 'queued',
+                            'retry_after_seconds': 10,
+                        },
                         'meta': {'request_id': 'rq_export'},
                     },
                 )
 
                 self.assertEqual(
-                    [{
-                        'label': '导出文件',
-                        'url': 'https://files.test/result.xlsx',
-                    }],
-                    result['structured_content']['files'],
+                    {
+                        'task_ref': 'mexp_abc',
+                        'status': 'queued',
+                        'retry_after_seconds': 10,
+                    },
+                    result['structured_content']['task'],
                 )
                 self.assertNotIn('file_url', json.dumps(result, ensure_ascii=False))
 
+    def test_export_task_query_presents_all_known_states(self):
+        queued = self.presenter.present(
+            'query_export_task',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'task_ref': 'mexp_abc',
+                    'status': 'queued',
+                    'retry_after_seconds': 10,
+                },
+            },
+        )
+        running = self.presenter.present(
+            'query_export_task',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'task_ref': 'mexp_abc',
+                    'status': 'running',
+                    'retry_after_seconds': 10,
+                },
+            },
+        )
+        completed = self.presenter.present(
+            'query_export_task',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'task_ref': 'mexp_abc',
+                    'status': 'completed',
+                    'files': [{
+                        'label': 'port.zip',
+                        'url': 'https://files.test/port.zip',
+                    }],
+                },
+            },
+        )
+        failed = self.presenter.present(
+            'query_export_task',
+            {
+                'code': 'MCP_0000',
+                'data': {'task_ref': 'mexp_abc', 'status': 'failed'},
+            },
+        )
+
+        self.assertEqual('queued', queued['structured_content']['task']['status'])
+        self.assertEqual('running', running['structured_content']['task']['status'])
+        self.assertEqual(
+            'https://files.test/port.zip',
+            completed['structured_content']['files'][0]['url'],
+        )
+        self.assertEqual('failed', failed['structured_content']['task']['status'])
+
+    def test_export_task_unknown_or_malformed_states_fail_closed(self):
+        cases = (
+            {
+                'task_ref': 123,
+                'status': 'queued',
+                'retry_after_seconds': 10,
+            },
+            {'task_ref': 'mexp_abc', 'status': 'unknown'},
+            {'task_ref': 'mexp_abc', 'status': 'queued'},
+            {
+                'task_ref': 'mexp_abc',
+                'status': 'queued',
+                'retry_after_seconds': 0,
+            },
+            {
+                'task_ref': 'mexp_abc',
+                'status': 'running',
+                'retry_after_seconds': 10,
+                'files': [],
+            },
+            {
+                'task_ref': 'mexp_abc',
+                'status': 'completed',
+                'files': None,
+            },
+            {
+                'task_ref': 'mexp_abc',
+                'status': 'completed',
+                'files': ['bad'],
+            },
+            {
+                'task_ref': 'mexp_abc',
+                'status': 'completed',
+                'files': [{'label': 'bad', 'url': None}],
+            },
+            {
+                'task_ref': 'mexp_abc',
+                'status': 'completed',
+                'files': [{'label': 'bad', 'url': 'javascript:alert(1)'}],
+            },
+            {
+                'task_ref': 'mexp_abc',
+                'status': 'completed',
+                'files': [],
+                'extra': True,
+            },
+            {
+                'task_ref': 'mexp_abc',
+                'status': 'failed',
+                'remark': 'secret',
+            },
+        )
+
+        for data in cases:
+            with self.subTest(data=data):
+                result = self.presenter.present(
+                    'query_export_task',
+                    {'code': 'MCP_0000', 'data': data},
+                )
+                self.assertTrue(result['is_error'])
+
+    def test_export_task_rejects_unsafe_download_urls(self):
+        urls = (
+            'https://user:pass@files.test/a.xlsx',
+            'https://files.test/a file.xlsx',
+            'https://files.test/a\x00.xlsx',
+            'http:foo',
+            'ftp://files.test/a.xlsx',
+            'https://files.test:bad/a.xlsx',
+            'https://files.test:99999/a.xlsx',
+            'https://files.test\\evil/a.xlsx',
+        )
+        for url in urls:
+            with self.subTest(url=repr(url)):
+                result = self.presenter.present(
+                    'query_export_task',
+                    {
+                        'code': 'MCP_0000',
+                        'data': {
+                            'task_ref': 'mexp_abc',
+                            'status': 'completed',
+                            'files': [{'label': 'a.xlsx', 'url': url}],
+                        },
+                    },
+                )
+                self.assertTrue(result['is_error'])
+
     def test_known_parameter_error_uses_business_label(self):
         result = self.presenter.present(
             'query_track',
@@ -464,11 +609,37 @@ class OutputPresenterTest(unittest.TestCase):
             ),
             self.presenter.present(
                 'export_pending_outbound_orders',
-                {'code': 'MCP_0000', 'data': {'file_url': None}},
+                {
+                    'code': 'MCP_0000',
+                    'data': {
+                        'task_ref': '',
+                        'status': 'queued',
+                        'retry_after_seconds': 10,
+                    },
+                },
             ),
             self.presenter.present(
                 'export_pending_outbound_orders',
-                {'code': 'MCP_0000', 'data': {'file_url': '  '}},
+                {
+                    'code': 'MCP_0000',
+                    'data': {
+                        'task_ref': 'mexp_abc',
+                        'status': 'queued',
+                        'retry_after_seconds': 10,
+                        'extra': True,
+                    },
+                },
+            ),
+            self.presenter.present(
+                'export_pending_outbound_orders',
+                {
+                    'code': 'MCP_0000',
+                    'data': {
+                        'task_ref': 'mexp_abc',
+                        'status': 'queued',
+                        'retry_after_seconds': 0,
+                    },
+                },
             ),
         )
 

+ 100 - 0
tests/test_query_export_task_tool.py

@@ -0,0 +1,100 @@
+import io
+import unittest
+from unittest.mock import patch
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from tools.query_export_task import QueryExportTaskTool
+
+
+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': {'task_ref': payload['task_ref'], 'status': 'queued'},
+        }
+
+
+class QueryExportTaskToolTest(unittest.TestCase):
+    def test_metadata_exposes_only_bounded_task_reference(self):
+        metadata = QueryExportTaskTool().metadata()
+        schema = metadata['input_schema']
+
+        self.assertEqual('query_export_task', metadata['name'])
+        self.assertEqual(['task_ref'], schema['required'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual('string', schema['properties']['task_ref']['type'])
+        self.assertEqual(1, schema['properties']['task_ref']['minLength'])
+        self.assertEqual(512, schema['properties']['task_ref']['maxLength'])
+        self.assertIn('单独', metadata['description'])
+        self.assertIn('不得在同一次调用内等待', metadata['description'])
+
+    def test_call_trims_and_forwards_reference(self):
+        api = RecordingApiClient()
+        tool = QueryExportTaskTool(api_client=api)
+
+        result = tool.call(task_ref=' mexp_abc ', request_id='rq_1')
+
+        self.assertEqual('queued', result['data']['status'])
+        self.assertEqual((
+            'query_export_task',
+            '/mcp/tools/queryExportTask',
+            {'task_ref': 'mexp_abc'},
+            'rq_1',
+        ), api.call)
+
+    def test_invalid_references_are_rejected(self):
+        tool = QueryExportTaskTool(api_client=RecordingApiClient())
+        for value in (None, '', '   ', 123, 'a' * 513):
+            with self.subTest(value=value):
+                with self.assertRaises(ValueError):
+                    tool.call(task_ref=value)
+
+    def test_api_client_is_required(self):
+        with self.assertRaises(RuntimeError):
+            QueryExportTaskTool().call(task_ref='mexp_abc')
+
+    def test_local_and_public_registries_contain_same_thirteen_tools(self):
+        local = GatewayApp(api_client=RecordingApiClient()).registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+
+        self.assertEqual(local, public)
+        self.assertEqual(13, len(local))
+        self.assertIn('query_export_task', local)
+
+    def test_cli_forwards_only_task_reference(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_export_task',
+                '--task-ref', 'mexp_abc',
+                '--request-id', 'rq_cli',
+            ], stdout=stdout)
+
+        self.assertEqual(0, code)
+        call_tool.assert_called_once_with(
+            'query_export_task',
+            {'task_ref': 'mexp_abc'},
+            request_id='rq_cli',
+        )
+
+    def test_cli_requires_task_reference(self):
+        with self.assertRaises(ValueError):
+            GatewayApp().run_cli([
+                'call',
+                '--tool', 'query_export_task',
+            ], stdout=io.StringIO())
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 11 - 0
tests/test_tool_description_boundaries.py

@@ -94,6 +94,17 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
                 self.assertIn('仅当用户明确', description)
                 self.assertIn('不得放入其他类型号码', description)
 
+    def test_export_tools_describe_async_follow_up_without_long_wait(self):
+        for tool in (
+            ExportPendingOutboundOrdersTool(),
+            ExportOutOfProvincePortDataTool(),
+        ):
+            with self.subTest(tool=tool.name):
+                description = tool.metadata()['description']
+                self.assertIn('异步导出任务', description)
+                self.assertIn('query_export_task', description)
+                self.assertIn('不会在本次调用中等待文件生成', description)
+
 
 if __name__ == '__main__':
     unittest.main()

+ 6 - 2
tools/export_out_of_province_port_data.py

@@ -14,12 +14,16 @@ class ExportOutOfProvincePortDataTool:
         return {
             'name': self.name,
             'description': (
+                '本工具只提交异步导出任务,不会在本次调用中等待文件生成。'
+                '成功后返回任务引用(task_ref)和'
+                '建议等待时间(retry_after_seconds)。稍后使用 query_export_task 单独查询'
+                '任务状态或下载链接。'
                 '使用场景:只有用户明确要求导出后台排舱单列表中的省外进港资料并需要'
-                '下载文件时使用,返回可下载文件URL。'
+                '下载文件时使用。'
                 '只能在用户明确要求导出省外进港资料,并明确提供排舱单号、'
                 '柜号、提单号或SO号中的一种,以及宁波、上海或美森资料类型时调用。'
                 '号码类型不明确时必须先询问用户;用户没有明确号码类型时必须先询问:'
-                '请确认使用哪种单号导出:排舱单号、柜号、提单号还是SO号?”'
+                '请确认使用哪种单号导出:排舱单号、柜号、提单号还是SO号?”'
                 '确认前不得调用。禁止使用:普通排舱查询、排舱详情、订单查询或普通文件'
                 '查询不得调用本工具。不得根据号码格式猜测,不得跨字段或跨工具试查,'
                 '也不得在查询失败后切换号码类型重试。一次只能选择一种号码类型。'

+ 5 - 1
tools/export_pending_outbound_orders.py

@@ -64,11 +64,15 @@ class ExportPendingOutboundOrdersTool:
         return {
             'name': self.name,
             'description': (
+                '本工具只提交异步导出任务,不会在本次调用中等待文件生成。'
+                '成功后返回任务引用(task_ref)和'
+                '建议等待时间(retry_after_seconds)。稍后使用 query_export_task 单独查询'
+                '任务状态或下载链接。'
                 '使用场景:只有用户明确要求导出未排舱订单并需要下载文件时,才按'
                 'outbound/add.html的筛选条件调用。筛选名称必须先通过'
                 'list_pending_outbound_export_filter_options 获取当前员工有权限的 value。'
                 '禁止使用:用户只是查看或查询订单、排舱列表或排舱详情时不得调用;'
-                '本工具不导出已排舱列表,也不导出省外进港资料。用户只说单号”时必须'
+                '本工具不导出已排舱列表,也不导出省外进港资料。用户只说单号”时必须'
                 '先确认是订单号还是客户参考号;不得用导出结果试探号码类型。'
                 '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,不得展示内部参数名。'
             ),

+ 45 - 0
tools/query_export_task.py

@@ -0,0 +1,45 @@
+class QueryExportTaskTool:
+    name = 'query_export_task'
+    route_path = '/mcp/tools/queryExportTask'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '查询此前由导出工具提交的一个异步导出任务。只能使用导出工具返回的'
+                '任务引用,并通过单独的新调用查询。任务仍在等待或执行时,可按返回的'
+                '建议等待时间稍后再次查询;不得在同一次调用内等待或循环轮询。'
+                '任务完成后返回安全下载链接。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'task_ref': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 512,
+                        'description': '导出工具返回的任务引用。',
+                    },
+                },
+                'required': ['task_ref'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(self, task_ref=None, request_id='rq_query_export_task'):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for query_export_task')
+        if not isinstance(task_ref, str):
+            raise ValueError('task_ref must be a string')
+        task_ref = task_ref.strip()
+        if not task_ref or len(task_ref) > 512:
+            raise ValueError('task_ref must contain 1 to 512 characters')
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            {'task_ref': task_ref},
+            request_id,
+        )