ソースを参照

第一次更新

liuq 5 ヶ月 前
コミット
5ab5e0bbce
9 ファイル変更763 行追加50 行削除
  1. 44 48
      .gitignore
  2. 114 2
      README.md
  3. 167 0
      calculator.py
  4. 19 0
      mcp_config.json
  5. 282 0
      mcp_pipe.py
  6. 7 0
      requirements.txt
  7. 42 0
      start.ps1
  8. 38 0
      utils/logger_config.py
  9. 50 0
      video_config_prod.yaml

+ 44 - 48
.gitignore

@@ -1,60 +1,56 @@
-# ---> Python
-# Byte-compiled / optimized / DLL files
+# Python
 __pycache__/
 *.py[cod]
 *$py.class
-
-# C extensions
 *.so
-
-# Distribution / packaging
 .Python
-env/
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-*.egg-info/
-.installed.cfg
-*.egg
-
-# PyInstaller
-#  Usually these files are written by a python script from a template
-#  before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
 
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
+# Virtual Environment
+.venv/
+venv/
+ENV/
+env/
 
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*,cover
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+.DS_Store
+
+# Environment variables
+.env
+.env.local
+.env.*.local
+
+# Logs
+logs/
+*.log
+*.log.*
 
-# Translations
-*.mo
-*.pot
+# Node modules (if any)
+node_modules/
 
-# Django stuff:
-*.log
+# System files
+Thumbs.db
+.DS_Store
+*.bak
+*.tmp
 
-# Sphinx documentation
-docs/_build/
+# Python package files
+*.egg
+*.egg-info/
+dist/
+build/
+*.whl
 
-# PyBuilder
-target/
+# Testing
+.pytest_cache/
+.coverage
+htmlcov/
+.tox/
 
+# Configuration overrides (keep templates but ignore local changes)
+video_config_local.yaml
+mcp_config_local.json

+ 114 - 2
README.md

@@ -1,3 +1,115 @@
-# zhanting_AI_MCP
+# MCP Sample Project | MCP 示例项目
 
