feat: 原生MCP工具 + xiaozhi CLI客户端测试工具
parent
6c483b8ff2
commit
9b79ccd0a3
|
|
@ -0,0 +1,174 @@
|
||||||
|
"""
|
||||||
|
儿童心理陪伴 MCP 工具(原生 mcp 协议版)
|
||||||
|
使用官方 mcp Python SDK,不依赖 fastmcp
|
||||||
|
|
||||||
|
适用于直接集成到 xiaozhi-esp32-server 的 Docker 容器环境中。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from mcp.server import Server
|
||||||
|
from mcp.server.stdio import stdio_server
|
||||||
|
from mcp.types import Tool, TextContent
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("PsychoScreenerMCP")
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 导入筛查器
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from psycho_screener.screener import PsychoScreener, ScreeningResult
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# MCP Server
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
APP_NAME = "psycho-screener"
|
||||||
|
APP_VERSION = "1.0.0"
|
||||||
|
|
||||||
|
server = Server(APP_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
@server.list_tools()
|
||||||
|
async def list_tools() -> list[Tool]:
|
||||||
|
"""列出所有可用工具"""
|
||||||
|
return [
|
||||||
|
Tool(
|
||||||
|
name="psycho_screen",
|
||||||
|
description="对儿童对话进行心理问题筛查。当儿童与玩偶对话中可能存在霸凌、抑郁、焦虑、家庭矛盾等心理问题时,调用此工具进行分析。",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"messages": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "儿童与玩偶的完整对话上下文,格式为消息数组。每条消息包含 role(system/user/assistant)和 content(内容)。",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"role": {"type": "string"},
|
||||||
|
"content": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["role", "content"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include_prefix": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": True,
|
||||||
|
"description": "检测到问题时是否返回注入前缀"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["messages"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@server.call_tool()
|
||||||
|
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||||
|
"""执行工具调用"""
|
||||||
|
if name != "psycho_screen":
|
||||||
|
return [TextContent(type="text", text=json.dumps({"error": f"Unknown tool: {name}"}))]
|
||||||
|
|
||||||
|
messages = arguments.get("messages", [])
|
||||||
|
include_prefix = arguments.get("include_prefix", True)
|
||||||
|
|
||||||
|
api_key = os.environ.get("MINIMAX_API_KEY", "")
|
||||||
|
if not api_key:
|
||||||
|
result = {
|
||||||
|
"detected": False,
|
||||||
|
"category": "none",
|
||||||
|
"severity": "none",
|
||||||
|
"summary": "API key 未配置",
|
||||||
|
"suggestion": "",
|
||||||
|
"prefix": "",
|
||||||
|
"error": "MINIMAX_API_KEY not set"
|
||||||
|
}
|
||||||
|
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 提取孩子消息
|
||||||
|
child_messages = [
|
||||||
|
msg["content"]
|
||||||
|
for msg in messages
|
||||||
|
if msg.get("role") == "user" and msg.get("content")
|
||||||
|
]
|
||||||
|
context = "\n".join(child_messages)
|
||||||
|
|
||||||
|
if not context.strip():
|
||||||
|
result = {
|
||||||
|
"detected": False,
|
||||||
|
"category": "none",
|
||||||
|
"severity": "none",
|
||||||
|
"summary": "无儿童对话内容可分析",
|
||||||
|
"suggestion": "",
|
||||||
|
"prefix": ""
|
||||||
|
}
|
||||||
|
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|
||||||
|
|
||||||
|
# 调用筛查器
|
||||||
|
screener = PsychoScreener(api_key=api_key)
|
||||||
|
screening_result = screener.screen(context)
|
||||||
|
|
||||||
|
# 构建结果
|
||||||
|
result: dict = {
|
||||||
|
"detected": screening_result.detected,
|
||||||
|
"category": screening_result.category,
|
||||||
|
"severity": "none" if not screening_result.detected else screening_result.severity,
|
||||||
|
"summary": screening_result.summary,
|
||||||
|
"suggestion": screening_result.suggestion if screening_result.detected else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if include_prefix and screening_result.detected:
|
||||||
|
result["prefix"] = screener.build_response_prefix(screening_result)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"psycho_screen: detected={screening_result.detected}, "
|
||||||
|
f"category={screening_result.category}, severity={screening_result.severity}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"psycho_screen error: {e}")
|
||||||
|
result = {
|
||||||
|
"detected": False,
|
||||||
|
"category": "none",
|
||||||
|
"severity": "none",
|
||||||
|
"summary": f"筛查过程出错: {str(e)}",
|
||||||
|
"suggestion": "",
|
||||||
|
"prefix": "",
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 主入口
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""启动 MCP stdio 服务器"""
|
||||||
|
logger.info(f"启动 PsychoScreener MCP 服务器 v{APP_VERSION}")
|
||||||
|
async with stdio_server() as (read_stream, write_stream):
|
||||||
|
await server.run(
|
||||||
|
read_stream,
|
||||||
|
write_stream,
|
||||||
|
server.create_initialization_options()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import asyncio
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
小智设备连接测试客户端
|
||||||
|
连接到 xiaozhi-server,模拟设备握手,触发 MCP 工具初始化
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import websockets
|
||||||
|
import sys
|
||||||
|
|
||||||
|
XIAOZHI_WS = "ws://localhost:8000/xiaozhi/v1/"
|
||||||
|
DEVICE_ID = str(uuid.uuid4())
|
||||||
|
TOPIC = str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print(f"连接 xiaozhi-server: {XIAOZHI_WS}")
|
||||||
|
print(f"Device ID: {DEVICE_ID}")
|
||||||
|
|
||||||
|
async with websockets.connect(XIAOZHI_WS, ping_interval=None) as ws:
|
||||||
|
# 发送设备 hello
|
||||||
|
hello = {
|
||||||
|
"type": "hello",
|
||||||
|
"deviceId": DEVICE_ID,
|
||||||
|
"deviceName": "测试设备",
|
||||||
|
"clientType": "py-websocket-test",
|
||||||
|
"protocolVersion": "1.0.0",
|
||||||
|
"featureSet": {"mcp": True},
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(hello))
|
||||||
|
print(f"发送 hello: {hello}")
|
||||||
|
|
||||||
|
# 接收消息(最多等 30 秒)
|
||||||
|
for i in range(20):
|
||||||
|
try:
|
||||||
|
msg = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
||||||
|
data = json.loads(msg)
|
||||||
|
msg_type = data.get("type", "unknown")
|
||||||
|
print(f"收到 [{msg_type}]: {json.dumps(data, ensure_ascii=False)[:200]}")
|
||||||
|
|
||||||
|
if msg_type == "welcome":
|
||||||
|
print("\n✅ 连接成功!")
|
||||||
|
if "tools" in data:
|
||||||
|
print(f"MCP 工具列表: {[t.get('name') for t in data.get('tools', [])]}")
|
||||||
|
if "mcpTools" in data:
|
||||||
|
print(f"MCP 工具: {data.get('mcpTools')}")
|
||||||
|
break
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print(f" ... 等待中 ({i+1}/20)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print("\n测试完成,5秒后关闭...")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n已退出")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"错误: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
小智 AI CLI 客户端 - 简化版(含 MCP 工具处理)
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 连接 xiaozhi-server
|
||||||
|
- 声明 MCP 能力
|
||||||
|
- 发送文本对话
|
||||||
|
- 服务器可调用 MCP 工具(psycho_screen)
|
||||||
|
- 客户端将 MCP 调用转发给 psycho-screener stdio 工具
|
||||||
|
- 返回结果给服务器
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python xiaozhi_cli_client.py "今天小朋友打我,我好害怕"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
import websockets
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
|
||||||
|
DEVICE_ID = str(uuid.uuid4())
|
||||||
|
WS_URL = "ws://localhost:8000/xiaozhi/v1/"
|
||||||
|
MINIMAX_API_KEY = os.environ.get(
|
||||||
|
"MINIMAX_API_KEY",
|
||||||
|
"sk-cp-Sd2G0paJUZWdQhKrISIICVqQnuiE4qvT-yMszahI7s0Sau02Pa1XZCXNsj2Z91n-xNV8hIG-xL8lENaEgFNQBZr7S6Y8_R7OASOScenpJIxxWOb6vc7sF38"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# MCP 工具调用处理
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _mcp_call_sync(messages: list, include_prefix: bool) -> dict:
|
||||||
|
"""同步调用 MCP 工具(在独立线程中运行)"""
|
||||||
|
import select, threading
|
||||||
|
|
||||||
|
script_path = os.path.join(os.path.dirname(__file__), "src", "psycho_screener", "mcp_tool_native.py")
|
||||||
|
env = {**os.environ, "MINIMAX_API_KEY": MINIMAX_API_KEY}
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, script_path],
|
||||||
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
text=True, env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
_id = [0]
|
||||||
|
def send(msg):
|
||||||
|
_id[0] += 1
|
||||||
|
msg["id"] = _id[0]
|
||||||
|
proc.stdin.write(json.dumps(msg) + "\n")
|
||||||
|
proc.stdin.flush()
|
||||||
|
if select.select([proc.stdout], [], [], 20)[0]:
|
||||||
|
return json.loads(proc.stdout.readline())
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
send({"jsonrpc": "2.0", "method": "initialize", "params": {
|
||||||
|
"protocolVersion": "2024-11-05", "capabilities": {},
|
||||||
|
"clientInfo": {"name": "xiaozhi-cli", "version": "1.0"}
|
||||||
|
}})
|
||||||
|
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
|
||||||
|
proc.stdin.flush()
|
||||||
|
|
||||||
|
r = send({"jsonrpc": "2.0", "method": "tools/call", "params": {
|
||||||
|
"name": "psycho_screen",
|
||||||
|
"arguments": {"messages": messages, "include_prefix": include_prefix}
|
||||||
|
}})
|
||||||
|
if r and "result" in r:
|
||||||
|
return json.loads(r["result"][0]["text"])
|
||||||
|
return {"error": "no result"}
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=3)
|
||||||
|
|
||||||
|
|
||||||
|
async def call_psycho_screener(messages: list, include_prefix: bool = True) -> dict:
|
||||||
|
"""异步调用(在独立线程池中运行同步 subprocess)"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, _mcp_call_sync, messages, include_prefix)
|
||||||
|
|
||||||
|
|
||||||
|
def build_mcp_response(req_id: int | str, result: dict) -> dict:
|
||||||
|
"""构建 MCP JSON-RPC 成功响应"""
|
||||||
|
return {
|
||||||
|
"type": "mcp",
|
||||||
|
"payload": {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"result": {
|
||||||
|
"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}],
|
||||||
|
"isError": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# WebSocket 客户端
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def run_single(text: str):
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"设备ID: {DEVICE_ID}")
|
||||||
|
print(f"发送: {text}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
url = f"{WS_URL}?device-id={DEVICE_ID}&authorization=cli"
|
||||||
|
async with websockets.connect(url, ping_interval=None, max_size=10 * 1024 * 1024) as ws:
|
||||||
|
# 1. 发 hello,等 welcome(含工具列表)
|
||||||
|
await ws.send(json.dumps({
|
||||||
|
"type": "hello", "version": 1,
|
||||||
|
"features": {"mcp": True},
|
||||||
|
"transport": "websocket",
|
||||||
|
"audio_params": {"format": "opus", "sample_rate": 16000, "channels": 1, "frame_duration": 60},
|
||||||
|
}))
|
||||||
|
raw = await asyncio.wait_for(ws.recv(), timeout=10.0)
|
||||||
|
welcome = json.loads(raw)
|
||||||
|
print(f"[服务器] type={welcome.get('type')}, session={welcome.get('session_id','')[:16]}")
|
||||||
|
tools = [t.get("name") for t in welcome.get("tools", [])]
|
||||||
|
print(f"[工具列表] {tools}")
|
||||||
|
|
||||||
|
# 2. 发文本(用 listen 消息类型)
|
||||||
|
await ws.send(json.dumps({"type": "listen", "state": "detect", "text": text}))
|
||||||
|
print("[已发送文本]\n")
|
||||||
|
|
||||||
|
# 3. 循环收消息,处理 MCP 调用
|
||||||
|
replies = []
|
||||||
|
mcp_init_id = None
|
||||||
|
|
||||||
|
for i in range(30):
|
||||||
|
try:
|
||||||
|
raw = await asyncio.wait_for(ws.recv(), timeout=30.0)
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
# 二进制音频数据,跳过
|
||||||
|
print(f"[二进制数据] (跳过 {len(raw)} bytes)")
|
||||||
|
continue
|
||||||
|
t = data.get("type")
|
||||||
|
|
||||||
|
if t == "mcp":
|
||||||
|
payload = data.get("payload", {})
|
||||||
|
method = payload.get("method")
|
||||||
|
req_id = payload.get("id")
|
||||||
|
params = payload.get("params", {})
|
||||||
|
|
||||||
|
if method == "initialize":
|
||||||
|
print(f"[MCP] initialize 请求")
|
||||||
|
resp = {
|
||||||
|
"type": "mcp",
|
||||||
|
"payload": {
|
||||||
|
"jsonrpc": "2.0", "id": req_id,
|
||||||
|
"result": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {"tools": {}},
|
||||||
|
"serverInfo": {"name": "xiaozhi-cli", "version": "1.0"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(resp))
|
||||||
|
print("[MCP] 已回复 initialize")
|
||||||
|
|
||||||
|
elif method == "tools/call":
|
||||||
|
tool_name = params.get("name")
|
||||||
|
args = params.get("arguments", {})
|
||||||
|
print(f"[MCP] 调用工具: {tool_name}")
|
||||||
|
|
||||||
|
if tool_name == "psycho_screen":
|
||||||
|
# 调用我们的筛查器
|
||||||
|
result = await call_psycho_screener(
|
||||||
|
messages=args.get("messages", []),
|
||||||
|
include_prefix=args.get("include_prefix", True),
|
||||||
|
)
|
||||||
|
resp = build_mcp_response(req_id, result)
|
||||||
|
await ws.send(json.dumps(resp))
|
||||||
|
print(f"[MCP] 筛查结果: detected={result.get('detected')}, category={result.get('category')}")
|
||||||
|
if result.get("prefix"):
|
||||||
|
print(f"[MCP] 前缀: {result['prefix'][:80]}")
|
||||||
|
else:
|
||||||
|
print(f"[MCP] 未知工具: {tool_name}")
|
||||||
|
|
||||||
|
elif t == "text":
|
||||||
|
content = data.get("content", "")
|
||||||
|
replies.append(content)
|
||||||
|
print(f"[玩偶] {content[:200]}")
|
||||||
|
|
||||||
|
elif t == "llm":
|
||||||
|
print(f"[LLM] {str(data)[:150]}")
|
||||||
|
|
||||||
|
elif t == "tts":
|
||||||
|
print("[TTS] (音频跳过)")
|
||||||
|
|
||||||
|
elif t == "error":
|
||||||
|
print(f"[ERROR] {data}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"[{t}] {str(data)[:100]}")
|
||||||
|
|
||||||
|
# 收到第二条文本后退出
|
||||||
|
if t == "text" and len(replies) >= 2:
|
||||||
|
break
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print(f"[超时 {i+1}/30]")
|
||||||
|
if i > 3:
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"完成!收到 {len(replies)} 条玩偶回复")
|
||||||
|
for j, r in enumerate(replies):
|
||||||
|
print(f" 回复{j+1}: {r[:300]}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
parser = argparse.ArgumentParser(description="小智 AI CLI 客户端(含 MCP)")
|
||||||
|
parser.add_argument("text", nargs="?", help="对话内容")
|
||||||
|
parser.add_argument("--interactive", "-i", action="store_true", help="交互模式")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.interactive or not args.text:
|
||||||
|
print("交互模式暂不支持 MCP 工具处理")
|
||||||
|
else:
|
||||||
|
await run_single(args.text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n已退出")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[错误] {e}")
|
||||||
|
sys.exit(1)
|
||||||
Loading…
Reference in New Issue