Bläddra i källkod

mcp错误码重构

jackson 1 vecka sedan
förälder
incheckning
605bb89958

+ 9 - 6
app.py

@@ -123,13 +123,16 @@ class GatewayApp:
             if isinstance(code, str) and code.strip()
         }
 
-    def _load_enabled_tool_names(self):
+    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())
+        return self._enabled_tool_names(
+            self.api_client.list_enabled_tools(request_id=request_id)
+        )
 
-    def list_tools(self):
-        enabled = self._load_enabled_tool_names()
+    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()
@@ -159,10 +162,10 @@ class GatewayApp:
         tool = self._tools[name]
         if getattr(tool, 'requires_session', True):
             self.ensure_session()
-        if name not in self._load_enabled_tool_names():
+        request_id = self.build_request_id(request_id)
+        if name not in self._load_enabled_tool_names(request_id):
             raise RuntimeError('tool disabled: {0}'.format(name))
         arguments = arguments or {}
-        request_id = self.build_request_id(request_id)
         return tool.call(request_id=request_id, **arguments)
 
     def create_protocol_handler(self):

+ 95 - 12
mcp_protocol.py

@@ -1,9 +1,14 @@
 import json
+import logging
 import sys
+import uuid
 
 from services.output_presenter import OutputPresenter
 
 
+logger = logging.getLogger(__name__)
+
+
 class McpProtocolHandler:
     protocol_version = '2025-06-18'
     server_name = 'fms-mcp-gateway'
@@ -16,7 +21,12 @@ class McpProtocolHandler:
 
     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()
@@ -29,6 +39,7 @@ class McpProtocolHandler:
         request_id = request.get('id')
         method = str(request.get('method') or '').strip()
         tool_name = ''
+        trace_request_id = self._build_trace_request_id()
         try:
             if method == 'initialize':
                 self.initialized = True
@@ -48,7 +59,12 @@ class McpProtocolHandler:
                     },
                 )
             if method == 'tools/list':
-                tools = [self._normalize_tool(tool) for tool in self.gateway_app.list_tools()]
+                tools = [
+                    self._normalize_tool(tool)
+                    for tool in self.gateway_app.list_tools(
+                        request_id=trace_request_id,
+                    )
+                ]
                 return self._success_response(request_id, {'tools': tools})
             if method == 'tools/call':
                 params = request.get('params') or {}
@@ -59,21 +75,68 @@ class McpProtocolHandler:
                     raise ValueError('tool name must be a non-empty string')
                 tool_name = raw_tool_name.strip()
                 arguments = params.get('arguments') or {}
-                tool_result = self.gateway_app.call_tool(tool_name, arguments)
+                if tool_name == 'query_order':
+                    tool_result = self.gateway_app.call_tool(tool_name, arguments)
+                else:
+                    tool_result = self.gateway_app.call_tool(
+                        tool_name,
+                        arguments,
+                        request_id=trace_request_id,
+                    )
                 return self._tool_call_response(
                     request_id,
                     tool_name,
                     tool_result,
                 )
-            return self._error_response(request_id, -32601, 'Method not found: {0}'.format(method or '<empty>'))
+            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':
+                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._tool_exception_response(
                     request_id,
                     tool_name,
                     exc,
+                    trace_request_id,
                 )
-            return self._error_response(request_id, -32000, str(exc))
+            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
@@ -85,7 +148,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:
@@ -110,7 +178,13 @@ class McpProtocolHandler:
         return cls._presented_response(request_id, presented)
 
     @classmethod
-    def _tool_exception_response(cls, request_id, tool_name, exception):
+    def _tool_exception_response(
+        cls,
+        request_id,
+        tool_name,
+        exception,
+        trace_request_id='',
+    ):
         if tool_name == 'query_order':
             return cls._success_response(
                 request_id,
@@ -127,6 +201,8 @@ class McpProtocolHandler:
             tool_name,
             exception,
         )
+        if trace_request_id:
+            presented['meta'] = {'request_id': trace_request_id}
         return cls._presented_response(request_id, presented)
 
     @classmethod
@@ -291,12 +367,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,
         }

+ 46 - 9
public_gateway.py

@@ -68,13 +68,20 @@ class PublicGatewayApp:
             if isinstance(code, str) and code.strip()
         }
 
