Files
govin 2aff867641 backend: serve the writing domain to agents as an MCP server
The paper is written by a model now. A browser is the wrong client for that:
the work is "generate the paper, then put it in", and doing it through a form
means a person retyping what a model already produced. So the same domain is
served over the Model Context Protocol, which Claude Code, Codex and the
DeepSeek Harness all speak.

It is a third front door, not a second implementation. Every tool is three
lines around an `app.crud` call and validates through `app.schemas`, exactly
as the REST routes do, so a rule fixed in the CRUD layer is fixed on both
surfaces 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:

- 29 tools, prefixed `paper_` `paragraph_` `sentence_` `template_` `field_`,
  because a model picks a tool out of a list by name rather than by reading 29
  descriptions;
- results as compact `None`-free JSON, since a tool result is paid for in
  context tokens and `PaperRead.model_dump()` carries four counts and two
  timestamps into every list row;
- paragraphs addressed by **heading** as well as by position. Storage is
  correct as it stands — a sentence remembers the position it sits at, which is
  what makes a template switch non-destructive — but nobody writing
  "1. Introduction" knows the template places it at `sort = 20`. The server
  translates, and refuses with the real heading list when it cannot, so a model
  that guessed wrong corrects itself in one retry;
- `paper_write` and `paper_write_text`: one intention, one call. The latter
  finds its own sections from Markdown headings or from lines that name a
  template heading, and reports every heading it could not place instead of
  writing half a paper;
- `sentence_search` across papers, for consistency rather than retrieval — a
  paper that says 洪水损失 should not be joined by one that says GUL;
- `paper_delete` refuses once, naming what would go with it. A cascading delete
  has no undo in a tool call.

Two transports, one build. `stdio` is what a client spawns — so nothing in the
process may print to stdout, and diagnostics go to stderr. `streamable-http` is
what a client on another machine connects to, optionally behind a bearer token;
binding a non-loopback address disables the SDK's DNS-rebinding allow-list,
because a LAN client sends whatever Host it knows the server by.

Tools register with `structured_output=False` on purpose: 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 came back.

`scripts/smoke_mcp.py` drives the whole loop through a real MCP client — the
child process and JSON-RPC over stdin/stdout a client actually uses — and runs
unchanged against a running HTTP server via `--url`. 47 checks pass on both
transports; the REST suite still passes its 45.
2026-09-19 00:01:43 +08:00

