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.
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
"""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),
|
||||
}
|
||||
Reference in New Issue
Block a user