self_test.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. """
  2. self_test.py — 不用 subprocess,直接用 stdio 当 MCP server 跟小智 AI 通信
  3. xiaozhi.me 协议:
  4. 1. xiaozhi 主动发 initialize request (id=0, method=initialize)
  5. 2. 我们回 initialize response
  6. 3. xiaozhi 发 notifications/initialized
  7. 4. xiaozhi 发 tools/list, tools/call, ping
  8. """
  9. import json
  10. import logging
  11. import os
  12. import sys
  13. import websockets.sync.client as ws_sync
  14. logging.basicConfig(level="INFO", format="%(asctime)s - %(levelname)s - %(message)s")
  15. logger = logging.getLogger("self_test")
  16. API_BASE = os.environ.get("AUDIOBOOK_API_BASE", "http://127.0.0.1:38381")
  17. endpoint = os.environ["MCP_ENDPOINT"]
  18. TOOLS = [
  19. {"name": "search_audiobooks", "description": "搜索有声书",
  20. "inputSchema": {"type": "object", "properties": {"keywords": {"type": "string"}}, "required": ["keywords"]}},
  21. {"name": "list_categories", "description": "分类列表",
  22. "inputSchema": {"type": "object", "properties": {}}},
  23. {"name": "list_books_by_category", "description": "分类下书籍",
  24. "inputSchema": {"type": "object", "properties": {"category_id": {"type": "integer"}, "page": {"type": "integer"}}, "required": ["category_id"]}},
  25. {"name": "get_book_details", "description": "书籍详情",
  26. "inputSchema": {"type": "object", "properties": {"book_id": {"type": "integer"}}, "required": ["book_id"]}},
  27. {"name": "get_chapter_audio_url", "description": "章节音频URL",
  28. "inputSchema": {"type": "object", "properties": {"chapter_id": {"type": "integer"}}, "required": ["chapter_id"]}},
  29. {"name": "get_chapter_text", "description": "章节文本",
  30. "inputSchema": {"type": "object", "properties": {"chapter_id": {"type": "integer"}, "max_chars": {"type": "integer"}}, "required": ["chapter_id"]}},
  31. {"name": "list_recent_history", "description": "历史",
  32. "inputSchema": {"type": "object", "properties": {"limit": {"type": "integer"}}}},
  33. {"name": "generate_audiobook", "description": "生成书",
  34. "inputSchema": {"type": "object", "properties": {"title": {"type": "string"}, "description": {"type": "string"}}, "required": ["title"]}},
  35. ]
  36. def http_get(path: str):
  37. import urllib.request
  38. with urllib.request.urlopen(f"{API_BASE}{path}", timeout=10) as r:
  39. return json.loads(r.read().decode("utf-8"))
  40. def handle_request(req: dict) -> dict:
  41. """处理 xiaozhi 发来的 MCP 请求,返回 response(notifications 返回 None)"""
  42. method = req.get("method")
  43. rid = req.get("id")
  44. params = req.get("params") or {}
  45. if method == "initialize":
  46. return {"jsonrpc": "2.0", "id": rid, "result": {
  47. "protocolVersion": "2024-11-05",
  48. "capabilities": {"tools": {"listChanged": False}},
  49. "serverInfo": {"name": "AudioBook", "version": "1.0.0"},
  50. }}
  51. if method == "ping":
  52. return {"jsonrpc": "2.0", "id": rid, "result": {}}
  53. if method == "tools/list":
  54. return {"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}}
  55. if method == "tools/call":
  56. name = params.get("name")
  57. args = params.get("arguments") or {}
  58. if name == "list_categories":
  59. data = http_get("/api/categories")
  60. items = data.get("data") or []
  61. result = {"count": len(items), "categories": [{"id": i.get("id"), "name": i.get("name") or i.get("title")} for i in items]}
  62. return {"jsonrpc": "2.0", "id": rid, "result": {
  63. "content": [{"type": "text", "text": json.dumps({"success": True, "result": json.dumps(result, ensure_ascii=False)}, ensure_ascii=False)}],
  64. "isError": False,
  65. }}
  66. if name == "get_chapter_audio_url":
  67. cid = args.get("chapter_id")
  68. data = http_get(f"/api/book-generator/books/chapters/{cid}")
  69. info = data.get("data") or {}
  70. audio_url = info.get("audioUrl") or ""
  71. text = json.dumps({"success": bool(audio_url and not audio_url.startswith("/uploads/")),
  72. "chapter_id": cid, "title": info.get("title"),
  73. "audio_url": audio_url, "book_id": info.get("bookId")}, ensure_ascii=False)
  74. return {"jsonrpc": "2.0", "id": rid, "result": {
  75. "content": [{"type": "text", "text": text}], "isError": False,
  76. }}
  77. if name == "get_book_details":
  78. bid = args.get("book_id")
  79. data = http_get(f"/api/book-generator/books/{bid}")
  80. info = data.get("data") or {}
  81. chapters = info.get("chapters") or []
  82. result = {"id": info.get("id"), "title": info.get("title"),
  83. "chapters": [{"id": c.get("id"), "title": c.get("title"), "has_audio": bool(c.get("audioUrl"))} for c in chapters[:30]]}
  84. return {"jsonrpc": "2.0", "id": rid, "result": {
  85. "content": [{"type": "text", "text": json.dumps({"success": True, "result": json.dumps(result, ensure_ascii=False)}, ensure_ascii=False)}],
  86. "isError": False,
  87. }}
  88. if name == "search_audiobooks":
  89. kw = args.get("keywords", "")
  90. import urllib.parse
  91. with __import__("urllib.request").request.urlopen(f"{API_BASE}/api/search?q={urllib.parse.quote(kw)}&limit=8", timeout=10) as r:
  92. data = json.loads(r.read().decode("utf-8"))
  93. items = (data.get("data") or [])[:8]
  94. result = {"count": len(items), "items": [{"id": it.get("id") or it.get("bookId"), "title": it.get("title")} for it in items]}
  95. return {"jsonrpc": "2.0", "id": rid, "result": {
  96. "content": [{"type": "text", "text": json.dumps({"success": True, "result": json.dumps(result, ensure_ascii=False)}, ensure_ascii=False)}],
  97. "isError": False,
  98. }}
  99. return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": f"unknown tool: {name}"}}
  100. return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": f"unknown method: {method}"}}
  101. def main():
  102. logger.info("=== Self-test ===")
  103. ws = ws_sync.connect(endpoint, max_size=4 * 1024 * 1024)
  104. logger.info("WS connected, waiting for xiaozhi initialize...")
  105. while True:
  106. try:
  107. raw = ws.recv(timeout=60)
  108. except TimeoutError:
  109. logger.info("WS timeout 60s, exiting")
  110. break
  111. if not raw:
  112. break
  113. try:
  114. req = json.loads(raw)
  115. except Exception as e:
  116. logger.warning("parse err: %s", e)
  117. continue
  118. method = req.get("method")
  119. rid = req.get("id")
  120. logger.info("<- %s id=%s", method, rid)
  121. if rid is None and method and method.startswith("notifications/"):
  122. # notification, don't respond
  123. continue
  124. resp = handle_request(req)
  125. line = json.dumps(resp, ensure_ascii=False, separators=(",", ":")) + "\n"
  126. logger.info("-> response for %s id=%s, %d bytes", method, rid, len(line))
  127. ws.send(line)
  128. ws.close()
  129. if __name__ == "__main__":
  130. main()