22 次代码提交 ddced266c9 ... 3d2a5a6ed0

作者 SHA1 备注 提交日期
  jackson 3d2a5a6ed0 mcp导出接口修改成异步 3 天之前
  jackson 4c175a62b2 mcp导出接口修改成异步 3 天之前
  jackson e9b818b2df 新增mcp排障中心 4 天之前
  jackson b91e33ddd1 mcp接口增加订单详情,优化接口并发数 6 天之前
  jackson a07b0cf765 获取排舱单列表,获取排舱单详情 1 周之前
  jackson 605bb89958 mcp错误码重构 1 周之前
  jackson 062ec99cbe 增加获取报关数据接口 1 周之前
  jackson c4d04d98f8 防止在workbuddy给用户直接暴露字段 1 周之前
  jackson fa1bba85da 导出进港数据 1 周之前
  jackson 264c922cc9 导出排舱单mcp接口 1 周之前
  jackson 76241b056f docs: 制定未排舱订单导出实施计划 1 周之前
  jackson 139a87ddb9 docs: 补充未排舱筛选项权限设计 1 周之前
  jackson ac97bab7f2 docs: 设计 MCP 未排舱订单导出 1 周之前
  jackson 5057203575 优化订单查询提示词规则,提升单测覆盖率100% 1 周之前
  jackson e201c63ceb docs: 设计 Python Gateway 100% 覆盖率方案 1 周之前
  jackson 94e61452d3 docs: 统一精准查询单号开放格式规则 1 周之前
  jackson 2d0d6acd3f docs: 补充排舱单号 PC 前缀 1 周之前
  jackson 210a362f26 docs: 统一排舱单号字段文案设计 1 周之前
  jackson c000f89edb 优化暴露接口逻辑,新增订单接口,订单筛选列 1 周之前
  jackson 846bd51413 mcp 限流优化 2 周之前
  jackson 96f864bcf3 mcp 限流优化 2 周之前
  jackson 9e650fb577 修复日志写入问题 2 周之前
共有 67 个文件被更改,包括 12765 次插入216 次删除
  1. 16 2
      .env.example
  2. 6 1
      .gitignore
  3. 71 0
      AGENTS.md
  4. 0 30
      COVERAGE_GUIDE.md
  5. 149 8
      README.md
  6. 289 9
      app.py
  7. 110 1
      config.py
  8. 209 0
      docs/superpowers/specs/2026-07-14-mcp-export-pending-outbound-orders-design.md
  9. 80 0
      docs/superpowers/specs/2026-07-14-mcp-open-number-format-guidance-design.md
  10. 50 0
      docs/superpowers/specs/2026-07-14-mcp-outbound-number-label-design.md
  11. 111 0
      docs/superpowers/specs/2026-07-14-mcp-python-coverage-100-design.md
  12. 65 0
      docs/superpowers/specs/2026-07-17-order-detail-design.md
  13. 287 0
      docs/superpowers/specs/2026-07-22-mcp-async-export-task-design.md
  14. 307 36
      mcp_protocol.py
  15. 203 11
      public_gateway.py
  16. 516 50
      public_server.py
  17. 9 0
      services/api_client.py
  18. 206 0
      services/diagnostic_event.py
  19. 217 0
      services/diagnostic_reporter.py
  20. 1149 0
      services/output_presenter.py
  21. 16 2
      services/scoped_api_client.py
  22. 3 0
      tests/TEST_README.md
  23. 0 26
      tests/TEST_REPORT.md
  24. 474 0
      tests/test_app_coverage.py
  25. 21 2
      tests/test_bind_auth_code_tool.py
  26. 7 1
      tests/test_cli_and_file_store.py
  27. 160 0
      tests/test_config_compat.py
  28. 509 0
      tests/test_diagnostic_reporter.py
  29. 250 0
      tests/test_export_out_of_province_port_data_tool.py
  30. 153 0
      tests/test_export_pending_outbound_tools.py
  31. 47 2
      tests/test_gateway_query_order.py
  32. 61 1
      tests/test_gateway_runtime.py
  33. 8 0
      tests/test_gateway_session_store_unit.py
  34. 127 0
      tests/test_list_order_filter_options_tool.py
  35. 237 3
      tests/test_mcp_protocol.py
  36. 412 0
      tests/test_mcp_protocol_coverage.py
  37. 481 0
      tests/test_order_detail_tool.py
  38. 274 0
      tests/test_outbound_filter_options.py
  39. 516 0
      tests/test_outbound_query_tools.py
  40. 767 0
      tests/test_output_presenter.py
  41. 212 4
      tests/test_public_gateway.py
  42. 157 4
      tests/test_public_gateway_unit.py
  43. 610 10
      tests/test_public_server.py
  44. 237 0
      tests/test_public_server_coverage.py
  45. 40 5
      tests/test_public_server_integration.py
  46. 314 0
      tests/test_query_customs_declaration_files_tool.py
  47. 100 0
      tests/test_query_export_task_tool.py
  48. 87 0
      tests/test_query_order_coverage.py
  49. 304 0
      tests/test_query_order_exact_tool.py
  50. 39 0
      tests/test_rate_limiter.py
  51. 41 0
      tests/test_scoped_api_client.py
  52. 389 0
      tests/test_token_store_coverage.py
  53. 110 0
      tests/test_tool_description_boundaries.py
  54. 185 0
      tools/export_out_of_province_port_data.py
  55. 90 0
      tools/export_pending_outbound_orders.py
  56. 73 0
      tools/list_order_filter_options.py
  57. 95 0
      tools/list_outbound_filter_options.py
  58. 73 0
      tools/list_pending_outbound_export_filter_options.py
  59. 156 0
      tools/query_customs_declaration_files.py
  60. 45 0
      tools/query_export_task.py
  61. 17 2
      tools/query_order.py
  62. 98 0
      tools/query_order_detail.py
  63. 323 0
      tools/query_order_exact.py
  64. 82 0
      tools/query_outbound_detail.py
  65. 267 0
      tools/query_outbound_list.py
  66. 24 5
      tools/query_track.py
  67. 24 1
      utils/rate_limiter.py

+ 16 - 2
.env.example

@@ -10,6 +10,7 @@ FMS_TOOLS_BASE=http://chenjiacheng.fmsoperate.dahuo.fudingri.com
 
 FMS_CLIENT_TYPE=workbuddy
 FMS_TIMEOUT_SECONDS=10
+FMS_MAX_IN_FLIGHT_PER_TOOL=5
 FMS_REFRESH_SKEW_SECONDS=120
 FMS_LOG_LEVEL=info
 
@@ -50,5 +51,18 @@ 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
+
+# MCP diagnosis events are sent asynchronously to support. Keep disabled until
+# the support collector and matching HMAC key are deployed.
+MCP_DIAGNOSIS_ENABLED=false
+MCP_DIAGNOSIS_URL=https://support.example.com/internal/mcp-diagnostics/events
+MCP_DIAGNOSIS_KEY_ID=gateway-current
+MCP_DIAGNOSIS_SECRET=
+MCP_DIAGNOSIS_ALLOW_INSECURE_HTTP=false
+MCP_DIAGNOSIS_QUEUE_SIZE=1000
+MCP_DIAGNOSIS_BATCH_SIZE=100
+MCP_DIAGNOSIS_TIMEOUT_SECONDS=0.5
+MCP_DIAGNOSIS_INITIAL_BACKOFF_SECONDS=0.25
+MCP_DIAGNOSIS_MAX_BACKOFF_SECONDS=5.0

+ 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.
+- `Y:/all_project_docs/mcp/overview.md`: current system summary and cross-repository ownership map.
+- The same directory's `requirements.md`, `tech-specs.md`, `user-structure.md`, and `timeline.md`: current contracts, architecture, flows, and operational handoff.
+- `Y:/all_project_docs/mcp/guides/mcp-team-sharing.md`: current team onboarding and sharing guide.
+- `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.
+
+If `Y:/all_project_docs` is unavailable, stop changes to contracts, architecture, databases, or deployment behavior and report the missing documentation repository instead of relying on stale information.
+
+## 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 - 30
COVERAGE_GUIDE.md

@@ -1,30 +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
-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.
-
-The retired authorization-code binding files and tools should not appear in the runtime coverage list.

+ 149 - 8
README.md

@@ -1,6 +1,6 @@
 # Python MCP Gateway
 
-这是物流系统给 Workbuddy 使用的轻量 MCP Gateway。它负责接收 MCP 请求、管理员工授权会话,并把工具调用转发到现有 ThinkPHP 项目的 MCP 接口。
+这是物流系统给 Workbuddy 使用的轻量 MCP Gateway。它负责接收 MCP 请求、解析并转发设备会话,并把工具调用转发到现有 ThinkPHP 项目的 MCP 接口;员工认证与设备会话签发仍由 `base` 负责
 
 Gateway 保持“薄网关”边界:
 
@@ -17,11 +17,76 @@ Gateway 保持“薄网关”边界:
 - 支持 Redis token/session 存储。
 - 支持文件 token store 作为开发排障兜底。
 - 不再支持授权码绑定工具;正式接入只使用后台生成的 `GWS_xxx` 设备配置。
-- 支持 `query_order` 订单查询工具。
-- 支持 `query_track` 轨迹查询工具。
+- 本地 stdio 与公网 HTTP 注册同一组 13 个查询、筛选和导出工具。
+- 支持订单、订单详情、轨迹、报关资料、排舱列表与详情查询。
+- 支持订单与排舱筛选项,以及未排舱订单和省外进港资料导出。
 - 支持 MCP `initialize`、`tools/list`、`tools/call`。
 - 公网模式支持 `gateway_session_id` 请求级隔离、Redis Gateway session、审计日志和基础限流。
 
+## 工具目录
+
+`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 13 个候选工具:
+
+| MCP 工具 | 用途 | ThinkPHP 路由 | 最终展示 |
+|---|---|---|---|
+| `query_order` | 普通订单列表查询 | `/mcp/tools/queryOrder` | 旧协议兼容 |
+| `query_track` | 按订单或物流号码查询轨迹 | `/mcp/tools/queryTrack` | 安全表格 |
+| `query_order_exact` | 按明确号码类型精准查询订单 | `/mcp/tools/queryOrderExact` | 安全表格 |
+| `query_order_detail` | 按明确订单号查询订单详情,支持“全部”聚合 | `/mcp/tools/queryOrderDetail` | 安全中文详情 |
+| `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` | 签名任务引用 |
+| `query_export_task` | 查询异步导出状态或文件 | `/mcp/tools/queryExportTask` | 安全任务状态/文件链接 |
+| `list_pending_outbound_export_filter_options` | 查询未排舱导出筛选项 | `/mcp/tools/listPendingOutboundExportFilterOptions` | 安全筛选项 |
+
+**异步导出流程:**
+
+1. 调用 `export_pending_outbound_orders` 或 `export_out_of_province_port_data` 提交任务,返回 `task_ref`
+2. 等待建议时间(`retry_after_seconds`)后,使用 `query_export_task` 和 `task_ref` 查询状态
+3. 任务完成后从 `query_export_task` 响应获取下载链接(`files[].url`)
+
+这 13 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是”Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
+
+MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明或注册集合变化后,必须重启对应 Gateway 进程并让客户端重新连接,客户端才会重新获取工具列表。
+
+## 工具结果展示协议
+
+Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内部接口和工具入参:
+
+- `query_order` 保持原有 `columns + records` 结果和文本展示,不参与本次转换。
+- `services/output_presenter.py` 对其余 12 个安全工具执行显式白名单展示。
+- `query_order_exact`、`query_track`、`query_customs_declaration_files`、`query_outbound_list` 对外使用中文 `headers + rows + pagination`,不返回内部字段键。
+- `query_outbound_detail` 使用中文 `summary + details + pagination` 两层结构。
+- `query_order_detail` 按中文详情模块返回固定分组或明细;传入“全部”时一次返回概览和十个明细模块,各明细模块独立分页。附件保留文件名、预览与下载链接;后端机器字段和内部 ID 不进入最终展示。
+- 三个筛选项工具保留“可传值、显示名称、业务编码”,确保返回值可继续传给查询或导出工具。
+- 两个导出工具只返回 `task_ref + queued + retry_after_seconds`。客户端稍后在新的调用中使用 `query_export_task`;完成后才返回 `files[].label + files[].url`。Gateway 不在一次调用内等待或循环轮询。
+- `request_id` 位于 MCP 结果 `_meta`;参数错误使用业务名称,未知异常不透传后端细节。
+- stdio 与公网 `tools/call` 缺少有效工具名时返回 JSON-RPC `-32602 Invalid params`,不包装为业务 `isError`。
+- stdio 与 public 模式共用 `services/output_presenter.py`;未知工具或畸形响应关闭失败。
+
+详细设计见集中文档仓的 [技术规格 Presenter 分类](../all_project_docs/mcp/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`:当前工具目录、接入配置、运行方式、运维与排障。
+- [`Y:/all_project_docs/mcp/guides/mcp-team-sharing.md`](../all_project_docs/mcp/guides/mcp-team-sharing.md):面向团队的从 0 到 1 架构、实践与问题复盘分享稿。
+- [`Y:/all_project_docs/mcp/overview.md`](../all_project_docs/mcp/overview.md):当前系统概况与跨仓职责。
+- [`requirements.md`](../all_project_docs/mcp/requirements.md):现行协议、安全、工具选择和输出合同。
+- [`tech-specs.md`](../all_project_docs/mcp/tech-specs.md):组件、会话、展示、限流、追踪与日志设计。
+- [`user-structure.md`](../all_project_docs/mcp/user-structure.md):员工、调试、工具选择和跨仓开发流程。
+- [`timeline.md`](../all_project_docs/mcp/timeline.md):短里程碑和当前交接状态。
+
+临时规划与已完成的执行计划不作为现行合同;交付后应把稳定事实并回上述入口,并从活动工作区清理过程文件。
+
 ## 两种运行模式
 
 ### 本地 stdio 模式
@@ -188,7 +253,19 @@ python app.py serve-public --host 0.0.0.0 --port 8765
 Invoke-WebRequest http://127.0.0.1:8765/health -UseBasicParsing
 ```
 
-正常情况下会返回包含 `ok` 的 JSON。## 常用命令
+正常情况下会返回包含 `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` 返回的动态工具集合符合当前员工权限与预期启用状态。
+
+## 常用命令
 
 查看工具列表:
 
@@ -208,12 +285,33 @@ python app.py call --tool query_order --keyword USC26070371955 --page 1 --limit
 python app.py call --tool query_track --order-number USC26070371955
 ```
 
+按订单号批量查询报关资料:
+
+```powershell
+python app.py call --tool query_customs_declaration_files --order-numbers ORD001,ORD002 --page 1 --limit 20
+```
+
+按排舱单号批量查询报关资料:
+
+```powershell
+python app.py call --tool query_customs_declaration_files --outbound-numbers PC001,PC002 --page 1 --limit 20
+```
+
+`order_numbers` 只接收后台订单列表及排舱详情“订单号”列展示的订单号,不接收系统单号、内部 `order_id/id`、客户参考号、快递单号或排舱单号。只有用户明确说明“订单号”或“排舱单号”后才能调用;如果未明确号码类型,AI 必须先提问让用户二选一,确认前不得调用。两种号码数组不能同时传入。
+
 运行全部测试:
 
 ```powershell
 python -m unittest discover -s tests -p "test_*.py"
 ```
 
+严格覆盖率验收:
+
+```powershell
+python -m coverage run -m unittest discover -s tests -p 'test_*.py'
+python -m coverage report -m --fail-under=100
+```
+
 ## 环境变量
 
 基础配置:
@@ -253,6 +351,32 @@ FMS_REDIS_PASSWORD=change-me
 FMS_REDIS_PREFIX=fms:mcp:gateway:
 ```
 
+Gateway 诊断事件上报默认关闭。Support 的 internal collector、Mongo 索引和
+HMAC 密钥部署完成后,在 Gateway `.env` 增加:
+
+四项目统一发布时按集中文档仓的 [`MCP 排障中心全版本部署清单`](../all_project_docs/support/operations/mcp-diagnosis-full-deployment.md) 操作;Gateway 必须放在 Collector、Mongo 索引和两套 PHP Worker 之后灰度启用。
+
+```dotenv
+MCP_DIAGNOSIS_ENABLED=true
+MCP_DIAGNOSIS_URL=https://support.example.com/internal/mcp-diagnostics/events
+MCP_DIAGNOSIS_KEY_ID=gateway-current
+MCP_DIAGNOSIS_SECRET=replace-with-at-least-32-random-characters
+MCP_DIAGNOSIS_ALLOW_INSECURE_HTTP=false
+MCP_DIAGNOSIS_QUEUE_SIZE=1000
+MCP_DIAGNOSIS_BATCH_SIZE=100
+MCP_DIAGNOSIS_TIMEOUT_SECONDS=0.5
+MCP_DIAGNOSIS_INITIAL_BACKOFF_SECONDS=0.25
+MCP_DIAGNOSIS_MAX_BACKOFF_SECONDS=5.0
+```
+
+生产环境 `MCP_DIAGNOSIS_URL` 必须使用 HTTPS。只有隔离测试环境可显式设置 `MCP_DIAGNOSIS_ALLOW_INSECURE_HTTP=true` 使用 HTTP,默认值 `false` 会使 HTTP 配置回退为 `NullDiagnosticReporter`。
+
+`MCP_DIAGNOSIS_KEY_ID` 和 `MCP_DIAGNOSIS_SECRET` 必须与 Support
+`mcp_diagnosis.php` 的 Gateway current/previous key 对应。Reporter 使用内存有界队列、
+批量 HMAC 和短超时异步发送;Support 超时、拒绝、队列满或线程启动失败均不改变 MCP
+响应。进程异常退出时尚未发送的内存事件可能丢失,因此该链路用于排障观测,不作为业务
+审计唯一依据。
+
 兼容旧配置键:
 
 - `MCP_AUTH_BASE_URL`
@@ -309,21 +433,36 @@ FMS_TOKEN_STORE_PATH=C:/fms-mcp/.mcp_token.json
 - 反向代理必须覆盖而不是透传用户伪造的转发头。
 - 当前 Python 内置限流默认使用真实 TCP 连接 IP;公网生产建议同时在 Nginx、负载均衡或 API 网关层配置限流。
 - 审计日志只记录 session hash 的短前缀、员工 ID、公司 ID、工具名和 request_id。
-- 第一版公网只开放查询型工具,不开放审批、费用修改、状态流转、批量写入。
+- 公网只注册经过安全评审的查询和导出工具;实际可见性继续由后端工具注册表动态控制,不开放审批、费用修改、状态流转或批量写入。
 
 ## ThinkPHP 路由口径
 
 ThinkPHP MCP 路由位于各后端项目的 `route/mcp/mcp_route.php`。
 
-Gateway 会追加以下路径:
+Gateway 会调用以下路径:
 
-- Auth:`/mcp/auth/exchange`、`/mcp/auth/refresh`、`/mcp/auth/revoke`
-- Tools:`/mcp/tools/queryOrder`、`/mcp/tools/queryTrack`
+- Auth:本地兼容会话只使用 `/mcp/auth/refresh`、`/mcp/auth/revoke`;正式公网设备配置由 base 的登录态设备接口创建,不调用已退役的 `/mcp/auth/exchange`。
+- 动态工具列表:`/mcp/tools/listEnabledTools`。
+- 查询:`/mcp/tools/queryOrder`、`/mcp/tools/queryTrack`、`/mcp/tools/queryOrderExact`、`/mcp/tools/queryOrderDetail`、`/mcp/tools/queryCustomsDeclarationFiles`、`/mcp/tools/queryOutboundList`、`/mcp/tools/queryOutboundDetail`。
+- 筛选项:`/mcp/tools/listOutboundFilterOptions`、`/mcp/tools/listOrderFilterOptions`、`/mcp/tools/listPendingOutboundExportFilterOptions`。
+- 导出:`/mcp/tools/exportPendingOutboundOrders`、`/mcp/tools/exportOutOfProvincePortData`、`/mcp/tools/queryExportTask`。
 
 `FMS_AUTH_BASE` 和 `FMS_TOOLS_BASE` 只配置域名或基础地址,不要包含 `/admin/mcp`。
 
 ## 排障
 
+跨 Gateway、PHP 和数据库访问日志的排查顺序见集中文档仓的 [`mcp-team-sharing.md`](../all_project_docs/mcp/guides/mcp-team-sharing.md)“排错时怎么看日志”章节。
+
+若 Support 页面只能看到 fmsoperate 事件、看不到 Gateway 前置阶段,依次确认:
+
+1. Gateway `.env` 的 `MCP_DIAGNOSIS_ENABLED=true`,URL 指向已部署的 internal collector。
+2. Gateway 与 Support 使用相同的 key id 和至少 32 字符的 secret,且系统时钟误差在 Support 允许范围内。
+3. Support collector 已开启,Redis nonce 存储可用,Mongo 事件索引已在目标环境验证。
+4. 修改配置后已重启 Gateway;先灰度单个实例,再模拟设备失效或限流并从 Support 查询。
+
+不要在日志、命令历史、工单或群聊中打印 `MCP_DIAGNOSIS_SECRET`、请求体、`GWS_*`、
+`MT_*`、Authorization 或 Cookie。Gateway 事件只保存 `GWS_*` 的 SHA-256 前 12 位。
+
 ### query_order 返回 UTF-8 BOM 错误
 
 如果 Workbuddy 提示:
@@ -349,4 +488,6 @@ Windows 下 Python 标准输出可能使用本机控制台编码。当前 `mcp_p
 - Gateway 不是业务系统,不直接查 MySQL。
 - Gateway 不替代 ThinkPHP 权限体系。
 - 公网 Gateway 是否能生产放量,取决于 Workbuddy 远程 MCP 是否能稳定传递 `gateway_session_id`。
+- fmsoperate 原有 SQL 和 base SQL 已确认执行;订单详情与异步导出任务查询注册 SQL 尚待执行,工具当前动态启用值仍必须从运行环境核验。
+- 仓库无法证明运行中 Gateway 已重启或客户端已重连,发布交接必须显式确认这两项。
 - 写入型 MCP 工具需要单独安全评审后再开放。

+ 289 - 9
app.py

@@ -11,20 +11,83 @@ from public_server import serve_public
 from services.api_client import ApiClient
 from services.auth_client import AuthClient
 from services.gateway_session_store import GatewaySessionStore
+from services.diagnostic_reporter import (
+    NullDiagnosticReporter,
+    diagnostic_reporter_from_config,
+)
 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,
+)
+from tools.list_pending_outbound_export_filter_options import (
+    ListPendingOutboundExportFilterOptionsTool,
+)
 from tools.query_order import QueryOrderTool
+from tools.query_customs_declaration_files import (
+    QueryCustomsDeclarationFilesTool,
+)
+from tools.query_order_exact import QueryOrderExactTool
+from tools.query_order_detail import QueryOrderDetailTool
+from tools.query_export_task import QueryExportTaskTool
+from tools.query_outbound_detail import QueryOutboundDetailTool
+from tools.query_outbound_list import QueryOutboundListTool
 from tools.query_track import QueryTrackTool
 
 
+def parse_int_list(value):
+    if not value:
+        return []
+    return [int(item.strip()) for item in value.split(',') if item.strip()]
+
+
+def parse_string_list(value):
+    result = []
+    for item in (value or '').split(','):
+        item = item.strip()
+        if item and item not in result:
+            result.append(item)
+    return result
+
+
 class GatewayApp:
-    def __init__(self, auth_client=None, api_client=None, token_store=None):
+    def __init__(
+        self,
+        auth_client=None,
+        api_client=None,
+        token_store=None,
+        reporter=None,
+    ):
         self.auth_client = auth_client
         self.api_client = api_client
         self.token_store = token_store
+        self.reporter = reporter or NullDiagnosticReporter()
         self._tools = {
             'query_order': QueryOrderTool(api_client=api_client),
             'query_track': QueryTrackTool(api_client=api_client),
+            'query_order_exact': QueryOrderExactTool(api_client=api_client),
+            'query_order_detail': QueryOrderDetailTool(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
+            ),
+            'export_pending_outbound_orders': ExportPendingOutboundOrdersTool(
+                api_client=api_client
+            ),
+            'export_out_of_province_port_data':
+                ExportOutOfProvincePortDataTool(api_client=api_client),
+            'query_export_task': QueryExportTaskTool(api_client=api_client),
+            'list_pending_outbound_export_filter_options':
+                ListPendingOutboundExportFilterOptionsTool(api_client=api_client),
         }
 
     @classmethod
@@ -63,10 +126,46 @@ class GatewayApp:
             token_store=token_store,
             timeout=config.timeout_seconds,
         )
-        return cls(auth_client=auth_client, api_client=api_client, token_store=token_store)
+        return cls(
+            auth_client=auth_client,
+            api_client=api_client,
+            token_store=token_store,
+            reporter=diagnostic_reporter_from_config(config),
+        )
 
-    def list_tools(self):
-        return [tool.metadata() for tool in self._tools.values()]
+    def registered_tool_names(self):
+        return tuple(self._tools.keys())
+
+    def _enabled_tool_names(self, response):
+        if not isinstance(response, dict):
+            raise RuntimeError('invalid enabled tool response')
+        if response.get('code') != 'MCP_0000':
+            raise RuntimeError(response.get('msg') or 'list enabled tools failed')
+        data = response.get('data')
+        codes = data.get('tool_codes') if isinstance(data, dict) else None
+        if not isinstance(codes, list):
+            raise RuntimeError('invalid enabled tool response')
+        return {
+            code.strip().lower()
+            for code in codes
+            if isinstance(code, str) and code.strip()
+        }
+
+    def _load_enabled_tool_names(self, request_id=''):
+        if self.api_client is None or not hasattr(self.api_client, 'list_enabled_tools'):
+            raise RuntimeError('enabled tool client unavailable')
+        return self._enabled_tool_names(
+            self.api_client.list_enabled_tools(request_id=request_id)
+        )
+
+    def list_tools(self, request_id=''):
+        request_id = self.build_request_id(request_id)
+        enabled = self._load_enabled_tool_names(request_id)
+        return [
+            tool.metadata()
+            for name, tool in self._tools.items()
+            if name in enabled
+        ]
 
     def build_request_id(self, request_id=''):
         request_id = str(request_id or '').strip()
@@ -91,12 +190,14 @@ class GatewayApp:
         tool = self._tools[name]
         if getattr(tool, 'requires_session', True):
             self.ensure_session()
-        arguments = arguments or {}
         request_id = self.build_request_id(request_id)
+        if name not in self._load_enabled_tool_names(request_id):
+            raise RuntimeError('tool disabled: {0}'.format(name))
+        arguments = arguments or {}
         return tool.call(request_id=request_id, **arguments)
 
     def create_protocol_handler(self):
-        return McpProtocolHandler(self)
+        return McpProtocolHandler(self, reporter=self.reporter)
 
     def run_cli(self, argv=None, stdin=None, stdout=None):
         stdin = stdin or sys.stdin
@@ -116,7 +217,47 @@ class GatewayApp:
         call_parser.add_argument('--keyword', default='')
         call_parser.add_argument('--order-id', type=int, default=0)
         call_parser.add_argument('--order-number', default='')
+        call_parser.add_argument('--section', default='全部')
+        call_parser.add_argument('--order-numbers', default='')
         call_parser.add_argument('--tracking-number', default='')
+        call_parser.add_argument('--tracking-numbers', default='')
+        call_parser.add_argument('--reference-number', default='')
+        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='')
+        call_parser.add_argument('--so-numbers', default='')
+        call_parser.add_argument('--shipment-id', default='')
+        call_parser.add_argument('--receiver-country', default='')
+        call_parser.add_argument('--product-ids', default='')
+        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='')
+        call_parser.add_argument('--outbound-date-end', default='')
+        call_parser.add_argument('--filter-type', default='')
+        call_parser.add_argument('--task-ref', default='')
         call_parser.add_argument('--page', type=int, default=1)
         call_parser.add_argument('--limit', type=int, default=20)
         call_parser.add_argument('--request-id', default='')
@@ -126,9 +267,18 @@ class GatewayApp:
         if args.command == 'list-tools':
             payload = self.list_tools()
         elif args.command == 'serve-stdio':
-            return self.create_protocol_handler().run_stdio(stdin=stdin, stdout=stdout)
+            try:
+                return self.create_protocol_handler().run_stdio(
+                    stdin=stdin,
+                    stdout=stdout,
+                )
+            finally:
+                self.reporter.close()
         elif args.command == 'serve-public':
             config = GatewayConfig.from_env()
+            reporter = self.reporter
+            if isinstance(reporter, NullDiagnosticReporter):
+                reporter = diagnostic_reporter_from_config(config)
             redis = RedisSocketClient(
                 host=config.redis_host,
                 port=config.redis_port,
@@ -145,7 +295,19 @@ class GatewayApp:
                 session_store=session_store,
                 api_client=ScopedApiClient(config.tools_base_url, timeout=config.timeout_seconds),
             )
-            return serve_public(public_app, host=args.host, port=args.port)
+            try:
+                return serve_public(
+                    public_app,
+                    host=args.host,
+                    port=args.port,
+                    enable_rate_limit=config.rate_limit_enabled,
+                    rate_limit_max_requests=config.rate_limit_max_requests,
+                    rate_limit_window_seconds=config.rate_limit_window_seconds,
+                    max_in_flight_per_tool=config.max_in_flight_per_tool,
+                    reporter=reporter,
+                )
+            finally:
+                reporter.close()
         elif args.command == 'call':
             tool_args = {
                 'page': args.page,
@@ -165,6 +327,124 @@ class GatewayApp:
                     tool_args['tracking_number'] = args.tracking_number
                 if args.order_id <= 0 and not args.order_number and not args.tracking_number:
                     raise ValueError('--order-id, --order-number or --tracking-number is required for query_track')
+            elif args.tool == 'query_order_exact':
+                exact_strings = {
+                    'order_number': args.order_number,
+                    'reference_number': args.reference_number,
+                    'tracking_number': args.tracking_number,
+                    'outbound_number': args.outbound_number,
+                    'container_code': args.container_code,
+                    'so_number': args.so_number,
+                    'shipment_id': args.shipment_id,
+                    'receiver_country': args.receiver_country,
+                    'inbound_date_start': args.inbound_date_start,
+                    'inbound_date_end': args.inbound_date_end,
+                    'outbound_date_start': args.outbound_date_start,
+                    'outbound_date_end': args.outbound_date_end,
+                }
+                for field, value in exact_strings.items():
+                    if value:
+                        tool_args[field] = value
+                exact_number_lists = {
+                    'order_numbers': args.order_numbers,
+                    'reference_numbers': args.reference_numbers,
+                    'tracking_numbers': args.tracking_numbers,
+                    'outbound_numbers': args.outbound_numbers,
+                    'container_codes': args.container_codes,
+                    'so_numbers': args.so_numbers,
+                }
+                for field, value in exact_number_lists.items():
+                    if value:
+                        tool_args[field] = parse_string_list(value)
+                exact_lists = {
+                    'product_ids': args.product_ids,
+                    'customer_ids': args.customer_ids,
+                    'warehouse_ids': args.warehouse_ids,
+                }
+                for field, value in exact_lists.items():
+                    if value:
+                        tool_args[field] = parse_int_list(value)
+                if args.sales_id > 0:
+                    tool_args['sales_id'] = args.sales_id
+                if args.department_id > 0:
+                    tool_args['department_id'] = args.department_id
+            elif args.tool == 'query_order_detail':
+                if not args.order_number:
+                    raise ValueError('--order-number is required for query_order_detail')
+                tool_args['order_number'] = args.order_number
+                tool_args['section'] = args.section
+            elif args.tool == 'query_customs_declaration_files':
+                if args.outbound_numbers:
+                    tool_args['outbound_numbers'] = parse_string_list(
+                        args.outbound_numbers
+                    )
+                if args.order_numbers:
+                    tool_args['order_numbers'] = parse_string_list(
+                        args.order_numbers
+                    )
+            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 == 'query_export_task':
+                if not args.task_ref:
+                    raise ValueError(
+                        '--task-ref is required for query_export_task'
+                    )
+                tool_args = {'task_ref': args.task_ref}
+            elif args.tool in (
+                'list_order_filter_options', 'list_outbound_filter_options'
+            ):
+                if not args.filter_type:
+                    raise ValueError(
+                        '--filter-type is required for '
+                        + args.tool
+                    )
+                tool_args['filter_type'] = args.filter_type
+                tool_args['keyword'] = args.keyword
             else:
                 if args.keyword:
                     tool_args['keyword'] = args.keyword
@@ -188,4 +468,4 @@ def main(argv=None):
 
 
 if __name__ == '__main__':
-    raise SystemExit(main(sys.argv[1:]))
+    raise SystemExit(main(sys.argv[1:]))

+ 110 - 1
config.py

@@ -20,6 +20,20 @@ class GatewayConfig:
     session_key: str = ''
     gateway_mode: str = 'local'
     gateway_session_ttl_seconds: int = 2592000
+    rate_limit_enabled: bool = True
+    rate_limit_max_requests: int = 60
+    rate_limit_window_seconds: int = 60
+    max_in_flight_per_tool: int = 2
+    diagnosis_enabled: bool = False
+    diagnosis_url: str = ''
+    diagnosis_key_id: str = ''
+    diagnosis_secret: str = ''
+    diagnosis_allow_insecure_http: bool = False
+    diagnosis_queue_size: int = 1000
+    diagnosis_batch_size: int = 100
+    diagnosis_timeout_seconds: float = 0.5
+    diagnosis_initial_backoff_seconds: float = 0.25
+    diagnosis_max_backoff_seconds: float = 5.0
 
     @classmethod
     def from_env(cls, env=None, dotenv_path=''):
@@ -67,6 +81,59 @@ class GatewayConfig:
             session_key=session_key or cls._build_default_session_key(primary_env),
             gateway_mode=gateway_mode,
             gateway_session_ttl_seconds=int(cls._pick(dotenv_env, primary_env, 'FMS_GATEWAY_SESSION_TTL_SECONDS', 'MCP_GATEWAY_SESSION_TTL_SECONDS') or '2592000'),
+            rate_limit_enabled=cls._parse_bool(cls._pick(dotenv_env, primary_env, 'FMS_RATE_LIMIT_ENABLED', 'MCP_RATE_LIMIT_ENABLED'), default=True),
+            rate_limit_max_requests=cls._parse_int(cls._pick(dotenv_env, primary_env, 'FMS_RATE_LIMIT_MAX_REQUESTS', 'MCP_RATE_LIMIT_MAX_REQUESTS'), default=60),
+            rate_limit_window_seconds=cls._parse_int(cls._pick(dotenv_env, primary_env, 'FMS_RATE_LIMIT_WINDOW_SECONDS', 'MCP_RATE_LIMIT_WINDOW_SECONDS'), default=60),
+            max_in_flight_per_tool=cls._parse_int(cls._pick(dotenv_env, primary_env, 'FMS_MAX_IN_FLIGHT_PER_TOOL', 'MCP_MAX_IN_FLIGHT_PER_TOOL'), default=2),
+            diagnosis_enabled=cls._parse_bool(
+                cls._pick(dotenv_env, primary_env, 'MCP_DIAGNOSIS_ENABLED'),
+                default=False,
+            ),
+            diagnosis_url=cls._pick(
+                dotenv_env, primary_env, 'MCP_DIAGNOSIS_URL'
+            ).rstrip('/'),
+            diagnosis_key_id=cls._pick(
+                dotenv_env, primary_env, 'MCP_DIAGNOSIS_KEY_ID'
+            ),
+            diagnosis_secret=cls._pick(
+                dotenv_env, primary_env, 'MCP_DIAGNOSIS_SECRET'
+            ),
+            diagnosis_allow_insecure_http=cls._parse_bool(
+                cls._pick(
+                    dotenv_env,
+                    primary_env,
+                    'MCP_DIAGNOSIS_ALLOW_INSECURE_HTTP',
+                ),
+                default=False,
+            ),
+            diagnosis_queue_size=cls._parse_int(
+                cls._pick(dotenv_env, primary_env, 'MCP_DIAGNOSIS_QUEUE_SIZE'),
+                default=1000,
+            ),
+            diagnosis_batch_size=cls._parse_int(
+                cls._pick(dotenv_env, primary_env, 'MCP_DIAGNOSIS_BATCH_SIZE'),
+                default=100,
+            ),
+            diagnosis_timeout_seconds=cls._parse_float(
+                cls._pick(dotenv_env, primary_env, 'MCP_DIAGNOSIS_TIMEOUT_SECONDS'),
+                default=0.5,
+            ),
+            diagnosis_initial_backoff_seconds=cls._parse_float(
+                cls._pick(
+                    dotenv_env,
+                    primary_env,
+                    'MCP_DIAGNOSIS_INITIAL_BACKOFF_SECONDS',
+                ),
+                default=0.25,
+            ),
+            diagnosis_max_backoff_seconds=cls._parse_float(
+                cls._pick(
+                    dotenv_env,
+                    primary_env,
+                    'MCP_DIAGNOSIS_MAX_BACKOFF_SECONDS',
+                ),
+                default=5.0,
+            ),
         )
 
     @staticmethod
@@ -111,6 +178,41 @@ class GatewayConfig:
                 return value
         return ''
 
+    @staticmethod
+    def _strip_comment(value):
+        """Strip inline comment from a raw env value (space+# pattern)."""
+        pos = str(value or '').find(' #')
+        return value[:pos].strip() if pos >= 0 else value
+
+    @staticmethod
+    def _parse_int(raw, default):
+        """Parse int from env value, tolerating inline comments from OS env vars."""
+        value = GatewayConfig._strip_comment(str(raw or '').strip())
+        if not value:
+            return default
+        try:
+            return int(value)
+        except ValueError:
+            return default
+
+    @staticmethod
+    def _parse_bool(raw, default=True):
+        """Parse bool from env value; empty / unset → default."""
+        value = GatewayConfig._strip_comment(str(raw or '').strip()).lower()
+        if not value:
+            return default
+        return value not in ('0', 'false', 'no', 'off')
+
+    @staticmethod
+    def _parse_float(raw, default):
+        value = GatewayConfig._strip_comment(str(raw or '').strip())
+        if not value:
+            return default
+        try:
+            return float(value)
+        except ValueError:
+            return default
+
     @staticmethod
     def _build_default_session_key(env):
         computer = env.get('COMPUTERNAME') or env.get('HOSTNAME') or os.environ.get('COMPUTERNAME') or os.environ.get('HOSTNAME') or 'unknown-computer'
@@ -131,7 +233,14 @@ class GatewayConfig:
                     continue
                 key, value = line.split('=', 1)
                 key = key.strip()
-                value = value.strip().strip('"').strip("'")
+                value = value.strip()
+                # Strip inline comments for unquoted values (e.g. KEY=123  # comment)
+                if value and value[0] not in ('"', "'"):
+                    comment_pos = value.find(' #')
+                    if comment_pos >= 0:
+                        value = value[:comment_pos].strip()
+                else:
+                    value = value.strip('"').strip("'")
                 if key:
                     data[key] = value
         return data

+ 209 - 0
docs/superpowers/specs/2026-07-14-mcp-export-pending-outbound-orders-design.md

@@ -0,0 +1,209 @@
+# MCP 导出未排舱订单设计
+
+> 本设计中的同步等待和直接返回文件 URL 合同已由
+> [`2026-07-22-mcp-async-export-task-design.md`](2026-07-22-mcp-async-export-task-design.md)
+> 取代;本文只保留为历史设计背景。
+
+## 背景
+
+`fmsoperate/app/admin/view/outbound/add.html` 已提供“导出未排舱订单”功能。页面把当前筛选条件提交到
+`export/asyncExportData`,固定使用 `EXPORT_PEND_OUTBOUND_ORDER`,并通过 `is_sync=1` 同步等待导出
+任务完成后取得 OSS 下载链接。
+
+本次在 MCP 中开放相同能力。MCP 不重新实现订单筛选或 Excel 生成逻辑,只复用 fmsoperate 已有的
+员工会话、数据权限、未排舱订单查询和导出任务,最终向调用方返回下载链接。
+
+## 目标
+
+- 新增 MCP 工具 `export_pending_outbound_orders`。
+- 新增独立工具 `list_pending_outbound_export_filter_options`,让 AI 先把用户提供的筛选名称解析成当前员工有权使用的值。
+- 筛选参数名称、业务含义、类型和多选传参方式与 `outbound/add.html` 完全一致。
+- 复用 `ExportPendOutboundOrderLogic` 和 `OutboundModel::getPendOutboundOrder()`,确保导出内容与页面一致。
+- 使用当前 MCP 员工身份、公司和数据权限,不接受调用方传入身份或权限字段。
+- 筛选项列表沿用 add 页面当前员工权限,禁止返回其他公司、其他客户或当前员工无权使用的筛选值。
+- 成功时返回可下载的文件 URL;失败时返回明确业务错误且不终止 MCP 会话。
+
+## 参数契约
+
+所有筛选参数均为可选。不传筛选条件时,行为与页面清空筛选后点击“导出未排舱订单”一致。
+
+| 页面中文名称 | MCP 参数名 | 类型与传参方式 | 业务说明 |
+|---|---|---|---|
+| 单号 | `number` | 字符串 | 订单号、参考号。可录入多个单号,以空格、英文逗号或回车分割。保持页面原始字符串格式,由现有导出逻辑调用 `format_numbers()`。 |
+| 产品分类 | `product_type_id` | 整数 | 单选产品分类 ID。 |
+| 物流产品 | `product_id` | 整数数组 | 多选物流产品 ID。与页面 `product_id` 多选提交的数组一致。 |
+| 集货仓库 | `order_warehouse_id` | 整数 | 单选集货仓库 ID。 |
+| 目的国 | `receiver_country` | 字符串 | 单选目的国国家代码,如 `US`。 |
+| 是否装柜剔除 | `is_remove` | 整数 | `1` 表示是,`0` 表示否;不传表示全部。必须保留 `0` 的有效含义。 |
+| 派送地址 | `address` | 字符串 | 按页面现有规则模糊匹配派送地址。 |
+| 入库时间 | `inbound_date` | 字符串 | 日期范围,格式为 `YYYY-MM-DD - YYYY-MM-DD`。由现有导出逻辑按员工时区转换开始和结束时间。 |
+| 订单状态 | `status` | 整数数组 | 多选订单状态。页面可选值为 `0`、`10`、`20`、`30`、`40`,默认选中 `30`、`40`;MCP 不传时不擅自补默认值,只有用户明确要求按页面默认筛选时才传 `[30, 40]`。 |
+| 商品属性 | `is_battery` | 字符串枚举 | 页面选择“带电”后提交 `is_battery="Y"`。 |
+| 商品属性 | `is_magnetic` | 字符串枚举 | 页面选择“带磁”后提交 `is_magnetic="Y"`。 |
+| 商品属性 | `is_wood` | 字符串枚举 | 页面选择“带木”后提交 `is_wood="Y"`。 |
+| 商品属性 | `is_other` | 字符串枚举 | 页面选择“其它”后提交 `is_other="Y"`。 |
+| 商品属性 | `is_fda` | 字符串枚举 | 页面选择“FDA产品”后提交 `is_fda="Y"`。 |
+| 商品属性 | `is_toy` | 字符串枚举 | 页面选择“玩具”后提交 `is_toy="Y"`。 |
+| 商品属性 | `is_ultra_limit` | 字符串枚举 | 页面选择“单箱尺寸超长超重”后提交 `is_ultra_limit="Y"`。 |
+| 商品属性 | `is_sensitive` | 字符串枚举 | 页面选择“敏感货”后提交 `is_sensitive="Y"`。 |
+| 商品属性 | `is_food` | 字符串枚举 | 页面选择“食品”后提交 `is_food="Y"`。 |
+| 商品属性 | `no_property` | 字符串枚举 | 页面选择“无属性”后提交 `no_property="Y"`。 |
+| 合并报关单号 | `merge_declare_number` | 字符串 | 精确匹配合并报关单号。 |
+| 进口商 | `importer_id` | 整数 | 单选进口商 ID。 |
+| 货物类型 | `packing_type` | 字符串 | 页面多选后以英文逗号拼接,例如 `散货,整柜`;不能改为数组。 |
+
+### 多选规则
+
+多选参数必须严格保持页面的三种不同传法:
+
+1. `product_id`、`status` 使用数组。
+2. 商品属性不传 `property_2`;每个选中属性转换为对应字段并传字符串 `Y`。
+3. `packing_type` 使用英文逗号拼接的字符串。
+
+Gateway 只接受白名单中的参数。`property_2`、`export_type`、`is_sync`、`company_id`、`operator_id`、
+`is_super`、`timezone` 等页面内部字段或身份字段不放入 MCP Schema。
+
+### 名称解析规则
+
+用户提供产品、仓库、国家、进口商等业务名称时,AI 必须先调用
+`list_pending_outbound_export_filter_options` 查询当前员工有权使用的选项,再把返回的 `value` 原样传给
+导出工具。不得根据名称、历史调用或示例猜测 ID。
+
+- `product_id`、`product_type_id`、`order_warehouse_id`、`importer_id` 使用筛选项返回的 ID。
+- `receiver_country` 使用国家选项返回的国家代码。
+- `packing_type` 使用货物类型选项返回的名称值;多选时以英文逗号拼接。
+- 商品属性使用选项返回的字段名,并把对应字段设置为 `Y`。
+- `status` 使用订单状态选项返回的整数值。
+
+## 架构与数据流
+
+### Python Gateway
+
+新增 `ExportPendingOutboundOrdersTool`:
+
+- 工具名为 `export_pending_outbound_orders`。
+- 路由为 `/mcp/tools/exportPendingOutboundOrders`。
+- Schema 使用上表原始参数名和中文业务备注。
+- 调用前仅做类型、枚举、长度、数组数量及日期范围格式校验。
+- 保留页面要求的原始传参结构,不在 Gateway 改写多选格式。
+- 通过现有 `ApiClient` / `ScopedApiClient` 转发,并携带工具代码和 request ID。
+
+本地 stdio 与公网 Gateway 都注册该工具;是否展示和允许调用继续由 fmsoperate 的动态工具注册表控制。
+
+### 筛选项工具
+
+新增 `ListPendingOutboundExportFilterOptionsTool`:
+
+- 工具名为 `list_pending_outbound_export_filter_options`。
+- 路由为 `/mcp/tools/listPendingOutboundExportFilterOptions`。
+- 必填 `filter_type`,可选 `keyword`、`page`、`limit`;查询物流产品时可选传
+  `product_type_id`,实现与 add 页面相同的产品分类联动。
+- `filter_type` 支持 `product_type`、`product`、`warehouse`、`country`、`importer`、
+  `packing_type`、`order_status`、`goods_attribute`、`is_remove`。
+- 返回统一的 `records`,每项包含可直接用于导出参数的 `value`、显示名称 `label` 和辅助识别字段
+  `code`;分页信息放在 `meta`。
+- 工具说明明确要求返回值只能用于对应导出字段,不得跨字段使用或猜测未返回的 ID。
+
+### fmsoperate
+
+新增 MCP Controller 路由、Validate 规则和专用 Logic。Controller 仅接收请求、调用 Logic 并返回统一响应。
+
+Logic 执行以下步骤:
+
+1. 从当前 MCP token 恢复的员工会话读取身份、公司、时区和权限上下文。
+2. 只提取参数白名单,拒绝未知参数和调用方提供的身份字段。
+3. 服务端固定补充 `export_type=EXPORT_PEND_OUTBOUND_ORDER`、`is_sync=1`。
+4. 复用现有导出任务创建及 `ExportPendOutboundOrderLogic`,不复制 SQL、Excel 表头或 OSS 上传逻辑。
+5. 将现有导出结果中的 `url` 标准化为 MCP 返回字段 `file_url`。
+
+为了保证与后台页面权限一致,MCP 工具必须复用后台“新增排舱/导出未排舱订单”对应菜单权限,并继续经过
+动态工具白名单校验。不得仅依赖工具注册表绕过后台菜单权限。
+
+### 筛选项权限边界
+
+筛选项工具必须先通过动态工具白名单和后台“新增排舱/导出未排舱订单”菜单权限,再查询选项。所有查询只
+使用 MCP token 恢复出的服务端会话,禁止接收 `company_id`、`admin_id`、`is_super`、`account_type`、
+`country_auth`、`platform_company_id`、`platform_customer_id` 等外部身份或权限参数。
+
+各类筛选项必须复用 add 页面的数据来源和权限口径:
+
+| `filter_type` | add 页面数据来源与权限要求 | 返回 `value` |
+|---|---|---|
+| `product_type` | `OutboundTypeModel::getAllOutboundType()`,按当前公司和模型现有条件返回可用排舱分类 | 分类 ID |
+| `product` | `ProductModel::getProductList()`,保留当前公司或平台客户产品配置限制,以及页面对产品状态的展示条件;传 `product_type_id` 时只能在已授权产品内继续过滤 | 产品 ID |
+| `warehouse` | `OrderModel::getSortWarehouse()`,保留当前公司、账号和仓库权限排序/过滤规则 | 仓库 ID |
+| `country` | `getTcAddCountryCode()` 可用国家范围;非超级管理员且账号类型包含 `3` 时,继续按当前员工 `country_auth` 取交集;无授权返回空数组 | 国家代码 |
+| `importer` | `fms_importer.company_id=session('company_id')` 且 `status=1`,并保留页面的“由EAC提供”选项 | 进口商 ID,或 `-1` |
+| `packing_type` | `dictionary('fms_packing_type')` 当前有效字典值 | 页面提交的货物类型名称 |
+| `order_status` | `OrderModel::getOperateOrderStatus()` 中 add 页面实际展示的 `0/10/20/30/40` | 整数状态值 |
+| `goods_attribute` | `GoodsModel::getGoodsAttr()` 及页面附加的“无属性” | 对应属性字段名,如 `is_battery` 或 `no_property` |
+| `is_remove` | 页面固定选项“是/否” | 整数 `1/0` |
+
+不得为了分页或关键字搜索改成绕过上述业务方法的无权限全表查询。关键字过滤必须在授权结果集内执行;先按
+关键字查全表再补权限过滤也不允许。对于静态字典项,仍需先通过工具和菜单权限后才返回。
+
+筛选项查询只负责减少错误输入,不授予数据权限。导出接口收到筛选值后,仍由
+`OutboundModel::getPendOutboundOrder()` 和现有导出链路再次按当前员工上下文限制实际订单数据。
+
+## 返回与错误处理
+
+成功响应示例:
+
+```json
+{
+  "code": "MCP_0000",
+  "msg": "导出成功",
+  "data": {
+    "file_url": "https://example.com/fms_export/order-20260714.xlsx"
+  },
+  "meta": {
+    "request_id": "rq_xxx"
+  }
+}
+```
+
+以下情况返回业务错误:
+
+- 参数类型、枚举、数量或日期格式不合法;
+- 当前员工没有对应后台菜单权限或工具已停用;
+- 没有可导出的未排舱订单;
+- 导出任务、Excel 生成或 OSS 上传失败;
+- fmsoperate 返回成功但缺少有效下载链接。
+
+业务错误继续由现有 MCP 协议适配器转换为 `isError=true`,保留 `code`、`msg` 和 `request_id`,不产生
+JSON-RPC 协议错误,不中断会话。
+
+## 测试
+
+### Python
+
+- 工具元数据逐项校验参数名、中文备注、类型和枚举。
+- 验证 `product_id`、`status` 保持数组。
+- 验证商品属性转换后的独立 `Y` 字段,不暴露 `property_2`。
+- 验证 `packing_type` 保持英文逗号字符串。
+- 验证 `is_remove=0` 不会被当作空值删除。
+- 验证 stdio、公网工具列表、动态启用和调用转发。
+- 验证成功 URL、业务错误及缺失 URL 的处理。
+- 验证筛选项工具的全部 `filter_type`、产品分类联动、分页结构和名称解析引导。
+- 验证筛选项工具不接受任何身份或权限覆盖参数。
+
+### PHP
+
+- Validate 覆盖全部白名单参数及边界。
+- Controller/Logic 测试断言传给导出链路的参数与 `add.html` 实际提交结构一致。
+- 断言服务端固定导出类型、同步标记和会话身份字段,外部无法覆盖。
+- 断言员工菜单权限、动态工具状态和公司数据权限继续生效。
+- 断言成功响应返回 `file_url`,无数据和导出失败返回明确错误。
+- 使用 A/B 两个公司或客户上下文验证选项隔离,A 员工不得看到 B 公司的产品、仓库、进口商和排舱分类。
+- 验证平台客户只能看到其产品配置范围内的物流产品。
+- 验证受限头程账号只看到 `country_auth` 允许的目的国,无国家授权时返回空列表。
+- 验证 `product_type_id` 只能缩小已授权产品范围,不能通过分类联动扩大权限。
+- 验证无菜单权限、工具被停用和伪造身份字段均被拒绝并记录访问日志。
+
+## 非目标
+
+- 不修改 `outbound/add.html` 或其现有导出行为。
+- 不在 Python 中生成、保存或代理传输 Excel 文件。
+- 不新增导出模板、Excel 字段或筛选项。
+- 不改变 `OutboundModel::getPendOutboundOrder()` 的查询口径。
+- 不把同步导出改造成新的任务轮询协议。

+ 80 - 0
docs/superpowers/specs/2026-07-14-mcp-open-number-format-guidance-design.md

@@ -0,0 +1,80 @@
+# MCP 精准订单开放单号格式引导设计
+
+## 背景
+
+`query_order_exact` 通过字段说明和示例引导 AI 选择精准查询参数。现有说明虽然要求
+单号类型不明确时先追问,但部分字段示例或说明仍可能让 AI 把 `USC`、`PC`、`SO`
+等前缀误当成固定格式,并据此猜测号码类型。
+
+实际业务中,订单号、客户参考号、快递单号、排舱单号、柜号、SO 号和
+Shipment ID 都应视为开放格式。用户明确指定业务类型后,工具按对应字段原值精准
+查询;格式、前缀、长度和字符组合不参与字段选择。
+
+## 目标
+
+- 所有单号只按用户明确指定的业务类型选择查询字段。
+- 明确所有单号格式开放,禁止根据前缀、长度、字符组合或示例猜测类型。
+- 保留含糊“单号”先追问、无结果不跨字段重试的现有规则。
+- 保持 MCP 参数、PHP 查询、数据库字段和权限逻辑不变。
+
+## 适用字段
+
+统一规则覆盖以下单值字段及其现有批量字段:
+
+- `order_number` / `order_numbers`
+- `reference_number` / `reference_numbers`
+- `tracking_number` / `tracking_numbers`
+- `outbound_number` / `outbound_numbers`
+- `container_code` / `container_codes`
+- `so_number` / `so_numbers`
+- `shipment_id`
+
+`shipment_id` 当前没有批量字段,本次不新增。
+
+## 方案
+
+### 工具级规则
+
+`query_order_exact` 总说明新增统一规则:所有单号格式均为开放格式,只能根据用户
+明确说出的业务类型选择字段,不得根据号码的前缀、长度、字符组合或示例猜测。
+
+### 字段说明
+
+- 每个单值字段说明其业务含义,并要求用户明确指定该类型时使用。
+- 批量字段继续表达“多个同类号码使用数组”,不声明固定格式。
+- `outbound_number` 保留“排舱单号、不是海外仓出库单号”的业务区分,删除
+  “以 `PC` 开头”的规则。
+- `so_number` 说明格式不固定,只有用户明确说“SO号”时使用。
+
+### 示例
+
+- 示例只展示参数类型和调用形式,不代表格式规则。
+- 排舱单号可继续使用 `PC...` 作为普通示例,但说明和测试不得把 `PC` 设为必要
+  条件。
+- SO 号单值示例使用纯数字 `97964454`;批量示例同时包含 `97964454` 和
+  `OOLU12345678`,体现格式多样性。
+- 其他现有示例可以保留,但不得用于推断或校验格式。
+
+## 查询与错误处理
+
+- 用户明确类型:直接使用对应字段精准查询。
+- 用户只说“单号”或类型含糊:先追问,不调用工具猜测。
+- 查询无结果:原样返回,不改用其他单号字段重试。
+- Gateway 不新增正则、前缀、长度或字符集校验;现有非空、字符串长度和批量数量
+  边界保持不变。
+
+## 测试
+
+元数据回归测试需要验证:
+
+1. 工具总说明包含“所有单号格式开放”和禁止按前缀、长度、字符组合、示例猜测。
+2. 排舱单号说明不再声明 `PC` 前缀规则,但仍使用正确业务名称并区分海外仓出库
+   单号。
+3. SO 号说明强调格式不固定和用户明确指定,单值/批量示例覆盖不同格式。
+4. 所有单号字段名、Schema 类型和调用转发行为保持不变。
+
+## 文档覆盖关系
+
+本文覆盖 `2026-07-14-mcp-outbound-number-label-design.md` 中关于排舱单号必须以
+`PC` 开头、示例必须全部使用 `PC` 前缀的要求。旧设计中“业务名称统一为排舱单号、
+不是海外仓出库单号、字段名和查询链不变”的结论继续有效。

+ 50 - 0
docs/superpowers/specs/2026-07-14-mcp-outbound-number-label-design.md

@@ -0,0 +1,50 @@
+# MCP 排舱单号字段文案统一设计
+
+> **部分废止:** 本文关于排舱单号“以 `PC` 开头”以及示例必须使用 `PC`
+> 前缀的要求,已被
+> `2026-07-14-mcp-open-number-format-guidance-design.md` 覆盖。保留“业务名称统一为
+> 排舱单号、不是海外仓出库单号、字段名和查询链不变”的结论。
+
+## 背景
+
+`query_order_exact` 使用 `outbound_number` 和 `outbound_numbers` 查询
+`fms_outbound.outbound_number`。当前工具说明将该字段称为“出库单号”,但业务页面
+使用的准确名称是“排舱单号”,容易使 AI 和员工误解字段含义。
+
+## 目标
+
+- 对外说明统一使用“排舱单号”。
+- 说明排舱单号以 `PC` 开头,并使用真实格式的示例。
+- 明确该字段不是海外仓出库单号,减少号码类型误判。
+- 保持现有接口和查询行为不变。
+
+## 方案
+
+保留 `outbound_number` 和 `outbound_numbers` 字段名,因为它们已经与数据库字段、
+Python Gateway、PHP 校验及查询逻辑对齐。仅修改以下文案和回归断言:
+
+1. `outbound_number` 说明改为单个排舱单号精准查询,注明号码以 `PC` 开头,
+   示例使用 `PC20210331070002001`,并提示不是海外仓出库单号。
+2. `outbound_numbers` 说明改为多个排舱单号批量精准查询,示例中的每个号码均以
+   `PC` 开头。
+3. 工具总说明中的“出库单号”改为“排舱单号”。
+4. 工具元数据测试断言同步改为“排舱单号”,并验证区分海外仓出库单号的提示。
+5. 项目文档记录本次业务术语修正。
+
+## 不在范围内
+
+- 不根据 `PC` 前缀在 Gateway 增加格式校验;号码是否合法仍由精准查询结果判断,
+  避免阻断历史数据或特殊业务数据。
+- 不修改数据库字段名。
+- 不修改 MCP 入参或返回字段名。
+- 不修改 PHP 查询、校验和权限逻辑。
+- 不把 `outbound_date_start`、`outbound_date_end` 的“出库日期”改为“排舱日期”;
+  日期字段与本次单号术语修正不是同一业务概念。
+
+## 验证
+
+- 运行 `tests.test_query_order_exact_tool`,确认工具元数据文案正确,且单值、批量
+  示例都使用 `PC` 前缀。
+- 运行 Python Gateway 全量单元测试,确认字段结构和调用行为无回归。
+- 搜索 `query_order_exact` 相关说明,确认不再将 `outbound_number(s)` 标注为
+  “出库单号”。

+ 111 - 0
docs/superpowers/specs/2026-07-14-mcp-python-coverage-100-design.md

@@ -0,0 +1,111 @@
+# MCP Python Gateway 100% 覆盖率设计
+
+## 背景
+
+Python Gateway 当前执行:
+
+```powershell
+python -m coverage run -m unittest discover -s tests -p "test_*.py"
+python -m coverage report -m
+```
+
+全量 `297` 个测试通过,但 `.coveragerc` 启用了语句和分支覆盖,当前 TOTAL 仅为
+`93.63%`。报告包含 1153 条语句和 418 条分支,其中缺少 52 条语句、48 条分支,
+共有 9 个生产文件未达到 100%。
+
+## 目标
+
+- 保持现有 `.coveragerc` 的 `source`、`omit`、`branch=True` 和报告口径不变。
+- 所有纳入统计的非空生产文件逐个达到 `100.00%`。
+- TOTAL 达到 `100.00%`,并通过 `coverage report --fail-under=100`。
+- 全量 unittest 保持通过,测试不依赖真实 Redis、外部 HTTP 服务或长期运行线程。
+- 不覆盖、不回退当前工作区已有的单号元数据和测试改动。
+
+## 不采用的方案
+
+### 扩大 omit 或添加 pragma
+
+不通过 `.coveragerc omit`、`exclude_lines` 或 `# pragma: no cover` 隐藏当前生产代码
+缺口。这会改变统计边界,无法证明现有代码路径经过测试。
+
+### 为覆盖率改写业务逻辑
+
+当前函数级报告未发现必须重构才能触达的代码。默认只修改测试;若实施中证明某条
+分支结构上不可达,应暂停并重新评审,而不是直接修改生产逻辑或排除该分支。
+
+## 实施结构
+
+### 第一批:叶子模块和数据边界
+
+| 生产文件 | 测试文件 | 覆盖重点 |
+|----------|----------|----------|
+| `tools/query_order_exact.py` | `tests/test_query_order_exact_tool.py` | 非法 scalar ID、逗号字符串 ID 列表、字符串列表去重回边 |
+| `services/token_store.py` | `tests/test_token_store_coverage.py` | 无目录文件、延迟读取、已删除文件清理、bytes 请求、非法 Redis 行 |
+| `services/gateway_session_store.py` | `tests/test_gateway_session_store_unit.py` | 不存在 session 的 touch |
+| `config.py` | `tests/test_config_compat.py` | timeout 环境变量优先级、无效 dotenv 行、带引号值和循环回边 |
+
+这一批不改生产代码,只补可观察的输入、返回值、异常和依赖调用断言。
+
+### 第二批:Gateway 应用和 CLI
+
+| 生产文件 | 测试文件 | 覆盖重点 |
+|----------|----------|----------|
+| `app.py` | `tests/test_app_coverage.py` | 注册工具名称、动态列表错误、request ID、缺少刷新客户端、public 启动、CLI 必填/可选参数及最终防御分支 |
+
+`serve-public` 使用 mock 替换 Redis client、session store、public app 和 server 入口;
+CLI 防御分支通过模拟参数解析结果触发,不启动真实服务。
+
+### 第三批:协议与公网服务
+
+| 生产文件 | 测试文件 | 覆盖重点 |
+|----------|----------|----------|
+| `mcp_protocol.py` | `tests/test_mcp_protocol_coverage.py` | 无 flush 输出、无 input schema、工具响应数据形态、错误 data/meta、空轨迹 tips |
+| `public_gateway.py` | `tests/test_public_gateway_unit.py` | 动态列表无效响应、不支持 touch_session 的 store |
+| `public_server.py` | `tests/test_public_server_coverage.py` | input schema 归一化、启用限流、清理线程、KeyboardInterrupt 关闭 |
+
+HTTP server、线程和无限清理循环都使用 mock 控制。清理循环测试捕获
+`threading.Thread` 的 target,并让受控 `time.sleep` 抛出测试专用异常终止循环,不做
+真实等待。
+
+## 测试质量要求
+
+- 测试名称描述具体行为,不以“覆盖第 N 行”作为唯一目的。
+- 每个异常分支同时断言异常类型和关键消息。
+- 每个成功分支断言返回值或依赖调用参数,不能只调用代码而不验证结果。
+- mock 仅用于 Redis、socket、HTTP server、线程、时钟和 CLI 解析等外部边界。
+- 不添加只为测试存在的生产 API。
+- 每批完成后运行目标测试,再重新执行全量 coverage,依据新的 Missing 列继续收敛。
+
+## 验证流程
+
+每批执行:
+
+```powershell
+python -m unittest <本批目标测试模块>
+python -m coverage run -m unittest discover -s tests -p "test_*.py"
+python -m coverage report -m
+```
+
+最终验收:
+
+```powershell
+python -m coverage run -m unittest discover -s tests -p "test_*.py"
+python -m coverage report -m --fail-under=100
+git diff --check
+```
+
+验收标准:
+
+1. 全量 unittest 返回 0。
+2. 报告中每个非空生产文件为 `100.00%`。
+3. TOTAL 为 `100.00%`,Missing 为空。
+4. `.coveragerc` 的统计范围和分支覆盖设置未被放宽。
+5. 工作区原有单号工具改动保持完整。
+
+## 风险与处理
+
+- 覆盖率 100% 不等于业务逻辑绝对正确;测试仍以可观察行为和关键错误边界为主。
+- 线程/服务器测试若泄漏真实后台线程会导致套件不稳定,因此必须 mock `Thread.start`
+  或捕获 target 后同步执行。
+- 分支覆盖可能在新增测试后暴露新的回边提示;以最新 `coverage report -m` 为准逐项
+  处理,不通过删除有效断言或放宽配置收尾。

+ 65 - 0
docs/superpowers/specs/2026-07-17-order-detail-design.md

@@ -0,0 +1,65 @@
+# MCP 订单详情设计
+
+## 目标
+
+`query_order_detail` 按明确订单号读取 `fmsoperate` 后台订单详情页可见数据,同时保持员工菜单、公司和订单数据范围。Gateway 只定义工具 Schema 和中文展示 DTO,不复制 PHP 权限或业务查询。
+
+## 调用合同
+
+ThinkPHP 路由:`POST /mcp/tools/queryOrderDetail`
+
+输入:
+
+| 字段 | 规则 |
+|---|---|
+| `order_number` | 必填,去空后 1 至 100 字符 |
+| `section` | 可选,默认“订单概览”,仅允许工具 Schema 中的中文枚举 |
+| `page` | 1 至 100,默认 1 |
+| `limit` | 1 至 100,默认 20 |
+
+模块包括:订单概览、箱单信息、箱单商品、DW授权信息、附件信息、入库信息、查验信息、订单轨迹、操作日志、应收与结算日志、派送信息。
+
+完整详情必须先查询订单概览,再逐一查询其他十个模块。分页模块持续查询到“是否还有更多”为否;一个模块为空不代表其他模块为空。
+
+## 权限边界
+
+每次调用都执行以下顺序:
+
+```text
+设备/Token 会话
+  -> 动态工具注册与 route_code
+  -> admin/Order/index 菜单
+  -> query_order_exact 同源父订单数据范围
+  -> 服务端父订单 ID
+  -> 目标 section
+```
+
+调用方不能提供订单 ID、公司/员工/角色、箱单 ID、查验 ID 或其他子资源 ID。不存在、跨公司和数据范围外订单使用相同的目标不可用结果。合作伙伴关联 ID 只能由已授权展示订单在服务端派生。
+
+## PHP 响应
+
+PHP 使用固定机器结构供 Gateway 校验:
+
+- 所有结果包含规范订单号、内部 section 和 payload。
+- 概览 payload 固定包含状态节点、订单信息、货运信息、箱单汇总和进出口商。
+- 普通模块固定包含 records。
+- 入库和派送固定包含 summary 与 records。
+- 分页信息只包含页码、每页数量和是否还有更多,不统计总数。
+
+MySQL 模块在父订单重新授权后读取。Mongo 日志、轨迹接口和 `track_db` 无法加入同一快照,只能在授权成功后使用服务端派生参数访问。
+
+## 中文展示
+
+`OutputPresenter` 对顶层、分组、状态枚举和每行字段执行完整白名单校验。未知、缺失或多余字段均关闭失败。最终结构化结果和文本只使用中文业务键;机器字段、内部参数名及数据库 ID 不进入 AI 展示。
+
+附件展示保留:附件分类、文件名称、文件类型、是否图片、预览链接、下载链接和收费项目。链接由 PHP 校验 HTTP(S) 协议与配置主机。商品图片、入库/测量照片和查验照片不返回链接或内容,只返回数量。Freight Tower 只返回地图是否可用,不返回含密钥的 iframe URL。
+
+## 发布
+
+1. 部署 fmsoperate 与 Gateway 代码。
+2. 审核并执行 `fmsoperate/sql/mcp_add_query_order_detail_tool.sql`;首次注册默认 `status=0`,重复执行保留管理员状态。
+3. 核对注册表 route_code 和预期 status。
+4. 重启 Gateway,并让 Workbuddy 重新连接。
+5. 使用有权、无权、跨公司和合作伙伴员工完成只读冒烟。
+
+代码存在、测试通过或 SQL 文件存在均不表示目标环境已启用该工具。

+ 287 - 0
docs/superpowers/specs/2026-07-22-mcp-async-export-task-design.md

@@ -0,0 +1,287 @@
+# MCP 异步导出任务设计
+
+## 背景
+
+`export_pending_outbound_orders` 和 `export_out_of_province_port_data` 当前都在一次
+MCP `tools/call` 内同步完成查询、文件生成、上传并返回下载链接。未排舱导出固定
+`is_sync=1`;省外进港资料同步调用 Support 模板导出。数据量较大时整个请求可能
+持续一分钟以上。
+
+MCP Gateway 调用 fmsoperate 的默认 HTTP 超时为 10 秒,外层反向代理和 Workbuddy
+也可能提前关闭长连接。单纯增加超时无法保证整条链路稳定,而且会长期占用 Gateway
+并发名额和 PHP 请求进程。
+
+本设计将两个导出工具改成短请求提交任务,并新增独立状态查询工具。文件生成继续复用
+fmsoperate 现有导出算法、Redis 队列、`st_download_list` 和文件存储,不把业务逻辑
+迁移到 Gateway。
+
+本设计替代
+`2026-07-14-mcp-export-pending-outbound-orders-design.md` 中关于同步等待、直接返回
+文件 URL 和“不改造成任务轮询协议”的结论;原文件只作为当时设计记录保留。
+
+## 目标
+
+- 两个导出提交请求在完成参数、权限和数据存在性校验后快速返回,不等待文件生成。
+- 新增 `query_export_task`,通过新的短 MCP 调用查询等待、执行、完成或失败状态。
+- 完成状态返回与当前合同相同的安全文件链接。
+- 复用现有导出实现,不复制 Excel 字段、模板、业务查询或 OSS 上传算法。
+- 保持工具注册、菜单权限、公司隔离和员工数据范围校验。
+- 防止客户端重试造成同一员工短时间内重复创建相同导出任务。
+- 不暴露 `st_download_list.id`、内部异常、队列载荷或原始失败备注。
+
+## 非目标
+
+- 不通过一个 MCP 调用在 Gateway 内持续轮询。
+- 不使用流式响应、SSE 或 WebSocket 传输导出进度。
+- 不通过提高 Gateway、Nginx 或 Workbuddy 超时解决长任务。
+- 不修改后台页面现有导出交互。
+- 不改变两个导出的筛选条件、号码匹配、文件内容或模板。
+- 不新增业务导出任务表;继续使用 `st_download_list`。
+
+## 方案选择
+
+### 采用:全异步提交和独立查询
+
+两个导出工具一律创建任务并返回任务引用,调用方稍后单独调用
+`query_export_task`。该方案使每次 HTTP 请求都保持短时、可重试,并且不依赖数据量
+阈值或调用端连接时长。
+
+### 不采用:按数据量混合同步与异步
+
+导出耗时不仅取决于行数,还取决于关联查询、模板服务、文件写入和上传。预估阈值不稳定,
+会让同一个工具出现两套时序和输出分支,增加调用方及测试复杂度。
+
+### 不采用:只增加超时
+
+Gateway、PHP、反向代理和 Workbuddy 任一层仍可能先超时。长请求还会占用每工具并发
+配额,超时重试可能重复生成文件,因此只可作为临时排障手段。
+
+## 对外工具合同
+
+### 导出提交工具
+
+保留两个现有工具名及输入 Schema:
+
+- `export_pending_outbound_orders`
+- `export_out_of_province_port_data`
+
+成功提交后统一返回:
+
+```json
+{
+  "code": "MCP_0000",
+  "msg": "导出任务已提交",
+  "data": {
+    "task_ref": "mexp_xxx",
+    "status": "queued",
+    "retry_after_seconds": 10
+  },
+  "meta": {
+    "request_id": "rq_xxx"
+  }
+}
+```
+
+`task_ref` 是带完整性校验的不可枚举引用,不是数据库主键。`retry_after_seconds`
+只是调用建议,不代表精确完成时间,也不允许 Gateway 在原请求内等待。
+
+提交阶段仍完成以下同步检查:
+
+1. MCP Token、动态工具注册和 route_code。
+2. 对应后台菜单权限。
+3. 参数格式和组合关系。
+4. 当前公司和员工数据范围。
+5. 未排舱导出的单行数据存在性探测,或省外进港资料的全部号码解析和授权。
+
+因此参数错误、无权限、目标不存在或无可导出数据仍立即返回现有安全业务错误,不创建
+队列任务。
+
+### `query_export_task`
+
+新增工具:
+
+- 工具名:`query_export_task`
+- 路由:`POST /mcp/tools/queryExportTask`
+- 必填参数:`task_ref`,字符串,最长 512 字符
+- 每次只查询一个任务,不接受任务 ID、员工 ID、公司 ID 或批量数组
+
+返回状态:
+
+| 内部状态 | 对外状态 | 对外数据 |
+|---|---|---|
+| `st_download_list.status=1` | `queued` | `task_ref`、建议重试秒数 |
+| `status=4` | `running` | `task_ref`、建议重试秒数 |
+| `status=2` | `completed` | `task_ref`、`files[].label/url` |
+| `status=3` | `failed` | `task_ref`、固定安全失败提示 |
+
+任务不存在、引用签名无效、任务不属于当前员工或公司、来源工具权限失效时,统一返回
+“导出任务不可用”,不区分具体原因,避免枚举和越权探测。不得返回
+`st_download_list.remark`。
+
+`query_export_task` 自身先经过动态注册校验。Logic 再根据任务中记录的来源工具执行该
+导出的菜单权限检查;只有任务所属员工、公司和来源权限均仍有效时才返回状态或文件链接。
+
+### Gateway 展示
+
+两个提交工具的 Presenter 白名单改为:
+
+- `task_ref`
+- `status=queued`
+- `retry_after_seconds`
+
+`query_export_task` 的 Presenter 只接受四个已知状态。完成时复用现有导出 URL 和主机
+白名单校验;其他状态不得携带文件链接。未知字段、未知状态、畸形 URL 或未知错误继续
+关闭失败。
+
+工具说明明确:
+
+- 导出工具只负责提交任务。
+- 收到 `queued` 后应告知用户任务已提交。
+- 用户要求查看结果或经过建议等待时间后,才调用 `query_export_task`。
+- 不得在一次工具调用内睡眠、循环轮询或并行创建相同任务。
+
+CLI 保留 fmsoperate 原始 `code/msg/data/meta` 信封。
+
+## 任务引用
+
+`task_ref` 使用 `mexp_` 前缀和 URL-safe Base64 编码,载荷至少包含:
+
+- `task_id`
+- `create_user_id`
+- `company_id`
+- `source_tool`
+- `issued_at`
+
+载荷使用从 fmsoperate 专用 `MCP_EXPORT.TASK_REF_SECRET` 分别派生的 AES-256-CBC 加密密钥和 HMAC-SHA256 认证密钥,按 encrypt-then-MAC 生成不暴露内部 ID 的引用。密钥少于 32 字节、为空、解密失败或引用验签失败时
+关闭访问。验签使用恒定时间比较;解析失败不得记录完整引用。引用有效期固定为 7 天,
+超过有效期后要求用户重新提交导出。
+
+签名只防止引用伪造,不替代数据库授权。查询时仍必须按以下条件读取:
+
+- `id=task_id`
+- `create_user_id=当前员工`
+- `system_id=当前系统`
+- `export_params.company_id=当前公司`
+- `export_params.mcp_source_tool=source_tool`
+
+日志只记录任务数据库 ID 或引用短哈希中的一种内部关联值,不记录完整 `task_ref`。
+
+## fmsoperate 数据流
+
+### 未排舱订单
+
+1. `McpPendingOutboundExportLogic` 保留当前权限检查、参数归一化和单行探测。
+2. 服务端固定写入 `export_type=EXPORT_PEND_OUTBOUND_ORDER`、`is_sync=0`、
+   当前员工和公司上下文及 `mcp_source_tool`。
+3. 创建 `st_download_list` 等待任务。
+4. 通过现有 `RedisAction::EXPORT_PEND_OUTBOUND_ORDER` 和
+   `ExportPendOutboundOrder` Handler 入队。
+5. Worker 复用 `ExportPendOutboundOrderLogic`,按现有约定将状态从等待更新为执行中、
+   完成或失败。
+6. 提交接口返回由任务 ID 和可信会话生成的 `task_ref`。
+
+### 省外进港资料
+
+1. `McpOutOfProvincePortExportLogic` 保留四类号码严格四选一、全部匹配和全部授权。
+2. 把已授权排舱 ID、资料类型、可信员工/公司上下文及 `mcp_source_tool` 写入任务参数。
+3. 创建 `st_download_list` 等待任务并推送新的专用 Redis Action/Handler。
+4. Worker 把任务标记为执行中,调用从现有 `ExportLogic::shangHaiExport()` 抽出的可复用
+   模板导出方法。
+5. 新的内部方法接受既有 `task_id` 作为 `down_record_id`,不得再次创建第二条
+   `st_download_list` 记录;后台现有 `shangHaiExport()` 对外行为保持不变。
+6. Worker 根据模板响应更新同一任务的文件 URL、文件名、完成时间或安全失败状态。
+
+省外进港 Worker 只接收提交阶段已经全部授权并固定下来的排舱 ID,不重新根据原始号码做
+模糊匹配。状态查询和链接交付阶段再次验证当前员工、公司及来源菜单权限。
+
+### 队列失败
+
+创建任务与入队不能组成单个数据库事务,因此采用补偿:
+
+- 任务插入失败:不入队,返回系统繁忙。
+- 入队返回失败或抛出异常:把任务标记为失败并返回系统繁忙。
+- Worker 异常:先把任务标记为失败,再重新抛给队列框架记录队列 Action、任务 ID 和
+  内部异常;提交与查询的 request ID 继续由 `st_mcp_access_log` 保留,公共响应不返回异常。
+- Worker 重试必须以任务状态为门禁,完成任务不得重复生成文件。
+
+## 去重与并发
+
+提交前基于以下内容生成稳定 SHA-256 指纹:
+
+- 当前公司 ID
+- 当前员工 ID
+- 来源工具
+- 归一化后的业务参数
+
+使用 Redis 原子 `SET NX EX` 建立 60 秒提交锁,并把已创建的任务 ID 保存为值。同一指纹
+在锁有效期内再次提交时返回同一个 `task_ref`,不重复插入或入队。入队失败时释放锁。
+
+去重只覆盖客户端超时或快速重试,不缓存导出结果,不跨员工复用文件,也不改变用户在
+60 秒后主动再次导出的能力。
+
+## 安全边界
+
+- 身份、公司、员工、超级管理员、时区和数据范围只来自 MCP Token 恢复的 Session。
+- 外部请求不能提交 `task_id`、`company_id`、`operator_id`、`is_super` 或队列参数。
+- 导出提交时做完整业务授权;状态查询和链接交付时再次检查任务归属和来源菜单权限。
+- 任务引用验签、Redis、数据库或菜单查询失败时关闭访问。
+- Presenter 只输出固定状态、建议等待时间和安全文件链接。
+- 不返回队列名、内部 Action、数据库主键、导出参数、失败备注、SQL 或异常栈。
+- `st_mcp_access_log` 继续记录每次提交和查询的独立 request ID 与安全响应。
+
+## 注册与部署
+
+新增 `query_export_task` 后,本地和公网 Gateway 候选工具由 12 个变为 13 个。需要同步:
+
+- Python 工具 metadata、双注册表、CLI 转发和 Presenter。
+- fmsoperate 路由、Validate、Controller、Logic、任务查询 Model。
+- fmsoperate 动态工具注册 SQL,首次插入默认 `status=0`,重复执行保留管理员现有状态。
+- 新省外进港导出 Redis Action、配置和 Handler。
+- README 与集中仓当前需求、技术规格、用户流程和里程碑。
+
+部署顺序:
+
+1. 部署 fmsoperate 代码及持续运行的 `fmsoperate:inside` Worker。
+2. 执行并核验新工具注册 SQL,由管理员决定是否启用。
+3. 部署并重启 MCP Gateway。
+4. 让 Workbuddy 重新连接;`tools.listChanged=false` 不会热更新 Schema。
+5. 分别用小数据和超过一分钟的数据验证提交、状态查询及下载。
+
+不执行新的业务表 DDL。队列 Worker、Redis 和文件存储不可用时不得启用新合同。
+
+## 测试与验收
+
+### fmsoperate
+
+- 两个提交 Logic 验证参数、菜单、公司和数据范围后快速返回任务引用。
+- 未排舱无数据、省外号码缺失/越权时不创建任务。
+- 外部身份字段、任务 ID 和队列字段不能覆盖服务端值。
+- 入队失败会把任务置为失败且不留下可再次执行的等待任务。
+- Worker 状态完整覆盖 `queued -> running -> completed/failed`。
+- 省外进港异步导出只创建一条下载任务并复用原模板结果。
+- 状态查询覆盖有效引用、篡改、过期、跨员工、跨公司、来源工具权限失效和任务不存在。
+- 失败响应不包含 `remark`、异常或内部 ID。
+- 相同员工和参数 60 秒内重试返回同一任务;不同员工或公司不共享。
+
+### MCP Gateway
+
+- stdio 和公网均注册 13 个同序工具,并受动态启用列表控制。
+- 两个导出工具只接受规范的 `queued` 响应。
+- 状态查询 Presenter 覆盖四种状态及完成文件链接。
+- 非完成状态携带 URL、未知状态、未知字段和非法 URL 时关闭失败。
+- CLI 原样保留新的后端信封。
+- 全量 unittest、语句和分支覆盖率继续为 100%。
+
+### 时序验收
+
+- 提交接口不等待实际文件生成,在正常数据库和 Redis 条件下应在短请求时间内返回。
+- 模拟导出耗时超过 60 秒时,原提交 MCP 请求仍已正常结束。
+- 导出过程中多次状态查询不会重复入队或生成文件。
+- 完成后新的状态查询返回可下载文件,失败后返回固定安全提示。
+
+## 回滚
+
+- 先在动态注册表中关闭 `query_export_task` 和两个导出工具,阻止产生新合同调用。
+- 已入队任务允许 Worker 完成;不要删除等待或执行中的 `st_download_list` 记录。
+- 回滚 Gateway 和 fmsoperate 代码后重启 Gateway,并让客户端重新连接。
+- 不依赖回滚 SQL;注册表状态由管理员保留,代码回滚期间关闭相关工具即可。

+ 307 - 36
mcp_protocol.py

@@ -1,19 +1,35 @@
 import json
+import logging
 import sys
+import uuid
+
+from services.output_presenter import OutputPresenter
+from services.diagnostic_event import RequestDiagnosticEmitter
+from services.diagnostic_reporter import NullDiagnosticReporter
+
+
+logger = logging.getLogger(__name__)
 
 
 class McpProtocolHandler:
     protocol_version = '2025-06-18'
     server_name = 'fms-mcp-gateway'
     server_version = '0.1.0'
+    output_presenter = OutputPresenter()
 
-    def __init__(self, gateway_app):
+    def __init__(self, gateway_app, reporter=None):
         self.gateway_app = gateway_app
+        self.reporter = reporter or NullDiagnosticReporter()
         self.initialized = False
 
     def handle_message(self, message):
         if not isinstance(message, dict):
-            return self._error_response(None, -32600, 'Invalid Request')
+            return self._error_response(
+                None,
+                -32600,
+                'Invalid Request',
+                self._build_trace_request_id(),
+            )
         if 'id' in message:
             return self.handle_request(message)
         method = str(message.get('method') or '').strip()
@@ -25,8 +41,30 @@ class McpProtocolHandler:
     def handle_request(self, request):
         request_id = request.get('id')
         method = str(request.get('method') or '').strip()
+        tool_name = ''
+        trace_request_id = self._build_trace_request_id()
+        emitter = RequestDiagnosticEmitter(self.reporter, trace_request_id)
+        emitter.emit(
+            stage='request_ingress',
+            status='started',
+            event_code='REQUEST_RECEIVED',
+            context={
+                'jsonrpc_method': method or 'unknown',
+                'transport': 'stdio',
+            },
+        )
+        backend_started = False
         try:
             if method == 'initialize':
+                emitter.emit(
+                    stage='protocol_validation',
+                    status='succeeded',
+                    event_code='PROTOCOL_VALIDATION_COMPLETED',
+                    context={
+                        'jsonrpc_method': method,
+                        'transport': 'stdio',
+                    },
+                )
                 self.initialized = True
                 return self._success_response(
                     request_id,
@@ -44,43 +82,163 @@ class McpProtocolHandler:
                     },
                 )
             if method == 'tools/list':
-                tools = [self._normalize_tool(tool) for tool in self.gateway_app.list_tools()]
+                emitter.emit(
+                    stage='protocol_validation',
+                    status='succeeded',
+                    event_code='PROTOCOL_VALIDATION_COMPLETED',
+                    context={
+                        'jsonrpc_method': method,
+                        'transport': 'stdio',
+                    },
+                )
+                tools = [
+                    self._normalize_tool(tool)
+                    for tool in self.gateway_app.list_tools(
+                        request_id=trace_request_id,
+                    )
+                ]
                 return self._success_response(request_id, {'tools': tools})
             if method == 'tools/call':
                 params = request.get('params') or {}
-                tool_name = params.get('name')
+                if not isinstance(params, dict):
+                    raise ValueError('tool parameters must be an object')
+                raw_tool_name = params.get('name')
+                if not isinstance(raw_tool_name, str) or not raw_tool_name.strip():
+                    emitter.emit(
+                        stage='protocol_validation',
+                        status='failed',
+                        event_code='PARAM_VALIDATION_FAILED',
+                        context={
+                            'jsonrpc_method': method,
+                            'jsonrpc_code': -32602,
+                            'transport': 'stdio',
+                        },
+                    )
+                    return self._error_response(
+                        request_id,
+                        -32602,
+                        'Invalid params',
+                        trace_request_id,
+                    )
+                tool_name = raw_tool_name.strip()
                 arguments = params.get('arguments') or {}
-                tool_result = self.gateway_app.call_tool(tool_name, arguments)
-                structured_content = tool_result.get('data') or {}
-                return self._success_response(
-                    request_id,
-                    {
-                        'content': [
-                            {
-                                'type': 'text',
-                                'text': self._render_text(structured_content),
-                            }
-                        ],
-                        'structuredContent': structured_content,
-                        'isError': False,
+                emitter.emit(
+                    stage='protocol_validation',
+                    status='succeeded',
+                    event_code='PROTOCOL_VALIDATION_COMPLETED',
+                    tool_code=tool_name,
+                    context={
+                        'jsonrpc_method': method,
+                        'transport': 'stdio',
                     },
                 )
-            return self._error_response(request_id, -32601, 'Method not found: {0}'.format(method or '<empty>'))
+                backend_started = True
+                emitter.emit(
+                    stage='backend_call',
+                    status='started',
+                    event_code='BACKEND_CALL_STARTED',
+                    tool_code=tool_name,
+                    context={'transport': 'stdio'},
+                )
+                tool_result = self.gateway_app.call_tool(
+                    tool_name,
+                    arguments,
+                    request_id=trace_request_id,
+                )
+                backend_started = False
+                emitter.emit(
+                    stage='backend_call',
+                    status='succeeded',
+                    event_code='BACKEND_CALL_COMPLETED',
+                    tool_code=tool_name,
+                    response_code=(
+                        tool_result.get('code')
+                        if isinstance(tool_result, dict)
+                        else None
+                    ),
+                    context={'transport': 'stdio'},
+                )
+                try:
+                    response = self._tool_call_response(
+                        request_id,
+                        tool_name,
+                        tool_result,
+                    )
+                except Exception:
+                    emitter.emit(
+                        stage='response_safety',
+                        status='failed',
+                        event_code='RESPONSE_SAFETY_REJECTED',
+                        tool_code=tool_name,
+                        context={'transport': 'stdio'},
+                    )
+                    raise
+                emitter.emit(
+                    stage='response_safety',
+                    status='succeeded',
+                    event_code='RESPONSE_SAFETY_COMPLETED',
+                    tool_code=tool_name,
+                    context={'transport': 'stdio'},
+                )
+                return response
+            return self._error_response(
+                request_id,
+                -32601,
+                'Method not found: {0}'.format(method or '<empty>'),
+                trace_request_id,
+            )
         except Exception as exc:
             if method == 'tools/call':
-                return self._success_response(
-                    request_id,
-                    {
-                        'content': [
-                            {
-                                'type': 'text',
-                                'text': str(exc),
-                            }
-                        ],
-                        'isError': True,
+                diagnostic_reason = (
+                    'PARAM_VALIDATION_FAILED'
+                    if isinstance(exc, ValueError)
+                    else 'UNEXPECTED_EXCEPTION'
+                )
+                logger.error(
+                    'MCP stdio tool request failed',
+                    extra={
+                        'request_id': trace_request_id,
+                        'jsonrpc_id': request_id,
+                        'protocol_method': method,
+                        'tool_code': tool_name,
+                        'response_code': 'MCP_9001',
+                        'diagnostic_reason': diagnostic_reason,
+                        'exception_class': exc.__class__.__name__,
                     },
                 )
-            return self._error_response(request_id, -32000, str(exc))
+                if backend_started:
+                    emitter.emit(
+                        stage='backend_call',
+                        status='failed',
+                        event_code=diagnostic_reason,
+                        tool_code=tool_name or None,
+                        response_code='MCP_9001',
+                        context={'transport': 'stdio'},
+                    )
+                return self._tool_exception_response(
+                    request_id,
+                    tool_name,
+                    exc,
+                    trace_request_id,
+                )
+            logger.error(
+                "MCP stdio request failed",
+                extra={
+                    'request_id': trace_request_id,
+                    'jsonrpc_id': request_id,
+                    'protocol_method': method,
+                    'tool_code': tool_name,
+                    'protocol_code': -32000,
+                    'diagnostic_reason': 'UNEXPECTED_EXCEPTION',
+                    'exception_class': exc.__class__.__name__,
+                },
+            )
+            return self._error_response(
+                request_id,
+                -32000,
+                'Gateway request failed. Please try again later.',
+                trace_request_id,
+            )
 
     def run_stdio(self, stdin=None, stdout=None):
         stdin = stdin or sys.stdin
@@ -92,7 +250,12 @@ class McpProtocolHandler:
             try:
                 message = json.loads(line)
             except ValueError:
-                response = self._error_response(None, -32700, 'Parse error')
+                response = self._error_response(
+                    None,
+                    -32700,
+                    'Parse error',
+                    self._build_trace_request_id(),
+                )
             else:
                 response = self.handle_message(message)
             if response is None:
@@ -108,6 +271,107 @@ class McpProtocolHandler:
             normalized['inputSchema'] = normalized.pop('input_schema')
         return normalized
 
+    @classmethod
+    def _tool_call_response(cls, request_id, tool_name, tool_result):
+        if tool_name == 'query_order':
+            return cls._legacy_tool_call_response(request_id, tool_result)
+
+        presented = cls.output_presenter.present(tool_name, tool_result)
+        return cls._presented_response(request_id, presented)
+
+    @classmethod
+    def _tool_exception_response(
+        cls,
+        request_id,
+        tool_name,
+        exception,
+        trace_request_id='',
+    ):
+        if tool_name == 'query_order':
+            return cls._success_response(
+                request_id,
+                {
+                    'content': [{
+                        'type': 'text',
+                        'text': str(exception),
+                    }],
+                    'isError': True,
+                },
+            )
+
+        presented = cls.output_presenter.present_exception(
+            tool_name,
+            exception,
+        )
+        if trace_request_id:
+            presented['meta'] = {'request_id': trace_request_id}
+        return cls._presented_response(request_id, presented)
+
+    @classmethod
+    def _presented_response(cls, request_id, presented):
+        result = {
+            'content': [{
+                'type': 'text',
+                'text': presented['text'],
+            }],
+            'structuredContent': presented['structured_content'],
+            'isError': presented['is_error'],
+        }
+        if presented['meta']:
+            result['_meta'] = presented['meta']
+        return cls._success_response(request_id, result)
+
+    @classmethod
+    def _legacy_tool_call_response(cls, request_id, tool_result):
+        if not isinstance(tool_result, dict):
+            raise RuntimeError('invalid tool response')
+
+        raw_code = tool_result.get('code')
+        code = str(raw_code).strip() if raw_code is not None else ''
+        message = str(tool_result.get('msg') or '').strip()
+        data = tool_result.get('data')
+        meta = tool_result.get('meta')
+
+        if code in ('MCP_0000', '0'):
+            if isinstance(data, dict):
+                structured_content = dict(data)
+            elif data:
+                structured_content = {'data': data}
+            else:
+                structured_content = {}
+            if isinstance(meta, dict) and meta:
+                structured_content['meta'] = dict(meta)
+
+            return cls._success_response(request_id, {
+                'content': [{
+                    'type': 'text',
+                    'text': cls._render_text(structured_content),
+                }],
+                'structuredContent': structured_content,
+                'isError': False,
+            })
+
+        error_content = {
+            'code': code or 'MCP_9001',
+            'msg': message or 'tool call failed',
+        }
+        if data not in (None, [], {}):
+            error_content['data'] = data
+        if isinstance(meta, dict) and meta:
+            error_content['meta'] = dict(meta)
+
+        return cls._success_response(request_id, {
+            'content': [{
+                'type': 'text',
+                'text': '{0}: {1}'.format(
+                    error_content['code'],
+                    error_content['msg'],
+                ),
+            }],
+            'structuredContent': error_content,
+            'isError': True,
+        })
+
     @staticmethod
     def _render_text(structured_content):
         if not structured_content:
@@ -205,12 +469,19 @@ class McpProtocolHandler:
         }
 
     @staticmethod
-    def _error_response(request_id, code, message):
+    def _build_trace_request_id():
+        return 'rq_stdio_{0}'.format(uuid.uuid4().hex[:16])
+
+    @staticmethod
+    def _error_response(request_id, code, message, trace_request_id=''):
+        error = {
+            'code': code,
+            'message': message,
+        }
+        if trace_request_id:
+            error['data'] = {'request_id': trace_request_id}
         return {
             'jsonrpc': '2.0',
             'id': request_id,
-            'error': {
-                'code': code,
-                'message': message,
-            },
-        }
+            'error': error,
+        }

+ 203 - 11
public_gateway.py

@@ -1,8 +1,26 @@
 import logging
+import time
 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,
+)
+from tools.list_pending_outbound_export_filter_options import (
+    ListPendingOutboundExportFilterOptionsTool,
+)
 from tools.query_order import QueryOrderTool
+from tools.query_customs_declaration_files import (
+    QueryCustomsDeclarationFilesTool,
+)
+from tools.query_order_exact import QueryOrderExactTool
+from tools.query_order_detail import QueryOrderDetailTool
+from tools.query_export_task import QueryExportTaskTool
+from tools.query_outbound_detail import QueryOutboundDetailTool
+from tools.query_outbound_list import QueryOutboundListTool
 from tools.query_track import QueryTrackTool
 from utils.security import hash_gateway_session_id
 
@@ -17,43 +35,217 @@ class PublicGatewayApp:
         self._tools = {
             'query_order': QueryOrderTool(api_client=None),
             'query_track': QueryTrackTool(api_client=None),
+            'query_order_exact': QueryOrderExactTool(api_client=None),
+            'query_order_detail': QueryOrderDetailTool(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
+            ),
+            'export_pending_outbound_orders': ExportPendingOutboundOrdersTool(
+                api_client=None
+            ),
+            'export_out_of_province_port_data':
+                ExportOutOfProvincePortDataTool(api_client=None),
+            'query_export_task': QueryExportTaskTool(api_client=None),
+            'list_pending_outbound_export_filter_options':
+                ListPendingOutboundExportFilterOptionsTool(api_client=None),
         }
 
-    def list_tools(self):
-        return [tool.metadata() for tool in self._tools.values()]
+    def registered_tool_names(self):
+        return tuple(self._tools.keys())
+
+    def _require_session(self, gateway_session_id, diagnostic_emitter=None):
+        session = self.session_store.get(gateway_session_id)
+        if not session or not session.get('mcp_token'):
+            if diagnostic_emitter is not None:
+                diagnostic_emitter.emit(
+                    stage='gateway_session',
+                    status='failed',
+                    event_code='GATEWAY_SESSION_NOT_FOUND',
+                    session_credential=gateway_session_id,
+                    context={'transport': 'http'},
+                )
+            raise RuntimeError(DEVICE_INVALID_MESSAGE)
+        if diagnostic_emitter is not None:
+            diagnostic_emitter.set_defaults(
+                session_credential=gateway_session_id,
+                admin_id=session.get('admin_id'),
+                company_id=session.get('company_id'),
+                context={'transport': 'http'},
+            )
+            diagnostic_emitter.emit(
+                stage='gateway_session',
+                status='succeeded',
+                event_code='GATEWAY_SESSION_RESOLVED',
+            )
+        return session
+
+    def _enabled_tool_names(self, response):
+        if not isinstance(response, dict):
+            raise RuntimeError('invalid enabled tool response')
+        if response.get('code') != 'MCP_0000':
+            raise RuntimeError(response.get('msg') or 'list enabled tools failed')
+        data = response.get('data')
+        codes = data.get('tool_codes') if isinstance(data, dict) else None
+        if not isinstance(codes, list):
+            raise RuntimeError('invalid enabled tool response')
+        return {
+            code.strip().lower()
+            for code in codes
+            if isinstance(code, str) and code.strip()
+        }
+
+    def _load_enabled_tool_names(self, token, request_id=''):
+        response = self.api_client.list_enabled_tools(
+            token,
+            request_id=request_id,
+        )
+        return self._enabled_tool_names(response)
+
+    def list_tools(self, gateway_session_id, request_id=''):
+        session = self._require_session(gateway_session_id)
+        request_id = self.build_request_id(request_id)
+        enabled = self._load_enabled_tool_names(
+            session['mcp_token'],
+            request_id,
+        )
+        return [
+            tool.metadata()
+            for name, tool in self._tools.items()
+            if name in enabled
+        ]
 
     def build_request_id(self, request_id=''):
         request_id = str(request_id or '').strip()
         return request_id or 'rq_{0}'.format(uuid.uuid4().hex[:16])
 
-    def call_tool(self, gateway_session_id, name, arguments=None, request_id=''):
+    def call_tool(
+        self,
+        gateway_session_id,
+        name,
+        arguments=None,
+        request_id='',
+        client_ip='',
+        diagnostic_emitter=None,
+    ):
+        session = self._require_session(gateway_session_id, diagnostic_emitter)
+        request_id = self.build_request_id(request_id)
         if name not in self._tools:
+            if diagnostic_emitter is not None:
+                diagnostic_emitter.emit(
+                    stage='backend_call',
+                    status='failed',
+                    event_code='TOOL_NOT_REGISTERED',
+                    context={'transport': 'http'},
+                )
             raise KeyError('tool not registered: {0}'.format(name))
 
-        session = self.session_store.get(gateway_session_id)
-        if not session or not session.get('mcp_token'):
-            raise RuntimeError(DEVICE_INVALID_MESSAGE)
+        try:
+            enabled_tools = self._load_enabled_tool_names(
+                session['mcp_token'],
+                request_id,
+            )
+        except Exception:
+            if diagnostic_emitter is not None:
+                diagnostic_emitter.emit(
+                    stage='backend_call',
+                    status='failed',
+                    event_code='ENABLED_TOOL_LOOKUP_FAILED',
+                    tool_code=name,
+                    context={'transport': 'http'},
+                )
+            raise
+        if name not in enabled_tools:
+            if diagnostic_emitter is not None:
+                diagnostic_emitter.emit(
+                    stage='backend_call',
+                    status='failed',
+                    event_code='TOOL_DISABLED',
+                    tool_code=name,
+                    context={'transport': 'http'},
+                )
+            raise RuntimeError('tool disabled: {0}'.format(name))
 
         tool = self._tools[name]
-        request_id = self.build_request_id(request_id)
+
+        if diagnostic_emitter is not None:
+            diagnostic_emitter.set_defaults(tool_code=name)
 
         session_hash = hash_gateway_session_id(gateway_session_id)[:12]
         admin_id = session.get('admin_id')
         company_id = session.get('company_id')
-        logger.info(f"[AUDIT] tool_call: session_hash={session_hash}, admin_id={admin_id}, company_id={company_id}, tool={name}, request_id={request_id}")
+        logger.info(
+            'MCP public tool call',
+            extra={
+                'request_id': request_id,
+                'tool_code': name,
+                'session_hash': session_hash,
+                'admin_id': admin_id,
+                'company_id': company_id,
+            },
+        )
 
         try:
+            started_at = time.monotonic()
+            if diagnostic_emitter is not None:
+                diagnostic_emitter.emit(
+                    stage='backend_call',
+                    status='started',
+                    event_code='BACKEND_CALL_STARTED',
+                )
             result = self.api_client.call_tool(
                 token=session['mcp_token'],
                 tool_code=tool.name,
                 route_path=tool.route_path,
                 payload=arguments or {},
                 request_id=request_id,
+                client_ip=client_ip,
             )
             if hasattr(self.session_store, 'touch_session'):
                 self.session_store.touch_session(gateway_session_id)
-            logger.info(f"[AUDIT] tool_success: session_hash={session_hash}, tool={name}, request_id={request_id}, code={result.get('code')}")
+            logger.info(
+                'MCP public tool success',
+                extra={
+                    'request_id': request_id,
+                    'tool_code': name,
+                    'session_hash': session_hash,
+                    'response_code': result.get('code'),
+                },
+            )
+            if diagnostic_emitter is not None:
+                diagnostic_emitter.emit(
+                    stage='backend_call',
+                    status='succeeded',
+                    event_code='BACKEND_CALL_COMPLETED',
+                    response_code=(
+                        result.get('code') if isinstance(result, dict) else None
+                    ),
+                    cost_ms=max(0, int((time.monotonic() - started_at) * 1000)),
+                )
             return result
         except Exception as e:
-            logger.error(f"[AUDIT] tool_error: session_hash={session_hash}, tool={name}, request_id={request_id}, error={str(e)}")
-            raise
+            logger.error(
+                'MCP public tool failed',
+                extra={
+                    'request_id': request_id,
+                    'tool_code': name,
+                    'session_hash': session_hash,
+                    'response_code': 'MCP_9001',
+                    'diagnostic_reason': 'UNEXPECTED_EXCEPTION',
+                    'exception_class': e.__class__.__name__,
+                },
+            )
+            if diagnostic_emitter is not None:
+                diagnostic_emitter.emit(
+                    stage='backend_call',
+                    status='failed',
+                    event_code='UNEXPECTED_EXCEPTION',
+                    response_code='MCP_9001',
+                )
+            raise

+ 516 - 50
public_server.py

@@ -1,9 +1,15 @@
+import hashlib
 import json
 import logging
+import threading
+import time
+import uuid
 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 
 from constants import DEVICE_INVALID_MESSAGE
 from mcp_protocol import McpProtocolHandler
+from services.diagnostic_event import RequestDiagnosticEmitter
+from services.diagnostic_reporter import NullDiagnosticReporter
 from services.request_context import RequestContextParser
 from utils.rate_limiter import SimpleRateLimiter
 
@@ -17,23 +23,126 @@ def extract_client_ip(headers, client_address):
 
 
 class PublicMcpHttpHandler:
-    def __init__(self, gateway_app, context_parser=None, rate_limiter=None):
+    def __init__(
+        self,
+        gateway_app,
+        context_parser=None,
+        rate_limiter=None,
+        reporter=None,
+    ):
         self.gateway_app = gateway_app
         self.context_parser = context_parser or RequestContextParser()
         self.rate_limiter = rate_limiter
+        self.reporter = reporter or NullDiagnosticReporter()
+        # Cache registered tool names at startup for rate-key validation;
+        # unknown names fall back to the bare IP bucket, preventing bucket explosion
+        self._known_tools = frozenset(gateway_app.registered_tool_names())
 
-    def handle_json_rpc(self, headers, message, client_ip=''):
+    @staticmethod
+    def _build_trace_request_id(headers):
+        return 'rq_http_{0}'.format(uuid.uuid4().hex[:16])
+
+    @staticmethod
+    def _client_request_id(headers):
+        incoming = ''
+        for key, value in (headers or {}).items():
+            if str(key).lower() == 'x-request-id':
+                incoming = str(value or '').strip()
+                break
+        if not incoming:
+            return ''
+        return hashlib.sha256(incoming.encode('utf-8')).hexdigest()[:16]
+
+    def _check_rate_limit(
+        self,
+        rate_key,
+        method,
+        trace_request_id,
+        jsonrpc_id=None,
+        tool_name='',
+        *,
+        log_identity=None,
+        diagnostic_emitter=None,
+    ):
+        """Returns an error response if rate limit exceeded, else None.
+
+        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):
+            if diagnostic_emitter is not None:
+                diagnostic_emitter.emit(
+                    stage='rate_limit',
+                    status='failed',
+                    event_code='RATE_LIMIT_EXCEEDED',
+                    tool_code=tool_name or None,
+                    context={
+                        'limit_type': 'request_window',
+                        'transport': 'http',
+                    },
+                )
+            logger.warning(
+                "MCP public rate limit exceeded",
+                extra={
+                    'request_id': trace_request_id,
+                    'jsonrpc_id': jsonrpc_id,
+                    'protocol_method': method,
+                    'tool_code': tool_name,
+                    'client_identity': log_identity or rate_key,
+                    'protocol_code': -32029,
+                    'diagnostic_reason': 'RATE_LIMIT_EXCEEDED',
+                },
+            )
+            return McpProtocolHandler._error_response(
+                jsonrpc_id,
+                -32029,
+                'Rate limit exceeded. Please try again later.',
+                trace_request_id,
+            )
+        return None
+
+    def handle_json_rpc(
+        self,
+        headers,
+        message,
+        client_ip='',
+        trace_request_id='',
+        diagnostic_emitter=None,
+    ):
         request_id = message.get('id') if isinstance(message, dict) else None
+        trace_request_id = str(trace_request_id or '').strip() \
+            or self._build_trace_request_id(headers)
         method = str((message or {}).get('method') or '').strip()
-
-        # Rate limiting by IP
-        if self.rate_limiter and client_ip:
-            if not self.rate_limiter.is_allowed(client_ip):
-                logger.warning(f"[RATE_LIMIT] IP rate limit exceeded: ip={client_ip}, method={method}")
-                return McpProtocolHandler._error_response(request_id, -32000, 'Rate limit exceeded. Please try again later.')
+        message_params = (message or {}).get('params') or {}
+        tool_name = str(message_params.get('name') or '').strip() \
+            if isinstance(message_params, dict) else ''
+        emitter = diagnostic_emitter or RequestDiagnosticEmitter(
+            self.reporter,
+            trace_request_id,
+            defer_until_identity=True,
+        )
+        emitter.emit(
+            stage='request_ingress',
+            status='started',
+            event_code='REQUEST_RECEIVED',
+            context={'jsonrpc_method': method or 'unknown', 'transport': 'http'},
+        )
+        protocol_validated = False
 
         try:
             if method == 'initialize':
+                emitter.emit(
+                    stage='protocol_validation',
+                    status='succeeded',
+                    event_code='PROTOCOL_VALIDATION_COMPLETED',
+                    context={
+                        'jsonrpc_method': method,
+                        'transport': 'http',
+                    },
+                )
+                protocol_validated = True
+                # initialize is a stateless handshake that only returns server metadata;
+                # rate-limiting it would block clients from connecting at all, so we skip it.
                 return McpProtocolHandler._success_response(request_id, {
                     'protocolVersion': McpProtocolHandler.protocol_version,
                     'capabilities': {'tools': {'listChanged': False}},
@@ -43,42 +152,279 @@ class PublicMcpHttpHandler:
                     },
                 })
             if method == 'tools/list':
+                emitter.emit(
+                    stage='protocol_validation',
+                    status='succeeded',
+                    event_code='PROTOCOL_VALIDATION_COMPLETED',
+                    context={
+                        'jsonrpc_method': method,
+                        'transport': 'http',
+                    },
+                )
+                protocol_validated = True
+                context = self.context_parser.parse(headers or {})
+                if not context.has_session():
+                    emitter.emit(
+                        stage='gateway_session',
+                        status='failed',
+                        event_code='GATEWAY_SESSION_NOT_FOUND',
+                        context={'transport': 'http'},
+                    )
+                    logger.warning(
+                        'MCP public device session unavailable',
+                        extra={
+                            'request_id': trace_request_id,
+                            'jsonrpc_id': request_id,
+                            'protocol_method': method,
+                            'tool_code': '',
+                            'protocol_code': -32001,
+                            'diagnostic_reason': 'GATEWAY_SESSION_NOT_FOUND',
+                        },
+                    )
+                    return McpProtocolHandler._error_response(
+                        request_id,
+                        -32001,
+                        DEVICE_INVALID_MESSAGE,
+                        trace_request_id,
+                    )
                 tools = []
-                for tool in self.gateway_app.list_tools():
+                for tool in self.gateway_app.list_tools(
+                    context.gateway_session_id,
+                    request_id=trace_request_id,
+                ):
                     normalized = dict(tool)
                     if 'input_schema' in normalized:
                         normalized['inputSchema'] = normalized.pop('input_schema')
                     tools.append(normalized)
                 return McpProtocolHandler._success_response(request_id, {'tools': tools})
             if method == 'tools/call':
+                if not isinstance(message_params, dict):
+                    raise ValueError('tool parameters must be an object')
+                if not tool_name:
+                    emitter.emit(
+                        stage='protocol_validation',
+                        status='failed',
+                        event_code='PARAM_VALIDATION_FAILED',
+                        context={
+                            'jsonrpc_method': method,
+                            'jsonrpc_code': -32602,
+                            'transport': 'http',
+                        },
+                    )
+                    return McpProtocolHandler._error_response(
+                        request_id,
+                        -32602,
+                        'Invalid params',
+                        trace_request_id,
+                    )
+                emitter.emit(
+                    stage='protocol_validation',
+                    status='succeeded',
+                    event_code='PROTOCOL_VALIDATION_COMPLETED',
+                    tool_code=tool_name,
+                    context={
+                        'jsonrpc_method': method,
+                        'transport': 'http',
+                    },
+                )
+                protocol_validated = True
+                # Parse context first — tools/call always requires a valid session
                 context = self.context_parser.parse(headers or {})
                 if not context.has_session():
-                    raise RuntimeError(DEVICE_INVALID_MESSAGE)
-                params = message.get('params') or {}
-                result = self.gateway_app.call_tool(
-                    context.gateway_session_id,
-                    params.get('name'),
-                    params.get('arguments') or {},
-                    request_id='rq_http_{0}'.format(request_id),
+                    emitter.emit(
+                        stage='gateway_session',
+                        status='failed',
+                        event_code='GATEWAY_SESSION_NOT_FOUND',
+                        context={'transport': 'http'},
+                    )
+                    logger.warning(
+                        'MCP public device session unavailable',
+                        extra={
+                            'request_id': trace_request_id,
+                            'jsonrpc_id': request_id,
+                            'protocol_method': method,
+                            'tool_code': tool_name,
+                            'protocol_code': -32001,
+                            'diagnostic_reason': 'GATEWAY_SESSION_NOT_FOUND',
+                        },
+                    )
+                    return McpProtocolHandler._error_response(
+                        request_id,
+                        -32001,
+                        DEVICE_INVALID_MESSAGE,
+                        trace_request_id,
+                    )
+                # Session-based rate limiting: each employee gets an independent quota per tool.
+                # Unknown tool names fall back to the bare session bucket to prevent key explosion.
+                session_id = context.gateway_session_id
+                rate_key = '{0}:{1}'.format(session_id, tool_name) if tool_name in self._known_tools else session_id
+                blocked = self._check_rate_limit(
+                    rate_key,
+                    method,
+                    trace_request_id,
+                    request_id,
+                    tool_name,
+                    log_identity=client_ip,
+                    diagnostic_emitter=emitter,
                 )
-                structured_content = result.get('data') or {}
-                return McpProtocolHandler._success_response(request_id, {
-                    'content': [{'type': 'text', 'text': McpProtocolHandler._render_text(structured_content)}],
-                    'structuredContent': structured_content,
-                    'isError': False,
-                })
-            return McpProtocolHandler._error_response(request_id, -32601, 'Method not found: {0}'.format(method))
+                if blocked:
+                    return blocked
+                params = message_params
+                acquired = self.rate_limiter is None \
+                    or self.rate_limiter.try_acquire(rate_key)
+                if not acquired:
+                    emitter.emit(
+                        stage='rate_limit',
+                        status='failed',
+                        event_code='CONCURRENCY_LIMIT_EXCEEDED',
+                        tool_code=tool_name or None,
+                        context={
+                            'limit_type': 'concurrency',
+                            'transport': 'http',
+                        },
+                    )
+                    logger.warning(
+                        'MCP public concurrency limit exceeded',
+                        extra={
+                            'request_id': trace_request_id,
+                            'jsonrpc_id': request_id,
+                            'protocol_method': method,
+                            'tool_code': tool_name,
+                            'client_identity': client_ip,
+                            'protocol_code': -32029,
+                            'diagnostic_reason': 'CONCURRENCY_LIMIT_EXCEEDED',
+                        },
+                    )
+                    return McpProtocolHandler._error_response(
+                        request_id,
+                        -32029,
+                        'Too many requests in progress. Please try again later.',
+                        trace_request_id,
+                    )
+                emitter.emit(
+                    stage='rate_limit',
+                    status='succeeded',
+                    event_code='RATE_LIMIT_ALLOWED',
+                    tool_code=tool_name or None,
+                    context={
+                        'limit_type': 'tools_call',
+                        'transport': 'http',
+                    },
+                )
+                try:
+                    result = self.gateway_app.call_tool(
+                        session_id,
+                        params.get('name'),
+                        params.get('arguments') or {},
+                        request_id=trace_request_id,
+                        client_ip=client_ip,
+                        diagnostic_emitter=emitter,
+                    )
+                finally:
+                    if self.rate_limiter is not None:
+                        self.rate_limiter.release(rate_key)
+                try:
+                    response = McpProtocolHandler._tool_call_response(
+                        request_id,
+                        tool_name,
+                        result,
+                    )
+                except Exception:
+                    emitter.emit(
+                        stage='response_safety',
+                        status='failed',
+                        event_code='RESPONSE_SAFETY_REJECTED',
+                        context={'transport': 'http'},
+                    )
+                    raise
+                emitter.emit(
+                    stage='response_safety',
+                    status='succeeded',
+                    event_code='RESPONSE_SAFETY_COMPLETED',
+                    context={'transport': 'http'},
+                )
+                return response
+            emitter.emit(
+                stage='protocol_validation',
+                status='failed',
+                event_code='METHOD_NOT_FOUND',
+                context={
+                    'jsonrpc_method': method or 'unknown',
+                    'jsonrpc_code': -32601,
+                    'transport': 'http',
+                },
+            )
+            return McpProtocolHandler._error_response(
+                request_id,
+                -32601,
+                'Method not found: {0}'.format(method),
+                trace_request_id,
+            )
         except Exception as exc:
+            if not protocol_validated:
+                emitter.emit(
+                    stage='protocol_validation',
+                    status='failed',
+                    event_code='PARAM_VALIDATION_FAILED',
+                    tool_code=tool_name or None,
+                    context={
+                        'jsonrpc_method': method or 'unknown',
+                        'jsonrpc_code': -32602,
+                        'transport': 'http',
+                    },
+                )
             if method == 'tools/call':
-                return McpProtocolHandler._success_response(request_id, {
-                    'content': [{'type': 'text', 'text': str(exc)}],
-                    'isError': True,
-                })
-            return McpProtocolHandler._error_response(request_id, -32000, str(exc))
+                diagnostic_reason = (
+                    'PARAM_VALIDATION_FAILED'
+                    if isinstance(exc, ValueError)
+                    else 'UNEXPECTED_EXCEPTION'
+                )
+                logger.error(
+                    'MCP public tool request failed',
+                    extra={
+                        'request_id': trace_request_id,
+                        'jsonrpc_id': request_id,
+                        'protocol_method': method,
+                        'tool_code': tool_name,
+                        'response_code': 'MCP_9001',
+                        'diagnostic_reason': diagnostic_reason,
+                        'exception_class': exc.__class__.__name__,
+                    },
+                )
+                return McpProtocolHandler._tool_exception_response(
+                    request_id,
+                    tool_name,
+                    exc,
+                    trace_request_id,
+                )
+            logger.error(
+                "MCP public request failed",
+                extra={
+                    'request_id': trace_request_id,
+                    'jsonrpc_id': request_id,
+                    'protocol_method': method,
+                    'tool_code': tool_name,
+                    'protocol_code': -32000,
+                    'diagnostic_reason': 'UNEXPECTED_EXCEPTION',
+                    'exception_class': exc.__class__.__name__,
+                },
+            )
+            return McpProtocolHandler._error_response(
+                request_id,
+                -32000,
+                'Gateway request failed. Please try again later.',
+                trace_request_id,
+            )
+        finally:
+            emitter.flush()
 
 
-def create_http_handler(gateway_app, rate_limiter=None):
-    rpc_handler = PublicMcpHttpHandler(gateway_app, rate_limiter=rate_limiter)
+def create_http_handler(gateway_app, rate_limiter=None, reporter=None):
+    rpc_handler = PublicMcpHttpHandler(
+        gateway_app,
+        rate_limiter=rate_limiter,
+        reporter=reporter,
+    )
 
     class Handler(BaseHTTPRequestHandler):
         def log_message(self, format, *args):
@@ -93,33 +439,137 @@ def create_http_handler(gateway_app, rate_limiter=None):
             self.end_headers()
 
         def do_POST(self):
-            client_ip = extract_client_ip(dict(self.headers.items()), self.client_address)
+            request_headers = dict(self.headers.items())
+            client_ip = extract_client_ip(request_headers, self.client_address)
+            trace_request_id = rpc_handler._build_trace_request_id(request_headers)
+            diagnostic_emitter = RequestDiagnosticEmitter(
+                rpc_handler.reporter,
+                trace_request_id,
+                defer_until_identity=True,
+            )
+            client_request_id_hash = rpc_handler._client_request_id(
+                request_headers
+            )
+            length = int(self.headers.get('Content-Length') or '0')
             if self.path != '/mcp':
                 logger.warning(f"[HTTP] 404: ip={client_ip}, path={self.path}")
+                if length > 0:
+                    self.rfile.read(length)
                 self.send_response(404)
                 self.end_headers()
                 return
-            length = int(self.headers.get('Content-Length') or '0')
             body = self.rfile.read(length).decode('utf-8-sig')
-            message = json.loads(body)
+            try:
+                message = json.loads(body)
+            except json.JSONDecodeError as exc:
+                diagnostic_emitter.emit(
+                    stage='request_ingress',
+                    status='started',
+                    event_code='REQUEST_RECEIVED',
+                    context={
+                        'jsonrpc_method': 'unknown',
+                        'transport': 'http',
+                    },
+                )
+                diagnostic_emitter.emit(
+                    stage='protocol_validation',
+                    status='failed',
+                    event_code='PARAM_VALIDATION_FAILED',
+                    context={
+                        'jsonrpc_code': -32700,
+                        'transport': 'http',
+                    },
+                )
+                logger.warning(
+                    'MCP public invalid JSON',
+                    extra={
+                        'request_id': trace_request_id,
+                        'jsonrpc_id': None,
+                        'protocol_method': '',
+                        'tool_code': '',
+                        'protocol_code': -32700,
+                        'diagnostic_reason': 'PARAM_VALIDATION_FAILED',
+                        'exception_class': exc.__class__.__name__,
+                        'client_identity': client_ip,
+                    },
+                )
+                self._write_json(
+                    McpProtocolHandler._error_response(
+                        None,
+                        -32700,
+                        'Parse error',
+                        trace_request_id,
+                    ),
+                    diagnostic_emitter=diagnostic_emitter,
+                )
+                return
             method = message.get('method', '')
-            request_id = message.get('id', '')
-            logger.info(f"[HTTP] request: ip={client_ip}, method={method}, id={request_id}")
-            response = rpc_handler.handle_json_rpc(dict(self.headers.items()), message, client_ip)
-            self._write_json(response)
+            request_id = message.get('id') if message.get('id') is not None else ''
+            logger.info(
+                'MCP public request',
+                extra={
+                    'request_id': trace_request_id,
+                    'jsonrpc_id': request_id,
+                    'protocol_method': method,
+                    'client_identity': client_ip,
+                    'client_request_id_hash': client_request_id_hash,
+                },
+            )
+            response = rpc_handler.handle_json_rpc(
+                request_headers,
+                message,
+                client_ip,
+                trace_request_id=trace_request_id,
+                diagnostic_emitter=diagnostic_emitter,
+            )
+            self._write_json(
+                response,
+                diagnostic_emitter=diagnostic_emitter,
+            )
 
-        def _write_json(self, payload):
+        def _write_json(self, payload, diagnostic_emitter=None):
             raw = json.dumps(payload, ensure_ascii=False).encode('utf-8')
-            self.send_response(200)
-            self.send_header('Content-Type', 'application/json; charset=utf-8')
-            self.send_header('Content-Length', str(len(raw)))
-            self.end_headers()
-            self.wfile.write(raw)
+            try:
+                self.send_response(200)
+                self.send_header('Content-Type', 'application/json; charset=utf-8')
+                self.send_header('Content-Length', str(len(raw)))
+                self.end_headers()
+                self.wfile.write(raw)
+                if diagnostic_emitter is not None:
+                    diagnostic_emitter.emit(
+                        stage='response_write',
+                        status='succeeded',
+                        event_code='RESPONSE_WRITE_COMPLETED',
+                        context={
+                            'http_status': 200,
+                            'client_disconnected': False,
+                            'transport': 'http',
+                        },
+                    )
+            except (BrokenPipeError, ConnectionResetError):
+                self.close_connection = True
+                if diagnostic_emitter is not None:
+                    diagnostic_emitter.emit(
+                        stage='response_write',
+                        status='failed',
+                        event_code='CLIENT_DISCONNECTED',
+                        context={
+                            'http_status': 200,
+                            'client_disconnected': True,
+                            'transport': 'http',
+                        },
+                    )
+                logger.info('MCP client disconnected before response was written')
+            finally:
+                if diagnostic_emitter is not None:
+                    diagnostic_emitter.flush()
 
     return Handler
 
 
-def serve_public(gateway_app, host='0.0.0.0', port=8765, enable_rate_limit=True):
+def serve_public(gateway_app, host='0.0.0.0', port=8765, enable_rate_limit=True,
+                 rate_limit_max_requests=60, rate_limit_window_seconds=60,
+                 max_in_flight_per_tool=2, reporter=None):
     # Configure logging
     logging.basicConfig(
         level=logging.INFO,
@@ -127,18 +577,34 @@ def serve_public(gateway_app, host='0.0.0.0', port=8765, enable_rate_limit=True)
         datefmt='%Y-%m-%d %H:%M:%S'
     )
 
-    # Configure rate limiting (default: 60 requests per minute per IP)
+    # Configure rate limiting
     rate_limiter = None
     if enable_rate_limit:
-        rate_limiter = SimpleRateLimiter(max_requests=60, window_seconds=60)
-        logger.info("Rate limiting enabled: 60 requests/minute per IP")
+        rate_limiter = SimpleRateLimiter(
+            max_requests=rate_limit_max_requests,
+            window_seconds=rate_limit_window_seconds,
+            max_in_flight=max_in_flight_per_tool,
+        )
+        logger.info(f"Rate limiting enabled: {rate_limit_max_requests} requests/{rate_limit_window_seconds}s and {max_in_flight_per_tool} in-flight per session and tool (tools/call only)")
 
     logger.info(f"Starting public MCP Gateway on {host}:{port}")
-    server = ThreadingHTTPServer((host, int(port)), create_http_handler(gateway_app, rate_limiter))
+    server = ThreadingHTTPServer(
+        (host, int(port)),
+        create_http_handler(gateway_app, rate_limiter, reporter=reporter),
+    )
+
+    # Schedule periodic cleanup to prevent unbounded memory growth in the rate limiter
+    if rate_limiter is not None:
+        def _cleanup_loop():
+            while True:
+                time.sleep(300)
+                # Use the actual window as max_age to avoid deleting entries still within the window
+                rate_limiter.cleanup(max_age_seconds=rate_limit_window_seconds)
+        t = threading.Thread(target=_cleanup_loop, daemon=True)
+        t.start()
+
     try:
         server.serve_forever()
     except KeyboardInterrupt:
         logger.info("Shutting down public MCP Gateway")
         server.shutdown()
-
-

+ 9 - 0
services/api_client.py

@@ -30,3 +30,12 @@ class ApiClient:
             'X-Request-Id': request_id,
         }
         return self.transport.post_json(url, payload, headers, self.timeout)
+
+    def list_enabled_tools(self, request_id=''):
+        token = self.token_store.require_token()
+        url = self.base_url + '/mcp/tools/listEnabledTools'
+        headers = {
+            'Authorization': 'Bearer {0}'.format(token),
+            'X-Request-Id': str(request_id or '').strip(),
+        }
+        return self.transport.post_json(url, {}, headers, self.timeout)

+ 206 - 0
services/diagnostic_event.py

@@ -0,0 +1,206 @@
+import hashlib
+import re
+import uuid
+from datetime import datetime, timezone
+
+
+_STAGE_CONTEXT_FIELDS = {
+    'request_ingress': frozenset(('jsonrpc_method', 'http_status', 'transport')),
+    'protocol_validation': frozenset(('jsonrpc_method', 'jsonrpc_code', 'transport')),
+    'gateway_session': frozenset(('transport',)),
+    'rate_limit': frozenset(('limit_type', 'transport')),
+    'backend_call': frozenset(('http_status', 'transport')),
+    'response_safety': frozenset(('transport',)),
+    'response_write': frozenset(('http_status', 'client_disconnected', 'transport')),
+}
+_STATUSES = frozenset(('started', 'succeeded', 'failed', 'skipped'))
+_CODE_PATTERN = re.compile(r'^[A-Z][A-Z0-9_]*$')
+
+
+def _utc_timestamp():
+    return datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace(
+        '+00:00', 'Z'
+    )
+
+
+def _optional_positive_integer(event, name, value):
+    if value is None:
+        return
+    if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
+        raise ValueError('{0} must be a positive integer'.format(name))
+    event[name] = value
+
+
+def _optional_code(event, name, value, max_length):
+    if value is None or value == '':
+        return
+    if (
+        not isinstance(value, str)
+        or len(value) > max_length
+        or _CODE_PATTERN.fullmatch(value) is None
+    ):
+        raise ValueError('{0} is invalid'.format(name))
+    event[name] = value
+
+
+def _validated_context(stage, context):
+    context = {} if context is None else context
+    if not isinstance(context, dict):
+        raise ValueError('context must be an object')
+    unknown = set(context) - _STAGE_CONTEXT_FIELDS[stage]
+    if unknown:
+        raise ValueError('context contains unsupported fields')
+
+    result = {}
+    for key, value in context.items():
+        if key == 'http_status':
+            if isinstance(value, bool) or not isinstance(value, int) or not 100 <= value <= 599:
+                raise ValueError('http_status is invalid')
+        elif key == 'jsonrpc_code':
+            if isinstance(value, bool) or not isinstance(value, int):
+                raise ValueError('jsonrpc_code is invalid')
+        elif key == 'client_disconnected':
+            if not isinstance(value, bool):
+                raise ValueError('client_disconnected is invalid')
+        elif not isinstance(value, str) or not value or len(value) > 64:
+            raise ValueError('{0} is invalid'.format(key))
+        result[key] = value
+    return result
+
+
+def build_diagnostic_event(
+    request_id,
+    stage,
+    status,
+    event_code,
+    occurred_at=None,
+    session_credential='',
+    company_id=None,
+    admin_id=None,
+    tool_code=None,
+    response_code=None,
+    cost_ms=None,
+    summary_code=None,
+    context=None,
+):
+    if not isinstance(request_id, str) or not re.fullmatch(
+        r'rq_[A-Za-z0-9._:-]{1,97}', request_id
+    ):
+        raise ValueError('request_id is invalid')
+    if stage not in _STAGE_CONTEXT_FIELDS:
+        raise ValueError('stage is invalid')
+    if status not in _STATUSES:
+        raise ValueError('status is invalid')
+    if (
+        not isinstance(event_code, str)
+        or len(event_code) > 64
+        or _CODE_PATTERN.fullmatch(event_code) is None
+    ):
+        raise ValueError('event_code is invalid')
+
+    event = {
+        'schema_version': 1,
+        'event_id': 'evt_gateway_{0}'.format(uuid.uuid4().hex),
+        'request_id': request_id,
+        'source': 'gateway',
+        'stage': stage,
+        'status': status,
+        'event_code': event_code,
+        'occurred_at': occurred_at or _utc_timestamp(),
+    }
+    _optional_positive_integer(event, 'company_id', company_id)
+    _optional_positive_integer(event, 'admin_id', admin_id)
+    if tool_code is not None and tool_code != '':
+        if not isinstance(tool_code, str) or re.fullmatch(r'[a-z][a-z0-9_]{0,63}', tool_code) is None:
+            raise ValueError('tool_code is invalid')
+        event['tool_code'] = tool_code
+    if session_credential:
+        if not isinstance(session_credential, str):
+            raise ValueError('session_credential must be text')
+        event['session_hash'] = hashlib.sha256(
+            session_credential.encode('utf-8')
+        ).hexdigest()[:12]
+    _optional_code(event, 'response_code', response_code, 32)
+    _optional_code(event, 'summary_code', summary_code, 64)
+    if cost_ms is not None:
+        if (
+            isinstance(cost_ms, bool)
+            or not isinstance(cost_ms, int)
+            or not 0 <= cost_ms <= 3600000
+        ):
+            raise ValueError('cost_ms is invalid')
+        event['cost_ms'] = cost_ms
+    validated_context = _validated_context(stage, context)
+    if validated_context:
+        event['context'] = validated_context
+    return event
+
+
+class RequestDiagnosticEmitter:
+    MAX_EVENTS = 20
+
+    def __init__(
+        self,
+        reporter,
+        request_id,
+        defer_until_identity=False,
+        **event_defaults
+    ):
+        self.reporter = reporter
+        self.request_id = request_id
+        self.event_defaults = event_defaults
+        self.defer_until_identity = bool(defer_until_identity)
+        self._deferred = []
+        self.emitted_count = 0
+        self.dropped_count = 0
+
+    def set_defaults(self, **event_defaults):
+        self.event_defaults.update(event_defaults)
+        company_id = self.event_defaults.get('company_id')
+        if (
+            self.defer_until_identity
+            and isinstance(company_id, int)
+            and not isinstance(company_id, bool)
+            and company_id > 0
+        ):
+            self.flush()
+
+    def emit(self, **fields):
+        if self.emitted_count >= self.MAX_EVENTS:
+            self.dropped_count += 1
+            return False
+        event_fields = dict(self.event_defaults)
+        event_fields.update(fields)
+        event_fields['request_id'] = self.request_id
+        self.emitted_count += 1
+        if (
+            self.defer_until_identity
+            and not event_fields.get('company_id')
+        ):
+            self._deferred.append(event_fields)
+            return True
+        return self._deliver(event_fields)
+
+    def flush(self):
+        deferred = self._deferred
+        self._deferred = []
+        accepted = True
+        for fields in deferred:
+            enriched = dict(fields)
+            for name in ('company_id', 'admin_id', 'session_credential'):
+                if name in self.event_defaults:
+                    enriched[name] = self.event_defaults[name]
+            if not self._deliver(enriched):
+                accepted = False
+        return accepted
+
+    def _deliver(self, event_fields):
+        try:
+            event = build_diagnostic_event(**event_fields)
+            accepted = self.reporter.report(event)
+        except Exception:
+            self.dropped_count += 1
+            return False
+        if not accepted:
+            self.dropped_count += 1
+        return accepted

+ 217 - 0
services/diagnostic_reporter.py

@@ -0,0 +1,217 @@
+import hashlib
+import hmac
+import json
+import logging
+import queue
+import threading
+import time
+import urllib.request
+from urllib.parse import urlsplit
+import uuid
+
+
+logger = logging.getLogger(__name__)
+
+
+def diagnostic_reporter_from_config(config, **overrides):
+    if getattr(config, 'diagnosis_enabled', False) is not True:
+        return NullDiagnosticReporter()
+    url = str(getattr(config, 'diagnosis_url', '') or '').strip()
+    key_id = str(getattr(config, 'diagnosis_key_id', '') or '').strip()
+    secret = str(getattr(config, 'diagnosis_secret', '') or '')
+    allow_insecure_http = getattr(
+        config, 'diagnosis_allow_insecure_http', False
+    ) is True
+    parsed_url = urlsplit(url)
+    if (
+        (
+            parsed_url.scheme != 'https'
+            and not (parsed_url.scheme == 'http' and allow_insecure_http)
+        )
+        or not parsed_url.netloc
+        or not key_id
+        or len(secret) < 32
+    ):
+        logger.warning('MCP diagnostic reporter configuration is invalid')
+        return NullDiagnosticReporter()
+    options = {
+        'url': url,
+        'key_id': key_id,
+        'secret': secret,
+        'queue_size': getattr(config, 'diagnosis_queue_size', 1000),
+        'batch_size': getattr(config, 'diagnosis_batch_size', 100),
+        'timeout_seconds': getattr(config, 'diagnosis_timeout_seconds', 0.5),
+        'initial_backoff': getattr(
+            config, 'diagnosis_initial_backoff_seconds', 0.25
+        ),
+        'max_backoff': getattr(
+            config, 'diagnosis_max_backoff_seconds', 5.0
+        ),
+    }
+    options.update(overrides)
+    reporter = DiagnosticReporter(**options)
+    if reporter.start():
+        return reporter
+    reporter.close()
+    return NullDiagnosticReporter()
+
+
+def _http_transport(url, body, headers, timeout):
+    request = urllib.request.Request(url, data=body, headers=headers, method='POST')
+    with urllib.request.urlopen(request, timeout=timeout) as response:
+        return response.getcode(), response.read()
+
+
+class NullDiagnosticReporter:
+    drop_count = 0
+    pending_count = 0
+
+    def start(self):
+        return True
+
+    def report(self, _event):
+        return True
+
+    def close(self):
+        return None
+
+
+class DiagnosticReporter:
+    def __init__(
+        self,
+        url,
+        key_id,
+        secret,
+        queue_size=1000,
+        batch_size=100,
+        timeout_seconds=0.5,
+        initial_backoff=0.25,
+        max_backoff=5.0,
+        transport=None,
+        clock=None,
+        nonce_factory=None,
+        sleeper=None,
+        thread_factory=None,
+    ):
+        self.url = str(url)
+        self.key_id = str(key_id)
+        self.secret = str(secret)
+        self.batch_size = max(1, min(100, int(batch_size)))
+        self.timeout_seconds = float(timeout_seconds)
+        self.initial_backoff = max(0.0, float(initial_backoff))
+        self.max_backoff = max(self.initial_backoff, float(max_backoff))
+        self.transport = transport or _http_transport
+        self.clock = clock or time.time
+        self.nonce_factory = nonce_factory or (lambda: uuid.uuid4().hex)
+        self.sleeper = sleeper or time.sleep
+        self.thread_factory = thread_factory or threading.Thread
+        self._queue = queue.Queue(maxsize=max(1, int(queue_size)))
+        self._pending = []
+        self._backoff = self.initial_backoff
+        self._stop = threading.Event()
+        self._thread = None
+        self.drop_count = 0
+
+    @property
+    def pending_count(self):
+        return len(self._pending)
+
+    def report(self, event):
+        try:
+            self._queue.put_nowait(event)
+            return True
+        except queue.Full:
+            self.drop_count += 1
+            return False
+
+    def start(self):
+        if self._thread is not None:
+            return True
+        try:
+            thread = self.thread_factory(target=self._run, daemon=True)
+            thread.start()
+            self._thread = thread
+            return True
+        except Exception:
+            logger.warning('MCP diagnostic reporter thread unavailable')
+            return False
+
+    def close(self):
+        self._stop.set()
+        thread = self._thread
+        if thread is not None and thread.is_alive():
+            thread.join(timeout=self.timeout_seconds)
+
+    def process_once(self):
+        if not self._pending:
+            self._pending = self._take_batch()
+        if not self._pending:
+            return True
+
+        body = json.dumps(
+            {'events': self._pending},
+            ensure_ascii=False,
+            separators=(',', ':'),
+        ).encode('utf-8')
+        headers = self._signed_headers(body)
+        try:
+            status, response_body = self.transport(
+                self.url,
+                body,
+                headers,
+                self.timeout_seconds,
+            )
+            payload = json.loads(response_body.decode('utf-8'))
+            if status < 200 or status >= 300 or payload.get('code') != 'MCP_DIAG_INGEST_0000':
+                raise ValueError('collector rejected batch')
+        except Exception:
+            delay = self._backoff
+            self._backoff = min(
+                self.max_backoff,
+                max(self.initial_backoff, self._backoff * 2),
+            )
+            self.sleeper(delay)
+            return False
+
+        self._pending = []
+        self._backoff = self.initial_backoff
+        return True
+
+    def _take_batch(self):
+        events = []
+        while len(events) < self.batch_size:
+            try:
+                events.append(self._queue.get_nowait())
+            except queue.Empty:
+                break
+        return events
+
+    def _signed_headers(self, body):
+        timestamp = str(int(self.clock()))
+        nonce = str(self.nonce_factory())
+        path = urlsplit(self.url).path or '/'
+        canonical = 'POST\n{0}\n{1}\n{2}\n{3}'.format(
+            path,
+            timestamp,
+            nonce,
+            hashlib.sha256(body).hexdigest(),
+        )
+        signature = hmac.new(
+            self.secret.encode('utf-8'),
+            canonical.encode('utf-8'),
+            hashlib.sha256,
+        ).hexdigest()
+        return {
+            'Content-Type': 'application/json',
+            'X-MCP-Source': 'gateway',
+            'X-MCP-Timestamp': timestamp,
+            'X-MCP-Nonce': nonce,
+            'X-MCP-Key-Id': self.key_id,
+            'X-MCP-Signature': signature,
+        }
+
+    def _run(self):
+        while not self._stop.is_set():
+            if not self.process_once():
+                continue
+            self._stop.wait(0.1)

文件差异内容过多而无法显示
+ 1149 - 0
services/output_presenter.py


+ 16 - 2
services/scoped_api_client.py

@@ -1,4 +1,4 @@
-from services.api_client import JsonTransport
+from services.api_client import JsonTransport
 
 
 class ScopedApiClient:
@@ -7,7 +7,7 @@ class ScopedApiClient:
         self.transport = transport or JsonTransport()
         self.timeout = int(timeout)
 
-    def call_tool(self, token, tool_code, route_path, payload, request_id):
+    def call_tool(self, token, tool_code, route_path, payload, request_id, client_ip=''):
         token = str(token or '').strip()
         if not token:
             raise RuntimeError('mcp token missing')
@@ -17,4 +17,18 @@ class ScopedApiClient:
             'X-MCP-Tool-Code': tool_code,
             'X-Request-Id': request_id,
         }
+        client_ip = str(client_ip or '').strip()
+        if client_ip:
+            headers['X-MCP-Client-IP'] = client_ip
         return self.transport.post_json(url, payload, headers, self.timeout)
+
+    def list_enabled_tools(self, token, request_id=''):
+        token = str(token or '').strip()
+        if not token:
+            raise RuntimeError('mcp token missing')
+        url = self.base_url + '/mcp/tools/listEnabledTools'
+        headers = {
+            'Authorization': 'Bearer {0}'.format(token),
+            'X-Request-Id': str(request_id or '').strip(),
+        }
+        return self.transport.post_json(url, {}, headers, self.timeout)

+ 3 - 0
tests/TEST_README.md

@@ -27,3 +27,6 @@ python -m unittest discover -s tests -p "test_*.py" -v
 - `test_request_context.py`: session extraction from headers, bearer token, and cookies.
 - `test_auth_client.py`: token refresh/revoke only.
 - `test_gateway_runtime.py`: local runtime tool registration and token refresh.
+- `test_output_presenter.py`: safe table/filter/export DTOs, error hiding, and cross-tool value reuse.
+- `test_mcp_protocol.py`: stdio response shape plus unchanged `query_order` compatibility.
+- `test_public_server.py`: public response parity and malformed-input safety.

+ 0 - 26
tests/TEST_REPORT.md

@@ -1,26 +0,0 @@
-# MCP Gateway Test Report
-
-Updated: 2026-07-08
-
-## Result
-
-The current MCP Gateway test suite targets the direct Gateway session flow.
-
-- Public Workbuddy access uses `GWS_xxx` Gateway sessions.
-- The legacy authorization-code binding path has been retired.
-- Public and stdio tool lists expose only query tools such as `query_order` and `query_track`.
-- `AuthClient` keeps only token refresh/revoke behavior.
-
-## 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.

+ 474 - 0
tests/test_app_coverage.py

@@ -0,0 +1,474 @@
+"""
+Coverage补充测试:app.py
+
+目标路径:
+- GatewayApp.from_config — token_store_type 不支持 → ValueError
+- GatewayApp.run_cli call — else 分支(tool 既非 query_order 也非 query_track,有 keyword)
+- GatewayApp.run_cli call — else 分支(tool 既非 query_order 也非 query_track,无 keyword)
+- main() — 正常调用 list-tools
+"""
+import io
+import json
+import os
+import tempfile
+import unittest
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+from app import GatewayApp, main
+from services.token_store import FileTokenStore
+from tools.query_order import QueryOrderTool
+
+
+# ---------------------------------------------------------------------------
+# GatewayApp.from_config — 不支持的 store type
+# ---------------------------------------------------------------------------
+
+class FromConfigUnsupportedStoreTest(unittest.TestCase):
+    def test_raises_value_error_for_unknown_store_type(self):
+        config = MagicMock()
+        config.token_store_type = 'memcached'
+        with self.assertRaises(ValueError) as ctx:
+            GatewayApp.from_config(config)
+        self.assertIn('unsupported token store type', str(ctx.exception))
+        self.assertIn('memcached', str(ctx.exception))
+
+    def test_from_config_file_store(self):
+        """file store 分支:正常返回 GatewayApp 实例。"""
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            config = MagicMock()
+            config.token_store_type = 'file'
+            config.token_store_path = os.path.join(tmp_dir, 'tok.json')
+            config.refresh_skew_seconds = 120
+            config.auth_base_url = 'http://auth.test'
+            config.client_type = 'workbuddy'
+            config.timeout_seconds = 5
+            config.session_key = 'test_session'
+            config.tools_base_url = 'http://api.test'
+            app = GatewayApp.from_config(config)
+            self.assertIsInstance(app, GatewayApp)
+
+    def test_from_config_redis_store_uses_provided_redis_client(self):
+        """redis store 分支:传入 redis_client 时跳过 RedisSocketClient 创建。"""
+        mock_redis = MagicMock()
+        config = MagicMock()
+        config.token_store_type = 'redis'
+        config.redis_prefix = 'test:'
+        config.session_key = 'sess_1'
+        config.refresh_skew_seconds = 120
+        config.auth_base_url = 'http://auth.test'
+        config.client_type = 'workbuddy'
+        config.timeout_seconds = 5
+        config.tools_base_url = 'http://api.test'
+        # get() 返回 None,避免 RedisTokenStore 在初始化时崩溃
+        mock_redis.get.return_value = None
+        app = GatewayApp.from_config(config, redis_client=mock_redis)
+        self.assertIsInstance(app, GatewayApp)
+
+
+# ---------------------------------------------------------------------------
+# GatewayApp.run_cli call — else 分支
+# ---------------------------------------------------------------------------
+
+class DummyApiClient:
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_order',
+                    'query_track',
+                    'query_order_exact',
+                    'list_order_filter_options',
+                    'fake_tool',
+                    'no_kw_tool',
+                ],
+            },
+        }
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        return {
+            'code': 'MCP_0000',
+            'data': {},
+            'meta': {'request_id': request_id},
+        }
+
+
+class DummyAuthClient:
+    def __init__(self, token_store):
+        self.token_store = token_store
+
+    def refresh(self, mcp_token):
+        pass
+
+
+class GatewayAppBoundaryTest(unittest.TestCase):
+    def test_protocol_handler_uses_gateway_reporter(self):
+        reporter = MagicMock()
+        app = GatewayApp(reporter=reporter)
+
+        self.assertIs(reporter, app.create_protocol_handler().reporter)
+
+    def test_registered_tool_names_returns_local_registry(self):
+        app = GatewayApp(api_client=DummyApiClient())
+
+        self.assertEqual(tuple(app._tools.keys()), app.registered_tool_names())
+
+    def test_enabled_tool_names_rejects_non_dict_response(self):
+        with self.assertRaisesRegex(RuntimeError, 'invalid enabled tool response'):
+            GatewayApp()._enabled_tool_names(None)
+
+    def test_enabled_tool_names_rejects_missing_tool_code_list(self):
+        response = {'code': 'MCP_0000', 'data': {}}
+
+        with self.assertRaisesRegex(RuntimeError, 'invalid enabled tool response'):
+            GatewayApp()._enabled_tool_names(response)
+
+    def test_load_enabled_tools_requires_capable_client(self):
+        for api_client in (None, object()):
+            with self.subTest(api_client=api_client):
+                with self.assertRaisesRegex(RuntimeError, 'client unavailable'):
+                    GatewayApp(api_client=api_client)._load_enabled_tool_names()
+
+    def test_build_request_id_preserves_provided_value(self):
+        self.assertEqual(
+            'rq_given',
+            GatewayApp().build_request_id(' rq_given '),
+        )
+
+    def test_ensure_session_requires_auth_client_for_expiring_token(self):
+        token_store = MagicMock()
+        token_store.get.return_value = {'token': 'MT_test'}
+        token_store.is_expiring.return_value = True
+
+        with self.assertRaisesRegex(RuntimeError, 'auth client missing'):
+            GatewayApp(token_store=token_store).ensure_session()
+
+
+def _make_app_with_token():
+    """创建有有效 token 的 GatewayApp,附带一个自定义 fake_tool 工具。"""
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        path = os.path.join(tmp_dir, 'token.json')
+        token_store = FileTokenStore(path)
+        token_store.save('MT_test', '2099-01-01T00:00:00')
+        api_client = DummyApiClient()
+        app = GatewayApp(
+            auth_client=DummyAuthClient(token_store),
+            api_client=api_client,
+            token_store=token_store,
+        )
+        # 注册一个假工具,使 else 分支可以正常 call_tool
+        app._tools['fake_tool'] = QueryOrderTool(api_client=api_client)
+        return app, tmp_dir
+
+
+class RunCliCallElseBranchTest(unittest.TestCase):
+    def test_call_else_branch_with_keyword_passes_keyword_to_tool(self):
+        """
+        --tool fake_tool(非 query_order / query_track)且 --keyword 有值 →
+        走 else 分支,tool_args 中加入 keyword。
+        """
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            path = os.path.join(tmp_dir, 'token.json')
+            token_store = FileTokenStore(path)
+            token_store.save('MT_test', '2099-01-01T00:00:00')
+            api_client = DummyApiClient()
+            app = GatewayApp(
+                auth_client=DummyAuthClient(token_store),
+                api_client=api_client,
+                token_store=token_store,
+            )
+            app._tools['fake_tool'] = QueryOrderTool(api_client=api_client)
+
+            stdout = io.StringIO()
+            code = app.run_cli(
+                ['call', '--tool', 'fake_tool', '--keyword', 'hello'],
+                stdout=stdout,
+            )
+            self.assertEqual(0, code)
+            payload = json.loads(stdout.getvalue())
+            self.assertEqual('MCP_0000', payload['code'])
+
+
+class RunCliBranchCoverageTest(unittest.TestCase):
+    def _run_with_mocked_call(self, argv):
+        app = GatewayApp(api_client=DummyApiClient())
+        stdout = io.StringIO()
+        with patch.object(
+            app,
+            'call_tool',
+            return_value={'code': 'MCP_0000'},
+        ) as call_tool:
+            result = app.run_cli(argv, stdout=stdout)
+        return result, call_tool
+
+    def test_query_order_requires_keyword(self):
+        with self.assertRaisesRegex(ValueError, 'keyword is required'):
+            GatewayApp().run_cli([
+                'call', '--tool', 'query_order',
+            ], stdout=io.StringIO())
+
+    def test_query_track_forwards_each_supported_identifier(self):
+        cases = (
+            (['--order-id', '5'], {'order_id': 5}),
+            (['--order-number', 'ORDER-1'], {'order_number': 'ORDER-1'}),
+            (['--tracking-number', 'TRACK-1'], {'tracking_number': 'TRACK-1'}),
+        )
+        for cli_args, expected in cases:
+            with self.subTest(cli_args=cli_args):
+                result, call_tool = self._run_with_mocked_call([
+                    'call', '--tool', 'query_track', *cli_args,
+                ])
+                self.assertEqual(0, result)
+                payload = call_tool.call_args.args[1]
+                for key, value in expected.items():
+                    self.assertEqual(value, payload[key])
+
+    def test_query_track_requires_one_identifier(self):
+        with self.assertRaisesRegex(ValueError, 'order-id'):
+            GatewayApp().run_cli([
+                'call', '--tool', 'query_track',
+            ], stdout=io.StringIO())
+
+    def test_query_order_exact_forwards_scalar_ids(self):
+        result, call_tool = self._run_with_mocked_call([
+            'call', '--tool', 'query_order_exact',
+            '--order-number', 'ORDER-1',
+            '--sales-id', '7',
+            '--department-id', '8',
+        ])
+
+        self.assertEqual(0, result)
+        payload = call_tool.call_args.args[1]
+        self.assertEqual(7, payload['sales_id'])
+        self.assertEqual(8, payload['department_id'])
+
+    def test_query_order_detail_requires_order_number_and_forwards_section(self):
+        with self.assertRaisesRegex(ValueError, 'order-number is required'):
+            GatewayApp().run_cli([
+                'call', '--tool', 'query_order_detail',
+            ], stdout=io.StringIO())
+
+        result, call_tool = self._run_with_mocked_call([
+            'call', '--tool', 'query_order_detail',
+            '--order-number', 'ORDER-1', '--section', '附件信息',
+            '--page', '2', '--limit', '10',
+        ])
+        self.assertEqual(0, result)
+        self.assertEqual({
+            'page': 2, 'limit': 10,
+            'order_number': 'ORDER-1', 'section': '附件信息',
+        }, call_tool.call_args.args[1])
+
+        result, call_tool = self._run_with_mocked_call([
+            'call', '--tool', 'query_order_detail', '--order-number', 'ORDER-1',
+        ])
+        self.assertEqual(0, result)
+        self.assertEqual('全部', call_tool.call_args.args[1]['section'])
+
+    def test_filter_options_requires_filter_type(self):
+        with self.assertRaisesRegex(ValueError, 'filter-type is required'):
+            GatewayApp().run_cli([
+                'call', '--tool', 'list_order_filter_options',
+            ], stdout=io.StringIO())
+
+    def test_serve_public_builds_scoped_dependencies(self):
+        config = SimpleNamespace(
+            redis_host='redis.test',
+            redis_port=6380,
+            redis_db=2,
+            redis_password='secret',
+            timeout_seconds=9,
+            redis_prefix='gateway:',
+            gateway_session_ttl_seconds=600,
+            tools_base_url='https://tools.test',
+            rate_limit_enabled=True,
+            rate_limit_max_requests=12,
+            rate_limit_window_seconds=34,
+            max_in_flight_per_tool=2,
+        )
+        with patch('app.GatewayConfig.from_env', return_value=config), \
+                patch('app.RedisSocketClient') as redis_cls, \
+                patch('app.GatewaySessionStore') as store_cls, \
+                patch('app.ScopedApiClient') as api_cls, \
+                patch('app.PublicGatewayApp') as public_app_cls, \
+                patch('app.diagnostic_reporter_from_config') as reporter_factory, \
+                patch('app.serve_public', return_value=17) as serve:
+            result = GatewayApp().run_cli([
+                'serve-public', '--host', '127.0.0.1', '--port', '9000',
+            ])
+
+        self.assertEqual(17, result)
+        redis_cls.assert_called_once_with(
+            host='redis.test',
+            port=6380,
+            db=2,
+            password='secret',
+            timeout=9,
+        )
+        store_cls.assert_called_once_with(
+            redis_cls.return_value,
+            prefix='gateway:',
+            ttl_seconds=600,
+        )
+        api_cls.assert_called_once_with('https://tools.test', timeout=9)
+        public_app_cls.assert_called_once_with(
+            session_store=store_cls.return_value,
+            api_client=api_cls.return_value,
+        )
+        serve.assert_called_once_with(
+            public_app_cls.return_value,
+            host='127.0.0.1',
+            port=9000,
+            enable_rate_limit=True,
+            rate_limit_max_requests=12,
+            rate_limit_window_seconds=34,
+            max_in_flight_per_tool=2,
+            reporter=reporter_factory.return_value,
+        )
+        reporter_factory.return_value.close.assert_called_once_with()
+
+    def test_serve_public_reuses_existing_non_null_reporter(self):
+        config = SimpleNamespace(
+            redis_host='redis.test',
+            redis_port=6379,
+            redis_db=0,
+            redis_password='',
+            timeout_seconds=1,
+            redis_prefix='gateway:',
+            gateway_session_ttl_seconds=600,
+            tools_base_url='https://tools.test',
+            rate_limit_enabled=False,
+            rate_limit_max_requests=0,
+            rate_limit_window_seconds=60,
+            max_in_flight_per_tool=1,
+        )
+        reporter = MagicMock()
+        with patch('app.GatewayConfig.from_env', return_value=config), \
+                patch('app.RedisSocketClient'), \
+                patch('app.GatewaySessionStore'), \
+                patch('app.ScopedApiClient'), \
+                patch('app.PublicGatewayApp'), \
+                patch('app.diagnostic_reporter_from_config') as factory, \
+                patch('app.serve_public', return_value=0) as serve:
+            result = GatewayApp(reporter=reporter).run_cli([
+                'serve-public', '--host', '127.0.0.1', '--port', '9000',
+            ])
+
+        self.assertEqual(0, result)
+        factory.assert_not_called()
+        self.assertIs(reporter, serve.call_args.kwargs['reporter'])
+        reporter.close.assert_called_once_with()
+
+    def test_unsupported_command_raises_runtime_error(self):
+        with patch(
+            'app.argparse.ArgumentParser.parse_args',
+            return_value=SimpleNamespace(command='unknown'),
+        ):
+            with self.assertRaisesRegex(RuntimeError, 'unsupported command'):
+                GatewayApp().run_cli([], stdout=io.StringIO())
+
+    def test_call_else_branch_without_keyword_still_calls_tool(self):
+        """
+        --tool fake_tool(非 query_order / query_track)且 --keyword 为空 →
+        走 else 分支,tool_args 中不含 keyword(由工具自己决定是否报错)。
+        注:fake_tool 是 QueryOrderTool,无 keyword 会抛 ValueError →
+        GatewayApp.run_cli 不 catch,直接抛出。
+        """
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            path = os.path.join(tmp_dir, 'token.json')
+            token_store = FileTokenStore(path)
+            token_store.save('MT_test', '2099-01-01T00:00:00')
+            api_client = DummyApiClient()
+            app = GatewayApp(
+                auth_client=DummyAuthClient(token_store),
+                api_client=api_client,
+                token_store=token_store,
+            )
+
+            # 注册一个不需要 keyword 的假工具
+            class NoKeywordTool:
+                name = 'no_kw_tool'
+                route_path = '/fake'
+                requires_session = False
+
+                def metadata(self):
+                    return {'name': self.name, 'description': '', 'input_schema': {'type': 'object', 'properties': {}}}
+
+                def call(self, request_id='', **_kwargs):
+                    return {'code': 'MCP_0000', 'data': {}}
+
+            app._tools['no_kw_tool'] = NoKeywordTool()
+
+            stdout = io.StringIO()
+            # --keyword 不传,else 分支 keyword 为空 → 不加入 tool_args
+            code = app.run_cli(
+                ['call', '--tool', 'no_kw_tool'],
+                stdout=stdout,
+            )
+            self.assertEqual(0, code)
+
+
+# ---------------------------------------------------------------------------
+# main() — 正常调用
+# ---------------------------------------------------------------------------
+
+class MainFunctionTest(unittest.TestCase):
+    def test_main_list_tools_returns_zero(self):
+        """
+        main() 读取 env、构建 app、调用 run_cli(['list-tools'])。
+        直接 mock GatewayConfig.from_env 返回 file store 配置,避免依赖 .env 文件。
+        """
+        import config as config_module
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            token_path = os.path.join(tmp_dir, 'token.json')
+            fake_config = config_module.GatewayConfig(
+                auth_base_url='http://auth.example.com',
+                tools_base_url='http://api.example.com',
+                token_store_type='file',
+                token_store_path=token_path,
+                session_key='test_machine:test_user',
+            )
+            stdout = io.StringIO()
+            enabled = {
+                'code': 'MCP_0000',
+                'data': {
+                    'tool_codes': [
+                        'query_order',
+                        'query_track',
+                        'query_order_exact',
+                        'list_order_filter_options',
+                    ],
+                },
+            }
+            with patch.object(config_module.GatewayConfig, 'from_env', return_value=fake_config):
+                with patch('services.api_client.ApiClient.list_enabled_tools', return_value=enabled):
+                    with patch('sys.stdout', stdout):
+                        code = main(['list-tools'])
+
+            self.assertEqual(0, code)
+            tools = json.loads(stdout.getvalue())
+            names = [t['name'] for t in tools]
+            self.assertIn('query_order', names)
+            self.assertIn('query_track', names)
+            self.assertIn('query_order_exact', names)
+            self.assertIn('list_order_filter_options', names)
+
+    def test_main_propagates_value_error_for_bad_store(self):
+        """
+        当 token_store_type 为不支持的值时,main() 应抛出 ValueError。
+        """
+        import config as config_module
+        bad_config = config_module.GatewayConfig(
+            auth_base_url='http://auth.example.com',
+            tools_base_url='http://api.example.com',
+            token_store_type='bad_store_type',
+            session_key='host:user',
+        )
+        with self.assertRaises(ValueError):
+            with patch.object(config_module.GatewayConfig, 'from_env', return_value=bad_config):
+                main(['list-tools'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 21 - 2
tests/test_bind_auth_code_tool.py

@@ -6,6 +6,19 @@ from services.token_store import InMemoryTokenStore
 
 
 class DummyApiClient:
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_order',
+                    'query_track',
+                    'query_order_exact',
+                    'list_order_filter_options',
+                ],
+            },
+        }
+
     def call_tool(self, tool_code, route_path, payload, request_id):
         return {
             'code': 'MCP_0000',
@@ -48,6 +61,8 @@ class NoBindAuthCodeToolTest(unittest.TestCase):
         names = [tool['name'] for tool in response['result']['tools']]
         self.assertIn('query_order', names)
         self.assertIn('query_track', names)
+        self.assertIn('query_order_exact', names)
+        self.assertIn('list_order_filter_options', names)
         self.assertNotIn('bind_auth_code', names)
 
     def test_bind_auth_code_call_is_not_registered(self):
@@ -68,7 +83,11 @@ class NoBindAuthCodeToolTest(unittest.TestCase):
         )
 
         self.assertTrue(response['result']['isError'])
-        self.assertIn('tool not registered', response['result']['content'][0]['text'])
+        self.assertNotIn('bind_auth_code', response['result']['content'][0]['text'])
+        self.assertEqual(
+            'MCP_9001',
+            response['result']['structuredContent']['code'],
+        )
 
     def test_query_order_without_token_returns_device_invalid_message(self):
         handler, _ = self.build_handler(with_token=False)
@@ -93,4 +112,4 @@ class NoBindAuthCodeToolTest(unittest.TestCase):
 
 
 if __name__ == '__main__':
-    unittest.main()
+    unittest.main()

+ 7 - 1
tests/test_cli_and_file_store.py

@@ -30,6 +30,12 @@ class DummyApiClient:
     def __init__(self):
         self.calls = []
 
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_order', 'query_track']},
+        }
+
     def call_tool(self, tool_code, route_path, payload, request_id):
         self.calls.append(
             {
@@ -174,4 +180,4 @@ class CliAndFileStoreTest(unittest.TestCase):
 
 
 if __name__ == '__main__':
-    unittest.main()
+    unittest.main()

+ 160 - 0
tests/test_config_compat.py

@@ -92,6 +92,40 @@ class GatewayConfigCompatTest(unittest.TestCase):
         self.assertEqual('public', config.gateway_mode)
         self.assertEqual('fms:mcp:gateway:', config.redis_prefix)
 
+    def test_diagnostic_reporter_config_reads_all_operational_limits(self):
+        config = GatewayConfig.from_env(env={
+            'FMS_API_BASE': 'https://base.example.com',
+            'MCP_DIAGNOSIS_ENABLED': 'true',
+            'MCP_DIAGNOSIS_URL': 'https://support.internal/internal/mcp-diagnostics/events',
+            'MCP_DIAGNOSIS_KEY_ID': 'gateway-current',
+            'MCP_DIAGNOSIS_SECRET': 's' * 32,
+            'MCP_DIAGNOSIS_QUEUE_SIZE': '500',
+            'MCP_DIAGNOSIS_BATCH_SIZE': '50',
+            'MCP_DIAGNOSIS_TIMEOUT_SECONDS': '0.4',
+            'MCP_DIAGNOSIS_INITIAL_BACKOFF_SECONDS': '0.2',
+            'MCP_DIAGNOSIS_MAX_BACKOFF_SECONDS': '3.0',
+            'MCP_DIAGNOSIS_ALLOW_INSECURE_HTTP': 'true',
+        }, dotenv_path='missing.env')
+
+        self.assertTrue(config.diagnosis_enabled)
+        self.assertEqual(
+            'https://support.internal/internal/mcp-diagnostics/events',
+            config.diagnosis_url,
+        )
+        self.assertEqual('gateway-current', config.diagnosis_key_id)
+        self.assertEqual('s' * 32, config.diagnosis_secret)
+        self.assertEqual(500, config.diagnosis_queue_size)
+        self.assertEqual(50, config.diagnosis_batch_size)
+        self.assertEqual(0.4, config.diagnosis_timeout_seconds)
+        self.assertEqual(0.2, config.diagnosis_initial_backoff_seconds)
+        self.assertEqual(3.0, config.diagnosis_max_backoff_seconds)
+        self.assertTrue(config.diagnosis_allow_insecure_http)
+
+        default_config = GatewayConfig.from_env(env={
+            'FMS_API_BASE': 'https://base.example.com',
+        }, dotenv_path='missing.env')
+        self.assertFalse(default_config.diagnosis_allow_insecure_http)
+
     def test_timeout_ms_conversion_in_preferred_env(self):
         """Test FMS_TIMEOUT_MS conversion in preferred env"""
         config = GatewayConfig.from_env(env={
@@ -110,5 +144,131 @@ class GatewayConfigCompatTest(unittest.TestCase):
 
         self.assertEqual(1, config.timeout_seconds)
 
+    def test_invalid_diagnostic_float_uses_default(self):
+        config = GatewayConfig.from_env(env={
+            'FMS_API_BASE': 'https://base.example.com',
+            'MCP_DIAGNOSIS_TIMEOUT_SECONDS': 'not-a-number',
+        }, dotenv_path='missing.env')
+
+        self.assertEqual(0.5, config.diagnosis_timeout_seconds)
+
+    def test_timeout_resolution_covers_supported_env_keys(self):
+        cases = (
+            ({'MCP_TIMEOUT_SECONDS': '11'}, {}, 11),
+            ({'FMS_TIMEOUT_MS': '2500'}, {}, 2),
+            ({}, {'FMS_TIMEOUT_SECONDS': '12'}, 12),
+        )
+        for preferred, fallback, expected in cases:
+            with self.subTest(preferred=preferred, fallback=fallback):
+                self.assertEqual(
+                    expected,
+                    GatewayConfig._resolve_timeout_seconds(
+                        preferred,
+                        fallback,
+                    ),
+                )
+
+    def test_load_dotenv_skips_invalid_lines_and_unquotes_values(self):
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            path = os.path.join(tmp_dir, '.env')
+            with open(path, 'w', encoding='utf-8') as file:
+                file.write('# comment\n')
+                file.write('invalid-line\n')
+                file.write('=ignored\n')
+                file.write('DOUBLE="double value"\n')
+                file.write("SINGLE='single value'\n")
+
+            values = GatewayConfig._load_dotenv(path)
+
+        self.assertEqual({
+            'DOUBLE': 'double value',
+            'SINGLE': 'single value',
+        }, values)
+
+class RateLimitConfigTest(unittest.TestCase):
+    """Tests for _parse_int / _parse_bool helpers and rate limit config parsing."""
+
+    def _config_from_env(self, env):
+        return GatewayConfig.from_env(
+            env=dict({'FMS_API_BASE': 'http://x.test'}, **env),
+            dotenv_path='missing.env',
+        )
+
+    # --- _parse_int via OS env var ---
+
+    def test_rate_limit_max_requests_reads_from_env(self):
+        config = self._config_from_env({'FMS_RATE_LIMIT_MAX_REQUESTS': '120'})
+        self.assertEqual(120, config.rate_limit_max_requests)
+
+    def test_rate_limit_window_seconds_reads_from_env(self):
+        config = self._config_from_env({'FMS_RATE_LIMIT_WINDOW_SECONDS': '30'})
+        self.assertEqual(30, config.rate_limit_window_seconds)
+
+    def test_max_in_flight_per_tool_reads_from_env(self):
+        config = self._config_from_env({'FMS_MAX_IN_FLIGHT_PER_TOOL': '3'})
+        self.assertEqual(3, config.max_in_flight_per_tool)
+
+    def test_rate_limit_max_requests_os_env_with_inline_comment_does_not_crash(self):
+        # OS env vars are not processed by _load_dotenv, so inline comments must be
+        # stripped by _parse_int before int() conversion
+        config = self._config_from_env({'FMS_RATE_LIMIT_MAX_REQUESTS': '60 # max per window'})
+        self.assertEqual(60, config.rate_limit_max_requests)
+
+    def test_rate_limit_window_seconds_invalid_value_falls_back_to_default(self):
+        config = self._config_from_env({'FMS_RATE_LIMIT_WINDOW_SECONDS': 'not_a_number'})
+        self.assertEqual(60, config.rate_limit_window_seconds)
+
+    def test_rate_limit_max_requests_empty_falls_back_to_default(self):
+        config = self._config_from_env({})
+        self.assertEqual(60, config.rate_limit_max_requests)
+
+    # --- _parse_bool via OS env var ---
+
+    def test_rate_limit_enabled_default_is_true(self):
+        config = self._config_from_env({})
+        self.assertTrue(config.rate_limit_enabled)
+
+    def test_rate_limit_enabled_zero_disables(self):
+        config = self._config_from_env({'FMS_RATE_LIMIT_ENABLED': '0'})
+        self.assertFalse(config.rate_limit_enabled)
+
+    def test_rate_limit_enabled_false_string_disables(self):
+        config = self._config_from_env({'FMS_RATE_LIMIT_ENABLED': 'false'})
+        self.assertFalse(config.rate_limit_enabled)
+
+    def test_rate_limit_enabled_off_string_disables(self):
+        config = self._config_from_env({'FMS_RATE_LIMIT_ENABLED': 'off'})
+        self.assertFalse(config.rate_limit_enabled)
+
+    def test_rate_limit_enabled_empty_string_keeps_default_enabled(self):
+        # empty string → _pick returns '' → _parse_bool returns default=True
+        config = self._config_from_env({'FMS_RATE_LIMIT_ENABLED': ''})
+        self.assertTrue(config.rate_limit_enabled)
+
+    def test_rate_limit_enabled_one_enables(self):
+        config = self._config_from_env({'FMS_RATE_LIMIT_ENABLED': '1'})
+        self.assertTrue(config.rate_limit_enabled)
+
+    # --- dotenv inline comment stripping ---
+
+    def test_dotenv_inline_comment_stripped_from_int_value(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            path = os.path.join(tmp, '.env')
+            with open(path, 'w') as f:
+                f.write('FMS_API_BASE=http://x.test\n')
+                f.write('FMS_RATE_LIMIT_MAX_REQUESTS=45 # requests per window\n')
+            config = GatewayConfig.from_env(env={}, dotenv_path=path)
+            self.assertEqual(45, config.rate_limit_max_requests)
+
+    def test_dotenv_inline_comment_stripped_from_bool_value(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            path = os.path.join(tmp, '.env')
+            with open(path, 'w') as f:
+                f.write('FMS_API_BASE=http://x.test\n')
+                f.write('FMS_RATE_LIMIT_ENABLED=0 # disabled for testing\n')
+            config = GatewayConfig.from_env(env={}, dotenv_path=path)
+            self.assertFalse(config.rate_limit_enabled)
+
+
 if __name__ == '__main__':
     unittest.main()

+ 509 - 0
tests/test_diagnostic_reporter.py

@@ -0,0 +1,509 @@
+import hashlib
+import json
+import unittest
+from unittest.mock import MagicMock, patch
+
+from services.diagnostic_event import (
+    RequestDiagnosticEmitter,
+    build_diagnostic_event,
+)
+from services.diagnostic_reporter import (
+    DiagnosticReporter,
+    NullDiagnosticReporter,
+    _http_transport,
+    diagnostic_reporter_from_config,
+)
+
+
+class DiagnosticReporterTest(unittest.TestCase):
+    def test_event_builder_rejects_invalid_required_and_optional_fields(self):
+        base = {
+            'request_id': 'rq_valid',
+            'stage': 'request_ingress',
+            'status': 'started',
+            'event_code': 'REQUEST_RECEIVED',
+        }
+        cases = (
+            ('request_id', None),
+            ('request_id', 'bad'),
+            ('stage', 'unknown'),
+            ('status', 'unknown'),
+            ('event_code', 1),
+            ('event_code', 'A' * 65),
+            ('event_code', 'lowercase'),
+            ('company_id', True),
+            ('company_id', '1'),
+            ('company_id', 0),
+            ('tool_code', 1),
+            ('tool_code', 'UPPER'),
+            ('session_credential', 1),
+            ('response_code', 'bad-code'),
+            ('summary_code', 'A' * 65),
+            ('cost_ms', True),
+            ('cost_ms', '1'),
+            ('cost_ms', -1),
+            ('cost_ms', 3600001),
+        )
+        for field, value in cases:
+            with self.subTest(field=field, value=value):
+                values = dict(base)
+                values[field] = value
+                with self.assertRaises(ValueError):
+                    build_diagnostic_event(**values)
+
+    def test_event_builder_enforces_stage_context_types(self):
+        cases = (
+            ('request_ingress', 'not-object'),
+            ('request_ingress', {'secret': 'x'}),
+            ('request_ingress', {'http_status': True}),
+            ('request_ingress', {'http_status': '200'}),
+            ('request_ingress', {'http_status': 99}),
+            ('protocol_validation', {'jsonrpc_code': True}),
+            ('protocol_validation', {'jsonrpc_code': 'bad'}),
+            ('response_write', {'client_disconnected': 1}),
+            ('request_ingress', {'transport': 1}),
+            ('request_ingress', {'transport': ''}),
+            ('request_ingress', {'transport': 'x' * 65}),
+        )
+        for stage, context in cases:
+            with self.subTest(stage=stage, context=context):
+                with self.assertRaises(ValueError):
+                    build_diagnostic_event(
+                        request_id='rq_valid',
+                        stage=stage,
+                        status='failed',
+                        event_code='INVALID_EVENT',
+                        context=context,
+                    )
+
+    def test_event_builder_accepts_complete_whitelisted_event(self):
+        event = build_diagnostic_event(
+            request_id='rq_complete',
+            stage='response_write',
+            status='succeeded',
+            event_code='RESPONSE_WRITE_COMPLETED',
+            company_id=1,
+            admin_id=2,
+            tool_code='query_order',
+            response_code='MCP_0000',
+            summary_code='OK',
+            cost_ms=0,
+            context={
+                'http_status': 200,
+                'client_disconnected': False,
+                'transport': 'http',
+            },
+        )
+
+        self.assertTrue(event['occurred_at'].endswith('Z'))
+        self.assertEqual('OK', event['summary_code'])
+
+    def test_event_builder_hashes_session_and_keeps_whitelist(self):
+        event = build_diagnostic_event(
+            request_id='rq_http_test_1',
+            stage='gateway_session',
+            status='failed',
+            event_code='GATEWAY_SESSION_NOT_FOUND',
+            occurred_at='2026-07-20T02:00:00.000Z',
+            session_credential='GWS_super_secret',
+            company_id=1002,
+            admin_id=88,
+            tool_code='query_order_detail',
+            context={'transport': 'http'},
+        )
+
+        self.assertTrue(event['event_id'].startswith('evt_gateway_'))
+        self.assertEqual(
+            hashlib.sha256(b'GWS_super_secret').hexdigest()[:12],
+            event['session_hash'],
+        )
+        self.assertNotIn('session_credential', event)
+        self.assertNotIn('GWS_super_secret', json.dumps(event))
+        self.assertEqual('gateway', event['source'])
+
+    def test_request_emitter_caps_each_request_at_twenty_events(self):
+        reporter = RecordingReporter()
+        emitter = RequestDiagnosticEmitter(reporter, 'rq_http_test_1')
+
+        for index in range(25):
+            emitter.emit(
+                stage='request_ingress',
+                status='started',
+                event_code='REQUEST_RECEIVED',
+                context={'transport': 'http'},
+            )
+
+        self.assertEqual(20, len(reporter.events))
+        self.assertEqual(5, emitter.dropped_count)
+        self.assertEqual(20, len({item['event_id'] for item in reporter.events}))
+
+    def test_request_emitter_counts_reporter_rejection(self):
+        reporter = RecordingReporter(accepted=False)
+        emitter = RequestDiagnosticEmitter(reporter, 'rq_rejected')
+        emitter.set_defaults(context={'transport': 'http'})
+
+        self.assertFalse(emitter.emit(
+            stage='request_ingress',
+            status='started',
+            event_code='REQUEST_RECEIVED',
+        ))
+        self.assertEqual(1, emitter.dropped_count)
+
+    def test_deferred_emitter_enriches_prestage_events_after_identity_resolution(self):
+        reporter = RecordingReporter()
+        emitter = RequestDiagnosticEmitter(
+            reporter,
+            'rq_deferred',
+            defer_until_identity=True,
+        )
+        emitter.emit(
+            stage='request_ingress',
+            status='started',
+            event_code='REQUEST_RECEIVED',
+            context={'transport': 'http'},
+        )
+        emitter.emit(
+            stage='protocol_validation',
+            status='succeeded',
+            event_code='PROTOCOL_VALIDATION_COMPLETED',
+            context={'transport': 'http'},
+        )
+        self.assertEqual([], reporter.events)
+
+        emitter.set_defaults(
+            company_id=1002,
+            admin_id=88,
+            session_credential='GWS_private',
+        )
+
+        self.assertEqual(2, len(reporter.events))
+        self.assertTrue(all(
+            event['company_id'] == 1002 for event in reporter.events
+        ))
+        self.assertNotIn('GWS_private', str(reporter.events))
+
+    def test_deferred_emitter_flushes_unscoped_failures(self):
+        reporter = RecordingReporter()
+        emitter = RequestDiagnosticEmitter(
+            reporter,
+            'rq_unscoped',
+            defer_until_identity=True,
+        )
+        emitter.emit(
+            stage='gateway_session',
+            status='failed',
+            event_code='GATEWAY_SESSION_NOT_FOUND',
+            session_credential='GWS_missing',
+            context={'transport': 'http'},
+        )
+
+        self.assertTrue(emitter.flush())
+        self.assertEqual(1, len(reporter.events))
+        self.assertIn('session_hash', reporter.events[0])
+        self.assertNotIn('company_id', reporter.events[0])
+
+    def test_queue_full_only_increments_drop_count(self):
+        reporter = self.reporter(queue_size=1)
+        first = build_diagnostic_event(
+            request_id='rq_http_1', stage='request_ingress',
+            status='started', event_code='REQUEST_RECEIVED',
+        )
+        second = build_diagnostic_event(
+            request_id='rq_http_2', stage='request_ingress',
+            status='started', event_code='REQUEST_RECEIVED',
+        )
+
+        self.assertTrue(reporter.report(first))
+        self.assertFalse(reporter.report(second))
+        self.assertEqual(1, reporter.drop_count)
+
+    def test_batch_signs_exact_json_bytes_and_accepts_success(self):
+        calls = []
+
+        def transport(url, body, headers, timeout):
+            calls.append((url, body, headers, timeout))
+            return 200, json.dumps({
+                'code': 'MCP_DIAG_INGEST_0000',
+                'msg': 'success',
+                'data': {'accepted': 1, 'duplicate': 0, 'rejected': 0},
+            }).encode('utf-8')
+
+        reporter = self.reporter(transport=transport)
+        reporter.report(build_diagnostic_event(
+            request_id='rq_http_1', stage='request_ingress',
+            status='started', event_code='REQUEST_RECEIVED',
+        ))
+
+        self.assertTrue(reporter.process_once())
+        self.assertEqual(1, len(calls))
+        url, body, headers, timeout = calls[0]
+        self.assertEqual('https://support.internal/internal/mcp-diagnostics/events', url)
+        self.assertEqual(0.5, timeout)
+        self.assertEqual({'events'}, set(json.loads(body.decode('utf-8'))))
+        canonical = 'POST\n/internal/mcp-diagnostics/events\n{0}\n{1}\n{2}'.format(
+            headers['X-MCP-Timestamp'],
+            headers['X-MCP-Nonce'],
+            hashlib.sha256(body).hexdigest(),
+        )
+        expected = hashlib.pbkdf2_hmac(
+            'sha256', canonical.encode('utf-8'), b'x', 1
+        )
+        self.assertNotEqual(expected.hex(), headers['X-MCP-Signature'])
+        import hmac
+        self.assertEqual(
+            hmac.new(b's' * 32, canonical.encode('utf-8'), hashlib.sha256).hexdigest(),
+            headers['X-MCP-Signature'],
+        )
+
+    def test_failed_send_keeps_batch_and_uses_bounded_backoff(self):
+        attempts = []
+        sleeps = []
+
+        def transport(_url, _body, _headers, _timeout):
+            attempts.append(1)
+            if len(attempts) == 1:
+                raise OSError('support unavailable secret')
+            return 200, b'{"code":"MCP_DIAG_INGEST_0000","data":{}}'
+
+        reporter = self.reporter(
+            transport=transport,
+            sleeper=sleeps.append,
+            initial_backoff=0.25,
+            max_backoff=1.0,
+        )
+        reporter.report(build_diagnostic_event(
+            request_id='rq_http_1', stage='request_ingress',
+            status='started', event_code='REQUEST_RECEIVED',
+        ))
+
+        self.assertFalse(reporter.process_once())
+        self.assertEqual([0.25], sleeps)
+        self.assertTrue(reporter.process_once())
+        self.assertEqual(2, len(attempts))
+        self.assertEqual(0, reporter.pending_count)
+
+    def test_thread_start_failure_and_null_reporter_are_non_fatal(self):
+        class BrokenThread:
+            def start(self):
+                raise RuntimeError('thread unavailable')
+
+        reporter = self.reporter(thread_factory=lambda **_kwargs: BrokenThread())
+        self.assertFalse(reporter.start())
+        reporter.close()
+
+        null = NullDiagnosticReporter()
+        self.assertTrue(null.start())
+        self.assertTrue(null.report({'ignored': True}))
+        null.close()
+
+    def test_start_is_idempotent_and_close_joins_live_thread(self):
+        thread = AliveThread()
+        reporter = self.reporter(thread_factory=lambda **_kwargs: thread)
+
+        self.assertTrue(reporter.start())
+        self.assertTrue(reporter.start())
+        reporter.close()
+
+        self.assertEqual([0.5], thread.join_timeouts)
+
+    def test_process_once_accepts_empty_queue_and_rejects_bad_responses(self):
+        reporter = self.reporter()
+        self.assertTrue(reporter.process_once())
+
+        responses = (
+            (199, b'{"code":"MCP_DIAG_INGEST_0000"}'),
+            (300, b'{"code":"MCP_DIAG_INGEST_0000"}'),
+            (200, b'{"code":"MCP_DIAG_INGEST_INVALID"}'),
+        )
+        for status, body in responses:
+            with self.subTest(status=status, body=body):
+                reporter = self.reporter(
+                    transport=lambda *_args, result=(status, body): result,
+                )
+                reporter.report(build_diagnostic_event(
+                    request_id='rq_rejected',
+                    stage='request_ingress',
+                    status='started',
+                    event_code='REQUEST_RECEIVED',
+                ))
+                self.assertFalse(reporter.process_once())
+                self.assertEqual(1, reporter.pending_count)
+
+    def test_batch_size_caps_single_send_and_run_loop_handles_retry(self):
+        reporter = self.reporter(batch_size=1)
+        for request_id in ('rq_one', 'rq_two'):
+            reporter.report(build_diagnostic_event(
+                request_id=request_id,
+                stage='request_ingress',
+                status='started',
+                event_code='REQUEST_RECEIVED',
+            ))
+        self.assertTrue(reporter.process_once())
+        self.assertEqual(0, reporter.pending_count)
+        self.assertFalse(reporter._queue.empty())
+
+        reporter = self.reporter()
+        reporter._stop = MagicMock()
+        reporter._stop.is_set.side_effect = (False, False, True)
+        reporter.process_once = MagicMock(side_effect=(False, True))
+        reporter._run()
+        reporter._stop.wait.assert_called_once_with(0.1)
+
+    def test_default_http_transport_posts_and_reads_response(self):
+        response = MagicMock()
+        response.getcode.return_value = 200
+        response.read.return_value = b'{}'
+        response.__enter__.return_value = response
+        response.__exit__.return_value = False
+
+        with patch(
+            'services.diagnostic_reporter.urllib.request.urlopen',
+            return_value=response,
+        ) as urlopen:
+            result = _http_transport(
+                'https://support.test/path',
+                b'{}',
+                {'X-Test': '1'},
+                0.5,
+            )
+
+        self.assertEqual((200, b'{}'), result)
+        self.assertEqual(0.5, urlopen.call_args.kwargs['timeout'])
+
+    def test_config_factory_uses_null_when_disabled_or_invalid(self):
+        disabled = type('Config', (), {'diagnosis_enabled': False})()
+        invalid = type('Config', (), {
+            'diagnosis_enabled': True,
+            'diagnosis_url': '',
+            'diagnosis_key_id': '',
+            'diagnosis_secret': '',
+        })()
+
+        self.assertIsInstance(
+            diagnostic_reporter_from_config(disabled),
+            NullDiagnosticReporter,
+        )
+        self.assertIsInstance(
+            diagnostic_reporter_from_config(invalid),
+            NullDiagnosticReporter,
+        )
+
+    def test_config_factory_starts_enabled_reporter(self):
+        config = type('Config', (), {
+            'diagnosis_enabled': True,
+            'diagnosis_url': 'https://support.test/internal/mcp-diagnostics/events',
+            'diagnosis_key_id': 'gateway-current',
+            'diagnosis_secret': 's' * 32,
+            'diagnosis_queue_size': 10,
+            'diagnosis_batch_size': 20,
+            'diagnosis_timeout_seconds': 0.5,
+            'diagnosis_initial_backoff_seconds': 0.25,
+            'diagnosis_max_backoff_seconds': 2.0,
+        })()
+
+        reporter = diagnostic_reporter_from_config(
+            config,
+            thread_factory=lambda **_kwargs: StartedThread(),
+        )
+
+        self.assertIsInstance(reporter, DiagnosticReporter)
+        self.assertTrue(reporter._thread.started)
+        reporter.close()
+
+    def test_config_factory_requires_explicit_opt_in_for_http(self):
+        values = {
+            'diagnosis_enabled': True,
+            'diagnosis_url': 'http://support.test/internal/mcp-diagnostics/events',
+            'diagnosis_key_id': 'gateway-current',
+            'diagnosis_secret': 's' * 32,
+        }
+        default_config = type('Config', (), values)()
+        allowed_config = type(
+            'Config',
+            (),
+            dict(values, diagnosis_allow_insecure_http=True),
+        )()
+
+        self.assertIsInstance(
+            diagnostic_reporter_from_config(default_config),
+            NullDiagnosticReporter,
+        )
+        reporter = diagnostic_reporter_from_config(
+            allowed_config,
+            thread_factory=lambda **_kwargs: StartedThread(),
+        )
+        self.assertIsInstance(reporter, DiagnosticReporter)
+        reporter.close()
+
+    def test_config_factory_falls_back_when_thread_cannot_start(self):
+        config = type('Config', (), {
+            'diagnosis_enabled': True,
+            'diagnosis_url': 'https://support.test/events',
+            'diagnosis_key_id': 'gateway-current',
+            'diagnosis_secret': 's' * 32,
+        })()
+
+        reporter = diagnostic_reporter_from_config(
+            config,
+            thread_factory=lambda **_kwargs: BrokenThread(),
+        )
+
+        self.assertIsInstance(reporter, NullDiagnosticReporter)
+
+    def reporter(self, **overrides):
+        options = {
+            'url': 'https://support.internal/internal/mcp-diagnostics/events',
+            'key_id': 'gateway-current',
+            'secret': 's' * 32,
+            'queue_size': 10,
+            'batch_size': 100,
+            'timeout_seconds': 0.5,
+            'transport': lambda *_args: (200, b'{"code":"MCP_DIAG_INGEST_0000","data":{}}'),
+            'clock': lambda: 1784512800,
+            'nonce_factory': lambda: 'nonce-gateway-1234',
+            'sleeper': lambda _seconds: None,
+        }
+        options.update(overrides)
+        return DiagnosticReporter(**options)
+
+
+class RecordingReporter:
+    def __init__(self, accepted=True):
+        self.events = []
+        self.accepted = accepted
+
+    def report(self, event):
+        self.events.append(event)
+        return self.accepted
+
+
+class StartedThread:
+    def __init__(self):
+        self.started = False
+
+    def start(self):
+        self.started = True
+
+    def is_alive(self):
+        return False
+
+
+class AliveThread(StartedThread):
+    def __init__(self):
+        super().__init__()
+        self.join_timeouts = []
+
+    def is_alive(self):
+        return True
+
+    def join(self, timeout=None):
+        self.join_timeouts.append(timeout)
+
+
+class BrokenThread:
+    def start(self):
+        raise RuntimeError('thread unavailable')
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 250 - 0
tests/test_export_out_of_province_port_data_tool.py

@@ -0,0 +1,250 @@
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from tools.export_out_of_province_port_data import (
+    ExportOutOfProvincePortDataTool,
+)
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.last_call = None
+
+    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': {'file_url': 'https://files.test/port.zip'},
+        }
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['export_out_of_province_port_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 __init__(self, enabled=True):
+        self.enabled = enabled
+        self.calls = []
+
+    def list_enabled_tools(self, token, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': (
+                ['export_out_of_province_port_data'] if self.enabled else []
+            )},
+        }
+
+    def call_tool(self, **kwargs):
+        self.calls.append(kwargs)
+        return {'code': 'MCP_0000'}
+
+
+class ExportOutOfProvincePortDataToolTest(unittest.TestCase):
+    def test_metadata_explains_business_fields_and_ai_decision_boundaries(self):
+        metadata = ExportOutOfProvincePortDataTool().metadata()
+        schema = metadata['input_schema']
+        properties = schema['properties']
+
+        self.assertEqual(['file_type'], schema['required'])
+        self.assertEqual([
+            {'required': ['outbound_numbers']},
+            {'required': ['container_codes']},
+            {'required': ['bl_numbers']},
+            {'required': ['so_numbers']},
+        ], schema['oneOf'])
+        self.assertFalse(schema['additionalProperties'])
+        for field in (
+            'outbound_numbers',
+            'container_codes',
+            'bl_numbers',
+            'so_numbers',
+        ):
+            numbers = properties[field]
+            self.assertEqual('array', numbers['type'])
+            self.assertEqual('string', numbers['items']['type'])
+            self.assertEqual(1, numbers['items']['minLength'])
+            self.assertEqual(100, numbers['items']['maxLength'])
+            self.assertEqual(1, numbers['minItems'])
+            self.assertEqual(100, numbers['maxItems'])
+            self.assertTrue(numbers['uniqueItems'])
+            self.assertIsInstance(numbers['examples'][0], list)
+            self.assertGreater(len(numbers['examples'][0]), 1)
+
+        file_type = properties['file_type']
+        self.assertEqual(['NB', 'SH', 'MS'], file_type['enum'])
+        self.assertEqual(['NB'], file_type['examples'])
+
+        description = metadata['description']
+        for phrase in (
+            '排舱单列表',
+            '柜号',
+            '提单号',
+            'SO号',
+            '请确认使用哪种单号导出:排舱单号、柜号、提单号还是SO号',
+            '确认前不得调用',
+            '不得根据号码格式猜测',
+            '先询问',
+            '一次只能选择一种资料类型',
+        ):
+            self.assertIn(phrase, description)
+        self.assertIn('排舱单号', properties['outbound_numbers']['description'])
+        self.assertIn('柜号', properties['container_codes']['description'])
+        self.assertIn('集装箱号', properties['container_codes']['description'])
+        self.assertIn('提单号', properties['bl_numbers']['description'])
+        self.assertIn('不是系统提单号', properties['bl_numbers']['description'])
+        self.assertIn('SO号', properties['so_numbers']['description'])
+        self.assertIn('fms_booking_detail.so_number', properties['so_numbers']['description'])
+        for phrase in ('NB=宁波', 'SH=上海', 'MS=美森', '先询问'):
+            self.assertIn(phrase, file_type['description'])
+
+    def test_call_normalizes_and_forwards_only_supported_fields(self):
+        client = RecordingApiClient()
+        tool = ExportOutOfProvincePortDataTool(api_client=client)
+
+        result = tool.call(
+            [' PC001 ', 'PC002', 'PC001'],
+            ' nb ',
+            request_id='rq_port',
+        )
+
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual(
+            'export_out_of_province_port_data',
+            client.last_call['tool_code'],
+        )
+        self.assertEqual(
+            '/mcp/tools/exportOutOfProvincePortData',
+            client.last_call['route_path'],
+        )
+        self.assertEqual({
+            'outbound_numbers': ['PC001', 'PC002'],
+            'file_type': 'NB',
+        }, client.last_call['payload'])
+        self.assertEqual('rq_port', client.last_call['request_id'])
+
+    def test_call_forwards_all_supported_alternative_number_types(self):
+        cases = (
+            (
+                {'container_codes': [' MSCU001 ', 'MSCU002', 'MSCU001']},
+                {'container_codes': ['MSCU001', 'MSCU002'], 'file_type': 'SH'},
+            ),
+            (
+                {'bl_numbers': [' BL001 ', 'BL002', 'BL001']},
+                {'bl_numbers': ['BL001', 'BL002'], 'file_type': 'SH'},
+            ),
+            (
+                {'so_numbers': [' SO001 ', 'SO002', 'SO001']},
+                {'so_numbers': ['SO001', 'SO002'], 'file_type': 'SH'},
+            ),
+        )
+        for arguments, expected_payload in cases:
+            with self.subTest(arguments=arguments):
+                client = RecordingApiClient()
+                tool = ExportOutOfProvincePortDataTool(api_client=client)
+                tool.call(file_type='SH', **arguments)
+                self.assertEqual(expected_payload, client.last_call['payload'])
+
+    def test_call_requires_exactly_one_explicit_number_type(self):
+        tool = ExportOutOfProvincePortDataTool(api_client=RecordingApiClient())
+        invalid = (
+            {},
+            {'outbound_numbers': ['PC001'], 'container_codes': ['MSCU001']},
+            {'container_codes': ['MSCU001'], 'bl_numbers': ['BL001']},
+            {'bl_numbers': ['BL001'], 'so_numbers': ['SO001']},
+        )
+        for arguments in invalid:
+            with self.subTest(arguments=arguments):
+                with self.assertRaisesRegex(
+                    ValueError,
+                    'provide exactly one number type',
+                ):
+                    tool.call(file_type='NB', **arguments)
+
+    def test_call_rejects_invalid_inputs(self):
+        tool = ExportOutOfProvincePortDataTool(api_client=RecordingApiClient())
+
+        invalid_numbers = (
+            [],
+            'PC001',
+            [''],
+            ['PC001', 2],
+            ['X' * 101],
+            ['PC{0}'.format(index) for index in range(101)],
+            ['PC001'] * 101,
+        )
+        for numbers in invalid_numbers:
+            with self.subTest(numbers=numbers):
+                with self.assertRaises(ValueError):
+                    tool.call(numbers, 'NB')
+        with self.assertRaisesRegex(ValueError, 'file_type must be NB, SH, or MS'):
+            tool.call(['PC001'], 'OTHER')
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            ExportOutOfProvincePortDataTool().call(['PC001'], 'NB')
+
+    def test_local_and_public_gateways_expose_enabled_tool(self):
+        local_names = {
+            item['name']
+            for item in GatewayApp(api_client=RecordingApiClient()).list_tools()
+        }
+        public_names = {
+            item['name']
+            for item in PublicGatewayApp(
+                PublicSessionStore(),
+                PublicApiClient(),
+            ).list_tools('GWS_test')
+        }
+
+        self.assertIn('export_out_of_province_port_data', local_names)
+        self.assertIn('export_out_of_province_port_data', public_names)
+
+    def test_public_gateway_hides_disabled_tool(self):
+        names = {
+            item['name']
+            for item in PublicGatewayApp(
+                PublicSessionStore(),
+                PublicApiClient(enabled=False),
+            ).list_tools('GWS_test')
+        }
+
+        self.assertNotIn('export_out_of_province_port_data', names)
+
+    def test_public_gateway_forwards_scoped_token_route_and_payload(self):
+        api_client = PublicApiClient()
+        gateway = PublicGatewayApp(PublicSessionStore(), api_client)
+
+        gateway.call_tool(
+            'GWS_test',
+            'export_out_of_province_port_data',
+            {'outbound_numbers': ['PC001'], 'file_type': 'NB'},
+            request_id='rq_public_port',
+        )
+
+        self.assertEqual('MT_test', api_client.calls[0]['token'])
+        self.assertEqual(
+            '/mcp/tools/exportOutOfProvincePortData',
+            api_client.calls[0]['route_path'],
+        )
+        self.assertEqual({
+            'outbound_numbers': ['PC001'],
+            'file_type': 'NB',
+        }, api_client.calls[0]['payload'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 153 - 0
tests/test_export_pending_outbound_tools.py

@@ -0,0 +1,153 @@
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from tools.export_pending_outbound_orders import ExportPendingOutboundOrdersTool
+from tools.list_pending_outbound_export_filter_options import (
+    ListPendingOutboundExportFilterOptionsTool,
+)
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.calls.append((tool_code, route_path, payload, request_id))
+        return {'code': 'MCP_0000', 'data': {'file_url': 'https://files.test/order.xlsx'}}
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'export_pending_outbound_orders',
+                    'list_pending_outbound_export_filter_options',
+                ],
+            },
+        }
+
+
+class PublicSessionStore:
+    def get(self, gateway_session_id):
+        return {'mcp_token': 'MT_test'} if gateway_session_id == 'GWS_test' else None
+
+
+class PublicApiClient:
+    def list_enabled_tools(self, token, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'export_pending_outbound_orders',
+                    'list_pending_outbound_export_filter_options',
+                ],
+            },
+        }
+
+
+class ExportPendingOutboundToolsTest(unittest.TestCase):
+    def test_metadata_uses_add_page_filter_names_and_native_types(self):
+        schema = ExportPendingOutboundOrdersTool().metadata()['input_schema']
+        properties = schema['properties']
+
+        for field in (
+            'number', 'product_type_id', 'product_id', 'order_warehouse_id',
+            'receiver_country', 'is_remove', 'address', 'inbound_date', 'status',
+            'is_battery', 'is_magnetic', 'is_wood', 'is_other', 'is_fda',
+            'is_toy', 'is_ultra_limit', 'is_sensitive', 'is_food', 'no_property',
+            'merge_declare_number', 'importer_id', 'packing_type',
+        ):
+            self.assertIn(field, properties)
+
+        self.assertEqual('array', properties['product_id']['type'])
+        self.assertEqual('array', properties['status']['type'])
+        self.assertEqual('string', properties['packing_type']['type'])
+        self.assertNotIn('property_2', properties)
+
+    def test_export_call_preserves_three_page_multi_select_shapes(self):
+        client = RecordingApiClient()
+        tool = ExportPendingOutboundOrdersTool(api_client=client)
+
+        result = tool.call(
+            product_id=[12, 13],
+            status=[30, 40],
+            is_battery='Y',
+            packing_type='散货,整柜',
+            is_remove=0,
+            request_id='rq_export',
+        )
+
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual(
+            {
+                'product_id': [12, 13],
+                'status': [30, 40],
+                'is_battery': 'Y',
+                'packing_type': '散货,整柜',
+                'is_remove': 0,
+            },
+            client.calls[0][2],
+        )
+        self.assertEqual('/mcp/tools/exportPendingOutboundOrders', client.calls[0][1])
+
+    def test_filter_options_forwards_product_type_dependency_and_bounds_page(self):
+        client = RecordingApiClient()
+        tool = ListPendingOutboundExportFilterOptionsTool(api_client=client)
+
+        tool.call(
+            filter_type=' product ',
+            product_type_id=8,
+            keyword=' 海运 ',
+            page=0,
+            limit=200,
+            request_id='rq_filters',
+        )
+
+        self.assertEqual(
+            {
+                'filter_type': 'product',
+                'product_type_id': 8,
+                'keyword': '海运',
+                'page': 1,
+                'limit': 100,
+            },
+            client.calls[0][2],
+        )
+        self.assertEqual('/mcp/tools/listPendingOutboundExportFilterOptions', client.calls[0][1])
+
+    def test_filter_options_rejects_missing_client_unknown_type_and_invalid_dependency(self):
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            ListPendingOutboundExportFilterOptionsTool().call('country')
+
+        tool = ListPendingOutboundExportFilterOptionsTool(api_client=RecordingApiClient())
+        with self.assertRaisesRegex(ValueError, 'unsupported filter_type'):
+            tool.call('unknown')
+        with self.assertRaisesRegex(ValueError, 'product_type_id must be greater than 0'):
+            tool.call('product', product_type_id=0)
+        tool.call('country')
+
+    def test_export_requires_api_client(self):
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            ExportPendingOutboundOrdersTool().call()
+
+    def test_gateways_register_both_tools_when_backend_enables_them(self):
+        local_names = {
+            item['name']
+            for item in GatewayApp(api_client=RecordingApiClient()).list_tools()
+        }
+        public_names = {
+            item['name']
+            for item in PublicGatewayApp(
+                PublicSessionStore(), PublicApiClient()
+            ).list_tools('GWS_test')
+        }
+
+        self.assertIn('export_pending_outbound_orders', local_names)
+        self.assertIn('list_pending_outbound_export_filter_options', local_names)
+        self.assertIn('export_pending_outbound_orders', public_names)
+        self.assertIn('list_pending_outbound_export_filter_options', public_names)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 47 - 2
tests/test_gateway_query_order.py

@@ -20,6 +20,19 @@ class DummyTransport:
                 'timeout': timeout,
             }
         )
+        if url.endswith('/mcp/tools/listEnabledTools'):
+            return {
+                'code': 'MCP_0000',
+                'msg': 'success',
+                'data': {
+                    'tool_codes': [
+                        'query_order',
+                        'query_track',
+                        'query_order_exact',
+                        'list_order_filter_options',
+                    ],
+                },
+            }
         return {
             'code': 'MCP_0000',
             'msg': 'success',
@@ -29,7 +42,7 @@ class DummyTransport:
                 'tips': [],
             },
             'meta': {
-                'request_id': headers['X-Request-Id'],
+                'request_id': headers.get('X-Request-Id', ''),
             },
         }
 
@@ -49,12 +62,20 @@ class BomHttpResponse:
         return self.body
 class GatewayQueryOrderTest(unittest.TestCase):
     def test_gateway_lists_query_order_tool(self):
-        app = GatewayApp()
+        store = InMemoryTokenStore(refresh_skew_seconds=60)
+        store.save('MT_demo', '2099-01-01T00:00:00')
+        app = GatewayApp(api_client=ApiClient(
+            base_url='http://tools.example.test',
+            token_store=store,
+            transport=DummyTransport(),
+        ))
 
         tools = app.list_tools()
 
         by_name = {tool['name']: tool for tool in tools}
         self.assertIn('query_order', by_name)
+        self.assertIn('query_order_exact', by_name)
+        self.assertIn('list_order_filter_options', by_name)
         self.assertIn('keyword', by_name['query_order']['input_schema']['required'])
 
     def test_json_transport_accepts_utf8_bom_response(self):
@@ -96,6 +117,30 @@ class GatewayQueryOrderTest(unittest.TestCase):
         self.assertEqual('rq_demo', transport.calls[0]['headers']['X-Request-Id'])
         self.assertEqual('http://tools.example.test/mcp/tools/queryOrder', transport.calls[0]['url'])
 
+    def test_api_client_lists_enabled_tools_without_tool_headers(self):
+        transport = DummyTransport()
+        store = InMemoryTokenStore(refresh_skew_seconds=60)
+        store.save('MT_demo', '2099-01-01T00:00:00')
+        client = ApiClient(
+            base_url='http://tools.example.test',
+            token_store=store,
+            transport=transport,
+            timeout=8,
+        )
+
+        response = client.list_enabled_tools('rq_enabled_tools')
+
+        self.assertEqual('MCP_0000', response['code'])
+        self.assertEqual(
+            'http://tools.example.test/mcp/tools/listEnabledTools',
+            transport.calls[0]['url'],
+        )
+        self.assertEqual({}, transport.calls[0]['payload'])
+        self.assertEqual({
+            'Authorization': 'Bearer MT_demo',
+            'X-Request-Id': 'rq_enabled_tools',
+        }, transport.calls[0]['headers'])
+
     def test_query_order_tool_normalizes_input_before_forwarding(self):
         transport = DummyTransport()
         store = InMemoryTokenStore(refresh_skew_seconds=60)

+ 61 - 1
tests/test_gateway_runtime.py

@@ -26,6 +26,22 @@ class DummyAuthClient:
 class DummyApiClient:
     def __init__(self):
         self.calls = []
+        self.enabled_calls = 0
+        self.enabled_response = {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_order',
+                    'query_track',
+                    'query_order_exact',
+                    'list_order_filter_options',
+                ],
+            },
+        }
+
+    def list_enabled_tools(self, request_id=''):
+        self.enabled_calls += 1
+        return self.enabled_response
 
     def call_tool(self, tool_code, route_path, payload, request_id):
         self.calls.append(
@@ -63,9 +79,53 @@ class GatewayRuntimeTest(unittest.TestCase):
 
         self.assertIn('query_order', names)
         self.assertIn('query_track', names)
+        self.assertIn('query_order_exact', names)
+        self.assertIn('list_order_filter_options', names)
         self.assertNotIn('bind_auth_code', names)
         self.assertFalse(hasattr(app, 'bind'))
 
+    def test_list_tools_intersects_registry_codes_with_local_tools(self):
+        api_client = DummyApiClient()
+        api_client.enabled_response = {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': ['query_order_exact', 'unknown_tool'],
+            },
+        }
+        app = GatewayApp(api_client=api_client)
+
+        names = [tool['name'] for tool in app.list_tools()]
+
+        self.assertEqual(['query_order_exact'], names)
+        self.assertEqual(1, api_client.enabled_calls)
+
+    def test_list_tools_fails_closed_on_registry_error(self):
+        api_client = DummyApiClient()
+        api_client.enabled_response = {
+            'code': 'MCP_9001',
+            'msg': 'registry unavailable',
+            'data': {},
+        }
+        app = GatewayApp(api_client=api_client)
+
+        with self.assertRaisesRegex(RuntimeError, 'registry unavailable'):
+            app.list_tools()
+
+    def test_call_tool_rejects_dynamically_disabled_tool_before_forwarding(self):
+        store = InMemoryTokenStore(refresh_skew_seconds=60)
+        store.save('MT_valid', '2099-01-01T00:00:00')
+        api_client = DummyApiClient()
+        api_client.enabled_response = {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_track']},
+        }
+        app = GatewayApp(api_client=api_client, token_store=store)
+
+        with self.assertRaisesRegex(RuntimeError, 'tool disabled: query_order'):
+            app.call_tool('query_order', {'keyword': 'ORDER-1'})
+
+        self.assertEqual([], api_client.calls)
+
     def test_call_tool_refreshes_expiring_token_and_generates_request_id(self):
         store = InMemoryTokenStore(refresh_skew_seconds=60)
         expiring_time = (datetime.now() + timedelta(seconds=10)).isoformat(timespec='seconds')
@@ -88,4 +148,4 @@ class GatewayRuntimeTest(unittest.TestCase):
 
 
 if __name__ == '__main__':
-    unittest.main()
+    unittest.main()

+ 8 - 0
tests/test_gateway_session_store_unit.py

@@ -118,6 +118,14 @@ class TestGatewaySessionStore(unittest.TestCase):
 
         self.assertTrue(key.startswith('custom:prefix:session:'))
 
+    def test_touch_missing_session_returns_none(self):
+        self.mock_redis.get.return_value = None
+
+        result = self.store.touch_session('GWS_missing')
+
+        self.assertIsNone(result)
+        self.mock_redis.set.assert_not_called()
+
 
 if __name__ == '__main__':
     unittest.main()

+ 127 - 0
tests/test_list_order_filter_options_tool.py

@@ -0,0 +1,127 @@
+import unittest
+from io import StringIO
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from tools.list_order_filter_options import ListOrderFilterOptionsTool
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.last_call = None
+
+    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'}
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['list_order_filter_options']},
+        }
+
+
+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_order_filter_options']},
+        }
+
+
+class ListOrderFilterOptionsToolTest(unittest.TestCase):
+    def test_local_and_public_gateways_register_tool(self):
+        local_names = {
+            tool['name']
+            for tool in GatewayApp(api_client=RecordingApiClient()).list_tools()
+        }
+        public_names = {
+            tool['name']
+            for tool in PublicGatewayApp(
+                PublicSessionStore(),
+                PublicApiClient(),
+            ).list_tools('GWS_test')
+        }
+
+        self.assertIn('list_order_filter_options', local_names)
+        self.assertIn('list_order_filter_options', public_names)
+
+    def test_metadata_requires_filter_type_and_describes_all_types(self):
+        schema = ListOrderFilterOptionsTool().metadata()['input_schema']
+
+        self.assertEqual(['filter_type'], schema['required'])
+        self.assertEqual(
+            ['country', 'product', 'customer', 'sales', 'warehouse', 'department'],
+            schema['properties']['filter_type']['enum'],
+        )
+        self.assertIn('keyword', schema['properties'])
+
+    def test_call_normalizes_and_forwards_options_query(self):
+        client = RecordingApiClient()
+        tool = ListOrderFilterOptionsTool(api_client=client)
+
+        result = tool.call(
+            filter_type=' customer ',
+            keyword=' 客户A ',
+            page=0,
+            limit=200,
+            request_id='rq_options',
+        )
+
+        self.assertEqual({'code': 'MCP_0000'}, result)
+        self.assertEqual('list_order_filter_options', client.last_call['tool_code'])
+        self.assertEqual(
+            '/mcp/tools/listOrderFilterOptions',
+            client.last_call['route_path'],
+        )
+        self.assertEqual({
+            'filter_type': 'customer',
+            'keyword': '客户A',
+            'page': 1,
+            'limit': 100,
+        }, client.last_call['payload'])
+
+    def test_call_rejects_unknown_filter_type(self):
+        tool = ListOrderFilterOptionsTool(api_client=RecordingApiClient())
+
+        with self.assertRaisesRegex(ValueError, 'unsupported filter_type'):
+            tool.call('carrier')
+
+    def test_call_requires_api_client(self):
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            ListOrderFilterOptionsTool().call('country')
+
+    def test_cli_forwards_filter_type_and_optional_keyword(self):
+        client = RecordingApiClient()
+        output = StringIO()
+
+        exit_code = GatewayApp(api_client=client).run_cli([
+            'call',
+            '--tool', 'list_order_filter_options',
+            '--filter-type', 'customer',
+            '--keyword', '客户A',
+        ], stdout=output)
+
+        self.assertEqual(0, exit_code)
+        self.assertEqual({
+            'filter_type': 'customer',
+            'keyword': '客户A',
+            'page': 1,
+            'limit': 20,
+        }, client.last_call['payload'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 237 - 3
tests/test_mcp_protocol.py

@@ -11,6 +11,19 @@ class DummyApiClient:
     def __init__(self):
         self.calls = []
 
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_order',
+                    'query_track',
+                    'query_order_exact',
+                    'list_order_filter_options',
+                ],
+            },
+        }
+
     def call_tool(self, tool_code, route_path, payload, request_id):
         self.calls.append(
             {
@@ -37,6 +50,20 @@ class DummyApiClient:
             },
         }
 
+
+class BusinessErrorApiClient(DummyApiClient):
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        super().call_tool(tool_code, route_path, payload, request_id)
+        return {
+            'code': 'MCP_1301',
+            'msg': 'no order query permission',
+            'data': [],
+            'meta': {
+                'request_id': request_id,
+            },
+        }
+
+
 class FullColumnsApiClient(DummyApiClient):
     def call_tool(self, tool_code, route_path, payload, request_id):
         response = super().call_tool(tool_code, route_path, payload, request_id)
@@ -92,15 +119,142 @@ class FullColumnsApiClient(DummyApiClient):
 
 
 class McpProtocolTest(unittest.TestCase):
-    def build_handler(self):
+    def build_handler(self, api_client=None, reporter=None):
         token_store = InMemoryTokenStore(refresh_skew_seconds=60)
         token_store.save('MT_demo', '2099-01-01T00:00:00')
         app = GatewayApp(
             auth_client=None,
-            api_client=DummyApiClient(),
+            api_client=api_client or DummyApiClient(),
             token_store=token_store,
         )
-        return McpProtocolHandler(app)
+        return McpProtocolHandler(app, reporter=reporter)
+
+    def test_stdio_tool_call_emits_correlated_diagnostic_stages(self):
+        reporter = RecordingReporter()
+        handler = self.build_handler(reporter=reporter)
+
+        response = handler.handle_request({
+            'jsonrpc': '2.0',
+            'id': 10,
+            'method': 'tools/call',
+            'params': {
+                'name': 'query_order',
+                'arguments': {'keyword': 'SO20260706001'},
+            },
+        })
+
+        self.assertFalse(response['result']['isError'])
+        self.assertEqual(
+            [
+                'request_ingress',
+                'protocol_validation',
+                'backend_call',
+                'backend_call',
+                'response_safety',
+            ],
+            [event['stage'] for event in reporter.events],
+        )
+        self.assertEqual(1, len({event['request_id'] for event in reporter.events}))
+
+    def test_stdio_missing_tool_name_returns_invalid_params(self):
+        reporter = RecordingReporter()
+        response = self.build_handler(reporter=reporter).handle_request({
+            'jsonrpc': '2.0',
+            'id': 11,
+            'method': 'tools/call',
+            'params': {'arguments': {}},
+        })
+
+        self.assertIn('error', response)
+        self.assertEqual(-32602, response['error']['code'])
+        self.assertNotIn('result', response)
+        self.assertEqual(
+            ['request_ingress', 'protocol_validation'],
+            [event['stage'] for event in reporter.events],
+        )
+        self.assertEqual('failed', reporter.events[-1]['status'])
+        self.assertEqual(
+            'PARAM_VALIDATION_FAILED',
+            reporter.events[-1]['event_code'],
+        )
+
+    def test_stdio_reporter_failure_does_not_change_response(self):
+        class BrokenReporter:
+            def report(self, _event):
+                raise RuntimeError('support unavailable')
+
+        response = self.build_handler(reporter=BrokenReporter()).handle_request({
+            'jsonrpc': '2.0',
+            'id': 1,
+            'method': 'initialize',
+            'params': {},
+        })
+
+        self.assertIn('result', response)
+
+    def test_stdio_invalid_tool_result_emits_response_safety_failure(self):
+        class InvalidGateway:
+            def call_tool(self, name, arguments, request_id=''):
+                return []
+
+        reporter = RecordingReporter()
+        response = McpProtocolHandler(
+            InvalidGateway(),
+            reporter=reporter,
+        ).handle_request({
+            'jsonrpc': '2.0',
+            'id': 1,
+            'method': 'tools/call',
+            'params': {'name': 'query_order', 'arguments': {}},
+        })
+
+        self.assertTrue(response['result']['isError'])
+        event = next(
+            item for item in reporter.events
+            if item['stage'] == 'response_safety'
+        )
+        self.assertEqual('failed', event['status'])
+
+    def test_non_tool_exception_is_sanitized(self):
+        class ExplodingGateway:
+            def list_tools(self):
+                raise RuntimeError('database password leaked')
+
+        response = McpProtocolHandler(ExplodingGateway()).handle_request({
+            'jsonrpc': '2.0',
+            'id': 99,
+            'method': 'tools/list',
+        })
+
+        self.assertEqual(-32000, response['error']['code'])
+        self.assertEqual('Gateway request failed. Please try again later.', response['error']['message'])
+        self.assertNotIn('password', json.dumps(response))
+
+    def test_tool_exception_is_logged_and_correlated_without_raw_message(self):
+        class ExplodingGateway:
+            def call_tool(self, name, arguments, request_id=''):
+                raise RuntimeError('database password leaked')
+
+        handler = McpProtocolHandler(ExplodingGateway())
+        with self.assertLogs('mcp_protocol', level='ERROR') as logs:
+            response = handler.handle_request({
+                'jsonrpc': '2.0',
+                'id': 100,
+                'method': 'tools/call',
+                'params': {'name': 'query_track', 'arguments': {'tracking_number': 'TN1'}},
+            })
+
+        serialized = json.dumps(response)
+        self.assertTrue(response['result']['isError'])
+        self.assertNotIn('password', serialized)
+        record = logs.records[0]
+        self.assertEqual(100, record.jsonrpc_id)
+        self.assertTrue(record.request_id.startswith('rq_stdio_'))
+        self.assertEqual('query_track', record.tool_code)
+        self.assertEqual('MCP_9001', record.response_code)
+        self.assertEqual('UNEXPECTED_EXCEPTION', record.diagnostic_reason)
+        self.assertEqual('RuntimeError', record.exception_class)
+        self.assertEqual(record.request_id, response['result']['_meta']['request_id'])
 
     def test_initialize_returns_server_capabilities(self):
         handler = self.build_handler()
@@ -154,6 +308,15 @@ class McpProtocolTest(unittest.TestCase):
         self.assertIn('query_order', by_name)
         self.assertNotIn('bind_auth_code', by_name)
         self.assertIn('inputSchema', by_name['query_order'])
+        exact_schema = by_name['query_order_exact']['inputSchema']
+        self.assertIn(
+            '系统订单号',
+            exact_schema['properties']['order_number']['description'],
+        )
+        self.assertIsInstance(
+            exact_schema['properties']['order_numbers']['examples'][0],
+            list,
+        )
 
     def test_tools_call_wraps_gateway_result_as_structured_content(self):
         handler = self.build_handler()
@@ -181,6 +344,53 @@ class McpProtocolTest(unittest.TestCase):
         self.assertEqual('SO20260706001', response['result']['structuredContent']['records'][0]['order_no'])
         self.assertEqual('text', response['result']['content'][0]['type'])
         self.assertIn('matched 1 order', response['result']['content'][0]['text'])
+        self.assertTrue(
+            response['result']['structuredContent']['meta']['request_id'].startswith('rq_')
+        )
+
+    def test_business_error_is_tool_error_and_stdio_continues(self):
+        handler = self.build_handler(BusinessErrorApiClient())
+        stdin = io.StringIO(
+            json.dumps({
+                'jsonrpc': '2.0',
+                'id': 5,
+                'method': 'tools/call',
+                'params': {
+                    'name': 'query_order',
+                    'arguments': {'keyword': 'SO20260706001'},
+                },
+            })
+            + '\n'
+            + json.dumps({
+                'jsonrpc': '2.0',
+                'id': 6,
+                'method': 'initialize',
+                'params': {},
+            })
+            + '\n'
+        )
+        stdout = io.StringIO()
+
+        exit_code = handler.run_stdio(stdin=stdin, stdout=stdout)
+        responses = [json.loads(line) for line in stdout.getvalue().splitlines()]
+
+        self.assertEqual(0, exit_code)
+        self.assertEqual(2, len(responses))
+        self.assertNotIn('error', responses[0])
+        self.assertTrue(responses[0]['result']['isError'])
+        self.assertIn('MCP_1301', responses[0]['result']['content'][0]['text'])
+        self.assertEqual(
+            'MCP_1301',
+            responses[0]['result']['structuredContent']['code'],
+        )
+        self.assertEqual(
+            'no order query permission',
+            responses[0]['result']['structuredContent']['msg'],
+        )
+        self.assertTrue(
+            responses[0]['result']['structuredContent']['meta']['request_id'].startswith('rq_')
+        )
+        self.assertEqual('2025-06-18', responses[1]['result']['protocolVersion'])
 
     def test_tools_call_renders_all_query_order_columns_in_text_content(self):
         token_store = InMemoryTokenStore(refresh_skew_seconds=60)
@@ -219,6 +429,12 @@ class McpProtocolTest(unittest.TestCase):
                 response = super().call_tool(tool_code, route_path, payload, request_id)
                 response['data'] = {
                     'summary': '共查询到 1 条轨迹记录',
+                    'columns': [
+                        {'key': 'status', 'name': '轨迹节点'},
+                        {'key': 'location', 'name': '轨迹地点'},
+                        {'key': 'time', 'name': '时间'},
+                        {'key': 'content', 'name': '轨迹内容'},
+                    ],
                     'records': [
                         {
                             'status': '清关放行',
@@ -258,6 +474,15 @@ class McpProtocolTest(unittest.TestCase):
         self.assertIn('\\u6e05\\u5173\\u653e\\u884c', line)
         response = json.loads(line)
         self.assertIn('清关放行', response['result']['content'][0]['text'])
+        self.assertEqual(
+            ['轨迹节点', '轨迹地点', '时间', '轨迹内容'],
+            [
+                header['label']
+                for header in response['result']['structuredContent']['headers']
+            ],
+        )
+        self.assertNotIn('status', json.dumps(response['result'], ensure_ascii=False))
+        self.assertTrue(response['result']['_meta']['request_id'].startswith('rq_'))
 
     def test_run_stdio_writes_only_request_responses(self):
         handler = self.build_handler()
@@ -294,5 +519,14 @@ class McpProtocolTest(unittest.TestCase):
         self.assertEqual('2025-06-18', response['result']['protocolVersion'])
 
 
+class RecordingReporter:
+    def __init__(self):
+        self.events = []
+
+    def report(self, event):
+        self.events.append(event)
+        return True
+
+
 if __name__ == '__main__':
     unittest.main()

+ 412 - 0
tests/test_mcp_protocol_coverage.py

@@ -0,0 +1,412 @@
+"""
+Coverage补充测试:mcp_protocol.py
+
+目标路径:
+- run_stdio — ValueError 导致的 parse error 响应、空行跳过、非 dict message
+- handle_request — 未知 method、非 tools/call 的异常
+- _render_text — 无 columns/track 字段时的 json.dumps fallback
+- _render_text — records 存在但第一条无 status/content/time 时 fallback
+- _render_track_like_text — location / tracking_number / shipment_id / sub_track 可选字段
+- _render_track_like_text — 空 records + tips
+- _render_table_like_text — 空 records + tips
+"""
+import io
+import json
+import unittest
+
+from mcp_protocol import McpProtocolHandler
+
+
+# ---------------------------------------------------------------------------
+# _render_text 的 fallback 路径
+# ---------------------------------------------------------------------------
+
+class RenderTextFallbackTest(unittest.TestCase):
+    def test_empty_dict_returns_ok(self):
+        self.assertEqual('ok', McpProtocolHandler._render_text({}))
+
+    def test_none_returns_ok(self):
+        self.assertEqual('ok', McpProtocolHandler._render_text(None))
+
+    def test_empty_list_returns_ok(self):
+        # non-dict → 走 "not structured_content" 分支
+        self.assertEqual('ok', McpProtocolHandler._render_text([]))
+
+    def test_dict_without_columns_or_track_fields_falls_back_to_json_dumps(self):
+        """有 records 但第一条没有 status/content/time → 走 json.dumps fallback。"""
+        content = {'records': [{'order_no': 'SO001', 'qty': 10}]}
+        result = McpProtocolHandler._render_text(content)
+        # 应该是 json.dumps 的输出,包含键名
+        self.assertIn('order_no', result)
+        self.assertIn('SO001', result)
+
+    def test_dict_with_no_records_falls_back_to_json_dumps(self):
+        """有 columns 但 records 是 None(不是 list)→ columns 是 list 但 records 不是 → fallback。"""
+        content = {'columns': [{'key': 'no', 'name': '编号'}], 'records': None}
+        result = McpProtocolHandler._render_text(content)
+        self.assertIn('columns', result)
+
+    def test_plain_dict_no_columns_no_records_falls_back_to_json(self):
+        content = {'foo': 'bar', 'count': 42}
+        result = McpProtocolHandler._render_text(content)
+        self.assertIn('foo', result)
+        self.assertIn('bar', result)
+
+    def test_records_empty_list_no_columns_falls_back_to_json(self):
+        """records 是空 list(falsy),columns 是 None → 第二个条件 `records` falsy → fallback。"""
+        content = {'records': []}
+        result = McpProtocolHandler._render_text(content)
+        # json.dumps 输出
+        self.assertIn('records', result)
+
+
+# ---------------------------------------------------------------------------
+# _render_table_like_text 的 tips / 空 records 路径
+# ---------------------------------------------------------------------------
+
+class RenderTableEmptyRecordsTest(unittest.TestCase):
+    def test_empty_records_with_tips_shows_tips(self):
+        content = {
+            'columns': [{'key': 'no', 'name': '编号'}, {'key': 'name', 'name': '名称'}],
+            'records': [],
+            'tips': ['暂无数据', '请调整关键词'],
+        }
+        result = McpProtocolHandler._render_text(content)
+        self.assertIn('表头共 2 列:', result)
+        self.assertIn('1. 编号 (no)', result)
+        self.assertIn('提示:暂无数据;请调整关键词', result)
+
+    def test_records_with_none_value_renders_empty_string(self):
+        content = {
+            'columns': [{'key': 'k', 'name': '键'}],
+            'records': [{'k': None}],
+        }
+        result = McpProtocolHandler._render_text(content)
+        self.assertIn('- 键:', result)
+
+
+# ---------------------------------------------------------------------------
+# _render_track_like_text 的可选字段
+# ---------------------------------------------------------------------------
+
+class RenderTrackOptionalFieldsTest(unittest.TestCase):
+    """每个可选字段(location / tracking_number / shipment_id / sub_track)单独覆盖。"""
+
+    def _call(self, **extra):
+        record = {'status': '清关放行', 'content': '启运港放行', 'time': '2026-07-07 10:00:00'}
+        record.update(extra)
+        content = {'records': [record]}
+        return McpProtocolHandler._render_text(content)
+
+    def test_location_rendered_when_present(self):
+        result = self._call(location='宁波港')
+        self.assertIn('- 地点: 宁波港', result)
+
+    def test_location_omitted_when_empty(self):
+        result = self._call(location='')
+        self.assertNotIn('地点', result)
+
+    def test_location_omitted_when_absent(self):
+        result = self._call()
+        self.assertNotIn('地点', result)
+
+    def test_tracking_number_rendered_when_present(self):
+        result = self._call(tracking_number='TN1234567890')
+        self.assertIn('- 快递单号: TN1234567890', result)
+
+    def test_tracking_number_omitted_when_empty(self):
+        result = self._call(tracking_number='')
+        self.assertNotIn('快递单号', result)
+
+    def test_shipment_id_rendered_when_present(self):
+        result = self._call(shipment_id='SHP-9900')
+        self.assertIn('- Shipment ID: SHP-9900', result)
+
+    def test_shipment_id_omitted_when_empty(self):
+        result = self._call(shipment_id='')
+        self.assertNotIn('Shipment ID', result)
+
+    def test_sub_track_rendered_when_truthy(self):
+        result = self._call(sub_track=1)
+        self.assertIn('- 子单轨迹: 是', result)
+
+    def test_sub_track_omitted_when_zero(self):
+        result = self._call(sub_track=0)
+        self.assertNotIn('子单轨迹', result)
+
+    def test_sub_track_omitted_when_absent(self):
+        result = self._call()
+        self.assertNotIn('子单轨迹', result)
+
+    def test_all_optional_fields_together(self):
+        result = self._call(
+            location='上海港',
+            tracking_number='YT123',
+            shipment_id='SHP001',
+            sub_track=2,
+        )
+        self.assertIn('地点: 上海港', result)
+        self.assertIn('快递单号: YT123', result)
+        self.assertIn('Shipment ID: SHP001', result)
+        self.assertIn('子单轨迹: 是', result)
+
+    def test_track_with_summary_and_tips(self):
+        content = {
+            'summary': '共1条轨迹',
+            'records': [
+                {'status': '已签收', 'content': '已签收', 'time': '2026-07-08 12:00:00'}
+            ],
+            'tips': ['仅展示最新轨迹'],
+        }
+        result = McpProtocolHandler._render_text(content)
+        self.assertIn('共1条轨迹', result)
+        self.assertIn('已签收', result)
+        self.assertIn('提示:仅展示最新轨迹', result)
+
+    def test_multiple_track_records(self):
+        content = {
+            'records': [
+                {'status': '已发货', 'content': '揽件', 'time': '2026-07-06 09:00:00'},
+                {'status': '已签收', 'content': '签收', 'time': '2026-07-08 12:00:00'},
+            ],
+        }
+        result = McpProtocolHandler._render_text(content)
+        self.assertIn('轨迹 1:', result)
+        self.assertIn('轨迹 2:', result)
+        self.assertIn('已发货', result)
+        self.assertIn('已签收', result)
+
+
+# ---------------------------------------------------------------------------
+# run_stdio — parse error 分支
+# ---------------------------------------------------------------------------
+
+class RunStdioParseErrorTest(unittest.TestCase):
+    def test_invalid_json_emits_parse_error_response(self):
+        """无效 JSON 行 → 写出 -32700 Parse error 响应。"""
+        handler = McpProtocolHandler(None)
+        stdin = io.StringIO('not-valid-json\n')
+        stdout = io.StringIO()
+
+        handler.run_stdio(stdin=stdin, stdout=stdout)
+
+        line = stdout.getvalue().strip()
+        self.assertTrue(line, 'expected a response line on parse error')
+        response = json.loads(line)
+        self.assertIn('error', response)
+        self.assertEqual(-32700, response['error']['code'])
+        self.assertEqual('Parse error', response['error']['message'])
+        self.assertIsNone(response['id'])
+
+    def test_blank_lines_are_skipped(self):
+        handler = McpProtocolHandler(None)
+        stdin = io.StringIO('\n   \n\t\n')
+        stdout = io.StringIO()
+
+        handler.run_stdio(stdin=stdin, stdout=stdout)
+
+        self.assertEqual('', stdout.getvalue().strip())
+
+    def test_mixed_valid_and_invalid_lines(self):
+        """有效请求和无效行混合;两者都产生输出。"""
+        handler = McpProtocolHandler(None)
+        stdin = io.StringIO(
+            'bad-json\n'
+            + json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'initialize', 'params': {}})
+            + '\n'
+        )
+        stdout = io.StringIO()
+        # initialize 需要 gateway_app,这里简单 patch
+        from unittest.mock import MagicMock
+        handler.gateway_app = MagicMock()
+        handler.gateway_app.list_tools.return_value = []
+
+        handler.run_stdio(stdin=stdin, stdout=stdout)
+
+        lines = [l for l in stdout.getvalue().splitlines() if l.strip()]
+        # parse error + initialize response = 2 lines
+        self.assertEqual(2, len(lines))
+        parse_err = json.loads(lines[0])
+        self.assertEqual(-32700, parse_err['error']['code'])
+
+    def test_stdout_without_flush_writes_all_responses(self):
+        class WriteOnlyOutput:
+            def __init__(self):
+                self.values = []
+
+            def write(self, value):
+                self.values.append(value)
+
+        stdout = WriteOnlyOutput()
+        handler = McpProtocolHandler(None)
+
+        result = handler.run_stdio(
+            stdin=io.StringIO('bad-json\nstill-bad\n'),
+            stdout=stdout,
+        )
+
+        self.assertEqual(0, result)
+        self.assertEqual(2, len(stdout.values))
+
+
+class ProtocolResponseBoundaryTest(unittest.TestCase):
+    def test_normalize_tool_preserves_metadata_without_input_schema(self):
+        tool = {'name': 'plain_tool', 'description': 'plain'}
+
+        self.assertEqual(tool, McpProtocolHandler(None)._normalize_tool(tool))
+
+    def test_tool_call_response_rejects_non_dict(self):
+        with self.assertRaisesRegex(RuntimeError, 'invalid tool response'):
+            McpProtocolHandler._tool_call_response('1', 'query_order', 'invalid')
+
+    def test_success_response_wraps_non_dict_data(self):
+        response = McpProtocolHandler._tool_call_response(
+            '1',
+            'query_order',
+            {'code': 'MCP_0000', 'data': ['A']},
+        )
+
+        self.assertEqual(
+            {'data': ['A']},
+            response['result']['structuredContent'],
+        )
+
+    def test_success_response_uses_empty_structured_content_without_data(self):
+        response = McpProtocolHandler._tool_call_response(
+            '1',
+            'query_order',
+            {'code': '0', 'data': None},
+        )
+
+        self.assertEqual({}, response['result']['structuredContent'])
+        self.assertEqual('ok', response['result']['content'][0]['text'])
+
+    def test_error_response_includes_data_without_meta(self):
+        response = McpProtocolHandler._tool_call_response(
+            '1',
+            'query_order',
+            {'code': 'MCP_9001', 'msg': 'failed', 'data': ['detail']},
+        )
+
+        structured = response['result']['structuredContent']
+        self.assertEqual(['detail'], structured['data'])
+        self.assertNotIn('meta', structured)
+        self.assertTrue(response['result']['isError'])
+
+    def test_empty_table_without_tips_returns_headers(self):
+        result = McpProtocolHandler._render_text({
+            'columns': [{'key': 'number'}],
+            'records': [],
+        })
+
+        self.assertIn('1. number (number)', result)
+        self.assertNotIn('提示', result)
+
+    def test_empty_track_with_tips_returns_tip(self):
+        result = McpProtocolHandler._render_track_like_text(
+            {'tips': ['暂无轨迹']},
+            [],
+        )
+
+        self.assertEqual('提示:暂无轨迹', result)
+
+    def test_empty_track_without_tips_returns_empty_text(self):
+        result = McpProtocolHandler._render_track_like_text({}, [])
+
+        self.assertEqual('', result)
+
+
+# ---------------------------------------------------------------------------
+# handle_message / handle_request 边界情况
+# ---------------------------------------------------------------------------
+
+class HandleMessageEdgeCasesTest(unittest.TestCase):
+    def test_non_dict_message_returns_invalid_request_error(self):
+        handler = McpProtocolHandler(None)
+        response = handler.handle_message('not a dict')
+        self.assertEqual(-32600, response['error']['code'])
+
+    def test_notification_with_unknown_method_returns_none(self):
+        handler = McpProtocolHandler(None)
+        # 无 id → 通知,非 notifications/initialized → returns None
+        response = handler.handle_message({'jsonrpc': '2.0', 'method': 'some/notification'})
+        self.assertIsNone(response)
+
+    def test_unknown_request_method_returns_method_not_found(self):
+        handler = McpProtocolHandler(None)
+        response = handler.handle_request({'id': 1, 'method': 'unknown/method'})
+        self.assertEqual(-32601, response['error']['code'])
+        self.assertIn('Method not found', response['error']['message'])
+
+    def test_non_tools_call_exception_returns_error(self):
+        """tools/list 抛异常 → 返回 error 响应(非 isError 内容)。"""
+        from unittest.mock import MagicMock
+        handler = McpProtocolHandler(MagicMock())
+        handler.gateway_app.list_tools.side_effect = RuntimeError('backend error')
+
+        with self.assertLogs('mcp_protocol', level='ERROR') as logs:
+            response = handler.handle_request({'id': 5, 'method': 'tools/list'})
+        self.assertIn('error', response)
+        self.assertEqual(-32000, response['error']['code'])
+        self.assertEqual('Gateway request failed. Please try again later.', response['error']['message'])
+        self.assertNotIn('backend error', json.dumps(response))
+        record = logs.records[0]
+        self.assertEqual(5, record.jsonrpc_id)
+        self.assertTrue(record.request_id.startswith('rq_stdio_'))
+        self.assertEqual(response['error']['data']['request_id'], record.request_id)
+        self.assertEqual('tools/list', record.protocol_method)
+        self.assertEqual('', record.tool_code)
+        self.assertEqual(-32000, record.protocol_code)
+        self.assertEqual('UNEXPECTED_EXCEPTION', record.diagnostic_reason)
+
+    def test_tools_call_exception_returns_is_error_content(self):
+        """tools/call 抛异常 → 返回 isError: true 的 content。"""
+        from unittest.mock import MagicMock
+        handler = McpProtocolHandler(MagicMock())
+        handler.gateway_app.call_tool.side_effect = RuntimeError('tool failed')
+
+        response = handler.handle_request({
+            'id': 6,
+            'method': 'tools/call',
+            'params': {'name': 'query_order', 'arguments': {}},
+        })
+        self.assertEqual(False, 'error' in response)
+        self.assertTrue(response['result']['isError'])
+        self.assertIn('tool failed', response['result']['content'][0]['text'])
+
+    def test_tools_call_with_non_object_params_fails_safely(self):
+        handler = McpProtocolHandler(None)
+
+        response = handler.handle_request({
+            'id': 7,
+            'method': 'tools/call',
+            'params': 'not-an-object',
+        })
+
+        self.assertTrue(response['result']['isError'])
+        self.assertEqual(
+            '工具返回格式异常',
+            response['result']['structuredContent']['message'],
+        )
+
+    def test_tools_call_with_non_string_tool_name_fails_safely(self):
+        from unittest.mock import MagicMock
+
+        handler = McpProtocolHandler(MagicMock())
+
+        for tool_name in ([], {}):
+            with self.subTest(tool_name=tool_name):
+                response = handler.handle_request({
+                    'id': 8,
+                    'method': 'tools/call',
+                    'params': {'name': tool_name, 'arguments': {}},
+                })
+
+                self.assertEqual(-32602, response['error']['code'])
+                self.assertNotIn('result', response)
+
+        handler.gateway_app.call_tool.assert_not_called()
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 481 - 0
tests/test_order_detail_tool.py

@@ -0,0 +1,481 @@
+import json
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.query_order_detail import QueryOrderDetailTool
+
+
+class RecordingClient:
+    def __init__(self):
+        self.calls = []
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.calls.append((tool_code, route_path, payload, request_id))
+        return {'code': 'MCP_0000', 'data': {}, 'meta': {}}
+
+
+class OrderDetailToolTest(unittest.TestCase):
+    def test_schema_uses_only_chinese_sections_and_rejects_extra_fields(self):
+        schema = QueryOrderDetailTool().metadata()['input_schema']
+        self.assertEqual(['order_number'], schema['required'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual([
+            '订单概览', '箱单信息', '箱单商品', 'DW授权信息', '附件信息',
+            '入库信息', '查验信息', '订单轨迹', '操作日志',
+            '应收与结算日志', '派送信息', '全部',
+        ], schema['properties']['section']['enum'])
+        self.assertNotIn('order_id', schema['properties'])
+        self.assertNotIn('company_id', schema['properties'])
+        self.assertEqual('全部', schema['properties']['section']['default'])
+
+    def test_call_forwards_normalized_payload_to_exact_route(self):
+        client = RecordingClient()
+        result = QueryOrderDetailTool(client).call(
+            order_number=' ORD001 ', section='附件信息', page=2, limit=10,
+            request_id='rq_detail',
+        )
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual([(
+            'query_order_detail', '/mcp/tools/queryOrderDetail',
+            {'order_number': 'ORD001', 'section': '附件信息', 'page': 2, 'limit': 10},
+            'rq_detail',
+        )], client.calls)
+
+    def test_call_rejects_missing_invalid_and_out_of_range_parameters(self):
+        tool = QueryOrderDetailTool()
+        with self.assertRaisesRegex(RuntimeError, 'api client'):
+            tool.call(order_number='ORD001')
+
+        tool = QueryOrderDetailTool(RecordingClient())
+        invalid_calls = (
+            ({'order_number': None}, 'order_number'),
+            ({'order_number': ' '}, 'order_number'),
+            ({'order_number': 'A' * 101}, 'order_number'),
+            ({'order_number': 'ORD001', 'section': None}, 'section'),
+            ({'order_number': 'ORD001', 'section': 'unknown'}, 'section'),
+            ({'order_number': 'ORD001', 'page': True}, 'page'),
+            ({'order_number': 'ORD001', 'page': '01'}, 'page'),
+            ({'order_number': 'ORD001', 'page': 'x'}, 'page'),
+            ({'order_number': 'ORD001', 'limit': 0}, 'limit'),
+            ({'order_number': 'ORD001', 'limit': 101}, 'limit'),
+        )
+        for kwargs, expected in invalid_calls:
+            with self.subTest(kwargs=kwargs):
+                with self.assertRaisesRegex(ValueError, expected):
+                    tool.call(**kwargs)
+
+        tool.call(order_number='ORD001', page='2', limit='100')
+        self.assertEqual(2, tool.api_client.calls[-1][2]['page'])
+        self.assertEqual(100, tool.api_client.calls[-1][2]['limit'])
+
+    def test_call_forwards_all_section_without_changing_pagination_contract(self):
+        client = RecordingClient()
+        QueryOrderDetailTool(client).call(
+            order_number='ORD001', section='全部', page=1, limit=20,
+            request_id='rq_all',
+        )
+        self.assertEqual({
+            'order_number': 'ORD001', 'section': '全部', 'page': 1, 'limit': 20,
+        }, client.calls[0][2])
+
+    def test_call_defaults_to_all_when_section_is_omitted(self):
+        client = RecordingClient()
+        QueryOrderDetailTool(client).call(order_number='ORD001')
+        self.assertEqual('全部', client.calls[0][2]['section'])
+
+    def test_local_and_public_registries_include_order_detail(self):
+        local = GatewayApp(api_client=object())
+        public = PublicGatewayApp(session_store=object(), api_client=object())
+        self.assertIn('query_order_detail', local.registered_tool_names())
+        self.assertIn('query_order_detail', public.registered_tool_names())
+
+    def test_presenter_outputs_attachment_links_with_only_chinese_keys(self):
+        presenter = OutputPresenter()
+        result = presenter.present('query_order_detail', {
+            'code': 'MCP_0000',
+            'data': {
+                'section': 'attachments',
+                'order_number': 'ORD001',
+                'payload': {'records': [{
+                    'category': '订单附件', 'file_name': 'photo.jpg',
+                    'file_extension': 'jpg', 'is_image': True,
+                    'preview_url': 'https://files.example/photo.jpg',
+                    'download_url': 'https://files.example/download/photo.jpg',
+                    'cost_name': '',
+                }]},
+            },
+            'meta': {'request_id': 'rq_detail', 'page': 1, 'limit': 20, 'has_more': False},
+        })
+        self.assertFalse(result['is_error'])
+        content = result['structured_content']
+        self.assertEqual('ORD001', content['订单号'])
+        self.assertEqual('附件信息', content['详情模块'])
+        self.assertEqual('photo.jpg', content['明细'][0]['文件名称'])
+        self.assertEqual('https://files.example/photo.jpg', content['明细'][0]['预览链接'])
+        serialized = json.dumps(content, ensure_ascii=False) + result['text']
+        for internal in ('order_number', 'section', 'payload', 'file_name', 'preview_url', 'page', 'limit', 'has_more'):
+            self.assertNotIn(internal, serialized)
+
+    def test_presenter_outputs_complete_overview_and_translates_enums(self):
+        presenter = OutputPresenter()
+        result = presenter.present('query_order_detail', {
+            'code': 'MCP_0000',
+            'data': {
+                'section': 'overview', 'order_number': 'ORD001',
+                'payload': {
+                    'status_nodes': [{
+                        'stage': '起运段', 'label': '已提交', 'state': 'completed',
+                        'occurred_at': '2026-01-01', 'time_kind': 'actual',
+                    }],
+                    'order_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['order_info']),
+                    'freight_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['freight_info']),
+                    'package_summary': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['package_summary']),
+                    'trader_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['trader_info']),
+                },
+            },
+            'meta': {'request_id': 'rq_overview'},
+        })
+        self.assertFalse(result['is_error'])
+        content = result['structured_content']
+        self.assertEqual('已完成', content['状态节点'][0]['状态'])
+        self.assertEqual('实际', content['状态节点'][0]['时间类型'])
+        self.assertEqual(
+            ['订单号', '详情模块', '状态节点', '订单信息', '货运信息', '箱单汇总', '进出口商'],
+            list(content.keys()),
+        )
+
+    def test_presenter_outputs_all_sections_as_strict_grouped_content(self):
+        presenter = OutputPresenter()
+        payload = self.valid_all_payload(presenter)
+
+        result = presenter.present('query_order_detail', {
+            'code': 'MCP_0000',
+            'data': {'section': 'all', 'order_number': 'ORD001', 'payload': payload},
+            'meta': {'request_id': 'rq_all'},
+        })
+        self.assertFalse(result['is_error'])
+        content = result['structured_content']
+        self.assertEqual('全部', content['详情模块'])
+        self.assertIn('订单概览', content)
+        self.assertIn('箱单信息', content)
+        self.assertIn('分页', content['箱单信息'])
+        serialized = json.dumps(content, ensure_ascii=False) + result['text']
+        for internal in ('order_number', 'section', 'payload', 'preview_url', 'has_more'):
+            self.assertNotIn(internal, serialized)
+
+    def valid_all_payload(self, presenter):
+        overview = {
+            'status_nodes': [{
+                'stage': '起运段', 'label': '已提交', 'state': 'completed',
+                'occurred_at': '2026-01-01', 'time_kind': 'actual',
+            }],
+            'order_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['order_info']),
+            'freight_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['freight_info']),
+            'package_summary': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['package_summary']),
+            'trader_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['trader_info']),
+        }
+        pagination = {'page': 1, 'limit': 20, 'has_more': False}
+        payload = {'overview': overview}
+        for section in presenter.ORDER_DETAIL_SECTIONS:
+            if section in ('overview', 'all'):
+                continue
+            if section == 'inbound':
+                value = {
+                    'summary': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['inbound_summary']),
+                    'records': [],
+                }
+            elif section == 'delivery':
+                value = {
+                    'summary': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['delivery_summary']),
+                    'records': [],
+                }
+            else:
+                value = {'records': []}
+            value['pagination'] = dict(pagination)
+            payload[section] = value
+        return payload
+
+    def test_presenter_rejects_malformed_all_section_groups(self):
+        presenter = OutputPresenter()
+        cases = []
+
+        missing = self.valid_all_payload(presenter)
+        del missing['checks']
+        cases.append(missing)
+
+        bad_overview = self.valid_all_payload(presenter)
+        bad_overview['overview'] = []
+        cases.append(bad_overview)
+
+        bad_node = self.valid_all_payload(presenter)
+        bad_node['overview']['status_nodes'][0]['state'] = 'unknown'
+        cases.append(bad_node)
+
+        bad_node_fields = self.valid_all_payload(presenter)
+        del bad_node_fields['overview']['status_nodes'][0]['stage']
+        cases.append(bad_node_fields)
+
+        bad_group = self.valid_all_payload(presenter)
+        bad_group['overview']['order_info']['unexpected'] = ''
+        cases.append(bad_group)
+
+        bad_pagination = self.valid_all_payload(presenter)
+        bad_pagination['packages']['pagination']['page'] = True
+        cases.append(bad_pagination)
+
+        missing_pagination = self.valid_all_payload(presenter)
+        del missing_pagination['packages']['pagination']
+        cases.append(missing_pagination)
+
+        bad_inbound_keys = self.valid_all_payload(presenter)
+        bad_inbound_keys['inbound']['extra'] = ''
+        cases.append(bad_inbound_keys)
+
+        bad_inbound_summary = self.valid_all_payload(presenter)
+        bad_inbound_summary['inbound']['summary']['extra'] = ''
+        cases.append(bad_inbound_summary)
+
+        bad_delivery_keys = self.valid_all_payload(presenter)
+        bad_delivery_keys['delivery']['extra'] = ''
+        cases.append(bad_delivery_keys)
+
+        bad_delivery_summary = self.valid_all_payload(presenter)
+        bad_delivery_summary['delivery']['summary']['extra'] = ''
+        cases.append(bad_delivery_summary)
+
+        bad_rows = self.valid_all_payload(presenter)
+        bad_rows['packages']['records'] = {}
+        cases.append(bad_rows)
+
+        bad_generic_keys = self.valid_all_payload(presenter)
+        bad_generic_keys['packages']['extra'] = ''
+        cases.append(bad_generic_keys)
+
+        for payload in cases:
+            with self.subTest(payload_keys=list(payload)):
+                result = presenter.present('query_order_detail', {
+                    'code': 'MCP_0000',
+                    'data': {'section': 'all', 'order_number': 'ORD001', 'payload': payload},
+                    'meta': {'request_id': 'rq_all_bad'},
+                })
+                self.assertTrue(result['is_error'])
+
+    def test_presenter_outputs_inbound_and_delivery_sections(self):
+        presenter = OutputPresenter()
+        inbound_mapping = presenter.ORDER_DETAIL_FIELDS['inbound']
+        inbound_rows = []
+        for mode, expected_label in (
+            ('single_box', '单箱入库重量'),
+            ('total', '总重量KG'),
+        ):
+            row = self.fixed_values(inbound_mapping)
+            row['weight_mode'] = mode
+            row['weight'] = '12.50'
+            inbound_rows.append(row)
+        inbound = presenter.present('query_order_detail', {
+            'code': 'MCP_0000',
+            'data': {
+                'section': 'inbound', 'order_number': ' ORD001 ',
+                'payload': {
+                    'summary': self.fixed_values(
+                        presenter.ORDER_DETAIL_FIELDS['inbound_summary']
+                    ),
+                    'records': inbound_rows,
+                },
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': True},
+        })
+        self.assertFalse(inbound['is_error'])
+        self.assertEqual('ORD001', inbound['structured_content']['订单号'])
+        for index, label in enumerate(('单箱入库重量', '总重量KG')):
+            self.assertEqual('12.50', inbound['structured_content']['明细'][index][label])
+
+        delivery = presenter.present('query_order_detail', {
+            'code': 'MCP_0000',
+            'data': {
+                'section': 'delivery', 'order_number': 'ORD001',
+                'payload': {
+                    'summary': self.fixed_values(
+                        presenter.ORDER_DETAIL_FIELDS['delivery_summary']
+                    ),
+                    'records': [self.fixed_values(
+                        presenter.ORDER_DETAIL_FIELDS['delivery']
+                    )],
+                },
+            },
+            'meta': {'page': 2, 'limit': 10, 'has_more': False},
+        })
+        self.assertFalse(delivery['is_error'])
+        self.assertIn('派送汇总', delivery['structured_content'])
+
+    def test_package_item_product_image_is_exposed_as_count_only(self):
+        presenter = OutputPresenter()
+        mapping = presenter.ORDER_DETAIL_FIELDS['package_items']
+        self.assertEqual('商品图片数量', mapping.get('product_image_count'))
+        row = self.fixed_values(mapping)
+        row['product_image_count'] = 1
+        result = presenter.present('query_order_detail', {
+            'code': 'MCP_0000',
+            'data': {
+                'section': 'package_items', 'order_number': 'ORD001',
+                'payload': {'records': [row]},
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        })
+        self.assertFalse(result['is_error'])
+        self.assertEqual(1, result['structured_content']['明细'][0]['商品图片数量'])
+        self.assertNotIn('image_url', json.dumps(result, ensure_ascii=False))
+
+    def test_unknown_missing_or_extra_fields_fail_closed(self):
+        presenter = OutputPresenter()
+        cases = [
+            {'section': 'secret', 'order_number': 'ORD001', 'payload': {'records': []}},
+            {'section': 'attachments', 'order_number': 'ORD001', 'payload': {'records': [{'category': 'x'}]}},
+            {'section': 'attachments', 'order_number': 'ORD001', 'payload': {'records': [{
+                'category': '', 'file_name': '', 'file_extension': '', 'is_image': False,
+                'preview_url': '', 'download_url': '', 'cost_name': '', 'secret_id': 9,
+            }]}},
+        ]
+        for data in cases:
+            with self.subTest(data=data):
+                result = presenter.present('query_order_detail', {
+                    'code': 'MCP_0000', 'data': data, 'meta': {},
+                })
+                self.assertTrue(result['is_error'])
+                self.assertNotIn('secret_id', json.dumps(result, ensure_ascii=False))
+
+    def test_presenter_rejects_every_invalid_order_detail_shape(self):
+        presenter = OutputPresenter()
+        valid_meta = {'page': 1, 'limit': 20, 'has_more': False}
+        overview_groups = {
+            'status_nodes': [],
+            'order_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['order_info']),
+            'freight_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['freight_info']),
+            'package_summary': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['package_summary']),
+            'trader_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['trader_info']),
+        }
+        invalid_results = [
+            presenter._present_order_detail({'section': 'attachments'}, valid_meta, {}),
+            presenter._present_order_detail(
+                {'section': 'unknown', 'order_number': 'ORD', 'payload': {}}, valid_meta, {}
+            ),
+            presenter._present_order_detail(
+                {'section': 'attachments', 'order_number': 1, 'payload': {}}, valid_meta, {}
+            ),
+            presenter._present_order_detail(
+                {'section': 'attachments', 'order_number': ' ', 'payload': {}}, valid_meta, {}
+            ),
+            presenter._present_order_detail(
+                {'section': 'attachments', 'order_number': 'ORD', 'payload': []}, valid_meta, {}
+            ),
+            presenter._present_order_detail(
+                {'section': 'overview', 'order_number': 'ORD', 'payload': {'status_nodes': []}}, {}, {}
+            ),
+            presenter._present_order_detail(
+                {'section': 'overview', 'order_number': 'ORD', 'payload': dict(overview_groups, status_nodes={})}, {}, {}
+            ),
+        ]
+
+        bad_node_groups = dict(overview_groups)
+        bad_node_groups['status_nodes'] = [{'stage': 'x'}]
+        invalid_results.append(presenter._present_order_detail(
+            {'section': 'overview', 'order_number': 'ORD', 'payload': bad_node_groups}, {}, {}
+        ))
+        bad_enum_groups = dict(overview_groups)
+        bad_enum_groups['status_nodes'] = [{
+            'stage': 'x', 'label': 'x', 'state': 'secret',
+            'occurred_at': '', 'time_kind': 'empty',
+        }]
+        invalid_results.append(presenter._present_order_detail(
+            {'section': 'overview', 'order_number': 'ORD', 'payload': bad_enum_groups}, {}, {}
+        ))
+        bad_group = dict(overview_groups)
+        bad_group['order_info'] = {}
+        invalid_results.append(presenter._present_order_detail(
+            {'section': 'overview', 'order_number': 'ORD', 'payload': bad_group}, {}, {}
+        ))
+
+        for section, summary_key in (
+            ('inbound', 'inbound_summary'),
+            ('delivery', 'delivery_summary'),
+        ):
+            mapping = presenter.ORDER_DETAIL_FIELDS[summary_key]
+            invalid_results.extend([
+                presenter._present_order_detail(
+                    {'section': section, 'order_number': 'ORD', 'payload': {'records': []}}, valid_meta, {}
+                ),
+                presenter._present_order_detail(
+                    {'section': section, 'order_number': 'ORD', 'payload': {'summary': {}, 'records': []}}, valid_meta, {}
+                ),
+                presenter._present_order_detail(
+                    {'section': section, 'order_number': 'ORD', 'payload': {
+                        'summary': self.fixed_values(mapping), 'records': []
+                    }}, {}, {}
+                ),
+            ])
+
+        invalid_results.extend([
+            presenter._present_order_detail(
+                {'section': 'attachments', 'order_number': 'ORD', 'payload': {}}, valid_meta, {}
+            ),
+            presenter._present_order_detail(
+                {'section': 'attachments', 'order_number': 'ORD', 'payload': {'records': {}}}, valid_meta, {}
+            ),
+            presenter._present_order_detail(
+                {'section': 'attachments', 'order_number': 'ORD', 'payload': {'records': []}}, {}, {}
+            ),
+        ])
+        self.assertTrue(all(result['is_error'] for result in invalid_results))
+
+    def test_order_detail_translation_helpers_reject_nested_and_invalid_values(self):
+        presenter = OutputPresenter()
+        self.assertIsNone(presenter._translate_order_detail_rows('attachments', {}))
+        self.assertIsNone(presenter._translate_order_detail_rows('unknown', []))
+        self.assertIsNone(presenter._translate_order_detail_rows('attachments', [{}]))
+
+        inbound = self.fixed_values(presenter.ORDER_DETAIL_FIELDS['inbound'])
+        inbound['weight_mode'] = 'invalid'
+        self.assertIsNone(presenter._translate_order_detail_rows('inbound', [inbound]))
+
+        self.assertIsNone(presenter._translate_exact([], {'key': '标签'}))
+        self.assertIsNone(presenter._translate_exact({'wrong': 1}, {'key': '标签'}))
+        self.assertIsNone(presenter._translate_exact({'key': []}, {'key': '标签'}))
+        self.assertEqual({'标签': ''}, presenter._translate_exact({'key': None}, {'key': '标签'}))
+        self.assertEqual({}, presenter._translate_exact({'key': 'hidden'}, {'key': None}))
+
+        invalid_meta = (
+            None,
+            {},
+            {'page': True, 'limit': 20, 'has_more': False},
+            {'page': 0, 'limit': 20, 'has_more': False},
+            {'page': 1, 'limit': True, 'has_more': False},
+            {'page': 1, 'limit': 0, 'has_more': False},
+            {'page': 1, 'limit': 20, 'has_more': 1},
+        )
+        for meta in invalid_meta:
+            with self.subTest(meta=meta):
+                self.assertIsNone(presenter._order_detail_pagination(meta))
+
+    def test_parameter_errors_use_chinese_business_labels(self):
+        presenter = OutputPresenter()
+        for backend, expected in (
+            ('order_number is required', '订单号参数不正确'),
+            ('section is invalid', '详情模块参数不正确'),
+            ('page is invalid', '页码参数不正确'),
+            ('limit is invalid', '每页数量参数不正确'),
+        ):
+            result = presenter.present('query_order_detail', {
+                'code': 'MCP_1401', 'msg': backend, 'data': {},
+            })
+            self.assertEqual(expected, result['structured_content']['message'])
+            self.assertNotIn(backend.split()[0], json.dumps(result, ensure_ascii=False))
+
+    @staticmethod
+    def fixed_values(mapping):
+        return {key: '' for key in mapping}
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 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()

+ 767 - 0
tests/test_output_presenter.py

@@ -0,0 +1,767 @@
+import json
+import unittest
+
+from services.output_presenter import OutputPresenter
+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_order import QueryOrderTool
+from tools.query_order_exact import QueryOrderExactTool
+from tools.query_track import QueryTrackTool
+
+
+class OutputPresenterTest(unittest.TestCase):
+    def setUp(self):
+        self.presenter = OutputPresenter()
+
+    def test_exact_order_uses_labels_and_drops_internal_fields(self):
+        result = self.presenter.present(
+            'query_order_exact',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'summary': '当前页返回 1 条订单',
+                    'columns': [
+                        {'key': 'order_number', 'name': '订单号'},
+                        {
+                            'key': 'tracking_number',
+                            'name': '快递单号',
+                            'description': '承运商跟踪号码',
+                        },
+                    ],
+                    'records': [{
+                        'order_number': 'SO001',
+                        'tracking_number': 'TN001',
+                        'order_id': 99,
+                        'time_zone': '8.00',
+                    }],
+                    'tips': [],
+                },
+                'meta': {
+                    'page': 1,
+                    'limit': 20,
+                    'has_more': False,
+                    'request_id': 'rq_exact',
+                },
+            },
+        )
+
+        self.assertFalse(result['is_error'])
+        self.assertEqual(
+            [
+                {'label': '订单号'},
+                {'label': '快递单号', 'description': '承运商跟踪号码'},
+            ],
+            result['structured_content']['headers'],
+        )
+        self.assertEqual(
+            [['SO001', 'TN001']],
+            result['structured_content']['rows'],
+        )
+        self.assertEqual(
+            {'page': 1, 'limit': 20, 'has_more': False},
+            result['structured_content']['pagination'],
+        )
+        self.assertEqual({'request_id': 'rq_exact'}, result['meta'])
+        serialized = json.dumps(result, ensure_ascii=False)
+        self.assertNotIn('order_number', serialized)
+        self.assertNotIn('tracking_number', serialized)
+        self.assertNotIn('order_id', serialized)
+        self.assertNotIn('time_zone', serialized)
+        self.assertIn('订单号', result['text'])
+        self.assertIn('SO001', result['text'])
+
+    def test_table_tools_reject_unknown_backend_columns(self):
+        for tool_name in (
+            'query_order_exact',
+            'query_track',
+            'query_customs_declaration_files',
+        ):
+            with self.subTest(tool_name=tool_name):
+                result = self.presenter.present(
+                    tool_name,
+                    {
+                        'code': 'MCP_0000',
+                        'data': {
+                            'columns': [
+                                {'key': 'order_id', 'name': '内部订单ID'},
+                            ],
+                            'records': [{'order_id': 99}],
+                        },
+                    },
+                )
+
+                self.assertTrue(result['is_error'])
+                self.assertEqual(
+                    '工具返回格式异常',
+                    result['structured_content']['message'],
+                )
+                self.assertNotIn('99', result['text'])
+
+    def test_table_tools_use_fixed_labels_instead_of_backend_labels(self):
+        result = self.presenter.present(
+            'query_customs_declaration_files',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'columns': [
+                        {'key': 'order_number', 'name': '内部名称'},
+                    ],
+                    'records': [{'order_number': 'ORD001'}],
+                },
+            },
+        )
+
+        self.assertFalse(result['is_error'])
+        self.assertEqual(
+            [{'label': '订单号'}],
+            result['structured_content']['headers'],
+        )
+        self.assertNotIn('内部名称', result['text'])
+
+    def test_table_tool_without_column_definition_fails_closed(self):
+        definition = self.presenter.TABLE_COLUMNS.pop('query_track')
+        try:
+            result = self.presenter.present(
+                'query_track',
+                {
+                    'code': 'MCP_0000',
+                    'data': {
+                        'columns': [{'key': 'status', 'name': '轨迹节点'}],
+                        'records': [],
+                    },
+                },
+            )
+        finally:
+            self.presenter.TABLE_COLUMNS['query_track'] = definition
+
+        self.assertTrue(result['is_error'])
+        self.assertEqual(
+            '工具返回格式异常',
+            result['structured_content']['message'],
+        )
+
+    def test_table_missing_value_is_empty_and_fixed_labels_are_used(self):
+        result = self.presenter.present(
+            'query_track',
+            {
+                'code': '0',
+                'data': {
+                    'columns': [
+                        {'key': 'status', 'name': '状态'},
+                        {'key': 'content', 'name': '状态'},
+                    ],
+                    'records': [{'status': '已发货'}],
+                    'tips': ['仅展示已有轨迹'],
+                },
+                'meta': {'request_id': 'rq_track'},
+            },
+        )
+
+        self.assertEqual(
+            [
+                {
+                    'label': '轨迹节点',
+                    'description': '轨迹状态,如:开始集港、离港放行、清关、配送等',
+                },
+                {'label': '轨迹内容', 'description': '详细描述'},
+            ],
+            result['structured_content']['headers'],
+        )
+        self.assertEqual([['已发货', '']], result['structured_content']['rows'])
+        self.assertEqual(['仅展示已有轨迹'], result['structured_content']['tips'])
+
+    def test_empty_table_keeps_safe_headers_and_tips(self):
+        result = self.presenter.present(
+            'query_track',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'columns': [{'key': 'status', 'name': '轨迹节点'}],
+                    'records': [],
+                    'tips': ['未查询到轨迹信息'],
+                },
+                'meta': {'page': 1, 'limit': 5, 'total': 0},
+            },
+        )
+
+        self.assertEqual(
+            [{
+                'label': '轨迹节点',
+                'description': '轨迹状态,如:开始集港、离港放行、清关、配送等',
+            }],
+            result['structured_content']['headers'],
+        )
+        self.assertEqual([], result['structured_content']['rows'])
+        self.assertIn('未查询到轨迹信息', result['text'])
+
+        without_tips = self.presenter.present(
+            'query_track',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'columns': [{'key': 'status', 'name': '轨迹节点'}],
+                    'records': [],
+                    'tips': 'not-a-list',
+                },
+            },
+        )
+        self.assertNotIn('tips', without_tips['structured_content'])
+
+    def test_filter_options_preserve_values_for_follow_up_calls(self):
+        result = self.presenter.present(
+            'list_order_filter_options',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'records': [
+                        {'value': 101, 'label': '客户甲', 'code': 'CUS-A'},
+                        {'value': -1, 'label': '客户仓', 'code': '-1'},
+                    ],
+                },
+                'meta': {'page': 2, 'limit': 20, 'has_more': True},
+            },
+        )
+
+        self.assertEqual(
+            [[101, '客户甲', 'CUS-A'], [-1, '客户仓', '-1']],
+            result['structured_content']['rows'],
+        )
+        self.assertEqual(
+            ['可传值', '显示名称', '业务编码'],
+            [item['label'] for item in result['structured_content']['headers']],
+        )
+        self.assertNotIn('"value"', json.dumps(result, ensure_ascii=False))
+
+    def test_pending_filter_options_use_same_safe_shape(self):
+        result = self.presenter.present(
+            'list_pending_outbound_export_filter_options',
+            {
+                'code': 'MCP_0000',
+                'data': {'records': [{'value': 'US', 'label': '美国', 'code': 'US'}]},
+                'meta': {},
+            },
+        )
+
+        self.assertEqual([['US', '美国', 'US']], result['structured_content']['rows'])
+
+    def test_export_submission_returns_only_safe_queued_task(self):
+        for tool_name in (
+            'export_pending_outbound_orders',
+            'export_out_of_province_port_data',
+        ):
+            with self.subTest(tool_name=tool_name):
+                result = self.presenter.present(
+                    tool_name,
+                    {
+                        'code': 'MCP_0000',
+                        'data': {
+                            'task_ref': 'mexp_abc',
+                            'status': 'queued',
+                            'retry_after_seconds': 10,
+                        },
+                        'meta': {'request_id': 'rq_export'},
+                    },
+                )
+
+                self.assertEqual(
+                    {
+                        'task_ref': 'mexp_abc',
+                        'status': 'queued',
+                        'retry_after_seconds': 10,
+                    },
+                    result['structured_content']['task'],
+                )
+                self.assertNotIn('file_url', json.dumps(result, ensure_ascii=False))
+
+    def test_export_task_query_presents_all_known_states(self):
+        queued = self.presenter.present(
+            'query_export_task',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'task_ref': 'mexp_abc',
+                    'status': 'queued',
+                    'retry_after_seconds': 10,
+                },
+            },
+        )
+        running = self.presenter.present(
+            'query_export_task',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'task_ref': 'mexp_abc',
+                    'status': 'running',
+                    'retry_after_seconds': 10,
+                },
+            },
+        )
+        completed = self.presenter.present(
+            'query_export_task',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'task_ref': 'mexp_abc',
+                    'status': 'completed',
+                    'files': [{
+                        'label': 'port.zip',
+                        'url': 'https://files.test/port.zip',
+                    }],
+                },
+            },
+        )
+        failed = self.presenter.present(
+            'query_export_task',
+            {
+                'code': 'MCP_0000',
+                'data': {'task_ref': 'mexp_abc', 'status': 'failed'},
+            },
+        )
+
+        self.assertEqual('queued', queued['structured_content']['task']['status'])
+        self.assertEqual('running', running['structured_content']['task']['status'])
+        self.assertEqual(
+            'https://files.test/port.zip',
+            completed['structured_content']['files'][0]['url'],
+        )
+        self.assertEqual('failed', failed['structured_content']['task']['status'])
+
+    def test_export_task_unknown_or_malformed_states_fail_closed(self):
+        cases = (
+            {
+                'task_ref': 123,
+                'status': 'queued',
+                'retry_after_seconds': 10,
+            },
+            {'task_ref': 'mexp_abc', 'status': 'unknown'},
+            {'task_ref': 'mexp_abc', 'status': 'queued'},
+            {
+                'task_ref': 'mexp_abc',
+                'status': 'queued',
+                'retry_after_seconds': 0,
+            },
+            {
+                'task_ref': 'mexp_abc',
+                'status': 'running',
+                'retry_after_seconds': 10,
+                'files': [],
+            },
+            {
+                'task_ref': 'mexp_abc',
+                'status': 'completed',
+                'files': None,
+            },
+            {
+                'task_ref': 'mexp_abc',
+                'status': 'completed',
+                'files': ['bad'],
+            },
+            {
+                'task_ref': 'mexp_abc',
+                'status': 'completed',
+                'files': [{'label': 'bad', 'url': None}],
+            },
+            {
+                'task_ref': 'mexp_abc',
+                'status': 'completed',
+                'files': [{'label': 'bad', 'url': 'javascript:alert(1)'}],
+            },
+            {
+                'task_ref': 'mexp_abc',
+                'status': 'completed',
+                'files': [],
+                'extra': True,
+            },
+            {
+                'task_ref': 'mexp_abc',
+                'status': 'failed',
+                'remark': 'secret',
+            },
+        )
+
+        for data in cases:
+            with self.subTest(data=data):
+                result = self.presenter.present(
+                    'query_export_task',
+                    {'code': 'MCP_0000', 'data': data},
+                )
+                self.assertTrue(result['is_error'])
+
+    def test_export_task_rejects_unsafe_download_urls(self):
+        urls = (
+            'https://user:pass@files.test/a.xlsx',
+            'https://files.test/a file.xlsx',
+            'https://files.test/a\x00.xlsx',
+            'http:foo',
+            'ftp://files.test/a.xlsx',
+            'https://files.test:bad/a.xlsx',
+            'https://files.test:99999/a.xlsx',
+            'https://files.test\\evil/a.xlsx',
+        )
+        for url in urls:
+            with self.subTest(url=repr(url)):
+                result = self.presenter.present(
+                    'query_export_task',
+                    {
+                        'code': 'MCP_0000',
+                        'data': {
+                            'task_ref': 'mexp_abc',
+                            'status': 'completed',
+                            'files': [{'label': 'a.xlsx', 'url': url}],
+                        },
+                    },
+                )
+                self.assertTrue(result['is_error'])
+
+    def test_known_parameter_error_uses_business_label(self):
+        result = self.presenter.present(
+            'query_track',
+            {
+                'code': 'MCP_1401',
+                'msg': 'tracking_number is required',
+                'data': {'tracking_number': 'bad'},
+                'meta': {'request_id': 'rq_error'},
+            },
+        )
+
+        self.assertTrue(result['is_error'])
+        self.assertEqual('快递单号参数不正确', result['structured_content']['message'])
+        self.assertNotIn('tracking_number', json.dumps(result, ensure_ascii=False))
+        self.assertEqual({'request_id': 'rq_error'}, result['meta'])
+
+    def test_port_export_so_number_error_uses_business_label(self):
+        result = self.presenter.present(
+            'export_out_of_province_port_data',
+            {
+                'code': 'MCP_1401',
+                'msg': 'so_numbers must be an array',
+                'data': {'so_numbers': 'bad'},
+                'meta': {'request_id': 'rq_so_error'},
+            },
+        )
+
+        self.assertTrue(result['is_error'])
+        self.assertEqual('SO号参数不正确', result['structured_content']['message'])
+        self.assertNotIn('so_numbers', json.dumps(result, ensure_ascii=False))
+
+    def test_system_error_hides_backend_message_and_data(self):
+        result = self.presenter.present(
+            'query_order_exact',
+            {
+                'code': 'MCP_9001',
+                'msg': 'SQLSTATE table st_order order_number failed',
+                'data': {'sql': 'select * from st_order'},
+            },
+        )
+
+        serialized = json.dumps(result, ensure_ascii=False)
+        self.assertIn('工具调用失败,请稍后重试', serialized)
+        self.assertNotIn('SQLSTATE', serialized)
+        self.assertNotIn('st_order', serialized)
+        self.assertNotIn('order_number', serialized)
+
+    def test_new_business_error_codes_have_stable_messages_and_are_not_retryable(self):
+        cases = {
+            'MCP_1103': '员工账号不可用,请联系管理员',
+            'MCP_1104': '身份信息已失效,请重新登录或重新生成设备配置',
+            'MCP_1501': '目标数据不可用',
+            'MCP_1502': '结果过多,请缩小查询范围',
+            'MCP_1601': '没有可导出的数据',
+        }
+
+        for code, message in cases.items():
+            with self.subTest(code=code):
+                result = self.presenter.present(
+                    'query_order_exact',
+                    {'code': code, 'msg': 'internal detail', 'data': []},
+                )
+                self.assertEqual(code, result['structured_content']['code'])
+                self.assertEqual(message, result['structured_content']['message'])
+                self.assertFalse(result['structured_content']['retryable'])
+
+    def test_unknown_business_error_code_is_normalized_to_system_error(self):
+        with self.assertLogs('services.output_presenter', level='WARNING') as logs:
+            result = self.presenter.present(
+                'query_order_exact',
+                {
+                    'code': 'MCP_7777',
+                    'msg': 'secret backend detail',
+                    'data': [],
+                    'meta': {'request_id': 'rq_unknown_code'},
+                },
+            )
+
+        self.assertEqual('MCP_9001', result['structured_content']['code'])
+        self.assertEqual('工具调用失败,请稍后重试', result['structured_content']['message'])
+        self.assertTrue(result['structured_content']['retryable'])
+        self.assertNotIn('secret', json.dumps(result, ensure_ascii=False))
+        record = logs.records[0]
+        self.assertEqual('rq_unknown_code', record.request_id)
+        self.assertEqual('query_order_exact', record.tool_code)
+        self.assertEqual('MCP_7777', record.backend_code)
+        self.assertEqual('MCP_9001', record.response_code)
+        self.assertEqual('UNEXPECTED_EXCEPTION', record.diagnostic_reason)
+
+    def test_unknown_tool_and_malformed_success_fail_closed(self):
+        unknown = self.presenter.present(
+            'future_tool',
+            {'code': 'MCP_0000', 'data': {'secret_field': 'secret'}},
+        )
+        malformed = self.presenter.present(
+            'query_track',
+            {'code': 'MCP_0000', 'data': {'records': []}},
+        )
+
+        for result in (unknown, malformed):
+            self.assertTrue(result['is_error'])
+            self.assertEqual('工具返回格式异常', result['structured_content']['message'])
+            self.assertNotIn('secret', json.dumps(result, ensure_ascii=False))
+
+    def test_present_exception_maps_value_error_and_hides_unknown_exception(self):
+        parameter = self.presenter.present_exception(
+            'query_track',
+            ValueError('order_number is required'),
+        )
+        system = self.presenter.present_exception(
+            'query_track',
+            RuntimeError('SQL password leaked'),
+        )
+
+        self.assertEqual('订单号参数不正确', parameter['structured_content']['message'])
+        self.assertEqual('工具调用失败,请稍后重试', system['structured_content']['message'])
+        self.assertNotIn('password', json.dumps(system, ensure_ascii=False))
+
+    def test_present_exception_handles_device_disabled_and_unknown_tool(self):
+        device = self.presenter.present_exception(
+            'query_track',
+            RuntimeError('这台设备的 Workbuddy 配置已失效,请重新生成配置。'),
+        )
+        disabled = self.presenter.present_exception(
+            'query_track',
+            RuntimeError('tool disabled: query_track'),
+        )
+        unknown = self.presenter.present_exception(
+            'future_tool',
+            RuntimeError('secret error'),
+        )
+
+        self.assertIn('Workbuddy', device['structured_content']['message'])
+        self.assertEqual('MCP_1202', disabled['structured_content']['code'])
+        self.assertEqual('工具返回格式异常', unknown['structured_content']['message'])
+
+    def test_invalid_payload_types_and_generic_errors_fail_safely(self):
+        cases = (
+            self.presenter.present('query_track', 'invalid'),
+            self.presenter.present('query_track', {'code': 'MCP_0000', 'data': []}),
+            self.presenter.present('query_track', {'code': '', 'msg': 'secret'}),
+            self.presenter.present(
+                'query_track',
+                {'code': 'MCP_7777', 'msg': 'secret backend error'},
+            ),
+            self.presenter.present(
+                'query_track',
+                {'code': 'MCP_1301', 'msg': 'secret permission detail'},
+            ),
+        )
+
+        for result in cases:
+            self.assertTrue(result['is_error'])
+            self.assertNotIn('secret', json.dumps(result, ensure_ascii=False))
+
+    def test_malformed_table_parts_fail_closed(self):
+        base = {
+            'code': 'MCP_0000',
+            'data': {
+                'columns': [{'key': 'status', 'name': '状态'}],
+                'records': [],
+            },
+        }
+        bad_data = (
+            {'columns': [], 'records': []},
+            {'columns': [{'key': 'status', 'name': '状态'}], 'records': None},
+            {'columns': ['status'], 'records': []},
+            {'columns': [{'key': None, 'name': '状态'}], 'records': []},
+            {'columns': [{'key': ' ', 'name': '状态'}], 'records': []},
+            {'columns': [{'key': 'status', 'name': None}], 'records': []},
+            {'columns': [{'key': 'status', 'name': ' '}], 'records': []},
+            {'columns': [{'key': 'status', 'name': '状态'}], 'records': ['bad']},
+        )
+
+        for data in bad_data:
+            with self.subTest(data=data):
+                payload = dict(base)
+                payload['data'] = data
+                self.assertTrue(self.presenter.present('query_track', payload)['is_error'])
+
+    def test_malformed_options_and_exports_fail_closed(self):
+        cases = (
+            self.presenter.present(
+                'list_order_filter_options',
+                {'code': 'MCP_0000', 'data': {'records': None}},
+            ),
+            self.presenter.present(
+                'list_order_filter_options',
+                {'code': 'MCP_0000', 'data': {'records': ['bad']}},
+            ),
+            self.presenter.present(
+                'export_pending_outbound_orders',
+                {
+                    'code': 'MCP_0000',
+                    'data': {
+                        'task_ref': '',
+                        'status': 'queued',
+                        'retry_after_seconds': 10,
+                    },
+                },
+            ),
+            self.presenter.present(
+                'export_pending_outbound_orders',
+                {
+                    'code': 'MCP_0000',
+                    'data': {
+                        'task_ref': 'mexp_abc',
+                        'status': 'queued',
+                        'retry_after_seconds': 10,
+                        'extra': True,
+                    },
+                },
+            ),
+            self.presenter.present(
+                'export_pending_outbound_orders',
+                {
+                    'code': 'MCP_0000',
+                    'data': {
+                        'task_ref': 'mexp_abc',
+                        'status': 'queued',
+                        'retry_after_seconds': 0,
+                    },
+                },
+            ),
+        )
+
+        self.assertTrue(all(result['is_error'] for result in cases))
+
+    def test_optional_text_meta_and_nested_values_cover_safe_boundaries(self):
+        result = self.presenter.present(
+            'query_track',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'summary': None,
+                    'columns': [
+                        {'key': 'status', 'name': '状态', 'description': ''},
+                        {'key': 'content', 'name': '详情', 'description': 123},
+                    ],
+                    'records': [{'status': {'name': '已发货'}, 'content': ['A']}],
+                    'tips': ['', None, '有效提示'],
+                },
+                'meta': {'request_id': ' ', 'page': 1},
+            },
+        )
+
+        self.assertFalse(result['is_error'])
+        self.assertEqual({}, result['meta'])
+        self.assertIn('{"name": "已发货"}', result['text'])
+        self.assertIn('["A"]', result['text'])
+        self.assertEqual(['有效提示'], result['structured_content']['tips'])
+
+        no_meta = self.presenter.present(
+            'list_order_filter_options',
+            {'code': 'MCP_0000', 'data': {'records': []}, 'meta': None},
+        )
+        self.assertNotIn('pagination', no_meta['structured_content'])
+
+    def test_parameter_error_without_known_field_is_generic(self):
+        result = self.presenter.present(
+            'query_track',
+            {'code': 'MCP_1401', 'msg': 'invalid request'},
+        )
+
+        self.assertEqual(
+            '工具参数不正确,请检查后重试',
+            result['structured_content']['message'],
+        )
+
+    def test_query_order_is_explicitly_not_presented(self):
+        self.assertFalse(self.presenter.handles('query_order'))
+        self.assertTrue(self.presenter.handles('query_order_exact'))
+
+    def test_unhashable_tool_names_fail_closed(self):
+        for tool_name in ([], {}):
+            with self.subTest(tool_name=tool_name):
+                self.assertFalse(self.presenter.handles(tool_name))
+                result = self.presenter.present_exception(
+                    tool_name,
+                    RuntimeError('secret backend error'),
+                )
+                self.assertTrue(result['is_error'])
+                self.assertEqual(
+                    '工具返回格式异常',
+                    result['structured_content']['message'],
+                )
+
+    def test_safe_tool_descriptions_require_business_labels_only(self):
+        safe_tools = (
+            QueryOrderExactTool(),
+            QueryTrackTool(),
+            ListOrderFilterOptionsTool(),
+            ListPendingOutboundExportFilterOptionsTool(),
+            ExportPendingOutboundOrdersTool(),
+            ExportOutOfProvincePortDataTool(),
+        )
+
+        for tool in safe_tools:
+            with self.subTest(tool=tool.name):
+                self.assertIn('不得展示内部参数名', tool.metadata()['description'])
+        self.assertNotIn('不得展示内部参数名', QueryOrderTool().metadata()['description'])
+
+    def test_presented_order_and_option_values_can_feed_follow_up_tools(self):
+        class RecordingClient:
+            def __init__(self):
+                self.calls = []
+
+            def call_tool(self, tool_code, route_path, payload, request_id):
+                self.calls.append((tool_code, payload))
+                return {'code': 'MCP_0000'}
+
+        order_result = self.presenter.present(
+            'query_order_exact',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'columns': [{'key': 'order_number', 'name': '订单号'}],
+                    'records': [{'order_number': 'SO-FOLLOW-UP'}],
+                },
+            },
+        )
+        option_result = self.presenter.present(
+            'list_order_filter_options',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'records': [{'value': 901, 'label': '客户甲', 'code': 'C901'}],
+                },
+            },
+        )
+
+        client = RecordingClient()
+        QueryTrackTool(client).call(
+            order_number=order_result['structured_content']['rows'][0][0],
+        )
+        QueryOrderExactTool(client).call(
+            customer_ids=[option_result['structured_content']['rows'][0][0]],
+        )
+
+        self.assertEqual(
+            ('query_track', {'page': 1, 'limit': 5, 'order_number': 'SO-FOLLOW-UP'}),
+            client.calls[0],
+        )
+        self.assertEqual([901], client.calls[1][1]['customer_ids'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 212 - 4
tests/test_public_gateway.py

@@ -1,6 +1,7 @@
 import unittest
 
 from public_gateway import PublicGatewayApp
+from services.diagnostic_event import RequestDiagnosticEmitter
 
 
 class FakeSessionStore:
@@ -19,12 +20,196 @@ class FakeApiClient:
     def __init__(self):
         self.calls = []
 
-    def call_tool(self, token, tool_code, route_path, payload, request_id):
-        self.calls.append((token, tool_code, route_path, payload, request_id))
+    def call_tool(self, token, tool_code, route_path, payload, request_id, client_ip=''):
+        self.calls.append((token, tool_code, route_path, payload, request_id, client_ip))
         return {'code': 'MCP_0000', 'data': {'token_used': token}}
 
+    def list_enabled_tools(self, token, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_order',
+                    'query_track',
+                    'query_order_exact',
+                    'list_order_filter_options',
+                ],
+            },
+        }
+
 
 class PublicGatewayAppTest(unittest.TestCase):
+    def test_missing_redis_session_emits_gateway_session_failure(self):
+        reporter = RecordingReporter()
+        emitter = RequestDiagnosticEmitter(
+            reporter,
+            'rq_missing',
+            defer_until_identity=True,
+        )
+        app = PublicGatewayApp(FakeSessionStore(), FakeApiClient())
+
+        with self.assertRaises(RuntimeError):
+            app.call_tool(
+                'GWS_missing',
+                'query_order',
+                request_id='rq_missing',
+                diagnostic_emitter=emitter,
+            )
+        emitter.flush()
+
+        event = reporter.events[-1]
+        self.assertEqual('gateway_session', event['stage'])
+        self.assertEqual('failed', event['status'])
+        self.assertEqual('GATEWAY_SESSION_NOT_FOUND', event['event_code'])
+        self.assertIn('session_hash', event)
+
+    def test_enabled_tool_lookup_failure_emits_backend_failure(self):
+        class FailingListClient(FakeApiClient):
+            def list_enabled_tools(self, token, request_id=''):
+                raise OSError('registry unavailable secret')
+
+        store = FakeSessionStore()
+        store.sessions['GWS_A'] = {
+            'mcp_token': 'MT_A',
+            'admin_id': 88,
+            'company_id': 1002,
+        }
+        reporter = RecordingReporter()
+        emitter = RequestDiagnosticEmitter(
+            reporter,
+            'rq_lookup',
+            defer_until_identity=True,
+        )
+        emitter.emit(
+            stage='request_ingress',
+            status='started',
+            event_code='REQUEST_RECEIVED',
+            context={'transport': 'http'},
+        )
+        app = PublicGatewayApp(store, FailingListClient())
+
+        with self.assertRaises(OSError):
+            app.call_tool(
+                'GWS_A',
+                'query_order',
+                request_id='rq_lookup',
+                diagnostic_emitter=emitter,
+            )
+
+        self.assertTrue(all(
+            event['company_id'] == 1002 for event in reporter.events
+        ))
+        failed = reporter.events[-1]
+        self.assertEqual('backend_call', failed['stage'])
+        self.assertEqual('failed', failed['status'])
+        self.assertEqual('ENABLED_TOOL_LOOKUP_FAILED', failed['event_code'])
+
+    def test_unknown_and_disabled_tools_emit_backend_failures(self):
+        store = FakeSessionStore()
+        store.sessions['GWS_A'] = {
+            'mcp_token': 'MT_A',
+            'admin_id': 88,
+            'company_id': 1002,
+        }
+        app = PublicGatewayApp(store, FakeApiClient())
+
+        cases = (
+            ('not_registered', 'TOOL_NOT_REGISTERED', KeyError),
+            ('query_outbound_detail', 'TOOL_DISABLED', RuntimeError),
+        )
+        for tool_name, event_code, exception_class in cases:
+            with self.subTest(tool_name=tool_name):
+                reporter = RecordingReporter()
+                emitter = RequestDiagnosticEmitter(
+                    reporter,
+                    'rq_tool_check',
+                    defer_until_identity=True,
+                )
+                with self.assertRaises(exception_class):
+                    app.call_tool(
+                        'GWS_A',
+                        tool_name,
+                        request_id='rq_tool_check',
+                        diagnostic_emitter=emitter,
+                    )
+                self.assertEqual(event_code, reporter.events[-1]['event_code'])
+                self.assertEqual('failed', reporter.events[-1]['status'])
+
+    def test_enabled_tool_lookup_failure_without_emitter_keeps_exception(self):
+        class FailingListClient(FakeApiClient):
+            def list_enabled_tools(self, token, request_id=''):
+                raise OSError('registry unavailable')
+
+        store = FakeSessionStore()
+        store.sessions['GWS_A'] = {'mcp_token': 'MT_A'}
+        app = PublicGatewayApp(store, FailingListClient())
+
+        with self.assertRaises(OSError):
+            app.call_tool('GWS_A', 'query_order', request_id='rq_lookup')
+    def test_diagnostic_events_include_session_identity_and_backend_result(self):
+        store = FakeSessionStore()
+        store.sessions['GWS_A'] = {
+            'mcp_token': 'MT_A',
+            'admin_id': 88,
+            'company_id': 1002,
+        }
+        reporter = RecordingReporter()
+        emitter = RequestDiagnosticEmitter(reporter, 'rq_a')
+        app = PublicGatewayApp(
+            session_store=store,
+            api_client=FakeApiClient(),
+            auth_client=None,
+        )
+
+        app.call_tool(
+            'GWS_A',
+            'query_order',
+            {'keyword': 'A'},
+            request_id='rq_a',
+            diagnostic_emitter=emitter,
+        )
+
+        self.assertEqual(
+            ['gateway_session', 'backend_call', 'backend_call'],
+            [event['stage'] for event in reporter.events],
+        )
+        self.assertEqual(
+            ['succeeded', 'started', 'succeeded'],
+            [event['status'] for event in reporter.events],
+        )
+        self.assertTrue(all(event['company_id'] == 1002 for event in reporter.events))
+        self.assertNotIn('GWS_A', str(reporter.events))
+        self.assertNotIn('MT_A', str(reporter.events))
+
+    def test_backend_failure_emits_failed_event_without_changing_exception(self):
+        class FailingApiClient(FakeApiClient):
+            def call_tool(self, *args, **kwargs):
+                raise OSError('backend secret unavailable')
+
+        store = FakeSessionStore()
+        store.sessions['GWS_A'] = {
+            'mcp_token': 'MT_A',
+            'admin_id': 88,
+            'company_id': 1002,
+        }
+        reporter = RecordingReporter()
+        emitter = RequestDiagnosticEmitter(reporter, 'rq_a')
+        app = PublicGatewayApp(store, FailingApiClient(), auth_client=None)
+
+        with self.assertRaisesRegex(OSError, 'backend secret unavailable'):
+            app.call_tool(
+                'GWS_A',
+                'query_order',
+                request_id='rq_a',
+                diagnostic_emitter=emitter,
+            )
+
+        failed = reporter.events[-1]
+        self.assertEqual('backend_call', failed['stage'])
+        self.assertEqual('failed', failed['status'])
+        self.assertEqual('UNEXPECTED_EXCEPTION', failed['event_code'])
+        self.assertNotIn('backend secret', str(failed))
+
     def test_two_employees_use_isolated_tokens(self):
         store = FakeSessionStore()
         store.sessions['GWS_A'] = {'mcp_token': 'MT_A'}
@@ -41,13 +226,17 @@ class PublicGatewayAppTest(unittest.TestCase):
         self.assertEqual('MT_B', api_client.calls[1][0])
 
     def test_public_tools_do_not_include_bind_auth_code(self):
-        app = PublicGatewayApp(session_store=FakeSessionStore(), api_client=FakeApiClient(), auth_client=None)
+        store = FakeSessionStore()
+        store.sessions['GWS_A'] = {'mcp_token': 'MT_A'}
+        app = PublicGatewayApp(session_store=store, api_client=FakeApiClient(), auth_client=None)
 
-        tool_names = [tool['name'] for tool in app.list_tools()]
+        tool_names = [tool['name'] for tool in app.list_tools('GWS_A')]
 
         self.assertNotIn('bind_auth_code', tool_names)
         self.assertIn('query_order', tool_names)
         self.assertIn('query_track', tool_names)
+        self.assertIn('query_order_exact', tool_names)
+        self.assertIn('list_order_filter_options', tool_names)
 
     def test_missing_session_returns_human_device_message(self):
         app = PublicGatewayApp(session_store=FakeSessionStore(), api_client=FakeApiClient(), auth_client=None)
@@ -57,6 +246,16 @@ class PublicGatewayAppTest(unittest.TestCase):
 
         self.assertIn('这台设备的 Workbuddy 配置已失效,请重新生成配置', str(error.exception))
 
+    def test_tool_call_forwards_client_ip_to_api_client(self):
+        store = FakeSessionStore()
+        store.sessions['GWS_A'] = {'mcp_token': 'MT_A'}
+        api_client = FakeApiClient()
+        app = PublicGatewayApp(session_store=store, api_client=api_client, auth_client=None)
+
+        app.call_tool('GWS_A', 'query_order', {'keyword': 'A'}, request_id='rq_a', client_ip='203.0.113.9')
+
+        self.assertEqual('203.0.113.9', api_client.calls[0][5])
+
     def test_successful_tool_call_touches_gateway_session(self):
         store = FakeSessionStore()
         store.sessions['GWS_A'] = {'mcp_token': 'MT_A'}
@@ -67,5 +266,14 @@ class PublicGatewayAppTest(unittest.TestCase):
         self.assertEqual(['GWS_A'], store.touched)
 
 
+class RecordingReporter:
+    def __init__(self):
+        self.events = []
+
+    def report(self, event):
+        self.events.append(event)
+        return True
+
+
 if __name__ == '__main__':
     unittest.main()

+ 157 - 4
tests/test_public_gateway_unit.py

@@ -9,6 +9,17 @@ class TestPublicGatewayApp(unittest.TestCase):
     def setUp(self):
         self.mock_session_store = MagicMock()
         self.mock_api_client = MagicMock()
+        self.mock_api_client.list_enabled_tools.return_value = {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_order',
+                    'query_track',
+                    'query_order_exact',
+                    'list_order_filter_options',
+                ],
+            },
+        }
 
         self.app = PublicGatewayApp(
             session_store=self.mock_session_store,
@@ -16,13 +27,30 @@ class TestPublicGatewayApp(unittest.TestCase):
         )
 
     def test_list_tools_returns_metadata(self):
-        tools = self.app.list_tools()
+        self.mock_session_store.get.return_value = {
+            'mcp_token': 'MT_token',
+        }
+        self.mock_api_client.list_enabled_tools.return_value = {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_order',
+                    'query_track',
+                    'query_order_exact',
+                    'list_order_filter_options',
+                ],
+            },
+        }
+
+        tools = self.app.list_tools('GWS_test')
 
         self.assertIsInstance(tools, list)
         self.assertGreater(len(tools), 0)
         tool_names = [tool['name'] for tool in tools]
         self.assertIn('query_order', tool_names)
         self.assertIn('query_track', tool_names)
+        self.assertIn('query_order_exact', tool_names)
+        self.assertIn('list_order_filter_options', tool_names)
         self.assertNotIn('bind_auth_code', tool_names)
 
         for tool in tools:
@@ -30,6 +58,61 @@ class TestPublicGatewayApp(unittest.TestCase):
             self.assertIn('description', tool)
             self.assertIn('input_schema', tool)
 
+    def test_list_tools_intersects_enabled_codes_and_preserves_local_order(self):
+        self.mock_session_store.get.return_value = {
+            'mcp_token': 'MT_token',
+        }
+        self.mock_api_client.list_enabled_tools.return_value = {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_track',
+                    'query_order_exact',
+                    'unknown_tool',
+                ],
+            },
+        }
+
+        tools = self.app.list_tools('GWS_test')
+
+        self.assertEqual(
+            ['query_track', 'query_order_exact'],
+            [tool['name'] for tool in tools],
+        )
+        self.mock_api_client.list_enabled_tools.assert_called_once()
+        call = self.mock_api_client.list_enabled_tools.call_args
+        self.assertEqual('MT_token', call.args[0])
+        self.assertTrue(call.kwargs['request_id'].startswith('rq_'))
+
+    def test_list_tools_rejects_missing_session_without_querying_registry(self):
+        self.mock_session_store.get.return_value = None
+
+        with self.assertRaisesRegex(RuntimeError, DEVICE_INVALID_MESSAGE):
+            self.app.list_tools('GWS_missing')
+
+        self.mock_api_client.list_enabled_tools.assert_not_called()
+
+    def test_list_tools_fails_closed_on_registry_error(self):
+        self.mock_session_store.get.return_value = {'mcp_token': 'MT_token'}
+        self.mock_api_client.list_enabled_tools.return_value = {
+            'code': 'MCP_9001',
+            'msg': 'registry unavailable',
+            'data': {},
+        }
+
+        with self.assertRaisesRegex(RuntimeError, 'registry unavailable'):
+            self.app.list_tools('GWS_test')
+
+    def test_enabled_tool_names_rejects_non_dict_response(self):
+        with self.assertRaisesRegex(RuntimeError, 'invalid enabled tool response'):
+            self.app._enabled_tool_names(None)
+
+    def test_enabled_tool_names_rejects_missing_tool_code_list(self):
+        response = {'code': 'MCP_0000', 'data': {}}
+
+        with self.assertRaisesRegex(RuntimeError, 'invalid enabled tool response'):
+            self.app._enabled_tool_names(response)
+
     def test_build_request_id_generates_id_when_empty(self):
         request_id = self.app.build_request_id('')
 
@@ -89,6 +172,10 @@ class TestPublicGatewayApp(unittest.TestCase):
             'msg': 'success',
             'data': {'order': 'details'}
         }
+        self.mock_api_client.list_enabled_tools.return_value = {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_order']},
+        }
 
         result = self.app.call_tool(gateway_session_id, tool_name, arguments, 'rq_test')
 
@@ -100,6 +187,50 @@ class TestPublicGatewayApp(unittest.TestCase):
         self.assertEqual(call_args['request_id'], 'rq_test')
         self.assertEqual(result['code'], '0')
 
+    def test_call_tool_succeeds_when_store_has_no_touch_method(self):
+        class ReadOnlySessionStore:
+            def get(self, gateway_session_id):
+                return {
+                    'mcp_token': 'MT_read_only',
+                    'admin_id': 1,
+                    'company_id': 2,
+                }
+
+        api_client = MagicMock()
+        api_client.list_enabled_tools.return_value = {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_order']},
+        }
+        api_client.call_tool.return_value = {'code': 'MCP_0000'}
+        app = PublicGatewayApp(ReadOnlySessionStore(), api_client)
+
+        result = app.call_tool(
+            'GWS_read_only',
+            'query_order',
+            {'keyword': 'ORDER-1'},
+            'rq_read_only',
+        )
+
+        self.assertEqual('MCP_0000', result['code'])
+        api_client.call_tool.assert_called_once()
+
+    def test_call_tool_rejects_dynamically_disabled_tool_before_forwarding(self):
+        self.mock_session_store.get.return_value = {
+            'mcp_token': 'MT_token',
+            'admin_id': 1,
+            'company_id': 1,
+        }
+        self.mock_api_client.list_enabled_tools.return_value = {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_track']},
+        }
+
+        with self.assertRaisesRegex(RuntimeError, 'tool disabled: query_order'):
+            self.app.call_tool('GWS_test', 'query_order', {})
+
+        self.mock_session_store.get.assert_called_once_with('GWS_test')
+        self.mock_api_client.call_tool.assert_not_called()
+
     def test_call_tool_generates_request_id_when_not_provided(self):
         self.mock_session_store.get.return_value = {
             'mcp_token': 'MT_token',
@@ -129,6 +260,20 @@ class TestPublicGatewayApp(unittest.TestCase):
         call_args = self.mock_api_client.call_tool.call_args[1]
         self.assertEqual(call_args['request_id'], custom_request_id)
 
+    def test_call_tool_forwards_client_ip_to_api_client(self):
+        self.mock_session_store.get.return_value = {
+            'mcp_token': 'MT_token',
+            'admin_id': 1,
+            'company_id': 1
+        }
+
+        self.mock_api_client.call_tool.return_value = {'code': '0'}
+
+        self.app.call_tool('GWS_test', 'query_order', {}, 'rq_client_ip', client_ip='203.0.113.9')
+
+        call_args = self.mock_api_client.call_tool.call_args[1]
+        self.assertEqual(call_args['client_ip'], '203.0.113.9')
+
     def test_call_tool_logs_error_on_exception(self):
         gateway_session_id = 'GWS_error_test'
         tool_name = 'query_order'
@@ -141,11 +286,19 @@ class TestPublicGatewayApp(unittest.TestCase):
 
         self.mock_api_client.call_tool.side_effect = RuntimeError('API connection failed')
 
-        with self.assertRaises(RuntimeError) as context:
-            self.app.call_tool(gateway_session_id, tool_name, {}, 'rq_err')
+        with self.assertLogs('public_gateway', level='ERROR') as logs:
+            with self.assertRaises(RuntimeError) as context:
+                self.app.call_tool(gateway_session_id, tool_name, {}, 'rq_err')
 
         self.assertIn('API connection failed', str(context.exception))
+        self.assertNotIn('API connection failed', logs.output[0])
+        record = logs.records[0]
+        self.assertEqual('rq_err', record.request_id)
+        self.assertEqual('query_order', record.tool_code)
+        self.assertEqual('MCP_9001', record.response_code)
+        self.assertEqual('UNEXPECTED_EXCEPTION', record.diagnostic_reason)
+        self.assertEqual('RuntimeError', record.exception_class)
 
 
 if __name__ == '__main__':
-    unittest.main()
+    unittest.main()

+ 610 - 10
tests/test_public_server.py

@@ -1,6 +1,10 @@
+import json
 import unittest
+from unittest.mock import Mock
 
-from public_server import PublicMcpHttpHandler, extract_client_ip
+from public_server import PublicMcpHttpHandler, create_http_handler, extract_client_ip
+from services.diagnostic_event import RequestDiagnosticEmitter
+from utils.rate_limiter import SimpleRateLimiter
 
 
 class FakeContext:
@@ -19,21 +23,238 @@ class FakeParser:
 class FakeGateway:
     def __init__(self):
         self.calls = []
+        self.list_calls = []
+        self.tool_result = {'code': 'MCP_0000', 'data': {'ok': True}}
 
-    def list_tools(self):
-        return [{'name': 'query_order', 'description': 'query order', 'input_schema': {'type': 'object'}}]
+    def registered_tool_names(self):
+        return ('query_order', 'query_track')
 
-    def call_tool(self, gateway_session_id, name, arguments=None, request_id=''):
-        self.calls.append((gateway_session_id, name, arguments, request_id))
-        return {'code': 'MCP_0000', 'data': {'ok': True}}
+    def list_tools(self, gateway_session_id, request_id=''):
+        self.list_calls.append((gateway_session_id, request_id))
+        return [{'name': 'query_track', 'description': 'query track', 'input_schema': {'type': 'object'}}]
+
+    def call_tool(
+        self,
+        gateway_session_id,
+        name,
+        arguments=None,
+        request_id='',
+        client_ip='',
+        diagnostic_emitter=None,
+    ):
+        self.calls.append((gateway_session_id, name, arguments, request_id, client_ip))
+        return self.tool_result
 
 
 class PublicMcpHttpHandlerTest(unittest.TestCase):
-    def test_handle_tools_call_passes_gateway_session_to_public_gateway(self):
+    def test_initialize_emits_successful_protocol_validation(self):
+        reporter = RecordingReporter()
+        handler = PublicMcpHttpHandler(FakeGateway(), reporter=reporter)
+
+        response = handler.handle_json_rpc(
+            headers={},
+            message={'jsonrpc': '2.0', 'id': 1, 'method': 'initialize'},
+        )
+
+        self.assertIn('result', response)
+        self.assertEqual(
+            ['request_ingress', 'protocol_validation'],
+            [event['stage'] for event in reporter.events],
+        )
+        self.assertEqual('succeeded', reporter.events[-1]['status'])
+
+    def test_missing_session_emits_safe_ingress_and_session_failure(self):
+        reporter = RecordingReporter()
+        handler = PublicMcpHttpHandler(
+            FakeGateway(),
+            context_parser=FakeParser(),
+            reporter=reporter,
+        )
+
+        response = handler.handle_json_rpc(
+            headers={},
+            message={
+                'jsonrpc': '2.0',
+                'id': 1,
+                'method': 'tools/call',
+                'params': {'name': 'query_order', 'arguments': {}},
+            },
+        )
+
+        self.assertEqual(-32001, response['error']['code'])
+        self.assertEqual(
+            ['request_ingress', 'protocol_validation', 'gateway_session'],
+            [event['stage'] for event in reporter.events],
+        )
+        self.assertEqual('failed', reporter.events[-1]['status'])
+
+    def test_reporter_failure_does_not_change_successful_response(self):
+        class BrokenReporter:
+            def report(self, _event):
+                raise RuntimeError('support unavailable')
+
+        handler = PublicMcpHttpHandler(
+            FakeGateway(),
+            context_parser=FakeParser(),
+            reporter=BrokenReporter(),
+        )
+
+        response = handler.handle_json_rpc(
+            headers={'X-Gateway-Session': 'GWS_A'},
+            message={'jsonrpc': '2.0', 'id': 1, 'method': 'initialize'},
+        )
+
+        self.assertIn('result', response)
+
+    def test_invalid_backend_result_emits_response_safety_failure(self):
+        reporter = RecordingReporter()
+        gateway = FakeGateway()
+        gateway.tool_result = []
+        handler = PublicMcpHttpHandler(
+            gateway,
+            context_parser=FakeParser(),
+            reporter=reporter,
+        )
+
+        response = handler.handle_json_rpc(
+            headers={'X-Gateway-Session': 'GWS_A'},
+            message={
+                'jsonrpc': '2.0',
+                'id': 1,
+                'method': 'tools/call',
+                'params': {'name': 'query_order', 'arguments': {}},
+            },
+        )
+
+        self.assertTrue(response['result']['isError'])
+        event = next(
+            item for item in reporter.events
+            if item['stage'] == 'response_safety'
+        )
+        self.assertEqual('failed', event['status'])
+        self.assertEqual('RESPONSE_SAFETY_REJECTED', event['event_code'])
+
+    def test_missing_tool_name_emits_protocol_validation_failure(self):
+        reporter = RecordingReporter()
+        handler = PublicMcpHttpHandler(
+            FakeGateway(),
+            context_parser=FakeParser(),
+            reporter=reporter,
+        )
+
+        response = handler.handle_json_rpc(
+            headers={'X-Gateway-Session': 'GWS_A'},
+            message={
+                'jsonrpc': '2.0',
+                'id': 1,
+                'method': 'tools/call',
+                'params': {'arguments': {}},
+            },
+        )
+
+        self.assertNotIn('result', response)
+        self.assertEqual(-32602, response['error']['code'])
+        self.assertTrue(response['error']['data']['request_id'].startswith('rq_http_'))
+        self.assertEqual(
+            'failed',
+            next(
+                event for event in reporter.events
+                if event['stage'] == 'protocol_validation'
+            )['status'],
+        )
+
+    def test_constructor_uses_registered_names_without_loading_dynamic_list(self):
+        gateway = FakeGateway()
+
+        PublicMcpHttpHandler(gateway, context_parser=FakeParser())
+
+        self.assertEqual([], gateway.list_calls)
+
+    def test_handle_tools_list_passes_session_and_returns_dynamic_tools(self):
+        gateway = FakeGateway()
+        handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
+
+        response = handler.handle_json_rpc(
+            headers={'X-Gateway-Session': 'GWS_A'},
+            message={
+                'jsonrpc': '2.0',
+                'id': 2,
+                'method': 'tools/list',
+                'params': {},
+            },
+            client_ip='10.0.0.5',
+        )
+
+        self.assertEqual('GWS_A', gateway.list_calls[0][0])
+        self.assertTrue(gateway.list_calls[0][1].startswith('rq_http_'))
+        self.assertEqual(
+            ['query_track'],
+            [tool['name'] for tool in response['result']['tools']],
+        )
+        self.assertIn('inputSchema', response['result']['tools'][0])
+
+    def test_missing_session_on_tools_list_returns_protocol_error(self):
+        gateway = FakeGateway()
+        handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
+
+        with self.assertLogs('public_server', level='WARNING') as logs:
+            response = handler.handle_json_rpc(
+                headers={},
+                message={
+                    'jsonrpc': '2.0',
+                    'id': 3,
+                    'method': 'tools/list',
+                    'params': {},
+                },
+                client_ip='10.0.0.5',
+            )
+
+        self.assertEqual(-32001, response['error']['code'])
+        self.assertIn('Workbuddy', response['error']['message'])
+        self.assertTrue(response['error']['data']['request_id'].startswith('rq_http_'))
+        self.assertEqual([], gateway.list_calls)
+        record = logs.records[0]
+        self.assertEqual(3, record.jsonrpc_id)
+        self.assertEqual(response['error']['data']['request_id'], record.request_id)
+        self.assertEqual('tools/list', record.protocol_method)
+        self.assertEqual(-32001, record.protocol_code)
+        self.assertEqual('GATEWAY_SESSION_NOT_FOUND', record.diagnostic_reason)
+
+    def test_client_request_id_is_logged_only_as_safe_hash(self):
         gateway = FakeGateway()
         handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
+
+        value = handler._client_request_id({
+            'X-Request-Id': 'client-id\nAuthorization: secret',
+        })
+
+        self.assertRegex(value, r'^[0-9a-f]{16}$')
+        self.assertNotIn('client-id', value)
+
+    def test_tools_list_internal_exception_is_sanitized(self):
+        class ExplodingGateway(FakeGateway):
+            def list_tools(self, gateway_session_id, request_id=''):
+                raise RuntimeError('database password leaked')
+
+        handler = PublicMcpHttpHandler(ExplodingGateway(), context_parser=FakeParser())
         response = handler.handle_json_rpc(
             headers={'X-Gateway-Session': 'GWS_A'},
+            message={'jsonrpc': '2.0', 'id': 31, 'method': 'tools/list', 'params': {}},
+            client_ip='10.0.0.5',
+        )
+
+        self.assertEqual(-32000, response['error']['code'])
+        self.assertEqual('Gateway request failed. Please try again later.', response['error']['message'])
+        self.assertNotIn('password', json.dumps(response))
+
+    def test_handle_tools_call_passes_gateway_session_to_public_gateway(self):
+        gateway = FakeGateway()
+        handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
+        response = handler.handle_json_rpc(
+            headers={
+                'X-Gateway-Session': 'GWS_A',
+                'X-Request-Id': 'rq_public_incoming',
+            },
             message={
                 'jsonrpc': '2.0',
                 'id': 1,
@@ -43,13 +264,118 @@ class PublicMcpHttpHandlerTest(unittest.TestCase):
                     'arguments': {'keyword': 'USC'},
                 },
             },
+            client_ip='10.0.0.5'
         )
 
         self.assertEqual(False, response['result']['isError'])
         self.assertEqual('GWS_A', gateway.calls[0][0])
         self.assertEqual('query_order', gateway.calls[0][1])
+        self.assertTrue(gateway.calls[0][3].startswith('rq_http_'))
+        self.assertNotEqual('rq_public_incoming', gateway.calls[0][3])
+        self.assertEqual('10.0.0.5', gateway.calls[0][4])
+
+    def test_reused_client_request_id_gets_unique_server_trace_ids(self):
+        gateway = FakeGateway()
+        handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
+        headers = {
+            'X-Gateway-Session': 'GWS_A',
+            'X-Request-Id': 'client-retry-id',
+        }
+        message = {
+            'jsonrpc': '2.0',
+            'id': 1,
+            'method': 'tools/call',
+            'params': {'name': 'query_track', 'arguments': {}},
+        }
+
+        handler.handle_json_rpc(headers, message, client_ip='10.0.0.5')
+        handler.handle_json_rpc(headers, message, client_ip='10.0.0.5')
+
+        first_request_id = gateway.calls[0][3]
+        second_request_id = gateway.calls[1][3]
+        self.assertTrue(first_request_id.startswith('rq_http_'))
+        self.assertTrue(second_request_id.startswith('rq_http_'))
+        self.assertNotEqual(first_request_id, second_request_id)
 
-    def test_missing_session_on_tool_call_returns_error_content(self):
+    def test_backend_business_error_returns_tool_error_result(self):
+        gateway = FakeGateway()
+        gateway.tool_result = {
+            'code': 'MCP_1401',
+            'msg': 'invalid exact order query conditions',
+            'data': [],
+            'meta': {'request_id': 'rq_backend'},
+        }
+        handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
+
+        response = handler.handle_json_rpc(
+            headers={'X-Gateway-Session': 'GWS_A'},
+            message={
+                'jsonrpc': '2.0',
+                'id': 7,
+                'method': 'tools/call',
+                'params': {
+                    'name': 'query_order',
+                    'arguments': {'keyword': 'USC'},
+                },
+            },
+            client_ip='10.0.0.5',
+        )
+
+        self.assertNotIn('error', response)
+        self.assertTrue(response['result']['isError'])
+        self.assertIn('MCP_1401', response['result']['content'][0]['text'])
+        self.assertEqual(
+            {
+                'code': 'MCP_1401',
+                'msg': 'invalid exact order query conditions',
+                'meta': {'request_id': 'rq_backend'},
+            },
+            response['result']['structuredContent'],
+        )
+
+    def test_query_track_success_uses_safe_public_output(self):
+        gateway = FakeGateway()
+        gateway.tool_result = {
+            'code': 'MCP_0000',
+            'data': {
+                'summary': '共 1 条轨迹',
+                'columns': [
+                    {'key': 'status', 'name': '轨迹节点'},
+                    {'key': 'tracking_number', 'name': '快递单号'},
+                ],
+                'records': [{
+                    'status': '已发货',
+                    'tracking_number': 'TN-PUBLIC',
+                    'time_zone': '8.00',
+                }],
+            },
+            'meta': {'request_id': 'rq_public', 'page': 1, 'limit': 5},
+        }
+        handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
+
+        response = handler.handle_json_rpc(
+            headers={'X-Gateway-Session': 'GWS_A'},
+            message={
+                'jsonrpc': '2.0',
+                'id': 8,
+                'method': 'tools/call',
+                'params': {
+                    'name': 'query_track',
+                    'arguments': {'tracking_number': 'TN-PUBLIC'},
+                },
+            },
+            client_ip='10.0.0.5',
+        )
+
+        serialized = json.dumps(response['result'], ensure_ascii=False)
+        self.assertFalse(response['result']['isError'])
+        self.assertNotIn('tracking_number', serialized)
+        self.assertNotIn('time_zone', serialized)
+        self.assertIn('快递单号', serialized)
+        self.assertIn('TN-PUBLIC', serialized)
+        self.assertEqual('rq_public', response['result']['_meta']['request_id'])
+
+    def test_missing_session_on_tool_call_returns_device_protocol_error(self):
         gateway = FakeGateway()
         handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
         response = handler.handle_json_rpc(
@@ -62,8 +388,40 @@ class PublicMcpHttpHandlerTest(unittest.TestCase):
             },
         )
 
+        self.assertEqual(-32001, response['error']['code'])
+        self.assertIn('Workbuddy', response['error']['message'])
+        self.assertEqual([], gateway.calls)
+
+    def test_non_object_tool_params_fail_safely(self):
+        gateway = FakeGateway()
+        handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
+
+        with self.assertLogs('public_server', level='ERROR') as logs:
+            response = handler.handle_json_rpc(
+                headers={'X-Gateway-Session': 'GWS_A'},
+                message={
+                    'jsonrpc': '2.0',
+                    'id': 9,
+                    'method': 'tools/call',
+                    'params': 'not-an-object',
+                },
+                client_ip='10.0.0.5',
+            )
+
         self.assertTrue(response['result']['isError'])
-        self.assertIn('这台设备的 Workbuddy 配置已失效,请重新生成配置', response['result']['content'][0]['text'])
+        self.assertEqual(
+            '工具返回格式异常',
+            response['result']['structuredContent']['message'],
+        )
+        self.assertEqual([], gateway.calls)
+        record = logs.records[0]
+        self.assertEqual(9, record.jsonrpc_id)
+        self.assertTrue(record.request_id.startswith('rq_http_'))
+        self.assertEqual('', record.tool_code)
+        self.assertEqual('MCP_9001', record.response_code)
+        self.assertEqual('PARAM_VALIDATION_FAILED', record.diagnostic_reason)
+        self.assertEqual('ValueError', record.exception_class)
+        self.assertEqual(record.request_id, response['result']['_meta']['request_id'])
 
     def test_extract_client_ip_ignores_spoofable_forwarded_for_header(self):
         client_ip = extract_client_ip(
@@ -73,6 +431,248 @@ class PublicMcpHttpHandlerTest(unittest.TestCase):
 
         self.assertEqual('10.0.0.5', client_ip)
 
+
+class RateLimitTest(unittest.TestCase):
+    def test_rate_limit_helper_remains_usable_without_emitter(self):
+        handler = self._make_handler(max_requests=1)
+        self.assertIsNone(handler._check_rate_limit(
+            'GWS_A:query_order', 'tools/call', 'rq_http_first'
+        ))
+
+        response = handler._check_rate_limit(
+            'GWS_A:query_order', 'tools/call', 'rq_http_second'
+        )
+
+        self.assertEqual(-32029, response['error']['code'])
+
+    def test_rate_limit_rejection_emits_failed_diagnostic_event(self):
+        reporter = RecordingReporter()
+        limiter = SimpleRateLimiter(
+            max_requests=1,
+            window_seconds=60,
+            max_in_flight=1,
+        )
+        handler = PublicMcpHttpHandler(
+            FakeGateway(),
+            context_parser=FakeParser(),
+            rate_limiter=limiter,
+            reporter=reporter,
+        )
+        headers = {'X-Gateway-Session': 'GWS_A'}
+
+        handler.handle_json_rpc(headers, self._tools_call_msg())
+        reporter.events.clear()
+        handler.handle_json_rpc(headers, self._tools_call_msg())
+
+        event = next(
+            item for item in reporter.events if item['stage'] == 'rate_limit'
+        )
+        self.assertEqual('failed', event['status'])
+        self.assertEqual('RATE_LIMIT_EXCEEDED', event['event_code'])
+
+    def _make_handler(self, max_requests=2, max_in_flight=2):
+        gateway = FakeGateway()
+        limiter = SimpleRateLimiter(
+            max_requests=max_requests,
+            window_seconds=60,
+            max_in_flight=max_in_flight,
+        )
+        return PublicMcpHttpHandler(gateway, context_parser=FakeParser(), rate_limiter=limiter)
+
+    def _tools_call_msg(self, tool_name='query_order'):
+        return {
+            'jsonrpc': '2.0', 'id': 1,
+            'method': 'tools/call',
+            'params': {'name': tool_name, 'arguments': {'keyword': 'test'}},
+        }
+
+    # --- tools/call per-tool独立限流 ---
+
+    def test_known_tool_rate_limited_after_quota_exhausted(self):
+        handler = self._make_handler(max_requests=1)
+        handler.handle_json_rpc({'X-Gateway-Session': 'GWS_A'}, self._tools_call_msg(), client_ip='1.2.3.4')
+        with self.assertLogs('public_server', level='WARNING') as logs:
+            response = handler.handle_json_rpc({'X-Gateway-Session': 'GWS_A'}, self._tools_call_msg(), client_ip='1.2.3.4')
+        self.assertIn('error', response)
+        self.assertEqual(-32029, response['error']['code'])
+        self.assertIn('Rate limit', response['error']['message'])
+        record = logs.records[0]
+        self.assertEqual(1, record.jsonrpc_id)
+        self.assertEqual('query_order', record.tool_code)
+        self.assertEqual(-32029, record.protocol_code)
+        self.assertEqual('RATE_LIMIT_EXCEEDED', record.diagnostic_reason)
+        self.assertEqual(response['error']['data']['request_id'], record.request_id)
+
+    def test_two_known_tools_have_independent_quotas(self):
+        # query_order 限流不影响 query_track
+        handler = self._make_handler(max_requests=1)
+        handler.handle_json_rpc({'X-Gateway-Session': 'GWS_A'}, self._tools_call_msg('query_order'), client_ip='1.2.3.4')
+        # query_order quota exhausted, query_track should still work
+        response = handler.handle_json_rpc({'X-Gateway-Session': 'GWS_A'}, self._tools_call_msg('query_track'), client_ip='1.2.3.4')
+        self.assertNotIn('error', response)
+
+    def test_unknown_tool_name_uses_shared_session_bucket_not_new_bucket(self):
+        # 未知工具名应归入 session_id 桶,同一会话的不同未知工具共享同一配额,不能无限新建桶
+        handler = self._make_handler(max_requests=1)
+        # 先用 session 桶打一次(用未知工具名触发)
+        handler.handle_json_rpc({'X-Gateway-Session': 'GWS_A'}, self._tools_call_msg('fake_tool_1'), client_ip='1.2.3.4')
+        # 再用同会话的另一个未知工具名,应该命中同一个 session 桶,被限流
+        response = handler.handle_json_rpc({'X-Gateway-Session': 'GWS_A'}, self._tools_call_msg('fake_tool_2'), client_ip='1.2.3.4')
+        self.assertIn('error', response)
+        self.assertIn('Rate limit', response['error']['message'])
+
+    def test_different_sessions_have_independent_quotas(self):
+        # 不同会话(不同员工)即使来自同一IP,也拥有独立的配额
+        handler = self._make_handler(max_requests=1)
+        handler.handle_json_rpc({'X-Gateway-Session': 'GWS_A'}, self._tools_call_msg(), client_ip='1.1.1.1')
+        # 不同 session,即使同一 IP,quota 独立 → 应该允许
+        response = handler.handle_json_rpc({'X-Gateway-Session': 'GWS_B'}, self._tools_call_msg(), client_ip='1.1.1.1')
+        self.assertNotIn('error', response)
+
+    def test_same_session_from_different_ips_shares_quota(self):
+        # 同一会话从不同IP发来(如移动网络切换),仍共享同一会话配额
+        handler = self._make_handler(max_requests=1)
+        handler.handle_json_rpc({'X-Gateway-Session': 'GWS_A'}, self._tools_call_msg(), client_ip='1.1.1.1')
+        # 同 session,不同 IP → 命中同一 session 桶,被限流
+        response = handler.handle_json_rpc({'X-Gateway-Session': 'GWS_A'}, self._tools_call_msg(), client_ip='2.2.2.2')
+        self.assertIn('error', response)
+        self.assertIn('Rate limit', response['error']['message'])
+
+    # --- initialize 和 tools/list 不受限流 ---
+
+    def test_initialize_is_never_rate_limited(self):
+        # initialize 是握手协议,无论请求多少次都不应被限流
+        handler = self._make_handler(max_requests=1)
+        for i in range(5):
+            response = handler.handle_json_rpc({}, {'jsonrpc': '2.0', 'id': i, 'method': 'initialize'}, client_ip='1.2.3.4')
+            self.assertNotIn('error', response, f'initialize should never be rate limited (attempt {i})')
+            self.assertIn('protocolVersion', response['result'])
+
+    def test_tools_list_is_never_rate_limited(self):
+        handler = self._make_handler(max_requests=1)
+        headers = {'X-Gateway-Session': 'GWS_A'}
+        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_requests_do_not_consume_tools_call_quota(self):
+        # tools/list 不进入限流器,因此不会消耗 tools/call 配额
+        handler = self._make_handler(max_requests=1)
+        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 应仍可正常执行(使用 session_id:tool_name bucket)
+        response = handler.handle_json_rpc(
+            {'X-Gateway-Session': 'GWS_A'},
+            {'jsonrpc': '2.0', 'id': 2, 'method': 'tools/call',
+             'params': {'name': 'query_order', 'arguments': {'keyword': 'test'}}},
+            client_ip='1.2.3.4',
+        )
+        self.assertNotIn('error', response)
+
+    def test_completed_tool_call_releases_in_flight_slot_immediately(self):
+        handler = self._make_handler(max_requests=0, max_in_flight=1)
+        headers = {'X-Gateway-Session': 'GWS_A'}
+
+        for request_id in range(1, 6):
+            message = self._tools_call_msg('query_order')
+            message['id'] = request_id
+            response = handler.handle_json_rpc(
+                headers,
+                message,
+                client_ip='1.2.3.4',
+            )
+            self.assertNotIn('error', response)
+
+    def test_exhausted_in_flight_slots_return_rate_limit_error(self):
+        handler = self._make_handler(max_requests=0, max_in_flight=2)
+        key = 'GWS_A:query_order'
+        self.assertTrue(handler.rate_limiter.try_acquire(key))
+        self.assertTrue(handler.rate_limiter.try_acquire(key))
+
+        response = handler.handle_json_rpc(
+            {'X-Gateway-Session': 'GWS_A'},
+            self._tools_call_msg('query_order'),
+            client_ip='1.2.3.4',
+        )
+
+        self.assertEqual(-32029, response['error']['code'])
+        self.assertIn('in progress', response['error']['message'])
+
+    def test_failed_tool_call_releases_in_flight_slot(self):
+        handler = self._make_handler(max_requests=0, max_in_flight=1)
+        original_call = handler.gateway_app.call_tool
+
+        def fail(*args, **kwargs):
+            raise RuntimeError('backend failed')
+
+        handler.gateway_app.call_tool = fail
+        handler.handle_json_rpc(
+            {'X-Gateway-Session': 'GWS_A'},
+            self._tools_call_msg('query_order'),
+            client_ip='1.2.3.4',
+        )
+        handler.gateway_app.call_tool = original_call
+
+        response = handler.handle_json_rpc(
+            {'X-Gateway-Session': 'GWS_A'},
+            self._tools_call_msg('query_order'),
+            client_ip='1.2.3.4',
+        )
+        self.assertNotIn('error', response)
+
+
+class HttpDisconnectTest(unittest.TestCase):
+    def test_broken_pipe_emits_response_write_failure(self):
+        reporter = RecordingReporter()
+        emitter = RequestDiagnosticEmitter(reporter, 'rq_http_write')
+        handler_class = create_http_handler(
+            FakeGateway(),
+            rate_limiter=None,
+            reporter=reporter,
+        )
+        handler = object.__new__(handler_class)
+        handler.send_response = Mock()
+        handler.send_header = Mock()
+        handler.end_headers = Mock()
+        handler.wfile = Mock()
+        handler.wfile.write.side_effect = BrokenPipeError()
+
+        handler._write_json(
+            {'jsonrpc': '2.0', 'id': 1, 'result': {}},
+            diagnostic_emitter=emitter,
+        )
+
+        self.assertEqual('response_write', reporter.events[-1]['stage'])
+        self.assertEqual('failed', reporter.events[-1]['status'])
+        self.assertTrue(
+            reporter.events[-1]['context']['client_disconnected']
+        )
+
+    def test_broken_pipe_while_writing_response_is_handled(self):
+        handler_class = create_http_handler(FakeGateway(), rate_limiter=None)
+        handler = object.__new__(handler_class)
+        handler.send_response = Mock()
+        handler.send_header = Mock()
+        handler.end_headers = Mock()
+        handler.wfile = Mock()
+        handler.wfile.write.side_effect = BrokenPipeError()
+
+        with self.assertLogs('public_server', level='INFO') as logs:
+            handler._write_json({'jsonrpc': '2.0', 'id': 1, 'result': {}})
+
+        self.assertIn('client disconnected before response', logs.output[0])
+
+
+class RecordingReporter:
+    def __init__(self):
+        self.events = []
+
+    def report(self, event):
+        self.events.append(event)
+        return True
+
 if __name__ == '__main__':
     unittest.main()
-

+ 237 - 0
tests/test_public_server_coverage.py

@@ -0,0 +1,237 @@
+"""
+Coverage补充测试:public_server.py
+
+目标路径:
+- extract_client_ip — 空 client_address
+- do_GET — 非 /health 路径 → 404
+- do_POST — Content-Type 正确但 body 为无效 JSON → -32700 parse error
+- PublicMcpHttpHandler.handle_json_rpc — 未知 method → -32601 error
+"""
+import json
+import socket
+import threading
+import unittest
+from http.server import HTTPServer
+from unittest.mock import MagicMock, patch
+
+from public_server import (
+    PublicMcpHttpHandler,
+    create_http_handler,
+    extract_client_ip,
+    serve_public,
+)
+
+
+# ---------------------------------------------------------------------------
+# extract_client_ip 边界情况
+# ---------------------------------------------------------------------------
+
+class ExtractClientIpTest(unittest.TestCase):
+    def test_returns_empty_string_when_client_address_is_none(self):
+        result = extract_client_ip({}, None)
+        self.assertEqual('', result)
+
+    def test_returns_empty_string_when_client_address_is_empty_tuple(self):
+        result = extract_client_ip({}, ())
+        self.assertEqual('', result)
+
+    def test_returns_ip_from_client_address(self):
+        result = extract_client_ip({'X-Forwarded-For': '1.2.3.4'}, ('10.0.0.1', 12345))
+        self.assertEqual('10.0.0.1', result)
+
+
+# ---------------------------------------------------------------------------
+# PublicMcpHttpHandler.handle_json_rpc — 未知 method
+# ---------------------------------------------------------------------------
+
+class FakeGateway:
+    def registered_tool_names(self):
+        return ('query_order',)
+
+    def list_tools(self, gateway_session_id):
+        return [{'name': 'query_order', 'description': '', 'input_schema': {'type': 'object'}}]
+
+    def call_tool(self, *args, **kwargs):
+        return {'data': {}}
+
+
+class HandleJsonRpcUnknownMethodTest(unittest.TestCase):
+    def test_unknown_method_returns_method_not_found(self):
+        handler = PublicMcpHttpHandler(FakeGateway())
+        response = handler.handle_json_rpc(
+            headers={},
+            message={'jsonrpc': '2.0', 'id': 1, 'method': 'unknown/method'},
+        )
+        self.assertIn('error', response)
+        self.assertEqual(-32601, response['error']['code'])
+
+    def test_tools_list_preserves_metadata_without_input_schema(self):
+        gateway = FakeGateway()
+        gateway.list_tools = MagicMock(return_value=[{'name': 'query_order'}])
+        handler = PublicMcpHttpHandler(gateway)
+
+        response = handler.handle_json_rpc(
+            headers={'X-Gateway-Session': 'GWS_A'},
+            message={'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list'},
+        )
+
+        tool = response['result']['tools'][0]
+        self.assertEqual({'name': 'query_order'}, tool)
+        self.assertNotIn('inputSchema', tool)
+
+
+class ServePublicLifecycleTest(unittest.TestCase):
+    def test_rate_limiter_cleanup_and_keyboard_interrupt_shutdown(self):
+        class StopCleanupLoop(Exception):
+            pass
+
+        server = MagicMock()
+        server.serve_forever.side_effect = KeyboardInterrupt
+        limiter = MagicMock()
+
+        with patch('public_server.SimpleRateLimiter', return_value=limiter) as limiter_class, \
+                patch('public_server.ThreadingHTTPServer', return_value=server) as server_class, \
+                patch('public_server.threading.Thread') as thread_class:
+            serve_public(
+                FakeGateway(),
+                host='127.0.0.1',
+                port='9876',
+                rate_limit_max_requests=12,
+                rate_limit_window_seconds=34,
+            )
+
+            limiter_class.assert_called_once_with(
+                max_requests=12,
+                window_seconds=34,
+                max_in_flight=2,
+            )
+        self.assertEqual(('127.0.0.1', 9876), server_class.call_args.args[0])
+        thread_class.assert_called_once_with(
+            target=thread_class.call_args.kwargs['target'],
+            daemon=True,
+        )
+        thread_class.return_value.start.assert_called_once_with()
+        server.serve_forever.assert_called_once_with()
+        server.shutdown.assert_called_once_with()
+
+        cleanup_target = thread_class.call_args.kwargs['target']
+        with patch('public_server.time.sleep', side_effect=[None, StopCleanupLoop]):
+            with self.assertRaises(StopCleanupLoop):
+                cleanup_target()
+        limiter.cleanup.assert_called_once_with(max_age_seconds=34)
+
+
+# ---------------------------------------------------------------------------
+# 集成测试:启动真实 HTTP 服务器,覆盖 do_GET 404 和 do_POST JSON 错误
+# ---------------------------------------------------------------------------
+
+def _find_free_port():
+    with socket.socket() as s:
+        s.bind(('127.0.0.1', 0))
+        return s.getsockname()[1]
+
+
+class HttpHandlerIntegrationTest(unittest.TestCase):
+    """
+    启动一个真实的 ThreadingHTTPServer(随机端口),测试
+    HTTP 层面的边界路径。
+    """
+
+    @classmethod
+    def setUpClass(cls):
+        port = _find_free_port()
+        gateway = FakeGateway()
+        handler_class = create_http_handler(gateway, rate_limiter=None)
+        cls.server = HTTPServer(('127.0.0.1', port), handler_class)
+        cls.server_thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
+        cls.server_thread.start()
+        cls.base_url = 'http://127.0.0.1:{0}'.format(port)
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.server.shutdown()
+
+    def _get(self, path):
+        import urllib.request
+        req = urllib.request.Request(cls_url := self.base_url + path, method='GET')
+        try:
+            with urllib.request.urlopen(req, timeout=5) as resp:
+                return resp.status, resp.read()
+        except Exception as e:
+            # urllib raises HTTPError for non-2xx
+            if hasattr(e, 'code'):
+                return e.code, b''
+            raise
+
+    def _post(self, path, body, content_type='application/json'):
+        import urllib.request
+        data = body.encode('utf-8') if isinstance(body, str) else body
+        req = urllib.request.Request(
+            self.base_url + path,
+            data=data,
+            headers={'Content-Type': content_type, 'Content-Length': str(len(data))},
+            method='POST',
+        )
+        try:
+            with urllib.request.urlopen(req, timeout=5) as resp:
+                return resp.status, json.loads(resp.read())
+        except Exception as e:
+            if hasattr(e, 'code') and hasattr(e, 'read'):
+                body_bytes = e.read()
+                return e.code, json.loads(body_bytes) if body_bytes else {}
+            raise
+
+    # do_GET /health → 200
+    def test_get_health_returns_200(self):
+        status, body = self._get('/health')
+        self.assertEqual(200, status)
+
+    # do_GET 非 /health → 404(覆盖 lines 113-114)
+    def test_get_unknown_path_returns_404(self):
+        import urllib.request
+        req = urllib.request.Request(self.base_url + '/not-a-real-path', method='GET')
+        try:
+            urllib.request.urlopen(req, timeout=5)
+            self.fail('Expected 404')
+        except Exception as e:
+            self.assertEqual(404, e.code)
+
+    # do_POST 无效 JSON → -32700 parse error(覆盖 lines 127-130)
+    def test_post_invalid_json_returns_parse_error(self):
+        status, body = self._post('/mcp', 'this is not json at all')
+        self.assertEqual(200, status)
+        self.assertIn('error', body)
+        self.assertEqual(-32700, body['error']['code'])
+        self.assertEqual('Parse error', body['error']['message'])
+
+    # do_POST 到非 /mcp 路径 → 404
+    def test_post_wrong_path_returns_404(self):
+        import urllib.request
+        data = json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'initialize'}).encode()
+        req = urllib.request.Request(
+            self.base_url + '/wrong',
+            data=data,
+            headers={'Content-Type': 'application/json', 'Content-Length': str(len(data))},
+            method='POST',
+        )
+        try:
+            urllib.request.urlopen(req, timeout=5)
+            self.fail('Expected 404')
+        except Exception as e:
+            self.assertEqual(404, e.code)
+
+    # do_POST 正常 initialize → 200
+    def test_post_initialize_returns_200(self):
+        status, body = self._post('/mcp', json.dumps({
+            'jsonrpc': '2.0',
+            'id': 1,
+            'method': 'initialize',
+            'params': {},
+        }))
+        self.assertEqual(200, status)
+        self.assertIn('result', body)
+        self.assertIn('protocolVersion', body['result'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 40 - 5
tests/test_public_server_integration.py

@@ -43,6 +43,18 @@ class TestPublicServerIntegration(unittest.TestCase):
         self.mock_session_store.reset_mock()
         self.mock_api_client.reset_mock()
         self.mock_auth_client.reset_mock()
+        self.mock_session_store.get.return_value = None
+        self.mock_api_client.list_enabled_tools.return_value = {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_order',
+                    'query_track',
+                    'query_order_exact',
+                    'list_order_filter_options',
+                ],
+            },
+        }
 
     def _make_request(self, method, params=None, headers=None):
         """Helper to make JSON-RPC requests"""
@@ -86,7 +98,15 @@ class TestPublicServerIntegration(unittest.TestCase):
         self.assertIn('serverInfo', result['result'])
 
     def test_tools_list(self):
-        result = self._make_request('tools/list')
+        session_id = generate_gateway_session_id()
+        self.mock_session_store.get.return_value = {
+            'mcp_token': 'MT_valid_token',
+        }
+
+        result = self._make_request(
+            'tools/list',
+            headers={'X-Gateway-Session': session_id},
+        )
 
         self.assertIn('result', result)
         self.assertIn('tools', result['result'])
@@ -95,6 +115,8 @@ class TestPublicServerIntegration(unittest.TestCase):
         tool_names = [tool['name'] for tool in tools]
         self.assertIn('query_order', tool_names)
         self.assertIn('query_track', tool_names)
+        self.assertIn('query_order_exact', tool_names)
+        self.assertIn('list_order_filter_options', tool_names)
         self.assertNotIn('bind_auth_code', tool_names)
 
         # Check tool structure
@@ -103,12 +125,21 @@ class TestPublicServerIntegration(unittest.TestCase):
             self.assertIn('description', tool)
             self.assertIn('inputSchema', tool)
 
+        by_name = {tool['name']: tool for tool in tools}
+        exact_tool = by_name['query_order_exact']
+        self.assertIn('单号类型不明确', exact_tool['description'])
+        self.assertIn(
+            '系统订单号',
+            exact_tool['inputSchema']['properties']['order_number'][
+                'description'
+            ],
+        )
+
     def test_tools_call_without_session(self):
         result = self._make_request('tools/call', {'name': 'query_order', 'arguments': {}})
 
-        self.assertIn('result', result)
-        self.assertTrue(result['result']['isError'])
-        self.assertIn(DEVICE_INVALID_MESSAGE, result['result']['content'][0]['text'])
+        self.assertEqual(-32001, result['error']['code'])
+        self.assertIn(DEVICE_INVALID_MESSAGE, result['error']['message'])
 
     def test_tools_call_bind_auth_code_is_not_registered_in_public_mode(self):
         session_id = generate_gateway_session_id()
@@ -125,7 +156,11 @@ class TestPublicServerIntegration(unittest.TestCase):
 
         self.assertIn('result', result)
         self.assertTrue(result['result']['isError'])
-        self.assertIn('tool not registered', result['result']['content'][0]['text'])
+        self.assertNotIn('bind_auth_code', result['result']['content'][0]['text'])
+        self.assertEqual(
+            'MCP_9001',
+            result['result']['structuredContent']['code'],
+        )
         self.mock_auth_client.exchange.assert_not_called()
 
     def test_tools_call_with_valid_session(self):

+ 314 - 0
tests/test_query_customs_declaration_files_tool.py

@@ -0,0 +1,314 @@
+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
+
+
+class RecordingApiClient:
+    def __init__(self, enabled=True):
+        self.enabled = enabled
+        self.last_call = None
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': (
+                ['query_customs_declaration_files'] if self.enabled else []
+            )},
+        }
+
+    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': {'columns': [], 'records': []}}
+
+
+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 __init__(self, enabled=True):
+        self.enabled = enabled
+        self.calls = []
+
+    def list_enabled_tools(self, token, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': (
+                ['query_customs_declaration_files'] if self.enabled else []
+            )},
+        }
+
+    def call_tool(self, **kwargs):
+        self.calls.append(kwargs)
+        return {'code': 'MCP_0000'}
+
+
+class QueryCustomsDeclarationFilesToolTest(unittest.TestCase):
+    def tool_class(self):
+        path = os.path.join(
+            os.path.dirname(os.path.dirname(__file__)),
+            'tools',
+            'query_customs_declaration_files.py',
+        )
+        self.assertTrue(os.path.exists(path), path)
+        module = importlib.import_module(
+            'tools.query_customs_declaration_files'
+        )
+        return module.QueryCustomsDeclarationFilesTool
+
+    def test_metadata_teaches_ai_exact_order_number_semantics(self):
+        metadata = self.tool_class()().metadata()
+        schema = metadata['input_schema']
+        properties = schema['properties']
+
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual([
+            {'required': ['outbound_numbers']},
+            {'required': ['order_numbers']},
+        ], schema['oneOf'])
+        for field in ('outbound_numbers', 'order_numbers'):
+            prop = properties[field]
+            self.assertEqual('array', prop['type'])
+            self.assertEqual('string', prop['items']['type'])
+            self.assertEqual(1, prop['minItems'])
+            self.assertEqual(100, prop['maxItems'])
+            self.assertNotIn('uniqueItems', prop)
+
+        order_description = properties['order_numbers']['description']
+        for phrase in (
+            '订单号',
+            '后台订单列表',
+            '排舱详情',
+            '不是系统单号',
+            'order_id/id',
+            '不是客户参考号',
+            '快递单号',
+            '排舱单号',
+        ):
+            self.assertIn(phrase, order_description)
+        self.assertNotIn('系统订单号', order_description)
+
+        description = metadata['description']
+        for phrase in (
+            '用户明确说“订单号”',
+            '用户明确说“排舱单号”',
+            '没有明确说明是订单号还是排舱单号',
+            '必须先提问,让用户选择“订单号”或“排舱单号”',
+            '只说“单号”时必须先追问',
+            '不是系统单号',
+            '用户说“系统单号”时也不得当作订单号',
+            '不得根据号码格式猜测',
+            '不得跨字段重试',
+            '不得展示内部参数名',
+        ):
+            self.assertIn(phrase, description)
+        self.assertNotIn('系统订单号', description)
+
+    def test_outbound_batch_normalizes_and_forwards_supported_fields(self):
+        client = RecordingApiClient()
+        tool = self.tool_class()(api_client=client)
+
+        result = tool.call(
+            outbound_numbers=[' PC001 ', 'PC002', 'PC001'],
+            page=2,
+            limit=50,
+            request_id='rq_customs',
+        )
+
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual(
+            'query_customs_declaration_files',
+            client.last_call['tool_code'],
+        )
+        self.assertEqual(
+            '/mcp/tools/queryCustomsDeclarationFiles',
+            client.last_call['route_path'],
+        )
+        self.assertEqual({
+            'outbound_numbers': ['PC001', 'PC002'],
+            'page': 2,
+            'limit': 50,
+        }, client.last_call['payload'])
+
+    def test_order_batch_uses_order_numbers_business_field(self):
+        client = RecordingApiClient()
+        tool = self.tool_class()(api_client=client)
+
+        tool.call(order_numbers=[' ORD001 ', 'ORD002'])
+
+        self.assertEqual({
+            'order_numbers': ['ORD001', 'ORD002'],
+            'page': 1,
+            'limit': 20,
+        }, client.last_call['payload'])
+
+    def test_call_rejects_mixed_missing_and_invalid_batches(self):
+        tool = self.tool_class()(api_client=RecordingApiClient())
+        invalid = (
+            {},
+            {'outbound_numbers': ['PC001'], 'order_numbers': ['ORD001']},
+            {'order_numbers': []},
+            {'order_numbers': 'ORD001'},
+            {'order_numbers': ['']},
+            {'order_numbers': ['ORD001', 2]},
+            {'order_numbers': ['X' * 101]},
+            {'order_numbers': ['ORD{0}'.format(i) for i in range(101)]},
+            {'order_numbers': ['ORD001'], 'page': 0},
+            {'order_numbers': ['ORD001'], 'page': 101},
+            {'order_numbers': ['ORD001'], 'limit': 101},
+            {'order_numbers': ['ORD001'], 'page': True},
+            {'order_numbers': ['ORD001'], 'page': 'not-a-number'},
+        )
+        for arguments in invalid:
+            with self.subTest(arguments=arguments):
+                with self.assertRaises(ValueError):
+                    tool.call(**arguments)
+
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            self.tool_class()().call(order_numbers=['ORD001'])
+
+    def test_local_and_public_gateways_follow_dynamic_registry(self):
+        local_enabled = {
+            item['name']
+            for item in GatewayApp(api_client=RecordingApiClient()).list_tools()
+        }
+        local_disabled = {
+            item['name']
+            for item in GatewayApp(
+                api_client=RecordingApiClient(enabled=False)
+            ).list_tools()
+        }
+        public_enabled = {
+            item['name']
+            for item in PublicGatewayApp(
+                PublicSessionStore(),
+                PublicApiClient(),
+            ).list_tools('GWS_test')
+        }
+
+        self.assertIn('query_customs_declaration_files', local_enabled)
+        self.assertNotIn('query_customs_declaration_files', local_disabled)
+        self.assertIn('query_customs_declaration_files', public_enabled)
+
+    def test_public_gateway_forwards_request_scoped_token_without_company_input(self):
+        api_client = PublicApiClient()
+        gateway = PublicGatewayApp(PublicSessionStore(), api_client)
+        self.assertIn(
+            'query_customs_declaration_files',
+            gateway.registered_tool_names(),
+        )
+
+        gateway.call_tool(
+            'GWS_test',
+            'query_customs_declaration_files',
+            {'order_numbers': ['ORD001']},
+            request_id='rq_public_customs',
+        )
+
+        call = api_client.calls[0]
+        self.assertEqual('MT_test', call['token'])
+        self.assertEqual(
+            '/mcp/tools/queryCustomsDeclarationFiles',
+            call['route_path'],
+        )
+        self.assertEqual({'order_numbers': ['ORD001']}, call['payload'])
+        self.assertNotIn('company_id', call['payload'])
+
+    def test_output_presenter_hides_internal_fields_and_keeps_links(self):
+        result = OutputPresenter().present(
+            'query_customs_declaration_files',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'summary': '当前页返回 1 个订单的报关资料',
+                    'columns': [
+                        {'key': 'outbound_number', 'name': '排舱单号'},
+                        {'key': 'order_number', 'name': '订单号'},
+                        {'key': 'file_name', 'name': '文件名'},
+                        {'key': 'file_url', 'name': '文件链接'},
+                    ],
+                    'records': [{
+                        'outbound_number': 'PC001',
+                        'order_number': 'ORD001',
+                        'file_name': '报关单.pdf',
+                        'file_url': 'https://files.test/a.pdf',
+                        'order_id': 99,
+                    }],
+                },
+                'meta': {
+                    'page': 1,
+                    'limit': 20,
+                    'has_more': False,
+                    'request_id': 'rq_present',
+                },
+            },
+        )
+
+        self.assertFalse(result['is_error'])
+        self.assertEqual(
+            ['排舱单号', '订单号', '文件名', '文件链接'],
+            [header['label'] for header in result['structured_content']['headers']],
+        )
+        self.assertIn('https://files.test/a.pdf', result['text'])
+        serialized = json.dumps(result, ensure_ascii=False)
+        for internal in (
+            'outbound_number',
+            'order_number',
+            'file_name',
+            'file_url',
+            'order_id',
+        ):
+            self.assertNotIn(internal, serialized)
+
+    def test_cli_forwards_batch_arguments(self):
+        client = RecordingApiClient()
+        stdout = io.StringIO()
+        app = GatewayApp(api_client=client)
+        self.assertIn(
+            'query_customs_declaration_files',
+            app.registered_tool_names(),
+        )
+
+        result = app.run_cli([
+            'call',
+            '--tool', 'query_customs_declaration_files',
+            '--order-numbers', 'ORD001,ORD002',
+            '--page', '2',
+            '--limit', '10',
+        ], stdout=stdout)
+
+        self.assertEqual(0, result)
+        self.assertEqual({
+            'order_numbers': ['ORD001', 'ORD002'],
+            'page': 2,
+            'limit': 10,
+        }, client.last_call['payload'])
+
+        app.run_cli([
+            'call',
+            '--tool', 'query_customs_declaration_files',
+            '--outbound-numbers', 'PC001,PC002',
+        ], stdout=io.StringIO())
+        self.assertEqual({
+            'outbound_numbers': ['PC001', 'PC002'],
+            'page': 1,
+            'limit': 20,
+        }, client.last_call['payload'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 100 - 0
tests/test_query_export_task_tool.py

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

+ 87 - 0
tests/test_query_order_coverage.py

@@ -0,0 +1,87 @@
+"""
+Coverage补充测试:tools/query_order.py
+
+目标路径:
+- call() — api_client is None → RuntimeError
+- call() — keyword strip 后为空 → ValueError
+"""
+import unittest
+from unittest.mock import MagicMock
+
+from tools.query_order import QueryOrderTool
+
+
+class QueryOrderToolCoverageTest(unittest.TestCase):
+    # ---- api_client is None ----
+
+    def test_call_raises_when_no_api_client(self):
+        tool = QueryOrderTool(api_client=None)
+        with self.assertRaises(RuntimeError) as ctx:
+            tool.call('some keyword')
+        self.assertIn('api client is required', str(ctx.exception))
+
+    def test_call_raises_when_api_client_not_set(self):
+        tool = QueryOrderTool()
+        with self.assertRaises(RuntimeError):
+            tool.call('kw')
+
+    # ---- keyword 为空 ----
+
+    def test_call_raises_on_empty_string_keyword(self):
+        tool = QueryOrderTool(api_client=MagicMock())
+        with self.assertRaises(ValueError) as ctx:
+            tool.call('')
+        self.assertIn('keyword is required', str(ctx.exception))
+
+    def test_call_raises_on_whitespace_only_keyword(self):
+        tool = QueryOrderTool(api_client=MagicMock())
+        with self.assertRaises(ValueError) as ctx:
+            tool.call('   ')
+        self.assertIn('keyword is required', str(ctx.exception))
+
+    def test_call_raises_on_tab_keyword(self):
+        tool = QueryOrderTool(api_client=MagicMock())
+        with self.assertRaises(ValueError):
+            tool.call('\t')
+
+    # ---- 正常路径(验证 strip 和参数传递)----
+
+    def test_call_strips_keyword_before_passing(self):
+        client = MagicMock()
+        client.call_tool.return_value = {'code': 'MCP_0000', 'data': {}}
+        tool = QueryOrderTool(api_client=client)
+
+        tool.call('  SO001  ', page=2, limit=50, request_id='rq_test')
+
+        _, _, payload, _ = client.call_tool.call_args[0]
+        self.assertEqual('SO001', payload['keyword'])
+
+    def test_call_clamps_limit_to_100(self):
+        client = MagicMock()
+        client.call_tool.return_value = {'code': 'MCP_0000', 'data': {}}
+        tool = QueryOrderTool(api_client=client)
+
+        tool.call('kw', limit=999)
+
+        _, _, payload, _ = client.call_tool.call_args[0]
+        self.assertEqual(100, payload['limit'])
+
+    def test_call_clamps_page_to_1(self):
+        client = MagicMock()
+        client.call_tool.return_value = {'code': 'MCP_0000', 'data': {}}
+        tool = QueryOrderTool(api_client=client)
+
+        tool.call('kw', page=0)
+
+        _, _, payload, _ = client.call_tool.call_args[0]
+        self.assertEqual(1, payload['page'])
+
+    def test_metadata_has_required_keyword(self):
+        tool = QueryOrderTool()
+        meta = tool.metadata()
+        self.assertIn('keyword', meta['input_schema']['required'])
+        self.assertEqual('query_order', meta['name'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 304 - 0
tests/test_query_order_exact_tool.py

@@ -0,0 +1,304 @@
+import inspect
+import unittest
+from io import StringIO
+
+import app as gateway_app_module
+from app import GatewayApp, parse_int_list
+from public_gateway import PublicGatewayApp
+from tools.query_order_exact import QueryOrderExactTool
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.last_call = None
+
+    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'}
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_order_exact']},
+        }
+
+
+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': ['query_order_exact']},
+        }
+
+
+class QueryOrderExactToolTest(unittest.TestCase):
+    def test_local_and_public_gateways_register_tool(self):
+        local_names = {
+            tool['name']
+            for tool in GatewayApp(api_client=RecordingApiClient()).list_tools()
+        }
+        public_names = {
+            tool['name']
+            for tool in PublicGatewayApp(
+                PublicSessionStore(),
+                PublicApiClient(),
+            ).list_tools('GWS_test')
+        }
+
+        self.assertIn('query_order_exact', local_names)
+        self.assertIn('query_order_exact', public_names)
+
+    def test_parse_int_list_accepts_comma_separated_ids(self):
+        self.assertEqual([9, 12], parse_int_list('9, 12'))
+        self.assertEqual([], parse_int_list(''))
+
+    def test_metadata_exposes_all_exact_filters(self):
+        schema = QueryOrderExactTool().metadata()['input_schema']
+
+        for field in (
+            'order_number', 'reference_number', 'tracking_number',
+            'outbound_number', 'container_code', 'so_number', 'shipment_id',
+            'receiver_country', 'product_ids', 'customer_ids', 'sales_id',
+            'warehouse_ids', 'department_id', 'inbound_date_start',
+            'inbound_date_end', 'outbound_date_start', 'outbound_date_end',
+            'page', 'limit',
+        ):
+            self.assertIn(field, schema['properties'])
+        for field in (
+            'order_numbers', 'reference_numbers', 'tracking_numbers',
+            'outbound_numbers', 'container_codes', 'so_numbers',
+        ):
+            self.assertIn(field, schema['properties'])
+            self.assertEqual('array', schema['properties'][field]['type'])
+            self.assertEqual(
+                'string',
+                schema['properties'][field]['items']['type'],
+            )
+        self.assertEqual(100, schema['properties']['limit']['maximum'])
+
+    def test_metadata_guides_ai_to_explicit_fields_without_fallback(self):
+        metadata = QueryOrderExactTool().metadata()
+        description = metadata['description']
+        properties = metadata['input_schema']['properties']
+
+        for phrase in (
+            '单号类型不明确', '先询问用户', '不得改用其他字段',
+            '同一字段使用 IN', '不同字段使用 AND', '排舱单号',
+            '所有单号格式均为开放格式',
+            '只能根据用户明确说出的业务类型',
+            '前缀、长度、字符组合或示例',
+        ):
+            self.assertIn(phrase, description)
+        self.assertNotIn('出库单号、柜号', description)
+
+        single_number_fields = (
+            'order_number', 'reference_number', 'tracking_number',
+            'outbound_number', 'container_code', 'so_number', 'shipment_id',
+        )
+        batch_fields = (
+            'order_numbers', 'reference_numbers', 'tracking_numbers',
+            'outbound_numbers', 'container_codes', 'so_numbers',
+        )
+        for field in single_number_fields:
+            self.assertTrue(properties[field]['description'])
+            self.assertIsInstance(properties[field]['examples'][0], str)
+            self.assertIn('用户明确', properties[field]['description'])
+        for field in batch_fields:
+            self.assertTrue(properties[field]['description'])
+            self.assertIsInstance(properties[field]['examples'][0], list)
+            self.assertGreater(len(properties[field]['examples'][0]), 1)
+            self.assertIn('用户明确', properties[field]['description'])
+
+        self.assertIn('系统订单号', properties['order_number']['description'])
+        self.assertIn(
+            '客户参考号',
+            properties['reference_number']['description'],
+        )
+        self.assertIn('承运商', properties['tracking_number']['description'])
+        outbound_description = properties['outbound_number']['description']
+        self.assertIn('排舱单号', outbound_description)
+        self.assertIn('不是海外仓出库单号', outbound_description)
+        self.assertNotIn('以 PC 开头', outbound_description)
+        self.assertNotIn(
+            '每个号码均以 PC 开头',
+            properties['outbound_numbers']['description'],
+        )
+        so_description = properties['so_number']['description']
+        self.assertIn('格式不固定', so_description)
+        self.assertIn('用户明确', so_description)
+        self.assertEqual(
+            '97964454',
+            properties['so_number']['examples'][0],
+        )
+        self.assertEqual(
+            ['97964454', 'OOLU12345678'],
+            properties['so_numbers']['examples'][0],
+        )
+        self.assertIn('柜号', properties['container_code']['description'])
+        self.assertIn('Shipping Order', so_description)
+
+        for field in (
+            'receiver_country', 'product_ids', 'customer_ids',
+            'sales_id', 'warehouse_ids', 'department_id',
+            'inbound_date_start', 'inbound_date_end',
+            'outbound_date_start', 'outbound_date_end', 'page', 'limit',
+        ):
+            self.assertTrue(properties[field]['description'])
+
+    def test_call_forwards_normalized_number_arrays(self):
+        tool = QueryOrderExactTool(api_client=RecordingApiClient())
+        self.assertIn('order_numbers', inspect.signature(tool.call).parameters)
+
+        tool.call(
+            order_number=' A ',
+            order_numbers=[' B ', 'A'],
+            tracking_numbers=['T1', 'T2'],
+        )
+
+        self.assertEqual('A', tool.api_client.last_call['payload']['order_number'])
+        self.assertEqual(
+            ['B', 'A'],
+            tool.api_client.last_call['payload']['order_numbers'],
+        )
+        self.assertEqual(
+            ['T1', 'T2'],
+            tool.api_client.last_call['payload']['tracking_numbers'],
+        )
+
+    def test_call_rejects_invalid_number_arrays(self):
+        tool = QueryOrderExactTool(api_client=RecordingApiClient())
+        self.assertIn('order_numbers', inspect.signature(tool.call).parameters)
+
+        invalid_values = ('A,B', [1], [''], [' '], ['X' * 101])
+        for values in invalid_values:
+            with self.subTest(values=values):
+                with self.assertRaises(ValueError):
+                    tool.call(order_numbers=values)
+
+        with self.assertRaisesRegex(ValueError, 'must not exceed 200'):
+            tool.call(
+                order_numbers=['O{0}'.format(i) for i in range(200)],
+                so_numbers=['S1'],
+            )
+
+    def test_call_normalizes_exact_filters_and_id_arrays(self):
+        client = RecordingApiClient()
+        tool = QueryOrderExactTool(api_client=client)
+
+        result = tool.call(
+            order_number=' USC001 ',
+            customer_ids=['9', 9, 0, -1, '12'],
+            warehouse_ids=['-1', '5'],
+            sales_id='7',
+            page=0,
+            limit=200,
+            request_id='rq_exact',
+        )
+
+        self.assertEqual({'code': 'MCP_0000'}, result)
+        self.assertEqual('query_order_exact', client.last_call['tool_code'])
+        self.assertEqual('/mcp/tools/queryOrderExact', client.last_call['route_path'])
+        self.assertEqual('rq_exact', client.last_call['request_id'])
+        self.assertEqual({
+            'order_number': 'USC001',
+            'customer_ids': [9, 12],
+            'sales_id': 7,
+            'warehouse_ids': [-1, 5],
+            'page': 1,
+            'limit': 100,
+        }, client.last_call['payload'])
+
+    def test_call_requires_one_business_filter(self):
+        tool = QueryOrderExactTool(api_client=RecordingApiClient())
+
+        with self.assertRaisesRegex(ValueError, 'at least one exact order filter'):
+            tool.call(page=1, limit=20)
+
+    def test_call_requires_api_client(self):
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            QueryOrderExactTool().call(order_number='USC001')
+
+    def test_call_rejects_non_positive_scalar_ids(self):
+        tool = QueryOrderExactTool(api_client=RecordingApiClient())
+
+        for field in ('sales_id', 'department_id'):
+            with self.subTest(field=field):
+                with self.assertRaisesRegex(ValueError, 'must be greater than 0'):
+                    tool.call(order_number='O1', **{field: 0})
+
+    def test_normalize_int_list_accepts_comma_separated_values(self):
+        self.assertEqual(
+            [9, 12],
+            QueryOrderExactTool._normalize_int_list('9, 12, 9, 0'),
+        )
+
+    def test_normalize_string_list_deduplicates_values(self):
+        self.assertEqual(
+            ['A', 'B'],
+            QueryOrderExactTool._normalize_string_list(
+                ['A', 'B', 'A'],
+                'numbers',
+            ),
+        )
+
+    def test_cli_forwards_exact_fields_and_id_lists(self):
+        client = RecordingApiClient()
+        output = StringIO()
+
+        exit_code = GatewayApp(api_client=client).run_cli([
+            'call',
+            '--tool', 'query_order_exact',
+            '--order-number', 'USC001',
+            '--customer-ids', '9,12',
+            '--warehouse-ids=-1,5',
+            '--inbound-date-start', '2026-07-01',
+        ], stdout=output)
+
+        self.assertEqual(0, exit_code)
+        self.assertEqual({
+            'order_number': 'USC001',
+            'customer_ids': [9, 12],
+            'warehouse_ids': [-1, 5],
+            'inbound_date_start': '2026-07-01',
+            'page': 1,
+            'limit': 20,
+        }, client.last_call['payload'])
+
+    def test_cli_forwards_batch_number_lists(self):
+        self.assertTrue(hasattr(gateway_app_module, 'parse_string_list'))
+        self.assertEqual(
+            ['A', 'B'],
+            gateway_app_module.parse_string_list(' A, B, A '),
+        )
+
+        client = RecordingApiClient()
+        output = StringIO()
+        exit_code = GatewayApp(api_client=client).run_cli([
+            'call',
+            '--tool', 'query_order_exact',
+            '--order-numbers', 'A,B,A',
+            '--tracking-numbers', 'T1,T2',
+        ], stdout=output)
+
+        self.assertEqual(0, exit_code)
+        self.assertEqual(['A', 'B'], client.last_call['payload']['order_numbers'])
+        self.assertEqual(
+            ['T1', 'T2'],
+            client.last_call['payload']['tracking_numbers'],
+        )
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 39 - 0
tests/test_rate_limiter.py

@@ -68,6 +68,45 @@ class TestSimpleRateLimiter(unittest.TestCase):
         limiter.cleanup(max_age_seconds=0.05)
         self.assertEqual(len(limiter._requests), 0)
 
+    def test_in_flight_slot_is_reusable_after_release(self):
+        limiter = SimpleRateLimiter(
+            max_requests=0,
+            window_seconds=60,
+            max_in_flight=2,
+        )
+        key = 'GWS_A:query_order_detail'
+
+        self.assertTrue(limiter.try_acquire(key))
+        self.assertTrue(limiter.try_acquire(key))
+        self.assertFalse(limiter.try_acquire(key))
+
+        limiter.release(key)
+        self.assertTrue(limiter.try_acquire(key))
+        limiter.release(key)
+        limiter.release(key)
+        self.assertNotIn(key, limiter._in_flight)
+
+    def test_zero_window_limit_disables_request_count_limit(self):
+        limiter = SimpleRateLimiter(
+            max_requests=0,
+            window_seconds=60,
+            max_in_flight=1,
+        )
+
+        for _ in range(100):
+            self.assertTrue(limiter.is_allowed('GWS_A:query_order_detail'))
+
+    def test_zero_in_flight_limit_is_unbounded_and_unknown_release_is_safe(self):
+        limiter = SimpleRateLimiter(
+            max_requests=0,
+            window_seconds=60,
+            max_in_flight=0,
+        )
+
+        self.assertTrue(limiter.try_acquire('GWS_A:query_order_detail'))
+        limiter.release('GWS_A:query_order_detail')
+        limiter.release('unknown')
+
 
 if __name__ == '__main__':
     unittest.main()

+ 41 - 0
tests/test_scoped_api_client.py

@@ -44,6 +44,21 @@ class TestScopedApiClient(unittest.TestCase):
         self.assertEqual(result['code'], '0')
         self.assertEqual(result['data']['order'], 'details')
 
+    def test_call_tool_includes_client_ip_header_when_present(self):
+        self.mock_transport.post_json.return_value = {'code': '0'}
+
+        self.client.call_tool(
+            'MT_valid_token',
+            'query_order',
+            '/mcp/tools/query_order',
+            {'order_no': 'ABC123'},
+            'rq_ip_header',
+            client_ip='203.0.113.9'
+        )
+
+        call_headers = self.mock_transport.post_json.call_args[0][2]
+        self.assertEqual(call_headers['X-MCP-Client-IP'], '203.0.113.9')
+
     def test_call_tool_raises_on_empty_token(self):
         with self.assertRaises(RuntimeError) as context:
             self.client.call_tool('', 'query_order', '/path', {}, 'rq_1')
@@ -120,6 +135,32 @@ class TestScopedApiClient(unittest.TestCase):
         self.assertEqual(call_headers['X-MCP-Tool-Code'], 'query_track')
         self.assertEqual(call_headers['X-Request-Id'], 'rq_xyz')
 
+    def test_list_enabled_tools_uses_only_explicit_token(self):
+        expected = {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_order_exact']},
+        }
+        self.mock_transport.post_json.return_value = expected
+
+        result = self.client.list_enabled_tools(' MT_scoped ', 'rq_tool_list')
+
+        self.assertIs(expected, result)
+        self.mock_transport.post_json.assert_called_once_with(
+            'http://api.example.com/mcp/tools/listEnabledTools',
+            {},
+            {
+                'Authorization': 'Bearer MT_scoped',
+                'X-Request-Id': 'rq_tool_list',
+            },
+            15,
+        )
+
+    def test_list_enabled_tools_rejects_empty_token(self):
+        with self.assertRaisesRegex(RuntimeError, 'mcp token missing'):
+            self.client.list_enabled_tools('')
+
+        self.mock_transport.post_json.assert_not_called()
+
 
 if __name__ == '__main__':
     unittest.main()

+ 389 - 0
tests/test_token_store_coverage.py

@@ -0,0 +1,389 @@
+"""
+Coverage补充测试:services/token_store.py
+
+目标路径:
+- InMemoryTokenStore.is_expiring — 无session、无expire_time、Z后缀、过期中、未过期
+- InMemoryTokenStore.require_token — 无session、token为空、正常返回
+- RedisTokenStore — 初始化校验、save(含/不含TTL)、get(bytes/str/空串/None)、clear、is_expiring
+- RedisTokenStore._ttl_seconds — None、Z后缀、naive datetime
+- RedisSocketClient._read_response — +/-/:/$/$(nil)/*(含空数组)/unsupported/empty
+- RedisSocketClient._execute — 带 password+db、不带 password+db
+- RedisSocketClient.set/get/delete(mocked socket)
+"""
+import io
+import json
+import os
+import tempfile
+import unittest
+from datetime import datetime, timedelta, timezone
+from unittest.mock import MagicMock, patch
+
+from services.token_store import (
+    FileTokenStore,
+    InMemoryTokenStore,
+    RedisSocketClient,
+    RedisTokenStore,
+)
+
+
+# ---------------------------------------------------------------------------
+# InMemoryTokenStore
+# ---------------------------------------------------------------------------
+
+class InMemoryIsExpiringTest(unittest.TestCase):
+    def test_no_session_is_expiring(self):
+        store = InMemoryTokenStore()
+        self.assertTrue(store.is_expiring())
+
+    def test_session_with_none_expire_time_is_expiring(self):
+        store = InMemoryTokenStore()
+        store._session = {'token': 'T', 'expire_time': None}
+        self.assertTrue(store.is_expiring())
+
+    def test_session_with_empty_expire_time_is_expiring(self):
+        store = InMemoryTokenStore()
+        store._session = {'token': 'T', 'expire_time': ''}
+        self.assertTrue(store.is_expiring())
+
+    def test_expire_soon_within_skew_is_expiring(self):
+        store = InMemoryTokenStore(refresh_skew_seconds=120)
+        # 60 秒后到期,在 120s 的 skew 窗口内 → 算作 expiring
+        soon = (datetime.now(timezone.utc) + timedelta(seconds=60)).strftime('%Y-%m-%dT%H:%M:%SZ')
+        store._session = {'token': 'T', 'expire_time': soon}
+        self.assertTrue(store.is_expiring())
+
+    def test_expire_far_future_not_expiring(self):
+        store = InMemoryTokenStore(refresh_skew_seconds=120)
+        store._session = {'token': 'T', 'expire_time': '2099-01-01T00:00:00Z'}
+        self.assertFalse(store.is_expiring())
+
+    def test_expire_time_z_suffix_parsed_correctly(self):
+        store = InMemoryTokenStore(refresh_skew_seconds=0)
+        # 已过期(1秒前)
+        past = (datetime.now(timezone.utc) - timedelta(seconds=1)).strftime('%Y-%m-%dT%H:%M:%SZ')
+        store._session = {'token': 'T', 'expire_time': past}
+        self.assertTrue(store.is_expiring())
+
+    def test_expire_time_without_z_suffix(self):
+        store = InMemoryTokenStore(refresh_skew_seconds=0)
+        # 无时区后缀的 ISO 格式(naive datetime)
+        past = (datetime.now() - timedelta(seconds=1)).strftime('%Y-%m-%dT%H:%M:%S')
+        store._session = {'token': 'T', 'expire_time': past}
+        self.assertTrue(store.is_expiring())
+
+
+class InMemoryRequireTokenTest(unittest.TestCase):
+    def test_raises_when_no_session(self):
+        store = InMemoryTokenStore()
+        with self.assertRaises(RuntimeError) as ctx:
+            store.require_token()
+        self.assertIn('mcp token missing', str(ctx.exception))
+
+    def test_raises_when_token_is_empty_string(self):
+        store = InMemoryTokenStore()
+        store._session = {'token': '', 'expire_time': '2099-01-01T00:00:00'}
+        with self.assertRaises(RuntimeError):
+            store.require_token()
+
+    def test_returns_token_when_present(self):
+        store = InMemoryTokenStore()
+        store.save('MT_abc123', '2099-01-01T00:00:00')
+        self.assertEqual('MT_abc123', store.require_token())
+
+
+class FileTokenStoreBoundaryTest(unittest.TestCase):
+    def test_save_without_parent_directory(self):
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            old_cwd = os.getcwd()
+            try:
+                os.chdir(tmp_dir)
+                store = FileTokenStore('token.json')
+                store.save('MT_test', '2099-01-01T00:00:00')
+                self.assertTrue(os.path.exists('token.json'))
+            finally:
+                os.chdir(old_cwd)
+
+    def test_get_reloads_when_session_is_missing_in_memory(self):
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            path = os.path.join(tmp_dir, 'token.json')
+            store = FileTokenStore(path)
+            with open(path, 'w', encoding='utf-8') as handle:
+                json.dump({
+                    'token': 'MT_disk',
+                    'expire_time': '2099-01-01T00:00:00',
+                }, handle)
+
+            self.assertEqual('MT_disk', store.get()['token'])
+
+    def test_clear_when_file_is_already_missing(self):
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            path = os.path.join(tmp_dir, 'missing-token.json')
+            store = FileTokenStore(path)
+
+            store.clear()
+
+            self.assertIsNone(store.get())
+
+
+# ---------------------------------------------------------------------------
+# RedisTokenStore(使用 MagicMock client 隔离真实 Redis)
+# ---------------------------------------------------------------------------
+
+class RedisTokenStoreMockedTest(unittest.TestCase):
+    def _make_store(self, raw_get=None, prefix='test:', session_key='session_1'):
+        client = MagicMock()
+        client.get.return_value = raw_get
+        store = RedisTokenStore(client, prefix=prefix, session_key=session_key)
+        return store, client
+
+    # 初始化
+    def test_empty_session_key_raises_value_error(self):
+        client = MagicMock()
+        with self.assertRaises(ValueError) as ctx:
+            RedisTokenStore(client, session_key='')
+        self.assertIn('session_key is required', str(ctx.exception))
+
+    def test_whitespace_session_key_raises_value_error(self):
+        client = MagicMock()
+        with self.assertRaises(ValueError):
+            RedisTokenStore(client, session_key='   ')
+
+    def test_key_composed_from_prefix_and_session_key(self):
+        store, _ = self._make_store(prefix='ns:', session_key='abc')
+        self.assertEqual('ns:abc', store.key)
+
+    # save
+    def test_save_calls_client_set_with_positive_ttl(self):
+        store, client = self._make_store()
+        store.save('MT_001', '2099-01-01T00:00:00Z')
+        self.assertTrue(client.set.called)
+        args, kwargs = client.set.call_args
+        self.assertEqual('test:session_1', args[0])
+        parsed = json.loads(args[1])
+        self.assertEqual('MT_001', parsed['token'])
+        self.assertIsNotNone(kwargs.get('ex'))
+        self.assertGreater(kwargs['ex'], 0)
+
+    def test_save_with_none_expire_time_passes_no_ex(self):
+        store, client = self._make_store()
+        store.save('MT_001', None)
+        _, kwargs = client.set.call_args
+        self.assertIsNone(kwargs.get('ex'))
+
+    # get
+    def test_get_returns_none_when_redis_returns_none(self):
+        store, _ = self._make_store(raw_get=None)
+        self.assertIsNone(store.get())
+
+    def test_get_returns_none_on_empty_string(self):
+        store, _ = self._make_store(raw_get='')
+        self.assertIsNone(store.get())
+
+    def test_get_decodes_bytes(self):
+        data = {'token': 'MT_bytes', 'expire_time': '2099-01-01T00:00:00'}
+        store, _ = self._make_store(raw_get=json.dumps(data).encode('utf-8'))
+        result = store.get()
+        self.assertEqual('MT_bytes', result['token'])
+
+    def test_get_handles_string_response(self):
+        data = {'token': 'MT_str', 'expire_time': '2099-01-01T00:00:00'}
+        store, _ = self._make_store(raw_get=json.dumps(data))
+        result = store.get()
+        self.assertEqual('MT_str', result['token'])
+
+    # clear
+    def test_clear_calls_client_delete_with_correct_key(self):
+        store, client = self._make_store()
+        store.clear()
+        client.delete.assert_called_once_with('test:session_1')
+        self.assertIsNone(store._session)
+
+    # is_expiring — refreshes from Redis first
+    def test_is_expiring_reads_from_redis_first(self):
+        client = MagicMock()
+        data = {'token': 'MT_live', 'expire_time': '2099-01-01T00:00:00'}
+        client.get.return_value = json.dumps(data)
+        store = RedisTokenStore(client, prefix='t:', session_key='s')
+        self.assertFalse(store.is_expiring())
+        self.assertTrue(client.get.called)
+
+    def test_is_expiring_true_when_redis_has_no_data(self):
+        store, _ = self._make_store(raw_get=None)
+        self.assertTrue(store.is_expiring())
+
+
+class RedisTtlSecondsTest(unittest.TestCase):
+    def test_none_returns_none(self):
+        self.assertIsNone(RedisTokenStore._ttl_seconds(None))
+
+    def test_empty_string_returns_none(self):
+        self.assertIsNone(RedisTokenStore._ttl_seconds(''))
+
+    def test_z_suffix_parsed_as_utc(self):
+        future = (datetime.now(timezone.utc) + timedelta(hours=2)).strftime('%Y-%m-%dT%H:%M:%SZ')
+        ttl = RedisTokenStore._ttl_seconds(future)
+        self.assertGreater(ttl, 3600)
+
+    def test_naive_datetime_uses_local_time(self):
+        future = (datetime.now() + timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M:%S')
+        ttl = RedisTokenStore._ttl_seconds(future)
+        self.assertIsNotNone(ttl)
+        self.assertGreater(ttl, 0)
+
+    def test_already_past_returns_1_at_minimum(self):
+        # 已过期的时间仍返回 max(1, ...)
+        past = (datetime.now(timezone.utc) - timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M:%SZ')
+        ttl = RedisTokenStore._ttl_seconds(past)
+        self.assertEqual(1, ttl)
+
+
+# ---------------------------------------------------------------------------
+# RedisSocketClient._read_response(直接传入 BytesIO 流,不需要真实 socket)
+# ---------------------------------------------------------------------------
+
+class RedisReadResponseTest(unittest.TestCase):
+    """_read_response 是实例方法,通过实例调用。"""
+
+    def _client(self):
+        return RedisSocketClient()
+
+    def _stream(self, data: bytes) -> io.BytesIO:
+        return io.BytesIO(data)
+
+    def test_simple_string_ok(self):
+        result = self._client()._read_response(self._stream(b'+OK\r\n'))
+        self.assertEqual('OK', result)
+
+    def test_simple_string_pong(self):
+        result = self._client()._read_response(self._stream(b'+PONG\r\n'))
+        self.assertEqual('PONG', result)
+
+    def test_error_raises_runtime_error(self):
+        with self.assertRaises(RuntimeError) as ctx:
+            self._client()._read_response(self._stream(b'-ERR unknown command\r\n'))
+        self.assertIn('ERR unknown command', str(ctx.exception))
+
+    def test_integer(self):
+        result = self._client()._read_response(self._stream(b':7\r\n'))
+        self.assertEqual(7, result)
+
+    def test_integer_zero(self):
+        result = self._client()._read_response(self._stream(b':0\r\n'))
+        self.assertEqual(0, result)
+
+    def test_bulk_string(self):
+        result = self._client()._read_response(self._stream(b'$5\r\nhello\r\n'))
+        self.assertEqual('hello', result)
+
+    def test_bulk_string_nil(self):
+        result = self._client()._read_response(self._stream(b'$-1\r\n'))
+        self.assertIsNone(result)
+
+    def test_array_two_elements(self):
+        result = self._client()._read_response(self._stream(b'*2\r\n+alpha\r\n+beta\r\n'))
+        self.assertEqual(['alpha', 'beta'], result)
+
+    def test_array_empty(self):
+        result = self._client()._read_response(self._stream(b'*0\r\n'))
+        self.assertEqual([], result)
+
+    def test_array_with_bulk_strings(self):
+        result = self._client()._read_response(self._stream(b'*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n'))
+        self.assertEqual(['foo', 'bar'], result)
+
+    def test_unsupported_prefix_raises(self):
+        with self.assertRaises(RuntimeError) as ctx:
+            self._client()._read_response(self._stream(b'?nope\r\n'))
+        self.assertIn('unsupported redis response', str(ctx.exception))
+
+    def test_empty_response_raises(self):
+        with self.assertRaises(RuntimeError) as ctx:
+            self._client()._read_response(self._stream(b''))
+        self.assertIn('empty redis response', str(ctx.exception))
+
+    def test_read_line_rejects_missing_crlf(self):
+        with self.assertRaisesRegex(RuntimeError, 'invalid redis line'):
+            RedisSocketClient._read_line(self._stream(b'invalid'))
+
+
+class RedisSendTest(unittest.TestCase):
+    def test_send_accepts_bytes(self):
+        sock = MagicMock()
+
+        RedisSocketClient._send(sock, b'PING')
+
+        self.assertIn(b'PING', sock.sendall.call_args.args[0])
+
+
+# ---------------------------------------------------------------------------
+# RedisSocketClient._execute(用 patch mock socket,覆盖 AUTH/SELECT 分支)
+# ---------------------------------------------------------------------------
+
+class RedisSocketClientExecuteTest(unittest.TestCase):
+    """通过 mock socket 验证 _execute 中 AUTH/SELECT 的条件分支。"""
+
+    def _mock_sock(self, responses):
+        """
+        responses: list[bytes],每个是一次 _read_response 调用的完整 RESP 响应。
+        """
+        stream = io.BytesIO(b''.join(responses))
+        sock = MagicMock()
+        sock.makefile.return_value = stream
+        sock.__enter__ = lambda s: s
+        sock.__exit__ = MagicMock(return_value=False)
+        return sock
+
+    def test_execute_with_password_sends_auth(self):
+        """password 非空 → 发送 AUTH 命令。"""
+        client = RedisSocketClient(password='secret', db=0)
+        # AUTH → OK;GET → nil
+        mock_sock = self._mock_sock([b'+OK\r\n', b'$-1\r\n'])
+        with patch('socket.create_connection', return_value=mock_sock):
+            result = client.get('k')
+        self.assertIsNone(result)
+        # sendall 应被调用多次(AUTH + GET)
+        self.assertGreater(mock_sock.sendall.call_count, 0)
+
+    def test_execute_with_db_sends_select(self):
+        """db 非0 → 发送 SELECT 命令。"""
+        client = RedisSocketClient(password='', db=3)
+        # SELECT → OK;GET → value
+        mock_sock = self._mock_sock([b'+OK\r\n', b'$5\r\nvalue\r\n'])
+        with patch('socket.create_connection', return_value=mock_sock):
+            result = client.get('k')
+        self.assertEqual('value', result)
+
+    def test_execute_with_password_and_db(self):
+        """password 非空 且 db 非0 → 发送 AUTH + SELECT。"""
+        client = RedisSocketClient(password='pass', db=2)
+        # AUTH → OK;SELECT → OK;SET → OK
+        mock_sock = self._mock_sock([b'+OK\r\n', b'+OK\r\n', b'+OK\r\n'])
+        with patch('socket.create_connection', return_value=mock_sock):
+            ok = client.set('k', 'v')
+        self.assertTrue(ok)
+
+    def test_execute_without_password_or_db(self):
+        """password 为空、db 为0 → 直接执行命令,不发 AUTH/SELECT。"""
+        client = RedisSocketClient(password='', db=0)
+        mock_sock = self._mock_sock([b'+OK\r\n'])
+        with patch('socket.create_connection', return_value=mock_sock):
+            ok = client.set('k', 'v', ex=300)
+        self.assertTrue(ok)
+
+    def test_delete_returns_integer(self):
+        client = RedisSocketClient()
+        mock_sock = self._mock_sock([b':1\r\n'])
+        with patch('socket.create_connection', return_value=mock_sock):
+            result = client.delete('k')
+        self.assertEqual(1, result)
+
+    def test_set_without_ex(self):
+        client = RedisSocketClient()
+        mock_sock = self._mock_sock([b'+OK\r\n'])
+        with patch('socket.create_connection', return_value=mock_sock):
+            ok = client.set('k', 'v')  # no ex= argument
+        self.assertTrue(ok)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 110 - 0
tests/test_tool_description_boundaries.py

@@ -0,0 +1,110 @@
+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_detail import QueryOrderDetailTool
+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(): ('订单列表', 'query_order_detail', '排舱列表'),
+            QueryOrderDetailTool(): ('单个订单的详情', '逐个调用', 'query_order_exact', '订单列表'),
+            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)
+
+    def test_export_tools_describe_async_follow_up_without_long_wait(self):
+        for tool in (
+            ExportPendingOutboundOrdersTool(),
+            ExportOutOfProvincePortDataTool(),
+        ):
+            with self.subTest(tool=tool.name):
+                description = tool.metadata()['description']
+                self.assertIn('异步导出任务', description)
+                self.assertIn('query_export_task', description)
+                self.assertIn('不会在本次调用中等待文件生成', description)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 185 - 0
tools/export_out_of_province_port_data.py

@@ -0,0 +1,185 @@
+class ExportOutOfProvincePortDataTool:
+    name = 'export_out_of_province_port_data'
+    route_path = '/mcp/tools/exportOutOfProvincePortData'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        number_items = {
+            'type': 'string',
+            'minLength': 1,
+            'maxLength': 100,
+        }
+        return {
+            'name': self.name,
+            'description': (
+                '本工具只提交异步导出任务,不会在本次调用中等待文件生成。'
+                '成功后返回任务引用(task_ref)和'
+                '建议等待时间(retry_after_seconds)。稍后使用 query_export_task 单独查询'
+                '任务状态或下载链接。'
+                '使用场景:只有用户明确要求导出后台排舱单列表中的省外进港资料并需要'
+                '下载文件时使用。'
+                '只能在用户明确要求导出省外进港资料,并明确提供排舱单号、'
+                '柜号、提单号或SO号中的一种,以及宁波、上海或美森资料类型时调用。'
+                '号码类型不明确时必须先询问用户;用户没有明确号码类型时必须先询问:'
+                '”请确认使用哪种单号导出:排舱单号、柜号、提单号还是SO号?”'
+                '确认前不得调用。禁止使用:普通排舱查询、排舱详情、订单查询或普通文件'
+                '查询不得调用本工具。不得根据号码格式猜测,不得跨字段或跨工具试查,'
+                '也不得在查询失败后切换号码类型重试。一次只能选择一种号码类型。'
+                '一次只能选择一种资料类型;用户要求多个类型时应分别调用。'
+                '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,'
+                '不得展示内部参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'outbound_numbers': {
+                        'type': 'array',
+                        'items': dict(number_items),
+                        'minItems': 1,
+                        'maxItems': 100,
+                        'uniqueItems': True,
+                        'description': (
+                            '要导出的排舱单号数组。排舱单号是后台排舱单列表中的'
+                            '业务单号,不是系统订单号、客户参考号、快递单号、柜号、'
+                            'SO号或Shipment ID。只能使用用户明确提供并确认类型为'
+                            '排舱单号的值,不得根据号码格式猜测。'
+                        ),
+                        'examples': [[
+                            'PC202607140001',
+                            'PC202607140002',
+                        ]],
+                    },
+                    'container_codes': {
+                        'type': 'array',
+                        'items': dict(number_items),
+                        'minItems': 1,
+                        'maxItems': 100,
+                        'uniqueItems': True,
+                        'description': (
+                            '要导出的柜号或集装箱号数组。仅当用户明确说明号码类型'
+                            '为“柜号”或“集装箱号”时使用,不得放入排舱单号或提单号。'
+                        ),
+                        'examples': [[
+                            'MSCU1234567',
+                            'TGHU7654321',
+                        ]],
+                    },
+                    'bl_numbers': {
+                        'type': 'array',
+                        'items': dict(number_items),
+                        'minItems': 1,
+                        'maxItems': 100,
+                        'uniqueItems': True,
+                        'description': (
+                            '要导出的提单号数组,对应后台排舱单列表中的“提单号”。'
+                            '不是系统提单号;仅当用户明确说明号码类型为“提单号”时使用。'
+                        ),
+                        'examples': [[
+                            'BL202607150001',
+                            'BL202607150002',
+                        ]],
+                    },
+                    'so_numbers': {
+                        'type': 'array',
+                        'items': dict(number_items),
+                        'minItems': 1,
+                        'maxItems': 100,
+                        'uniqueItems': True,
+                        'description': (
+                            '要导出的SO号数组,对应后台排舱单列表中的“SO号”,'
+                            '数据字段为fms_booking_detail.so_number。仅当用户'
+                            '明确说明号码类型为“SO号”时使用。'
+                        ),
+                        'examples': [[
+                            'SO202607160001',
+                            'SO202607160002',
+                        ]],
+                    },
+                    'file_type': {
+                        'type': 'string',
+                        'enum': ['NB', 'SH', 'MS'],
+                        'description': (
+                            '进港资料类型:NB=宁波进港资料,SH=上海进港资料,'
+                            'MS=美森进港资料。必须根据用户明确选择传值;'
+                            '用户未说明时先询问,不得默认选择。'
+                        ),
+                        'examples': ['NB'],
+                    },
+                },
+                'required': ['file_type'],
+                'oneOf': [
+                    {'required': ['outbound_numbers']},
+                    {'required': ['container_codes']},
+                    {'required': ['bl_numbers']},
+                    {'required': ['so_numbers']},
+                ],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        outbound_numbers=None,
+        file_type=None,
+        request_id='rq_export_out_of_province_port_data',
+        container_codes=None,
+        bl_numbers=None,
+        so_numbers=None,
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for export_out_of_province_port_data'
+            )
+
+        number_fields = {
+            'outbound_numbers': outbound_numbers,
+            'container_codes': container_codes,
+            'bl_numbers': bl_numbers,
+            'so_numbers': so_numbers,
+        }
+        provided = [
+            field for field, values in number_fields.items()
+            if values is not None
+        ]
+        if len(provided) != 1:
+            raise ValueError('provide exactly one number type')
+        number_field = provided[0]
+        numbers = self._normalize_numbers(number_fields[number_field], number_field)
+        normalized_type = str(file_type or '').strip().upper()
+        if normalized_type not in ('NB', 'SH', 'MS'):
+            raise ValueError('file_type must be NB, SH, or MS')
+
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            {
+                number_field: numbers,
+                'file_type': normalized_type,
+            },
+            request_id,
+        )
+
+    @staticmethod
+    def _normalize_numbers(values, field):
+        if not isinstance(values, list):
+            raise ValueError('{0} must be an array'.format(field))
+        if len(values) > 100:
+            raise ValueError('{0} must contain 1 to 100 values'.format(field))
+
+        numbers = []
+        for value in values:
+            if not isinstance(value, str):
+                raise ValueError('{0} must contain strings'.format(field))
+            value = value.strip()
+            if not value:
+                continue
+            if len(value) > 100:
+                raise ValueError('{0} contains a number that is too long'.format(field))
+            if value not in numbers:
+                numbers.append(value)
+
+        if not numbers or len(numbers) > 100:
+            raise ValueError('{0} must contain 1 to 100 values'.format(field))
+        return numbers

+ 90 - 0
tools/export_pending_outbound_orders.py

@@ -0,0 +1,90 @@
+class ExportPendingOutboundOrdersTool:
+    name = 'export_pending_outbound_orders'
+    route_path = '/mcp/tools/exportPendingOutboundOrders'
+
+    FILTER_FIELDS = (
+        'number', 'product_type_id', 'product_id', 'order_warehouse_id',
+        'receiver_country', 'is_remove', 'address', 'inbound_date', 'status',
+        'is_battery', 'is_magnetic', 'is_wood', 'is_other', 'is_fda', 'is_toy',
+        'is_ultra_limit', 'is_sensitive', 'is_food', 'no_property',
+        'merge_declare_number', 'importer_id', 'packing_type',
+    )
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        properties = {
+            'number': {
+                'type': 'string',
+                'description': '单号:订单号、参考号;多个值用空格、英文逗号或回车分隔。',
+            },
+            'product_type_id': {'type': 'integer', 'description': '产品分类 ID。'},
+            'product_id': {
+                'type': 'array',
+                'items': {'type': 'integer'},
+                'description': '物流产品 ID 数组。先从筛选项工具获取授权值。',
+            },
+            'order_warehouse_id': {'type': 'integer', 'description': '集货仓库 ID。'},
+            'receiver_country': {'type': 'string', 'description': '目的国国家代码。'},
+            'is_remove': {
+                'type': 'integer',
+                'enum': [0, 1],
+                'description': '是否装柜剔除:1 是,0 否。',
+            },
+            'address': {'type': 'string', 'description': '派送地址模糊筛选。'},
+            'inbound_date': {
+                'type': 'string',
+                'description': '入库时间范围,格式 YYYY-MM-DD - YYYY-MM-DD。',
+            },
+            'status': {
+                'type': 'array',
+                'items': {'type': 'integer'},
+                'description': '订单状态整数数组。',
+            },
+            'merge_declare_number': {'type': 'string', 'description': '合并报关单号。'},
+            'importer_id': {'type': 'integer', 'description': '进口商 ID。'},
+            'packing_type': {
+                'type': 'string',
+                'description': '货物类型,多选值用英文逗号拼接。',
+            },
+        }
+        for field, label in (
+            ('is_battery', '带电'), ('is_magnetic', '带磁'), ('is_wood', '带木'),
+            ('is_other', '其它'), ('is_fda', 'FDA产品'), ('is_toy', '玩具'),
+            ('is_ultra_limit', '单箱尺寸超长超重'), ('is_sensitive', '敏感货'),
+            ('is_food', '食品'), ('no_property', '无属性'),
+        ):
+            properties[field] = {
+                'type': 'string',
+                'enum': ['Y'],
+                'description': '商品属性:{0},选中时传 Y。'.format(label),
+            }
+
+        return {
+            'name': self.name,
+            'description': (
+                '本工具只提交异步导出任务,不会在本次调用中等待文件生成。'
+                '成功后返回任务引用(task_ref)和'
+                '建议等待时间(retry_after_seconds)。稍后使用 query_export_task 单独查询'
+                '任务状态或下载链接。'
+                '使用场景:只有用户明确要求导出未排舱订单并需要下载文件时,才按'
+                'outbound/add.html的筛选条件调用。筛选名称必须先通过'
+                'list_pending_outbound_export_filter_options 获取当前员工有权限的 value。'
+                '禁止使用:用户只是查看或查询订单、排舱列表或排舱详情时不得调用;'
+                '本工具不导出已排舱列表,也不导出省外进港资料。用户只说”单号”时必须'
+                '先确认是订单号还是客户参考号;不得用导出结果试探号码类型。'
+                '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,不得展示内部参数名。'
+            ),
+            'input_schema': {'type': 'object', 'properties': properties},
+        }
+
+    def call(self, request_id='rq_export_pending_outbound_orders', **kwargs):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for export_pending_outbound_orders')
+
+        payload = {}
+        for field in self.FILTER_FIELDS:
+            if field in kwargs and kwargs[field] is not None:
+                payload[field] = kwargs[field]
+        return self.api_client.call_tool(self.name, self.route_path, payload, request_id)

+ 73 - 0
tools/list_order_filter_options.py

@@ -0,0 +1,73 @@
+class ListOrderFilterOptionsTool:
+    name = 'list_order_filter_options'
+    route_path = '/mcp/tools/listOrderFilterOptions'
+    FILTER_TYPES = (
+        'country',
+        'product',
+        'customer',
+        'sales',
+        'warehouse',
+        'department',
+    )
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:只为query_order_exact查询产品、客户、销售、仓库、事业部和国家'
+                '条件时取得当前员工的授权筛选值,并将返回值直接传给对应条件。'
+                '禁止使用:本工具不返回订单、排舱或轨迹等业务数据,不得用于'
+                'query_outbound_list、未排舱导出或其他工具;用户提供名称时禁止猜测ID。'
+                '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,'
+                '不得展示内部参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'filter_type': {
+                        'type': 'string',
+                        'enum': list(self.FILTER_TYPES),
+                    },
+                    'keyword': {'type': 'string'},
+                    'page': {'type': 'integer', 'minimum': 1},
+                    'limit': {
+                        'type': 'integer',
+                        'minimum': 1,
+                        'maximum': 100,
+                    },
+                },
+                'required': ['filter_type'],
+            },
+        }
+
+    def call(
+        self,
+        filter_type,
+        keyword='',
+        page=1,
+        limit=20,
+        request_id='rq_list_order_filter_options',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for list_order_filter_options'
+            )
+        filter_type = str(filter_type).strip()
+        if filter_type not in self.FILTER_TYPES:
+            raise ValueError('unsupported filter_type')
+
+        payload = {
+            'filter_type': filter_type,
+            'keyword': str(keyword or '').strip(),
+            'page': max(1, int(page)),
+            'limit': max(1, min(100, int(limit))),
+        }
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            payload,
+            request_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

+ 73 - 0
tools/list_pending_outbound_export_filter_options.py

@@ -0,0 +1,73 @@
+class ListPendingOutboundExportFilterOptionsTool:
+    name = 'list_pending_outbound_export_filter_options'
+    route_path = '/mcp/tools/listPendingOutboundExportFilterOptions'
+    FILTER_TYPES = (
+        'product_type', 'product', 'warehouse', 'country', 'importer',
+        'packing_type', 'order_status', 'goods_attribute', 'is_remove',
+    )
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:只为export_pending_outbound_orders取得当前员工有权限使用的'
+                '未排舱订单导出授权筛选值;用户提供名称时必须先查到value,不得猜测ID,'
+                '关键字搜索也只在授权结果内执行。禁止使用:本工具不返回订单或排舱业务数据,'
+                '不得为query_order_exact、query_outbound_list或其他工具提供筛选值。'
+                '参数名仅用于工具调用;'
+                '向用户回答时只能使用中文业务名称,不得展示内部参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'filter_type': {
+                        'type': 'string',
+                        'enum': list(self.FILTER_TYPES),
+                        'description': '筛选项类型。',
+                    },
+                    'product_type_id': {
+                        'type': 'integer',
+                        'description': '查询物流产品时可选的产品分类 ID。',
+                    },
+                    'keyword': {'type': 'string', 'description': '按名称或编码搜索。'},
+                    'page': {'type': 'integer', 'minimum': 1, 'default': 1},
+                    'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 20},
+                },
+                'required': ['filter_type'],
+            },
+        }
+
+    def call(
+        self,
+        filter_type,
+        product_type_id=None,
+        keyword='',
+        page=1,
+        limit=20,
+        request_id='rq_list_pending_outbound_export_filter_options',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for list_pending_outbound_export_filter_options'
+            )
+
+        filter_type = str(filter_type).strip()
+        if filter_type not in self.FILTER_TYPES:
+            raise ValueError('unsupported filter_type')
+
+        payload = {
+            'filter_type': filter_type,
+            'keyword': str(keyword or '').strip(),
+            'page': max(1, int(page)),
+            'limit': max(1, min(100, int(limit))),
+        }
+        if product_type_id is not None:
+            product_type_id = int(product_type_id)
+            if product_type_id <= 0:
+                raise ValueError('product_type_id must be greater than 0')
+            payload['product_type_id'] = product_type_id
+
+        return self.api_client.call_tool(self.name, self.route_path, payload, request_id)

+ 156 - 0
tools/query_customs_declaration_files.py

@@ -0,0 +1,156 @@
+class QueryCustomsDeclarationFilesTool:
+    name = 'query_customs_declaration_files'
+    route_path = '/mcp/tools/queryCustomsDeclarationFiles'
+    MAX_NUMBERS = 100
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        number_item = {
+            'type': 'string',
+            'minLength': 1,
+            'maxLength': 100,
+        }
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户明确要查看或下载报关资料文件和文件链接时,批量查询订单'
+                '的报关资料文件链接。用户明确说“订单号”时使用'
+                '订单号数组;用户明确说“排舱单号”时使用排舱单号数组。'
+                '这里的订单号是后台订单列表及排舱详情“订单号”列显示的号码,'
+                '不是系统单号、数据库 order_id/id、客户参考号、快递单号或排舱单号。'
+                '一次调用只能选择一种号码,单个号码也必须放入数组。'
+                '如果用户没有明确说明是订单号还是排舱单号,必须先提问,'
+                '让用户选择“订单号”或“排舱单号”,确认前不得调用工具。'
+                '用户只说“单号”时必须先追问是订单号还是排舱单号。'
+                '用户说“系统单号”时也不得当作订单号;必须先确认并取得订单号。'
+                '号码类型不明确时必须先询问用户。禁止使用:用户要查看完整排舱详情时'
+                '应使用 query_outbound_detail,不得用本工具替代;本工具也不返回订单资料、'
+                '排舱列表、物流轨迹或导出文件。不得根据号码格式猜测。'
+                '查询无结果后不得切换号码类型,不得跨字段重试,'
+                '不得跨字段或跨工具试查。'
+                '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,'
+                '不得展示内部参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'outbound_numbers': {
+                        'type': 'array',
+                        'items': dict(number_item),
+                        'minItems': 1,
+                        'maxItems': self.MAX_NUMBERS,
+                        'description': (
+                            '多个排舱单号。仅当用户明确说“排舱单号”时使用;'
+                            '不是订单号、客户参考号或快递单号。'
+                        ),
+                        'examples': [['PC202607150001', 'PC202607150002']],
+                    },
+                    'order_numbers': {
+                        'type': 'array',
+                        'items': dict(number_item),
+                        'minItems': 1,
+                        'maxItems': self.MAX_NUMBERS,
+                        'description': (
+                            '多个订单号,对应后台订单列表及排舱详情“订单号”列的值。'
+                            '仅当用户明确说“订单号”时使用;不是系统单号或数据库'
+                            ' order_id/id,也不是客户参考号、快递单号或排舱单号。'
+                            '用户提供“系统单号”时不得填入此字段,必须先追问取得订单号。'
+                        ),
+                        'examples': [['ORD202607150001', 'ORD202607150002']],
+                    },
+                    'page': {
+                        'type': 'integer',
+                        'minimum': 1,
+                        'maximum': 100,
+                        'default': 1,
+                        'description': '页码,从 1 开始,最大 100。',
+                    },
+                    'limit': {
+                        'type': 'integer',
+                        'minimum': 1,
+                        'maximum': 100,
+                        'default': 20,
+                        'description': '每页订单数量,范围 1 到 100。',
+                    },
+                },
+                'oneOf': [
+                    {'required': ['outbound_numbers']},
+                    {'required': ['order_numbers']},
+                ],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        outbound_numbers=None,
+        order_numbers=None,
+        page=1,
+        limit=20,
+        request_id='rq_query_customs_declaration_files',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for query_customs_declaration_files'
+            )
+
+        has_outbound = outbound_numbers is not None
+        has_order = order_numbers is not None
+        if has_outbound == has_order:
+            raise ValueError(
+                'provide outbound_numbers or order_numbers'
+            )
+
+        field = 'outbound_numbers' if has_outbound else 'order_numbers'
+        values = outbound_numbers if has_outbound else order_numbers
+        numbers = self._normalize_numbers(values, field)
+        page = self._bounded_integer(page, 'page', 100)
+        limit = self._bounded_integer(limit, 'limit', 100)
+
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            {
+                field: numbers,
+                'page': page,
+                'limit': limit,
+            },
+            request_id,
+        )
+
+    @classmethod
+    def _normalize_numbers(cls, values, field):
+        if not isinstance(values, list):
+            raise ValueError('{0} must be an array'.format(field))
+        if len(values) > cls.MAX_NUMBERS:
+            raise ValueError('{0} must contain 1 to 100 values'.format(field))
+
+        numbers = []
+        for value in values:
+            if not isinstance(value, str):
+                raise ValueError('{0} must contain strings'.format(field))
+            value = value.strip()
+            if not value:
+                continue
+            if len(value) > 100:
+                raise ValueError('{0} contains an invalid number'.format(field))
+            if value not in numbers:
+                numbers.append(value)
+
+        if not numbers:
+            raise ValueError('{0} must contain 1 to 100 values'.format(field))
+        return numbers
+
+    @staticmethod
+    def _bounded_integer(value, field, maximum):
+        if isinstance(value, bool):
+            raise ValueError('{0} must be between 1 and {1}'.format(field, maximum))
+        try:
+            value = int(value)
+        except (TypeError, ValueError):
+            raise ValueError('{0} must be between 1 and {1}'.format(field, maximum))
+        if value < 1 or value > maximum:
+            raise ValueError('{0} must be between 1 and {1}'.format(field, maximum))
+        return value

+ 45 - 0
tools/query_export_task.py

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

+ 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},
                 },

+ 98 - 0
tools/query_order_detail.py

@@ -0,0 +1,98 @@
+class QueryOrderDetailTool:
+    name = 'query_order_detail'
+    route_path = '/mcp/tools/queryOrderDetail'
+    sections = (
+        '订单概览', '箱单信息', '箱单商品', 'DW授权信息', '附件信息',
+        '入库信息', '查验信息', '订单轨迹', '操作日志',
+        '应收与结算日志', '派送信息', '全部',
+    )
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户明确提供订单号并要求查看订单详情时使用。可查询订单概览、'
+                '箱单、箱单商品、DW授权、附件、入库、查验、订单轨迹、操作日志、应收与'
+                '结算日志及派送信息。用户要求完整详情时可使用“全部”一次取得所有模块;'
+                '各明细模块仍按独立分页信息展示,任何模块为空都不能作为跳过其他模块的依据。'
+                '本工具只用于单个订单的详情查询;用户要求订单列表、批量筛选或多个订单汇总时,'
+                '必须改用 query_order_exact,不得使用本工具替代。即使用户提供了订单号,'
+                '只要目标是列表或批量结果,也不能调用本工具。用户要求分别查询多个订单详情时,'
+                '必须按订单号逐个调用本工具,每次只传一个订单号,不得合并成列表查询。'
+                '只接受订单号,不接受订单ID、箱单ID、查验ID、公司、员工'
+                '或权限字段。不得猜测号码类型或把同一号码跨工具试查。参数名仅用于工具'
+                '调用;向用户回答时只能展示固定中文业务名称,不得展示内部参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'order_number': {
+                        'type': 'string', 'minLength': 1, 'maxLength': 100,
+                        'description': '明确的订单号。',
+                    },
+                    'section': {
+                        'type': 'string', 'enum': list(self.sections),
+                        'default': '全部', 'description': '要查看的中文详情模块;未指定时返回全部模块。',
+                    },
+                    'page': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 20,
+                    },
+                },
+                'required': ['order_number'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        order_number=None,
+        section='全部',
+        page=1,
+        limit=20,
+        request_id='rq_query_order_detail',
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for query_order_detail')
+        if not isinstance(order_number, str):
+            raise ValueError('order_number is required')
+        order_number = order_number.strip()
+        if not order_number or len(order_number) > 100:
+            raise ValueError('order_number is required')
+        if not isinstance(section, str) or section.strip() not in self.sections:
+            raise ValueError('section is invalid')
+        section = section.strip()
+        page = self._bounded_integer(page, 'page')
+        limit = self._bounded_integer(limit, 'limit')
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            {
+                'order_number': order_number,
+                'section': section,
+                'page': page,
+                'limit': limit,
+            },
+            request_id,
+        )
+
+    @staticmethod
+    def _bounded_integer(value, field):
+        if isinstance(value, bool):
+            raise ValueError('{0} is invalid'.format(field))
+        if isinstance(value, int):
+            number = value
+        elif isinstance(value, str) and value.isdigit() and not value.startswith('0'):
+            number = int(value)
+        else:
+            raise ValueError('{0} is invalid'.format(field))
+        if number < 1 or number > 100:
+            raise ValueError('{0} is invalid'.format(field))
+        return number

+ 323 - 0
tools/query_order_exact.py

@@ -0,0 +1,323 @@
+class QueryOrderExactTool:
+    name = 'query_order_exact'
+    route_path = '/mcp/tools/queryOrderExact'
+
+    STRING_FILTERS = (
+        'order_number',
+        'reference_number',
+        'tracking_number',
+        'outbound_number',
+        'container_code',
+        'so_number',
+        'shipment_id',
+        'receiver_country',
+        'inbound_date_start',
+        'inbound_date_end',
+        'outbound_date_start',
+        'outbound_date_end',
+    )
+    ARRAY_FILTERS = ('product_ids', 'customer_ids', 'warehouse_ids')
+    NUMBER_ARRAY_FILTERS = (
+        'order_numbers',
+        'reference_numbers',
+        'tracking_numbers',
+        'outbound_numbers',
+        'container_codes',
+        'so_numbers',
+    )
+    NUMBER_FILTER_MAP = {
+        'order_numbers': 'order_number',
+        'reference_numbers': 'reference_number',
+        'tracking_numbers': 'tracking_number',
+        'outbound_numbers': 'outbound_number',
+        'container_codes': 'container_code',
+        'so_numbers': 'so_number',
+    }
+    SCALAR_ID_FILTERS = ('sales_id', 'department_id')
+    MAX_NUMBER_FILTER_COUNT = 200
+    FIELD_GUIDANCE = {
+        'order_number': (
+            '系统订单号,用户明确说“订单号”且只有一个值时使用;'
+            '不是客户参考号或快递单号。',
+            ['USC26070917207'],
+        ),
+        'order_numbers': (
+            '多个系统订单号的批量精准查询;用户明确给出多个订单号时使用。',
+            [['USC26070917207', 'USC26070917208']],
+        ),
+        'reference_number': (
+            '客户参考号,用户明确说“客户参考号”且只有一个值时使用;'
+            '不是系统订单号。',
+            ['REF-20260713-001'],
+        ),
+        'reference_numbers': (
+            '多个客户参考号的批量精准查询;用户明确给出多个客户参考号时使用。',
+            [['REF-001', 'REF-002']],
+        ),
+        'tracking_number': (
+            '快递单号、物流跟踪号或承运商跟踪号码;用户明确说“快递单号”、'
+            '“物流跟踪号”或“承运商跟踪号”且只有一个值时使用;'
+            '不是系统订单号。',
+            ['1Z999AA10123456784'],
+        ),
+        'tracking_numbers': (
+            '多个快递单号或承运商跟踪号码的批量精准查询;'
+            '用户明确给出多个此类号码时使用。',
+            [['TRACK-001', 'TRACK-002']],
+        ),
+        'outbound_number': (
+            '排舱单号,用户明确说“排舱单号”且只有一个值时使用;'
+            '不是海外仓出库单号。',
+            ['PC20210331070002001'],
+        ),
+        'outbound_numbers': (
+            '多个排舱单号的批量精准查询;用户明确给出多个排舱单号时使用。',
+            [['PC20210331070002001', 'PC20210331070002002']],
+        ),
+        'container_code': (
+            '柜号或集装箱号,用户明确说“柜号”或“集装箱号”且只有一个值时使用。',
+            ['MSCU1234567'],
+        ),
+        'container_codes': (
+            '多个柜号或集装箱号的批量精准查询;用户明确给出多个此类号码时使用。',
+            [['MSCU1234567', 'TGHU7654321']],
+        ),
+        'so_number': (
+            'SO号,即 Shipping Order 编号,格式不固定;'
+            '用户明确说“SO号”且只有一个值时使用。',
+            ['97964454'],
+        ),
+        'so_numbers': (
+            '多个格式不固定的 SO 号或 Shipping Order 编号的批量精准查询;'
+            '用户明确给出多个 SO 号时使用。',
+            [['97964454', 'OOLU12345678']],
+        ),
+        'shipment_id': (
+            'Shipment ID,用户明确说“Shipment ID”且只有一个值时使用。',
+            ['FBA123456789'],
+        ),
+        'receiver_country': (
+            '收货国家代码;先调用 list_order_filter_options 的 country 类型'
+            '取得可用值。',
+            ['US'],
+        ),
+        'product_ids': (
+            '物流产品 ID 数组;必须先调用 list_order_filter_options 的 '
+            'product 类型取得 ID。',
+            [[101, 102]],
+        ),
+        'customer_ids': (
+            '客户 ID 数组;必须先调用 list_order_filter_options 的 customer '
+            '类型取得 ID。',
+            [[201, 202]],
+        ),
+        'sales_id': (
+            '销售人员 ID;必须先调用 list_order_filter_options 的 sales 类型'
+            '取得 ID。',
+            [301],
+        ),
+        'warehouse_ids': (
+            '仓库 ID 数组;必须先调用 list_order_filter_options 的 warehouse '
+            '类型取得 ID,-1 表示客户仓。',
+            [[401, -1]],
+        ),
+        'department_id': (
+            '事业部 ID;必须先调用 list_order_filter_options 的 department '
+            '类型取得 ID。',
+            [501],
+        ),
+        'inbound_date_start': (
+            '入库日期范围开始日期,格式 YYYY-MM-DD。',
+            ['2026-07-01'],
+        ),
+        'inbound_date_end': (
+            '入库日期范围结束日期,格式 YYYY-MM-DD。',
+            ['2026-07-13'],
+        ),
+        'outbound_date_start': (
+            '出库日期范围开始日期,格式 YYYY-MM-DD。',
+            ['2026-07-01'],
+        ),
+        'outbound_date_end': (
+            '出库日期范围结束日期,格式 YYYY-MM-DD。',
+            ['2026-07-13'],
+        ),
+        'page': ('结果页码,从 1 开始。', [1]),
+        'limit': ('每页记录数,范围 1 到 100。', [20]),
+    }
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        properties = {
+            field: {'type': 'string'}
+            for field in self.STRING_FILTERS
+        }
+        for field in self.ARRAY_FILTERS:
+            properties[field] = {
+                'type': 'array',
+                'items': {'type': 'integer'},
+            }
+        for field in self.NUMBER_ARRAY_FILTERS:
+            properties[field] = {
+                'type': 'array',
+                'items': {'type': 'string'},
+            }
+        for field in self.SCALAR_ID_FILTERS:
+            properties[field] = {'type': 'integer', 'minimum': 1}
+        properties.update({
+            'page': {'type': 'integer', 'minimum': 1, 'maximum': 100},
+            'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100},
+        })
+        for field, guidance in self.FIELD_GUIDANCE.items():
+            properties[field]['description'] = guidance[0]
+            properties[field]['examples'] = guidance[1]
+
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户要查看订单列表或按条件批量精准筛选订单,并且已经明确每个号码的'
+                '业务类型时,按指定字段精准查询订单;即使使用排舱单号、柜号或SO号筛选,'
+                '返回目标仍是订单资料。'
+                '本工具只返回订单列表结果;用户要求查看单个订单的详情、概览、箱单、商品、'
+                '轨迹、日志或其他详情模块时,必须改用 query_order_detail,不得使用本工具替代。'
+                '订单号、客户参考号、快递单号、排舱单号、柜号和 SO 号必须'
+                '使用各自对应字段。'
+                '所有单号格式均为开放格式,只能根据用户明确说出的业务类型'
+                '选择字段,不得根据号码的前缀、长度、字符组合或示例猜测。'
+                '不得根据号码格式猜测。'
+                '号码类型不明确时必须先询问用户;单号类型不明确时先询问用户,'
+                '确认前不得调用。'
+                '一个值使用单数字段,多个同类值使用复数字段数组;'
+                '同一字段使用 IN,不同字段使用 AND。'
+                '禁止使用:用户要看排舱列表、排舱详情、物流轨迹、报关资料文件或导出'
+                '结果时不得使用本工具。不得跨字段或跨工具试查;查询无结果时不得改用'
+                '其他字段重试。'
+                '产品、客户、销售、仓库、事业部和国家条件先调用 '
+                'list_order_filter_options。'
+                '参数名仅用于工具调用;向用户回答时只能使用对应的中文业务名称,'
+                '不得展示内部参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': properties,
+            },
+        }
+
+    def call(
+        self,
+        order_number='',
+        reference_number='',
+        tracking_number='',
+        outbound_number='',
+        container_code='',
+        so_number='',
+        shipment_id='',
+        receiver_country='',
+        product_ids=None,
+        customer_ids=None,
+        sales_id=None,
+        warehouse_ids=None,
+        department_id=None,
+        inbound_date_start='',
+        inbound_date_end='',
+        outbound_date_start='',
+        outbound_date_end='',
+        page=1,
+        limit=20,
+        request_id='rq_query_order_exact',
+        order_numbers=None,
+        reference_numbers=None,
+        tracking_numbers=None,
+        outbound_numbers=None,
+        container_codes=None,
+        so_numbers=None,
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for query_order_exact')
+
+        values = locals()
+        payload = {}
+        for field in self.STRING_FILTERS:
+            value = str(values[field] or '').strip()
+            if value:
+                payload[field] = value
+
+        number_count = 0
+        for field in self.NUMBER_ARRAY_FILTERS:
+            value = self._normalize_string_list(values[field], field)
+            if value:
+                payload[field] = value
+            singular = self.NUMBER_FILTER_MAP[field]
+            merged = list(value)
+            singular_value = payload.get(singular)
+            if singular_value and singular_value not in merged:
+                merged.insert(0, singular_value)
+            number_count += len(merged)
+        if number_count > self.MAX_NUMBER_FILTER_COUNT:
+            raise ValueError('number filters must not exceed 200 items')
+
+        for field in self.ARRAY_FILTERS:
+            allow_customer_warehouse = field == 'warehouse_ids'
+            value = self._normalize_int_list(
+                values[field],
+                allow_customer_warehouse=allow_customer_warehouse,
+            )
+            if value:
+                payload[field] = value
+
+        for field in self.SCALAR_ID_FILTERS:
+            value = values[field]
+            if value is None or value == '':
+                continue
+            value = int(value)
+            if value <= 0:
+                raise ValueError('{0} must be greater than 0'.format(field))
+            payload[field] = value
+
+        if not payload:
+            raise ValueError('at least one exact order filter is required')
+
+        payload['page'] = max(1, min(100, int(page)))
+        payload['limit'] = max(1, min(100, int(limit)))
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            payload,
+            request_id,
+        )
+
+    @staticmethod
+    def _normalize_int_list(value, allow_customer_warehouse=False):
+        if value is None or value == '':
+            return []
+        if isinstance(value, str):
+            value = [item.strip() for item in value.split(',') if item.strip()]
+
+        result = []
+        for item in value:
+            item = int(item)
+            if item <= 0 and not (allow_customer_warehouse and item == -1):
+                continue
+            if item not in result:
+                result.append(item)
+        return result
+
+    @staticmethod
+    def _normalize_string_list(value, field):
+        if value is None or value == '':
+            return []
+        if not isinstance(value, (list, tuple)):
+            raise ValueError('{0} must be an array'.format(field))
+
+        result = []
+        for item in value:
+            if not isinstance(item, str):
+                raise ValueError('{0} items must be strings'.format(field))
+            item = item.strip()
+            if not item or len(item) > 100:
+                raise ValueError('{0} contains an invalid number'.format(field))
+            if item not in result:
+                result.append(item)
+        return result

+ 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

+ 24 - 5
tools/query_track.py

@@ -8,13 +8,32 @@ class QueryTrackTool:
     def metadata(self):
         return {
             'name': self.name,
-            'description': 'Query tracking information with the current employee permissions. Provide order_id, order_number, or tracking_number.',
+            'description': (
+                '使用场景:用户明确要查看物流轨迹时使用,只返回轨迹节点、地点、时间、'
+                '内容、跟踪号和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},
                 },
@@ -45,4 +64,4 @@ class QueryTrackTool:
         if tracking_number:
             payload['tracking_number'] = str(tracking_number).strip()
 
-        return self.api_client.call_tool(self.name, self.route_path, payload, request_id)
+        return self.api_client.call_tool(self.name, self.route_path, payload, request_id)

+ 24 - 1
utils/rate_limiter.py

@@ -9,10 +9,12 @@ class SimpleRateLimiter:
     For production, consider using Redis-based rate limiting.
     """
 
-    def __init__(self, max_requests=60, window_seconds=60):
+    def __init__(self, max_requests=60, window_seconds=60, max_in_flight=2):
         self.max_requests = int(max_requests)
         self.window_seconds = int(window_seconds)
+        self.max_in_flight = int(max_in_flight)
         self._requests = defaultdict(list)
+        self._in_flight = defaultdict(int)
         self._lock = Lock()
 
     def is_allowed(self, key):
@@ -29,6 +31,8 @@ class SimpleRateLimiter:
         window_start = now - self.window_seconds
 
         with self._lock:
+            if self.max_requests <= 0:
+                return True
             # Clean old requests
             requests = self._requests[key]
             self._requests[key] = [ts for ts in requests if ts > window_start]
@@ -41,6 +45,25 @@ class SimpleRateLimiter:
             self._requests[key].append(now)
             return True
 
+    def try_acquire(self, key):
+        """Acquire one reusable in-flight slot for a session/tool key."""
+        with self._lock:
+            if self.max_in_flight <= 0:
+                return True
+            if self._in_flight[key] >= self.max_in_flight:
+                return False
+            self._in_flight[key] += 1
+            return True
+
+    def release(self, key):
+        """Release a previously acquired slot; extra releases are harmless."""
+        with self._lock:
+            if self.max_in_flight <= 0 or key not in self._in_flight:
+                return
+            self._in_flight[key] -= 1
+            if self._in_flight[key] <= 0:
+                del self._in_flight[key]
+
     def cleanup(self, max_age_seconds=3600):
         """
         Remove old entries to prevent memory leak.