jackson преди 2 седмици
родител
ревизия
8f2bebf87b
променени са 8 файла, в които са добавени 190 реда и са изтрити 16 реда
  1. 26 1
      README.md
  2. 37 0
      mcp_protocol.py
  3. 2 2
      services/api_client.py
  4. 3 3
      services/auth_client.py
  5. 5 5
      tests/test_auth_client.py
  6. 32 3
      tests/test_gateway_query_order.py
  7. 83 0
      tests/test_mcp_protocol.py
  8. 2 2
      tools/query_order.py

+ 26 - 1
README.md

@@ -193,4 +193,29 @@ python -m unittest discover -s tests -p "test_*.py"
 - 不在 Python 中沉淀额外业务规则
 - 不直接连接业务数据库
 - 不做动态插件加载
-- 第一阶段不开放写入型工具
+- 第一阶段不开放写入型工具
+## query_order BOM 排障
+
+如果 Workbuddy 调用 `query_order` 时提示:
+
+```text
+Unexpected UTF-8 BOM (decode using utf-8-sig)
+```
+
+说明后端工具接口返回的 JSON 前面带了 UTF-8 BOM。当前 Gateway 已在 HTTP JSON 解码层使用 `utf-8-sig` 做兼容。排查时先在 `mcp/` 目录运行:
+
+```powershell
+python -m unittest discover -s tests -p "test_*.py"
+```
+
+若全量测试通过但 Workbuddy 仍报同样错误,优先确认 Workbuddy 启动的 `app.py` 是否为 `\\192.168.1.241\chenjiacheng\mcp\app.py` 的最新文件。
+
+## 2026-07-07 ThinkPHP MCP route path
+
+ThinkPHP MCP routes now live in each backend project's `route/mcp/mcp_route.php`.
+The Gateway appends these paths to `FMS_AUTH_BASE` and `FMS_TOOLS_BASE`:
+
+- Auth: `/mcp/auth/exchange`, `/mcp/auth/refresh`, `/mcp/auth/revoke`
+- Tools: `/mcp/tools/queryOrder`
+
+Keep `FMS_AUTH_BASE` and `FMS_TOOLS_BASE` as host/base-domain values. Do not include `/admin/mcp` in these environment variables.

+ 37 - 0
mcp_protocol.py

@@ -112,8 +112,45 @@ class McpProtocolHandler:
     def _render_text(structured_content):
         if not structured_content:
             return 'ok'
+        columns = structured_content.get('columns') if isinstance(structured_content, dict) else None
+        records = structured_content.get('records') if isinstance(structured_content, dict) else None
+        if isinstance(columns, list) and isinstance(records, list):
+            return McpProtocolHandler._render_table_like_text(structured_content, columns, records)
         return json.dumps(structured_content, ensure_ascii=False)
 
+    @staticmethod
+    def _render_table_like_text(structured_content, columns, records):
+        lines = []
+        summary = str(structured_content.get('summary') or '').strip()
+        if summary:
+            lines.append(summary)
+        lines.append('表头共 {0} 列:'.format(len(columns)))
+        for index, column in enumerate(columns, start=1):
+            key = str(column.get('key') or '').strip()
+            name = str(column.get('name') or key).strip()
+            lines.append('{0}. {1} ({2})'.format(index, name, key))
+
+        if not records:
+            tips = structured_content.get('tips') or []
+            if tips:
+                lines.append('提示:{0}'.format(';'.join(str(tip) for tip in tips)))
+            return '\n'.join(lines)
+
+        for record_index, record in enumerate(records, start=1):
+            lines.append('订单 {0}:'.format(record_index))
+            for column in columns:
+                key = str(column.get('key') or '').strip()
+                name = str(column.get('name') or key).strip()
+                value = record.get(key, '') if isinstance(record, dict) else ''
+                if value is None:
+                    value = ''
+                lines.append('- {0}: {1}'.format(name, value))
+
+        tips = structured_content.get('tips') or []
+        if tips:
+            lines.append('提示:{0}'.format(';'.join(str(tip) for tip in tips)))
+        return '\n'.join(lines)
+
     @staticmethod
     def _success_response(request_id, result):
         return {

+ 2 - 2
services/api_client.py

@@ -1,4 +1,4 @@
-import json
+import json
 import urllib.request
 
 
@@ -11,7 +11,7 @@ class JsonTransport:
             method='POST',
         )
         with urllib.request.urlopen(request, timeout=timeout) as response:
-            return json.loads(response.read().decode('utf-8'))
+            return json.loads(response.read().decode('utf-8-sig'))
 
 
 class ApiClient:

+ 3 - 3
services/auth_client.py

