自然进化

1.恢复文字、唤醒词、意图接口打断功能;
2、新增支持本地mcp工具调用;
3、支持mcp工具独立控制;
4、内置mcp工具箱及日程管理mcp工具;
5、结束fay时主动关闭(断开)mcp服务;
6、优化线程管理逻辑;
7、支持ctrl+c退出fay。
This commit is contained in:
xszyou
2025-08-28 00:24:21 +08:00
parent dd96e5001d
commit 4889583cc1
23 changed files with 3886 additions and 257 deletions

View File

@@ -323,7 +323,7 @@ class FeiFei:
return result
except BaseException as e:
print(e) #TODO 不合成声音时这里打印了1调试了一下
print(e)
return None
#下载wav

View File

@@ -17,6 +17,7 @@ import tempfile
import wave
from core import fay_core
from core import interact
from core import stream_manager
# 麦克风启动时间 (秒)
_ATTACK = 0.1
@@ -135,6 +136,7 @@ class Recorder:
intt = interact.Interact("auto_play", 2, {'user': self.username, 'text': "在呢,你说?" , "isfirst" : True, "isend" : True})
self.__fay.on_interact(intt)
stream_manager.new_instance().clear_Stream_with_audio(self.username)
self.processing = False
self.timer.cancel() # 取消之前的计时器任务
@@ -169,9 +171,8 @@ class Recorder:
if wsa_server.get_instance().is_connected(self.username):
content = {'Topic': 'human', 'Data': {'Key': 'log', 'Value': "唤醒成功!"}, 'Username' : self.username, 'robot': f'{cfg.fay_url}/robot/Listening.jpg'}
wsa_server.get_instance().add_cmd(content)
#去除唤醒词后语句
question = text#[len(wake_up_word):].lstrip()
self.__fay.sound_query = Queue()
question = text#[len(wake_up_word):].lstrip()不去除唤醒词
stream_manager.new_instance().clear_Stream_with_audio(self.username)
time.sleep(0.3)
self.on_speaking(question)
self.processing = False

View File

@@ -78,11 +78,14 @@ class StreamManager:
if len(sentence) > 10240: # 10KB限制
sentence = sentence[:10240]
if sentence.endswith('_<isfirst>'):
self.clear_Stream(username)
# 使用锁保护获取和写入操作
with self.lock:
# 检查是否包含_<isfirst>标记(可能在句子中间)
if '_<isfirst>' in sentence:
# 清空文本流
self._clear_Stream_internal(username)
# 清空音频队列(打断时需要清空音频)
self._clear_audio_queue(username)
try:
Stream, nlp_Stream = self.get_Stream(username)
success = Stream.write(sentence)
@@ -92,16 +95,41 @@ class StreamManager:
print(f"写入句子时出错: {e}")
return False
def _clear_Stream_internal(self, username):
"""
内部清除文本流方法,不获取锁(调用者必须已持有锁)
:param username: 用户名
"""
if username in self.streams:
self.streams[username].clear()
if username in self.nlp_streams:
self.nlp_streams[username].clear()
def _clear_audio_queue(self, username):
"""
清空指定用户的音频队列
:param username: 用户名
"""
import queue
fay_core = fay_booter.feiFei
fay_core.sound_query = queue.Queue()
def clear_Stream(self, username):
"""
清除指定用户ID的文本流数据
清除指定用户ID的文本流数据(外部调用接口,仅清除文本流)
:param username: 用户名
"""
with self.lock:
if username in self.streams:
self.streams[username].clear()
if username in self.nlp_streams:
self.nlp_streams[username].clear()
self._clear_Stream_internal(username)
def clear_Stream_with_audio(self, username):
"""
清除指定用户ID的文本流数据和音频队列完全清除
:param username: 用户名
"""
with self.lock:
self._clear_Stream_internal(username)
self._clear_audio_queue(username)
def listen(self, username, stream, nlp_stream):
while self.running:

View File

@@ -291,6 +291,15 @@ def stop():
util.log(1, '正在关闭服务...')
__running = False
# 断开所有MCP服务连接
util.log(1, '正在断开所有MCP服务连接...')
try:
from faymcp import mcp_service
mcp_service.disconnect_all_mcp_servers()
util.log(1, '所有MCP服务连接已断开')
except Exception as e:
util.log(1, f'断开MCP服务连接失败: {str(e)}')
# 保存代理记忆
util.log(1, '正在保存代理记忆...')
try:

View File

@@ -1,9 +1,30 @@
[
{
"id": 1,
"name": "time",
"ip": "https://mcp.api-inference.modelscope.cn/sse/896ded03280143",
"connection_time": "2025-05-17 01:11:25",
"key": ""
"name": "tools",
"ip": "",
"connection_time": "2025-08-28 00:17:54",
"key": "",
"transport": "stdio",
"command": "python",
"args": [
"test/mcp_stdio_example.py"
],
"cwd": "",
"env": {}
},
{
"id": 2,
"name": "Fay日程管理",
"ip": "",
"connection_time": "2025-08-28 00:17:57",
"key": "",
"transport": "stdio",
"command": "python",
"args": [
"server.py"
],
"cwd": "mcp_servers/schedule_manager",
"env": {}
}
]

View File

@@ -0,0 +1,16 @@
{
"5": {
"now": true,
"add": false,
"upper": false,
"echo": false,
"ping": false
},
"1": {
"add": false,
"upper": false,
"echo": false,
"ping": false
},
"2": {}
}

View File