-展厅AI工具
+A powerful interface for extending AI capabilities through remote control, calculations, email operations, knowledge search, and more.
+
+一个强大的接口,用于通过远程控制、计算、邮件操作、知识搜索等方式扩展AI能力。
+
+## Overview | 概述
+
+MCP (Model Context Protocol) is a protocol that allows servers to expose tools that can be invoked by language models. Tools enable models to interact with external systems, such as querying databases, calling APIs, or performing computations. Each tool is uniquely identified by a name and includes metadata describing its schema.
+
+MCP(模型上下文协议)是一个允许服务器向语言模型暴露可调用工具的协议。这些工具使模型能够与外部系统交互,例如查询数据库、调用API或执行计算。每个工具都由一个唯一的名称标识,并包含描述其模式的元数据。
+
+## Features | 特性
+
+- 🔌 Bidirectional communication between AI and external tools | AI与外部工具之间的双向通信
+- 🔄 Automatic reconnection with exponential backoff | 具有指数退避的自动重连机制
+- 📊 Real-time data streaming | 实时数据流传输
+- 🛠️ Easy-to-use tool creation interface | 简单易用的工具创建接口
+- 🔒 Secure WebSocket communication | 安全的WebSocket通信
+- ⚙️ Multiple transport types support (stdio/sse/http) | 支持多种传输类型(stdio/sse/http)
+
+## Quick Start | 快速开始
+
+1. Install dependencies | 安装依赖:
+```bash
+pip install -r requirements.txt
+```
+
+2. Set up environment variables | 设置环境变量:
+```bash
+export MCP_ENDPOINT=<your_mcp_endpoint>
+```
+
+3. Run the calculator example | 运行计算器示例:
+```bash
+python mcp_pipe.py calculator.py
+```
+
+Or run all configured servers | 或运行所有配置的服务:
+```bash
+python mcp_pipe.py
+```
+
+*Requires `mcp_config.json` configuration file with server definitions (supports stdio/sse/http transport types)*
+
+*需要 `mcp_config.json` 配置文件定义服务器(支持 stdio/sse/http 传输类型)*
+
+## Project Structure | 项目结构
+
+- `mcp_pipe.py`: Main communication pipe that handles WebSocket connections and process management | 处理WebSocket连接和进程管理的主通信管道
+- `calculator.py`: Example MCP tool implementation for mathematical calculations | 用于数学计算的MCP工具示例实现
+- `requirements.txt`: Project dependencies | 项目依赖
+
+## Config-driven Servers | 通过配置驱动的服务
+
+编辑 `mcp_config.json` 文件来配置服务器列表(也可设置 `MCP_CONFIG` 环境变量指向其他配置文件)。
+
+配置说明:
+- 无参数时启动所有配置的服务(自动跳过 `disabled: true` 的条目)
+- 有参数时运行单个本地脚本文件
+- `type=stdio` 直接启动;`type=sse/http` 通过 `python -m mcp_proxy` 代理
+
+## Creating Your Own MCP Tools | 创建自己的MCP工具
+
+Here's a simple example of creating an MCP tool | 以下是一个创建MCP工具的简单示例:
+
+```python
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("YourToolName")
+
+@mcp.tool()
+def your_tool(parameter: str) -> dict:
+    """Tool description here"""
+    # Your implementation
+    return {"success": True, "result": result}
+
+if __name__ == "__main__":
+    mcp.run(transport="stdio")
+```
+
+## Use Cases | 使用场景
+
+- Mathematical calculations | 数学计算
+- Email operations | 邮件操作
+- Knowledge base search | 知识库搜索
+- Remote device control | 远程设备控制
+- Data processing | 数据处理
+- Custom tool integration | 自定义工具集成
+
+## Requirements | 环境要求
+
+- Python 3.7+
+- websockets>=11.0.3
+- python-dotenv>=1.0.0
+- mcp>=1.8.1
+- pydantic>=2.11.4
+- mcp-proxy>=0.8.2
+
+## Contributing | 贡献指南
+
+Contributions are welcome! Please feel free to submit a Pull Request.
+
+欢迎贡献代码!请随时提交Pull Request。
+
+## License | 许可证
+
+This project is licensed under the MIT License - see the LICENSE file for details.
+
+本项目采用MIT许可证 - 详情请查看LICENSE文件。
+
+## Acknowledgments | 致谢
+
+- Thanks to all contributors who have helped shape this project | 感谢所有帮助塑造这个项目的贡献者
+- Inspired by the need for extensible AI capabilities | 灵感来源于对可扩展AI能力的需求

+ 167 - 0
calculator.py

