2aff867641
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.
507 lines
20 KiB
Python
507 lines
20 KiB
Python
"""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
|