Pārlūkot izejas kodu

Merge branch 'uat' of http://gogs.dev.fudingri.com/dh/mcp into uat

zhongjr 1 nedēļu atpakaļ
vecāks
revīzija
c0a426501d
100 mainītis faili ar 22270 papildinājumiem un 225 dzēšanām
  1. 19 4
      .env.example
  2. 6 1
      .gitignore
  3. 72 0
      AGENTS.md
  4. 216 0
      CONTEXT.md
  5. 0 30
      COVERAGE_GUIDE.md
  6. 263 12
      README.md
  7. 818 10
      app.py
  8. 112 3
      config.py
  9. 7 0
      docs/adr/0001-mcp-pallet-data-export.md
  10. 7 0
      docs/adr/0002-headhaul-document-upload-out-of-band.md
  11. 9 0
      docs/adr/0002-inbound-receipt-template-follows-direct-send.md
  12. 700 0
      docs/superpowers/plans/2026-09-08-export-pallet-data-identifiers.md
  13. 209 0
      docs/superpowers/specs/2026-07-14-mcp-export-pending-outbound-orders-design.md
  14. 80 0
      docs/superpowers/specs/2026-07-14-mcp-open-number-format-guidance-design.md
  15. 50 0
      docs/superpowers/specs/2026-07-14-mcp-outbound-number-label-design.md
  16. 111 0
      docs/superpowers/specs/2026-07-14-mcp-python-coverage-100-design.md
  17. 65 0
      docs/superpowers/specs/2026-07-17-order-detail-design.md
  18. 287 0
      docs/superpowers/specs/2026-07-22-mcp-async-export-task-design.md
  19. 115 0
      docs/superpowers/specs/2026-09-08-export-pallet-data-identifier-design.md
  20. 364 41
      mcp_protocol.py
  21. 380 11
      public_gateway.py
  22. 881 50
      public_server.py
  23. BIN
      services/__init__.pyc
  24. 82 0
      services/api_client.py
  25. BIN
      services/api_client.pyc
  26. 206 0
      services/diagnostic_event.py
  27. 217 0
      services/diagnostic_reporter.py
  28. 6 3
      services/gateway_session_store.py
  29. 42 0
      services/headhaul_document_app.py
  30. 2937 0
      services/output_presenter.py
  31. 52 2
      services/scoped_api_client.py
  32. BIN
      services/scoped_api_client.pyc
  33. 3 0
      tests/TEST_README.md
  34. 0 26
      tests/TEST_REPORT.md
  35. 474 0
      tests/test_app_coverage.py
  36. 187 0
      tests/test_arrange_destination_trailer_tool.py
  37. BIN
      tests/test_auth_client.pyc
  38. 21 2
      tests/test_bind_auth_code_tool.py
  39. 7 1
      tests/test_cli_and_file_store.py
  40. BIN
      tests/test_cli_and_file_store.pyc
  41. 177 0
      tests/test_config_compat.py
  42. 306 0
      tests/test_container_timeliness_tools.py
  43. 80 0
      tests/test_customer_filter_options_tool.py
  44. 273 0
      tests/test_customer_payment_followup_tool.py
  45. 254 0
      tests/test_customer_payment_records_tool.py
  46. 97 0
      tests/test_customer_query_tools.py
  47. 154 0
      tests/test_customer_unverified_bill_details_tool.py
  48. 505 0
      tests/test_destination_trailer_tools.py
  49. 509 0
      tests/test_diagnostic_reporter.py
  50. 250 0
      tests/test_export_out_of_province_port_data_tool.py
  51. 331 0
      tests/test_export_pallet_data_tool.py
  52. 153 0
      tests/test_export_pending_outbound_tools.py
  53. 209 0
      tests/test_export_receivable_cost_list_tool.py
  54. 47 2
      tests/test_gateway_query_order.py
  55. BIN
      tests/test_gateway_query_order.pyc
  56. 61 1
      tests/test_gateway_runtime.py
  57. BIN
      tests/test_gateway_runtime.pyc
  58. 22 0
      tests/test_gateway_session_store.py
  59. BIN
      tests/test_gateway_session_store.pyc
  60. 15 0
      tests/test_gateway_session_store_unit.py
  61. 1279 0
      tests/test_headhaul_document_tools.py
  62. 85 0
      tests/test_inbound_receipt_tools.py
  63. 127 0
      tests/test_list_order_filter_options_tool.py
  64. 237 3
      tests/test_mcp_protocol.py
  65. 412 0
      tests/test_mcp_protocol_coverage.py
  66. 347 0
      tests/test_order_abnormal_tools.py
  67. 481 0
      tests/test_order_detail_tool.py
  68. 231 0
      tests/test_order_receivable_cost_details_tool.py
  69. 274 0
      tests/test_outbound_filter_options.py
  70. 531 0
      tests/test_outbound_query_tools.py
  71. 1028 0
      tests/test_output_presenter.py
  72. 654 0
      tests/test_payable_cost_tools.py
  73. 221 4
      tests/test_public_gateway.py
  74. 175 4
      tests/test_public_gateway_unit.py
  75. BIN
      tests/test_public_gateway_unit.pyc
  76. BIN
      tests/test_public_request_context.pyc
  77. 639 10
      tests/test_public_server.py
  78. 256 0
      tests/test_public_server_coverage.py
  79. 40 5
      tests/test_public_server_integration.py
  80. 314 0
      tests/test_query_customs_declaration_files_tool.py
  81. 100 0
      tests/test_query_export_task_tool.py
  82. 242 0
      tests/test_query_freightower_track_tool.py
  83. 158 0
      tests/test_query_goods_info_tool.py
  84. 87 0
      tests/test_query_order_coverage.py
  85. 304 0
      tests/test_query_order_exact_tool.py
  86. 14 0
      tests/test_query_track_tool.py
  87. BIN
      tests/test_query_track_tool.pyc
  88. 39 0
      tests/test_rate_limiter.py
  89. 496 0
      tests/test_receivable_cost_list_tools.py
  90. 412 0
      tests/test_receive_volume_tools.py
  91. BIN
      tests/test_redis_token_store.pyc
  92. BIN
      tests/test_request_context.pyc
  93. BIN
      tests/test_run_coverage.pyc
  94. 41 0
      tests/test_scoped_api_client.py
  95. BIN
      tests/test_scoped_api_client.pyc
  96. BIN
      tests/test_scoped_clients.pyc
  97. BIN
      tests/test_security.pyc
  98. 389 0
      tests/test_token_store_coverage.py
  99. 209 0
      tests/test_tool_description_boundaries.py
  100. 0 0
      tests/test_trial_goods_tax_tool.py

+ 19 - 4
.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
 
@@ -44,11 +45,25 @@ FMS_GATEWAY_MODE=public
 # Public gateway base URL (used for client configuration)
 FMS_GATEWAY_PUBLIC_BASE=https://mcp.example.com
 
-# Gateway session TTL in seconds (default: 2592000 = 30 days)
-FMS_GATEWAY_SESSION_TTL_SECONDS=2592000
+# Gateway Redis session TTL in seconds. 0 = no expiry (default; matches device records).
+# Set a positive value only if idle sessions must expire independently of revoke.
+FMS_GATEWAY_SESSION_TTL_SECONDS=0
 
 # 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

+ 72 - 0
AGENTS.md

@@ -0,0 +1,72 @@
+# 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.
+- `Y:/all_project_docs/mcp/guides/mcp-interface-guide.md`: business-facing field catalog and usage examples (not a protocol contract).
+- `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()))"
+```

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 216 - 0
CONTEXT.md


+ 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.

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 263 - 12
README.md


+ 818 - 10
app.py

@@ -11,20 +11,211 @@ 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_customer_filter_options import ListCustomerFilterOptionsTool
+from tools.list_receivable_cost_filter_options import (
+    ListReceivableCostFilterOptionsTool,
+)
+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.export_receivable_cost_list import ExportReceivableCostListTool
+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 tools.query_customer_list import QueryCustomerListTool
+from tools.query_customer_payment_followup import QueryCustomerPaymentFollowupTool
+from tools.query_customer_unverified_bill_details import QueryCustomerUnverifiedBillDetailsTool
+from tools.query_customer_payment_records import QueryCustomerPaymentRecordsTool
+from tools.query_order_receivable_cost_details import (
+    QueryOrderReceivableCostDetailsTool,
+)
+from tools.query_receivable_cost_list import QueryReceivableCostListTool
+from tools.query_payable_cost_list import QueryPayableCostListTool
+from tools.list_payable_cost_filter_options import ListPayableCostFilterOptionsTool
+from tools.export_payable_cost_list import ExportPayableCostListTool
+from tools.export_pallet_data import ExportPalletDataTool
+from tools.query_destination_trailer_list import QueryDestinationTrailerListTool
+from tools.arrange_destination_trailer import ArrangeDestinationTrailerTool
+from tools.list_destination_trailer_filter_options import (
+    ListDestinationTrailerFilterOptionsTool,
+)
+from tools.query_order_abnormal_list import QueryOrderAbnormalListTool
+from tools.list_order_abnormal_filter_options import (
+    ListOrderAbnormalFilterOptionsTool,
+)
+from tools.query_receive_volume_list import QueryReceiveVolumeListTool
+from tools.list_receive_volume_filter_options import (
+    ListReceiveVolumeFilterOptionsTool,
+)
+from tools.query_goods_info import QueryGoodsInfoTool
+from tools.trial_goods_tax import TrialGoodsTaxTool
+from tools.query_container_timeliness_list import QueryContainerTimelinessListTool
+from tools.export_container_timeliness_report import (
+    ExportContainerTimelinessReportTool,
+)
+from tools.query_headhaul_document_list import QueryHeadhaulDocumentListTool
+from tools.list_headhaul_document_filter_options import (
+    ListHeadhaulDocumentFilterOptionsTool,
+)
+from tools.prepare_headhaul_document_upload import (
+    PrepareHeadhaulDocumentUploadTool,
+)
+from tools.save_headhaul_document import SaveHeadhaulDocumentTool
+from tools.delete_headhaul_document import DeleteHeadhaulDocumentTool
+from tools.list_inbound_receipt_templates import ListInboundReceiptTemplatesTool
+from tools.export_inbound_receipt import ExportInboundReceiptTool
+from tools.query_freightower_track import QueryFreightowerTrackTool
+
+
+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()
+        from services.headhaul_document_app import HeadhaulSlotMemory
+        self._headhaul_slots = HeadhaulSlotMemory()
         self._tools = {
             'query_order': QueryOrderTool(api_client=api_client),
             'query_track': QueryTrackTool(api_client=api_client),
+            '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),
+            'query_customer_list': QueryCustomerListTool(api_client=api_client),
+            'query_customer_payment_followup': QueryCustomerPaymentFollowupTool(
+                api_client=api_client
+            ),
+            'query_customer_unverified_bill_details':
+                QueryCustomerUnverifiedBillDetailsTool(api_client=api_client),
+            'query_customer_payment_records': QueryCustomerPaymentRecordsTool(
+                api_client=api_client
+            ),
+            'query_order_receivable_cost_details':
+                QueryOrderReceivableCostDetailsTool(api_client=api_client),
+            'query_receivable_cost_list':
+                QueryReceivableCostListTool(api_client=api_client),
+            'query_payable_cost_list':
+                QueryPayableCostListTool(api_client=api_client),
+            'list_outbound_filter_options': ListOutboundFilterOptionsTool(
+                api_client=api_client
+            ),
+            'list_order_filter_options': ListOrderFilterOptionsTool(
+                api_client=api_client
+            ),
+            'list_customer_filter_options': ListCustomerFilterOptionsTool(
+                api_client=api_client
+            ),
+            'list_receivable_cost_filter_options':
+                ListReceivableCostFilterOptionsTool(api_client=api_client),
+            'list_payable_cost_filter_options':
+                ListPayableCostFilterOptionsTool(api_client=api_client),
+            'export_pending_outbound_orders': ExportPendingOutboundOrdersTool(
+                api_client=api_client
+            ),
+            'export_out_of_province_port_data':
+                ExportOutOfProvincePortDataTool(api_client=api_client),
+            'export_receivable_cost_list': ExportReceivableCostListTool(
+                api_client=api_client
+            ),
+            'export_payable_cost_list': ExportPayableCostListTool(
+                api_client=api_client
+            ),
+            'export_pallet_data': ExportPalletDataTool(
+                api_client=api_client
+            ),
+            'query_export_task': QueryExportTaskTool(api_client=api_client),
+            'list_pending_outbound_export_filter_options':
+                ListPendingOutboundExportFilterOptionsTool(api_client=api_client),
+            'query_destination_trailer_list': QueryDestinationTrailerListTool(
+                api_client=api_client
+            ),
+            'arrange_destination_trailer': ArrangeDestinationTrailerTool(
+                api_client=api_client
+            ),
+            'list_destination_trailer_filter_options':
+                ListDestinationTrailerFilterOptionsTool(api_client=api_client),
+            'query_order_abnormal_list': QueryOrderAbnormalListTool(
+                api_client=api_client
+            ),
+            'list_order_abnormal_filter_options':
+                ListOrderAbnormalFilterOptionsTool(api_client=api_client),
+            'query_receive_volume_list': QueryReceiveVolumeListTool(
+                api_client=api_client
+            ),
+            'query_goods_info': QueryGoodsInfoTool(api_client=api_client),
+            'trial_goods_tax': TrialGoodsTaxTool(api_client=api_client),
+            'list_receive_volume_filter_options':
+                ListReceiveVolumeFilterOptionsTool(api_client=api_client),
+            'query_container_timeliness_list': QueryContainerTimelinessListTool(
+                api_client=api_client
+            ),
+            'export_container_timeliness_report':
+                ExportContainerTimelinessReportTool(api_client=api_client),
+            'query_headhaul_document_list': QueryHeadhaulDocumentListTool(
+                api_client=api_client
+            ),
+            'list_headhaul_document_filter_options':
+                ListHeadhaulDocumentFilterOptionsTool(api_client=api_client),
+            'prepare_headhaul_document_upload':
+                PrepareHeadhaulDocumentUploadTool(api_client=api_client),
+            'save_headhaul_document': SaveHeadhaulDocumentTool(
+                api_client=api_client
+            ),
+            'delete_headhaul_document': DeleteHeadhaulDocumentTool(
+                api_client=api_client
+            ),
+            'list_inbound_receipt_templates': ListInboundReceiptTemplatesTool(
+                api_client=api_client
+            ),
+            'export_inbound_receipt': ExportInboundReceiptTool(
+                api_client=api_client
+            ),
+            'query_freightower_track': QueryFreightowerTrackTool(
+                api_client=api_client
+            ),
         }
 
     @classmethod
@@ -63,10 +254,55 @@ 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 list_resources(self):
+        return []
+
+    def read_resource(self, uri, gateway_session_id='local'):
+        return None
+
+    def session_for_headhaul_token(self, token):
+        return self._headhaul_slots.session_for_token(token)
 
     def build_request_id(self, request_id=''):
         request_id = str(request_id or '').strip()
@@ -91,12 +327,22 @@ 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)
-        return tool.call(request_id=request_id, **arguments)
+        if name not in self._load_enabled_tool_names(request_id):
+            raise RuntimeError('tool disabled: {0}'.format(name))
+        arguments = arguments or {}
+        result = tool.call(request_id=request_id, **arguments)
+        if name == 'prepare_headhaul_document_upload' and isinstance(result, dict):
+            data = result.get('data')
+            token = ''
+            if isinstance(data, dict):
+                token = str(data.get('upload_token') or '').strip()
+            if token:
+                self._headhaul_slots.remember('local', token)
+        return result
 
     def create_protocol_handler(self):
-        return McpProtocolHandler(self)
+        return McpProtocolHandler(self, reporter=self.reporter)
 
     def run_cli(self, argv=None, stdin=None, stdout=None):
         stdin = stdin or sys.stdin
@@ -114,9 +360,103 @@ class GatewayApp:
         call_parser = subparsers.add_parser('call')
         call_parser.add_argument('--tool', required=True)
         call_parser.add_argument('--keyword', default='')
+        call_parser.add_argument('--sku', default='')
+        call_parser.add_argument('--cn-name', default='')
+        call_parser.add_argument('--departure-country', default='')
+        call_parser.add_argument('--destination-country', default='')
+        call_parser.add_argument('--price', type=float, default=0)
+        call_parser.add_argument('--qty', type=float, default=0)
+        call_parser.add_argument('--gross-weight', type=float, default=0)
+        call_parser.add_argument('--box', type=float, default=0)
         call_parser.add_argument('--order-id', type=int, default=0)
         call_parser.add_argument('--order-number', default='')
