Files
book-mcp/book_mcp_server.py
Quentin 9b0910ccb1 Initial commit: book-mcp 教辅处理 MCP server + Arco Vue 管理端
后端 (book_mcp_server.py, 335 行, 15 个 MCP 工具):
- create_book / add_document / mark_html_ready / mark_json_ready / mark_failed
- update_book_status / soft_delete_book / retry_failed
- get_book / list_books / get_document / list_documents / get_document_url
- upload_to_rustfs / health
- 走 JSON-RPC + stateless_http + json_response(前端可直接调)
- launchd 守护 / 6 秒拉起(macOS)
- systemd 守护脚本(Linux/codelab)

存储:
- MongoDB (mongo:8.0 容器) - book/book_document/document_file 三表
- RustFS 对象存储(fs.code-lab.cn/scan/)- book/<id>/docs/<doc_id>/{docx,html,json}
- 幂等设计: zip_sha256 唯一 + (book_id+original_path) 唯一

前端 (Vue 3 + TS + Arco Design):
- 列表页 /book/list - 搜索 + 状态过滤 + 进度
- 详情页 /book/:id - 文档折叠 + 文件下载
- MCP 客户端 src/api/mcp.ts (JSON-RPC over fetch)

部署:
- deploy/install.sh (codelab 一键装 backend + systemd)
- deploy/install-frontend.sh (vite dev / 生产构建)
- docs/deploy.md (运维手册)

测试:
- tests/test_basic.py (sanity 7 项)
- tests/test_e2e.py (完整 e2e: docx→HTML→入库→上传→校验)
2026-10-11 18:03:57 +08:00

