Browse Source

master_task_710395169MCP接口增加文件的查询,更新和上传

jackson 2 weeks ago
parent
commit
0e60b37dfd
35 changed files with 2751 additions and 36 deletions
  1. 49 0
      CONTEXT.md
  2. 12 5
      README.md
  3. 43 1
      app.py
  4. 7 0
      docs/adr/0002-headhaul-document-upload-out-of-band.md
  5. 57 5
      mcp_protocol.py
  6. 73 0
      public_gateway.py
  7. 339 2
      public_server.py
  8. 73 0
      services/api_client.py
  9. 42 0
      services/headhaul_document_app.py
  10. 170 0
      services/output_presenter.py
  11. 36 0
      services/scoped_api_client.py
  12. 2 2
      tests/test_container_timeliness_tools.py
  13. 1 1
      tests/test_customer_payment_followup_tool.py
  14. 1 1
      tests/test_customer_payment_records_tool.py
  15. 1 1
      tests/test_customer_query_tools.py
  16. 1 1
      tests/test_customer_unverified_bill_details_tool.py
  17. 2 2
      tests/test_destination_trailer_tools.py
  18. 2 2
      tests/test_export_pallet_data_tool.py
  19. 2 2
      tests/test_export_receivable_cost_list_tool.py
  20. 1277 0
      tests/test_headhaul_document_tools.py
  21. 2 2
      tests/test_order_abnormal_tools.py
  22. 1 1
      tests/test_order_receivable_cost_details_tool.py
  23. 1 1
      tests/test_output_presenter.py
  24. 2 2
      tests/test_payable_cost_tools.py
  25. 19 0
      tests/test_public_server_coverage.py
  26. 1 1
      tests/test_query_export_task_tool.py
  27. 2 2
      tests/test_receivable_cost_list_tools.py
  28. 2 2
      tests/test_receive_volume_tools.py
  29. 30 0
      tests/test_tool_description_boundaries.py
  30. 49 0
      tools/delete_headhaul_document.py
  31. 78 0
      tools/headhaul_document_common.py
  32. 79 0
      tools/list_headhaul_document_filter_options.py
  33. 56 0
      tools/prepare_headhaul_document_upload.py
  34. 97 0
      tools/query_headhaul_document_list.py
  35. 142 0
      tools/save_headhaul_document.py

+ 49 - 0
CONTEXT.md

@@ -11,9 +11,58 @@
 - 头程问题件列表行为:[`Y:/all_project_docs/fmsoperate/requirements.md`](Y:/all_project_docs/fmsoperate/requirements.md)「头程问题件列表与筛选」
 - 收货量列表行为:[`Y:/all_project_docs/fmsoperate/requirements.md`](Y:/all_project_docs/fmsoperate/requirements.md)「收货量 MCP 列表与筛选」
 - 柜子时效列表行为:[`Y:/all_project_docs/fmsoperate/requirements.md`](Y:/all_project_docs/fmsoperate/requirements.md)「柜子时效 MCP 查询与导出」
+- 头程文档行为:[`Y:/all_project_docs/home/requirements.md`](Y:/all_project_docs/home/requirements.md)「头程文档」;MCP 工具与权限:[`Y:/all_project_docs/fmsoperate/requirements.md`](Y:/all_project_docs/fmsoperate/requirements.md)「头程文档 MCP」
 
 ## Language
 
+**MCP 头程文档**:
+Workbuddy 查询、上传、保存和删除的那份头程文档,与后台 `admin/document/index` 是同一对象:一份文件或一条外链,挂若干录入单号。业务入口在物流 MCP 后端,home 页面不接 MCP 流量。不是报关资料,也不是订单详情里的附件。
+_Avoid_: 报关资料文件, 订单详情附件, 文档类型配置, 把「文件」当成另一套附件, Gateway 直连 home 页面, MCP 调用后台 DocumentController
+
+**头程文档变更**:
+头程文档没有行内编辑。要换文件、换外链或改录入单号关联,只能删除后重新添加。MCP 不提供更新工具,也不能出现页面不能做、对话里却能改的能力。
+_Avoid_: 文档更新, 原地改挂单号, 换文件覆盖保存, MCP 单独编辑
+
+**MCP 头程文档删除**:
+一次只删一份已经查到的头程文档。必须使用查询返回的文档身份,禁止只凭文件名或单号盲删。硬删文档行、关联行和 OSS 对象;外链无对象可删。不软删、不批量。权限与后台相同:可操作职能部门或超管。
+_Avoid_: 按文件名删除, 按单号删全部, 批量删除, 软删, 回收站
+
+**MCP 头程文档上传**:
+员工在 Workbuddy 对话窗口添加文件。签发后由宿主把该文件 POST 到公网上传 HTTP,由 PHP `OssHelper` 写入 OSS;不进 `tools/call`,不进模型上下文,也不经 Gateway JSON-RPC 转发。上传成功换一次性 `ticket_ref` 后再保存。不提供选文件框,禁止走 home 后台页。
+_Avoid_: Base64, 把文件当工具参数, 复用 admin 会话票据, Gateway JSON-RPC 扛文件, 对话内选文件框
+
+**ticket_ref**:
+MCP 上传成功后交给保存步骤的一次性凭证,绑定当前公司、当前员工和本次文档类型。保存时核销,不能转让、不能复用。未保存前不是头程文档。默认 30 分钟过期,过期作废并删除这次上传的 OSS 对象。
+_Avoid_: 让模型手填 file_path, 后台 Session 票据, 永久上传地址, 过期还留存储孤儿
+
+**MCP 头程文档查询**:
+按已经标明种类的录入单号精确查找头程文档;种类不明时先问。也可以不带单号、按文档类型分页列出当前人可见的文档。不把订单号、SO 号、柜号等放进同一个框混查。
+_Avoid_: 后台列表的单号混查框, 按格式猜测号码种类, 跨种类 OR
+
+**MCP 头程文档定位**:
+查询至少要有一样:标明种类的录入单号、文档类型、或完整文件名。文件名精确匹配。三都没有就先问,不准空扫当前人全部文档。
+_Avoid_: 空筛选列出全部, 文件名前后模糊 LIKE
+
+**document_ref**:
+查询返回的不透明文档身份,绑定当前公司、当前员工和这一份头程文档。删除只认它。不是内部自增 ID。
+_Avoid_: 让模型手填 st_document.id, 按文件名当身份删除
+
+**MCP 头程文档列**:
+固定列:文档类型、录入单号快照、文件名、安全链接、操作人、操作时间,外加 `document_ref`。文件给安全 HTTP(S) URL,外链给保存时的 URL。不另做把文件流过 Gateway 的下载工具。
+_Avoid_: 内部 ID 当列, MCP 再拉 OSS 流, 没有链接只给文件名
+
+**MCP 头程文档权限**:
+查询和写入是两套 MCP 接口权限。能看不等于能传、能删。查询还要当前公司内可见或可操作职能部门并集;上传、保存、删除还要可操作职能部门。home 菜单不是 MCP 门票。超管跳过职能部门集合,不跳过公司隔离。
+_Avoid_: 一套权限通吃查传删, 用 home 文档页菜单当 MCP 准入, 超管跨公司看文档
+
+**MCP 头程文档工具**:
+五块互不合并:筛选项、查询、签发上传、保存、删除。保存不收文件字节;查询不删除;上传未保存还不是头程文档。
+_Avoid_: 一个万能文件工具, 保存时夹带二进制, 上传成功就当已保存
+
+**MCP 头程文档保存**:
+与后台添加同一套规则。文档类型必须是当前人可操作且已启用、已配录入单号的类型。至少一条录入单号关联;号码按种类在当前公司精确命中,有一个无效则整次失败。`ticket_ref` 与外链二选一。一次只保存一份。禁用类型不能新增。
+_Avoid_: 无关联先存文件, 后补单号, 文件和外链同时有, 模型手填类型内部 ID, 禁用类型还能新增
+
 **打托批次**:
 海外仓侧把若干订单打成一组托盘后形成的批次,业务号为 `PB` 开头的打托批次号。命中批次内任一订单,导出该批次的全部订单行和全部打托附件。
 _Avoid_: 入仓单、海外仓入库明细、打托数(件数)、勾选行内部 ID、按筛选拆成半批次

+ 12 - 5
README.md

@@ -17,15 +17,15 @@ Gateway 保持“薄网关”边界:
 - 支持 Redis token/session 存储。
 - 支持文件 token store 作为开发排障兜底。
 - 不再支持授权码绑定工具;正式接入只使用后台生成的 `GWS_xxx` 设备配置。
-- 本地 stdio 与公网 HTTP 注册同一组 34 个查询、筛选和导出工具。
+- 本地 stdio 与公网 HTTP 注册同一组 39 个查询、筛选、导出和头程文档工具。
 - 支持客户、订单、订单详情、轨迹、报关资料、排舱列表与详情查询。
 - 支持客户、订单与排舱筛选项,以及未排舱订单和省外进港资料导出。
