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

228 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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