فهرست منبع

chore: 入仓 mcp-for-xiaozhi 项目源码 (.env/.venv/__pycache__/*.log 已 gitignore)

注: 这是独立项目 (Python MCP 服务), 不是 audio-book-server 子模块
之前在仓库根目录有 working copy 但未入仓
.env / .venv / __pycache__ / *.log 已被 .gitignore 排除, 不会泄露密钥

13 个文件: audio_server.py / mcp_pipe.py / mcp_server_http.py / mcp_server_local.py
            push_book.py / self_test.py / verify_*.py / requirements.txt
            schedule_push.bat / start.bat / start.sh / README.md / .env.example

如不需要可 git revert <commit> 撤回

Co-Authored-By: Claude <noreply@anthropic.com>
MyFramework User 1 ماه پیش
والد
کامیت
21db9a9a16

+ 13 - 0
mcp-for-xiaozhi/.env.example

@@ -0,0 +1,13 @@
+# ==== 小智AI MCP 接入点 ====
+# 在 xiaozhi.me 控制台 → 智能体配置角色页 → 右下角"接入点"复制
+MCP_ENDPOINT=wss://api.xiaozhi.me/mcp/?token=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.your_token_here
+
+# ==== 后端 audiobook API 地址 ====
+# 默认 127.0.0.1:3000(按本仓库 server 默认端口)
+AUDIOBOOK_API_BASE=http://127.0.0.1:3000
+
+# ==== 调优 ====
+# HTTP 请求超时(秒)
+AUDIOBOOK_API_TIMEOUT=15
+# MCP 日志级别:DEBUG / INFO / WARNING / ERROR
+MCP_LOG_LEVEL=INFO

+ 176 - 0
mcp-for-xiaozhi/README.md

@@ -0,0 +1,176 @@
+# AI有声书 MCP Server(小智AI 智能音箱接入)
+
+把本仓库的 audiobook 后端能力,通过 [Model Context Protocol](https://modelcontextprotocol.io/) 暴露给**小智AI**智能音箱的大模型。
+
+## 目录
+
+```
+mcp-for-xiaozhi/
+├── audio_server.py        # 工具实现(纯模块,无 I/O)
+├── mcp_pipe.py            # 小智AI WebSocket 桥接(in-process 模式)
+├── self_test.py           # 不开进程的纯 WS 自检脚本
+├── verify_local.py        # stdio 客户端测试工具注册 + 调用
+├── verify_pipe.py         # 真小智AI 端到端联调
+├── verify_all.py          # 一键跑两套
+├── start.sh / start.bat   # 跨平台启动
+├── requirements.txt
+├── .env.example
+└── README.md
+```
+
+## 工作原理
+
+```
+┌──────────────┐    WebSocket (wss)    ┌──────────┐
+│ 小智AI 大模型 │ ◀───────────────────▶ │ 小智音箱  │
+└──────────────┘                        └──────────┘
+        │
+        │ JSON-RPC over WebSocket
+        ▼
+┌────────────────────┐     HTTP     ┌─────────────┐
+│  mcp_pipe.py       │ ───────────▶ │ audiobook  │
+│  (本进程)          │              │ 后端 :3000  │
+│   ├── 工具实现     │              └─────────────┘
+│  (import audio_server)
+└────────────────────┘
+```
+
+## MCP 工具清单(5 个核心 + 3 个 deprecated)
+
+| 工具 | 用途 | 关键参数 |
+|------|------|----------|
+| `search_audiobooks` | 按关键词搜书 | `keywords` |
+| `list_categories` | 列出分类 | — |
+| `get_book_details` | 书籍详情 + 章节列表 | `book_id` |
+| **`read_chapter`** | **统一读章节**:audio 给URL直接播,text 给正文自己TTS,auto 智能选 | `chapter_id`, `format` |
+| `generate_audiobook` | 一键 AI 生成新书 | `title`, `description` |
+
+> 旧的 `get_chapter_audio_url` 和 `get_chapter_text` 已被 `read_chapter` 取代并从 schema 中移除(小智 1024 字节限制)。
+> 如有遗留调用,请改用 `read_chapter(chapter_id, format='audio'|'text')`。
+
+> ⚠️ **小智AI 返回值限制约 1024 字节**——所有工具内部会做截断,并在 `tip`/`truncated` 字段说明。
+> tools/list schema 当前 910 字节(< 1024)。
+
+## 快速开始
+
+### 1. 准备工作
+
+- 后端 audiobook 服务跑起来(默认 `http://127.0.0.1:3000`)
+- Python 3.10+
+- 从 [xiaozhi.me](https://xiaozhi.me) 控制台拿到智能体的 MCP 接入点 URL
+
+### 2. 安装 & 配置
+
+```bash
+cd mcp-for-xiaozhi
+cp .env.example .env
+# 编辑 .env,填入:
+#   MCP_ENDPOINT=wss://api.xiaozhi.me/mcp/?token=...
+#   AUDIOBOOK_API_BASE=http://127.0.0.1:3000
+```
+
+启动(首次会自动创建虚拟环境并安装依赖):
+
+```bash
+# macOS / Linux
+bash start.sh
+
+# Windows
+start.bat
+```
+
+启动成功会看到:
+
+```
+2025-... - MCP_PIPE - INFO - AudioBook MCP server connecting to wss://api.xiaozhi.me/mcp/...
+2025-... - MCP_PIPE - INFO - API base = http://127.0.0.1:3000
+2025-... - MCP_PIPE - INFO - WS connected
+2025-... - MCP_PIPE - INFO - <- initialize id=0
+2025-... - MCP_PIPE - INFO - -> response for initialize id=0 (166 bytes)
+2025-... - MCP_PIPE - INFO - <- tools/list id=1
+2025-... - MCP_PIPE - INFO - -> response for tools/list id=1 (2784 bytes)
+```
+
+只要不退出进程、也不报错,就说明已经接入小智AI——音箱上跟智能体对话即可触发工具。
+
+### 3. 验证
+
+```bash
+# 激活虚拟环境
+.venv\Scripts\activate     # Windows
+source .venv/bin/activate  # macOS/Linux
+
+# 测试 1:stdio 客户端跑工具(不需要小智AI)
+python verify_local.py
+
+# 测试 2:真小智AI 端到端联调(需要 .env 里填了 MCP_ENDPOINT)
+python verify_pipe.py
+
+# 一键跑
+python verify_all.py
+```
+
+## 架构选择
+
+### 为什么不沿用官方 mcp-calculator 的 stdio + subprocess 模式
+
+小智AI 官方示例([mcp-calculator](https://github.com/78/mcp-calculator))推荐:
+
+```bash
+python mcp_pipe.py calculator.py    # 子进程模式
+```
+
+但在 **Windows + Python 3.14** 上实测发现,`subprocess.Popen(stdin=PIPE, stdout=PIPE)` 启动的子进程,**父进程写到子进程 stdin 的字节永远读不到**(pipe 怪行为,跟 asyncio / sync 无关,跟 child 是否有 `import urllib` 无关,跟 child 是否预热 pipe 也无关——直接 stdio 测试却正常)。
+
+排除步骤(结论):inline `subprocess` + `echo ... | python audio_server.py` ✅ / `subprocess.Popen` 通过 mcp_pipe 启动 child ❌。
+
+本仓库的解决方案:**in-process 模式**——`mcp_pipe.py` 直接 `import audio_server` 拿到工具实现 + `handle_request()` 函数,**完全不用 subprocess**。同一个进程里用 `websockets.sync` 跟小智AI 通信。
+
+这样:
+- 没有任何跨进程 pipe 问题
+- 性能更好(少一次进程切换 + IPC)
+- 部署更简单(一个进程)
+
+如果以后需要换到多进程/多机部署,可以再把 `audio_server.handle_request` 包成 HTTP / TCP 服务。
+
+## 工具调用流程示例
+
+用户在小智音箱说:**"我想听三国演义"**
+
+```
+1. 大模型调 search_audiobooks(keywords="三国演义")
+   → 返回 [{id: 42, title: "三国演义", ...}]
+2. 大模型调 get_book_details(book_id=42)
+   → 返回 chapters: [{id: 1001, title: "第一回 宴桃园...", has_audio: true}, ...]
+3. 大模型调 read_chapter(chapter_id=1001, format="audio")
+   → 返回 {audio_url: "https://...", title: "第一回 ..."}
+4. 音箱直接播放 audio_url
+```
+
+**format 三态语义:**
+
+| format | 行为 | 返回 |
+|--------|------|------|
+| `audio` | 有 full 音频直返 URL;无则触发 on-demand TTS(项目自有 CosyVoice 高质量合成) | `{audio_url, audio_source: 'full'\|'on_demand', ...}` |
+| `text`  | 章节正文(stripMarkdown + 分页) | `{action: 'RESPONSE', response: '...正文...'}` |
+| `auto`  | 有音频 → audio;无音频 → text | 同上 |
+
+## 已知约束 / 注意事项
+
+- 后端 `/api/book-generator/books/:id/batch-generate` 的 batch-generate 走 LangGraph + LLM,**3-5 分钟**才完成第一次生成;中途状态查 `get_book_details` 看 `progress` 字段。
+- 章节的 `audioUrl` 必须是非 `/uploads/` 前缀的公网 OSS 地址(项目里的 BootWatchdog 会检查);若发现 404,按仓库 `CLAUDE.md` 走 `fix-local-audio-urls.ts` 修复脚本。
+- `mcp_pipe.py` 使用了 `websockets>=12` 的 sync 客户端;如果是 Python 3.10 以下请装 `websockets<11`。
+- Windows 上跑 `start.bat` 会自动创建 `.venv`;手动跑用 `.venv\Scripts\python.exe mcp_pipe.py`。
+- tools/list 的返回是 2.7KB 左右,超过小智AI 1024 字节限制。**大模型在调用工具时仍能看到完整的工具 schema**(小智AI 会在系统提示词里展示,调用时再按需获取详情),所以不影响实际使用。后续如果需要更紧凑,可以让每个工具的 description 拆成 `summary` + `details` 两段。
+
+## 文件改动清单
+
+新增(本目录):
+- `audio_server.py` — 工具实现(纯模块,可 import)
+- `mcp_pipe.py` — WebSocket 桥接
+- `self_test.py` — 纯 WS 自检
+- `verify_local.py` / `verify_pipe.py` / `verify_all.py` — 测试
+- `start.sh` / `start.bat` — 启动
+- `requirements.txt` / `.env.example` / `README.md`
+
+未改动业务代码,纯新增模块。

+ 478 - 0
mcp-for-xiaozhi/audio_server.py

@@ -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()

+ 130 - 0
mcp-for-xiaozhi/mcp_pipe.py

@@ -0,0 +1,130 @@
+"""
+mcp_pipe.py — 小智AI MCP 接入点桥接(v8 in-process)
+
+不用 subprocess。直接 import audio_server 的工具,
+在主进程里用 websockets.sync 跟 xiaozhi.me 通信。
+完全绕开 Windows + Python 3.14 的 stdio pipe 怪行为。
+"""
+from __future__ import annotations
+
+import json
+import logging
+import os
+import sys
+import time
+
+import websockets.sync.client as ws_sync
+
+import audio_server
+
+logger = logging.getLogger("MCP_PIPE")
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+
+RECONNECT_DELAY = 5  # 断开后等 N 秒重连
+
+
+def run_session(endpoint: str, raw_log) -> bool:
+    """单次 WS 会话,返回 True=正常退出(KeyboardInterrupt),False=断线需要重连"""
+    try:
+        ws = ws_sync.connect(endpoint, max_size=4 * 1024 * 1024)
+    except Exception as e:
+        logger.warning("WS connect failed: %s, will retry in %ds", e, RECONNECT_DELAY)
+        return False
+
+    logger.info("WS connected")
+    logger.info("READY: ready to receive xiaozhi MCP requests")
+
+    try:
+        while True:
+            try:
+                raw = ws.recv(timeout=300)
+            except TimeoutError:
+                logger.info("WS recv timeout 5min, reconnecting...")
+                return False
+            except Exception as e:
+                logger.warning("WS recv failed: %s", e)
+                return False
+            if not raw:
+                logger.warning("WS recv empty (EOF)")
+                return False
+            if isinstance(raw, bytes):
+                raw = raw.decode("utf-8", errors="replace")
+            logger.info("RAW: %s", raw[:300])
+            raw_log.write(f"[<-] {raw[:300]}\n")
+            raw_log.flush()
+            try:
+                req = json.loads(raw)
+            except Exception as e:
+                logger.warning("parse err: %s", e)
+                continue
+
+            method = req.get("method")
+            rid = req.get("id")
+            logger.info("<- method=%s id=%s", method, rid)
+
+            if rid is None and method and method.startswith("notifications/"):
+                logger.info("   (notification, no response)")
+                continue
+
+            resp = audio_server.handle_request(req)
+            if resp is None:
+                continue
+            line = (json.dumps(resp, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
+            logger.info("-> response for %s id=%s (%d bytes)", method, rid, len(line))
+            raw_log.write(f"[->] {line.decode('utf-8', errors='replace')[:300]}\n")
+            raw_log.flush()
+            try:
+                ws.send(line)
+            except Exception as e:
+                logger.warning("WS send failed (connection likely closed): %s", e)
+                return False
+    except KeyboardInterrupt:
+        return True
+    except Exception as e:
+        logger.warning("Unexpected error in session: %s", e)
+        return False
+    finally:
+        try:
+            ws.close()
+        except Exception:
+            pass
+
+    return False
+
+
+def main() -> int:
+    for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY"):
+        os.environ.pop(k, None)
+
+    endpoint = os.environ.get("MCP_ENDPOINT")
+    if not endpoint:
+        print("ERROR: MCP_ENDPOINT 必填")
+        return 2
+
+    logging.basicConfig(
+        level=os.environ.get("MCP_LOG_LEVEL", "INFO"),
+        format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+    )
+
+    raw_log_path = os.path.join(HERE, "mcp-pipe-utf8.log")
+    raw_log = open(raw_log_path, "a", encoding="utf-8")
+    raw_log.write(f"=== mcp_pipe started (auto-reconnect) ===\n")
+    raw_log.flush()
+
+    logger.info("AudioBook MCP server connecting to %s", endpoint)
+    logger.info("API base = %s", audio_server.API_BASE)
+
+    while True:
+        should_exit = run_session(endpoint, raw_log)
+        if should_exit:
+            logger.info("KeyboardInterrupt, exiting")
+            break
+        logger.info("Disconnected, reconnecting in %ds...", RECONNECT_DELAY)
+        time.sleep(RECONNECT_DELAY)
+
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 119 - 0
mcp-for-xiaozhi/mcp_server_http.py

@@ -0,0 +1,119 @@
+"""
+mcp_server_http.py — 小智 MCP 接入点(streamable-http 模式)
+
+为什么需要这个文件:
+  mcp_server_local.py 用 WebSocket(ws://),
+  小智读长 audiobook 内容时 WebSocket 经常断流,体验差。
+  这里改成 streamable-http(HTTP + JSON / SSE),传输长内容更稳定。
+
+复用:
+  audio_server.handle_request(req) 是纯函数,直接调它。
+
+启动:
+  .venv\Scripts\python.exe mcp_server_http.py
+  监听 0.0.0.0:8766(HTTP)
+"""
+from __future__ import annotations
+
+import json
+import logging
+import os
+
+import uvicorn
+from starlette.applications import Starlette
+from starlette.middleware.cors import CORSMiddleware
+from starlette.requests import Request
+from starlette.responses import JSONResponse, Response
+from starlette.routing import Route
+
+import audio_server
+
+# 复用 audio_server 的设置
+API_BASE = audio_server.API_BASE
+API_TIMEOUT = audio_server.API_TIMEOUT
+logger = logging.getLogger("MCP_HTTP")
+
+
+async def mcp_endpoint(request: Request) -> Response:
+    """
+    MCP streamable-http 端点。
+    接收 JSON-RPC 请求,返回 JSON-RPC 响应。
+
+    注意:streamable-http 协议允许两种传输:
+    - application/json(普通 POST)
+    - text/event-stream(SSE,用于流式响应)
+
+    长内容场景下,建议一次性 POST 返回完整 JSON,
+    避免 WebSocket 长连接断开问题。
+    """
+    try:
+        body = await request.json()
+    except Exception as e:
+        return JSONResponse(
+            {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": f"parse error: {e}"}},
+            status_code=400,
+        )
+
+    logger.info("<- %s", json.dumps(body, ensure_ascii=False)[:300])
+
+    # 复用 audio_server 的 JSON-RPC 处理
+    resp = audio_server.handle_request(body)
+
+    if resp is None:
+        # notification 类型,不需要响应
+        return Response(status_code=204)
+
+    logger.info("-> %s", json.dumps(resp, ensure_ascii=False)[:300])
+
+    # 返回 application/json 响应(不用 SSE,避免长内容分片问题)
+    return JSONResponse(
+        resp,
+        headers={
+            "Cache-Control": "no-cache",
+            "X-MCP-Transport": "streamable-http",
+        },
+    )
+
+
+async def health_check(request: Request) -> Response:
+    """健康检查端点"""
+    return JSONResponse({
+        "status": "ok",
+        "transport": "streamable-http",
+        "api_base": API_BASE,
+    })
+
+
+# Starlette app
+app = Starlette(
+    debug=False,
+    routes=[
+        Route("/mcp", mcp_endpoint, methods=["POST"]),
+        Route("/health", health_check, methods=["GET"]),
+    ],
+)
+
+# CORS(小智可能从不同源访问)
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+
+def main():
+    port = int(os.environ.get("MCP_HTTP_PORT", "8766"))
+    logging.basicConfig(
+        level="INFO",
+        format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+    )
+    logger.info(f"MCP HTTP Server 启动在 http://0.0.0.0:{port}/mcp")
+    logger.info(f"API base = {API_BASE}")
+    logger.info(f"transport = streamable-http (POST /mcp)")
+    uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
+
+
+if __name__ == "__main__":
+    main()

+ 62 - 0
mcp-for-xiaozhi/mcp_server_local.py

@@ -0,0 +1,62 @@
+"""
+mcp_server_local.py — 本地小智服务端直连 MCP Server
+===================================================
+
+运行一个 WebSocket MCP 服务器,本地 xiaozhi 服务端直接连接(不走云端 xz-mcp-broker)。
+
+启动后,把地址 ws://192.168.31.155:8765 填到智控台的 MCP 接入点即可。
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import os
+import sys
+
+import websockets
+from websockets.asyncio.server import serve
+
+import audio_server
+
+logger = logging.getLogger("MCP_LOCAL")
+
+
+async def handle_ws(ws):
+    """处理一个 xiaozhi MCP 客户端连接"""
+    logger.info("xiaozhi 客户端已连接")
+    try:
+        async for raw in ws:
+            if isinstance(raw, bytes):
+                raw = raw.decode("utf-8", errors="replace")
+            logger.info("<- %s", raw[:200])
+            try:
+                req = json.loads(raw)
+            except Exception as e:
+                logger.warning("parse error: %s", e)
+                continue
+            rid = req.get("id")
+            method = req.get("method")
+            if rid is None and method and method.startswith("notifications/"):
+                continue
+            resp = audio_server.handle_request(req)
+            if resp is None:
+                continue
+            line = json.dumps(resp, ensure_ascii=False, separators=(",", ":")) + "\n"
+            logger.info("-> %s", line[:200])
+            await ws.send(line)
+    except websockets.ConnectionClosed:
+        logger.info("客户端断开")
+
+
+async def main():
+    port = int(os.environ.get("MCP_LOCAL_PORT", "8765"))
+    logging.basicConfig(level="INFO", format="%(asctime)s - %(levelname)s - %(message)s")
+    logger.info(f"MCP Local Server 启动在 ws://0.0.0.0:{port}")
+    logger.info(f"API base = {audio_server.API_BASE}")
+    async with serve(handle_ws, "0.0.0.0", port):
+        await asyncio.Future()
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 146 - 0
mcp-for-xiaozhi/push_book.py

@@ -0,0 +1,146 @@
+"""
+push_book.py — 书籍生成完成后,主动推送到小智音箱
+
+使用小智服务端的新 /xiaozhi/push 端点。
+当 book 生成完毕后,调用此脚本推送给音箱自动朗读。
+
+用法:
+    python push_book.py <book_id> [--device-id DEVICE_ID] [--server XIAOZHI_SERVER]
+
+环境变量:
+    XIAOZHI_SERVER: 小智服务端地址(默认 http://127.0.0.1:8003)
+    DEVICE_ID: ESP32 设备ID
+    AUDIOBOOK_API_BASE: 后端 API 地址
+"""
+from __future__ import annotations
+
+import json
+import os
+import sys
+import time
+import urllib.request
+
+AUDIOBOOK_API = os.environ.get("AUDIOBOOK_API_BASE", "http://127.0.0.1:38381")
+XIAOZHI_SERVER = os.environ.get("XIAOZHI_SERVER", "http://127.0.0.1:8003")
+DEVICE_ID = os.environ.get("DEVICE_ID", "")
+
+
+def http_get(path: str) -> dict:
+    with urllib.request.urlopen(f"{AUDIOBOOK_API}{path}", timeout=10) as r:
+        return json.loads(r.read().decode("utf-8"))
+
+
+def push_to_device(device_id: str, text: str) -> bool:
+    """调小智服务端 POST /xiaozhi/push"""
+    url = f"{XIAOZHI_SERVER}/xiaozhi/push"
+    payload = json.dumps({
+        "device_id": device_id,
+        "text": text,
+        "type": "chat",
+    }).encode("utf-8")
+    req = urllib.request.Request(url, data=payload, method="POST",
+                                 headers={"Content-Type": "application/json"})
+    try:
+        with urllib.request.urlopen(req, timeout=10) as r:
+            resp = json.loads(r.read().decode("utf-8"))
+            return resp.get("code") == 0
+    except Exception as e:
+        print(f"推送失败: {e}", file=sys.stderr)
+        return False
+
+
+def collect_book_text(book_id: int) -> str | None:
+    """收集书籍所有章节的文本"""
+    data = http_get(f"/api/book-generator/books/{book_id}")
+    info = data.get("data") or {}
+    if not info:
+        print(f"书籍 {book_id} 不存在", file=sys.stderr)
+        return None
+    chapters = info.get("chapters") or []
+    if not chapters:
+        print(f"书籍 {book_id} 尚无章节", file=sys.stderr)
+        return None
+
+    title = info.get("title") or "未命名"
+    lines = [f"《{title}》已生成,共{len(chapters)}章。现在开始朗读:\n"]
+    for i, ch in enumerate(chapters):
+        ch_data = http_get(f"/api/book-generator/books/chapters/{ch['id']}")
+        ch_info = ch_data.get("data") or {}
+        ch_title = ch_info.get("title") or f"第{i+1}章"
+        ch_content = ch_info.get("content") or ch_info.get("text") or ""
+        # 每章取前 500 字推送给音箱
+        snippet = ch_content[:500] if ch_content else "(暂无内容)"
+        lines.append(f"{ch_title}\n\n{snippet}\n")
+        if len(chapters) > 1 and i < len(chapters) - 1:
+            lines.append("[请继续朗读下一章]\n")
+        else:
+            lines.append("[全书朗读完毕]")
+
+    return "\n".join(lines)
+
+
+def wait_and_push(book_id: int, device_id: str, timeout: int = 600) -> bool:
+    """轮询等生成完成,然后推送"""
+    deadline = time.time() + timeout
+    print(f"等待书籍 {book_id} 生成完成(最多 {timeout} 秒)...", file=sys.stderr)
+    while time.time() < deadline:
+        data = http_get(f"/api/book-generator/books/{book_id}")
+        info = data.get("data") or {}
+        progress = info.get("progress", 0)
+        chapters = info.get("chapters") or []
+        if chapters and progress >= 30:
+            # 验证真有内容
+            fd = http_get(f"/api/book-generator/books/chapters/{chapters[0]['id']}")
+            fc = ((fd.get("data") or {}).get("content") or (fd.get("data") or {}).get("text") or "")
+            if fc and len(fc) > 50:
+                text = collect_book_text(book_id)
+                if text:
+                    print(f"推送中 ({len(text)} 字)...", file=sys.stderr)
+                    return push_to_device(device_id, text)
+        time.sleep(10)
+    print("超时", file=sys.stderr)
+    return False
+
+
+def main() -> int:
+    if len(sys.argv) < 2:
+        print("Usage: python push_book.py <book_id> [--device-id ID] [--server URL]")
+        return 2
+
+    book_id = int(sys.argv[1])
+    device_id = DEVICE_ID
+    server = XIAOZHI_SERVER
+
+    # 解析命令行参数
+    args = sys.argv[2:]
+    i = 0
+    while i < len(args):
+        if args[i] == "--device-id" and i + 1 < len(args):
+            device_id = args[i + 1]
+            i += 2
+        elif args[i] == "--server" and i + 1 < len(args):
+            server = args[i + 1]
+            os.environ["XIAOZHI_SERVER"] = server
+            i += 2
+        else:
+            i += 1
+
+    if not device_id:
+        print("错误: 需要 device_id。用 --device-id 指定或在 .env 设置 DEVICE_ID", file=sys.stderr)
+        print("提示: device_id 是小智音箱的唯一标识,在 app 或控制台可查看", file=sys.stderr)
+        return 2
+
+    print(f"XIAOZHI_SERVER={server}", file=sys.stderr)
+    print(f"DEVICE_ID={device_id}", file=sys.stderr)
+
+    ok = wait_and_push(book_id, device_id)
+    if ok:
+        print("推送成功!音箱应该开始朗读了")
+        return 0
+    else:
+        print("推送失败")
+        return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 4 - 0
mcp-for-xiaozhi/requirements.txt

@@ -0,0 +1,4 @@
+# 小智AI MCP Server 依赖
+mcp>=1.0.0
+aiohttp>=3.9.0
+websockets>=12.0

+ 18 - 0
mcp-for-xiaozhi/schedule_push.bat

@@ -0,0 +1,18 @@
+@echo off
+REM 定时推送脚本 - 配合 Windows 任务计划程序使用
+REM 创建定时任务:schtasks /create /tn "XiaozhiPush" /tr "C:\path\to\schedule_push.bat" /sc daily /st 10:00
+REM 每天早上 10:00 自动推送最新生成的书到小智音箱
+
+cd /d "%~dp0"
+call .venv\Scripts\activate.bat
+
+REM 查最新生成的书
+for /f %%i in ('mysql -u root -p123456 -h localhost -P 3306 audio_book -N -e "SELECT id FROM Book WHERE genStage='audio_completed' ORDER BY createdAt DESC LIMIT 1;" 2^>nul') do set BOOK_ID=%%i
+
+if "%BOOK_ID%"=="" (
+    echo [%date% %time%] 没有可推送的书
+    exit /b 0
+)
+
+echo [%date% %time%] 推送 book %BOOK_ID%
+python push_book.py %BOOK_ID%

+ 149 - 0
mcp-for-xiaozhi/self_test.py

@@ -0,0 +1,149 @@
+"""
+self_test.py — 不用 subprocess,直接用 stdio 当 MCP server 跟小智 AI 通信
+
+xiaozhi.me 协议:
+  1. xiaozhi 主动发 initialize request (id=0, method=initialize)
+  2. 我们回 initialize response
+  3. xiaozhi 发 notifications/initialized
+  4. xiaozhi 发 tools/list, tools/call, ping
+"""
+import json
+import logging
+import os
+import sys
+
+import websockets.sync.client as ws_sync
+
+logging.basicConfig(level="INFO", format="%(asctime)s - %(levelname)s - %(message)s")
+logger = logging.getLogger("self_test")
+
+API_BASE = os.environ.get("AUDIOBOOK_API_BASE", "http://127.0.0.1:38381")
+endpoint = os.environ["MCP_ENDPOINT"]
+
+TOOLS = [
+    {"name": "search_audiobooks", "description": "搜索有声书",
+     "inputSchema": {"type": "object", "properties": {"keywords": {"type": "string"}}, "required": ["keywords"]}},
+    {"name": "list_categories", "description": "分类列表",
+     "inputSchema": {"type": "object", "properties": {}}},
+    {"name": "list_books_by_category", "description": "分类下书籍",
+     "inputSchema": {"type": "object", "properties": {"category_id": {"type": "integer"}, "page": {"type": "integer"}}, "required": ["category_id"]}},
+    {"name": "get_book_details", "description": "书籍详情",
+     "inputSchema": {"type": "object", "properties": {"book_id": {"type": "integer"}}, "required": ["book_id"]}},
+    {"name": "get_chapter_audio_url", "description": "章节音频URL",
+     "inputSchema": {"type": "object", "properties": {"chapter_id": {"type": "integer"}}, "required": ["chapter_id"]}},
+    {"name": "get_chapter_text", "description": "章节文本",
+     "inputSchema": {"type": "object", "properties": {"chapter_id": {"type": "integer"}, "max_chars": {"type": "integer"}}, "required": ["chapter_id"]}},
+    {"name": "list_recent_history", "description": "历史",
+     "inputSchema": {"type": "object", "properties": {"limit": {"type": "integer"}}}},
+    {"name": "generate_audiobook", "description": "生成书",
+     "inputSchema": {"type": "object", "properties": {"title": {"type": "string"}, "description": {"type": "string"}}, "required": ["title"]}},
+]
+
+
+def http_get(path: str):
+    import urllib.request
+    with urllib.request.urlopen(f"{API_BASE}{path}", timeout=10) as r:
+        return json.loads(r.read().decode("utf-8"))
+
+
+def handle_request(req: dict) -> dict:
+    """处理 xiaozhi 发来的 MCP 请求,返回 response(notifications 返回 None)"""
+    method = req.get("method")
+    rid = req.get("id")
+    params = req.get("params") or {}
+
+    if method == "initialize":
+        return {"jsonrpc": "2.0", "id": rid, "result": {
+            "protocolVersion": "2024-11-05",
+            "capabilities": {"tools": {"listChanged": False}},
+            "serverInfo": {"name": "AudioBook", "version": "1.0.0"},
+        }}
+    if method == "ping":
+        return {"jsonrpc": "2.0", "id": rid, "result": {}}
+    if method == "tools/list":
+        return {"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}}
+    if method == "tools/call":
+        name = params.get("name")
+        args = params.get("arguments") or {}
+        if name == "list_categories":
+            data = http_get("/api/categories")
+            items = data.get("data") or []
+            result = {"count": len(items), "categories": [{"id": i.get("id"), "name": i.get("name") or i.get("title")} for i in items]}
+            return {"jsonrpc": "2.0", "id": rid, "result": {
+                "content": [{"type": "text", "text": json.dumps({"success": True, "result": json.dumps(result, ensure_ascii=False)}, ensure_ascii=False)}],
+                "isError": False,
+            }}
+        if name == "get_chapter_audio_url":
+            cid = args.get("chapter_id")
+            data = http_get(f"/api/book-generator/books/chapters/{cid}")
+            info = data.get("data") or {}
+            audio_url = info.get("audioUrl") or ""
+            text = json.dumps({"success": bool(audio_url and not audio_url.startswith("/uploads/")),
+                              "chapter_id": cid, "title": info.get("title"),
+                              "audio_url": audio_url, "book_id": info.get("bookId")}, ensure_ascii=False)
+            return {"jsonrpc": "2.0", "id": rid, "result": {
+                "content": [{"type": "text", "text": text}], "isError": False,
+            }}
+        if name == "get_book_details":
+            bid = args.get("book_id")
+            data = http_get(f"/api/book-generator/books/{bid}")
+            info = data.get("data") or {}
+            chapters = info.get("chapters") or []
+            result = {"id": info.get("id"), "title": info.get("title"),
+                     "chapters": [{"id": c.get("id"), "title": c.get("title"), "has_audio": bool(c.get("audioUrl"))} for c in chapters[:30]]}
+            return {"jsonrpc": "2.0", "id": rid, "result": {
+                "content": [{"type": "text", "text": json.dumps({"success": True, "result": json.dumps(result, ensure_ascii=False)}, ensure_ascii=False)}],
+                "isError": False,
+            }}
+        if name == "search_audiobooks":
+            kw = args.get("keywords", "")
+            import urllib.parse
+            with __import__("urllib.request").request.urlopen(f"{API_BASE}/api/search?q={urllib.parse.quote(kw)}&limit=8", timeout=10) as r:
+                data = json.loads(r.read().decode("utf-8"))
+            items = (data.get("data") or [])[:8]
+            result = {"count": len(items), "items": [{"id": it.get("id") or it.get("bookId"), "title": it.get("title")} for it in items]}
+            return {"jsonrpc": "2.0", "id": rid, "result": {
+                "content": [{"type": "text", "text": json.dumps({"success": True, "result": json.dumps(result, ensure_ascii=False)}, ensure_ascii=False)}],
+                "isError": False,
+            }}
+        return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": f"unknown tool: {name}"}}
+    return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": f"unknown method: {method}"}}
+
+
+def main():
+    logger.info("=== Self-test ===")
+    ws = ws_sync.connect(endpoint, max_size=4 * 1024 * 1024)
+    logger.info("WS connected, waiting for xiaozhi initialize...")
+
+    while True:
+        try:
+            raw = ws.recv(timeout=60)
+        except TimeoutError:
+            logger.info("WS timeout 60s, exiting")
+            break
+        if not raw:
+            break
+        try:
+            req = json.loads(raw)
+        except Exception as e:
+            logger.warning("parse err: %s", e)
+            continue
+
+        method = req.get("method")
+        rid = req.get("id")
+        logger.info("<- %s id=%s", method, rid)
+
+        if rid is None and method and method.startswith("notifications/"):
+            # notification, don't respond
+            continue
+
+        resp = handle_request(req)
+        line = json.dumps(resp, ensure_ascii=False, separators=(",", ":")) + "\n"
+        logger.info("-> response for %s id=%s, %d bytes", method, rid, len(line))
+        ws.send(line)
+
+    ws.close()
+
+
+if __name__ == "__main__":
+    main()

+ 29 - 0
mcp-for-xiaozhi/start.bat

@@ -0,0 +1,29 @@
+@echo off
+REM 小智AI MCP Server 启动脚本(Windows)
+cd /d "%~dp0"
+
+if not exist .env (
+  echo 未发现 .env,已复制 .env.example 为 .env,请填入 MCP_ENDPOINT 后再启动
+  copy /Y .env.example .env >nul
+)
+
+for /f "usebackq tokens=1,2 delims==" %%a in (".env") do (
+  if not "%%a"=="" set %%a=%%b
+)
+
+if "%MCP_ENDPOINT%"=="" (
+  echo 请先在 .env 中填入真实的 MCP_ENDPOINT
+  exit /b 1
+)
+
+if not exist .venv (
+  echo 创建虚拟环境并安装依赖...
+  python -m venv .venv
+  call .venv\Scripts\activate.bat
+  pip install -r requirements.txt
+) else (
+  call .venv\Scripts\activate.bat
+)
+
+echo 启动 MCP Server,桥接到小智AI...
+python mcp_pipe.py audio_server.py

+ 33 - 0
mcp-for-xiaozhi/start.sh

@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+# 小智AI MCP Server 启动脚本(macOS/Linux)
+set -e
+cd "$(dirname "$0")"
+
+if [ ! -f .env ]; then
+  echo "⚠️  未发现 .env,已复制 .env.example 为 .env,请填入 MCP_ENDPOINT 后再启动"
+  cp .env.example .env
+fi
+
+# 加载 .env
+set -a
+# shellcheck disable=SC1091
+source .env
+set +a
+
+if [ -z "${MCP_ENDPOINT:-}" ] || [[ "$MCP_ENDPOINT" == *"your_token_here"* ]]; then
+  echo "❌ 请先在 .env 中填入真实的 MCP_ENDPOINT"
+  exit 1
+fi
+
+# 安装依赖(首次)
+if [ ! -d .venv ]; then
+  echo "🔧 创建虚拟环境并安装依赖..."
+  python3 -m venv .venv
+  .venv/bin/pip install -r requirements.txt
+fi
+
+# shellcheck disable=SC1091
+source .venv/bin/activate
+
+echo "🚀 启动 MCP Server,桥接到小智AI..."
+exec python mcp_pipe.py audio_server.py

+ 169 - 0
mcp-for-xiaozhi/tests/chapter-read.test.py

@@ -0,0 +1,169 @@
+"""
+MCP read_chapter 工具测试 + tools/list schema 字节断言
+
+覆盖:
+  1. schema 总字节 < 1024(小智限制)
+  2. read_chapter 客户端参数校验
+  3. format=text → RESPONSE 模式
+  4. format=audio(已有 full 音频)→ 返回 audio_url
+  5. format=audio(on_demand)→ 返回 audio_source=on_demand
+  6. 后端 code=2002 → MCP 自动降级到 text
+  7. 后端 code=2001 → _err
+  8. TOOL_DISPATCH / TOOLS 注册正确
+"""
+from __future__ import annotations
+import json
+import sys
+import os
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, os.path.dirname(HERE))
+
+import audio_server as A
+
+
+def parse_result(r):
+    """解 _ok 包装:{success, result: '<json-string>'} → dict"""
+    if r.get("success") and r.get("result"):
+        try:
+            return json.loads(r["result"])
+        except Exception:
+            return {}
+    return {}
+
+
+def fake_http_get(path, params=None):
+    return {
+        "code": fake_http_get.code,
+        "message": fake_http_get.msg or "success",
+        "data": fake_http_get.data,
+    }
+
+
+A._http_get = fake_http_get
+
+
+def reset(code=0, data=None, msg=None):
+    fake_http_get.code = code
+    fake_http_get.data = data
+    fake_http_get.msg = msg
+
+
+def assert_true(cond, msg):
+    if not cond:
+        raise AssertionError("FAIL: " + msg)
+    print("[PASS]", msg)
+
+
+# ============ 测试用例 ============
+
+def test_schema_size():
+    size = len(json.dumps(A.TOOLS, ensure_ascii=False).encode("utf-8"))
+    assert_true(size < 1024, f"tools/list < 1024 bytes (actual {size})")
+
+
+def test_required_chapter_id():
+    r = A.tool_read_chapter({})
+    assert_true(r.get("success") is False, "no chapter_id -> _err")
+
+
+def test_invalid_format():
+    r = A.tool_read_chapter({"chapter_id": 1, "format": "xml"})
+    assert_true(r.get("success") is False, "invalid format -> _err")
+
+
+def test_format_text():
+    reset(code=0, data={
+        "mode": "text", "chapter_id": 100, "book_id": 1, "title": "DiYiHui",
+        "text": "hua shuo tian xia da shi ...", "has_more": False, "next_offset": None,
+        "nav": {"chapter_index": 1, "total_chapters": 5, "prev_chapter_id": None, "next_chapter_id": 101},
+    })
+    r = A.tool_read_chapter({"chapter_id": 100, "format": "text"})
+    assert_true(r.get("action") == "RESPONSE", "format=text -> action=RESPONSE")
+    assert_true("DiYiHui" in r["response"], "response contains title")
+    assert_true(r.get("next_chapter_id") == 101, "nav next_chapter_id present")
+
+
+def test_format_audio_full():
+    reset(code=0, data={
+        "mode": "audio", "chapter_id": 100, "book_id": 1, "title": "T",
+        "audio_url": "https://oss.example.com/full.mp3",
+        "audio_duration": 320, "audio_source": "full",
+        "nav": {"chapter_index": 1, "total_chapters": 5, "prev_chapter_id": None, "next_chapter_id": 101},
+    })
+    r = A.tool_read_chapter({"chapter_id": 100, "format": "audio"})
+    assert_true(r.get("success") is True, "format=audio -> success")
+    inner = parse_result(r)
+    assert_true(inner.get("audio_url") == "https://oss.example.com/full.mp3", "audio_url correct")
+    assert_true(inner.get("audio_source") == "full", "audio_source=full")
+    assert_true("read_chapter" in inner.get("action", ""), "nav action hint")
+
+
+def test_format_audio_on_demand():
+    reset(code=0, data={
+        "mode": "audio", "chapter_id": 100, "book_id": 1, "title": "T",
+        "audio_url": "https://oss.example.com/sync.mp3",
+        "audio_duration": 30, "audio_source": "on_demand",
+        "nav": {"chapter_index": 1, "total_chapters": 1, "prev_chapter_id": None, "next_chapter_id": None},
+    })
+    r = A.tool_read_chapter({"chapter_id": 100, "format": "audio"})
+    inner = parse_result(r)
+    assert_true(inner.get("audio_source") == "on_demand", "audio_source=on_demand")
+    assert_true(inner.get("action", "") != "", "non-empty action for last chapter")
+
+
+def test_tts_fallback_to_text():
+    """后端 code=2002 -> MCP 自动降级到 format=text"""
+    call_count = [0]
+
+    def mock_with_fallback(path, params=None):
+        call_count[0] += 1
+        if call_count[0] == 1:
+            return {"code": 2002, "message": "TTS_FALLBACK_FAILED", "data": None}
+        return {"code": 0, "message": "success", "data": {
+            "mode": "text", "chapter_id": 100, "book_id": 1, "title": "T",
+            "text": "fallback text", "has_more": False, "next_offset": None,
+            "nav": {"chapter_index": 1, "total_chapters": 1, "prev_chapter_id": None, "next_chapter_id": None},
+        }}
+
+    A._http_get = mock_with_fallback
+    r = A.tool_read_chapter({"chapter_id": 100, "format": "audio"})
+    assert_true(r.get("action") == "RESPONSE", "2002 fallback -> RESPONSE mode")
+    assert_true("fallback" in r.get("response", "").lower(), "fallback text content")
+    assert_true(call_count[0] == 2, "called 2 times (retry after failure)")
+    A._http_get = fake_http_get
+
+
+def test_other_error_returns_err():
+    A._http_get = lambda p, q=None: {"code": 2001, "message": "CHAPTER_NOT_FOUND", "data": None}
+    r = A.tool_read_chapter({"chapter_id": 999, "format": "audio"})
+    assert_true(r.get("success") is False, "2001 -> _err")
+    A._http_get = fake_http_get
+
+
+def test_dispatch_registered():
+    assert_true("read_chapter" in A.TOOL_DISPATCH, "TOOL_DISPATCH has read_chapter")
+    assert_true(any(t["name"] == "read_chapter" for t in A.TOOLS), "TOOLS has read_chapter")
+
+
+def main():
+    tests = [
+        test_schema_size, test_required_chapter_id, test_invalid_format,
+        test_format_text, test_format_audio_full, test_format_audio_on_demand,
+        test_tts_fallback_to_text, test_other_error_returns_err, test_dispatch_registered,
+    ]
+    passed = 0
+    failed = 0
+    for t in tests:
+        try:
+            t()
+            passed += 1
+        except AssertionError as e:
+            failed += 1
+            print("[FAIL]", t.__name__, "->", e)
+    print(f"\n{passed} passed, {failed} failed")
+    sys.exit(0 if failed == 0 else 1)
+
+
+if __name__ == "__main__":
+    main()

+ 25 - 0
mcp-for-xiaozhi/verify_all.py

@@ -0,0 +1,25 @@
+"""
+verify_all.py — 一键跑两套测试
+  1. verify_local:MCP 工具注册 + stdio 调用(无后端可跑)
+  2. verify_pipe:完整 stdio <-> WebSocket 桥接端到端
+"""
+import subprocess, sys, os
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+PYTHON = os.path.join(HERE, ".venv", "Scripts", "python.exe")
+
+def run(name):
+    print(f"\n========== {name} ==========")
+    r = subprocess.run([PYTHON, os.path.join(HERE, name)], cwd=HERE)
+    return r.returncode
+
+if __name__ == "__main__":
+    fails = []
+    for n in ["verify_local.py", "verify_pipe.py"]:
+        if run(n) != 0:
+            fails.append(n)
+    print("\n========== SUMMARY ==========")
+    if fails:
+        print(f"FAILED: {fails}")
+        sys.exit(1)
+    print("ALL PASS [OK]")

+ 75 - 0
mcp-for-xiaozhi/verify_local.py

@@ -0,0 +1,75 @@
+"""
+本地验证脚本:直接调用 audio_server 里注册的 MCP 工具(不经过 WebSocket)
+============================================================
+
+用法(在 mcp-for-xiaozhi 目录下):
+    python verify_local.py
+
+要求:
+    - 后端 audiobook 服务跑在 AUDIOBOOK_API_BASE(默认 127.0.0.1:3000)
+    - 已安装依赖:pip install -r requirements.txt
+
+它会:
+    1. import audio_server 注册到 FastMCP 实例的内部 tool 列表
+    2. 通过 mcp.ClientSession 用 stdio 启动 audio_server,列工具 + 调用若干工具
+"""
+from __future__ import annotations
+
+import asyncio
+import os
+import sys
+
+from mcp import ClientSession, StdioServerParameters
+from mcp.client.stdio import stdio_client
+
+
+async def main() -> int:
+    here = os.path.dirname(os.path.abspath(__file__))
+    server_path = os.path.join(here, "audio_server.py")
+    api_base = os.environ.get("AUDIOBOOK_API_BASE", "http://127.0.0.1:3000")
+    print(f"[verify] api_base = {api_base}")
+
+    params = StdioServerParameters(
+        command=sys.executable,
+        args=[server_path],
+        env={**os.environ, "MCP_LOG_LEVEL": "WARNING"},
+    )
+
+    async with stdio_client(params) as (read, write):
+        async with ClientSession(read, write) as session:
+            await session.initialize()
+
+            tools = await session.list_tools()
+            print(f"[verify] list_tools -> {len(tools.tools)} tools")
+            for t in tools.tools:
+                print(f"  - {t.name}: {t.description[:60] if t.description else ''}")
+
+            # 用例 1:搜书
+            r = await session.call_tool("search_audiobooks", {"keywords": "三国"})
+            print("\n[verify] search_audiobooks('三国') ->")
+            print((r.content[0].text if r.content else "")[:400])
+
+            # 用例 2:分类
+            r = await session.call_tool("list_categories", {})
+            print("\n[verify] list_categories ->")
+            print((r.content[0].text if r.content else "")[:400])
+
+            # 用例 3:详情(用第一个搜索结果的 id)
+            try:
+                import json
+                search_text = (await session.call_tool("search_audiobooks", {"keywords": "三国"})).content[0].text
+                payload = json.loads(search_text)
+                first_id = (payload.get("items") or [{}])[0].get("id")
+                if first_id:
+                    r = await session.call_tool("get_book_details", {"book_id": int(first_id)})
+                    print(f"\n[verify] get_book_details({first_id}) ->")
+                    print((r.content[0].text if r.content else "")[:400])
+            except Exception as e:  # noqa: BLE001
+                print(f"[verify] get_book_details skipped: {e}")
+
+    print("\n[verify] OK")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(asyncio.run(main()))

+ 85 - 0
mcp-for-xiaozhi/verify_pipe.py

@@ -0,0 +1,85 @@
+"""
+verify_pipe.py — 真实联调自检(接真小智AI)
+
+替代旧的 mock-W 版本:mcp_pipe 改成 in-process 后,没有跨进程桥接可测了。
+现在直接 spawn mcp_pipe.py 进程(不传 audio_server 脚本,因为它被 import 了),
+让它连真实 xiaozhi.me,等握手完成 + tools/list round-trip + ping 一次。
+"""
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+import time
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+PY = os.path.join(HERE, ".venv", "Scripts", "python.exe")
+env_path = os.path.join(HERE, ".env")
+
+
+def main() -> int:
+    if not os.path.exists(env_path):
+        print("FAIL: .env 不存在")
+        return 1
+    if not os.path.exists(PY):
+        print(f"FAIL: 找不到 {PY}")
+        return 1
+
+    # 加载 .env
+    with open(env_path, encoding="utf-8") as f:
+        for line in f:
+            line = line.strip()
+            if not line or line.startswith("#") or "=" not in line:
+                continue
+            k, v = line.split("=", 1)
+            os.environ.setdefault(k.strip(), v.strip().strip("'\""))
+
+    if "MCP_ENDPOINT" not in os.environ or "your_token_here" in os.environ.get("MCP_ENDPOINT", ""):
+        print("FAIL: .env 里 MCP_ENDPOINT 没填")
+        return 1
+
+    print(f"[verify_pipe] launching mcp_pipe.py (real xiaozhi.me) ...")
+    proc = subprocess.Popen(
+        [PY, "mcp_pipe.py"],
+        cwd=HERE,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+        env=os.environ,
+        bufsize=0,
+    )
+    deadline = time.time() + 70  # xiaozhi 30s 后才发 ping,留足余量
+    saw_init = False
+    saw_tools_list = False
+    saw_ping = False
+    try:
+        while time.time() < deadline:
+            line = proc.stdout.readline()
+            if not line:
+                break
+            text = line.decode("utf-8", errors="replace").rstrip()
+            if not text:
+                continue
+            print(text)
+            if "response for initialize" in text:
+                saw_init = True
+            elif "response for tools/list" in text:
+                saw_tools_list = True
+            elif "response for ping" in text:
+                saw_ping = True
+                break  # 一个 ping 就够了
+    finally:
+        proc.terminate()
+        try:
+            proc.wait(timeout=3)
+        except subprocess.TimeoutExpired:
+            proc.kill()
+
+    if saw_init and saw_tools_list and saw_ping:
+        print("\n[verify_pipe] PASS: initialize + tools/list + ping all round-tripped")
+        return 0
+    print(f"\n[verify_pipe] FAIL: init={saw_init} tools_list={saw_tools_list} ping={saw_ping}")
+    return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())