Forráskód Böngészése

获取排舱单列表,获取排舱单详情

jackson 1 hete
szülő
commit
a07b0cf765

+ 2 - 2
.env.example

@@ -50,5 +50,5 @@ FMS_GATEWAY_SESSION_TTL_SECONDS=2592000
 # Redis prefix for public mode (must differ from local mode to avoid conflicts)
 FMS_REDIS_PREFIX=fms:mcp:gateway:
 
-# Rate limiting (default: 60 requests/minute per IP)
-# Modify in public_server.py if needed, or disable with enable_rate_limit=False
+# tools/call rate limiting (default: 60 requests/minute per device session and tool)
+# initialize and tools/list are not rate limited

+ 6 - 1
.gitignore

@@ -1,5 +1,10 @@
 __pycache__
-/project-docs
+/project-docs/*
+!/project-docs/overview.md
+!/project-docs/requirements.md
+!/project-docs/tech-specs.md
+!/project-docs/user-structure.md
+!/project-docs/timeline.md
 .env
 
 # Coverage reports

+ 71 - 0
AGENTS.md

@@ -0,0 +1,71 @@
+# MCP Gateway Engineering Guide
+
+## Ownership
+
+This repository owns the Python MCP Gateway boundary:
+
+- MCP tool names, descriptions, input Schema, local/public registration, and HTTP route mapping.
+- MCP protocol adaptation, safe display DTOs, request/session forwarding, and Gateway operations.
+- The cross-repository integration entry point and operator-facing runbook.
+
+Business ownership remains outside this repository:
+
+- `Y:/fmsoperate`: tool routes, Validate/Logic/Model, registry state, business permissions, data scope, exports, and access logs.
+- `Y:/base`: employee identity, Gateway device sessions, MCP token lifecycle, and authentication decisions.
+- `Y:/home`: employee UI and thin proxy only.
+- `Y:/settlement_tests`: cross-repository PHP contract tests.
+
+Other repositories should link to this repository's README and project docs instead of copying the Gateway contract.
+
+## Hard Boundaries
+
+- Keep the Gateway thin. It must not query business databases or reproduce logistics rules.
+- Never decide employee, company, menu, or data permissions in Python. Forward the authenticated context to ThinkPHP.
+- Never accept caller-supplied employee, company, role, permission, or internal identity overrides.
+- Public requests use a `GWS_xxx` device credential to load a Redis Gateway session and its server-side `mcp_token`.
+- Never log tokens, full Gateway credentials, cookies, authorization headers, or raw backend responses.
+- Local and public tool registries must stay identical. Actual visibility is the intersection with the enabled-tool list returned by fmsoperate; failures close access.
+- `query_order` is the legacy display exception. All other registered tools use `services/output_presenter.py` and must fail closed on unknown fields, malformed responses, or unknown errors.
+- `initialize` and `tools/list` are not rate limited. `tools/call` is limited per authenticated device session and tool.
+- The retired authorization-code binding flow must not return to the runtime without a new security review.
+
+## Documentation Routes
+
+- `README.md`: current tool catalog, routes, configuration, operations, and troubleshooting.
+- `project-docs/overview.md`: current system summary and cross-repository ownership map.
+- `project-docs/requirements.md`: current protocol, security, and output contracts.
+- `project-docs/tech-specs.md`: architecture, session, Presenter, logging, and rate-limit details.
+- `project-docs/user-structure.md`: user, request, and developer flows plus directory map.
+- `project-docs/timeline.md`: short milestones and current operational handoff only.
+- `docs/superpowers/specs/`: dated design decisions and ADR-like context.
+
+The following are not authoritative and are not read by default:
+
+- `.planning/**`: temporary session state; archive or remove from the active workspace after delivery.
+- Completed `docs/superpowers/plans/**`: execution history, not the current contract.
+When a temporary plan finishes, promote durable facts into README/project docs, record the milestone once, then retire the plan. Do not keep completed checklists as active instructions.
+
+## Change Rules
+
+- Update tool metadata, both registries, CLI forwarding, Presenter mapping, and tests together when adding or changing a tool.
+- Keep tool selection zero-inference: ambiguous result intent or number type requires a user question before any call.
+- Keep `request_id` across Gateway, PHP, responses, and logs; public ingress generates the trusted trace ID.
+- Changes that affect fmsoperate or base contracts require matching changes and tests in those owning repositories.
+- Do not run registry SQL or restart a shared Gateway process unless the task explicitly authorizes that operational action.
+
+## Verification
+
+Run from `Y:/mcp`:
+
+```powershell
+python -m unittest discover -s tests -p "test_*.py"
+python -m coverage run -m unittest discover -s tests -p "test_*.py"
+python -m coverage report -m --fail-under=100
+git diff --check
+```
+
+Verify the code registry without relying on documentation:
+
+```powershell
+python -c "from app import GatewayApp; print('\n'.join(GatewayApp().registered_tool_names()))"
+```

+ 0 - 31
COVERAGE_GUIDE.md

@@ -1,31 +0,0 @@
-# MCP Gateway Coverage Guide
-
-Use this guide to run coverage for the current direct Gateway session implementation.
-
-## Run Coverage
-
-```powershell
-python run_coverage.py
-python run_coverage.py --open
-```
-
-Manual form:
-
-```powershell
-python -m coverage run -m unittest discover -s tests -p "test_*.py"
-python -m coverage report -m --fail-under=100
-python -m coverage html
-```
-
-## Current Scope
-
-Coverage should focus on:
-
-- Public Gateway request handling.
-- Redis Gateway session storage.
-- Request-scoped API forwarding.
-- Token refresh/revoke compatibility.
-- Query tool registration and JSON-RPC behavior.
-- OutputPresenter field hiding, fail-closed errors, cross-tool values, and legacy `query_order` behavior.
-
-The retired authorization-code binding files and tools should not appear in the runtime coverage list.

+ 63 - 13
README.md

@@ -1,6 +1,6 @@
 # Python MCP Gateway
 
-这是物流系统给 Workbuddy 使用的轻量 MCP Gateway。它负责接收 MCP 请求、管理员工授权会话,并把工具调用转发到现有 ThinkPHP 项目的 MCP 接口。
+这是物流系统给 Workbuddy 使用的轻量 MCP Gateway。它负责接收 MCP 请求、解析并转发设备会话,并把工具调用转发到现有 ThinkPHP 项目的 MCP 接口;员工认证与设备会话签发仍由 `base` 负责
 
 Gateway 保持“薄网关”边界:
 
@@ -17,30 +17,65 @@ Gateway 保持“薄网关”边界:
 - 支持 Redis token/session 存储。
 - 支持文件 token store 作为开发排障兜底。
 - 不再支持授权码绑定工具;正式接入只使用后台生成的 `GWS_xxx` 设备配置。
-- 支持 `query_order` 订单查询工具。
-- 支持 `query_track` 轨迹查询工具。
-- 支持 `query_order_exact` 精准订单查询和 `list_order_filter_options` 权限筛选项。
-- 支持 `query_customs_declaration_files` 按排舱单号或订单号批量查询报关资料文件链接。
-- 支持未排舱订单导出、导出筛选项和按排舱单号、柜号、提单号或 SO 号四选一的省外进港资料导出。
+- 本地 stdio 与公网 HTTP 注册同一组 11 个查询、筛选和导出工具。
+- 支持订单、轨迹、报关资料、排舱列表与详情查询。
+- 支持订单与排舱筛选项,以及未排舱订单和省外进港资料导出。
 - 支持 MCP `initialize`、`tools/list`、`tools/call`。
 - 公网模式支持 `gateway_session_id` 请求级隔离、Redis Gateway session、审计日志和基础限流。
 
+## 工具目录
+
+`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 11 个候选工具:
+
+| MCP 工具 | 用途 | ThinkPHP 路由 | 最终展示 |
+|---|---|---|---|
+| `query_order` | 普通订单列表查询 | `/mcp/tools/queryOrder` | 旧协议兼容 |
+| `query_track` | 按订单或物流号码查询轨迹 | `/mcp/tools/queryTrack` | 安全表格 |
+| `query_order_exact` | 按明确号码类型精准查询订单 | `/mcp/tools/queryOrderExact` | 安全表格 |
+| `query_customs_declaration_files` | 按订单号或排舱单号查询报关资料 | `/mcp/tools/queryCustomsDeclarationFiles` | 安全表格 |
+| `query_outbound_list` | 按业务阶段和筛选条件查询排舱列表 | `/mcp/tools/queryOutboundList` | 安全表格 |
+| `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` | 安全文件链接 |
+| `list_pending_outbound_export_filter_options` | 查询未排舱导出筛选项 | `/mcp/tools/listPendingOutboundExportFilterOptions` | 安全筛选项 |
+
+这 11 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
+
+MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明或注册集合变化后,必须重启对应 Gateway 进程并让客户端重新连接,客户端才会重新获取工具列表。
+
 ## 工具结果展示协议
 
 Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内部接口和工具入参:
 
 - `query_order` 保持原有 `columns + records` 结果和文本展示,不参与本次转换。
-- `query_order_exact`、`query_track` 对外使用中文 `headers + rows + pagination`,不返回内部字段键。
-- `query_customs_declaration_files` 对外只展示排舱单号、订单号、文件名、文件类型、文件链接和分页信息。
-- 两个筛选项工具保留“可传值、显示名称、业务编码”,确保返回值可继续传给查询或导出工具。
+- `services/output_presenter.py` 对其余 10 个安全工具执行显式白名单展示。
+- `query_order_exact`、`query_track`、`query_customs_declaration_files`、`query_outbound_list` 对外使用中文 `headers + rows + pagination`,不返回内部字段键。
+- `query_outbound_detail` 使用中文 `summary + details + pagination` 两层结构。
+- 三个筛选项工具保留“可传值、显示名称、业务编码”,确保返回值可继续传给查询或导出工具。
 - 两个导出工具返回 `files[].label + files[].url`,不暴露后端 `file_url` 键。
 - `request_id` 位于 MCP 结果 `_meta`;参数错误使用业务名称,未知异常不透传后端细节。
 - stdio 与 public 模式共用 `services/output_presenter.py`;未知工具或畸形响应关闭失败。
 
-详细设计见 `../base/project-docs/mcp-output-field-presentation-design.md`。
+详细设计见 [技术规格的 Presenter 分类](project-docs/tech-specs.md#presenter-分类)
 
 省外进港资料导出使用 `outbound_numbers`、`container_codes`、`bl_numbers`、`so_numbers` 四个号码数组之一,并同时提供 `file_type=NB/SH/MS`。用户未明确号码类型时,AI 必须先让用户从排舱单号、柜号、提单号、SO 号中选择,确认前不得调用;提单号指后台排舱单列表的普通提单号,SO 号对应 `fms_booking_detail.so_number`。
 
+排舱列表查询的 `outbound_status` 必填,用户未说明排舱阶段时必须先询问;`shipping_method` 可选,未提供时查询空运、海运和陆运全部,只有用户明确指定后才按运输方式筛选。
+
+## 文档入口
+
+- `AGENTS.md`:项目所有权、红线、协作规则和验证命令。
+- `README.md`:当前工具目录、接入配置、运行方式、运维与排障。
+- `project-docs/overview.md`:当前系统概况与跨仓职责。
+- `project-docs/requirements.md`:现行协议、安全、工具选择和输出合同。
+- `project-docs/tech-specs.md`:组件、会话、展示、限流、追踪与日志设计。
+- `project-docs/user-structure.md`:员工、调试、工具选择和跨仓开发流程。
+- `project-docs/timeline.md`:短里程碑和当前交接状态。
+
+临时规划与已完成的执行计划不作为现行合同;交付后应把稳定事实并回上述入口,并从活动工作区清理过程文件。
+
 ## 两种运行模式
 
 ### 本地 stdio 模式
@@ -209,6 +244,16 @@ Invoke-WebRequest http://127.0.0.1:8765/health -UseBasicParsing
 
 正常情况下会返回包含 `ok` 的 JSON。
 
+### 部署状态核对
+
+用户已确认 `Y:\fmsoperate\sql` 中 10 个 MCP SQL 和 `Y:\base\sql` 中 5 个 MCP SQL 均已执行。SQL 已执行不等于工具当前处于启用状态;实际可见工具仍以 fmsoperate 动态注册表的当前启用值为准。
+
+仓库静态内容无法证明运行中的 Gateway 是否已加载当前代码,也无法证明 Workbuddy 是否已重新连接。发布工具或 Schema 变更后,运维交接必须分别确认:
+
+1. 公网或本地 Gateway 已按实际部署方式重启。
+2. Workbuddy 已断开并重新连接,重新执行 `initialize` 和 `tools/list`。
+3. `tools/list` 返回的动态工具集合符合当前员工权限与预期启用状态。
+
 ## 常用命令
 
 查看工具列表:
@@ -357,10 +402,13 @@ FMS_TOKEN_STORE_PATH=C:/fms-mcp/.mcp_token.json
 
 ThinkPHP MCP 路由位于各后端项目的 `route/mcp/mcp_route.php`。
 
-Gateway 会追加以下路径:
+Gateway 会调用以下路径:
 
-- Auth:`/mcp/auth/exchange`、`/mcp/auth/refresh`、`/mcp/auth/revoke`
-- Tools:`/mcp/tools/listEnabledTools`、`queryOrder`、`queryTrack`、`queryOrderExact`、`listOrderFilterOptions`、`exportPendingOutboundOrders`、`listPendingOutboundExportFilterOptions`、`exportOutOfProvincePortData`,均位于 `/mcp/tools/` 下。
+- Auth:`/mcp/auth/exchange`、`/mcp/auth/refresh`、`/mcp/auth/revoke`。
+- 动态工具列表:`/mcp/tools/listEnabledTools`。
+- 查询:`/mcp/tools/queryOrder`、`/mcp/tools/queryTrack`、`/mcp/tools/queryOrderExact`、`/mcp/tools/queryCustomsDeclarationFiles`、`/mcp/tools/queryOutboundList`、`/mcp/tools/queryOutboundDetail`。
+- 筛选项:`/mcp/tools/listOutboundFilterOptions`、`/mcp/tools/listOrderFilterOptions`、`/mcp/tools/listPendingOutboundExportFilterOptions`。
+- 导出:`/mcp/tools/exportPendingOutboundOrders`、`/mcp/tools/exportOutOfProvincePortData`。
 
 `FMS_AUTH_BASE` 和 `FMS_TOOLS_BASE` 只配置域名或基础地址,不要包含 `/admin/mcp`。
 
@@ -391,4 +439,6 @@ Windows 下 Python 标准输出可能使用本机控制台编码。当前 `mcp_p
 - Gateway 不是业务系统,不直接查 MySQL。
 - Gateway 不替代 ThinkPHP 权限体系。
 - 公网 Gateway 是否能生产放量,取决于 Workbuddy 远程 MCP 是否能稳定传递 `gateway_session_id`。
+- fmsoperate 和 base 的 MCP SQL 已确认执行;工具当前动态启用值仍必须从运行环境核验。
+- 仓库无法证明运行中 Gateway 已重启或客户端已重连,发布交接必须显式确认这两项。
 - 写入型 MCP 工具需要单独安全评审后再开放。

+ 75 - 2
app.py

@@ -14,6 +14,7 @@ from services.gateway_session_store import GatewaySessionStore
 from services.scoped_api_client import ScopedApiClient
 from services.token_store import FileTokenStore, RedisSocketClient, RedisTokenStore
 from tools.list_order_filter_options import ListOrderFilterOptionsTool
+from tools.list_outbound_filter_options import ListOutboundFilterOptionsTool
 from tools.export_pending_outbound_orders import ExportPendingOutboundOrdersTool
 from tools.export_out_of_province_port_data import (
     ExportOutOfProvincePortDataTool,
@@ -26,6 +27,8 @@ from tools.query_customs_declaration_files import (
     QueryCustomsDeclarationFilesTool,
 )
 from tools.query_order_exact import QueryOrderExactTool
+from tools.query_outbound_detail import QueryOutboundDetailTool
+from tools.query_outbound_list import QueryOutboundListTool
 from tools.query_track import QueryTrackTool
 
 
@@ -55,6 +58,11 @@ class GatewayApp:
             'query_order_exact': QueryOrderExactTool(api_client=api_client),
             'query_customs_declaration_files':
                 QueryCustomsDeclarationFilesTool(api_client=api_client),
+            'query_outbound_list': QueryOutboundListTool(api_client=api_client),
+            'query_outbound_detail': QueryOutboundDetailTool(api_client=api_client),
+            'list_outbound_filter_options': ListOutboundFilterOptionsTool(
+                api_client=api_client
+            ),
             'list_order_filter_options': ListOrderFilterOptionsTool(
                 api_client=api_client
             ),
@@ -196,6 +204,7 @@ class GatewayApp:
         call_parser.add_argument('--reference-numbers', default='')
         call_parser.add_argument('--outbound-number', default='')
         call_parser.add_argument('--outbound-numbers', default='')
+        call_parser.add_argument('--bl-numbers', default='')
         call_parser.add_argument('--container-code', default='')
         call_parser.add_argument('--container-codes', default='')
         call_parser.add_argument('--so-number', default='')
@@ -206,7 +215,22 @@ class GatewayApp:
         call_parser.add_argument('--customer-ids', default='')
         call_parser.add_argument('--sales-id', type=int, default=0)
         call_parser.add_argument('--warehouse-ids', default='')
+        call_parser.add_argument('--warehouse-id', type=int, default=0)
         call_parser.add_argument('--department-id', type=int, default=0)
+        call_parser.add_argument('--outbound-status', type=int, default=0)
+        call_parser.add_argument('--shipping-method', type=int, default=0)
+        call_parser.add_argument('--is-direct-send', type=int, default=None)
+        call_parser.add_argument('--trailer-types', default='')
+        call_parser.add_argument('--declaration-types', default='')
+        call_parser.add_argument('--clearance-types', default='')
+        call_parser.add_argument('--closing-time-start', default='')
+        call_parser.add_argument('--closing-time-end', default='')
+        call_parser.add_argument('--est-loading-time-start', default='')
+        call_parser.add_argument('--est-loading-time-end', default='')
+        call_parser.add_argument('--create-date-start', default='')
+        call_parser.add_argument('--create-date-end', default='')
+        call_parser.add_argument('--loading-time-start', default='')
+        call_parser.add_argument('--loading-time-end', default='')
         call_parser.add_argument('--inbound-date-start', default='')
         call_parser.add_argument('--inbound-date-end', default='')
         call_parser.add_argument('--outbound-date-start', default='')
@@ -317,11 +341,60 @@ class GatewayApp:
                     tool_args['order_numbers'] = parse_string_list(
                         args.order_numbers
                     )
-            elif args.tool == 'list_order_filter_options':
+            elif args.tool == 'query_outbound_list':
+                outbound_number_lists = {
+                    'outbound_numbers': args.outbound_numbers,
+                    'order_numbers': args.order_numbers,
+                    'container_codes': args.container_codes,
+                    'so_numbers': args.so_numbers,
+                    'bl_numbers': args.bl_numbers,
+                }
+                for field, value in outbound_number_lists.items():
+                    if value:
+                        tool_args[field] = parse_string_list(value)
+                outbound_mode_lists = {
+                    'trailer_types': args.trailer_types,
+                    'declaration_types': args.declaration_types,
+                    'clearance_types': args.clearance_types,
+                }
+                for field, value in outbound_mode_lists.items():
+                    if value:
+                        tool_args[field] = parse_int_list(value)
+                outbound_dates = {
+                    'closing_time_start': args.closing_time_start,
+                    'closing_time_end': args.closing_time_end,
+                    'est_loading_time_start': args.est_loading_time_start,
+                    'est_loading_time_end': args.est_loading_time_end,
+                    'create_date_start': args.create_date_start,
+                    'create_date_end': args.create_date_end,
+                    'loading_time_start': args.loading_time_start,
+                    'loading_time_end': args.loading_time_end,
+                }
+                for field, value in outbound_dates.items():
+                    if value:
+                        tool_args[field] = value
+                if args.outbound_status > 0:
+                    tool_args['outbound_status'] = args.outbound_status
+                if args.shipping_method > 0:
+                    tool_args['shipping_method'] = args.shipping_method
+                if args.warehouse_id == -1 or args.warehouse_id > 0:
+                    tool_args['warehouse_id'] = args.warehouse_id
+                if args.is_direct_send is not None:
+                    tool_args['is_direct_send'] = args.is_direct_send
+            elif args.tool == 'query_outbound_detail':
+                if not args.outbound_number:
+                    raise ValueError(
+                        '--outbound-number is required for '
+                        'query_outbound_detail'
+                    )
+                tool_args['outbound_number'] = args.outbound_number
+            elif args.tool in (
+                'list_order_filter_options', 'list_outbound_filter_options'
+            ):
                 if not args.filter_type:
                     raise ValueError(
                         '--filter-type is required for '
-                        'list_order_filter_options'
+                        + args.tool
                     )
                 tool_args['filter_type'] = args.filter_type
                 tool_args['keyword'] = args.keyword

+ 0 - 89
docs/superpowers/plans/2026-07-14-mcp-export-pending-outbound-orders.md

@@ -1,89 +0,0 @@
-# MCP Export Pending Outbound Orders Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
-
-**Goal:** Expose the fmsoperate add-page “导出未排舱订单” workflow through MCP, including a permission-scoped filter-options tool that resolves user-facing names to valid values.
-
-**Architecture:** Python Gateway owns tool metadata, argument shape, and HTTP forwarding only. fmsoperate owns validation, tool registry checks, session-derived identity, permission-scoped option queries, and the existing synchronous export task. Both new tools use the established MCP response and access-log boundary.
-
-**Tech Stack:** Python 3 unittest, ThinkPHP 6/PHP 7.1+, MySQL ORM, Redis-backed MCP registry, existing export queue/OSS logic.
-
----
-
-### Task 1: Add Python tool contracts and red tests
-
-**Files:**
-- Create: `Y:/mcp/tools/export_pending_outbound_orders.py`
-- Create: `Y:/mcp/tools/list_pending_outbound_export_filter_options.py`
-- Create: `Y:/mcp/tests/test_export_pending_outbound_tools.py`
-- Modify: `Y:/mcp/app.py`
-- Modify: `Y:/mcp/public_gateway.py`
-
-- [ ] **Step 1: Write failing tests** for both tool metadata contracts, all add-page filter names, the three multi-select representations, `is_remove=0` preservation, product-type dependency forwarding, and local/public registration.
-- [ ] **Step 2: Run the focused tests** with `python -m unittest tests.test_export_pending_outbound_tools`; confirm failure because the new tool modules and registrations do not exist.
-- [ ] **Step 3: Implement minimal Python tools** with explicit schemas, whitelist-based argument forwarding, route paths, and bounded pagination.
-- [ ] **Step 4: Register both tools** in `GatewayApp` and `PublicGatewayApp`, preserving dynamic enabled-tool filtering.
-- [ ] **Step 5: Run focused tests** and commit the Python contract changes.
-
-### Task 2: Add fmsoperate tool routes, validation, and registry migrations
-
-**Files:**
-- Modify: `Y:/fmsoperate/route/mcp/mcp_route.php`
-- Modify: `Y:/fmsoperate/app/mcp/validate/McpToolValidate.php`
-- Modify: `Y:/fmsoperate/sql/mcp_add_exact_order_tools.sql`
-- Create: `Y:/fmsoperate/sql/mcp_add_pending_outbound_export_tools.sql`
-- Create: `Y:/settlement_tests/tests/Unit/FmsOperate/McpPendingOutboundExportRegisterTest.php`
-
-- [ ] **Step 1: Write failing tests** asserting both routes, validation scenes, and idempotent registry rows with the exact tool codes and route codes.
-- [ ] **Step 2: Run the focused PHP test** with `php Y:/settlement_tests/bin/phpunit.phar -c Y:/settlement_tests/phpunit.xml --filter McpPendingOutboundExportRegisterTest`; confirm the new routes/scenes are absent.
-- [ ] **Step 3: Add route closures** for `exportPendingOutboundOrders` and `listPendingOutboundExportFilterOptions`, both behind `checkMcpToken`.
-- [ ] **Step 4: Add strict validation scenes** for page-native parameters, array/scalar distinctions, `Y` property flags, date format, pagination, and reject identity/internal fields by scene allow-listing.
-- [ ] **Step 5: Add repeatable SQL registry entries** with risk level 1, POST, correct route codes, enabled status, and permission-oriented remarks.
-- [ ] **Step 6: Run the focused PHP test and PHP lint** on changed files.
-
-### Task 3: Implement permission-scoped pending-export filter options
-
-**Files:**
-- Create: `Y:/fmsoperate/app/mcp/logic/McpPendingOutboundExportFilterOptionsLogic.php`
-- Modify: `Y:/fmsoperate/app/mcp/controller/McpToolsController.php`
-- Create: `Y:/settlement_tests/tests/Unit/FmsOperate/McpPendingOutboundExportFilterOptionsLogicTest.php`
-- Create: `Y:/settlement_tests/tests/Unit/FmsOperate/McpPendingOutboundExportFilterOptionsDispatchTest.php`
-
-- [ ] **Step 1: Write failing Logic tests** for each filter type, A/B company isolation, platform-customer product restriction, `country_auth`, empty country authorization, product-type narrowing, keyword-after-authorization, and static option values.
-- [ ] **Step 2: Run focused Logic tests** and confirm expected failures before production implementation.
-- [ ] **Step 3: Implement the Logic** using session-derived identity only and the same page data sources: `OutboundTypeModel`, `ProductModel`, `OrderModel::getSortWarehouse`, `getTcAddCountryCode` plus account country auth, company-scoped importer rows, packing dictionary, operate statuses, and goods attributes.
-- [ ] **Step 4: Apply keyword and pagination only after the authorized result set is built**; never query unrestricted values before filtering.
-- [ ] **Step 5: Add the controller method through `executeNewQueryTool`** so dynamic registry checks, request IDs, response envelopes, and access logs match existing tools.
-- [ ] **Step 6: Run focused PHP tests and lint** for the new Logic/controller.
-
-### Task 4: Implement export dispatch with page-native parameters
-
-**Files:**
-- Create: `Y:/fmsoperate/app/mcp/logic/McpPendingOutboundExportLogic.php`
-- Modify: `Y:/fmsoperate/app/mcp/controller/McpToolsController.php`
-- Create: `Y:/settlement_tests/tests/Unit/FmsOperate/McpPendingOutboundExportLogicTest.php`
-- Create: `Y:/settlement_tests/tests/Unit/FmsOperate/McpPendingOutboundExportDispatchTest.php`
-
-- [ ] **Step 1: Write failing tests** for whitelist filtering, fixed `EXPORT_PEND_OUTBOUND_ORDER`, fixed `is_sync=1`, session-derived timezone/company/operator fields, URL normalization to `file_url`, missing URL failure, and permission/tool-disabled rejection.
-- [ ] **Step 2: Run focused tests** and confirm they fail before implementation.
-- [ ] **Step 3: Implement Logic** to accept only the add-page fields, preserve arrays/string formats exactly, invoke the existing async export controller/service path, and normalize the successful URL without duplicating SQL or Excel code.
-- [ ] **Step 4: Add the controller method through the shared MCP execution boundary** with the dedicated route code and validation scene.
-- [ ] **Step 5: Run focused PHP tests and lint**.
-
-### Task 5: Complete Python integration and regression coverage
-
-**Files:**
-- Modify: `Y:/mcp/tests/test_app_coverage.py`
-- Modify: `Y:/mcp/tests/test_mcp_protocol.py`
-- Modify: `Y:/mcp/tests/test_public_gateway.py`
-- Modify: `Y:/mcp/tests/test_public_gateway_unit.py`
-- Modify: `Y:/mcp/tests/test_public_server_integration.py`
-- Modify: `Y:/mcp/project-docs/requirements.md`
-- Modify: `Y:/mcp/project-docs/timeline.md`
-
-- [ ] **Step 1: Add integration assertions** that tools/list exposes both tools only when the backend registry enables them, tools/call forwards the exact route/tool headers, and business errors become `isError=true` without JSON-RPC termination.
-- [ ] **Step 2: Run all focused Python tests** with `python -m unittest discover -s tests -p 'test_*pending*outbound*.py'` and the touched integration modules.
-- [ ] **Step 3: Run the complete Python suite** with `python -m unittest discover -s tests -p 'test_*.py'`.
-- [ ] **Step 4: Run PHP MCP regression tests** including existing query/order-filter tests and all new pending-export tests.
-- [ ] **Step 5: Update project requirements/timeline** with the delivered tools, permission guarantees, and verification results.
-- [ ] **Step 6: Run `git diff --check` in both repositories and record final test evidence before completion.

+ 44 - 0
project-docs/overview.md

@@ -0,0 +1,44 @@
+# 项目概述
+
+## 项目定位
+
+`mcp` 是物流货运系统面向 Workbuddy 的 Python MCP Gateway。它负责 MCP 工具 Schema 与注册、stdio/public 协议适配、设备会话转发、安全展示 DTO、追踪日志和运行手册,不拥有物流业务规则。
+
+Gateway 保持薄边界:不直连业务数据库,不在 Python 中复制员工、公司、菜单或数据权限。员工身份、工具权限和业务数据范围由 ThinkPHP 在每次请求中最终校验。
+
+## 当前能力
+
+本地 `GatewayApp` 与公网 `PublicGatewayApp` 注册同一组 11 个候选工具:
+
+| 类别 | 工具 |
+|---|---|
+| 订单与轨迹 | `query_order`、`query_order_exact`、`query_track` |
+| 报关与排舱 | `query_customs_declaration_files`、`query_outbound_list`、`query_outbound_detail` |
+| 筛选项 | `list_outbound_filter_options`、`list_order_filter_options`、`list_pending_outbound_export_filter_options` |
+| 导出 | `export_pending_outbound_orders`、`export_out_of_province_port_data` |
+
+候选注册不代表员工一定可见。实际工具集合是本地注册集合与 fmsoperate 动态启用列表的交集,并继续受员工权限与数据范围约束。动态列表不可用或格式异常时关闭访问。
+
+`query_order` 保持旧响应兼容;其余 10 个工具由 `services/output_presenter.py` 转为中文白名单展示 DTO。所有工具遵守零推断边界:结果意图或号码业务类型不明确时先询问,禁止按格式猜测、并行试查或失败后跨字段、跨工具重试。
+
+## 跨仓职责
+
+| 仓库 | 所有权 |
+|---|---|
+| `Y:/mcp` | 工具 Schema、双注册表、协议、展示、Gateway 会话转发、运行与排障入口 |
+| `Y:/fmsoperate` | 工具路由、Validate/Logic/Model、动态注册表、业务权限、数据范围、导出和访问日志 |
+| `Y:/base` | 员工身份、设备会话、MCP token 生命周期和认证决策 |
+| `Y:/home` | 员工入口 UI 与薄代理,不承载认证或物流业务逻辑 |
+| `Y:/settlement_tests` | 跨仓 PHP 合同测试 |
+
+## 文档权威层
+
+1. 代码与运行时数据决定真实行为:代码给出候选注册和合同,fmsoperate 当前启用值决定实际可见工具。
+2. `README.md` 是接入、工具目录、配置、运维和排障入口。
+3. `project-docs/` 五件套记录现行产品、协议、架构、流程和短里程碑。
+4. `AGENTS.md` 只记录 AI 必须遵守的所有权、红线和协作规则。
+5. 临时规划和已完成的执行计划不属于权威层;交付后把稳定事实并回上述入口,并清理过程文件。
+
+## 当前交接状态
+
+用户已确认 `Y:/fmsoperate/sql` 的 10 个 MCP SQL 与 `Y:/base/sql` 的 5 个 MCP SQL 均已执行。仓库仍无法静态判断动态注册表的当前启用值、运行中 Gateway 是否已重启、Workbuddy 是否已重新连接;这些状态必须从部署环境核验。

+ 65 - 0
project-docs/requirements.md

@@ -0,0 +1,65 @@
+# 需求与功能清单
+
+## 产品边界
+
+- Gateway 只处理 MCP 协议、会话转发、候选工具注册和展示适配,不直连业务数据库,不复制 ThinkPHP 业务规则。
+- 所有员工、公司、菜单、工具和数据权限由 ThinkPHP 最终判断;Gateway 不接受调用方提供的身份或权限覆盖参数。
+- 本地 stdio 与公网 HTTP 必须注册同一组候选工具,并共享同一套协议与展示合同。
+- 写入、审批、费用修改、订单状态流转或批量业务变更工具不得在未完成独立安全评审时开放。
+
+## 协议合同
+
+- 支持 MCP `initialize`、`tools/list` 和 `tools/call`;能力声明保持 `tools.listChanged=false`。
+- `tools/list` 返回本地 11 个候选工具与 fmsoperate 动态启用列表的交集。
+- `tools/call` 在调用前再次校验工具已注册、设备会话有效且后端仍允许当前员工使用该工具。
+- 动态列表缺失、格式错误或查询失败时关闭访问,不允许回退为本地全量工具。
+- 后端业务失败仍放在 JSON-RPC `result` 中并设置 `isError=true`,不得中断 stdio 或 HTTP 会话;协议、参数或未知方法错误使用 JSON-RPC `error`。
+- CLI `call` 保留工具原始 `code/msg/data/meta` 信封,不经过面向 Workbuddy 的 Presenter。
+
+## 候选工具
+
+| 类别 | 工具 | 核心要求 |
+|---|---|---|
+| 查询 | `query_order` | 保持旧展示协议兼容 |
+| 查询 | `query_order_exact`、`query_track` | 只在结果意图和号码类型明确后调用 |
+| 查询 | `query_customs_declaration_files` | 订单号数组与排舱单号数组必须二选一 |
+| 查询 | `query_outbound_list` | 排舱阶段必选;运输方式未指定时查询全部 |
+| 查询 | `query_outbound_detail` | 只按明确排舱单号查询,不接受内部排舱 ID |
+| 筛选 | `list_outbound_filter_options` | 统一返回排舱阶段、运输方式、集货仓库、是否直送柜、拖车、报关和清关七类选项 |
+| 筛选 | `list_order_filter_options` | 返回当前员工可用的精准订单筛选值 |
+| 筛选 | `list_pending_outbound_export_filter_options` | 返回当前员工可用的未排舱导出筛选值 |
+| 导出 | `export_pending_outbound_orders` | 复用筛选工具返回值,不猜测内部 ID |
+| 导出 | `export_out_of_province_port_data` | 排舱单号、柜号、提单号、SO 号四类数组严格四选一,并要求文件类型 |
+
+## 零推断与工具选择
+
+- AI 必须先确认用户要看的结果类别,再确认所给号码的业务类型;任一项不明确时先提问。
+- 禁止按号码外观猜测类型,禁止并行调用多个候选工具,禁止跨字段试查,禁止一次失败后自行换号码字段或换工具重试。
+- 多个筛选值命中时必须让用户选择;唯一命中才可把内部 `value` 传给目标工具。
+- 面向用户描述筛选条件时使用中文标签,不展示英文筛选字段、内部数字代码或技术参数文案。
+- 中文筛选展示约束不影响查询结果中的件数、重量、体积、日期和其他正常业务值。
+
+## 输出与错误安全
+
+- `query_order` 是唯一旧展示例外,继续使用 `columns + records` 和原文本行为。
+- 其余 10 个工具由 `OutputPresenter` 白名单处理:4 个表格工具、1 个排舱详情工具、3 个筛选项工具和 2 个导出工具。
+- 表格使用中文 `headers + rows + pagination`;排舱详情使用 `summary + details + pagination`;导出只返回 `files[].label + files[].url`。
+- 筛选项保留可继续传参的 `value`、用户显示 `label` 和业务 `code`,但不得泄露未列入白名单的后端字段。
+- `request_id` 放在 MCP 结果 `_meta`;安全工具的业务错误使用固定中文消息,不透传后端 `msg`、异常数据、堆栈或原始响应。
+- Gateway 已知错误码与重试策略以 `OutputPresenter.ERROR_MESSAGES`、`NON_RETRYABLE_CODES` 为代码源;未知业务码对外归一为 `MCP_9001`。
+- stdio 与 public 共用同一个 Presenter;未知工具、畸形响应、未知字段和未知异常关闭失败。
+
+## 公网安全与运行合同
+
+- 公网请求使用 `GWS_xxx` 查找 Redis Gateway session,并从服务端会话取得 `mcp_token`;不得把 token 返回给客户端。
+- 公网入口生成可信 `rq_http_*` 追踪号;调用方 `X-Request-Id` 只允许记录短哈希,不得作为可信追踪号。
+- 日志不得记录明文 Gateway 凭据、MCP token、Cookie、Authorization、授权码或后端原始响应。
+- `initialize` 与 `tools/list` 不限流;`tools/call` 按已认证的 `gateway_session_id + tool_name` 使用独立滑动窗口配额。
+- 公网必须位于 HTTPS 反向代理后,Redis 只允许内网访问并启用生产密码。
+
+## 变更与验收
+
+- 工具变更必须同步更新工具 metadata、stdio/public 双注册表、CLI 转发、Presenter、ThinkPHP 路由/验证/Logic/Model、动态注册数据和相关测试。
+- 因 `tools.listChanged=false`,工具或 Schema 变化后必须重启 Gateway 并让客户端重新连接。
+- 用户已确认 fmsoperate 的 10 个 MCP SQL 与 base 的 5 个 MCP SQL 均已执行;动态启用值仍以运行环境为准。
+- Python 生产代码变更必须运行全量 unittest 和 `.coveragerc` 要求的语句、分支 100% 严格覆盖率;跨仓 PHP 变更必须运行对应合同测试与语法检查。

+ 86 - 0
project-docs/tech-specs.md

@@ -0,0 +1,86 @@
+# 技术规格
+
+## 技术栈与边界
+
+- Gateway:Python 3,核心运行模块只使用标准库;stdio 使用逐行 JSON-RPC,公网使用 `ThreadingHTTPServer`。
+- 后端:ThinkPHP 6.0 + PHP 7.1+,按 Controller / Logic / Model / Validate 分层实现业务工具。
+- 存储:Redis 保存本地 token 或公网 Gateway session;MySQL、MongoDB 和文件存储只由所属业务系统访问。
+- Gateway 不连接业务数据库;跨仓调用只通过明确的 HTTP 路由和服务端会话进行。
+
+## 组件
+
+| 组件 | 职责 |
+|---|---|
+| `app.py` | 本地 `GatewayApp`、stdio/CLI 入口、11 个工具注册与参数转发 |
+| `public_gateway.py` | 公网 `PublicGatewayApp`、设备会话解析后的 scoped 调用、同组 11 个工具注册 |
+| `public_server.py` | `/mcp` HTTP JSON-RPC、可信追踪号、审计上下文、限流和 `/health` |
+| `mcp_protocol.py` | MCP 握手、`tools/list`、`tools/call`、旧/新结果分流和协议错误 |
+| `services/api_client.py` | 本地 token 上下文下的 ThinkPHP 工具请求与动态列表请求 |
+| `services/scoped_api_client.py` | 公网请求级 `mcp_token` 转发,避免进程级身份串用 |
+| `services/gateway_session_store.py` | `GWS_xxx` 到服务端员工会话的 Redis 映射 |
+| `services/output_presenter.py` | 10 个安全工具的白名单 DTO、错误映射和文本渲染 |
+| `tools/*.py` | 工具名称、说明、输入 Schema、路由和调用封装 |
+
+## 本地与公网会话
+
+本地 stdio 流程:
+
+1. `GatewayApp` 从 Redis 或开发用文件 store 读取本机 session。
+2. token 临近过期时通过 base 刷新。
+3. `tools/list` 和 `tools/call` 携带该 token 请求 fmsoperate。
+
+公网流程:
+
+1. Workbuddy 通过 Header、Bearer 或 Cookie 提交 `GWS_xxx`。
+2. `RequestContextParser` 提取设备凭据,`GatewaySessionStore` 从 Redis 获取服务端 `mcp_token`。
+3. `PublicGatewayApp` 为单次请求创建 scoped API 调用上下文,不把 token 放进进程全局状态。
+4. fmsoperate 再校验 token、员工状态、公司、工具权限和业务数据范围。
+
+会话缺失、过期或后端动态列表不可用时关闭访问。公网不得使用文件 token store、固定 `FMS_SESSION_KEY` 或共享进程级员工 token。
+
+## 动态工具可见性
+
+- `GatewayApp` 与 `PublicGatewayApp` 的候选注册顺序和名称必须完全一致,当前各为 11 个。
+- `tools/list` 调用 `/mcp/tools/listEnabledTools`,只返回本地注册与后端启用代码的交集。
+- `tools/call` 重新读取启用集合,避免工具被禁用后继续调用。
+- MCP 返回 `tools.listChanged=false`,因此工具或 Schema 变更必须通过 Gateway 重启和客户端重连刷新。
+
+## Presenter 分类
+
+`query_order` 走旧协议分支,保留 `columns/records/meta` 和既有文本行为。`OutputPresenter.SAFE_TOOLS` 显式处理其余 10 个工具:
+
+| 类型 | 工具 | DTO |
+|---|---|---|
+| 表格 | `query_order_exact`、`query_track`、`query_customs_declaration_files`、`query_outbound_list` | `headers + rows + pagination` |
+| 排舱详情 | `query_outbound_detail` | `summary + details`,分页位于 `details.pagination` |
+| 筛选项 | `list_order_filter_options`、`list_outbound_filter_options`、`list_pending_outbound_export_filter_options` | `value + label + code` 三列 |
+| 导出 | `export_pending_outbound_orders`、`export_out_of_province_port_data` | `files[].label + files[].url` |
+
+Presenter 对列定义、记录类型、详情汇总、附件结构和导出 URL 做白名单校验。未知工具、未允许列、畸形响应或未知异常返回安全 `isError=true`;真实异常仅记录在服务端日志。后端业务错误映射为固定消息,不透传原始 `msg/data`。
+
+## 追踪、日志与限流
+
+- stdio 自动生成 `rq_*`;公网入口始终生成可信 `rq_http_*`,不采信调用方 `X-Request-Id`。
+- 调用方 `X-Request-Id` 只记录 SHA-256 短哈希 `client_request_id_hash`,用于关联客户端反馈。
+- 追踪号贯穿动态工具列表、工具调用、MCP `_meta` 和结构化日志。
+- 审计日志只记录必要的 request/jsonrpc ID、协议方法、工具代码、员工/公司标识和脱敏客户端标识;禁止记录凭据与原始响应。
+- `initialize` 与 `tools/list` 不进入限流器;`tools/call` 使用进程内 `SimpleRateLimiter`,已知工具按 `gateway_session_id + tool_name` 分桶。
+- 限流配置为 `FMS_RATE_LIMIT_ENABLED`、`FMS_RATE_LIMIT_MAX_REQUESTS`、`FMS_RATE_LIMIT_WINDOW_SECONDS`;生产环境还应在反向代理层限流。
+
+## 关键业务参数
+
+- `query_outbound_list.outbound_status` 必填,只暴露后台业务阶段;`shipping_method` 无默认值,未传表示全部运输方式。
+- `query_outbound_list.warehouse_id` 接受正整数或 `-1`(客户仓),拒绝 0 与其他负数。
+- `list_outbound_filter_options.filter_type` 使用七个中文枚举;仓库分支由 fmsoperate 复用 `OrderModel::getSortWarehouse()`,所有分支先校验 `admin/Outbound/index`。
+- 排舱筛选响应保留 `value/label/code` 供跨工具传参,面向用户只展示中文标签。
+
+## 验证基线
+
+```powershell
+python -m unittest discover -s tests -p "test_*.py"
+python -m coverage run -m unittest discover -s tests -p "test_*.py"
+python -m coverage report -m --fail-under=100
+git diff --check
+```
+
+运行 coverage 会更新本地 `.coverage` 文件;只做文档治理时使用已有只读报告,不应制造覆盖率产物变更。

+ 36 - 0
project-docs/timeline.md

@@ -0,0 +1,36 @@
+# 时间线
+
+本文件只保留可帮助交接的里程碑与部署状态。测试过程细节由 Git 历史和当前测试代码承载,现行合同以 `README.md` 与其他四份 `project-docs` 为准。
+
+## 2026-07-06 至 2026-07-08:基础 Gateway 与公网安全
+
+- 建立项目文档与本地 stdio Gateway,随后增加公网 HTTP JSON-RPC、Redis 设备会话和健康检查。
+- 公网审计不记录授权码或 token,限流身份不再默认信任调用方可伪造的转发头。
+- 正式员工接入统一使用后台生成的 `GWS_xxx`,旧授权码绑定流程退出运行时。
+
+## 2026-07-13 至 2026-07-15:协议错误与安全展示
+
+- stdio/public 统一工具响应适配:后端业务失败返回 MCP `isError=true`,不再错误标记为成功,也不中断 JSON-RPC 会话。
+- 引入 `OutputPresenter`,将受支持工具的内部结果转换为中文白名单 DTO;`query_order` 保持旧协议兼容。
+- 增加未排舱订单导出、省外进港资料导出及对应权限筛选项。
+
+## 2026-07-16:排舱查询
+
+- 增加 `query_outbound_list` 与 `query_outbound_detail`,同步本地/公网注册、CLI 参数和 Presenter。
+- 排舱列表使用固定中文业务列;详情使用汇总与订单明细两层结构,附件只保留安全文件字段。
+
+## 2026-07-17:工具边界、筛选与限流
+
+- 11 个候选工具统一采用零推断说明:先确认结果意图与号码类型,禁止格式猜测、并行试查和跨字段/跨工具重试。
+- `list_outbound_filter_options` 统一七类排舱筛选项,仓库能力并入该工具,不保留重复入口。
+- 排舱阶段改为必选;运输方式未指定时查询全部运输方式。
+- `initialize` 与 `tools/list` 退出限流,`tools/call` 保持按设备会话与工具分桶。
+- 本地 `GatewayApp` 与公网 `PublicGatewayApp` 对齐为 11 个候选工具;`OutputPresenter` 处理其中 10 个安全工具。
+- 用户确认 `Y:/fmsoperate/sql` 的 10 个 MCP SQL 与 `Y:/base/sql` 的 5 个 MCP SQL 均已执行。
+- 文档治理建立 `AGENTS.md`,放行并重写 `project-docs` 五件套;历史计划与旧测试快照经校验备份后从活动工作区清理。
+
+## 当前交接
+
+- 只读 `.coverage` 报告记录为 `1822 statements / 734 branches / 100.00%`;这是已有产物读数,不替代发布前的重新验证。
+- SQL 执行状态已确认,工具的当前动态启用值仍需通过实际员工会话的 `tools/list` 核验。
+- 运行中 Gateway 是否已加载当前代码、Workbuddy 是否已重新连接,无法由仓库静态判断,发布交接必须显式确认。

+ 67 - 0
project-docs/user-structure.md

@@ -0,0 +1,67 @@
+# 用户流程与项目结构
+
+## 员工公共使用流程
+
+1. 员工从后台或 home 的“连接 Workbuddy”入口创建设备配置。
+2. base 生成 `GWS_xxx`、服务端 MCP token session 和 Redis Gateway session;配置中不暴露 `mcp_token`。
+3. Workbuddy 连接公网 `/mcp`,通过 Header、Bearer 或 Cookie 稳定携带同一 `GWS_xxx`。
+4. `initialize` 完成握手,`tools/list` 返回 Gateway 候选集合与 fmsoperate 动态启用列表的交集。
+5. `tools/call` 解析设备会话、按会话和工具限流,再携带服务端 token 调用 fmsoperate。
+6. ThinkPHP 校验员工、公司、工具和业务数据权限;Gateway 将结果转为安全 MCP 展示。
+7. 设备撤销、token 失效或 Gateway session 过期后,后续调用关闭失败,员工重新创建设备配置。
+
+## 本地调试流程
+
+1. 开发者从 `Y:/mcp` 或员工可访问的 UNC 路径运行 `python app.py serve-stdio`。
+2. 本地 session 从 Redis 读取;文件 token store 只用于开发排障,不放在共享目录。
+3. 使用 `python app.py list-tools` 检查动态可见工具,使用 `python app.py call --tool ...` 查看原始 ThinkPHP 信封。
+4. 使用真实 MCP 客户端验证 `initialize`、`tools/list`、`tools/call` 和 Presenter 展示。
+5. 工具或 Schema 变化后重启 stdio 进程并重新连接客户端;`tools.listChanged=false` 不会主动推送变化。
+
+## AI 工具选择流程
+
+1. 确认结果意图:普通订单、精准订单、轨迹、报关资料、排舱列表、排舱详情、筛选项或导出。
+2. 确认号码类型:订单号、排舱单号、物流单号、柜号、提单号或 SO 号。上下文不明确时先询问。
+3. 只调用一个已确认的目标工具;不得按号码格式猜测、并行试查、跨字段试查或失败后自行换工具。
+4. 需要筛选值时先调用对应筛选工具。唯一命中可传递内部 `value`,多条命中让用户选择。
+5. 排舱列表统一使用 `list_outbound_filter_options` 查询七类选项,包括集货仓库;不另设仓库工具。
+6. 面向用户展示中文标签和完整业务结果,内部值只用于结构化串联。
+
+## 跨仓开发流程
+
+1. 从 `README.md` 与 `project-docs/` 确认产品合同,再读取 `AGENTS.md` 的所有权和红线。
+2. 在 `Y:/mcp` 同步工具 metadata、输入 Schema、stdio/public 双注册、CLI、Presenter 与 Python 测试。
+3. 在 `Y:/fmsoperate` 按 Controller / Logic / Model / Validate 分层同步路由、业务权限、动态注册和 PHP 测试。
+4. 涉及员工身份、设备会话或 token 生命周期时,在 `Y:/base` 同步认证实现和合同测试;home 保持 UI/薄代理边界。
+5. 运行各仓定向与全量验证,核对双注册表和实际 `tools/list`。
+6. 发布后确认 Gateway 已重启、Workbuddy 已重连、动态启用值符合预期,再更新 README 和五件套中的稳定事实。
+
+## 请求与输出路径
+
+```text
+Workbuddy
+  -> /mcp (GWS_xxx)
+  -> PublicMcpHttpHandler
+  -> PublicGatewayApp + GatewaySessionStore
+  -> ScopedApiClient (server-side mcp_token)
+  -> fmsoperate MCP route
+  -> ThinkPHP permission/data checks
+  -> OutputPresenter
+  -> MCP content + structuredContent + _meta.request_id
+```
+
+本地 stdio 使用 `GatewayApp + ApiClient + TokenStore`,从协议处理开始与公网共用相同的工具合同和 Presenter。`query_order` 是旧展示例外。
+
+## 目录地图
+
+| 路径 | 内容 |
+|---|---|
+| `app.py` | 本地入口、CLI 和 `GatewayApp` |
+| `public_gateway.py`、`public_server.py` | 公网 Gateway 与 HTTP JSON-RPC 服务 |
+| `mcp_protocol.py` | MCP 协议处理与结果分流 |
+| `tools/` | 11 个工具的 Schema、说明和路由封装 |
+| `services/` | API、认证、session、request context、scoped client 与 Presenter |
+| `utils/` | 限流和安全散列工具 |
+| `tests/` | Python 单元、协议、展示、会话和安全回归测试 |
+| `project-docs/` | 现行项目五件套 |
+| `docs/superpowers/specs/` | 设计决策背景;不替代现行合同 |

+ 8 - 0
public_gateway.py

@@ -3,6 +3,7 @@ import uuid
 
 from constants import DEVICE_INVALID_MESSAGE
 from tools.list_order_filter_options import ListOrderFilterOptionsTool
+from tools.list_outbound_filter_options import ListOutboundFilterOptionsTool
 from tools.export_pending_outbound_orders import ExportPendingOutboundOrdersTool
 from tools.export_out_of_province_port_data import (
     ExportOutOfProvincePortDataTool,
@@ -15,6 +16,8 @@ from tools.query_customs_declaration_files import (
     QueryCustomsDeclarationFilesTool,
 )
 from tools.query_order_exact import QueryOrderExactTool
+from tools.query_outbound_detail import QueryOutboundDetailTool
+from tools.query_outbound_list import QueryOutboundListTool
 from tools.query_track import QueryTrackTool
 from utils.security import hash_gateway_session_id
 
@@ -32,6 +35,11 @@ class PublicGatewayApp:
             'query_order_exact': QueryOrderExactTool(api_client=None),
             'query_customs_declaration_files':
                 QueryCustomsDeclarationFilesTool(api_client=None),
+            'query_outbound_list': QueryOutboundListTool(api_client=None),
+            'query_outbound_detail': QueryOutboundDetailTool(api_client=None),
+            'list_outbound_filter_options': ListOutboundFilterOptionsTool(
+                api_client=None
+            ),
             'list_order_filter_options': ListOrderFilterOptionsTool(
                 api_client=None
             ),

+ 2 - 14
public_server.py

@@ -56,8 +56,7 @@ class PublicMcpHttpHandler:
     ):
         """Returns an error response if rate limit exceeded, else None.
 
-        rate_key     — the bucket key used by the limiter (session-based for tools/call,
-                       IP-based for tools/list)
+        rate_key     — the session-based bucket key used for tools/call
         log_identity — optional string shown in warning logs (e.g. client_ip)
         """
         if self.rate_limiter and rate_key and not self.rate_limiter.is_allowed(rate_key):
@@ -109,17 +108,6 @@ class PublicMcpHttpHandler:
                     },
                 })
             if method == 'tools/list':
-                # Keep list traffic in a dedicated IP-based bucket so it does not
-                # compete with the per-session tools/call quota.
-                blocked = self._check_rate_limit(
-                    'list:{0}'.format(client_ip),
-                    method,
-                    trace_request_id,
-                    request_id,
-                    log_identity=client_ip,
-                )
-                if blocked:
-                    return blocked
                 context = self.context_parser.parse(headers or {})
                 if not context.has_session():
                     logger.warning(
@@ -351,7 +339,7 @@ def serve_public(gateway_app, host='0.0.0.0', port=8765, enable_rate_limit=True,
             max_requests=rate_limit_max_requests,
             window_seconds=rate_limit_window_seconds,
         )
-        logger.info(f"Rate limiting enabled: {rate_limit_max_requests} requests/{rate_limit_window_seconds}s per session (tools/call) / per IP (tools/list)")
+        logger.info(f"Rate limiting enabled: {rate_limit_max_requests} requests/{rate_limit_window_seconds}s per session and tool (tools/call only)")
 
     logger.info(f"Starting public MCP Gateway on {host}:{port}")
     server = ThreadingHTTPServer((host, int(port)), create_http_handler(gateway_app, rate_limiter))

+ 201 - 1
services/output_presenter.py

@@ -13,16 +13,19 @@ class OutputPresenter:
         'query_order_exact',
         'query_track',
         'query_customs_declaration_files',
+        'query_outbound_list',
     ))
+    DETAIL_TOOLS = frozenset(('query_outbound_detail',))
     OPTION_TOOLS = frozenset((
         'list_order_filter_options',
+        'list_outbound_filter_options',
         'list_pending_outbound_export_filter_options',
     ))
     EXPORT_TOOLS = frozenset((
         'export_pending_outbound_orders',
         'export_out_of_province_port_data',
     ))
-    SAFE_TOOLS = TABLE_TOOLS | OPTION_TOOLS | EXPORT_TOOLS
+    SAFE_TOOLS = TABLE_TOOLS | DETAIL_TOOLS | OPTION_TOOLS | EXPORT_TOOLS
 
     TABLE_COLUMNS = {
         'query_order_exact': {
@@ -82,6 +85,77 @@ class OutputPresenter:
             'file_type': ('文件类型',),
             'file_url': ('文件链接',),
         },
+        'query_outbound_list': {
+            'outbound_number': ('排舱单号',),
+            'direct_send': ('直送柜',),
+            'remark': ('备注',),
+            'cargo_type': ('超长超重',),
+            'warehouse_name': ('集货仓库',),
+            'status': ('状态',),
+            'so_number': ('SO号',),
+            'container_code': ('柜号',),
+            'seal_number': ('封条号',),
+            'container_type': ('柜型',),
+            'shipping_method': ('运输方式',),
+            'total_volume': ('体积(CBM)',),
+            'total_weight': ('重量(KG)',),
+            'ship_schedule': ('船期',),
+            'closing_time': ('截关时间',),
+            'est_loading_time': ('预计装柜时间',),
+            'operator': ('操作人',),
+            'operation_modes': ('拖报清方式',),
+            'operation_time': ('操作时间',),
+        },
+        'query_outbound_detail': {
+            'order_number': ('订单号',),
+            'cargo_type': ('超长超重',),
+            'customs_files': ('报关资料',),
+            'reference_number': ('客户参考号',),
+            'customer_name': ('客户名称',),
+            'declaration_type': ('报关方式',),
+            'merge_declare_number': ('合并报关单号',),
+            'order_remark': ('订单备注',),
+            'bl_number': ('提单号',),
+            'container_code': ('柜号',),
+            'customer_service': ('客户经理',),
+            'est_inbound_date': ('预计入库时间',),
+            'inbound_date': ('实际入库时间',),
+            'status': ('状态',),
+            'pieces': ('预报件数 / 入库件数',),
+            'weight': ('预报实重(KG) / 入库实重(KG)',),
+            'volume': ('预报体积(m³) / 入库体积(m³)',),
+            'pickup_place': ('交货地',),
+            'product_name': ('物流产品',),
+            'goods_name': ('品名',),
+            'closing_time': ('截关时间',),
+            'clearance_remark': ('报关备注',),
+            'import_clearance_remark': ('清关备注',),
+            'delivery_address': ('派送地址',),
+            'delivery_type': ('派送类型',),
+            'delivery_way': ('派送方式',),
+            'channel': ('派送渠道',),
+            'country_name': ('目的国',),
+            'importer_name': ('进口商',),
+            'vat_type': ('清关税号',),
+            'packing_type': ('货物类型',),
+            'is_abnormal': ('是否问题件',),
+            'package_method': ('包装类型',),
+            'order_reply': ('到货回复',),
+        },
+    }
+
+    OUTBOUND_DETAIL_SUMMARY = {
+        'bl_number': '提单号',
+        'container_code': '柜号',
+        'container_type': '柜型',
+        'total_volume': '总体积',
+        'total_weight': '总重量',
+        'total_pieces': '总件数',
+        'sku': 'SKU',
+        'buy_declaration_count': '买单报关数量',
+        'general_declaration_count': '一般贸易报关数量',
+        'must_load': '必装单量/体积',
+        'backup_load': '备装单量/体积',
     }
 
     FIELD_LABELS = {
@@ -125,12 +199,36 @@ class OutputPresenter:
             'page': '页码',
             'limit': '每页数量',
         },
+        'query_outbound_list': {
+            'outbound_numbers': '排舱单号',
+            'order_numbers': '订单号',
+            'container_codes': '柜号',
+            'so_numbers': 'SO号',
+            'bl_numbers': '提单号',
+            'outbound_status': '排舱状态',
+            'shipping_method': '运输方式',
+            'warehouse_id': '集货仓库',
+            'is_direct_send': '是否直送柜',
+            'page': '页码',
+            'limit': '每页数量',
+        },
+        'query_outbound_detail': {
+            'outbound_number': '排舱单号',
+            'page': '页码',
+            'limit': '每页数量',
+        },
         'list_order_filter_options': {
             'filter_type': '筛选项类型',
             'keyword': '关键词',
             'page': '页码',
             'limit': '每页数量',
         },
+        'list_outbound_filter_options': {
+            'filter_type': '筛选类别',
+            'keyword': '显示名称',
+            'page': '页码',
+            'limit': '每页数量',
+        },
         'list_pending_outbound_export_filter_options': {
             'filter_type': '筛选项类型',
             'product_type_id': '产品分类',
@@ -221,6 +319,13 @@ class OutputPresenter:
         if not isinstance(data, dict):
             return self._format_error(meta)
 
+        if tool_name in self.DETAIL_TOOLS:
+            return self._present_outbound_detail(
+                tool_name,
+                data,
+                tool_result.get('meta'),
+                meta,
+            )
         if tool_name in self.TABLE_TOOLS:
             return self._present_table(
                 tool_name,
@@ -308,6 +413,101 @@ class OutputPresenter:
             content['pagination'] = pagination
         return self._success_result(content, self._render_table(content), meta)
 
+    def _present_outbound_detail(self, tool_name, data, raw_meta, meta):
+        summary = data.get('summary')
+        columns = data.get('columns')
+        records = data.get('records')
+        if not isinstance(summary, dict):
+            return self._format_error(meta)
+        if not isinstance(columns, list) or not columns or not isinstance(records, list):
+            return self._format_error(meta)
+        if any(key not in summary for key in self.OUTBOUND_DETAIL_SUMMARY):
+            return self._format_error(meta)
+
+        allowed_columns = self.TABLE_COLUMNS.get(tool_name)
+        headers = []
+        keys = []
+        for column in columns:
+            if not isinstance(column, dict):
+                return self._format_error(meta)
+            key = column.get('key')
+            definition = allowed_columns.get(key) if isinstance(key, str) else None
+            if not isinstance(definition, tuple) or not definition:
+                return self._format_error(meta)
+            keys.append(key)
+            headers.append({'label': definition[0]})
+
+        rows = []
+        for record in records:
+            if not isinstance(record, dict):
+                return self._format_error(meta)
+            row = []
+            for key in keys:
+                value = '' if record.get(key) is None else record.get(key, '')
+                if key == 'customs_files':
+                    if not isinstance(value, list):
+                        return self._format_error(meta)
+                    files = []
+                    for item in value:
+                        if not isinstance(item, dict):
+                            return self._format_error(meta)
+                        files.append({
+                            '文件名称': item.get('file_name', ''),
+                            '文件类型': item.get('file_type', ''),
+                            '文件链接': item.get('file_url', ''),
+                        })
+                    value = files
+                row.append(value)
+            rows.append(row)
+
+        summary_keys = list(self.OUTBOUND_DETAIL_SUMMARY.keys())
+        content = {
+            'summary': {
+                'headers': [
+                    {'label': self.OUTBOUND_DETAIL_SUMMARY[key]}
+                    for key in summary_keys
+                ],
+                'row': [summary.get(key, '') for key in summary_keys],
+            },
+            'details': {
+                'headers': headers,
+                'rows': rows,
+            },
+        }
+        tips = self._safe_tips(data.get('tips'))
+        if tips:
+            content['details']['tips'] = tips
+        pagination = self._build_pagination(raw_meta)
+        if pagination:
+            content['details']['pagination'] = pagination
+        return self._success_result(
+            content,
+            self._render_outbound_detail(content),
+            meta,
+        )
+
+    @classmethod
+    def _render_outbound_detail(cls, content):
+        lines = ['排舱汇总:']
+        summary = content['summary']
+        for header, value in zip(summary['headers'], summary['row']):
+            lines.append('- {0}: {1}'.format(header['label'], value))
+        lines.append('订单明细:')
+        details = content['details']
+        lines.append('表头共 {0} 列:'.format(len(details['headers'])))
+        for index, header in enumerate(details['headers'], start=1):
+            lines.append('{0}. {1}'.format(index, header['label']))
+        for index, row in enumerate(details['rows'], start=1):
+            lines.append('记录 {0}:'.format(index))
+            for header, value in zip(details['headers'], row):
+                if isinstance(value, (dict, list)):
+                    value = json.dumps(value, ensure_ascii=False)
+                lines.append('- {0}: {1}'.format(header['label'], value))
+        tips = details.get('tips') or []
+        if tips:
+            lines.append('提示:{0}'.format(';'.join(tips)))
+        return '\n'.join(lines)
+
     def _present_options(self, data, raw_meta, meta):
         records = data.get('records')
         if not isinstance(records, list):

+ 0 - 34
tests/TEST_REPORT.md

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

+ 274 - 0
tests/test_outbound_filter_options.py

@@ -0,0 +1,274 @@
+import importlib
+import io
+import json
+import os
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+
+
+FILTER_TYPES = [
+    '排舱阶段', '运输方式', '集货仓库', '是否直送柜',
+    '拖车方式', '报关方式', '清关方式',
+]
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.last_call = None
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['list_outbound_filter_options']},
+        }
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.last_call = {
+            'tool_code': tool_code,
+            'route_path': route_path,
+            'payload': payload,
+            'request_id': request_id,
+        }
+        return {'code': 'MCP_0000', 'data': {}}
+
+
+class PublicSessionStore:
+    def get(self, gateway_session_id):
+        if gateway_session_id == 'GWS_test':
+            return {'mcp_token': 'MT_test'}
+        return None
+
+
+class PublicApiClient:
+    def list_enabled_tools(self, token, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['list_outbound_filter_options']},
+        }
+
+
+class OutboundFilterOptionsTest(unittest.TestCase):
+    def tool_class(self):
+        root = os.path.dirname(os.path.dirname(__file__))
+        path = os.path.join(root, 'tools', 'list_outbound_filter_options.py')
+        self.assertTrue(os.path.exists(path), path)
+        module = importlib.import_module('tools.list_outbound_filter_options')
+        return module.ListOutboundFilterOptionsTool
+
+    def test_metadata_requires_seven_chinese_filter_categories(self):
+        cls = self.tool_class()
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+
+        self.assertEqual('list_outbound_filter_options', metadata['name'])
+        self.assertEqual('/mcp/tools/listOutboundFilterOptions', cls.route_path)
+        self.assertEqual(['filter_type'], schema['required'])
+        self.assertEqual(
+            FILTER_TYPES,
+            schema['properties']['filter_type']['enum'],
+        )
+        self.assertEqual(
+            {'filter_type', 'keyword', 'page', 'limit'},
+            set(schema['properties']),
+        )
+        self.assertFalse(schema['additionalProperties'])
+
+    def test_call_forwards_chinese_category_keyword_and_pagination(self):
+        client = RecordingApiClient()
+        tool = self.tool_class()(api_client=client)
+
+        tool.call(
+            filter_type=' 集货仓库 ', keyword=' 深圳仓 ',
+            page=2, limit=100, request_id='rq_filters',
+        )
+
+        self.assertEqual('list_outbound_filter_options', client.last_call['tool_code'])
+        self.assertEqual(
+            '/mcp/tools/listOutboundFilterOptions',
+            client.last_call['route_path'],
+        )
+        self.assertEqual({
+            'filter_type': '集货仓库',
+            'keyword': '深圳仓',
+            'page': 2,
+            'limit': 100,
+        }, client.last_call['payload'])
+
+    def test_call_rejects_missing_client_unknown_category_and_invalid_bounds(self):
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            self.tool_class()().call('排舱阶段')
+
+        tool = self.tool_class()(api_client=RecordingApiClient())
+        for args, kwargs in (
+            (('未知类别',), {}),
+            (('运输方式',), {'keyword': 'x' * 101}),
+            (('运输方式',), {'page': True}),
+            (('运输方式',), {'page': 'bad'}),
+            (('运输方式',), {'page': 0}),
+            (('运输方式',), {'limit': 101}),
+        ):
+            with self.subTest(args=args, kwargs=kwargs):
+                with self.assertRaises(ValueError):
+                    tool.call(*args, **kwargs)
+
+    def test_gateways_register_and_expose_unified_tool_when_enabled(self):
+        local = GatewayApp(api_client=RecordingApiClient())
+        public = PublicGatewayApp(PublicSessionStore(), PublicApiClient())
+
+        self.assertIn('list_outbound_filter_options', local.registered_tool_names())
+        self.assertIn('list_outbound_filter_options', public.registered_tool_names())
+        self.assertEqual(
+            ['list_outbound_filter_options'],
+            [item['name'] for item in local.list_tools()],
+        )
+        self.assertEqual(
+            ['list_outbound_filter_options'],
+            [item['name'] for item in public.list_tools('GWS_test')],
+        )
+
+    def test_legacy_warehouse_tool_is_fully_merged(self):
+        root = os.path.dirname(os.path.dirname(__file__))
+        legacy_path = os.path.join(
+            root, 'tools', 'list_outbound_warehouse_options.py'
+        )
+        local = GatewayApp(api_client=RecordingApiClient())
+        public = PublicGatewayApp(PublicSessionStore(), PublicApiClient())
+
+        self.assertFalse(os.path.exists(legacy_path), legacy_path)
+        self.assertNotIn(
+            'list_outbound_warehouse_options', local.registered_tool_names()
+        )
+        self.assertNotIn(
+            'list_outbound_warehouse_options', public.registered_tool_names()
+        )
+        self.assertNotIn(
+            'list_outbound_warehouse_options', OutputPresenter.OPTION_TOOLS
+        )
+
+        from tools.query_outbound_list import QueryOutboundListTool
+        warehouse = QueryOutboundListTool().metadata()[
+            'input_schema'
+        ]['properties']['warehouse_id']['description']
+        self.assertNotIn('list_outbound_warehouse_options', warehouse)
+        self.assertIn('list_outbound_filter_options', warehouse)
+
+    def test_cli_requires_and_forwards_chinese_filter_category(self):
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+
+        with self.assertRaisesRegex(ValueError, 'filter-type is required'):
+            app.run_cli([
+                'call', '--tool', 'list_outbound_filter_options',
+            ], stdout=io.StringIO())
+
+        result = app.run_cli([
+            'call', '--tool', 'list_outbound_filter_options',
+            '--filter-type', '运输方式', '--keyword', '海运',
+        ], stdout=io.StringIO())
+
+        self.assertEqual(0, result)
+        self.assertEqual({
+            'filter_type': '运输方式', 'keyword': '海运',
+            'page': 1, 'limit': 20,
+        }, client.last_call['payload'])
+
+    def test_presenter_displays_chinese_labels_and_keeps_values_for_chaining(self):
+        result = OutputPresenter().present('list_outbound_filter_options', {
+            'code': 'MCP_0000',
+            'data': {
+                'records': [
+                    {'value': 1, 'label': '空运', 'code': ''},
+                    {'value': 2, 'label': '海运', 'code': ''},
+                ],
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        })
+
+        self.assertFalse(result['is_error'])
+        self.assertEqual([1, 2], [row[0] for row in result['structured_content']['rows']])
+        serialized = json.dumps(result['structured_content'], ensure_ascii=False)
+        self.assertIn('显示名称', serialized)
+        self.assertIn('海运', serialized)
+        self.assertNotIn('filter_type', serialized)
+
+        warehouse_result = OutputPresenter().present(
+            'list_outbound_filter_options', {
+                'code': 'MCP_0000',
+                'data': {'records': [
+                    {'value': -1, 'label': '客户仓', 'code': ''},
+                    {'value': 3, 'label': '深圳仓', 'code': ''},
+                ]},
+            }
+        )
+        values = [
+            row[0] for row in warehouse_result['structured_content']['rows']
+        ]
+        from tools.query_outbound_list import QueryOutboundListTool
+        client = RecordingApiClient()
+        QueryOutboundListTool(client).call(
+            outbound_status=20, warehouse_id=values[0]
+        )
+        self.assertEqual(-1, client.last_call['payload']['warehouse_id'])
+
+    def test_outbound_list_warehouse_schema_accepts_unified_option_values(self):
+        from tools.query_outbound_list import QueryOutboundListTool
+
+        warehouse = QueryOutboundListTool().metadata()[
+            'input_schema'
+        ]['properties']['warehouse_id']
+        self.assertEqual([
+            {'type': 'integer', 'const': -1},
+            {'type': 'integer', 'minimum': 1},
+        ], warehouse['oneOf'])
+        self.assertIn('list_outbound_filter_options', warehouse['description'])
+        self.assertNotIn('list_outbound_warehouse_options', warehouse['description'])
+
+        tool = QueryOutboundListTool(RecordingApiClient())
+        for invalid_value in (True, 'bad', 0, -2):
+            with self.subTest(warehouse_id=invalid_value):
+                with self.assertRaisesRegex(ValueError, 'warehouse_id is invalid'):
+                    tool.call(
+                        outbound_status=20, warehouse_id=invalid_value
+                    )
+
+    def test_tool_descriptions_forbid_narrating_internal_arguments(self):
+        from tools.query_outbound_list import QueryOutboundListTool
+
+        for tool in (self.tool_class()(), QueryOutboundListTool()):
+            with self.subTest(tool=tool.name):
+                description = tool.metadata()['description']
+                self.assertIn('不得展示筛选字段的英文参数名', description)
+                self.assertIn('不得使用“筛选字段=内部值”', description)
+                self.assertIn('不得展示筛选项内部数字代码', description)
+                self.assertIn('正常业务数值必须保留', description)
+                self.assertIn('API固定值', description)
+                self.assertIn('接口参数', description)
+                self.assertIn('默认参数', description)
+
+    def test_outbound_result_keeps_normal_numeric_business_values(self):
+        fields = list(OutputPresenter.TABLE_COLUMNS['query_outbound_list'])
+        record = {key: '' for key in fields}
+        record['total_volume'] = 12.5
+        record['total_weight'] = 30
+        result = OutputPresenter().present('query_outbound_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'summary': '当前页返回 1 张排舱单',
+                'columns': [
+                    {'key': key, 'name': 'backend_' + key} for key in fields
+                ],
+                'records': [record],
+            },
+        })
+        volume_index = fields.index('total_volume')
+        weight_index = fields.index('total_weight')
+
+        self.assertEqual(12.5, result['structured_content']['rows'][0][volume_index])
+        self.assertEqual(30, result['structured_content']['rows'][0][weight_index])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 516 - 0
tests/test_outbound_query_tools.py

@@ -0,0 +1,516 @@
+import importlib
+import io
+import json
+import os
+import unittest
+
+from app import GatewayApp
+from mcp_protocol import McpProtocolHandler
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+
+
+LIST_KEYS = [
+    'outbound_number', 'direct_send', 'remark', 'cargo_type',
+    'warehouse_name', 'status', 'so_number', 'container_code',
+    'seal_number', 'container_type', 'shipping_method', 'total_volume',
+    'total_weight', 'ship_schedule', 'closing_time', 'est_loading_time',
+    'operator', 'operation_modes', 'operation_time',
+]
+
+DETAIL_KEYS = [
+    'order_number', 'cargo_type', 'customs_files', 'reference_number',
+    'customer_name', 'declaration_type', 'merge_declare_number',
+    'order_remark', 'bl_number', 'container_code', 'customer_service',
+    'est_inbound_date', 'inbound_date', 'status', 'pieces', 'weight',
+    'volume', 'pickup_place', 'product_name', 'goods_name', 'closing_time',
+    'clearance_remark', 'import_clearance_remark', 'delivery_address',
+    'delivery_type', 'delivery_way', 'channel', 'country_name',
+    'importer_name', 'vat_type', 'packing_type', 'is_abnormal',
+    'package_method', 'order_reply',
+]
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.last_call = None
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': [
+                'query_outbound_list', 'query_outbound_detail',
+            ]},
+        }
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.last_call = {
+            'tool_code': tool_code,
+            'route_path': route_path,
+            'payload': payload,
+            'request_id': request_id,
+        }
+        return {'code': 'MCP_0000', 'data': {}}
+
+
+class PublicSessionStore:
+    def get(self, gateway_session_id):
+        if gateway_session_id == 'GWS_test':
+            return {'mcp_token': 'MT_test', 'company_id': 7, 'admin_id': 9}
+        return None
+
+
+class PublicApiClient:
+    def list_enabled_tools(self, token, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': [
+                'query_outbound_list', 'query_outbound_detail',
+            ]},
+        }
+
+    def call_tool(self, **kwargs):
+        return {'code': 'MCP_0000', 'data': {}}
+
+
+class OutboundQueryToolTest(unittest.TestCase):
+    def tool_class(self, module_name, class_name):
+        root = os.path.dirname(os.path.dirname(__file__))
+        self.assertTrue(os.path.exists(os.path.join(
+            root, 'tools', module_name + '.py'
+        )))
+        return getattr(importlib.import_module('tools.' + module_name), class_name)
+
+    def test_list_metadata_is_bounded_and_never_accepts_identity_overrides(self):
+        cls = self.tool_class('query_outbound_list', 'QueryOutboundListTool')
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        properties = schema['properties']
+
+        self.assertEqual('query_outbound_list', metadata['name'])
+        self.assertFalse(schema['additionalProperties'])
+        for forbidden in ('company_id', 'admin_id', 'is_super', 'outbound_id'):
+            self.assertNotIn(forbidden, properties)
+        for field in (
+            'outbound_numbers', 'order_numbers', 'container_codes',
+            'so_numbers', 'bl_numbers',
+        ):
+            self.assertEqual('array', properties[field]['type'])
+            self.assertEqual(100, properties[field]['maxItems'])
+        self.assertEqual(['outbound_status'], schema['required'])
+        self.assertNotIn('default', properties['outbound_status'])
+        self.assertEqual(
+            [20, 30, 60, 70, 80, 90, 100, 110, 120],
+            properties['outbound_status']['enum'],
+        )
+        for label in (
+            '国内排舱', '国内拖车', '出库装柜', '出口报关', '干线运输',
+            '目的国清关', '海外拖车', '海外仓入库', '完成',
+        ):
+            self.assertIn(label, properties['outbound_status']['description'])
+        self.assertNotIn('10、20', properties['outbound_status']['description'])
+        self.assertNotIn('40、45、50、60', properties['outbound_status']['description'])
+        self.assertNotIn('default', properties['shipping_method'])
+        self.assertIn('未指定时查询全部', properties['shipping_method']['description'])
+        self.assertEqual(100, properties['limit']['maximum'])
+        self.assertIn('后台排舱单列表', metadata['description'])
+        self.assertIn('不得展示内部参数名', metadata['description'])
+
+    def test_list_call_normalizes_and_forwards_only_supported_filters(self):
+        cls = self.tool_class('query_outbound_list', 'QueryOutboundListTool')
+        client = RecordingApiClient()
+        tool = cls(api_client=client)
+
+        tool.call(
+            outbound_numbers=[' PC001 ', 'PC001', 'PC002'],
+            outbound_status=60,
+            shipping_method=2,
+            trailer_types=[1, 2],
+            page=2,
+            limit=50,
+            request_id='rq_list',
+        )
+
+        self.assertEqual('query_outbound_list', client.last_call['tool_code'])
+        self.assertEqual('/mcp/tools/queryOutboundList', client.last_call['route_path'])
+        self.assertEqual({
+            'outbound_numbers': ['PC001', 'PC002'],
+            'outbound_status': 60,
+            'shipping_method': 2,
+            'trailer_types': [1, 2],
+            'page': 2,
+            'limit': 50,
+        }, client.last_call['payload'])
+
+    def test_list_call_forwards_complete_optional_filter_contract(self):
+        cls = self.tool_class('query_outbound_list', 'QueryOutboundListTool')
+        client = RecordingApiClient()
+        tool = cls(api_client=client)
+
+        tool.call(
+            order_numbers=['ORDER001'],
+            outbound_status=20,
+            container_codes=['CONT001'],
+            so_numbers=['SO001'],
+            bl_numbers=['BL001'],
+            warehouse_id=3,
+            is_direct_send=0,
+            declaration_types=[1],
+            clearance_types=[2],
+            closing_time_start='2026-07-01',
+            closing_time_end='2026-07-02',
+            est_loading_time_start='2026-07-03',
+            est_loading_time_end='2026-07-04',
+            create_date_start='2026-07-05',
+            create_date_end='2026-07-06',
+            loading_time_start='2026-07-07',
+            loading_time_end='2026-07-08',
+        )
+
+        payload = client.last_call['payload']
+        self.assertNotIn('shipping_method', payload)
+        self.assertEqual(['ORDER001'], payload['order_numbers'])
+        self.assertEqual(['CONT001'], payload['container_codes'])
+        self.assertEqual(['SO001'], payload['so_numbers'])
+        self.assertEqual(['BL001'], payload['bl_numbers'])
+        self.assertEqual(3, payload['warehouse_id'])
+        self.assertEqual(0, payload['is_direct_send'])
+        self.assertEqual([1], payload['declaration_types'])
+        self.assertEqual([2], payload['clearance_types'])
+        for field in (
+            'closing_time_start', 'closing_time_end',
+            'est_loading_time_start', 'est_loading_time_end',
+            'create_date_start', 'create_date_end',
+            'loading_time_start', 'loading_time_end',
+        ):
+            self.assertIn(field, payload)
+
+    def test_list_call_rejects_invalid_boundary_shapes(self):
+        cls = self.tool_class('query_outbound_list', 'QueryOutboundListTool')
+        with self.assertRaises(RuntimeError):
+            cls().call()
+
+        client = RecordingApiClient()
+        tool = cls(api_client=client)
+        invalid_calls = (
+            lambda: tool.call(),
+            lambda: tool.call(outbound_status=20, outbound_numbers='PC001'),
+            lambda: tool.call(outbound_status=20, outbound_numbers=[1]),
+            lambda: tool.call(outbound_status=20, outbound_numbers=[' ']),
+            lambda: tool.call(outbound_status=20, outbound_numbers=[]),
+            lambda: tool.call(
+                outbound_status=20,
+                outbound_numbers=[str(i) for i in range(101)],
+            ),
+            lambda: tool.call(outbound_status=20, trailer_types=[]),
+            lambda: tool.call(outbound_status=20, page=True),
+            lambda: tool.call(outbound_status=20, page='bad'),
+            lambda: tool.call(outbound_status=20, page=0),
+            lambda: tool.call(outbound_status=True),
+            lambda: tool.call(outbound_status='bad'),
+            lambda: tool.call(outbound_status=999),
+            lambda: tool.call(outbound_status=20, closing_time_start='x' * 20),
+        )
+        for invalid_call in invalid_calls:
+            with self.subTest(invalid_call=invalid_call):
+                with self.assertRaises(ValueError):
+                    invalid_call()
+
+        tool.call(outbound_status=20, trailer_types=[1, 1])
+        self.assertEqual([1], client.last_call['payload']['trailer_types'])
+
+    def test_detail_requires_outbound_number_and_caps_page_size_at_twenty(self):
+        cls = self.tool_class('query_outbound_detail', 'QueryOutboundDetailTool')
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual(['outbound_number'], schema['required'])
+        self.assertEqual(20, schema['properties']['limit']['maximum'])
+        self.assertNotIn('outbound_id', schema['properties'])
+
+        client = RecordingApiClient()
+        tool = cls(api_client=client)
+        with self.assertRaises(ValueError):
+            tool.call(outbound_number=' ', limit=10)
+        with self.assertRaises(ValueError):
+            tool.call(outbound_number='PC001', limit=21)
+
+        tool.call(
+            outbound_number=' PC001 ', page=2, limit=20,
+            request_id='rq_detail',
+        )
+        self.assertEqual('/mcp/tools/queryOutboundDetail', client.last_call['route_path'])
+        self.assertEqual({
+            'outbound_number': 'PC001', 'page': 2, 'limit': 20,
+        }, client.last_call['payload'])
+
+    def test_detail_rejects_missing_client_and_invalid_integer_shapes(self):
+        cls = self.tool_class('query_outbound_detail', 'QueryOutboundDetailTool')
+        with self.assertRaises(RuntimeError):
+            cls().call(outbound_number='PC001')
+
+        tool = cls(api_client=RecordingApiClient())
+        invalid_calls = (
+            lambda: tool.call(outbound_number=1),
+            lambda: tool.call(outbound_number='PC001', page=True),
+            lambda: tool.call(outbound_number='PC001', page='bad'),
+            lambda: tool.call(outbound_number='PC001', page=0),
+        )
+        for invalid_call in invalid_calls:
+            with self.subTest(invalid_call=invalid_call):
+                with self.assertRaises(ValueError):
+                    invalid_call()
+
+    def test_local_and_public_gateways_register_both_tools(self):
+        local = GatewayApp(api_client=RecordingApiClient())
+        public = PublicGatewayApp(PublicSessionStore(), PublicApiClient())
+        for name in ('query_outbound_list', 'query_outbound_detail'):
+            self.assertIn(name, local.registered_tool_names())
+            self.assertIn(name, public.registered_tool_names())
+
+    def test_cli_forwards_outbound_list_filters(self):
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        output = io.StringIO()
+
+        result = app.run_cli([
+            'call', '--tool', 'query_outbound_list',
+            '--outbound-numbers', 'PC001,PC002',
+            '--bl-numbers', 'BL001,BL002',
+            '--outbound-status', '120',
+            '--shipping-method', '2',
+            '--warehouse-id', '3',
+            '--is-direct-send', '1',
+            '--trailer-types', '1,2',
+            '--closing-time-start', '2026-07-01',
+            '--page', '2', '--limit', '5',
+        ], stdout=output)
+
+        self.assertEqual(0, result)
+        self.assertEqual({
+            'outbound_numbers': ['PC001', 'PC002'],
+            'bl_numbers': ['BL001', 'BL002'],
+            'outbound_status': 120,
+            'shipping_method': 2,
+            'warehouse_id': 3,
+            'is_direct_send': 1,
+            'trailer_types': [1, 2],
+            'closing_time_start': '2026-07-01',
+            'page': 2,
+            'limit': 5,
+        }, client.last_call['payload'])
+
+    def test_cli_outbound_list_requires_stage_and_omits_unspecified_shipping_method(self):
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+
+        with self.assertRaises(ValueError):
+            app.run_cli([
+                'call', '--tool', 'query_outbound_list',
+            ], stdout=io.StringIO())
+
+        result = app.run_cli([
+            'call', '--tool', 'query_outbound_list',
+            '--outbound-status', '20',
+        ], stdout=io.StringIO())
+
+        self.assertEqual(0, result)
+        self.assertEqual({
+            'outbound_status': 20,
+            'page': 1,
+            'limit': 20,
+        }, client.last_call['payload'])
+
+    def test_cli_requires_and_forwards_outbound_detail_number(self):
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+
+        with self.assertRaises(ValueError):
+            app.run_cli([
+                'call', '--tool', 'query_outbound_detail',
+            ], stdout=io.StringIO())
+
+        result = app.run_cli([
+            'call', '--tool', 'query_outbound_detail',
+            '--outbound-number', 'PC001', '--page', '2', '--limit', '10',
+        ], stdout=io.StringIO())
+        self.assertEqual(0, result)
+        self.assertEqual({
+            'outbound_number': 'PC001', 'page': 2, 'limit': 10,
+        }, client.last_call['payload'])
+
+    def test_list_presenter_outputs_exact_nineteen_chinese_columns(self):
+        presenter = OutputPresenter()
+        data = {
+            'summary': '当前页返回 1 张排舱单',
+            'columns': [
+                {'key': key, 'name': 'backend_' + key} for key in LIST_KEYS
+            ],
+            'records': [{key: 'display value' for key in LIST_KEYS}],
+        }
+        result = presenter.present('query_outbound_list', {
+            'code': 'MCP_0000',
+            'data': data,
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        })
+
+        self.assertFalse(result['is_error'])
+        content = result['structured_content']
+        self.assertEqual(19, len(content['headers']))
+        self.assertEqual(19, len(content['rows'][0]))
+        serialized = json.dumps(content, ensure_ascii=False)
+        for key in LIST_KEYS:
+            self.assertNotIn(key, serialized)
+        self.assertIn('排舱单号', serialized)
+        self.assertIn('拖报清方式', serialized)
+
+    def test_detail_presenter_outputs_eleven_item_summary_and_thirty_four_fields(self):
+        presenter = OutputPresenter()
+        summary = {
+            'bl_number': 'BL001',
+            'container_code': 'CONT001',
+            'container_type': '纸箱',
+            'total_volume': '10',
+            'total_weight': '20',
+            'total_pieces': 3,
+            'sku': 4,
+            'buy_declaration_count': 1,
+            'general_declaration_count': 2,
+            'must_load': '1/5',
+            'backup_load': '2/5',
+        }
+        detail_record = {key: 'display value' for key in DETAIL_KEYS}
+        detail_record['customs_files'] = [{
+            'file_name': 'declaration.pdf',
+            'file_type': 'pdf',
+            'file_url': 'https://files.example/declaration.pdf',
+        }]
+        result = presenter.present('query_outbound_detail', {
+            'code': 'MCP_0000',
+            'data': {
+                'summary': summary,
+                'columns': [
+                    {'key': key, 'name': 'backend_' + key}
+                    for key in DETAIL_KEYS
+                ],
+                'records': [detail_record],
+            },
+            'meta': {'page': 1, 'limit': 10, 'has_more': False},
+        })
+
+        self.assertFalse(result['is_error'])
+        content = result['structured_content']
+        self.assertEqual(11, len(content['summary']['headers']))
+        self.assertEqual(11, len(content['summary']['row']))
+        self.assertEqual(34, len(content['details']['headers']))
+        self.assertEqual(34, len(content['details']['rows'][0]))
+        serialized = json.dumps(content, ensure_ascii=False)
+        for key in list(summary) + DETAIL_KEYS:
+            self.assertNotIn(key, serialized)
+        for key in ('file_name', 'file_type', 'file_url'):
+            self.assertNotIn(key, serialized)
+        self.assertIn('提单号', serialized)
+        self.assertIn('订单号', serialized)
+        self.assertIn('报关资料', serialized)
+        self.assertIn('文件名称', serialized)
+        self.assertIn('文件链接', serialized)
+
+    def test_detail_presenter_rejects_every_malformed_boundary(self):
+        presenter = OutputPresenter()
+
+        def valid_data():
+            summary = {
+                key: '' for key in presenter.OUTBOUND_DETAIL_SUMMARY
+            }
+            record = {key: '' for key in DETAIL_KEYS}
+            record['customs_files'] = []
+            return {
+                'summary': summary,
+                'columns': [{'key': key} for key in DETAIL_KEYS],
+                'records': [record],
+            }
+
+        malformed = []
+        data = valid_data()
+        data['summary'] = []
+        malformed.append(data)
+        data = valid_data()
+        data['columns'] = 'bad'
+        malformed.append(data)
+        data = valid_data()
+        data['columns'] = []
+        malformed.append(data)
+        data = valid_data()
+        data['records'] = {}
+        malformed.append(data)
+        data = valid_data()
+        del data['summary']['sku']
+        malformed.append(data)
+        data = valid_data()
+        data['columns'] = [None]
+        malformed.append(data)
+        data = valid_data()
+        data['columns'] = [{'key': 1}]
+        malformed.append(data)
+        data = valid_data()
+        data['columns'] = [{'key': 'unknown'}]
+        malformed.append(data)
+        data = valid_data()
+        data['records'] = [None]
+        malformed.append(data)
+        data = valid_data()
+        data['records'][0]['customs_files'] = 'bad'
+        malformed.append(data)
+        data = valid_data()
+        data['records'][0]['customs_files'] = [None]
+        malformed.append(data)
+
+        for data in malformed:
+            with self.subTest(data=data):
+                result = presenter.present('query_outbound_detail', {
+                    'code': 'MCP_0000',
+                    'data': data,
+                })
+                self.assertTrue(result['is_error'])
+                self.assertEqual(
+                    '工具返回格式异常',
+                    result['structured_content']['message'],
+                )
+
+    def test_detail_presenter_renders_tips_without_pagination_and_none_values(self):
+        presenter = OutputPresenter()
+        summary = {key: '' for key in presenter.OUTBOUND_DETAIL_SUMMARY}
+        record = {key: '' for key in DETAIL_KEYS}
+        record['order_number'] = None
+        record['customs_files'] = []
+
+        result = presenter.present('query_outbound_detail', {
+            'code': 'MCP_0000',
+            'data': {
+                'summary': summary,
+                'columns': [{'key': key} for key in DETAIL_KEYS],
+                'records': [record],
+                'tips': ['没有更多数据'],
+            },
+        })
+
+        self.assertFalse(result['is_error'])
+        details = result['structured_content']['details']
+        self.assertNotIn('pagination', details)
+        self.assertEqual(['没有更多数据'], details['tips'])
+        self.assertEqual('', details['rows'][0][0])
+        self.assertIn('提示:没有更多数据', result['text'])
+
+    def test_protocol_safe_error_paths_work_without_trace_request_id(self):
+        response = McpProtocolHandler._tool_exception_response(
+            1,
+            'query_outbound_detail',
+            ValueError('outbound_number is required'),
+        )
+        self.assertNotIn('_meta', response['result'])
+
+        error = McpProtocolHandler._error_response(2, -32600, 'Invalid Request')
+        self.assertNotIn('data', error['error'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 12 - 10
tests/test_public_server.py

@@ -371,7 +371,7 @@ class RateLimitTest(unittest.TestCase):
         self.assertIn('error', response)
         self.assertIn('Rate limit', response['error']['message'])
 
-    # --- initialize 不受限流,tools/list 有独立 bucket ---
+    # --- initialize 和 tools/list 不受限流 ---
 
     def test_initialize_is_never_rate_limited(self):
         # initialize 是握手协议,无论请求多少次都不应被限流
@@ -381,20 +381,22 @@ class RateLimitTest(unittest.TestCase):
             self.assertNotIn('error', response, f'initialize should never be rate limited (attempt {i})')
             self.assertIn('protocolVersion', response['result'])
 
-    def test_tools_list_is_rate_limited(self):
+    def test_tools_list_is_never_rate_limited(self):
         handler = self._make_handler(max_requests=1)
         headers = {'X-Gateway-Session': 'GWS_A'}
-        handler.handle_json_rpc(headers, {'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list'}, client_ip='1.2.3.4')
-        response = handler.handle_json_rpc(headers, {'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'}, client_ip='1.2.3.4')
-        self.assertIn('error', response)
-        self.assertIn('Rate limit', response['error']['message'])
+        for request_id in range(1, 4):
+            response = handler.handle_json_rpc(
+                headers,
+                {'jsonrpc': '2.0', 'id': request_id, 'method': 'tools/list'},
+                client_ip='1.2.3.4',
+            )
+            self.assertNotIn('error', response)
 
-    def test_tools_list_quota_independent_from_tools_call_quota(self):
-        # tools/list 和 tools/call 使用不同 bucket,互不影响
+    def test_tools_list_requests_do_not_consume_tools_call_quota(self):
+        # tools/list 不进入限流器,因此不会消耗 tools/call 配额
         handler = self._make_handler(max_requests=1)
-        # 用掉 tools/list 的1个 slot
         handler.handle_json_rpc({'X-Gateway-Session': 'GWS_A'}, {'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list'}, client_ip='1.2.3.4')
-        # tools/call 应仍可正常执行(使用独立的 client_ip:tool_name bucket)
+        # tools/call 应仍可正常执行(使用 session_id:tool_name bucket)
         response = handler.handle_json_rpc(
             {'X-Gateway-Session': 'GWS_A'},
             {'jsonrpc': '2.0', 'id': 2, 'method': 'tools/call',

+ 97 - 0
tests/test_tool_description_boundaries.py

@@ -0,0 +1,97 @@
+import unittest
+
+from tools.export_out_of_province_port_data import (
+    ExportOutOfProvincePortDataTool,
+)
+from tools.export_pending_outbound_orders import ExportPendingOutboundOrdersTool
+from tools.list_order_filter_options import ListOrderFilterOptionsTool
+from tools.list_pending_outbound_export_filter_options import (
+    ListPendingOutboundExportFilterOptionsTool,
+)
+from tools.query_customs_declaration_files import QueryCustomsDeclarationFilesTool
+from tools.query_order import QueryOrderTool
+from tools.query_order_exact import QueryOrderExactTool
+from tools.query_outbound_detail import QueryOutboundDetailTool
+from tools.query_outbound_list import QueryOutboundListTool
+from tools.query_track import QueryTrackTool
+
+
+class ToolDescriptionBoundaryTest(unittest.TestCase):
+    def test_every_existing_tool_states_use_and_prohibition_boundaries(self):
+        tools = (
+            QueryOrderTool(),
+            QueryOrderExactTool(),
+            QueryTrackTool(),
+            QueryCustomsDeclarationFilesTool(),
+            QueryOutboundListTool(),
+            QueryOutboundDetailTool(),
+            ListOrderFilterOptionsTool(),
+            ListPendingOutboundExportFilterOptionsTool(),
+            ExportPendingOutboundOrdersTool(),
+            ExportOutOfProvincePortDataTool(),
+        )
+
+        for tool in tools:
+            with self.subTest(tool=tool.name):
+                description = tool.metadata()['description']
+                self.assertIn('使用场景:', description)
+                self.assertIn('禁止使用:', description)
+
+    def test_number_tools_require_clarification_and_forbid_trial_queries(self):
+        tools = (
+            QueryOrderTool(),
+            QueryOrderExactTool(),
+            QueryTrackTool(),
+            QueryCustomsDeclarationFilesTool(),
+            QueryOutboundListTool(),
+            QueryOutboundDetailTool(),
+            ExportOutOfProvincePortDataTool(),
+        )
+
+        for tool in tools:
+            with self.subTest(tool=tool.name):
+                description = tool.metadata()['description']
+                self.assertIn('号码类型不明确时必须先询问用户', description)
+                self.assertIn('不得根据号码格式猜测', description)
+                self.assertIn('不得跨字段或跨工具试查', description)
+
+    def test_result_intent_distinguishes_adjacent_tools(self):
+        expected = {
+            QueryOrderTool(): ('旧版跨字段通用搜索', 'query_order_exact', '订单列表'),
+            QueryOrderExactTool(): ('订单资料', '排舱列表', '物流轨迹'),
+            QueryTrackTool(): ('物流轨迹', '订单资料', '报关资料文件'),
+            QueryCustomsDeclarationFilesTool(): ('报关资料文件', '完整排舱详情', '文件链接'),
+            QueryOutboundListTool(): ('排舱列表', '19个字段', '排舱详情'),
+            QueryOutboundDetailTool(): ('排舱详情', '11项汇总', '34项订单明细'),
+            ListOrderFilterOptionsTool(): ('query_order_exact', '授权筛选值', '业务数据'),
+            ListPendingOutboundExportFilterOptionsTool(): (
+                'export_pending_outbound_orders', '授权筛选值', 'query_outbound_list',
+            ),
+            ExportPendingOutboundOrdersTool(): ('明确要求导出', '未排舱订单', '查看或查询'),
+            ExportOutOfProvincePortDataTool(): ('明确要求导出', '省外进港资料', '普通排舱查询'),
+        }
+
+        for tool, phrases in expected.items():
+            with self.subTest(tool=tool.name):
+                description = tool.metadata()['description']
+                for phrase in phrases:
+                    self.assertIn(phrase, description)
+
+    def test_outbound_number_fields_require_explicit_business_types(self):
+        properties = QueryOutboundListTool().metadata()['input_schema']['properties']
+        for field, label in (
+            ('outbound_numbers', '排舱单号'),
+            ('order_numbers', '订单号'),
+            ('container_codes', '柜号'),
+            ('so_numbers', 'SO号'),
+            ('bl_numbers', '提单号'),
+        ):
+            with self.subTest(field=field):
+                description = properties[field]['description']
+                self.assertIn(label, description)
+                self.assertIn('仅当用户明确', description)
+                self.assertIn('不得放入其他类型号码', description)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 6 - 4
tools/export_out_of_province_port_data.py

@@ -14,13 +14,15 @@ class ExportOutOfProvincePortDataTool:
         return {
             'name': self.name,
             'description': (
-                '导出后台排舱单列表中的省外进港资料,并返回可下载文件 URL。'
+                '使用场景:只有用户明确要求导出后台排舱单列表中的省外进港资料并需要'
+                '下载文件时使用,返回可下载文件URL。'
                 '只能在用户明确要求导出省外进港资料,并明确提供排舱单号、'
                 '柜号、提单号或SO号中的一种,以及宁波、上海或美森资料类型时调用。'
-                '用户没有明确号码类型时必须先询问:'
+                '号码类型不明确时必须先询问用户;用户没有明确号码类型时必须先询问:'
                 '“请确认使用哪种单号导出:排舱单号、柜号、提单号还是SO号?”'
-                '确认前不得调用。不得根据号码格式猜测,也不得在查询失败后'
-                '切换号码类型重试。一次只能选择一种号码类型。'
+                '确认前不得调用。禁止使用:普通排舱查询、排舱详情、订单查询或普通文件'
+                '查询不得调用本工具。不得根据号码格式猜测,不得跨字段或跨工具试查,'
+                '也不得在查询失败后切换号码类型重试。一次只能选择一种号码类型。'
                 '一次只能选择一种资料类型;用户要求多个类型时应分别调用。'
                 '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,'
                 '不得展示内部参数名。'

+ 5 - 1
tools/export_pending_outbound_orders.py

@@ -64,8 +64,12 @@ class ExportPendingOutboundOrdersTool:
         return {
             'name': self.name,
             'description': (
-                '按 outbound/add.html 的筛选条件导出未排舱订单。筛选名称必须先通过 '
+                '使用场景:只有用户明确要求导出未排舱订单并需要下载文件时,才按'
+                'outbound/add.html的筛选条件调用。筛选名称必须先通过'
                 'list_pending_outbound_export_filter_options 获取当前员工有权限的 value。'
+                '禁止使用:用户只是查看或查询订单、排舱列表或排舱详情时不得调用;'
+                '本工具不导出已排舱列表,也不导出省外进港资料。用户只说“单号”时必须'
+                '先确认是订单号还是客户参考号;不得用导出结果试探号码类型。'
                 '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,不得展示内部参数名。'
             ),
             'input_schema': {'type': 'object', 'properties': properties},

+ 4 - 2
tools/list_order_filter_options.py

@@ -17,8 +17,10 @@ class ListOrderFilterOptionsTool:
         return {
             'name': self.name,
             'description': (
-                'List authorized values for query_order_exact filters. '
-                'Use the returned value directly in the corresponding filter. '
+                '使用场景:只为query_order_exact查询产品、客户、销售、仓库、事业部和国家'
+                '条件时取得当前员工的授权筛选值,并将返回值直接传给对应条件。'
+                '禁止使用:本工具不返回订单、排舱或轨迹等业务数据,不得用于'
+                'query_outbound_list、未排舱导出或其他工具;用户提供名称时禁止猜测ID。'
                 '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,'
                 '不得展示内部参数名。'
             ),

+ 95 - 0
tools/list_outbound_filter_options.py

@@ -0,0 +1,95 @@
+class ListOutboundFilterOptionsTool:
+    name = 'list_outbound_filter_options'
+    route_path = '/mcp/tools/listOutboundFilterOptions'
+    FILTER_TYPES = (
+        '排舱阶段', '运输方式', '集货仓库', '是否直送柜',
+        '拖车方式', '报关方式', '清关方式',
+    )
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户需要筛选排舱列表时,先按一个中文筛选类别调用本工具,'
+                '取得后台同源的中文名称和可传值,再把选中的值传给query_outbound_list。'
+                '排舱阶段未明确时先列出中文阶段并让用户选择;运输方式未指定时不调用该类'
+                '筛选,列表将查询全部运输方式。集货仓库唯一命中可直接使用,多条命中必须'
+                '让用户选择。禁止使用:本工具不返回排舱或订单业务数据,禁止猜测任何筛选值,'
+                '也禁止用于排舱列表以外的工具。参数只用于工具内部调用;最终回答只能展示中文'
+                '业务名称;描述筛选条件时不得展示筛选字段的英文参数名,不得使用'
+                '“筛选字段=内部值”形式,不得展示筛选项内部数字代码,不得使用“API固定值”'
+                '“接口参数”“默认参数”等技术表述。查询结果中的件数、重量、体积、日期等'
+                '正常业务数值必须保留。'
+            ),
+            '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_outbound_filter_options',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for list_outbound_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')
+        page = self._bounded_integer(page, 'page')
+        limit = self._bounded_integer(limit, 'limit')
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            {
+                'filter_type': filter_type,
+                'keyword': keyword,
+                'page': page,
+                'limit': limit,
+            },
+            request_id,
+        )
+
+    @staticmethod
+    def _bounded_integer(value, field):
+        if isinstance(value, bool):
+            raise ValueError('{0} is invalid'.format(field))
+        try:
+            value = int(value)
+        except (TypeError, ValueError):
+            raise ValueError('{0} is invalid'.format(field))
+        if value < 1 or value > 100:
+            raise ValueError('{0} is invalid'.format(field))
+        return value

+ 5 - 2
tools/list_pending_outbound_export_filter_options.py

@@ -13,8 +13,11 @@ class ListPendingOutboundExportFilterOptionsTool:
         return {
             'name': self.name,
             'description': (
-                '列出当前员工有权限使用的未排舱订单导出筛选项。用户提供名称时,必须先查到 value,'
-                '不得猜测 ID;关键字搜索也只在授权结果内执行。参数名仅用于工具调用;'
+                '使用场景:只为export_pending_outbound_orders取得当前员工有权限使用的'
+                '未排舱订单导出授权筛选值;用户提供名称时必须先查到value,不得猜测ID,'
+                '关键字搜索也只在授权结果内执行。禁止使用:本工具不返回订单或排舱业务数据,'
+                '不得为query_order_exact、query_outbound_list或其他工具提供筛选值。'
+                '参数名仅用于工具调用;'
                 '向用户回答时只能使用中文业务名称,不得展示内部参数名。'
             ),
             'input_schema': {

+ 7 - 3
tools/query_customs_declaration_files.py

@@ -15,7 +15,8 @@ class QueryCustomsDeclarationFilesTool:
         return {
             'name': self.name,
             'description': (
-                '批量查询订单的报关资料文件链接。用户明确说“订单号”时使用'
+                '使用场景:用户明确要查看或下载报关资料文件和文件链接时,批量查询订单'
+                '的报关资料文件链接。用户明确说“订单号”时使用'
                 '订单号数组;用户明确说“排舱单号”时使用排舱单号数组。'
                 '这里的订单号是后台订单列表及排舱详情“订单号”列显示的号码,'
                 '不是系统单号、数据库 order_id/id、客户参考号、快递单号或排舱单号。'
@@ -24,8 +25,11 @@ class QueryCustomsDeclarationFilesTool:
                 '让用户选择“订单号”或“排舱单号”,确认前不得调用工具。'
                 '用户只说“单号”时必须先追问是订单号还是排舱单号。'
                 '用户说“系统单号”时也不得当作订单号;必须先确认并取得订单号。'
-                '不得根据号码格式猜测。'
-                '查询无结果后不得切换号码类型,不得跨字段重试。'
+                '号码类型不明确时必须先询问用户。禁止使用:用户要查看完整排舱详情时'
+                '应使用 query_outbound_detail,不得用本工具替代;本工具也不返回订单资料、'
+                '排舱列表、物流轨迹或导出文件。不得根据号码格式猜测。'
+                '查询无结果后不得切换号码类型,不得跨字段重试,'
+                '不得跨字段或跨工具试查。'
                 '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,'
                 '不得展示内部参数名。'
             ),

+ 17 - 2
tools/query_order.py

@@ -8,11 +8,26 @@ class QueryOrderTool:
     def metadata(self):
         return {
             'name': self.name,
-            'description': 'Query orders with the current employee permissions.',
+            'description': (
+                '使用场景:仅兼容旧版跨字段通用搜索;只有用户明确要求把同一号码'
+                '同时作为订单号、客户参考号、快递单号、排舱单号、柜号和SO号搜索,'
+                '并且目标是查看订单列表时使用。禁止使用:用户已明确号码类型时必须'
+                '改用 query_order_exact;用户只说“单号”“这个号码”或号码类型不明确时'
+                '必须先询问用户,确认前不得调用。不得根据号码格式猜测,不得并行调用'
+                '候选工具,不得跨字段或跨工具试查,查询无结果后也不得换字段重试。'
+                '本工具返回订单列表,不返回排舱列表、排舱详情、物流轨迹、报关资料文件'
+                '或导出文件。'
+            ),
             'input_schema': {
                 'type': 'object',
                 'properties': {
-                    'keyword': {'type': 'string'},
+                    'keyword': {
+                        'type': 'string',
+                        'description': (
+                            '旧版跨字段通用搜索值。只有用户明确接受同时搜索订单号、'
+                            '客户参考号、快递单号、排舱单号、柜号和SO号时才可传入。'
+                        ),
+                    },
                     'page': {'type': 'integer', 'minimum': 1},
                     'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100},
                 },

+ 9 - 3
tools/query_order_exact.py

@@ -177,15 +177,21 @@ class QueryOrderExactTool:
         return {
             'name': self.name,
             'description': (
-                '按用户明确指定的字段精准查询订单,并应用当前员工权限。'
+                '使用场景:用户明确要查看订单资料或订单列表,并且已经明确每个号码的'
+                '业务类型时,按指定字段精准查询订单;即使使用排舱单号、柜号或SO号筛选,'
+                '返回目标仍是订单资料。'
                 '订单号、客户参考号、快递单号、排舱单号、柜号和 SO 号必须'
                 '使用各自对应字段。'
                 '所有单号格式均为开放格式,只能根据用户明确说出的业务类型'
                 '选择字段,不得根据号码的前缀、长度、字符组合或示例猜测。'
-                '单号类型不明确时先询问用户,不要调用工具猜测。'
+                '不得根据号码格式猜测。'
+                '号码类型不明确时必须先询问用户;单号类型不明确时先询问用户,'
+                '确认前不得调用。'
                 '一个值使用单数字段,多个同类值使用复数字段数组;'
                 '同一字段使用 IN,不同字段使用 AND。'
-                '查询无结果时不得改用其他字段重试。'
+                '禁止使用:用户要看排舱列表、排舱详情、物流轨迹、报关资料文件或导出'
+                '结果时不得使用本工具。不得跨字段或跨工具试查;查询无结果时不得改用'
+                '其他字段重试。'
                 '产品、客户、销售、仓库、事业部和国家条件先调用 '
                 'list_order_filter_options。'
                 '参数名仅用于工具调用;向用户回答时只能使用对应的中文业务名称,'

+ 82 - 0
tools/query_outbound_detail.py

@@ -0,0 +1,82 @@
+class QueryOutboundDetailTool:
+    name = 'query_outbound_detail'
+    route_path = '/mcp/tools/queryOutboundDetail'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户明确要查看一张排舱单的排舱详情,并明确提供排舱单号时'
+                '使用。返回第一层11项汇总和第二层34项订单明细,包括明细表中的报关资料。'
+                '只接受排舱单号,不接受订单号、柜号、SO号、提单号或数据库ID。用户明确'
+                '要看排舱详情但只提供订单号时,应先用query_outbound_list按订单号定位;'
+                '多条命中必须让用户选择。号码类型不明确时必须先询问用户。'
+                '禁止使用:用户要看排舱列表、订单资料、物流轨迹或仅查看报关资料文件链接'
+                '时不得使用本工具。不得根据号码格式猜测,不得跨字段或跨工具试查,'
+                '查询无结果后不得把同一号码改作其他类型重试。'
+                '公司、员工和权限范围由当前设备会话确定。参数名仅用于工具调用;'
+                '向用户回答时只能展示中文业务名称,不得展示内部参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'outbound_number': {
+                        'type': 'string', 'minLength': 1, 'maxLength': 100,
+                        'description': '排舱单号,不是订单号、柜号、SO号或数据库ID。',
+                    },
+                    'page': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 20,
+                        'default': 10,
+                    },
+                },
+                'required': ['outbound_number'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        outbound_number=None,
+        page=1,
+        limit=10,
+        request_id='rq_query_outbound_detail',
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for query_outbound_detail')
+        if not isinstance(outbound_number, str):
+            raise ValueError('outbound_number is required')
+        outbound_number = outbound_number.strip()
+        if not outbound_number or len(outbound_number) > 100:
+            raise ValueError('outbound_number is required')
+        page = self._bounded_integer(page, 'page', 100)
+        limit = self._bounded_integer(limit, 'limit', 20)
+
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            {
+                'outbound_number': outbound_number,
+                'page': page,
+                'limit': limit,
+            },
+            request_id,
+        )
+
+    @staticmethod
+    def _bounded_integer(value, field, maximum):
+        if isinstance(value, bool):
+            raise ValueError('{0} is invalid'.format(field))
+        try:
+            value = int(value)
+        except (TypeError, ValueError):
+            raise ValueError('{0} is invalid'.format(field))
+        if value < 1 or value > maximum:
+            raise ValueError('{0} is invalid'.format(field))
+        return value

+ 267 - 0
tools/query_outbound_list.py

@@ -0,0 +1,267 @@
+class QueryOutboundListTool:
+    name = 'query_outbound_list'
+    route_path = '/mcp/tools/queryOutboundList'
+    MAX_NUMBERS = 100
+    STATUS_VALUES = (20, 30, 60, 70, 80, 90, 100, 110, 120)
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        number_array = {
+            'type': 'array',
+            'items': {'type': 'string', 'minLength': 1, 'maxLength': 100},
+            'minItems': 1,
+            'maxItems': self.MAX_NUMBERS,
+        }
+        mode_array = {
+            'type': 'array',
+            'items': {'type': 'integer', 'enum': [1, 2]},
+            'minItems': 1,
+            'maxItems': 2,
+            'uniqueItems': True,
+        }
+        properties = {
+            'outbound_numbers': dict(
+                number_array,
+                description='排舱单号数组。仅当用户明确说排舱单号时使用;不得放入其他类型号码。',
+            ),
+            'order_numbers': dict(
+                number_array,
+                description='订单号数组。仅当用户明确说订单号时使用;不得放入其他类型号码。',
+            ),
+            'container_codes': dict(
+                number_array,
+                description='柜号或集装箱号数组。仅当用户明确说柜号时使用;不得放入其他类型号码。',
+            ),
+            'so_numbers': dict(
+                number_array,
+                description='SO号数组。仅当用户明确说SO号时使用;不得放入其他类型号码。',
+            ),
+            'bl_numbers': dict(
+                number_array,
+                description='提单号数组。仅当用户明确说提单号时使用;不得放入其他类型号码。',
+            ),
+            'outbound_status': {
+                'type': 'integer', 'enum': list(self.STATUS_VALUES),
+                'description': (
+                    '排舱阶段筛选值。先调用list_outbound_filter_options并选择“排舱阶段”'
+                    '取得后台中文阶段和可传值;可选阶段为国内排舱、国内拖车、出库装柜、'
+                    '出口报关、干线运输、目的国清关、海外拖车、海外仓入库、完成。'
+                    '用户未明确时必须先询问,不得默认选择。'
+                ),
+            },
+            'shipping_method': {
+                'type': 'integer', 'enum': [1, 2, 3],
+                'description': (
+                    '运输方式筛选值。明确指定时先通过统一筛选选项工具取得;未指定时查询全部,'
+                    '调用工具时不传该筛选。'
+                ),
+            },
+            'warehouse_id': {
+                'oneOf': [
+                    {'type': 'integer', 'const': -1},
+                    {'type': 'integer', 'minimum': 1},
+                ],
+                'description': (
+                    '集货仓库筛选值。必须先调用list_outbound_filter_options并选择'
+                    '“集货仓库”取得;禁止猜测内部值。'
+                ),
+            },
+            'is_direct_send': dict(
+                {'type': 'integer', 'enum': [0, 1]},
+                description='是否直送柜筛选值,先通过统一筛选选项工具取得。',
+            ),
+            'trailer_types': dict(mode_array, description='拖车方式筛选值,先通过统一筛选选项工具取得。'),
+            'declaration_types': dict(mode_array, description='报关方式筛选值,先通过统一筛选选项工具取得。'),
+            'clearance_types': dict(mode_array, description='清关方式筛选值,先通过统一筛选选项工具取得。'),
+            'page': {'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 1},
+            'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 20},
+        }
+        for field, label in (
+            ('closing_time_start', '截关开始时间'),
+            ('closing_time_end', '截关结束时间'),
+            ('est_loading_time_start', '预计装柜开始时间'),
+            ('est_loading_time_end', '预计装柜结束时间'),
+            ('create_date_start', '创建开始时间'),
+            ('create_date_end', '创建结束时间'),
+            ('loading_time_start', '出库开始时间'),
+            ('loading_time_end', '出库结束时间'),
+        ):
+            properties[field] = {
+                'type': 'string', 'maxLength': 19,
+                'description': label + ',格式与后台筛选一致。',
+            }
+
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户明确要查看或筛选排舱列表时使用,返回后台排舱单列表权限'
+                '范围内固定的19个字段。运输方式未指定时查询全部;排舱阶段必须由用户明确,未明确时'
+                '必须先询问,不得默认选择。号码类型不明确时必须先'
+                '询问用户,确认是排舱单号、订单号、柜号、SO号还是提单号后再调用。'
+                '只有用户明确分别提供多个不同类型条件时才按AND组合。阶段、运输方式、仓库、'
+                '直送柜、拖车、报关和清关筛选均先调用list_outbound_filter_options取得可传值。'
+                '禁止使用:用户要查看订单资料、排舱详情、物流轨迹、报关资料文件或导出'
+                '结果时不得使用本工具。不得根据号码格式猜测,不得跨字段或跨工具试查,'
+                '查询无结果后不得换号码字段重试。'
+                '公司、员工和权限范围由当前设备会话确定,调用方不得覆盖。'
+                '参数只用于工具内部调用;描述筛选条件时只能使用中文业务名称,不得展示筛选'
+                '字段的英文参数名,不得展示内部参数名,不得使用“筛选字段=内部值”形式,'
+                '不得展示筛选项内部数字'
+                '代码,不得使用“API固定值”“接口参数”“默认参数”等技术表述。查询结果中的'
+                '件数、重量、体积、日期等正常业务数值必须保留。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': properties,
+                'required': ['outbound_status'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        outbound_numbers=None,
+        order_numbers=None,
+        container_codes=None,
+        so_numbers=None,
+        bl_numbers=None,
+        outbound_status=None,
+        shipping_method=None,
+        warehouse_id=None,
+        is_direct_send=None,
+        trailer_types=None,
+        declaration_types=None,
+        clearance_types=None,
+        closing_time_start='',
+        closing_time_end='',
+        est_loading_time_start='',
+        est_loading_time_end='',
+        create_date_start='',
+        create_date_end='',
+        loading_time_start='',
+        loading_time_end='',
+        page=1,
+        limit=20,
+        request_id='rq_query_outbound_list',
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for query_outbound_list')
+
+        payload = {
+            'outbound_status': self._enum_integer(
+                outbound_status, 'outbound_status', self.STATUS_VALUES
+            ),
+            'page': self._bounded_integer(page, 'page', 100),
+            'limit': self._bounded_integer(limit, 'limit', 100),
+        }
+        if shipping_method is not None:
+            payload['shipping_method'] = self._enum_integer(
+                shipping_method, 'shipping_method', (1, 2, 3)
+            )
+        for field, values in (
+            ('outbound_numbers', outbound_numbers),
+            ('order_numbers', order_numbers),
+            ('container_codes', container_codes),
+            ('so_numbers', so_numbers),
+            ('bl_numbers', bl_numbers),
+        ):
+            if values is not None:
+                payload[field] = self._string_array(values, field)
+        for field, values in (
+            ('trailer_types', trailer_types),
+            ('declaration_types', declaration_types),
+            ('clearance_types', clearance_types),
+        ):
+            if values is not None:
+                payload[field] = self._mode_array(values, field)
+        if warehouse_id is not None:
+            payload['warehouse_id'] = self._warehouse_id(warehouse_id)
+        if is_direct_send is not None:
+            payload['is_direct_send'] = self._enum_integer(
+                is_direct_send, 'is_direct_send', (0, 1)
+            )
+        for field, value in (
+            ('closing_time_start', closing_time_start),
+            ('closing_time_end', closing_time_end),
+            ('est_loading_time_start', est_loading_time_start),
+            ('est_loading_time_end', est_loading_time_end),
+            ('create_date_start', create_date_start),
+            ('create_date_end', create_date_end),
+            ('loading_time_start', loading_time_start),
+            ('loading_time_end', loading_time_end),
+        ):
+            value = str(value or '').strip()
+            if len(value) > 19:
+                raise ValueError('{0} is too long'.format(field))
+            if value:
+                payload[field] = value
+
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id
+        )
+
+    @classmethod
+    def _string_array(cls, values, field):
+        if not isinstance(values, list) or len(values) > cls.MAX_NUMBERS:
+            raise ValueError('{0} must contain 1 to 100 values'.format(field))
+        result = []
+        for value in values:
+            if not isinstance(value, str):
+                raise ValueError('{0} must contain strings'.format(field))
+            value = value.strip()
+            if not value or len(value) > 100:
+                raise ValueError('{0} contains an invalid value'.format(field))
+            if value not in result:
+                result.append(value)
+        if not result:
+            raise ValueError('{0} must contain 1 to 100 values'.format(field))
+        return result
+
+    @classmethod
+    def _mode_array(cls, values, field):
+        if not isinstance(values, list) or not values or len(values) > 2:
+            raise ValueError('{0} must contain 1 or 2'.format(field))
+        result = []
+        for value in values:
+            value = cls._enum_integer(value, field, (1, 2))
+            if value not in result:
+                result.append(value)
+        return result
+
+    @staticmethod
+    def _bounded_integer(value, field, maximum):
+        if isinstance(value, bool):
+            raise ValueError('{0} is invalid'.format(field))
+        try:
+            value = int(value)
+        except (TypeError, ValueError):
+            raise ValueError('{0} is invalid'.format(field))
+        if value < 1 or value > maximum:
+            raise ValueError('{0} is invalid'.format(field))
+        return value
+
+    @staticmethod
+    def _enum_integer(value, field, allowed):
+        if isinstance(value, bool):
+            raise ValueError('{0} is invalid'.format(field))
+        try:
+            value = int(value)
+        except (TypeError, ValueError):
+            raise ValueError('{0} is invalid'.format(field))
+        if value not in allowed:
+            raise ValueError('{0} is invalid'.format(field))
+        return value
+
+    @staticmethod
+    def _warehouse_id(value):
+        if isinstance(value, bool):
+            raise ValueError('warehouse_id is invalid')
+        try:
+            value = int(value)
+        except (TypeError, ValueError):
+            raise ValueError('warehouse_id is invalid')
+        if value != -1 and value < 1:
+            raise ValueError('warehouse_id is invalid')
+        return value

+ 19 - 5
tools/query_track.py

@@ -9,17 +9,31 @@ class QueryTrackTool:
         return {
             'name': self.name,
             'description': (
-                'Query tracking information with the current employee permissions. '
-                'Provide order_id, order_number, or tracking_number. '
+                '使用场景:用户明确要查看物流轨迹时使用,只返回轨迹节点、地点、时间、'
+                '内容、跟踪号和Shipment ID。应只选择一种定位方式:用户明确说订单号时'
+                '使用订单号,明确说快递单号或物流跟踪号时使用快递单号;内部订单ID只可'
+                '使用可信系统上下文中已有的值,不得向用户索取或自行推断。号码类型不明确'
+                '时必须先询问用户,确认前不得调用。禁止使用:不得用本工具查询订单资料、'
+                '排舱列表、排舱详情、报关资料文件或导出结果。不得根据号码格式猜测,'
+                '不得跨字段或跨工具试查,查询无结果后不得换号码类型重试。'
                 '参数名仅用于工具调用;向用户回答时只能使用“订单号”、'
                 '“快递单号”等业务名称,不得展示内部参数名。'
             ),
             'input_schema': {
                 'type': 'object',
                 'properties': {
-                    'order_id': {'type': 'integer', 'minimum': 1, 'description': 'Order ID to query tracking for'},
-                    'order_number': {'type': 'string', 'description': 'Order number or track query number'},
-                    'tracking_number': {'type': 'string', 'description': 'Tracking number to query directly'},
+                    'order_id': {
+                        'type': 'integer', 'minimum': 1,
+                        'description': '可信系统上下文中已有的内部订单ID;不得向用户索取或猜测。',
+                    },
+                    'order_number': {
+                        'type': 'string',
+                        'description': '订单号;仅当用户明确说明是订单号时使用。',
+                    },
+                    'tracking_number': {
+                        'type': 'string',
+                        'description': '快递单号或物流跟踪号;仅当用户明确说明该类型时使用。',
+                    },
                     'page': {'type': 'integer', 'minimum': 1, 'default': 1},
                     'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 5},
                 },