-    def _load_enabled_tool_names(self, token):
-        response = self.api_client.list_enabled_tools(token)
+    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):
+    def list_tools(self, gateway_session_id, request_id=''):
         session = self._require_session(gateway_session_id)
-        enabled = self._load_enabled_tool_names(session['mcp_token'])
+        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()
@@ -90,16 +97,28 @@ class PublicGatewayApp:
             raise KeyError('tool not registered: {0}'.format(name))
 
         session = self._require_session(gateway_session_id)
-        if name not in self._load_enabled_tool_names(session['mcp_token']):
+        request_id = self.build_request_id(request_id)
+        if name not in self._load_enabled_tool_names(
+            session['mcp_token'],
+            request_id,
+        ):
             raise RuntimeError('tool disabled: {0}'.format(name))
 
         tool = self._tools[name]
-        request_id = self.build_request_id(request_id)
 
         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:
             result = self.api_client.call_tool(
@@ -112,8 +131,26 @@ class PublicGatewayApp:
             )
             if hasattr(self.session_store, 'touch_session'):
                 self.session_store.touch_session(gateway_session_id)
-            logger.info(f"[AUDIT] tool_success: session_hash={session_hash}, tool={name}, request_id={request_id}, code={result.get('code')}")
+            logger.info(
+                'MCP public tool success',
+                extra={
+                    'request_id': request_id,
+                    'tool_code': name,
+                    'session_hash': session_hash,
+                    'response_code': result.get('code'),
+                },
+            )
             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)}")
+            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__,
+                },
+            )
             raise

+ 185 - 17
public_server.py

@@ -1,3 +1,4 @@
+import hashlib
 import json
 import logging
 import threading
@@ -28,7 +29,31 @@ class PublicMcpHttpHandler:
         # unknown names fall back to the bare IP bucket, preventing bucket explosion
         self._known_tools = frozenset(gateway_app.registered_tool_names())
 
-    def _check_rate_limit(self, rate_key, method, request_id=None, *, log_identity=None):
+    @staticmethod
+    def _build_trace_request_id(headers):
+        return 'rq_http_{0}'.format(uuid.uuid4().hex[:16])
+
+    @staticmethod
+    def _client_request_id(headers):
+        incoming = ''
+        for key, value in (headers or {}).items():
+            if str(key).lower() == 'x-request-id':
+                incoming = str(value or '').strip()
+                break
+        if not incoming:
+            return ''
+        return hashlib.sha256(incoming.encode('utf-8')).hexdigest()[:16]
+
+    def _check_rate_limit(
+        self,
+        rate_key,
+        method,
+        trace_request_id,
+        jsonrpc_id=None,
+        tool_name='',
+        *,
+        log_identity=None
+    ):
         """Returns an error response if rate limit exceeded, else None.
 
         rate_key     — the bucket key used by the limiter (session-based for tools/call,
@@ -37,13 +62,35 @@ class PublicMcpHttpHandler:
         """
         if self.rate_limiter and rate_key and not self.rate_limiter.is_allowed(rate_key):
             logger.warning(
-                f"[RATE_LIMIT] rate limit exceeded: identity={log_identity or rate_key}, method={method}"
+                "MCP public rate limit exceeded",
+                extra={
+                    'request_id': trace_request_id,
+                    'jsonrpc_id': jsonrpc_id,
+                    'protocol_method': method,
+                    'tool_code': tool_name,
+                    'client_identity': log_identity or rate_key,
+                    'protocol_code': -32029,
+                    'diagnostic_reason': 'RATE_LIMIT_EXCEEDED',
+                },
+            )
+            return McpProtocolHandler._error_response(
+                jsonrpc_id,
+                -32029,
+                'Rate limit exceeded. Please try again later.',
+                trace_request_id,
             )
-            return McpProtocolHandler._error_response(request_id, -32000, 'Rate limit exceeded. Please try again later.')
         return None
 
-    def handle_json_rpc(self, headers, message, client_ip=''):
+    def handle_json_rpc(
+        self,
+        headers,
+        message,
+        client_ip='',
+        trace_request_id='',
+    ):
         request_id = message.get('id') if isinstance(message, dict) else None
+        trace_request_id = str(trace_request_id or '').strip() \
+            or self._build_trace_request_id(headers)
         method = str((message or {}).get('method') or '').strip()
         message_params = (message or {}).get('params') or {}
         tool_name = str(message_params.get('name') or '').strip() \