+        call_parser.add_argument('--business-type', type=int, default=0)
+        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-number', 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('--template-code', 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('--customer-id', type=int, default=0)
+        call_parser.add_argument('--sub-customer-id', type=int, default=0)
+        call_parser.add_argument('--bill-numbers', default='')
+        call_parser.add_argument('--business-date-start', default='')
+        call_parser.add_argument('--business-date-end', default='')
+        call_parser.add_argument('--cost-date-start', default='')
+        call_parser.add_argument('--cost-date-end', default='')
+        call_parser.add_argument('--operation-date-start', default='')
+        call_parser.add_argument('--operation-date-end', default='')
+        call_parser.add_argument('--business-node-id', type=int, default=0)
+        call_parser.add_argument('--provider-id', type=int, default=0)
+        call_parser.add_argument('--cost-type-ids', default='')
+        call_parser.add_argument('--billing-status', type=int, default=None)
+        call_parser.add_argument('--payment-status', type=int, default=None)
+        call_parser.add_argument('--verification-status', type=int, default=None)
+        call_parser.add_argument('--document-type', type=int, default=None)
+        call_parser.add_argument('--cost-type-id', type=int, default=0)
+        call_parser.add_argument('--receive-date-start', default='')
+        call_parser.add_argument('--receive-date-end', default='')
+        call_parser.add_argument('--sales-id', type=int, default=0)
+        call_parser.add_argument('--merchandiser-id', type=int, default=0)
+        call_parser.add_argument(
+            '--has-unverified-receivable-only',
+            choices=('true', 'false'),
+            default='true',
+        )
+        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('--inbound-time-start', default='')
+        call_parser.add_argument('--inbound-time-end', default='')
+        call_parser.add_argument('--departure-time-start', default='')
+        call_parser.add_argument('--departure-time-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('--trailer-stage', default='')
+        call_parser.add_argument('--abnormal-status', default='')
+        call_parser.add_argument('--sales-ids', default='')
+        call_parser.add_argument('--merchandiser-ids', default='')
+        call_parser.add_argument('--packing-types', default='')
+        call_parser.add_argument('--providers-id', type=int, default=0)
+        call_parser.add_argument('--bill-status', type=int, default=None)
+        call_parser.add_argument('--cabinet-type', type=int, default=0)
+        call_parser.add_argument('--eta-start', default='')
+        call_parser.add_argument('--eta-end', default='')
+        call_parser.add_argument('--pickup-time-start', default='')
+        call_parser.add_argument('--pickup-time-end', default='')
+        call_parser.add_argument('--container-return-time-start', default='')
+        call_parser.add_argument('--container-return-time-end', 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 +466,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 +494,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 +526,453 @@ 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_customer_list':
+                for field, value in (
+                    ('customer_id', args.customer_id),
+                    ('department_id', args.department_id),
+                    ('sales_id', args.sales_id),
+                    ('merchandiser_id', args.merchandiser_id),
+                ):
+                    if value > 0:
+                        tool_args[field] = value
+            elif args.tool == 'query_customer_payment_followup':
+                for field, value in (
+                    ('customer_id', args.customer_id),
+                    ('department_id', args.department_id),
+                    ('sales_id', args.sales_id),
+                    ('merchandiser_id', args.merchandiser_id),
+                ):
+                    if value > 0:
+                        tool_args[field] = value
+                tool_args['has_unverified_receivable_only'] = (
+                    args.has_unverified_receivable_only == 'true'
+                )
+            elif args.tool == 'query_customer_unverified_bill_details':
+                if args.customer_id <= 0:
+                    raise ValueError(
+                        '--customer-id is required for query_customer_unverified_bill_details'
+                    )
+                tool_args['customer_id'] = args.customer_id
+            elif args.tool == 'query_customer_payment_records':
+                if args.customer_id <= 0:
+                    raise ValueError(
+                        '--customer-id is required for query_customer_payment_records'
+                    )
+                tool_args['customer_id'] = args.customer_id
+                if args.receive_date_start:
+                    tool_args['receive_date_start'] = args.receive_date_start
+                if args.receive_date_end:
+                    tool_args['receive_date_end'] = args.receive_date_end
+            elif args.tool == 'query_order_receivable_cost_details':
+                if not args.order_number:
+                    raise ValueError(
+                        '--order-number is required for query_order_receivable_cost_details'
+                    )
+                tool_args['order_number'] = args.order_number
+            elif args.tool in (
+                'query_payable_cost_list', 'export_payable_cost_list'
+            ):
+                if args.business_type <= 0:
+                    raise ValueError(
+                        '--business-type is required for ' + args.tool
+                    )
+                tool_args['business_type'] = args.business_type
+                payable_number_lists = {
+                    'order_numbers': args.order_numbers,
+                    'tracking_numbers': args.tracking_numbers,
+                    'container_codes': args.container_codes,
+                    'bl_numbers': args.bl_numbers,
+                    'so_numbers': args.so_numbers,
+                }
+                for field, value in payable_number_lists.items():
+                    if value:
+                        tool_args[field] = parse_string_list(value)
+                payable_dates = {
+                    'business_date_start': args.business_date_start,
+                    'business_date_end': args.business_date_end,
+                    'cost_date_start': args.cost_date_start,
+                    'cost_date_end': args.cost_date_end,
+                    'operation_date_start': args.operation_date_start,
+                    'operation_date_end': args.operation_date_end,
+                }
+                for field, value in payable_dates.items():
+                    if value:
+                        tool_args[field] = value
+                for field, value in (
+                    ('business_node_id', args.business_node_id),
+                    ('provider_id', args.provider_id),
+                ):
+                    if value > 0:
+                        tool_args[field] = value
+                if args.cost_type_ids:
+                    tool_args['cost_type_ids'] = parse_int_list(
+                        args.cost_type_ids
+                    )
+                for field, value in (
+                    ('billing_status', args.billing_status),
+                    ('payment_status', args.payment_status),
+                    ('verification_status', args.verification_status),
+                    ('document_type', args.document_type),
+                ):
+                    if value is not None:
+                        tool_args[field] = value
+                if args.tool == 'export_payable_cost_list':
+                    tool_args.pop('page', None)
+                    tool_args.pop('limit', None)
+            elif args.tool == 'query_receivable_cost_list':
+                for field, value in (
+                    ('reference_numbers', args.reference_numbers),
+                    ('tracking_numbers', args.tracking_numbers),
+                    ('order_numbers', args.order_numbers),
+                    ('bill_numbers', args.bill_numbers),
+                ):
+                    if value:
+                        tool_args[field] = parse_string_list(value)
+                for field, value in (
+                    ('business_date_start', args.business_date_start),
+                    ('business_date_end', args.business_date_end),
+                ):
+                    if value:
+                        tool_args[field] = value
+                for field, value in (
+                    ('customer_id', args.customer_id),
+                    ('sub_customer_id', args.sub_customer_id),
+                    ('cost_type_id', args.cost_type_id),
+                ):
+                    if value > 0:
+                        tool_args[field] = value
+                for field, value in (
+                    ('billing_status', args.billing_status),
+                    ('verification_status', args.verification_status),
+                    ('document_type', args.document_type),
+                ):
+                    if value is not None:
+                        tool_args[field] = value
+            elif args.tool == 'export_receivable_cost_list':
+                for field, value in (
+                    ('reference_numbers', args.reference_numbers),
+                    ('tracking_numbers', args.tracking_numbers),
+                    ('order_numbers', args.order_numbers),
+                    ('bill_numbers', args.bill_numbers),
+                ):
+                    if value:
+                        tool_args[field] = parse_string_list(value)
+                for field, value in (
+                    ('business_date_start', args.business_date_start),
+                    ('business_date_end', args.business_date_end),
+                ):
+                    if value:
+                        tool_args[field] = value
+                for field, value in (
+                    ('customer_id', args.customer_id),
+                    ('sub_customer_id', args.sub_customer_id),
+                    ('cost_type_id', args.cost_type_id),
+                ):
+                    if value > 0:
+                        tool_args[field] = value
+                for field, value in (
+                    ('billing_status', args.billing_status),
+                    ('verification_status', args.verification_status),
+                    ('document_type', args.document_type),
+                ):
+                    if value is not None:
+                        tool_args[field] = value
+                tool_args.pop('page', None)
+                tool_args.pop('limit', None)
+            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 == 'export_pallet_data':
+                if args.container_codes:
+                    tool_args['container_codes'] = parse_string_list(
+                        args.container_codes
+                    )
+                if args.bl_numbers:
+                    tool_args['bl_numbers'] = parse_string_list(
+                        args.bl_numbers
+                    )
+                if args.inbound_time_start:
+                    tool_args['inbound_time_start'] = args.inbound_time_start
+                if args.inbound_time_end:
+                    tool_args['inbound_time_end'] = args.inbound_time_end
+                tool_args.pop('page', None)
+                tool_args.pop('limit', None)
+            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 == 'list_payable_cost_filter_options':
+                if not args.filter_type:
+                    raise ValueError(
+                        '--filter-type is required for '
+                        'list_payable_cost_filter_options'
+                    )
+                tool_args['filter_type'] = args.filter_type
+                tool_args['keyword'] = args.keyword
+                if args.business_type > 0:
+                    tool_args['business_type'] = args.business_type
+            elif args.tool == 'list_receivable_cost_filter_options':
+                if not args.filter_type:
+                    raise ValueError(
+                        '--filter-type is required for '
+                        'list_receivable_cost_filter_options'
+                    )
+                tool_args['filter_type'] = args.filter_type
+                tool_args['keyword'] = args.keyword
+                if args.customer_id > 0:
+                    tool_args['customer_id'] = args.customer_id
+            elif args.tool == 'query_destination_trailer_list':
+                if not args.trailer_stage:
+                    raise ValueError(
+                        '--trailer-stage is required for '
+                        'query_destination_trailer_list'
+                    )
+                tool_args['trailer_stage'] = args.trailer_stage
+                if args.bl_numbers:
+                    tool_args['bl_numbers'] = parse_string_list(args.bl_numbers)
+                if args.container_codes:
+                    tool_args['container_codes'] = parse_string_list(
+                        args.container_codes
+                    )
+                if args.providers_id > 0:
+                    tool_args['providers_id'] = args.providers_id
+                if 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
+                if args.bill_status is not None:
+                    tool_args['bill_status'] = args.bill_status
+                if args.cabinet_type > 0:
+                    tool_args['cabinet_type'] = args.cabinet_type
+                if args.shipping_method > 0:
+                    tool_args['shipping_method'] = args.shipping_method
+                trailer_dates = {
+                    'eta_start': args.eta_start,
+                    'eta_end': args.eta_end,
+                    'pickup_time_start': args.pickup_time_start,
+                    'pickup_time_end': args.pickup_time_end,
+                    'container_return_time_start':
+                        args.container_return_time_start,
+                    'container_return_time_end':
+                        args.container_return_time_end,
+                }
+                for field, value in trailer_dates.items():
+                    if value:
+                        tool_args[field] = value
+            elif args.tool == 'query_order_abnormal_list':
+                if not args.abnormal_status:
+                    raise ValueError(
+                        '--abnormal-status is required for '
+                        'query_order_abnormal_list'
+                    )
+                tool_args['abnormal_status'] = args.abnormal_status
+                if args.order_numbers:
+                    tool_args['order_numbers'] = parse_string_list(
+                        args.order_numbers
+                    )
+            elif args.tool == 'trial_goods_tax':
+                tool_args['sku'] = args.sku
+                tool_args['departure_country'] = args.departure_country
+                tool_args['destination_country'] = args.destination_country
+                tool_args['price'] = args.price
+                tool_args['qty'] = args.qty
+                tool_args['gross_weight'] = args.gross_weight
+                tool_args['box'] = args.box
+            elif args.tool == 'query_goods_info':
+                if args.sku:
+                    tool_args['sku'] = args.sku
+                if args.cn_name:
+                    tool_args['cn_name'] = args.cn_name
+                if not args.sku and not args.cn_name:
+                    raise ValueError(
+                        '--sku or --cn-name is required for query_goods_info'
+                    )
+            elif args.tool == 'query_receive_volume_list':
+                if not args.inbound_date_start or not args.inbound_date_end:
+                    raise ValueError(
+                        '--inbound-date-start and --inbound-date-end are '
+                        'required for query_receive_volume_list'
+                    )
+                tool_args['inbound_date_start'] = args.inbound_date_start
+                tool_args['inbound_date_end'] = args.inbound_date_end
+                if args.customer_ids:
+                    tool_args['customer_ids'] = parse_int_list(args.customer_ids)
+                if args.sales_ids:
+                    tool_args['sales_ids'] = parse_int_list(args.sales_ids)
+                if args.merchandiser_ids:
+                    tool_args['merchandiser_ids'] = parse_int_list(
+                        args.merchandiser_ids
+                    )
+                if args.packing_types:
+                    tool_args['packing_types'] = parse_string_list(
+                        args.packing_types
+                    )
+                if args.product_ids:
+                    tool_args['product_ids'] = parse_int_list(args.product_ids)
+            elif args.tool in (
+                'query_container_timeliness_list',
+                'export_container_timeliness_report',
+            ):
+                if args.container_codes:
+                    tool_args['container_codes'] = parse_string_list(
+                        args.container_codes
+                    )
+                if args.bl_numbers:
+                    tool_args['bl_numbers'] = parse_string_list(args.bl_numbers)
+                if args.departure_time_start:
+                    tool_args['departure_time_start'] = args.departure_time_start
+                if args.departure_time_end:
+                    tool_args['departure_time_end'] = args.departure_time_end
+                if args.tool == 'export_container_timeliness_report':
+                    tool_args.pop('page', None)
+                    tool_args.pop('limit', None)
+            elif args.tool in (
+                'list_inbound_receipt_templates',
+                'export_inbound_receipt',
+            ):
+                tool_args.pop('page', None)
+                tool_args.pop('limit', None)
+                if not args.container_codes:
+                    raise ValueError(
+                        '--container-codes is required for ' + args.tool
+                    )
+                tool_args['container_codes'] = parse_string_list(
+                    args.container_codes
+                )
+                if args.tool == 'export_inbound_receipt':
+                    if not args.template_code:
+                        raise ValueError(
+                            '--template-code is required for export_inbound_receipt'
+                        )
+                    tool_args['template_code'] = args.template_code
+            elif args.tool == 'query_freightower_track':
+                tool_args.pop('page', None)
+                tool_args.pop('limit', None)
+                if args.bl_number:
+                    tool_args['bl_number'] = args.bl_number
+                if args.container_code:
+                    tool_args['container_code'] = args.container_code
+                if not tool_args.get('bl_number') and not tool_args.get('container_code'):
+                    raise ValueError(
+                        '--bl-number or --container-code is required for '
+                        'query_freightower_track'
+                    )
+            elif args.tool in (
+                'list_order_filter_options', 'list_outbound_filter_options',
+                'list_customer_filter_options',
+                'list_destination_trailer_filter_options',
+                'list_order_abnormal_filter_options',
+                'list_receive_volume_filter_options',
+                'list_headhaul_document_filter_options',
+            ):
+                if not args.filter_type:
+                    raise ValueError(
+                        '--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 +996,4 @@ def main(argv=None):
 
 
 if __name__ == '__main__':
-    raise SystemExit(main(sys.argv[1:]))
+    raise SystemExit(main(sys.argv[1:]))

+ 112 - 3
config.py

@@ -19,7 +19,21 @@ class GatewayConfig:
     redis_prefix: str = 'fms:mcp:workbuddy:'
     session_key: str = ''
     gateway_mode: str = 'local'
-    gateway_session_ttl_seconds: int = 2592000
+    gateway_session_ttl_seconds: int = 0
+    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=''):
@@ -66,7 +80,60 @@ class GatewayConfig:
             redis_prefix=redis_prefix,
             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'),
+            gateway_session_ttl_seconds=int(cls._pick(dotenv_env, primary_env, 'FMS_GATEWAY_SESSION_TTL_SECONDS', 'MCP_GATEWAY_SESSION_TTL_SECONDS') or '0'),
+            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

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 7 - 0
docs/adr/0001-mcp-pallet-data-export.md


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

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

+ 9 - 0
docs/adr/0002-inbound-receipt-template-follows-direct-send.md

@@ -0,0 +1,9 @@
+# 入仓单模板按柜上的是否直送划分
+
+MCP 查入仓单模板和导出入仓单时,只看这一柜排舱单当前是不是直送柜。直送柜用海外提柜页那两张直送模板里已启用的;非直送柜用海外仓页入仓单模板里去掉这两张之后、已启用的全部。调用方只传柜号,不声明种类。
+
+海外提柜页的「入仓单导出」不是这样:不管勾中的柜是不是直送,弹窗都只给那两张直送模板。若 MCP 照抄页面,非直送柜会拿到直送模板。按柜上的标记划分,同一次才能只返回一套模板。
+
+**Status:** accepted
+
+**Considered Options:** 照抄海外提柜页,非直送柜也只给直送模板(和页面按钮一致,但和「非直送入仓单用海外仓模板」相反);让调用方自己声明直送或非直送(实现简单,但用户要求只凭柜号判断)。

+ 700 - 0
docs/superpowers/plans/2026-09-08-export-pallet-data-identifiers.md

@@ -0,0 +1,700 @@
+# `export_pallet_data` Identifier Update Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Change `export_pallet_data` from inbound-order-number filtering to an explicit three-way choice of container numbers, bill-of-lading numbers, or overseas-warehouse inbound-time range, while preserving the 200-item limit, company isolation, async task contract, and pallet export output.
+
+**Architecture:** Keep the Gateway as a schema/forwarding boundary and keep all business resolution in fmsoperate. Gateway accepts `container_codes` and `bl_numbers` as separate arrays and rejects ambiguous or mixed selectors; fmsoperate resolves each selector through its own indexed database branch before reusing the existing inbound-record → order → pallet-batch pipeline. The old `inbound_numbers` field is rejected at both the public schema and PHP task boundary.
+
+**Tech Stack:** Python 3 standard-library MCP Gateway, ThinkPHP 6/PHP 7.1+, ThinkORM/MySQL, Redis queue, PHPUnit contract tests, Markdown project documentation.
+
+**Delivery boundary:** Do not execute registration SQL, restart Gateway or workers, or commit Git changes. Preserve all unrelated user changes already present in the three repositories.
+
+---
+
+## Files and responsibilities
+
+### Gateway (`Y:/mcp`)
+
+- Modify `tools/export_pallet_data.py` for metadata, runtime validation, and forwarding.
+- Modify `app.py` for CLI selector flags and payload construction.
+- Modify `services/output_presenter.py` to map new parameter errors to Chinese labels.
+- Modify `tests/test_export_pallet_data_tool.py` for the new closed schema, forwarding, limits, and CLI contract.
+- Modify `tests/test_tool_description_boundaries.py` so this number-based export requires explicit type clarification.
+- Modify `tests/test_output_presenter.py` for new field-label error mapping.
+- Modify `README.md` and `CONTEXT.md` to remove the old selector contract from Gateway-facing material.
+
+### Business backend (`Y:/fmsoperate`)
+
+- Modify `app/mcp/validate/McpToolValidate.php` for the new selector XOR rules and explicit legacy-field rejection.
+- Modify `app/mcp/logic/McpPalletDataExportLogic.php` for normalization, validation, task parameters, and model calls.
+- Modify `app/mcp/model/McpPalletDataExportModel.php` for separate container and bill-of-lading lookup branches.
+- Modify `app/mcp/logic/McpPalletDataExportService.php` for the new persisted task parameters.
+- Modify `app/mcp/controller/McpToolsController.php` so audit payload allowlisting records the new selector fields.
+- Modify `app/job/think/handle/inside/McpPalletDataExport.php` so queued-task validation accepts only the new selector contract.
+- Do not modify the route, queue action, task-reference source list, or export output generation.
+
+### Contract tests (`Y:/settlement_tests`)
+
+- Modify `tests/Unit/FmsOperate/McpPalletDataExportTest.php` for validator, logic, model-shape, identity, and new selector behavior.
+- Modify `tests/Unit/FmsOperate/McpPalletDataExportJobTest.php` for worker payload validation and legacy-task rejection.
+- Modify `docs/manual/mcp-async-export-workbuddy-cases.md` with new pallet selector placeholders and manual cases.
+
+### Central documentation (`Y:/all_project_docs`)
+
+- Modify `fmsoperate/requirements.md`, `fmsoperate/tech-specs.md`, `fmsoperate/user-structure.md`, and `fmsoperate/timeline.md`.
+- Modify `mcp/overview.md`, `mcp/requirements.md`, `mcp/tech-specs.md`, `mcp/user-structure.md`, and `mcp/timeline.md`.
+- Modify `fmsoperate/sql/mcp_add_pallet_data_export_tool.sql` description only; do not execute it.
+
+## Task 1: Add failing Gateway contract tests
+
+**Files:**
+
+- Modify: `Y:/mcp/tests/test_export_pallet_data_tool.py`
+- Modify: `Y:/mcp/tests/test_tool_description_boundaries.py`
+- Modify: `Y:/mcp/tests/test_output_presenter.py`
+
+- [ ] **Step 1: Replace the old schema expectations.**
+
+Change the schema test to require exactly:
+
+```python
+{
+    'container_codes',
+    'bl_numbers',
+    'inbound_time_start',
+    'inbound_time_end',
+}
+```
+
+Keep `additionalProperties=False`, assert both number fields have `maxItems == 200`, and assert that `inbound_numbers`, `page`, `limit`, `ids`, and `inbound_id` are absent.
+
+- [ ] **Step 2: Add forwarding tests for both number types.**
+
+Add separate tests that call the tool with trimmed duplicate values and assert the exact payload:
+
+```python
+ExportPalletDataTool(client).call(
+    container_codes=[' CONT-1 ', 'CONT-1', 'CONT-2'],
+)
+assert client.calls[-1][2] == {
+    'container_codes': ['CONT-1', 'CONT-2'],
+}
+
+ExportPalletDataTool(client).call(
+    bl_numbers=[' BL-1 ', 'BL-1'],
+)
+assert client.calls[-1][2] == {
+    'bl_numbers': ['BL-1'],
+}
+```
+
+Retain the existing date-only and date-time boundary tests, changing only their surrounding selector assumptions.
+
+- [ ] **Step 3: Add the new validation boundary cases.**
+
+Cover all of these cases:
+
+```python
+tool.call(container_codes=['CONT-1'], bl_numbers=['BL-1'])  # raises ValueError
+tool.call(container_codes=['CONT-1'], inbound_time_start='2026-09-01',
+          inbound_time_end='2026-09-02')                    # raises ValueError
+tool.call(bl_numbers=['BL-1'], inbound_time_start='2026-09-01',
+          inbound_time_end='2026-09-02')                    # raises ValueError
+tool.call(container_codes=[])                               # raises ValueError
+tool.call(bl_numbers=['BL-' + 'x' * 100])                   # accepted at 100 chars
+tool.call(container_codes=['x' * 101])                      # raises ValueError
+tool.call(bl_numbers=['BL-{0}'.format(i) for i in range(201)])  # raises ValueError
+tool.call()                                                 # raises ValueError
+```
+
+The metadata test must also prove that passing the old field is impossible through the closed schema; do not add `inbound_numbers` back to the Python call signature.
+
+- [ ] **Step 4: Update description-boundary tests.**
+
+Include `ExportPalletDataTool()` in the number-type clarification group and require its description to contain:
+
+- `号码类型不明确时必须先询问用户`
+- `不得根据号码格式猜测`
+- `不得跨字段或跨工具试查`
+
+Keep the existing asynchronous export boundary assertions.
+
+- [ ] **Step 5: Add Presenter field-label coverage.**
+
+Add a `MCP_1401` case for `container_codes` and one for `bl_numbers`, asserting the public message is respectively `柜号参数不正确` and `提单号参数不正确`, and that raw Python field names are absent from serialized output.
+
+- [ ] **Step 6: Run the Gateway tests and confirm they fail for the old implementation.**
+
+Run from `Y:/mcp`:
+
+```powershell
+python -m unittest tests.test_export_pallet_data_tool tests.test_tool_description_boundaries tests.test_output_presenter
+```
+
+Expected result: failures identify the still-existing `inbound_numbers` schema/signature/CLI behavior and missing field labels. Do not change production code until these tests demonstrate the contract gap.
+
+## Task 2: Implement the Gateway selector contract
+
+**Files:**
+
+- Modify: `Y:/mcp/tools/export_pallet_data.py`
+- Modify: `Y:/mcp/app.py`
+- Modify: `Y:/mcp/services/output_presenter.py`
+
+- [ ] **Step 1: Replace the metadata fields and descriptions.**
+
+Define `container_codes` and `bl_numbers` with the same array constraints as the former number field:
+
+```python
+{
+    'type': 'array',
+    'minItems': 1,
+    'maxItems': 200,
+    'items': {
+        'type': 'string',
+        'minLength': 1,
+        'maxLength': 100,
+        'pattern': r'.*\S.*',
+    },
+}
+```
+
+Describe each field using its Chinese business label, say that it is only valid when the user explicitly identifies that type, and prohibit other number types and internal IDs. Update the tool description to say the selector is exactly “柜号 / 提单号 / 海外仓入库时间”三选一. Explicitly state that an unclear number type requires a user question before calling and that the tool must not guess or try another field.
+
+- [ ] **Step 2: Implement a single normalized array helper.**
+
+Use one helper with the following behavior for both fields:
+
+```python
+def _clean_numbers(self, field_name, values):
+    if not isinstance(values, list) or not values:
+        raise ValueError('{0} must be a non-empty list'.format(field_name))
+    cleaned = []
+    for value in values:
+        if not isinstance(value, str):
+            raise ValueError('{0} items must be strings'.format(field_name))
+        item = value.strip()
+        if not item or len(item) > 100:
+            raise ValueError(
+                '{0} items must be 1 to 100 chars'.format(field_name)
+            )
+        if item not in cleaned:
+            cleaned.append(item)
+    if len(cleaned) > 200:
+        raise ValueError('at most 200 {0}'.format(field_name))
+    return cleaned
+```
+
+- [ ] **Step 3: Change `call()` to enforce the three-way XOR.**
+
+The public signature must be:
+
+```python
+def call(
+    self,
+    container_codes=None,
+    bl_numbers=None,
+    inbound_time_start=None,
+    inbound_time_end=None,
+    request_id='rq_export_pallet_data',
+):
+```
+
+Treat a non-`None` number argument as supplied, even if it is an empty list, so malformed empty arrays cannot silently disappear. Reject both number arguments together, reject any number argument combined with a time range, require both time bounds when time is selected, and reject an entirely empty payload. Forward exactly one cleaned selector or the two original time-bound strings.
+
+- [ ] **Step 4: Update the CLI branch without affecting other tools.**
+
+`--container-codes` and `--bl-numbers` already exist as shared CLI options for other tools. Remove only the obsolete `--inbound-numbers` parser option and change the `export_pallet_data` branch to build:
+
+```python
+if args.container_codes:
+    tool_args['container_codes'] = parse_string_list(args.container_codes)
+if args.bl_numbers:
+    tool_args['bl_numbers'] = parse_string_list(args.bl_numbers)
+if args.inbound_time_start:
+    tool_args['inbound_time_start'] = args.inbound_time_start
+if args.inbound_time_end:
+    tool_args['inbound_time_end'] = args.inbound_time_end
+tool_args.pop('page', None)
+tool_args.pop('limit', None)
+```
+
+- [ ] **Step 5: Add safe parameter labels.**
+
+Extend `OutputPresenter.FIELD_LABELS['export_pallet_data']` with:
+
+```python
+'container_codes': '柜号',
+'bl_numbers': '提单号',
+'inbound_time_start': '入库时间开始',
+'inbound_time_end': '入库时间结束',
+```
+
+Do not expose raw field names in the public error text.
+
+- [ ] **Step 6: Re-run the focused Gateway tests.**
+
+Run:
+
+```powershell
+python -m unittest tests.test_export_pallet_data_tool tests.test_tool_description_boundaries tests.test_output_presenter
+```
+
+Expected result: all focused tests pass, including the unchanged 26-tool registry and 25-safe-tool assertions.
+
+## Task 3: Add failing fmsoperate contract tests
+
+**Files:**
+
+- Modify: `Y:/settlement_tests/tests/Unit/FmsOperate/McpPalletDataExportTest.php`
+- Modify: `Y:/settlement_tests/tests/Unit/FmsOperate/McpPalletDataExportJobTest.php`
+
+- [ ] **Step 1: Update Validate test inputs to the new selector names.**
+
+Change the accepted cases to:
+
+```php
+$this->assertTrue($this->validator()->check([
+    'container_codes' => ['CONT-1'],
+]));
+$this->assertTrue($this->validator()->check([
+    'bl_numbers' => ['BL-1'],
+]));
+$this->assertTrue($this->validator()->check([
+    'inbound_time_start' => '2026-09-01',
+    'inbound_time_end' => '2026-09-07 18:30:00',
+]));
+```
+
+- [ ] **Step 2: Add PHP boundary assertions.**
+
+Assert false for:
+
+```php
+[
+    'container_codes' => ['CONT-1'],
+    'bl_numbers' => ['BL-1'],
+]
+[
+    'container_codes' => ['CONT-1'],
+    'inbound_time_start' => '2026-09-01',
+    'inbound_time_end' => '2026-09-02',
+]
+[
+    'inbound_numbers' => ['INB-1'],
+]
+```
+
+Retain assertions for one-sided time, over-31-day time, 201 items, forbidden pagination, `ids`, and `inbound_id`.
+
+- [ ] **Step 3: Update trusted-identity submission coverage.**
+
+Use `container_codes` in the submission test and assert the submitted task parameters contain the normalized new field, trusted `company_id`, `operator_id`, `is_super`, and `mcp_source_tool`, while still excluding `ADMIN_ID`.
+
+Add a second submission assertion using `bl_numbers` so both code paths persist the correct selector.
+
+- [ ] **Step 4: Add model query-shape guards.**
+
+Assert the model source contains `fms_outbound`, `container_code`, `fms_booking_detail`, and `bl_number`, keeps `company_id`, does not contain `CONVERT_TZ` or `COUNT(`, and does not contain a combined `container_code|bl_number` OR expression. This freezes the performance requirement that the two number types use separate branches.
+
+- [ ] **Step 5: Update worker fixtures and add legacy-task rejection.**
+
+Change existing queued-task JSON fixtures from `inbound_numbers` to `container_codes`. Add a successful `bl_numbers` fixture. Add a test whose task JSON contains only `inbound_numbers`; assert the worker does not start the task, exporter is not called, and the task is marked failed.
+
+- [ ] **Step 6: Run the focused PHP tests and confirm they fail before implementation.**
+
+Run from `Y:/settlement_tests`:
+
+```bat
+bin\test.bat fmsoperate --filter McpPalletDataExport
+```
+
+Expected result: failures identify the old validator field, old Logic parameter payload, missing model branches, and worker assumptions. Do not weaken the new assertions to accommodate the old contract.
+
+## Task 4: Implement fmsoperate validation and orchestration
+
+**Files:**
+
+- Modify: `Y:/fmsoperate/app/mcp/validate/McpToolValidate.php`
+- Modify: `Y:/fmsoperate/app/mcp/logic/McpPalletDataExportLogic.php`
+- Modify: `Y:/fmsoperate/app/mcp/controller/McpToolsController.php`
+
+- [ ] **Step 1: Collapse the duplicate `exportPalletData` scene definition.**
+
+Keep one scene definition containing only:
+
+```php
+'container_codes',
+'bl_numbers',
+'inbound_time_start',
+'inbound_time_end',
+'page',
+'limit',
+'ids',
+'inbound_id',
+'inbound_numbers',
+```
+
+The last five legacy/forbidden keys must be attached to `palletExportForbidden`, with `inbound_numbers` included only to return `MCP_1401` when an old caller sends it. Do not expose it in the Gateway schema or accept it in Logic normalization.
+
+- [ ] **Step 2: Apply the same array and range rules to both new fields.**
+
+Keep the existing 1–200, string, non-empty, 100-character `palletNumberArray` behavior and attach it to both `container_codes` and `bl_numbers`. Update the XOR callback to evaluate:
+
+```php
+$hasContainer = is_array($data['container_codes'] ?? null)
+    && $data['container_codes'] !== [];
+$hasBl = is_array($data['bl_numbers'] ?? null)
+    && $data['bl_numbers'] !== [];
+$hasStart = trim((string)($data['inbound_time_start'] ?? '')) !== '';
+$hasEnd = trim((string)($data['inbound_time_end'] ?? '')) !== '';
+
+return !($hasContainer && $hasBl)
+    && !($hasContainer && ($hasStart || $hasEnd))
+    && !($hasBl && ($hasStart || $hasEnd))
+    && (($hasContainer || $hasBl) || ($hasStart && $hasEnd));
+```
+
+Retain the existing date parser and maximum 31-calendar-day rule.
+
+- [ ] **Step 3: Normalize only the new fields in Logic.**
+
+Replace `inbound_numbers` normalization with two independent de-duplicating loops for `container_codes` and `bl_numbers`. The normalized result must always contain both arrays, even when one is empty:
+
+```php
+[
+    'container_codes' => $containerCodes,
+    'bl_numbers' => $blNumbers,
+    'inbound_time_start' => ...,
+    'inbound_time_end' => ...,
+    'request_id' => ...,
+]
+```
+
+`buildExportParams()` must add only these normalized selectors plus trusted `company_id`, `timezone`, `operator`, `operator_id`, `is_super`, and `mcp_source_tool`; never copy caller-supplied identity fields.
+
+- [ ] **Step 4: Update Logic validation and model calls.**
+
+`validateFilters()` must enforce the same three-way XOR as Validate, including per-field 200/100 limits. `hasExportableData()` and `findPalletIds()` calls must receive both arrays and both time bounds. Keep the existing permission checks, no-data `MCP_1601`, async submission, task reference, and retry response unchanged.
+
+- [ ] **Step 5: Update audit allowlisting in the controller.**
+
+In `exportPalletData()`, replace the old log-field list with:
+
+```php
+[
+    'container_codes',
+    'bl_numbers',
+    'inbound_time_start',
+    'inbound_time_end',
+]
+```
+
+The route, route code, permission checks, response envelope, and task-source mapping remain unchanged.
+
+- [ ] **Step 6: Run the focused PHP tests.**
+
+Run:
+
+```bat
+bin\test.bat fmsoperate --filter McpPalletDataExport
+```
+
+Expected result: Validate and Logic tests pass; model and worker tests remain red until Tasks 5 and 6 are complete.
+
+## Task 5: Implement separate model lookup branches and service persistence
+
+**Files:**
+
+- Modify: `Y:/fmsoperate/app/mcp/model/McpPalletDataExportModel.php`
+- Modify: `Y:/fmsoperate/app/mcp/logic/McpPalletDataExportService.php`
+
+- [ ] **Step 1: Change the model method signatures.**
+
+Use this argument order consistently:
+
+```php
+public function hasExportableData(
+    $companyId,
+    array $containerCodes,
+    array $blNumbers,
+    $timeStart,
+    $timeEnd
+)
+
+public function findPalletIds(
+    $companyId,
+    array $containerCodes,
+    array $blNumbers,
+    $timeStart,
+    $timeEnd
+)
+```
+
+- [ ] **Step 2: Add the container-code branch.**
+
+Add `findInboundIdsByContainerCodes($companyId, array $codes)` using one query that:
+
+```php
+Db::table('fms_destination_inbound')->alias('di')
+    ->join(['fms_outbound' => 'o'], 'di.outbound_id = o.id')
+    ->where('di.company_id', (int)$companyId)
+    ->where('di.is_delete', 0)
+    ->where('o.container_code', 'in', $codes)
+    ->field('di.id')
+    ->group('di.id')
+```
+
+Return integer inbound IDs. Do not loop over the 200 values.
+
+- [ ] **Step 3: Add the bill-of-lading branch.**
+
+Add `findInboundIdsByBlNumbers($companyId, array $numbers)` using:
+
+```php
+Db::table('fms_destination_inbound')->alias('di')
+    ->join(['fms_outbound' => 'o'], 'di.outbound_id = o.id')
+    ->join(['fms_booking_detail' => 'bd'], 'o.booking_detail_id = bd.id')
+    ->where('di.company_id', (int)$companyId)
+    ->where('di.is_delete', 0)
+    ->where('bd.bl_number', 'in', $numbers)
+    ->field('di.id')
+    ->group('di.id')
+```
+
+Return integer inbound IDs and keep the branch separate from the container query. Do not use `where('o.container_code|bd.bl_number', ...)`.
+
+- [ ] **Step 4: Select exactly one branch in `findPalletIds()`.**
+
+Use this branch order:
+
+```php
+if ($containerCodes !== []) {
+    $inboundIds = $this->findInboundIdsByContainerCodes($companyId, $containerCodes);
+} elseif ($blNumbers !== []) {
+    $inboundIds = $this->findInboundIdsByBlNumbers($companyId, $blNumbers);
+} else {
+    $inboundIds = $this->findInboundIdsByTime($companyId, $timeStart, $timeEnd);
+}
+```
+
+Keep the current inbound ID → order ID → pallet ID stages, company condition on pallet batches, and no `COUNT` behavior.
+
+- [ ] **Step 5: Pass new selectors from the async Service.**
+
+Replace the old Service call with:
+
+```php
+$palletIds = $this->model->findPalletIds(
+    $companyId,
+    is_array($params['container_codes'] ?? null)
+        ? $params['container_codes'] : [],
+    is_array($params['bl_numbers'] ?? null)
+        ? $params['bl_numbers'] : [],
+    (string)($params['inbound_time_start'] ?? ''),
+    (string)($params['inbound_time_end'] ?? '')
+);
+```
+
+Do not change Excel headers, row grouping, attachment download behavior, zip layout, uploader behavior, or temporary-file cleanup.
+
+- [ ] **Step 6: Run PHP tests and syntax checks.**
+
+Run:
+
+```bat
+bin\test.bat fmsoperate --filter McpPalletDataExport
+php -l Y:\fmsoperate\app\mcp\validate\McpToolValidate.php
+php -l Y:\fmsoperate\app\mcp\logic\McpPalletDataExportLogic.php
+php -l Y:\fmsoperate\app\mcp\model\McpPalletDataExportModel.php
+php -l Y:\fmsoperate\app\mcp\logic\McpPalletDataExportService.php
+php -l Y:\fmsoperate\app\mcp\controller\McpToolsController.php
+```
+
+Expected result: focused tests pass and every changed PHP file reports no syntax errors.
+
+## Task 6: Tighten queued-task validation and consumer regression tests
+
+**Files:**
+
+- Modify: `Y:/fmsoperate/app/job/think/handle/inside/McpPalletDataExport.php`
+- Modify: `Y:/settlement_tests/tests/Unit/FmsOperate/McpPalletDataExportJobTest.php`
+
+- [ ] **Step 1: Validate the persisted selector contract before starting.**
+
+In `validatedParams()`, reject any task containing `inbound_numbers`. Accept exactly one of:
+
+```php
+$containerCodes = $params['container_codes'] ?? [];
+$blNumbers = $params['bl_numbers'] ?? [];
+$hasContainer = is_array($containerCodes) && $containerCodes !== [];
+$hasBl = is_array($blNumbers) && $blNumbers !== [];
+$hasTime = trim((string)($params['inbound_time_start'] ?? '')) !== ''
+    && trim((string)($params['inbound_time_end'] ?? '')) !== '';
+
+if (
+    ($hasContainer && $hasBl)
+    || (($hasContainer || $hasBl) && $hasTime)
+    || (!$hasContainer && !$hasBl && !$hasTime)
+) {
+    return false;
+}
+```
+
+Also require each new array to contain strings with 1–100-character trimmed values and at most 200 entries, so a manually altered task cannot bypass the HTTP validator.
+
+- [ ] **Step 2: Preserve worker identity and terminal-state behavior.**
+
+Do not change message/company/operator matching, atomic queued-task start, exporter invocation, completed file mapping, failed-state update, or exception rethrow. Only the persisted selector validation changes.
+
+- [ ] **Step 3: Update and extend job tests.**
+
+Keep the existing success, identity mismatch, and exception tests, replacing old task JSON with `container_codes`. Add a bill-of-lading success fixture and a legacy `inbound_numbers` fixture that never reaches the exporter and ends in status `3`.
+
+- [ ] **Step 4: Run the job-focused test.**
+
+Run:
+
+```bat
+bin\test.bat fmsoperate --filter McpPalletDataExportJob
+```
+
+Expected result: all worker identity, success, failure, and legacy-selector tests pass.
+
+## Task 7: Update current documentation and SQL registration description
+
+**Files:**
+
+- Modify: `Y:/mcp/README.md`
+- Modify: `Y:/mcp/CONTEXT.md`
+- Modify: `Y:/all_project_docs/fmsoperate/requirements.md`
+- Modify: `Y:/all_project_docs/fmsoperate/tech-specs.md`
+- Modify: `Y:/all_project_docs/fmsoperate/user-structure.md`
+- Modify: `Y:/all_project_docs/fmsoperate/timeline.md`
+- Modify: `Y:/all_project_docs/fmsoperate/sql/mcp_add_pallet_data_export_tool.sql`
+- Modify: `Y:/all_project_docs/mcp/overview.md`
+- Modify: `Y:/all_project_docs/mcp/requirements.md`
+- Modify: `Y:/all_project_docs/mcp/tech-specs.md`
+- Modify: `Y:/all_project_docs/mcp/user-structure.md`
+- Modify: `Y:/all_project_docs/mcp/timeline.md`
+- Modify: `Y:/settlement_tests/docs/manual/mcp-async-export-workbuddy-cases.md`
+
+- [ ] **Step 1: Replace the fmsoperate business contract.**
+
+In the `export_pallet_data` sections, state that the selector is exactly one of:
+
+```text
+container_codes(柜号数组,1–200)
+bl_numbers(提单号数组,1–200)
+inbound_time_start + inbound_time_end(最多31个日历日)
+```
+
+State that each number is at most 100 characters, container and bill-of-lading arrays cannot coexist, neither can mix with time, and `inbound_numbers`/internal IDs/order numbers/pallet-batch numbers are unsupported. Preserve the existing company isolation, permissions, async task, output, attachment, and `MCP_1601` behavior.
+
+- [ ] **Step 2: Replace Gateway user-facing guidance.**
+
+Update the Gateway README and central MCP documents to tell the caller:
+
+1. Ask whether a supplied number is a cabinet/container number or a bill-of-lading number when the user has not said.
+2. Do not infer from formatting or try both fields.
+3. Use only one number type with up to 200 values, or use the time range.
+4. After submission, query the task separately with `query_export_task`.
+
+Remove statements saying the tool accepts an inbound number.
+
+- [ ] **Step 3: Update current context and acceptance manual.**
+
+Replace stale terminology in `Y:/mcp/CONTEXT.md`. Add pallet export placeholders for `<有权柜号>` and `<有权提单号>` to the manual, then add cases for:
+
+- successful container-number submission;
+- successful bill-of-lading submission;
+- ambiguous number requiring clarification;
+- container and bill-of-lading arrays mixed and rejected;
+- 201 values rejected;
+- number mixed with time rejected;
+- async task follow-up unchanged.
+
+Do not add real credentials, URLs, task references, or customer data.
+
+- [ ] **Step 4: Update the SQL registration description only.**
+
+Change the `st_mcp_tool_registry.description` text in `mcp_add_pallet_data_export_tool.sql` from inbound-number/time wording to container-number/bill-of-lading/time wording. Keep status defaults and the idempotent `ON DUPLICATE KEY UPDATE` behavior unchanged. Do not run the SQL or change the execution status in `sql/README.md`.
+
+- [ ] **Step 5: Check document consistency.**
+
+Search all affected documentation for the old active contract:
+
+```powershell
+rg -n "入仓单号数组|入仓单号或海外仓入库时间|inbound_numbers" Y:\mcp Y:\all_project_docs\fmsoperate Y:\all_project_docs\mcp Y:\settlement_tests\docs
+```
+
+Remaining matches must be limited to the design/implementation history explicitly saying that the old field is removed or rejected; no current usage instructions may tell callers to send it.
+
+## Task 8: Full verification and handoff
+
+**Files:** all changed files from Tasks 1–7.
+
+- [ ] **Step 1: Run all Gateway unit tests.**
+
+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
+```
+
+Expected result: all tests pass and the strict coverage threshold is satisfied. Do not claim coverage success without the actual report.
+
+- [ ] **Step 2: Run fmsoperate contract tests.**
+
+From `Y:/settlement_tests`:
+
+```bat
+bin\test.bat fmsoperate --filter McpPalletDataExport
+```
+
+Then run the full fmsoperate suite:
+
+```bat
+bin\test.bat fmsoperate
+```
+
+Expected result: both the focused pallet-export tests and the full fmsoperate test configuration pass.
+
+- [ ] **Step 3: Run PHP syntax checks for every changed PHP file.**
+
+Run `php -l` for:
+
+```text
+Y:/fmsoperate/app/mcp/validate/McpToolValidate.php
+Y:/fmsoperate/app/mcp/logic/McpPalletDataExportLogic.php
+Y:/fmsoperate/app/mcp/model/McpPalletDataExportModel.php
+Y:/fmsoperate/app/mcp/logic/McpPalletDataExportService.php
+Y:/fmsoperate/app/mcp/controller/McpToolsController.php
+Y:/fmsoperate/app/job/think/handle/inside/McpPalletDataExport.php
+```
+
+- [ ] **Step 4: Run diff and status checks without altering unrelated work.**
+
+Run from each repository:
+
+```powershell
+git diff --check
+git status --short
+```
+
+Run the same checks from `Y:/all_project_docs`. Review that only the requested files and the approved design/plan are changed; do not reset, checkout, clean, commit, execute SQL, or restart services.
+
+- [ ] **Step 5: Review the final contract manually.**
+
+Confirm all of the following from code and tests:
+
+- Gateway metadata and PHP Validate both reject the old inbound-number selector.
+- Container and bill-of-lading selectors are separate and mutually exclusive.
+- Time remains a third mutually exclusive selector.
+- 200 values are handled in one batch without per-number queries.
+- Container lookup uses `fms_outbound.container_code`.
+- Bill-of-lading lookup uses `fms_booking_detail.bl_number`.
+- Company isolation, permission checks, task references, async queue, 15-column output, attachment behavior, and safe task status responses are unchanged.

+ 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;注册表状态由管理员保留,代码回滚期间关闭相关工具即可。

+ 115 - 0
docs/superpowers/specs/2026-09-08-export-pallet-data-identifier-design.md

@@ -0,0 +1,115 @@
+# `export_pallet_data` 号码筛选调整设计
+
+**日期:** 2026-09-08  
+**范围:** `mcp` Gateway、`fmsoperate` MCP 业务链路、`settlement_tests` 合同测试及集中项目文档
+
+## 1. 背景与目标
+
+当前 `export_pallet_data` 使用“入仓单号数组”或“海外仓入库时间起止”二选一。此次调整移除入仓单号,增加两类明确的物流号码筛选:
+
+- 柜号数组 `container_codes`
+- 提单号数组 `bl_numbers`
+
+入库时间筛选继续保留。调用方必须明确号码类型;只给出号码但未说明是柜号还是提单号时,Gateway 的工具说明必须引导用户先确认,不能按格式猜测、并行试查或跨字段重试。
+
+## 2. 输入合同
+
+`export_pallet_data` 的输入为以下三种筛选方式严格三选一:
+
+1. `container_codes`
+2. `bl_numbers`
+3. `inbound_time_start` + `inbound_time_end`
+
+号码数组先去除首尾空白、空值和重复值,再校验:
+
+- 至少 1 个,最多 200 个;
+- 每项必须是字符串;
+- 每项长度不超过 100 个字符。
+
+时间边界继续接受 `YYYY-MM-DD` 或 `YYYY-MM-DD HH:MM:SS`。只填日期时,开始边界展开为当天 `00:00:00`,结束边界展开为当天 `23:59:59`;起止日期部分差不超过 30 天,即最多 31 个日历日。
+
+以下输入必须拒绝:
+
+- `container_codes` 与 `bl_numbers` 同时非空;
+- 任一号码数组与时间范围混用;
+- 只提供一侧时间边界;
+- 三种筛选方式均未提供;
+- 使用 `inbound_numbers`、内部 ID、订单号或打托批次号。
+
+参数错误沿用 `MCP_1401` 安全错误,不创建下载任务。
+
+## 3. 调用链与数据范围
+
+### Gateway
+
+- 工具 Schema 只暴露 `container_codes`、`bl_numbers`、`inbound_time_start`、`inbound_time_end`。
+- 保持 `additionalProperties=false`,不保留 `inbound_numbers` 兼容别名。
+- CLI 增加柜号和提单号数组参数,移除入仓单号 CLI 参数。
+- 工具描述明确“柜号 / 提单号 / 入库时间三选一”,并写明号码类型不明确时必须先询问。
+- 本地与公网工具注册、路由、异步任务展示合同不变。
+
+### fmsoperate
+
+`McpPalletDataExportLogic` 负责归一化、三选一校验、权限检查和任务提交;`McpPalletDataExportModel` 只负责取数:
+
+- 柜号筛选只按当前公司的海外仓关联排舱记录匹配 `fms_outbound.container_code`;
+- 提单号筛选只按当前公司的海外仓关联排舱记录匹配 `fms_booking_detail.bl_number`;
+- 时间筛选继续按当前公司、未删除的海外仓入库明细 `inbound_time` 闭区间匹配;
+- 所有路径最终都沿用“海外仓入库记录 ID → 订单 ID → 打托批次 ID”的批量解析链路;
+- 结果只允许当前 `company_id` 的打托批次,原有海外仓菜单和导出菜单权限不变。
+
+柜号和提单号必须在不同的查询分支中使用索引友好的 `IN` 条件,不能合并成跨字段 `OR`。不按号码逐个查询,不增加无必要的总数查询。
+
+队列任务中的筛选白名单、消费者参数校验和导出 Service 入参同步改为新合同;新接口不再接受或重新解释 `inbound_numbers`。
+
+## 4. 输出与异步行为
+
+以下行为保持不变:
+
+- 仍只提交异步任务并立即返回签名 `task_ref`;
+- 仍投递 `fmsoperate:inside` 的独立 `McpPalletDataExport` 消费者;
+- 仍生成后台同口径的 15 列 Excel;
+- 有成功下载的附件时按 `pallet_batch` 分目录生成 zip;
+- 单个 OSS 附件失败时跳过该附件,全部附件失败时仍只上传 Excel;
+- 通过 `query_export_task` 查询任务状态和下载链接;
+- 没有匹配的打托批次返回 `MCP_1601`,不创建空任务。
+
+## 5. 性能与安全约束
+
+- 200 个号码一次性批量传入并在数据库层 `IN` 处理,禁止 PHP 循环逐号查库。
+- 柜号和提单号使用独立查询分支,防止跨字段 OR 造成不可控扫描。
+- 保留当前的分阶段 ID 查询和批量补齐,不回退到逐订单查询。
+- 查询和队列消费均重新绑定公司、员工和任务来源身份,不信任请求传入的身份字段。
+- 不在公共响应、工具说明或日志中暴露 SQL、内部 ID、异常栈或任务内部信息。
+
+## 6. 测试计划
+
+### Gateway Python
+
+- Schema 只包含新字段,拒绝 `inbound_numbers`、内部 ID 和分页字段;
+- 柜号数组、提单号数组可正常去重并转发;
+- 柜号与提单号混用、号码与时间混用、空筛选、单侧时间、超过 200 个或单项超长均拒绝;
+- 时间最多 31 个日历日,日期边界展开保持现有行为;
+- 工具描述覆盖明确导出、号码类型询问、禁止猜测和异步查询;
+- CLI 新参数正确转发,本地/公网注册仍一致。
+
+### fmsoperate PHP
+
+- Validate、Logic、Model 和队列消费者全部使用新筛选字段;
+- 覆盖三选一、200 上限、每项长度、时间范围和禁止旧参数;
+- 覆盖权限失败、无可导出数据不建任务、可信公司/员工身份和异步任务引用;
+- 覆盖柜号/提单号查询只使用对应字段,不产生跨字段 OR 或逐号查询;
+- 保留公司隔离、15 列产物、独立消费者和任务终态安全合同。
+
+### 文档与交付验证
+
+- 运行 Python 全量单测及严格覆盖率检查;
+- 运行 `settlement_tests` 的 fmsoperate 定向合同测试和 PHP 语法检查;
+- 运行受影响仓库的 `git diff --check`、Markdown 链接检查和最终状态检查;
+- 不执行注册 SQL,不重启 Gateway 或 Worker,不据代码变更推断目标环境已上线。
+
+## 7. 非目标
+
+- 不修改后台海外仓页面、原有后台导出路由或打托文件列定义。
+- 不改变任务签名、任务去重、权限菜单、公司隔离和附件处理策略。
+- 不增加柜号/提单号模糊匹配、格式推断或自动试查。

+ 364 - 41
mcp_protocol.py

@@ -1,19 +1,46 @@
 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):
+    @staticmethod
+    def server_capabilities():
+        return {
+            'tools': {
+                'listChanged': False,
+            },
+            'resources': {
+                'listChanged': False,
+            },
+        }
+
+    def __init__(self, gateway_app, reporter=None):
         self.gateway_app = gateway_app
+        self.reporter = reporter or NullDiagnosticReporter()
         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,18 +52,36 @@ 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,
                     {
                         'protocolVersion': self.protocol_version,
-                        'capabilities': {
-                            'tools': {
-                                'listChanged': False,
-                            }
-                        },
+                        'capabilities': self.server_capabilities(),
                         'serverInfo': {
                             'name': self.server_name,
                             'version': self.server_version,
@@ -44,43 +89,208 @@ 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':
+            if method == 'resources/list':
+                emitter.emit(
+                    stage='protocol_validation',
+                    status='succeeded',
+                    event_code='PROTOCOL_VALIDATION_COMPLETED',
+                    context={
+                        'jsonrpc_method': method,
+                        'transport': 'stdio',
+                    },
+                )
+                resources = []
+                if hasattr(self.gateway_app, 'list_resources'):
+                    resources = list(self.gateway_app.list_resources() or [])
+                return self._success_response(
+                    request_id,
+                    {'resources': resources},
+                )
+            if method == 'resources/read':
                 params = request.get('params') or {}
-                tool_name = params.get('name')
-                arguments = params.get('arguments') or {}
-                tool_result = self.gateway_app.call_tool(tool_name, arguments)
-                structured_content = tool_result.get('data') or {}
+                uri = ''
+                if isinstance(params, dict):
+                    uri = str(params.get('uri') or '').strip()
+                emitter.emit(
+                    stage='protocol_validation',
+                    status='succeeded',
+                    event_code='PROTOCOL_VALIDATION_COMPLETED',
+                    context={
+                        'jsonrpc_method': method,
+                        'transport': 'stdio',
+                    },
+                )
+                resource = None
+                if uri and hasattr(self.gateway_app, 'read_resource'):
+                    resource = self.gateway_app.read_resource(uri)
+                if not isinstance(resource, dict):
+                    return self._error_response(
+                        request_id,
+                        -32602,
+                        'Invalid params',
+                        trace_request_id,
+                    )
                 return self._success_response(
                     request_id,
-                    {
-                        'content': [
-                            {
-                                'type': 'text',
-                                'text': self._render_text(structured_content),
-                            }
-                        ],
-                        'structuredContent': structured_content,
-                        'isError': False,
+                    {'contents': [resource]},
+                )
+            if method == 'tools/call':
+                params = request.get('params') or {}
+                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 {}
+                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 +302,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 +323,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 +521,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,
+        }

+ 380 - 11
public_gateway.py

@@ -1,9 +1,75 @@
 import logging
+import time
 import uuid
 
 from constants import DEVICE_INVALID_MESSAGE
+from tools.list_order_filter_options import ListOrderFilterOptionsTool
+from tools.list_customer_filter_options import ListCustomerFilterOptionsTool
+from tools.list_receivable_cost_filter_options import (
+    ListReceivableCostFilterOptionsTool,
+)
+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.export_receivable_cost_list import ExportReceivableCostListTool
+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 tools.query_customer_list import QueryCustomerListTool
+from tools.query_customer_payment_followup import QueryCustomerPaymentFollowupTool
+from tools.query_customer_unverified_bill_details import QueryCustomerUnverifiedBillDetailsTool
+from tools.query_customer_payment_records import QueryCustomerPaymentRecordsTool
+from tools.query_order_receivable_cost_details import (
+    QueryOrderReceivableCostDetailsTool,
+)
+from tools.query_receivable_cost_list import QueryReceivableCostListTool
+from tools.query_payable_cost_list import QueryPayableCostListTool
+from tools.list_payable_cost_filter_options import ListPayableCostFilterOptionsTool
+from tools.export_payable_cost_list import ExportPayableCostListTool
+from tools.export_pallet_data import ExportPalletDataTool
+from tools.query_destination_trailer_list import QueryDestinationTrailerListTool
+from tools.arrange_destination_trailer import ArrangeDestinationTrailerTool
+from tools.list_destination_trailer_filter_options import (
+    ListDestinationTrailerFilterOptionsTool,
+)
+from tools.query_order_abnormal_list import QueryOrderAbnormalListTool
+from tools.list_order_abnormal_filter_options import (
+    ListOrderAbnormalFilterOptionsTool,
+)
+from tools.query_goods_info import QueryGoodsInfoTool
+from tools.trial_goods_tax import TrialGoodsTaxTool
+from tools.query_receive_volume_list import QueryReceiveVolumeListTool
+from tools.list_receive_volume_filter_options import (
+    ListReceiveVolumeFilterOptionsTool,
+)
+from tools.query_container_timeliness_list import QueryContainerTimelinessListTool
+from tools.export_container_timeliness_report import (
+    ExportContainerTimelinessReportTool,
+)
+from tools.query_headhaul_document_list import QueryHeadhaulDocumentListTool
+from tools.list_headhaul_document_filter_options import (
+    ListHeadhaulDocumentFilterOptionsTool,
+)
+from tools.prepare_headhaul_document_upload import (
+    PrepareHeadhaulDocumentUploadTool,
+)
+from tools.save_headhaul_document import SaveHeadhaulDocumentTool
+from tools.delete_headhaul_document import DeleteHeadhaulDocumentTool
+from tools.list_inbound_receipt_templates import ListInboundReceiptTemplatesTool
+from tools.export_inbound_receipt import ExportInboundReceiptTool
+from tools.query_freightower_track import QueryFreightowerTrackTool
 from utils.security import hash_gateway_session_id
 
 
@@ -17,43 +83,346 @@ 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),
+            'query_customer_list': QueryCustomerListTool(api_client=None),
+            'query_customer_payment_followup': QueryCustomerPaymentFollowupTool(
+                api_client=None
+            ),
+            'query_customer_unverified_bill_details':
+                QueryCustomerUnverifiedBillDetailsTool(api_client=None),
+            'query_customer_payment_records': QueryCustomerPaymentRecordsTool(
+                api_client=None
+            ),
+            'query_order_receivable_cost_details':
+                QueryOrderReceivableCostDetailsTool(api_client=None),
+            'query_receivable_cost_list':
+                QueryReceivableCostListTool(api_client=None),
+            'query_payable_cost_list':
+                QueryPayableCostListTool(api_client=None),
+            'list_outbound_filter_options': ListOutboundFilterOptionsTool(
+                api_client=None
+            ),
+            'list_order_filter_options': ListOrderFilterOptionsTool(
+                api_client=None
+            ),
+            'list_customer_filter_options': ListCustomerFilterOptionsTool(
+                api_client=None
+            ),
+            'list_receivable_cost_filter_options':
+                ListReceivableCostFilterOptionsTool(api_client=None),
+            'list_payable_cost_filter_options':
+                ListPayableCostFilterOptionsTool(api_client=None),
+            'export_pending_outbound_orders': ExportPendingOutboundOrdersTool(
+                api_client=None
+            ),
+            'export_out_of_province_port_data':
+                ExportOutOfProvincePortDataTool(api_client=None),
+            'export_receivable_cost_list': ExportReceivableCostListTool(
+                api_client=None
+            ),
+            'export_payable_cost_list': ExportPayableCostListTool(
+                api_client=None
+            ),
+            'export_pallet_data': ExportPalletDataTool(
+                api_client=None
+            ),
+            'query_export_task': QueryExportTaskTool(api_client=None),
+            'list_pending_outbound_export_filter_options':
+                ListPendingOutboundExportFilterOptionsTool(api_client=None),
+            'query_destination_trailer_list': QueryDestinationTrailerListTool(
+                api_client=None
+            ),
+            'arrange_destination_trailer': ArrangeDestinationTrailerTool(
+                api_client=None
+            ),
+            'list_destination_trailer_filter_options':
+                ListDestinationTrailerFilterOptionsTool(api_client=None),
+            'query_order_abnormal_list': QueryOrderAbnormalListTool(
+                api_client=None
+            ),
+            'list_order_abnormal_filter_options':
+                ListOrderAbnormalFilterOptionsTool(api_client=None),
+            'query_receive_volume_list': QueryReceiveVolumeListTool(
+                api_client=None
+            ),
+            'query_goods_info': QueryGoodsInfoTool(api_client=None),
+            'trial_goods_tax': TrialGoodsTaxTool(api_client=None),
+            'list_receive_volume_filter_options':
+                ListReceiveVolumeFilterOptionsTool(api_client=None),
+            'query_container_timeliness_list': QueryContainerTimelinessListTool(
+                api_client=None
+            ),
+            'export_container_timeliness_report':
+                ExportContainerTimelinessReportTool(api_client=None),
+            'query_headhaul_document_list': QueryHeadhaulDocumentListTool(
+                api_client=None
+            ),
+            'list_headhaul_document_filter_options':
+                ListHeadhaulDocumentFilterOptionsTool(api_client=None),
+            'prepare_headhaul_document_upload':
+                PrepareHeadhaulDocumentUploadTool(api_client=None),
+            'save_headhaul_document': SaveHeadhaulDocumentTool(
+                api_client=None
+            ),
+            'delete_headhaul_document': DeleteHeadhaulDocumentTool(
+                api_client=None
+            ),
+            'list_inbound_receipt_templates': ListInboundReceiptTemplatesTool(
+                api_client=None
+            ),
+            'export_inbound_receipt': ExportInboundReceiptTool(
+                api_client=None
+            ),
+            'query_freightower_track': QueryFreightowerTrackTool(api_client=None),
         }
+        from services.headhaul_document_app import HeadhaulSlotMemory
+        self._headhaul_slots = HeadhaulSlotMemory()
 
-    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)
+        if hasattr(self.session_store, 'touch_session'):
+            self.session_store.touch_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 list_resources(self):
+        return []
+
+    def remember_headhaul_slot(self, gateway_session_id, token):
+        self._headhaul_slots.remember(gateway_session_id, token)
+
+    def session_for_headhaul_token(self, token):
+        return self._headhaul_slots.session_for_token(token)
+
+    def read_resource(self, uri, gateway_session_id=''):
+        return None
+
+    def upload_headhaul_document(
+        self,
+        gateway_session_id,
+        upload_token,
+        filename,
+        file_bytes,
+        content_type='',
+        request_id='',
+        client_ip='',
+    ):
+        session = self._require_session(gateway_session_id)
+        request_id = self.build_request_id(request_id)
+        enabled = self._load_enabled_tool_names(
+            session['mcp_token'],
+            request_id,
+        )
+        if 'prepare_headhaul_document_upload' not in enabled:
+            raise RuntimeError('tool disabled: prepare_headhaul_document_upload')
+        if not hasattr(self.api_client, 'upload_headhaul_document'):
+            raise RuntimeError('upload client unavailable')
+        return self.api_client.upload_headhaul_document(
+            token=session['mcp_token'],
+            upload_token=upload_token,
+            filename=filename,
+            file_bytes=file_bytes,
+            content_type=content_type,
+            request_id=request_id,
+            client_ip=client_ip,
+        )
 
     def build_request_id(self, request_id=''):
         request_id = str(request_id or '').strip()
         return request_id or 'rq_{0}'.format(uuid.uuid4().hex[:16])
 
-    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')}")
+            if name == 'prepare_headhaul_document_upload' and isinstance(result, dict):
+                data = result.get('data')
+                token = ''
+                if isinstance(data, dict):
+                    token = str(data.get('upload_token') or '').strip()
+                if token:
+                    self.remember_headhaul_slot(gateway_session_id, token)
+            logger.info(
+                'MCP public tool success',
+                extra={
+                    '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

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 881 - 50
public_server.py


BIN
services/__init__.pyc


+ 82 - 0
services/api_client.py

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

BIN
services/api_client.pyc


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

+ 6 - 3
services/gateway_session_store.py

@@ -5,10 +5,10 @@ from utils.security import hash_gateway_session_id
 
 
 class GatewaySessionStore:
-    def __init__(self, client, prefix='fms:mcp:gateway:', ttl_seconds=2592000):
+    def __init__(self, client, prefix='fms:mcp:gateway:', ttl_seconds=0):
         self.client = client
         self.prefix = str(prefix or 'fms:mcp:gateway:')
-        self.ttl_seconds = int(ttl_seconds or 2592000)
+        self.ttl_seconds = int(ttl_seconds or 0)
 
     def key_for(self, gateway_session_id):
         return self.prefix.rstrip(':') + ':session:' + hash_gateway_session_id(gateway_session_id)
@@ -16,10 +16,13 @@ class GatewaySessionStore:
     def save(self, gateway_session_id, session):
         payload = dict(session or {})
         payload['gateway_session_id_hash'] = hash_gateway_session_id(gateway_session_id)
+        set_kwargs = {}
+        if self.ttl_seconds > 0:
+            set_kwargs['ex'] = self.ttl_seconds
         self.client.set(
             self.key_for(gateway_session_id),
             json.dumps(payload, ensure_ascii=False),
-            ex=self.ttl_seconds,
+            **set_kwargs,
         )
         return payload
 

+ 42 - 0
services/headhaul_document_app.py

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

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 2937 - 0
services/output_presenter.py


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

BIN
services/scoped_api_client.pyc


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

+ 187 - 0
tests/test_arrange_destination_trailer_tool.py

@@ -0,0 +1,187 @@
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.arrange_destination_trailer import ArrangeDestinationTrailerTool
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def call_tool(self, name, route_path, payload, request_id):
+        self.calls.append((name, route_path, payload, request_id))
+        return {'code': 'MCP_0000', 'data': {}, 'meta': {'request_id': request_id}}
+
+
+class ArrangeDestinationTrailerToolTest(unittest.TestCase):
+    def test_metadata_rejects_unknown_fields(self):
+        schema = ArrangeDestinationTrailerTool().metadata()['input_schema']
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(['trailer_provider'], schema['required'])
+        self.assertNotIn('outbound_id', schema['properties'])
+
+    def test_call_requires_a_number_and_posts_the_route(self):
+        client = RecordingApiClient()
+        tool = ArrangeDestinationTrailerTool(api_client=client)
+        with self.assertRaises(ValueError):
+            tool.call(trailer_provider='甲拖车-A')
+        tool.call(trailer_provider='甲拖车-A', container_code='CONT1')
+        self.assertEqual('arrange_destination_trailer', client.calls[0][0])
+        self.assertEqual('/mcp/tools/arrangeDestinationTrailer', client.calls[0][1])
+        self.assertEqual(
+            {'trailer_provider': '甲拖车-A', 'container_code': 'CONT1'},
+            client.calls[0][2],
+        )
+
+    def test_presenter_returns_only_three_labels(self):
+        presenter = OutputPresenter()
+        result = presenter.present('arrange_destination_trailer', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_number': 'DET1',
+                'bl_number': 'BL1',
+                'container_code': 'CONT1',
+            },
+            'meta': {'request_id': 'rq1'},
+        })
+        self.assertEqual({
+            '拖车单号': 'DET1',
+            '提单号': 'BL1',
+            '柜号': 'CONT1',
+        }, result['structured_content'])
+        self.assertFalse(presenter.present('arrange_destination_trailer', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_number': 'DET1',
+                'bl_number': 'BL1',
+                'container_code': 'CONT1',
+                'providers_id': 1,
+            },
+            'meta': {'request_id': 'rq1'},
+        })['is_error'] is False)
+        self.assertTrue(presenter.present('arrange_destination_trailer', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_number': 1,
+                'bl_number': 'BL1',
+                'container_code': 'CONT1',
+            },
+            'meta': {'request_id': 'rq1'},
+        })['is_error'])
+
+    def test_call_rejects_bad_values_and_forwards_spoken_fields(self):
+        tool = ArrangeDestinationTrailerTool()
+        with self.assertRaises(RuntimeError):
+            tool.call(trailer_provider='甲拖车-A', container_code='CONT1')
+
+        client = RecordingApiClient()
+        tool = ArrangeDestinationTrailerTool(api_client=client)
+        with self.assertRaises(ValueError):
+            tool.call(trailer_provider='  ', container_code='CONT1')
+        with self.assertRaises(ValueError):
+            tool.call(trailer_provider='甲拖车-A', container_code=1)
+        with self.assertRaises(ValueError):
+            tool.call(trailer_provider='甲拖车-A', bl_number='BL1', is_direct_send='未知')
+        with self.assertRaises(ValueError):
+            tool.call(
+                trailer_provider='甲拖车-A',
+                bl_number='BL1',
+                est_pickup_time='2026-10-12',
+            )
+        with self.assertRaises(ValueError):
+            tool.call(
+                trailer_provider='甲拖车-A',
+                bl_number='BL1',
+                order_measures=[{'weight': '1'}],
+            )
+        with self.assertRaises(ValueError):
+            tool.call(
+                trailer_provider='甲拖车-A',
+                bl_number='BL1',
+                order_measures='1',
+            )
+        with self.assertRaises(ValueError):
+            tool.call(
+                trailer_provider='甲拖车-A',
+                bl_number='BL1',
+                order_measures=[1],
+            )
+        with self.assertRaises(ValueError):
+            tool.call(
+                trailer_provider='甲拖车-A',
+                bl_number='BL1',
+                order_measures=[{'order_number': 'SO1'}] * 501,
+            )
+        with self.assertRaises(ValueError):
+            tool.call(trailer_provider='甲' * 201, container_code='CONT1')
+        with self.assertRaises(ValueError):
+            tool.call(
+                trailer_provider='甲拖车-A',
+                bl_number='BL1',
+                direct_change_reason='其他',
+            )
+
+        tool.call(
+            trailer_provider=' 甲拖车-A ',
+            bl_number=' BL1 ',
+            container_code=' CONT1 ',
+            is_direct_send='否',
+            warehouse_code='LAX1',
+            warehouse_contact='张三',
+            warehouse_tel='10086',
+            follow_up_user='空',
+            est_pickup_time='2026-10-12 08:00:00',
+            is_direct_change='是',
+            direct_change_reason='直送爆仓被拒',
+            order_measures=[{
+                'order_number': 'SO1',
+                'weight': '1.5',
+                'volume': None,
+            }],
+            request_id='rq_case',
+        )
+        self.assertEqual({
+            'trailer_provider': '甲拖车-A',
+            'bl_number': 'BL1',
+            'container_code': 'CONT1',
+            'is_direct_send': '否',
+            'warehouse_code': 'LAX1',
+            'warehouse_contact': '张三',
+            'warehouse_tel': '10086',
+            'follow_up_user': '空',
+            'est_pickup_time': '2026-10-12 08:00:00',
+            'is_direct_change': '是',
+            'direct_change_reason': '直送爆仓被拒',
+            'order_measures': [{'order_number': 'SO1', 'weight': '1.5'}],
+        }, client.calls[-1][2])
+        self.assertEqual('rq_case', client.calls[-1][3])
+
+    def test_presenter_keeps_known_arrange_errors(self):
+        presenter = OutputPresenter()
+        result = presenter.present('arrange_destination_trailer', {
+            'code': 'MCP_1401',
+            'msg': '请填写订单O1的重量和体积',
+            'meta': {'request_id': 'rq1'},
+        })
+        self.assertEqual('请填写订单O1的重量和体积', result['structured_content']['message'])
+        self.assertFalse(result['structured_content']['retryable'])
+        hidden = presenter.present('arrange_destination_trailer', {
+            'code': 'MCP_1401',
+            'msg': 'SQLSTATE[42S02]',
+            'meta': {'request_id': 'rq1'},
+        })
+        self.assertEqual('工具参数不正确,请检查后重试', hidden['structured_content']['message'])
+
+    def test_registered_on_both_gateways(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertIn('arrange_destination_trailer', local)
+        self.assertEqual(40, len(local))
+        self.assertIn('arrange_destination_trailer', OutputPresenter.SAFE_TOOLS)
+
+
+if __name__ == '__main__':
+    unittest.main()

BIN
tests/test_auth_client.pyc


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

BIN
tests/test_cli_and_file_store.pyc


+ 177 - 0
tests/test_config_compat.py

@@ -83,6 +83,23 @@ class GatewayConfigCompatTest(unittest.TestCase):
         self.assertEqual(600, config.gateway_session_ttl_seconds)
         self.assertEqual('fms:mcp:gateway:', config.redis_prefix)
 
+    def test_public_gateway_session_ttl_defaults_to_zero(self):
+        config = GatewayConfig.from_env(env={
+            'FMS_API_BASE': 'https://base.example.com',
+            'FMS_GATEWAY_MODE': 'public',
+        }, dotenv_path='missing.env')
+
+        self.assertEqual(0, config.gateway_session_ttl_seconds)
+
+    def test_public_gateway_session_ttl_zero_is_preserved(self):
+        config = GatewayConfig.from_env(env={
+            'FMS_API_BASE': 'https://base.example.com',
+            'FMS_GATEWAY_MODE': 'public',
+            'FMS_GATEWAY_SESSION_TTL_SECONDS': '0',
+        }, dotenv_path='missing.env')
+
+        self.assertEqual(0, config.gateway_session_ttl_seconds)
+
     def test_public_gateway_mode_defaults_to_gateway_redis_prefix(self):
         config = GatewayConfig.from_env(env={
             'FMS_API_BASE': 'https://base.example.com',
@@ -92,6 +109,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 +161,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()

+ 306 - 0
tests/test_container_timeliness_tools.py

@@ -0,0 +1,306 @@
+import io
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.query_container_timeliness_list import (
+    COLUMNS,
+    QueryContainerTimelinessListTool,
+)
+from tools.export_container_timeliness_report import (
+    ExportContainerTimelinessReportTool,
+)
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_container_timeliness_list',
+                    'export_container_timeliness_report',
+                ],
+            },
+        }
+
+    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 ContainerTimelinessToolContractTest(unittest.TestCase):
+    def test_query_schema_is_closed_without_identity_or_page_filters(self):
+        metadata = QueryContainerTimelinessListTool().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('query_container_timeliness_list', metadata['name'])
+        self.assertEqual(
+            '/mcp/tools/queryContainerTimelinessList',
+            QueryContainerTimelinessListTool.route_path,
+        )
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual([], schema['required'])
+        self.assertEqual(
+            {
+                'container_codes',
+                'bl_numbers',
+                'departure_time_start',
+                'departure_time_end',
+                'page',
+                'limit',
+            },
+            set(schema['properties']),
+        )
+        for forbidden in (
+            'company_id', 'admin_id', 'is_super', 'outbound_numbers',
+            'ship_company', 'loading_id',
+        ):
+            self.assertNotIn(forbidden, schema['properties'])
+        description = metadata['description']
+        self.assertIn('使用场景:', description)
+        self.assertIn('禁止使用:', description)
+        self.assertIn('admin/Report/outboundReport', description)
+        self.assertIn('query_outbound_list', description)
+        self.assertIn('干线实际出发时间', description)
+        self.assertIn('54列', description)
+        self.assertIn('号码类型不明确时必须先询问用户', description)
+        self.assertIn('不得根据号码格式猜测', description)
+        self.assertIn('不得跨字段或跨工具试查', description)
+
+    def test_query_forwards_numbers_and_optional_departure_window(self):
+        client = RecordingApiClient()
+        QueryContainerTimelinessListTool(client).call(
+            container_codes=['CONT-1', ' CONT-1 '],
+            bl_numbers=['BL-1'],
+            departure_time_start='2026-09-01',
+            departure_time_end='2026-09-30',
+            page=2,
+            limit=10,
+        )
+        self.assertEqual(
+            (
+                'query_container_timeliness_list',
+                '/mcp/tools/queryContainerTimelinessList',
+                {
+                    'container_codes': ['CONT-1'],
+                    'bl_numbers': ['BL-1'],
+                    'departure_time_start': '2026-09-01',
+                    'departure_time_end': '2026-09-30',
+                    'page': 2,
+                    'limit': 10,
+                },
+                'rq_query_container_timeliness_list',
+            ),
+            client.calls[-1],
+        )
+
+    def test_query_rejects_empty_and_oversize_window(self):
+        tool = QueryContainerTimelinessListTool(RecordingApiClient())
+        with self.assertRaisesRegex(ValueError, 'provide'):
+            tool.call()
+        with self.assertRaisesRegex(ValueError, 'departure time range requires both'):
+            tool.call(departure_time_start='2026-09-01')
+        with self.assertRaisesRegex(ValueError, 'departure time window'):
+            tool.call(
+                departure_time_start='2026-08-01',
+                departure_time_end='2026-09-01',
+            )
+        with self.assertRaisesRegex(ValueError, 'is invalid'):
+            tool.call(departure_time_start='09/01/2026', departure_time_end='2026-09-02')
+        with self.assertRaisesRegex(ValueError, 'at most 200'):
+            tool.call(
+                container_codes=['C1'],
+                bl_numbers=['B{0}'.format(i) for i in range(200)],
+            )
+        with self.assertRaisesRegex(ValueError, 'non-empty'):
+            tool.call(container_codes=[])
+        with self.assertRaisesRegex(ValueError, 'strings'):
+            tool.call(container_codes=[1])
+        with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
+            tool.call(container_codes=[''])
+        with self.assertRaisesRegex(ValueError, 'at most 200 container_codes'):
+            tool.call(container_codes=['C{0}'.format(i) for i in range(201)])
+        with self.assertRaisesRegex(ValueError, 'page is invalid'):
+            tool.call(container_codes=['CONT-1'], page=True)
+        with self.assertRaisesRegex(ValueError, 'page is invalid'):
+            tool.call(container_codes=['CONT-1'], page=0)
+        with self.assertRaisesRegex(ValueError, 'limit is invalid'):
+            tool.call(container_codes=['CONT-1'], limit='20')
+        with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
+            tool.call(container_codes=['x' * 101])
+        with self.assertRaisesRegex(ValueError, 'departure time window'):
+            tool.call(
+                departure_time_start='2026-09-30',
+                departure_time_end='2026-09-01',
+            )
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            QueryContainerTimelinessListTool().call(container_codes=['CONT-1'])
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            ExportContainerTimelinessReportTool().call(container_codes=['CONT-1'])
+
+    def test_export_schema_forbids_pagination_and_requires_async_follow_up(self):
+        metadata = ExportContainerTimelinessReportTool().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('export_container_timeliness_report', metadata['name'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(
+            {
+                'container_codes',
+                'bl_numbers',
+                'departure_time_start',
+                'departure_time_end',
+            },
+            set(schema['properties']),
+        )
+        self.assertNotIn('page', schema['properties'])
+        description = metadata['description']
+        self.assertIn('明确要求导出', description)
+        self.assertIn('柜子时效', description)
+        self.assertIn('异步导出任务', description)
+        self.assertIn('query_export_task', description)
+        self.assertIn('不会在本次调用中等待文件生成', description)
+        self.assertIn('query_container_timeliness_list', description)
+
+    def test_export_forwards_locator_without_page(self):
+        client = RecordingApiClient()
+        ExportContainerTimelinessReportTool(client).call(
+            bl_numbers=['BL-1'],
+        )
+        self.assertEqual(
+            {
+                'bl_numbers': ['BL-1'],
+            },
+            client.calls[-1][2],
+        )
+        self.assertNotIn('page', client.calls[-1][2])
+
+    def test_cli_forwards_query_and_export_filters(self):
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        code = app.run_cli([
+            'call', '--tool', 'query_container_timeliness_list',
+            '--container-codes', 'CONT-1',
+            '--departure-time-start', '2026-09-01',
+            '--departure-time-end', '2026-09-14',
+            '--page', '2',
+            '--limit', '10',
+        ], stdout=io.StringIO())
+        self.assertEqual(0, code)
+        self.assertEqual(
+            {
+                'container_codes': ['CONT-1'],
+                'departure_time_start': '2026-09-01',
+                'departure_time_end': '2026-09-14',
+                'page': 2,
+                'limit': 10,
+            },
+            client.calls[-1][2],
+        )
+
+        code = app.run_cli([
+            'call', '--tool', 'export_container_timeliness_report',
+            '--bl-numbers', 'BL-1, BL-2',
+        ], stdout=io.StringIO())
+        self.assertEqual(0, code)
+        self.assertEqual(
+            {'bl_numbers': ['BL-1', 'BL-2']},
+            client.calls[-1][2],
+        )
+        self.assertNotIn('page', client.calls[-1][2])
+
+    def test_presenter_accepts_fifty_four_string_columns(self):
+        presenter = OutputPresenter()
+        columns = [{'key': key, 'name': name} for key, name in COLUMNS]
+        record = {key: 'v_{0}'.format(key) for key, _ in COLUMNS}
+        ok = presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': [record]},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+            },
+        })
+        self.assertFalse(ok['is_error'])
+        self.assertEqual(54, len(ok['structured_content']['headers']))
+        self.assertEqual('v_container_code', ok['structured_content']['rows'][0][2])
+
+        numeric = dict(record)
+        numeric['total_volume'] = 1.2
+        self.assertTrue(presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': [numeric]},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+            },
+        })['is_error'])
+        self.assertTrue(presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': [], 'extra': 1},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+            },
+        })['is_error'])
+        self.assertTrue(presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': []},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False,
+                'request_id': 'rq_x', 'total': 1,
+            },
+        })['is_error'])
+        bad_columns = list(columns)
+        bad_columns[0] = {'key': 'ship_company', 'name': '错'}
+        self.assertTrue(presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': bad_columns, 'records': []},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+            },
+        })['is_error'])
+        extra = dict(record)
+        extra['seq'] = '1'
+        self.assertTrue(presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': [extra]},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+            },
+        })['is_error'])
+        self.assertTrue(presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': 'bad'},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+            },
+        })['is_error'])
+        short_columns = columns[:-1]
+        self.assertTrue(presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': short_columns, 'records': []},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+            },
+        })['is_error'])
+        self.assertTrue(presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': ['bad']},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+            },
+        })['is_error'])
+
+    def test_local_and_public_registries_include_both_tools(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertEqual(42, len(local))
+        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+        self.assertIn('query_container_timeliness_list', local)
+        self.assertIn('export_container_timeliness_report', local)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 80 - 0
tests/test_customer_filter_options_tool.py

@@ -0,0 +1,80 @@
+import unittest
+from io import StringIO
+
+from app import GatewayApp
+from tools.list_customer_filter_options import ListCustomerFilterOptionsTool
+
+
+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_customer_filter_options']}}
+
+
+class CustomerFilterOptionsToolTest(unittest.TestCase):
+    def test_schema_is_closed_and_has_four_business_types(self):
+        metadata = ListCustomerFilterOptionsTool().metadata()
+        schema = metadata['input_schema']
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(['filter_type'], schema['required'])
+        self.assertEqual(
+            ['客户名称', '事业部', '商务经理', '客户经理'],
+            schema['properties']['filter_type']['enum'],
+        )
+        self.assertEqual(100, schema['properties']['keyword']['maxLength'])
+        self.assertIn('query_customer_list', metadata['description'])
+        self.assertIn('query_customer_payment_followup', metadata['description'])
+
+    def test_call_normalizes_and_forwards(self):
+        client = RecordingApiClient()
+        result = ListCustomerFilterOptionsTool(client).call(
+            ' 客户名称 ', keyword=' 客户A ', page=2, limit=30,
+            request_id='rq_customer_options',
+        )
+        self.assertEqual({'code': 'MCP_0000'}, result)
+        self.assertEqual('list_customer_filter_options', client.last_call['tool_code'])
+        self.assertEqual('/mcp/tools/listCustomerFilterOptions', client.last_call['route_path'])
+        self.assertEqual({
+            'filter_type': '客户名称', 'keyword': '客户A', 'page': 2, 'limit': 30,
+        }, client.last_call['payload'])
+
+    def test_call_rejects_bad_type_keyword_and_numbers(self):
+        tool = ListCustomerFilterOptionsTool(RecordingApiClient())
+        for kwargs, message in (
+            ({'filter_type': '客户'}, 'filter_type'),
+            ({'filter_type': '客户名称', 'keyword': 3}, 'keyword'),
+            ({'filter_type': '客户名称', 'keyword': 'x' * 101}, 'keyword'),
+            ({'filter_type': '客户名称', 'page': '2'}, 'page'),
+            ({'filter_type': '客户名称', 'limit': True}, 'limit'),
+        ):
+            with self.assertRaisesRegex(ValueError, message):
+                tool.call(**kwargs)
+
+    def test_call_rejects_non_string_filter_type_and_requires_client(self):
+        with self.assertRaisesRegex(ValueError, 'filter_type'):
+            ListCustomerFilterOptionsTool(RecordingApiClient()).call(3)
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            ListCustomerFilterOptionsTool().call('客户名称')
+
+    def test_cli_reuses_filter_type_and_keyword(self):
+        client = RecordingApiClient()
+        GatewayApp(api_client=client).run_cli([
+            'call', '--tool', 'list_customer_filter_options',
+            '--filter-type', '事业部', '--keyword', '华南',
+        ], stdout=StringIO())
+        self.assertEqual({
+            'filter_type': '事业部', 'keyword': '华南', 'page': 1, 'limit': 20,
+        }, client.last_call['payload'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 273 - 0
tests/test_customer_payment_followup_tool.py

@@ -0,0 +1,273 @@
+import importlib.util
+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):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_customer_payment_followup']},
+        }
+
+    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': {}}
+
+
+class CustomerPaymentFollowupToolTest(unittest.TestCase):
+    def load_tool_class(self):
+        path = os.path.join(
+            os.path.dirname(os.path.dirname(__file__)),
+            'tools',
+            'query_customer_payment_followup.py',
+        )
+        self.assertTrue(os.path.isfile(path), '客户回款跟进 Gateway 工具尚未实现')
+        spec = importlib.util.spec_from_file_location('payment_followup_tool', path)
+        module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(module)
+        return module.QueryCustomerPaymentFollowupTool
+
+    def test_closed_schema_and_call_forwarding(self):
+        tool_class = self.load_tool_class()
+        client = RecordingApiClient()
+        tool = tool_class(client)
+        metadata = tool.metadata()
+        schema = metadata['input_schema']
+
+        self.assertEqual('query_customer_payment_followup', metadata['name'])
+        self.assertIn('query_customer_unverified_bill_details', metadata['description'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual([], schema['required'])
+        self.assertEqual({
+            'customer_id', 'department_id', 'sales_id', 'merchandiser_id',
+            'has_unverified_receivable_only', 'page', 'limit',
+        }, set(schema['properties']))
+        self.assertEqual(True, schema['properties']['has_unverified_receivable_only']['default'])
+        self.assertEqual(100, schema['properties']['page']['maximum'])
+        self.assertEqual(100, schema['properties']['limit']['maximum'])
+
+        result = tool.call(
+            customer_id=11,
+            department_id=12,
+            sales_id=13,
+            merchandiser_id=14,
+            has_unverified_receivable_only=False,
+            page=2,
+            limit=40,
+            request_id='rq_payment',
+        )
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual((
+            'query_customer_payment_followup',
+            '/mcp/tools/queryCustomerPaymentFollowup',
+            {
+                'page': 2,
+                'limit': 40,
+                'has_unverified_receivable_only': False,
+                'customer_id': 11,
+                'department_id': 12,
+                'sales_id': 13,
+                'merchandiser_id': 14,
+            },
+            'rq_payment',
+        ), client.calls[0])
+
+    def test_defaults_and_illegal_parameters_are_rejected(self):
+        tool_class = self.load_tool_class()
+        client = RecordingApiClient()
+        tool = tool_class(client)
+        tool.call()
+        self.assertEqual({
+            'page': 1,
+            'limit': 20,
+            'has_unverified_receivable_only': True,
+        }, client.calls[0][2])
+
+        invalid_calls = (
+            {'customer_id': True}, {'department_id': 0}, {'sales_id': 1.5},
+            {'merchandiser_id': '2'}, {'page': 0}, {'page': 101},
+            {'limit': False}, {'limit': 101},
+            {'has_unverified_receivable_only': 1},
+            {'has_unverified_receivable_only': 'true'},
+        )
+        for arguments in invalid_calls:
+            with self.subTest(arguments=arguments):
+                with self.assertRaises(ValueError):
+                    tool.call(**arguments)
+
+        with self.assertRaises(RuntimeError):
+            tool_class().call()
+
+    def test_local_public_registries_and_cli_have_eighteen_tools(self):
+        local = GatewayApp(api_client=RecordingApiClient())
+        public = PublicGatewayApp(None, None)
+        self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
+        self.assertEqual(42, len(local.registered_tool_names()))
+        self.assertIn('query_customer_payment_followup', local.registered_tool_names())
+
+        stdout = io.StringIO()
+        local.run_cli([
+            'call', '--tool', 'query_customer_payment_followup',
+            '--customer-id', '11', '--has-unverified-receivable-only', 'false',
+            '--page', '2', '--limit', '10', '--request-id', 'rq_cli_payment',
+        ], stdout=stdout)
+        self.assertEqual('MCP_0000', json.loads(stdout.getvalue())['code'])
+        self.assertEqual({
+            'page': 2,
+            'limit': 10,
+            'customer_id': 11,
+            'has_unverified_receivable_only': False,
+        }, local.api_client.calls[-1][2])
+
+
+class CustomerPaymentFollowupPresenterTest(unittest.TestCase):
+    KEYS = [
+        'customer_name', 'settlement_currency', 'billed_unverified_amount',
+        'unbilled_amount', 'overdue_unpaid_amount',
+        'billed_unverified_amount_cny', 'unbilled_amount_cny',
+        'overdue_unpaid_amount_cny', 'unverified_receivable_monthly_summary',
+        'receipt_unverified_amount', 'current_balance', 'credit_limit',
+        'bad_debt_total', 'contract_status',
+    ]
+    SUMMARY_KEYS = [
+        'receivable_month', 'unverified_amount', 'is_overdue',
+    ]
+
+    def setUp(self):
+        self.presenter = OutputPresenter()
+
+    def payload(self):
+        record = {
+            'customer_name': '甲客户',
+            'settlement_currency': 'USD',
+            'billed_unverified_amount': 90.0,
+            'unbilled_amount': 50.0,
+            'overdue_unpaid_amount': 90.0,
+            'billed_unverified_amount_cny': 630.0,
+            'unbilled_amount_cny': 350.0,
+            'overdue_unpaid_amount_cny': 630.0,
+            'unverified_receivable_monthly_summary': [{
+                'receivable_month': '2026-01',
+                'unverified_amount': 90.0,
+                'is_overdue': True,
+            }],
+            'receipt_unverified_amount': 30.0,
+            'current_balance': -300.0,
+            'credit_limit': 1000.0,
+            'bad_debt_total': 15.0,
+            'contract_status': '生效中',
+        }
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'columns': [{'key': key, 'name': key} for key in self.KEYS],
+                'records': [record],
+            },
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False,
+                'request_id': 'rq_payment',
+            },
+        }
+
+    def test_success_translates_every_top_level_and_monthly_summary_field(self):
+        result = self.presenter.present(
+            'query_customer_payment_followup', self.payload()
+        )
+        self.assertFalse(result['is_error'])
+        self.assertEqual(14, len(result['structured_content']['headers']))
+        self.assertEqual(14, len(result['structured_content']['rows'][0]))
+        summaries = result['structured_content']['rows'][0][8]
+        self.assertEqual({
+            '应收月份', '未核销金额', '是否逾期',
+        }, set(summaries[0]))
+        self.assertEqual(
+            {'page': 1, 'limit': 20, 'has_more': False},
+            result['structured_content']['pagination'],
+        )
+        instruction = result['structured_content']['display_rules']['instruction']
+        self.assertIn('月份汇总', instruction)
+        self.assertNotIn('账单明细', instruction)
+        serialized = json.dumps(result, ensure_ascii=False)
+        for raw_key in self.KEYS + self.SUMMARY_KEYS:
+            self.assertNotIn(raw_key, serialized)
+
+    def test_missing_extra_or_reordered_top_level_fields_fail_closed(self):
+        cases = []
+        missing = self.payload()
+        del missing['data']['records'][0]['credit_limit']
+        cases.append(missing)
+        extra = self.payload()
+        extra['data']['records'][0]['secret'] = 'hidden'
+        cases.append(extra)
+        column_missing = self.payload()
+        column_missing['data']['columns'].pop()
+        cases.append(column_missing)
+        reordered = self.payload()
+        reordered['data']['columns'].reverse()
+        cases.append(reordered)
+        data_extra = self.payload()
+        data_extra['data']['internal'] = True
+        cases.append(data_extra)
+
+        for payload in cases:
+            with self.subTest(payload=payload):
+                result = self.presenter.present('query_customer_payment_followup', payload)
+                self.assertTrue(result['is_error'])
+
+    def test_malformed_monthly_summary_amounts_and_pagination_fail_closed(self):
+        cases = []
+        for summary in (
+            'bad',
+            [{'receivable_month': '2026-01'}],
+            [{**self.payload()['data']['records'][0]['unverified_receivable_monthly_summary'][0], 'secret': 1}],
+            [{**self.payload()['data']['records'][0]['unverified_receivable_monthly_summary'][0], 'receivable_month': []}],
+            [{**self.payload()['data']['records'][0]['unverified_receivable_monthly_summary'][0], 'unverified_amount': True}],
+            [{**self.payload()['data']['records'][0]['unverified_receivable_monthly_summary'][0], 'is_overdue': 1}],
+        ):
+            payload = self.payload()
+            payload['data']['records'][0]['unverified_receivable_monthly_summary'] = summary
+            cases.append(payload)
+
+        bad_amount = self.payload()
+        bad_amount['data']['records'][0]['credit_limit'] = '1000'
+        cases.append(bad_amount)
+        bad_name = self.payload()
+        bad_name['data']['records'][0]['customer_name'] = []
+        cases.append(bad_name)
+        bad_record = self.payload()
+        bad_record['data']['records'] = ['bad']
+        cases.append(bad_record)
+        bad_columns = self.payload()
+        bad_columns['data']['columns'] = 'bad'
+        cases.append(bad_columns)
+        bad_column_item = self.payload()
+        bad_column_item['data']['columns'][0]['internal'] = True
+        cases.append(bad_column_item)
+        bad_page = self.payload()
+        bad_page['meta']['has_more'] = 1
+        cases.append(bad_page)
+        page_over_limit = self.payload()
+        page_over_limit['meta']['page'] = 101
+        cases.append(page_over_limit)
+        extra_pagination = self.payload()
+        extra_pagination['meta']['total'] = 1
+        cases.append(extra_pagination)
+
+        for payload in cases:
+            with self.subTest(payload=payload):
+                result = self.presenter.present('query_customer_payment_followup', payload)
+                self.assertTrue(result['is_error'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 254 - 0
tests/test_customer_payment_records_tool.py

@@ -0,0 +1,254 @@
+import copy
+import importlib.util
+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):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_customer_payment_records']},
+        }
+
+    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': {}}
+
+
+class CustomerPaymentRecordsToolTest(unittest.TestCase):
+    def load_tool_class(self):
+        path = os.path.join(
+            os.path.dirname(os.path.dirname(__file__)),
+            'tools',
+            'query_customer_payment_records.py',
+        )
+        self.assertTrue(os.path.isfile(path), '客户回款记录 Gateway 工具尚未实现')
+        spec = importlib.util.spec_from_file_location('payment_records_tool', path)
+        module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(module)
+        return module.QueryCustomerPaymentRecordsTool
+
+    def test_closed_schema_requires_customer_and_forwards_dates(self):
+        tool_class = self.load_tool_class()
+        client = RecordingApiClient()
+        tool = tool_class(client)
+        metadata = tool.metadata()
+        schema = metadata['input_schema']
+
+        self.assertEqual('query_customer_payment_records', metadata['name'])
+        self.assertEqual(['customer_id'], schema['required'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual({
+            'customer_id', 'receive_date_start', 'receive_date_end',
+            'page', 'limit',
+        }, set(schema['properties']))
+        self.assertEqual('date', schema['properties']['receive_date_start']['format'])
+        self.assertIn('list_customer_filter_options', metadata['description'])
+        self.assertIn('不得根据名称猜测', metadata['description'])
+
+        result = tool.call(
+            customer_id=7,
+            receive_date_start='2026-01-01',
+            receive_date_end='2026-12-31',
+            page=2,
+            limit=30,
+            request_id='rq_records',
+        )
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual((
+            'query_customer_payment_records',
+            '/mcp/tools/queryCustomerPaymentRecords',
+            {
+                'customer_id': 7,
+                'receive_date_start': '2026-01-01',
+                'receive_date_end': '2026-12-31',
+                'page': 2,
+                'limit': 30,
+            },
+            'rq_records',
+        ), client.calls[0])
+
+    def test_invalid_arguments_and_oversized_date_range_fail(self):
+        tool_class = self.load_tool_class()
+        tool = tool_class(RecordingApiClient())
+        invalid = (
+            {'customer_id': True}, {'customer_id': 0}, {'customer_id': '7'},
+            {'customer_id': 7, 'receive_date_start': '2026-02-30'},
+            {'customer_id': 7, 'receive_date_start': 20260101},
+            {'customer_id': 7, 'receive_date_start': '20260101'},
+            {'customer_id': 7, 'receive_date_start': '2026-02-02', 'receive_date_end': '2026-02-01'},
+            {'customer_id': 7, 'receive_date_start': '2025-01-01', 'receive_date_end': '2026-01-02'},
+            {'customer_id': 7, 'page': 0}, {'customer_id': 7, 'page': 101},
+            {'customer_id': 7, 'limit': False}, {'customer_id': 7, 'limit': 101},
+        )
+        for arguments in invalid:
+            with self.subTest(arguments=arguments):
+                with self.assertRaises(ValueError):
+                    tool.call(**arguments)
+        with self.assertRaises(RuntimeError):
+            tool_class().call(customer_id=7)
+
+        client = RecordingApiClient()
+        tool = tool_class(client)
+        tool.call(customer_id=7, receive_date_start='2026-01-01')
+        self.assertEqual('2026-01-01', client.calls[-1][2]['receive_date_start'])
+        self.assertNotIn('receive_date_end', client.calls[-1][2])
+        tool.call(customer_id=7, receive_date_end='2026-12-31')
+        self.assertEqual('2026-12-31', client.calls[-1][2]['receive_date_end'])
+        self.assertNotIn('receive_date_start', client.calls[-1][2])
+        tool.call(
+            customer_id=7,
+            receive_date_start='2025-01-01',
+            receive_date_end='2026-01-01',
+        )
+        self.assertEqual('2026-01-01', client.calls[-1][2]['receive_date_end'])
+
+    def test_registries_and_cli_have_eighteen_tools(self):
+        client = RecordingApiClient()
+        local = GatewayApp(api_client=client)
+        public = PublicGatewayApp(None, None)
+        self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
+        self.assertEqual(42, len(local.registered_tool_names()))
+        self.assertIn('query_customer_payment_records', local.registered_tool_names())
+
+        stdout = io.StringIO()
+        local.run_cli([
+            'call', '--tool', 'query_customer_payment_records',
+            '--customer-id', '7', '--receive-date-start', '2026-01-01',
+            '--receive-date-end', '2026-12-31', '--page', '2', '--limit', '30',
+        ], stdout=stdout)
+        self.assertEqual('MCP_0000', json.loads(stdout.getvalue())['code'])
+        self.assertEqual({
+            'customer_id': 7,
+            'receive_date_start': '2026-01-01',
+            'receive_date_end': '2026-12-31',
+            'page': 2,
+            'limit': 30,
+        }, client.calls[-1][2])
+
+        start_only = io.StringIO()
+        local.run_cli([
+            'call', '--tool', 'query_customer_payment_records',
+            '--customer-id', '7', '--receive-date-start', '2026-01-01',
+        ], stdout=start_only)
+        self.assertIn('receive_date_start', client.calls[-1][2])
+        self.assertNotIn('receive_date_end', client.calls[-1][2])
+
+        end_only = io.StringIO()
+        local.run_cli([
+            'call', '--tool', 'query_customer_payment_records',
+            '--customer-id', '7', '--receive-date-end', '2026-12-31',
+        ], stdout=end_only)
+        self.assertIn('receive_date_end', client.calls[-1][2])
+        self.assertNotIn('receive_date_start', client.calls[-1][2])
+
+        with self.assertRaises(ValueError):
+            local.run_cli([
+                'call', '--tool', 'query_customer_payment_records',
+            ], stdout=io.StringIO())
+
+
+class CustomerPaymentRecordsPresenterTest(unittest.TestCase):
+    KEYS = [
+        'customer_name', 'payment_reference', 'original_received_amount',
+        'actual_received_amount', 'receive_date', 'verified_amount',
+        'unverified_amount', 'payment_approval_status',
+    ]
+    HEADERS = [
+        '客户名称', '收款水单号', '原币到账金额', '实际收款金额',
+        '收款日期', '已核销金额', '未核销金额', '收款审核状态',
+    ]
+
+    def payload(self):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'columns': [{'key': key, 'name': key} for key in self.KEYS],
+                'records': [{
+                    'customer_name': '甲客户',
+                    'payment_reference': 'BANK-260001',
+                    'original_received_amount': 100.25,
+                    'actual_received_amount': 700.5,
+                    'receive_date': '2026-07-20 16:30:00',
+                    'verified_amount': 500.25,
+                    'unverified_amount': 200.25,
+                    'payment_approval_status': '审核通过',
+                }],
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        }
+
+    def test_presenter_translates_exact_eight_columns(self):
+        result = OutputPresenter().present(
+            'query_customer_payment_records', self.payload()
+        )
+        self.assertFalse(result['is_error'])
+        self.assertEqual(
+            [{'label': header} for header in self.HEADERS],
+            result['structured_content']['headers'],
+        )
+        self.assertEqual(8, len(result['structured_content']['rows'][0]))
+        serialized = json.dumps(result, ensure_ascii=False)
+        for key in self.KEYS:
+            self.assertNotIn(key, serialized)
+
+    def test_unknown_missing_malformed_and_nonfinite_fields_fail_closed(self):
+        cases = []
+        unknown = self.payload()
+        unknown['data']['records'][0]['secret'] = 'hidden'
+        cases.append(unknown)
+        missing = self.payload()
+        del missing['data']['records'][0]['payment_reference']
+        cases.append(missing)
+        reordered = self.payload()
+        reordered['data']['columns'].reverse()
+        cases.append(reordered)
+        bad_columns = self.payload()
+        bad_columns['data']['columns'] = 'bad'
+        cases.append(bad_columns)
+        bad_column = self.payload()
+        bad_column['data']['columns'][0]['internal'] = True
+        cases.append(bad_column)
+        bad_records = self.payload()
+        bad_records['data']['records'] = 'bad'
+        cases.append(bad_records)
+        bad_text = self.payload()
+        bad_text['data']['records'][0]['customer_name'] = []
+        cases.append(bad_text)
+        bool_amount = self.payload()
+        bool_amount['data']['records'][0]['verified_amount'] = True
+        cases.append(bool_amount)
+        for amount in (float('inf'), float('-inf'), float('nan'), '100.00'):
+            bad_amount = self.payload()
+            bad_amount['data']['records'][0]['actual_received_amount'] = amount
+            cases.append(bad_amount)
+        extra_data = self.payload()
+        extra_data['data']['internal'] = True
+        cases.append(extra_data)
+        bad_meta = self.payload()
+        bad_meta['meta']['total'] = 1
+        cases.append(bad_meta)
+
+        presenter = OutputPresenter()
+        for payload in cases:
+            with self.subTest(payload=payload):
+                self.assertTrue(presenter.present(
+                    'query_customer_payment_records', payload
+                )['is_error'])
+
+    def test_payload_factory_does_not_share_nested_state(self):
+        self.assertEqual(self.payload(), copy.deepcopy(self.payload()))
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 97 - 0
tests/test_customer_query_tools.py

@@ -0,0 +1,97 @@
+import unittest
+from io import StringIO
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from tools.query_customer_list import QueryCustomerListTool
+
+
+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_customer_list']}}
+
+
+class CustomerQueryToolTest(unittest.TestCase):
+    def test_schema_is_closed_and_uses_strict_positive_integers(self):
+        metadata = QueryCustomerListTool().metadata()
+        schema = metadata['input_schema']
+        self.assertIn('不得合并或缩写', metadata['description'])
+        self.assertIn('完整逐条展示17个字段', metadata['description'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual([], schema['required'])
+        for field in ('customer_id', 'department_id', 'sales_id', 'merchandiser_id'):
+            self.assertEqual({'type': 'integer', 'minimum': 1}, schema['properties'][field])
+        for field, default in (('page', 1), ('limit', 20)):
+            self.assertEqual(1, schema['properties'][field]['minimum'])
+            self.assertEqual(100, schema['properties'][field]['maximum'])
+            self.assertEqual(default, schema['properties'][field]['default'])
+
+    def test_call_forwards_all_filters(self):
+        client = RecordingApiClient()
+        result = QueryCustomerListTool(client).call(
+            customer_id=1, department_id=2, sales_id=3,
+            merchandiser_id=4, page=5, limit=6, request_id='rq_customer',
+        )
+        self.assertEqual({'code': 'MCP_0000'}, result)
+        self.assertEqual('query_customer_list', client.last_call['tool_code'])
+        self.assertEqual('/mcp/tools/queryCustomerList', client.last_call['route_path'])
+        self.assertEqual({
+            'customer_id': 1, 'department_id': 2, 'sales_id': 3,
+            'merchandiser_id': 4, 'page': 5, 'limit': 6,
+        }, client.last_call['payload'])
+
+    def test_call_rejects_non_strict_and_out_of_range_integers(self):
+        tool = QueryCustomerListTool(RecordingApiClient())
+        for field, value in (
+            ('customer_id', True), ('department_id', '2'), ('sales_id', 1.5),
+            ('merchandiser_id', 0), ('page', 101), ('limit', -1),
+        ):
+            with self.subTest(field=field, value=value):
+                with self.assertRaisesRegex(ValueError, field):
+                    tool.call(**{field: value})
+
+    def test_call_requires_api_client(self):
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            QueryCustomerListTool().call()
+
+    def test_local_and_public_registries_are_identical_and_have_25_tools(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertEqual(42, len(local))
+        self.assertIn('query_customer_list', local)
+        self.assertIn('list_customer_filter_options', local)
+
+    def test_cli_forwards_four_customer_filters(self):
+        client = RecordingApiClient()
+        output = StringIO()
+        GatewayApp(api_client=client).run_cli([
+            'call', '--tool', 'query_customer_list', '--customer-id', '11',
+            '--department-id', '12', '--sales-id', '13',
+            '--merchandiser-id', '14', '--page', '2', '--limit', '30',
+        ], stdout=output)
+        self.assertEqual({
+            'customer_id': 11, 'department_id': 12, 'sales_id': 13,
+            'merchandiser_id': 14, 'page': 2, 'limit': 30,
+        }, client.last_call['payload'])
+
+    def test_cli_allows_customer_query_without_filters(self):
+        client = RecordingApiClient()
+        GatewayApp(api_client=client).run_cli([
+            'call', '--tool', 'query_customer_list',
+        ], stdout=StringIO())
+        self.assertEqual({'page': 1, 'limit': 20}, client.last_call['payload'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 154 - 0
tests/test_customer_unverified_bill_details_tool.py

@@ -0,0 +1,154 @@
+import json
+import unittest
+from io import StringIO
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.query_customer_unverified_bill_details import (
+    QueryCustomerUnverifiedBillDetailsTool,
+)
+
+
+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': {}}
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_customer_unverified_bill_details']},
+        }
+
+
+class CustomerUnverifiedBillDetailsToolTest(unittest.TestCase):
+    def test_schema_is_closed_requires_customer_and_forwards_call(self):
+        client = RecordingApiClient()
+        tool = QueryCustomerUnverifiedBillDetailsTool(client)
+        schema = tool.metadata()['input_schema']
+
+        self.assertEqual(['customer_id'], schema['required'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual({'customer_id', 'page', 'limit'}, set(schema['properties']))
+        result = tool.call(customer_id=7, page=2, limit=30, request_id='rq_detail')
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual((
+            'query_customer_unverified_bill_details',
+            '/mcp/tools/queryCustomerUnverifiedBillDetails',
+            {'customer_id': 7, 'page': 2, 'limit': 30},
+            'rq_detail',
+        ), client.calls[0])
+
+    def test_invalid_arguments_and_missing_client_fail(self):
+        tool = QueryCustomerUnverifiedBillDetailsTool(RecordingApiClient())
+        for arguments in (
+            {'customer_id': True}, {'customer_id': 0}, {'customer_id': '7'},
+            {'customer_id': 7, 'page': 0}, {'customer_id': 7, 'page': 101},
+            {'customer_id': 7, 'limit': False}, {'customer_id': 7, 'limit': 101},
+        ):
+            with self.subTest(arguments=arguments):
+                with self.assertRaises(ValueError):
+                    tool.call(**arguments)
+        with self.assertRaises(RuntimeError):
+            QueryCustomerUnverifiedBillDetailsTool().call(customer_id=7)
+
+    def test_registries_and_cli_include_detail_tool(self):
+        client = RecordingApiClient()
+        local = GatewayApp(api_client=client)
+        public = PublicGatewayApp(None, None)
+        self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
+        self.assertEqual(42, len(local.registered_tool_names()))
+        self.assertIn('query_customer_unverified_bill_details', local.registered_tool_names())
+
+        output = StringIO()
+        local.run_cli([
+            'call', '--tool', 'query_customer_unverified_bill_details',
+            '--customer-id', '7', '--page', '2', '--limit', '30',
+        ], stdout=output)
+        self.assertEqual('MCP_0000', json.loads(output.getvalue())['code'])
+        self.assertEqual({'customer_id': 7, 'page': 2, 'limit': 30}, client.calls[-1][2])
+
+        with self.assertRaises(ValueError):
+            local.run_cli([
+                'call', '--tool', 'query_customer_unverified_bill_details',
+            ], stdout=StringIO())
+
+
+class CustomerUnverifiedBillDetailsPresenterTest(unittest.TestCase):
+    KEYS = [
+        'bill_month', 'bill_no', 'business_type', 'settlement_mode',
+        'unverified_amount', 'customer_receivable_date',
+    ]
+
+    def payload(self):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'columns': [{'key': key, 'name': key} for key in self.KEYS],
+                'records': [{
+                    'bill_month': '2026-01',
+                    'bill_no': 'DFC2601010001',
+                    'business_type': '头程业务',
+                    'settlement_mode': '月结30天',
+                    'unverified_amount': 90.0,
+                    'customer_receivable_date': '2026-01-31',
+                }],
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        }
+
+    def test_translates_all_six_fields(self):
+        result = OutputPresenter().present(
+            'query_customer_unverified_bill_details', self.payload()
+        )
+        self.assertFalse(result['is_error'])
+        self.assertEqual(6, len(result['structured_content']['headers']))
+        self.assertEqual(6, len(result['structured_content']['rows'][0]))
+
+    def test_missing_extra_malformed_or_bad_pagination_fails_closed(self):
+        cases = []
+        missing = self.payload()
+        del missing['data']['records'][0]['bill_no']
+        cases.append(missing)
+        extra = self.payload()
+        extra['data']['records'][0]['secret'] = 1
+        cases.append(extra)
+        bad_amount = self.payload()
+        bad_amount['data']['records'][0]['unverified_amount'] = True
+        cases.append(bad_amount)
+        bad_text = self.payload()
+        bad_text['data']['records'][0]['bill_month'] = []
+        cases.append(bad_text)
+        bad_page = self.payload()
+        bad_page['meta']['page'] = 101
+        cases.append(bad_page)
+        extra_meta = self.payload()
+        extra_meta['meta']['total'] = 1
+        cases.append(extra_meta)
+        extra_data = self.payload()
+        extra_data['data']['internal'] = True
+        cases.append(extra_data)
+        bad_records = self.payload()
+        bad_records['data']['records'] = 'bad'
+        cases.append(bad_records)
+        bad_columns = self.payload()
+        bad_columns['data']['columns'] = 'bad'
+        cases.append(bad_columns)
+        bad_column = self.payload()
+        bad_column['data']['columns'][0]['internal'] = True
+        cases.append(bad_column)
+
+        presenter = OutputPresenter()
+        for payload in cases:
+            with self.subTest(payload=payload):
+                self.assertTrue(presenter.present(
+                    'query_customer_unverified_bill_details', payload
+                )['is_error'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 505 - 0
tests/test_destination_trailer_tools.py

@@ -0,0 +1,505 @@
+import importlib
+import io
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+
+
+PROGRESS_COLUMNS = [
+    ('bl_number', '提单号'), ('container_code', '柜号'),
+    ('container_type', '柜型'), ('warehouse_name', '仓库名称'),
+    ('providers_name', '拖车行'), ('trailer_status', '提柜状态'),
+    ('latest_remark', '最新操作备注'), ('is_direct_send', '直送'),
+    ('cabinet_type', '整拼类型'), ('vessel_name', '船名航次'),
+    ('route', '航线'), ('clearance_check', '清关查验'),
+    ('etd', 'ETD'), ('atd', 'ATD'), ('eta', 'ETA'), ('ata', 'ATA'),
+    ('loading_port', '起运港'), ('destination_port', '目的港'),
+    ('wharf_name', '到港码头'), ('appt_time', 'APPT时间'),
+    ('delivery_end_time', '卡车实际派送时间'),
+    ('est_pickup_time', '预计提柜时间'), ('pickup_time', '实际提柜时间'),
+    ('inbound_date', '到仓时间'), ('container_return_time', '还柜时间'),
+    ('pickup_prescription', '提柜时效'), ('return_prescription', '还柜时效'),
+    ('wharf_wait_time', '码头等待时间'),     ('amazon_wait_time', '亚马逊等待时间'),
+    ('bill_status', '应付费用状态'),
+    ('issue_way', '出单方式'),
+    ('release_status', '放货状态'),
+    ('freight_collect', '海运费到付'),
+    ('is_dg', 'DG'),
+    ('is_soc', 'SOC'),
+    ('is_oneway', 'ONEWAY'),
+    ('appointment_method', '预约方式'),
+    ('need_appointment_code', '是否需要预约码'),
+    ('warehouse_special_remark', '仓库特殊备注'),
+]
+DO_COLUMNS = [
+    ('trailer_number', '拖车单号'), ('outbound_number', '出库单号'),
+    ('container_code', '柜号'), ('container_type', '柜型'),
+    ('bl_number', '提单号'), ('is_direct_send', '是否直送'),
+    ('original_do_file', '原DO单'), ('new_do_file', '新DO单'),
+    ('email_subject', '邮件主题'), ('email_from', '邮件发送人'),
+    ('received_at', '邮件接收时间'),
+]
+FILTER_TYPES = [
+    '海外提柜类型', '拖车行', '海外仓', '直送柜', '应付费用', '整拼类型', '运输方式',
+]
+STAGES = [
+    '待安排拖车', '待提柜', '待还柜(海)', '已还柜(海)', '已提货(空)', 'DO单制作',
+]
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_destination_trailer_list',
+                    'list_destination_trailer_filter_options',
+                ],
+            },
+        }
+
+    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 DestinationTrailerToolContractTest(unittest.TestCase):
+    def tool_class(self, module_name, class_name):
+        return getattr(importlib.import_module('tools.' + module_name), class_name)
+
+    def test_query_schema_requires_stage_and_forbids_identity(self):
+        cls = self.tool_class(
+            'query_destination_trailer_list', 'QueryDestinationTrailerListTool'
+        )
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('query_destination_trailer_list', metadata['name'])
+        self.assertEqual('/mcp/tools/queryDestinationTrailerList', cls.route_path)
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(['trailer_stage'], schema['required'])
+        self.assertEqual(STAGES, schema['properties']['trailer_stage']['enum'])
+        self.assertNotIn('default', schema['properties']['trailer_stage'])
+        for forbidden in ('company_id', 'admin_id', 'is_super', 'outbound_id'):
+            self.assertNotIn(forbidden, schema['properties'])
+        self.assertIn('使用场景:', metadata['description'])
+        self.assertIn('禁止使用:', metadata['description'])
+        self.assertIn('号码类型不明确时必须先询问用户', metadata['description'])
+        self.assertIn('不得根据号码格式猜测', metadata['description'])
+        self.assertIn('不得跨字段或跨工具试查', metadata['description'])
+        self.assertIn('query_outbound_list', metadata['description'])
+        self.assertIn('提单号、柜号、日期和其他筛选均为可选', metadata['description'])
+        self.assertNotIn('必须再有', metadata['description'])
+
+    def test_query_forwards_numbers_dates_and_rejects_invalid(self):
+        cls = self.tool_class(
+            'query_destination_trailer_list', 'QueryDestinationTrailerListTool'
+        )
+        client = RecordingApiClient()
+        cls(client).call(
+            trailer_stage='待提柜',
+            bl_numbers=[' BL-1 ', 'BL-1'],
+            container_codes=['CONT-1'],
+            providers_id=8,
+            warehouse_id=3,
+            is_direct_send=0,
+            bill_status=10,
+            cabinet_type=1,
+            shipping_method=2,
+            eta_start='2026-07-01',
+            eta_end='2026-07-31',
+            page=2,
+            limit=30,
+            request_id='rq_dt',
+        )
+        self.assertEqual(
+            {
+                'trailer_stage': '待提柜',
+                'bl_numbers': ['BL-1'],
+                'container_codes': ['CONT-1'],
+                'providers_id': 8,
+                'warehouse_id': 3,
+                'is_direct_send': 0,
+                'bill_status': 10,
+                'cabinet_type': 1,
+                'shipping_method': 2,
+                'eta_start': '2026-07-01',
+                'eta_end': '2026-07-31',
+                'page': 2,
+                'limit': 30,
+            },
+            client.calls[-1][2],
+        )
+        self.assertEqual(
+            '/mcp/tools/queryDestinationTrailerList',
+            client.calls[-1][1],
+        )
+        cls(client).call(trailer_stage='待提柜')
+        self.assertEqual(
+            {
+                'trailer_stage': '待提柜',
+                'page': 1,
+                'limit': 20,
+            },
+            client.calls[-1][2],
+        )
+        cls(client).call(trailer_stage='待提柜', providers_id=8)
+        with self.assertRaises(RuntimeError):
+            cls().call(trailer_stage='待提柜')
+        invalid = (
+            {'trailer_stage': '全部', 'bl_numbers': ['BL-1']},
+            {'trailer_stage': '待提柜', 'eta_start': '2026-07-01'},
+            {
+                'trailer_stage': '待提柜',
+                'eta_start': '2026-07-01',
+                'eta_end': '2026-08-01',
+            },
+            {
+                'trailer_stage': 'DO单制作',
+                'bl_numbers': ['BL-1'],
+                'providers_id': 8,
+            },
+            {
+                'trailer_stage': 'DO单制作',
+                'eta_start': '2026-07-01',
+                'eta_end': '2026-07-02',
+            },
+        )
+        for arguments in invalid:
+            with self.subTest(arguments=arguments):
+                with self.assertRaises(ValueError):
+                    cls(client).call(**arguments)
+
+    def test_filter_schema_and_forwarding(self):
+        cls = self.tool_class(
+            'list_destination_trailer_filter_options',
+            'ListDestinationTrailerFilterOptionsTool',
+        )
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual(['filter_type'], schema['required'])
+        self.assertEqual(FILTER_TYPES, schema['properties']['filter_type']['enum'])
+        self.assertIn('使用场景:', metadata['description'])
+        self.assertIn('禁止使用:', metadata['description'])
+        client = RecordingApiClient()
+        cls(client).call('拖车行', keyword='美西', page=2, limit=10)
+        self.assertEqual(
+            {
+                'filter_type': '拖车行',
+                'keyword': '美西',
+                'page': 2,
+                'limit': 10,
+            },
+            client.calls[-1][2],
+        )
+        with self.assertRaises(ValueError):
+            cls(client).call('排舱阶段')
+        with self.assertRaises(RuntimeError):
+            cls().call('海外提柜类型')
+        with self.assertRaises(ValueError):
+            cls(client).call('海外提柜类型', keyword='x' * 101)
+        with self.assertRaises(ValueError):
+            cls(client).call('海外提柜类型', page=0)
+        with self.assertRaises(ValueError):
+            cls(client).call('海外提柜类型', limit=True)
+
+    def test_query_covers_remaining_validation_branches(self):
+        cls = self.tool_class(
+            'query_destination_trailer_list', 'QueryDestinationTrailerListTool'
+        )
+        client = RecordingApiClient()
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                bl_numbers=['X'] * 201,
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                eta_start='2026-07-02',
+                eta_end='2026-07-01',
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                bl_numbers=['BL-1'],
+                bill_status=99,
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(trailer_stage='待提柜', bl_numbers='BL-1')
+        with self.assertRaises(ValueError):
+            cls(client).call(trailer_stage='待提柜', bl_numbers=[1])
+        with self.assertRaises(ValueError):
+            cls(client).call(trailer_stage='待提柜', bl_numbers=[' '])
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                eta_start=20260701,
+                eta_end='2026-07-02',
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                eta_start='2026-99-01',
+                eta_end='2026-07-02',
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                eta_start='2026-7-01',
+                eta_end='2026-07-02',
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                bl_numbers=['BL-1'],
+                providers_id=True,
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                bl_numbers=['BL-1'],
+                warehouse_id=0,
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                bl_numbers=['BL-1'],
+                page=0,
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                bl_numbers=['BL-1'],
+                limit=True,
+            )
+        cls(client).call(
+            trailer_stage='待安排拖车',
+            pickup_time_start='2026-07-01',
+            pickup_time_end='2026-07-02',
+            providers_id=8,
+            bill_status=20,
+        )
+        cls(client).call(trailer_stage='DO单制作', bl_numbers=['BL-1'])
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                eta_start='20260701',
+                eta_end='2026-07-02',
+            )
+
+    def test_cli_forwards_optional_trailer_filters(self):
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        with self.assertRaises(ValueError):
+            app.run_cli([
+                'call', '--tool', 'query_destination_trailer_list',
+            ], stdout=io.StringIO())
+        app.run_cli([
+            'call', '--tool', 'query_destination_trailer_list',
+            '--trailer-stage', '待还柜(海)',
+            '--container-codes', 'C1',
+            '--providers-id', '8',
+            '--warehouse-id', '3',
+            '--is-direct-send', '1',
+            '--bill-status', '30',
+            '--cabinet-type', '2',
+            '--shipping-method', '2',
+            '--eta-start', '2026-07-01',
+            '--eta-end', '2026-07-02',
+            '--pickup-time-start', '2026-07-03',
+            '--pickup-time-end', '2026-07-04',
+            '--container-return-time-start', '2026-07-05',
+            '--container-return-time-end', '2026-07-06',
+        ], stdout=io.StringIO())
+        self.assertEqual(
+            {
+                'trailer_stage': '待还柜(海)',
+                'container_codes': ['C1'],
+                'providers_id': 8,
+                'warehouse_id': 3,
+                'is_direct_send': 1,
+                'bill_status': 30,
+                'cabinet_type': 2,
+                'shipping_method': 2,
+                'eta_start': '2026-07-01',
+                'eta_end': '2026-07-02',
+                'pickup_time_start': '2026-07-03',
+                'pickup_time_end': '2026-07-04',
+                'container_return_time_start': '2026-07-05',
+                'container_return_time_end': '2026-07-06',
+                'page': 1,
+                'limit': 20,
+            },
+            client.calls[-1][2],
+        )
+
+    def test_presenter_fails_closed_on_unknown_shape(self):
+        presenter = OutputPresenter()
+        meta = {
+            'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+        }
+        self.assertTrue(presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {'trailer_stage': '待提柜', 'columns': [], 'records': [], 'extra': 1},
+            'meta': meta,
+        })['is_error'])
+        self.assertTrue(presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_stage': '未知',
+                'columns': [],
+                'records': [],
+            },
+            'meta': meta,
+        })['is_error'])
+        self.assertTrue(presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_stage': '待提柜',
+                'columns': [{'key': 'bl_number', 'name': '提单号'}],
+                'records': [],
+            },
+            'meta': meta,
+        })['is_error'])
+        columns = [{'key': key, 'name': name} for key, name in PROGRESS_COLUMNS]
+        columns[0] = {'key': 'bl_number', 'name': '错'}
+        self.assertTrue(presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_stage': '待提柜',
+                'columns': columns,
+                'records': [],
+            },
+            'meta': meta,
+        })['is_error'])
+        record = {key: 'v' for key, _ in PROGRESS_COLUMNS}
+        record['bl_number'] = 1
+        self.assertTrue(presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_stage': '待提柜',
+                'columns': [
+                    {'key': key, 'name': name} for key, name in PROGRESS_COLUMNS
+                ],
+                'records': [record],
+            },
+            'meta': meta,
+        })['is_error'])
+
+    def test_local_public_registry_cli_and_safe_counts(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertEqual(42, len(local))
+        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+        self.assertIn('query_destination_trailer_list', local)
+        self.assertIn('list_destination_trailer_filter_options', local)
+
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        app.run_cli([
+            'call', '--tool', 'query_destination_trailer_list',
+            '--trailer-stage', '待提柜', '--bl-numbers', ' BL-1,BL-2 ',
+            '--page', '2', '--limit', '30',
+        ], stdout=io.StringIO())
+        self.assertEqual(
+            {
+                'trailer_stage': '待提柜',
+                'bl_numbers': ['BL-1', 'BL-2'],
+                'page': 2,
+                'limit': 30,
+            },
+            client.calls[-1][2],
+        )
+        with self.assertRaises(ValueError):
+            app.run_cli([
+                'call', '--tool', 'list_destination_trailer_filter_options',
+            ], stdout=io.StringIO())
+        app.run_cli([
+            'call', '--tool', 'list_destination_trailer_filter_options',
+            '--filter-type', '海外提柜类型',
+        ], stdout=io.StringIO())
+        self.assertEqual(
+            'list_destination_trailer_filter_options',
+            client.calls[-1][0],
+        )
+
+    def test_presenter_locks_thirty_and_eleven_columns(self):
+        presenter = OutputPresenter()
+        progress_record = {key: 'v' for key, _ in PROGRESS_COLUMNS}
+        progress_record['trailer_status'] = '待提柜'
+        result = presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_stage': '待提柜',
+                'columns': [
+                    {'key': key, 'name': name} for key, name in PROGRESS_COLUMNS
+                ],
+                'records': [progress_record],
+            },
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_p',
+            },
+        })
+        self.assertFalse(result['is_error'])
+        self.assertEqual(39, len(result['structured_content']['headers']))
+        self.assertEqual('待提柜', result['structured_content']['trailer_stage'])
+
+        do_record = {key: 'd' for key, _ in DO_COLUMNS}
+        do_record['is_direct_send'] = '否'
+        do_record['original_do_file'] = ''
+        do_record['new_do_file'] = 'https://files.example/do.pdf'
+        do_ok = presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_stage': 'DO单制作',
+                'columns': [
+                    {'key': key, 'name': name} for key, name in DO_COLUMNS
+                ],
+                'records': [do_record],
+            },
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_d',
+            },
+        })
+        self.assertFalse(do_ok['is_error'])
+        self.assertEqual(11, len(do_ok['structured_content']['headers']))
+
+        bad_url = dict(do_record)
+        bad_url['new_do_file'] = 'javascript:alert(1)'
+        bad = presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_stage': 'DO单制作',
+                'columns': [
+                    {'key': key, 'name': name} for key, name in DO_COLUMNS
+                ],
+                'records': [bad_url],
+            },
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_b',
+            },
+        })
+        self.assertTrue(bad['is_error'])
+        extra = dict(progress_record)
+        extra['outbound_number'] = 'OB1'
+        extra_result = presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_stage': '待提柜',
+                'columns': [
+                    {'key': key, 'name': name} for key, name in PROGRESS_COLUMNS
+                ],
+                'records': [extra],
+            },
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_e',
+            },
+        })
+        self.assertTrue(extra_result['is_error'])

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

+ 331 - 0
tests/test_export_pallet_data_tool.py

@@ -0,0 +1,331 @@
+import io
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.export_pallet_data import ExportPalletDataTool
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['export_pallet_data']},
+        }
+
+    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': {
+                'task_ref': 'mexp_pallet',
+                'status': 'queued',
+                'retry_after_seconds': 10,
+            },
+        }
+
+
+class ExportPalletDataToolTest(unittest.TestCase):
+    def _assert_number_array_schema(self, field_schema, business_label):
+        self.assertEqual('array', field_schema['type'])
+        self.assertEqual(1, field_schema['minItems'])
+        self.assertEqual(200, field_schema['maxItems'])
+        items = field_schema['items']
+        self.assertEqual('string', items['type'])
+        self.assertEqual(1, items['minLength'])
+        self.assertEqual(100, items['maxLength'])
+        self.assertIn('pattern', items)
+        description = field_schema['description']
+        self.assertIn(business_label, description)
+        self.assertIn('仅当用户明确', description)
+        self.assertIn('不得放入', description)
+
+    def test_schema_is_closed_without_ids_or_pagination(self):
+        metadata = ExportPalletDataTool().metadata()
+        schema = metadata['input_schema']
+        properties = schema['properties']
+        self.assertEqual('export_pallet_data', metadata['name'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(
+            {
+                'container_codes',
+                'bl_numbers',
+                'inbound_time_start',
+                'inbound_time_end',
+            },
+            set(properties),
+        )
+        self.assertNotIn('page', properties)
+        self.assertNotIn('limit', properties)
+        self.assertNotIn('ids', properties)
+        self.assertNotIn('inbound_id', properties)
+        self.assertNotIn('inbound_ids', properties)
+        self.assertNotIn('inbound_numbers', properties)
+        self._assert_number_array_schema(properties['container_codes'], '柜号')
+        self._assert_number_array_schema(properties['bl_numbers'], '提单号')
+        description = metadata['description']
+        self.assertIn('明确要求导出打托数据', description)
+        self.assertIn('三选一', description)
+        self.assertIn('异步导出', description)
+        self.assertIn('query_export_task', description)
+        self.assertIn('不会在本次调用中等待', description)
+        self.assertIn('禁止在一次调用内轮询', description)
+        self.assertIn('柜号', description)
+        self.assertIn('提单号', description)
+        self.assertIn('海外仓入库时间', description)
+
+    def test_call_rejects_legacy_inbound_numbers_argument(self):
+        client = RecordingApiClient()
+        tool = ExportPalletDataTool(client)
+        with self.assertRaises(TypeError):
+            tool.call(inbound_numbers=['OLD'])
+        self.assertEqual([], client.calls)
+
+    def test_call_forwards_container_codes(self):
+        client = RecordingApiClient()
+        result = ExportPalletDataTool(client).call(
+            container_codes=[' CONT-1 ', 'CONT-1', 'CONT-2'],
+        )
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual(
+            (
+                'export_pallet_data',
+                '/mcp/tools/exportPalletData',
+                {'container_codes': ['CONT-1', 'CONT-2']},
+                'rq_export_pallet_data',
+            ),
+            client.calls[0],
+        )
+
+    def test_call_forwards_bl_numbers(self):
+        client = RecordingApiClient()
+        result = ExportPalletDataTool(client).call(
+            bl_numbers=[' BL-1 ', 'BL-1'],
+        )
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual(
+            (
+                'export_pallet_data',
+                '/mcp/tools/exportPalletData',
+                {'bl_numbers': ['BL-1']},
+                'rq_export_pallet_data',
+            ),
+            client.calls[0],
+        )
+
+    def test_call_ignores_empty_container_codes_when_bl_numbers_provided(self):
+        client = RecordingApiClient()
+        ExportPalletDataTool(client).call(
+            container_codes=[],
+            bl_numbers=['BL-1'],
+        )
+        self.assertEqual(
+            (
+                'export_pallet_data',
+                '/mcp/tools/exportPalletData',
+                {'bl_numbers': ['BL-1']},
+                'rq_export_pallet_data',
+            ),
+            client.calls[0],
+        )
+
+    def test_call_ignores_empty_container_codes_when_time_range_provided(self):
+        client = RecordingApiClient()
+        ExportPalletDataTool(client).call(
+            container_codes=[],
+            inbound_time_start='2026-09-01',
+            inbound_time_end='2026-09-07',
+        )
+        self.assertEqual(
+            {
+                'inbound_time_start': '2026-09-01',
+                'inbound_time_end': '2026-09-07',
+            },
+            client.calls[-1][2],
+        )
+
+    def test_call_ignores_empty_bl_numbers_when_container_codes_provided(self):
+        client = RecordingApiClient()
+        ExportPalletDataTool(client).call(
+            container_codes=['CONT-1'],
+            bl_numbers=[],
+        )
+        self.assertEqual(
+            (
+                'export_pallet_data',
+                '/mcp/tools/exportPalletData',
+                {'container_codes': ['CONT-1']},
+                'rq_export_pallet_data',
+            ),
+            client.calls[0],
+        )
+
+    def test_call_forwards_inbound_time_range(self):
+        client = RecordingApiClient()
+        ExportPalletDataTool(client).call(
+            inbound_time_start='2026-09-01',
+            inbound_time_end='2026-09-07 18:30:00',
+        )
+        self.assertEqual(
+            {
+                'inbound_time_start': '2026-09-01',
+                'inbound_time_end': '2026-09-07 18:30:00',
+            },
+            client.calls[-1][2],
+        )
+
+    def test_call_validation_boundaries(self):
+        tool = ExportPalletDataTool()
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            tool.call(container_codes=['CONT-1'])
+
+        client = RecordingApiClient()
+        tool = ExportPalletDataTool(client)
+        with self.assertRaisesRegex(ValueError, 'non-empty list'):
+            tool.call(container_codes='CONT-1')
+        with self.assertRaisesRegex(ValueError, 'must be strings'):
+            tool.call(container_codes=[1])
+        with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
+            tool.call(container_codes=[''])
+        with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
+            tool.call(container_codes=['x' * 101])
+        with self.assertRaisesRegex(ValueError, 'at most 200'):
+            tool.call(container_codes=['n{0}'.format(i) for i in range(201)])
+        with self.assertRaisesRegex(ValueError, 'non-empty list'):
+            tool.call(container_codes=[])
+        with self.assertRaisesRegex(ValueError, 'non-empty list'):
+            tool.call(bl_numbers=[])
+        with self.assertRaisesRegex(ValueError, 'non-empty list'):
+            tool.call(bl_numbers='BL-1')
+        with self.assertRaisesRegex(ValueError, 'must be strings'):
+            tool.call(bl_numbers=[1])
+        with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
+            tool.call(bl_numbers=['x' * 101])
+        with self.assertRaisesRegex(ValueError, 'at most 200'):
+            tool.call(bl_numbers=['BL-{0}'.format(i) for i in range(201)])
+        with self.assertRaisesRegex(ValueError, 'cannot mix'):
+            tool.call(container_codes=['CONT-1'], bl_numbers=['BL-1'])
+        with self.assertRaisesRegex(ValueError, 'cannot mix'):
+            tool.call(
+                container_codes=['CONT-1'],
+                inbound_time_start='2026-09-01',
+                inbound_time_end='2026-09-02',
+            )
+        with self.assertRaisesRegex(ValueError, 'cannot mix'):
+            tool.call(
+                bl_numbers=['BL-1'],
+                inbound_time_start='2026-09-01',
+                inbound_time_end='2026-09-02',
+            )
+        with self.assertRaisesRegex(ValueError, 'both start and end'):
+            tool.call(inbound_time_start='2026-09-01')
+        with self.assertRaisesRegex(ValueError, 'both start and end'):
+            tool.call(inbound_time_end='2026-09-07')
+        with self.assertRaisesRegex(ValueError, 'within 31 days'):
+            tool.call(
+                inbound_time_start='2026-09-10',
+                inbound_time_end='2026-09-01',
+            )
+        with self.assertRaisesRegex(ValueError, 'within 31 days'):
+            tool.call(
+                inbound_time_start='2026-09-01',
+                inbound_time_end='2026-10-02',
+            )
+        with self.assertRaisesRegex(ValueError, 'date or datetime'):
+            tool.call(
+                inbound_time_start='09/01/2026',
+                inbound_time_end='2026-09-02',
+            )
+        with self.assertRaisesRegex(ValueError, 'provide'):
+            tool.call()
+
+        ExportPalletDataTool(client).call(
+            inbound_time_start='2026-09-01 00:00:00',
+            inbound_time_end='2026-10-01 23:59:59',
+        )
+        self.assertEqual(
+            {
+                'inbound_time_start': '2026-09-01 00:00:00',
+                'inbound_time_end': '2026-10-01 23:59:59',
+            },
+            client.calls[-1][2],
+        )
+
+    def test_cli_forwards_export_filters(self):
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        code = app.run_cli([
+            'call', '--tool', 'export_pallet_data',
+            '--container-codes', 'CONT-1, CONT-2',
+        ], stdout=io.StringIO())
+        self.assertEqual(0, code)
+        self.assertEqual(
+            {'container_codes': ['CONT-1', 'CONT-2']},
+            client.calls[-1][2],
+        )
+
+        code = app.run_cli([
+            'call', '--tool', 'export_pallet_data',
+            '--bl-numbers', 'BL-1, BL-2',
+        ], stdout=io.StringIO())
+        self.assertEqual(0, code)
+        self.assertEqual(
+            {'bl_numbers': ['BL-1', 'BL-2']},
+            client.calls[-1][2],
+        )
+
+        code = app.run_cli([
+            'call', '--tool', 'export_pallet_data',
+            '--inbound-time-start', '2026-09-01',
+            '--inbound-time-end', '2026-09-07 18:30:00',
+        ], stdout=io.StringIO())
+        self.assertEqual(0, code)
+        self.assertEqual(
+            {
+                'inbound_time_start': '2026-09-01',
+                'inbound_time_end': '2026-09-07 18:30:00',
+            },
+            client.calls[-1][2],
+        )
+
+    def test_local_and_public_registries_include_export_tool(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertEqual(42, len(local))
+        self.assertIn('export_pallet_data', local)
+        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+
+    def test_presenter_reuses_queued_export_contract(self):
+        presented = OutputPresenter().present(
+            'export_pallet_data',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'task_ref': 'mexp_pallet',
+                    'status': 'queued',
+                    'retry_after_seconds': 10,
+                },
+            },
+        )
+        self.assertFalse(presented['is_error'])
+        task = presented['structured_content']['task']
+        self.assertEqual('queued', task['status'])
+        self.assertEqual('mexp_pallet', task['task_ref'])
+        self.assertEqual(10, task['retry_after_seconds'])
+
+    def test_wrong_route_path_fails_and_restore_passes(self):
+        tool = ExportPalletDataTool()
+        original = tool.route_path
+        tool.route_path = '/mcp/tools/exportPalletDataWrong'
+        self.assertNotEqual('/mcp/tools/exportPalletData', tool.route_path)
+        tool.route_path = original
+        self.assertEqual('/mcp/tools/exportPalletData', tool.route_path)
+
+
+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()

+ 209 - 0
tests/test_export_receivable_cost_list_tool.py

@@ -0,0 +1,209 @@
+import io
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.export_receivable_cost_list import ExportReceivableCostListTool
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['export_receivable_cost_list']},
+        }
+
+    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': {
+                'task_ref': 'mexp_test',
+                'status': 'queued',
+                'retry_after_seconds': 10,
+            },
+        }
+
+
+class ExportReceivableCostListToolTest(unittest.TestCase):
+    def test_schema_is_closed_and_matches_list_filters_without_pagination(self):
+        metadata = ExportReceivableCostListTool().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('export_receivable_cost_list', metadata['name'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual({
+            'reference_numbers', 'tracking_numbers', 'order_numbers',
+            'bill_numbers', 'business_date_start', 'business_date_end',
+            'customer_id', 'sub_customer_id', 'billing_status',
+            'verification_status', 'document_type', 'cost_type_id',
+        }, set(schema['properties']))
+        self.assertNotIn('page', schema['properties'])
+        self.assertNotIn('limit', schema['properties'])
+        self.assertNotIn('ids', schema['properties'])
+        self.assertIn('明确要求导出应收费用单', metadata['description'])
+        self.assertIn('query_receivable_cost_list', metadata['description'])
+        self.assertIn('query_export_task', metadata['description'])
+        self.assertIn('禁止在一次调用内轮询', metadata['description'])
+
+    def test_call_forwards_exact_filters(self):
+        client = RecordingApiClient()
+        result = ExportReceivableCostListTool(client).call(
+            order_numbers=[' ORDER-1 '],
+            billing_status=0,
+        )
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual(
+            (
+                'export_receivable_cost_list',
+                '/mcp/tools/exportReceivableCostList',
+                {
+                    'order_numbers': ['ORDER-1'],
+                    'billing_status': 0,
+                },
+                'rq_export_receivable_cost_list',
+            ),
+            client.calls[0],
+        )
+
+    def test_call_validation_boundaries(self):
+        tool = ExportReceivableCostListTool()
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            tool.call(order_numbers=['O1'])
+
+        client = RecordingApiClient()
+        tool = ExportReceivableCostListTool(client)
+        with self.assertRaisesRegex(ValueError, 'non-empty list'):
+            tool.call(order_numbers='O1')
+        with self.assertRaisesRegex(ValueError, 'must be strings'):
+            tool.call(order_numbers=[1])
+        with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
+            tool.call(order_numbers=[''])
+        with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
+            tool.call(order_numbers=['x' * 101])
+        with self.assertRaisesRegex(ValueError, 'at most 200'):
+            tool.call(order_numbers=['n{0}'.format(i) for i in range(201)])
+        with self.assertRaisesRegex(ValueError, 'both start and end'):
+            tool.call(business_date_start='2026-07-01')
+        with self.assertRaisesRegex(ValueError, 'within 31 days'):
+            tool.call(
+                business_date_start='2026-07-01',
+                business_date_end='2026-08-01',
+            )
+        with self.assertRaisesRegex(ValueError, 'number filters or a business'):
+            tool.call()
+        with self.assertRaisesRegex(ValueError, 'requires customer_id'):
+            tool.call(order_numbers=['O1'], sub_customer_id=9)
+
+        ExportReceivableCostListTool(client).call(
+            order_numbers=['ORDER-1', ' ORDER-1 '],
+        )
+        self.assertEqual({'order_numbers': ['ORDER-1']}, client.calls[-1][2])
+
+        ExportReceivableCostListTool(client).call(
+            business_date_start='2026-07-01',
+            business_date_end='2026-07-31',
+            customer_id=7,
+            sub_customer_id=9,
+            verification_status=-1,
+            document_type=8,
+            cost_type_id=12,
+        )
+        self.assertEqual(
+            {
+                'business_date_start': '2026-07-01',
+                'business_date_end': '2026-07-31',
+                'customer_id': 7,
+                'sub_customer_id': 9,
+                'verification_status': -1,
+                'document_type': 8,
+                'cost_type_id': 12,
+            },
+            client.calls[-1][2],
+        )
+
+    def test_cli_forwards_export_filters(self):
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        code = app.run_cli([
+            'call', '--tool', 'export_receivable_cost_list',
+            '--order-numbers', 'ORDER-1',
+        ], stdout=io.StringIO())
+        self.assertEqual(0, code)
+        self.assertEqual({'order_numbers': ['ORDER-1']}, client.calls[-1][2])
+
+        code = app.run_cli([
+            'call', '--tool', 'export_receivable_cost_list',
+            '--order-numbers', 'ORDER-1',
+            '--business-date-start', '2026-07-01',
+            '--business-date-end', '2026-07-31',
+            '--customer-id', '7',
+            '--sub-customer-id', '9',
+            '--billing-status', '0',
+            '--verification-status', '-1',
+            '--document-type', '8',
+            '--cost-type-id', '12',
+        ], stdout=io.StringIO())
+        self.assertEqual(0, code)
+        self.assertEqual('export_receivable_cost_list', client.calls[-1][0])
+        self.assertEqual(
+            {
+                'order_numbers': ['ORDER-1'],
+                'business_date_start': '2026-07-01',
+                'business_date_end': '2026-07-31',
+                'customer_id': 7,
+                'sub_customer_id': 9,
+                'billing_status': 0,
+                'verification_status': -1,
+                'document_type': 8,
+                'cost_type_id': 12,
+            },
+            client.calls[-1][2],
+        )
+
+    def test_local_and_public_registries_include_export_tool(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertEqual(42, len(local))
+        self.assertIn('export_receivable_cost_list', local)
+        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+
+    def test_presenter_reuses_queued_export_contract(self):
+        presented = OutputPresenter().present(
+            'export_receivable_cost_list',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'task_ref': 'mexp_test',
+                    'status': 'queued',
+                    'retry_after_seconds': 10,
+                },
+            },
+        )
+        self.assertFalse(presented['is_error'])
+        task = presented['structured_content']['task']
+        self.assertEqual('queued', task['status'])
+        self.assertEqual('mexp_test', task['task_ref'])
+        self.assertEqual(10, task['retry_after_seconds'])
+
+    def test_wrong_route_path_fails_and_restore_passes(self):
+        tool = ExportReceivableCostListTool()
+        original = tool.route_path
+        tool.route_path = '/mcp/tools/exportReceivableCostListWrong'
+        self.assertNotEqual(
+            '/mcp/tools/exportReceivableCostList',
+            tool.route_path,
+        )
+        tool.route_path = original
+        self.assertEqual(
+            '/mcp/tools/exportReceivableCostList',
+            tool.route_path,
+        )
+
+
+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)

BIN
tests/test_gateway_query_order.pyc


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

BIN
tests/test_gateway_runtime.pyc


+ 22 - 0
tests/test_gateway_session_store.py

@@ -72,5 +72,27 @@ class GatewaySessionStoreTest(unittest.TestCase):
         self.assertTrue(touched['last_access_time'].endswith('Z'))
         self.assertEqual(touched['last_access_time'], store.get('GWS_employee_a')['last_access_time'])
 
+    def test_zero_ttl_is_not_coerced_to_thirty_days(self):
+        store = GatewaySessionStore(FakeRedis(), ttl_seconds=0)
+
+        self.assertEqual(0, store.ttl_seconds)
+
+    def test_save_without_ttl_omits_redis_expiry(self):
+        redis = FakeRedis()
+        store = GatewaySessionStore(redis, prefix='fms:mcp:gateway:', ttl_seconds=0)
+
+        store.save('GWS_employee_a', {'mcp_token': 'MT_A'})
+
+        self.assertIsNone(list(redis.values.values())[0]['ex'])
+
+    def test_touch_session_without_ttl_omits_redis_expiry(self):
+        redis = FakeRedis()
+        store = GatewaySessionStore(redis, prefix='fms:mcp:gateway:', ttl_seconds=0)
+
+        store.save('GWS_employee_a', {'mcp_token': 'MT_A'})
+        store.touch_session('GWS_employee_a')
+
+        self.assertIsNone(list(redis.values.values())[0]['ex'])
+
 if __name__ == '__main__':
     unittest.main()

BIN
tests/test_gateway_session_store.pyc


+ 15 - 0
tests/test_gateway_session_store_unit.py

@@ -118,6 +118,21 @@ 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()
+
+    def test_save_with_zero_ttl_does_not_pass_ex(self):
+        store = GatewaySessionStore(self.mock_redis, ttl_seconds=0)
+
+        store.save('GWS_permanent', {'mcp_token': 'MT_live'})
+
+        self.assertNotIn('ex', store.client.set.call_args[1])
+
 
 if __name__ == '__main__':
     unittest.main()

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1279 - 0
tests/test_headhaul_document_tools.py


+ 85 - 0
tests/test_inbound_receipt_tools.py

@@ -0,0 +1,85 @@
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.export_inbound_receipt import ExportInboundReceiptTool
+from tools.list_inbound_receipt_templates import ListInboundReceiptTemplatesTool
+
+
+class _Api:
+    def __init__(self):
+        self.calls = []
+
+    def call_tool(self, tool_name, route_path, payload, request_id):
+        self.calls.append((tool_name, route_path, payload, request_id))
+        return {'code': 'MCP_0000', 'data': {}}
+
+
+class InboundReceiptToolTest(unittest.TestCase):
+    def test_local_and_public_registries_match(self):
+        local = set(GatewayApp(api_client=object()).registered_tool_names())
+        public = set(PublicGatewayApp(object(), object()).registered_tool_names())
+        self.assertEqual(local, public)
+        self.assertIn('list_inbound_receipt_templates', local)
+        self.assertIn('export_inbound_receipt', local)
+
+    def test_list_tool_strips_and_dedupes_container_codes(self):
+        api = _Api()
+        tool = ListInboundReceiptTemplatesTool(api)
+        tool.call([' CONT-1 ', 'CONT-1'], request_id='rq_list')
+        self.assertEqual(
+            ('list_inbound_receipt_templates', '/mcp/tools/listInboundReceiptTemplates',
+             {'container_codes': ['CONT-1']}, 'rq_list'),
+            api.calls[0]
+        )
+        self.assertIn('container_codes', tool.metadata()['input_schema']['required'])
+
+    def test_export_requires_selected_template_and_does_not_wait(self):
+        api = _Api()
+        tool = ExportInboundReceiptTool(api)
+        description = tool.metadata()['description']
+        self.assertIn('query_export_task', description)
+        self.assertIn('不会在本次调用中等待文件生成', description)
+        tool.call(['CONT-1'], ' FMS-INBOUND-LAX ', request_id='rq_export')
+        self.assertEqual('FMS-INBOUND-LAX', api.calls[0][2]['template_code'])
+        with self.assertRaises(ValueError):
+            tool.call(['CONT-1'], '   ')
+
+    def test_presenter_shows_kind_and_passes_container_rejection(self):
+        presenter = OutputPresenter()
+        shown = presenter.present('list_inbound_receipt_templates', {
+            'code': 'MCP_0000',
+            'data': {
+                'receipt_kind': '直送入仓单',
+                'templates': [
+                    {'name': '直送柜', 'template_code': 'FMS-INBOUND-ZSG'},
+                ],
+            },
+            'meta': {'request_id': 'rq_ok'},
+        })
+        self.assertFalse(shown['is_error'])
+        self.assertEqual('直送入仓单', shown['structured_content']['receipt_kind'])
+        self.assertNotIn('id', shown['structured_content']['rows'][0])
+
+        rejected = presenter.present('list_inbound_receipt_templates', {
+            'code': 'MCP_1601',
+            'msg': '这些柜号没有对应的排舱单:NONE-1',
+            'data': {},
+            'meta': {'request_id': 'rq_missing'},
+        })
+        self.assertTrue(rejected['is_error'])
+        self.assertIn('NONE-1', rejected['text'])
+
+        hidden = presenter.present('export_inbound_receipt', {
+            'code': 'MCP_1601',
+            'msg': 'SQLSTATE select * from fms_outbound',
+            'data': {},
+            'meta': {'request_id': 'rq_sql'},
+        })
+        self.assertNotIn('SQLSTATE', hidden['text'])
+        self.assertIn('没有可导出的数据', hidden['text'])
+
+
+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()

+ 347 - 0
tests/test_order_abnormal_tools.py

@@ -0,0 +1,347 @@
+import importlib
+import io
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+
+
+COLUMNS = [
+    ('order_number', '订单号'), ('customer_name', '客户'),
+    ('product_name', '物流产品'), ('abnormal_status', '问题件状态'),
+    ('abnormal_type', '问题件类型'), ('abnormal_desc', '问题件描述'),
+    ('outbound_status', '排舱单状态'), ('inbound_date', '入库时间'),
+    ('marker_name', '标记人'),
+]
+STATUSES = [
+    '待处理', '放行待审核', '包裹放行', '退件待审核', '退件驳回', '已退件',
+]
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_order_abnormal_list',
+                    'list_order_abnormal_filter_options',
+                ],
+            },
+        }
+
+    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 OrderAbnormalToolContractTest(unittest.TestCase):
+    def tool_class(self, module_name, class_name):
+        return getattr(importlib.import_module('tools.' + module_name), class_name)
+
+    def test_query_schema_requires_status_and_forbids_identity(self):
+        cls = self.tool_class(
+            'query_order_abnormal_list', 'QueryOrderAbnormalListTool'
+        )
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('query_order_abnormal_list', metadata['name'])
+        self.assertEqual('/mcp/tools/queryOrderAbnormalList', cls.route_path)
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(['abnormal_status'], schema['required'])
+        self.assertEqual(STATUSES, schema['properties']['abnormal_status']['enum'])
+        self.assertNotIn('default', schema['properties']['abnormal_status'])
+        for forbidden in (
+            'company_id', 'admin_id', 'is_super', 'customer_id', 'product_id',
+        ):
+            self.assertNotIn(forbidden, schema['properties'])
+        self.assertIn('使用场景:', metadata['description'])
+        self.assertIn('禁止使用:', metadata['description'])
+        self.assertIn('号码类型不明确时必须先询问用户', metadata['description'])
+        self.assertIn('不得根据号码格式猜测', metadata['description'])
+        self.assertIn('不得跨字段或跨工具试查', metadata['description'])
+        self.assertIn('admin/Order/abnormal', metadata['description'])
+        self.assertIn('订单号可选', metadata['description'])
+
+    def test_query_forwards_optional_order_numbers(self):
+        cls = self.tool_class(
+            'query_order_abnormal_list', 'QueryOrderAbnormalListTool'
+        )
+        client = RecordingApiClient()
+        cls(client).call(
+            abnormal_status='包裹放行',
+            order_numbers=[' FMS-1 ', 'FMS-1'],
+            page=2,
+            limit=30,
+            request_id='rq_abn',
+        )
+        self.assertEqual(
+            (
+                'query_order_abnormal_list',
+                '/mcp/tools/queryOrderAbnormalList',
+                {
+                    'abnormal_status': '包裹放行',
+                    'order_numbers': ['FMS-1'],
+                    'page': 2,
+                    'limit': 30,
+                },
+                'rq_abn',
+            ),
+            client.calls[-1],
+        )
+        cls(client).call(abnormal_status='待处理')
+        self.assertEqual(
+            {'abnormal_status': '待处理', 'page': 1, 'limit': 20},
+            client.calls[-1][2],
+        )
+        with self.assertRaises(ValueError):
+            cls(client).call(abnormal_status='结案')
+        with self.assertRaises(RuntimeError):
+            cls().call(abnormal_status='待处理')
+
+    def test_filter_schema_and_call(self):
+        cls = self.tool_class(
+            'list_order_abnormal_filter_options',
+            'ListOrderAbnormalFilterOptionsTool',
+        )
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('list_order_abnormal_filter_options', metadata['name'])
+        self.assertEqual(
+            '/mcp/tools/listOrderAbnormalFilterOptions', cls.route_path
+        )
+        self.assertEqual(['filter_type'], schema['required'])
+        self.assertEqual(['问题件状态'], schema['properties']['filter_type']['enum'])
+        self.assertIn('query_order_abnormal_list', metadata['description'])
+        self.assertIn('禁止猜测', metadata['description'])
+
+        client = RecordingApiClient()
+        cls(client).call('问题件状态', keyword='待', page=2, limit=10)
+        self.assertEqual(
+            {
+                'filter_type': '问题件状态',
+                'keyword': '待',
+                'page': 2,
+                'limit': 10,
+            },
+            client.calls[-1][2],
+        )
+        with self.assertRaises(ValueError):
+            cls(client).call('问题件类型')
+        with self.assertRaises(RuntimeError):
+            cls().call('问题件状态')
+        with self.assertRaises(ValueError):
+            cls(client).call('问题件状态', keyword='x' * 101)
+        with self.assertRaises(ValueError):
+            cls(client).call('问题件状态', page=0)
+        with self.assertRaises(ValueError):
+            cls(client).call('问题件状态', limit=True)
+
+    def test_query_covers_remaining_validation_branches(self):
+        cls = self.tool_class(
+            'query_order_abnormal_list', 'QueryOrderAbnormalListTool'
+        )
+        client = RecordingApiClient()
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                abnormal_status='待处理',
+                order_numbers=['X%s' % i for i in range(201)],
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(abnormal_status='待处理', order_numbers='FMS-1')
+        with self.assertRaises(ValueError):
+            cls(client).call(abnormal_status='待处理', order_numbers=[1])
+        with self.assertRaises(ValueError):
+            cls(client).call(abnormal_status='待处理', order_numbers=[' '])
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                abnormal_status='待处理',
+                order_numbers=['X' * 101],
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(abnormal_status='待处理', page=0)
+        with self.assertRaises(ValueError):
+            cls(client).call(abnormal_status='待处理', limit=True)
+        with self.assertRaises(ValueError):
+            cls(client).call(abnormal_status='待处理', order_numbers=[])
+        with self.assertRaises(ValueError):
+            cls(client).call(abnormal_status='待处理', page=101)
+
+    def test_cli_forwards_status_and_order_numbers(self):
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        with self.assertRaises(ValueError):
+            app.run_cli([
+                'call', '--tool', 'query_order_abnormal_list',
+            ], stdout=io.StringIO())
+        app.run_cli([
+            'call', '--tool', 'query_order_abnormal_list',
+            '--abnormal-status', '退件待审核',
+            '--order-numbers', ' FMS-1,FMS-2 ',
+        ], stdout=io.StringIO())
+        self.assertEqual(
+            {
+                'abnormal_status': '退件待审核',
+                'order_numbers': ['FMS-1', 'FMS-2'],
+                'page': 1,
+                'limit': 20,
+            },
+            client.calls[-1][2],
+        )
+        app.run_cli([
+            'call', '--tool', 'query_order_abnormal_list',
+            '--abnormal-status', '待处理',
+        ], stdout=io.StringIO())
+        self.assertEqual(
+            {'abnormal_status': '待处理', 'page': 1, 'limit': 20},
+            client.calls[-1][2],
+        )
+        app.run_cli([
+            'call', '--tool', 'list_order_abnormal_filter_options',
+            '--filter-type', '问题件状态',
+        ], stdout=io.StringIO())
+        self.assertEqual('list_order_abnormal_filter_options', client.calls[-1][0])
+        with self.assertRaises(ValueError):
+            app.run_cli([
+                'call', '--tool', 'list_order_abnormal_filter_options',
+            ], stdout=io.StringIO())
+
+    def test_presenter_renders_nine_columns_and_fails_closed(self):
+        presenter = OutputPresenter()
+        meta = {
+            'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+        }
+        columns = [{'key': key, 'name': name} for key, name in COLUMNS]
+        record = {key: 'v' for key, _ in COLUMNS}
+        record['abnormal_status'] = '待处理'
+        ok = presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': columns,
+                'records': [record],
+            },
+            'meta': meta,
+        })
+        self.assertFalse(ok['is_error'])
+        self.assertEqual(
+            ['订单号', '客户', '物流产品', '问题件状态', '问题件类型',
+             '问题件描述', '排舱单状态', '入库时间', '标记人'],
+            [item['label'] for item in ok['structured_content']['headers']],
+        )
+
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': [],
+                'records': [],
+                'extra': 1,
+            },
+            'meta': meta,
+        })['is_error'])
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '结案',
+                'columns': columns,
+                'records': [],
+            },
+            'meta': meta,
+        })['is_error'])
+        bad_columns = list(columns)
+        bad_columns[0] = {'key': 'order_number', 'name': '错'}
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': bad_columns,
+                'records': [],
+            },
+            'meta': meta,
+        })['is_error'])
+        bad_record = dict(record)
+        bad_record['order_number'] = 1
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': columns,
+                'records': [bad_record],
+            },
+            'meta': meta,
+        })['is_error'])
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': columns,
+                'records': 'bad',
+            },
+            'meta': meta,
+        })['is_error'])
+        extra_record = dict(record)
+        extra_record['deal_remark'] = 'x'
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': columns,
+                'records': [extra_record],
+            },
+            'meta': meta,
+        })['is_error'])
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': None,
+                'records': [],
+            },
+            'meta': meta,
+        })['is_error'])
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': columns,
+                'records': [None],
+            },
+            'meta': meta,
+        })['is_error'])
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': columns,
+                'records': [],
+            },
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False,
+                'request_id': 'rq_x', 'total': 1,
+            },
+        })['is_error'])
+
+        options = presenter.present('list_order_abnormal_filter_options', {
+            'code': 'MCP_0000',
+            'data': {
+                'records': [
+                    {'value': '待处理', 'label': '待处理', 'code': ''},
+                ],
+            },
+            'meta': meta,
+        })
+        self.assertFalse(options['is_error'])
+
+    def test_local_public_registry_includes_both_tools(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertEqual(42, len(local))
+        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+        self.assertIn('query_order_abnormal_list', local)
+        self.assertIn('list_order_abnormal_filter_options', local)

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

+ 231 - 0
tests/test_order_receivable_cost_details_tool.py

@@ -0,0 +1,231 @@
+import copy
+import importlib.util
+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):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_order_receivable_cost_details']},
+        }
+
+    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': {}}
+
+
+class OrderReceivableCostDetailsToolTest(unittest.TestCase):
+    def load_tool_class(self):
+        path = os.path.join(
+            os.path.dirname(os.path.dirname(__file__)),
+            'tools',
+            'query_order_receivable_cost_details.py',
+        )
+        self.assertTrue(os.path.isfile(path), '订单应收费用 Gateway 工具尚未实现')
+        spec = importlib.util.spec_from_file_location('receivable_cost_tool', path)
+        module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(module)
+        return module.QueryOrderReceivableCostDetailsTool
+
+    def test_closed_schema_requires_explicit_order_number_and_forwards_exactly(self):
+        tool_class = self.load_tool_class()
+        client = RecordingApiClient()
+        metadata = tool_class(client).metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('query_order_receivable_cost_details', metadata['name'])
+        self.assertEqual(['order_number'], schema['required'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual({'order_number', 'page', 'limit'}, set(schema['properties']))
+        self.assertIn('明确订单号', metadata['description'])
+        self.assertIn('不得使用参考号', metadata['description'])
+        self.assertIn('不得模糊', metadata['description'])
+
+        result = tool_class(client).call(
+            order_number='ORD-260001', page=2, limit=30, request_id='rq_cost'
+        )
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual((
+            'query_order_receivable_cost_details',
+            '/mcp/tools/queryOrderReceivableCostDetails',
+            {'order_number': 'ORD-260001', 'page': 2, 'limit': 30},
+            'rq_cost',
+        ), client.calls[-1])
+
+    def test_invalid_arguments_fail_before_backend_call(self):
+        tool_class = self.load_tool_class()
+        client = RecordingApiClient()
+        tool = tool_class(client)
+        for arguments in (
+            {}, {'order_number': ''}, {'order_number': '   '},
+            {'order_number': 123}, {'order_number': True},
+            {'order_number': 'x' * 101}, {'order_number': 'ORD-1', 'page': 0},
+            {'order_number': 'ORD-1', 'page': 101},
+            {'order_number': 'ORD-1', 'limit': False},
+            {'order_number': 'ORD-1', 'limit': 101},
+        ):
+            with self.subTest(arguments=arguments):
+                with self.assertRaises((TypeError, ValueError)):
+                    tool.call(**arguments)
+        self.assertEqual([], client.calls)
+        with self.assertRaises(RuntimeError):
+            tool_class().call(order_number='ORD-1')
+
+    def test_registries_cli_and_payment_records_remain_available(self):
+        client = RecordingApiClient()
+        local = GatewayApp(api_client=client)
+        public = PublicGatewayApp(None, None)
+        self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
+        self.assertEqual(42, len(local.registered_tool_names()))
+        self.assertIn('query_order_receivable_cost_details', local.registered_tool_names())
+        self.assertIn('query_customer_payment_records', local.registered_tool_names())
+
+        stdout = io.StringIO()
+        local.run_cli([
+            'call', '--tool', 'query_order_receivable_cost_details',
+            '--order-number', 'ORD-260001', '--page', '2', '--limit', '30',
+        ], stdout=stdout)
+        self.assertEqual('MCP_0000', json.loads(stdout.getvalue())['code'])
+        self.assertEqual('ORD-260001', client.calls[-1][2]['order_number'])
+        with self.assertRaises(ValueError):
+            local.run_cli([
+                'call', '--tool', 'query_order_receivable_cost_details',
+            ], stdout=io.StringIO())
+
+
+class OrderReceivableCostDetailsPresenterTest(unittest.TestCase):
+    KEYS = [
+        'order_number', 'customer_name', 'cost_name', 'cost_customer_name',
+        'charge_weight', 'unit_price', 'quote_currency',
+        'receivable_original_amount', 'receivable_amount_cny',
+        'settlement_amount_cny', 'cost_confirmation_status',
+        'closing_status', 'verification_status',
+    ]
+    HEADERS = [
+        '订单号', '客户名称', '费用项', '费用客户', '计费重', '计费单价',
+        '原币币种', '应收原币', '应收金额(CNY)', '结算金额(CNY)',
+        '费用确认状态', '关账状态', '核销状态',
+    ]
+
+    SUMMARY_KEYS = [
+        'order_total_receivable_cny',
+        'order_total_settlement_cny',
+        'special_approval_amount_cny',
+        'settlement_gross_profit_cny',
+    ]
+    SUMMARY_HEADERS = [
+        '订单总应收(CNY)', '订单总结算(CNY)', '特批金额(CNY)', '结算毛利(CNY)',
+    ]
+
+    def payload(self):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'summary': {
+                    'order_total_receivable_cny': 72.0,
+                    'order_total_settlement_cny': 39.0,
+                    'special_approval_amount_cny': -14.4,
+                    'settlement_gross_profit_cny': 33.0,
+                },
+                'columns': [{'key': key, 'name': key} for key in self.KEYS],
+                'records': [{
+                    'order_number': 'ORD-260001', 'customer_name': '主客户',
+                    'cost_name': '海运费', 'cost_customer_name': '子客户(SC01)',
+                    'charge_weight': 12.35, 'unit_price': 8.5,
+                    'quote_currency': 'USD', 'receivable_original_amount': 10.0,
+                    'receivable_amount_cny': 72.0, 'settlement_amount_cny': 39.0,
+                    'cost_confirmation_status': '已确认', 'closing_status': '部分关账',
+                    'verification_status': '是',
+                }],
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        }
+
+    def test_presenter_translates_summary_and_exact_thirteen_columns(self):
+        result = OutputPresenter().present(
+            'query_order_receivable_cost_details', self.payload()
+        )
+        self.assertFalse(result['is_error'])
+        self.assertEqual(
+            [{'label': header} for header in self.SUMMARY_HEADERS],
+            result['structured_content']['summary']['headers'],
+        )
+        self.assertEqual(
+            [72.0, 39.0, -14.4, 33.0],
+            result['structured_content']['summary']['row'],
+        )
+        self.assertEqual(
+            [{'label': header} for header in self.HEADERS],
+            result['structured_content']['headers'],
+        )
+        self.assertEqual(13, len(result['structured_content']['rows'][0]))
+        self.assertIn('订单总应收(CNY)', result['text'])
+        self.assertIn('结算毛利(CNY)', result['text'])
+        serialized = json.dumps(result, ensure_ascii=False)
+        for key in self.KEYS + self.SUMMARY_KEYS:
+            self.assertNotIn(key, serialized)
+
+    def test_unknown_missing_reordered_and_bad_values_fail_closed(self):
+        cases = []
+        unknown = self.payload()
+        unknown['data']['records'][0]['secret'] = 'hidden'
+        cases.append(unknown)
+        missing = self.payload()
+        del missing['data']['records'][0]['cost_name']
+        cases.append(missing)
+        reordered = self.payload()
+        reordered['data']['columns'].reverse()
+        cases.append(reordered)
+        bad_columns = self.payload()
+        bad_columns['data']['columns'] = 'bad'
+        cases.append(bad_columns)
+        bad_column = self.payload()
+        bad_column['data']['columns'][0]['internal'] = True
+        cases.append(bad_column)
+        extra_data = self.payload()
+        extra_data['data']['internal'] = True
+        cases.append(extra_data)
+        missing_summary = self.payload()
+        del missing_summary['data']['summary']
+        cases.append(missing_summary)
+        extra_summary = self.payload()
+        extra_summary['data']['summary']['secret'] = 1
+        cases.append(extra_summary)
+        for amount in (True, '10.00', float('inf'), float('-inf'), float('nan')):
+            bad_summary = self.payload()
+            bad_summary['data']['summary']['order_total_receivable_cny'] = amount
+            cases.append(bad_summary)
+        bad_meta = self.payload()
+        bad_meta['meta']['total'] = 1
+        cases.append(bad_meta)
+        bad_text = self.payload()
+        bad_text['data']['records'][0]['order_number'] = []
+        cases.append(bad_text)
+        for amount in (True, '10.00', float('inf'), float('-inf'), float('nan')):
+            bad_amount = self.payload()
+            bad_amount['data']['records'][0]['receivable_amount_cny'] = amount
+            cases.append(bad_amount)
+
+        presenter = OutputPresenter()
+        for payload in cases:
+            with self.subTest(payload=payload):
+                self.assertTrue(presenter.present(
+                    'query_order_receivable_cost_details', payload
+                )['is_error'])
+
+    def test_payload_factory_does_not_share_nested_state(self):
+        self.assertEqual(self.payload(), copy.deepcopy(self.payload()))
+
+
+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()

+ 531 - 0
tests/test_outbound_query_tools.py

@@ -0,0 +1,531 @@
+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', 'cabinet_type', 'shipping_method',
+    'total_volume', 'total_weight', 'ship_schedule', 'closing_time',
+    'est_loading_time', 'operator', 'operation_modes', 'handle_method',
+    'operation_time', 'ship_company', 'vessel_name', 'route',
+    'cutoff_time_si', 'clearance_port', 'clearance_port_eta',
+    'loading_port', 'destination_port', 'wharf_name', 'transit_port',
+    'etd', 'eta', 'ata', 'clearance_release_time', 'overseas_warehouse',
+    'providers_name', 'has_fda', 'has_cpsc', 'has_food',
+]
+
+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',
+    'is_must_load', 'has_fda', 'has_cpsc', 'has_food',
+]
+
+
+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(40, len(content['headers']))
+        self.assertEqual(40, 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)
+        self.assertIn('航线', serialized)
+        self.assertIn('清关口岸ETA', serialized)
+        self.assertIn('整拼类型', serialized)
+        self.assertIn('操作方式', serialized)
+        self.assertIn('清关放行时间', serialized)
+        self.assertIn('ATA', serialized)
+        self.assertIn('到港码头', 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(38, len(content['details']['headers']))
+        self.assertEqual(38, 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()

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1028 - 0
tests/test_output_presenter.py


+ 654 - 0
tests/test_payable_cost_tools.py

@@ -0,0 +1,654 @@
+import copy
+import importlib
+import io
+import math
+from pathlib import Path
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_payable_cost_list',
+                    'list_payable_cost_filter_options',
+                    'export_payable_cost_list',
+                ],
+            },
+        }
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.calls.append((tool_code, route_path, payload, request_id))
+        if tool_code == 'export_payable_cost_list':
+            return {
+                'code': 'MCP_0000',
+                'data': {
+                    'task_ref': 'mexp_payable',
+                    'status': 'queued',
+                    'retry_after_seconds': 10,
+                },
+            }
+        return {'code': 'MCP_0000', 'data': {}, 'meta': {}}
+
+
+class PayableCostToolContractTest(unittest.TestCase):
+    FILTER_TYPES = (
+        '业务类型', '业务节点', '物流商', '费用项',
+        '是否生成账单', '是否付款', '核销状态', '单据类型',
+    )
+    QUERY_PROPERTIES = {
+        'business_type', 'order_numbers', 'tracking_numbers',
+        'container_codes', 'bl_numbers', 'so_numbers',
+        'business_date_start', 'business_date_end',
+        'cost_date_start', 'cost_date_end',
+        'operation_date_start', 'operation_date_end',
+        'business_node_id', 'provider_id', 'cost_type_ids',
+        'billing_status', 'payment_status', 'verification_status',
+        'document_type', 'page', 'limit',
+    }
+
+    def tool_class(self, module_name, class_name):
+        path = Path(__file__).parents[1] / 'tools' / (module_name + '.py')
+        self.assertTrue(path.is_file(), str(path))
+        return getattr(importlib.import_module('tools.' + module_name), class_name)
+
+    def test_query_schema_is_closed_and_locks_required_business_type(self):
+        cls = self.tool_class(
+            'query_payable_cost_list', 'QueryPayableCostListTool'
+        )
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('query_payable_cost_list', metadata['name'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(self.QUERY_PROPERTIES, set(schema['properties']))
+        self.assertIn('business_type', schema['required'])
+        self.assertEqual(
+            [1, 2, 3, 4, 5, 7],
+            schema['properties']['business_type']['enum'],
+        )
+        self.assertIn('list_payable_cost_filter_options', metadata['description'])
+        self.assertIn('禁止', metadata['description'])
+
+    def test_query_call_forwards_five_number_fields_and_three_dates(self):
+        cls = self.tool_class(
+            'query_payable_cost_list', 'QueryPayableCostListTool'
+        )
+        client = RecordingApiClient()
+        cls(client).call(
+            business_type=1,
+            order_numbers=[' O-1 '],
+            tracking_numbers=[' T-1 '],
+            container_codes=[' C-1 '],
+            bl_numbers=[' B-1 '],
+            so_numbers=[' S-1 '],
+            business_date_start='2026-07-01',
+            business_date_end='2026-07-31',
+            cost_date_start='2026-07-02',
+            cost_date_end='2026-07-30',
+            operation_date_start='2026-07-03',
+            operation_date_end='2026-07-29',
+            business_node_id=11,
+            provider_id=12,
+            cost_type_ids=[13, 14],
+            billing_status=0,
+            payment_status=-1,
+            verification_status=1,
+            document_type=0,
+            page=2,
+            limit=100,
+            request_id='rq_payable',
+        )
+        self.assertEqual(
+            (
+                'query_payable_cost_list',
+                '/mcp/tools/queryPayableCostList',
+                {
+                    'business_type': 1,
+                    'order_numbers': ['O-1'],
+                    'tracking_numbers': ['T-1'],
+                    'container_codes': ['C-1'],
+                    'bl_numbers': ['B-1'],
+                    'so_numbers': ['S-1'],
+                    'business_date_start': '2026-07-01',
+                    'business_date_end': '2026-07-31',
+                    'cost_date_start': '2026-07-02',
+                    'cost_date_end': '2026-07-30',
+                    'operation_date_start': '2026-07-03',
+                    'operation_date_end': '2026-07-29',
+                    'business_node_id': 11,
+                    'provider_id': 12,
+                    'cost_type_ids': [13, 14],
+                    'billing_status': 0,
+                    'payment_status': -1,
+                    'verification_status': 1,
+                    'document_type': 0,
+                    'page': 2,
+                    'limit': 100,
+                },
+                'rq_payable',
+            ),
+            client.calls[-1],
+        )
+
+    def test_query_call_rejects_number_date_and_filter_boundaries(self):
+        cls = self.tool_class(
+            'query_payable_cost_list', 'QueryPayableCostListTool'
+        )
+        tool = cls(RecordingApiClient())
+        invalid = [
+            {},
+            {'business_type': 6, 'order_numbers': ['O']},
+            {'business_type': 1},
+            {'business_type': 1, 'order_numbers': 'O'},
+            {'business_type': 1, 'order_numbers': [1]},
+            {'business_type': 1, 'order_numbers': ['']},
+            {'business_type': 1, 'order_numbers': ['x' * 101]},
+            {'business_type': 1, 'order_numbers': [str(i) for i in range(201)]},
+            {'business_type': 1, 'order_numbers': ['O'] * 201},
+            {'business_type': 1, 'business_date_start': '2026-07-01'},
+            {
+                'business_type': 1,
+                'cost_date_start': '2026-07-01',
+                'cost_date_end': '2026-08-01',
+            },
+            {'business_type': 1, 'operation_date_end': '2026-07-01'},
+            {'business_type': 1, 'order_numbers': ['O'], 'cost_type_ids': []},
+            {'business_type': 1, 'order_numbers': ['O'], 'cost_type_ids': [True]},
+            {'business_type': 1, 'order_numbers': ['O'], 'cost_type_ids': list(range(1, 202))},
+            {'business_type': 1, 'order_numbers': ['O'], 'page': 101},
+            {'business_type': 1, 'order_numbers': ['O'], 'limit': 0},
+        ]
+        for arguments in invalid:
+            with self.subTest(arguments=arguments):
+                with self.assertRaises((TypeError, ValueError)):
+                    tool.call(**arguments)
+
+    def test_filter_schema_and_call_lock_eight_types_and_cost_linkage(self):
+        cls = self.tool_class(
+            'list_payable_cost_filter_options',
+            'ListPayableCostFilterOptionsTool',
+        )
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(
+            {'filter_type', 'business_type', 'keyword', 'page', 'limit'},
+            set(schema['properties']),
+        )
+        self.assertEqual(
+            list(self.FILTER_TYPES),
+            schema['properties']['filter_type']['enum'],
+        )
+        client = RecordingApiClient()
+        cls(client).call(
+            filter_type=' 费用项 ', business_type=3,
+            keyword=' 运 ', page=2, limit=30,
+        )
+        self.assertEqual(
+            {
+                'filter_type': '费用项', 'business_type': 3,
+                'keyword': '运', 'page': 2, 'limit': 30,
+            },
+            client.calls[-1][2],
+        )
+        for arguments in (
+            {'filter_type': '费用项'},
+            {'filter_type': '业务节点', 'business_type': 6},
+            {'filter_type': '未知'},
+            {'filter_type': '业务类型', 'keyword': 1},
+            {'filter_type': '业务类型', 'page': 0},
+        ):
+            with self.subTest(arguments=arguments):
+                with self.assertRaises((TypeError, ValueError)):
+                    cls(client).call(**arguments)
+
+    def test_export_schema_matches_query_without_pagination(self):
+        cls = self.tool_class(
+            'export_payable_cost_list', 'ExportPayableCostListTool'
+        )
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('export_payable_cost_list', metadata['name'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(
+            self.QUERY_PROPERTIES - {'page', 'limit'},
+            set(schema['properties']),
+        )
+        self.assertIn('business_type', schema['required'])
+        self.assertIn('query_export_task', metadata['description'])
+        self.assertIn('单页签', metadata['description'])
+
+    def test_local_public_registry_cli_and_safe_counts_are_current(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertEqual(42, len(local))
+        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(
+            (
+                'query_payable_cost_list',
+                'list_payable_cost_filter_options',
+                'export_payable_cost_list',
+            ),
+            tuple(name for name in local if 'payable_cost' in name),
+        )
+
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        app.run_cli([
+            'call', '--tool', 'query_payable_cost_list',
+            '--business-type', '1', '--order-numbers', ' O-1,O-2 ',
+            '--cost-type-ids', '13,14', '--page', '2', '--limit', '30',
+        ], stdout=io.StringIO())
+        self.assertEqual(
+            {
+                'business_type': 1, 'order_numbers': ['O-1', 'O-2'],
+                'cost_type_ids': [13, 14], 'page': 2, 'limit': 30,
+            },
+            client.calls[-1][2],
+        )
+        app.run_cli([
+            'call', '--tool', 'list_payable_cost_filter_options',
+            '--filter-type', '费用项', '--business-type', '3',
+        ], stdout=io.StringIO())
+        self.assertEqual('list_payable_cost_filter_options', client.calls[-1][0])
+        app.run_cli([
+            'call', '--tool', 'export_payable_cost_list',
+            '--business-type', '7', '--order-numbers', 'E-1',
+        ], stdout=io.StringIO())
+        self.assertEqual('export_payable_cost_list', client.calls[-1][0])
+
+    def test_new_tool_error_paths_and_cli_forwarding_are_covered(self):
+        query_cls = self.tool_class(
+            'query_payable_cost_list', 'QueryPayableCostListTool'
+        )
+        with self.assertRaises(RuntimeError):
+            query_cls().call(1, order_numbers=['O-1'])
+        query = query_cls(RecordingApiClient())
+        query.call(business_type=1, order_numbers=['O', 'O'])
+        invalid = (
+            {
+                'business_type': 1, 'business_date_start': '2026-07-02',
+                'business_date_end': '2026-07-01',
+            },
+            {
+                'business_type': 1, 'business_date_start': 1,
+                'business_date_end': 1,
+            },
+            {
+                'business_type': 1, 'business_date_start': '2026-99-01',
+                'business_date_end': '2026-99-02',
+            },
+            {
+                'business_type': 1, 'business_date_start': '20260101',
+                'business_date_end': '20260102',
+            },
+            {'business_type': 1, 'order_numbers': ['O'], 'billing_status': 2},
+        )
+        for arguments in invalid:
+            with self.subTest(arguments=arguments):
+                with self.assertRaises((TypeError, ValueError)):
+                    query.call(**arguments)
+
+        export_cls = self.tool_class(
+            'export_payable_cost_list', 'ExportPayableCostListTool'
+        )
+        with self.assertRaises(RuntimeError):
+            export_cls().call(1, order_numbers=['O-1'])
+        with self.assertRaises(ValueError):
+            export_cls(RecordingApiClient()).call(6, order_numbers=['O-1'])
+
+        filter_cls = self.tool_class(
+            'list_payable_cost_filter_options',
+            'ListPayableCostFilterOptionsTool',
+        )
+        with self.assertRaises(RuntimeError):
+            filter_cls().call('业务类型')
+        filter_tool = filter_cls(RecordingApiClient())
+        with self.assertRaises(ValueError):
+            filter_tool.call(1)
+        with self.assertRaises(ValueError):
+            filter_tool.call('业务类型', business_type=True)
+        with self.assertRaises(ValueError):
+            filter_tool.call('业务类型', keyword='x' * 101)
+        filter_tool.call('业务类型')
+
+        calls = []
+        app = GatewayApp()
+        app.call_tool = lambda name, arguments, request_id='': (
+            calls.append((name, arguments, request_id))
+            or {'code': 'MCP_0000', 'data': {}, 'meta': {}}
+        )
+        output = io.StringIO()
+        app.run_cli([
+            'call', '--tool', 'query_payable_cost_list', '--business-type', '1',
+            '--order-numbers', 'O-1', '--tracking-numbers', 'T-1',
+            '--container-codes', 'C-1', '--bl-numbers', 'B-1', '--so-numbers', 'S-1',
+            '--business-date-start', '2026-07-01', '--business-date-end', '2026-07-02',
+            '--cost-date-start', '2026-07-03', '--cost-date-end', '2026-07-04',
+            '--operation-date-start', '2026-07-05', '--operation-date-end', '2026-07-06',
+            '--business-node-id', '11', '--provider-id', '12', '--cost-type-ids', '13,14',
+            '--billing-status', '1', '--payment-status', '-1',
+            '--verification-status', '2', '--document-type', '0',
+        ], stdout=output)
+        self.assertEqual('query_payable_cost_list', calls[-1][0])
+        app.run_cli([
+            'call', '--tool', 'export_payable_cost_list', '--business-type', '1',
+            '--order-numbers', 'O-1',
+        ], stdout=io.StringIO())
+        self.assertEqual('export_payable_cost_list', calls[-1][0])
+        with self.assertRaises(ValueError):
+            app.run_cli(['call', '--tool', 'query_payable_cost_list'], stdout=io.StringIO())
+        app.run_cli([
+            'call', '--tool', 'list_payable_cost_filter_options',
+            '--filter-type', '业务类型',
+        ], stdout=io.StringIO())
+        app.run_cli([
+            'call', '--tool', 'list_payable_cost_filter_options',
+            '--filter-type', '业务类型', '--business-type', '3',
+        ], stdout=io.StringIO())
+        with self.assertRaises(ValueError):
+            app.run_cli([
+                'call', '--tool', 'list_payable_cost_filter_options'
+            ], stdout=io.StringIO())
+
+        for argv in (
+            [
+                'call', '--tool', 'query_customer_payment_followup',
+                '--customer-id', '1', '--department-id', '2', '--sales-id', '3',
+                '--merchandiser-id', '4', '--has-unverified-receivable-only', 'false',
+            ],
+            [
+                'call', '--tool', 'query_customer_unverified_bill_details',
+                '--customer-id', '1',
+            ],
+            [
+                'call', '--tool', 'query_customer_payment_records',
+                '--customer-id', '1', '--receive-date-start', '2026-07-01',
+                '--receive-date-end', '2026-07-02',
+            ],
+            [
+                'call', '--tool', 'query_order_receivable_cost_details',
+                '--order-number', 'O-1',
+            ],
+        ):
+            app.run_cli(argv, stdout=io.StringIO())
+        app.run_cli([
+            'call', '--tool', 'query_customer_payment_followup',
+        ], stdout=io.StringIO())
+        for argv in (
+            ['call', '--tool', 'query_customer_unverified_bill_details'],
+            ['call', '--tool', 'query_customer_payment_records'],
+            ['call', '--tool', 'query_order_receivable_cost_details'],
+        ):
+            with self.assertRaises(ValueError):
+                app.run_cli(argv, stdout=io.StringIO())
+        app.run_cli([
+            'call', '--tool', 'query_customer_payment_records',
+            '--customer-id', '1', '--receive-date-start', '2026-07-01',
+        ], stdout=io.StringIO())
+        app.run_cli([
+            'call', '--tool', 'query_customer_payment_records',
+            '--customer-id', '1', '--receive-date-end', '2026-07-02',
+        ], stdout=io.StringIO())
+
+
+
+class PayableCostPresenterContractTest(unittest.TestCase):
+    COLUMN_MAP = {
+        1: [
+            ('number', '单号'), ('so_number', 'SO号'),
+            ('container_code', '柜号'), ('bl_number', '提单号'),
+            ('sub_number', '订单号'), ('business_node_name', '业务节点'),
+            ('providers_name', '物流商'), ('cost_name', '费用名称'),
+            ('payable_cost_q', '金额'), ('currency', '币种'),
+            ('company_money_q', '本位币金额'), ('yf_lock', '应付锁定'),
+            ('trade_date', '费用发生时间'),
+            ('verify_status_text', '核销状态'), ('remark', '备注'),
+            ('bill_no', '账单编号'),
+            ('payment_order_number', '付款单号'),
+        ],
+        2: [
+            ('number', '单号'), ('sub_number', '跟踪单号'),
+            ('workorder_number', '工单号'), ('order_type_name', '单据类型'),
+            ('providers_name', '物流商'), ('cost_name', '费用名称'),
+            ('payable_cost_q', '应付原币金额'), ('currency', '应付原币币种'),
+            ('company_money_q', '本位币金额CNY'), ('yf_lock', '应付锁定'),
+            ('business_date', '业务发生时间'),
+            ('trade_date', '费用发生时间'), ('create_date', '操作时间'),
+            ('verify_status_text', '核销状态'), ('remark', '备注'),
+            ('bill_no', '账单编号'),
+            ('payment_order_number', '付款单号'),
+        ],
+        3: [
+            ('number', '单号'), ('order_status_text', '包裹状态'),
+            ('providers_name', '物流商'),
+            ('business_node_name', '费用业务节点'),
+            ('cost_name', '费用名称'), ('detail_price', '计费单价'),
+            ('charge_weight', '计费重'), ('quote_cost', '计费金额'),
+            ('payable_cost_q', '实际应付金额'),
+            ('quote_currency', '原币币种'),
+            ('company_money_q', '计费本位币金额'),
+            ('yf_lock', '应付锁定'), ('bill_no', '账单编号'),
+            ('payment_order_number', '付款单号'),
+            ('trade_date', '费用发生时间'),
+            ('verify_status_text', '核销状态'), ('remark', '财务备注'),
+            ('create_user_name', '操作人'), ('create_date', '录入时间'),
+        ],
+        4: [
+            ('number', '订单号'), ('business_bill_no', '货代账单号'),
+            ('providers_name', '物流商'), ('cost_name', '费用名称'),
+            ('payable_cost_q', '金额'), ('currency', '币种'),
+            ('company_money_q', '本位币金额'),
+            ('department_name', '事业部'), ('yf_lock', '应付锁定'),
+            ('trade_date', '费用发生时间'),
+            ('verify_status_text', '核销状态'),
+            ('business_node_name', '业务节点'), ('remark', '备注'),
+            ('bill_no', '应付账单编号'),
+            ('payment_order_number', '付款单号'),
+        ],
+        5: [
+            ('order_type_name', '单据类型'), ('number', '订单号'),
+            ('warehouse_name', '仓库'),
+            ('business_node_name', '业务节点'),
+            ('providers_name', '物流商'), ('cost_name', '费用名称'),
+            ('payable_cost_q', '金额'), ('currency', '币种'),
+            ('company_money_q', '本位币金额'), ('yf_lock', '应付锁定'),
+            ('trade_date', '费用发生时间'),
+            ('verify_status_text', '核销状态'), ('remark', '备注'),
+            ('bill_no', '账单编号'),
+            ('payment_order_number', '付款单号'),
+        ],
+        7: [
+            ('number', '订单号'), ('ep_order_status_text', '订单状态'),
+            ('business_node_name', '业务节点'),
+            ('providers_name', '物流商'), ('cost_name', '费用名称'),
+            ('detail_price', '计费单价'), ('charge_weight', '计费重'),
+            ('quote_cost', '计费金额'),
+            ('payable_cost_q', '实际应付金额'),
+            ('quote_currency', '原币币种'),
+            ('company_money_q', '本位币金额'),
+            ('trade_date', '费用发生时间'), ('remark', '备注'),
+            ('verify_status_text', '核销状态'), ('bill_no', '账单编号'),
+            ('payment_order_number', '付款单号'),
+            ('create_user_name', '操作人'), ('create_date', '录入时间'),
+        ],
+    }
+    AMOUNT_KEYS = {
+        'detail_price', 'charge_weight', 'quote_cost',
+        'payable_cost_q', 'company_money_q',
+    }
+
+    def payload(self, business_type):
+        columns = self.COLUMN_MAP[business_type]
+        record = {}
+        for key, _ in columns:
+            record[key] = 12.5 if key in self.AMOUNT_KEYS else key + '-value'
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'business_type': business_type,
+                'company_currency': 'CNY',
+                'columns': [
+                    {'key': key, 'name': name} for key, name in columns
+                ],
+                'records': [record],
+            },
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False,
+                'request_id': 'rq_payable',
+            },
+        }
+
+    def test_six_business_types_have_exact_dynamic_columns(self):
+        presenter = OutputPresenter()
+        for business_type, columns in self.COLUMN_MAP.items():
+            with self.subTest(business_type=business_type):
+                result = presenter.present(
+                    'query_payable_cost_list', self.payload(business_type)
+                )
+                self.assertFalse(result['is_error'])
+                self.assertEqual(
+                    [{'label': name} for _, name in columns],
+                    result['structured_content']['headers'],
+                )
+                self.assertEqual(
+                    len(columns),
+                    len(result['structured_content']['rows'][0]),
+                )
+                self.assertEqual(
+                    business_type,
+                    result['structured_content']['business_type'],
+                )
+
+    def test_unknown_missing_wrong_order_and_non_finite_values_fail_closed(self):
+        presenter = OutputPresenter()
+        self.assertTrue(presenter.handles('query_payable_cost_list'))
+        cases = []
+        payload = self.payload(1)
+        payload['data']['columns'].append({'key': 'secret', 'name': '秘密'})
+        payload['data']['records'][0]['secret'] = 'hidden'
+        cases.append(payload)
+        payload = self.payload(1)
+        payload['data']['columns'].reverse()
+        cases.append(payload)
+        payload = self.payload(1)
+        payload['data']['records'][0].pop('cost_name')
+        cases.append(payload)
+        payload = self.payload(1)
+        payload['data']['records'][0]['secret'] = 'hidden'
+        cases.append(payload)
+        payload = self.payload(6) if 6 in self.COLUMN_MAP else self.payload(1)
+        payload['data']['business_type'] = 6
+        cases.append(payload)
+        for business_type in self.COLUMN_MAP:
+            for key, _ in self.COLUMN_MAP[business_type]:
+                if key not in self.AMOUNT_KEYS:
+                    continue
+                for value in (True, '12.5', math.inf, -math.inf, math.nan):
+                    payload = self.payload(business_type)
+                    payload['data']['records'][0][key] = value
+                    cases.append(payload)
+        for payload in cases:
+            with self.subTest(payload=payload):
+                self.assertTrue(presenter.present(
+                    'query_payable_cost_list', payload
+                )['is_error'])
+
+    def test_filter_and_export_presenters_use_safe_contracts(self):
+        presenter = OutputPresenter()
+        filtered = presenter.present(
+            'list_payable_cost_filter_options',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'records': [
+                        {'value': 0, 'label': '否', 'code': 'no'},
+                        {'value': -1, 'label': '未付款', 'code': 'unpaid'},
+                    ],
+                },
+                'meta': {'page': 1, 'limit': 20, 'has_more': False},
+            },
+        )
+        self.assertFalse(filtered['is_error'])
+        self.assertEqual(
+            [[0, '否', 'no'], [-1, '未付款', 'unpaid']],
+            filtered['structured_content']['rows'],
+        )
+        exported = presenter.present(
+            'export_payable_cost_list',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'task_ref': 'mexp_payable',
+                    'status': 'queued',
+                    'retry_after_seconds': 10,
+                },
+            },
+        )
+        self.assertFalse(exported['is_error'])
+        self.assertEqual(
+            'mexp_payable', exported['structured_content']['task']['task_ref']
+        )
+
+    def test_payable_filter_presenter_rejects_malformed_records_and_meta(self):
+        presenter = OutputPresenter()
+        valid = {
+            'code': 'MCP_0000',
+            'data': {
+                'records': [{'value': 0, 'label': 'ok', 'code': 'no'}],
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        }
+        cases = []
+        malformed = copy.deepcopy(valid)
+        malformed['data']['unexpected'] = True
+        cases.append(malformed)
+        malformed = copy.deepcopy(valid)
+        malformed['data']['records'][0].pop('code')
+        cases.append(malformed)
+        malformed = copy.deepcopy(valid)
+        malformed['data']['records'][0]['value'] = '0'
+        cases.append(malformed)
+        malformed = copy.deepcopy(valid)
+        malformed['data']['records'][0]['label'] = ''
+        cases.append(malformed)
+        malformed = copy.deepcopy(valid)
+        malformed['meta'].pop('has_more')
+        cases.append(malformed)
+        malformed = copy.deepcopy(valid)
+        malformed['meta']['unexpected'] = True
+        cases.append(malformed)
+        for payload in cases:
+            with self.subTest(payload=payload):
+                self.assertTrue(
+                    presenter.present(
+                        'list_payable_cost_filter_options', payload
+                    )['is_error']
+                )
+
+    def test_presenter_rejects_unknown_top_level_and_non_string_text_cells(self):
+        presenter = OutputPresenter()
+        payload = self.payload(1)
+        payload['data']['unexpected'] = True
+        self.assertTrue(
+            presenter.present('query_payable_cost_list', payload)['is_error']
+        )
+        payload = self.payload(1)
+        payload['data']['records'][0]['remark'] = 7
+        self.assertTrue(
+            presenter.present('query_payable_cost_list', payload)['is_error']
+        )
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 221 - 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,26 @@ 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_list_tools_touches_existing_session(self):
+        store = FakeSessionStore()
+        store.sessions['GWS_A'] = {'mcp_token': 'MT_A'}
+        app = PublicGatewayApp(session_store=store, api_client=FakeApiClient(), auth_client=None)
+
+        app.list_tools('GWS_A')
+
+        self.assertEqual(['GWS_A'], store.touched)
 
     def test_missing_session_returns_human_device_message(self):
         app = PublicGatewayApp(session_store=FakeSessionStore(), api_client=FakeApiClient(), auth_client=None)
@@ -57,6 +255,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 +275,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()

+ 175 - 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,19 +27,93 @@ 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:
             self.assertIn('name', tool)
             self.assertIn('description', tool)
             self.assertIn('input_schema', tool)
+        self.mock_session_store.touch_session.assert_called_once_with('GWS_test')
+
+    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()
+        self.mock_session_store.touch_session.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 +174,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 +189,66 @@ 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_list_tools_succeeds_when_store_has_no_touch_method(self):
+        class ReadOnlySessionStore:
+            def get(self, gateway_session_id):
+                return {'mcp_token': 'MT_read_only'}
+
+        api_client = MagicMock()
+        api_client.list_enabled_tools.return_value = {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_order']},
+        }
+        app = PublicGatewayApp(ReadOnlySessionStore(), api_client)
+
+        tools = app.list_tools('GWS_read_only')
+
+        self.assertEqual(['query_order'], [tool['name'] for tool in tools])
+
+    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 +278,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 +304,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()

BIN
tests/test_public_gateway_unit.pyc


BIN
tests/test_public_request_context.pyc


+ 639 - 10
tests/test_public_server.py

@@ -1,6 +1,11 @@
+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
+from constants import DEVICE_INVALID_MESSAGE
 
 
 class FakeContext:
@@ -19,21 +24,266 @@ 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_tools_list_redis_miss_returns_device_invalid(self):
+        class MissingSessionGateway(FakeGateway):
+            def list_tools(self, gateway_session_id, request_id=''):
+                raise RuntimeError(DEVICE_INVALID_MESSAGE)
+
+        reporter = RecordingReporter()
+        handler = PublicMcpHttpHandler(
+            MissingSessionGateway(),
+            context_parser=FakeParser(),
+            reporter=reporter,
+        )
+        with self.assertLogs('public_server', level='WARNING') as logs:
+            response = handler.handle_json_rpc(
+                headers={'X-Gateway-Session': 'GWS_A'},
+                message={'jsonrpc': '2.0', 'id': 32, 'method': 'tools/list', 'params': {}},
+                client_ip='10.0.0.5',
+            )
+
+        self.assertEqual(-32001, response['error']['code'])
+        self.assertEqual(DEVICE_INVALID_MESSAGE, response['error']['message'])
+        self.assertTrue(response['error']['data']['request_id'].startswith('rq_http_'))
+        event = next(item for item in reporter.events if item['stage'] == 'gateway_session')
+        self.assertEqual('failed', event['status'])
+        self.assertEqual('GATEWAY_SESSION_NOT_FOUND', event['event_code'])
+        record = logs.records[0]
+        self.assertEqual(-32001, record.protocol_code)
+        self.assertEqual('GATEWAY_SESSION_NOT_FOUND', record.diagnostic_reason)
+
+    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 +293,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': {}},
+        }
 
-    def test_missing_session_on_tool_call_returns_error_content(self):
+        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_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 +417,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 +460,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()
-

+ 256 - 0
tests/test_public_server_coverage.py

@@ -0,0 +1,256 @@
+"""
+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'])
+
+    def test_options_upload_returns_cors(self):
+        import urllib.request
+        req = urllib.request.Request(
+            self.base_url + '/mcp/upload-headhaul-document',
+            method='OPTIONS',
+        )
+        with urllib.request.urlopen(req, timeout=5) as resp:
+            self.assertEqual(204, resp.status)
+            self.assertEqual('*', resp.headers.get('Access-Control-Allow-Origin'))
+
+    def test_options_unknown_path_returns_404(self):
+        import urllib.request
+        req = urllib.request.Request(self.base_url + '/wrong', method='OPTIONS')
+        try:
+            urllib.request.urlopen(req, timeout=5)
+            self.fail('Expected 404')
+        except Exception as e:
+            self.assertEqual(404, e.code)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 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_seventeen_tools(self):
+        local = GatewayApp(api_client=RecordingApiClient()).registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+
+        self.assertEqual(local, public)
+        self.assertEqual(42, 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()

+ 242 - 0
tests/test_query_freightower_track_tool.py

@@ -0,0 +1,242 @@
+import io
+import unittest
+from unittest.mock import patch
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.query_freightower_track import QueryFreightowerTrackTool
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.call = None
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.call = (tool_code, route_path, payload, request_id)
+        return {'code': 'MCP_0000', 'data': {'ok': True}}
+
+
+def sample_data():
+    return {
+        'schedule': {
+            'bl_number': 'BL1',
+            'container_code': 'BOX1',
+            'carrier_name': '马士基',
+            'vessel': 'VESSEL',
+            'voyage': '001E',
+            'route_code': 'CVT',
+            'current_status': '抵港',
+            'current_status_time': '2026-09-20 01:00:00',
+            'current_status_place': '洛杉矶',
+            'update_time': '2026-09-20 02:00:00',
+            'etd': '2026-09-01 00:00:00',
+            'atd': '2026-09-02 00:00:00',
+            'eta': '2026-09-20 00:00:00',
+        },
+        'ports': [{
+            'place_type': '目的港',
+            'port_name': '洛杉矶',
+            'terminal_name': 'TCCL',
+            'etd': '',
+            'atd': '',
+            'eta': '2026-09-20',
+            'ata': '',
+        }],
+        'containers': [{
+            'container_no': 'BOX1',
+            'container_type': '40HC',
+            'seal_no': 'SEAL1',
+            'current_status': '抵港',
+            'off_load': '否',
+        }],
+        'tracks': [{
+            'time': '2026-09-20 01:00:00',
+            'location': 'LOS ANGELES',
+            'content': '抵港',
+            'event_code': 'BDAR',
+            'estimate_flag': '实际',
+            'vessel_voyage': 'VESSEL 001E',
+            'terminal_name': 'TCCL',
+        }],
+        'terminal_plan': {
+            'port_name': '上海',
+            'terminal_name': '外高桥',
+            'open_time': '2026-08-28 00:00:00',
+            'close_time': '2026-09-01 03:00:00',
+            'etd': '2026-09-01 00:00:00',
+            'atd': '',
+            'shipping_agent': '中外运',
+        },
+    }
+
+
+class QueryFreightowerTrackToolTest(unittest.TestCase):
+    def test_metadata_requires_explicit_number_type(self):
+        metadata = QueryFreightowerTrackTool().metadata()
+        description = metadata['description']
+        schema = metadata['input_schema']
+
+        self.assertEqual('query_freightower_track', metadata['name'])
+        self.assertNotIn('required', schema)
+        self.assertFalse(schema['additionalProperties'])
+        self.assertIn('使用场景:', description)
+        self.assertIn('禁止使用:', description)
+        self.assertIn('号码类型不明确时必须先询问用户', description)
+        self.assertIn('不得根据号码格式猜测', description)
+        self.assertIn('不得跨字段或跨工具试查', description)
+        self.assertIn('query_track', description)
+        self.assertIn('仅当用户明确', schema['properties']['bl_number']['description'])
+        self.assertIn('不得放入其他类型号码', schema['properties']['container_code']['description'])
+
+    def test_call_forwards_only_provided_numbers(self):
+        api = RecordingApiClient()
+        tool = QueryFreightowerTrackTool(api_client=api)
+
+        tool.call(bl_number=' BL1 ', container_code=' BOX1 ', request_id='rq_1')
+
+        self.assertEqual((
+            'query_freightower_track',
+            '/mcp/tools/queryFreightowerTrack',
+            {'bl_number': 'BL1', 'container_code': 'BOX1'},
+            'rq_1',
+        ), api.call)
+
+    def test_call_accepts_one_number(self):
+        api = RecordingApiClient()
+        QueryFreightowerTrackTool(api_client=api).call(container_code='BOX1')
+        self.assertEqual({'container_code': 'BOX1'}, api.call[2])
+
+    def test_invalid_numbers_are_rejected(self):
+        tool = QueryFreightowerTrackTool(api_client=RecordingApiClient())
+        with self.assertRaises(ValueError):
+            tool.call()
+        with self.assertRaises(ValueError):
+            tool.call(bl_number=123)
+        with self.assertRaises(ValueError):
+            tool.call(container_code='B' * 101)
+
+    def test_api_client_is_required(self):
+        with self.assertRaises(RuntimeError):
+            QueryFreightowerTrackTool().call(bl_number='BL1')
+
+    def test_registries_include_the_tool(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertIn('query_freightower_track', local)
+        self.assertEqual(42, len(local))
+        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+
+    def test_cli_forwards_bill_and_container(self):
+        app = GatewayApp()
+        stdout = io.StringIO()
+        with patch.object(app, 'call_tool', return_value={'code': 'MCP_0000'}) as call_tool:
+            code = app.run_cli([
+                'call',
+                '--tool', 'query_freightower_track',
+                '--bl-number', 'BL1',
+                '--container-code', 'BOX1',
+            ], stdout=stdout)
+        self.assertEqual(0, code)
+        self.assertEqual('query_freightower_track', call_tool.call_args[0][0])
+        self.assertEqual(
+            {'bl_number': 'BL1', 'container_code': 'BOX1'},
+            call_tool.call_args[0][1],
+        )
+
+    def test_cli_requires_a_number(self):
+        app = GatewayApp()
+        with self.assertRaises(ValueError):
+            app.run_cli([
+                'call',
+                '--tool', 'query_freightower_track',
+            ], stdout=io.StringIO())
+
+    def test_presenter_renders_schedule_ports_and_arrival(self):
+        result = OutputPresenter().present('query_freightower_track', {
+            'code': 'MCP_0000',
+            'data': sample_data(),
+            'meta': {'request_id': 'rq_ft'},
+        })
+        self.assertFalse(result['is_error'])
+        self.assertEqual('洛杉矶', result['structured_content']['港口'][0]['港口'])
+        self.assertEqual('BDAR', result['structured_content']['轨迹'][0]['状态代码'])
+        self.assertEqual('马士基', result['structured_content']['船期']['船公司'])
+        self.assertEqual('中外运', result['structured_content']['港区计划']['船代'])
+        self.assertIn('抵港', result['text'])
+        self.assertNotIn('bl_number', result['text'])
+        self.assertEqual({'request_id': 'rq_ft'}, result['meta'])
+
+    def test_presenter_accepts_empty_ports_and_tracks(self):
+        data = sample_data()
+        data['ports'] = []
+        data['containers'] = []
+        data['tracks'] = []
+        data['terminal_plan'] = {key: '' for key in data['terminal_plan']}
+        result = OutputPresenter().present('query_freightower_track', {
+            'code': 'MCP_0000',
+            'data': data,
+        })
+        self.assertFalse(result['is_error'])
+        self.assertIn('港口\n- 无', result['text'])
+        self.assertIn('箱子\n- 无', result['text'])
+        self.assertIn('轨迹\n- 无', result['text'])
+        self.assertIn('港区计划\n- 无', result['text'])
+
+    def test_presenter_rejects_unknown_shape(self):
+        cases = []
+        extra = sample_data()
+        extra['schedule']['secret'] = 'x'
+        cases.append(extra)
+        bad_type = sample_data()
+        bad_type['ports'][0]['place_type'] = '接货地'
+        cases.append(bad_type)
+        bad_value = sample_data()
+        bad_value['tracks'][0]['time'] = 1
+        cases.append(bad_value)
+        bad_schedule = sample_data()
+        bad_schedule['schedule']['vessel'] = 1
+        cases.append(bad_schedule)
+        bad_port_keys = sample_data()
+        bad_port_keys['ports'] = [{'place_type': '目的港'}]
+        cases.append(bad_port_keys)
+        bad_port_value = sample_data()
+        bad_port_value['ports'][0]['eta'] = 1
+        cases.append(bad_port_value)
+        cases.append(dict(sample_data(), ports=['x'], tracks=[1]))
+        bad_track_keys = sample_data()
+        bad_track_keys['tracks'] = [{'time': '2026-09-20'}]
+        cases.append(bad_track_keys)
+        cases.append({'schedule': {}})
+        for data in cases:
+            with self.subTest(data=data):
+                result = OutputPresenter().present('query_freightower_track', {
+                    'code': 'MCP_0000',
+                    'data': data,
+                })
+                self.assertTrue(result['is_error'])
+                self.assertNotIn('secret', result['text'])
+
+    def test_presenter_keeps_allowlisted_business_message(self):
+        result = OutputPresenter().present('query_freightower_track', {
+            'code': 'MCP_1501',
+            'msg': '暂时没有飞驼轨迹',
+            'data': [],
+        })
+        self.assertTrue(result['is_error'])
+        self.assertIn('暂时没有飞驼轨迹', result['text'])
+
+    def test_presenter_hides_unknown_upstream_message(self):
+        result = OutputPresenter().present('query_freightower_track', {
+            'code': 'MCP_1501',
+            'msg': 'carrier token leaked',
+            'data': [],
+        })
+        self.assertTrue(result['is_error'])
+        self.assertNotIn('leaked', result['text'])
+        self.assertIn('目标数据不可用', result['text'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 158 - 0
tests/test_query_goods_info_tool.py

@@ -0,0 +1,158 @@
+import io
+import unittest
+from unittest.mock import Mock, patch
+
+from app import GatewayApp
+from services.output_presenter import OutputPresenter
+from tools.query_goods_info import QueryGoodsInfoTool
+
+
+def goods_columns():
+    return [
+        {'key': key, 'name': name}
+        for key, name in OutputPresenter.GOODS_INFO_COLUMNS
+    ]
+
+
+def goods_record():
+    return {key: key + '-v' for key, _name in OutputPresenter.GOODS_INFO_COLUMNS}
+
+
+class QueryGoodsInfoToolTest(unittest.TestCase):
+    def test_call_forwards_sku_name_and_page(self):
+        client = Mock()
+        client.call_tool.return_value = {'code': 'MCP_0000'}
+        QueryGoodsInfoTool(client).call(
+            sku=' SKU-1 ',
+            cn_name=' 衬衫 ',
+            page=2,
+            limit=100,
+            request_id='rq_goods',
+            ignored=True,
+        )
+        client.call_tool.assert_called_once_with(
+            'query_goods_info',
+            '/mcp/tools/queryGoodsInfo',
+            {
+                'page': 2,
+                'limit': 100,
+                'sku': 'SKU-1',
+                'cn_name': '衬衫',
+            },
+            'rq_goods',
+        )
+
+    def test_call_accepts_either_sku_or_name(self):
+        client = Mock()
+        client.call_tool.return_value = {'code': 'MCP_0000'}
+        tool = QueryGoodsInfoTool(client)
+        tool.call(sku='SKU-1', cn_name='   ')
+        self.assertEqual({'page': 1, 'limit': 20, 'sku': 'SKU-1'}, client.call_tool.call_args[0][2])
+        tool.call(cn_name='衬衫', sku=None)
+        self.assertEqual(
+            {'page': 1, 'limit': 20, 'cn_name': '衬衫'},
+            client.call_tool.call_args[0][2],
+        )
+
+    def test_call_requires_api_client_and_a_lookup_field(self):
+        with self.assertRaises(RuntimeError):
+            QueryGoodsInfoTool().call(sku='SKU-1')
+        tool = QueryGoodsInfoTool(api_client=Mock())
+        with self.assertRaises(ValueError):
+            tool.call()
+        with self.assertRaises(ValueError):
+            tool.call(sku='', cn_name='')
+
+    def test_call_rejects_invalid_fields(self):
+        tool = QueryGoodsInfoTool(api_client=Mock())
+        with self.assertRaises(ValueError):
+            tool.call(sku=1)
+        with self.assertRaises(ValueError):
+            tool.call(cn_name=1)
+        with self.assertRaises(ValueError):
+            tool.call(sku='S' * 2001)
+        with self.assertRaises(ValueError):
+            tool.call(cn_name='名' * 101)
+        for page in (True, '1', 0, 101):
+            with self.subTest(page=page):
+                with self.assertRaises(ValueError):
+                    tool.call(sku='SKU-1', page=page)
+        with self.assertRaises(ValueError):
+            tool.call(sku='SKU-1', limit=0)
+
+    def test_cli_forwards_sku_or_name(self):
+        app = GatewayApp()
+        stdout = io.StringIO()
+        with self.assertRaises(ValueError):
+            app.run_cli(['call', '--tool', 'query_goods_info'], stdout=stdout)
+        with patch.object(app, 'call_tool', return_value={'code': 'MCP_0000'}) as call_tool:
+            self.assertEqual(0, app.run_cli([
+                'call', '--tool', 'query_goods_info', '--sku', 'SKU-1',
+            ], stdout=stdout))
+            self.assertEqual('SKU-1', call_tool.call_args[0][1]['sku'])
+            self.assertNotIn('cn_name', call_tool.call_args[0][1])
+            self.assertEqual(0, app.run_cli([
+                'call', '--tool', 'query_goods_info', '--cn-name', '衬衫',
+            ], stdout=stdout))
+            self.assertEqual('衬衫', call_tool.call_args[0][1]['cn_name'])
+            self.assertNotIn('sku', call_tool.call_args[0][1])
+            self.assertEqual(0, app.run_cli([
+                'call', '--tool', 'query_goods_info',
+                '--sku', 'SKU-1', '--cn-name', '衬衫',
+            ], stdout=stdout))
+            payload = call_tool.call_args[0][1]
+            self.assertEqual('SKU-1', payload['sku'])
+            self.assertEqual('衬衫', payload['cn_name'])
+
+    def test_presenter_renders_goods_table(self):
+        record = goods_record()
+        result = OutputPresenter().present('query_goods_info', {
+            'code': 'MCP_0000',
+            'data': {'columns': goods_columns(), 'records': [record]},
+            'meta': {'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_g'},
+        })
+        self.assertFalse(result['is_error'])
+        self.assertEqual(24, len(result['structured_content']['headers']))
+        self.assertEqual('sku-v', result['structured_content']['rows'][0][0])
+        self.assertIn('SKU', result['text'])
+        empty = OutputPresenter().present('query_goods_info', {
+            'code': 'MCP_0000',
+            'data': {'columns': goods_columns(), 'records': []},
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        })
+        self.assertFalse(empty['is_error'])
+        self.assertEqual([], empty['structured_content']['rows'])
+
+    def test_presenter_rejects_invalid_goods_payload(self):
+        presenter = OutputPresenter()
+        columns = goods_columns()
+        record = goods_record()
+        meta = {'page': 1, 'limit': 20, 'has_more': False}
+        bad_column = list(columns)
+        bad_column[0] = {'key': 'sku', 'name': '错'}
+        bad_record = dict(record)
+        bad_record['sku'] = 1
+        extra_record = dict(record)
+        extra_record['secret'] = 'x'
+        cases = [
+            {'columns': columns},
+            {'columns': 'bad', 'records': []},
+            {'columns': columns, 'records': None},
+            {'columns': columns[:1], 'records': []},
+            {'columns': columns, 'records': []},
+            {'columns': bad_column, 'records': []},
+            {'columns': columns, 'records': ['x']},
+            {'columns': columns, 'records': [extra_record]},
+            {'columns': columns, 'records': [bad_record]},
+        ]
+        metas = [meta, meta, meta, meta, {'request_id': 'rq'}, meta, meta, meta, meta]
+        for data, raw_meta in zip(cases, metas):
+            with self.subTest(data=data):
+                result = presenter.present('query_goods_info', {
+                    'code': 'MCP_0000',
+                    'data': data,
+                    'meta': raw_meta,
+                })
+                self.assertTrue(result['is_error'])
+                self.assertNotIn('secret', result['text'])
+                self.assertNotIn('sku-v', result['text'])

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

+ 14 - 0
tests/test_query_track_tool.py

@@ -33,6 +33,20 @@ class TestQueryTrackTool(unittest.TestCase):
         self.assertIn('tracking_number', schema['properties'])
         self.assertIn('page', schema['properties'])
         self.assertIn('limit', schema['properties'])
+        self.assertIn('订单号或排舱单号', meta['description'])
+        self.assertIn('跟踪号、柜号或快递单号', meta['description'])
+        self.assertIn(
+            '订单号、排舱单号、跟踪号、柜号还是快递单号',
+            meta['description'],
+        )
+        self.assertIn(
+            '订单号或排舱单号',
+            schema['properties']['order_number']['description'],
+        )
+        self.assertIn(
+            '跟踪号、柜号或快递单号',
+            schema['properties']['tracking_number']['description'],
+        )
         # order_id and order_number are optional but at least one is required
 
     def test_call_with_minimal_params(self):

BIN
tests/test_query_track_tool.pyc


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

+ 496 - 0
tests/test_receivable_cost_list_tools.py

@@ -0,0 +1,496 @@
+import copy
+import importlib.util
+import io
+import json
+import math
+import os
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+
+
+ROOT = os.path.dirname(os.path.dirname(__file__))
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_receivable_cost_list',
+                    'list_receivable_cost_filter_options',
+                ],
+            },
+        }
+
+    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': {}}
+
+
+class ToolLoaderMixin:
+    def load_tool(self, filename, class_name):
+        path = os.path.join(ROOT, 'tools', filename)
+        self.assertTrue(os.path.isfile(path), filename + ' is not implemented')
+        spec = importlib.util.spec_from_file_location(class_name, path)
+        module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(module)
+        return getattr(module, class_name)
+
+
+class ReceivableCostListToolTest(ToolLoaderMixin, unittest.TestCase):
+    NUMBER_FIELDS = (
+        'reference_numbers',
+        'tracking_numbers',
+        'order_numbers',
+        'bill_numbers',
+    )
+
+    def tool_class(self):
+        return self.load_tool(
+            'query_receivable_cost_list.py',
+            'QueryReceivableCostListTool',
+        )
+
+    def test_schema_is_closed_exact_and_zero_inference(self):
+        metadata = self.tool_class()().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('query_receivable_cost_list', metadata['name'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertNotIn('number', schema['properties'])
+        self.assertEqual({
+            'reference_numbers', 'tracking_numbers', 'order_numbers',
+            'bill_numbers', 'business_date_start', 'business_date_end',
+            'customer_id', 'sub_customer_id', 'billing_status',
+            'verification_status', 'document_type', 'cost_type_id',
+            'page', 'limit',
+        }, set(schema['properties']))
+        for field in self.NUMBER_FIELDS:
+            definition = schema['properties'][field]
+            self.assertEqual('array', definition['type'])
+            self.assertEqual(1, definition['minItems'])
+            self.assertEqual(200, definition['maxItems'])
+            self.assertEqual({
+                'type': 'string', 'minLength': 1, 'maxLength': 100,
+                'pattern': '.*\\S.*',
+            }, definition['items'])
+        self.assertEqual([0, 1], schema['properties']['billing_status']['enum'])
+        self.assertEqual(
+            [-1, 0, 1],
+            schema['properties']['verification_status']['enum'],
+        )
+        self.assertEqual(0, schema['properties']['document_type']['minimum'])
+        self.assertEqual(1, schema['properties']['cost_type_id']['minimum'])
+        self.assertIn('禁止根据格式猜测', metadata['description'])
+        self.assertIn(
+            'list_receivable_cost_filter_options',
+            metadata['description'],
+        )
+
+    def test_call_forwards_exact_filters_and_date_pair(self):
+        tool_class = self.tool_class()
+        client = RecordingApiClient()
+        result = tool_class(client).call(
+            reference_numbers=[' REF-1 '],
+            tracking_numbers=['TRACK-1'],
+            order_numbers=['ORDER-1'],
+            bill_numbers=['BILL-1'],
+            business_date_start='2026-07-01',
+            business_date_end='2026-07-31',
+            customer_id=7,
+            sub_customer_id=9,
+            billing_status=0,
+            verification_status=-1,
+            document_type=0,
+            cost_type_id=12,
+            page=2,
+            limit=30,
+            request_id='rq_receivable',
+        )
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual((
+            'query_receivable_cost_list',
+            '/mcp/tools/queryReceivableCostList',
+            {
+                'reference_numbers': ['REF-1'],
+                'tracking_numbers': ['TRACK-1'],
+                'order_numbers': ['ORDER-1'],
+                'bill_numbers': ['BILL-1'],
+                'business_date_start': '2026-07-01',
+                'business_date_end': '2026-07-31',
+                'customer_id': 7,
+                'sub_customer_id': 9,
+                'billing_status': 0,
+                'verification_status': -1,
+                'document_type': 0,
+                'cost_type_id': 12,
+                'page': 2,
+                'limit': 30,
+            },
+            'rq_receivable',
+        ), client.calls[-1])
+
+    def test_call_enforces_cross_field_and_strict_boundaries(self):
+        tool = self.tool_class()(RecordingApiClient())
+        invalid = [
+            {},
+            {'business_date_start': '2026-07-01'},
+            {'business_date_end': '2026-07-31'},
+            {
+                'business_date_start': '2026-07-01',
+                'business_date_end': '2026-08-01',
+            },
+            {'reference_numbers': []},
+            {'reference_numbers': [7]},
+            {'reference_numbers': [' ']},
+            {'reference_numbers': ['x' * 101]},
+            {'reference_numbers': ['x'] * 201},
+            {
+                'reference_numbers': ['x'] * 101,
+                'tracking_numbers': ['y'] * 100,
+            },
+            {'reference_numbers': ['x'], 'sub_customer_id': 9},
+            {'reference_numbers': ['x'], 'customer_id': True},
+            {'reference_numbers': ['x'], 'billing_status': 2},
+            {'reference_numbers': ['x'], 'verification_status': 2},
+            {'reference_numbers': ['x'], 'document_type': -1},
+            {'reference_numbers': ['x'], 'cost_type_id': 0},
+            {'reference_numbers': ['x'], 'page': 101},
+            {'reference_numbers': ['x'], 'limit': 1.5},
+            {
+                'reference_numbers': ['x'],
+                'business_date_start': 20260701,
+                'business_date_end': '2026-07-31',
+            },
+            {
+                'reference_numbers': ['x'],
+                'business_date_start': 'not-a-date',
+                'business_date_end': '2026-07-31',
+            },
+            {
+                'reference_numbers': ['x'],
+                'business_date_start': '20260701',
+                'business_date_end': '2026-07-31',
+            },
+            {
+                'reference_numbers': ['x'],
+                'business_date_start': '2026-99-01',
+                'business_date_end': '2026-07-31',
+            },
+        ]
+        for arguments in invalid:
+            with self.subTest(arguments=arguments):
+                with self.assertRaises((TypeError, ValueError)):
+                    tool.call(**arguments)
+        self.assertEqual([], tool.api_client.calls)
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            self.tool_class()().call(reference_numbers=['REF-1'])
+
+
+class ReceivableCostFilterOptionsToolTest(ToolLoaderMixin, unittest.TestCase):
+    FILTER_TYPES = ['主客户', '子客户', '出账状态', '核销状态', '单据类型', '费用项']
+
+    def tool_class(self):
+        return self.load_tool(
+            'list_receivable_cost_filter_options.py',
+            'ListReceivableCostFilterOptionsTool',
+        )
+
+    def test_schema_has_exact_types_and_optional_customer_id(self):
+        metadata = self.tool_class()().metadata()
+        schema = metadata['input_schema']
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(['filter_type'], schema['required'])
+        self.assertEqual(
+            self.FILTER_TYPES,
+            schema['properties']['filter_type']['enum'],
+        )
+        self.assertEqual(
+            {'type': 'integer', 'minimum': 1},
+            schema['properties']['customer_id'],
+        )
+        self.assertNotIn('allOf', schema)
+        self.assertIn('子客户', json.dumps(schema, ensure_ascii=False))
+        self.assertIn('禁止猜测', metadata['description'])
+        self.assertIn('可选', metadata['description'])
+
+    def test_call_forwards_sub_customer_with_optional_customer_id(self):
+        tool_class = self.tool_class()
+        client = RecordingApiClient()
+        tool = tool_class(client)
+        tool.call(
+            filter_type=' 子客户 ',
+            customer_id=7,
+            keyword=' 子 ',
+            page=2,
+            limit=30,
+            request_id='rq_filters',
+        )
+        self.assertEqual((
+            'list_receivable_cost_filter_options',
+            '/mcp/tools/listReceivableCostFilterOptions',
+            {
+                'filter_type': '子客户',
+                'customer_id': 7,
+                'keyword': '子',
+                'page': 2,
+                'limit': 30,
+            },
+            'rq_filters',
+        ), client.calls[-1])
+        tool.call(filter_type='子客户')
+        self.assertEqual(
+            {
+                'filter_type': '子客户',
+                'keyword': '',
+                'page': 1,
+                'limit': 20,
+            },
+            client.calls[-1][2],
+        )
+        tool.call(filter_type='主客户')
+        for arguments in (
+            {'filter_type': 7},
+            {'filter_type': '子客户', 'customer_id': 0},
+            {'filter_type': '主客户', 'customer_id': 7},
+            {'filter_type': '其他'},
+            {'filter_type': '主客户', 'keyword': 7},
+            {'filter_type': '主客户', 'keyword': 'x' * 101},
+            {'filter_type': '主客户', 'page': 0},
+            {'filter_type': '主客户', 'limit': True},
+            {'filter_type': '主客户', 'limit': 101},
+        ):
+            with self.subTest(arguments=arguments):
+                with self.assertRaises((TypeError, ValueError)):
+                    tool.call(**arguments)
+        self.assertEqual(3, len(client.calls))
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            tool_class().call('主客户')
+
+
+class ReceivableCostIntegrationTest(unittest.TestCase):
+    def test_registries_are_identical_ordered_and_counts_are_current(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertEqual(42, len(local))
+        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(
+            (
+                'query_receivable_cost_list',
+                'list_receivable_cost_filter_options',
+            ),
+            tuple(
+                name for name in local
+                if name in {
+                    'query_receivable_cost_list',
+                    'list_receivable_cost_filter_options',
+                }
+            ),
+        )
+
+    def test_cli_forwards_both_tools(self):
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        app.run_cli([
+            'call', '--tool', 'query_receivable_cost_list',
+            '--reference-numbers', ' REF-1,REF-2 ',
+            '--bill-numbers', ' BILL-1 ',
+            '--business-date-start', '2026-07-01',
+            '--business-date-end', '2026-07-31',
+            '--customer-id', '7', '--sub-customer-id', '9',
+            '--billing-status', '0', '--verification-status', '-1',
+            '--document-type', '0', '--cost-type-id', '12',
+            '--page', '2', '--limit', '30',
+        ], stdout=io.StringIO())
+        self.assertEqual({
+            'reference_numbers': ['REF-1', 'REF-2'],
+            'bill_numbers': ['BILL-1'],
+            'business_date_start': '2026-07-01',
+            'business_date_end': '2026-07-31',
+            'customer_id': 7,
+            'sub_customer_id': 9,
+            'billing_status': 0,
+            'verification_status': -1,
+            'document_type': 0,
+            'cost_type_id': 12,
+            'page': 2,
+            'limit': 30,
+        }, client.calls[-1][2])
+        app.run_cli([
+            'call', '--tool', 'query_receivable_cost_list',
+            '--order-numbers', 'ORDER-1',
+        ], stdout=io.StringIO())
+        self.assertEqual({
+            'order_numbers': ['ORDER-1'], 'page': 1, 'limit': 20,
+        }, client.calls[-1][2])
+        app.run_cli([
+            'call', '--tool', 'list_receivable_cost_filter_options',
+            '--filter-type', '子客户', '--customer-id', '7',
+            '--keyword', '子',
+        ], stdout=io.StringIO())
+        self.assertEqual({
+            'filter_type': '子客户', 'customer_id': 7,
+            'keyword': '子', 'page': 1, 'limit': 20,
+        }, client.calls[-1][2])
+        app.run_cli([
+            'call', '--tool', 'list_receivable_cost_filter_options',
+            '--filter-type', '主客户',
+        ], stdout=io.StringIO())
+        self.assertEqual({
+            'filter_type': '主客户', 'keyword': '',
+            'page': 1, 'limit': 20,
+        }, client.calls[-1][2])
+        with self.assertRaisesRegex(ValueError, '--filter-type is required'):
+            app.run_cli([
+                'call', '--tool', 'list_receivable_cost_filter_options',
+            ], stdout=io.StringIO())
+
+
+class ReceivableCostPresenterTest(unittest.TestCase):
+    KEYS = [
+        'main_customer_name', 'sub_customer_name', 'customer_attribute',
+        'department_name', 'document_type', 'warehouse_name', 'business_number',
+        'reference_number', 'cost_name', 'original_amount', 'total_amount',
+        'billed_amount', 'unbilled_amount', 'bill_number', 'verification_status',
+        'receipt_number', 'receipt_date', 'sales_name', 'merchandiser_name',
+        'drainage_user_name', 'first_leg_order_status', 'settlement_mode',
+        'cost_occurred_at', 'business_occurred_at',
+    ]
+    NAMES = [
+        '主客户', '子客户', '客户属性', '事业部', '单据类型', '仓库', '业务单号',
+        '参考号', '费用项', '原币金额', '总金额', '已出账金额', '未出账金额',
+        '账单编号', '核销状态', '收款水单号', '收款日期', '商务经理',
+        '客户经理', '引流人', '头程订单状态', '结算模式', '费用发生时间',
+        '业务发生时间',
+    ]
+    AMOUNT_KEYS = {
+        'original_amount', 'total_amount', 'billed_amount', 'unbilled_amount',
+    }
+
+    def payload(self):
+        record = {key: '值' for key in self.KEYS}
+        record.update({
+            'original_amount': {'amount': 10.25, 'currency': 'USD'},
+            'total_amount': {'amount': 70.5, 'currency': 'CNY'},
+            'billed_amount': {'amount': 20.0, 'currency': 'CNY'},
+            'unbilled_amount': {'amount': 50.5, 'currency': 'CNY'},
+        })
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'columns': [
+                    {'key': key, 'name': name}
+                    for key, name in zip(self.KEYS, self.NAMES)
+                ],
+                'records': [record],
+            },
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False,
+                'request_id': 'rq_receivable',
+            },
+        }
+
+    def test_query_presenter_requires_exact_contract(self):
+        result = OutputPresenter().present(
+            'query_receivable_cost_list',
+            self.payload(),
+        )
+        self.assertFalse(result['is_error'])
+        self.assertEqual(
+            [{'label': name} for name in self.NAMES],
+            result['structured_content']['headers'],
+        )
+        self.assertEqual(24, len(result['structured_content']['rows'][0]))
+        self.assertEqual(
+            {'page': 1, 'limit': 20, 'has_more': False},
+            result['structured_content']['pagination'],
+        )
+
+    def test_query_presenter_fails_closed_on_malformed_contract(self):
+        cases = []
+        for mutation in (
+            lambda value: value['data']['columns'].reverse(),
+            lambda value: value['data']['columns'][0].update({'secret': True}),
+            lambda value: value['data']['columns'][0].update({'name': '错误名称'}),
+            lambda value: value['data']['records'][0].update({'secret': 'hidden'}),
+            lambda value: value['data']['records'][0].pop('cost_name'),
+            lambda value: value['data']['records'][0].update({'cost_name': []}),
+            lambda value: value['data'].update({'secret': True}),
+            lambda value: value['meta'].update({'total': 1}),
+        ):
+            payload = self.payload()
+            mutation(payload)
+            cases.append(payload)
+        for key in self.AMOUNT_KEYS:
+            for amount in (True, '10', float('inf'), float('-inf'), float('nan')):
+                payload = self.payload()
+                payload['data']['records'][0][key]['amount'] = amount
+                cases.append(payload)
+            for malformed in (
+                {'amount': 1},
+                {'amount': 1, 'currency': 'CNY', 'secret': True},
+                {'amount': 1, 'currency': 7},
+            ):
+                payload = self.payload()
+                payload['data']['records'][0][key] = malformed
+                cases.append(payload)
+        for meta in (
+            {'page': 0, 'limit': 20, 'has_more': False},
+            {'page': 1, 'limit': 101, 'has_more': False},
+            {'page': 1, 'limit': 20, 'has_more': 0},
+        ):
+            payload = self.payload()
+            payload['meta'] = meta
+            cases.append(payload)
+        presenter = OutputPresenter()
+        for payload in cases:
+            with self.subTest(payload=payload):
+                self.assertTrue(presenter.present(
+                    'query_receivable_cost_list', payload
+                )['is_error'])
+
+    def test_filter_presenter_preserves_zero_and_negative_values(self):
+        payload = {
+            'code': 'MCP_0000',
+            'data': {
+                'records': [
+                    {'value': 0, 'label': '未出账', 'code': 'unbilled'},
+                    {'value': -1, 'label': '未核销', 'code': 'unverified'},
+                ],
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        }
+        result = OutputPresenter().present(
+            'list_receivable_cost_filter_options',
+            payload,
+        )
+        self.assertFalse(result['is_error'])
+        self.assertEqual(
+            [[0, '未出账', 'unbilled'], [-1, '未核销', 'unverified']],
+            result['structured_content']['rows'],
+        )
+        for mutation in (
+            lambda value: value['data'].update({'secret': True}),
+            lambda value: value['data']['records'][0].update({'secret': True}),
+            lambda value: value['data']['records'][0].update({'value': True}),
+            lambda value: value['data']['records'][0].update({'label': ''}),
+            lambda value: value['data']['records'][0].update({'code': 1}),
+            lambda value: value['meta'].update({'total': 2}),
+        ):
+            malformed = copy.deepcopy(payload)
+            mutation(malformed)
+            self.assertTrue(OutputPresenter().present(
+                'list_receivable_cost_filter_options',
+                malformed,
+            )['is_error'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 412 - 0
tests/test_receive_volume_tools.py

@@ -0,0 +1,412 @@
+import importlib
+import io
+import math
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+
+
+COLUMNS = [
+    ('order_number', '订单号'),
+    ('customer_name', '客户名称'),
+    ('packing_type', '货物类型'),
+    ('inbound_volume', '入库体积'),
+    ('inbound_date', '入库时间'),
+    ('sales_name', '商务经理'),
+    ('merchandiser_name', '客户经理'),
+    ('department_name', '事业部'),
+    ('product_name', '物流产品'),
+]
+FILTER_TYPES = ['客户名称', '商务经理', '客户经理', '货物类型', '物流产品']
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_receive_volume_list',
+                    'list_receive_volume_filter_options',
+                ],
+            },
+        }
+
+    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 ReceiveVolumeToolContractTest(unittest.TestCase):
+    def tool_class(self, module_name, class_name):
+        return getattr(importlib.import_module('tools.' + module_name), class_name)
+
+    def test_query_schema_requires_inbound_dates_and_forbids_identity(self):
+        cls = self.tool_class(
+            'query_receive_volume_list', 'QueryReceiveVolumeListTool'
+        )
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('query_receive_volume_list', metadata['name'])
+        self.assertEqual('/mcp/tools/queryReceiveVolumeList', cls.route_path)
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(
+            ['inbound_date_start', 'inbound_date_end'],
+            schema['required'],
+        )
+        for forbidden in (
+            'company_id', 'admin_id', 'is_super', 'order_numbers',
+            'business_date_start',
+        ):
+            self.assertNotIn(forbidden, schema['properties'])
+        self.assertIn('使用场景:', metadata['description'])
+        self.assertIn('禁止使用:', metadata['description'])
+        self.assertIn('入库时间', metadata['description'])
+        self.assertIn('admin/Report/receiveForm', metadata['description'])
+        self.assertIn('list_receive_volume_filter_options', metadata['description'])
+        self.assertIn('不得改走query_order', metadata['description'])
+
+    def test_query_forwards_optional_filters(self):
+        cls = self.tool_class(
+            'query_receive_volume_list', 'QueryReceiveVolumeListTool'
+        )
+        client = RecordingApiClient()
+        cls(client).call(
+            inbound_date_start='2026-09-01',
+            inbound_date_end='2026-09-30',
+            customer_ids=[11, 11, 12],
+            sales_ids=[3],
+            merchandiser_ids=[4],
+            packing_types=[' 散货 ', '散货'],
+            product_ids=[8],
+            page=2,
+            limit=30,
+            request_id='rq_rv',
+        )
+        self.assertEqual(
+            (
+                'query_receive_volume_list',
+                '/mcp/tools/queryReceiveVolumeList',
+                {
+                    'inbound_date_start': '2026-09-01',
+                    'inbound_date_end': '2026-09-30',
+                    'customer_ids': [11, 12],
+                    'sales_ids': [3],
+                    'merchandiser_ids': [4],
+                    'packing_types': ['散货'],
+                    'product_ids': [8],
+                    'page': 2,
+                    'limit': 30,
+                },
+                'rq_rv',
+            ),
+            client.calls[-1],
+        )
+        cls(client).call(
+            inbound_date_start='2026-09-01',
+            inbound_date_end='2026-09-01',
+        )
+        self.assertEqual(
+            {
+                'inbound_date_start': '2026-09-01',
+                'inbound_date_end': '2026-09-01',
+                'page': 1,
+                'limit': 20,
+            },
+            client.calls[-1][2],
+        )
+        with self.assertRaises(RuntimeError):
+            cls().call(
+                inbound_date_start='2026-09-01',
+                inbound_date_end='2026-09-30',
+            )
+
+    def test_query_rejects_invalid_window_and_ids(self):
+        cls = self.tool_class(
+            'query_receive_volume_list', 'QueryReceiveVolumeListTool'
+        )
+        client = RecordingApiClient()
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                inbound_date_start='2026-09-30',
+                inbound_date_end='2026-09-01',
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                inbound_date_start='2026-08-01',
+                inbound_date_end='2026-09-01',
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                inbound_date_start='2026-09-01',
+                inbound_date_end='2026-09-30',
+                customer_ids=[0],
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                inbound_date_start='2026-09-01',
+                inbound_date_end='2026-09-30',
+                packing_types=[''],
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                inbound_date_start='2026-09-01',
+                inbound_date_end='2026-09-30',
+                sales_ids=list(range(1, 202)),
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                inbound_date_start='2026-09-01',
+                inbound_date_end='2026-09-30',
+                packing_types=['x' * 101],
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                inbound_date_start='2026-09-01',
+                inbound_date_end='2026-09-30',
+                packing_types=[1],
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                inbound_date_start='2026-09-01',
+                inbound_date_end='2026-09-30',
+                packing_types=[],
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                inbound_date_start='bad',
+                inbound_date_end='2026-09-30',
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                inbound_date_start='2026-9-01',
+                inbound_date_end='2026-09-30',
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                inbound_date_start='2026-09-01',
+                inbound_date_end='2026-09-30',
+                packing_types=['n{0}'.format(i) for i in range(201)],
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                inbound_date_start='2026-09-01',
+                inbound_date_end='2026-09-30',
+                product_ids=['8'],
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                inbound_date_start='2026-09-01',
+                inbound_date_end='2026-09-30',
+                merchandiser_ids=[],
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                inbound_date_start='2026-09-01',
+                inbound_date_end='2026-09-30',
+                page=101,
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                inbound_date_start='2026-09-01',
+                inbound_date_end='2026-09-30',
+                limit=True,
+            )
+
+    def test_filter_schema_and_call(self):
+        cls = self.tool_class(
+            'list_receive_volume_filter_options',
+            'ListReceiveVolumeFilterOptionsTool',
+        )
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('list_receive_volume_filter_options', metadata['name'])
+        self.assertEqual(
+            '/mcp/tools/listReceiveVolumeFilterOptions', cls.route_path
+        )
+        self.assertEqual(['filter_type'], schema['required'])
+        self.assertEqual(FILTER_TYPES, schema['properties']['filter_type']['enum'])
+        self.assertIn('query_receive_volume_list', metadata['description'])
+        self.assertIn('禁止猜测', metadata['description'])
+        self.assertIn('使用场景:', metadata['description'])
+        self.assertIn('禁止使用:', metadata['description'])
+
+        client = RecordingApiClient()
+        cls(client).call('货物类型', keyword='散', page=2, limit=10)
+        self.assertEqual(
+            {
+                'filter_type': '货物类型',
+                'keyword': '散',
+                'page': 2,
+                'limit': 10,
+            },
+            client.calls[-1][2],
+        )
+        with self.assertRaises(ValueError):
+            cls(client).call('事业部')
+        with self.assertRaises(RuntimeError):
+            cls().call('客户名称')
+        with self.assertRaises(ValueError):
+            cls(client).call('客户名称', keyword='x' * 101)
+        with self.assertRaises(ValueError):
+            cls(client).call('客户名称', page=0)
+        with self.assertRaises(ValueError):
+            cls(client).call('客户名称', page=True)
+
+    def test_cli_forwards_dates_and_filters(self):
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        with self.assertRaises(ValueError):
+            app.run_cli([
+                'call', '--tool', 'query_receive_volume_list',
+            ], stdout=io.StringIO())
+        app.run_cli([
+            'call', '--tool', 'query_receive_volume_list',
+            '--inbound-date-start', '2026-09-01',
+            '--inbound-date-end', '2026-09-30',
+            '--customer-ids', '11,12',
+            '--sales-ids', '3',
+            '--merchandiser-ids', '4',
+            '--packing-types', '散货,整柜',
+            '--product-ids', '8',
+        ], stdout=io.StringIO())
+        self.assertEqual(
+            {
+                'inbound_date_start': '2026-09-01',
+                'inbound_date_end': '2026-09-30',
+                'customer_ids': [11, 12],
+                'sales_ids': [3],
+                'merchandiser_ids': [4],
+                'packing_types': ['散货', '整柜'],
+                'product_ids': [8],
+                'page': 1,
+                'limit': 20,
+            },
+            client.calls[-1][2],
+        )
+        app.run_cli([
+            'call', '--tool', 'query_receive_volume_list',
+            '--inbound-date-start', '2026-09-01',
+            '--inbound-date-end', '2026-09-02',
+        ], stdout=io.StringIO())
+        self.assertEqual(
+            {
+                'inbound_date_start': '2026-09-01',
+                'inbound_date_end': '2026-09-02',
+                'page': 1,
+                'limit': 20,
+            },
+            client.calls[-1][2],
+        )
+        app.run_cli([
+            'call', '--tool', 'list_receive_volume_filter_options',
+            '--filter-type', '物流产品',
+        ], stdout=io.StringIO())
+        self.assertEqual(
+            {
+                'filter_type': '物流产品',
+                'keyword': '',
+                'page': 1,
+                'limit': 20,
+            },
+            client.calls[-1][2],
+        )
+
+    def test_presenter_accepts_nine_columns_and_finite_volume(self):
+        presenter = OutputPresenter()
+        columns = [{'key': key, 'name': name} for key, name in COLUMNS]
+        record = {
+            'order_number': 'FMS-1',
+            'customer_name': '客户甲',
+            'packing_type': '散货',
+            'inbound_volume': 12.5,
+            'inbound_date': '2026-09-01',
+            'sales_name': '张三',
+            'merchandiser_name': '李四',
+            'department_name': '事业部A',
+            'product_name': '美线-US01',
+        }
+        meta = {
+            'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+        }
+        ok = presenter.present('query_receive_volume_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': [record]},
+            'meta': meta,
+        })
+        self.assertFalse(ok['is_error'])
+        self.assertEqual(12.5, ok['structured_content']['rows'][0][3])
+
+        string_volume = dict(record)
+        string_volume['inbound_volume'] = '12.5'
+        self.assertTrue(presenter.present('query_receive_volume_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': [string_volume]},
+            'meta': meta,
+        })['is_error'])
+        inf_volume = dict(record)
+        inf_volume['inbound_volume'] = math.inf
+        self.assertTrue(presenter.present('query_receive_volume_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': [inf_volume]},
+            'meta': meta,
+        })['is_error'])
+        extra = dict(record)
+        extra['inbound_pieces'] = '1'
+        self.assertTrue(presenter.present('query_receive_volume_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': [extra]},
+            'meta': meta,
+        })['is_error'])
+        self.assertTrue(presenter.present('query_receive_volume_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': [], 'extra': 1},
+            'meta': meta,
+        })['is_error'])
+        bad_columns = list(columns)
+        bad_columns[0] = {'key': 'order_number', 'name': '错'}
+        self.assertTrue(presenter.present('query_receive_volume_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': bad_columns, 'records': []},
+            'meta': meta,
+        })['is_error'])
+        non_string = dict(record)
+        non_string['order_number'] = 1
+        self.assertTrue(presenter.present('query_receive_volume_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': [non_string]},
+            'meta': meta,
+        })['is_error'])
+        self.assertTrue(presenter.present('query_receive_volume_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': []},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False,
+                'request_id': 'rq_x', 'total': 1,
+            },
+        })['is_error'])
+
+        options = presenter.present('list_receive_volume_filter_options', {
+            'code': 'MCP_0000',
+            'data': {
+                'records': [
+                    {'value': '散货', 'label': '散货', 'code': ''},
+                ],
+            },
+            'meta': meta,
+        })
+        self.assertFalse(options['is_error'])
+
+    def test_local_public_registry_includes_both_tools(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertEqual(42, len(local))
+        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+        self.assertIn('query_receive_volume_list', local)
+        self.assertIn('list_receive_volume_filter_options', local)

BIN
tests/test_redis_token_store.pyc


BIN
tests/test_request_context.pyc


BIN
tests/test_run_coverage.pyc


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

BIN
tests/test_scoped_api_client.pyc


BIN
tests/test_scoped_clients.pyc


BIN
tests/test_security.pyc


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

+ 209 - 0
tests/test_tool_description_boundaries.py

@@ -0,0 +1,209 @@
+import unittest
+
+from tools.export_out_of_province_port_data import (
+    ExportOutOfProvincePortDataTool,
+)
+from tools.export_pallet_data import ExportPalletDataTool
+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_destination_trailer_list import QueryDestinationTrailerListTool
+from tools.list_destination_trailer_filter_options import (
+    ListDestinationTrailerFilterOptionsTool,
+)
+from tools.query_order_abnormal_list import QueryOrderAbnormalListTool
+from tools.list_order_abnormal_filter_options import (
+    ListOrderAbnormalFilterOptionsTool,
+)
+from tools.query_goods_info import QueryGoodsInfoTool
+from tools.trial_goods_tax import TrialGoodsTaxTool
+from tools.query_receive_volume_list import QueryReceiveVolumeListTool
+from tools.list_receive_volume_filter_options import (
+    ListReceiveVolumeFilterOptionsTool,
+)
+from tools.query_container_timeliness_list import QueryContainerTimelinessListTool
+from tools.query_freightower_track import QueryFreightowerTrackTool
+from tools.export_container_timeliness_report import (
+    ExportContainerTimelinessReportTool,
+)
+from tools.query_headhaul_document_list import QueryHeadhaulDocumentListTool
+from tools.list_headhaul_document_filter_options import (
+    ListHeadhaulDocumentFilterOptionsTool,
+)
+from tools.prepare_headhaul_document_upload import (
+    PrepareHeadhaulDocumentUploadTool,
+)
+from tools.save_headhaul_document import SaveHeadhaulDocumentTool
+from tools.delete_headhaul_document import DeleteHeadhaulDocumentTool
+from tools.query_track import QueryTrackTool
+
+
+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(),
+            ExportPalletDataTool(),
+            QueryDestinationTrailerListTool(),
+            ListDestinationTrailerFilterOptionsTool(),
+            QueryOrderAbnormalListTool(),
+            ListOrderAbnormalFilterOptionsTool(),
+            QueryReceiveVolumeListTool(),
+            QueryGoodsInfoTool(),
+            TrialGoodsTaxTool(),
+            ListReceiveVolumeFilterOptionsTool(),
+            QueryContainerTimelinessListTool(),
+            ExportContainerTimelinessReportTool(),
+            QueryHeadhaulDocumentListTool(),
+            ListHeadhaulDocumentFilterOptionsTool(),
+            PrepareHeadhaulDocumentUploadTool(),
+            SaveHeadhaulDocumentTool(),
+            DeleteHeadhaulDocumentTool(),
+            QueryFreightowerTrackTool(),
+        )
+
+        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(),
+            ExportPalletDataTool(),
+            QueryDestinationTrailerListTool(),
+            QueryOrderAbnormalListTool(),
+            QueryContainerTimelinessListTool(),
+            ExportContainerTimelinessReportTool(),
+            QueryHeadhaulDocumentListTool(),
+            TrialGoodsTaxTool(),
+            QueryFreightowerTrackTool(),
+        )
+
+        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(): ('排舱列表', '40个字段', '排舱详情'),
+            QueryOutboundDetailTool(): ('排舱详情', '11项汇总', '38项订单明细'),
+            ListOrderFilterOptionsTool(): ('query_order_exact', '授权筛选值', '业务数据'),
+            ListPendingOutboundExportFilterOptionsTool(): (
+                'export_pending_outbound_orders', '授权筛选值', 'query_outbound_list',
+            ),
+            ExportPendingOutboundOrdersTool(): ('明确要求导出', '未排舱订单', '查看或查询'),
+            ExportOutOfProvincePortDataTool(): ('明确要求导出', '省外进港资料', '普通排舱查询'),
+            ExportPalletDataTool(): ('明确要求导出', '打托数据', 'query_export_task'),
+            QueryDestinationTrailerListTool(): (
+                '海外提柜', 'query_outbound_list', '六个类型',
+            ),
+            ListDestinationTrailerFilterOptionsTool(): (
+                'query_destination_trailer_list', '禁止猜测', '排舱列表',
+            ),
+            QueryOrderAbnormalListTool(): (
+                '头程问题件', 'admin/Order/abnormal', 'query_order',
+            ),
+            ListOrderAbnormalFilterOptionsTool(): (
+                'query_order_abnormal_list', '禁止猜测', '作废审核',
+            ),
+            QueryReceiveVolumeListTool(): (
+                '收货量', 'admin/Report/receiveForm', 'query_order',
+            ),
+            ListReceiveVolumeFilterOptionsTool(): (
+                'query_receive_volume_list', '禁止猜测', '导出',
+            ),
+            QueryContainerTimelinessListTool(): (
+                '柜子时效', 'admin/Report/outboundReport', 'query_outbound_list',
+            ),
+            ExportContainerTimelinessReportTool(): (
+                '明确要求导出', '柜子时效', 'query_export_task',
+            ),
+            QueryHeadhaulDocumentListTool(): (
+                '头程文档', 'admin/document/index', 'query_customs_declaration_files',
+            ),
+            ListHeadhaulDocumentFilterOptionsTool(): (
+                'query_headhaul_document_list', '禁止猜测', '报关资料',
+            ),
+            PrepareHeadhaulDocumentUploadTool(): (
+                '对话窗口', '选文件框', '工具参数',
+            ),
+            SaveHeadhaulDocumentTool(): (
+                'ticket_ref', '外链', 'OSS路径',
+            ),
+            DeleteHeadhaulDocumentTool(): (
+                'document_ref', '盲删', '批量删除',
+            ),
+            QueryFreightowerTrackTool(): (
+                '飞驼', 'query_track', '提单号',
+            ),
+        }
+
+        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(),
+            ExportPalletDataTool(),
+            ExportContainerTimelinessReportTool(),
+        ):
+            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()

+ 0 - 0
tests/test_trial_goods_tax_tool.py


Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels