2aff867641
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.
248 lines
8.9 KiB
Python
248 lines
8.9 KiB
Python
"""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_text(Markdown 标题分段),单段用 paragraph_write,
|
||
多段一起用 paper_write。段落可以用 heading 指定,服务端会解析成 position。
|
||
4. 微调用 sentence_add / sentence_update / sentence_delete,不必重写整段。
|
||
5. 验证用 paper_document(text 形式)或 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"]
|