@@ -65,16 +112,38 @@ class PublicMcpHttpHandler:
                 # Keep list traffic in a dedicated IP-based bucket so it does not
                 # compete with the per-session tools/call quota.
                 blocked = self._check_rate_limit(
-                    'list:{0}'.format(client_ip), method, request_id,
+                    'list:{0}'.format(client_ip),
+                    method,
+                    trace_request_id,
+                    request_id,
                     log_identity=client_ip,
                 )
                 if blocked:
                     return blocked
                 context = self.context_parser.parse(headers or {})
                 if not context.has_session():
-                    raise RuntimeError(DEVICE_INVALID_MESSAGE)
+                    logger.warning(
+                        'MCP public device session unavailable',
+                        extra={
+                            'request_id': trace_request_id,
+                            'jsonrpc_id': request_id,
+                            'protocol_method': method,
+                            'tool_code': '',
+                            'protocol_code': -32001,
+                            'diagnostic_reason': 'GATEWAY_SESSION_NOT_FOUND',
+                        },
+                    )
+                    return McpProtocolHandler._error_response(
+                        request_id,
+                        -32001,
+                        DEVICE_INVALID_MESSAGE,
+                        trace_request_id,
+                    )
                 tools = []
-                for tool in self.gateway_app.list_tools(context.gateway_session_id):
+                for tool in self.gateway_app.list_tools(
+                    context.gateway_session_id,
+                    request_id=trace_request_id,
+                ):
                     normalized = dict(tool)
                     if 'input_schema' in normalized:
                         normalized['inputSchema'] = normalized.pop('input_schema')
@@ -86,12 +155,35 @@ class PublicMcpHttpHandler:
                 # Parse context first — tools/call always requires a valid session
                 context = self.context_parser.parse(headers or {})
                 if not context.has_session():
-                    raise RuntimeError(DEVICE_INVALID_MESSAGE)
+                    logger.warning(
+                        'MCP public device session unavailable',
+                        extra={
+                            'request_id': trace_request_id,
+                            'jsonrpc_id': request_id,
+                            'protocol_method': method,
+                            'tool_code': tool_name,
+                            'protocol_code': -32001,
+                            'diagnostic_reason': 'GATEWAY_SESSION_NOT_FOUND',
+                        },
+                    )
+                    return McpProtocolHandler._error_response(
+                        request_id,
+                        -32001,
+                        DEVICE_INVALID_MESSAGE,
+                        trace_request_id,
+                    )
                 # Session-based rate limiting: each employee gets an independent quota per tool.
                 # Unknown tool names fall back to the bare session bucket to prevent key explosion.
                 session_id = context.gateway_session_id
                 rate_key = '{0}:{1}'.format(session_id, tool_name) if tool_name in self._known_tools else session_id
-                blocked = self._check_rate_limit(rate_key, method, request_id, log_identity=client_ip)
+                blocked = self._check_rate_limit(
+                    rate_key,
+                    method,
+                    trace_request_id,
+                    request_id,
+                    tool_name,
+                    log_identity=client_ip,
+                )
                 if blocked:
                     return blocked
                 params = message_params
@@ -99,7 +191,7 @@ class PublicMcpHttpHandler:
                     session_id,
                     params.get('name'),
                     params.get('arguments') or {},
-                    request_id='rq_http_{0}_{1}'.format(request_id, uuid.uuid4().hex[:16]),
+                    request_id=trace_request_id,
                     client_ip=client_ip,
                 )
                 return McpProtocolHandler._tool_call_response(
@@ -107,15 +199,55 @@ class PublicMcpHttpHandler:
                     tool_name,
                     result,
                 )
-            return McpProtocolHandler._error_response(request_id, -32601, 'Method not found: {0}'.format(method))
+            return McpProtocolHandler._error_response(
+                request_id,
+                -32601,
+                'Method not found: {0}'.format(method),
+                trace_request_id,
+            )
         except Exception as exc:
             if method == 'tools/call':
+                diagnostic_reason = (
+                    'PARAM_VALIDATION_FAILED'
+                    if isinstance(exc, ValueError)
+                    else 'UNEXPECTED_EXCEPTION'
+                )
+                logger.error(
+                    'MCP public tool request failed',
+                    extra={
+                        'request_id': trace_request_id,
+                        'jsonrpc_id': request_id,
+                        'protocol_method': method,
+                        'tool_code': tool_name,
+                        'response_code': 'MCP_9001',
+                        'diagnostic_reason': diagnostic_reason,
+                        'exception_class': exc.__class__.__name__,
+                    },
+                )
                 return McpProtocolHandler._tool_exception_response(
                     request_id,
                     tool_name,
                     exc,
+                    trace_request_id,
                 )
-            return McpProtocolHandler._error_response(request_id, -32000, str(exc))
+            logger.error(
+                "MCP public request failed",
+                extra={
+                    'request_id': trace_request_id,
+                    'jsonrpc_id': request_id,
+                    'protocol_method': method,
+                    'tool_code': tool_name,
+                    'protocol_code': -32000,
+                    'diagnostic_reason': 'UNEXPECTED_EXCEPTION',
+                    'exception_class': exc.__class__.__name__,
+                },
+            )
+            return McpProtocolHandler._error_response(
+                request_id,
+                -32000,
+                'Gateway request failed. Please try again later.',
+                trace_request_id,
+            )
 
 
 def create_http_handler(gateway_app, rate_limiter=None):
@@ -134,7 +266,12 @@ def create_http_handler(gateway_app, rate_limiter=None):
             self.end_headers()
 
         def do_POST(self):
-            client_ip = extract_client_ip(dict(self.headers.items()), self.client_address)
+            request_headers = dict(self.headers.items())
+            client_ip = extract_client_ip(request_headers, self.client_address)
+            trace_request_id = rpc_handler._build_trace_request_id(request_headers)
+            client_request_id_hash = rpc_handler._client_request_id(
+                request_headers
+            )
             length = int(self.headers.get('Content-Length') or '0')
             if self.path != '/mcp':
                 logger.warning(f"[HTTP] 404: ip={client_ip}, path={self.path}")
@@ -147,13 +284,44 @@ def create_http_handler(gateway_app, rate_limiter=None):
             try:
                 message = json.loads(body)
             except json.JSONDecodeError as exc:
-                logger.warning(f"[HTTP] invalid JSON: ip={client_ip}, error={exc}")
-                self._write_json(McpProtocolHandler._error_response(None, -32700, 'Parse error'))
+                logger.warning(
+                    'MCP public invalid JSON',
+                    extra={
+                        'request_id': trace_request_id,
+                        'jsonrpc_id': None,
+                        'protocol_method': '',
+                        'tool_code': '',
+                        'protocol_code': -32700,
+                        'diagnostic_reason': 'PARAM_VALIDATION_FAILED',
+                        'exception_class': exc.__class__.__name__,
+                        'client_identity': client_ip,
+                    },
+                )
+                self._write_json(McpProtocolHandler._error_response(
+                    None,
+                    -32700,
+                    'Parse error',
+                    trace_request_id,
+                ))
                 return
             method = message.get('method', '')
             request_id = message.get('id') if message.get('id') is not None else ''
-            logger.info(f"[HTTP] request: ip={client_ip}, method={method}, id={request_id}")
-            response = rpc_handler.handle_json_rpc(dict(self.headers.items()), message, client_ip)
+            logger.info(
+                'MCP public request',
+                extra={
+                    'request_id': trace_request_id,
+                    'jsonrpc_id': request_id,
+                    'protocol_method': method,
+                    'client_identity': client_ip,
+                    'client_request_id_hash': client_request_id_hash,
+                },
+            )
+            response = rpc_handler.handle_json_rpc(
+                request_headers,
+                message,
+                client_ip,
+                trace_request_id=trace_request_id,
+            )
             self._write_json(response)
 
         def _write_json(self, payload):

+ 5 - 2
services/api_client.py

@@ -31,8 +31,11 @@ class ApiClient:
         }
         return self.transport.post_json(url, payload, headers, self.timeout)
 
-    def list_enabled_tools(self):
+    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)}
+        headers = {
+            'Authorization': 'Bearer {0}'.format(token),
+            'X-Request-Id': str(request_id or '').strip(),
+        }
         return self.transport.post_json(url, {}, headers, self.timeout)

+ 37 - 6
services/output_presenter.py

@@ -1,8 +1,12 @@
 import json
+import logging
 
 from constants import DEVICE_INVALID_MESSAGE
 
 
+logger = logging.getLogger(__name__)
+
+
 # Builds AI-safe MCP results for the explicitly supported tool set.
 class OutputPresenter:
     TABLE_TOOLS = frozenset((
@@ -170,12 +174,31 @@ class OutputPresenter:
     ERROR_MESSAGES = {
         'MCP_1101': '设备配置已失效,请重新生成配置',
         'MCP_1102': '设备会话已过期,请重新连接',
+        'MCP_1103': '员工账号不可用,请联系管理员',
+        'MCP_1104': '身份信息已失效,请重新登录或重新生成设备配置',
         'MCP_1201': '工具当前不可用',
         'MCP_1202': '工具当前不可用',
         'MCP_1301': '没有权限使用此工具',
+        'MCP_1501': '目标数据不可用',
+        'MCP_1502': '结果过多,请缩小查询范围',
+        'MCP_1601': '没有可导出的数据',
         'MCP_9001': '工具调用失败,请稍后重试',
     }
 
+    NON_RETRYABLE_CODES = frozenset((
+        'MCP_1101',
+        'MCP_1102',
+        'MCP_1103',
+        'MCP_1104',
+        'MCP_1201',
+        'MCP_1202',
+        'MCP_1301',
+        'MCP_1401',
+        'MCP_1501',
+        'MCP_1502',
+        'MCP_1601',
+    ))
+
     def handles(self, tool_name):
         return isinstance(tool_name, str) and tool_name in self.SAFE_TOOLS
 
@@ -325,16 +348,24 @@ class OutputPresenter:
         return self._success_result(content, text, meta)
 
     def _business_error(self, tool_name, code, raw_message, meta):
+        if code not in self.ERROR_MESSAGES and code != 'MCP_1401':
+            logger.warning(
+                'Unknown MCP business error code',
+                extra={
+                    'request_id': meta.get('request_id', ''),
+                    'tool_code': tool_name,
+                    'backend_code': code,
+                    'response_code': 'MCP_9001',
+                    'diagnostic_reason': 'UNEXPECTED_EXCEPTION',
+                },
+            )
+            code = 'MCP_9001'
         if code == 'MCP_1401':
             label = self._find_field_label(tool_name, raw_message)
             message = self._parameter_message(label)
-            retryable = False
         else:
-            message = self.ERROR_MESSAGES.get(
-                code,
-                '工具调用失败,请稍后重试',
-            )
-            retryable = code not in ('MCP_1101', 'MCP_1102', 'MCP_1201', 'MCP_1202', 'MCP_1301')
+            message = self.ERROR_MESSAGES[code]
+        retryable = code not in self.NON_RETRYABLE_CODES
         return self._error_result(code, message, retryable, meta)
 
     def _find_field_label(self, tool_name, raw_message):

+ 5 - 2
services/scoped_api_client.py

@@ -22,10 +22,13 @@ class ScopedApiClient:
             headers['X-MCP-Client-IP'] = client_ip
         return self.transport.post_json(url, payload, headers, self.timeout)
 
-    def list_enabled_tools(self, token):
+    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)}
+        headers = {
+            'Authorization': 'Bearer {0}'.format(token),
+            'X-Request-Id': str(request_id or '').strip(),
+        }
         return self.transport.post_json(url, {}, headers, self.timeout)

+ 1 - 1
tests/test_app_coverage.py

@@ -71,7 +71,7 @@ class FromConfigUnsupportedStoreTest(unittest.TestCase):
 # ---------------------------------------------------------------------------
 
 class DummyApiClient:
