Files
govin 5a6461eeec docs: describe the MCP surface and how to connect a client
`docs/MCP.md` is the manual a person needs to put this in front of an agent:
the 29 tools with their arguments, the workflow ("generate, then write it in"
in one call, versus paragraph by paragraph), the error contract, and the exact
configuration for the three clients in play — the Harness's
`cordis.patch.yml`, Claude Code's `claude mcp add` and `.mcp.json`, and Codex's
`config.toml` — including the note that a new Harness entry has to sit inside
the `insert:` list or it is silently treated as an override of an entry that
does not exist.

`overview.md` gains the section that belongs in a design document rather than a
manual: why the MCP surface is shaped differently from the REST one, and the
two rules a new tool has to follow — keep the docstring to a line or two,
because it is sent with every request, and register with
`structured_output=False`, because the inferred envelope sends the same JSON
twice and clients disagree about which copy counts.
2026-09-19 00:01:47 +08:00

21 KiB
Raw Permalink Blame History

MCP 服务(paper-doc-mcp

把 paper-doc 的论文、段落、句子、模板、字段库能力暴露成 Model Context Protocol 工具, 让 Claude Code、Codex、DeepSeek HarnessDSH)等任何 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 自检(先确认能连数据库)

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

/data/project/paper/backend/.venv/bin/python \
  /data/project/paper/backend/scripts/mcp_server.py

从任何工作目录都能跑(脚本自己锚定 sys.path),所以客户端不需要配 cwd

1.3 streamable-http(推荐给远程/常驻)

cd /data/project/paper/backend
.venv/bin/python scripts/mcp_server.py \
  --transport http --host 0.0.0.0 --port 8931 --token '你的口令'
  • 端点:http://<host>: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 跑冒烟测试

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 HarnessDSH

编辑 ~/.dsh/profiles/web/cordis.patch.yml新条目必须放进 insert: 列表 (裸条目会被当成"覆盖已有 id"profile 里没有该 id 时只 warn 后静默跳过)。

stdio(Harness 自己拉起进程,最简单):

- 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(服务常驻,多个客户端共用):

- 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

claude mcp add paper -- /data/project/paper/backend/.venv/bin/python \
  /data/project/paper/backend/scripts/mcp_server.py

远程 HTTP(带口令):

claude mcp add --transport http paper http://192.168.1.88:8931/mcp \
  --header "Authorization: Bearer 你的口令"

或者项目根目录放 .mcp.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 listclaude mcp get paper、会话里 /mcp

2.3 Codex CLI

编辑 ~/.codex/config.toml

本地 stdio~/.codex/config.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

[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 文档

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 论文 papers9 个)

工具 作用 关键参数
paper_list 列论文(不含正文),返回 id/标题/模板/进度 keyword? status? template_id? page? page_size?
paper_get 取一篇论文的元信息与进度 paper_id
paper_create 新建论文(只写元信息) titletemplate_id? author? status? keywords? target_journal?
paper_update 改元信息 / 换模板 paper_idtitle? template_id? clear_template? author? status? keywords? target_journal?
paper_delete 删论文(连句子引用) paper_idsint 或数组)、confirm=true 必填
paper_outline 写之前先看:每段 position/标题/已写句数 paper_id
paper_document 读全文 paper_idformat?text/json/bothinclude_empty? citations?
paper_write 批量写段落(按标题或位置) paper_idparagraphsmode?replace/append
paper_write_text 整篇一次写入Markdown 标题分段) paper_idtextsplit? mode? strict?

paper_update 的语义:不传 = 不改;传空字符串 = 清空该文本字段;clear_template: true 才能把模板置空。 换 template_id 只是重塑标题,正文按位置留在原地,不丢。

paper_delete 必须先被拒一次(告诉你删掉什么、不可恢复),确认后才带 confirm=true 重调。

3.2 段落 paragraphs5 个)

段落 = 位置。它由模板定义,或者由内容占据;没有 paragraph_create——往空位置写内容就是创建它。

工具 作用 关键参数
paragraph_list 段落骨架 + 预览 paper_idinclude_empty? preview?
paragraph_get 读一段:拼好的正文、逐句 id/内容/引用/分隔符 paper_idposition? heading?
paragraph_write 写一段(覆盖或追加) paper_idposition? heading?sentences? text? split? citations? mode?
paragraph_delete 清空该段全部句子(段落仍在) paper_idposition? heading?
paragraph_move 整段搬到另一个位置(接在目标段之后) paper_idposition? heading?target_position? target_heading?

