- Add deployment scripts (deploy.sh, test_connection.sh, core/start_server.sh) - Add deployment documentation (DEPLOYMENT_INFO.md, DEPLOYMENT_SUCCESS.md) - Add .env.example configuration template - Add requirements.txt for Python dependencies - Update README.md with latest information - Update core/server.py with improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
753 lines
26 KiB
Python
753 lines
26 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Funstat BOT MCP Server
|
||
|
||
基于 Telethon 的 MCP 服务器,用于与 @openaiw_bot 交互
|
||
提供搜索、查询、统计等功能
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
import time
|
||
from typing import Any, Dict, List, Optional
|
||
from datetime import datetime, timedelta
|
||
from collections import deque
|
||
|
||
from mcp.server import Server
|
||
from mcp.types import (
|
||
Resource,
|
||
Tool,
|
||
TextContent,
|
||
ImageContent,
|
||
EmbeddedResource,
|
||
)
|
||
from pydantic import AnyUrl
|
||
from telethon import TelegramClient
|
||
from telethon.errors import FloodWaitError
|
||
from telethon.tl.functions.messages import GetBotCallbackAnswerRequest
|
||
from telethon.tl.types import Message
|
||
|
||
# 配置日志
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||
)
|
||
logger = logging.getLogger("funstat_mcp")
|
||
|
||
# 配置
|
||
API_ID = int(os.getenv("TELEGRAM_API_ID", "24660516"))
|
||
API_HASH = os.getenv("TELEGRAM_API_HASH", "eae564578880a59c9963916ff1bbbd3a")
|
||
# Session 文件路径 - 使用独立的安全目录,防止被意外删除
|
||
SESSION_PATH = os.path.expanduser(
|
||
os.getenv("TELEGRAM_SESSION_PATH", "~/telegram_sessions/funstat_bot")
|
||
)
|
||
BOT_USERNAME = os.getenv("FUNSTAT_BOT_USERNAME", "@openaiw_bot")
|
||
|
||
PROXY_TYPE = os.getenv("FUNSTAT_PROXY_TYPE", "socks5")
|
||
PROXY_HOST = os.getenv("FUNSTAT_PROXY_HOST")
|
||
PROXY_PORT = os.getenv("FUNSTAT_PROXY_PORT")
|
||
PROXY_USERNAME = os.getenv("FUNSTAT_PROXY_USERNAME")
|
||
PROXY_PASSWORD = os.getenv("FUNSTAT_PROXY_PASSWORD")
|
||
|
||
# 速率限制配置
|
||
RATE_LIMIT_PER_SECOND = 18 # 每秒最多18个请求
|
||
RATE_LIMIT_WINDOW = 1.0 # 1秒时间窗口
|
||
|
||
# 缓存配置
|
||
CACHE_TTL = 3600 # 缓存1小时
|
||
|
||
# 按钮文本转换表,用于将常见的变体字符标准化为 ASCII
|
||
BUTTON_TEXT_TRANSLATIONS = str.maketrans({
|
||
'ƒ': 'f',
|
||
'Μ': 'M',
|
||
'τ': 't',
|
||
'ѕ': 's',
|
||
'η': 'n',
|
||
'Ғ': 'F',
|
||
'α': 'a',
|
||
'ο': 'o',
|
||
'ᴜ': 'u',
|
||
'о': 'o',
|
||
'е': 'e',
|
||
'с': 'c',
|
||
'℮': 'e',
|
||
'Τ': 'T',
|
||
'ρ': 'p',
|
||
'Δ': 'D',
|
||
'χ': 'x',
|
||
'β': 'b',
|
||
'λ': 'l',
|
||
'γ': 'y',
|
||
'Ν': 'N',
|
||
'μ': 'm',
|
||
'ψ': 'y',
|
||
'Α': 'A',
|
||
'Ρ': 'P',
|
||
'С': 'C',
|
||
'ё': 'e',
|
||
'ł': 'l',
|
||
'Ł': 'L',
|
||
'ց': 'g',
|
||
})
|
||
|
||
|
||
class RateLimiter:
|
||
"""速率限制器"""
|
||
|
||
def __init__(self, max_requests: int, time_window: float):
|
||
self.max_requests = max_requests
|
||
self.time_window = time_window
|
||
self.requests = deque()
|
||
|
||
async def acquire(self):
|
||
"""获取请求许可,如果超过限制则等待"""
|
||
now = time.time()
|
||
|
||
# 移除超出时间窗口的请求记录
|
||
while self.requests and self.requests[0] < now - self.time_window:
|
||
self.requests.popleft()
|
||
|
||
# 如果达到限制,等待
|
||
if len(self.requests) >= self.max_requests:
|
||
sleep_time = self.requests[0] + self.time_window - now
|
||
if sleep_time > 0:
|
||
logger.info(f"速率限制: 等待 {sleep_time:.2f} 秒")
|
||
await asyncio.sleep(sleep_time)
|
||
return await self.acquire() # 递归重试
|
||
|
||
# 记录请求时间
|
||
self.requests.append(now)
|
||
|
||
|
||
class ResponseCache:
|
||
"""响应缓存"""
|
||
|
||
def __init__(self, ttl: int = CACHE_TTL):
|
||
self.cache: Dict[str, tuple[Any, float]] = {}
|
||
self.ttl = ttl
|
||
|
||
def get(self, key: str) -> Optional[Any]:
|
||
"""获取缓存"""
|
||
if key in self.cache:
|
||
value, timestamp = self.cache[key]
|
||
if time.time() - timestamp < self.ttl:
|
||
logger.info(f"缓存命中: {key}")
|
||
return value
|
||
else:
|
||
# 过期,删除
|
||
del self.cache[key]
|
||
return None
|
||
|
||
def set(self, key: str, value: Any):
|
||
"""设置缓存"""
|
||
self.cache[key] = (value, time.time())
|
||
logger.info(f"缓存保存: {key}")
|
||
|
||
def clear_expired(self):
|
||
"""清理过期缓存"""
|
||
now = time.time()
|
||
expired_keys = [
|
||
key for key, (_, timestamp) in self.cache.items()
|
||
if now - timestamp >= self.ttl
|
||
]
|
||
for key in expired_keys:
|
||
del self.cache[key]
|
||
if expired_keys:
|
||
logger.info(f"清理了 {len(expired_keys)} 个过期缓存")
|
||
|
||
|
||
class FunstatMCPServer:
|
||
"""Funstat MCP 服务器"""
|
||
|
||
def __init__(self):
|
||
self.server = Server("funstat-mcp")
|
||
self.client: Optional[TelegramClient] = None
|
||
self.bot_entity = None
|
||
self.rate_limiter = RateLimiter(RATE_LIMIT_PER_SECOND, RATE_LIMIT_WINDOW)
|
||
self.cache = ResponseCache()
|
||
|
||
# 注册处理器
|
||
self.server.list_tools()(self.list_tools)
|
||
self.server.call_tool()(self.call_tool)
|
||
|
||
def _normalize_button_text(self, text: str) -> str:
|
||
"""标准化按钮文本,消除不同字符集的影响"""
|
||
return text.translate(BUTTON_TEXT_TRANSLATIONS)
|
||
|
||
async def _press_button(self, message: Message, keyword: str) -> Message:
|
||
"""在消息中查找包含关键字的按钮并触发"""
|
||
if not message.buttons:
|
||
raise ValueError(f"消息中缺少可用按钮,无法执行 {keyword} 操作")
|
||
|
||
target_button = None
|
||
normalized_keyword = keyword.lower()
|
||
|
||
for row in message.buttons:
|
||
for button in row:
|
||
normalized_text = self._normalize_button_text(button.text).lower()
|
||
if normalized_keyword in normalized_text:
|
||
target_button = button
|
||
break
|
||
if target_button:
|
||
break
|
||
|
||
if not target_button:
|
||
available = [
|
||
self._normalize_button_text(button.text)
|
||
for row in message.buttons
|
||
for button in row
|
||
]
|
||
raise ValueError(
|
||
f"未找到包含关键字 '{keyword}' 的按钮。可用按钮: {available}"
|
||
)
|
||
|
||
await self.rate_limiter.acquire()
|
||
|
||
try:
|
||
await self.client(
|
||
GetBotCallbackAnswerRequest(
|
||
peer=self.bot_entity,
|
||
msg_id=message.id,
|
||
data=target_button.data,
|
||
)
|
||
)
|
||
except FloodWaitError as exc:
|
||
wait_seconds = exc.seconds + 1
|
||
logger.warning("触发 Telegram FloodWait,需要等待 %s 秒", wait_seconds)
|
||
await asyncio.sleep(wait_seconds)
|
||
await self.client(
|
||
GetBotCallbackAnswerRequest(
|
||
peer=self.bot_entity,
|
||
msg_id=message.id,
|
||
data=target_button.data,
|
||
)
|
||
)
|
||
|
||
await asyncio.sleep(1.2)
|
||
refreshed = await self.client.get_messages(self.bot_entity, ids=message.id)
|
||
if not refreshed:
|
||
raise RuntimeError("回调执行后未能获取最新消息内容")
|
||
return refreshed
|
||
|
||
def _extract_total_pages(self, message: Message) -> Optional[int]:
|
||
"""从按钮中提取总页数(如果提供了跳页按钮)"""
|
||
if not message.buttons:
|
||
return None
|
||
|
||
total_pages = None
|
||
for row in message.buttons:
|
||
for button in row:
|
||
if '⏭' in button.text:
|
||
normalized = self._normalize_button_text(button.text)
|
||
digits = ''.join(ch for ch in normalized if ch.isdigit())
|
||
if digits:
|
||
try:
|
||
total_pages = int(digits)
|
||
except ValueError:
|
||
continue
|
||
return total_pages
|
||
|
||
|
||
async def send_command_and_wait_message(
|
||
self,
|
||
command: str,
|
||
timeout: int = 10,
|
||
) -> Message:
|
||
"""发送命令并返回原始消息对象(包含按钮等信息)"""
|
||
if not self.client or not self.bot_entity:
|
||
raise RuntimeError("Telegram 客户端尚未初始化")
|
||
|
||
await self.rate_limiter.acquire()
|
||
|
||
last_message_id = 0
|
||
async for message in self.client.iter_messages(self.bot_entity, limit=1):
|
||
last_message_id = message.id
|
||
break
|
||
|
||
logger.info("📤 发送命令(原始消息模式): %s", command)
|
||
await self.client.send_message(self.bot_entity, command)
|
||
|
||
await asyncio.sleep(1.5)
|
||
start_time = time.time()
|
||
|
||
while time.time() - start_time < timeout:
|
||
async for message in self.client.iter_messages(self.bot_entity, limit=5):
|
||
if message.id <= last_message_id:
|
||
continue
|
||
if message.out:
|
||
continue
|
||
if message.text or message.buttons:
|
||
logger.info(
|
||
"✅ 收到原始响应 (ID: %s, 文本长度: %s)",
|
||
message.id,
|
||
len(message.text or ""),
|
||
)
|
||
return message
|
||
await asyncio.sleep(0.5)
|
||
|
||
raise TimeoutError(f"等待 BOT 响应超时 ({timeout}秒)")
|
||
|
||
async def initialize(self):
|
||
"""初始化 Telegram 客户端"""
|
||
logger.info("初始化 Telegram 客户端...")
|
||
|
||
# 检查 session 文件
|
||
session_file = f"{SESSION_PATH}.session"
|
||
if not os.path.exists(session_file):
|
||
raise FileNotFoundError(
|
||
f"Session 文件不存在: {session_file}\n"
|
||
f"请先运行 create_session.py 创建 session 文件\n"
|
||
f"或者将现有 session 文件复制到: ~/telegram_sessions/"
|
||
)
|
||
|
||
logger.info(f"使用 Session 文件: {session_file}")
|
||
|
||
proxy = None
|
||
if PROXY_HOST and PROXY_PORT:
|
||
try:
|
||
proxy_port = int(PROXY_PORT)
|
||
if PROXY_USERNAME:
|
||
proxy = (
|
||
PROXY_TYPE,
|
||
PROXY_HOST,
|
||
proxy_port,
|
||
PROXY_USERNAME,
|
||
PROXY_PASSWORD or ""
|
||
)
|
||
else:
|
||
proxy = (PROXY_TYPE, PROXY_HOST, proxy_port)
|
||
logger.info(
|
||
"使用代理连接: %s://%s:%s",
|
||
PROXY_TYPE,
|
||
PROXY_HOST,
|
||
proxy_port,
|
||
)
|
||
except ValueError:
|
||
logger.warning(
|
||
"代理端口无效,忽略代理配置: %s",
|
||
PROXY_PORT,
|
||
)
|
||
|
||
# 创建客户端
|
||
self.client = TelegramClient(SESSION_PATH, API_ID, API_HASH, proxy=proxy)
|
||
await self.client.start()
|
||
|
||
# 获取 bot 实体
|
||
logger.info(f"连接到 {BOT_USERNAME}...")
|
||
self.bot_entity = await self.client.get_entity(BOT_USERNAME)
|
||
logger.info(f"✅ 已连接到: {self.bot_entity.first_name}")
|
||
|
||
# 获取当前用户信息
|
||
me = await self.client.get_me()
|
||
logger.info(f"✅ 当前账号: @{me.username} (ID: {me.id})")
|
||
|
||
async def send_command_and_wait(
|
||
self,
|
||
command: str,
|
||
timeout: int = 10,
|
||
use_cache: bool = True
|
||
) -> str:
|
||
"""发送命令到 BOT 并等待响应"""
|
||
|
||
# 检查缓存
|
||
cache_key = f"cmd:{command}"
|
||
if use_cache:
|
||
cached = self.cache.get(cache_key)
|
||
if cached:
|
||
return cached
|
||
|
||
# 速率限制
|
||
await self.rate_limiter.acquire()
|
||
|
||
logger.info(f"📤 发送命令: {command}")
|
||
|
||
# 记录发送前的最新消息 ID
|
||
last_message_id = 0
|
||
async for message in self.client.iter_messages(self.bot_entity, limit=1):
|
||
last_message_id = message.id
|
||
break
|
||
|
||
# 发送消息
|
||
send_time = datetime.now()
|
||
await self.client.send_message(self.bot_entity, command)
|
||
|
||
# 等待响应(稍等一下让 BOT 有时间响应)
|
||
await asyncio.sleep(1.5)
|
||
|
||
# 获取新消息
|
||
start_time = time.time()
|
||
while time.time() - start_time < timeout:
|
||
# 获取最新消息
|
||
async for message in self.client.iter_messages(self.bot_entity, limit=5):
|
||
# 检查是否是新消息
|
||
if message.id > last_message_id:
|
||
# 检查是否是 BOT 的消息
|
||
if not message.out and message.text:
|
||
response_text = message.text
|
||
logger.info(f"✅ 收到响应 ({len(response_text)} 字符)")
|
||
|
||
# 保存到缓存
|
||
if use_cache:
|
||
self.cache.set(cache_key, response_text)
|
||
|
||
return response_text
|
||
|
||
# 继续等待
|
||
await asyncio.sleep(0.5)
|
||
|
||
raise TimeoutError(f"等待 BOT 响应超时 ({timeout}秒)")
|
||
|
||
async def fetch_user_messages(
|
||
self,
|
||
identifier: str,
|
||
max_pages: Optional[int] = None
|
||
) -> str:
|
||
"""获取指定用户的历史消息,支持自动翻页"""
|
||
if not identifier or not identifier.strip():
|
||
raise ValueError("用户标识不能为空")
|
||
|
||
identifier = identifier.strip()
|
||
display_identifier = identifier
|
||
|
||
if identifier.startswith("/"):
|
||
command = identifier
|
||
else:
|
||
if not identifier.startswith("@") and not identifier.replace("+", "").isdigit():
|
||
identifier = f"@{identifier}"
|
||
display_identifier = identifier
|
||
command = f"/user_info {identifier}"
|
||
|
||
logger.info("开始获取用户消息: %s", display_identifier)
|
||
|
||
base_message = await self.send_command_and_wait_message(command, timeout=15)
|
||
|
||
message_stage = await self._press_button(base_message, "messages")
|
||
all_stage = await self._press_button(message_stage, "all")
|
||
|
||
collected_pages: List[str] = []
|
||
seen_texts: set[str] = set()
|
||
current_message = all_stage
|
||
current_page = 1
|
||
total_pages = self._extract_total_pages(current_message)
|
||
|
||
if max_pages is not None and max_pages <= 0:
|
||
raise ValueError("max_pages 必须大于 0")
|
||
|
||
while True:
|
||
page_text = current_message.text or ""
|
||
normalized_text = page_text.strip()
|
||
|
||
if normalized_text and normalized_text not in seen_texts:
|
||
header_parts = [f"第 {current_page} 页"]
|
||
if total_pages:
|
||
header_parts[-1] += f"/{total_pages}"
|
||
header_parts.append(f"用户: {display_identifier}")
|
||
collected_pages.append(
|
||
"\n".join(header_parts + ["", page_text.strip()])
|
||
)
|
||
seen_texts.add(normalized_text)
|
||
|
||
if max_pages and current_page >= max_pages:
|
||
logger.info("达到 max_pages 限制,停止翻页")
|
||
break
|
||
|
||
next_button = None
|
||
if current_message.buttons:
|
||
for row in current_message.buttons:
|
||
for button in row:
|
||
if "➡" in button.text:
|
||
next_button = button
|
||
break
|
||
if next_button:
|
||
break
|
||
|
||
if not next_button:
|
||
break
|
||
|
||
logger.info("翻到第 %s 页 (目标按钮: %s)", current_page + 1, next_button.text)
|
||
|
||
try:
|
||
current_message = await self._press_button(current_message, "➡")
|
||
except ValueError:
|
||
logger.warning("未能找到下一页按钮,提前结束翻页")
|
||
break
|
||
|
||
# 如果返回的内容与上一页一致,则终止
|
||
if (current_message.text or "").strip() in seen_texts:
|
||
logger.info("检测到重复页面内容,结束翻页")
|
||
break
|
||
|
||
current_page += 1
|
||
|
||
if not collected_pages:
|
||
return f"未找到 {display_identifier} 的消息记录。"
|
||
|
||
summary_lines = [
|
||
f"共收集 {len(collected_pages)} 页消息"
|
||
+ (f"(存在 {total_pages} 页)" if total_pages else ""),
|
||
""
|
||
]
|
||
|
||
return "\n\n".join(summary_lines + collected_pages)
|
||
|
||
async def list_tools(self) -> List[Tool]:
|
||
"""列出所有可用工具"""
|
||
return [
|
||
Tool(
|
||
name="funstat_search",
|
||
description="搜索 Telegram 群组、频道。支持关键词搜索,返回相关的群组列表",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {
|
||
"type": "string",
|
||
"description": "搜索关键词,例如: 'python', '区块链', 'AI'"
|
||
}
|
||
},
|
||
"required": ["query"]
|
||
}
|
||
),
|
||
Tool(
|
||
name="funstat_topchat",
|
||
description="获取热门群组/频道列表,按成员数或活跃度排序",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {
|
||
"category": {
|
||
"type": "string",
|
||
"description": "分类筛选(可选),例如: 'tech', 'crypto', 'news'"
|
||
}
|
||
}
|
||
}
|
||
),
|
||
Tool(
|
||
name="funstat_text",
|
||
description="通过消息文本搜索,查找包含特定文本的消息和来源群组",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {
|
||
"text": {
|
||
"type": "string",
|
||
"description": "要搜索的文本内容"
|
||
}
|
||
},
|
||
"required": ["text"]
|
||
}
|
||
),
|
||
Tool(
|
||
name="funstat_human",
|
||
description="通过姓名搜索用户,查找 Telegram 用户信息",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {
|
||
"type": "string",
|
||
"description": "用户姓名"
|
||
}
|
||
},
|
||
"required": ["name"]
|
||
}
|
||
),
|
||
Tool(
|
||
name="funstat_user_info",
|
||
description="查询用户详细信息,支持通过用户名、用户ID、联系人等方式查询",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {
|
||
"identifier": {
|
||
"type": "string",
|
||
"description": "用户标识: 用户名(@username)、用户ID、或手机号"
|
||
}
|
||
},
|
||
"required": ["identifier"]
|
||
}
|
||
),
|
||
Tool(
|
||
name="funstat_user_messages",
|
||
description="获取指定用户的历史消息列表,并自动翻页汇总",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {
|
||
"identifier": {
|
||
"type": "string",
|
||
"description": "用户标识: 用户名(@username) 或用户ID"
|
||
},
|
||
"max_pages": {
|
||
"type": "integer",
|
||
"minimum": 1,
|
||
"description": "可选,限制抓取的最大页数"
|
||
}
|
||
},
|
||
"required": ["identifier"]
|
||
}
|
||
),
|
||
Tool(
|
||
name="funstat_balance",
|
||
description="查询当前账号的积分余额和使用统计",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {}
|
||
}
|
||
),
|
||
Tool(
|
||
name="funstat_menu",
|
||
description="显示 funstat BOT 的主菜单和所有可用功能",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {}
|
||
}
|
||
),
|
||
Tool(
|
||
name="funstat_start",
|
||
description="获取 funstat BOT 的欢迎信息和使用说明",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {}
|
||
}
|
||
)
|
||
]
|
||
|
||
async def call_tool(self, name: str, arguments: Dict[str, Any]) -> List[TextContent]:
|
||
"""调用工具"""
|
||
logger.info(f"🔧 调用工具: {name} with {arguments}")
|
||
|
||
try:
|
||
if name == "funstat_search":
|
||
query = arguments["query"]
|
||
response = await self.send_command_and_wait(f"/search {query}")
|
||
return [TextContent(type="text", text=response)]
|
||
|
||
elif name == "funstat_topchat":
|
||
category = arguments.get("category", "")
|
||
if category:
|
||
response = await self.send_command_and_wait(f"/topchat {category}")
|
||
else:
|
||
response = await self.send_command_and_wait("/topchat")
|
||
return [TextContent(type="text", text=response)]
|
||
|
||
elif name == "funstat_text":
|
||
text = arguments["text"]
|
||
response = await self.send_command_and_wait(f"/text {text}")
|
||
return [TextContent(type="text", text=response)]
|
||
|
||
elif name == "funstat_human":
|
||
name_query = arguments["name"]
|
||
response = await self.send_command_and_wait(f"/human {name_query}")
|
||
return [TextContent(type="text", text=response)]
|
||
|
||
elif name == "funstat_user_info":
|
||
identifier = arguments["identifier"].strip()
|
||
if not identifier:
|
||
raise ValueError("用户标识不能为空")
|
||
|
||
# funstat BOT 需要显式的 /user_info 命令
|
||
response = await self.send_command_and_wait(f"/user_info {identifier}")
|
||
return [TextContent(type="text", text=response)]
|
||
|
||
elif name == "funstat_user_messages":
|
||
identifier = arguments["identifier"]
|
||
max_pages = arguments.get("max_pages")
|
||
response = await self.fetch_user_messages(identifier, max_pages=max_pages)
|
||
return [TextContent(type="text", text=response)]
|
||
|
||
elif name == "funstat_balance":
|
||
response = await self.send_command_and_wait("/balance")
|
||
return [TextContent(type="text", text=response)]
|
||
|
||
elif name == "funstat_menu":
|
||
response = await self.send_command_and_wait("/menu")
|
||
return [TextContent(type="text", text=response)]
|
||
|
||
elif name == "funstat_start":
|
||
response = await self.send_command_and_wait("/start")
|
||
return [TextContent(type="text", text=response)]
|
||
|
||
else:
|
||
raise ValueError(f"未知工具: {name}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ 工具调用失败: {e}")
|
||
return [TextContent(
|
||
type="text",
|
||
text=f"❌ 错误: {str(e)}"
|
||
)]
|
||
|
||
async def run(self):
|
||
"""运行服务器"""
|
||
await self.initialize()
|
||
|
||
# 启动定期清理过期缓存的任务
|
||
async def cache_cleanup_task():
|
||
while True:
|
||
await asyncio.sleep(300) # 每5分钟清理一次
|
||
self.cache.clear_expired()
|
||
|
||
asyncio.create_task(cache_cleanup_task())
|
||
|
||
logger.info("🚀 Funstat MCP Server 已启动")
|
||
|
||
# 运行 MCP 服务器 - Streamable HTTP 模式
|
||
from mcp.server.streamable_http import StreamableHTTPServerTransport
|
||
from starlette.applications import Starlette
|
||
from starlette.routing import Mount
|
||
import uvicorn
|
||
import uuid
|
||
|
||
# 是否启用会话校验
|
||
require_session = os.getenv("FUNSTAT_REQUIRE_SESSION", "false").lower() in ("1", "true", "yes")
|
||
|
||
# 创建 Streamable HTTP 传输(生成唯一 session ID,默认关闭强校验以兼容旧客户端)
|
||
session_id = str(uuid.uuid4()) if require_session else None
|
||
transport = StreamableHTTPServerTransport(
|
||
mcp_session_id=session_id,
|
||
is_json_response_enabled=True, # 启用 JSON 响应
|
||
)
|
||
|
||
# 在后台运行 MCP 服务器
|
||
async def run_mcp_server():
|
||
async with transport.connect() as streams:
|
||
await self.server.run(
|
||
streams[0],
|
||
streams[1],
|
||
self.server.create_initialization_options(),
|
||
)
|
||
|
||
# 启动 MCP 服务器任务
|
||
asyncio.create_task(run_mcp_server())
|
||
|
||
# 创建 Starlette 应用(transport.handle_request 是 ASGI 应用)
|
||
app = Starlette()
|
||
app.mount("/", transport.handle_request)
|
||
|
||
# 获取端口配置
|
||
port = int(os.getenv("FUNSTAT_PORT", "8091"))
|
||
host = os.getenv("FUNSTAT_HOST", "127.0.0.1")
|
||
|
||
logger.info(f"🌐 启动 SSE 服务器: http://{host}:{port}")
|
||
logger.info(f"📡 SSE 端点: http://{host}:{port}/sse")
|
||
logger.info(f"📨 消息端点: http://{host}:{port}/messages")
|
||
if session_id:
|
||
logger.info(f"🔒 Session ID: {session_id}")
|
||
|
||
# 启动服务器
|
||
config = uvicorn.Config(
|
||
app,
|
||
host=host,
|
||
port=port,
|
||
log_level="info"
|
||
)
|
||
server_instance = uvicorn.Server(config)
|
||
await server_instance.serve()
|
||
|
||
|
||
async def main():
|
||
"""主函数"""
|
||
server = FunstatMCPServer()
|
||
await server.run()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|