Files
paper-doc/backend/app/mcp/support.py
T
govin 2aff867641 backend: serve the writing domain to agents as an MCP server
The paper is written by a model now. A browser is the wrong client for that:
the work is "generate the paper, then put it in", and doing it through a form
means a person retyping what a model already produced. So the same domain is
served over the Model Context Protocol, which Claude Code, Codex and the
DeepSeek Harness all speak.

It is a third front door, not a second implementation. Every tool is three
lines around an `app.crud` call and validates through `app.schemas`, exactly
as the REST routes do, so a rule fixed in the CRUD layer is fixed on both
surfaces and a paper written by an agent is indistinguishable from one written
by hand. What `app/mcp/` adds is only what a model needs and a browser does
not:

- 29 tools, prefixed `paper_` `paragraph_` `sentence_` `template_` `field_`,
  because a model picks a tool out of a list by name rather than by reading 29
  descriptions;
- results as compact `None`-free JSON, since a tool result is paid for in
  context tokens and `PaperRead.model_dump()` carries four counts and two
  timestamps into every list row;
- paragraphs addressed by **heading** as well as by position. Storage is
  correct as it stands — a sentence remembers the position it sits at, which is
  what makes a template switch non-destructive — but nobody writing
  "1. Introduction" knows the template places it at `sort = 20`. The server
  translates, and refuses with the real heading list when it cannot, so a model
  that guessed wrong corrects itself in one retry;
- `paper_write` and `paper_write_text`: one intention, one call. The latter
  finds its own sections from Markdown headings or from lines that name a
  template heading, and reports every heading it could not place instead of
  writing half a paper;
- `sentence_search` across papers, for consistency rather than retrieval — a
  paper that says 洪水损失 should not be joined by one that says GUL;
- `paper_delete` refuses once, naming what would go with it. A cascading delete
  has no undo in a tool call.

Two transports, one build. `stdio` is what a client spawns — so nothing in the
process may print to stdout, and diagnostics go to stderr. `streamable-http` is
what a client on another machine connects to, optionally behind a bearer token;
binding a non-loopback address disables the SDK's DNS-rebinding allow-list,
because a LAN client sends whatever Host it knows the server by.

Tools register with `structured_output=False` on purpose: inferred from a
`-> str` annotation the SDK publishes a `{"result": ...}` envelope and sends
the JSON twice, once as `structuredContent` and once as text, and clients that
read only one of the two then disagree about what came back.

`scripts/smoke_mcp.py` drives the whole loop through a real MCP client — the
child process and JSON-RPC over stdin/stdout a client actually uses — and runs
unchanged against a running HTTP server via `--url`. 47 checks pass on both
transports; the REST suite still passes its 45.
2026-09-19 00:01:43 +08:00

671 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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",
]