jackson 2 주 전
부모
커밋
0fbd342225
7개의 변경된 파일355개의 추가작업 그리고 10개의 파일을 삭제
  1. 35 1
      README.md
  2. 30 7
      app.py
  3. 46 1
      mcp_protocol.py
  4. 27 0
      tests/test_cli_and_file_store.py
  5. 48 1
      tests/test_mcp_protocol.py
  6. 121 0
      tests/test_query_track_tool.py
  7. 48 0
      tools/query_track.py

+ 35 - 1
README.md

@@ -218,4 +218,38 @@ 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.
+Keep `FMS_AUTH_BASE` and `FMS_TOOLS_BASE` as host/base-domain values. Do not include `/admin/mcp` in these environment variables.
+## query_track / stdio 中文乱码排障
+
+如果后端 HTTP 返回、保存到 UTF-8 文件都正常,但 Workbuddy/AI 调 MCP 工具时中文显示成乱码,优先检查 Python Gateway 的 `serve-stdio` 输出层。
+
+本次踩坑原因:Windows 下 Python 标准输出可能使用本机控制台编码,而 MCP stdio 客户端按 JSON/UTF-8 读取时会出现中文乱码。当前 `mcp_protocol.py` 已将 JSON-RPC stdio 响应改为 `json.dumps(..., ensure_ascii=True)`,传输行只包含 ASCII JSON 转义;客户端解析 JSON 后仍是正常中文。
+
+排查顺序:
+- 先确认 HTTP 层 `ApiClient` 能用 `utf-8-sig` 正常解析后端 JSON。
+- 再确认 `serve-stdio` 输出不是直接吐本机编码中文。
+- 修改后需要重启 Workbuddy 拉起的 MCP Gateway 进程。
+
+验证命令:
+
+```powershell
+python -m unittest discover -s tests -p "test_*.py"
+```
+## 2026-07-07 query_track direct tracking-number lookup
+
+`query_track` now accepts `order_id`, `order_number`, or `tracking_number`.
+
+- With `order_id`, the backend keeps using `TrackLogic::fmsOrderQueryTrack($orderId)`.
+- With `tracking_number`, or with only `order_number`, the backend calls `TrackLogic::queryTrack($number)` directly. This matches the existing fmsoperate page `admin/Order/trackInfo.html?order_number=xxx`.
+- Python Gateway only forwards the arguments to `/mcp/tools/queryTrack`; it does not resolve orders locally or connect to the business database.
+
+Debug command:
+
+```powershell
+python app.py call --tool query_track --tracking-number 1471904540000000301 --page 1 --limit 20
+```
+## 2026-07-07 query_track timezone alignment
+
+Root cause: the backend page has `session('timezone')` from the normal login session, while MCP tool calls rebuild session data from `mcp_token`. The fmsoperate MCP middleware was reading timezone from `st_user`, but the normal backend session uses the company's timezone (`st_company.timezone_id -> st_timezone.value`). When timezone was empty, `CommonHelper::changeDateByTimeZone($time, 0, '')` returned the original UTC time.
+
+Fix: fmsoperate `McpTokenLogic::checkToken()` now loads the company timezone and returns a normalized `session_context`; `CheckMcpToken` writes `session('timezone')` from that context before calling tool logic. This keeps `query_track` aligned with `admin/Order/trackInfo.html?order_number=xxx`.

+ 30 - 7
app.py

@@ -10,9 +10,10 @@ from services.auth_client import AuthClient
 from services.token_store import FileTokenStore, RedisSocketClient, RedisTokenStore
 from tools.bind_auth_code import BindAuthCodeTool
 from tools.query_order import QueryOrderTool
+from tools.query_track import QueryTrackTool
 
 
-BIND_HINT = '请先登录后台获取 Workbuddy 授权码,然后在 Workbuddy 中输入“绑定授权码 xxx”完成绑定。'
+BIND_HINT = '\u8bf7\u5148\u767b\u5f55\u540e\u53f0\u83b7\u53d6 Workbuddy \u6388\u6743\u7801\uff0c\u7136\u540e\u5728 Workbuddy \u4e2d\u8f93\u5165\u201c\u7ed1\u5b9a\u6388\u6743\u7801 xxx\u201d\u5b8c\u6210\u7ed1\u5b9a\u3002'
 
 
 class GatewayApp:
@@ -23,6 +24,7 @@ class GatewayApp:
         self._tools = {
             'bind_auth_code': BindAuthCodeTool(auth_client=auth_client),
             'query_order': QueryOrderTool(api_client=api_client),
+            'query_track': QueryTrackTool(api_client=api_client),
         }
 
     @classmethod