@@ -15,7 +15,7 @@ class AuthClient:
         if self.session_key:
             payload['session_key'] = self.session_key
         response = self.transport.post_json(
-            self.base_url + '/admin/mcp/auth/exchange',
+            self.base_url + '/mcp/auth/exchange',
             payload,
             {},
             self.timeout,
@@ -25,7 +25,7 @@ class AuthClient:
 
     def refresh(self, mcp_token):
         response = self.transport.post_json(
-            self.base_url + '/admin/mcp/auth/refresh',
+            self.base_url + '/mcp/auth/refresh',
             {'mcp_token': mcp_token},
             {'Authorization': 'Bearer {0}'.format(mcp_token)},
             self.timeout,
@@ -35,7 +35,7 @@ class AuthClient:
 
     def revoke(self, mcp_token):
         response = self.transport.post_json(
-            self.base_url + '/admin/mcp/auth/revoke',
+            self.base_url + '/mcp/auth/revoke',
             {'mcp_token': mcp_token},
             {'Authorization': 'Bearer {0}'.format(mcp_token)},
             self.timeout,

+ 5 - 5
tests/test_auth_client.py

@@ -21,7 +21,7 @@ class DummyTransport:
                 'timeout': timeout,
             }
         )
-        if url.endswith('/admin/mcp/auth/exchange'):
+        if url.endswith('/mcp/auth/exchange'):
             return {
                 'code': 'MCP_0000',
                 'msg': 'success',
@@ -30,7 +30,7 @@ class DummyTransport:
                     'expire_time': '2099-01-01T00:00:00',
                 },
             }
-        if url.endswith('/admin/mcp/auth/refresh'):
+        if url.endswith('/mcp/auth/refresh'):
             return {
                 'code': 'MCP_0000',
                 'msg': 'success',
@@ -84,9 +84,9 @@ class AuthClientTest(unittest.TestCase):
         self.assertEqual('MT_refresh', refresh['data']['mcp_token'])
         self.assertIsNone(store.get())
         self.assertEqual({}, revoke['data'])
-        self.assertEqual('http://base.example.test/admin/mcp/auth/exchange', transport.calls[0]['url'])
-        self.assertEqual('http://base.example.test/admin/mcp/auth/refresh', transport.calls[1]['url'])
-        self.assertEqual('http://base.example.test/admin/mcp/auth/revoke', transport.calls[2]['url'])
+        self.assertEqual('http://base.example.test/mcp/auth/exchange', transport.calls[0]['url'])
+        self.assertEqual('http://base.example.test/mcp/auth/refresh', transport.calls[1]['url'])
+        self.assertEqual('http://base.example.test/mcp/auth/revoke', transport.calls[2]['url'])
         self.assertEqual({'auth_code': 'AUTH123', 'client_type': 'workbuddy'}, transport.calls[0]['payload'])
         self.assertEqual({'mcp_token': 'MT_exchange'}, transport.calls[1]['payload'])
         self.assertEqual({'mcp_token': 'MT_refresh'}, transport.calls[2]['payload'])

+ 32 - 3
tests/test_gateway_query_order.py

@@ -1,7 +1,8 @@
 import unittest
+from unittest.mock import patch
 
 from app import GatewayApp
-from services.api_client import ApiClient
+from services.api_client import ApiClient, JsonTransport
 from services.token_store import InMemoryTokenStore
 from tools.query_order import QueryOrderTool
 
@@ -33,6 +34,19 @@ class DummyTransport:
         }
 
 
+
+class BomHttpResponse:
+    def __init__(self, body):
+        self.body = body
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        return False
+
+    def read(self):
+        return self.body
 class GatewayQueryOrderTest(unittest.TestCase):
     def test_gateway_lists_query_order_tool(self):
         app = GatewayApp()
@@ -43,6 +57,20 @@ class GatewayQueryOrderTest(unittest.TestCase):
         self.assertIn('query_order', by_name)
         self.assertIn('keyword', by_name['query_order']['input_schema']['required'])
 
+    def test_json_transport_accepts_utf8_bom_response(self):
+        transport = JsonTransport()
+        body = b'\xef\xbb\xbf{"code":"MCP_0000","data":{"summary":"ok"}}'
+
+        with patch('urllib.request.urlopen', return_value=BomHttpResponse(body)):
+            response = transport.post_json(
+                'http://tools.example.test/mcp/tools/queryOrder',
+                {'keyword': 'SO20260706001'},
+                {},
+                8,
+            )
+
+        self.assertEqual('MCP_0000', response['code'])
+        self.assertEqual('ok', response['data']['summary'])
     def test_api_client_adds_mcp_headers_when_calling_tool(self):
         transport = DummyTransport()
         store = InMemoryTokenStore(refresh_skew_seconds=60)
@@ -56,7 +84,7 @@ class GatewayQueryOrderTest(unittest.TestCase):
 
         response = client.call_tool(
             tool_code='query_order',
-            route_path='/admin/mcp/tools/queryOrder',
+            route_path='/mcp/tools/queryOrder',
             payload={'keyword': 'SO20260706001', 'page': 1, 'limit': 20},
             request_id='rq_demo',
         )
@@ -66,7 +94,7 @@ class GatewayQueryOrderTest(unittest.TestCase):
         self.assertEqual('Bearer MT_demo', transport.calls[0]['headers']['Authorization'])
         self.assertEqual('query_order', transport.calls[0]['headers']['X-MCP-Tool-Code'])
         self.assertEqual('rq_demo', transport.calls[0]['headers']['X-Request-Id'])
