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.
57 lines
1.7 KiB
Python
57 lines
1.7 KiB
Python
"""Tool registration helpers.
|
|
|
|
One shape, one place
|
|
--------------------
|
|
Every tool in this project returns a JSON string, and that is a decision
|
|
rather than a default: :func:`mcp_tool` registers with
|
|
``structured_output=False`` so a result travels as a text block and nothing
|
|
else. The alternative — letting the SDK infer a structured output schema from
|
|
the ``-> str`` annotation — publishes a ``{"result": "..."}`` envelope and
|
|
sends the same JSON twice, once as ``structuredContent`` and once as text.
|
|
Half the clients read only one of the two, and the other half reads both and
|
|
pays for the duplication twice.
|
|
|
|
So a tool is registered by::
|
|
|
|
@mcp_tool(server, title="列出论文")
|
|
def paper_list(...) -> str:
|
|
\"\"\"Short description — this text is what the model reads.\"\"\"
|
|
|
|
The docstring becomes the tool description verbatim, which makes it the most
|
|
expensive string in the project: it is sent with every request. Keep it to the
|
|
one or two lines a model needs to choose the tool, and put the reasoning in
|
|
the module docstring instead, where it costs nothing per request.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from typing import Any, TypeVar
|
|
|
|
from mcp.server.mcpserver import MCPServer
|
|
|
|
F = TypeVar("F", bound=Callable[..., Any])
|
|
|
|
|
|
def mcp_tool(
|
|
server: MCPServer,
|
|
*,
|
|
name: str | None = None,
|
|
title: str | None = None,
|
|
) -> Callable[[F], F]:
|
|
"""Register one tool as unstructured text output."""
|
|
|
|
def decorator(fn: F) -> F:
|
|
server.add_tool(
|
|
fn,
|
|
name=name or fn.__name__,
|
|
title=title,
|
|
structured_output=False,
|
|
)
|
|
return fn
|
|
|
|
return decorator
|
|
|
|
|
|
__all__ = ["mcp_tool"]
|