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:
2026-09-19 00:01:43 +08:00
parent 20cd63f9f9
commit 2aff867641
17 changed files with 3276 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
"""Tool registry: the five groups, registered in one call.
The groups mirror the domain rather than the HTTP routes — papers and
paragraphs and sentences are the same tables the REST API serves, but a model
reaches for "append a sentence" and "write a whole paper" as different jobs,
and the split between :mod:`~app.mcp.tools.papers` and
:mod:`~app.mcp.tools.paragraphs` follows those jobs.
Prefixes are load-bearing: every tool name starts with its group
(``paper_``, ``paragraph_``, ``sentence_``, ``template_``, ``field_``), because
that prefix is what a model uses to pick a tool out of a list of twenty-nine
without reading all twenty-nine descriptions.
"""
from __future__ import annotations
from mcp.server.mcpserver import MCPServer
from app.mcp.tools import fields, papers, paragraphs, sentences, templates
#: Registration order is also the order a client lists the tools in, which is
#: the order a paper is actually written in: find it, shape it, write it, then
#: the things it is written against.
MODULES = (papers, paragraphs, sentences, templates, fields)
def register_all(server: MCPServer) -> None:
"""Register every paper-doc tool on ``server``."""
for module in MODULES:
module.register(server)
__all__ = ["MODULES", "register_all"]
+185
View File
@@ -0,0 +1,185 @@
"""Field-library tools (字段库): the reusable headings templates are built from.
The library is the catalogue, and ``template_field`` is a placement in one
template — one word apart, opposite meanings. A field exists once and may be
placed by any number of templates, which is why editing a field restyles every
outline that uses it and why deleting one is refused while any template still
places it.
The numbering a reader sees ("1. Introduction", "2.1 Study area") is part of
``name`` and is written by the caller. Nothing here derives or rewrites it:
``level`` is a rendering hint, not a parent pointer, which is what lets one
level-2 heading sit under two different level-1 headings in two templates.
"""
from __future__ import annotations
from typing import Annotated
from mcp.server.mcpserver import MCPServer
from pydantic import Field
from sqlalchemy.orm import Session
from app.crud import template_field_library as field_crud
from app.mcp import support as s
from app.mcp.tools.registry import mcp_tool
from app.models import TemplateFieldLibrary
from app.schemas.template_field_library import (
TemplateFieldLibraryCreate,
TemplateFieldLibraryUpdate,
)
def register(server: MCPServer) -> None:
"""Register every field-library tool on ``server``."""
@mcp_tool(server, title="列出字段库")
def field_list(
keyword: Annotated[str | None, Field(description="按字段名称模糊搜索")] = None,
level: Annotated[
int | None, Field(description="按层级过滤:1 是一级标题,2 是二级")
] = None,
page: Annotated[int, Field(description="页码,从 1 开始")] = 1,
page_size: Annotated[int, Field(description="每页条数,最大 200")] = 50,
) -> str:
"""列出字段库(模板能用的所有标题)。建模板前先查这里的 id。"""
page, page_size = s.page_args(page, page_size)
with s.session() as db:
rows, total = field_crud.list_fields(
db, keyword=keyword, level=level, page=page, page_size=page_size
)
return s.dumps(
s.page_payload(
items=[_field_row(row) for row in rows],
total=total,
page=page,
page_size=page_size,
)
)
@mcp_tool(server, title="取一个字段")
def field_get(
field_id: Annotated[int, Field(description="字段 id")],
) -> str:
"""取字段库里一个字段,并给出它被几个模板使用。"""
with s.session() as db:
field = s.field_or_fail(db, field_id)
payload = _field_row(field)
payload["used_by_templates"] = field_crud.usage_counts(db, [field.id]).get(
field.id, 0
)
return s.dumps(payload)
@mcp_tool(server, title="新建字段")
def field_create(
name: Annotated[
str,
Field(description="字段名,编号要自己写进去,例如 “3. Results” 或 “3.1 Study area”"),
],
level: Annotated[
int, Field(description="层级 1-9,只决定缩进,1 是一级标题")
] = 1,
font_size: Annotated[
float, Field(description="字号(磅),中文五号是 10.5")
] = 12.0,
font_color: Annotated[
str, Field(description="颜色,#RRGGBB 或 rgb(r,g,b)")
] = "#000000",
) -> str:
"""往字段库加一个标题字段,可带字号颜色。"""
payload = TemplateFieldLibraryCreate(
name=name, level=level, font_size=font_size, font_color=font_color
)
with s.session() as db:
return s.dumps(_field_row(field_crud.create(db, payload)))
@mcp_tool(server, title="修改字段")
def field_update(
field_id: Annotated[int, Field(description="字段 id")],
name: Annotated[str | None, Field(description="新名称;不传则不改")] = None,
level: Annotated[int | None, Field(description="新层级")] = None,
font_size: Annotated[float | None, Field(description="新字号")] = None,
font_color: Annotated[str | None, Field(description="新颜色")] = None,
) -> str:
"""改字段的名称或排版。所有引用它的模板会立刻跟着变。"""
# Only the keys actually given are sent: the update schema treats an
# explicitly-passed ``None`` as "set this column to null", which no
# column here allows, so a parameter left out must not reach it.
values: dict[str, object] = {}
if name is not None:
values["name"] = name
if level is not None:
values["level"] = level
if font_size is not None:
values["font_size"] = font_size
if font_color is not None:
values["font_color"] = font_color
if not values:
s.fail("没有给出任何要修改的内容")
payload = TemplateFieldLibraryUpdate(**values)
with s.session() as db:
field = s.field_or_fail(db, field_id)
updated = field_crud.update(db, field, payload)
result = _field_row(updated)
result["used_by_templates"] = field_crud.usage_counts(db, [field_id]).get(
field_id, 0
)
return s.dumps(result)
@mcp_tool(server, title="删除字段")
def field_delete(
field_ids: Annotated[int | list[int], Field(description="字段 id,或 id 数组")],
) -> str:
"""删除字段(仍被模板使用的会被拒绝,需先从模板里移除)。"""
ids = s.id_list(field_ids)
if not ids:
s.fail("必须给出至少一个字段 id")
with s.session() as db:
fields = field_crud.get_many(db, ids)
_assert_unused(db, fields)
for field in fields:
field_crud.delete(db, field)
found = {field.id for field in fields}
return s.dumps(
{
"deleted": len(fields),
"ids": [field_id for field_id in ids if field_id in found],
"missing": [field_id for field_id in ids if field_id not in found],
}
)
# --- helpers -----------------------------------------------------------------
def _field_row(field: TemplateFieldLibrary) -> dict:
"""One library entry as a model reads it."""
return {
"id": field.id,
"name": field.name,
"level": field.level,
"font_size": float(field.font_size),
"font_color": field.font_color,
}
def _assert_unused(db: Session, fields: list[TemplateFieldLibrary]) -> None:
"""Refuse the delete while any template still places the field.
Dropping a field that is placed somewhere would silently remove a heading
from that template's outline — data loss wearing the costume of cleanup.
Every offender is named at once, with its template count.
"""
counts = field_crud.usage_counts(db, [field.id for field in fields])
if not counts:
return
names = {field.id: field.name for field in fields}
blockers = "".join(
f"{names.get(field_id, field_id)}”({count} 个模板)"
for field_id, count in sorted(counts.items())
)
s.fail(f"以下字段正被模板使用,请先用 template_update 从模板里移除:{blockers}")
+506
View File
@@ -0,0 +1,506 @@
"""Paper tools (论文): the library, the document, and the write path.
The write path is the reason this package exists. A model asked to "write the
paper into the system" arrives with a generated document, not with paragraph
positions, so the tools accept either and the server does the translating:
* :func:`paper_write` takes one request per paragraph, each addressed by
``heading`` ("1. Introduction") or by ``position``;
* :func:`paper_write_text` takes the whole generated document as one string and
finds the sections itself, so a twenty-paragraph paper is one tool call.
Both end in :func:`app.crud.paper.replace_paragraph` — the same function the
paragraph editor calls — which is why a paper written through MCP and a paper
written by hand are indistinguishable afterwards.
"""
from __future__ import annotations
import json
from typing import Annotated, Any, Literal
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from pydantic import Field
from app.crud import paper as paper_crud
from app.mcp import support as s
from app.mcp.specs import ParagraphSpec
from app.mcp.tools.registry import mcp_tool
from app.models import Paper
from app.schemas.paper import (
PaperCreate,
PaperUpdate,
ParagraphUpdate,
SentenceCreate,
)
def register(server: MCPServer) -> None:
"""Register every paper tool on ``server``."""
@mcp_tool(server, title="列出论文")
def paper_list(
keyword: Annotated[
str | None, Field(description="按标题、作者或关键词模糊搜索")
] = None,
status: Annotated[
Literal["draft", "writing", "done"] | None,
Field(description="按状态过滤"),
] = None,
template_id: Annotated[
int | None, Field(description="按所用模板过滤")
] = None,
page: Annotated[int, Field(description="页码,从 1 开始")] = 1,
page_size: Annotated[int, Field(description="每页条数,最大 200")] = 20,
) -> str:
"""列出/搜索论文(论文列表,不含正文)。返回 id、标题、模板、进度。"""
page, page_size = s.page_args(page, page_size)
with s.session() as db:
items, total = paper_crud.list_papers(
db,
keyword=keyword,
status=status,
template_id=template_id,
page=page,
page_size=page_size,
)
return s.dumps(
s.page_payload(
items=[
{
"id": item.id,
"title": item.title,
"template_id": item.template_id,
"template": item.template_name,
"author": item.author,
"status": item.status,
"keywords": item.keywords,
"paragraphs": item.paragraph_count,
"template_paragraphs": item.template_paragraph_count,
"sentences": item.sentence_count,
}
for item in items
],
total=total,
page=page,
page_size=page_size,
)
)
@mcp_tool(server, title="取一篇论文")
def paper_get(
paper_id: Annotated[int, Field(description="论文 id")],
) -> str:
"""取一篇论文的元信息与写作进度,不含正文。"""
with s.session() as db:
return s.dumps(s.paper_row(s.paper_or_fail(db, paper_id)))
@mcp_tool(server, title="新建论文")
def paper_create(
title: Annotated[str, Field(description="论文标题,不能为空")],
template_id: Annotated[
int | None, Field(description="所用模板 id;给出后论文立刻拥有整篇结构")
] = None,
author: Annotated[str | None, Field(description="作者")] = None,
status: Annotated[
Literal["draft", "writing", "done"] | None,
Field(description="状态,默认 draft"),
] = None,
keywords: Annotated[
str | None, Field(description="关键词,逗号分隔;会去重并存成规范形式")
] = None,
target_journal: Annotated[
str | None, Field(description="投稿目标期刊")
] = None,
) -> str:
"""新建一篇论文。只写元信息,正文为空;结构来自模板,随时可换。"""
payload = PaperCreate(
title=title,
template_id=template_id,
author=author,
status=status or "draft",
keywords=keywords,
target_journal=target_journal,
)
with s.session() as db:
_assert_template_exists(db, payload.template_id)
return s.dumps(s.paper_row(paper_crud.create(db, payload)))
@mcp_tool(server, title="修改论文")
def paper_update(
paper_id: Annotated[int, Field(description="论文 id")],
title: Annotated[str | None, Field(description="新标题;不传则不变")] = None,
template_id: Annotated[
int | None,
Field(description="切换到另一个模板;句子留在原位置,标题随之改变"),
] = None,
clear_template: Annotated[
bool, Field(description="true 表示把模板置空(正文全部落到“未设定”)")
] = False,
author: Annotated[
str | None, Field(description="作者;空字符串表示清空")
] = None,
status: Annotated[
Literal["draft", "writing", "done"] | None, Field(description="状态")
] = None,
keywords: Annotated[
str | None, Field(description="关键词;空字符串表示清空")
] = None,
target_journal: Annotated[
str | None, Field(description="目标期刊;空字符串表示清空")
] = None,
) -> str:
"""修改论文元信息,或切换模板(换模板会重塑全文结构,正文不丢)。"""
values: dict[str, Any] = {}
if title is not None:
values["title"] = title
if clear_template:
values["template_id"] = None
elif template_id is not None:
values["template_id"] = template_id
if author is not None:
values["author"] = author
if status is not None:
values["status"] = status
if keywords is not None:
values["keywords"] = keywords
if target_journal is not None:
values["target_journal"] = target_journal
if not values:
s.fail("没有给出任何要修改的字段")
payload = PaperUpdate(**values)
# The schemas normalise (trimmed title, canonical keywords, blank ->
# None); only the keys actually sent are applied, so a field left out
# is not mistaken for an explicit null.
applied = payload.model_dump(exclude_unset=True)
with s.session() as db:
paper = s.paper_or_fail(db, paper_id)
if "template_id" in applied:
_assert_template_exists(db, applied["template_id"])
template_changed = (
"template_id" in applied
and applied["template_id"] != paper.template_id
)
updated = paper_crud.update(
db, paper, values=applied, template_changed=template_changed
)
return s.dumps({**s.paper_row(updated), "template_changed": template_changed})
@mcp_tool(server, title="删除论文")
def paper_delete(
paper_ids: Annotated[int | list[int], Field(description="论文 id,或 id 数组")],
confirm: Annotated[
bool,
Field(description="必须显式传 true;删除会连同句子和引用一起消失,不可恢复"),
] = False,
) -> str:
"""删除论文(连句子和引用一起)。必须 confirm=true 才会执行。"""
ids = s.id_list(paper_ids)
if not ids:
s.fail("必须给出至少一个论文 id")
if not confirm:
s.fail(
f"删除论文 {ids} 会连同其全部句子和引用一起消失,且无法恢复。"
"确认无误后请带 confirm=true 重新调用"
)
with s.session() as db:
return s.dumps({"deleted": paper_crud.delete_many(db, ids), "ids": ids})
@mcp_tool(server, title="论文骨架")
def paper_outline(
paper_id: Annotated[int, Field(description="论文 id")],
) -> str:
"""取论文的段落骨架:每段的 position、标题、已写句子数。写正文前先看这个。"""
with s.session() as db:
paper = s.paper_or_fail(db, paper_id)
document = paper_crud.build_document(db, paper)
written = {
paragraph.paper_template_filed_sort: len(paragraph.sentences)
for paragraph in document.paragraphs
}
return s.dumps(
{
"paper": s.paper_row(paper),
"paragraphs": [
{**item, "sentences": written.get(item["position"], 0)}
for item in s.heading_index(paper)
],
"warnings": document.warnings,
}
)
@mcp_tool(server, title="读论文全文")
def paper_document(
paper_id: Annotated[int, Field(description="论文 id")],
format: Annotated[
Literal["text", "json", "both"],
Field(description="text=可直接阅读的全文(默认),json=带 id 的结构,both=两者都要"),
] = "text",
include_empty: Annotated[
bool, Field(description="是否输出还没有内容的段落")
] = False,
citations: Annotated[
bool, Field(description="是否带上引用标记与参考文献列表")
] = True,
) -> str:
"""读整篇论文:按模板顺序输出全文(含引用编号和参考文献)。"""
with s.session() as db:
paper = s.paper_or_fail(db, paper_id)
document = paper_crud.build_document(db, paper)
payload: dict[str, Any] = {"paper": s.paper_row(paper)}
if format in ("text", "both"):
payload["text"] = s.document_text(
document, include_empty=include_empty, citations=citations
)
if format in ("json", "both"):
payload["paragraphs"] = [
{
**s.paragraph_row(paragraph, preview=0),
"sentences": [
s.sentence_row(row, separator=row.separator_before)
for row in paragraph.sentences
],
}
for paragraph in document.paragraphs
if include_empty or paragraph.sentences
]
payload["citations"] = [
{
"index": item.index,
"reference_id": item.reference_id,
"quote": item.quote,
"sentence_id": item.sentence_id,
}
for item in document.citations
]
if document.warnings:
payload["warnings"] = document.warnings
return s.dumps(payload)
@mcp_tool(server, title="批量写段落")
def paper_write(
paper_id: Annotated[int, Field(description="论文 id")],
paragraphs: Annotated[
list[ParagraphSpec],
Field(
description=(
"要写的段落数组。每段用 heading(如 “1. Introduction”)或 position 指定,"
"正文放 sentences(字符串数组或带 citations 的对象数组),"
"或放 text + split 让服务端切句"
)
),
],
mode: Annotated[
Literal["replace", "append"],
Field(description="replace=整段覆盖(默认),append=追加到该段末尾"),
] = "replace",
) -> str:
"""把生成好的段落写入论文:按标题或位置定位,一次可写多段。AI 写完直接调用这个。"""
if not paragraphs:
s.fail("paragraphs 不能为空")
with s.session() as db:
paper = s.paper_or_fail(db, paper_id)
written: list[dict[str, Any]] = []
for spec in paragraphs:
position = s.resolve_position(
paper, position=spec.position, heading=spec.heading
)
inputs = s.paragraph_inputs(spec)
heading = _heading_at(paper, position)
if mode == "append":
for item in inputs:
paper_crud.append_sentence(
db,
paper,
SentenceCreate(
paper_template_filed_sort=position,
content=item.content,
citations=item.citations,
sort=item.sort,
),
)
else:
paper_crud.replace_paragraph(
db,
paper,
position,
ParagraphUpdate(sentences=inputs),
)
written.append(
{
"position": position,
"heading": heading,
"mode": mode,
"sentences": len(inputs),
"chars": sum(len(item.content) for item in inputs),
}
)
return s.dumps(
{
"paper_id": paper.id,
"written": written,
"total_sentences": sum(item["sentences"] for item in written),
}
)
@mcp_tool(server, title="整篇写入")
def paper_write_text(
paper_id: Annotated[int, Field(description="论文 id")],
text: Annotated[
str,
Field(
description=(
"整篇论文的文本。用 Markdown 标题(# 0 Abstract / ## 1. Introduction"
"或与模板同名的标题行分段,标题下的内容写进对应段落"
)
),
],
split: Annotated[
Literal["line", "sentence", "paragraph"],
Field(description="段落内怎么切句:line 一行一句(默认)/ sentence 按句号 / paragraph 整段一句"),
] = "line",
mode: Annotated[
Literal["replace", "append"],
Field(description="replace=整段覆盖(默认),append=追加到该段末尾"),
] = "replace",
strict: Annotated[
bool,
Field(description="true=有标题匹配不上就整篇不写;false=能写的先写,其余在 unmatched 里返回"),
] = False,
) -> str:
"""把一整篇生成好的论文按标题切段后写入论文。适合“AI 写完直接灌进去”。"""
sections = _split_sections(text)
if not sections:
s.fail("没有从 text 里找到任何与模板同名的标题,请改用 paper_write 按 position 写")
paragraphs: list[ParagraphSpec] = []
unmatched: list[str] = []
with s.session() as db:
paper = s.paper_or_fail(db, paper_id)
for heading, body in sections:
try:
s.resolve_position(paper, heading=heading)
except ToolError as error:
# ToolError carries the candidate list the model needs to
# correct the heading, so it is reported rather than raised
# when the caller asked for a lenient run.
unmatched.append(f"{heading}{error}")
if strict:
s.fail(f"标题“{heading}”无法定位到模板段落;{error}")
continue
paragraphs.append(
ParagraphSpec(heading=heading, text=body, split=split)
)
if not paragraphs:
s.fail("没有任何标题能定位到模板段落:" + "".join(unmatched))
payload = json.loads(paper_write(paper_id=paper_id, paragraphs=paragraphs, mode=mode))
if unmatched:
payload["unmatched"] = unmatched
return s.dumps(payload)
# --- helpers -----------------------------------------------------------------
def _assert_template_exists(db: Any, template_id: int | None) -> None:
"""Refuse a paper pointing at a template that is not there.
Checked in Python rather than by a foreign key, exactly as the REST route
does: TiDB parses but does not enforce ``FOREIGN KEY``, so an unchecked
write would leave a paper with no structure and no way to notice.
"""
if template_id is None:
return
s.template_or_fail(db, template_id)
def _heading_at(paper: Paper, position: 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"] == position:
return str(item["heading"])
return None
#: A Markdown ATX heading, the most likely shape of generated output.
_MARKDOWN_HEADING = "#"
def _split_sections(text: str) -> list[tuple[str, str]]:
"""Cut a generated document into ``(heading, body)`` pairs.
Two heading shapes are recognised, in this order:
1. a Markdown heading line — ``# 0 Abstract`` / ``## 1. Introduction``;
2. a plain line that is *exactly* one of the template's headings after
normalisation, which is what a model produces when it copies the outline
out of :func:`paper_outline` and writes prose under it.
A body line is never mistaken for a heading: rule 2 needs a short line with
no sentence-ending punctuation, and it still has to match a heading the
caller's paper actually defines. Anything before the first heading is
dropped — there is no paragraph to put it in — which is why the tool
refuses outright when no heading matched at all.
"""
sections: list[tuple[str, str]] = []
heading: str | None = None
body: list[str] = []
def close() -> None:
if heading is not None:
sections.append((heading, "\n".join(body).strip()))
for line in text.splitlines():
stripped = line.strip()
candidate = stripped.lstrip(_MARKDOWN_HEADING).strip() if stripped.startswith(_MARKDOWN_HEADING) else None
if candidate is not None:
if not candidate:
continue
close()
heading, body = candidate, []
continue
if _looks_like_plain_heading(stripped):
close()
heading, body = stripped, []
continue
if heading is not None:
body.append(line)
close()
# A heading with an empty body is a section the caller left blank; keeping
# it would overwrite a written paragraph with nothing.
return [(name, block) for name, block in sections if block]
def _looks_like_plain_heading(line: str) -> bool:
"""Whether a plain line could be a heading rather than prose.
Deliberately conservative. The caller's paper decides the final answer in
:func:`app.mcp.support.resolve_position`; this only avoids handing it every
sentence in the document.
"""
if not line or len(line) > 60:
return False
if line[-1] in "。!?.!?,,、;:":
return False
# A heading has no more than a handful of words before the first full stop;
# a prose line at this length that ends without punctuation is rare enough
# that the template match is what settles it.
return True
+277
View File
@@ -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),
}
+56
View File
@@ -0,0 +1,56 @@
"""Tool registration helpers.
One shape, one place
--------------------
Every tool in this project returns a JSON string, and that is a decision
rather than a default: :func:`mcp_tool` registers with
``structured_output=False`` so a result travels as a text block and nothing
else. The alternative — letting the SDK infer a structured output schema from
the ``-> str`` annotation — publishes a ``{"result": "..."}`` envelope and
sends the same JSON twice, once as ``structuredContent`` and once as text.
Half the clients read only one of the two, and the other half reads both and
pays for the duplication twice.
So a tool is registered by::
@mcp_tool(server, title="列出论文")
def paper_list(...) -> str:
\"\"\"Short description — this text is what the model reads.\"\"\"
The docstring becomes the tool description verbatim, which makes it the most
expensive string in the project: it is sent with every request. Keep it to the
one or two lines a model needs to choose the tool, and put the reasoning in
the module docstring instead, where it costs nothing per request.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any, TypeVar
from mcp.server.mcpserver import MCPServer
F = TypeVar("F", bound=Callable[..., Any])
def mcp_tool(
server: MCPServer,
*,
name: str | None = None,
title: str | None = None,
) -> Callable[[F], F]:
"""Register one tool as unstructured text output."""
def decorator(fn: F) -> F:
server.add_tool(
fn,
name=name or fn.__name__,
title=title,
structured_output=False,
)
return fn
return decorator
__all__ = ["mcp_tool"]
+227
View File
@@ -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
+253
View File
@@ -0,0 +1,253 @@
"""Template tools (模板): the outline a paper is written against.
A template is not configuration, it is *structure*: a paper renders its
headings by reading its template live, so editing a template reshapes every
paper written against it and deleting one would empty them. That is why
:func:`template_delete` refuses while any paper points at the template, with the
paper count in the message — the same refusal, and the same words, the REST API
gives.
The two ways to send a selection exist because two callers exist. A model that
knows the field ids and the order it wants them in sends ``field_ids`` and lets
the server number them 10, 20, 30 — spaced so a field can later be slipped
between two others without renumbering the rest. A caller reproducing an exact
outline sends ``fields`` with the sorts it means.
"""
from __future__ import annotations
from typing import Annotated, Any
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.crud import template as template_crud
from app.mcp import support as s
from app.mcp.specs import FieldSpec
from app.mcp.tools.registry import mcp_tool
from app.models import Template
from app.schemas.template import TemplateCreate, TemplateFieldInput
#: Spacing between generated sorts. Ties are legal in a template and gaps are
#: expected — the editor numbers a fresh selection 1..N only on request — so
#: numbering 10, 20, 30 leaves room to insert without touching the rest.
SORT_STEP = 10
def register(server: MCPServer) -> None:
"""Register every template tool on ``server``."""
@mcp_tool(server, title="列出模板")
def template_list(
keyword: Annotated[
str | None, Field(description="按模板名称或摘要模糊搜索")
] = None,
page: Annotated[int, Field(description="页码,从 1 开始")] = 1,
page_size: Annotated[int, Field(description="每页条数,最大 200")] = 20,
) -> str:
"""列出论文模板(含字段数量,不含大纲)。"""
page, page_size = s.page_args(page, page_size)
with s.session() as db:
items, total = template_crud.list_templates(
db, keyword=keyword, page=page, page_size=page_size
)
return s.dumps(
s.page_payload(
items=[
{
"id": item.id,
"name": item.name,
"abstract": item.abstract,
"fields": item.field_count,
"updated_at": item.updated_at,
}
for item in items
],
total=total,
page=page,
page_size=page_size,
)
)
@mcp_tool(server, title="取模板大纲")
def template_get(
template_id: Annotated[int, Field(description="模板 id")],
) -> str:
"""取一个模板的完整大纲:每个字段的位置、标题、层级与字号。"""
with s.session() as db:
template = s.template_or_fail(db, template_id)
payload = _template_row(template)
payload["used_by_papers"] = paper_crud.template_paper_counts(
db, [template.id]
).get(template.id, 0)
return s.dumps(payload)
@mcp_tool(server, title="新建模板")
def template_create(
name: Annotated[str, Field(description="模板名称,必须唯一")],
abstract: Annotated[str | None, Field(description="模板说明")] = None,
field_ids: Annotated[
list[int] | None,
Field(description="字段库 id 数组,按顺序排成大纲;sort 会自动编号 10/20/30…"),
] = None,
fields: Annotated[
list[FieldSpec] | None,
Field(description="要精确指定 sort 时用这个,格式 [{field_id, sort}];给了它就忽略 field_ids"),
] = None,
) -> str:
"""新建模板:名称 + 说明 + 有序字段选择。用 field_list 先查字段 id。"""
selection = _selection(field_ids, fields)
payload = TemplateCreate(name=name, abstract=abstract, fields=selection)
with s.session() as db:
_assert_name_free(db, payload.name)
_assert_fields_exist(db, payload.fields)
template = template_crud.create(
db,
name=payload.name,
abstract=payload.abstract,
fields=payload.fields,
)
return s.dumps(_template_row(template))
@mcp_tool(server, title="修改模板")
def template_update(
template_id: Annotated[int, Field(description="模板 id")],
name: Annotated[str | None, Field(description="新名称;不传则不改")] = None,
abstract: Annotated[str | None, Field(description="新说明")] = None,
clear_abstract: Annotated[
bool, Field(description="true 表示把说明清空")
] = False,
field_ids: Annotated[
list[int] | None,
Field(description="整组替换字段选择,按顺序编号;不传则不动大纲"),
] = None,
fields: Annotated[
list[FieldSpec] | None,
Field(description="整组替换并精确指定 sort,格式 [{field_id, sort}]"),
] = None,
) -> str:
"""修改模板名称、说明或整组字段。改字段会立刻影响所有用它的论文。"""
if name is None and abstract is None and not clear_abstract and field_ids is None and fields is None:
s.fail("没有给出任何要修改的内容")
selection = (
None if (field_ids is None and fields is None) else _selection(field_ids, fields)
)
with s.session() as db:
template = s.template_or_fail(db, template_id)
if name is not None:
_assert_name_free(db, name, exclude_id=template_id)
if selection is not None:
_assert_fields_exist(db, selection)
updated = template_crud.update(
db,
template,
name=name,
abstract=abstract,
abstract_provided=clear_abstract or abstract is not None,
fields=selection,
)
return s.dumps(_template_row(updated))
@mcp_tool(server, title="删除模板")
def template_delete(
template_ids: Annotated[int | list[int], Field(description="模板 id,或 id 数组")],
) -> str:
"""删除模板(正被论文使用的模板会被拒绝,需先给论文换模板)。"""
ids = s.id_list(template_ids)
if not ids:
s.fail("必须给出至少一个模板 id")
with s.session() as db:
_assert_not_used_by_papers(db, ids)
return s.dumps({"deleted": template_crud.delete_many(db, ids), "ids": ids})
# --- helpers -----------------------------------------------------------------
def _template_row(template: Template) -> dict:
"""One template flattened for a model: the outline is the point."""
return {
"id": template.id,
"name": template.name,
"abstract": template.abstract,
"fields": [
{
"position": item.sort,
"field_id": item.field_id,
"name": item.field.name,
"level": item.field.level,
"font_size": float(item.field.font_size),
"font_color": item.field.font_color,
}
for item in template.items
],
}
def _selection(
field_ids: list[int] | None,
fields: list[FieldSpec] | None,
) -> list[TemplateFieldInput]:
"""Turn either way of describing a selection into placement rows.
The order of ``field_ids`` is the outline order; the numbering is generated
rather than asked for, because a model that has just looked at the field
library knows what it wants in what order and not which integers to store.
"""
if fields:
return [
TemplateFieldInput(field_id=item.field_id, sort=item.sort) for item in fields
]
if field_ids:
return [
TemplateFieldInput(field_id=field_id, sort=(index + 1) * SORT_STEP)
for index, field_id in enumerate(field_ids)
]
return []
def _assert_name_free(db: Session, name: str, *, exclude_id: int | None = None) -> None:
if template_crud.name_taken(db, name, exclude_id=exclude_id):
s.fail(f"模板名称“{name}”已存在")
def _assert_fields_exist(db: Session, fields: list[Any]) -> None:
"""Reject a selection that points at fields the library does not have.
Checked in Python rather than by a foreign key, exactly as the REST route
does: TiDB parses but does not enforce ``FOREIGN KEY``.
"""
missing = template_crud.missing_field_ids(db, [item.field_id for item in fields])
if missing:
joined = "".join(str(field_id) for field_id in missing)
s.fail(f"字段不存在:{joined}(先用 field_list 查可用的字段 id")
def _assert_not_used_by_papers(db: Session, template_ids: list[int]) -> None:
"""Refuse to delete a template a paper is written against.
Reported for every offending template at once, so a batch delete does not
become trial and error. The fix is a template switch on the paper, which
:func:`paper_update` does in one call.
"""
counts = paper_crud.template_paper_counts(db, template_ids)
if not counts:
return
blockers = []
for template_id, count in sorted(counts.items()):
template = db.get(Template, template_id)
name = template.name if template is not None else template_id
blockers.append(f"{name}”({count} 篇论文)")
s.fail(
"以下模板正被论文使用,请先用 paper_update 给论文切换模板:"
+ "".join(blockers)
)