backend: serve the writing domain to agents as an MCP server
The paper is written by a model now. A browser is the wrong client for that:
the work is "generate the paper, then put it in", and doing it through a form
means a person retyping what a model already produced. So the same domain is
served over the Model Context Protocol, which Claude Code, Codex and the
DeepSeek Harness all speak.
It is a third front door, not a second implementation. Every tool is three
lines around an `app.crud` call and validates through `app.schemas`, exactly
as the REST routes do, so a rule fixed in the CRUD layer is fixed on both
surfaces and a paper written by an agent is indistinguishable from one written
by hand. What `app/mcp/` adds is only what a model needs and a browser does
not:
- 29 tools, prefixed `paper_` `paragraph_` `sentence_` `template_` `field_`,
because a model picks a tool out of a list by name rather than by reading 29
descriptions;
- results as compact `None`-free JSON, since a tool result is paid for in
context tokens and `PaperRead.model_dump()` carries four counts and two
timestamps into every list row;
- paragraphs addressed by **heading** as well as by position. Storage is
correct as it stands — a sentence remembers the position it sits at, which is
what makes a template switch non-destructive — but nobody writing
"1. Introduction" knows the template places it at `sort = 20`. The server
translates, and refuses with the real heading list when it cannot, so a model
that guessed wrong corrects itself in one retry;
- `paper_write` and `paper_write_text`: one intention, one call. The latter
finds its own sections from Markdown headings or from lines that name a
template heading, and reports every heading it could not place instead of
writing half a paper;
- `sentence_search` across papers, for consistency rather than retrieval — a
paper that says 洪水损失 should not be joined by one that says GUL;
- `paper_delete` refuses once, naming what would go with it. A cascading delete
has no undo in a tool call.
Two transports, one build. `stdio` is what a client spawns — so nothing in the
process may print to stdout, and diagnostics go to stderr. `streamable-http` is
what a client on another machine connects to, optionally behind a bearer token;
binding a non-loopback address disables the SDK's DNS-rebinding allow-list,
because a LAN client sends whatever Host it knows the server by.
Tools register with `structured_output=False` on purpose: inferred from a
`-> str` annotation the SDK publishes a `{"result": ...}` envelope and sends
the JSON twice, once as `structuredContent` and once as text, and clients that
read only one of the two then disagree about what came back.
`scripts/smoke_mcp.py` drives the whole loop through a real MCP client — the
child process and JSON-RPC over stdin/stdout a client actually uses — and runs
unchanged against a running HTTP server via `--url`. 47 checks pass on both
transports; the REST suite still passes its 45.
This commit is contained in:
@@ -0,0 +1,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",
|
||||
]
|
||||
Reference in New Issue
Block a user