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,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)
|
||||
)
|
||||
Reference in New Issue
Block a user