| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386 |
- 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'
- server_version = '0.1.0'
- output_presenter = OutputPresenter()
- def __init__(self, gateway_app):
- self.gateway_app = gateway_app
- self.initialized = False
- def handle_message(self, message):
- if not isinstance(message, dict):
- 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()
- if method == 'notifications/initialized':
- self.initialized = True
- return None
- return None
- 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()
- try:
- if method == 'initialize':
- self.initialized = True
- return self._success_response(
- request_id,
- {
- 'protocolVersion': self.protocol_version,
- 'capabilities': {
- 'tools': {
- 'listChanged': False,
- }
- },
- 'serverInfo': {
- 'name': self.server_name,
- 'version': self.server_version,
- },
- },
- )
- if method == 'tools/list':
- 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 {}
- 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():
- raise ValueError('tool name must be a non-empty string')
- tool_name = raw_tool_name.strip()
- arguments = params.get('arguments') or {}
- 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>'),
- 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,
- )
- 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
- stdout = stdout or sys.stdout
- for raw_line in stdin:
- line = str(raw_line).strip()
- if not line:
- continue
- try:
- message = json.loads(line)
- except ValueError:
- response = self._error_response(
- None,
- -32700,
- 'Parse error',
- self._build_trace_request_id(),
- )
- else:
- response = self.handle_message(message)
- if response is None:
- continue
- stdout.write(json.dumps(response, ensure_ascii=True) + '\n')
- if hasattr(stdout, 'flush'):
- stdout.flush()
- return 0
- def _normalize_tool(self, tool):
- normalized = dict(tool)
- if 'input_schema' in normalized:
- 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:
- 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)
- 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
- 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 _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 {
- 'jsonrpc': '2.0',
- 'id': request_id,
- 'result': result,
- }
- @staticmethod
- 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': error,
- }
|