-- 支持 MCP `initialize`、`tools/list`、`tools/call`。
+- 支持 MCP `initialize`、`tools/list`、`tools/call`、`resources/list`、`resources/read`。
 - 公网模式支持 `gateway_session_id` 请求级隔离、Redis Gateway session、审计日志和基础限流。
 
 ## 工具目录
 
-`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 34 个候选工具:
+`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 39 个候选工具:
 
 | MCP 工具 | 用途 | ThinkPHP 路由 | 最终展示 |
 |---|---|---|---|
@@ -51,6 +51,11 @@ Gateway 保持“薄网关”边界:
 | `list_receive_volume_filter_options` | 查询收货量五类筛选项 | `/mcp/tools/listReceiveVolumeFilterOptions` | 安全筛选项 |
 | `query_container_timeliness_list` | 按柜号、提单号或干线实际出发时间查询柜子时效 | `/mcp/tools/queryContainerTimelinessList` | 固定 54 列安全表格 |
 | `export_container_timeliness_report` | 提交柜子时效统计报表异步导出 | `/mcp/tools/exportContainerTimelinessReport` | 签名任务引用 |
+| `query_headhaul_document_list` | 按明确种类号码、文档类型或完整文件名查询头程文档 | `/mcp/tools/queryHeadhaulDocumentList` | 固定 6 列 + 文档身份 |
+| `list_headhaul_document_filter_options` | 查询头程文档三类筛选项 | `/mcp/tools/listHeadhaulDocumentFilterOptions` | 安全筛选项 |
+| `prepare_headhaul_document_upload` | 签发一次性上传槽位,不接收文件字节 | `/mcp/tools/prepareHeadhaulDocumentUpload` | 上传令牌与地址 |
+| `save_headhaul_document` | 按 ticket_ref 或外链保存头程文档 | `/mcp/tools/saveHeadhaulDocument` | 文档身份 |
+| `delete_headhaul_document` | 按 document_ref 删除一份头程文档 | `/mcp/tools/deleteHeadhaulDocument` | 固定已删除 |
 | `list_outbound_filter_options` | 查询七类排舱筛选项 | `/mcp/tools/listOutboundFilterOptions` | 安全筛选项 |
 | `list_order_filter_options` | 查询精准订单筛选项 | `/mcp/tools/listOrderFilterOptions` | 安全筛选项 |
 | `list_customer_filter_options` | 查询客户列表/客户回款工具共用的客户、事业部、商务经理和客户经理筛选项 | `/mcp/tools/listCustomerFilterOptions` | 安全筛选项 |
@@ -78,7 +83,9 @@ 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 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
+这 39 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
+
+公网上传 HTTP `POST /mcp/upload-headhaul-document` 不是独立 MCP 工具;权限与启用对齐 `prepare_headhaul_document_upload`,文件字节不进入 `tools/call`。员工在对话窗口添加文件后签发,由宿主把该文件 POST 到该地址。不提供选文件框。
 
 MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明或注册集合变化后,必须重启对应 Gateway 进程并让客户端重新连接,客户端才会重新获取工具列表。
 
@@ -87,7 +94,7 @@ MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明
 Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内部接口和工具入参:
 
 - `query_order` 保持原有 `columns + records` 结果和文本展示,不参与本次转换。
-- `services/output_presenter.py` 对其余 33 个安全工具执行显式白名单展示。
+- `services/output_presenter.py` 对其余 38 个安全工具执行显式白名单展示。
 - `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`,不返回内部字段键。
 - 客户回款跟进严格校验三字段月份汇总;单个客户的六字段账单明细由独立分页工具返回。未知、缺失、额外字段或畸形分页均返回安全错误,不静默丢弃。

+ 43 - 1
app.py

@@ -69,6 +69,15 @@ from tools.query_container_timeliness_list import QueryContainerTimelinessListTo
 from tools.export_container_timeliness_report import (
     ExportContainerTimelinessReportTool,
 )
+from tools.query_headhaul_document_list import QueryHeadhaulDocumentListTool
+from tools.list_headhaul_document_filter_options import (
+    ListHeadhaulDocumentFilterOptionsTool,
+)
+from tools.prepare_headhaul_document_upload import (
+    PrepareHeadhaulDocumentUploadTool,
+)
+from tools.save_headhaul_document import SaveHeadhaulDocumentTool
+from tools.delete_headhaul_document import DeleteHeadhaulDocumentTool
 
 
 def parse_int_list(value):
@@ -98,6 +107,8 @@ class GatewayApp:
         self.api_client = api_client
         self.token_store = token_store
         self.reporter = reporter or NullDiagnosticReporter()
+        from services.headhaul_document_app import HeadhaulSlotMemory
+        self._headhaul_slots = HeadhaulSlotMemory()
         self._tools = {
             'query_order': QueryOrderTool(api_client=api_client),
             'query_track': QueryTrackTool(api_client=api_client),
@@ -172,6 +183,19 @@ class GatewayApp:
             ),
             'export_container_timeliness_report':
                 ExportContainerTimelinessReportTool(api_client=api_client),
+            'query_headhaul_document_list': QueryHeadhaulDocumentListTool(
+                api_client=api_client
+            ),
+            'list_headhaul_document_filter_options':
+                ListHeadhaulDocumentFilterOptionsTool(api_client=api_client),
+            'prepare_headhaul_document_upload':
+                PrepareHeadhaulDocumentUploadTool(api_client=api_client),
+            'save_headhaul_document': SaveHeadhaulDocumentTool(
+                api_client=api_client
+            ),
+            'delete_headhaul_document': DeleteHeadhaulDocumentTool(
+                api_client=api_client
+            ),
         }
 
     @classmethod
@@ -251,6 +275,15 @@ class GatewayApp:
             if name in enabled
         ]
 
+    def list_resources(self):
+        return []
+
+    def read_resource(self, uri, gateway_session_id='local'):
+        return None
+
+    def session_for_headhaul_token(self, token):
+        return self._headhaul_slots.session_for_token(token)
+
     def build_request_id(self, request_id=''):
         request_id = str(request_id or '').strip()
         if request_id:
@@ -278,7 +311,15 @@ class GatewayApp:
         if name not in self._load_enabled_tool_names(request_id):
             raise RuntimeError('tool disabled: {0}'.format(name))
         arguments = arguments or {}
-        return tool.call(request_id=request_id, **arguments)
+        result = tool.call(request_id=request_id, **arguments)
+        if name == 'prepare_headhaul_document_upload' and isinstance(result, dict):
+            data = result.get('data')
+            token = ''
+            if isinstance(data, dict):
+                token = str(data.get('upload_token') or '').strip()
+            if token:
+                self._headhaul_slots.remember('local', token)
+        return result
 
     def create_protocol_handler(self):
         return McpProtocolHandler(self, reporter=self.reporter)
@@ -845,6 +886,7 @@ class GatewayApp:
                 'list_destination_trailer_filter_options',
                 'list_order_abnormal_filter_options',
                 'list_receive_volume_filter_options',
