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,227 @@
|
||||
"""Sentence tools (语句): the smallest unit the paper model stores.
|
||||
|
||||
One row is one sentence, and nothing about the writing path ever re-splits it —
|
||||
the writer decides where a sentence ends (see ``docs/OVERVIEW.md``). That makes
|
||||
these the tools to reach for when a paragraph is *almost* right: rewriting the
|
||||
whole paragraph to fix one line would relabel every sentence id in it, and an
|
||||
id is what a citation points at.
|
||||
|
||||
:func:`sentence_search` reads across papers instead of within one, because the
|
||||
job it serves is consistency: a model that has already written "flood damage"
|
||||
in one paper should not start writing "GUL" in the next.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from pydantic import Field
|
||||
|
||||
from app.crud import paper as paper_crud
|
||||
from app.mcp import support as s
|
||||
from app.mcp.specs import CitationSpec
|
||||
from app.mcp.tools.registry import mcp_tool
|
||||
from app.models import Paper
|
||||
from app.schemas.paper import CitationInput, SentenceCreate, SentenceUpdate
|
||||
|
||||
|
||||
def register(server: MCPServer) -> None:
|
||||
"""Register every sentence tool on ``server``."""
|
||||
|
||||
@mcp_tool(server, title="列出句子")
|
||||
def sentence_list(
|
||||
paper_id: Annotated[int, Field(description="论文 id")],
|
||||
position: Annotated[
|
||||
int | None, Field(description="只看某一段;不传则返回全文句子")
|
||||
] = None,
|
||||
heading: Annotated[
|
||||
str | None, Field(description="只看某个标题下的句子")
|
||||
] = None,
|
||||
include_empty: Annotated[
|
||||
bool, Field(description="是否包含空句子(只挂引用的行)")
|
||||
] = False,
|
||||
) -> str:
|
||||
"""列出论文里的句子,带句子 id、所属段落和引用。"""
|
||||
with s.session() as db:
|
||||
paper = s.paper_or_fail(db, paper_id)
|
||||
target = (
|
||||
s.resolve_position(paper, position=position, heading=heading)
|
||||
if (position is not None or heading is not None)
|
||||
else None
|
||||
)
|
||||
|
||||
rows = [
|
||||
row
|
||||
for row in paper.sentences
|
||||
if (target is None or row.paper_template_filed_sort == target)
|
||||
and (include_empty or row.content)
|
||||
]
|
||||
rows.sort(key=lambda row: (row.paper_template_filed_sort, row.sort, row.id))
|
||||
|
||||
return s.dumps(
|
||||
{
|
||||
"paper_id": paper.id,
|
||||
"paper_title": paper.title,
|
||||
"total": len(rows),
|
||||
"items": [
|
||||
{
|
||||
**s.sentence_row(row),
|
||||
"heading": _heading_at(paper, row.paper_template_filed_sort),
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
@mcp_tool(server, title="加一句")
|
||||
def sentence_add(
|
||||
paper_id: Annotated[int, Field(description="论文 id")],
|
||||
content: Annotated[str, Field(description="句子正文;会折叠成一行")],
|
||||
position: Annotated[
|
||||
int | None, Field(description="加到哪一段;与 heading 二选一")
|
||||
] = None,
|
||||
heading: Annotated[str | None, Field(description="加到哪个标题下")] = None,
|
||||
citations: Annotated[
|
||||
list[CitationSpec] | None, Field(description="这句引用了哪些文献")
|
||||
] = None,
|
||||
sort: Annotated[
|
||||
int | 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)
|
||||
sentence = paper_crud.append_sentence(
|
||||
db,
|
||||
paper,
|
||||
SentenceCreate(
|
||||
paper_template_filed_sort=field_sort,
|
||||
content=content,
|
||||
citations=_citations(citations or []),
|
||||
sort=sort,
|
||||
),
|
||||
)
|
||||
return s.dumps(
|
||||
{
|
||||
"paper_id": paper.id,
|
||||
"position": field_sort,
|
||||
"heading": _heading_at(paper, field_sort),
|
||||
"sentence": s.sentence_row(sentence),
|
||||
}
|
||||
)
|
||||
|
||||
@mcp_tool(server, title="改一句")
|
||||
def sentence_update(
|
||||
paper_id: Annotated[int, Field(description="论文 id")],
|
||||
sentence_id: Annotated[int, Field(description="句子 id(见 sentence_list)")],
|
||||
content: Annotated[str | None, Field(description="新正文;不传则不改")] = None,
|
||||
position: Annotated[
|
||||
int | None, Field(description="把这句话移到另一段;不传则不动")
|
||||
] = None,
|
||||
sort: Annotated[
|
||||
int | None, Field(description="段内排序值;不传则不动")
|
||||
] = None,
|
||||
citations: Annotated[
|
||||
list[CitationSpec] | None,
|
||||
Field(description="引用整组替换;传 [] 表示清空引用"),
|
||||
] = None,
|
||||
) -> str:
|
||||
"""修改一句话的正文、所属段落、排序或引用(引用是整体替换)。"""
|
||||
if content is None and position is None and sort is None and citations is None:
|
||||
s.fail("没有给出任何要修改的内容")
|
||||
|
||||
with s.session() as db:
|
||||
paper = s.paper_or_fail(db, paper_id)
|
||||
sentence = s.sentence_or_fail(paper, sentence_id)
|
||||
updated = paper_crud.update_sentence(
|
||||
db,
|
||||
paper,
|
||||
sentence,
|
||||
SentenceUpdate(
|
||||
content=content,
|
||||
paper_template_filed_sort=position,
|
||||
sort=sort,
|
||||
citations=None if citations is None else _citations(citations),
|
||||
),
|
||||
)
|
||||
return s.dumps(
|
||||
{
|
||||
"paper_id": paper.id,
|
||||
"sentence": s.sentence_row(updated),
|
||||
}
|
||||
)
|
||||
|
||||
@mcp_tool(server, title="删一句")
|
||||
def sentence_delete(
|
||||
paper_id: Annotated[int, Field(description="论文 id")],
|
||||
sentence_id: Annotated[int, Field(description="句子 id")],
|
||||
) -> str:
|
||||
"""删除一句话,连同它挂着的引用。"""
|
||||
with s.session() as db:
|
||||
paper = s.paper_or_fail(db, paper_id)
|
||||
sentence = s.sentence_or_fail(paper, sentence_id)
|
||||
snapshot = s.sentence_row(sentence)
|
||||
paper_crud.delete_sentence(db, paper, sentence)
|
||||
return s.dumps(
|
||||
{
|
||||
"paper_id": paper.id,
|
||||
"deleted": snapshot,
|
||||
}
|
||||
)
|
||||
|
||||
@mcp_tool(server, title="搜索句子")
|
||||
def sentence_search(
|
||||
keyword: Annotated[str, Field(description="要搜索的字词")],
|
||||
paper_id: Annotated[
|
||||
int | None, Field(description="只在这篇论文里搜;不传则全库搜")
|
||||
] = None,
|
||||
limit: Annotated[int, Field(description="最多返回多少条")] = 30,
|
||||
) -> str:
|
||||
"""跨论文搜索已经写过的句子,用来保持术语和说法一致。"""
|
||||
if not keyword.strip():
|
||||
s.fail("keyword 不能为空")
|
||||
|
||||
with s.session() as db:
|
||||
rows = paper_crud.search_sentences(
|
||||
db,
|
||||
keyword=keyword,
|
||||
paper_id=paper_id,
|
||||
limit=max(1, min(int(limit), 200)),
|
||||
)
|
||||
return s.dumps(
|
||||
{
|
||||
"keyword": keyword,
|
||||
"total": len(rows),
|
||||
"items": [
|
||||
{
|
||||
"paper_id": sentence.paper_id,
|
||||
"paper_title": title,
|
||||
"sentence_id": sentence.id,
|
||||
"position": sentence.paper_template_filed_sort,
|
||||
"content": sentence.content,
|
||||
}
|
||||
for sentence, title in rows
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --- helpers -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _citations(items: list[Any]) -> list[CitationInput]:
|
||||
"""Convert the tool-level citation spec into the project's own payload."""
|
||||
return [
|
||||
CitationInput(quote=item.quote, reference_id=item.reference_id)
|
||||
for item in items
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user