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
+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"]