diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 0000000..5261786 --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,475 @@ +# 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) | diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index 2a993f9..8355782 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -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 +```