507 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Paper tools (论文): the library, the document, and the write path.
The write path is the reason this package exists. A model asked to "write the
paper into the system" arrives with a generated document, not with paragraph
positions, so the tools accept either and the server does the translating:
* :func:`paper_write` takes one request per paragraph, each addressed by
``heading`` ("1. Introduction") or by ``position``;
* :func:`paper_write_text` takes the whole generated document as one string and
finds the sections itself, so a twenty-paragraph paper is one tool call.
Both end in :func:`app.crud.paper.replace_paragraph` — the same function the
paragraph editor calls — which is why a paper written through MCP and a paper
written by hand are indistinguishable afterwards.
"""
from __future__ import annotations
import json
from typing import Annotated, Any, Literal
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from pydantic import Field
from app.crud import paper as paper_crud
from app.mcp import support as s
from app.mcp.specs import ParagraphSpec
from app.mcp.tools.registry import mcp_tool
from app.models import Paper
from app.schemas.paper import (
PaperCreate,
PaperUpdate,
ParagraphUpdate,
SentenceCreate,
)
def register(server: MCPServer) -> None:
"""Register every paper tool on ``server``."""
@mcp_tool(server, title="列出论文")
def paper_list(
keyword: Annotated[
str | None, Field(description="按标题、作者或关键词模糊搜索")
] = None,
status: Annotated[
Literal["draft", "writing", "done"] | None,
Field(description="按状态过滤"),
] = None,
template_id: Annotated[
int | None, Field(description="按所用模板过滤")
] = None,
page: Annotated[int, Field(description="页码,从 1 开始")] = 1,
page_size: Annotated[int, Field(description="每页条数,最大 200")] = 20,
) -> str:
"""列出/搜索论文(论文列表,不含正文)。返回 id、标题、模板、进度。"""
page, page_size = s.page_args(page, page_size)
with s.session() as db:
items, total = paper_crud.list_papers(
db,
keyword=keyword,
status=status,
template_id=template_id,
page=page,
page_size=page_size,
)
return s.dumps(
s.page_payload(
items=[
{
"id": item.id,
"title": item.title,
"template_id": item.template_id,
"template": item.template_name,
"author": item.author,
"status": item.status,
"keywords": item.keywords,
"paragraphs": item.paragraph_count,
"template_paragraphs": item.template_paragraph_count,
"sentences": item.sentence_count,
}
for item in items
],
total=total,
page=page,
page_size=page_size,
)
)
@mcp_tool(server, title="取一篇论文")
def paper_get(
paper_id: Annotated[int, Field(description="论文 id")],
) -> str:
"""取一篇论文的元信息与写作进度,不含正文。"""
with s.session() as db:
return s.dumps(s.paper_row(s.paper_or_fail(db, paper_id)))
@mcp_tool(server, title="新建论文")
def paper_create(
title: Annotated[str, Field(description="论文标题,不能为空")],
template_id: Annotated[
int | None, Field(description="所用模板 id;给出后论文立刻拥有整篇结构")
] = None,
author: Annotated[str | None, Field(description="作者")] = None,
status: Annotated[
Literal["draft", "writing", "done"] | None,
Field(description="状态,默认 draft"),
] = None,
keywords: Annotated[
str | None, Field(description="关键词,逗号分隔;会去重并存成规范形式")
] = None,
target_journal: Annotated[
str | None, Field(description="投稿目标期刊")
] = None,
) -> str:
"""新建一篇论文。只写元信息,正文为空;结构来自模板,随时可换。"""
payload = PaperCreate(
title=title,
template_id=template_id,
author=author,
status=status or "draft",
keywords=keywords,
target_journal=target_journal,
)
with s.session() as db:
_assert_template_exists(db, payload.template_id)
return s.dumps(s.paper_row(paper_crud.create(db, payload)))
@mcp_tool(server, title="修改论文")
def paper_update(
paper_id: Annotated[int, Field(description="论文 id")],
title: Annotated[str | None, Field(description="新标题;不传则不变")] = None,
template_id: Annotated[
int | None,
Field(description="切换到另一个模板;句子留在原位置,标题随之改变"),
] = None,
clear_template: Annotated[
bool, Field(description="true 表示把模板置空(正文全部落到“未设定”)")
] = False,
author: Annotated[
str | None, Field(description="作者;空字符串表示清空")
] = None,
status: Annotated[
Literal["draft", "writing", "done"] | None, Field(description="状态")
] = None,
keywords: Annotated[
str | None, Field(description="关键词;空字符串表示清空")
] = None,
target_journal: Annotated[
str | None, Field(description="目标期刊;空字符串表示清空")
] = None,
) -> str:
"""修改论文元信息,或切换模板(换模板会重塑全文结构,正文不丢)。"""
values: dict[str, Any] = {}
if title is not None:
values["title"] = title
if clear_template:
values["template_id"] = None
elif template_id is not None:
values["template_id"] = template_id
if author is not None:
values["author"] = author
if status is not None:
values["status"] = status
if keywords is not None:
values["keywords"] = keywords
if target_journal is not None:
values["target_journal"] = target_journal
if not values:
s.fail("没有给出任何要修改的字段")
payload = PaperUpdate(**values)
# The schemas normalise (trimmed title, canonical keywords, blank ->
# None); only the keys actually sent are applied, so a field left out
# is not mistaken for an explicit null.
applied = payload.model_dump(exclude_unset=True)
with s.session() as db:
paper = s.paper_or_fail(db, paper_id)
if "template_id" in applied:
_assert_template_exists(db, applied["template_id"])
template_changed = (
"template_id" in applied
and applied["template_id"] != paper.template_id
)
updated = paper_crud.update(
db, paper, values=applied, template_changed=template_changed
)
return s.dumps({**s.paper_row(updated), "template_changed": template_changed})
@mcp_tool(server, title="删除论文")
def paper_delete(
paper_ids: Annotated[int | list[int], Field(description="论文 id,或 id 数组")],
confirm: Annotated[
bool,
Field(description="必须显式传 true;删除会连同句子和引用一起消失,不可恢复"),
] = False,
) -> str:
"""删除论文(连句子和引用一起)。必须 confirm=true 才会执行。"""
ids = s.id_list(paper_ids)
if not ids:
s.fail("必须给出至少一个论文 id")
if not confirm:
s.fail(
f"删除论文 {ids} 会连同其全部句子和引用一起消失,且无法恢复。"
"确认无误后请带 confirm=true 重新调用"
)
with s.session() as db:
return s.dumps({"deleted": paper_crud.delete_many(db, ids), "ids": ids})
@mcp_tool(server, title="论文骨架")
def paper_outline(
paper_id: Annotated[int, Field(description="论文 id")],
) -> str:
"""取论文的段落骨架:每段的 position、标题、已写句子数。写正文前先看这个。"""
with s.session() as db:
paper = s.paper_or_fail(db, paper_id)
document = paper_crud.build_document(db, paper)
written = {
paragraph.paper_template_filed_sort: len(paragraph.sentences)
for paragraph in document.paragraphs
}
return s.dumps(
{
"paper": s.paper_row(paper),
"paragraphs": [
{**item, "sentences": written.get(item["position"], 0)}
for item in s.heading_index(paper)
],
"warnings": document.warnings,
}
)
@mcp_tool(server, title="读论文全文")
def paper_document(
paper_id: Annotated[int, Field(description="论文 id")],
format: Annotated[
Literal["text", "json", "both"],
Field(description="text=可直接阅读的全文(默认),json=带 id 的结构,both=两者都要"),
] = "text",
include_empty: Annotated[
bool, Field(description="是否输出还没有内容的段落")
] = False,
citations: Annotated[
bool, Field(description="是否带上引用标记与参考文献列表")
] = True,
) -> str:
"""读整篇论文:按模板顺序输出全文(含引用编号和参考文献)。"""
with s.session() as db:
paper = s.paper_or_fail(db, paper_id)
document = paper_crud.build_document(db, paper)
payload: dict[str, Any] = {"paper": s.paper_row(paper)}
if format in ("text", "both"):
payload["text"] = s.document_text(
document, include_empty=include_empty, citations=citations
)
if format in ("json", "both"):
payload["paragraphs"] = [
{
**s.paragraph_row(paragraph, preview=0),
"sentences": [
s.sentence_row(row, separator=row.separator_before)
for row in paragraph.sentences
],
}
for paragraph in document.paragraphs
if include_empty or paragraph.sentences
]
payload["citations"] = [
{
"index": item.index,
"reference_id": item.reference_id,
"quote": item.quote,
"sentence_id": item.sentence_id,
}
for item in document.citations
]
if document.warnings:
payload["warnings"] = document.warnings
return s.dumps(payload)
@mcp_tool(server, title="批量写段落")
def paper_write(
paper_id: Annotated[int, Field(description="论文 id")],
paragraphs: Annotated[
list[ParagraphSpec],
Field(
description=(
"要写的段落数组。每段用 heading(如 “1. Introduction”)或 position 指定,"
"正文放 sentences(字符串数组或带 citations 的对象数组),"
"或放 text + split 让服务端切句"
)
),
],
mode: Annotated[
Literal["replace", "append"],
Field(description="replace=整段覆盖(默认),append=追加到该段末尾"),
] = "replace",
) -> str:
"""把生成好的段落写入论文:按标题或位置定位,一次可写多段。AI 写完直接调用这个。"""
if not paragraphs:
s.fail("paragraphs 不能为空")
with s.session() as db:
paper = s.paper_or_fail(db, paper_id)
written: list[dict[str, Any]] = []
for spec in paragraphs:
position = s.resolve_position(
paper, position=spec.position, heading=spec.heading
)
inputs = s.paragraph_inputs(spec)
heading = _heading_at(paper, position)
if mode == "append":
for item in inputs:
paper_crud.append_sentence(
db,
paper,
SentenceCreate(
paper_template_filed_sort=position,
content=item.content,
citations=item.citations,
sort=item.sort,
),
)
else:
paper_crud.replace_paragraph(
db,
paper,
position,
ParagraphUpdate(sentences=inputs),
)
written.append(
{
"position": position,
"heading": heading,
"mode": mode,
"sentences": len(inputs),
"chars": sum(len(item.content) for item in inputs),
}
)
return s.dumps(
{
"paper_id": paper.id,
"written": written,
"total_sentences": sum(item["sentences"] for item in written),
}
)
@mcp_tool(server, title="整篇写入")
def paper_write_text(
paper_id: Annotated[int, Field(description="论文 id")],
text: Annotated[
str,
Field(
description=(
"整篇论文的文本。用 Markdown 标题(# 0 Abstract / ## 1. Introduction"
"或与模板同名的标题行分段,标题下的内容写进对应段落"
)
),
],
split: Annotated[
Literal["line", "sentence", "paragraph"],
Field(description="段落内怎么切句:line 一行一句(默认)/ sentence 按句号 / paragraph 整段一句"),
] = "line",
mode: Annotated[
Literal["replace", "append"],
Field(description="replace=整段覆盖(默认),append=追加到该段末尾"),
] = "replace",
strict: Annotated[
bool,
Field(description="true=有标题匹配不上就整篇不写;false=能写的先写,其余在 unmatched 里返回"),
] = False,
) -> str:
"""把一整篇生成好的论文按标题切段后写入论文。适合“AI 写完直接灌进去”。"""
sections = _split_sections(text)
if not sections:
s.fail("没有从 text 里找到任何与模板同名的标题,请改用 paper_write 按 position 写")
paragraphs: list[ParagraphSpec] = []
unmatched: list[str] = []
with s.session() as db:
paper = s.paper_or_fail(db, paper_id)
for heading, body in sections:
try:
s.resolve_position(paper, heading=heading)
except ToolError as error:
# ToolError carries the candidate list the model needs to
# correct the heading, so it is reported rather than raised
# when the caller asked for a lenient run.
unmatched.append(f"{heading}{error}")
if strict:
s.fail(f"标题“{heading}”无法定位到模板段落;{error}")
continue
paragraphs.append(
ParagraphSpec(heading=heading, text=body, split=split)
)
if not paragraphs:
s.fail("没有任何标题能定位到模板段落:" + "".join(unmatched))
payload = json.loads(paper_write(paper_id=paper_id, paragraphs=paragraphs, mode=mode))
if unmatched:
payload["unmatched"] = unmatched
return s.dumps(payload)
# --- helpers -----------------------------------------------------------------
def _assert_template_exists(db: Any, template_id: int | None) -> None:
"""Refuse a paper pointing at a template that is not there.
Checked in Python rather than by a foreign key, exactly as the REST route
does: TiDB parses but does not enforce ``FOREIGN KEY``, so an unchecked
write would leave a paper with no structure and no way to notice.
"""
if template_id is None:
return
s.template_or_fail(db, template_id)
def _heading_at(paper: Paper, position: int) -> str | None:
"""What the paper's template calls this position, if it names it at all."""
for item in s.heading_index(paper):
if item["position"] == position:
return str(item["heading"])
return None
#: A Markdown ATX heading, the most likely shape of generated output.
_MARKDOWN_HEADING = "#"
def _split_sections(text: str) -> list[tuple[str, str]]:
"""Cut a generated document into ``(heading, body)`` pairs.
Two heading shapes are recognised, in this order:
1. a Markdown heading line — ``# 0 Abstract`` / ``## 1. Introduction``;
2. a plain line that is *exactly* one of the template's headings after
normalisation, which is what a model produces when it copies the outline
out of :func:`paper_outline` and writes prose under it.
A body line is never mistaken for a heading: rule 2 needs a short line with
no sentence-ending punctuation, and it still has to match a heading the
caller's paper actually defines. Anything before the first heading is
dropped — there is no paragraph to put it in — which is why the tool
refuses outright when no heading matched at all.
"""
sections: list[tuple[str, str]] = []
heading: str | None = None
body: list[str] = []
def close() -> None:
if heading is not None:
sections.append((heading, "\n".join(body).strip()))
for line in text.splitlines():
stripped = line.strip()
candidate = stripped.lstrip(_MARKDOWN_HEADING).strip() if stripped.startswith(_MARKDOWN_HEADING) else None
if candidate is not None:
if not candidate:
continue
close()
heading, body = candidate, []
continue
if _looks_like_plain_heading(stripped):
close()
heading, body = stripped, []
continue
if heading is not None:
body.append(line)
close()
# A heading with an empty body is a section the caller left blank; keeping
# it would overwrite a written paragraph with nothing.
return [(name, block) for name, block in sections if block]
def _looks_like_plain_heading(line: str) -> bool:
"""Whether a plain line could be a heading rather than prose.
Deliberately conservative. The caller's paper decides the final answer in
:func:`app.mcp.support.resolve_position`; this only avoids handing it every
sentence in the document.
"""
if not line or len(line) > 60:
return False
if line[-1] in "。!?.!?,,、;:":
return False
# A heading has no more than a handful of words before the first full stop;
# a prose line at this length that ends without punctuation is rare enough
# that the template match is what settles it.
return True