@@ -118,7 +120,10 @@ class GatewayApp:
 
         call_parser = subparsers.add_parser('call')
         call_parser.add_argument('--tool', required=True)
-        call_parser.add_argument('--keyword', required=True)
+        call_parser.add_argument('--keyword', default='')
+        call_parser.add_argument('--order-id', type=int, default=0)
+        call_parser.add_argument('--order-number', default='')
+        call_parser.add_argument('--tracking-number', 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='')
@@ -132,13 +137,31 @@ class GatewayApp:
         elif args.command == 'bind':
             payload = self.bind(args.auth_code)
         elif args.command == 'call':
+            tool_args = {
+                'page': args.page,
+                'limit': args.limit,
+            }
+
+            if args.tool == 'query_order':
+                if not args.keyword:
+                    raise ValueError('--keyword is required for query_order')
+                tool_args['keyword'] = args.keyword
+            elif args.tool == 'query_track':
+                if args.order_id > 0:
+                    tool_args['order_id'] = args.order_id
+                if args.order_number:
+                    tool_args['order_number'] = args.order_number
+                if args.tracking_number:
+                    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')
+            else:
+                if args.keyword:
+                    tool_args['keyword'] = args.keyword
+
             payload = self.call_tool(
                 args.tool,
-                {
-                    'keyword': args.keyword,
-                    'page': args.page,
-                    'limit': args.limit,
-                },
+                tool_args,
                 request_id=args.request_id,
             )
         else:

+ 46 - 1
mcp_protocol.py

@@ -97,7 +97,7 @@ class McpProtocolHandler:
                 response = self.handle_message(message)
             if response is None:
                 continue
-            stdout.write(json.dumps(response, ensure_ascii=False) + '\n')
+            stdout.write(json.dumps(response, ensure_ascii=True) + '\n')
             if hasattr(stdout, 'flush'):
                 stdout.flush()
         return 0
@@ -116,6 +116,11 @@ class McpProtocolHandler:
         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)
+        if isinstance(records, list) and records:
+            # Check if this is a track-like structure (no columns, but has records)
+            first_record = records[0] if records else {}
+            if 'status' in first_record and 'content' in first_record and 'time' in first_record:
+                return McpProtocolHandler._render_track_like_text(structured_content, records)
         return json.dumps(structured_content, ensure_ascii=False)
 
     @staticmethod
@@ -151,6 +156,46 @@ class McpProtocolHandler:
             lines.append('提示:{0}'.format(';'.join(str(tip) for tip in tips)))
         return '\n'.join(lines)
 
