Files
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

248 lines
8.9 KiB
Python
Raw Permalink 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.
"""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"]