├── config ├── __init__.py ├── config.toml └── config.py ├── requirements.txt ├── server ├── servers_config.json ├── servers_config.json.example ├── configuration.py ├── server.py └── douyin_open_api_server.py ├── common ├── tool.py ├── log.py └── utils.py ├── README.md ├── api ├── service_api.py └── api_request.py ├── main-api.py ├── main-webui.py ├── client └── llm_client_qwen.py └── session └── douyin_open_api_session.py /config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | httpx~=0.27.2 2 | config~=0.5.1 3 | fastapi~=0.115.12 4 | dashscope~=1.20.0 5 | pydantic~=2.10.6 6 | toml~=0.10.2 7 | mcp~=1.5.0 8 | python-dotenv~=1.0.1 9 | uvicorn~=0.30.6 10 | gradio~=5.23.3 -------------------------------------------------------------------------------- /config/config.toml: -------------------------------------------------------------------------------- 1 | [model] 2 | qwen_api_key = "sk-**********" 3 | qwen_model_name = "qwen-max" 4 | 5 | [server] 6 | API_HOST="127.0.0.1" 7 | API_PORT=8899 8 | WEB_SERVER_HOST="127.0.0.1" 9 | WEB_SERVER_PORT=8080 10 | 11 | -------------------------------------------------------------------------------- /server/servers_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "python-tools": { 4 | "command": "python", 5 | "args": [ 6 | "server/douyin_open_api_server.py" 7 | ], 8 | "transport": "stdio" 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /server/servers_config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "python-tools": { 4 | "command": "python", 5 | "args": [ 6 | "server/math_server.py" 7 | ], 8 | "transport": "stdio" 9 | }, 10 | "weather": { 11 | "url": "http://localhost:8000/sse", 12 | "transport": "sse", 13 | "timeout": 5, 14 | "sse_read_timeout": 300, 15 | "headers": { 16 | "Authorization": "Bearer 1234567890" 17 | } 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /server/configuration.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from dotenv import load_dotenv 4 | from typing import Any 5 | 6 | class Configuration: 7 | """Manages configuration and environment variables for the MCP client.""" 8 | 9 | def __init__(self) -> None: 10 | """Initialize configuration with environment variables.""" 11 | self.load_env() 12 | # self.api_key = os.getenv("LLM_API_KEY") 13 | 14 | @staticmethod 15 | def load_env() -> None: 16 | """Load environment variables from .env file.""" 17 | load_dotenv() 18 | 19 | @staticmethod 20 | def load_config(file_path: str) -> dict[str, Any]: 21 | """Load server configuration from JSON file. 22 | 23 | Args: 24 | file_path: Path to the JSON configuration file. 25 | 26 | Returns: 27 | Dict containing server configuration. 28 | 29 | Raises: 30 | FileNotFoundError: If configuration file doesn't exist. 31 | JSONDecodeError: If configuration file is invalid JSON. 32 | """ 33 | with open(file_path, "r") as f: 34 | return json.load(f) 35 | -------------------------------------------------------------------------------- /common/tool.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | class Tool: 4 | """Represents a tool with its properties and formatting.""" 5 | 6 | def __init__( 7 | self, name: str, description: str, input_schema: dict[str, Any] 8 | ) -> None: 9 | self.name: str = name 10 | self.description: str = description 11 | self.input_schema: dict[str, Any] = input_schema 12 | 13 | def format_for_llm(self) -> str: 14 | """Format tool information for LLM. 15 | 16 | Returns: 17 | A formatted string describing the tool. 18 | """ 19 | args_desc = [] 20 | if "properties" in self.input_schema: 21 | for param_name, param_info in self.input_schema["properties"].items(): 22 | arg_desc = ( 23 | f"- {param_name}: {param_info.get('description', 'No description')}" 24 | ) 25 | if param_name in self.input_schema.get("required", []): 26 | arg_desc += " (required)" 27 | args_desc.append(arg_desc) 28 | 29 | return f""" 30 | Tool: {self.name} 31 | Description: {self.description} 32 | Arguments: 33 | {chr(10).join(args_desc)} 34 | """ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mcp-ChatBI 2 | 3 | ## 介绍 4 | 5 | 基于 [Mcp](https://mcp-docs.cn/) 架构的 ChatBI,是一种数据分析智能体的解决方案。 6 | 7 | 本项目主要解决ChatBI常见的三个问题 8 | + 1、如何保障数据的100%的准确性? 9 | 10 | 由于模型存在幻觉,无论是NL2SQL、NL2Code,都无法保障数据100%的准确。且数据的准确性又是BI系统的红线,因此,本项目使用NL2Tools的方案,Tools可以是Headless BI的服务,也可是API。 11 | 12 | + 2、一次对话如何查询多个指标? 13 | 14 | 过去一次对话只能查一个指标,若多个指标需要工程层面去拆解,架构的复杂度非常高,本项目,利用模型的任务规划、推理能力,模型自动拆解多个指标,并按照顺序调用Tools,并返回结果。 15 | 16 | + 3、如何让数据分析的链路自动化? 17 | 18 | 常见的数据分析方法,如对比分析、多维钻取、归因运算等,它常常伴有复杂的逻辑推理,当前大模型能力突飞猛进,已经具有复杂问题的推理能力,因此,本项目,利用模型推理能力,自动生成数据链路,并逐次调用Tools,返回结果,最后总结分析。 19 | 20 | 21 | ## 部署 22 | 23 | ### 1. 环境配置 24 | 25 | + 确保你的机器安装了 Python 3.10 - 3.12 26 | ```shell 27 | # 拉取仓库 28 | $ git clone https://github.com/dynamiclu/Mcp-ChatBI.git 29 | 30 | # 进入目录 31 | $ cd Mcp-ChatBI 32 | 33 | # 安装全部依赖 34 | $ pip3 install -r requirements.txt 35 | ``` 36 | 37 | + 大模型配置 38 | ```shell 39 | $ vim config/config.toml 40 | [model] 41 | qwen_api_key = "sk-**********" 42 | qwen_model_name = "qwen-max" 43 | ``` 44 | 45 | ### 2. 启动接口 46 | ```shell 47 | # 启动API 48 | $ python3 main-api.py 49 | ``` 50 | 51 | ### 3. 启动Gradio 52 | ```shell 53 | # 启动Gradio 54 | $ python3 main-webui.py 55 | ``` 56 | ### 4. 演示 57 | [https://www.bilibili.com/video/BV1b95vzPEjf/](https://www.bilibili.com/video/BV1b95vzPEjf/) -------------------------------------------------------------------------------- /api/service_api.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from fastapi.responses import StreamingResponse 3 | from fastapi import Body 4 | from server.configuration import Configuration 5 | from server.server import Server 6 | from session.douyin_open_api_session import DouyinOpenApiSession 7 | 8 | config = Configuration() 9 | server_config = config.load_config("server/servers_config.json") 10 | servers = [ 11 | Server(name, srv_config) 12 | for name, srv_config in server_config["mcpServers"].items() 13 | ] 14 | chat_session = DouyinOpenApiSession(servers) 15 | 16 | 17 | 18 | async def gen_chat_response(query: str = Body(..., description="用户输入", examples=["请您输入问题"]), session_id: str = Body(..., description="会话ID", examples=["0"]), open_id: str = Body(..., description="用户ID", examples=["0"])): 19 | if not query or query == "" or not session_id or not open_id: 20 | return StreamingResponse(content="抱歉,请您输入问题", status_code=200, 21 | media_type="text/markdown; charset=utf-8") 22 | if session_id == "0": 23 | await chat_session.init_servers() 24 | 25 | async def event_generator(): 26 | yield "小精灵开始工作了... ![加载中](https://static.fengmiai.com/source_image/loading.gif)" 27 | try: 28 | async for chunk in chat_session.start(query=query, session_id=session_id, open_id=open_id): 29 | await asyncio.sleep(0.01) 30 | yield chunk 31 | except asyncio.CancelledError: 32 | await chat_session.cleanup_servers() 33 | yield "小精灵工作被取消了..." 34 | 35 | return StreamingResponse(event_generator(), media_type="text/plain; charset=utf-8") 36 | -------------------------------------------------------------------------------- /api/api_request.py: -------------------------------------------------------------------------------- 1 | from config.config import API_URL 2 | import httpx 3 | 4 | def set_httpx_timeout(timeout=60.0): 5 | httpx._config.DEFAULT_TIMEOUT_CONFIG.connect = timeout 6 | httpx._config.DEFAULT_TIMEOUT_CONFIG.read = timeout 7 | httpx._config.DEFAULT_TIMEOUT_CONFIG.write = timeout 8 | 9 | set_httpx_timeout() 10 | 11 | class ApiRequest: 12 | def __init__( 13 | self, 14 | base_url: str = API_URL, 15 | timeout: float = 120.0, 16 | no_remote_api: bool = False, # call api view function directly 17 | ): 18 | self.base_url = base_url 19 | self.timeout = timeout 20 | self.no_remote_api = no_remote_api 21 | 22 | def _parse_url(self, url: str) -> str: 23 | if not url.startswith("http") and self.base_url: 24 | part1 = self.base_url.strip(" /") 25 | part2 = url.strip(" /") 26 | return f"{part1}/{part2}" 27 | else: 28 | return url 29 | 30 | 31 | async def data_assistant_chat( 32 | self, 33 | query: str, 34 | session_id: str = None, 35 | open_id: str = "" 36 | ): 37 | data = { 38 | "query": query, 39 | "session_id": session_id, 40 | "open_id": open_id 41 | } 42 | url = self._parse_url("/api/data/assistant/chat") 43 | async with httpx.AsyncClient() as client: 44 | async with client.stream("POST", url, json=data) as response: 45 | async for chunk in response.aiter_bytes(None): 46 | chunk_text = chunk.decode('utf-8') 47 | yield chunk_text 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /config/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import socket 3 | import toml 4 | import shutil 5 | from common.log import logger 6 | 7 | root_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) 8 | config_file = f"{root_dir}/config/config.toml" 9 | 10 | OPEN_CROSS_DOMAIN = True 11 | 12 | def load_config(): 13 | if os.path.isdir(config_file): 14 | shutil.rmtree(config_file) 15 | 16 | if not os.path.isfile(config_file): 17 | example_file = f"{root_dir}/config.example.toml" 18 | if os.path.isfile(example_file): 19 | shutil.copyfile(example_file, config_file) 20 | logger.info(f"copy config.example.toml to config.toml") 21 | 22 | logger.info(f"load config from file: {config_file}") 23 | 24 | try: 25 | _config_ = toml.load(config_file) 26 | except Exception as e: 27 | logger.warning(f"load config failed: {str(e)}, try to load as utf-8-sig") 28 | with open(config_file, mode="r", encoding="utf-8-sig") as fp: 29 | _cfg_content = fp.read() 30 | _config_ = toml.loads(_cfg_content) 31 | return _config_ 32 | 33 | 34 | 35 | _cfg = load_config() 36 | app = _cfg.get("model", {}) 37 | server = _cfg.get("server", {}) 38 | 39 | if server: 40 | API_HOST = server.get("API_HOST", "127.0.0.1") 41 | API_PORT = server.get("API_PORT", 8899) 42 | API_URL = f"http://{API_HOST}:{API_PORT}" 43 | WEB_SERVER_HOST = server.get("WEB_SERVER_HOST", "127.0.0.1") 44 | WEB_SERVER_PORT = server.get("WEB_SERVER_PORT", 8080) 45 | else: 46 | API_HOST = "127.0.0.1" 47 | API_PORT = 8899 48 | API_URL = f"http://{API_HOST}:{API_PORT}" 49 | WEB_SERVER_HOST = "127.0.0.1" 50 | WEB_SERVER_PORT = 8080 51 | -------------------------------------------------------------------------------- /common/log.py: -------------------------------------------------------------------------------- 1 | import os 2 | import datetime 3 | import logging 4 | from logging.handlers import TimedRotatingFileHandler 5 | from threading import Lock 6 | 7 | LOG_BASE_PATH = 'log/' 8 | now = datetime.datetime.now() 9 | now_time = now.strftime("%Y-%m-%d") 10 | LOG_PATH = LOG_BASE_PATH + now_time + "/" 11 | try: 12 | os.makedirs(LOG_PATH, exist_ok=True) 13 | except Exception as e: 14 | pass 15 | 16 | 17 | class LoggerProject(object): 18 | 19 | def __init__(self, name): 20 | self.mutex = Lock() 21 | self.name = name 22 | self.formatter = '%(asctime)s - %(filename)s - [line]:%(lineno)d - %(levelname)s - %(message)s' 23 | 24 | def _create_logger(self): 25 | _logger = logging.getLogger(self.name + __name__) 26 | _logger.setLevel(level=logging.INFO) 27 | return _logger 28 | 29 | def _file_logger(self): 30 | time_rotate_file = TimedRotatingFileHandler(filename=LOG_PATH + self.name, when='D', interval=1, 31 | backupCount=30) 32 | time_rotate_file.setFormatter(logging.Formatter(self.formatter)) 33 | time_rotate_file.setLevel(logging.INFO) 34 | return time_rotate_file 35 | 36 | def _console_logger(self): 37 | console_handler = logging.StreamHandler() 38 | console_handler.setLevel(level=logging.INFO) 39 | console_handler.setFormatter(logging.Formatter(self.formatter)) 40 | return console_handler 41 | 42 | def pub_logger(self): 43 | logger = self._create_logger() 44 | self.mutex.acquire() 45 | logger.addHandler(self._file_logger()) 46 | logger.addHandler(self._console_logger()) 47 | self.mutex.release() 48 | return logger 49 | 50 | 51 | log_api = LoggerProject('mcp-chatbi.log') 52 | logger = log_api.pub_logger() 53 | -------------------------------------------------------------------------------- /main-api.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import uvicorn 3 | from fastapi import FastAPI 4 | from common.utils import ListResponse 5 | from fastapi.middleware.cors import CORSMiddleware 6 | from config.config import OPEN_CROSS_DOMAIN, API_HOST, API_PORT 7 | from api.service_api import gen_chat_response 8 | 9 | 10 | def create_app(): 11 | app = FastAPI() 12 | 13 | if OPEN_CROSS_DOMAIN: 14 | app.add_middleware( 15 | CORSMiddleware, 16 | allow_origins=["*"], 17 | allow_credentials=True, 18 | allow_methods=["*"], 19 | allow_headers=["*"], 20 | ) 21 | app.post("/api/data/assistant/chat", 22 | tags=["data chat "], 23 | response_model=ListResponse, 24 | summary="生成式数据助手")(gen_chat_response) 25 | 26 | return app 27 | 28 | 29 | app = create_app() 30 | 31 | 32 | def run_api(host, port, **kwargs): 33 | if kwargs.get("ssl_keyfile") and kwargs.get("ssl_certfile"): 34 | uvicorn.run(app, 35 | host=host, 36 | port=port, 37 | ssl_keyfile=kwargs.get("ssl_keyfile"), 38 | ssl_certfile=kwargs.get("ssl_certfile"), 39 | ) 40 | else: 41 | uvicorn.run(app, host=host, port=port) 42 | 43 | 44 | if __name__ == "__main__": 45 | parser = argparse.ArgumentParser(prog='data-genius', 46 | description='') 47 | parser.add_argument("--host", type=str, default=API_HOST) 48 | parser.add_argument("--port", type=int, default=API_PORT) 49 | parser.add_argument("--ssl_keyfile", type=str) 50 | parser.add_argument("--ssl_certfile", type=str) 51 | 52 | args = parser.parse_args() 53 | args_dict = vars(args) 54 | run_api(host=args.host, 55 | port=args.port, 56 | ssl_keyfile=args.ssl_keyfile, 57 | ssl_certfile=args.ssl_certfile, 58 | ) 59 | -------------------------------------------------------------------------------- /common/utils.py: -------------------------------------------------------------------------------- 1 | 2 | import json 3 | import pydantic 4 | import asyncio 5 | from pydantic import BaseModel 6 | from typing import List 7 | 8 | def is_valid_json(llm_response: str) -> bool: 9 | """判断字符串是否为有效的 JSON 结构。 10 | 11 | Args: 12 | llm_response: 要判断的字符串。 13 | 14 | Returns: 15 | 如果字符串是有效的 JSON 结构,返回 True;否则返回 False。 16 | """ 17 | try: 18 | json.loads(llm_response.strip()) 19 | return True 20 | except json.JSONDecodeError as e: 21 | # logger.error(f"Invalid JSON: {e}") 22 | return False 23 | 24 | def run_async(cor): 25 | ''' 26 | 在同步环境中运行异步代码. 27 | ''' 28 | try: 29 | loop = asyncio.get_event_loop() 30 | except: 31 | loop = asyncio.new_event_loop() 32 | return loop.run_until_complete(cor) 33 | 34 | def iter_over_async(ait, loop): 35 | ''' 36 | 将异步生成器封装成同步生成器. 37 | ''' 38 | ait = ait.__aiter__() 39 | async def get_next(): 40 | try: 41 | obj = await ait.__anext__() 42 | return False, obj 43 | except StopAsyncIteration: 44 | return True, None 45 | while True: 46 | done, obj = loop.run_until_complete(get_next()) 47 | if done: 48 | break 49 | yield obj 50 | 51 | class BaseResponse(BaseModel): 52 | code: int = pydantic.Field(200, description="HTTP status code") 53 | msg: str = pydantic.Field("success", description="HTTP status message") 54 | 55 | class Config: 56 | json_schema_extra = { 57 | "example": { 58 | "code": 200, 59 | "msg": "success", 60 | } 61 | } 62 | 63 | 64 | class ListResponse(BaseResponse): 65 | data: List[str] = pydantic.Field(..., description="List of names") 66 | 67 | class Config: 68 | json_schema_extra = { 69 | "example": { 70 | "code": 200, 71 | "msg": "success", 72 | "data": ["", "", ""], 73 | } 74 | } -------------------------------------------------------------------------------- /main-webui.py: -------------------------------------------------------------------------------- 1 | import gradio as gr 2 | import time 3 | import asyncio 4 | from api.api_request import ApiRequest 5 | from config.config import API_URL, WEB_SERVER_PORT, WEB_SERVER_HOST 6 | api = ApiRequest(base_url=API_URL, no_remote_api=False) 7 | 8 | open_id = "first_man" 9 | session_id = "0" 10 | examples_list = [["查看视频列表数据,生成数据分析报告"], ["分析一下美猴王大闹天宫的情况"], ["粉丝来源分析"], ["粉丝画像数据"], ["用户粉丝数"]] 11 | 12 | # 定义一个生成器函数,模拟逐步生成回复 13 | async def stream_response(message, history): 14 | global session_id 15 | async for chunk in api.data_assistant_chat(query=message, session_id=session_id, open_id=open_id): 16 | if chunk: 17 | await asyncio.sleep(0.005) 18 | new_history = history + [{"role": "assistant", "content": str(chunk)}] 19 | yield new_history # 逐步更新历史记录 20 | 21 | 22 | 23 | webui_title = """ 24 | # 抖数精灵 (Data Genius) 25 | """ 26 | init_message = [{"role": "assistant", 27 | "content": "您好,我是抖数精灵,您的抖音运营数据分析助手。请告诉我您想了解的数据分析问题,我会为您提供详细的分析和建议。"}] 28 | custom_css = """ 29 | #chat_bi { 30 | height: 60vh !important; /* 使聊天机器人高度占满整个视口 */ 31 | overflow-y: auto !important; /* 添加滚动条以处理内容溢出 */ 32 | } 33 | """ 34 | 35 | # 定义 Gradio 界面 36 | with gr.Blocks(css=custom_css) as demo: 37 | gr.Markdown(webui_title) 38 | chatbot = gr.Chatbot(type="messages", value=init_message, elem_id="chat_bi") # 创建 Chatbot 组件 39 | msg = gr.Textbox(label="输入你的问题") # 用户输入框 40 | hidden_msg = gr.Textbox(visible=False) 41 | examples = gr.Examples(examples=examples_list, inputs=[hidden_msg], elem_id="examples") 42 | 43 | 44 | def user_submit(message, history): 45 | global session_id 46 | if not history or len(history) <= 1: 47 | session_id = "0" # 如果历史记录为空,重置 session_id 为 0 48 | else: 49 | session_id = str(int(time.time())) # 如果历史记录不为空,设置 session_id 为当前时间戳 50 | new_history = history + [{"role": "user", "content": message}] 51 | return "", new_history, message # 返回空字符串、更新后的历史记录和用户消息 52 | 53 | hidden_msg.change(user_submit, [hidden_msg, chatbot], [msg, chatbot, hidden_msg], queue=False).then( 54 | stream_response, 55 | [hidden_msg, chatbot], 56 | chatbot 57 | ) 58 | msg.submit(user_submit, [msg, chatbot], [msg, chatbot, msg], queue=False).then( 59 | stream_response, 60 | [msg, chatbot], 61 | chatbot 62 | ) 63 | 64 | # 启动 Gradio 应用 65 | if __name__ == '__main__': 66 | demo.launch(server_name=WEB_SERVER_HOST, server_port=WEB_SERVER_PORT, share=False) 67 | -------------------------------------------------------------------------------- /client/llm_client_qwen.py: -------------------------------------------------------------------------------- 1 | import dashscope 2 | from dashscope.api_entities.dashscope_response import GenerationResponse 3 | 4 | from config import config 5 | 6 | _max_retries = 5 7 | 8 | 9 | def _generate_response_stream(messages: [dict]) -> str: 10 | api_key = config.app.get("qwen_api_key") 11 | model_name = config.app.get("qwen_model_name") 12 | dashscope.api_key = api_key 13 | response_stream = dashscope.Generation.call( 14 | model=model_name, messages=messages, stream=True 15 | ) 16 | if response_stream: 17 | try: 18 | # 初始化一个变量用于存储完整的生成内容 19 | full_content = "" 20 | # 遍历流式响应 21 | for chunk in response_stream: 22 | if isinstance(chunk, GenerationResponse): 23 | status_code = chunk.status_code 24 | if status_code != 200: 25 | raise Exception( 26 | f' returned an error response: "{chunk}"' 27 | ) 28 | 29 | # 获取当前 chunk 的内容 30 | if "output" in chunk and "text" in chunk["output"]: 31 | content_chunk = chunk["output"]["text"] 32 | full_content += content_chunk 33 | 34 | # 实时输出当前 chunk 的内容(可以根据需求调整) 35 | # print(content_chunk, end="", flush=True) 36 | yield content_chunk 37 | 38 | else: 39 | raise Exception( 40 | f' returned an invalid chunk: "{chunk}"' 41 | ) 42 | else: 43 | raise Exception( 44 | f' returned an invalid response type: "{chunk}"' 45 | ) 46 | # 返回完整的生成内容 47 | # return full_content 48 | except Exception as e: 49 | raise Exception(f"Error during streaming: {e}") 50 | else: 51 | raise Exception(f" returned an empty response") 52 | 53 | 54 | def _generate_response(messages: [dict]) -> str: 55 | content = "" 56 | api_key = config.app.get("qwen_api_key") 57 | model_name = config.app.get("qwen_model_name") 58 | dashscope.api_key = api_key 59 | response = dashscope.Generation.call( 60 | model=model_name, messages=messages 61 | ) 62 | if response: 63 | if isinstance(response, GenerationResponse): 64 | status_code = response.status_code 65 | if status_code != 200: 66 | raise Exception( 67 | f'returned an error response: "{response}"' 68 | ) 69 | content = response["output"]["text"] 70 | return content 71 | else: 72 | raise Exception( 73 | f'returned an invalid response: "{response}"' 74 | ) 75 | else: 76 | raise Exception(f" returned an empty response") 77 | 78 | 79 | -------------------------------------------------------------------------------- /server/server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from typing import Any, Literal, cast 4 | from mcp import ClientSession, StdioServerParameters 5 | from mcp.client.stdio import stdio_client 6 | from mcp.client.sse import sse_client 7 | from contextlib import AsyncExitStack 8 | 9 | from common.log import logger 10 | from common.tool import Tool 11 | 12 | DEFAULT_ENCODING = "utf-8" 13 | DEFAULT_ENCODING_ERROR_HANDLER = "strict" 14 | DEFAULT_HTTP_TIMEOUT = 5 15 | DEFAULT_SSE_READ_TIMEOUT = 60 * 5 16 | 17 | 18 | class Server: 19 | """Manages MCP server connections and tool execution.""" 20 | 21 | def __init__(self, name: str, config: dict[str, Any]) -> None: 22 | self.name: str = name 23 | self.config: dict[str, Any] = config 24 | self.stdio_context: Any | None = None 25 | self.session: ClientSession | None = None 26 | self._cleanup_lock: asyncio.Lock = asyncio.Lock() 27 | self.exit_stack: AsyncExitStack = AsyncExitStack() 28 | 29 | async def initialize(self) -> None: 30 | transport = self.config.get("transport", "stdio") 31 | if transport == "sse": 32 | await self.connect_to_server_via_sse( 33 | url=self.config["url"], 34 | headers=self.config["headers"], 35 | timeout=self.config.get("timeout", DEFAULT_HTTP_TIMEOUT), 36 | sse_read_timeout=self.config.get("sse_read_timeout", DEFAULT_SSE_READ_TIMEOUT), 37 | ) 38 | elif transport == "stdio": 39 | await self.connect_to_server_via_stdio( 40 | command=self.config["command"], 41 | args=self.config["args"], 42 | env={**os.environ, **self.config["env"]} 43 | if self.config.get("env") 44 | else None, 45 | encoding=DEFAULT_ENCODING, 46 | ) 47 | else: 48 | raise ValueError(f"Unsupported transport: {transport}. Must be 'stdio' or 'sse'") 49 | 50 | async def connect_to_server_via_stdio( 51 | self, 52 | *, 53 | command: str, 54 | args: list[str], 55 | env: dict[str, str] | None = None, 56 | encoding: str = DEFAULT_ENCODING, 57 | encoding_error_handler: Literal[ 58 | "strict", "ignore", "replace" 59 | ] = DEFAULT_ENCODING_ERROR_HANDLER, 60 | ) -> None: 61 | """ 62 | 通过标准输入输出连接到服务器。 63 | 64 | Args: 65 | server_name (str): 服务器名称。 66 | command (str): 要执行的命令。 67 | args (list[str]): 命令参数列表。 68 | env (dict[str, str] | None): 环境变量,默认为 None。 69 | encoding (str): 编码方式,默认为 DEFAULT_ENCODING。 70 | encoding_error_handler (Literal["strict", "ignore", "replace"]): 编码错误处理方式,默认为 DEFAULT_ENCODING_ERROR_HANDLER。 71 | 72 | Returns: 73 | None 74 | 75 | """ 76 | server_params = StdioServerParameters( 77 | command=command, 78 | args=args, 79 | env=env, 80 | encoding=encoding, 81 | encoding_error_handler=encoding_error_handler, 82 | ) 83 | 84 | # Create and store the connection 85 | try: 86 | stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params)) 87 | read, write = stdio_transport 88 | session = cast( 89 | ClientSession, 90 | await self.exit_stack.enter_async_context(ClientSession(read, write)), 91 | ) 92 | await session.initialize() 93 | self.session = session 94 | 95 | except Exception as e: 96 | logger.error(f"Error initializing server {self.name}: {e}") 97 | await self.cleanup() 98 | raise 99 | 100 | async def connect_to_server_via_sse( 101 | self, 102 | *, 103 | url: str, 104 | headers: dict[str, Any] | None = None, 105 | timeout: float = DEFAULT_HTTP_TIMEOUT, 106 | sse_read_timeout: float = DEFAULT_SSE_READ_TIMEOUT, 107 | ) -> None: 108 | """ 109 | 通过SSE连接到服务器。 110 | Args: 111 | server_name (str): 服务器名称。 112 | url (str): SSE服务器的URL。 113 | headers (dict[str, Any] | None): 请求头,默认为None。 114 | timeout (float): HTTP请求超时时间,默认为DEFAULT_HTTP_TIMEOUT。 115 | sse_read_timeout (float): SSE读取超时时间,默认为DEFAULT_SSE_READ_TIMEOUT。 116 | Returns: 117 | None 118 | """ 119 | try: 120 | sse_transport = await self.exit_stack.enter_async_context( 121 | sse_client(url, headers, timeout, sse_read_timeout) 122 | ) 123 | read, write = sse_transport 124 | session = cast( 125 | ClientSession, 126 | await self.exit_stack.enter_async_context(ClientSession(read, write)), 127 | ) 128 | await session.initialize() 129 | self.session = session 130 | 131 | except Exception as e: 132 | logger.error(f"Error initializing server {self.name}: {e}") 133 | await self.cleanup() 134 | raise 135 | 136 | async def list_tools(self) -> list[Any]: 137 | """List available tools from the server. 138 | 139 | Returns: 140 | A list of available tools. 141 | 142 | Raises: 143 | RuntimeError: If the server is not initialized. 144 | """ 145 | if not self.session: 146 | raise RuntimeError(f"Server {self.name} not initialized") 147 | 148 | tools_response = await self.session.list_tools() 149 | tools = [] 150 | 151 | for item in tools_response: 152 | if isinstance(item, tuple) and item[0] == "tools": 153 | for tool in item[1]: 154 | tools.append(Tool(tool.name, tool.description, tool.inputSchema)) 155 | 156 | return tools 157 | 158 | async def execute_tool( 159 | self, 160 | tool_name: str, 161 | arguments: dict[str, Any], 162 | retries: int = 2, 163 | delay: float = 1.0, 164 | ) -> Any: 165 | """Execute a tool with retry mechanism. 166 | 167 | Args: 168 | tool_name: Name of the tool to execute. 169 | arguments: Tool arguments. 170 | retries: Number of retry attempts. 171 | delay: Delay between retries in seconds. 172 | 173 | Returns: 174 | Tool execution result. 175 | 176 | Raises: 177 | RuntimeError: If server is not initialized. 178 | Exception: If tool execution fails after all retries. 179 | """ 180 | if not self.session: 181 | raise RuntimeError(f"Server {self.name} not initialized") 182 | 183 | attempt = 0 184 | while attempt < retries: 185 | try: 186 | logger.info(f"Executing {tool_name}...") 187 | result = await self.session.call_tool(tool_name, arguments) 188 | 189 | return result 190 | 191 | except Exception as e: 192 | attempt += 1 193 | logger.warning( 194 | f"Error executing tool: {e}. Attempt {attempt} of {retries}." 195 | ) 196 | if attempt < retries: 197 | logger.info(f"Retrying in {delay} seconds...") 198 | await asyncio.sleep(delay) 199 | else: 200 | logger.error("Max retries reached. Failing.") 201 | raise 202 | 203 | async def cleanup(self) -> None: 204 | """Clean up server resources.""" 205 | async with self._cleanup_lock: 206 | try: 207 | await self.exit_stack.aclose() 208 | self.session = None 209 | self.stdio_context = None 210 | except Exception as e: 211 | logger.error(f"Error during cleanup of server {self.name}: {e}") 212 | -------------------------------------------------------------------------------- /session/douyin_open_api_session.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from types import GeneratorType 4 | from typing import Any 5 | from common.log import logger 6 | from client.llm_client_qwen import _generate_response, _generate_response_stream 7 | from common.utils import is_valid_json 8 | from server.server import Server 9 | 10 | messages = [] 11 | 12 | 13 | async def trim_messages(messages_list: list, max_messages: int) -> list: 14 | """Trim the messages list to ensure its length does not exceed max_messages using FIFO logic. 15 | Ensure that messages with {"role": "system"} are always retained. 16 | """ 17 | system_messages = [msg for msg in messages_list if msg.get("role") == "system"] 18 | other_messages = [msg for msg in messages_list if msg.get("role") != "system"] 19 | 20 | if len(system_messages) + len(other_messages) > max_messages: 21 | # Calculate the maximum number of other messages to keep 22 | max_other_messages = max_messages - len(system_messages) 23 | other_messages = other_messages[-max_other_messages:] 24 | 25 | # Combine system messages and other messages 26 | return system_messages + other_messages 27 | 28 | 29 | class DouyinOpenApiSession: 30 | """Orchestrates the interaction between user, LLM, and tools.""" 31 | 32 | def __init__(self, servers: list[Server]) -> None: 33 | self.servers: list[Server] = servers 34 | # self.init_servers() 35 | 36 | async def init_servers(self) -> None: 37 | """Initialize all servers.""" 38 | for server in self.servers: 39 | try: 40 | await server.initialize() 41 | # yield "Success to initialize server" 42 | except Exception as e: 43 | logger.error(f"Failed to initialize server: {e}") 44 | await self.cleanup_servers() 45 | return 46 | 47 | async def cleanup_servers(self) -> None: 48 | """Clean up all servers properly.""" 49 | cleanup_tasks = [] 50 | for server in self.servers: 51 | cleanup_tasks.append(asyncio.create_task(server.cleanup())) 52 | 53 | if cleanup_tasks: 54 | try: 55 | await asyncio.gather(*cleanup_tasks, return_exceptions=True) 56 | except Exception as e: 57 | logger.warning(f"Warning during final cleanup: {e}") 58 | 59 | async def process_llm_response(self, llm_response: str) -> str: 60 | """Process the LLM response and execute tools if needed. 61 | 62 | Args: 63 | llm_response: The response from the LLM. 64 | 65 | Returns: 66 | The result of tool execution or the original response. 67 | """ 68 | 69 | try: 70 | tool_call = json.loads(llm_response) 71 | if "工具" in tool_call and "参数" in tool_call: 72 | logger.info(f"Executing tool: {tool_call['工具']}") 73 | logger.info(f"With arguments: {tool_call['参数']}") 74 | 75 | for server in self.servers: 76 | tools = await server.list_tools() 77 | if any(tool.name == tool_call["工具"] for tool in tools): 78 | try: 79 | result = await server.execute_tool( 80 | tool_call["工具"], tool_call["参数"] 81 | ) 82 | 83 | if isinstance(result, dict) and "progress" in result: 84 | progress = result["progress"] 85 | total = result["total"] 86 | percentage = (progress / total) * 100 87 | logger.info( 88 | f"Progress: {progress}/{total} " 89 | f"({percentage:.1f}%)" 90 | ) 91 | 92 | return f"Tool execution result: {result}" 93 | except Exception as e: 94 | error_msg = f"Error executing tool: {str(e)}" 95 | logger.error(error_msg) 96 | return error_msg 97 | 98 | return f"No server found with tool: {tool_call['工具']}" 99 | return llm_response 100 | except json.JSONDecodeError: 101 | return llm_response 102 | 103 | async def start(self, query: str, session_id: str = None, open_id: str = "", max_messages: int = 18) -> Any: 104 | global messages 105 | yield "开始理解语义... ![加载中](https://static.fengmiai.com/source_image/loading.gif)" 106 | if session_id is None or session_id == "0": 107 | all_tools = [] 108 | for server in self.servers: 109 | tools = await server.list_tools() 110 | all_tools.extend(tools) 111 | tools_description = "\n".join([tool.format_for_llm() for tool in all_tools]) 112 | system_message = ( 113 | "You are called 抖数精灵, a senior data analysis assistant, and you can use the following tools:\n\n" 114 | f"{tools_description}\n" 115 | "Your open_id:"f"{open_id}\n" 116 | "Choose the appropriate tool based on the user's question. \n" 117 | "IMPORTANT: When you need to use the tool, you must only use JSON for response, while planning the exact number of steps and step names for using the tools\n" 118 | "Before all the tool is fully tuned, you can only return the exact JSON object format below, nothing else:\n" 119 | "{\n" 120 | ' "工具": "tool-name",\n' 121 | ' "参数": {\n' 122 | ' "argument-name": "value"\n' 123 | ' },\n' 124 | ' "任务数": "Total number of steps"' 125 | ' "任务名称": "All step names"' 126 | ' "当前任务ID": "Current step ID"' 127 | ' "当前任务名": "Name of the current step"' 128 | "}\n\n" 129 | "After receiving all steps tool's response:\n" 130 | "If no tools are required, generate the content in Markdown format according to the following template, excluding JSON format:\n" 131 | " # 一、数据概览与问题分析 \n" 132 | " ## 1.1 Subheading\n" 133 | " data_list to markdown table \n" 134 | " - data list description" 135 | " ![charts](chart_url)" 136 | " - data charts description" 137 | " ### problem title\n" 138 | " - problem Content\n" 139 | " ## 1.2 Subheading \n" 140 | " ..." 141 | " # 二、优化建议 \n" 142 | " ## 2.1 suggestions1\n" 143 | " - suggestions1 Content\n" 144 | " ..." 145 | " # 三、总结 \n" 146 | " - Content\n" 147 | " ..." 148 | "1. Transform the raw data into a natural, conversational response\n" 149 | "2. Have a deep understanding of the data, find n potential problems, and provide n improvement suggestions\n" 150 | "3. Focus on the most relevant information,Be concise and don't say anything unnecessary\n" 151 | "4. Use appropriate context from the user's question\n" 152 | ) 153 | messages.clear() 154 | messages.append({"role": "system", "content": system_message}) 155 | 156 | if len(messages) > max_messages: # 只保留20条对话的记忆 157 | messages = await trim_messages(messages_list=messages, max_messages=max_messages) 158 | 159 | try: 160 | messages.append({"role": "user", "content": query}) 161 | logger.info("First Messages: %s", messages) 162 | yield "正在理解意图... ![加载中](https://static.fengmiai.com/source_image/loading.gif)" 163 | await asyncio.sleep(0.01) 164 | # llm_response = _generate_response(messages=messages) 165 | llm_response = _generate_response_stream(messages=messages) 166 | full_llm = "" 167 | for stream in llm_response: 168 | yield stream 169 | full_llm = stream 170 | logger.info("First llm_response: %s", full_llm) 171 | if is_valid_json(full_llm): 172 | tool_call = json.loads(full_llm) 173 | if "任务数" in tool_call and "当前任务ID" in tool_call and "当前任务名" in tool_call: 174 | yield "开始规划任务..." 175 | await asyncio.sleep(0.01) 176 | steps = tool_call["任务数"] 177 | step_names = tool_call["任务名称"] 178 | curr_step_id = tool_call["当前任务ID"] 179 | curr_step_name = tool_call["当前任务名"] 180 | logger.info( 181 | "steps:%s curr_step_id: %s curr_step_name: %s" % (steps, curr_step_id, curr_step_name)) 182 | curr_step_id = 1 183 | yield "计算出共有%s个步骤, 分布为%s... ![加载中](https://static.fengmiai.com/source_image/loading.gif) " % (str(steps), step_names) 184 | await asyncio.sleep(0.01) 185 | while curr_step_id <= int(steps): 186 | yield "正在执行第%s个任务: %s... ![加载中](https://static.fengmiai.com/source_image/loading.gif)" % (str(curr_step_id), str(curr_step_name)) 187 | await asyncio.sleep(0.01) 188 | result = await self.process_llm_response(full_llm) 189 | logger.info("process_llm_response result: %s" % result) 190 | 191 | if result != full_llm: 192 | messages.append({"role": "assistant", "content": full_llm}) 193 | messages.append({"role": "system", "content": result}) 194 | logger.info("curr_step_id: %s messages: %s" % (curr_step_id, messages)) 195 | yield "正在判断第%s个任务:'%s'的执行情况... ![加载中](https://static.fengmiai.com/source_image/loading.gif)" % (str(curr_step_id), str(curr_step_name)) 196 | await asyncio.sleep(0.01) 197 | llm_response_stream = _generate_response_stream(messages=messages) 198 | curr_step_id += 1 199 | full_llm = "" 200 | # isEnd = True 201 | for stream in llm_response_stream: 202 | yield stream 203 | full_llm = stream 204 | if is_valid_json(full_llm): 205 | logger.info("curr_step_id: %s llm_response: %s" % (curr_step_id, full_llm)) 206 | messages.append( 207 | {"role": "assistant", "content": full_llm} 208 | ) 209 | else: 210 | messages.append({"role": "assistant", 211 | "content": 'The Json format is incorrect. Return again'}) 212 | continue 213 | else: 214 | messages.append({"role": "assistant", "content": llm_response}) 215 | 216 | # for stream int llm_response: 217 | if is_valid_json(full_llm): 218 | yield "正在做最后的总结... ![加载中](https://static.fengmiai.com/source_image/loading.gif)" 219 | llm_response_stream_final = _generate_response_stream(messages=messages) 220 | for stream in llm_response_stream_final: 221 | yield stream 222 | 223 | 224 | except Exception as e: 225 | logger.error(f"Error processing LLM response: {e}") 226 | yield f"Error processing LLM response: {e}" 227 | finally: 228 | logger.info("Main finally") 229 | -------------------------------------------------------------------------------- /server/douyin_open_api_server.py: -------------------------------------------------------------------------------- 1 | from mcp.server.fastmcp import FastMCP 2 | 3 | mcp = FastMCP("Douyin-Open-API-Server") 4 | 5 | """ 6 | 这是一个demo的演示,对应tool的数据都是固定的。 7 | 实际使用场景,可根据需求,配置相应的tool,可以查询数据库,也可调接口 8 | """ 9 | 10 | @mcp.tool(name="视频列表数据", description="查询视频列表, 返回视频标题、转发数、评论数、点赞数、下载数、播放数、分享数") 11 | def video_list(open_id: str) -> dict: 12 | print("open_id:", open_id) 13 | response = { 14 | "extra": { 15 | "error_code": 0, 16 | "description": "", 17 | "sub_error_code": 0, 18 | "sub_description": "", 19 | "logid": "202008121419360101980821035705926A", 20 | "now": 1597213176393 21 | }, 22 | "data": { 23 | "error_code": 0, 24 | "description": "", 25 | "has_more": False, 26 | "list": [ 27 | { 28 | "title": "测试视频1 #测试话题1 @抖音小助手", 29 | "is_top": False, 30 | "create_time": 1571075129, 31 | "is_reviewed": True, 32 | "video_status": 5, 33 | "share_url": "https://www.iesdouyin.com/share/video/QDlWd0EzdWVMU2Q0aU5tKzVaOElvVU03ODBtRHFQUCtLUHBSMHFRT21MVkFYYi9UMDYwemRSbVlxaWczNTd6RUJRc3MrM2hvRGlqK2EwNnhBc1lGUkpRPT0=/?region=CN&mid=6753173704399670023&u_code=12h9je425&titleType=title", 34 | "item_id": "@8hxdhauTCMppanGnM4ltGM780mDqPP+KPpR0qQOmLVAXb/T060zdRmYqig357zEBq6CZRp4NVe6qLIJW/V/x1w==", 35 | "media_type": 2, 36 | "cover": "https://p3-dy.byteimg.com/img/tos-cn-p-0015/cfa0d6421bdc4580876cb16974539ff6~c5_300x400.jpeg", 37 | "statistics": { 38 | "forward_count": 100, 39 | "comment_count": 1000, 40 | "digg_count": 2000, 41 | "download_count": 100, 42 | "play_count": 30000, 43 | "share_count": 1000 44 | } 45 | }, 46 | { 47 | "title": "测试视频2 #测试话题2", 48 | "is_top": False, 49 | "create_time": 1571075130, 50 | "is_reviewed": True, 51 | "video_status": 5, 52 | "share_url": "https://www.iesdouyin.com/share/video/QDlWd0EzdWVMU2Q0aU5tKzVaOElvVU03ODBtRHFQUCtLUHBSMHFRT21MVkFYYi9UMDYwemRSbVlxaWczNTd6RUJRc3MrM2hvRGlqK2EwNnhBc1lGUkpRPT0=/?region=CN&mid=6753173704399670023&u_code=12h9je425&titleType=title", 53 | "item_id": "@8hxdhauTCMppanGnM4ltGM780mDqPP+KPpR0qQOmLVAXb/T060zdRmYqig357zEBq6CZRp4NVe6qLIJW/V/x1w==", 54 | "media_type": 2, 55 | "cover": "https://p3-dy.byteimg.com/img/tos-cn-p-0015/cfa0d6421bdc4580876cb16974539ff6~c5_300x400.jpeg", 56 | "statistics": { 57 | "forward_count": 190, 58 | "comment_count": 500, 59 | "digg_count": 6001, 60 | "download_count": 1210, 61 | "play_count": 80400, 62 | "share_count": 2600 63 | } 64 | } 65 | ], 66 | "cursor": 0 67 | } 68 | } 69 | 70 | result = { 71 | "data_list": [ 72 | { 73 | "title": "美猴王大闹天宫", 74 | "item_id": "@8hxdhauTCMppanGnM4ltGM780mDqPP+KPpR0qQOmLVAXb/T060zdRmYqig357zEBq6CZRp4NVe6qLIJW/V/x1w==", 75 | "forward_count": 34200, 76 | "comment_count": 32401, 77 | "digg_count": 20040, 78 | "download_count": 98343, 79 | "play_count": 35645000, 80 | "share_count": 473080 81 | }, 82 | { 83 | "title": "东海龙王藏金刚棒", 84 | "item_id": "@7hxdhauTCMppanGnM4ltGM780mDqPP+KPpR0qQOmLVAXb/T060zdRmYqig357zEBq6CZRp4NVe6qLIJW/V/x2w==", 85 | "forward_count": 13490, 86 | "comment_count": 54500, 87 | "digg_count": 604301, 88 | "download_count": 14210, 89 | "play_count": 80452400, 90 | "share_count": 234600 91 | }, 92 | { 93 | "title": "中国粮食金融保护战", 94 | "item_id": "@9hxdhauTCMppanGnM4ltGM780mDqPP+KPpR0qQOmLVAXb/T060zdRmYqig357zEBq6CZRp4NVe6qLIJW/V/x9w==", 95 | "forward_count": 436000, 96 | "comment_count": 83662, 97 | "digg_count": 339000, 98 | "download_count": 23025, 99 | "play_count": 83020400, 100 | "share_count": 909600 101 | }, 102 | { 103 | "title": "猫猫车诞生后,中国再无轻步兵", 104 | "item_id": "@10hxdhauTCMppanGnM4ltGM780mDqPP+KPpR0qQOmLVAXb/T060zdRmYqig357zEBq6CZRp4NVe6qLIJW/V/10w==", 105 | "forward_count": 19024, 106 | "comment_count": 4539, 107 | "digg_count": 90001, 108 | "download_count": 671210, 109 | "play_count": 18304400, 110 | "share_count": 110000 111 | } 112 | ] 113 | } 114 | return result 115 | 116 | 117 | @mcp.tool(name="视频点赞数据", description="视频点赞数据, 返回日期(date),点赞数(like)") 118 | def item_like(open_id: str, item_id: str) -> dict: 119 | print("open_id:", open_id) 120 | result1 = { 121 | "data_list": [ 122 | { 123 | "date": "2025-04-06", 124 | "like": 14260 125 | }, 126 | { 127 | "date": "2025-04-07", 128 | "like": 15300 129 | }, 130 | { 131 | "date": "2025-04-08", 132 | "like": 16320 133 | }, 134 | { 135 | "date": "2025-04-09", 136 | "like": 17330 137 | }, 138 | { 139 | "date": "2025-04-10", 140 | "like": 18380 141 | }, 142 | { 143 | "date": "2025-04-11", 144 | "like": 16369 145 | }, 146 | { 147 | "date": "2025-04-12", 148 | "like": 13349 149 | }, 150 | { 151 | "date": "2025-04-13", 152 | "like": 12369 153 | }, 154 | { 155 | "date": "2025-04-14", 156 | "like": 11369 157 | }, 158 | { 159 | "date": "2025-04-15", 160 | "like": 20369 161 | } 162 | ], 163 | "chart_url": "" 164 | } 165 | 166 | result2 = { 167 | "data_list": [ 168 | { 169 | "date": "2025-04-06", 170 | "like": 1460 171 | }, 172 | { 173 | "date": "2025-04-07", 174 | "like": 1500 175 | }, 176 | { 177 | "date": "2025-04-08", 178 | "like": 1620 179 | }, 180 | { 181 | "date": "2025-04-09", 182 | "like": 1730 183 | }, 184 | { 185 | "date": "2025-04-10", 186 | "like": 1880 187 | }, 188 | { 189 | "date": "2025-04-11", 190 | "like": 1980 191 | }, 192 | { 193 | "date": "2025-04-12", 194 | "like": 780 195 | }, 196 | { 197 | "date": "2025-04-13", 198 | "like": 1380 199 | }, 200 | { 201 | "date": "2025-04-14", 202 | "like": 2180 203 | }, 204 | { 205 | "date": "2025-04-15", 206 | "like": 3080 207 | } 208 | ], 209 | "chart_url": "" 210 | } 211 | 212 | if item_id == "@8hxdhauTCMppanGnM4ltGM780mDqPP+KPpR0qQOmLVAXb/T060zdRmYqig357zEBq6CZRp4NVe6qLIJW/V/x1w==": 213 | result_dict = result1 214 | else: 215 | result_dict = result2 216 | 217 | # https://quickchart.io/chart?c={type:'bar',data:{labels:[%27A%27,%27B%27,%27C%27],datasets:[{data:[30,60,10]}]}} 218 | chart_type = "bar" 219 | chart_labels = [] 220 | chart_datasets_data = [] 221 | chart_datasets_labels = "点赞数据" 222 | for item in result_dict["data_list"]: 223 | chart_labels.append(item["date"]) 224 | chart_datasets_data.append(item["like"]) 225 | chart_url = "https://quickchart.io/chart?c={type:'%s',data:{labels:%s,datasets:[{label:'%s',data:%s}]}}" % ( 226 | chart_type, chart_labels, chart_datasets_labels, chart_datasets_data) 227 | chart_url = chart_url.replace(" ", "") 228 | result_dict["chart_url"] = chart_url 229 | 230 | return result_dict 231 | 232 | 233 | @mcp.tool(name="视频播放数据", description="视频播放数据, 返回日期(date),播放数(play)") 234 | def item_play(open_id: str, item_id: str) -> dict: 235 | print("open_id:", open_id) 236 | result1 = { 237 | "data_list": [ 238 | { 239 | "date": "2025-04-06", 240 | "play": 3114260 241 | }, 242 | { 243 | "date": "2025-04-07", 244 | "play": 3215300 245 | }, 246 | { 247 | "date": "2025-04-08", 248 | "play": 3316320 249 | }, 250 | { 251 | "date": "2025-04-09", 252 | "play": 3417330 253 | }, 254 | { 255 | "date": "2025-04-10", 256 | "play": 3518380 257 | }, 258 | { 259 | "date": "2025-04-11", 260 | "play": 2916169 261 | }, 262 | { 263 | "date": "2025-04-12", 264 | "play": 4216469 265 | }, 266 | { 267 | "date": "2025-04-13", 268 | "play": 4816369 269 | }, 270 | { 271 | "date": "2025-04-14", 272 | "play": 5016369 273 | }, 274 | { 275 | "date": "2025-04-15", 276 | "play": 6616269 277 | } 278 | ], 279 | "chart_url": "" 280 | } 281 | 282 | result2 = { 283 | "data_list": [ 284 | { 285 | "date": "2025-04-06", 286 | "play": 211460 287 | }, 288 | { 289 | "date": "2025-04-07", 290 | "play": 211500 291 | }, 292 | { 293 | "date": "2025-04-08", 294 | "play": 221620 295 | }, 296 | { 297 | "date": "2025-04-09", 298 | "play": 231730 299 | }, 300 | { 301 | "date": "2025-04-10", 302 | "play": 241880 303 | }, 304 | { 305 | "date": "2025-04-11", 306 | "play": 251980 307 | }, 308 | { 309 | "date": "2025-04-12", 310 | "play": 261980 311 | }, 312 | { 313 | "date": "2025-04-13", 314 | "play": 259004 315 | }, 316 | { 317 | "date": "2025-04-14", 318 | "play": 293894 319 | }, 320 | { 321 | "date": "2025-04-15", 322 | "play": 350212 323 | } 324 | ], 325 | "chart_url": "" 326 | } 327 | 328 | if item_id == "@8hxdhauTCMppanGnM4ltGM780mDqPP+KPpR0qQOmLVAXb/T060zdRmYqig357zEBq6CZRp4NVe6qLIJW/V/x1w==": 329 | result_dict = result1 330 | else: 331 | result_dict = result2 332 | 333 | # https://quickchart.io/chart?c={type:'bar',data:{labels:[%27A%27,%27B%27,%27C%27],datasets:[{data:[30,60,10]}]}} 334 | chart_type = "line" 335 | chart_labels = [] 336 | chart_datasets_data = [] 337 | chart_datasets_labels = "播放数据" 338 | for item in result_dict["data_list"]: 339 | chart_labels.append(item["date"]) 340 | chart_datasets_data.append(item["play"]) 341 | chart_url = "https://quickchart.io/chart?c={type:'%s',data:{labels:%s,datasets:[{label:'%s',data:%s}]}}" % ( 342 | chart_type, chart_labels, chart_datasets_labels, chart_datasets_data) 343 | chart_url = chart_url.replace(" ", "") 344 | result_dict["chart_url"] = chart_url 345 | 346 | return result_dict 347 | 348 | # def generate_chart_url(result_list, label, item_type): 349 | # chart_type = "bar" 350 | # chart_labels = [item["date"] for item in result_list] 351 | # chart_datasets_data = [item[item_type] for item in result_list] 352 | # chart_datasets_labels = label 353 | # chart_url = f"https://quickchart.io/chart?c={{type: '{chart_type}',data:{{labels: {chart_labels},datasets: [{{label: '{chart_datasets_labels}',data: {chart_datasets_data}}}]}}" 354 | # return chart_url 355 | 356 | 357 | @mcp.tool(name="视频评论数据", description="视频评论数据, 返回日期(date),点赞数(like)") 358 | def item_comment(open_id: str, item_id: str) -> dict: 359 | result1 = { 360 | "data_list": [ 361 | { 362 | "date": "2025-04-06", 363 | "comment": 2465 364 | }, 365 | { 366 | "date": "2025-04-07", 367 | "comment": 3505 368 | }, 369 | { 370 | "date": "2025-04-08", 371 | "comment": 3625 372 | }, 373 | { 374 | "date": "2025-04-09", 375 | "comment": 3735 376 | }, 377 | { 378 | "date": "2025-04-10", 379 | "comment": 3885 380 | }, 381 | { 382 | "date": "2025-04-11", 383 | "comment": 3675 384 | }, 385 | { 386 | "date": "2025-04-12", 387 | "comment": 3275 388 | }, 389 | { 390 | "date": "2025-04-13", 391 | "comment": 3175 392 | }, 393 | { 394 | "date": "2025-04-14", 395 | "comment": 1875 396 | }, 397 | { 398 | "date": "2025-04-15", 399 | "comment": 2075 400 | } 401 | ] 402 | } 403 | 404 | result2 = { 405 | "data_list": [ 406 | { 407 | "date": "2025-04-06", 408 | "comment": 12265 409 | }, 410 | { 411 | "date": "2025-04-07", 412 | "comment": 13305 413 | }, 414 | { 415 | "date": "2025-04-08", 416 | "comment": 14325 417 | }, 418 | { 419 | "date": "2025-04-09", 420 | "comment": 15335 421 | }, 422 | { 423 | "date": "2025-04-10", 424 | "comment": 16385 425 | }, 426 | { 427 | "date": "2025-04-11", 428 | "comment": 15375 429 | }, 430 | { 431 | "date": "2025-04-12", 432 | "comment": 12375 433 | }, 434 | { 435 | "date": "2025-04-13", 436 | "comment": 11375 437 | }, 438 | { 439 | "date": "2025-04-14", 440 | "comment": 8375 441 | }, 442 | { 443 | "date": "2025-04-15", 444 | "comment": 14375 445 | } 446 | ] 447 | } 448 | 449 | result_ = {"data_list": [], "chart_url": ""} 450 | if item_id == "@8hxdhauTCMppanGnM4ltGM780mDqPP+KPpR0qQOmLVAXb/T060zdRmYqig357zEBq6CZRp4NVe6qLIJW/V/x1w==": 451 | result_["data_list"] = result1["data_list"] 452 | else: 453 | result_["data_list"] = result2["data_list"] 454 | 455 | # https://quickchart.io/chart?c={type:'bar',data:{labels:[%27A%27,%27B%27,%27C%27],datasets:[{data:[30,60,10]}]}} 456 | chart_type = "line" 457 | chart_labels = [] 458 | chart_datasets_data = [] 459 | chart_datasets_labels = "评论数据" 460 | for item in result_["data_list"]: 461 | chart_labels.append(item["date"]) 462 | chart_datasets_data.append(item["comment"]) 463 | chart_url = "https://quickchart.io/chart?c={type:'%s',data:{labels:%s,datasets:[{label:'%s',data:%s}]}}" % ( 464 | chart_type, chart_labels, chart_datasets_labels, chart_datasets_data) 465 | chart_url = chart_url.replace(" ", "") 466 | result_["chart_url"] = chart_url 467 | 468 | return result_ 469 | 470 | 471 | @mcp.tool(name="视频基础数据", description="获取视频基础数据, 返回总点赞数,总评论,总分享,平均播放时长,总播放次数") 472 | def item_base(open_id: str, item_id: str) -> dict: 473 | data = { 474 | "result": { 475 | "total_like": 230400, 476 | "total_comment": 25300, 477 | "total_share": 2050, 478 | "avg_play_duration": 30, 479 | "total_play": 24054300 480 | }, 481 | "description": "" 482 | } 483 | return data 484 | 485 | 486 | @mcp.tool(name="视频发布数据", description="用户视频发布数据, 每日发布内容数、每天新增视频播放、总内容数") 487 | def user_item(open_id: str) -> dict: 488 | data = { 489 | "result_list": [ 490 | { 491 | "date": "2025-04-09", 492 | "new_issue": "5", 493 | "new_play": "502323", 494 | "total_issue": "480" 495 | }, 496 | { 497 | "date": "2025-04-10", 498 | "new_issue": "8", 499 | "new_play": "502323", 500 | "total_issue": "490" 501 | }, 502 | { 503 | "date": "2025-04-11", 504 | "new_issue": "11", 505 | "new_play": "502323", 506 | "total_issue": "502" 507 | }, 508 | { 509 | "date": "2025-04-12", 510 | "new_issue": "15", 511 | "new_play": "502323", 512 | "total_issue": "520" 513 | }, 514 | { 515 | "date": "2025-04-13", 516 | "new_issue": "20", 517 | "new_play": "502323", 518 | "total_issue": "530" 519 | }, 520 | { 521 | "date": "2025-04-14", 522 | "new_issue": "3", 523 | "new_play": "502323", 524 | "total_issue": "533" 525 | }, 526 | { 527 | "date": "2025-04-15", 528 | "new_issue": "28", 529 | "new_play": "502323", 530 | "total_issue": "561" 531 | }, 532 | { 533 | "date": "2025-04-16", 534 | "new_issue": "19", 535 | "new_play": "234343", 536 | "total_issue": "580" 537 | } 538 | ] 539 | } 540 | return data 541 | 542 | 543 | @mcp.tool(name="粉丝情况数据", description="粉丝量变化趋势, 每日新粉丝数、每天总粉丝数") 544 | def fans_change(open_id: str) -> dict: 545 | data = { 546 | "result_list": [ 547 | { 548 | "date": "2025-04-10", 549 | "new_fans": "90", 550 | "total_fans": "17302" 551 | }, 552 | { 553 | "date": "2025-04-11", 554 | "new_fans": "290", 555 | "total_fans": "17450" 556 | }, 557 | { 558 | "date": "2025-04-12", 559 | "new_fans": "270", 560 | "total_fans": "17890" 561 | }, 562 | { 563 | "date": "2025-04-13", 564 | "new_fans": "360", 565 | "total_fans": "18110" 566 | }, 567 | { 568 | "date": "2025-04-14", 569 | "new_fans": "270", 570 | "total_fans": "18410" 571 | }, 572 | { 573 | "date": "2025-04-15", 574 | "new_fans": "380", 575 | "total_fans": "18810" 576 | }, 577 | { 578 | "date": "2025-04-16", 579 | "new_fans": "890", 580 | "total_fans": "19410" 581 | } 582 | ] 583 | } 584 | 585 | chart_type = "line" 586 | chart_labels = [] 587 | chart_datasets_data_total_fans = [] 588 | chart_datasets_labels_total_fans = "当日总粉丝数" 589 | chart_datasets_data_news_fans = [] 590 | chart_datasets_labels_news_fans = "当日新增粉丝数" 591 | for item in data["result_list"]: 592 | chart_labels.append(item["date"]) 593 | chart_datasets_data_total_fans.append(item["total_fans"]) 594 | chart_datasets_data_news_fans.append(item["new_fans"]) 595 | chart_url = "https://quickchart.io/chart?c={type:'%s',data:{labels:%s,datasets:[{label:'%s',data:%s},{label:'%s',data:%s}]}}" % ( 596 | chart_type, chart_labels, chart_datasets_labels_total_fans, chart_datasets_data_total_fans, chart_datasets_labels_news_fans, chart_datasets_data_news_fans) 597 | chart_url = chart_url.replace(" ", "") 598 | data["chart_url"] = chart_url 599 | 600 | return data 601 | 602 | 603 | @mcp.tool(name="粉丝画像数据", 604 | description="粉丝画像数据, 所有粉丝量、粉丝活跃天数分布、粉丝年龄分布、粉丝设备分布、粉丝流量贡献、粉丝地域分布、粉丝性别分布、粉丝兴趣分布") 605 | def fans_profile(open_id: str) -> dict: 606 | data = { 607 | "active_days_distributions": [ 608 | { 609 | "item": "0~4", 610 | "value": 24600 611 | }, 612 | { 613 | "item": "5~8", 614 | "value": 900 615 | }, 616 | ], 617 | "age_distributions": [ 618 | { 619 | "item": "1-23", 620 | "value": 18000 621 | }, 622 | { 623 | "item": "24-30", 624 | "value": 2400 625 | }, 626 | { 627 | "item": "31-40", 628 | "value": 900 629 | }, 630 | { 631 | "item": "41-50", 632 | "value": 1200 633 | }, 634 | { 635 | "item": "50-", 636 | "value": 10 637 | } 638 | ], 639 | "all_fans_num": 20280, 640 | "device_distributions": [ 641 | { 642 | "item": "华为", 643 | "value": 8300 644 | }, 645 | { 646 | "item": "小米", 647 | "value": 4000 648 | }, 649 | { 650 | "item": "Iphone", 651 | "value": 9000 652 | } 653 | ], 654 | "flow_contributions": [ 655 | { 656 | "all_sum": 800, 657 | "fans_sum": 0, 658 | "flow": "vv" 659 | } 660 | ], 661 | "gender_distributions": [ 662 | { 663 | "item": "1", 664 | "value": 12500 665 | }, 666 | { 667 | "item": "2", 668 | "value": 12800 669 | } 670 | ], 671 | "geographical_distributions": [ 672 | { 673 | "item": "北京", 674 | "value": 100 675 | }, 676 | { 677 | "item": "上海", 678 | "value": 150 679 | } 680 | ], 681 | "interest_distributions": [ 682 | { 683 | "item": "美食", 684 | "value": 380 685 | }, 686 | { 687 | "item": "旅行", 688 | "value": 400 689 | }, 690 | { 691 | "item": "生活", 692 | "value": 200 693 | } 694 | ] 695 | } 696 | return data 697 | 698 | @mcp.tool(name="粉丝来源数据", description="粉丝来源数据,直播、新用户引导页、视频详情页、面对面、发现页、其它等 ") 699 | def fans_source(open_id: str) -> dict: 700 | result = { 701 | "fans_source": [ 702 | { 703 | "source": "直播", 704 | "percent": 31.32 705 | }, 706 | { 707 | "source": "视频详情页", 708 | "percent": 40 709 | }, 710 | { 711 | "source": "发现页", 712 | "percent": 18.68 713 | }, 714 | { 715 | "source": "新用户引导页", 716 | "percent": 10 717 | }, 718 | ] 719 | } 720 | 721 | chart_type = "pie" 722 | chart_labels = [] 723 | chart_datasets_data = [] 724 | chart_datasets_labels = "粉丝来源数据" 725 | for item in result["fans_source"]: 726 | chart_labels.append(item["source"]) 727 | chart_datasets_data.append(item["percent"]) 728 | chart_url = "https://quickchart.io/chart?v=2.9.4&c={type:'%s',data:{labels:%s,datasets:[{label:'%s',data:%s}]}}" % ( 729 | chart_type, chart_labels, chart_datasets_labels, chart_datasets_data) 730 | chart_url = chart_url.replace(" ", "") 731 | result["chart_url"] = chart_url 732 | 733 | return result 734 | 735 | 736 | if __name__ == "__main__": 737 | mcp.run(transport="stdio") 738 | # result_dict = item_like(open_id="test", item_id="@8hxdhauTCMppanGnM4ltGM780mDqPP+KPpR0qQOmLVAXb/T060zdRmYqig357zEBq6CZRp4NVe6qLIJW/V/x1w==") 739 | # result_dict = fans_change(open_id="test") 740 | # print(result_dict) 741 | --------------------------------------------------------------------------------