定位规则:position 直接采用(包括模板没定义的位置,会以"未设定"渲染); heading 按模板解析,忽略编号、空格和大小写,所以下面三种写法等价:

"1. Introduction"   /   "1 Introduction"   /   "Introduction"

匹配不上或匹配到多个时,错误消息里会带上模板里真实可用的标题列表 模型据此改一次就能成功。

3.3 句子 sentences5 个)

一句一行。改一句话不要重写整段——重写会让整段的句子 id 全变,而引用是挂在 id 上的。

工具 作用 关键参数
sentence_list 列句子(可只列某段) paper_idposition? heading? include_empty?
sentence_add 段尾追加一句(可带引用) paper_idcontentposition? heading? citations? sort?
sentence_update 改正文 / 改所属段 / 改排序 / 整组换引用 paper_idsentence_idcontent? position? sort? citations?
sentence_delete 删一句(连引用) paper_idsentence_id
sentence_search 跨论文搜句子(保持术语一致) keywordpaper_id? limit?

3.4 模板 templates5 个)

模板就是结构:论文的标题是每次渲染现读模板得到的,所以改模板会影响所有用它的论文, 删模板会被拒绝(错误消息会说明有几篇论文在用、该怎么换)。

工具 作用 关键参数
template_list 列模板(含字段数) keyword? page? page_size?
template_get 取完整大纲 + 被几篇论文使用 template_id
template_create 新建模板 nameabstract?field_ids?(按顺序)或 fields?[{field_id, sort}]
template_update 改名/改说明/整组换字段 template_idname? abstract? clear_abstract? field_ids? fields?
template_delete 删模板 template_idsint 或数组)

field_ids: [2, 6, 9] 会自动编号成 sort = 10, 20, 30——留出间隙, 以后想插一个字段不用重排其余所有位置。

3.5 字段库 fields5 个)

字段库是"模板能用的所有标题"的目录,全局共享。编号写在 name 里,由人决定,系统不改写; level 只影响缩进,不构成父子关系——所以同一个二级标题可以出现在多个一级标题下。

工具 作用 关键参数
field_list 列字段库(建模板前先查 id keyword? level? page? page_size?
field_get 取一个字段 + 被几个模板使用 field_id
field_create 加一个标题字段 namelevel? font_size? font_color?
field_update 改名/层级/字号/颜色 field_idname? level? font_size? font_color?
field_delete 删字段(仍被模板使用会被拒) field_idsint 或数组)

4. 典型用法

4.1 AI 写完,整篇一次灌进去(主推)

// 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 分批写(更长、更可控)

{"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 微调:只动一句话

{"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 保持术语一致

{"name": "sentence_search", "arguments": {"keyword": "洪水损失", "limit": 20}}
// → 全库已写过的相关句子(带 paper_id / sentence_id),用来对齐说法

4.5 换模板(重塑结构但不丢正文)

{"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 从零建一套模板

{"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: truetext 是人话,例如:

    场景 消息
    找不到 论文 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 同时放进 structuredContentcontent,一半客户端只读其中一个—— 不如统一成文本,所有客户端都读得到。


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_movesentence_search


8. 排障

现象 原因 / 处理
客户端显示连不上、工具列表为空 先跑 --check;多数是数据库不通(backend/.envDB_*
HTTP 返回 401 口令没带或不对,检查 Authorization: Bearer <token>
HTTP 返回 421 绑定了非 127.0.0.1 但没走本次启动的 DNS-rebinding 关闭逻辑——确认用的是本项目入口,别包了一层反向代理改 Host
DSH 里工具不出现 检查 cordis.patch.yml 的条目是否写在 insert: 里;failOnStartupError: false 会把启动期连接失败吞掉,看 dsh-web 日志确认
工具调用超时 DSH/Codex 侧加大 toolCallTimeoutMs / tool_timeout_secpaper_write_text 写整篇会慢一些
日志里出现非 JSON 的 stdout 输出 不要在这个进程里 print——stdio 传输下会污染协议流(本项目的诊断都走 stderr)