# MCP 服务(paper-doc-mcp) 把 paper-doc 的论文、段落、句子、模板、字段库能力暴露成 **Model Context Protocol** 工具, 让 Claude Code、Codex、DeepSeek Harness(DSH)等任何 MCP 客户端直接增删改查论文。 **一句话用法**:AI 生成完整论文 → 调 `paper_write_text` 一次写入 → 用 `paragraph_write` / `sentence_update` 微调 → `paper_document` 读回来检查。 - 代码:`backend/app/mcp/`(工具实现)、`backend/scripts/mcp_server.py`(入口) - 冒烟测试:`backend/scripts/smoke_mcp.py`(真 MCP 客户端跑 47 项检查,stdio 与 HTTP 都过) - 工具数量:**29 个**,按 `paper_` / `paragraph_` / `sentence_` / `template_` / `field_` 五组前缀 - 协议:MCP 2024-11-05 ~ 2025-11-25(官方 Python SDK `mcp>=2.2,<3` 协商) - 传输:`stdio`(本地拉起)与 `streamable-http`(远程连接,支持 Bearer 口令),另兼容旧 `sse` --- ## 1. 快速开始 ### 1.1 自检(先确认能连数据库) ```bash cd /data/project/paper/backend .venv/bin/python scripts/mcp_server.py --check ``` 输出示例: ``` paper-doc MCP 1.0.0 (name=paper-doc) database : mysql+pymysql://Govin:***@192.168.1.88:32738/paper_doc rows : paper=1, template=3, paper_sentence=6, template_field_library=20 tools : 29 field=5, paper=9, paragraph=5, sentence=5, template=5 - paper_list ... ``` 连不上数据库会打印失败原因并返回非 0——这是最常见的"工具调不动"根因, 所以接入任何客户端前先跑一次。 ### 1.2 stdio(推荐给本地客户端) 客户端自己拉起进程,通过 stdin/stdout 讲 JSON-RPC: ```bash /data/project/paper/backend/.venv/bin/python \ /data/project/paper/backend/scripts/mcp_server.py ``` 从任何工作目录都能跑(脚本自己锚定 `sys.path`),所以客户端不需要配 `cwd`。 ### 1.3 streamable-http(推荐给远程/常驻) ```bash cd /data/project/paper/backend .venv/bin/python scripts/mcp_server.py \ --transport http --host 0.0.0.0 --port 8931 --token '你的口令' ``` - 端点:`http://:8931/mcp` - 设了 `--token` 后,每个请求都要带 `Authorization: Bearer <口令>`,否则 `401` - 绑定非 127.0.0.1 时会自动关掉 DNS-rebinding 保护(否则客户端用局域网域名访问会被 421), 此时**口令就是唯一的门**——不设口令会打印警告 也可以用环境变量代替参数:`MCP_TRANSPORT` / `MCP_HOST` / `MCP_PORT` / `MCP_PATH` / `MCP_HTTP_TOKEN`。 ### 1.4 跑冒烟测试 ```bash cd /data/project/paper/backend .venv/bin/python scripts/smoke_mcp.py # 自己拉起 stdio 子进程 .venv/bin/python scripts/smoke_mcp.py --url http://127.0.0.1:8931/mcp --token 你的口令 # 连已有的 HTTP 服务 ``` 同一个套件 **47 项检查**跑两种传输,覆盖 18 个步骤:建论文 → 看骨架 → 按标题写段落 → 定位失败时列出候选标题 → 读段落/追加带引用的句子 → 只改一句 → 删一句 → 整篇一次写入 → 读全文 → 跨论文搜索 → 搬段落 → 清空段落 → 字段库增删改 → 建/改模板 → 换模板后正文不丢 → 删模板被拒 → 删论文要确认 → 清理。写真实数据,结束时自己删干净。 --- ## 2. 客户端配置 ### 2.1 DeepSeek Harness(DSH) 编辑 `~/.dsh/profiles/web/cordis.patch.yml`,**新条目必须放进 `insert:` 列表** (裸条目会被当成"覆盖已有 id",profile 里没有该 id 时只 warn 后静默跳过)。 stdio(Harness 自己拉起进程,最简单): ```yaml - insert: - id: mcp-paper-doc name: '@deepseek-ai/dsh-mcp-client' config: serverName: paper transport: stdio command: /data/project/paper/backend/.venv/bin/python args: - /data/project/paper/backend/scripts/mcp_server.py cwd: /data/project/paper/backend toolCallTimeoutMs: 60000 failOnStartupError: false # 连不上也不影响 Harness 启动 ``` streamable-http(服务常驻,多个客户端共用): ```yaml - insert: - id: mcp-paper-doc name: '@deepseek-ai/dsh-mcp-client' config: serverName: paper transport: streamable-http url: http://127.0.0.1:8931/mcp headers: Authorization: Bearer 你的口令 toolCallTimeoutMs: 60000 failOnStartupError: false ``` `serverName: paper` 决定了模型看到的工具名是 `mcp__paper__paper_write_text` 这种形式。 web profile 是 `patchReload: live`,保存后通常热加载;不行就重启 dsh-web。 ### 2.2 Claude Code 本地 stdio: ```bash claude mcp add paper -- /data/project/paper/backend/.venv/bin/python \ /data/project/paper/backend/scripts/mcp_server.py ``` 远程 HTTP(带口令): ```bash claude mcp add --transport http paper http://192.168.1.88:8931/mcp \ --header "Authorization: Bearer 你的口令" ``` 或者项目根目录放 `.mcp.json` 提交进仓库,团队直接共享: ```json { "mcpServers": { "paper": { "type": "stdio", "command": "/data/project/paper/backend/.venv/bin/python", "args": ["/data/project/paper/backend/scripts/mcp_server.py"], "env": {} } } } ``` 查看与调试:`claude mcp list`、`claude mcp get paper`、会话里 `/mcp`。 ### 2.3 Codex CLI 编辑 `~/.codex/config.toml`。 本地 stdio(`~/.codex/config.toml`): ```toml [mcp_servers.paper] command = "/data/project/paper/backend/.venv/bin/python" args = ["/data/project/paper/backend/scripts/mcp_server.py"] enabled = true # 可选:数据库配置(不写就读 backend/.env) [mcp_servers.paper.env] # DB_HOST = "192.168.1.88" # DB_PORT = "32738" ``` 远程 streamable-http: ```toml [mcp_servers.paper] url = "http://192.168.1.88:8931/mcp" bearer_token_env_var = "PAPER_MCP_TOKEN" # 口令放环境变量,别写进配置文件 enabled = true ``` 命令行管理:`codex mcp list` / `codex mcp add` / `codex mcp get paper`。 > 参考:[Codex MCP Servers 文档](https://mintlify.wiki/openai/codex/configuration/mcp-servers) ### 2.4 远程(Windows 上的 Claude Code / Codex 连这台服务器) 三种做法,任选: 1. **HTTP + 口令**(最省事):服务器上跑 HTTP 模式,客户端填 `http://192.168.1.88:8931/mcp` + `Authorization: Bearer ...`。 2. **SSH 包一层 stdio**:客户端 `command = "ssh"`,让远端执行 `mcp_server.py`, 协议走 SSH 的 stdin/stdout(见 ssh_intranet_skill 的 SSH_ASKPASS 写法)。 3. **Tailscale + HTTP**:走 `100.64.0.1` 内网地址,仍然建议带口令。 --- ## 3. 工具参考 约定: - 工具名即函数名;`(可选)` 的参数可以不传,其余必填。 - 返回一律是 **JSON 文本**(不是结构化对象),字段为 `None` 的一律不输出。 - 失败不是抛异常,而是 `isError: true` + 一句中日文都看得懂的说明(例如 `论文 999999 不存在`、`模板“X”已存在`、`请先用 paper_update 给论文切换模板:…`)。 ### 3.1 论文 papers(9 个) | 工具 | 作用 | 关键参数 | |---|---|---| | `paper_list` | 列论文(不含正文),返回 id/标题/模板/进度 | `keyword?` `status?` `template_id?` `page?` `page_size?` | | `paper_get` | 取一篇论文的元信息与进度 | `paper_id` | | `paper_create` | 新建论文(只写元信息) | `title`、`template_id?` `author?` `status?` `keywords?` `target_journal?` | | `paper_update` | 改元信息 / 换模板 | `paper_id`、`title?` `template_id?` `clear_template?` `author?` `status?` `keywords?` `target_journal?` | | `paper_delete` | 删论文(连句子引用) | `paper_ids`(int 或数组)、`confirm=true` 必填 | | `paper_outline` | **写之前先看**:每段 position/标题/已写句数 | `paper_id` | | `paper_document` | 读全文 | `paper_id`、`format?`(`text`/`json`/`both`)`include_empty?` `citations?` | | `paper_write` | **批量写段落**(按标题或位置) | `paper_id`、`paragraphs`、`mode?`(`replace`/`append`) | | `paper_write_text` | **整篇一次写入**(Markdown 标题分段) | `paper_id`、`text`、`split?` `mode?` `strict?` | `paper_update` 的语义:不传 = 不改;传空字符串 = 清空该文本字段;`clear_template: true` 才能把模板置空。 换 `template_id` 只是重塑标题,正文按位置留在原地,不丢。 `paper_delete` 必须先被拒一次(告诉你删掉什么、不可恢复),确认后才带 `confirm=true` 重调。 ### 3.2 段落 paragraphs(5 个) 段落 = 位置。它由模板定义,或者由内容占据;**没有 `paragraph_create`**——往空位置写内容就是创建它。 | 工具 | 作用 | 关键参数 | |---|---|---| | `paragraph_list` | 段落骨架 + 预览 | `paper_id`、`include_empty?` `preview?` | | `paragraph_get` | 读一段:拼好的正文、逐句 id/内容/引用/分隔符 | `paper_id`、`position?` `heading?` | | `paragraph_write` | 写一段(覆盖或追加) | `paper_id`、`position?` `heading?`、`sentences?` `text?` `split?` `citations?` `mode?` | | `paragraph_delete` | 清空该段全部句子(段落仍在) | `paper_id`、`position?` `heading?` | | `paragraph_move` | 整段搬到另一个位置(接在目标段之后) | `paper_id`、`position?` `heading?`、`target_position?` `target_heading?` | 定位规则:`position` 直接采用(包括模板没定义的位置,会以"未设定"渲染); `heading` 按模板解析,忽略编号、空格和大小写,所以下面三种写法等价: ``` "1. Introduction" / "1 Introduction" / "Introduction" ``` 匹配不上或匹配到多个时,错误消息里会带上**模板里真实可用的标题列表**, 模型据此改一次就能成功。 ### 3.3 句子 sentences(5 个) 一句一行。改一句话不要重写整段——重写会让整段的句子 id 全变,而引用是挂在 id 上的。 | 工具 | 作用 | 关键参数 | |---|---|---| | `sentence_list` | 列句子(可只列某段) | `paper_id`、`position?` `heading?` `include_empty?` | | `sentence_add` | 段尾追加一句(可带引用) | `paper_id`、`content`、`position?` `heading?` `citations?` `sort?` | | `sentence_update` | 改正文 / 改所属段 / 改排序 / 整组换引用 | `paper_id`、`sentence_id`、`content?` `position?` `sort?` `citations?` | | `sentence_delete` | 删一句(连引用) | `paper_id`、`sentence_id` | | `sentence_search` | **跨论文搜句子**(保持术语一致) | `keyword`、`paper_id?` `limit?` | ### 3.4 模板 templates(5 个) 模板就是结构:论文的标题是**每次渲染现读模板**得到的,所以改模板会影响所有用它的论文, 删模板会被拒绝(错误消息会说明有几篇论文在用、该怎么换)。 | 工具 | 作用 | 关键参数 | |---|---|---| | `template_list` | 列模板(含字段数) | `keyword?` `page?` `page_size?` | | `template_get` | 取完整大纲 + 被几篇论文使用 | `template_id` | | `template_create` | 新建模板 | `name`、`abstract?`、`field_ids?`(按顺序)或 `fields?`(`[{field_id, sort}]`) | | `template_update` | 改名/改说明/整组换字段 | `template_id`、`name?` `abstract?` `clear_abstract?` `field_ids?` `fields?` | | `template_delete` | 删模板 | `template_ids`(int 或数组) | `field_ids: [2, 6, 9]` 会自动编号成 `sort = 10, 20, 30`——留出间隙, 以后想插一个字段不用重排其余所有位置。 ### 3.5 字段库 fields(5 个) 字段库是"模板能用的所有标题"的目录,全局共享。编号写在 `name` 里,由人决定,系统不改写; `level` 只影响缩进,不构成父子关系——所以同一个二级标题可以出现在多个一级标题下。 | 工具 | 作用 | 关键参数 | |---|---|---| | `field_list` | 列字段库(建模板前先查 id) | `keyword?` `level?` `page?` `page_size?` | | `field_get` | 取一个字段 + 被几个模板使用 | `field_id` | | `field_create` | 加一个标题字段 | `name`、`level?` `font_size?` `font_color?` | | `field_update` | 改名/层级/字号/颜色 | `field_id`、`name?` `level?` `font_size?` `font_color?` | | `field_delete` | 删字段(仍被模板使用会被拒) | `field_ids`(int 或数组) | --- ## 4. 典型用法 ### 4.1 AI 写完,整篇一次灌进去(主推) ```jsonc // 1) 先看骨架,拿到真实标题 {"name": "paper_outline", "arguments": {"paper_id": 96}} // → {"paper": {...}, "paragraphs": [{"position": 10, "heading": "0 Abstract", "sentences": 0}, ...]} // 2) 把生成的论文按标题切好,一次写入 {"name": "paper_write_text", "arguments": { "paper_id": 96, "text": "# 0 Abstract\n摘要第一句。摘要第二句。\n\n# 1 Introduction\n引言第一句。引言第二句。\n\n## 2 Related Work\n相关工作……", "split": "sentence" // 中文长句按 。!? 切;一行一句则用 "line" }} // → {"paper_id": 96, "written": [{"position": 10, "heading": "0 Abstract", "sentences": 2}, ...], // "total_sentences": 24} ``` `split` 三种模式: | 模式 | 含义 | 什么时候用 | |---|---|---| | `line`(默认) | 一行 = 一句 | 文本本身已经一行一句(最保险,不猜) | | `sentence` | 按 `。!?` 和英文 `.?!` 切句 | 一整段挤在一行时的中文长文 | | `paragraph` | 整段 = 一句 | 你确实想让它是一句 | > `sentence` 会避开小数点(`3.14`)和 `et al.` / `i.e.` 这类缩写,但它仍然是"猜"。 > 想完全不猜,就直接给 `sentences` 数组。 标题识别两种形态:Markdown 标题行(`# 0 Abstract`),或**与模板完全同名的独立一行** (把 `paper_outline` 的标题原样抄下来最稳)。前面没有标题的内容会被丢弃; 一个都没匹配上则整次调用报错,不会写半截。 ### 4.2 分批写(更长、更可控) ```jsonc {"name": "paper_write", "arguments": { "paper_id": 96, "mode": "replace", "paragraphs": [ {"heading": "0 Abstract", "sentences": ["摘要第一句。", "摘要第二句。"]}, {"heading": "2 Related Work", "text": "相关工作这一段我整段给。第二句在这里。", "split": "sentence"}, {"position": 70, "sentences": [ {"content": "带引用的一句。", "citations": [{"quote": "被引用的原话", "reference_id": 12}]} ]} ] }} // → {"paper_id": 96, "written": [{"position": 10, "heading": "0 Abstract", "sentences": 2}, ...], // "total_sentences": 5} ``` `mode: "append"` 改成追加,不动该段已有内容。 ### 4.3 微调:只动一句话 ```jsonc {"name": "paragraph_get", "arguments": {"paper_id": 96, "heading": "1 Introduction"}} // → {"sentences": [{"id": 501, "sort": 1, "content": "…", "separator_before": ""}, …]} {"name": "sentence_update", "arguments": { "paper_id": 96, "sentence_id": 501, "content": "改写后的这一句。" }} // → 只改这一句,其余句子 id 不变,挂在它们身上的引用不受影响 ``` ### 4.4 保持术语一致 ```jsonc {"name": "sentence_search", "arguments": {"keyword": "洪水损失", "limit": 20}} // → 全库已写过的相关句子(带 paper_id / sentence_id),用来对齐说法 ``` ### 4.5 换模板(重塑结构但不丢正文) ```jsonc {"name": "paper_update", "arguments": {"paper_id": 96, "template_id": 3}} // → {"template_changed": true, ...} {"name": "paper_document", "arguments": {"paper_id": 96, "format": "json", "include_empty": true}} // → {"paragraphs": [{"position": 10, "heading": "摘要", "matched": true, ...}, // {"position": 70, "matched": false, ...}]} ← 新模板没有的位置仍在,标记未匹配 ``` ### 4.6 从零建一套模板 ```jsonc {"name": "field_list", "arguments": {"keyword": "方法", "page_size": 20}} {"name": "field_create", "arguments": {"name": "8. Limitations", "level": 1, "font_size": 12, "font_color": "#1F1F1F"}} {"name": "template_create", "arguments": { "name": "期刊 A 模板", "abstract": "投期刊 A 用", "field_ids": [1, 2, 6, 9, 14, 18] }} {"name": "paper_create", "arguments": {"title": "新论文", "template_id": 4}} ``` --- ## 5. 返回值与错误约定 - **成功**:`content[0].text` 是一段 JSON 字符串。例如 `paper_list` 返回 `{"items": [...], "total": 1, "page": 1, "page_size": 20, "pages": 1}`。 - **失败**:`isError: true`,`text` 是人话,例如: | 场景 | 消息 | |---|---| | 找不到 | `论文 999 不存在` | | 标题对不上 | `模板里没有标题“X”。可用标题:0 Abstract(10)、1 Introduction(20)…` | | 标题有二义 | `标题“X”匹配到多个段落(…),请写完整标题或直接用 position` | | 删除模板被拒 | `以下模板正被论文使用,请先用 paper_update 给论文切换模板:“…”(2 篇论文)` | | 删字段被拒 | `以下字段正被模板使用,请先用 template_update 从模板里移除:“…”(1 个模板)` | | 删论文没确认 | `…确认无误后请带 confirm=true 重新调用` | | 参数写错 | SDK 层的参数校验错误(列出哪个字段不合法) | 所有消息都写成"哪里错 + 怎么修",因为读它的是模型,它据此重试一次就能成功。 - **段落拼接**:`separator_before` 是服务端算好的段间分隔符(`""` 或一个空格)。 客户端打印 `separator_before + content`,**自己不要再加空格**。 --- ## 6. 设计说明(为什么是这样) **三个入口,一个领域层。** REST API、MCP 服务、seed 脚本最终都调用 `app.crud`, 校验都过 `app.schemas`。MCP 层不重新实现任何业务规则——在 CRUD 层修的规则, REST 和 MCP 同时生效。这让"网页里看到的"和"AI 写进去的"永远一致。 **工具结果是手搓的紧凑 JSON,不是 schema dump。** 工具结果按 token 计费, `PaperRead.model_dump()` 会把 `created_at`/`updated_at`/四个计数字段塞进每一行。 所以 `*_row` 只挑模型真正要读的字段,`clean()` 把 `None` 全部丢掉。 **段落按 position 存储,但工具接受 heading。** `paper_sentence` 记住"我在第 7 段" (`paper_template_filed_sort`),这是文档渲染需要的、也是换模板不丢正文的原因。 但没人知道"1. Introduction"恰好是 `sort = 20`,所以工具用 heading 也能定位, 由服务端翻译。两者都接受,冲突时以 `position` 为准。 **切句是显式选项,不是默认行为。** 项目原则是"编辑器里一行就是一句, 写入路径不切任何文本"(见 `docs/OVERVIEW.md`)。只有 `text` + `split` 这条路会切, 默认还是 `line`。已存文本永远不会被重新切分。 **删除要二次确认。** `paper_delete` 第一次调用必定被拒并说明后果, 因为一次误删会连带整篇的句子和引用,而这在 MCP 里没有回收站。 **结果不带结构化输出。** 工具用 `structured_output=False` 注册: 返回 `-> str` 时 SDK 会自动包一层 `{"result": ...}` 并把同一份 JSON 同时放进 `structuredContent` 和 `content`,一半客户端只读其中一个—— 不如统一成文本,所有客户端都读得到。 --- ## 7. 与 REST API 的对照 | 能力 | REST | MCP | |---|---|---| | 列论文 | `GET /api/papers` | `paper_list` | | 建论文 | `POST /api/papers` | `paper_create` | | 换模板 | `PATCH /api/papers/{id}` | `paper_update(template_id=…)` | | 读全文 | `GET /api/papers/{id}/document` | `paper_document` | | 读一段 | `GET /api/papers/{id}/paragraphs/{sort}` | `paragraph_get` | | 整段重写 | `PUT /api/papers/{id}/paragraphs/{sort}` | `paragraph_write` | | 加一句 | `POST /api/papers/{id}/sentences` | `sentence_add` | | 改一句 | `PATCH /api/papers/{id}/sentences/{id}` | `sentence_update` | | 搜句子 | —(MCP 专有) | `sentence_search` | | 建模板 | `POST /api/templates` | `template_create` | | 字段库 | `/api/template-field-library` | `field_*` | MCP 独有:`paper_outline`(写前看骨架)、`paper_write` / `paper_write_text`(批量写入)、 `paragraph_move`、`sentence_search`。 --- ## 8. 排障 | 现象 | 原因 / 处理 | |---|---| | 客户端显示连不上、工具列表为空 | 先跑 `--check`;多数是数据库不通(`backend/.env` 的 `DB_*`) | | HTTP 返回 401 | 口令没带或不对,检查 `Authorization: Bearer ` | | HTTP 返回 421 | 绑定了非 127.0.0.1 但没走本次启动的 DNS-rebinding 关闭逻辑——确认用的是本项目入口,别包了一层反向代理改 Host | | DSH 里工具不出现 | 检查 cordis.patch.yml 的条目是否写在 `insert:` 里;`failOnStartupError: false` 会把启动期连接失败吞掉,看 dsh-web 日志确认 | | 工具调用超时 | DSH/Codex 侧加大 `toolCallTimeoutMs` / `tool_timeout_sec`;`paper_write_text` 写整篇会慢一些 | | 日志里出现非 JSON 的 stdout 输出 | 不要在这个进程里 `print`——stdio 传输下会污染协议流(本项目的诊断都走 stderr) |