-        self.assertEqual('http://tools.example.test/admin/mcp/tools/queryOrder', transport.calls[0]['url'])
+        self.assertEqual('http://tools.example.test/mcp/tools/queryOrder', transport.calls[0]['url'])
 
     def test_query_order_tool_normalizes_input_before_forwarding(self):
         transport = DummyTransport()
@@ -90,6 +118,7 @@ class GatewayQueryOrderTest(unittest.TestCase):
             },
             transport.calls[0]['payload'],
         )
+        self.assertEqual('http://tools.example.test/mcp/tools/queryOrder', transport.calls[0]['url'])
 
 
 if __name__ == '__main__':

+ 83 - 0
tests/test_mcp_protocol.py

@@ -37,6 +37,59 @@ class DummyApiClient:
             },
         }
 
+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)
+        columns = [
+            ('order_number', '订单号'),
+            ('reference_number', '客户参考号'),
+            ('status_txt_name', '状态'),
+            ('check_status_txt_name', '是否已查验'),
+            ('customer_name', '客户名称'),
+            ('customer_account_type_name', '客户属性'),
+            ('inbound_date', '入库时间'),
+            ('wo_num', '未完成工单'),
+            ('product_name', '物流产品'),
+            ('inbound_pieces', '件数'),
+            ('inbound_volume', '体积(CBM)'),
+            ('inbound_weight', '重量(KG)'),
+            ('pro_cn_name', '品名'),
+            ('export_declaration_type', '报关方式'),
+            ('merge_declare_number', '合并报关单号'),
+            ('delivery_address', '派送地址'),
+            ('container_code', '柜号'),
+            ('out_status_txt', '排舱单状态'),
+            ('hinge_of_destination', '目的港'),
+            ('etd', 'ETD'),
+            ('atd', 'ATD'),
+            ('eta', 'ETA'),
+            ('ata', 'ATA'),
+            ('release_time', '清关放行时间'),
+            ('oversea_inbound_date', '海外入库时间'),
+            ('appt_time', 'APPT时间'),
+            ('est_loading_time', '预计装柜时间'),
+            ('pickup_time', '海外提柜时间'),
+            ('delivery_way_title', '派送方式'),
+            ('tracking_number', '快递单号'),
+            ('shipment_id', 'SHIPMENT ID'),
+            ('goods_attribute', '商品属性'),
+            ('sku', 'SKU'),
+            ('sales_user', '商务经理'),
+            ('service_user', '客户经理'),
+            ('department_name', '事业部'),
+            ('remark', '订单备注'),
+            ('importer_name', '进口商'),
+            ('warehouse_name', '交货仓库'),
+            ('paid_status_name', '付款状态'),
+        ]
+        response['data']['columns'] = [
+            {'key': key, 'name': name, 'check': True} for key, name in columns
+        ]
+        response['data']['records'] = [
+            {key: '{0}-value'.format(key) for key, _name in columns}
+        ]
+        return response
+
 
 class McpProtocolTest(unittest.TestCase):
     def build_handler(self):
@@ -129,6 +182,36 @@ class McpProtocolTest(unittest.TestCase):
         self.assertEqual('text', response['result']['content'][0]['type'])
         self.assertIn('matched 1 order', response['result']['content'][0]['text'])
 
+    def test_tools_call_renders_all_query_order_columns_in_text_content(self):
+        token_store = InMemoryTokenStore(refresh_skew_seconds=60)
+        token_store.save('MT_demo', '2099-01-01T00:00:00')
+        app = GatewayApp(
+            auth_client=None,
+            api_client=FullColumnsApiClient(),
+            token_store=token_store,
+        )
+        handler = McpProtocolHandler(app)
+
+        response = handler.handle_request(
+            {
+                'jsonrpc': '2.0',
+                'id': 4,
+                'method': 'tools/call',
+                'params': {
+                    'name': 'query_order',
+                    'arguments': {
+                        'keyword': 'SO20260706001',
+                    },
+                },
+            }
+        )
+
+        text = response['result']['content'][0]['text']
+        self.assertIn('表头共 40 列', text)
+        self.assertIn('1. 订单号 (order_number)', text)
+        self.assertIn('40. 付款状态 (paid_status_name)', text)
+        self.assertIn('- 付款状态: paid_status_name-value', text)
+        self.assertEqual(40, len(response['result']['structuredContent']['columns']))
     def test_run_stdio_writes_only_request_responses(self):
         handler = self.build_handler()
         stdin = io.StringIO(

+ 2 - 2
tools/query_order.py

@@ -1,6 +1,6 @@
-class QueryOrderTool:
+class QueryOrderTool:
     name = 'query_order'
-    route_path = '/admin/mcp/tools/queryOrder'
+    route_path = '/mcp/tools/queryOrder'
 
     def __init__(self, api_client=None):
         self.api_client = api_client