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.
This commit is contained in:
2026-09-19 00:01:47 +08:00
parent 2aff867641
commit 5a6461eeec
2 changed files with 539 additions and 0 deletions
+475
View File
@@ -0,0 +1,475 @@
# 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 自检(先确认能连数据库)
```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://<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 跑冒烟测试
```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 HarnessDSH
编辑 `~/.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 论文 papers9 个)
| 工具 | 作用 | 关键参数 |
|---|---|---|
| `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 段落 paragraphs5 个)
段落 = 位置。它由模板定义,或者由内容占据;**没有 `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 句子 sentences5 个)
一句一行。改一句话不要重写整段——重写会让整段的句子 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 模板 templates5 个)
模板就是结构:论文的标题是**每次渲染现读模板**得到的,所以改模板会影响所有用它的论文,
删模板会被拒绝(错误消息会说明有几篇论文在用、该怎么换)。
| 工具 | 作用 | 关键参数 |
|---|---|---|
| `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 字段库 fields5 个)
字段库是"模板能用的所有标题"的目录,全局共享。编号写在 `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 <token>` |
| 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) |
+64
View File
@@ -22,6 +22,7 @@ touching a single sentence.
- FastAPI (Python) — REST API
- SQLAlchemy + Alembic — ORM layer and schema migrations
- TiDB v8.5.0 — MySQL-compatible distributed SQL database, deployed in k3s
- MCP Python SDK — the same domain layer, exposed as MCP tools (see below)
- Dependencies managed with `venv` + `requirements.txt`
**Frontend**
@@ -303,6 +304,51 @@ Conventions worth knowing:
are accepted on write and de-duplicated.
- Paper `status` is one of `draft`, `writing`, `done`.
## MCP server
The same domain layer is also served over the **Model Context Protocol**, so an
agent — Claude Code, Codex, the DeepSeek Harness — can write a paper without a
browser. Full manual: [docs/MCP.md](MCP.md).
It is a third front door rather than a second implementation. The REST routes,
the MCP tools and the seed script all call `app.crud` and all validate through
`app.schemas`, so a rule fixed in the CRUD layer is fixed everywhere and a paper
written by an agent is indistinguishable from one written by hand. What
`app/mcp/` adds is only what a *model* needs and a browser does not:
| Addition | Why the REST shape is wrong for a model |
|---|---|
| 29 tools with `paper_` / `paragraph_` / `sentence_` / `template_` / `field_` prefixes | a model picks a tool out of a list by its name, not by reading 29 descriptions |
| JSON strings, `None`-free, no pagination envelope on reads | a tool result is paid for in context tokens |
| `paragraph_write` addressed by **heading** as well as position | nobody writing "1. Introduction" knows the template places it at `sort = 20` |
| `paper_write` / `paper_write_text` | "generate the paper, then put it in" is one intention, not thirty round trips |
| `paper_delete` refuses once before it deletes | a cascading delete has no undo in a tool call |
| `sentence_search` across papers | the job is consistency — one paper says 洪水损失, the next must not say GUL |
Two transports are served from one build: `stdio`, which is what a client
spawns, and `streamable-http`, which is what a client on another machine
connects to (behind a bearer token). The package is split so that only
`app/mcp/server.py` knows a transport exists:
```
app/mcp/
├── server.py transports, CLI, and the instructions sent at initialize
├── support.py sessions, JSON shaping, heading resolution, the one text splitter
├── specs.py the shapes a model may send (a paragraph, a sentence, a citation)
├── selfcheck.py --check: connect once and print the tool surface
└── tools/ papers, paragraphs, sentences, templates, fields — registered
by register_all(), each tool three lines around an app.crud call
```
Two decisions are worth knowing before adding a tool. **Results are registered
with `structured_output=False`**: inferred from a `-> str` annotation, the SDK
publishes a `{"result": …}` envelope and sends the JSON twice, once as
`structuredContent` and once as text, and clients that read only one of the two
then disagree about what the tool returned. And **the docstring is the tool
description verbatim**, sent with every request — so a tool docstring is one or
two lines and the reasoning goes in the module docstring, where it costs
nothing.
## Frontend
### Shell
@@ -431,11 +477,14 @@ paper-doc/
│ │ ├── core/ # settings and configuration
│ │ ├── crud/ # data-access helpers
│ │ ├── db/ # engine, session, declarative base
│ │ ├── mcp/ # MCP server: tools, specs, transports, self-check
│ │ ├── models/ # SQLAlchemy models
│ │ └── schemas/ # Pydantic request/response models
│ ├── alembic/ # migration environment and revisions
│ ├── scripts/seed.py # idempotent seed for the field library + templates
│ ├── scripts/smoke_papers.py # end-to-end check of the writing loop
│ ├── scripts/mcp_server.py # MCP entry point (stdio / http)
│ ├── scripts/smoke_mcp.py # the same loop, spoken over MCP by a real client
│ ├── alembic.ini
│ ├── requirements.txt
│ └── .env.example
@@ -520,3 +569,18 @@ exits non-zero on the first failed expectation and cleans up after itself:
cd backend
.venv/bin/python scripts/smoke_papers.py
```
**Checking the MCP server**
`scripts/mcp_server.py --check` connects to the database once and prints the
tool surface, which is the failure this catches: a client that spawned the
server successfully and then sees every call fail. `scripts/smoke_mcp.py`
drives the whole writing loop through a real MCP client — the same child
process and JSON-RPC over stdin/stdout that Claude Code, Codex and the Harness
use — and runs unchanged against `--url` for the HTTP transport:
```bash
cd backend
.venv/bin/python scripts/mcp_server.py --check
.venv/bin/python scripts/smoke_mcp.py
```