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
+40
View File
@@ -666,3 +666,43 @@ def template_paper_counts(
with the count that explains why.
"""
return _paper_counts_by_template(db, template_ids)
# --- search ------------------------------------------------------------------
def search_sentences(
db: Session,
*,
keyword: str,
paper_id: int | None = None,
limit: int = 50,
) -> list[tuple[PaperSentence, str]]:
"""Find sentences whose text contains ``keyword``, newest paper first.
Returns ``(sentence, paper_title)`` pairs, the title read alongside the row
so the caller does not pay a lazy load per sentence.
The MCP server's ``sentence_search`` tool is the caller. What it serves is
consistency rather than retrieval: a paper that calls it 洪水损失 should
not be joined by one that calls it GUL, and reading whole documents to find
one phrase would not fit in a context window.
"""
pattern = like_pattern(keyword)
stmt = (
select(PaperSentence, Paper.title)
.join(Paper, Paper.id == PaperSentence.paper_id)
.where(PaperSentence.content.like(pattern, escape=LIKE_ESCAPE))
.order_by(
Paper.updated_at.desc(),
PaperSentence.paper_id,
PaperSentence.paper_template_filed_sort,
PaperSentence.sort,
PaperSentence.id,
)
.limit(limit)
)
if paper_id is not None:
stmt = stmt.where(PaperSentence.paper_id == paper_id)
return [(sentence, title) for sentence, title in db.execute(stmt).all()]
+25
View File
@@ -0,0 +1,25 @@
"""The paper-doc MCP server: the same domain layer, spoken as MCP tools.
Three front doors, one domain layer
-----------------------------------
The REST API (``app.api``), the MCP server (this package) and the seed script
(``scripts/seed.py``) all end up in :mod:`app.crud` and validate through
:mod:`app.schemas`. Nothing about a paper is decided here: a rule fixed in the
CRUD layer is fixed in the API, in the tools and in the next tool added.
What lives here is only what a *model* needs and a browser does not:
* compact JSON instead of HTTP envelopes, because tool results are paid for in
context tokens;
* paragraphs addressed by **heading** as well as by position, because a model
knows the paper says "1. Introduction" and does not know that the template
happens to place it at ``sort = 20``;
* a bulk write, because "generate the paper, then put it in" is one intention
and should not be thirty round trips.
Transports
----------
Both MCP transports are served from one build: ``stdio`` (what Claude Code,
Codex and the Harness spawn locally) and ``streamable-http`` (what a client on
another machine connects to). See ``scripts/mcp_server.py``.
"""
+15
View File
@@ -0,0 +1,15 @@
"""``python -m app.mcp`` — the same entry point as ``scripts/mcp_server.py``.
Having both means a client config can say either
``python -m app.mcp`` (from ``backend/``) or ``python scripts/mcp_server.py``
(from anywhere), and neither needs to know about the other.
"""
from __future__ import annotations
import sys
from app.mcp.server import main
if __name__ == "__main__":
sys.exit(main())
+64
View File
@@ -0,0 +1,64 @@
"""Self-check: prove the server can start before a client has to find out.
Run with ``python -m app.mcp --check`` (or ``scripts/mcp_server.py --check``).
The failure this exists for is the quiet one. A stdio MCP client spawns the
server, the process starts, the handshake succeeds, and every tool call fails
with a database error the model reads as "the tool is broken". Connecting once
here, on demand, turns that into a message with a host and a port in it.
It is deliberately independent of the transports: it builds the same server
object the transports serve, so the tool count it prints is the tool count a
client will see.
"""
from __future__ import annotations
import sys
import anyio
from mcp.server.mcpserver import MCPServer
from sqlalchemy import func, select
from app.core.config import get_settings
from app.db.session import SessionLocal
from app.mcp.server import SERVER_VERSION
from app.models import Paper, PaperSentence, Template, TemplateFieldLibrary
def run_selfcheck(server: MCPServer) -> int:
"""Report the database, the tool surface, and where the tools come from."""
settings = get_settings()
print(f"paper-doc MCP {SERVER_VERSION} (name={server.name})")
print(f"database : {settings.safe_database_url}")
try:
with SessionLocal() as db:
counts = {
"paper": db.scalar(select(func.count(Paper.id))) or 0,
"template": db.scalar(select(func.count(Template.id))) or 0,
"paper_sentence": db.scalar(select(func.count(PaperSentence.id))) or 0,
"template_field_library": db.scalar(
select(func.count(TemplateFieldLibrary.id))
)
or 0,
}
except Exception as error: # noqa: BLE001 - the message is the point
print(f"database : 连接失败 — {error}", file=sys.stderr)
print(
"检查 backend/.env 的 DB_* 配置,或用 proxy_endpoint skill 确认网络可达。",
file=sys.stderr,
)
return 1
print("rows : " + ", ".join(f"{name}={count}" for name, count in counts.items()))
tools = anyio.run(server.list_tools)
groups: dict[str, int] = {}
for tool in tools:
groups[tool.name.split("_", 1)[0]] = groups.get(tool.name.split("_", 1)[0], 0) + 1
print(f"tools : {len(tools)} " + ", ".join(f"{k}={v}" for k, v in sorted(groups.items())))
for tool in tools:
print(f" - {tool.name}")
return 0
+247
View File
@@ -0,0 +1,247 @@
"""Build and run the paper-doc MCP server.
One build, three transports
---------------------------
``stdio`` is what a client spawns: Claude Code, Codex and the DeepSeek Harness
all launch a command and speak JSON-RPC over its stdin/stdout. Nothing may be
printed to stdout by anything in this process — a stray ``print`` corrupts the
protocol stream, so diagnostics go to stderr (the SDK's own logging already
does, see ``configure_logging``).
``streamable-http`` is what a client *on another machine* connects to: one
long-lived process serving ``POST /mcp``, optionally behind a bearer token.
``sse`` is the older HTTP shape, kept for clients that have not moved yet.
Nothing is registered here
--------------------------
The tools are registered by :func:`app.mcp.tools.register_all`, so this module
stays about transports and configuration. It is also the only module in the
package that knows a transport exists at all, which keeps the tool layer
testable by calling its functions directly.
The instructions below are sent once, at ``initialize``. They are the model's
map of the tool surface, so they describe the *workflow* and leave the tool
descriptions to describe the tools.
"""
from __future__ import annotations
import argparse
import os
import sys
from typing import Any
from mcp.server.mcpserver import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
from app.core.config import get_settings
from app.mcp.tools import register_all
#: Version reported at ``initialize``. Bumped when the tool surface changes in
#: a way a client could notice, since it is the only version a client sees.
SERVER_VERSION = "1.0.0"
INSTRUCTIONS = """\
paper-doc 论文写作服务:管理论文(paper)、段落(paragraph)、句子(sentence)、
模板(template)和字段库(field)。工具名前缀对应这五类。
典型写作流程:
1. paper_list / paper_get 找到论文;没有就 paper_create(建议带 template_id)。
2. paper_outline 看段落骨架:每段有 position 和标题(如 “1. Introduction”)。
3. 写入正文:整篇用 paper_write_textMarkdown 标题分段),单段用 paragraph_write
多段一起用 paper_write。段落可以用 heading 指定,服务端会解析成 position。
4. 微调用 sentence_add / sentence_update / sentence_delete,不必重写整段。
5. 验证用 paper_documenttext 形式)或 paragraph_get。
关键规则:
- paper_sentence 是一行一句,写入时句子以数组给出最稳妥;给 text 时用 split 指定切句方式。
- 段落由模板的 position 决定;换模板不丢正文,模板没定义的位置会显示为“未设定”。
- 删除论文会连带句子与引用且不可恢复,paper_delete 需要 confirm=true。
- 模板正被论文使用时不能删;字段正被模板使用时不能删——两者都会说明原因和解决办法。
"""
def build_server() -> MCPServer:
"""Assemble the server with every paper-doc tool registered."""
server = MCPServer(
name="paper-doc",
title="paper-doc 论文写作",
version=SERVER_VERSION,
instructions=INSTRUCTIONS,
# The library's INFO stream includes one line per refused call, which
# is useful in a log and noise on a terminal shared with the protocol.
log_level="WARNING",
)
register_all(server)
return server
# --- HTTP transports ---------------------------------------------------------
class BearerTokenMiddleware:
"""Require ``Authorization: Bearer <token>`` on every MCP request.
A token, not an OAuth flow: this server is reached over a private network by
a client holding a shared secret, and the SDK's OAuth support would need a
full authorization server to say the same thing. When no token is
configured the check is skipped entirely — which is the right default for
``stdio`` and for a loopback bind, and the reason
:func:`_serve_http` warns when a non-loopback host is used without one.
"""
def __init__(self, app: Any, token: str) -> None:
self.app = app
self.token = token
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
if scope["type"] == "http":
headers = {
key.lower(): value for key, value in (scope.get("headers") or [])
}
presented = headers.get(b"authorization", b"").decode("latin-1")
if presented != f"Bearer {self.token}":
await send(
{
"type": "http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"www-authenticate", b"Bearer"),
],
}
)
await send(
{
"type": "http.response.body",
"body": b'{"error":"unauthorized"}',
}
)
return
await self.app(scope, receive, send)
def _transport_security(host: str) -> TransportSecuritySettings | None:
"""DNS-rebinding protection, off for a non-loopback bind.
The SDK turns it on by default for ``127.0.0.1``, where it costs nothing:
the client is local and sends the right ``Host``. A server bound to a LAN
address is reached by whatever name the client knows it by, so the
allow-list would reject every request with a confusing 421 — the token is
the gate there instead.
"""
if host in ("127.0.0.1", "localhost", "::1"):
return None
return TransportSecuritySettings(enable_dns_rebinding_protection=False)
def _serve_http(
server: MCPServer,
*,
transport: str,
host: str,
port: int,
path: str | None,
token: str | None,
) -> None:
"""Build a Starlette app for an HTTP transport and serve it with uvicorn."""
import uvicorn
security = _transport_security(host)
if transport == "sse":
app = server.sse_app(host=host, transport_security=security)
else:
app = server.streamable_http_app(
streamable_http_path=path or "/mcp",
host=host,
transport_security=security,
)
if token:
app = BearerTokenMiddleware(app, token)
elif host not in ("127.0.0.1", "localhost", "::1"):
print(
f"[paper-doc-mcp] 警告:绑定在 {host} 且没有设置 MCP_HTTP_TOKEN"
"网络内任何人都能改论文",
file=sys.stderr,
)
uvicorn.run(app, host=host, port=port, log_level="info")
# --- command line ------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
"""The CLI, shared by the script entry point and the module entry point."""
parser = argparse.ArgumentParser(
prog="paper-doc-mcp",
description="paper-doc 的 MCP 服务:论文、段落、句子、模板、字段的增删改查。",
)
parser.add_argument(
"--transport",
default=os.getenv("MCP_TRANSPORT", "stdio"),
choices=["stdio", "http", "streamable-http", "sse"],
help="stdio(默认,给 Claude Code / Codex / Harness 本地拉起)或 http",
)
parser.add_argument(
"--host",
default=os.getenv("MCP_HOST", "127.0.0.1"),
help="HTTP 监听地址,默认 127.0.0.1;对外提供服务时用 0.0.0.0",
)
parser.add_argument(
"--port",
type=int,
default=int(os.getenv("MCP_PORT", "8931")),
help="HTTP 端口,默认 8931",
)
parser.add_argument(
"--path",
default=os.getenv("MCP_PATH"),
help="streamable-http 的路径,默认 /mcp",
)
parser.add_argument(
"--token",
default=os.getenv("MCP_HTTP_TOKEN"),
help="HTTP 传输的 Bearer 口令;对外暴露时务必设置",
)
parser.add_argument(
"--check",
action="store_true",
help="只做自检:连数据库、列出工具名,然后退出(不启动服务)",
)
return parser
def main(argv: list[str] | None = None) -> int:
"""Entry point of ``scripts/mcp_server.py`` and ``python -m app.mcp``."""
args = build_parser().parse_args(argv)
server = build_server()
if args.check:
from app.mcp.selfcheck import run_selfcheck
return run_selfcheck(server)
settings = get_settings()
print(
f"[paper-doc-mcp] transport={args.transport} db={settings.safe_database_url}",
file=sys.stderr,
)
if args.transport == "stdio":
server.run(transport="stdio")
else:
_serve_http(
server,
transport=args.transport,
host=args.host,
port=args.port,
path=args.path,
token=args.token,
)
return 0
__all__ = ["BearerTokenMiddleware", "INSTRUCTIONS", "SERVER_VERSION", "build_server", "main"]
+120
View File
@@ -0,0 +1,120 @@
"""The shapes a model may send when it writes.
These are tool-argument models, not domain models: they exist so the JSON
Schema a client receives is precise enough that the model gets the shape right
the first time. Each one is converted into the project's own payload
(:class:`~app.schemas.paper.SentenceInput`,
:class:`~app.schemas.template.TemplateFieldInput`) before it reaches the CRUD
layer, so the validation rules stay in one place — a citation with a blank
quote is refused here for the same reason the REST API refuses it.
The union types are deliberate. A model writing sentences naturally produces
either plain strings or objects, and refusing one of the two shapes would
produce a retry that costs a whole round trip:
"sentences": ["First sentence.", "Second sentence."]
"sentences": [{"content": "Cited sentence.", "citations": [{"quote": ""}]}]
"sentences": [{"text": "One long block to be cut.", "split": "sentence"}]
"""
from __future__ import annotations
from typing import Annotated, Literal
from pydantic import BaseModel, Field
#: How a block of text becomes sentences when the caller sent ``text`` instead
#: of an explicit list. See :func:`app.mcp.support.split_text`.
SplitMode = Literal["line", "sentence", "paragraph"]
class CitationSpec(BaseModel):
"""One citation of one sentence."""
quote: Annotated[
str,
Field(description="引用内容,必填;空引用会被拒绝"),
]
reference_id: Annotated[
int | None,
Field(description="参考文献库的编号,暂无库时可省略"),
] = None
class SentenceSpec(BaseModel):
"""One sentence, optionally carrying citations."""
content: Annotated[str, Field(description="句子正文;空白会被折叠成一行")]
citations: Annotated[
list[CitationSpec],
Field(description="这一段引用了哪些文献"),
] = []
class TextSpec(BaseModel):
"""A block of text to be cut into sentences by the server."""
text: Annotated[str, Field(description="要写入的整段文本")]
split: Annotated[
SplitMode,
Field(description="切句方式:line 一行一句(默认)/ sentence 按句号切 / paragraph 整段一句"),
] = "line"
citations: Annotated[
list[CitationSpec],
Field(description="整段共用的引用"),
] = []
#: One entry of a paragraph's sentence list — a string, a sentence object, or a
#: block to be cut.
SentenceItem = str | SentenceSpec | TextSpec
class ParagraphSpec(BaseModel):
"""One paragraph to write, addressed by position or by heading."""
position: Annotated[
int | None,
Field(description="段落位置(模板字段的 sort);与 heading 二选一"),
] = None
heading: Annotated[
str | None,
Field(description="段落标题,如 “1. Introduction”;会按模板解析成位置"),
] = None
sentences: Annotated[
list[SentenceItem] | None,
Field(description="句子列表;字符串或对象都可以"),
] = None
text: Annotated[
str | None,
Field(description="整段文本,等价于 sentences 里放一个 {text, split} 对象"),
] = None
split: Annotated[
SplitMode,
Field(description="text 的切句方式;默认 line 一行一句"),
] = "line"
citations: Annotated[
list[CitationSpec],
Field(description="整段共用的引用"),
] = []
class FieldSpec(BaseModel):
"""One placement of a library field inside a template."""
field_id: Annotated[int, Field(description="字段库里的字段 id")]
sort: Annotated[
int,
Field(description="渲染顺序,升序整数;唯一决定段落顺序的就是它"),
]
__all__ = [
"CitationSpec",
"FieldSpec",
"ParagraphSpec",
"SentenceItem",
"SentenceSpec",
"SplitMode",
"TextSpec",
]
+670
View File
@@ -0,0 +1,670 @@
"""Shared plumbing for the MCP tools: sessions, JSON, lookups, and headings.
Every tool in :mod:`app.mcp.tools` is three steps — open a session, call the
same :mod:`app.crud` helper the REST API calls, serialise the result into a
compact JSON string. This module holds the shared half of that.
Two things here are worth knowing before reading a tool:
**Why the results are hand-shaped rather than schema dumps.** A tool result is
paid for in context tokens, and ``PaperRead.model_dump()`` carries
``created_at``, ``updated_at`` and four count columns through every list row.
The ``*_row`` helpers below pick what a model actually reads, and
:func:`clean` drops every ``None`` on the way out.
**Why a paragraph can be addressed by heading.** ``paper_sentence`` is stored
against a *position* (``paper_template_filed_sort``), which is correct for the
document and useless for a model: nobody writing "1. Introduction" knows that
the template happens to place it at ``sort = 20``. :func:`resolve_position`
accepts either, and matches a heading through the numbering, the level and a
unique substring so ``"Introduction"``, ``"1. Introduction"`` and
``"1 Introduction"`` all land in the same paragraph.
Text helpers
------------
:func:`split_text` is the one place this project ever cuts text, and it exists
only because a model arrives with a block of prose rather than with an array of
sentences. It is opt-in: the write tools default to ``line`` (one line, one
sentence — the project's own rule), and ``sentence`` refuses to cut at a
decimal point or after ``et al.``. The editor never uses it, so no stored text
is ever re-split.
"""
from __future__ import annotations
import json
import re
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from datetime import date, datetime
from decimal import Decimal
from typing import Any, NoReturn
from mcp.server.mcpserver.exceptions import ToolError
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.crud import paper as paper_crud
from app.crud import template as template_crud
from app.db.session import SessionLocal
from app.models import (
Paper,
PaperSentence,
Template,
TemplateFieldLibrary,
)
from app.schemas.paper import (
CitationInput,
PaperDocumentRead,
ParagraphRead,
SentenceInput,
fold_whitespace,
)
#: Upper bound on a page a tool may ask for. The REST API caps at 200 as well;
#: a tool result is a context window, so it is not raised here.
MAX_PAGE_SIZE = 200
# --- sessions and errors -----------------------------------------------------
@contextmanager
def session() -> Iterator[Session]:
"""One database session per tool call, always closed.
A tool is a whole request: there is no FastAPI dependency to hang a session
on, and leaving one open across calls would keep a TiDB connection pinned
for as long as the client stays alive.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def fail(message: str) -> NoReturn:
"""Refuse a call with a message the model can act on.
:class:`~mcp.server.mcpserver.exceptions.ToolError` becomes
``isError: true`` with this text as the content, which the model reads and
retries against — the opposite of an unhandled exception, whose message
never reaches it.
"""
raise ToolError(message)
# --- JSON shaping ------------------------------------------------------------
def _jsonable(value: Any) -> Any:
"""Render the two types SQLAlchemy hands back that JSON does not have."""
if isinstance(value, (datetime, date)):
# Seconds are enough for a list row, and microseconds are six tokens.
return value.strftime("%Y-%m-%d %H:%M:%S")
if isinstance(value, Decimal):
return float(value)
raise TypeError(f"不能序列化的类型:{type(value).__name__}")
def clean(value: Any) -> Any:
"""Recursively drop ``None`` from objects, so a result carries only what is set.
Lists and the empty string survive: an empty ``sentences`` list means "this
paragraph is empty", which is a fact, while ``author: null`` means "no
author", which the absence of the key says for free.
"""
if isinstance(value, dict):
return {
key: clean(item) for key, item in value.items() if item is not None
}
if isinstance(value, list):
return [clean(item) for item in value]
return value
def dumps(value: Any) -> str:
"""Serialise a tool result: compact, unescaped, ``None``-free."""
return json.dumps(
clean(value),
ensure_ascii=False,
default=_jsonable,
separators=(",", ":"),
)
def page_args(page: int, page_size: int) -> tuple[int, int]:
"""Clamp a page request to something a context window can hold."""
return max(1, int(page)), min(max(1, int(page_size)), MAX_PAGE_SIZE)
def page_payload(*, items: list[Any], total: int, page: int, page_size: int) -> dict:
"""The list envelope every list tool returns, mirroring ``PageResult``."""
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"pages": (total + page_size - 1) // page_size if page_size else 0,
}
# --- lookups that explain themselves -----------------------------------------
def paper_or_fail(db: Session, paper_id: int) -> Paper:
"""Return one paper, or refuse with the id that was not found."""
paper = paper_crud.get(db, paper_id)
if paper is None:
fail(f"论文 {paper_id} 不存在")
return paper
def template_or_fail(db: Session, template_id: int) -> Template:
"""Return one template, or refuse with the id that was not found."""
template = template_crud.get(db, template_id)
if template is None:
fail(f"模板 {template_id} 不存在")
return template
def field_or_fail(db: Session, field_id: int) -> TemplateFieldLibrary:
"""Return one library field, or refuse with the id that was not found."""
from app.crud import template_field_library as field_crud
field = field_crud.get(db, field_id)
if field is None:
fail(f"字段 {field_id} 不存在")
return field
def sentence_or_fail(paper: Paper, sentence_id: int) -> PaperSentence:
"""Find a sentence *within* one paper.
Scoped to the paper on purpose, exactly as the REST route is: an id from
another paper must not be editable through this one.
"""
for sentence in paper.sentences:
if sentence.id == sentence_id:
return sentence
fail(f"句子 {sentence_id} 不属于论文 {paper.id}")
raise AssertionError # unreachable; keeps type checkers honest
def id_list(value: int | Sequence[int] | None) -> list[int]:
"""Accept ``3`` or ``[3, 4]`` as the same request.
A model told to "delete these two" should not have to discover a separate
batch tool, and a batch of one is the common case anyway.
"""
if value is None:
return []
if isinstance(value, bool):
fail("ids 必须是整数或整数数组")
if isinstance(value, int):
return [value]
return [int(item) for item in value]
# --- rows --------------------------------------------------------------------
def _short_moment(value: datetime | None) -> str | None:
return value.strftime("%Y-%m-%d %H:%M") if value is not None else None
def paper_row(paper: Paper) -> dict:
"""One paper as a list row or a summary.
The counts come from :func:`app.crud.paper.read` rather than from a second
count here, so a tool result and a page of the UI can never disagree about
how much has been written.
"""
read = paper_crud.read(paper)
return {
"id": read.id,
"title": read.title,
"template_id": read.template_id,
"template": read.template_name,
"author": read.author,
"status": read.status,
"keywords": read.keywords,
"target_journal": read.target_journal,
# 已写段落 / 模板定义段落 — the progress read-out the paper list shows.
"paragraphs": read.paragraph_count,
"template_paragraphs": read.template_paragraph_count,
"sentences": read.sentence_count,
"updated_at": _short_moment(read.updated_at),
}
def citation_row(citation: Any) -> dict:
"""One citation, as the model reads it."""
return {"reference_id": citation.reference_id, "quote": citation.quote}
def sentence_row(sentence: Any, *, separator: str | None = None) -> dict:
"""One sentence with its citations.
``separator_before`` is passed through when the caller assembled a
paragraph, because it is derived from the neighbouring sentence and cannot
be recomputed from this row alone.
"""
row = {
"id": sentence.id,
"position": sentence.paper_template_filed_sort,
"sort": sentence.sort,
"content": sentence.content,
"citations": [citation_row(item) for item in sentence.citations],
}
if separator is not None:
row["separator_before"] = separator
return row
def paragraph_row(paragraph: ParagraphRead, *, preview: int = 80) -> dict:
"""One paragraph as an outline entry: where it is and how much is in it."""
text = paragraph_text(paragraph)
return {
"position": paragraph.paper_template_filed_sort,
"heading": paragraph.name,
"level": paragraph.level,
"matched": paragraph.matched,
"sentences": len(paragraph.sentences),
"chars": len(text),
"preview": text[:preview] if preview else None,
}
def paragraph_text(paragraph: ParagraphRead, *, citations: bool = False) -> str:
"""Print one paragraph exactly as the document renders it.
``separator_before`` is added, not invented: the server decided what
belongs at each seam, so a tool result and the paper view agree on where
the spaces go. Nothing here adds spacing of its own.
"""
parts: list[str] = []
for sentence in paragraph.sentences:
parts.append(sentence.separator_before + sentence.content)
if citations and sentence.citations:
markers = ",".join(str(index) for index in range(1, len(sentence.citations) + 1))
parts.append(f"[{markers}]")
return "".join(parts)
def document_text(
document: PaperDocumentRead,
*,
include_empty: bool = False,
citations: bool = True,
) -> str:
"""Render a whole paper as the text a reader would see.
Headings come from the template, paragraphs from the content, and
positions the template does not define are printed under 未设定 — the same
three rules the paper view renders by, applied here so a single tool call
can hand a model the finished manuscript.
"""
lines: list[str] = []
for paragraph in document.paragraphs:
body = paragraph_text(paragraph, citations=citations)
if not body and not include_empty:
continue
lines.append(paragraph.name if paragraph.matched and paragraph.name else "未设定")
lines.append(body)
lines.append("")
if citations and document.citations:
lines.append("参考文献")
for citation in document.citations:
reference = (
f"[{citation.reference_id}]" if citation.reference_id else ""
)
lines.append(f"[{citation.index}]{reference} {citation.quote}")
lines.append("")
text = "\n".join(lines).rstrip("\n")
if document.warnings:
text += "\n\n(提示:" + " ".join(document.warnings) + ""
return text
# --- headings ----------------------------------------------------------------
#: A leading outline number: ``1``, ``1.``, ``2.3.1``, ``3、``, ``4)``.
_NUMBER_PREFIX = re.compile(r"^\s*\d+(?:\.\d+)*\s*[.、)]?\s*")
def _heading_key(text: str) -> str:
"""A comparison key for a heading: no markup, no number, no space, no case.
Removing the numbering is what lets ``"Introduction"`` find
``"1. Introduction"``. Removing the space is what lets a line the model
wrote as ``"1.Introduction"`` still find it.
"""
stripped = text.strip().lstrip("#").strip()
stripped = _NUMBER_PREFIX.sub("", stripped)
return re.sub(r"[\s\u3000]+", "", stripped).casefold()
def heading_index(paper: Paper) -> list[dict]:
"""The paper's outline: every position its template defines.
Ties are collapsed the way :func:`app.crud.paper.build_document` collapses
them — the first placement at a position renders the heading — so this list
and the document it describes cannot disagree about what a position says.
"""
if paper.template is None:
return []
seen: dict[int, dict] = {}
for placement in paper.template.items:
if placement.sort in seen:
continue
seen[placement.sort] = {
"position": placement.sort,
"heading": placement.field.name,
"level": placement.field.level,
"field_id": placement.field_id,
}
return [seen[position] for position in sorted(seen)]
def resolve_position(
paper: Paper,
*,
position: int | None = None,
heading: str | None = None,
) -> int:
"""Turn "where the model wants to write" into a paragraph position.
A position is taken as given — including one the template does not define,
which is exactly the case the renderer already handles as 未设定. A heading
is matched against the paper's template and refused with the list of what
the template actually says, because a model that guessed a heading will
guess again correctly once it can see the real ones.
"""
if position is not None:
return int(position)
if heading is None or not heading.strip():
fail("必须给出 position 或 heading,两者至少要有一个")
outline = heading_index(paper)
if not outline:
fail(
f"论文 {paper.id} 还没有模板,无法按标题定位;"
"请改用 position,或先用 paper_update 给论文设置 template_id"
)
query = _heading_key(heading)
exact = [item for item in outline if _heading_key(item["heading"]) == query]
if len(exact) == 1:
return int(exact[0]["position"])
if len(exact) > 1:
fail(_ambiguous(heading, exact))
partial = [item for item in outline if query and query in _heading_key(item["heading"])]
if len(partial) == 1:
return int(partial[0]["position"])
if len(partial) > 1:
fail(_ambiguous(heading, partial))
available = "".join(f"{item['heading']}({item['position']})" for item in outline)
fail(f"模板里没有标题“{heading}”。可用标题:{available}")
def _ambiguous(heading: str, matches: list[dict]) -> str:
listed = "".join(f"{item['heading']}({item['position']})" for item in matches)
return f"标题“{heading}”匹配到多个段落({listed}),请写完整标题或直接用 position"
# --- text --------------------------------------------------------------------
#: Where a sentence may end in CJK text. ```` is deliberately absent: a
#: semicolon joins two halves of one argument often enough that cutting there
#: would produce fragments rather than sentences.
_CJK_TERMINATORS = "。!?…"
#: Trailing marks that belong to the sentence they follow.
_TRAILING = "”’」』))】》…"
#: Latin abbreviations whose full stop is not a sentence end.
_ABBREVIATIONS = frozenset(
{
"al.",
"approx.",
"ca.",
"cf.",
"dr.",
"e.g.",
"etc.",
"fig.",
"figs.",
"i.e.",
"mr.",
"mrs.",
"no.",
"prof.",
"vs.",
}
)
SPLIT_MODES = ("line", "sentence", "paragraph")
def split_text(text: str, mode: str = "line") -> list[str]:
"""Cut a block of prose into sentences — the only text cutting in the project.
Three modes, in increasing order of how much the tool is trusted:
``line``
Each non-blank line is one sentence. The default, and the project's own
rule ("one line in the editor is one sentence"): nothing is guessed, so
nothing can be guessed wrong.
``paragraph``
The whole block folds to a single sentence. For a writer who puts a
paragraph on one line and means it as one unit.
``sentence``
Best-effort splitting after ``。!?`` and after a Latin ``.`` ``!``
``?`` that is followed by a space and something that starts a sentence.
It does not cut at a decimal point or after ``et al.``, but it is still
a guess — which is why it is never the default and never applied to
text that is already stored.
Blank lines are dropped in every mode: the tool is writing, and an empty
sentence is only meaningful when a person puts one there on purpose.
"""
if mode not in SPLIT_MODES:
fail(f"split 只能是 {'/'.join(SPLIT_MODES)},收到的是“{mode}")
if mode == "paragraph":
folded = fold_whitespace(text)
return [folded] if folded else []
if mode == "line":
return [line.strip() for line in text.splitlines() if line.strip()]
return _split_sentences(fold_whitespace(text))
def _split_sentences(text: str) -> list[str]:
"""The ``sentence`` mode of :func:`split_text`, on already-folded text."""
if not text:
return []
out: list[str] = []
start = 0
index = 0
while index < len(text):
char = text[index]
if char in _CJK_TERMINATORS:
end = index + 1
while end < len(text) and text[end] in _TRAILING:
end += 1
out.append(text[start:end])
start = end
index = end
continue
if char in ".!?" and _is_latin_break(text, index):
out.append(text[start : index + 1])
start = index + 1
index += 1
tail = text[start:]
if tail:
out.append(tail)
return [item.strip() for item in out if item.strip()]
def _is_latin_break(text: str, index: int) -> bool:
"""Whether the ``.``/``!``/``?`` at ``index`` ends a sentence."""
# A boundary needs a space (or the end) after it, or "3.14" and "www.x.com"
# would each become two sentences.
after = index + 1
if after < len(text) and not text[after].isspace():
return False
follower = after
while follower < len(text) and text[follower].isspace():
follower += 1
if follower >= len(text):
return True
# The next sentence starts with a capital, a digit, a quote or CJK; a
# lowercase continuation means this was an abbreviation inside a sentence.
nxt = text[follower]
if not (nxt.isupper() or nxt.isdigit() or nxt in "\"'“(([" or ord(nxt) > 0x2E80):
return False
token_start = index
while token_start > 0 and not text[token_start - 1].isspace():
token_start -= 1
token = text[token_start : index + 1]
if token.casefold() in _ABBREVIATIONS:
return False
# A lone letter is an initial — "J. Smith" is one name, not two sentences.
if len(token) == 2 and token[0].isupper():
return False
return True
def sentence_inputs(items: Sequence[Any], *, default_split: str = "line") -> list[SentenceInput]:
"""Normalise what a model sent into the project's own sentence payload.
One item may arrive in any of the shapes a model naturally produces:
* ``"A sentence."`` — the common case;
* ``{"content": "...", "citations": [{"quote": "...", "reference_id": 3}]}``;
* ``{"text": "one\\ntwo", "split": "line"}`` — a block to be cut.
Every path ends in :class:`~app.schemas.paper.SentenceInput`, so the
whitespace folding and the "a citation must quote something" rule are the
ones the REST API enforces, not a second copy of them.
"""
out: list[SentenceInput] = []
for item in items:
if isinstance(item, str):
out.append(SentenceInput(content=item))
continue
# A tool signature declares the union as pydantic models, so the same
# path has to accept both an already-validated model and the raw dict
# that a lower-level caller passes.
if isinstance(item, BaseModel):
item = item.model_dump()
if not isinstance(item, dict):
fail(f"句子只能是字符串或对象,收到的是 {type(item).__name__}")
citations = [
CitationInput(
quote=str(citation.get("quote", "")),
reference_id=citation.get("reference_id"),
)
for citation in (item.get("citations") or [])
]
if item.get("content") is not None:
out.append(SentenceInput(content=str(item["content"]), citations=citations))
continue
if item.get("text") is not None:
mode = item.get("split") or default_split
for piece in split_text(str(item["text"]), mode):
out.append(SentenceInput(content=piece, citations=citations))
continue
fail("句子对象需要有 content 或 text 字段")
return out
def paragraph_inputs(spec: Any) -> list[SentenceInput]:
"""Collect one paragraph's sentences from every shape a model may send.
A caller may hand over an explicit ``sentences`` list, a single ``text``
block to be cut, or neither. "Neither" is refused rather than treated as an
empty paragraph: clearing a paragraph is a different intention with its own
tool, and silently emptying one because an argument was misspelled is the
one mistake this layer must not make.
"""
items: list[Any] = list(spec.sentences or [])
if not items and spec.text is not None and spec.text.strip():
items = [{"text": spec.text, "split": spec.split}]
if not items:
fail(
f"段落(position={spec.position} heading={spec.heading})既没有 sentences "
"也没有 text。若确实要清空这段,请用 paragraph_delete"
)
extra = [
CitationInput(quote=item.quote, reference_id=item.reference_id)
for item in (spec.citations or [])
]
inputs = sentence_inputs(items)
if extra:
# Paragraph-level citations apply to every sentence that does not carry
# its own — the shape a model produces when a whole paragraph rests on
# one source.
for item in inputs:
if not item.citations:
item.citations = list(extra)
return inputs
__all__ = [
"MAX_PAGE_SIZE",
"SPLIT_MODES",
"clean",
"citation_row",
"document_text",
"dumps",
"fail",
"field_or_fail",
"heading_index",
"id_list",
"page_args",
"page_payload",
"paper_or_fail",
"paper_row",
"paragraph_inputs",
"paragraph_row",
"paragraph_text",
"resolve_position",
"sentence_inputs",
"sentence_or_fail",
"sentence_row",
"session",
"split_text",
"template_or_fail",
]
+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)
)
+4
View File
@@ -5,3 +5,7 @@ alembic>=1.14
pymysql>=1.1
cryptography>=43.0
pydantic-settings>=2.6
# MCP server (app/mcp/) — the same domain layer, exposed as MCP tools for
# Claude Code, Codex and the DeepSeek Harness. Only the MCP entry points need
# it; the REST API does not import it.
mcp>=2.2,<3
+40
View File
@@ -0,0 +1,40 @@
"""MCP server entry point for paper-doc.
Usage (from anywhere — the script anchors ``sys.path`` itself)::
backend/.venv/bin/python backend/scripts/mcp_server.py # stdio
backend/.venv/bin/python backend/scripts/mcp_server.py --check # 自检
backend/.venv/bin/python backend/scripts/mcp_server.py \\
--transport http --host 0.0.0.0 --port 8931 --token secret
``stdio`` is what an MCP client spawns: Claude Code, Codex and the DeepSeek
Harness all run a command and speak JSON-RPC over its stdin/stdout. Two rules
follow from that, and both are easy to break by accident:
* **Nothing may print to stdout** except the protocol. Diagnostics go to
stderr; a ``print`` added here for debugging would corrupt the stream and
present as "the server disconnected".
* **The command must work from any working directory**, because a client's
``cwd`` is its own, not this project's. That is why the path anchor below
exists rather than a relative ``import app``.
The CLI itself lives in :func:`app.mcp.server.main` so this file stays a
launcher: the same arguments are reachable as ``python -m app.mcp`` from
``backend/``.
"""
from __future__ import annotations
import sys
from pathlib import Path
# Running this as a plain script puts scripts/ on sys.path, not backend/, so
# `import app` would fail. Anchor to the backend directory instead.
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from app.mcp.server import main # noqa: E402
if __name__ == "__main__":
sys.exit(main())
+514
View File
@@ -0,0 +1,514 @@
"""End-to-end smoke test for the MCP server, spoken as a real MCP client.
Run it from ``backend/``::
.venv/bin/python scripts/smoke_mcp.py
It spawns ``scripts/mcp_server.py`` exactly the way Claude Code, Codex and the
Harness do — a child process, JSON-RPC over its stdin/stdout — and then walks
the whole writing loop through tool calls: create a paper on a template, read
the outline, write paragraphs by *heading*, write a whole generated document in
one call, edit one sentence without rewriting its paragraph, search across
papers, switch the template, and delete everything it created.
Speaking the real protocol is the point. Calling the tool functions directly
would test the domain layer, which ``scripts/smoke_papers.py`` already covers;
what is untested until a client connects is the part that only exists on the
wire — argument schemas, tool discovery, error results, and the stdio stream
staying clean enough to carry the protocol.
Exits non-zero on the first failed expectation and cleans up after itself.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import anyio
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from mcp import types # noqa: E402
from mcp.client.session import ClientSession # noqa: E402
from mcp.client.stdio import StdioServerParameters, stdio_client # noqa: E402
from sqlalchemy import or_, select # noqa: E402
from app.db.session import SessionLocal # noqa: E402
from app.models import Paper, Template # noqa: E402
SERVER_SCRIPT = BACKEND_DIR / "scripts" / "mcp_server.py"
_checks = 0
_created_papers: list[int] = []
_created_templates: list[int] = []
def check(label: str, condition: bool, detail: str = "") -> None:
"""Assert one expectation and report it."""
global _checks
_checks += 1
if not condition:
raise AssertionError(f"{label}: {detail}")
print(f" ok {label}")
def payload(result: types.CallToolResult) -> Any:
"""The JSON a tool returned, refusing an error result.
A tool that failed returns ``isError`` with the message as its content, not
an exception — so a smoke test that only looked at the content would read a
refusal as data. That is the failure mode this helper exists to prevent.
"""
text = "".join(
block.text for block in result.content if isinstance(block, types.TextContent)
)
if result.is_error:
raise AssertionError(f"tool returned an error: {text}")
return json.loads(text) if text else None
async def call(session: ClientSession, tool: str, **arguments: Any) -> Any:
"""Call one tool and return its JSON payload.
The parameter is ``tool``, not ``name``: several tools here take an
argument called ``name`` (``template_create``, ``field_create``) and a
helper whose own parameter shadowed it would fail with a TypeError that
looks nothing like the tool call it came from.
"""
return payload(await session.call_tool(tool, arguments))
async def expect_error(session: ClientSession, tool: str, **arguments: Any) -> str:
"""Call one tool expecting a refusal, and return the message."""
result = await session.call_tool(tool, arguments)
if not result.is_error:
raise AssertionError(f"{tool} should have refused, but returned {result.content}")
return "".join(
block.text for block in result.content if isinstance(block, types.TextContent)
)
def pick_template() -> tuple[int, list[dict[str, Any]]]:
"""The template with the most fields, and its outline as the tools see it."""
with SessionLocal() as db:
template = db.scalars(
select(Template).order_by(Template.id.asc())
).all()
best = max(template, key=lambda item: len(item.items))
return best.id, [
{"position": item.sort, "name": item.field.name} for item in best.items
]
@asynccontextmanager
async def connect(url: str | None, token: str | None) -> AsyncIterator[ClientSession]:
"""Open a session over stdio (default) or against a running HTTP server.
Both paths yield the same :class:`ClientSession`, so every check below runs
unchanged on either transport — which is the point of testing the protocol
rather than the functions: the tools must behave identically whether a
client spawned them or connected to them.
"""
if url:
# headers live on the HTTP client, not on the transport call.
import httpx2
from mcp.client.streamable_http import streamable_http_client
headers = {"Authorization": f"Bearer {token}"} if token else {}
async with httpx2.AsyncClient(headers=headers) as http_client:
async with streamable_http_client(url, http_client=http_client) as (
read,
write,
):
async with ClientSession(read, write) as session:
yield session
return
parameters = StdioServerParameters(
command=sys.executable,
args=[str(SERVER_SCRIPT)],
cwd=str(BACKEND_DIR),
)
async with stdio_client(parameters) as (read, write):
async with ClientSession(read, write) as session:
yield session
async def run(url: str | None = None, token: str | None = None) -> int:
template_id, outline = pick_template()
check("a template with fields exists", len(outline) >= 3, f"got {len(outline)}")
first = outline[0]["name"]
second = outline[1]["name"]
async with connect(url, token) as session:
handshake = await session.initialize()
check(
"server announces itself",
handshake.server_info.name == "paper-doc",
str(handshake.server_info),
)
print("\n1. tool discovery")
listed = await session.list_tools()
names = {tool.name for tool in listed.tools}
check("29 tools are published", len(names) == 29, f"got {len(names)}")
check(
"every group is reachable",
{"paper_write", "paragraph_write", "sentence_add", "template_get", "field_list"}
<= names,
)
check(
"no tool returns a structured envelope",
all(tool.output_schema is None for tool in listed.tools),
)
write_tool = next(tool for tool in listed.tools if tool.name == "paper_write")
check(
"paper_write describes its paragraph array",
"paragraphs" in write_tool.input_schema["properties"],
)
print("\n2. create a paper on that template")
paper = await call(
session,
"paper_create",
title="SMOKE MCP 论文",
template_id=template_id,
author="smoke",
keywords="测试, smoke, 测试",
)
_created_papers.append(paper["id"])
check("created", paper["title"] == "SMOKE MCP 论文")
check("keywords are deduplicated", paper["keywords"] == "测试, smoke", paper["keywords"])
check(
"the whole structure exists with no content",
paper["paragraphs"] == 0 and paper["template_paragraphs"] == len(outline),
json.dumps(paper, ensure_ascii=False),
)
print("\n3. read the outline before writing")
skeleton = await call(session, "paper_outline", paper_id=paper["id"])
check(
"the outline carries every position",
len(skeleton["paragraphs"]) == len(outline),
json.dumps(skeleton, ensure_ascii=False),
)
check("nothing has been written", all(item["sentences"] == 0 for item in skeleton["paragraphs"]))
print("\n4. write paragraphs by heading")
written = await call(
session,
"paper_write",
paper_id=paper["id"],
paragraphs=[
{
"heading": first,
"sentences": ["第一句。", "第二句。"],
},
{
"heading": second.lstrip("0123456789. "),
"text": "这一整段只有一行,按句号切成两句。第二句在这里。",
"split": "sentence",
},
],
)
check("two paragraphs written", len(written["written"]) == 2, json.dumps(written, ensure_ascii=False))
check("four sentences total", written["total_sentences"] == 4, str(written["total_sentences"]))
check(
"both headings resolved to template positions",
all(item["heading"] for item in written["written"]),
json.dumps(written, ensure_ascii=False),
)
print("\n5. a heading the template does not have is refused, with candidates")
message = await expect_error(
session,
"paragraph_write",
paper_id=paper["id"],
heading="不存在的标题",
sentences=["x"],
)
check("the refusal lists the real headings", first in message, message)
print("\n6. read one paragraph back")
paragraph = await call(
session, "paragraph_get", paper_id=paper["id"], heading=first
)
check("two sentences stored", len(paragraph["sentences"]) == 2, json.dumps(paragraph, ensure_ascii=False))
check(
"CJK seams take no separator",
all(row["separator_before"] == "" for row in paragraph["sentences"]),
json.dumps(paragraph["sentences"], ensure_ascii=False),
)
print("\n7. append a sentence with a citation")
added = await call(
session,
"sentence_add",
paper_id=paper["id"],
heading=first,
content="A cited sentence.",
citations=[{"quote": "引用的原话", "reference_id": 7}],
)
check("appended", added["sentence"]["content"] == "A cited sentence.")
check("the citation is stored", added["sentence"]["citations"][0]["quote"] == "引用的原话")
after = await call(session, "paragraph_get", paper_id=paper["id"], heading=first)
check(
"the seam before it takes a space",
after["sentences"][-1]["separator_before"] == " ",
json.dumps(after["sentences"][-1], ensure_ascii=False),
)
print("\n8. edit one sentence without touching its paragraph")
edited = await call(
session,
"sentence_update",
paper_id=paper["id"],
sentence_id=after["sentences"][0]["id"],
content="改过的第一句。",
)
check("edited", edited["sentence"]["content"] == "改过的第一句。")
still = await call(session, "paragraph_get", paper_id=paper["id"], heading=first)
check("the other sentences kept their ids", still["sentences"][1]["id"] == after["sentences"][1]["id"])
print("\n9. delete one sentence")
before = await call(session, "sentence_list", paper_id=paper["id"])
await call(
session,
"sentence_delete",
paper_id=paper["id"],
sentence_id=still["sentences"][-1]["id"],
)
remaining = await call(session, "sentence_list", paper_id=paper["id"])
check(
"exactly one sentence fewer",
remaining["total"] == before["total"] - 1,
f"{before['total']} -> {remaining['total']}",
)
print("\n10. write a whole generated document in one call")
generated = "\n".join(
[f"# {item['name']}" + "\n" + f"{item['name']}的正文写在标题下面。这句话是第二句。"
for item in outline]
)
whole = await call(
session, "paper_write_text", paper_id=paper["id"], text=generated, split="sentence"
)
check(
"every heading in the document was written",
len(whole["written"]) == len(outline),
json.dumps(whole, ensure_ascii=False),
)
check("nothing was left unmatched", "unmatched" not in whole, json.dumps(whole, ensure_ascii=False))
print("\n11. read the paper as a document")
document = await call(session, "paper_document", paper_id=paper["id"])
check("the text carries every heading", all(item["name"] in document["text"] for item in outline))
check("and every paragraph", document["text"].count("正文写在标题下面") == len(outline))
structured = await call(
session, "paper_document", paper_id=paper["id"], format="json"
)
check(
"the json form carries sentence ids",
all(
row.get("id") is not None
for paragraph in structured["paragraphs"]
for row in paragraph["sentences"]
),
)
print("\n12. search across papers")
found = await call(session, "sentence_search", keyword="正文写在标题下面", paper_id=paper["id"])
check("the search finds what was written", found["total"] >= 1, str(found["total"]))
print("\n13. move a paragraph, then clear the one it moved onto")
home = outline[0]
target = outline[1]
relocation = await call(
session,
"paragraph_move",
paper_id=paper["id"],
heading=home["name"],
target_heading=target["name"],
)
check(
"the move reports both ends",
relocation["from"]["position"] == home["position"]
and relocation["to"]["position"] == target["position"],
json.dumps(relocation, ensure_ascii=False),
)
emptied = await call(session, "paragraph_get", paper_id=paper["id"], position=home["position"])
check("the source paragraph is empty now", emptied["sentences"] == [], json.dumps(emptied, ensure_ascii=False))
merged = await call(session, "paragraph_get", paper_id=paper["id"], position=target["position"])
check(
"the target holds both paragraphs' sentences",
len(merged["sentences"]) == 4,
json.dumps(merged["sentences"], ensure_ascii=False),
)
cleared = await call(
session, "paragraph_delete", paper_id=paper["id"], position=target["position"]
)
check("clearing reports how much it removed", cleared["deleted_sentences"] == 4, json.dumps(cleared, ensure_ascii=False))
after_clear = await call(session, "paragraph_get", paper_id=paper["id"], position=target["position"])
check("and the paragraph is empty", after_clear["sentences"] == [], json.dumps(after_clear, ensure_ascii=False))
print("\n14. the field library round-trips")
field = await call(session, "field_create", name="SMOKE MCP 字段", level=2, font_size=9.5)
check("created with its typography", field["level"] == 2 and field["font_size"] == 9.5, json.dumps(field, ensure_ascii=False))
renamed = await call(session, "field_update", field_id=field["id"], name="SMOKE MCP 字段(改)")
check("renamed", renamed["name"] == "SMOKE MCP 字段(改)", json.dumps(renamed, ensure_ascii=False))
dropped = await call(session, "field_delete", field_ids=field["id"])
check("deleted while no template places it", dropped["deleted"] == 1, json.dumps(dropped, ensure_ascii=False))
check("and it is reported as found, not missing", dropped["missing"] == [], json.dumps(dropped, ensure_ascii=False))
print("\n15. switch the template and keep the content")
spare = await call(
session,
"template_create",
name="SMOKE MCP 模板",
abstract="临时模板",
field_ids=[_field_id(outline[0]["name"])],
)
_created_templates.append(spare["id"])
check("a template can be built from field ids", spare["fields"][0]["position"] == 10, json.dumps(spare, ensure_ascii=False))
rebuilt = await call(
session,
"template_update",
template_id=spare["id"],
name="SMOKE MCP 模板",
field_ids=[_field_id(outline[0]["name"]), _field_id(outline[1]["name"])],
)
check(
"the outline is replaced wholesale and renumbered",
[item["position"] for item in rebuilt["fields"]] == [10, 20],
json.dumps(rebuilt, ensure_ascii=False),
)
sentences_before = (await call(session, "sentence_list", paper_id=paper["id"]))["total"]
switched = await call(
session, "paper_update", paper_id=paper["id"], template_id=spare["id"]
)
check("the switch is reported", switched["template_changed"] is True)
switched_document = await call(
session, "paper_document", paper_id=paper["id"], format="json"
)
check(
"content survives the switch, under 未设定",
sum(len(row["sentences"]) for row in switched_document["paragraphs"])
== sentences_before,
json.dumps(switched_document, ensure_ascii=False)[:400],
)
check(
"positions the new template does not define are marked unmatched",
any(row["matched"] is False for row in switched_document["paragraphs"]),
json.dumps(switched_document, ensure_ascii=False)[:400],
)
print("\n16. a template in use cannot be deleted")
message = await expect_error(session, "template_delete", template_ids=spare["id"])
check("the refusal names the blocker", "SMOKE MCP 模板" in message, message)
print("\n17. deleting a paper needs an explicit confirmation")
message = await expect_error(session, "paper_delete", paper_ids=paper["id"])
check("the refusal explains itself", "confirm=true" in message, message)
print("\n18. delete the paper and the template")
deleted = await call(session, "paper_delete", paper_ids=paper["id"], confirm=True)
check("deleted", deleted["deleted"] == 1, json.dumps(deleted, ensure_ascii=False))
_created_papers.remove(paper["id"])
gone = await call(session, "template_delete", template_ids=spare["id"])
check("the template is free to go now", gone["deleted"] == 1, json.dumps(gone, ensure_ascii=False))
_created_templates.remove(spare["id"])
print(f"\nall {_checks} checks passed")
return 0
def _field_id(name: str) -> int:
"""The library field behind a heading name, for the template-create step."""
from app.models import TemplateFieldLibrary
with SessionLocal() as db:
row = db.scalars(
select(TemplateFieldLibrary).where(TemplateFieldLibrary.name == name)
).first()
if row is None:
raise AssertionError(f"field {name!r} not in the library")
return row.id
def cleanup() -> None:
"""Remove anything this run created, even after a failed expectation."""
if _created_papers:
with SessionLocal() as db:
for paper in db.scalars(select(Paper).where(Paper.id.in_(_created_papers))).all():
db.delete(paper)
db.commit()
print(f"cleaned up papers {_created_papers}")
if _created_templates:
with SessionLocal() as db:
for template in db.scalars(
select(Template).where(Template.id.in_(_created_templates))
).all():
db.delete(template)
db.commit()
print(f"cleaned up templates {_created_templates}")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""``--url`` switches the suite from spawning a server to dialling one."""
parser = argparse.ArgumentParser(description="paper-doc MCP 冒烟测试")
parser.add_argument(
"--url",
default=os.getenv("MCP_SMOKE_URL"),
help="连到已在运行的 streamable-http 服务,例如 http://127.0.0.1:8931/mcp"
"不传则自己拉起 stdio 子进程",
)
parser.add_argument(
"--token",
default=os.getenv("MCP_HTTP_TOKEN"),
help="HTTP 传输的 Bearer 口令",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
# The smoke test writes real rows, so it removes its own first: a previous
# run that died before cleanup would otherwise leave a duplicate title.
with SessionLocal() as db:
stale = db.scalars(
select(Paper).where(or_(Paper.title == "SMOKE MCP 论文"))
).all()
for paper in stale:
db.delete(paper)
stale_templates = db.scalars(
select(Template).where(Template.name == "SMOKE MCP 模板")
).all()
for template in stale_templates:
db.delete(template)
db.commit()
try:
return anyio.run(run, args.url, args.token)
except AssertionError as error:
print(f"\nFAILED: {error}", file=sys.stderr)
return 1
finally:
cleanup()
if __name__ == "__main__":
sys.exit(main())