|
|
@@ -0,0 +1,478 @@
|
|
|
+"""
|
|
|
+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()
|