+    @staticmethod
+    def _render_track_like_text(structured_content, records):
+        lines = []
+        summary = str(structured_content.get('summary') or '').strip()
+        if summary:
+            lines.append(summary)
+
+        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):
+            status = str(record.get('status') or '').strip()
+            content = str(record.get('content') or '').strip()
+            location = str(record.get('location') or '').strip()
+            time = str(record.get('time') or '').strip()
+            tracking_number = str(record.get('tracking_number') or '').strip()
+            shipment_id = str(record.get('shipment_id') or '').strip()
+            sub_track = int(record.get('sub_track') or 0)
+
+            lines.append('轨迹 {0}:'.format(record_index))
+            lines.append('- 状态: {0}'.format(status or '无'))
+            lines.append('- 内容: {0}'.format(content or '无'))
+            if location:
+                lines.append('- 地点: {0}'.format(location))
+            lines.append('- 时间: {0}'.format(time or '无'))
+            if tracking_number:
+                lines.append('- 快递单号: {0}'.format(tracking_number))
+            if shipment_id:
+                lines.append('- Shipment ID: {0}'.format(shipment_id))
+            if sub_track:
+                lines.append('- 子单轨迹: 是')
+
+        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 {

+ 27 - 0
tests/test_cli_and_file_store.py

@@ -101,6 +101,33 @@ class CliAndFileStoreTest(unittest.TestCase):
             self.assertEqual('query_order', api_client.calls[0]['tool_code'])
             self.assertEqual({'keyword': 'SO20260706001', 'page': 2, 'limit': 15}, api_client.calls[0]['payload'])
 
+    def test_run_cli_query_track_accepts_tracking_number(self):
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            path = os.path.join(tmp_dir, 'token.json')
+            token_store = FileTokenStore(path, refresh_skew_seconds=60)
+            token_store.save('MT_bound', '2099-01-01T00:00:00')
+            api_client = DummyApiClient()
+            app = GatewayApp(
+                auth_client=DummyAuthClient(token_store),
+                api_client=api_client,
+                token_store=token_store,
+            )
+
+            stdout = io.StringIO()
+            exit_code = app.run_cli([
+                'call',
+                '--tool', 'query_track',
+                '--tracking-number', '1471904540000000301',
+                '--page', '1',
+                '--limit', '20',
+            ], stdout=stdout)
+
+            self.assertEqual(0, exit_code)
+            self.assertEqual('query_track', api_client.calls[0]['tool_code'])
+            self.assertEqual('/mcp/tools/queryTrack', api_client.calls[0]['route_path'])
+            self.assertEqual('1471904540000000301', api_client.calls[0]['payload']['tracking_number'])
+            self.assertNotIn('order_id', api_client.calls[0]['payload'])
+            self.assertNotIn('order_number', api_client.calls[0]['payload'])
     def test_run_cli_serve_stdio_handles_initialize_request(self):
         with tempfile.TemporaryDirectory() as tmp_dir:
             path = os.path.join(tmp_dir, 'token.json')

+ 48 - 1
tests/test_mcp_protocol.py

@@ -212,6 +212,53 @@ class McpProtocolTest(unittest.TestCase):
         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_serializes_non_ascii_as_ascii_json(self):
+        class ChineseApiClient(DummyApiClient):
+            def call_tool(self, tool_code, route_path, payload, request_id):
+                response = super().call_tool(tool_code, route_path, payload, request_id)
+                response['data'] = {
+                    'summary': '共查询到 1 条轨迹记录',
+                    'records': [
+                        {
+                            'status': '清关放行',
+                            'content': '启运港放行',
+                            'location': '宁波市',
+                            'time': '2026-07-07 10:00:00',
+                        }
+                    ],
+                    'tips': [],
+                }
+                return response
+
+        token_store = InMemoryTokenStore(refresh_skew_seconds=60)
+        token_store.save('MT_demo', '2099-01-01T00:00:00')
+        app = GatewayApp(auth_client=None, api_client=ChineseApiClient(), token_store=token_store)
+        handler = McpProtocolHandler(app)
+        stdin = io.StringIO(
+            json.dumps(
+                {
+                    'jsonrpc': '2.0',
+                    'id': 9,
+                    'method': 'tools/call',
+                    'params': {
+                        'name': 'query_track',
+                        'arguments': {'order_id': 6272},
+                    },
+                }
+            )
+            + '\n'
+        )
+        stdout = io.StringIO()
+
+        handler.run_stdio(stdin=stdin, stdout=stdout)
+
+        line = stdout.getvalue().strip()
+        line.encode('ascii')
+        self.assertIn('\\u6e05\\u5173\\u653e\\u884c', line)
+        response = json.loads(line)
+        self.assertIn('清关放行', response['result']['content'][0]['text'])
+
     def test_run_stdio_writes_only_request_responses(self):
         handler = self.build_handler()
         stdin = io.StringIO(
@@ -248,4 +295,4 @@ class McpProtocolTest(unittest.TestCase):
 
 
 if __name__ == '__main__':
-    unittest.main()
+    unittest.main()

+ 121 - 0
tests/test_query_track_tool.py

@@ -0,0 +1,121 @@
+import json
+import unittest
+
+from tools.query_track import QueryTrackTool
+
+
+class MockApiClient:
+    def __init__(self, response=None):
+        self.response = response or {}
+        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 self.response
+
+
+class TestQueryTrackTool(unittest.TestCase):
+    def test_metadata_structure(self):
+        tool = QueryTrackTool()
+        meta = tool.metadata()
+        self.assertEqual(meta['name'], 'query_track')
+        self.assertIn('description', meta)
+        self.assertIn('input_schema', meta)
+        schema = meta['input_schema']
+        self.assertEqual(schema['type'], 'object')
+        self.assertIn('order_id', schema['properties'])
+        self.assertIn('order_number', schema['properties'])
+        self.assertIn('tracking_number', schema['properties'])
+        self.assertIn('page', schema['properties'])
+        self.assertIn('limit', schema['properties'])
+        # order_id and order_number are optional but at least one is required
+
+    def test_call_with_minimal_params(self):
+        mock_response = {
+            'summary': 'Found 2 tracks',
+            'records': [],
+            'tips': [],
+            'meta': {'page': 1, 'limit': 5, 'total': 2},
+        }
+        client = MockApiClient(mock_response)
+        tool = QueryTrackTool(api_client=client)
+        result = tool.call(order_id=123)
+        self.assertEqual(result, mock_response)
+        self.assertEqual(client.last_call['tool_code'], 'query_track')
+        self.assertEqual(client.last_call['route_path'], '/mcp/tools/queryTrack')
+        self.assertEqual(client.last_call['payload']['order_id'], 123)
+        self.assertEqual(client.last_call['payload']['page'], 1)
+        self.assertEqual(client.last_call['payload']['limit'], 5)
+
+    def test_call_with_order_number(self):
+        mock_response = {'summary': 'ok'}
+        client = MockApiClient(mock_response)
+        tool = QueryTrackTool(api_client=client)
+        result = tool.call(order_number='USC26070371955')
+        self.assertEqual(client.last_call['payload']['order_number'], 'USC26070371955')
+        self.assertNotIn('order_id', client.last_call['payload'])
+        self.assertEqual(client.last_call['payload']['page'], 1)
+        self.assertEqual(client.last_call['payload']['limit'], 5)
+
+    def test_call_with_tracking_number(self):
+        mock_response = {'summary': 'ok'}
+        client = MockApiClient(mock_response)
+        tool = QueryTrackTool(api_client=client)
+        result = tool.call(tracking_number='1471904540000000301')
+        self.assertEqual(result, mock_response)
+        self.assertEqual(client.last_call['payload']['tracking_number'], '1471904540000000301')
+        self.assertNotIn('order_id', client.last_call['payload'])
+        self.assertNotIn('order_number', client.last_call['payload'])
+
+    def test_call_with_all_params(self):
+        mock_response = {'summary': 'ok'}
+        client = MockApiClient(mock_response)
+        tool = QueryTrackTool(api_client=client)
+        result = tool.call(order_id=456, order_number='USC123', tracking_number='TN123', page=2, limit=10, request_id='rq_test')
+        self.assertEqual(client.last_call['payload']['order_id'], 456)
+        self.assertEqual(client.last_call['payload']['order_number'], 'USC123')
+        self.assertEqual(client.last_call['payload']['tracking_number'], 'TN123')
+        self.assertEqual(client.last_call['payload']['page'], 2)
+        self.assertEqual(client.last_call['payload']['limit'], 10)
+        self.assertEqual(client.last_call['request_id'], 'rq_test')
+
+    def test_call_requires_api_client(self):
+        tool = QueryTrackTool()
+        with self.assertRaises(RuntimeError) as ctx:
+            tool.call(order_id=123)
+        self.assertIn('api client is required', str(ctx.exception))
+
+    def test_call_requires_order_identifier(self):
+        client = MockApiClient()
+        tool = QueryTrackTool(api_client=client)
+        with self.assertRaises(ValueError) as ctx:
+            tool.call()
+        self.assertIn('order_id, order_number or tracking_number is required', str(ctx.exception))
+
+    def test_call_validates_order_id(self):
+        client = MockApiClient()
+        tool = QueryTrackTool(api_client=client)
+        with self.assertRaises(ValueError) as ctx:
+            tool.call(order_id=0)
+        self.assertIn('must be greater than 0', str(ctx.exception))
+
+        with self.assertRaises(ValueError) as ctx:
+            tool.call(order_id=-1)
+        self.assertIn('must be greater than 0', str(ctx.exception))
+
+    def test_call_normalizes_page_and_limit(self):
+        client = MockApiClient()
+        tool = QueryTrackTool(api_client=client)
+        tool.call(order_id=123, page=0, limit=200)
+        self.assertEqual(client.last_call['payload']['page'], 1)
+        self.assertEqual(client.last_call['payload']['limit'], 100)
+
+
+if __name__ == '__main__':
+    unittest.main()
+

+ 48 - 0
tools/query_track.py

@@ -0,0 +1,48 @@
+class QueryTrackTool:
+    name = 'query_track'
+    route_path = '/mcp/tools/queryTrack'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': 'Query tracking information with the current employee permissions. Provide order_id, order_number, or tracking_number.',
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'order_id': {'type': 'integer', 'minimum': 1, 'description': 'Order ID to query tracking for'},
+                    'order_number': {'type': 'string', 'description': 'Order number or track query number'},
+                    'tracking_number': {'type': 'string', 'description': 'Tracking number to query directly'},
+                    'page': {'type': 'integer', 'minimum': 1, 'default': 1},
+                    'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 5},
+                },
+            },
+        }
+
+    def call(self, order_id=None, order_number=None, tracking_number=None, page=1, limit=5, request_id='rq_query_track'):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for query_track')
+
+        if order_id is None and not order_number and not tracking_number:
+            raise ValueError('order_id, order_number or tracking_number is required')
+
+        payload = {
+            'page': max(1, int(page)),
+            'limit': max(1, min(100, int(limit))),
+        }
+
+        if order_id is not None:
+            order_id = int(order_id)
+            if order_id <= 0:
+                raise ValueError('order_id must be greater than 0')
+            payload['order_id'] = order_id
+
+        if order_number:
+            payload['order_number'] = str(order_number).strip()
+
+        if tracking_number:
+            payload['tracking_number'] = str(tracking_number).strip()
+
+        return self.api_client.call_tool(self.name, self.route_path, payload, request_id)