From 1cfba1aa529d32261450c03683bf179c37e50b78 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sat, 5 Jul 2025 19:46:04 +0800 Subject: [PATCH] =?UTF-8?q?update:=20=E5=8D=95=E6=A8=A1=E5=9D=97=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E4=B9=9F=E8=83=BD=E4=BD=BF=E7=94=A8mcp=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E7=82=B9=20(#1763)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 优化:OTA接口需要适应最小示例参数 #1757 * update:修复智能体选择的模型不存在导致的500错误 #1752 * update: 单模块部署也能使用mcp接入点 --- docs/mcp-endpoint-enable.md | 125 ++++++++++++++++++ docs/mcp-endpoint-integration.md | 124 ++++++----------- .../service/impl/ConfigServiceImpl.java | 36 ++--- .../device/controller/OTAController.java | 9 +- .../sys/controller/SysParamsController.java | 2 +- .../src/components/FunctionDialog.vue | 4 +- main/xiaozhi-server/app.py | 13 +- main/xiaozhi-server/config.yaml | 3 +- main/xiaozhi-server/core/connection.py | 98 ++++++++++++-- main/xiaozhi-server/core/utils/util.py | 25 ++++ main/xiaozhi-server/core/websocket_server.py | 17 +++ 11 files changed, 339 insertions(+), 117 deletions(-) create mode 100644 docs/mcp-endpoint-enable.md diff --git a/docs/mcp-endpoint-enable.md b/docs/mcp-endpoint-enable.md new file mode 100644 index 00000000..7bc72484 --- /dev/null +++ b/docs/mcp-endpoint-enable.md @@ -0,0 +1,125 @@ +# MCP 接入点部署使用指南 + +本教程包含2个部分 +- 1、如何部署MCP接入点这个服务 +- 1、全模块部署时,怎么配置MCP接入点 +- 2、单模块部署时,怎么配置MCP接入点 + +# 1、如何部署MCP接入点这个服务 + +## 第一步,下载mcp接入点项目源码 + +浏览器打开[mcp接入点项目地址](https://github.com/xinnan-tech/mcp-endpoint-server) + +打开完,找到页面中一个绿色的按钮,写着`Code`的按钮,点开它,然后你就看到`Download ZIP`的按钮。 + +点击它,下载本项目源码压缩包。下载到你电脑后,解压它,此时它的名字可能叫`mcp-endpoint-server-main` +你需要把它重命名成`mcp-endpoint-server`。 + +## 第二步,启动程序 +这个项目是一个很简单的项目,建议使用docker运行。不过如果你不想使用docker运行,你可以参考[这个页面](https://github.com/xinnan-tech/mcp-endpoint-server/blob/main/README_dev.md)使用源码运行。以下是docker运行的方法 + +``` +# 进入本项目源码根目录 +cd mcp-endpoint-server + +# 清除缓存 +docker compose -f docker-compose.yml down +docker stop mcp-endpoint-server +docker rm mcp-endpoint-server +docker rmi ghcr.nju.edu.cn/xinnan-tech/mcp-endpoint-server:latest + +# 启动docker容器 +docker compose -f docker-compose.yml up -d +# 查看日志 +docker logs -f mcp-endpoint-server +``` + +此时,日志里会输出类似以下的日志 +``` +250705 INFO-=====下面的地址分别是智控台/单模块MCP接入点地址==== +250705 INFO-智控台MCP参数配置: http://172.22.0.2:8004/mcp_endpoint/health?key=abc +250705 INFO-单模块部署MCP接入点: ws://172.22.0.2:8004/mcp_endpoint/mcp/?token=def +250705 INFO-=====请根据具体部署选择使用,请勿泄露给任何人====== +``` + +请你把两个接口地址复制出来: + +由于你是docker部署,切不可直接使用上面的地址! + +由于你是docker部署,切不可直接使用上面的地址! + +由于你是docker部署,切不可直接使用上面的地址! + +你先把地址复制出来,放在一个草稿里,你要知道你的电脑的局域网ip是什么,例如我的电脑局域网ip是`192.168.1.25`,那么 +原来我的接口地址 +``` +智控台MCP参数配置: http://172.22.0.2:8004/mcp_endpoint/health?key=abc +单模块部署MCP接入点: ws://172.22.0.2:8004/mcp_endpoint/mcp/?token=def +``` +就要改成 +``` +智控台MCP参数配置: http://192.168.1.25:8004/mcp_endpoint/health?key=abc +单模块部署MCP接入点: ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=def +``` + +改好后,请使用浏览器直接访问`智控台MCP参数配置`。当浏览器出现类似这样的代码,说明是成功了。 +``` +{"result":{"status":"success","connections":{"tool_connections":0,"robot_connections":0,"total_connections":0}},"error":null,"id":null,"jsonrpc":"2.0"} +``` + +请你保留好上面两个`接口地址`,下一步要用到。 + +# 2、全模块部署时,怎么配置MCP接入点 + +如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。 + +然后搜索参数`server.mcp_endpoint`,此时,它的值应该是`null`值。 +点击修改按钮,把上一步得来的`智控台MCP参数配置`粘贴到`参数值`里。然后保存。 + +如果能保存成功,说明一切顺利,你可以去智能体查看效果了。如果不成功,说明智控台无法访问mcp接入点,很大概率是网络防火墙,或者没有填写正确的局域网ip。 + +# 3、单模块部署时,怎么配置MCP接入点 + +如果你是单模块部署,找到你的配置文件`data/.config.yaml`。 +在配置文件搜索`mcp_endpoint`,如果没有找到,你就增加`mcp_endpoint`配置。类似我是就是这样 +``` +server: + websocket: ws://你的ip或者域名:端口号/xiaozhi/v1/ + http_port: 8002 +log: + log_level: INFO + +# 此处可能还更多配置.. + +mcp_endpoint: 你的接入点websocket地址 +``` +这时,请你把`如何部署MCP接入点这个服务`中得到的`单模块部署MCP接入点` 粘贴到 `mcp_endpoint`中。类似这样 + +``` +server: + websocket: ws://你的ip或者域名:端口号/xiaozhi/v1/ + http_port: 8002 +log: + log_level: INFO + +# 此处可能还更多配置 + +mcp_endpoint: ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=def +``` + +配置好后,启动单模块会输出如下的日志。 +``` +250705[__main__]-INFO-初始化组件: vad成功 SileroVAD +250705[__main__]-INFO-初始化组件: asr成功 FunASRServer +250705[__main__]-INFO-OTA接口是 http://192.168.1.25:8002/xiaozhi/ota/ +250705[__main__]-INFO-视觉分析接口是 http://192.168.1.25:8002/mcp/vision/explain +250705[__main__]-INFO-mcp接入点是 ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc +250705[__main__]-INFO-Websocket地址是 ws://192.168.1.25:8000/xiaozhi/v1/ +250705[__main__]-INFO-=======上面的地址是websocket协议地址,请勿用浏览器访问======= +250705[__main__]-INFO-如想测试websocket请用谷歌浏览器打开test目录下的test_page.html +250705[__main__]-INFO-============================================================= +``` + +如上,如果能输出类似的`mcp接入点是`中`ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc`说明配置成功了。 + diff --git a/docs/mcp-endpoint-integration.md b/docs/mcp-endpoint-integration.md index ca72aef1..91d62278 100644 --- a/docs/mcp-endpoint-integration.md +++ b/docs/mcp-endpoint-integration.md @@ -1,92 +1,36 @@ -# MCP 接入点部署使用指南 +# MCP 接入点使用指南 -本教程包含2个部分 -- 1、如何开启mcp接入点 -- 2、如何为智能体接入一个简单的mcp功能,如计算器功能 +本教程以虾哥开源的mcp计算器功能为示例,介绍如何将自己自定义的mcp服务接入到自己的接入点里。 -部署的前提条件: -- 1、你已经部署了全模块,因为mcp接入点需要全模块中的智控台功能 -- 2、你想在不修改xiaozhi-server项目的前提下,扩展小智的功能 - -# 如何开启mcp接入点 - -## 第一步,下载mcp接入点项目源码 - -浏览器打开[mcp接入点项目地址](https://github.com/xinnan-tech/mcp-endpoint-server) - -打开完,找到页面中一个绿色的按钮,写着`Code`的按钮,点开它,然后你就看到`Download ZIP`的按钮。 - -点击它,下载本项目源码压缩包。下载到你电脑后,解压它,此时它的名字可能叫`mcp-endpoint-server-main` -你需要把它重命名成`mcp-endpoint-server`。 - -## 第二步,启动程序 -这个项目是一个很简单的项目,建议使用docker运行。不过如果你不想使用docker运行,你可以参考[这个页面](https://github.com/xinnan-tech/mcp-endpoint-server/blob/main/README_dev.md)使用源码运行。以下是docker运行的方法 - -``` -# 进入本项目源码根目录 -cd mcp-endpoint-server - -# 清除缓存 -docker compose -f docker-compose.yml down -docker stop mcp-endpoint-server -docker rm mcp-endpoint-server -docker rmi ghcr.nju.edu.cn/xinnan-tech/mcp-endpoint-server:latest - -# 启动docker容器 -docker compose -f docker-compose.yml up -d -# 查看日志 -docker logs -f mcp-endpoint-server -``` - -此时,日志里会输出类似以下的日志 -``` -====================================================== -接口地址: http://172.1.1.1:8004/mcp_endpoint/health?key=xxxx -=======上面的地址是MCP接入点地址,请勿泄露给任何人============ -``` - -请你把接口地址复制出来: - -由于你是docker部署,切不可直接使用上面的地址! - -由于你是docker部署,切不可直接使用上面的地址! - -由于你是docker部署,切不可直接使用上面的地址! - -你先把地址复制出来,放在一个草稿里,你要知道你的电脑的局域网ip是什么,例如我的电脑局域网ip是`192.168.1.25`,那么 -原来我的接口地址 -``` -http://172.1.1.1:8004/mcp_endpoint/health?key=xxxx -``` -就要改成 -``` -http://192.168.1.25:8004/mcp_endpoint/health?key=xxxx -``` - -改好后,请使用浏览器直接访问这个接口。当浏览器出现类似这样的代码,说明是成功了。 -``` -{"result":{"status":"success","connections":{"tool_connections":0,"robot_connections":0,"total_connections":0}},"error":null,"id":null,"jsonrpc":"2.0"} -``` - -请你保留好这个`接口地址`,下一步要用到。 - -## 第三步,配置智控台 - -使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。 - -然后搜索参数`server.mcp_endpoint`,此时,它的值应该是`null`值。 -点击修改按钮,把上一步得来的`接口地址`粘贴到`参数值`里。然后保存。 - -如果能保存成功,说明一切顺利,你可以去智能体查看效果了。如果不成功,说明智控台无法访问mcp接入点,很大概率是网络防火墙,或者没有填写正确的局域网ip。 +本教程的前提是,你的`xiaozhi-server`已经启用了mcp接入点功能,如果你还没启用,可以先根据[这个教程](./mcp-endpoint-enable.md)启用。 # 如何为智能体接入一个简单的mcp功能,如计算器功能 -如果以上步骤顺利,你可以进入智能体管理,点击`配置角色`,在`意图识别`的右边,有一个`编辑功能`的按钮。 +### 如果你是全模块部署 +如果你是全模块部署,你可以进入智控台,智能体管理,点击`配置角色`,在`意图识别`的右边,有一个`编辑功能`的按钮。 点击这个按钮。在弹出的页面里,位于底部,会有`MCP接入点`,正常来说,会显示这个智能体的`MCP接入点地址`,接下来,我们来给这个智能体扩展一个基于MCP技术的计算器的功能。 这个`MCP接入点地址`很重要,你等一下会用到。 +### 如果你是单模块部署 +如果你是单模块部署,且你已经在配置文件里配置了MCP接入点地址,那么正常来说,单模块部署启动的时候,会输出如下的日志。 +``` +250705[__main__]-INFO-初始化组件: vad成功 SileroVAD +250705[__main__]-INFO-初始化组件: asr成功 FunASRServer +250705[__main__]-INFO-OTA接口是 http://192.168.1.25:8002/xiaozhi/ota/ +250705[__main__]-INFO-视觉分析接口是 http://192.168.1.25:8002/mcp/vision/explain +250705[__main__]-INFO-mcp接入点是 ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc +250705[__main__]-INFO-Websocket地址是 ws://192.168.1.25:8000/xiaozhi/v1/ +250705[__main__]-INFO-=======上面的地址是websocket协议地址,请勿用浏览器访问======= +250705[__main__]-INFO-如想测试websocket请用谷歌浏览器打开test目录下的test_page.html +250705[__main__]-INFO-============================================================= +``` + +如上,输出`mcp接入点是`中`ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc`就是你的`MCP接入点地址`。 + +这个`MCP接入点地址`很重要,你等一下会用到。 + ## 第一步 下载虾哥MCP计算器项目代码 浏览器打开虾哥写的[计算器项目](https://github.com/78/mcp-calculator), @@ -114,13 +58,13 @@ pip install -r requirements.txt 例如我的智能体的mcp地址是 ``` -ws://192.168.4.7:8004/mcp_endpoint/mcp/?token=abc +ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc ``` 开始输入命令 ```bash -export MCP_ENDPOINT=ws://192.168.4.7:8004/mcp_endpoint/mcp/?token=abc +export MCP_ENDPOINT=ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc ``` 输入完后,启动程序 @@ -129,6 +73,22 @@ export MCP_ENDPOINT=ws://192.168.4.7:8004/mcp_endpoint/mcp/?token=abc python mcp_pipe.py calculator.py ``` +### 如果你是智控台部署 +如果你是智控台部署,启动完后,你再进入智控台,点击刷新MCP的接入状态,就会看到你扩展的功能列表了。 -启动完后,你再进入智控台,点击刷新MCP的接入状态,就会看到你扩展的功能列表了。 +### 如果你是单模块部署 +如果你是单模块部署,当设备连接后,会输出类似的日志,说明成功了 +``` +250705 -INFO-正在初始化MCP接入点: wss://2662r3426b.vicp.fun/mcp_e +250705 -INFO-发送MCP接入点初始化消息 +250705 -INFO-MCP接入点连接成功 +250705 -INFO-MCP接入点初始化成功 +250705 -INFO-统一工具处理器初始化完成 +250705 -INFO-MCP接入点服务器信息: name=Calculator, version=1.9.4 +250705 -INFO-MCP接入点支持的工具数量: 1 +250705 -INFO-所有MCP接入点工具已获取,客户端准备就绪 +250705 -INFO-工具缓存已刷新 +250705 -INFO-当前支持的函数列表: [ 'get_time', 'get_lunar', 'play_music', 'get_weather', 'handle_exit_intent', 'calculator'] +``` +如果包含了 `'calculator'`,说明设备将可以根据意图识别,调用计算器这个工具。 \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java index 294dbbe2..9c591c35 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java @@ -258,17 +258,17 @@ public class ConfigServiceImpl implements ConfigService { /** * 构建模块配置 * - * @param prompt 提示词 - * @param voice 音色 - * @param referenceAudio 参考音频路径 - * @param referenceText 参考文本 - * @param vadModelId VAD模型ID - * @param asrModelId ASR模型ID - * @param llmModelId LLM模型ID - * @param ttsModelId TTS模型ID - * @param memModelId 记忆模型ID - * @param intentModelId 意图模型ID - * @param result 结果Map + * @param prompt 提示词 + * @param voice 音色 + * @param referenceAudio 参考音频路径 + * @param referenceText 参考文本 + * @param vadModelId VAD模型ID + * @param asrModelId ASR模型ID + * @param llmModelId LLM模型ID + * @param ttsModelId TTS模型ID + * @param memModelId 记忆模型ID + * @param intentModelId 意图模型ID + * @param result 结果Map */ private void buildModuleConfig( String assistantName, @@ -298,14 +298,20 @@ public class ConfigServiceImpl implements ConfigService { continue; } ModelConfigEntity model = modelConfigService.getModelById(modelIds[i], isCache); + if (model == null) { + continue; + } Map typeConfig = new HashMap<>(); if (model.getConfigJson() != null) { typeConfig.put(model.getId(), model.getConfigJson()); // 如果是TTS类型,添加private_voice属性 - if ("TTS".equals(modelTypes[i])){ - if (voice != null) ((Map) model.getConfigJson()).put("private_voice", voice); - if (referenceAudio != null) ((Map) model.getConfigJson()).put("ref_audio", referenceAudio); - if (referenceText != null) ((Map) model.getConfigJson()).put("ref_text", referenceText); + if ("TTS".equals(modelTypes[i])) { + if (voice != null) + ((Map) model.getConfigJson()).put("private_voice", voice); + if (referenceAudio != null) + ((Map) model.getConfigJson()).put("ref_audio", referenceAudio); + if (referenceText != null) + ((Map) model.getConfigJson()).put("ref_text", referenceText); } // 如果是Intent类型,且type=intent_llm,则给他添加附加模型 if ("Intent".equals(modelTypes[i])) { diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java index 05d36e9a..023510cd 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java @@ -51,13 +51,12 @@ public class OTAController { if (StringUtils.isBlank(clientId)) { clientId = deviceId; } - String macAddress = deviceReportReqDTO.getMacAddress(); - boolean macAddressValid = isMacAddressValid(macAddress); + boolean macAddressValid = isMacAddressValid(deviceId); // 设备Id和Mac地址应是一致的, 并且必须需要application字段 - if (!deviceId.equals(macAddress) || !macAddressValid || deviceReportReqDTO.getApplication() == null) { - return createResponse(DeviceReportRespDTO.createError("Invalid OTA request")); + if (!macAddressValid) { + return createResponse(DeviceReportRespDTO.createError("Invalid device ID")); } - return createResponse(deviceService.checkDeviceActive(macAddress, clientId, deviceReportReqDTO)); + return createResponse(deviceService.checkDeviceActive(deviceId, clientId, deviceReportReqDTO)); } @Operation(summary = "设备快速检查激活状态") diff --git a/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java index edcc9328..9560e1a6 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java @@ -218,7 +218,7 @@ public class SysParamsController { if (response.getStatusCode() != HttpStatus.OK) { throw new RenException("MCP接口访问失败,状态码:" + response.getStatusCode()); } - // 检查响应内容是否包含OTA相关信息 + // 检查响应内容是否包含mcp相关信息 String body = response.getBody(); if (body == null || !body.contains("success")) { throw new RenException("MCP接口返回内容格式不正确,可能不是一个真实的MCP接口"); diff --git a/main/manager-web/src/components/FunctionDialog.vue b/main/manager-web/src/components/FunctionDialog.vue index 3bf148b9..76c69c8e 100644 --- a/main/manager-web/src/components/FunctionDialog.vue +++ b/main/manager-web/src/components/FunctionDialog.vue @@ -110,8 +110,10 @@
以下是智能体的MCP接入点地址。 + 如何部署MCP接入点   |   查看接入点使用文档 + target="_blank" class="doc-link">如何接入MCP功能  
diff --git a/main/xiaozhi-server/app.py b/main/xiaozhi-server/app.py index 2088e183..0edb475b 100644 --- a/main/xiaozhi-server/app.py +++ b/main/xiaozhi-server/app.py @@ -5,7 +5,7 @@ import asyncio from aioconsole import ainput from config.settings import load_config from config.logger import setup_logging -from core.utils.util import get_local_ip +from core.utils.util import get_local_ip, validate_mcp_endpoint from core.http_server import SimpleHttpServer from core.websocket_server import WebSocketServer from core.utils.util import check_ffmpeg_installed @@ -77,6 +77,17 @@ async def main(): get_local_ip(), port, ) + mcp_endpoint = config.get("mcp_endpoint", None) + if mcp_endpoint is not None and "你" not in mcp_endpoint: + # 校验MCP接入点格式 + if validate_mcp_endpoint(mcp_endpoint): + logger.bind(tag=TAG).info("mcp接入点是\t{}", mcp_endpoint) + # 将mcp计入点地址转成调用点 + mcp_endpoint = mcp_endpoint.replace("/mcp/", "/call/") + config["mcp_endpoint"] = mcp_endpoint + else: + logger.bind(tag=TAG).error("mcp接入点不符合规范") + config["mcp_endpoint"] = "你的接入点 websocket地址" # 获取WebSocket配置,使用安全的默认值 websocket_port = 8000 diff --git a/main/xiaozhi-server/config.yaml b/main/xiaozhi-server/config.yaml index 5a43c995..41ac96b7 100644 --- a/main/xiaozhi-server/config.yaml +++ b/main/xiaozhi-server/config.yaml @@ -104,7 +104,8 @@ wakeup_words: - "喵喵同学" - "小滨小滨" - "小冰小冰" -# MCP接入点地址 +# MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token +# 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md mcp_endpoint: 你的接入点 websocket地址 # 插件的基础配置 plugins: diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index 9d1949c4..892e5046 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -214,7 +214,17 @@ class ConnectionHandler: self.logger.bind(tag=TAG).error(f"Connection error: {str(e)}-{stack_trace}") return finally: - await self._save_and_close(ws) + try: + await self._save_and_close(ws) + except Exception as final_error: + self.logger.bind(tag=TAG).error(f"最终清理时出错: {final_error}") + # 确保即使保存记忆失败,也要关闭连接 + try: + await self.close(ws) + except Exception as close_error: + self.logger.bind(tag=TAG).error( + f"强制关闭连接时出错: {close_error}" + ) async def _save_and_close(self, ws): """保存记忆并关闭连接""" @@ -232,7 +242,10 @@ class ConnectionHandler: except Exception as e: self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}") finally: - loop.close() + try: + loop.close() + except Exception: + pass # 启动线程保存记忆,不等待完成 threading.Thread(target=save_memory_task, daemon=True).start() @@ -240,7 +253,12 @@ class ConnectionHandler: self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}") finally: # 立即关闭连接,不等待记忆保存完成 - await self.close(ws) + try: + await self.close(ws) + except Exception as close_error: + self.logger.bind(tag=TAG).error( + f"保存记忆后关闭连接失败: {close_error}" + ) async def _route_message(self, message): """消息路由""" @@ -837,13 +855,22 @@ class ConnectionHandler: """资源清理方法""" try: # 取消超时任务 - if self.timeout_task: + if self.timeout_task and not self.timeout_task.done(): self.timeout_task.cancel() + try: + await self.timeout_task + except asyncio.CancelledError: + pass self.timeout_task = None # 清理工具处理器资源 if hasattr(self, "func_handler") and self.func_handler: - await self.func_handler.cleanup() + try: + await self.func_handler.cleanup() + except Exception as cleanup_error: + self.logger.bind(tag=TAG).error( + f"清理工具处理器时出错: {cleanup_error}" + ) # 触发停止事件 if self.stop_event: @@ -853,19 +880,58 @@ class ConnectionHandler: self.clear_queues() # 关闭WebSocket连接 - if ws: - await ws.close() - elif self.websocket: - await self.websocket.close() + try: + if ws: + # 安全地检查WebSocket状态并关闭 + try: + if hasattr(ws, "closed") and not ws.closed: + await ws.close() + elif hasattr(ws, "state") and ws.state.name != "CLOSED": + await ws.close() + else: + # 如果没有closed属性,直接尝试关闭 + await ws.close() + except Exception: + # 如果关闭失败,忽略错误 + pass + elif self.websocket: + try: + if ( + hasattr(self.websocket, "closed") + and not self.websocket.closed + ): + await self.websocket.close() + elif ( + hasattr(self.websocket, "state") + and self.websocket.state.name != "CLOSED" + ): + await self.websocket.close() + else: + # 如果没有closed属性,直接尝试关闭 + await self.websocket.close() + except Exception: + # 如果关闭失败,忽略错误 + pass + except Exception as ws_error: + self.logger.bind(tag=TAG).error(f"关闭WebSocket连接时出错: {ws_error}") # 最后关闭线程池(避免阻塞) if self.executor: - self.executor.shutdown(wait=False) + try: + self.executor.shutdown(wait=False) + except Exception as executor_error: + self.logger.bind(tag=TAG).error( + f"关闭线程池时出错: {executor_error}" + ) self.executor = None self.logger.bind(tag=TAG).info("连接资源已释放") except Exception as e: self.logger.bind(tag=TAG).error(f"关闭连接时出错: {e}") + finally: + # 确保停止事件被设置 + if self.stop_event: + self.stop_event.set() def clear_queues(self): """清空所有任务队列""" @@ -922,9 +988,19 @@ class ConnectionHandler: ): if not self.stop_event.is_set(): self.logger.bind(tag=TAG).info("连接超时,准备关闭") - await self.close(self.websocket) + # 设置停止事件,防止重复处理 + self.stop_event.set() + # 使用 try-except 包装关闭操作,确保不会因为异常而阻塞 + try: + await self.close(self.websocket) + except Exception as close_error: + self.logger.bind(tag=TAG).error( + f"超时关闭连接时出错: {close_error}" + ) break # 每10秒检查一次,避免过于频繁 await asyncio.sleep(10) except Exception as e: self.logger.bind(tag=TAG).error(f"超时检查任务出错: {e}") + finally: + self.logger.bind(tag=TAG).info("超时检查任务已退出") diff --git a/main/xiaozhi-server/core/utils/util.py b/main/xiaozhi-server/core/utils/util.py index 35417abc..dd12392a 100644 --- a/main/xiaozhi-server/core/utils/util.py +++ b/main/xiaozhi-server/core/utils/util.py @@ -982,3 +982,28 @@ def sanitize_tool_name(name: str) -> str: """Sanitize tool names for OpenAI compatibility.""" # 支持中文、英文字母、数字、下划线和连字符 return re.sub(r"[^a-zA-Z0-9_\-\u4e00-\u9fff]", "_", name) + + +def validate_mcp_endpoint(mcp_endpoint: str) -> bool: + """ + 校验MCP接入点格式 + + Args: + mcp_endpoint: MCP接入点字符串 + + Returns: + bool: 是否有效 + """ + # 1. 检查是否以ws开头 + if not mcp_endpoint.startswith("ws"): + return False + + # 2. 检查是否包含key、call字样 + if "key" in mcp_endpoint.lower() or "call" in mcp_endpoint.lower(): + return False + + # 3. 检查是否包含/mcp/字样 + if "/mcp/" not in mcp_endpoint: + return False + + return True diff --git a/main/xiaozhi-server/core/websocket_server.py b/main/xiaozhi-server/core/websocket_server.py index 1a1ba4c0..09ba7961 100644 --- a/main/xiaozhi-server/core/websocket_server.py +++ b/main/xiaozhi-server/core/websocket_server.py @@ -57,8 +57,25 @@ class WebSocketServer: self.active_connections.add(handler) try: await handler.handle_connection(websocket) + except Exception as e: + self.logger.bind(tag=TAG).error(f"处理连接时出错: {e}") finally: + # 确保从活动连接集合中移除 self.active_connections.discard(handler) + # 强制关闭连接(如果还没有关闭的话) + try: + # 安全地检查WebSocket状态并关闭 + if hasattr(websocket, "closed") and not websocket.closed: + await websocket.close() + elif hasattr(websocket, "state") and websocket.state.name != "CLOSED": + await websocket.close() + else: + # 如果没有closed属性,直接尝试关闭 + await websocket.close() + except Exception as close_error: + self.logger.bind(tag=TAG).error( + f"服务器端强制关闭连接时出错: {close_error}" + ) async def _http_response(self, websocket, request_headers): # 检查是否为 WebSocket 升级请求