| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478 |
- """
- audio_server.py — AI有声书 MCP 工具集(纯模块,无 I/O)
- 为什么从可执行脚本变成纯模块:
- Windows + Python 3.14 上,mcp_pipe.py 通过 subprocess.Popen + stdio PIPE
- 启动子进程时,child 的 stdin 永远读不到数据(pipe 怪行为)。所以本工具
- 改成纯模块,让 mcp_pipe.py 直接 import,避免任何跨进程通信。
- 提供的接口:
- - TOOLS:MCP 工具 schema 列表
- - handle_request(req) -> response | None:处理 xiaozhi 发来的 JSON-RPC 请求
- (notifications 返回 None,request 返回 response dict)
- """
- from __future__ import annotations
- import json
- import logging
- import os
- import sys
- import urllib.parse
- import urllib.request
- from typing import Any
- API_BASE = os.environ.get("AUDIOBOOK_API_BASE", "http://127.0.0.1:3000")
- API_TIMEOUT = float(os.environ.get("AUDIOBOOK_API_TIMEOUT", "15"))
- MAX_RESP_BYTES = 900
- logger = logging.getLogger("audiobook_mcp")
- # ---------------- 同步 HTTP 客户端 ----------------
- def _http_get(path: str, params: dict | None = None) -> dict:
- url = f"{API_BASE}{path}"
- if params:
- url += "?" + urllib.parse.urlencode(params)
- try:
- with urllib.request.urlopen(url, timeout=API_TIMEOUT) as r:
- return json.loads(r.read().decode("utf-8"))
- except Exception as e: # noqa: BLE001
- return {"code": -1, "message": f"http error: {e}"}
- def _http_post(path: str, body: dict | None = None) -> dict:
- url = f"{API_BASE}{path}"
- data = json.dumps(body or {}).encode("utf-8")
- try:
- req = urllib.request.Request(url, data=data, method="POST",
- headers={"Content-Type": "application/json"})
- with urllib.request.urlopen(req, timeout=API_TIMEOUT) as r:
- return json.loads(r.read().decode("utf-8"))
- except Exception as e: # noqa: BLE001
- return {"code": -1, "message": f"http error: {e}"}
- # ---------------- 工具实现 ----------------
- def _truncate(text: str, max_bytes: int = MAX_RESP_BYTES) -> str:
- """仅在可信的短文本上使用——不要对 JSON 整体截断。"""
- enc = text.encode("utf-8")
- if len(enc) <= max_bytes:
- return text
- return enc[:max_bytes].decode("utf-8", errors="ignore") + "..."
- def _ok(payload: Any) -> dict:
- # 不截断外层 JSON——工具层已控制内容长度(text 截断到 250~300 字符)
- return {"success": True, "result": json.dumps(payload, ensure_ascii=False, separators=(",", ":"))}
- def _err(msg: str) -> dict:
- return {"success": False, "error": _truncate(msg, 400)}
- def tool_search_audiobooks(args: dict) -> dict:
- keywords = (args.get("keywords") or "").strip()
- if not keywords:
- return _err("搜索关键词不能为空")
- data = _http_get("/api/search", {"q": keywords, "limit": "8"})
- items = (data.get("data") or [])[:8]
- return _ok({
- "count": len(items),
- "items": [{"id": it.get("id") or it.get("audioId") or it.get("bookId"),
- "title": it.get("title") or it.get("name") or "",
- "desc": (it.get("description") or it.get("summary") or "")[:120]}
- for it in items],
- "tip": "用 get_book_details(book_id) 查看章节",
- })
- def tool_list_categories(args: dict) -> dict:
- data = _http_get("/api/categories")
- items = data.get("data") or []
- return _ok({"count": len(items),
- "categories": [{"id": it.get("id"), "name": it.get("name") or it.get("title")}
- for it in items]})
- def tool_list_books_by_category(args: dict) -> dict:
- cid = args.get("category_id")
- if not cid:
- return _err("category_id 必填")
- data = _http_get(f"/api/categories/{int(cid)}",
- {"page": str(args.get("page") or 1), "pageSize": "10"})
- info = data.get("data") or {}
- items = info.get("list") if isinstance(info, dict) else info
- if not isinstance(items, list):
- items = []
- return _ok({"category_id": cid, "count": len(items[:10]),
- "books": [{"id": it.get("id") or it.get("bookId"),
- "title": it.get("title") or "",
- "desc": (it.get("description") or "")[:120]}
- for it in items[:10]]})
- def tool_get_book_details(args: dict) -> dict:
- bid = args.get("book_id")
- if not bid:
- return _err("book_id 必填")
- data = _http_get(f"/api/book-generator/books/{int(bid)}")
- if data.get("code") != 0:
- return _err("书籍不存在或无权访问,可用 search_audiobooks 重新搜索或用 generate_audiobook 生成新书")
- info = data.get("data") or {}
- chapters = info.get("chapters") or []
- stage = info.get("genStage") or ""
- progress = info.get("progress") or 0
- # 阶段→中文提示
- stage_hint = {"outlining": "正在生成大纲", "content_generating": "正在写内容",
- "content_completed": "内容已生成", "audio_generating": "正在生成音频",
- "audio_completed": "全部完成"}.get(stage, stage)
- result = {
- "id": info.get("id"),
- "title": info.get("title"),
- "progress": progress,
- "stage": stage_hint,
- "total_chapters": info.get("totalChapters"),
- "chapters": [{"id": ch.get("id"), "title": ch.get("title"),
- "has_audio": bool(ch.get("audioUrl") and not str(ch.get("audioUrl", "")).startswith("/uploads/"))}
- for ch in chapters[:30]],
- }
- if chapters:
- result["action"] = "有%d章内容,调get_chapter_text(chapter_id=%d)开始朗读" % (len(chapters), chapters[0]["id"])
- else:
- result["action"] = "还没生成完(%s %d%%),告诉用户'正在%s,进度%d%%,说继续查看'。用户说继续后再调本工具" % (stage_hint, progress, stage_hint, progress)
- return _ok(result)
- def _chapter_nav(book_id: int, chapter_id: int) -> dict:
- """获取章节导航:第几章/共几章/下一章ID/上一章ID"""
- nav = {"chapter_index": 1, "total_chapters": 1, "next_chapter_id": None, "prev_chapter_id": None}
- try:
- bdata = _http_get(f"/api/book-generator/books/{book_id}")
- chapters = (bdata.get("data") or {}).get("chapters") or []
- ids = [c["id"] for c in chapters if c.get("id")]
- nav["total_chapters"] = len(ids)
- if chapter_id in ids:
- idx = ids.index(chapter_id)
- nav["chapter_index"] = idx + 1
- if idx > 0:
- nav["prev_chapter_id"] = ids[idx - 1]
- if idx < len(ids) - 1:
- nav["next_chapter_id"] = ids[idx + 1]
- except Exception: # noqa: BLE001
- pass
- return nav
- def tool_get_chapter_audio_url(args: dict) -> dict:
- """【核心】获取章节音频URL。音箱用此URL直接播放。
- 返回章节音频地址+导航信息(下一章ID、共几章)。播完当前章节后,
- 请自动调用 get_chapter_audio_url(chapter_id=下一章ID) 继续播放下一章。"""
- cid = args.get("chapter_id")
- if not cid:
- return _err("chapter_id 必填")
- data = _http_get(f"/api/book-generator/books/chapters/{int(cid)}")
- info = data.get("data") or {}
- audio_url = info.get("audioUrl") or ""
- book_id = info.get("bookId")
- if not audio_url or audio_url.startswith("/uploads/"):
- # 无音频 → 自动降级为文本朗读,不让小智卡在错误上
- return tool_get_chapter_text({"chapter_id": cid, "max_chars": 200})
- nav = _chapter_nav(int(book_id) if book_id else 0, int(cid))
- result = {
- "chapter_id": cid,
- "title": info.get("title"),
- "audio_url": audio_url,
- "duration": info.get("audioDuration"),
- "book_id": book_id,
- **nav,
- }
- if nav["next_chapter_id"]:
- result["action"] = "播完本章后请自动调 get_chapter_audio_url(chapter_id=%s)" % nav["next_chapter_id"]
- else:
- result["action"] = "最后一章播完后告诉用户全部播放完毕并总结"
- return _ok(result)
- def tool_get_chapter_text(args: dict) -> dict:
- """读正文自己TTS(推荐用 read_chapter)。"""
- cid = args.get("chapter_id")
- if not cid:
- return _err("chapter_id 必填")
- cid = int(cid)
- data = _http_get(f"/api/book-generator/books/chapters/{cid}/read",
- {"format": "text", "max_chars": "300"})
- if data.get("code") != 0:
- return _err(data.get("message") or "读取失败")
- info = (data.get("data") or {})
- title = info.get("title") or ""
- text = info.get("text") or ""
- if not text:
- return _err("该章节暂无文本内容")
- nav = info.get("nav") or {}
- return {
- "action": "RESPONSE",
- "response": "%s。%s" % (title, text),
- "chapter_index": nav.get("chapter_index"),
- "total_chapters": nav.get("total_chapters"),
- "has_more": info.get("has_more"),
- **({"next_chapter_id": nav["next_chapter_id"]} if nav.get("next_chapter_id") else {}),
- }
- def tool_read_chapter(args: dict) -> dict:
- """统一读章节:format=audio 返回URL播放;text 返回正文自己TTS;auto 智能选(推荐)。"""
- cid = args.get("chapter_id")
- if not cid:
- return _err("chapter_id 必填")
- fmt = args.get("format") or "auto"
- if fmt not in ("audio", "text", "auto"):
- return _err("format 必须是 audio/text/auto")
- # 调新端点
- data = _http_get(f"/api/book-generator/books/chapters/{int(cid)}/read",
- {"format": fmt, "max_chars": "300"})
- code = data.get("code")
- # 2002 = on-demand TTS 失败 → 自动降级到 text 让音箱自己读
- if code == 2002:
- logger.warning("read_chapter TTS failed, fallback to text: %s", data.get("message"))
- data = _http_get(f"/api/book-generator/books/chapters/{int(cid)}/read",
- {"format": "text", "max_chars": "300"})
- elif code != 0:
- return _err(data.get("message") or "章节读取失败")
- info = (data.get("data") or {})
- nav = (info.get("nav") or {})
- if info.get("mode") == "audio":
- result = {
- "chapter_id": info["chapter_id"],
- "title": info.get("title"),
- "audio_url": info.get("audio_url"),
- "duration": info.get("audio_duration"),
- "audio_source": info.get("audio_source"), # 'full' 或 'on_demand'
- "book_id": info.get("book_id"),
- **nav,
- }
- next_id = nav.get("next_chapter_id")
- if next_id:
- result["action"] = "播完调 read_chapter(chapter_id=%s)" % next_id
- else:
- result["action"] = "最后一章,播完总结"
- return _ok(result)
- # text 模式
- text = info.get("text", "")
- title = info.get("title", "")
- return {
- "action": "RESPONSE",
- "response": "%s。%s" % (title, text),
- "has_more": info.get("has_more", False),
- "next_offset": info.get("next_offset"),
- "chapter_id": info["chapter_id"],
- **({"next_chapter_id": nav["next_chapter_id"]} if nav.get("next_chapter_id") else {}),
- }
- def tool_list_recent_history(args: dict) -> dict:
- limit = max(1, min(int(args.get("limit") or 10), 30))
- data = _http_get("/api/history/list", {"page": "1", "pageSize": str(limit)})
- info = data.get("data") or {}
- items = info.get("list") or []
- return _ok({"count": len(items[:limit]),
- "history": [{"id": it.get("id"), "title": it.get("title"),
- "type": it.get("type", "audio"),
- "created_at": it.get("createdAt")}
- for it in items[:limit]]})
- def _bg_poll_and_push(book_id: int, title: str, device_id: str):
- """后台轮询生成进度,完成后主动 push 到小智音箱"""
- import time as _time
- import urllib.request as _rq
- push_url = os.environ.get("XIAOZHI_PUSH_URL", "http://192.168.31.155:58003/xiaozhi/push")
- deadline = _time.time() + 1800
- interval = 10
- while _time.time() < deadline:
- _time.sleep(interval)
- detail = _http_get(f"/api/book-generator/books/{book_id}")
- info = detail.get("data") or {}
- chapters = info.get("chapters") or []
- progress = info.get("progress", 0)
- interval = 5 if progress > 30 else 10
- if chapters and progress >= 30:
- first_c = chapters[0]
- fd = _http_get(f"/api/book-generator/books/chapters/{first_c['id']}")
- first_content = ((fd.get("data") or {}).get("content") or (fd.get("data") or {}).get("text") or "")
- if first_content and len(first_content) > 50:
- clean = first_content.replace("#", "").replace("*", "").strip()
- first_title = first_c.get("title") or ""
- book_title = info.get("title") or title
- push_text = "《%s》已生成!共%d章。%s。%s" % (book_title, len(chapters), first_title, clean[:200])
- try:
- data = json.dumps({"device_id": device_id, "text": push_text, "type": "tts"}).encode()
- req = _rq.Request(push_url, data=data, method="POST",
- headers={"Content-Type": "application/json"})
- _rq.urlopen(req, timeout=10)
- logger.info("push done: book_id=%d", book_id)
- except Exception as e:
- logger.error("push failed: %s", e)
- return
- logger.warning("generate timeout: book_id=%d", book_id)
- def tool_generate_audiobook(args: dict) -> dict:
- """一键生成有声书。创建书+后台生成→立即返回。完成后自动推送通知。"""
- import threading
- title = (args.get("title") or "").strip()
- if not title:
- return _err("title 必填")
- create = _http_post("/api/book-generator/books", {"title": title, "description": args.get("description") or ""})
- book_id = ((create.get("data") or {}) or {}).get("id")
- if not book_id:
- return _err(f"创建失败: {create.get('message') or create}")
- _http_post(f"/api/book-generator/books/{book_id}/batch-generate",
- {"steps": ["generate_content", "generate_audio"]})
- # 通知 xiaozhi-server 注册后台轮询任务
- xz_url = os.environ.get("XIAOZHI_SERVER_URL", "http://host.docker.internal:58003/xiaozhi/book-callback")
- device_id = (args.get("device_id") or "").strip()
- if device_id:
- try:
- cb = json.dumps({"book_id": book_id, "device_id": device_id, "title": title}).encode()
- urllib.request.urlopen(urllib.request.Request(xz_url, data=cb, method="POST",
- headers={"Content-Type": "application/json"}), timeout=5)
- except Exception:
- pass
- return {
- "action": "RESPONSE",
- "response": "已开始生成《%s》(书号%d),大约需要5-10分钟。" % (title, book_id),
- "book_id": book_id,
- }
- # 精简工具 schema:7 个核心工具,目标 < 1024 字节。
- TOOLS: list[dict] = [
- {"name": "search_audiobooks",
- "description": "搜书。",
- "inputSchema": {"type": "object",
- "properties": {"keywords": {"type": "string"}},
- "required": ["keywords"]}},
- {"name": "list_categories",
- "description": "分类。",
- "inputSchema": {"type": "object", "properties": {}}},
- {"name": "get_book_details",
- "description": "书详情+章节。",
- "inputSchema": {"type": "object",
- "properties": {"book_id": {"type": "integer"}},
- "required": ["book_id"]}},
- {"name": "read_chapter",
- "description": "audio返URL播,text返正文,auto智能选。",
- "inputSchema": {"type": "object",
- "properties": {
- "chapter_id": {"type": "integer"},
- "format": {"type": "string", "enum": ["audio", "text", "auto"]},
- },
- "required": ["chapter_id"]}},
- {"name": "generate_audiobook",
- "description": "AI生成新书。异步,立即返回,完成后自动推送通知。",
- "inputSchema": {"type": "object",
- "properties": {"title": {"type": "string"}, "device_id": {"type": "string"}},
- "required": ["title"]}},
- ]
- TOOL_DISPATCH = {
- "search_audiobooks": tool_search_audiobooks,
- "list_categories": tool_list_categories,
- "get_book_details": tool_get_book_details,
- "read_chapter": tool_read_chapter,
- "generate_audiobook": tool_generate_audiobook,
- }
- SERVER_INFO = {"name": "AudioBook", "version": "1.0.0"}
- SERVER_CAPABILITIES = {"tools": {"listChanged": False}}
- PROTOCOL_VERSION = "2024-11-05"
- def handle_request(req: dict) -> dict | None:
- """处理一个 JSON-RPC 请求。
- - notification(无 id)返回 None
- - request 返回 response dict
- """
- method = req.get("method")
- req_id = req.get("id")
- params = req.get("params") or {}
- if req_id is None and method and not method.startswith("notifications/"):
- return {"jsonrpc": "2.0", "id": None,
- "error": {"code": -32600, "message": "invalid request"}}
- if method == "initialize":
- return {"jsonrpc": "2.0", "id": req_id, "result": {
- "protocolVersion": PROTOCOL_VERSION,
- "capabilities": SERVER_CAPABILITIES,
- "serverInfo": SERVER_INFO,
- }}
- if method == "ping":
- return {"jsonrpc": "2.0", "id": req_id, "result": {}}
- if method == "tools/list":
- return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}}
- if method == "tools/call":
- name = params.get("name")
- args = params.get("arguments") or {}
- handler = TOOL_DISPATCH.get(name)
- if not handler:
- return {"jsonrpc": "2.0", "id": req_id,
- "error": {"code": -32602, "message": f"unknown tool: {name}"}}
- try:
- result = handler(args)
- text = json.dumps(result, ensure_ascii=False, separators=(",", ":"))
- return {"jsonrpc": "2.0", "id": req_id, "result": {
- "content": [{"type": "text", "text": text}],
- "isError": not result.get("success", True),
- }}
- except Exception as e: # noqa: BLE001
- logger.exception("tool %s failed", name)
- return {"jsonrpc": "2.0", "id": req_id, "result": {
- "content": [{"type": "text", "text": json.dumps(_err(str(e)), ensure_ascii=False)}],
- "isError": True,
- }}
- if method and method.startswith("notifications/"):
- return None
- return {"jsonrpc": "2.0", "id": req_id,
- "error": {"code": -32601, "message": f"method not found: {method}"}}
- # ---- stdio 入口(保留用于本地 stdio 调用,不通过 mcp_pipe) ----
- def main_stdio():
- """直接 stdio 模式启动一个 MCP server(用于 verify_local.py / test_stdio.py)"""
- logging.basicConfig(
- level=os.environ.get("MCP_LOG_LEVEL", "INFO"),
- format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
- stream=sys.stderr,
- )
- logger.info("AudioBook MCP server (stdio) starting, api_base=%s", API_BASE)
- while True:
- line = sys.stdin.buffer.readline()
- if not line:
- return
- try:
- req = json.loads(line.decode("utf-8").strip())
- except Exception as e: # noqa: BLE001
- err = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": f"parse error: {e}"}}
- sys.stdout.buffer.write((json.dumps(err, ensure_ascii=False) + "\n").encode("utf-8"))
- sys.stdout.buffer.flush()
- continue
- resp = handle_request(req)
- if resp is not None:
- sys.stdout.buffer.write((json.dumps(resp, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8"))
- sys.stdout.buffer.flush()
- if __name__ == "__main__":
- main_stdio()
|