@@ -0,0 +1,167 @@
+# server.py
+from mcp.server.fastmcp import FastMCP
+import sys
+import logging
+import os
+from typing import List, Dict, Any, Optional
+import requests
+from utils.logger_config import logger
+try:
+    import yaml
+except ImportError:  # 允许在未安装 PyYAML 的环境下给出清晰日志
+    yaml = None
+
+# Fix UTF-8 encoding for Windows console
+if sys.platform == 'win32':
+    sys.stderr.reconfigure(encoding='utf-8')
+    sys.stdout.reconfigure(encoding='utf-8')
+
+# Create an MCP server
+mcp = FastMCP("展厅交互模块")
+
+# YAML 配置路径
+VIDEO_CONFIG_PATH = os.path.join(os.path.dirname(__file__), 'video_config_prod.yaml')
+
+# 内存状态
+_is_running: bool = False
+_current_video_id: Optional[int] = None
+
+def _load_videos_from_yaml(config_path: str) -> List[Dict[str, Any]]:
+    if yaml is None:
+        logger.error("未安装 PyYAML,请在环境中安装 pyyaml 以读取配置文件")
+        return []
+    if not os.path.exists(config_path):
+        logger.error(f"未找到配置文件: {config_path}")
+        return []
+    try:
+        with open(config_path, 'r', encoding='utf-8') as f:
+            data = yaml.safe_load(f) or {}
+        videos = data.get('video_infos', [])
+        if not isinstance(videos, list):
+            logger.error("配置文件格式错误: 'video_infos' 应为列表")
+            return []
+        # 规范化字段
+        normalized = []
+        for v in videos:
+            if not isinstance(v, dict):
+                continue
+            if 'id' not in v or 'name' not in v:
+                continue
+            normalized.append({
+                'id': v.get('id'),
+                'name': v.get('name'),
+                'formula': v.get('formula'),
+                'description': v.get('description'),
+                'video_duration': v.get('video_duration'),
+                'video_path': v.get('video_path'),
+                'code': v.get('code'),
+            })
+        return normalized
+    except Exception as e:
+        logger.exception(f"读取配置失败: {e}")
+        return []
+
+def _index_videos(videos: List[Dict[str, Any]]) -> Dict[int, Dict[str, Any]]:
+    return {int(v['id']): v for v in videos if isinstance(v.get('id'), int)}
+
+# 启动时加载
+_videos: List[Dict[str, Any]] = _load_videos_from_yaml(VIDEO_CONFIG_PATH)
+_videos_by_id: Dict[int, Dict[str, Any]] = _index_videos(_videos)
+
+# Flask API 基础地址(与 README_API.md 一致)
+FLASK_BASE = os.environ.get('FLASK_API_BASE', 'http://192.168.254.242:5050')
+
+def _flask_get(path: str) -> Dict[str, Any]:
+    url = f"{FLASK_BASE}{path}"
+    try:
+        resp = requests.get(url, timeout=3)
+        resp.raise_for_status()
+        return resp.json()
+    except Exception as e:
+        logger.error(f"GET {url} 失败: {e}")
+        return {"success": False, "message": str(e)}
+
+def _flask_post(path: str, json_body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+    url = f"{FLASK_BASE}{path}"
+    try:
+        resp = requests.post(url, json=json_body or {}, timeout=3)
+        resp.raise_for_status()
+        return resp.json()
+    except Exception as e:
+        logger.error(f"POST {url} 失败: {e}")
+        return {"success": False, "message": str(e)}
+
+@mcp.tool(name="获取所有可用的视频列表")
+def list_videos() -> dict:
+    """获取所有可用的视频列表(来自 video_config.yaml)。
+
+    使用建议:
+    - 当用户只说出展品名称且可能存在谐音/近音时,先调用本工具获取候选列表,
+      在客户端侧做相似度/拼音/编辑距离匹配,选取"最接近"的名称对应的 video_id,
+      再调用 kodi_start(video_id)。
+    
+    返回的每个视频包含以下字段,可用于匹配:
+    - id: 视频ID(整数)
+    - name: 展品名称(如"氯化铷"、"硫酸钙新材料"等)
+    - code: 展品编号(如"1号展品"、"2号展品"等)
+    - formula: 化学式或公式
+    - description: 描述信息
+    """
+    logger.info(f"获取视频列表,共 {_videos.__len__()} 个")
+    return {"success": True, "videos": _videos, "count": len(_videos)}
+
+@mcp.tool(name="播放到指定的展品视频")
+def kodi_start(video_id: int) -> dict:
+    """启动或切换 Kodi 播放到指定 video_id(转发到 Flask: POST /api/kodi/start)。
+
+    参数:
+    - video_id: 整数类型的视频ID(从 list_videos() 获取)
+
+    使用场景和匹配策略:
+    
+    1. 用户说编号类表达(如"一号展品"、"1号产品"、"展品1"、"产品1"等):
+       - 提取数字部分(如"一号"→1、"1号"→1、"展品1"→1)
+       - 直接使用该数字作为 video_id
+    
+    2. 用户直接说物质名称(如"播放氯化铷"、"播放硫酸钙"、"氯化铯介绍视频"等):
+       - 先调用 list_videos() 获取所有视频列表
+       - 从返回的列表中匹配:
+         a) 优先匹配 name 字段(精确或模糊匹配)
+         b) 其次匹配 formula 字段
+         c) 再次匹配 code 字段中的名称部分
+       - 匹配时考虑:
+         * 精确匹配(如"氯化铷"完全匹配)
+         * 包含匹配(如输入"氯化铷"匹配到 name="氯化铷")
+         * 拼音/谐音匹配(如"氯化铯"与"氯化铷"的相似度)
+         * 编辑距离(允许轻微口误)
+       - 选择"最接近"的条目,使用其 id 字段作为 video_id 调用本接口
+    
+    3. 匹配示例:
+       - "播放1号展品" → video_id=1
+       - "播放一号产品" → video_id=1
+       - "播放氯化铷" → 匹配 name="氯化铷" → video_id=1
+       - "播放氯化铯介绍视频" → 匹配 name="氯化铯" → video_id=6
+       - "硫酸钙" → 匹配 name="硫酸钙新材料"(包含匹配) → video_id=2
+    
+    提示:如果用户给的是展品"名称"而非 id,且存在谐音/口误,
+    - 先调用 list_videos() 获取所有视频元数据
+    - 在调用侧进行名称相似度匹配(可用拼音/编辑距离/分词等方法)
+    - 选择"最接近"的条目并传入其 video_id 调用本接口
+    """
+    if not isinstance(video_id, int) or video_id < 0:
+        return {"success": False, "message": "video_id 必须为大于等于 0 的整数"}
+    if video_id not in _videos_by_id:
+        return {"success": False, "message": f"未找到视频ID={video_id}"}
+    video = _videos_by_id[video_id]
+    logger.info(f"请求 Flask 启动/切换播放: 视频ID={video_id},名称={video.get('name')}")
+    api = _flask_post('/api/kodi/start', {"video_id": video_id})
+    # 同步内存状态
+    if api.get('success') is True:
+        global _is_running, _current_video_id
+        _is_running = True
+        _current_video_id = video_id
+    return api
+
+# Start the server
+if __name__ == "__main__":
+    mcp.run(transport="stdio")

+ 19 - 0
mcp_config.json

@@ -0,0 +1,19 @@
+{
+  "mcpServers": {
+    "local-stdio-calculator": {
+      "type": "stdio",
+      "command": "python",
+      "args": ["-m", "calculator"]
+    },
+    "remote-sse-server": {
+      "type": "sse",
+      "url": "https://api.example.com/sse",
+      "disabled": true
+    },
+    "remote-http-server": {
+      "type": "http",
+      "url": "https://api.example.com/mcp",
+      "disabled": true
+    }
+  }
+}

+ 282 - 0
mcp_pipe.py

@@ -0,0 +1,282 @@
+"""
+Simple MCP stdio <-> WebSocket pipe with optional unified config.
+Version: 0.2.0
+
+Usage (env):
+    export MCP_ENDPOINT=<ws_endpoint>
+    # Windows (PowerShell): $env:MCP_ENDPOINT = "<ws_endpoint>"
+
+Start server process(es) from config:
+Run all configured servers (default)
+    python mcp_pipe.py
+
+Run a single local server script (back-compat)
+    python mcp_pipe.py path/to/server.py
+
+Config discovery order:
+    $MCP_CONFIG, then ./mcp_config.json
+
+Env overrides:
+    (none for proxy; uses current Python: python -m mcp_proxy)
+"""
+
+import asyncio
+import websockets
+import subprocess
+import logging
+import os
+import signal
+import sys
+import json
+from dotenv import load_dotenv
+
+# Auto-load environment variables from a .env file if present
+load_dotenv()
+
+# Configure logging
+logging.basicConfig(
+    level=logging.INFO,
+    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger('MCP_PIPE')
+
+# Reconnection settings
+INITIAL_BACKOFF = 1  # Initial wait time in seconds
+MAX_BACKOFF = 600  # Maximum wait time in seconds
+
+async def connect_with_retry(uri, target):
+    """Connect to WebSocket server with retry mechanism for a given server target."""
+    reconnect_attempt = 0
+    backoff = INITIAL_BACKOFF
+    while True:  # Infinite reconnection
+        try:
+            if reconnect_attempt > 0:
+                logger.info(f"[{target}] Waiting {backoff}s before reconnection attempt {reconnect_attempt}...")
+                await asyncio.sleep(backoff)
+
+            # Attempt to connect
+            await connect_to_server(uri, target)
+
+        except Exception as e:
+            reconnect_attempt += 1
+            logger.warning(f"[{target}] Connection closed (attempt {reconnect_attempt}): {e}")
+            # Calculate wait time for next reconnection (exponential backoff)
+            backoff = min(backoff * 2, MAX_BACKOFF)
+
+async def connect_to_server(uri, target):
+    """Connect to WebSocket server and pipe stdio for the given server target."""
+    try:
+        logger.info(f"[{target}] Connecting to WebSocket server...")
+        async with websockets.connect(uri) as websocket:
+            logger.info(f"[{target}] Successfully connected to WebSocket server")
+
+            # Start server process (built from CLI arg or config)
+            cmd, env = build_server_command(target)
+            process = subprocess.Popen(
+                cmd,
+                stdin=subprocess.PIPE,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.PIPE,
+                encoding='utf-8',
+                text=True,
+                env=env
+            )
+            logger.info(f"[{target}] Started server process: {' '.join(cmd)}")
+            
+            # Create two tasks: read from WebSocket and write to process, read from process and write to WebSocket
+            await asyncio.gather(
+                pipe_websocket_to_process(websocket, process, target),
+                pipe_process_to_websocket(process, websocket, target),
+                pipe_process_stderr_to_terminal(process, target)
+            )
+    except websockets.exceptions.ConnectionClosed as e:
+        logger.error(f"[{target}] WebSocket connection closed: {e}")
+        raise  # Re-throw exception to trigger reconnection
+    except Exception as e:
+        logger.error(f"[{target}] Connection error: {e}")
+        raise  # Re-throw exception
+    finally:
+        # Ensure the child process is properly terminated
+        if 'process' in locals():
+            logger.info(f"[{target}] Terminating server process")
+            try:
+                process.terminate()
+                process.wait(timeout=5)
+            except subprocess.TimeoutExpired:
+                process.kill()
+            logger.info(f"[{target}] Server process terminated")
+
+async def pipe_websocket_to_process(websocket, process, target):
+    """Read data from WebSocket and write to process stdin"""
+    try:
+        while True:
+            # Read message from WebSocket
+            message = await websocket.recv()
+            logger.debug(f"[{target}] << {message[:120]}...")
+            
+            # Write to process stdin (in text mode)
+            if isinstance(message, bytes):
+                message = message.decode('utf-8')
+            process.stdin.write(message + '\n')
+            process.stdin.flush()
+    except Exception as e:
+        logger.error(f"[{target}] Error in WebSocket to process pipe: {e}")
+        raise  # Re-throw exception to trigger reconnection
+    finally:
+        # Close process stdin
+        if not process.stdin.closed:
+            process.stdin.close()
+
+async def pipe_process_to_websocket(process, websocket, target):
+    """Read data from process stdout and send to WebSocket"""
+    try:
+        while True:
+            # Read data from process stdout
+            data = await asyncio.to_thread(process.stdout.readline)
+            
+            if not data:  # If no data, the process may have ended
+                logger.info(f"[{target}] Process has ended output")
+                break
+                
+            # Send data to WebSocket
+            logger.debug(f"[{target}] >> {data[:120]}...")
+            # In text mode, data is already a string, no need to decode
+            await websocket.send(data)
+    except Exception as e:
+        logger.error(f"[{target}] Error in process to WebSocket pipe: {e}")
+        raise  # Re-throw exception to trigger reconnection
+
+async def pipe_process_stderr_to_terminal(process, target):
+    """Read data from process stderr and print to terminal"""
+    try:
+        while True:
+            # Read data from process stderr
+            data = await asyncio.to_thread(process.stderr.readline)
+            
+            if not data:  # If no data, the process may have ended
+                logger.info(f"[{target}] Process has ended stderr output")
+                break
+                
+            # Print stderr data to terminal (in text mode, data is already a string)
+            sys.stderr.write(data)
+            sys.stderr.flush()
+    except Exception as e:
+        logger.error(f"[{target}] Error in process stderr pipe: {e}")
+        raise  # Re-throw exception to trigger reconnection
+
+def signal_handler(sig, frame):
+    """Handle interrupt signals"""
+    logger.info("Received interrupt signal, shutting down...")
+    sys.exit(0)
+
+def load_config():
+    """Load JSON config from $MCP_CONFIG or ./mcp_config.json. Return dict or {}."""
+    path = os.environ.get("MCP_CONFIG") or os.path.join(os.getcwd(), "mcp_config.json")
+    if not os.path.exists(path):
+        return {}
+    try:
+        with open(path, "r", encoding="utf-8") as f:
+            return json.load(f)
+    except Exception as e:
+        logger.warning(f"Failed to load config {path}: {e}")
+        return {}
+
+
+def build_server_command(target=None):
+    """Build [cmd,...] and env for the server process for a given target.
+
+    Priority:
+    - If target matches a server in config.mcpServers: use its definition
+    - Else: treat target as a Python script path (back-compat)
+    If target is None, read from sys.argv[1].
+    """
+    if target is None:
+        assert len(sys.argv) >= 2, "missing server name or script path"
+        target = sys.argv[1]
+    cfg = load_config()
+    servers = cfg.get("mcpServers", {}) if isinstance(cfg, dict) else {}
+
+    if target in servers:
+        entry = servers[target] or {}
+        if entry.get("disabled"):
+            raise RuntimeError(f"Server '{target}' is disabled in config")
+        typ = (entry.get("type") or entry.get("transportType") or "stdio").lower()
+
+        # environment for child process
+        child_env = os.environ.copy()
+        for k, v in (entry.get("env") or {}).items():
+            child_env[str(k)] = str(v)
+
+        if typ == "stdio":
+            command = entry.get("command")
+            args = entry.get("args") or []
+            if not command:
+                raise RuntimeError(f"Server '{target}' is missing 'command'")
+            return [command, *args], child_env
+
+        if typ in ("sse", "http", "streamablehttp"):
+            url = entry.get("url")
+            if not url:
+                raise RuntimeError(f"Server '{target}' (type {typ}) is missing 'url'")
+            # Unified approach: always use current Python to run mcp-proxy module
+            cmd = [sys.executable, "-m", "mcp_proxy"]
+            if typ in ("http", "streamablehttp"):
+                cmd += ["--transport", "streamablehttp"]
+            # optional headers: {"Authorization": "Bearer xxx"}
+            headers = entry.get("headers") or {}
+            for hk, hv in headers.items():
+                cmd += ["-H", hk, str(hv)]
+            cmd.append(url)
+            return cmd, child_env
+
+        raise RuntimeError(f"Unsupported server type: {typ}")
+
+    # Fallback to script path (back-compat)
+    script_path = target
+    if not os.path.exists(script_path):
+        raise RuntimeError(
+            f"'{target}' is neither a configured server nor an existing script"
+        )
+    return [sys.executable, script_path], os.environ.copy()
+
+if __name__ == "__main__":
+    # Register signal handler
+    signal.signal(signal.SIGINT, signal_handler)
+    
+    # Get token from environment variable or command line arguments
+    endpoint_url = os.environ.get('MCP_ENDPOINT')
+    if not endpoint_url:
+        logger.error("Please set the `MCP_ENDPOINT` environment variable")
+        sys.exit(1)
+    
+    # Determine target: default to all if no arg; single target otherwise
+    target_arg = sys.argv[1] if len(sys.argv) >= 2 else None
+
+    async def _main():
+        if not target_arg:
+            cfg = load_config()
+            servers_cfg = (cfg.get("mcpServers") or {})
+            all_servers = list(servers_cfg.keys())
+            enabled = [name for name, entry in servers_cfg.items() if not (entry or {}).get("disabled")]
+            skipped = [name for name in all_servers if name not in enabled]
+            if skipped:
+                logger.info(f"Skipping disabled servers: {', '.join(skipped)}")
+            if not enabled:
+                raise RuntimeError("No enabled mcpServers found in config")
+            logger.info(f"Starting servers: {', '.join(enabled)}")
+            tasks = [asyncio.create_task(connect_with_retry(endpoint_url, t)) for t in enabled]
+            # Run all forever; if any crashes it will auto-retry inside
+            await asyncio.gather(*tasks)
+        else:
+            if os.path.exists(target_arg):
+                await connect_with_retry(endpoint_url, target_arg)
+            else:
+                logger.error("Argument must be a local Python script path. To run configured servers, run without arguments.")
+                sys.exit(1)
+
+    try:
+        asyncio.run(_main())
+    except KeyboardInterrupt:
+        logger.info("Program interrupted by user")
+    except Exception as e:
+        logger.error(f"Program execution error: {e}")

+ 7 - 0
requirements.txt

@@ -0,0 +1,7 @@
+mcp>=1.19.0
+mcp-proxy>=0.10.0
+python-dotenv>=1.2.1
+pyyaml>=6.0.3
+requests>=2.32.5
+websockets>=15.0.1
+loguru==0.7.3

+ 42 - 0
start.ps1

@@ -0,0 +1,42 @@
+# 启动脚本 - MCP Calculator
+# 激活虚拟环境并启动服务
+
+# 设置编码为UTF-8,解决中文乱码问题
+[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
+$OutputEncoding = [System.Text.Encoding]::UTF8
+chcp 65001 | Out-Null
+
+# 检查虚拟环境是否存在
+$venvPath = ".venv"
+if (-not (Test-Path $venvPath)) {
+    $venvPath = "venv"
+    if (-not (Test-Path $venvPath)) {
+        Write-Host "错误: 未找到虚拟环境,请先创建虚拟环境" -ForegroundColor Red
+        exit 1
+    }
+}
+
+# 激活虚拟环境
+$activateScript = Join-Path $venvPath "Scripts\Activate.ps1"
+if (Test-Path $activateScript) {
+    Write-Host "正在激活虚拟环境..." -ForegroundColor Green
+    & $activateScript
+} else {
+    Write-Host "警告: 未找到虚拟环境激活脚本,继续执行..." -ForegroundColor Yellow
+}
+
+# 设置环境变量
+Write-Host "正在设置环境变量..." -ForegroundColor Green
+$env:FLASK_API_BASE = "http://192.168.254.242:5050"
+$env:MCP_ENDPOINT = "wss://api.xiaozhi.me/mcp/?token=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgxMSwiYWdlbnRJZCI6ODk0MzQxLCJlbmRwb2ludElkIjoiYWdlbnRfODk0MzQxIiwicHVycG9zZSI6Im1jcC1lbmRwb2ludCIsImlhdCI6MTc2MTcxOTA4MywiZXhwIjoxNzkzMjc2NjgzfQ.wu-N4mKWH3mINiEaS8UgbErwoehFyLREekAOg5pT5s-FzngzeFGlsid7fuvEW20E7O_wCvkgZd0N1vdhrCUJPQ"
+
+Write-Host "环境变量已设置:" -ForegroundColor Cyan
+Write-Host "  FLASK_API_BASE = $env:FLASK_API_BASE" -ForegroundColor Cyan
+Write-Host "  MCP_ENDPOINT = $env:MCP_ENDPOINT" -ForegroundColor Cyan
+
+# 启动服务
+Write-Host "`n正在启动服务..." -ForegroundColor Green
+Write-Host "执行命令: uv run mcp_pipe.py calculator.py`n" -ForegroundColor Yellow
+
+uv run mcp_pipe.py calculator.py
+

+ 38 - 0
utils/logger_config.py

@@ -0,0 +1,38 @@
+# logger_config.py
+from loguru import logger
+import sys
+import os
+
+# 移除默认配置(可选)
+logger.remove()
+
+# 配置控制台输出
+logger.add(
+    sys.stdout,
+    colorize=True,
+    format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
+    level="DEBUG"
+)
+
+# 配置文件输出(带轮换)
+log_path = os.path.join(os.getcwd(), "logs", "app_{time}.log")
+logger.add(
+    log_path,
+    rotation="100 MB",  # 按大小轮换
+    retention="30 days",  # 保留30天
+    compression="zip",  # 压缩旧日志
+    enqueue=True,  # 线程安全
+    backtrace=True,  # 记录异常堆栈
+    diagnose=True,  # 显示变量值(生产环境建议关闭)
+    level="INFO",
+    format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}"
+)
+
+# 可选:添加错误日志单独文件
+logger.add(
+    os.path.join(os.getcwd(), "logs", "error.log"),
+    rotation="00:00",  # 每天轮换
+    retention="90 days",
+    level="ERROR",
+    format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}"
+)

+ 50 - 0
video_config_prod.yaml

@@ -0,0 +1,50 @@
+video_infos:
+  - id: 0
+    name: 公司宣传片
+    formula: 公司宣传片
+    description: 公司宣传片
+    video_duration: 48
+    video_path: /sdcard/Movies/0.mp4
+    code: 公司宣传片
+  - id: 1
+    name: 氯化铷
+    formula: 氯化铷
+    description: 氯化铷是一种白色、易溶于水的离子晶体,是铷元素最常见的化合物之一。它在实验室中主要用于合成其他铷化合物,在生物医学研究中作为钾离子的类似物或示踪剂,在核医学中作为放射性药物用于诊断,并在物理、材料科学等领域有特定应用。
+    video_duration: 56
+    video_path: /sdcard/Movies/氯化铷.mp4
+    code: 1号展品
+  - id: 2
+    name: 硫酸钙新材料
+    formula: 硫酸钙新材料
+    description: 硫酸钙是一种来源广泛、应用极其多样的钙盐。其最重要的特性是水合状态的变化,这使其成为石膏建材、模型制作和骨科固定的基础。它在食品、农业、医药、工业等多个领域都扮演着重要角色,是一种安全、实用、多功能的化合物。
+    video_duration: 56
+    video_path: /sdcard/Movies/硫酸钙新材料.mp4
+    code: 2号展品
+  - id: 3
+    name: 白炭黑
+    formula: 白炭黑
+    description: 白炭黑是一种多功能、高性能的白色二氧化硅粉末材料。它凭借其超细粒径、巨大比表面积和可调控的表面性质,在橡胶补强、增稠触变、抗结块、助流、消光等众多领域发挥着不可替代的作用,是现代工业和日常生活中广泛应用的关键材料之一。
+    video_duration: 56
+    video_path: /sdcard/Movies/白炭黑.mp4
+    code: 3号展品
+  - id: 4
+    name: 碳酸锂
+    formula: 碳酸锂
+    description: 碳酸锂是一种碳酸锂的化合物,在科研和工业中有特殊用途,如原子钟等精密仪器。
+    video_duration: 56
+    video_path: /sdcard/Movies/碳酸锂.mp4
+    code: 4号展品
+  - id: 5
+    name: 硫酸钾
+    formula: 硫酸钾
+    description: 硫酸钾是一种重要的白色结晶盐类,是无氯优质钾肥的代表,尤其适用于忌氯经济作物。它提供植物必需的钾和硫元素,对提高作物产量和品质至关重要。
+    video_duration: 56
+    video_path: /sdcard/Movies/硫酸钾.mp4
+    code: 5号展品
+  - id: 6
+    name: 氯化铯
+    formula: 氯化铯
+    description: 氯化铯是一种密度极高、具有独特晶体结构的白色结晶盐。其浓水溶液在超速离心分离技术中扮演着不可替代的角色。它在科研、特种玻璃、核医学等领域也有重要应用。
+    video_duration: 56
+    video_path: /sdcard/Movies/氯化铯.mp4
+    code: 6号展品