-    def list_enabled_tools(self):
+    def list_enabled_tools(self, request_id=''):
         return {
             'code': 'MCP_0000',
             'data': {

+ 1 - 1
tests/test_bind_auth_code_tool.py

@@ -6,7 +6,7 @@ from services.token_store import InMemoryTokenStore
 
 
 class DummyApiClient:
-    def list_enabled_tools(self):
+    def list_enabled_tools(self, request_id=''):
         return {
             'code': 'MCP_0000',
             'data': {

+ 1 - 1
tests/test_cli_and_file_store.py

@@ -30,7 +30,7 @@ class DummyApiClient:
     def __init__(self):
         self.calls = []
 
-    def list_enabled_tools(self):
+    def list_enabled_tools(self, request_id=''):
         return {
             'code': 'MCP_0000',
             'data': {'tool_codes': ['query_order', 'query_track']},

+ 2 - 2
tests/test_export_out_of_province_port_data_tool.py

@@ -23,7 +23,7 @@ class RecordingApiClient:
             'data': {'file_url': 'https://files.test/port.zip'},
         }
 
-    def list_enabled_tools(self):
+    def list_enabled_tools(self, request_id=''):
         return {
             'code': 'MCP_0000',
             'data': {'tool_codes': ['export_out_of_province_port_data']},
@@ -42,7 +42,7 @@ class PublicApiClient:
         self.enabled = enabled
         self.calls = []
 
-    def list_enabled_tools(self, token):
+    def list_enabled_tools(self, token, request_id=''):
         return {
             'code': 'MCP_0000',
             'data': {'tool_codes': (

+ 2 - 2
tests/test_export_pending_outbound_tools.py

@@ -16,7 +16,7 @@ class RecordingApiClient:
         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):
+    def list_enabled_tools(self, request_id=''):
         return {
             'code': 'MCP_0000',
             'data': {
@@ -34,7 +34,7 @@ class PublicSessionStore:
 
 
 class PublicApiClient:
-    def list_enabled_tools(self, token):
+    def list_enabled_tools(self, token, request_id=''):
         return {
             'code': 'MCP_0000',
             'data': {

+ 5 - 5
tests/test_gateway_query_order.py

@@ -128,7 +128,7 @@ class GatewayQueryOrderTest(unittest.TestCase):
             timeout=8,
         )
 
-        response = client.list_enabled_tools()
+        response = client.list_enabled_tools('rq_enabled_tools')
 
         self.assertEqual('MCP_0000', response['code'])
         self.assertEqual(
@@ -136,10 +136,10 @@ class GatewayQueryOrderTest(unittest.TestCase):
             transport.calls[0]['url'],
         )
         self.assertEqual({}, transport.calls[0]['payload'])
-        self.assertEqual(
-            {'Authorization': 'Bearer MT_demo'},
-            transport.calls[0]['headers'],
-        )
+        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()

+ 1 - 1
tests/test_gateway_runtime.py

@@ -39,7 +39,7 @@ class DummyApiClient:
             },
         }
 
-    def list_enabled_tools(self):
+    def list_enabled_tools(self, request_id=''):
         self.enabled_calls += 1
         return self.enabled_response
 

+ 2 - 2
tests/test_list_order_filter_options_tool.py

@@ -19,7 +19,7 @@ class RecordingApiClient:
         }
         return {'code': 'MCP_0000'}
 
-    def list_enabled_tools(self):
+    def list_enabled_tools(self, request_id=''):
         return {
             'code': 'MCP_0000',
             'data': {'tool_codes': ['list_order_filter_options']},
@@ -34,7 +34,7 @@ class PublicSessionStore:
 
 
 class PublicApiClient:
-    def list_enabled_tools(self, token):
+    def list_enabled_tools(self, token, request_id=''):
         return {
             'code': 'MCP_0000',
             'data': {'tool_codes': ['list_order_filter_options']},

+ 42 - 1
tests/test_mcp_protocol.py

@@ -11,7 +11,7 @@ class DummyApiClient:
     def __init__(self):
         self.calls = []
 
-    def list_enabled_tools(self):
+    def list_enabled_tools(self, request_id=''):
         return {
             'code': 'MCP_0000',
             'data': {
@@ -129,6 +129,47 @@ class McpProtocolTest(unittest.TestCase):
         )
         return McpProtocolHandler(app)
 
+    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()
 

+ 13 - 2
tests/test_mcp_protocol_coverage.py

@@ -344,9 +344,20 @@ class HandleMessageEdgeCasesTest(unittest.TestCase):
         handler = McpProtocolHandler(MagicMock())
         handler.gateway_app.list_tools.side_effect = RuntimeError('backend error')
 
-        response = handler.handle_request({'id': 5, 'method': 'tools/list'})
+        with self.assertLogs('mcp_protocol', level='ERROR') as logs:
+            response = handler.handle_request({'id': 5, 'method': 'tools/list'})
         self.assertIn('error', response)
-        self.assertIn('backend error', response['error']['message'])
+        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。"""

+ 42 - 0
tests/test_output_presenter.py

@@ -319,6 +319,48 @@ class OutputPresenterTest(unittest.TestCase):
         self.assertNotIn('st_order', serialized)
         self.assertNotIn('order_number', serialized)
 
+    def test_new_business_error_codes_have_stable_messages_and_are_not_retryable(self):
+        cases = {
+            'MCP_1103': '员工账号不可用,请联系管理员',
+            'MCP_1104': '身份信息已失效,请重新登录或重新生成设备配置',
+            'MCP_1501': '目标数据不可用',
+            'MCP_1502': '结果过多,请缩小查询范围',
+            'MCP_1601': '没有可导出的数据',
+        }
+
+        for code, message in cases.items():
+            with self.subTest(code=code):
+                result = self.presenter.present(
+                    'query_order_exact',
+                    {'code': code, 'msg': 'internal detail', 'data': []},
+                )
+                self.assertEqual(code, result['structured_content']['code'])
+                self.assertEqual(message, result['structured_content']['message'])
+                self.assertFalse(result['structured_content']['retryable'])
+
+    def test_unknown_business_error_code_is_normalized_to_system_error(self):
+        with self.assertLogs('services.output_presenter', level='WARNING') as logs:
+            result = self.presenter.present(
+                'query_order_exact',
+                {
+                    'code': 'MCP_7777',
+                    'msg': 'secret backend detail',
+                    'data': [],
+                    'meta': {'request_id': 'rq_unknown_code'},
+                },
+            )
+
+        self.assertEqual('MCP_9001', result['structured_content']['code'])
+        self.assertEqual('工具调用失败,请稍后重试', result['structured_content']['message'])
+        self.assertTrue(result['structured_content']['retryable'])
+        self.assertNotIn('secret', json.dumps(result, ensure_ascii=False))
+        record = logs.records[0]
+        self.assertEqual('rq_unknown_code', record.request_id)
+        self.assertEqual('query_order_exact', record.tool_code)
+        self.assertEqual('MCP_7777', record.backend_code)
+        self.assertEqual('MCP_9001', record.response_code)
+        self.assertEqual('UNEXPECTED_EXCEPTION', record.diagnostic_reason)
+
     def test_unknown_tool_and_malformed_success_fail_closed(self):
         unknown = self.presenter.present(
             'future_tool',

+ 1 - 1
tests/test_public_gateway.py

@@ -23,7 +23,7 @@ class FakeApiClient:
         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):
+    def list_enabled_tools(self, token, request_id=''):
         return {
             'code': 'MCP_0000',
             'data': {

+ 14 - 3
tests/test_public_gateway_unit.py

@@ -79,7 +79,10 @@ class TestPublicGatewayApp(unittest.TestCase):
             ['query_track', 'query_order_exact'],
             [tool['name'] for tool in tools],
         )
-        self.mock_api_client.list_enabled_tools.assert_called_once_with('MT_token')
+        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
@@ -283,10 +286,18 @@ 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__':

+ 109 - 30
tests/test_public_server.py

@@ -27,8 +27,8 @@ class FakeGateway:
     def registered_tool_names(self):
         return ('query_order', 'query_track')
 
-    def list_tools(self, gateway_session_id):
-        self.list_calls.append(gateway_session_id)
+    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=''):
@@ -59,7 +59,8 @@ class PublicMcpHttpHandlerTest(unittest.TestCase):
             client_ip='10.0.0.5',
         )
 
-        self.assertEqual(['GWS_A'], gateway.list_calls)
+        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']],
@@ -70,26 +71,64 @@ class PublicMcpHttpHandlerTest(unittest.TestCase):
         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={},
-            message={
-                'jsonrpc': '2.0',
-                'id': 3,
-                'method': 'tools/list',
-                'params': {},
-            },
+            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.assertIn('Workbuddy', response['error']['message'])
-        self.assertEqual([], gateway.list_calls)
+        self.assertEqual('Gateway request failed. Please try again later.', response['error']['message'])
+        self.assertNotIn('password', json.dumps(response))
 
     def test_handle_tools_call_passes_gateway_session_to_public_gateway(self):
         gateway = FakeGateway()
         handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
         response = handler.handle_json_rpc(
-            headers={'X-Gateway-Session': 'GWS_A'},
+            headers={
+                'X-Gateway-Session': 'GWS_A',
+                'X-Request-Id': 'rq_public_incoming',
+            },
             message={
                 'jsonrpc': '2.0',
                 'id': 1,
@@ -105,8 +144,33 @@ class PublicMcpHttpHandlerTest(unittest.TestCase):
         self.assertEqual(False, response['result']['isError'])
         self.assertEqual('GWS_A', gateway.calls[0][0])
         self.assertEqual('query_order', gateway.calls[0][1])
+        self.assertTrue(gateway.calls[0][3].startswith('rq_http_'))
+        self.assertNotEqual('rq_public_incoming', gateway.calls[0][3])
         self.assertEqual('10.0.0.5', gateway.calls[0][4])
 
+    def test_reused_client_request_id_gets_unique_server_trace_ids(self):
+        gateway = FakeGateway()
+        handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())
+        headers = {
+            'X-Gateway-Session': 'GWS_A',
+            'X-Request-Id': 'client-retry-id',
+        }
+        message = {
+            'jsonrpc': '2.0',
+            'id': 1,
+            'method': 'tools/call',
+            'params': {'name': 'query_track', 'arguments': {}},
+        }
+
+        handler.handle_json_rpc(headers, message, client_ip='10.0.0.5')
+        handler.handle_json_rpc(headers, message, client_ip='10.0.0.5')
+
+        first_request_id = gateway.calls[0][3]
+        second_request_id = gateway.calls[1][3]
+        self.assertTrue(first_request_id.startswith('rq_http_'))
+        self.assertTrue(second_request_id.startswith('rq_http_'))
+        self.assertNotEqual(first_request_id, second_request_id)
+
     def test_backend_business_error_returns_tool_error_result(self):
         gateway = FakeGateway()
         gateway.tool_result = {
@@ -185,7 +249,7 @@ class PublicMcpHttpHandlerTest(unittest.TestCase):
         self.assertIn('TN-PUBLIC', serialized)
         self.assertEqual('rq_public', response['result']['_meta']['request_id'])
 
-    def test_missing_session_on_tool_call_returns_error_content(self):
+    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(
@@ -198,26 +262,25 @@ class PublicMcpHttpHandlerTest(unittest.TestCase):
             },
         )
 
-        self.assertTrue(response['result']['isError'])
-        self.assertIn(
-            '这台设备的 Workbuddy 配置已失效,请重新生成配置',
-            response['result']['content'][0]['text'],
-        )
+        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())
 
-        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',
-        )
+        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.assertEqual(
@@ -225,6 +288,14 @@ class PublicMcpHttpHandlerTest(unittest.TestCase):
             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(
@@ -253,9 +324,17 @@ class RateLimitTest(unittest.TestCase):
     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')
-        response = 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

+ 2 - 3
tests/test_public_server_integration.py

@@ -138,9 +138,8 @@ class TestPublicServerIntegration(unittest.TestCase):
     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()

+ 2 - 2
tests/test_query_customs_declaration_files_tool.py

@@ -14,7 +14,7 @@ class RecordingApiClient:
         self.enabled = enabled
         self.last_call = None
 
-    def list_enabled_tools(self):
+    def list_enabled_tools(self, request_id=''):
         return {
             'code': 'MCP_0000',
             'data': {'tool_codes': (
@@ -44,7 +44,7 @@ class PublicApiClient:
         self.enabled = enabled
         self.calls = []
 
-    def list_enabled_tools(self, token):
+    def list_enabled_tools(self, token, request_id=''):
         return {
             'code': 'MCP_0000',
             'data': {'tool_codes': (

+ 2 - 2
tests/test_query_order_exact_tool.py

@@ -21,7 +21,7 @@ class RecordingApiClient:
         }
         return {'code': 'MCP_0000'}
 
-    def list_enabled_tools(self):
+    def list_enabled_tools(self, request_id=''):
         return {
             'code': 'MCP_0000',
             'data': {'tool_codes': ['query_order_exact']},
@@ -36,7 +36,7 @@ class PublicSessionStore:
 
 
 class PublicApiClient:
-    def list_enabled_tools(self, token):
+    def list_enabled_tools(self, token, request_id=''):
         return {
             'code': 'MCP_0000',
             'data': {'tool_codes': ['query_order_exact']},

+ 5 - 2
tests/test_scoped_api_client.py

@@ -142,13 +142,16 @@ class TestScopedApiClient(unittest.TestCase):
         }
         self.mock_transport.post_json.return_value = expected
 
-        result = self.client.list_enabled_tools(' MT_scoped ')
+        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'},
+            {
+                'Authorization': 'Bearer MT_scoped',
+                'X-Request-Id': 'rq_tool_list',
+            },
             15,
         )