Files
paper-doc/backend/app/mcp/tools/paragraphs.py
T
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

278 lines
11 KiB
Python

"""Paragraph tools (段落): one position of one paper, read and written whole.
A paragraph is not a row — it is a *position*. It exists because the template
defines it or because a sentence occupies it, which is why there is no
``paragraph_create``: writing into an empty position creates the paragraph, and
there is nothing else a create could mean.
Every tool here takes ``position`` or ``heading`` and lets
:func:`app.mcp.support.resolve_position` decide. Writing goes through
:func:`app.crud.paper.replace_paragraph`, the same call the editor's save button
makes, so "replace" always means "this position now holds exactly these
sentences" — never a merge that quietly keeps a line the model meant to drop.
"""
from __future__ import annotations
from typing import Annotated, Literal
from mcp.server.mcpserver import MCPServer
from pydantic import Field
from sqlalchemy.orm import Session
from app.crud import paper as paper_crud
from app.mcp import support as s
from app.mcp.specs import CitationSpec, ParagraphSpec, SentenceItem
from app.mcp.tools.registry import mcp_tool
from app.models import Paper, PaperSentence
from app.schemas.paper import (
ParagraphUpdate,
SentenceCreate,
SentenceInput,
)
def register(server: MCPServer) -> None:
"""Register every paragraph tool on ``server``."""
@mcp_tool(server, title="列出段落")
def paragraph_list(
paper_id: Annotated[int, Field(description="论文 id")],
include_empty: Annotated[
bool, Field(description="是否包含模板里还没有内容的段落(默认包含)")
] = True,
preview: Annotated[
int, Field(description="每段返回多少字的开头预览,0 表示不返回")
] = 80,
) -> str:
"""列出论文的段落骨架:position、标题、句子数、开头预览。改正文前先看这个。"""
with s.session() as db:
paper = s.paper_or_fail(db, paper_id)
document = paper_crud.build_document(db, paper)
items = [
s.paragraph_row(paragraph, preview=preview)
for paragraph in document.paragraphs
if include_empty or paragraph.sentences
]
return s.dumps(
{
"paper_id": paper.id,
"paper_title": paper.title,
"total": len(items),
"items": items,
"warnings": document.warnings,
}
)
@mcp_tool(server, title="读一个段落")
def paragraph_get(
paper_id: Annotated[int, Field(description="论文 id")],
position: Annotated[
int | None, Field(description="段落位置;与 heading 二选一")
] = None,
heading: Annotated[
str | None, Field(description="段落标题,如 “2. Methods”")
] = None,
) -> str:
"""读一个段落:拼好的正文、逐句内容与 id、引用、段间分隔符。"""
with s.session() as db:
paper = s.paper_or_fail(db, paper_id)
field_sort = s.resolve_position(paper, position=position, heading=heading)
paragraph = paper_crud.get_paragraph(db, paper, field_sort)
if paragraph is None:
s.fail(
f"论文 {paper_id} 没有第 {field_sort} 段:模板没有这个位置,也没有内容"
)
return s.dumps(
{
"paper_id": paper.id,
"paper_title": paper.title,
"position": paragraph.paper_template_filed_sort,
"heading": paragraph.name,
"matched": paragraph.matched,
"text": s.paragraph_text(paragraph),
"sentences": [
s.sentence_row(row, separator=row.separator_before)
for row in paragraph.sentences
],
}
)
@mcp_tool(server, title="写一个段落")
def paragraph_write(
paper_id: Annotated[int, Field(description="论文 id")],
position: Annotated[
int | None, Field(description="段落位置;与 heading 二选一")
] = None,
heading: Annotated[
str | None, Field(description="段落标题,如 “3. Results”")
] = None,
sentences: Annotated[
list[SentenceItem] | None,
Field(description="句子数组:字符串、{content, citations} 或 {text, split}"),
] = None,
text: Annotated[
str | None, Field(description="整段文本;与 sentences 二选一")
] = None,
split: Annotated[
Literal["line", "sentence", "paragraph"],
Field(description="text 的切句方式;默认 line 一行一句"),
] = "line",
citations: Annotated[
list[CitationSpec] | None,
Field(description="整段共用的引用,会补到没有自带引用的句子上"),
] = None,
mode: Annotated[
Literal["replace", "append"],
Field(description="replace=整段覆盖(默认),append=追加到该段末尾"),
] = "replace",
) -> str:
"""写一个段落:按标题或位置定位,整段覆盖或追加。"""
spec = ParagraphSpec(
position=position,
heading=heading,
sentences=sentences,
text=text,
split=split,
citations=citations or [],
)
with s.session() as db:
paper = s.paper_or_fail(db, paper_id)
field_sort = s.resolve_position(paper, position=position, heading=heading)
inputs = s.paragraph_inputs(spec)
return s.dumps(
_write(db, paper, field_sort, inputs, mode, heading_label=heading)
)
@mcp_tool(server, title="清空段落")
def paragraph_delete(
paper_id: Annotated[int, Field(description="论文 id")],
position: Annotated[
int | None, Field(description="段落位置;与 heading 二选一")
] = None,
heading: Annotated[str | None, Field(description="段落标题")] = None,
) -> str:
"""清空一个段落的全部句子。段落本身还在,仍会以空段落渲染。"""
with s.session() as db:
paper = s.paper_or_fail(db, paper_id)
field_sort = s.resolve_position(paper, position=position, heading=heading)
existing = _sentences_at(db, paper, field_sort)
paper_crud.replace_paragraph(
db, paper, field_sort, ParagraphUpdate(sentences=[])
)
return s.dumps(
{
"paper_id": paper.id,
"position": field_sort,
"heading": _heading_at(paper, field_sort),
"deleted_sentences": len(existing),
}
)
@mcp_tool(server, title="移动段落")
def paragraph_move(
paper_id: Annotated[int, Field(description="论文 id")],
position: Annotated[
int | None, Field(description="要搬走的段落位置;与 heading 二选一")
] = None,
heading: Annotated[str | None, Field(description="要搬走的段落标题")] = None,
target_position: Annotated[
int | None, Field(description="搬到哪个位置;与 target_heading 二选一")
] = None,
target_heading: Annotated[
str | None, Field(description="搬到哪个标题下")
] = None,
) -> str:
"""把一个段落的全部句子搬到另一个位置,接在目标段落已有内容之后。"""
with s.session() as db:
paper = s.paper_or_fail(db, paper_id)
source = s.resolve_position(paper, position=position, heading=heading)
target = s.resolve_position(
paper, position=target_position, heading=target_heading
)
if source == target:
s.fail(f"源位置和目标位置都是 {source},没有可搬的内容")
rows = _sentences_at(db, paper, source)
if not rows:
s.fail(f"{source} 段没有内容可以搬")
inputs = [
SentenceInput(content=row.content, sort=row.sort, citations=list(row.citations))
for row in rows
]
paper_crud.replace_paragraph(
db, paper, source, ParagraphUpdate(sentences=inputs, target_sort=target)
)
return s.dumps(
{
"paper_id": paper.id,
"from": {"position": source, "heading": _heading_at(paper, source)},
"to": {"position": target, "heading": _heading_at(paper, target)},
"moved_sentences": len(inputs),
}
)
# --- helpers -----------------------------------------------------------------
def _sentences_at(db: Session, paper: Paper, field_sort: int) -> list[PaperSentence]:
"""The stored sentences of one paragraph, in render order."""
return sorted(
(row for row in paper.sentences if row.paper_template_filed_sort == field_sort),
key=lambda row: (row.sort, row.id),
)
def _heading_at(paper: Paper, field_sort: 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"] == field_sort:
return str(item["heading"])
return None
def _write(
db: Session,
paper: Paper,
field_sort: int,
inputs: list[SentenceInput],
mode: str,
*,
heading_label: str | None,
) -> dict:
"""Apply one paragraph write and describe what happened.
``append`` goes through :func:`app.crud.paper.append_sentence` one sentence
at a time — the same call ``POST /papers/{id}/sentences`` makes — rather
than read-modify-write, so two sentences appended in one request cannot
overwrite each other's ``sort``.
"""
if mode == "append":
for item in inputs:
paper_crud.append_sentence(
db,
paper,
SentenceCreate(
paper_template_filed_sort=field_sort,
content=item.content,
citations=item.citations,
sort=item.sort,
),
)
else:
paper_crud.replace_paragraph(
db, paper, field_sort, ParagraphUpdate(sentences=inputs)
)
return {
"paper_id": paper.id,
"position": field_sort,
"heading": _heading_at(paper, field_sort) or heading_label,
"mode": mode,
"sentences": len(inputs),
"chars": sum(len(item.content) for item in inputs),
}