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.
278 lines
11 KiB
Python
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),
|
|
}
|