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

254 lines
10 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.
"""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)
)