+                'list_headhaul_document_filter_options',
             ):
                 if not args.filter_type:
                     raise ValueError(

+ 7 - 0
docs/adr/0002-headhaul-document-upload-out-of-band.md

@@ -0,0 +1,7 @@
+# 头程文档字节不走 tools/call
+
+Workbuddy 要上传头程文档,但 MCP `tools/call` 只有 JSON 参数,现网对话内选文件框拿不到令牌。决定:员工在对话窗口添加文件;签发后由宿主把该文件 POST 到公网 Gateway 上传 HTTP(反代 fmsoperate,`OssHelper` 写入 OSS),换短时 `ticket_ref` 后再 `save`。不提供选文件框。Gateway JSON-RPC 不转发文件,也不直连 OSS。禁止 `tools/call` 传二进制,禁止走 home 后台页。
+
+**Status:** accepted
+
+**Considered Options:** 工具参数里塞文件(100M 会打爆协议和 10 秒超时);分片走 tools/call(模型没有原文件可切,宿主能切就能一次 POST,且会撞限流);对话内 MCP App iframe(现网 Workbuddy 不注入令牌);跳出对话打开 Gateway HTTP 上传页(用户不接受)。

+ 57 - 5
mcp_protocol.py

@@ -17,6 +17,17 @@ class McpProtocolHandler:
     server_version = '0.1.0'
     output_presenter = OutputPresenter()
 
+    @staticmethod
+    def server_capabilities():
+        return {
+            'tools': {
+                'listChanged': False,
+            },
+            'resources': {
+                'listChanged': False,
+            },
+        }
+
     def __init__(self, gateway_app, reporter=None):
         self.gateway_app = gateway_app
         self.reporter = reporter or NullDiagnosticReporter()
@@ -70,11 +81,7 @@ class McpProtocolHandler:
                     request_id,
                     {
                         'protocolVersion': self.protocol_version,
-                        'capabilities': {
-                            'tools': {
-                                'listChanged': False,
-                            }
-                        },
+                        'capabilities': self.server_capabilities(),
                         'serverInfo': {
                             'name': self.server_name,
                             'version': self.server_version,
@@ -98,6 +105,51 @@ class McpProtocolHandler:
                     )
                 ]
                 return self._success_response(request_id, {'tools': tools})
+            if method == 'resources/list':
+                emitter.emit(
+                    stage='protocol_validation',
+                    status='succeeded',
+                    event_code='PROTOCOL_VALIDATION_COMPLETED',
+                    context={
+                        'jsonrpc_method': method,
+                        'transport': 'stdio',
+                    },
+                )
+                resources = []
+                if hasattr(self.gateway_app, 'list_resources'):
+                    resources = list(self.gateway_app.list_resources() or [])
+                return self._success_response(
+                    request_id,
+                    {'resources': resources},
+                )
+            if method == 'resources/read':
+                params = request.get('params') or {}
+                uri = ''
+                if isinstance(params, dict):
+                    uri = str(params.get('uri') or '').strip()
+                emitter.emit(
+                    stage='protocol_validation',
+                    status='succeeded',
+                    event_code='PROTOCOL_VALIDATION_COMPLETED',
+                    context={
+                        'jsonrpc_method': method,
+                        'transport': 'stdio',
+                    },
+                )
+                resource = None
+                if uri and hasattr(self.gateway_app, 'read_resource'):
+                    resource = self.gateway_app.read_resource(uri)
+                if not isinstance(resource, dict):
+                    return self._error_response(
+                        request_id,
+                        -32602,
+                        'Invalid params',
+                        trace_request_id,
+                    )
+                return self._success_response(
+                    request_id,
+                    {'contents': [resource]},
+                )
             if method == 'tools/call':
                 params = request.get('params') or {}
                 if not isinstance(params, dict):

+ 73 - 0
public_gateway.py

@@ -55,6 +55,15 @@ from tools.query_container_timeliness_list import QueryContainerTimelinessListTo
 from tools.export_container_timeliness_report import (
     ExportContainerTimelinessReportTool,
 )
+from tools.query_headhaul_document_list import QueryHeadhaulDocumentListTool
+from tools.list_headhaul_document_filter_options import (
+    ListHeadhaulDocumentFilterOptionsTool,
+)
+from tools.prepare_headhaul_document_upload import (
+    PrepareHeadhaulDocumentUploadTool,
+)
+from tools.save_headhaul_document import SaveHeadhaulDocumentTool
+from tools.delete_headhaul_document import DeleteHeadhaulDocumentTool
 from utils.security import hash_gateway_session_id
 
 
@@ -139,7 +148,22 @@ class PublicGatewayApp:
             ),
             'export_container_timeliness_report':
                 ExportContainerTimelinessReportTool(api_client=None),
+            'query_headhaul_document_list': QueryHeadhaulDocumentListTool(
+                api_client=None
+            ),
+            'list_headhaul_document_filter_options':
+                ListHeadhaulDocumentFilterOptionsTool(api_client=None),
+            'prepare_headhaul_document_upload':
+                PrepareHeadhaulDocumentUploadTool(api_client=None),
+            'save_headhaul_document': SaveHeadhaulDocumentTool(
+                api_client=None
+            ),
+            'delete_headhaul_document': DeleteHeadhaulDocumentTool(
+                api_client=None
+            ),
         }
+        from services.headhaul_document_app import HeadhaulSlotMemory
+        self._headhaul_slots = HeadhaulSlotMemory()
 
     def registered_tool_names(self):
         return tuple(self._tools.keys())
@@ -207,6 +231,48 @@ class PublicGatewayApp:
             if name in enabled
         ]
 
+    def list_resources(self):
+        return []
+
+    def remember_headhaul_slot(self, gateway_session_id, token):
+        self._headhaul_slots.remember(gateway_session_id, token)
+
+    def session_for_headhaul_token(self, token):
+        return self._headhaul_slots.session_for_token(token)
+
+    def read_resource(self, uri, gateway_session_id=''):
+        return None
+
+    def upload_headhaul_document(
+        self,
+        gateway_session_id,
+        upload_token,
+        filename,
+        file_bytes,
+        content_type='',
+        request_id='',
+        client_ip='',
+    ):
+        session = self._require_session(gateway_session_id)
+        request_id = self.build_request_id(request_id)
+        enabled = self._load_enabled_tool_names(
+            session['mcp_token'],
+            request_id,
+        )
+        if 'prepare_headhaul_document_upload' not in enabled:
+            raise RuntimeError('tool disabled: prepare_headhaul_document_upload')
+        if not hasattr(self.api_client, 'upload_headhaul_document'):
+            raise RuntimeError('upload client unavailable')
+        return self.api_client.upload_headhaul_document(
+            token=session['mcp_token'],
+            upload_token=upload_token,
+            filename=filename,
+            file_bytes=file_bytes,
+            content_type=content_type,
+            request_id=request_id,
+            client_ip=client_ip,
+        )
+
     def build_request_id(self, request_id=''):
         request_id = str(request_id or '').strip()
         return request_id or 'rq_{0}'.format(uuid.uuid4().hex[:16])
@@ -295,6 +361,13 @@ class PublicGatewayApp:
             )
             if hasattr(self.session_store, 'touch_session'):
                 self.session_store.touch_session(gateway_session_id)