@@ -4,11 +4,24 @@
import asyncio
import logging
import time
import os
import sys
import threading
from contextlib import AsyncExitStack
from typing import Optional, Dict, Any
from mcp import ClientSession
from mcp.client.sse import sse_client
from utils import util
# 尝试导入本地 stdio 传输
try:
from mcp.client.stdio import stdio_client, StdioServerParameters
HAS_STDIO = True
except Exception:
stdio_client = None
StdioServerParameters = None
HAS_STDIO = False
# 设置日志记录
logging.basicConfig(level=logging.ERROR)
logger = logging.getLogger(__name__)
@@ -16,101 +29,166 @@ logger = logging.getLogger(__name__)
class McpClient:
"""
MCP客户端类用于连接MCP服务器并调用其工具
支持两种传输:
- SSE: 远程HTTP(S) SSE服务器
- STDIO: 本地进程通过stdin/stdout通信
"""
def __init__(self, server_url, api_key=None):
def __init__(self, server_url: Optional[str] = None, api_key: Optional[str] = None,
transport: str = "sse", stdio_config: Optional[Dict[str, Any]] = None):
"""
初始化MCP客户端
:param server_url: MCP服务器URL
:param api_key: MCP服务器API密钥可选
:param server_url: MCP服务器URLSSE模式必填
:param api_key: MCP服务器API密钥可选仅SSE
:param transport: 传输类型: 'sse''stdio'
:param stdio_config: 本地stdio配置{command, args, env, cwd}
"""
self.server_url = server_url
self.api_key = api_key
self.transport = transport or "sse"
# 如果未显式指定按server_url推断
if self.transport not in ("sse", "stdio"):
self.transport = "stdio" if (server_url and str(server_url).startswith("stdio:")) else "sse"
self.stdio_config = stdio_config or {}
self.session = None
self.tools = None
self.connected = False
self.event_loop = None
self.exit_stack: Optional[AsyncExitStack] = None
# 超时配置(秒)
self.init_timeout_seconds = 20
self.list_timeout_seconds = 20
self.call_timeout_seconds = 60
self._ensure_event_loop()
# stdio 子进程stderr日志文件句柄
self._stdio_errlog_file = None
# 后台事件循环线程
self._loop_thread: Optional[threading.Thread] = None
def _ensure_event_loop(self):
"""
确保有可用的事件循环
启动一个后台事件循环线程,供所有异步操作使用,避免跨线程无事件循环的问题
"""
try:
self.event_loop = asyncio.get_event_loop()
except RuntimeError:
# 如果当前线程没有事件循环,创建一个新的
self.event_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.event_loop)
if getattr(self, "event_loop", None) and self._loop_thread and self._loop_thread.is_alive():
return
# 创建独立事件循环并在后台线程中常驻
loop = asyncio.new_event_loop()
self.event_loop = loop
def _runner():
asyncio.set_event_loop(loop)
loop.run_forever()
t = threading.Thread(target=_runner, name=f"McpClientLoop-{id(self)}", daemon=True)
t.start()
self._loop_thread = t
async def _connect_async(self):
"""
异步连接到MCP服务器
异步连接到MCP服务器或本地进程
"""
try:
# 创建退出栈
self.exit_stack = AsyncExitStack()
logger.info(f"正在连接到 SSE 服务: {self.server_url}")
# 准备请求头如果有API密钥则添加到请求头中
headers = {}
if self.api_key:
headers['Authorization'] = f'Bearer {self.api_key}'
# 增加超时设置
streams = await self.exit_stack.enter_async_context(
sse_client(url=self.server_url, timeout=60, headers=headers) # 增加超时时间到60秒并传递请求头
)
logger.info("SSE 连接已建立")
if self.transport == "stdio":
if not HAS_STDIO:
return False, "未安装或不可用的 MCP stdio 客户端,请确认 mcp 包版本并包含 mcp.client.stdio"
cfg = self.stdio_config or {}
command = cfg.get("command")
if not command:
return False, "本地MCP配置缺少 command"
args = cfg.get("args") or []
env = cfg.get("env") or None
cwd = cfg.get("cwd") or None
logger.info(f"正在通过 STDIO 启动本地MCP: {command} {args} (cwd={cwd})")
params = StdioServerParameters(
command=command,
args=list(args or []),
env=env,
cwd=cwd,
)
# 将子进程stderr写入日志文件便于排查
try:
log_dir = os.path.join(os.getcwd(), 'logs')
os.makedirs(log_dir, exist_ok=True)
base = os.path.basename(str(command))
log_path = os.path.join(log_dir, f"mcp_stdio_{base}.log")
self._stdio_errlog_file = open(log_path, 'a', encoding='utf-8')
except Exception:
self._stdio_errlog_file = None
streams = await self.exit_stack.enter_async_context(
stdio_client(params, errlog=self._stdio_errlog_file or sys.stderr)
)
logger.info("STDIO 连接已建立")
else:
logger.info(f"正在连接到 SSE 服务: {self.server_url}")
# 准备请求头如果有API密钥则添加到请求头中
headers = {}
if self.api_key:
headers['Authorization'] = f'Bearer {self.api_key}'
# 增加超时设置
streams = await self.exit_stack.enter_async_context(
sse_client(url=self.server_url, timeout=60, headers=headers) # 增加超时时间到60秒并传递请求头
)
logger.info("SSE 连接已建立")
# 创建会话
self.session = await self.exit_stack.enter_async_context(ClientSession(*streams))
await self.session.initialize()
try:
# 为 initialize 增加超时,避免服务器未握手导致阻塞
await asyncio.wait_for(self.session.initialize(), timeout=20)
except asyncio.TimeoutError:
logger.error("会话初始化超时 (initialize) — 请检查本地STDIO服务是否成功启动/输出")
return False, "会话初始化超时"
logger.info("会话已创建")
# 获取工具列表
logger.info("正在获取工具列表...")
try:
# 使用asyncio.wait_for添加超时控制
tools_response = await asyncio.wait_for(self.session.list_tools(), timeout=30)
tools_response = await asyncio.wait_for(self.session.list_tools(), timeout=self.list_timeout_seconds)
logger.info(f"可用工具: {tools_response}")
# 提取工具列表
if hasattr(tools_response, 'tools') and tools_response.tools:
self.tools = tools_response.tools
else:
# 如果返回的是直接的工具列表
self.tools = tools_response
self.connected = True
return True, self.tools
except asyncio.TimeoutError:
logger.error("获取工具列表超时")
return False, "获取工具列表超时"
except Exception as e:
logger.error(f"连接或调用过程中出错: {e}")
error_msg = str(e)
# 检查是否是网络相关错误
if "connection" in error_msg.lower() or "timeout" in error_msg.lower():
logger.error("网络连接问题,请检查网络或服务器状态")
return False, "网络连接问题,请检查网络或服务器状态"
# 检查是否是认证错误
elif "auth" in error_msg.lower() or "unauthorized" in error_msg.lower():
logger.error("可能存在认证问题,请检查是否需要提供 API 密钥")
return False, "认证问题,请检查是否需要提供 API 密钥"
# 检查是否是SSE相关错误
elif "sse" in error_msg.lower() or "stream" in error_msg.lower():
logger.error("SSE流处理错误可能是服务器提前关闭了连接")
return False, "SSE流处理错误可能是服务器提前关闭了连接"
# 分类错误信息
if self.transport == "sse":
if "connection" in error_msg.lower() or "timeout" in error_msg.lower():
logger.error("网络连接问题,请检查网络或服务器状态")
return False, "网络连接问题,请检查网络或服务器状态"
elif "auth" in error_msg.lower() or "unauthorized" in error_msg.lower():
logger.error("可能存在认证问题,请检查是否需要提供 API 密钥")
return False, "认证问题,请检查是否需要提供 API 密钥"
elif "sse" in error_msg.lower() or "stream" in error_msg.lower():
logger.error("SSE流处理错误可能是服务器提前关闭了连接")
return False, "SSE流处理错误可能是服务器提前关闭了连接"
else:
if "command" in error_msg.lower() or "not found" in error_msg.lower():
return False, "本地MCP命令启动失败请检查 command/args/cwd 是否正确"
return False, f"连接错误: {error_msg}"
def connect(self):
"""
连接到MCP服务器
连接到MCP服务器(提交到后台事件循环)
:return: (是否成功, 工具列表或错误信息)
"""
return self.event_loop.run_until_complete(self._connect_async())
fut = asyncio.run_coroutine_threadsafe(self._connect_async(), self.event_loop)
return fut.result(timeout=self.init_timeout_seconds + self.list_timeout_seconds + 10)
async def _call_tool_async(self, method, params=None):
"""
异步调用MCP工具
@@ -120,37 +198,39 @@ class McpClient:
"""
if not self.connected or not self.session:
return False, "未连接到MCP服务器"
try:
if params is None:
params = {}
logger.info(f"调用工具: {method}, 参数: {params}")
result = await asyncio.wait_for(self.session.call_tool(method, params), timeout=30)
result = await asyncio.wait_for(self.session.call_tool(method, params), timeout=self.call_timeout_seconds)
logger.info(f"调用结果: {result}")
return True, result
except asyncio.TimeoutError:
return False, f"调用工具超时({self.call_timeout_seconds}s)"
except Exception as e:
return False, f"调用工具失败: {str(e)}"
# 提供更可读的错误类型,并在日志中打印完整异常,便于排查
logger.exception("调用工具失败异常堆栈")
msg = str(e)
if not msg:
msg = repr(e)
return False, f"调用工具失败: {type(e).__name__}: {msg}"
def call_tool(self, method, params=None):
"""
调用MCP工具
调用MCP工具(提交到后台事件循环)
:param method: 方法名
:param params: 参数字典
:return: (是否成功, 结果或错误信息)
"""
try:
# 确保在同一个事件循环中执行
if asyncio.get_event_loop() != self.event_loop:
return self.event_loop.run_until_complete(self._call_tool_async(method, params))
else:
# 如果已经在事件循环中,创建一个新的任务并等待它完成
future = asyncio.run_coroutine_threadsafe(self._call_tool_async(method, params), self.event_loop)
return future.result(timeout=30)
future = asyncio.run_coroutine_threadsafe(self._call_tool_async(method, params), self.event_loop)
return future.result(timeout=self.call_timeout_seconds + 5)
except Exception as e:
util.log(1, f"调用MCP工具时出错: {str(e)}")
return False, f"调用工具失败: {str(e)}"
def list_tools(self):
"""
获取可用工具列表
@@ -161,16 +241,28 @@ class McpClient:
if not success:
return []
return self.tools or []
def disconnect(self):
"""
断开与MCP服务器的连接
"""
if self.connected and self.exit_stack:
try:
self.event_loop.run_until_complete(self.exit_stack.aclose())
self.connected = False
self.session = None
# 在后台事件循环中关闭资源
try:
if self.exit_stack:
fut = asyncio.run_coroutine_threadsafe(self.exit_stack.aclose(), self.event_loop)
fut.result(timeout=10)
finally:
self.connected = False
self.session = None
# 关闭子进程stderr日志文件
try:
if self._stdio_errlog_file:
self._stdio_errlog_file.close()
self._stdio_errlog_file = None
except Exception:
pass
logger.info("已断开与MCP服务器的连接")
return True
except Exception as e:

View File

@@ -4,6 +4,7 @@
from flask import Flask, render_template, request, jsonify, redirect, url_for
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
import json
import time
import threading
@@ -13,6 +14,8 @@ from flask_cors import CORS
from faymcp.mcp_client import McpClient
from utils import util
# 创建Flask应用
app = Flask(__name__)
@@ -23,6 +26,9 @@ CORS(app, resources={r"/*": {"origins": "*"}})
# MCP服务器数据文件路径
MCP_DATA_FILE = os.path.join(os.path.dirname(__file__), 'data', 'mcp_servers.json')
# MCP工具状态数据文件路径
MCP_TOOL_STATES_FILE = os.path.join(os.path.dirname(__file__), 'data', 'mcp_tool_states.json')
# 确保data目录存在
os.makedirs(os.path.dirname(MCP_DATA_FILE), exist_ok=True)
@@ -32,6 +38,9 @@ mcp_clients = {}
# 存储MCP服务器工具列表的字典键为服务器ID
mcp_tools = {}
# 存储工具状态的字典键为服务器ID值为工具名称->状态的字典
mcp_tool_states = {}
# 连接检查定时器
connection_check_timer = None
@@ -61,6 +70,42 @@ def load_mcp_servers():
util.log(1, f"加载MCP服务器数据失败: {e}")
return default_mcp_servers
# 加载MCP工具状态数据
def load_mcp_tool_states():
try:
if os.path.exists(MCP_TOOL_STATES_FILE):
with open(MCP_TOOL_STATES_FILE, 'r', encoding='utf-8') as f:
states = json.load(f)
# 转换字符串键为整数因为JSON中的键总是字符串
converted_states = {}
for server_id_str, tools in states.items():
try:
server_id = int(server_id_str)
converted_states[server_id] = tools
except ValueError:
continue
return converted_states
else:
return {}
except Exception as e:
util.log(1, f"加载MCP工具状态数据失败: {e}")
return {}
# 保存MCP工具状态数据
def save_mcp_tool_states():
try:
# 转换整数键为字符串JSON要求
states_to_save = {}
for server_id, tools in mcp_tool_states.items():
states_to_save[str(server_id)] = tools
with open(MCP_TOOL_STATES_FILE, 'w', encoding='utf-8') as f:
json.dump(states_to_save, f, ensure_ascii=False, indent=4)
return True
except Exception as e:
util.log(1, f"保存MCP工具状态数据失败: {e}")
return False
# 保存MCP服务器数据
def save_mcp_servers(servers):
try:
@@ -71,9 +116,14 @@ def save_mcp_servers(servers):
server_copy = {
"id": server['id'],
"name": server['name'],
"ip": server['ip'],
"ip": server.get('ip', ''),
"connection_time": server.get('connection_time', ''),
"key": server.get('key', '') # 保存Key字段
"key": server.get('key', ''), # 保存Key字段
"transport": server.get('transport', 'sse'),
"command": server.get('command', ''),
"args": server.get('args', []),
"cwd": server.get('cwd', ''),
"env": server.get('env', {})
}
servers_to_save.append(server_copy)
@@ -87,6 +137,24 @@ def save_mcp_servers(servers):
# 初始化MCP服务器数据
mcp_servers = load_mcp_servers()
# 初始化MCP工具状态数据
mcp_tool_states = load_mcp_tool_states()
# 工具状态管理函数
def get_tool_state(server_id, tool_name):
"""获取工具的启用状态默认为True"""
if server_id not in mcp_tool_states:
mcp_tool_states[server_id] = {}
return mcp_tool_states[server_id].get(tool_name, True)
def set_tool_state(server_id, tool_name, enabled):
"""设置工具的启用状态"""
if server_id not in mcp_tool_states:
mcp_tool_states[server_id] = {}
mcp_tool_states[server_id][tool_name] = enabled
# 立即保存到文件
save_mcp_tool_states()
# 连接真实MCP服务器
def connect_to_real_mcp(server):
"""
@@ -96,15 +164,39 @@ def connect_to_real_mcp(server):
"""
global mcp_clients
try:
# 获取服务器IP、ID和Key
ip = server['ip']
# 获取服务器配置
server_id = server['id']
api_key = server.get('key', '') # 获取Key如果不存在则为空字符串
transport = server.get('transport', 'sse')
api_key = server.get('key', '') # 获取Key
# 构建MCP服务器端点URL
endpoint = ip
# 创建MCP客户端传入API密钥
client = McpClient(endpoint, api_key)
# 如果已存在旧连接,先断开并清理(防止重复连接)
if server_id in mcp_clients:
try:
old_client = mcp_clients[server_id]
if hasattr(old_client, 'disconnect'):
old_client.disconnect()
# util.log(1, f"已断开服务器 {server['name']} (ID: {server_id}) 的旧连接")
except Exception as e:
pass # 静默处理断开旧连接的错误
del mcp_clients[server_id]
client = None
if transport == 'stdio':
# 统一默认工作目录为项目根目录faymcp 的上一级),避免相对路径在不同启动目录下失效
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
cfg_cwd = server.get('cwd')
cwd = cfg_cwd if (cfg_cwd and str(cfg_cwd).strip()) else repo_root
stdio_config = {
"command": server.get('command'),
"args": server.get('args', []) or [],
"cwd": cwd,
"env": (server.get('env') or None),
}
client = McpClient(server_url=None, api_key=None, transport='stdio', stdio_config=stdio_config)
else:
ip = server.get('ip', '')
endpoint = ip
client = McpClient(endpoint, api_key)
# 记录开始时间
start_time = time.time()
@@ -156,6 +248,54 @@ def get_mcp_client(server_id):
"""
return mcp_clients.get(server_id)
# 断开所有MCP服务连接
def disconnect_all_mcp_servers():
"""
断开所有MCP服务器连接清理资源
"""
global mcp_clients, mcp_tools, mcp_servers, connection_check_timer
util.log(1, f'开始断开 {len(mcp_clients)} 个MCP服务连接...')
# 停止连接检查定时器
if connection_check_timer:
try:
connection_check_timer.cancel()
util.log(1, '连接检查定时器已停止')
except Exception as e:
util.log(1, f'停止连接检查定时器失败: {e}')
connection_check_timer = None
# 断开所有MCP客户端连接
disconnected_count = 0
for server_id, client in list(mcp_clients.items()):
try:
if hasattr(client, 'disconnect'):
client.disconnect()
elif hasattr(client, 'close'):
client.close()
disconnected_count += 1
util.log(1, f'已断开MCP服务器连接: ID {server_id}')
except Exception as e:
util.log(1, f'断开MCP服务器连接失败 (ID: {server_id}): {e}')
# 清理所有数据
mcp_clients.clear()
mcp_tools.clear()
# 更新所有服务器状态为离线
for server in mcp_servers:
server['status'] = 'offline'
server['latency'] = '0ms'
# 保存服务器状态
try:
save_mcp_servers(mcp_servers)
except Exception as e:
util.log(1, f'保存MCP服务器状态失败: {e}')
util.log(1, f'成功断开 {disconnected_count} 个MCP服务连接资源已清理')
# 调用MCP服务器工具
def call_mcp_tool(server_id, method, params=None):
"""
@@ -166,6 +306,10 @@ def call_mcp_tool(server_id, method, params=None):
:return: (是否成功, 结果或错误信息)
"""
try:
# 检查工具是否被启用
if not get_tool_state(server_id, method):
return False, f"工具 '{method}' 已被禁用"
# 获取客户端对象
client = get_mcp_client(server_id)
if not client:
@@ -204,25 +348,35 @@ def add_mcp_server():
data = request.json
# 验证必要字段
required_fields = ['name', 'ip']
for field in required_fields:
if field not in data:
return jsonify({"error": f"缺少必要字段: {field}"}), 400
transport = data.get('transport', 'sse')
if transport == 'stdio':
if 'name' not in data or 'command' not in data:
return jsonify({"error": "缺少必要字段: name 或 command"}), 400
else:
required_fields = ['name', 'ip']
for field in required_fields:
if field not in data:
return jsonify({"error": f"缺少必要字段: {field}"}), 400
# 生成新ID (当前最大ID + 1)
new_id = 1
if mcp_servers:
new_id = max(server['id'] for server in mcp_servers) + 1
# 创建新服务器对象
new_server = {
"id": new_id,
"name": data['name'],
"status": "offline",
"ip": data['ip'],
"ip": data.get('ip', ''),
"latency": "0ms",
"connection_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"key": data.get('key', '') # 添加Key字段,如果不存在则为空字符串
"key": data.get('key', ''), # 添加Key字段
"transport": transport,
"command": data.get('command', ''),
"args": data.get('args', []),
"cwd": data.get('cwd', ''),
"env": data.get('env', {})
}
# 如果请求中包含 auto_connect 字段并且为 True则尝试连接
@@ -245,9 +399,11 @@ def add_mcp_server():
for tool in tools:
if hasattr(tool, 'name'):
# 如果是对象,转换为字典
tool_name = str(getattr(tool, 'name', '未知'))
tool_dict = {
'name': str(getattr(tool, 'name', '未知')),
'name': tool_name,
'description': str(getattr(tool, 'description', '')),
'enabled': get_tool_state(server_id, tool_name)
}
# 处理 inputSchema
@@ -261,18 +417,31 @@ def add_mcp_server():
else:
# 如果是字典
if isinstance(tool, dict) and 'name' in tool:
tool_name = str(tool.get('name', '未知'))
tools_list.append({
'name': str(tool.get('name', '未知')),
'name': tool_name,
'description': str(tool.get('description', '')),
'inputSchema': tool.get('inputSchema', {})
'inputSchema': tool.get('inputSchema', {}),
'enabled': get_tool_state(server_id, tool_name)
})
else:
# 其他情况,尝试转换为字符串
tools_list.append({'name': str(tool), 'description': ''})
tool_name = str(tool)
tools_list.append({
'name': tool_name,
'description': '',
'enabled': get_tool_state(server_id, tool_name)
})
except Exception as e:
util.log(1, f"工具列表序列化失败: {e}")
# 如果转换失败,只返回工具名称
tools_list = [{'name': str(tool)} for tool in tools]
tools_list = []
for tool in tools:
tool_name = str(tool)
tools_list.append({
'name': tool_name,
'enabled': get_tool_state(server_id, tool_name)
})
except Exception as e:
util.log(1, f"自动连接失败: {e}")
@@ -493,11 +662,22 @@ def get_server_tools(server_id):
# 检查是否已有缓存的工具列表
if server_id in mcp_tools and mcp_tools[server_id]:
# 使用缓存的工具列表,添加到结果中
# 使用缓存的工具列表,但需要更新enabled状态
cached_tools = mcp_tools[server_id]
updated_tools = []
for tool in cached_tools:
if isinstance(tool, dict) and 'name' in tool:
tool_name = tool['name']
updated_tool = tool.copy()
updated_tool['enabled'] = get_tool_state(server_id, tool_name)
updated_tools.append(updated_tool)
else:
updated_tools.append(tool)
return jsonify({
"success": True,
"message": "获取工具列表成功(缓存)",
"tools": mcp_tools[server_id]
"message": "获取工具列表成功(缓存+状态更新",
"tools": updated_tools
})
# 获取客户端对象
@@ -521,9 +701,11 @@ def get_server_tools(server_id):
for tool in tools:
if hasattr(tool, 'name'):
# 如果是对象,转换为字典
tool_name = str(getattr(tool, 'name', '未知'))
tool_dict = {
'name': str(getattr(tool, 'name', '未知')),
'name': tool_name,
'description': str(getattr(tool, 'description', '')),
'enabled': get_tool_state(server_id, tool_name)
}
# 处理 inputSchema
@@ -537,18 +719,31 @@ def get_server_tools(server_id):
else:
# 如果是字典
if isinstance(tool, dict) and 'name' in tool:
tool_name = str(tool.get('name', '未知'))
tools_list.append({
'name': str(tool.get('name', '未知')),
'name': tool_name,
'description': str(tool.get('description', '')),
'inputSchema': tool.get('inputSchema', {})
'inputSchema': tool.get('inputSchema', {}),
'enabled': get_tool_state(server_id, tool_name)
})
else:
# 其他情况,尝试转换为字符串
tools_list.append({'name': str(tool), 'description': ''})
tool_name = str(tool)
tools_list.append({
'name': tool_name,
'description': '',
'enabled': get_tool_state(server_id, tool_name)
})
except Exception as e:
util.log(1, f"工具列表序列化失败: {e}")
# 如果转换失败,只返回工具名称
tools_list = [{'name': str(tool)} for tool in tools]
tools_list = []
for tool in tools:
tool_name = str(tool)
tools_list.append({
'name': tool_name,
'enabled': get_tool_state(server_id, tool_name)
})
# 保存工具列表到全局字典中
mcp_tools[server_id] = tools_list
@@ -585,8 +780,18 @@ def get_all_online_server_tools():
# 检查是否有缓存的工具列表
if server_id in mcp_tools and mcp_tools[server_id]:
# 使用缓存的工具列表,添加到结果中
all_tools.extend(mcp_tools[server_id])
# 使用缓存的工具列表,但需要更新enabled状态
cached_tools = mcp_tools[server_id]
updated_tools = []
for tool in cached_tools:
if isinstance(tool, dict) and 'name' in tool:
tool_name = tool['name']
updated_tool = tool.copy()
updated_tool['enabled'] = get_tool_state(server_id, tool_name)
updated_tools.append(updated_tool)
else:
updated_tools.append(tool)
all_tools.extend(updated_tools)
else:
# 获取客户端对象
client = get_mcp_client(server_id)
@@ -690,8 +895,12 @@ def call_mcp_tool_direct(tool_name):
tools = client.list_tools()
tool_names = [str(getattr(tool, 'name', tool)) for tool in tools]
# 检查工具是否存在
# 检查工具是否存在且被启用
if tool_name in tool_names:
# 检查工具是否被启用
if not get_tool_state(server_id, tool_name):
continue # 如果工具被禁用,跳过这个服务器
# 调用工具
success, result = call_mcp_tool(server_id, tool_name, params)
@@ -835,6 +1044,49 @@ def schedule_connection_check():
connection_check_timer.daemon = True # 设置为守护线程,这样主程序退出时它会自动结束
connection_check_timer.start()
# API路由 - 切换工具状态
@app.route('/api/mcp/servers/<int:server_id>/tools/<string:tool_name>/toggle', methods=['POST'])
def toggle_tool_state(server_id, tool_name):
"""
切换工具的启用/禁用状态
"""
try:
# 获取请求数据
data = request.json or {}
enabled = data.get('enabled', True)
# 验证服务器是否存在
server = None
for s in mcp_servers:
if s['id'] == server_id:
server = s
break
if not server:
return jsonify({
"success": False,
"message": "服务器不存在"
}), 404
# 设置工具状态
set_tool_state(server_id, tool_name, enabled)
util.log(1, f"工具 {tool_name} 在服务器 {server['name']} 上已{'启用' if enabled else '禁用'}")
return jsonify({
"success": True,
"message": f"工具 {tool_name}{'启用' if enabled else '禁用'}",
"tool_name": tool_name,
"enabled": enabled
})
except Exception as e:
util.log(1, f"切换工具状态失败: {e}")
return jsonify({
"success": False,
"message": f"切换工具状态失败: {str(e)}"
}), 500
# 启动连接检查
def start_connection_check():
"""
@@ -870,3 +1122,10 @@ def start():
# 启动服务器
from scheduler.thread_manager import MyThread
MyThread(target=run).start()
if __name__ == '__main__':
import logging
logging.basicConfig(level=logging.DEBUG)
app.logger.setLevel(logging.DEBUG)
logging.getLogger('werkzeug').setLevel(logging.DEBUG)
app.run(host='0.0.0.0', port=5010, debug=True)

View File

@@ -292,17 +292,82 @@
flex-wrap: wrap;
}
.mcp-info-item {
width: 33.33%;
width: 50%;
margin-bottom: 10px;
display: flex;
}
/* 让工具列表占满整行 */
.mcp-info-item:nth-child(4) {
width: 100%;
flex-direction: column;
align-items: flex-start;
}
/* 工具列表容器样式 */
.tools-container {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 5px;
width: 100%;
}
/* 工具按钮样式 */
.tool-btn {
padding: 6px 12px;
border-radius: 20px;
border: 1px solid #dcdfe6;
background-color: #fff;
color: #606266;
cursor: pointer;
transition: all 0.3s;
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 5px;
user-select: none;
}
.tool-btn:hover {
border-color: #409eff;
color: #409eff;
}
/* 工具启用状态 */
.tool-btn.enabled {
background-color: #409eff;
color: #fff;
border-color: #409eff;
}
.tool-btn.enabled:hover {
background-color: #66b1ff;
border-color: #66b1ff;
}
/* 工具禁用状态 */
.tool-btn.disabled {
background-color: #f5f7fa;
color: #c0c4cc;
border-color: #e4e7ed;
cursor: not-allowed;
}
/* 状态指示点 */
.tool-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #c0c4cc;
}
.tool-btn.enabled .tool-status-dot {
background-color: #67c23a;
}
.mcp-info-label {
font-weight: bold;
width: 100px;
color: #606266;
flex-shrink: 0;
margin-right: 10px;
}
.mcp-info-value {
color: #303133;
flex: 1;
word-break: break-word;
line-height: 1.5;
}
/* 让 main_right 成为定位上下文 */
.main_right {
@@ -337,7 +402,7 @@
<div class="mcp-container">
<!-- 使用Jinja2模板循环渲染MCP服务器卡片 -->
{% for server in mcp_servers %}
<div class="mcp-card tooltip" data-server-id="{{ server.id }}">
<div class="mcp-card tooltip" data-server-id="{{ server.id }}" data-transport="{{ server.transport or 'sse' }}" data-command="{{ server.command or '' }}" data-args="{{ (server.args | join(' ')) if server.args else '' }}" data-cwd="{{ server.cwd or '' }}">
<div class="mcp-header">
<div class="mcp-title">{{ server.name }}</div>
<div class="status-{{ server.status }}">
@@ -387,12 +452,41 @@
<input type="text" class="mcp-input" id="mcpName" placeholder="请输入名称">
</div>
<div class="mcp-form-item">
<label class="mcp-label">SSE服务器:</label>
<input type="text" class="mcp-input" id="mcpIp" placeholder="请输入SSE地址">
<label class="mcp-label">类型:</label>
<select class="mcp-input" id="mcpTransport">
<option value="sse" selected>SSE远程</option>
<option value="stdio">STDIO本地</option>
</select>
</div>
<div class="mcp-form-item">
<label class="mcp-label">Key:</label>
<input type="text" class="mcp-input" id="mcpKey" placeholder="可留空请输入Key">
<!-- SSE 配置 -->
<div id="sseFields">
<div class="mcp-form-item">
<label class="mcp-label">SSE服务器:</label>
<input type="text" class="mcp-input" id="mcpIp" placeholder="请输入SSE地址">
</div>
<div class="mcp-form-item">
<label class="mcp-label">Key:</label>
<input type="text" class="mcp-input" id="mcpKey" placeholder="可留空请输入Key">
</div>
</div>
<!-- STDIO 配置 -->
<div id="stdioFields" style="display:none;">
<div class="mcp-form-item">
<label class="mcp-label">命令:</label>
<input type="text" class="mcp-input" id="mcpCommand" placeholder="例如: node">
</div>
<div class="mcp-form-item">
<label class="mcp-label">参数:</label>
<input type="text" class="mcp-input" id="mcpArgs" placeholder='例如: ./servers/fs-server.js --port 0'>
</div>
<div class="mcp-form-item">
<label class="mcp-label">工作目录:</label>
<input type="text" class="mcp-input" id="mcpCwd" placeholder="可留空,默认当前目录">
</div>
<div class="mcp-form-item">
<label class="mcp-label">环境变量(JSON):</label>
<input type="text" class="mcp-input" id="mcpEnv" placeholder='例如: {"MODE":"local"}'>
</div>
</div>
</div>
<div class="mcp-dialog-footer">
@@ -414,7 +508,7 @@
</div>
<div class="mcp-info-item">
<span class="mcp-info-label">服务器类型:</span>
<span class="mcp-info-value">SSE</span>
<span class="mcp-info-value">--</span>
</div>
<div class="mcp-info-item">
<span class="mcp-info-label">延迟:</span>
@@ -422,7 +516,11 @@
</div>
<div class="mcp-info-item">
<span class="mcp-info-label">可用工具:</span>
<span class="mcp-info-value">--</span>
<div class="mcp-info-value">
<div class="tools-container" id="toolsContainer">
<span style="color: #909399;">--</span>
</div>
</div>
</div>
</div>
</div>
@@ -446,24 +544,43 @@
console.log("添加按钮被点击");
// 获取输入值
const name = document.getElementById('mcpName').value.trim();
const ip = document.getElementById('mcpIp').value.trim();
const key = document.getElementById('mcpKey').value.trim();
console.log("输入值:", name, ip, key ? "(Key已输入)" : "(无Key)");
// 简单验证
if (!name || !ip) {
alert('请填写完整的MCP信息');
return;
const transport = document.getElementById('mcpTransport').value;
let data = { name, transport };
if (transport === 'stdio') {
const command = document.getElementById('mcpCommand').value.trim();
const argsStr = document.getElementById('mcpArgs').value.trim();
const cwd = document.getElementById('mcpCwd').value.trim();
const envStr = document.getElementById('mcpEnv').value.trim();
if (!name || !command) {
alert('请填写名称与命令');
return;
}
let args = [];
if (argsStr) {
// 简单按空格拆分,支持引号可后续优化
args = argsStr.match(/\"[^\"]*\"|'[^']*'|[^\s]+/g) || [];
args = args.map(s => s.replace(/^"|"$/g, '').replace(/^'|'$/g, ''));
}
let env = {};
if (envStr) {
try { env = JSON.parse(envStr); } catch (e) { alert('环境变量需为合法JSON'); return; }
}
data = { ...data, command, args, cwd, env };
} else {
const ip = document.getElementById('mcpIp').value.trim();
const key = document.getElementById('mcpKey').value.trim();
if (!name || !ip) {
alert('请填写名称与SSE地址');
return;
}
data = { ...data, ip, key };
}
// 准备数据
const data = {
name: name,
ip: ip,
key: key // 添加 Key 参数
};
console.log("准备发送数据:", JSON.stringify(data));
// 发送请求到后端
@@ -514,6 +631,22 @@
} else {
console.error('找不到添加服务器按钮元素');
}
// 绑定类型切换,显示/隐藏字段
const transportSel = document.getElementById('mcpTransport');
const sseFields = document.getElementById('sseFields');
const stdioFields = document.getElementById('stdioFields');
if (transportSel) {
transportSel.addEventListener('change', function() {
if (this.value === 'stdio') {
sseFields.style.display = 'none';
stdioFields.style.display = 'block';
} else {
sseFields.style.display = 'block';
stdioFields.style.display = 'none';
}
});
}
});
// 删除MCP服务器
@@ -639,22 +772,18 @@
// 更新信息面板
updateInfoPanel(data.server, toolsInfo);
// 选中当前卡片
mcpCards.forEach(c => c.classList.remove('selected'));
// 选中当前卡片并更新面板
document.querySelectorAll('.mcp-card.selected').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
// 触发卡片点击事件,更新信息卡片
card.click();
onCardSelected(card);
} else {
// 没有工具时也更新信息面板
updateInfoPanel(data.server, '无可用工具');
// 选中当前卡片
mcpCards.forEach(c => c.classList.remove('selected'));
// 选中当前卡片并更新面板
document.querySelectorAll('.mcp-card.selected').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
// 触发卡片点击事件,更新信息卡片
card.click();
onCardSelected(card);
}
} else {
// 连接失败
@@ -796,22 +925,18 @@
// 更新信息面板
updateInfoPanel(data.server, toolsInfo);
// 选中当前卡片
mcpCards.forEach(c => c.classList.remove('selected'));
// 选中当前卡片并更新面板
document.querySelectorAll('.mcp-card.selected').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
// 触发卡片点击事件,更新信息卡片
card.click();
onCardSelected(card);
} else {
// 没有工具时也更新信息面板
updateInfoPanel(data.server, '无可用工具');
// 选中当前卡片
mcpCards.forEach(c => c.classList.remove('selected'));
// 选中当前卡片并更新面板
document.querySelectorAll('.mcp-card.selected').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
// 触发卡片点击事件,更新信息卡片
card.click();
onCardSelected(card);
}
} else {
// 连接失败
@@ -855,9 +980,10 @@
// 卡片选中功能
const mcpCards = document.querySelectorAll('.mcp-card');
const container = document.querySelector('.mcp-container');
const infoPanel = document.querySelector('.mcp-info-panel');
const infoValues = document.querySelectorAll('.mcp-info-value');
// 处理长文本,超出长度使用省略号替代
function truncateText(text, maxLength = 30) {
if (!text) return '';
@@ -865,83 +991,179 @@
return text.substring(0, maxLength - 3) + '...';
}
mcpCards.forEach((card, index) => {
// 处理卡片中的服务器地址,如果太长则使用省略号替代
const ipValue = card.querySelector('.mcp-info:nth-child(2) .mcp-value');
if (ipValue) {
ipValue.textContent = truncateText(ipValue.textContent);
// 添加完整地址的 title 属性,鼠标悬停时显示完整地址
ipValue.title = ipValue.getAttribute('data-full-ip') || ipValue.textContent;
// 首次渲染:处理省略显示
mcpCards.forEach((card) => {
const ipNode = card.querySelector('.mcp-info:nth-child(2) .mcp-value');
if (ipNode) {
const full = ipNode.getAttribute('data-full-ip') || ipNode.textContent;
ipNode.textContent = truncateText(full);
ipNode.title = full;
}
card.addEventListener('click', () => {
// 移除其他卡片的选中状态
mcpCards.forEach(c => c.classList.remove('selected'));
// 添加当前卡片的选中状态
card.classList.add('selected');
// 显示信息面板
infoPanel.classList.add('active');
// 获取服务器ID和信息
const serverId = card.getAttribute('data-server-id');
const serverName = card.querySelector('.mcp-title').textContent;
const serverIp = card.querySelector('.mcp-info:nth-child(2) .mcp-value').getAttribute('data-full-ip') ||
card.querySelector('.mcp-info:nth-child(2) .mcp-value').textContent;
const serverLatency = card.querySelector('.mcp-info:nth-child(3) .mcp-value').textContent;
const serverStatus = card.querySelector('.status-online, .status-offline').className;
// 更新信息面板内容
const ipValue = infoPanel.querySelector('.mcp-info-item:nth-child(1) .mcp-info-value');
const typeValue = infoPanel.querySelector('.mcp-info-item:nth-child(2) .mcp-info-value');
const latencyValue = infoPanel.querySelector('.mcp-info-item:nth-child(3) .mcp-info-value');
const toolsValue = infoPanel.querySelector('.mcp-info-item:nth-child(4) .mcp-info-value');
if (ipValue) {
ipValue.textContent = truncateText(serverIp);
ipValue.title = serverIp; // 添加完整地址的 title 属性
}
if (latencyValue) latencyValue.textContent = serverLatency;
// 如果服务器在线,获取工具列表
if (serverStatus.includes('online')) {
// 显示加载状态
if (toolsValue) toolsValue.textContent = '加载中...';
// 发送请求获取工具列表
fetch(`/api/mcp/servers/${serverId}/tools`, {
method: 'GET'
})
.then(response => response.json())
.then(data => {
if (data.success && data.tools && data.tools.length > 0) {
const toolNames = data.tools.map(tool => {
if (typeof tool === 'object' && tool !== null && tool.name) {
return tool.name;
} else if (typeof tool === 'string') {
return tool;
} else {
return '未知工具';
}
});
toolsValue.textContent = toolNames.join(', ');
} else {
toolsValue.textContent = '无可用工具';
}
})
.catch(error => {
console.error('获取工具列表失败:', error);
toolsValue.textContent = '获取工具列表失败';
});
} else {
// 服务器离线
if (toolsValue) toolsValue.textContent = '服务器离线';
}
});
});
// 统一的卡片点击处理
function initCardSelection() {
const cards = document.querySelectorAll('.mcp-card');
cards.forEach((card) => {
card.addEventListener('click', function(e) {
// 如果点击的是操作按钮区域,不处理选中逻辑
if (e.target.closest('.mcp-actions')) {
e.stopPropagation();
return;
}
// 移除所有选中状态
document.querySelectorAll('.mcp-card.selected').forEach(c => c.classList.remove('selected'));
// 添加当前卡片选中状态
this.classList.add('selected');
// 调用选中处理函数
onCardSelected(this);
});
});
}
// 页面加载完成后初始化
function initPage() {
initCardSelection();
// 自动选中第一个在线的服务器卡片
const onlineCard = document.querySelector('.mcp-card .status-online')?.closest('.mcp-card');
const firstCard = document.querySelector('.mcp-card');
if (onlineCard) {
onlineCard.classList.add('selected');
onCardSelected(onlineCard);
} else if (firstCard) {
// 如果没有在线的,选中第一个卡片
firstCard.classList.add('selected');
onCardSelected(firstCard);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPage);
} else {
initPage();
}
function onCardSelected(card) {
const infoPanel = document.querySelector('.mcp-info-panel');
if (!infoPanel) return;
infoPanel.classList.add('active');
const serverId = card.getAttribute('data-server-id');
const nameNode = card.querySelector('.mcp-title');
const ipNode = card.querySelector('.mcp-info:nth-child(2) .mcp-value');
const latencyNode = card.querySelector('.mcp-info:nth-child(3) .mcp-value');
const connectionTimeNode = card.querySelector('.mcp-info:nth-child(4) .mcp-value');
const statusDiv = card.querySelector('.status-online, .status-offline');
const transport = card.getAttribute('data-transport') || 'sse';
const server = {
name: nameNode ? nameNode.textContent : '--',
ip: ipNode ? (ipNode.getAttribute('data-full-ip') || ipNode.textContent) : '--',
latency: latencyNode ? latencyNode.textContent : '--',
connectionTime: connectionTimeNode ? connectionTimeNode.textContent : '--',
transport: transport
};
// 更新面板基础信息
const ipValue = infoPanel.querySelector('.mcp-info-item:nth-child(1) .mcp-info-value');
const typeValue = infoPanel.querySelector('.mcp-info-item:nth-child(2) .mcp-info-value');
const latencyValue = infoPanel.querySelector('.mcp-info-item:nth-child(3) .mcp-info-value');
const toolsValue = infoPanel.querySelector('.mcp-info-item:nth-child(4) .mcp-info-value');
if (ipValue) {
ipValue.textContent = truncateText(server.ip);
ipValue.title = server.ip;
}
if (typeValue) {
typeValue.textContent = transport === 'stdio' ? 'STDIO本地' : 'SSE远程';
}
if (latencyValue) {
latencyValue.textContent = server.latency;
}
const toolsContainer = document.getElementById('toolsContainer');
if (toolsContainer) {
toolsContainer.innerHTML = '<span style="color: #909399;">加载中...</span>';
}
// 检查是否在线
const isOnline = statusDiv && statusDiv.classList.contains('status-online');
if (!isOnline) {
const toolsContainer = document.getElementById('toolsContainer');
if (toolsContainer) {
toolsContainer.innerHTML = '<span style="color: #909399;">服务器离线(点击"连接"按钮后可查看工具)</span>';
}
return;
}
// 加载工具列表
fetch(`/api/mcp/servers/${serverId}/tools`)
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch tools');
}
return response.json();
})
.then(data => {
console.log('工具数据:', data); // 调试信息
const toolsContainer = document.getElementById('toolsContainer');
if (toolsContainer) {
toolsContainer.innerHTML = '';
const tools = data.tools || [];
if (tools.length > 0) {
tools.forEach(tool => {
console.log('工具对象:', tool); // 调试信息
let toolName = '';
let toolEnabled = true; // 默认启用
if (typeof tool === 'object' && tool !== null) {
toolName = tool.name || '未知工具';
// 如果后端返回了启用状态,使用它
if (typeof tool.enabled !== 'undefined') {
toolEnabled = tool.enabled;
console.log(`工具 ${toolName} 状态:`, toolEnabled); // 调试信息
}
} else if (typeof tool === 'string') {
toolName = tool;
} else {
toolName = '未知工具';
}
// 创建工具按钮
const toolBtn = document.createElement('div');
toolBtn.className = `tool-btn ${toolEnabled ? 'enabled' : ''}`;
toolBtn.innerHTML = `
<span class="tool-status-dot"></span>
<span>${toolName}</span>
`;
toolBtn.title = `点击${toolEnabled ? '禁用' : '启用'}工具: ${toolName}`;
toolBtn.dataset.toolName = toolName;
toolBtn.dataset.serverId = serverId;
toolBtn.dataset.enabled = toolEnabled;
// 添加点击事件
toolBtn.addEventListener('click', function() {
toggleTool(this, serverId, toolName);
});
toolsContainer.appendChild(toolBtn);
});
} else {
toolsContainer.innerHTML = '<span style="color: #909399;">无可用工具</span>';
}
}
})
.catch(err => {
console.error('获取工具列表失败:', err);
const toolsContainer = document.getElementById('toolsContainer');
if (toolsContainer) {
toolsContainer.innerHTML = '<span style="color: #f56c6c;">获取工具列表失败</span>';
}
});
}
// 更新信息面板
function updateInfoPanel(server, toolsInfo) {
const infoPanel = document.querySelector('.mcp-info-panel');
@@ -949,7 +1171,7 @@
const ipValue = infoPanel.querySelector('.mcp-info-item:nth-child(1) .mcp-info-value');
const latencyValue = infoPanel.querySelector('.mcp-info-item:nth-child(3) .mcp-info-value');
const toolsValue = infoPanel.querySelector('.mcp-info-item:nth-child(4) .mcp-info-value');
const toolsContainer = document.getElementById('toolsContainer');
if (ipValue && server && server.ip) {
ipValue.textContent = truncateText(server.ip);
@@ -960,14 +1182,62 @@
latencyValue.textContent = server.latency;
}
if (toolsValue && toolsInfo) {
toolsValue.textContent = toolsInfo;
if (toolsContainer && toolsInfo) {
// 简单显示工具信息文本连接成功后会由onCardSelected更新为按钮
toolsContainer.innerHTML = `<span style="color: #303133;">${toolsInfo}</span>`;
}
// 显示信息面板
infoPanel.classList.add('active');
}
// 切换工具状态
function toggleTool(btn, serverId, toolName) {
const isEnabled = btn.dataset.enabled === 'true';
const newState = !isEnabled;
// 立即更新UI
btn.dataset.enabled = newState;
if (newState) {
btn.classList.add('enabled');
btn.title = `点击禁用工具: ${toolName}`;
} else {
btn.classList.remove('enabled');
btn.title = `点击启用工具: ${toolName}`;
}
// 发送API请求更新状态
fetch(`/api/mcp/servers/${serverId}/tools/${encodeURIComponent(toolName)}/toggle`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ enabled: newState })
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to toggle tool state');
}
return response.json();
})
.then(data => {
console.log(`工具 ${toolName}${newState ? '启用' : '禁用'}`);
})
.catch(err => {
console.error('切换工具状态失败:', err);
// 恢复原状态
btn.dataset.enabled = isEnabled;
if (isEnabled) {
btn.classList.add('enabled');
btn.title = `点击禁用工具: ${toolName}`;
} else {
btn.classList.remove('enabled');
btn.title = `点击启用工具: ${toolName}`;
}
alert(`切换工具状态失败: ${err.message}`);
});
}
// 初始化Vue实例
new Vue({
el: '#app',

View File

@@ -459,7 +459,7 @@ def non_streaming_response(last_content, username):
text += sentence.replace("_<isfirst>", "").replace("_<isend>", "")
if is_end:
if username in fay_booter.feiFei.nlp_streams:
stream_manager.new_instance().clear_Stream(username)
stream_manager.new_instance().clear_Stream(username)
break
return jsonify({
"id": "fay-" + str(uuid.uuid4()),
@@ -562,6 +562,8 @@ def to_stop_talking():
username = data.get('username', 'User')
message = data.get('text', '你好,请说?')
observation = data.get('observation', '')
from queue import Queue
stream_manager.clear_Stream_with_audio(username)
interact = Interact("stop_talking", 2, {'user': username, 'text': message, 'observation': str(observation)})
result = fay_booter.feiFei.on_interact(interact)
return jsonify({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -34,13 +34,19 @@ class MainWindow(QMainWindow):
def runnable(self):
while True:
if not self.isVisible():
# try:
# wsa_server.get_instance().stop_server()
# wsa_server.get_web_instance().stop_server()
# thread_manager.stopAll()
# except BaseException as e:
# print(e)
os.system("taskkill /F /PID {}".format(os.getpid()))
try:
# 正常关闭服务
import fay_booter
if fay_booter.is_running():
print("窗口关闭正在停止Fay服务...")
fay_booter.stop()
time.sleep(0.5) # 给服务一点时间完成清理
print("服务已停止")
except BaseException as e:
print(f"正常关闭服务时出错: {e}")
finally:
# 如果正常关闭失败,再强制终止
os.system("taskkill /F /PID {}".format(os.getpid()))
time.sleep(0.05)
def center(self):

View File

@@ -43,7 +43,7 @@ from core import stream_manager
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_ENDPOINT"] = "https://api.smith.langchain.com"
os.environ["LANGCHAIN_API_KEY"] = "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"
os.environ["LANGCHAIN_PROJECT"] = "fay3.1.2_github"
os.environ["LANGCHAIN_PROJECT"] = "fay3.8.2_github"
# 加载配置
cfg.load_config()
@@ -818,7 +818,7 @@ def question(content, username, observation=None):
# 2. 存在mcp工具走react agent
if mcp_tools:
is_agent_think_start = False
is_agent_think_start = False#记录是否已经写入start标签
#2.1 构建react agent
tools = [_build_tool(t) for t in mcp_tools] if mcp_tools else []
react_agent = create_react_agent(llm, tools)
@@ -830,7 +830,7 @@ def question(content, username, observation=None):
for chunk in react_agent.stream(
{"messages": messages}, {"configurable": {"thread_id": "tid{}".format(username)}}
):
react_response_text = ""
react_response_text = ""
# 消息类型1检测工具调用开始
if "agent" in chunk and "tool_calls" in str(chunk):
try:
@@ -845,6 +845,8 @@ def question(content, username, observation=None):
if is_first_sentence:
content_temp = react_response_text + "_<isfirst>"
is_first_sentence = False
else:
content_temp = react_response_text
stream_manager.new_instance().write_sentence(username, content_temp)
except (KeyError, IndexError, AttributeError) as e:
@@ -1065,9 +1067,14 @@ def perform_daily_reflection():
# 执行反思,传入当前时间戳
for username, agent in agents.items():
# 获取当前时间作为time_step
current_time_step = get_current_time_step(username)
agent.reflect(topic, time_step=current_time_step)
try:
# 获取当前时间作为time_step
current_time_step = get_current_time_step(username)
agent.reflect(topic, time_step=current_time_step)
except KeyError as e:
util.log(1, f"反思时出现KeyError: {e},跳过此次反思")
except Exception as e:
util.log(1, f"反思时出现错误: {e},跳过此次反思")
# 记录反思执行情况
util.log(1, f"反思主题: {topic}")

86
main.py
View File

@@ -6,6 +6,9 @@ import time
import psutil
import re
import argparse
import signal
import atexit
import threading
from utils import config_util, util
from asr import ali_nls
from core import wsa_server
@@ -15,6 +18,83 @@ import fay_booter
from scheduler.thread_manager import MyThread
from core.interact import Interact
# import sys, io, traceback
# class StdoutInterceptor(io.TextIOBase):
# def __init__(self, orig):
# self._orig = orig
# def write(self, s):
# try:
# if s.strip() == "1":
# self._orig.write("[debug] caught raw '1', stack:\n")
# traceback.print_stack(limit=8, file=self._orig)
# except Exception:
# pass
# return self._orig.write(s)
# def flush(self):
# return self._orig.flush()
# sys.stdout = StdoutInterceptor(sys.stdout)
# 程序退出处理
def cleanup_on_exit():
"""程序退出时的清理函数"""
try:
util.log(1, '程序退出,正在清理资源...')
if fay_booter.is_running():
fay_booter.stop()
# 停止所有自定义线程
try:
from scheduler.thread_manager import stopAll
util.log(1, '正在停止所有线程...')
stopAll()
util.log(1, '所有线程已停止')
except Exception as e:
util.log(1, f'停止线程时出错: {e}')
util.log(1, '资源清理完成')
except Exception as e:
util.log(1, f'清理资源时出错: {e}')
# 信号处理函数
def signal_handler(signum, frame):
"""处理终止信号"""
util.log(1, f'收到信号 {signum},正在退出程序...')
# 使用独立线程进行清理,避免信号处理器被阻塞
def cleanup_and_exit():
try:
cleanup_on_exit()
except Exception as e:
util.log(1, f'清理过程异常: {e}')
finally:
# 给其他线程一点时间完成清理
time.sleep(1.0)
# 强制退出,避免被非守护线程阻塞
util.log(1, '程序即将强制退出...')
os._exit(0) # 立即退出不调用atexit处理器
# 在单独线程中执行清理,避免阻塞信号处理器
cleanup_thread = threading.Thread(target=cleanup_and_exit, daemon=True)
cleanup_thread.start()
# 如果清理线程超过5秒还没完成强制退出
cleanup_thread.join(timeout=5.0)
if cleanup_thread.is_alive():
util.log(1, '清理超时,立即强制退出...')
os._exit(1)
# 注册退出处理和信号处理
atexit.register(cleanup_on_exit)
try:
signal.signal(signal.SIGINT, signal_handler) # Ctrl+C
signal.signal(signal.SIGTERM, signal_handler) # 终止信号
# Windows特有信号
if hasattr(signal, 'SIGBREAK'):
signal.signal(signal.SIGBREAK, signal_handler)
except Exception as e:
util.log(1, f'注册信号处理器失败: {e}')
#载入配置
config_util.load_config()
@@ -38,7 +118,11 @@ def __clear_logs():
os.mkdir("./logs")
for file_name in os.listdir('./logs'):
if file_name.endswith('.log'):
os.remove('./logs/' + file_name)
try:
os.remove('./logs/' + file_name)
except PermissionError:
print(f"Warning: Cannot delete {file_name} - file is in use by another process")
continue
def __create_memory():
if not os.path.exists("./memory"):

View File

@@ -0,0 +1,161 @@
# Fay日程管理MCP Server
基于MCP协议的智能日程管理系统深度集成Fay数字人助手提供自然语言日程管理和自动提醒功能。
## 核心功能
### 📅 日程管理
- **智能解析**:自然语言输入,如"明天上午提醒我开会"
- **灵活调度**:支持单次和周期性任务
- **状态管理**:活跃、完成、删除状态跟踪
- **多用户支持**基于UID的用户隔离
### 🌐 Web管理界面
- **自动启动**连接MCP时自动开启Web界面http://localhost:5011
- **可视化管理**:卡片式日程展示,支持增删改查
- **高级筛选**:按状态、日期、关键词筛选
- **响应式设计**支持PC和移动端访问
### 🤖 Fay集成
- **语音提醒**到时自动通过Fay播报提醒
- **智能交互**:支持语音和文字双向沟通
- **无缝集成**原生支持Fay的文字沟通接口
## 快速开始
### 1. 安装依赖
```bash
pip install mcp flask flask-cors
```
### 2. 配置Fay
在Fay的MCP管理界面添加
- **名称**: Fay日程管理
- **类型**: STDIO本地
- **命令**: python
- **参数**: server.py
- **工作目录**: mcp_servers/schedule_manager
### 3. 使用方式
#### 通过Fay语音交互
- "明天上午9点提醒我开会"
- "查看今天的日程"
- "取消下午3点的会议"
#### 通过Web界面管理
连接MCP后访问 http://localhost:5011
## MCP工具说明
### parse_natural_schedule
解析自然语言日程指令
**示例输入**
- "明天上午提醒我开会" → 标题:"开会",时间:"明天 09:00"
- "10分钟后提醒我休息" → 标题:"休息",时间:"当前时间+10分钟"
### add_schedule
添加日程任务
**参数**
- `title`: 日程标题
- `content`: 详细内容
- `schedule_time`: 执行时间YYYY-MM-DD HH:MM
- `repeat_rule`: 重复规则7位数字1=重复0=不重复)
- `uid`: 用户ID
### get_schedules
获取日程列表
**参数**
- `status`: 状态筛选active/completed/deleted
- `uid`: 用户ID筛选
### update_schedule
更新日程信息
### delete_schedule
删除日程(软删除)
### send_message_to_fay
直接发送消息给Fay
## 技术架构
```
┌─────────────┐ ┌──────────┐ ┌───────────┐
│ Fay主程序 │────▶│ MCP Server│────▶│ Web界面 │
└─────────────┘ └──────────┘ └───────────┘
│ │ │
└──────────────────┴─────────────────┘
┌──────────┐
│ SQLite │
└──────────┘
```
## 数据存储
使用SQLite数据库schedules.db存储日程
| 字段 | 类型 | 说明 |
|-----|------|-----|
| id | INTEGER | 主键 |
| title | TEXT | 标题 |
| content | TEXT | 内容 |
| schedule_time | TEXT | 执行时间 |
| repeat_rule | TEXT | 重复规则 |
| status | TEXT | 状态 |
| uid | INTEGER | 用户ID |
## 重复规则说明
7位数字字符串每位代表周一到周日
- `0000000`: 单次执行
- `1111100`: 工作日重复
- `1111111`: 每日重复
- `1000100`: 周一和周五重复
## 注意事项
1. **端口占用**Web界面使用5011端口确保未被占用
2. **时区设置**:使用系统本地时区
3. **进程管理**断开MCP连接时Web服务器自动关闭
4. **数据安全**:日程数据存储在本地,注意备份
## 故障排除
### Web界面无法访问
- 检查5011端口是否被占用
- 确认MCP服务器已连接
- 查看日志确认Web服务器已启动
### 提醒未触发
- 检查日程时间设置是否正确
- 确认Fay主程序正在运行
- 查看日程状态是否为active
### 自然语言解析错误
- 使用更明确的时间表述
- 避免过于复杂的句式
- 参考示例格式输入
## 更新日志
### v1.2.0 (2025-08-23)
- 新增自然语言解析功能
- 集成Web管理界面
- 优化进程生命周期管理
- 修复重复提醒问题
### v1.0.0
- 基础日程管理功能
- MCP协议支持
- Fay集成
## 许可证
MIT License

View File

@@ -0,0 +1,2 @@
mcp>=1.0.0
sqlite3

View File

@@ -0,0 +1,897 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fay日程管理</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft YaHei', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 32px;
margin-bottom: 10px;
}
.header p {
font-size: 16px;
opacity: 0.9;
}
.main-content {
display: flex;
min-height: 600px;
}
.sidebar {
width: 300px;
background: #f8f9fa;
padding: 30px;
border-right: 1px solid #eee;
}
.add-schedule-btn {
width: 100%;
padding: 15px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
margin-bottom: 30px;
transition: all 0.3s ease;
}
.add-schedule-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.filter-section {
margin-bottom: 20px;
}
.filter-title {
font-size: 18px;
margin-bottom: 15px;
color: #333;
}
.filter-group {
margin-bottom: 15px;
}
.filter-group label {
display: block;
margin-bottom: 5px;
color: #666;
font-size: 14px;
}
.filter-input {
width: 100%;
padding: 10px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s ease;
}
.filter-input:focus {
border-color: #667eea;
outline: none;
}
.content-area {
flex: 1;
padding: 30px;
}
.schedule-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.schedule-card {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-left: 4px solid #667eea;
transition: all 0.3s ease;
cursor: pointer;
}
.schedule-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.schedule-card.today {
border-left-color: #ff6b6b;
background: linear-gradient(135deg, rgba(255, 107, 107, 0.05) 0%, rgba(255, 107, 107, 0.02) 100%);
}
.schedule-card.completed {
opacity: 0.6;
border-left-color: #51cf66;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
}
.card-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.card-time {
font-size: 14px;
color: #667eea;
font-weight: 500;
}
.card-status {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.status-active {
background: rgba(102, 126, 234, 0.1);
color: #667eea;
}
.status-completed {
background: rgba(81, 207, 102, 0.1);
color: #51cf66;
}
.card-content {
font-size: 14px;
color: #666;
line-height: 1.6;
margin-bottom: 15px;
}
.card-repeat {
font-size: 12px;
color: #999;
margin-bottom: 15px;
}
.card-actions {
display: flex;
gap: 10px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
transition: all 0.3s ease;
}
.btn-edit {
background: #4ecdc4;
color: white;
}
.btn-delete {
background: #ff6b6b;
color: white;
}
.btn-complete {
background: #51cf66;
color: white;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.modal-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 30px;
border-radius: 12px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 15px;
border-bottom: 2px solid #f0f0f0;
}
.modal-title {
font-size: 24px;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
}
.form-input {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s ease;
}
.form-input:focus {
border-color: #667eea;
outline: none;
}
.form-textarea {
min-height: 100px;
resize: vertical;
}
.repeat-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
margin-top: 10px;
}
.day-btn {
padding: 8px;
border: 2px solid #e0e0e0;
background: white;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
font-size: 12px;
}
.day-btn.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 15px;
margin-top: 30px;
padding-top: 20px;
border-top: 2px solid #f0f0f0;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s ease;
}
.btn-secondary {
background: #f8f9fa;
color: #666;
padding: 12px 24px;
border: 2px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s ease;
}
.btn-primary:hover, .btn-secondary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-state h3 {
font-size: 24px;
margin-bottom: 10px;
}
.empty-state p {
font-size: 16px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.error {
text-align: center;
padding: 40px;
color: #ff6b6b;
}
/* Toast提示样式 */
.toast {
position: fixed;
top: 20px;
right: 20px;
padding: 15px 20px;
background: #51cf66;
color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 2000;
opacity: 0;
transform: translateX(100%);
transition: all 0.3s ease;
}
.toast.show {
opacity: 1;
transform: translateX(0);
}
.toast.error {
background: #ff6b6b;
}
.toast.warning {
background: #ffd43b;
color: #333;
}
@media (max-width: 768px) {
.main-content {
flex-direction: column;
}
.sidebar {
width: 100%;
padding: 20px;
}
.schedule-grid {
grid-template-columns: 1fr;
}
.modal-content {
width: 95%;
padding: 20px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📅 Fay日程管理</h1>
<p>智能助手的日程安排与提醒系统</p>
</div>
<div class="main-content">
<div class="sidebar">
<button class="add-schedule-btn" onclick="showAddModal()">
添加新日程
</button>
<div class="filter-section">
<div class="filter-title">筛选条件</div>
<div class="filter-group">
<label>状态</label>
<select class="filter-input" id="statusFilter" onchange="filterSchedules()">
<option value="all">全部</option>
<option value="active">活跃</option>
<option value="completed">已完成</option>
</select>
</div>
<div class="filter-group">
<label>搜索关键词</label>
<input type="text" class="filter-input" id="searchInput" placeholder="搜索标题或内容..." oninput="filterSchedules()">
</div>
<div class="filter-group">
<label>日期范围</label>
<input type="date" class="filter-input" id="dateFrom" onchange="filterSchedules()">
<input type="date" class="filter-input" id="dateTo" onchange="filterSchedules()" style="margin-top: 8px;">
</div>
</div>
</div>
<div class="content-area">
<div id="scheduleContainer" class="schedule-grid">
<div class="loading">正在加载日程...</div>
</div>
</div>
</div>
</div>
<!-- 添加/编辑日程模态框 -->
<div id="scheduleModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" id="modalTitle">添加新日程</h2>
<button class="close-btn" onclick="hideModal()">&times;</button>
</div>
<form id="scheduleForm">
<input type="hidden" id="scheduleId">
<div class="form-group">
<label class="form-label">日程标题 *</label>
<input type="text" id="scheduleTitle" class="form-input" placeholder="请输入日程标题" required>
</div>
<div class="form-group">
<label class="form-label">详细内容</label>
<textarea id="scheduleContent" class="form-input form-textarea" placeholder="请输入详细内容..."></textarea>
</div>
<div class="form-group">
<label class="form-label">执行时间 *</label>
<input type="datetime-local" id="scheduleTime" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">重复设置</label>
<div class="repeat-days">
<button type="button" class="day-btn" data-day="0" onclick="toggleDay(this)"></button>
<button type="button" class="day-btn" data-day="1" onclick="toggleDay(this)"></button>
<button type="button" class="day-btn" data-day="2" onclick="toggleDay(this)"></button>
<button type="button" class="day-btn" data-day="3" onclick="toggleDay(this)"></button>
<button type="button" class="day-btn" data-day="4" onclick="toggleDay(this)"></button>
<button type="button" class="day-btn" data-day="5" onclick="toggleDay(this)"></button>
<button type="button" class="day-btn" data-day="6" onclick="toggleDay(this)"></button>
</div>
<small style="color: #999; margin-top: 8px; display: block;">选择重复的日期,不选择表示只执行一次</small>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="hideModal()">取消</button>
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
</div>
</div>
<script>
let schedules = [];
let currentEditId = null;
// Toast提示函数
function showToast(message, type = 'success') {
// 移除已存在的toast
const existingToast = document.querySelector('.toast');
if (existingToast) {
existingToast.remove();
}
// 创建新的toast
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
document.body.appendChild(toast);
// 显示toast
setTimeout(() => toast.classList.add('show'), 10);
// 3秒后自动隐藏
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', function() {
loadSchedules();
// 设置表单提交事件
document.getElementById('scheduleForm').addEventListener('submit', function(e) {
e.preventDefault();
saveSchedule();
});
});
// 加载日程列表
async function loadSchedules() {
try {
const response = await fetch('/api/schedule/list');
if (!response.ok) {
throw new Error('获取日程列表失败');
}
const data = await response.json();
schedules = data.schedules || [];
renderSchedules();
} catch (error) {
console.error('加载日程失败:', error);
document.getElementById('scheduleContainer').innerHTML =
'<div class="error">加载日程失败,请刷新页面重试</div>';
}
}
// 渲染日程列表
function renderSchedules(filteredSchedules = null) {
const container = document.getElementById('scheduleContainer');
const schedulesToRender = filteredSchedules || schedules;
if (schedulesToRender.length === 0) {
container.innerHTML = `
<div class="empty-state">
<h3>暂无日程</h3>
<p>点击"添加新日程"开始创建您的第一个日程</p>
</div>
`;
return;
}
container.innerHTML = schedulesToRender.map(schedule => {
const scheduleDate = new Date(schedule.schedule_time);
const today = new Date();
const isToday = scheduleDate.toDateString() === today.toDateString();
const isCompleted = schedule.status === 'completed';
const repeatText = getRepeatText(schedule.repeat_rule);
return `
<div class="schedule-card ${isToday ? 'today' : ''} ${isCompleted ? 'completed' : ''}"
onclick="editSchedule(${schedule.id})">
<div class="card-header">
<div>
<div class="card-title">${schedule.title}</div>
<div class="card-time">${formatDateTime(schedule.schedule_time)}</div>
</div>
<div class="card-status status-${schedule.status}">
${schedule.status === 'active' ? '活跃' : '已完成'}
</div>
</div>
<div class="card-content">${schedule.content}</div>
<div class="card-repeat">${repeatText}</div>
<div class="card-actions" onclick="event.stopPropagation()">
<button class="btn btn-edit" onclick="editSchedule(${schedule.id})">编辑</button>
${schedule.status === 'active' && schedule.repeat_rule === '0000000' ?
`<button class="btn btn-complete" onclick="completeSchedule(${schedule.id})">完成</button>` : ''
}
<button class="btn btn-delete" onclick="deleteSchedule(${schedule.id})">删除</button>
</div>
</div>
`;
}).join('');
}
// 筛选日程
function filterSchedules() {
const statusFilter = document.getElementById('statusFilter').value;
const searchInput = document.getElementById('searchInput').value.toLowerCase();
const dateFrom = document.getElementById('dateFrom').value;
const dateTo = document.getElementById('dateTo').value;
let filtered = schedules.filter(schedule => {
// 状态筛选
if (statusFilter !== 'all' && schedule.status !== statusFilter) {
return false;
}
// 搜索筛选
if (searchInput &&
!schedule.title.toLowerCase().includes(searchInput) &&
!schedule.content.toLowerCase().includes(searchInput)) {
return false;
}
// 日期范围筛选
const scheduleDate = new Date(schedule.schedule_time);
if (dateFrom && scheduleDate < new Date(dateFrom)) {
return false;
}
if (dateTo && scheduleDate > new Date(dateTo + ' 23:59:59')) {
return false;
}
return true;
});
renderSchedules(filtered);
}
// 显示添加模态框
function showAddModal() {
document.getElementById('modalTitle').textContent = '添加新日程';
document.getElementById('scheduleForm').reset();
document.getElementById('scheduleId').value = '';
currentEditId = null;
// 清除重复日期选择
document.querySelectorAll('.day-btn').forEach(btn => {
btn.classList.remove('active');
});
document.getElementById('scheduleModal').style.display = 'block';
}
// 编辑日程
function editSchedule(id) {
const schedule = schedules.find(s => s.id === id);
if (!schedule) return;
document.getElementById('modalTitle').textContent = '编辑日程';
document.getElementById('scheduleId').value = schedule.id;
document.getElementById('scheduleTitle').value = schedule.title;
document.getElementById('scheduleContent').value = schedule.content;
// 格式化时间为datetime-local格式
const date = new Date(schedule.schedule_time);
const formattedDate = date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0') + 'T' +
String(date.getHours()).padStart(2, '0') + ':' +
String(date.getMinutes()).padStart(2, '0');
document.getElementById('scheduleTime').value = formattedDate;
// 设置重复日期
document.querySelectorAll('.day-btn').forEach((btn, index) => {
if (schedule.repeat_rule[index] === '1') {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
currentEditId = id;
document.getElementById('scheduleModal').style.display = 'block';
}
// 保存日程
async function saveSchedule() {
const id = document.getElementById('scheduleId').value;
const title = document.getElementById('scheduleTitle').value;
const content = document.getElementById('scheduleContent').value;
const scheduleTime = document.getElementById('scheduleTime').value;
// 获取重复规则
let repeatRule = '0000000';
document.querySelectorAll('.day-btn').forEach((btn, index) => {
if (btn.classList.contains('active')) {
repeatRule = repeatRule.substring(0, index) + '1' + repeatRule.substring(index + 1);
}
});
const scheduleData = {
title,
content,
schedule_time: scheduleTime.replace('T', ' '),
repeat_rule: repeatRule
};
try {
let response;
if (id) {
// 更新
response = await fetch(`/api/schedule/update/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(scheduleData)
});
} else {
// 创建
response = await fetch('/api/schedule/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(scheduleData)
});
}
if (!response.ok) {
throw new Error('保存日程失败');
}
hideModal();
loadSchedules();
showToast('日程保存成功');
} catch (error) {
console.error('保存日程失败:', error);
alert('保存日程失败,请重试');
}
}
// 完成日程(删除)
async function completeSchedule(id) {
if (!confirm('确定要完成此日程吗?完成后将自动删除。')) {
return;
}
try {
const response = await fetch(`/api/schedule/complete/${id}`, {
method: 'PUT'
});
if (!response.ok) {
throw new Error('完成日程失败');
}
loadSchedules();
showToast('日程已完成并删除');
} catch (error) {
console.error('完成日程失败:', error);
alert('操作失败,请重试');
}
}
// 删除日程
async function deleteSchedule(id) {
if (!confirm('确定要删除此日程吗?删除后无法恢复。')) {
return;
}
try {
const response = await fetch(`/api/schedule/delete/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`删除日程失败,状态码: ${response.status}`);
}
const data = await response.json();
if (data.success) {
// 如果删除的是当前正在编辑的日程,关闭模态框
const currentEditId = document.getElementById('scheduleId').value;
if (currentEditId && parseInt(currentEditId) === id) {
hideModal();
}
// 重新加载日程列表
loadSchedules();
// 显示成功提示
showToast('日程已成功删除');
} else {
throw new Error(data.message || '删除失败');
}
} catch (error) {
console.error('删除日程失败:', error);
alert('删除失败:' + error.message);
}
}
// 隐藏模态框
function hideModal() {
document.getElementById('scheduleModal').style.display = 'none';
}
// 切换重复日期
function toggleDay(btn) {
btn.classList.toggle('active');
}
// 格式化日期时间
function formatDateTime(dateTimeStr) {
const date = new Date(dateTimeStr);
return date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0') + ' ' +
String(date.getHours()).padStart(2, '0') + ':' +
String(date.getMinutes()).padStart(2, '0');
}
// 获取重复规则文本
function getRepeatText(repeatRule) {
if (repeatRule === '0000000') {
return '单次执行';
}
const days = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const repeatDays = [];
for (let i = 0; i < 7; i++) {
if (repeatRule[i] === '1') {
repeatDays.push(days[i]);
}
}
if (repeatDays.length === 7) {
return '每日重复';
} else if (repeatDays.length === 5 && !repeatRule.includes('1', 5)) {
return '工作日重复';
} else {
return '重复: ' + repeatDays.join(', ');
}
}
// 点击模态框外部关闭
window.onclick = function(event) {
const modal = document.getElementById('scheduleModal');
if (event.target === modal) {
hideModal();
}
}
</script>
</body>
</html>

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,386 @@
#!/usr/bin/env python3
"""
Fay日程管理Web服务器
提供独立的网页界面和API接口
"""
import os
import sys
import json
import sqlite3
import datetime
# 检查并安装依赖
try:
from flask import Flask, jsonify, request, send_from_directory
from flask_cors import CORS
except ImportError:
print("正在安装必要的依赖包...")
import subprocess
try:
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'flask', 'flask-cors'])
from flask import Flask, jsonify, request, send_from_directory
from flask_cors import CORS
print("依赖包安装完成")
except Exception as e:
print(f"安装依赖包失败: {e}")
print("请手动运行: pip install flask flask-cors")
sys.exit(1)
# 添加项目根目录到Python路径
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
sys.path.insert(0, project_root)
app = Flask(__name__)
CORS(app) # 允许跨域请求
# 数据库文件路径
DB_PATH = os.path.join(os.path.dirname(__file__), 'schedules.db')
class ScheduleWebAPI:
"""日程管理Web API类"""
def __init__(self):
self.init_database()
def init_database(self):
"""初始化数据库"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
schedule_time TEXT NOT NULL,
repeat_rule TEXT NOT NULL DEFAULT '0000000',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
uid INTEGER DEFAULT 0
)
''')
conn.commit()
conn.close()
def get_schedules(self, status='active', uid=None):
"""获取日程列表"""
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
if uid is not None:
if status == 'all':
# 'all' 表示显示所有未删除的日程active + completed
cursor.execute("SELECT * FROM schedules WHERE status != 'deleted' AND uid = ? ORDER BY schedule_time", (uid,))
else:
cursor.execute("SELECT * FROM schedules WHERE status = ? AND uid = ? ORDER BY schedule_time", (status, uid))
else:
if status == 'all':
# 'all' 表示显示所有未删除的日程active + completed
cursor.execute("SELECT * FROM schedules WHERE status != 'deleted' ORDER BY schedule_time")
else:
cursor.execute("SELECT * FROM schedules WHERE status = ? ORDER BY schedule_time", (status,))
rows = cursor.fetchall()
conn.close()
schedules = []
for row in rows:
schedules.append({
"id": row[0],
"title": row[1],
"content": row[2],
"schedule_time": row[3],
"repeat_rule": row[4],
"status": row[5],
"created_at": row[6],
"updated_at": row[7],
"uid": row[8]
})
return {"success": True, "schedules": schedules}
except Exception as e:
return {"success": False, "message": f"获取日程列表失败: {str(e)}"}
def add_schedule(self, title, content, schedule_time, repeat_rule='0000000', uid=0):
"""添加日程"""
try:
# 验证时间格式
try:
datetime.datetime.strptime(schedule_time, '%Y-%m-%d %H:%M')
except ValueError:
return {"success": False, "message": "时间格式错误,请使用 YYYY-MM-DD HH:MM 格式"}
# 验证重复规则
if not all(c in '01' for c in repeat_rule) or len(repeat_rule) != 7:
return {"success": False, "message": "重复规则格式错误"}
now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"INSERT INTO schedules (title, content, schedule_time, repeat_rule, created_at, updated_at, uid) VALUES (?, ?, ?, ?, ?, ?, ?)",
(title, content, schedule_time, repeat_rule, now, now, uid)
)
schedule_id = cursor.lastrowid
conn.commit()
conn.close()
return {"success": True, "message": "日程添加成功", "schedule_id": schedule_id}
except Exception as e:
return {"success": False, "message": f"添加日程失败: {str(e)}"}
def update_schedule(self, schedule_id, title=None, content=None, schedule_time=None, repeat_rule=None, status=None):
"""更新日程"""
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# 先获取当前日程信息
cursor.execute("SELECT status, repeat_rule FROM schedules WHERE id = ?", (schedule_id,))
current_schedule = cursor.fetchone()
if not current_schedule:
conn.close()
return {"success": False, "message": "日程不存在"}
current_status, current_repeat_rule = current_schedule
# 构建更新语句
updates = []
params = []
auto_activate = False # 是否需要自动激活
if title is not None:
updates.append("title = ?")
params.append(title)
if content is not None:
updates.append("content = ?")
params.append(content)
if schedule_time is not None:
# 验证时间格式
try:
new_time = datetime.datetime.strptime(schedule_time, '%Y-%m-%d %H:%M')
updates.append("schedule_time = ?")
params.append(schedule_time)
# 如果更新了时间并且当前状态是completed检查是否需要重新激活
if current_status == 'completed':
now = datetime.datetime.now()
# 如果新时间在未来,或者是重复任务,自动激活
if new_time > now or (repeat_rule or current_repeat_rule) != '0000000':
auto_activate = True
print(f"[Web] 日程 ID {schedule_id} 时间更新自动从completed恢复为active")
except ValueError:
return {"success": False, "message": "时间格式错误"}
if repeat_rule is not None:
if not all(c in '01' for c in repeat_rule) or len(repeat_rule) != 7:
return {"success": False, "message": "重复规则格式错误"}
updates.append("repeat_rule = ?")
params.append(repeat_rule)
# 如果从单次改为重复并且当前是completed自动激活
if current_status == 'completed' and repeat_rule != '0000000':
auto_activate = True
print(f"[Web] 日程 ID {schedule_id} 改为重复任务自动从completed恢复为active")
# 处理状态更新
if status is not None:
updates.append("status = ?")
params.append(status)
elif auto_activate:
# 如果需要自动激活并且没有明确指定状态则设置为active
updates.append("status = ?")
params.append('active')
print(f"[Web] 日程 ID {schedule_id} 状态设置为active")
if not updates:
return {"success": False, "message": "没有提供要更新的字段"}
updates.append("updated_at = ?")
params.append(datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
params.append(schedule_id)
cursor.execute(f"UPDATE schedules SET {', '.join(updates)} WHERE id = ?", params)
if cursor.rowcount == 0:
conn.close()
return {"success": False, "message": "日程不存在"}
conn.commit()
# 如果有更新时间或状态,通知调度器重新调度(通过创建触发文件)
if schedule_time is not None or repeat_rule is not None or status is not None or auto_activate:
# 创建一个触发文件,让调度器知道需要重新检查
trigger_file = os.path.join(os.path.dirname(__file__), f'.reschedule_{schedule_id}')
try:
with open(trigger_file, 'w') as f:
f.write(str(datetime.datetime.now()))
print(f"[Web] 创建重新调度触发文件: {trigger_file}")
except:
pass
conn.close()
if auto_activate:
return {"success": True, "message": "日程更新成功,状态已自动恢复为活跃"}
else:
return {"success": True, "message": "日程更新成功"}
except Exception as e:
return {"success": False, "message": f"更新日程失败: {str(e)}"}
def delete_schedule(self, schedule_id):
"""删除日程(软删除)"""
return self.update_schedule(schedule_id, status='deleted')
# 创建API实例
schedule_api = ScheduleWebAPI()
# Web路由
@app.route('/')
def index():
"""主页,返回日程管理网页"""
return send_from_directory(os.path.dirname(__file__), 'schedule_web.html')
@app.route('/api/schedule/list')
def api_get_schedules():
"""获取日程列表API"""
status = request.args.get('status', 'all')
uid = request.args.get('uid', type=int)
result = schedule_api.get_schedules(status, uid)
return jsonify(result)
@app.route('/api/schedule/create', methods=['POST'])
def api_create_schedule():
"""创建日程API"""
data = request.get_json()
if not data:
return jsonify({"success": False, "message": "无效的请求数据"})
title = data.get('title')
content = data.get('content', '')
schedule_time = data.get('schedule_time')
repeat_rule = data.get('repeat_rule', '0000000')
uid = data.get('uid', 0)
if not title or not schedule_time:
return jsonify({"success": False, "message": "标题和时间为必填项"})
result = schedule_api.add_schedule(title, content, schedule_time, repeat_rule, uid)
return jsonify(result)
@app.route('/api/schedule/update/<int:schedule_id>', methods=['PUT'])
def api_update_schedule(schedule_id):
"""更新日程API"""
data = request.get_json()
if not data:
return jsonify({"success": False, "message": "无效的请求数据"})
result = schedule_api.update_schedule(
schedule_id,
title=data.get('title'),
content=data.get('content'),
schedule_time=data.get('schedule_time'),
repeat_rule=data.get('repeat_rule'),
status=data.get('status')
)
return jsonify(result)
@app.route('/api/schedule/complete/<int:schedule_id>', methods=['PUT'])
def api_complete_schedule(schedule_id):
"""完成日程API - 直接删除"""
result = schedule_api.delete_schedule(schedule_id)
return jsonify(result)
@app.route('/api/schedule/delete/<int:schedule_id>', methods=['DELETE'])
def api_delete_schedule(schedule_id):
"""删除日程API"""
result = schedule_api.delete_schedule(schedule_id)
return jsonify(result)
@app.route('/api/health')
def api_health():
"""健康检查API"""
return jsonify({"status": "ok", "message": "Fay日程管理服务运行正常"})
if __name__ == '__main__':
import argparse
import signal
import atexit
import threading
import time
try:
import psutil
HAS_PSUTIL = True
except ImportError:
HAS_PSUTIL = False
print("警告: psutil未安装无法监控父进程")
# 获取父进程ID
parent_pid = os.getppid()
def cleanup():
print("Web服务器正在关闭...")
def signal_handler(sig, frame):
print(f"收到信号 {sig}正在关闭Web服务器...")
cleanup()
sys.exit(0)
def monitor_parent():
"""监控父进程,如果父进程不存在则退出"""
if not HAS_PSUTIL:
return
while True:
try:
# 检查父进程是否还存在
if not psutil.pid_exists(parent_pid):
print(f"父进程 {parent_pid} 已退出关闭Web服务器...")
cleanup()
os._exit(0) # 强制退出
time.sleep(5) # 每5秒检查一次
except Exception as e:
print(f"监控父进程时出错: {e}")
time.sleep(5)
# 注册清理和信号处理
atexit.register(cleanup)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# 启动父进程监控线程
if HAS_PSUTIL:
try:
monitor_thread = threading.Thread(target=monitor_parent, daemon=True)
monitor_thread.start()
print(f"已启动父进程监控 (父进程PID: {parent_pid})")
except Exception as e:
print(f"启动父进程监控失败: {e}")
else:
print("跳过父进程监控psutil不可用")
parser = argparse.ArgumentParser(description='Fay日程管理Web服务器')
parser.add_argument('--host', default='127.0.0.1', help='服务器地址')
parser.add_argument('--port', default=5011, type=int, help='服务器端口')
parser.add_argument('--debug', action='store_true', help='调试模式')
args = parser.parse_args()
print(f"启动Fay日程管理Web服务器...")
print(f"访问地址: http://{args.host}:{args.port}")
print(f"API文档: http://{args.host}:{args.port}/api/health")
try:
app.run(host=args.host, port=args.port, debug=args.debug, use_reloader=False)
except KeyboardInterrupt:
print("Web服务器已停止")
except Exception as e:
print(f"Web服务器启动失败: {e}")
sys.exit(1)

View File

@@ -38,6 +38,29 @@ def remove_thread(thread: MyThread):
def stopAll():
for thread in __thread_list:
thread.raise_exception()
thread.join()
"""停止所有MyThread线程"""
if not __thread_list:
return
# 先尝试正常停止
stopped_threads = []
for thread in __thread_list[:]: # 使用副本避免修改时出错
try:
if thread.is_alive():
thread.raise_exception()
stopped_threads.append(thread)
except Exception as e:
print(f"停止线程异常: {e}")
# 等待线程结束,但设置超时避免无限等待
import time
for thread in stopped_threads:
try:
thread.join(timeout=2.0) # 最多等待2秒
if thread.is_alive():
print(f"线程 {thread.name} 超时未结束")
except Exception as e:
print(f"等待线程结束异常: {e}")
# 清空线程列表
__thread_list.clear()

156
test/mcp_stdio_example.py Normal file
View File

@@ -0,0 +1,156 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
本文件是一个最小可用的 MCP STDIO本地服务器示例。
运行:
python test/mcp_stdio_example.py
在 UI 中添加本地 MCP 服务器时:
transport: stdio
command: python
args: ["test/mcp_stdio_example.py"]
cwd: 项目根目录 (可选)
提供的工具:
- ping() -> "pong"
- echo(text: str)
- upper(text: str)
- add(a: int, b: int)
- now(fmt: str = "%Y-%m-%d %H:%M:%S") -> 当前时间格式化
注意: 返回内容遵循 MCP 协议,使用 TextContent 包装文本。
"""
import asyncio
import os
import sys
from datetime import datetime
try:
# 核心 MCP 服务器 API低层
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types
except Exception as e:
print("[mcp_stdio_example] 请先安装 mcp 包: pip install mcp", file=sys.stderr)
raise
server = Server("Fay STDIO Example")
# --- 定义工具清单(低层 API 需要手动注册 list_tools 和 call_tool---
TOOLS: list[types.Tool] = [
types.Tool(
name="ping",
description="连通性检查:若提供 host 则执行系统 ping否则返回 pong",
inputSchema={
"type": "object",
"properties": {"host": {"type": "string"}},
"required": []
}
),
types.Tool(
name="echo",
description="返回相同文本",
inputSchema={
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"]
}
),
types.Tool(
name="upper",
description="将文本转为大写",
inputSchema={
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"]
}
),
types.Tool(
name="add",
description="两个整数求和",
inputSchema={
"type": "object",
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
"required": ["a", "b"]
}
),
types.Tool(
name="now",
description="返回当前时间,默认格式为 %Y-%m-%d %H:%M:%S可通过 fmt 指定",
inputSchema={
"type": "object",
"properties": {"fmt": {"type": "string"}},
"required": []
}
),
]
@server.list_tools()
async def list_tools() -> list[types.Tool]:
return TOOLS
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
try:
match name:
case "ping":
host = (arguments or {}).get("host")
if host:
# 在不同平台调用系统 ping避免 Windows 下 shlex.quote 与 shell=True 的兼容问题
import platform, subprocess
is_win = platform.system().lower().startswith("win")
cmd = ["ping", ("-n" if is_win else "-c"), "2", str(host)]
try:
out = subprocess.run(cmd, shell=False, capture_output=True, text=True, timeout=8)
ok = (out.returncode == 0)
stdout = out.stdout or ""
# 截取最后几行摘要
lines = [line for line in stdout.strip().splitlines() if line.strip()]
summary = lines[-6:] if lines else []
text = ("SUCCESS\n" if ok else "FAIL\n") + "\n".join(summary)
except Exception as e:
text = f"执行 ping 出错: {type(e).__name__}: {e}"
return [types.TextContent(type="text", text=text)]
else:
return [types.TextContent(type="text", text="pong")]
case "echo":
return [types.TextContent(type="text", text=str(arguments.get("text", "")))]
case "upper":
return [types.TextContent(type="text", text=str(arguments.get("text", "")).upper())]
case "add":
a = int(arguments.get("a", 0))
b = int(arguments.get("b", 0))
return [types.TextContent(type="text", text=str(a + b))]
case "now":
fmt = arguments.get("fmt") or "%Y-%m-%d %H:%M:%S"
try:
txt = datetime.now().strftime(fmt)
except Exception:
txt = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return [types.TextContent(type="text", text=txt)]
case _:
return [types.TextContent(type="text", text=f"未知工具: {name}")]
except Exception as e:
# 返回错误信息isError将由框架根据异常处理
return [types.TextContent(type="text", text=f"调用异常: {e}")]
async def main() -> None:
# 通过 STDIO 暴露 MCP Server
async with stdio_server() as (read_stream, write_stream):
# 显式开启 tools 能力,避免部分版本下未注册导致 list_tools/call_tool 问题
init_opts = server.create_initialization_options()
await server.run(read_stream, write_stream, init_opts)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass

View File

@@ -10,7 +10,7 @@ def test_gpt(prompt):
data = {
'model': 'fay-streaming',
'messages': [
{'role': 'user_device_32_6', 'content': prompt}
{'role': 'User', 'content': prompt}
],
'stream': True # 启用流式传输
}