后端 (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→入库→上传→校验)
340 lines
12 KiB
Python
340 lines
12 KiB
Python
"""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) |