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
+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)
)