2aff867641
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.
186 lines
7.4 KiB
Python
186 lines
7.4 KiB
Python
"""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}")
|