+            if name == 'prepare_headhaul_document_upload' and isinstance(result, dict):
+                data = result.get('data')
+                token = ''
+                if isinstance(data, dict):
+                    token = str(data.get('upload_token') or '').strip()
+                if token:
+                    self.remember_headhaul_slot(gateway_session_id, token)
             logger.info(
                 'MCP public tool success',
                 extra={

+ 339 - 2
public_server.py

@@ -1,6 +1,7 @@
 import hashlib
 import json
 import logging
+import socket
 import threading
 import time
 import uuid
@@ -16,6 +17,45 @@ from utils.rate_limiter import SimpleRateLimiter
 
 logger = logging.getLogger(__name__)
 
+UPLOAD_CORS_HEADERS = (
+    ('Access-Control-Allow-Origin', '*'),
+    ('Access-Control-Allow-Methods', 'POST, OPTIONS'),
+    ('Access-Control-Allow-Headers', 'Content-Type'),
+    ('Access-Control-Max-Age', '600'),
+)
+UPLOAD_BODY_LIMIT = 102 * 1024 * 1024
+UPLOAD_LIMIT_NAME = 'upload-headhaul-document'
+UPLOAD_DISCARD_CHUNK = 64 * 1024
+UPLOAD_DISCARD_IDLE_SECONDS = 0.5
+
+
+def discard_request_body(rfile, connection, length, chunk=UPLOAD_DISCARD_CHUNK,
+                         idle_timeout=UPLOAD_DISCARD_IDLE_SECONDS):
+    if length <= 0 or rfile is None:
+        return
+    remaining = length
+    old_timeout = None
+    if connection is not None:
+        try:
+            old_timeout = connection.gettimeout()
+            connection.settimeout(idle_timeout)
+        except OSError:
+            old_timeout = None
+    try:
+        while remaining > 0:
+            data = rfile.read(min(chunk, remaining))
+            if not data:
+                break
+            remaining -= len(data)
+    except (OSError, socket.timeout):
+        pass
+    finally:
+        if connection is not None and old_timeout is not None:
+            try:
+                connection.settimeout(old_timeout)
+            except OSError:
+                pass
+
 def extract_client_ip(headers, client_address):
     if client_address and len(client_address) > 0:
         return str(client_address[0])
@@ -101,6 +141,198 @@ class PublicMcpHttpHandler:
             )
         return None
 
+    @staticmethod
+    def _upload_error(code, message, trace_request_id):
+        return {
+            'code': code,
+            'msg': message,
+            'data': {},
+            'meta': {'request_id': trace_request_id},
+        }
+
+    def gate_upload(self, headers, content_length, client_ip='', trace_request_id=''):
+        if (
+            isinstance(content_length, bool)
+            or not isinstance(content_length, int)
+            or content_length < 0
+        ):
+            return 400, self._upload_error(
+                'MCP_1401',
+                '工具参数不正确,请检查后重试',
+                trace_request_id,
+            ), None
+        if content_length > UPLOAD_BODY_LIMIT:
+            return 413, self._upload_error(
+                'MCP_1401',
+                '文件不可大于100M',
+                trace_request_id,
+            ), None
+        context = self.context_parser.parse(headers or {})
+        session_id = context.gateway_session_id if context.has_session() else ''
+        identity = session_id or ('ip:' + str(client_ip or 'unknown'))
+        rate_key = '{0}:{1}'.format(identity, UPLOAD_LIMIT_NAME)
+        if self.rate_limiter is not None:
+            if not self.rate_limiter.is_allowed(rate_key):
+                logger.warning(
+                    "MCP public rate limit exceeded",
+                    extra={
+                        'request_id': trace_request_id,
+                        'protocol_method': 'upload',
+                        'tool_code': UPLOAD_LIMIT_NAME,
+                        'client_identity': identity,
+                        'diagnostic_reason': 'RATE_LIMIT_EXCEEDED',
+                    },
+                )
+                return 429, self._upload_error(
+                    'MCP_9001',
+                    '请求过于频繁,请稍后重试',
+                    trace_request_id,
+                ), None
+            if not self.rate_limiter.try_acquire(rate_key):
+                logger.warning(
+                    'MCP public concurrency limit exceeded',
+                    extra={
+                        'request_id': trace_request_id,
+                        'protocol_method': 'upload',
+                        'tool_code': UPLOAD_LIMIT_NAME,
+                        'client_identity': identity,
+                        'diagnostic_reason': 'CONCURRENCY_LIMIT_EXCEEDED',
+                    },
+                )
+                return 429, self._upload_error(
+                    'MCP_9001',
+                    '请求过于频繁,请稍后重试',
+                    trace_request_id,
+                ), None
+        return None, None, rate_key
+
+    def handle_upload(
+        self,
+        headers,
+        body,
+        client_ip='',
+        trace_request_id='',
+    ):
+        if len(body or b'') > UPLOAD_BODY_LIMIT:
+            return 413, self._upload_error(
+                'MCP_1401',
+                '文件不可大于100M',
+                trace_request_id,
+            )
+        fields, files = self._parse_multipart(headers, body or b'')
+        upload_token = str(fields.get('upload_token') or '').strip()
+        file_spec = files.get('file')
+        context = self.context_parser.parse(headers or {})
+        session_id = context.gateway_session_id if context.has_session() else ''
+        if (
+            not session_id
+            and upload_token
+            and hasattr(self.gateway_app, 'session_for_headhaul_token')
+        ):
+            session_id = str(
+                self.gateway_app.session_for_headhaul_token(upload_token) or ''
+            ).strip()
+        if not session_id:
+            return 401, {
+                'code': 'MCP_1101',
+                'msg': DEVICE_INVALID_MESSAGE,
+                'data': {},
+                'meta': {'request_id': trace_request_id},
+            }
+        if not upload_token or not file_spec:
+            return 400, {
+                'code': 'MCP_1401',
+                'msg': '工具参数不正确,请检查后重试',
+                'data': {},
+                'meta': {'request_id': trace_request_id},
+            }
+        filename, file_bytes, content_type = file_spec
+        try:
+            payload = self.gateway_app.upload_headhaul_document(
+                session_id,
+                upload_token,
+                filename,
+                file_bytes,
+                content_type,
+                request_id=trace_request_id,
+                client_ip=client_ip,
+            )
+        except RuntimeError as exc:
+            message = str(exc)
+            if message == DEVICE_INVALID_MESSAGE:
+                return 401, {
+                    'code': 'MCP_1101',
+                    'msg': DEVICE_INVALID_MESSAGE,
+                    'data': {},
+                    'meta': {'request_id': trace_request_id},
+                }
+            if message.lower().startswith('tool disabled'):
+                return 403, {
+                    'code': 'MCP_1202',
+                    'msg': '工具当前不可用',
+                    'data': {},
+                    'meta': {'request_id': trace_request_id},
+                }
+            return 500, {
+                'code': 'MCP_9001',
+                'msg': '系统繁忙,请稍后重试',
+                'data': {},
+                'meta': {'request_id': trace_request_id},
+            }
+        if not isinstance(payload, dict):
+            return 500, {
+                'code': 'MCP_9001',
+                'msg': '系统繁忙,请稍后重试',
+                'data': {},
+                'meta': {'request_id': trace_request_id},
+            }
+        return 200, payload
+
+    @staticmethod
+    def _parse_multipart(headers, body):
+        content_type = ''
+        for key, value in (headers or {}).items():
+            if str(key).lower() == 'content-type':
+                content_type = str(value or '')
+                break
+        if 'multipart/form-data' not in content_type or 'boundary=' not in content_type:
+            return {}, {}
+        boundary = content_type.split('boundary=', 1)[1].strip().strip('"')
+        delimiter = b'--' + boundary.encode('utf-8')
+        fields = {}
+        files = {}
+        for raw_part in body.split(delimiter):
+            part = raw_part.strip()
+            if not part or part == b'--':
+                continue
+            header_blob, separator, content = part.partition(b'\r\n\r\n')
+            if separator == b'':
+                continue
+            header_text = header_blob.decode('utf-8', 'replace')
+            disposition = ''
+            part_type = 'application/octet-stream'
+            for line in header_text.split('\r\n'):
+                lower = line.lower()
+                if lower.startswith('content-disposition:'):
+                    disposition = line.split(':', 1)[1].strip()
+                if lower.startswith('content-type:'):
+                    part_type = line.split(':', 1)[1].strip()
+            name = ''
+            filename = ''
+            for item in disposition.split(';'):
+                item = item.strip()
+                if item.startswith('name='):
+                    name = item.split('=', 1)[1].strip().strip('"')
+                elif item.startswith('filename='):
+                    filename = item.split('=', 1)[1].strip().strip('"')
+            if not name:
+                continue
+            if filename:
+                files[name] = (filename, content, part_type)
+            else:
+                fields[name] = content.decode('utf-8', 'replace')
+        return fields, files
+
     def handle_json_rpc(
         self,
         headers,
@@ -145,7 +377,7 @@ class PublicMcpHttpHandler:
                 # rate-limiting it would block clients from connecting at all, so we skip it.
                 return McpProtocolHandler._success_response(request_id, {
                     'protocolVersion': McpProtocolHandler.protocol_version,
-                    'capabilities': {'tools': {'listChanged': False}},
+                    'capabilities': McpProtocolHandler.server_capabilities(),
                     'serverInfo': {
                         'name': McpProtocolHandler.server_name,
                         'version': McpProtocolHandler.server_version,
@@ -197,6 +429,59 @@ class PublicMcpHttpHandler:
                         normalized['inputSchema'] = normalized.pop('input_schema')
                     tools.append(normalized)
                 return McpProtocolHandler._success_response(request_id, {'tools': tools})
+            if method in ('resources/list', 'resources/read'):
+                emitter.emit(
+                    stage='protocol_validation',
+                    status='succeeded',
+                    event_code='PROTOCOL_VALIDATION_COMPLETED',
+                    context={
+                        'jsonrpc_method': method,
+                        'transport': 'http',
+                    },
+                )
+                protocol_validated = True
+                context = self.context_parser.parse(headers or {})
+                if not context.has_session():
+                    emitter.emit(
+                        stage='gateway_session',
+                        status='failed',
+                        event_code='GATEWAY_SESSION_NOT_FOUND',
+                        context={'transport': 'http'},
+                    )
+                    return McpProtocolHandler._error_response(
+                        request_id,
+                        -32001,
+                        DEVICE_INVALID_MESSAGE,
+                        trace_request_id,
+                    )
+                if method == 'resources/list':
+                    resources = []
+                    if hasattr(self.gateway_app, 'list_resources'):
+                        resources = list(self.gateway_app.list_resources() or [])
+                    return McpProtocolHandler._success_response(
+                        request_id,
+                        {'resources': resources},
+                    )
+                uri = ''
+                if isinstance(message_params, dict):
+                    uri = str(message_params.get('uri') or '').strip()
+                resource = None
+                if uri and hasattr(self.gateway_app, 'read_resource'):
+                    resource = self.gateway_app.read_resource(
+                        uri,
+                        context.gateway_session_id,
+                    )
+                if not isinstance(resource, dict):
+                    return McpProtocolHandler._error_response(
+                        request_id,
+                        -32602,
+                        'Invalid params',
+                        trace_request_id,
+                    )
+                return McpProtocolHandler._success_response(
+                    request_id,
+                    {'contents': [resource]},
+                )
             if method == 'tools/call':
                 if not isinstance(message_params, dict):
                     raise ValueError('tool parameters must be an object')
@@ -466,6 +751,16 @@ def create_http_handler(gateway_app, rate_limiter=None, reporter=None):
             self.send_response(404)
             self.end_headers()
 
+        def do_OPTIONS(self):
+            if self.path == '/mcp/upload-headhaul-document':
+                self.send_response(204)
+                for key, value in UPLOAD_CORS_HEADERS:
+                    self.send_header(key, value)
+                self.end_headers()
+                return
+            self.send_response(404)
+            self.end_headers()
+
         def do_POST(self):
             request_headers = dict(self.headers.items())
             client_ip = extract_client_ip(request_headers, self.client_address)
@@ -478,6 +773,35 @@ def create_http_handler(gateway_app, rate_limiter=None, reporter=None):
             client_request_id_hash = rpc_handler._client_request_id(
                 request_headers
             )
+            if self.path == '/mcp/upload-headhaul-document':
+                try:
+                    length = int(self.headers.get('Content-Length') or '0')
+                except (TypeError, ValueError):
+                    length = -1
+                status, payload, rate_key = rpc_handler.gate_upload(
+                    request_headers,
+                    length,
+                    client_ip=client_ip,
+                    trace_request_id=trace_request_id,
+                )
+                if status is not None:
+                    if length > 0:
+                        discard_request_body(self.rfile, self.connection, length)
+                    self._write_upload_json(status, payload, close=True)
+                    return
+                try:
+                    body = self.rfile.read(length) if length > 0 else b''
+                    status, payload = rpc_handler.handle_upload(
+                        request_headers,
+                        body,
+                        client_ip=client_ip,
+                        trace_request_id=trace_request_id,
+                    )
+                    self._write_upload_json(status, payload)
+                finally:
+                    if rate_key and rpc_handler.rate_limiter is not None:
+                        rpc_handler.rate_limiter.release(rate_key)
+                return
             length = int(self.headers.get('Content-Length') or '0')
             if self.path != '/mcp':
                 logger.warning(f"[HTTP] 404: ip={client_ip}, path={self.path}")
@@ -555,6 +879,19 @@ def create_http_handler(gateway_app, rate_limiter=None, reporter=None):
                 diagnostic_emitter=diagnostic_emitter,
             )
 
+        def _write_upload_json(self, status, payload, close=False):
+            encoded = json.dumps(payload, ensure_ascii=False).encode('utf-8')
+            self.send_response(status)
+            self.send_header('Content-Type', 'application/json; charset=utf-8')
+            for key, value in UPLOAD_CORS_HEADERS:
+                self.send_header(key, value)
+            if close:
+                self.send_header('Connection', 'close')
+                self.close_connection = True
+            self.send_header('Content-Length', str(len(encoded)))
+            self.end_headers()
+            self.wfile.write(encoded)
+
         def _write_json(self, payload, diagnostic_emitter=None):
             raw = json.dumps(payload, ensure_ascii=False).encode('utf-8')
             try:
@@ -613,7 +950,7 @@ def serve_public(gateway_app, host='0.0.0.0', port=8765, enable_rate_limit=True,
             window_seconds=rate_limit_window_seconds,
             max_in_flight=max_in_flight_per_tool,
         )
-        logger.info(f"Rate limiting enabled: {rate_limit_max_requests} requests/{rate_limit_window_seconds}s and {max_in_flight_per_tool} in-flight per session and tool (tools/call only)")
+        logger.info(f"Rate limiting enabled: {rate_limit_max_requests} requests/{rate_limit_window_seconds}s and {max_in_flight_per_tool} in-flight per session and tool (tools/call and upload HTTP)")
 
     logger.info(f"Starting public MCP Gateway on {host}:{port}")
     server = ThreadingHTTPServer(

+ 73 - 0
services/api_client.py

@@ -1,4 +1,5 @@
 import json
+import uuid
 import urllib.request
 
 
@@ -13,6 +14,49 @@ class JsonTransport:
         with urllib.request.urlopen(request, timeout=timeout) as response:
             return json.loads(response.read().decode('utf-8-sig'))
 
+    def post_multipart(self, url, fields, files, headers, timeout):
+        boundary = '----McpFormBoundary{0}'.format(uuid.uuid4().hex)
+        body = bytearray()
+        for name, value in (fields or {}).items():
+            body.extend(('--{0}\r\n'.format(boundary)).encode('utf-8'))
+            body.extend(
+                'Content-Disposition: form-data; name="{0}"\r\n\r\n'.format(
+                    name
+                ).encode('utf-8')
+            )
+            body.extend(str(value).encode('utf-8'))
+            body.extend(b'\r\n')
+        for name, spec in (files or {}).items():
+            filename, file_bytes, content_type = spec
+            safe_name = str(filename or 'file').replace('"', '')
+            body.extend(('--{0}\r\n'.format(boundary)).encode('utf-8'))
+            body.extend(
+                (
+                    'Content-Disposition: form-data; name="{0}"; '
+                    'filename="{1}"\r\n'
+                ).format(name, safe_name).encode('utf-8')
+            )
+            body.extend(
+                'Content-Type: {0}\r\n\r\n'.format(
+                    content_type or 'application/octet-stream'
+                ).encode('utf-8')
+            )
+            body.extend(file_bytes if isinstance(file_bytes, (bytes, bytearray)) else b'')
+            body.extend(b'\r\n')
+        body.extend(('--{0}--\r\n'.format(boundary)).encode('utf-8'))
+        request_headers = dict(headers or {})
+        request_headers['Content-Type'] = (
+            'multipart/form-data; boundary={0}'.format(boundary)
+        )
+        request = urllib.request.Request(
+            url=url,
+            data=bytes(body),
+            headers=request_headers,
+            method='POST',
+        )
+        with urllib.request.urlopen(request, timeout=timeout) as response:
+            return json.loads(response.read().decode('utf-8-sig'))
+
 
 class ApiClient:
     def __init__(self, base_url, token_store, transport=None, timeout=10):
@@ -39,3 +83,32 @@ class ApiClient:
             'X-Request-Id': str(request_id or '').strip(),
         }
         return self.transport.post_json(url, {}, headers, self.timeout)
+
+    def upload_headhaul_document(
+        self,
+        upload_token,
+        filename,
+        file_bytes,
+        content_type='',
+        request_id='',
+    ):
+        token = self.token_store.require_token()
+        url = self.base_url + '/mcp/tools/uploadHeadhaulDocument'
+        headers = {
+            'Authorization': 'Bearer {0}'.format(token),
+            'X-MCP-Tool-Code': 'prepare_headhaul_document_upload',
+            'X-Request-Id': str(request_id or '').strip(),
+        }
+        return self.transport.post_multipart(
+            url,
+            {'upload_token': upload_token},
+            {
+                'file': (
+                    filename,
+                    file_bytes,
+                    content_type or 'application/octet-stream',
+                ),
+            },
+            headers,
+            max(self.timeout, 120),
+        )

+ 42 - 0
services/headhaul_document_app.py

@@ -0,0 +1,42 @@
+import os
+
+
+UPLOAD_PATH = '/mcp/upload-headhaul-document'
+
+
+class HeadhaulSlotMemory:
+    def __init__(self):
+        self._by_session = {}
+        self._by_token = {}
+
+    def remember(self, session_id, token):
+        session_id = str(session_id or '').strip()
+        token = str(token or '').strip()
+        if not session_id or not token:
+            return
+        previous = self._by_session.get(session_id)
+        if previous:
+            self._by_token.pop(previous, None)
+        self._by_session[session_id] = token
+        self._by_token[token] = session_id
+
+    def token_for_session(self, session_id):
+        return self._by_session.get(str(session_id or '').strip(), '')
+
+    def session_for_token(self, token):
+        return self._by_token.get(str(token or '').strip(), '')
+
+
+def public_upload_url():
+    base = str(os.environ.get('FMS_GATEWAY_PUBLIC_BASE') or '').strip().rstrip('/')
+    if base:
+        return base + UPLOAD_PATH
+    return UPLOAD_PATH
+
+
+def resource_list():
+    return []
+
+
+def read_resource(uri, upload_token='', upload_url=''):
+    return None

+ 170 - 0
services/output_presenter.py

@@ -37,6 +37,10 @@ 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',))
+    HEADHAUL_DOCUMENT_TOOLS = frozenset(('query_headhaul_document_list',))
+    HEADHAUL_PREPARE_TOOLS = frozenset(('prepare_headhaul_document_upload',))
+    HEADHAUL_SAVE_TOOLS = frozenset(('save_headhaul_document',))
+    HEADHAUL_DELETE_TOOLS = frozenset(('delete_headhaul_document',))
     DETAIL_TOOLS = frozenset(('query_outbound_detail',))
     ORDER_DETAIL_TOOLS = frozenset(('query_order_detail',))
     OPTION_TOOLS = frozenset((
@@ -47,6 +51,7 @@ class OutputPresenter:
         'list_destination_trailer_filter_options',
         'list_order_abnormal_filter_options',
         'list_receive_volume_filter_options',
+        'list_headhaul_document_filter_options',
     ))
     EXPORT_TOOLS = frozenset((
         'export_pending_outbound_orders',
@@ -65,6 +70,10 @@ class OutputPresenter:
         | ORDER_ABNORMAL_TOOLS
         | RECEIVE_VOLUME_TOOLS
         | CONTAINER_TIMELINESS_TOOLS
+        | HEADHAUL_DOCUMENT_TOOLS
+        | HEADHAUL_PREPARE_TOOLS
+        | HEADHAUL_SAVE_TOOLS
+        | HEADHAUL_DELETE_TOOLS
         | OPTION_TOOLS | EXPORT_TOOLS | TASK_TOOLS
     )
 
@@ -573,6 +582,14 @@ class OutputPresenter:
         ('merchandiser_name', '客户经理'), ('department_name', '事业部'),
         ('product_name', '物流产品'),
     )
+    HEADHAUL_DOCUMENT_COLUMNS = (
+        ('document_type_name', '文档类型'),
+        ('entry_no_text', '录入单号快照'),
+        ('original_name', '文件名'),
+        ('safe_url', '安全链接'),
+        ('operator_name', '操作人'),
+        ('operate_time', '操作时间'),
+    )
     CONTAINER_TIMELINESS_COLUMNS = (
         ('ship_company', '船公司'), ('bl_number', '提单号'),
         ('container_code', '柜号'), ('container_type', '柜型'),
@@ -782,6 +799,42 @@ class OutputPresenter:
             'departure_time_start': '干线实际出发时间开始',
             'departure_time_end': '干线实际出发时间结束',
         },
+        'query_headhaul_document_list': {
+            'document_type_id': '文档类型',
+            'original_name': '文件名',
+            'declaration_numbers': '报关单号',
+            'sys_bl_numbers': '系统提单号',
+            'container_codes': '柜号',
+            'order_numbers': '订单号',
+            'clearance_numbers': '清关单号',
+            'customer_names': '客户',
+            'so_numbers': 'SO号',
+            'bl_numbers': '提单号',
+            'trailer_numbers': '拖车单',
+            'truck_bol_numbers': '卡车单号',
+            'provider_names': '供应商',
+            'importer_names': '进口商',
+            'page': '页码',
+            'limit': '每页数量',
+        },
+        'list_headhaul_document_filter_options': {
+            'filter_type': '筛选类别',
+            'keyword': '显示名称',
+            'page': '页码',
+            'limit': '每页数量',
+        },
+        'prepare_headhaul_document_upload': {
+            'document_type_id': '文档类型',
+        },
+        'save_headhaul_document': {
+            'document_type_id': '文档类型',
+            'ticket_ref': '上传凭证',
+            'link_url': '外链',
+            'entries': '录入单号',
+        },
+        'delete_headhaul_document': {
+            'document_ref': '文档身份',
+        },
         'export_receivable_cost_list': {
             'reference_numbers': '参考号',
             'tracking_numbers': '跟踪号',
@@ -908,6 +961,16 @@ class OutputPresenter:
             return self._present_container_timeliness_list(
                 data, tool_result.get('meta'), meta
             )
+        if tool_name in self.HEADHAUL_DOCUMENT_TOOLS:
+            return self._present_headhaul_document_list(
+                data, tool_result.get('meta'), meta
+            )
+        if tool_name in self.HEADHAUL_PREPARE_TOOLS:
+            return self._present_headhaul_prepare(data, meta)
+        if tool_name in self.HEADHAUL_SAVE_TOOLS:
+            return self._present_headhaul_save(data, meta)
+        if tool_name in self.HEADHAUL_DELETE_TOOLS:
+            return self._present_headhaul_delete(data, meta)
         if tool_name in self.TABLE_TOOLS:
             return self._present_table(
                 tool_name,
@@ -1850,6 +1913,113 @@ class OutputPresenter:
         }
         return self._success_result(content, self._render_table(content), meta)
 
+    def _present_headhaul_document_list(self, data, raw_meta, meta):
+        if set(data) != {'columns', 'records'}:
+            return self._format_error(meta)
+        expected = list(self.HEADHAUL_DOCUMENT_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):
+                return self._format_error(meta)
+            if set(record) != set(expected_keys + ['document_ref']):
+                return self._format_error(meta)
+            document_ref = record.get('document_ref')
+            if not isinstance(document_ref, str) or not document_ref.strip():
+                return self._format_error(meta)
+            if not document_ref.startswith('mhdd_'):
+                return self._format_error(meta)
+            row = []
+            for key in expected_keys:
+                value = record[key]
+                if key == 'safe_url':
+                    if value != '' and not self._valid_http_url(value):
+                        return self._format_error(meta)
+                    row.append(value)
+                    continue
+                if not isinstance(value, str):
+                    return self._format_error(meta)
+                row.append(value)
+            row.append(document_ref.strip())
+            rows.append(row)
+        headers = [{'label': name} for _, name in expected]
+        headers.append({'label': '文档身份'})
+        content = {
+            'headers': headers,
+            'rows': rows,
+            'pagination': pagination,
+        }
+        return self._success_result(content, self._render_table(content), meta)
+
+    def _present_headhaul_prepare(self, data, meta):
+        expected = {
+            'upload_token', 'upload_url', 'expires_in',
+        }
+        if set(data) != expected:
+            return self._format_error(meta)
+        upload_token = data.get('upload_token')
+        upload_url = data.get('upload_url')
+        expires_in = data.get('expires_in')
+        if (
+            not isinstance(upload_token, str) or not upload_token.strip()
+            or not isinstance(upload_url, str)
+            or upload_url != '/mcp/upload-headhaul-document'
+            or isinstance(expires_in, bool)
+            or not isinstance(expires_in, int)
+            or expires_in <= 0
+        ):
+            return self._format_error(meta)
+        from services.headhaul_document_app import public_upload_url
+        posted_url = public_upload_url()
+        content = {
+            'upload_token': upload_token.strip(),
+            'upload_url': posted_url,
+            'expires_in': expires_in,
+        }
+        result_meta = dict(meta)
+        result_meta['upload_token'] = upload_token.strip()
+        text = (
+            '请把对话窗口里已添加的文件 POST 到上传地址,并带上上传令牌。'
+            '不要使用选文件框,也不要把文件放进工具参数。'
+            '上传地址:{0}'
+        ).format(posted_url)
+        return self._success_result(content, text, result_meta)
+
+    def _present_headhaul_save(self, data, meta):
+        if set(data) != {'document_ref'}:
+            return self._format_error(meta)
+        document_ref = data.get('document_ref')
+        if (
+            not isinstance(document_ref, str)
+            or not document_ref.strip()
+            or not document_ref.startswith('mhdd_')
+        ):
+            return self._format_error(meta)
+        content = {'文档身份': document_ref.strip(), '结果': '已保存'}
+        return self._success_result(content, '头程文档已保存', meta)
+
+    def _present_headhaul_delete(self, data, meta):
+        if set(data) != {'result'}:
+            return self._format_error(meta)
+        result = data.get('result')
+        if result != '已删除':
+            return self._format_error(meta)
+        content = {'结果': '已删除'}
+        return self._success_result(content, '头程文档已删除', meta)
+
     @staticmethod
     def _valid_volume_cell(value):
         return (

+ 36 - 0
services/scoped_api_client.py

@@ -32,3 +32,39 @@ class ScopedApiClient:
             'X-Request-Id': str(request_id or '').strip(),
         }
         return self.transport.post_json(url, {}, headers, self.timeout)
+
+    def upload_headhaul_document(
+        self,
+        token,
+        upload_token,
+        filename,
+        file_bytes,
+        content_type='',
+        request_id='',
+        client_ip='',
+    ):
+        token = str(token or '').strip()
+        if not token:
+            raise RuntimeError('mcp token missing')
+        url = self.base_url + '/mcp/tools/uploadHeadhaulDocument'
+        headers = {
+            'Authorization': 'Bearer {0}'.format(token),
+            'X-MCP-Tool-Code': 'prepare_headhaul_document_upload',
+            'X-Request-Id': str(request_id or '').strip(),
+        }
+        client_ip = str(client_ip or '').strip()
+        if client_ip:
+            headers['X-MCP-Client-IP'] = client_ip
+        return self.transport.post_multipart(
+            url,
+            {'upload_token': upload_token},
+            {
+                'file': (
+                    filename,
+                    file_bytes,
+                    content_type or 'application/octet-stream',
+                ),
+            },
+            headers,
+            max(self.timeout, 120),
+        )

+ 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(39, len(local))
+        self.assertEqual(38, 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(39, 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(39, 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(39, 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(39, 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(39, len(local))
+        self.assertEqual(38, 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(39, len(local))
         self.assertIn('export_pallet_data', local)
-        self.assertEqual(33, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(38, 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(39, len(local))
         self.assertIn('export_receivable_cost_list', local)
-        self.assertEqual(33, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(38, len(OutputPresenter.SAFE_TOOLS))
 
     def test_presenter_reuses_queued_export_contract(self):
         presented = OutputPresenter().present(

File diff suppressed because it is too large
+ 1277 - 0
tests/test_headhaul_document_tools.py


+ 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(39, len(local))
+        self.assertEqual(38, 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(39, 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(38, 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(39, len(local))
+        self.assertEqual(38, len(OutputPresenter.SAFE_TOOLS))
         self.assertEqual(
             (
                 'query_payable_cost_list',

+ 19 - 0
tests/test_public_server_coverage.py

@@ -232,6 +232,25 @@ class HttpHandlerIntegrationTest(unittest.TestCase):
         self.assertIn('result', body)
         self.assertIn('protocolVersion', body['result'])
 
+    def test_options_upload_returns_cors(self):
+        import urllib.request
+        req = urllib.request.Request(
+            self.base_url + '/mcp/upload-headhaul-document',
+            method='OPTIONS',
+        )
+        with urllib.request.urlopen(req, timeout=5) as resp:
+            self.assertEqual(204, resp.status)
+            self.assertEqual('*', resp.headers.get('Access-Control-Allow-Origin'))
+
+    def test_options_unknown_path_returns_404(self):
+        import urllib.request
+        req = urllib.request.Request(self.base_url + '/wrong', method='OPTIONS')
+        try:
+            urllib.request.urlopen(req, timeout=5)
+            self.fail('Expected 404')
+        except Exception as e:
+            self.assertEqual(404, e.code)
+
 
 if __name__ == '__main__':
     unittest.main()

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

+ 30 - 0
tests/test_tool_description_boundaries.py

@@ -31,6 +31,15 @@ from tools.query_container_timeliness_list import QueryContainerTimelinessListTo
 from tools.export_container_timeliness_report import (
     ExportContainerTimelinessReportTool,
 )
+from tools.query_headhaul_document_list import QueryHeadhaulDocumentListTool
+from tools.list_headhaul_document_filter_options import (
+    ListHeadhaulDocumentFilterOptionsTool,
+)
+from tools.prepare_headhaul_document_upload import (
+    PrepareHeadhaulDocumentUploadTool,
+)
+from tools.save_headhaul_document import SaveHeadhaulDocumentTool
+from tools.delete_headhaul_document import DeleteHeadhaulDocumentTool
 from tools.query_track import QueryTrackTool
 
 
@@ -56,6 +65,11 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             ListReceiveVolumeFilterOptionsTool(),
             QueryContainerTimelinessListTool(),
             ExportContainerTimelinessReportTool(),
+            QueryHeadhaulDocumentListTool(),
+            ListHeadhaulDocumentFilterOptionsTool(),
+            PrepareHeadhaulDocumentUploadTool(),
+            SaveHeadhaulDocumentTool(),
+            DeleteHeadhaulDocumentTool(),
         )
 
         for tool in tools:
@@ -78,6 +92,7 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             QueryOrderAbnormalListTool(),
             QueryContainerTimelinessListTool(),
             ExportContainerTimelinessReportTool(),
+            QueryHeadhaulDocumentListTool(),
         )
 
         for tool in tools:
@@ -127,6 +142,21 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             ExportContainerTimelinessReportTool(): (
                 '明确要求导出', '柜子时效', 'query_export_task',
             ),
+            QueryHeadhaulDocumentListTool(): (
+                '头程文档', 'admin/document/index', 'query_customs_declaration_files',
+            ),
+            ListHeadhaulDocumentFilterOptionsTool(): (
+                'query_headhaul_document_list', '禁止猜测', '报关资料',
+            ),
+            PrepareHeadhaulDocumentUploadTool(): (
+                '对话窗口', '选文件框', '工具参数',
+            ),
+            SaveHeadhaulDocumentTool(): (
+                'ticket_ref', '外链', 'OSS路径',
+            ),
+            DeleteHeadhaulDocumentTool(): (
+                'document_ref', '盲删', '批量删除',
+            ),
         }
 
         for tool, phrases in expected.items():

+ 49 - 0
tools/delete_headhaul_document.py

@@ -0,0 +1,49 @@
+class DeleteHeadhaulDocumentTool:
+    name = 'delete_headhaul_document'
+    route_path = '/mcp/tools/deleteHeadhaulDocument'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户要删除一份已经查到的头程文档时,必须使用查询返回的'
+                'document_ref。一次只删一份,硬删文档行、关联行和OSS对象。'
+                '禁止使用:按文件名或单号盲删、批量删除、行内编辑改成删除。'
+                '参数只用于工具内部调用;最终回答只能展示中文业务名称。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'document_ref': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 500,
+                        'description': '查询返回的文档身份,不能手填内部ID。',
+                    },
+                },
+                'required': ['document_ref'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        document_ref,
+        request_id='rq_delete_headhaul_document',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for delete_headhaul_document'
+            )
+        reference = str(document_ref or '').strip()
+        if not reference or len(reference) > 500:
+            raise ValueError('document_ref is invalid')
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            {'document_ref': reference},
+            request_id,
+        )

+ 78 - 0
tools/headhaul_document_common.py

@@ -0,0 +1,78 @@
+NUMBER_FIELDS = (
+    'declaration_numbers',
+    'sys_bl_numbers',
+    'container_codes',
+    'order_numbers',
+    'clearance_numbers',
+    'customer_names',
+    'so_numbers',
+    'bl_numbers',
+    'trailer_numbers',
+    'truck_bol_numbers',
+    'provider_names',
+    'importer_names',
+)
+
+NUMBER_FIELD_LABELS = {
+    'declaration_numbers': '报关单号',
+    'sys_bl_numbers': '系统提单号',
+    'container_codes': '柜号',
+    'order_numbers': '订单号',
+    'clearance_numbers': '清关单号',
+    'customer_names': '客户',
+    'so_numbers': 'SO号',
+    'bl_numbers': '提单号',
+    'trailer_numbers': '拖车单',
+    'truck_bol_numbers': '卡车单号',
+    'provider_names': '供应商',
+    'importer_names': '进口商',
+}
+
+FILTER_TYPES = ('查询文档类型', '添加文档类型', '录入单号种类')
+UPLOAD_PATH = '/mcp/upload-headhaul-document'
+
+
+def number_list(values, field):
+    if not isinstance(values, list) or not values:
+        raise ValueError(field + ' is invalid')
+    result = []
+    for value in values:
+        if not isinstance(value, str):
+            raise ValueError(field + ' is invalid')
+        value = value.strip()
+        if not value or len(value) > 100:
+            raise ValueError(field + ' is invalid')
+        if value not in result:
+            result.append(value)
+    if len(result) > 200:
+        raise ValueError('at most 200 numbers are allowed')
+    return result
+
+
+def bounded_integer(value, field):
+    if isinstance(value, bool) or not isinstance(value, int):
+        raise ValueError(field + ' is invalid')
+    if value < 1 or value > 100:
+        raise ValueError(field + ' is invalid')
+    return value
+
+
+def number_properties():
+    properties = {}
+    for field, label in NUMBER_FIELD_LABELS.items():
+        properties[field] = {
+            'type': 'array',
+            'minItems': 1,
+            'maxItems': 200,
+            'items': {
+                'type': 'string',
+                'minLength': 1,
+                'maxLength': 100,
+                'pattern': '.*\\S.*',
+            },
+            'description': (
+                '{0}数组。仅当用户明确说{0}时使用;不得与其他号码种类混放。'
+                .format(label)
+            ),
+        }
+    return properties

+ 79 - 0
tools/list_headhaul_document_filter_options.py

@@ -0,0 +1,79 @@
+from tools.headhaul_document_common import FILTER_TYPES, bounded_integer
+
+
+class ListHeadhaulDocumentFilterOptionsTool:
+    name = 'list_headhaul_document_filter_options'
+    route_path = '/mcp/tools/listHeadhaulDocumentFilterOptions'
+    FILTER_TYPES = FILTER_TYPES
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户需要筛选或添加头程文档时,先按中文筛选类别调用本工具,'
+                '取得后台同源的中文名称和可传值,再把选中的值传给'
+                'query_headhaul_document_list、prepare_headhaul_document_upload或'
+                'save_headhaul_document。查询文档类型用于列表;添加文档类型用于签发和保存,'
+                '没有写入权限时不可用;录入单号种类用于确认号码类型。'
+                '禁止使用:本工具不返回文档业务数据,禁止猜测任何筛选值,'
+                '也禁止用于报关资料或订单附件。参数只用于工具内部调用;'
+                '最终回答只能展示中文业务名称;描述筛选条件时不得展示筛选字段的英文参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'filter_type': {
+                        'type': 'string',
+                        'enum': list(self.FILTER_TYPES),
+                        'description': '要查询的中文筛选类别。',
+                    },
+                    'keyword': {
+                        'type': 'string', 'maxLength': 100,
+                        'description': '按中文显示名称搜索;可不传以分页列出全部。',
+                    },
+                    'page': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 20,
+                    },
+                },
+                'required': ['filter_type'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        filter_type,
+        keyword='',
+        page=1,
+        limit=20,
+        request_id='rq_list_headhaul_document_filter_options',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for list_headhaul_document_filter_options'
+            )
+        filter_type = str(filter_type or '').strip()
+        if filter_type not in self.FILTER_TYPES:
+            raise ValueError('unsupported filter_type')
+        keyword = str(keyword or '').strip()
+        if len(keyword) > 100:
+            raise ValueError('keyword is too long')
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            {
+                'filter_type': filter_type,
+                'keyword': keyword,
+                'page': bounded_integer(page, 'page'),
+                'limit': bounded_integer(limit, 'limit'),
+            },
+            request_id,
+        )

+ 56 - 0
tools/prepare_headhaul_document_upload.py

@@ -0,0 +1,56 @@
+class PrepareHeadhaulDocumentUploadTool:
+    name = 'prepare_headhaul_document_upload'
+    route_path = '/mcp/tools/prepareHeadhaulDocumentUpload'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户要上传头程文档文件且已在对话窗口添加该文件时,'
+                '先确认添加文档类型,再调用本工具签发一次性上传槽位。'
+                '签发后由宿主把窗口里的文件 POST 到返回的上传地址,并带上upload_token。'
+                '禁止使用:选文件框、把文件内容放进工具参数、打开独立上传页、'
+                '改走报关资料或订单附件、让用户去 home 后台上传。'
+                '参数只用于工具内部调用;最终回答只能展示中文业务名称。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'document_type_id': {
+                        'type': 'integer',
+                        'minimum': 1,
+                        'description': (
+                            '要添加的文档类型。必须先调用'
+                            'list_headhaul_document_filter_options并选择“添加文档类型”。'
+                        ),
+                    },
+                },
+                'required': ['document_type_id'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        document_type_id,
+        request_id='rq_prepare_headhaul_document_upload',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for prepare_headhaul_document_upload'
+            )
+        if (
+            isinstance(document_type_id, bool)
+            or not isinstance(document_type_id, int)
+            or document_type_id < 1
+        ):
+            raise ValueError('document_type_id is invalid')
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            {'document_type_id': document_type_id},
+            request_id,
+        )

+ 97 - 0
tools/query_headhaul_document_list.py

@@ -0,0 +1,97 @@
+from tools.headhaul_document_common import (
+    NUMBER_FIELDS,
+    bounded_integer,
+    number_list,
+    number_properties,
+)
+
+
+class QueryHeadhaulDocumentListTool:
+    name = 'query_headhaul_document_list'
+    route_path = '/mcp/tools/queryHeadhaulDocumentList'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        properties = {
+            'document_type_id': {
+                'type': 'integer',
+                'minimum': 1,
+                'description': (
+                    '文档类型。必须先调用list_headhaul_document_filter_options'
+                    '并选择“查询文档类型”取得可传值。'
+                ),
+            },
+            'original_name': {
+                'type': 'string',
+                'minLength': 1,
+                'maxLength': 255,
+                'description': '完整原始文件名,精确匹配,不是模糊搜索。',
+            },
+        }
+        properties.update(number_properties())
+        properties['page'] = {
+            'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 1,
+        }
+        properties['limit'] = {
+            'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 20,
+        }
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户要看头程文档列表时,必须先有文档类型、完整文件名'
+                '或已经标明种类的录入单号之一,再查询当前页。对照后台'
+                'admin/document/index,一行一份文件或外链。号码类型不明确时必须先询问用户,'
+                '不得根据号码格式猜测,不得跨字段或跨工具试查,也不得改走'
+                'query_customs_declaration_files或订单附件。'
+                '禁止使用:空扫全表、混查单号框、行内编辑、把文件放进工具参数。'
+                '参数只用于工具内部调用;最终回答只能展示中文业务名称;'
+                '描述筛选条件时不得展示筛选字段的英文参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': properties,
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        document_type_id=None,
+        original_name=None,
+        page=1,
+        limit=20,
+        request_id='rq_query_headhaul_document_list',
+        **numbers
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for query_headhaul_document_list'
+            )
+        payload = {}
+        if document_type_id is not None:
+            if isinstance(document_type_id, bool) or not isinstance(
+                document_type_id, int
+            ) or document_type_id < 1:
+                raise ValueError('document_type_id is invalid')
+            payload['document_type_id'] = document_type_id
+        if original_name is not None:
+            name = str(original_name).strip()
+            if not name or len(name) > 255:
+                raise ValueError('original_name is invalid')
+            payload['original_name'] = name
+        for field in NUMBER_FIELDS:
+            if field in numbers and numbers[field] is not None:
+                payload[field] = number_list(numbers[field], field)
+        if (
+            'document_type_id' not in payload
+            and 'original_name' not in payload
+            and not any(field in payload for field in NUMBER_FIELDS)
+        ):
+            raise ValueError('locator is required')
+        payload['page'] = bounded_integer(page, 'page')
+        payload['limit'] = bounded_integer(limit, 'limit')
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id,
+        )

+ 142 - 0
tools/save_headhaul_document.py

@@ -0,0 +1,142 @@
+class SaveHeadhaulDocumentTool:
+    name = 'save_headhaul_document'
+    route_path = '/mcp/tools/saveHeadhaulDocument'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:上传成功返回ticket_ref之后,或用户只要外链时,'
+                '用本工具保存一份新的头程文档。必须带添加文档类型和至少一条已确认种类的录入单号。'
+                'ticket_ref和外链不能同时提交。没有行内编辑;要改只能删除后重加。'
+                '禁止使用:手填OSS路径、混放号码种类、把文件放进工具参数。'
+                '参数只用于工具内部调用;最终回答只能展示中文业务名称。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'document_type_id': {
+                        'type': 'integer',
+                        'minimum': 1,
+                        'description': (
+                            '要添加的文档类型。必须先调用'
+                            'list_headhaul_document_filter_options并选择“添加文档类型”。'
+                        ),
+                    },
+                    'ticket_ref': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 500,
+                        'description': '上传成功后的一次性凭证,不能手填。',
+                    },
+                    'link_url': {
+                        'type': 'string',
+                        'minLength': 8,
+                        'maxLength': 1000,
+                        'description': 'http或https外链;与ticket_ref二选一。',
+                    },
+                    'entries': {
+                        'type': 'array',
+                        'minItems': 1,
+                        'items': {
+                            'type': 'object',
+                            'properties': {
+                                'entry_no_type': {
+                                    'type': 'integer',
+                                    'minimum': 1,
+                                    'maximum': 24,
+                                },
+                                'numbers': {
+                                    'type': 'array',
+                                    'minItems': 1,
+                                    'maxItems': 50,
+                                    'items': {
+                                        'type': 'string',
+                                        'minLength': 1,
+                                        'maxLength': 100,
+                                    },
+                                },
+                            },
+                            'required': ['entry_no_type', 'numbers'],
+                            'additionalProperties': False,
+                        },
+                        'description': '录入单号。entry_no_type来自录入单号种类筛选项。',
+                    },
+                },
+                'required': ['document_type_id', 'entries'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        document_type_id,
+        entries,
+        ticket_ref=None,
+        link_url=None,
+        request_id='rq_save_headhaul_document',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for save_headhaul_document'
+            )
+        if (
+            isinstance(document_type_id, bool)
+            or not isinstance(document_type_id, int)
+            or document_type_id < 1
+        ):
+            raise ValueError('document_type_id is invalid')
+        ticket = '' if ticket_ref is None else str(ticket_ref).strip()
+        link = '' if link_url is None else str(link_url).strip()
+        if (ticket != '' and link != '') or (ticket == '' and link == ''):
+            raise ValueError('ticket_ref and link_url are exclusive')
+        if link and not (
+            link.lower().startswith('http://')
+            or link.lower().startswith('https://')
+        ):
+            raise ValueError('link_url is invalid')
+        payload = {
+            'document_type_id': document_type_id,
+            'entries': self._entries(entries),
+        }
+        if ticket:
+            payload['ticket_ref'] = ticket
+        if link:
+            payload['link_url'] = link
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id,
+        )
+
+    @staticmethod
+    def _entries(values):
+        if not isinstance(values, list) or not values:
+            raise ValueError('entries is invalid')
+        result = []
+        for item in values:
+            if not isinstance(item, dict):
+                raise ValueError('entries is invalid')
+            type_id = item.get('entry_no_type')
+            numbers = item.get('numbers')
+            if (
+                isinstance(type_id, bool)
+                or not isinstance(type_id, int)
+                or type_id < 1
+                or type_id > 24
+            ):
+                raise ValueError('entry_no_type is invalid')
+            if not isinstance(numbers, list) or not numbers or len(numbers) > 50:
+                raise ValueError('numbers is invalid')
+            clean = []
+            for number in numbers:
+                if not isinstance(number, str):
+                    raise ValueError('numbers is invalid')
+                number = number.strip()
+                if not number or len(number) > 100:
+                    raise ValueError('numbers is invalid')
+                if number not in clean:
+                    clean.append(number)
+            result.append({'entry_no_type': type_id, 'numbers': clean})
+        return result