340 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""book-mcp — 教辅处理 MCP 服务(v1 最小可用,MongoDB 后端)
启动:/tmp/wordparse_venv/bin/python book_mcp_server.py
工具数:8 + 1(health)
"""
import os
import sys
import logging
# 让 services / db 包能 import
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import yaml
from fastmcp import FastMCP
from db.database import load_config
from db import get_collection, COL_DOC, health as db_health
from services import (
book_service, document_service, file_service,
rustfs_service, wordparse,
)
# ---- 日志 ----
_cfg = load_config()
logging.basicConfig(level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s')
log = logging.getLogger('book-mcp')
# ---- MCP server ----
mcp = FastMCP('book-mcp')
# ============================================================
# 1. create_book — 创建教辅
# ============================================================
@mcp.tool()
def create_book(name: str, zip_path: str, zip_sha256: str, zip_size: int = 0,
subject: str | None = None, grade: str | None = None,
version: str | None = None, type: str | None = None,
created_by: str | None = None) -> dict:
"""创建教辅(按 zip_sha256 幂等)。
Args:
name: 教辅名("2027版.曲一线3•2物理.广西版.一轮复习-突破训练")
zip_path: 对象存储里的 zip 路径
zip_sha256: zip 文件 sha256(去重用)
zip_size: 字节数
subject/grade/version/type: 学科/年级/版本/类型(可选)
created_by: 创建者(Agent 名 / 用户名)
Returns: {ok, book_id, status, total_count, idempotent}
"""
return book_service.create_book(
name=name, zip_path=zip_path, zip_sha256=zip_sha256, zip_size=zip_size,
subject=subject, grade=grade, version=version, type_=type,
created_by=created_by,
)
# ============================================================
# 2. add_document — 添加文档
# ============================================================
@mcp.tool()
def add_document(book_id: int, original_filename: str, original_path: str,
title: str | None = None, doc_type: str | None = None) -> dict:
"""向教辅添加一份文档(按 (book_id, original_path) 幂等)。
Args:
book_id: 教辅 ID
original_filename: 原始文件名(含扩展名)
original_path: zip 内的相对路径
title: 文档标题(可选)
doc_type: 文档类型(自动从文件名推断)
Returns: {ok, doc_id, order_index, status, doc_type, idempotent}
"""
return document_service.add_document(
book_id=book_id, original_filename=original_filename,
original_path=original_path, title=title, doc_type=doc_type,
)
# ============================================================
# 3. mark_html_ready — 标记 HTML 已上传
# ============================================================
@mcp.tool()
def mark_html_ready(doc_id: str, html_url: str, html_size: int,
formulas: int = 0, images: int = 0, chars: int = 0) -> dict:
"""标记文档 HTML 已就绪。
Args:
doc_id: 文档 ID
html_url: HTML 的对象存储 URL
html_size: 字节数
formulas/images/chars: 统计信息
Returns: {ok, file_id, object_key, url}
"""
stats = {'formulas': formulas, 'images': images, 'chars': chars}
r = document_service.mark_html_ready(
doc_id=doc_id, html_url=html_url, html_size=html_size, stats=stats,
)
if r.get('ok'):
d = get_collection(COL_DOC).find_one({'_id': doc_id})
if d:
book_service.recompute_book_stats(d['book_id'])
return r
# ============================================================
# 4. mark_json_ready — 标记 JSON 已上传
# ============================================================
@mcp.tool()
def mark_json_ready(doc_id: str, json_url: str, json_size: int,
schema_version: str | None = None) -> dict:
"""标记文档结构化 JSON 已就绪。
Args:
doc_id: 文档 ID
json_url: JSON 的对象存储 URL
json_size: 字节数
schema_version: JSON schema 版本(可选)
Returns: {ok, file_id, object_key, url}
"""
return document_service.mark_json_ready(
doc_id=doc_id, json_url=json_url, json_size=json_size,
schema_version=schema_version,
)
# ============================================================
# 5. mark_failed — 标记文档处理失败
# ============================================================
@mcp.tool()
def mark_failed(doc_id: str, error_msg: str, stage: str = 'html') -> dict:
"""标记文档处理失败。
Args:
doc_id: 文档 ID
error_msg: 失败原因
stage: 失败阶段(html / json / upload)
Returns: {ok}
"""
r = document_service.mark_failed(doc_id=doc_id, error_msg=error_msg, stage=stage)
if r.get('ok'):
d = get_collection(COL_DOC).find_one({'_id': doc_id})
if d:
book_service.recompute_book_stats(d['book_id'])
return r
# ============================================================
# 6. update_book_status — 更新教辅状态
# ============================================================
@mcp.tool()
def update_book_status(book_id: int, status: str, error_msg: str | None = None) -> dict:
"""更新教辅状态。
Args:
book_id: 教辅 ID
status: pending/processing/done/failed/partial
error_msg: 错误信息(可选)
Returns: {ok}
"""
ok = book_service.update_book_status(book_id, status, error_msg)
return {'ok': ok}
# ============================================================
# 7. get_book — 查教辅(含所有 doc + file)
# ============================================================
@mcp.tool()
def get_book(book_id: int) -> dict:
"""查教辅完整信息(教辅 + 所有文档 + 每个文档的所有文件)。
Args:
book_id: 教辅 ID
Returns: 完整 book dict(documents 嵌套 files),或 None
"""
return book_service.get_book(book_id, include_documents=True)
# ============================================================
# 8. list_books — 列表查询(分页)
# ============================================================
@mcp.tool()
def list_books(status: str | None = None, page: int = 1, page_size: int = 20,
keyword: str | None = None) -> dict:
"""列出教辅(分页)。
Args:
status: 状态过滤(pending/processing/done/failed/partial)
page: 页码(从 1 开始)
page_size: 每页条数
keyword: 关键词(name/subject/grade/version 模糊匹配)
Returns: {books[], total, page, page_size}
"""
return book_service.list_books(status=status, page=page,
page_size=page_size, keyword=keyword)
# ============================================================
# 9. get_document — 查单文档(含所有 file)
# ============================================================
@mcp.tool()
def get_document(doc_id: str) -> dict:
"""查单文档(所有 file 一起返)。
Args:
doc_id: 文档 ID(16 字符 sha1)
Returns: 完整 document dict(files[]),或 None
"""
return document_service.get_document(doc_id, include_files=True)
# ============================================================
# 10. list_documents — 文档列表(按 book_id 分页)
# ============================================================
@mcp.tool()
def list_documents(book_id: int, status: str | None = None,
page: int = 1, page_size: int = 50) -> dict:
"""列出教辅的所有文档(按 order_index 排序,分页)。
Args:
book_id: 教辅 ID
status: 状态过滤(pending/html_ready/json_ready/failed)
page: 页码(从 1 开始)
page_size: 每页条数
Returns: {documents[], total, page, page_size}
"""
return document_service.list_documents(book_id, status=status,
page=page, page_size=page_size)
# ============================================================
# 11. get_document_url — 查文档某个 file 的 URL
# ============================================================
@mcp.tool()
def get_document_url(doc_id: str, file_type: str) -> dict:
"""查文档某个 file(html/json/docx/error_log)的 URL。
Args:
doc_id: 文档 ID
file_type: html / json / docx / error_log
Returns: {file_type, url, size, checksum} 或 None
"""
f = file_service.get_file_by_type(doc_id, file_type)
if not f:
return None
return {
'file_type': f['file_type'],
'url': f['url'],
'size': f['size'],
'checksum': f['checksum'],
}
# ============================================================
# 12. upload_to_rustfs — 直接上传本地文件到对象存储
# ============================================================
@mcp.tool()
def upload_to_rustfs(local_path: str, key: str,
bucket: str | None = None) -> dict:
"""上传本地文件到 RustFS(Agent 用的上传助手)。
Args:
local_path: 本地文件路径
key: 对象 key(如 'book/123/docs/abc/output.html')
bucket: 桶名(默认 config.yaml 的 default_bucket)
Returns: {url, key, bucket, size, checksum}
"""
if not os.path.exists(local_path):
return {'ok': False, 'error': f'file not found: {local_path}'}
return rustfs_service.upload(local_path, key, bucket=bucket)
# ============================================================
# 13. soft_delete_book — 软删教辅
# ============================================================
@mcp.tool()
def soft_delete_book(book_id: int) -> dict:
"""软删教辅(标记 is_deleted=1,不删对象存储)。
Args:
book_id: 教辅 ID
Returns: {ok}
"""
ok = book_service.soft_delete_book(book_id)
return {'ok': ok}
# ============================================================
# 14. retry_failed — 重试失败的文档
# ============================================================
@mcp.tool()
def retry_failed(book_id: int) -> dict:
"""把教辅下所有 failed 文档改回 pending,方便重跑。
Args:
book_id: 教辅 ID
Returns: {ok, retried_count}
"""
from db import get_collection, COL_DOC
col = get_collection(COL_DOC)
r = col.update_many(
{'book_id': book_id, 'status': 'failed'},
{'$set': {'status': 'pending', 'error_msg': None}}
)
book_service.recompute_book_stats(book_id)
return {'ok': True, 'retried_count': r.modified_count}
# ============================================================
# health — 健康检查
# ============================================================
@mcp.tool()
def health() -> dict:
"""健康检查:wordparse 服务 + mongo"""
return {
'wordparse': wordparse.health(),
'mongo': db_health(),
}
# ---- main ----
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--port', type=int, default=8100)
parser.add_argument('--host', default='127.0.0.1')
parser.add_argument('--init-db', action='store_true', help='初始化 mongo 索引')
args = parser.parse_args()
if args.init_db:
from db import init_db
init_db()
sys.exit(0)
log.info(f"book-mcp 启动:http://{args.host}:{args.port}")
log.info(f"wordparse 服务: {_cfg['wordparse']['url']}")
# stateless_http + json_response:让前端跨域 JSON-RPC 调用不需要 SSE
mcp.run(transport='http', host=args.host, port=args.port,
stateless_http=True, json_response=True)