Files
govin 5a6461eeec docs: describe the MCP surface and how to connect a client
`docs/MCP.md` is the manual a person needs to put this in front of an agent:
the 29 tools with their arguments, the workflow ("generate, then write it in"
in one call, versus paragraph by paragraph), the error contract, and the exact
configuration for the three clients in play — the Harness's
`cordis.patch.yml`, Claude Code's `claude mcp add` and `.mcp.json`, and Codex's
`config.toml` — including the note that a new Harness entry has to sit inside
the `insert:` list or it is silently treated as an override of an entry that
does not exist.

`overview.md` gains the section that belongs in a design document rather than a
manual: why the MCP surface is shaped differently from the REST one, and the
two rules a new tool has to follow — keep the docstring to a line or two,
because it is sent with every request, and register with
`structured_output=False`, because the inferred envelope sends the same JSON
twice and clients disagree about which copy counts.
2026-09-19 00:01:47 +08:00

28 KiB
Raw Permalink Blame History

paper-doc

A tool that supports the paper-writing process: it manages the materials, sections, figures, and references that go into a paper. The name reflects the document-centric core of the workflow — a paper is composed from structured documents rather than written in one pass.

The first feature to land is the template configuration: a reusable library of section fields plus named templates built from them, so that a paper is written by filling in a known structure instead of deciding one.

The second is paper authoring: a paper is a document written against a template, sentence by sentence, paragraph by paragraph. The structure is never copied into the paper — it is read from the template on every render — so switching a paper's template re-shapes the whole document in one write without touching a single sentence.

Tech Stack

Backend

  • FastAPI (Python) — REST API
  • SQLAlchemy + Alembic — ORM layer and schema migrations
  • TiDB v8.5.0 — MySQL-compatible distributed SQL database, deployed in k3s
  • MCP Python SDK — the same domain layer, exposed as MCP tools (see below)
  • Dependencies managed with venv + requirements.txt

Frontend

  • Vue 3 + Vite — single-page application
  • axios — HTTP client
  • vue-router — client-side routing
  • pinia + pinia-plugin-persistedstate — state management with persistence
  • Element Plus — UI component library
  • Dependencies managed with pnpm

Architecture

A decoupled SPA + REST API layout:

  • The Vue SPA runs in the browser and talks to the backend exclusively over HTTP/JSON via axios. It holds no direct database access.
  • The FastAPI backend owns all persistence. It connects to TiDB through SQLAlchemy sessions and owns the schema through Alembic migrations.
  • TiDB runs inside the existing k3s cluster. The backend reaches it either through the in-cluster Service (tidb-tidb.tidb:4000) or, during local development, through the NodePort 192.168.1.88:32738.

Development runs both parts locally: Vite serves the SPA with a dev proxy that forwards /api to the FastAPI process, so the browser sees a single origin and no CORS configuration is required.

Browser ──HTTP/JSON──▶ FastAPI ──SQLAlchemy──▶ TiDB (k3s)
   │                      ▲
   └── Vite dev server ───┘  (proxy /api)

Domain Model

Six tables. Every one is prefixed with the module it belongs to, and a model class is named after its table, so the schema can be read as three groups:

template_field_library            the 字段库: reusable headings
  id, name, level, font_size, font_color

template                          a named outline (模板)
  id, name, abstract

template_field                    a field placed in a template — and the only
  id, template_id → template,     home of display order
  field_id → template_field_library, sort

paper                             the document
  id, title, template_id, author, status, keywords, target_journal

paper_sentence                    one sentence, at one position, in one paper
  id, paper_id → paper, template_id, paper_template_filed_sort, sort, content

paper_sentence_reference          the citations of one sentence
  id, sentence_id → paper_sentence, reference_id, quote, sort

paper has no abstract column, and that is a decision rather than an omission. A paper's abstract is a paragraph of its body — the template's 0 Abstract field — so it has a position in the document, the heading and typography the template gives it, and the same sentence-by-sentence editing as every other paragraph. A column for it would be a second home for the same text, one the document never reads: a paper could show two different abstracts, and the one in the column could drift from the one in the body. Revision a83f5c21d7b6 removed it, moving each stored abstract into the abstract paragraph first.

template_field and template_field_library are one word apart and mean opposite things. The first is a placement — this template puts this field here, sort included. The second is the catalogue the field was picked from, shared by every template and owned by none. The library is managed from the 模板 menu, because it is the raw material a template is built out of.

The field library is flat, not a tree

template_field_library has no parent_id. Hierarchy is expressed only by level (1 for 1., 2 for 1.1), which is a rendering hint: it drives indentation and numbering semantics in the UI and nothing else.

The reason is reuse. With a parent pointer a level-2 heading would belong to exactly one level-1 heading, so "Background" could not sit under both 1. Introduction and 2. Related Work. Flat fields are attached to templates, not to each other, so one field can appear in any number of templates, in a different position in each.

The numbering a reader sees is part of name and is written by the user ("1. Introduction", "0 Abstract"). Nothing derives or rewrites it.

Display order lives in the join table

template_field.sort is a plain ascending integer and is the only thing that decides what order a template's fields appear in. The order the user clicked fields in is never stored, so selecting 字段1, 字段2.1, 字段2, 字段1.1 and setting sorts 1, 4, 3, 2 renders as 字段1, 字段1.1, 字段2, 字段2.1.

Two consequences are deliberate:

  • There is no unique constraint on (template_id, field_id). Placing the same field twice in one template is a legitimate layout — the same level-2 heading under two different level-1 headings. The UI warns about a repeat; it does not forbid one.
  • Ties are legal. Equal sort values are broken by insertion order, so the ordering is always total and stable. The UI warns about ties too, and offers a button that renumbers the selection 1..N.

Templates hold references, not copies

template_field points at a library field; it does not snapshot its name or typography. Renaming or restyling a field therefore updates every template that places it, which is what makes "fix the template and the section names follow" work.

A sentence is addressed by position, not by field id

paper_sentence.paper_template_filed_sort holds the sort of the template placement the sentence belongs to — not template_field.id, and not template_field_library.id. Together with sort, the sentence's own position inside that paragraph, it is everything needed to place a line of text.

The indirection is the feature. Two templates have no rows in common, but they can share a position, so a sentence that remembers "I sit at position 7" lands on whatever the new template puts at position 7 when the paper is switched. The alternative — pointing at a placement row — would make every sentence belong to one template and turn a switch into a re-mapping of every line.

Three behaviours fall out of that one choice:

  • The structure exists before the content. A paragraph is any position the template defines or any position a sentence occupies, so an unwritten paragraph still renders, empty, and a paper created a second ago already has its full shape.
  • A switch destroys nothing. Positions the new template does not define are still rendered, in their place in the order, under the heading 未设定. Switch back and the old headings return, because the sentences never moved.
  • Ties and gaps are fine. Sentences may share a sort (broken by id) and may be numbered 10, 20, 30 to leave room, exactly as template_field.sort allows.

The name paper_template_filed_sort keeps the spelling the feature was specified with; it is the template field's sort (the sort column of template_field). paper_sentence.template_id is the template the sentence was written against. It is kept in step with the paper's current template and is provenance rather than a lookup key: rendering never filters on it, which is precisely why content survives a switch.

A sentence is a line, not parsed text

Nothing in the writing path splits text. There is no sentence detector, no punctuation rules and no comma handling: one line in the editor is one sentence, and what the writer types is what is stored, minus runs of whitespace (leading, trailing, and any newline pasted inside a line, which fold to a single space so a sentence really is one line). Commas never break anything — the only place a comma separates anything in this project is the keyword field.

That is deliberate. A splitter that guesses wrong corrupts content, and the guess would have to be right for every abbreviation (et al., i.e.), every decimal (3.14), every numbered list and every language the tool is used in. The writer decides where a sentence ends; the software only decides what goes at the seam between two of them.

Exactly one split has ever been performed, in revision a83f5c21d7b6, to move the old paper.abstract column into the body. It cut after a Chinese full stop () and nothing else — deliberately conservative: an English abstract survives that migration as a single row rather than being cut at the first et al., and one long line is a line the writer can split by hand.

What goes at the seam

A paragraph is printed by concatenating its sentences in sort order, so the seam between two of them has to be spelled out: app.crud.paper.sentence_separator returns "" or a single space, and the document response carries it as SentenceRead.separator_before.

Seam Separator Why
……缺口。 + 本研究…… "" Chinese needs nothing; the full stop separates
First sentence. + Second. " " without it the two print as First sentence.Second.
缺口。 + This study " " mixed text takes the space
test. + 本研究…… " " same, in the other direction
anything + an empty sentence "" an empty line carries citations, not text

So the rule is about the seam rather than the language: a space goes in unless both sides are CJK, where adjacency is already the convention. It is derived on every read and never stored — a stored separator is one that can go stale against the text it separates — and a client prints separator_before + content and adds no spacing of its own.

separator_before was added because the alternative was already a bug: CJK sentences printed correctly by accident (the full stop hides the missing space), so nothing looked wrong until the first English paper, whose sentences would have run together.

Citations are a table, not a column

One sentence may quote several references, so paper_sentence_reference holds one row per citation. quote — 引用内容 — is required and must not be blank: a citation that does not say what it quotes is rejected with 422 rather than stored as a half-record. reference_id is a plain nullable integer with no foreign key, because the reference library does not exist yet; a citation may therefore be written now and linked later.

Foreign keys are declared, and this cluster enforces them

TiDB parses FOREIGN KEY for compatibility, and from v6.6 honours it when tidb_enable_foreign_key is on. On the cluster this project runs against it is on, so the constraints are real integrity rather than documentation: a dangling insert is rejected (1452) and ON DELETE CASCADE / SET NULL actually fire. Two consequences are worth knowing before touching a migration:

  • Tables must be created in dependency order, and a rename must be checked rather than assumed. RENAME TABLE does carry a referencing constraint along with the renamed table here (the rename in revision f27a1c6d9e04 was rehearsed against the cluster before it was written), but that is a property of the deployment, not of SQL.
  • The API still checks first. A database violation surfaces as a generic driver error, while the application refuses with a message that names the row and the count:
    • deleting a library field still placed in a template → 409, naming the field and how many templates use it;
    • deleting a template a paper is written against → 409, naming the template and how many papers use it — the template is that paper's structure, so removing it would empty the paper rather than tidy up;
    • creating a template that references a missing field → 400, and creating or patching a paper that references a missing template likewise.

The ORM cascades stay as well. Template.items, Paper.sentences and PaperSentence.citations use cascade="all, delete-orphan", so deleting a row removes its dependents whether or not the database would have done it too — which keeps behaviour identical on a cluster with foreign keys switched off.

API

All routes are mounted under /api. Interactive docs at /docs.

Method Path Notes
GET /health liveness plus a TiDB probe
GET /template-field-library keyword, level, page, page_size
POST /template-field-library create a library entry
GET PATCH DELETE /template-field-library/{id} read / partial update / delete
POST /template-field-library/batch-delete body { "ids": [...] }
GET /templates keyword matches name or abstract
POST /templates name + abstract + ordered fields
GET PATCH DELETE /templates/{id} PATCH with fields replaces the selection
POST /templates/batch-delete body { "ids": [...] }
GET /papers keyword matches title/author/keywords, plus status, template_id
POST /papers title + template + author/status/keywords/journal; no sentence rows are created
GET PATCH DELETE /papers/{id} PATCH with template_id is the template switch
POST /papers/batch-delete body { "ids": [...] }
GET /papers/{id}/document the whole paper: paragraphs in order, citations, warnings
GET /papers/{id}/paragraphs/{sort} one paragraph, assembled as the document renders it
PUT /papers/{id}/paragraphs/{sort} full replacement; optional target_sort moves it
POST /papers/{id}/sentences append one sentence
PATCH DELETE /papers/{id}/sentences/{id} edit or remove one sentence

Conventions worth knowing:

  • font_color is stored and returned as canonical #RRGGBB. The API also accepts rgb(r, g, b), #rgb and bare aabbcc, and normalises them on write.
  • font_size is a JSON number, not a string — a client should not have to parse it before using it in a CSS rule.
  • List endpoints return { items, total, page, page_size, pages }.
  • A template read returns fields already ordered by sort; clients never sort.
  • Route paths mirror table names one for one: /templates for template, /template-field-library for template_field_library, /papers for paper.
  • A paper document returns its paragraphs in ascending position order, already carrying their sentences and citation numbering. The client sorts nothing and merges nothing: two implementations of the same ordering rule would eventually disagree.
  • Sentence content is folded to a single line (leading, trailing and inner whitespace runs collapse to one space), so an all-whitespace sentence becomes the empty string — legal, and rendered as a blank line. A citation with a blank quote is refused with 422.
  • keywords is stored as a canonical , -joined string; ; and are accepted on write and de-duplicated.
  • Paper status is one of draft, writing, done.

MCP server

The same domain layer is also served over the Model Context Protocol, so an agent — Claude Code, Codex, the DeepSeek Harness — can write a paper without a browser. Full manual: docs/MCP.md.

It is a third front door rather than a second implementation. The REST routes, the MCP tools and the seed script all call app.crud and all validate through app.schemas, so a rule fixed in the CRUD layer is fixed everywhere 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:

Addition Why the REST shape is wrong for a model
29 tools with paper_ / paragraph_ / sentence_ / template_ / field_ prefixes a model picks a tool out of a list by its name, not by reading 29 descriptions
JSON strings, None-free, no pagination envelope on reads a tool result is paid for in context tokens
paragraph_write addressed by heading as well as position nobody writing "1. Introduction" knows the template places it at sort = 20
paper_write / paper_write_text "generate the paper, then put it in" is one intention, not thirty round trips
paper_delete refuses once before it deletes a cascading delete has no undo in a tool call
sentence_search across papers the job is consistency — one paper says 洪水损失, the next must not say GUL

Two transports are served from one build: stdio, which is what a client spawns, and streamable-http, which is what a client on another machine connects to (behind a bearer token). The package is split so that only app/mcp/server.py knows a transport exists:

app/mcp/
├── server.py      transports, CLI, and the instructions sent at initialize
├── support.py     sessions, JSON shaping, heading resolution, the one text splitter
├── specs.py       the shapes a model may send (a paragraph, a sentence, a citation)
├── selfcheck.py   --check: connect once and print the tool surface
└── tools/         papers, paragraphs, sentences, templates, fields — registered
                   by register_all(), each tool three lines around an app.crud call

Two decisions are worth knowing before adding a tool. Results are registered with structured_output=False: 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 the tool returned. And the docstring is the tool description verbatim, sent with every request — so a tool docstring is one or two lines and the reasoning goes in the module docstring, where it costs nothing.

Frontend

Shell

┌───────────────────┬──────────────────────────────────────┐
│ [mark] paper-doc  │  论文   模板                    [mk] │  header
├───────────────────┼──────────────────────────────────────┤
│  论文             │                                      │  second-level
│   论文列表        │  <RouterView>                        │  menu, left
│   新建论文        │                                      │
│   ── 我的论文 ──  │                                      │
│   ● 论文标题 A    │                                      │
│   ● 论文标题 B    │                                      │
└───────────────────┴──────────────────────────────────────┘
       <- 208px ->

The mark appears at both ends of the header. The second-level menu sits on the left and is driven by route.meta.section, so a route declares which menu it belongs to and deep links render correctly on first paint. Routes without a section (the welcome page) show no menu.

The 论文 menu is the one that is not a static list: it carries the papers themselves, read from the papers store, so a paper can be opened straight from the rail. Every page that creates, renames or deletes a paper reloads that store, which is what keeps the rail and the table showing the same thing. A filter box appears once there are more than six papers.

Two things about this layout are deliberate and easy to break by accident:

  • Which side the menu lands on follows from its DOM order inside the flex row in App.vue — first child, left. Moving it is a one-block change there, not a stylesheet override.
  • The logo block is exactly as wide as the rail. Both read asideWidth() from src/layout.ts, so they cannot drift apart, and collapsing the rail narrows the logo block with it (dropping the wordmark and centring the mark). The header's own padding is zeroed for this; the two insets come from ASIDE_INSET so the mark lines up with the section title below it.

The column also carries through to the content: the rail is 208px and both the top nav's first item and .el-main are inset a further 20px by their own Element Plus defaults, so the menu text, the page body, and the page header all start on the same vertical line.

Shell metrics

src/layout.ts is the single source for the rail's width and inset. It is a module rather than CSS custom properties because el-aside takes its width through a prop, which would otherwise fight an inline style coming from the stylesheet.

List pages fill the window

A table is the page. Left alone it is as tall as its rows, so on a large screen most of the window sits empty below the last row while the page is still the thing that scrolls — the wrong half of the screen moving for a list that should simply show more. The three list pages (/papers, /templates/list, /templates/fields) therefore stretch the table to the height the window offers, with the toolbar above it and the pagination below it staying put while the rows scroll inside the table.

It is CSS only — no resize listener, no measured pixel height, nothing to go stale when the rail collapses or the window changes. Four links carry it, and each one is load-bearing:

Piece Why
.page--fill min-height: 100% of the content area — a floor that still grows
.card--fill makes the card a column so its body can pass the height down
.card--fill > .el-card__body a column with min-height: 0, without which a flex child refuses to shrink below its content
.table-fill + height="100%" flex: 1 1 0 so the table takes the leftover height instead of claiming its content height as its basis; Element Plus then pins the header and scrolls the rows

Measured in a real browser at three window sizes, with rows to scroll and with none:

Window Table Page scroll Rows scroll Empty state
1920x1080 781px none 519px inside centred, 741px
1440x900 601px none 699px inside centred, 561px
1280x620 321px none 979px inside centred, 281px

.table-fill keeps a 240px floor. Below it — a 1280x420 window — the table stops shrinking, the page grows past the viewport and the content area scrolls, which is the pre-existing behaviour rather than a squashed table. Pages that are not lists (the paper document) keep scrolling normally.

Routes

Path View Section
/ welcome
/papers paper table + CRUD (?new=1 opens the create dialog) papers
/papers/:id one paper, read as a document papers
/templates redirects to /templates/list
/templates/list template table + CRUD templates
/templates/fields field library + CRUD templates

The paper view

PapersView is the library; papers/PaperDetailView is the writing surface. The detail view renders the server's document verbatim and adds three things it alone knows how to do:

  • an edit button beside every paragraph, written or not — content only ever enters a paper through the paragraph editor, so it may never be the thing that is missing;
  • numbered citation markers in the text, matching the 参考文献 list, which is numbered in reading order rather than per paragraph;
  • a 切换模板 dialog that previews the impact — how much content lands under a new heading, how much keeps its place under 未设定, and how many empty paragraphs the new template adds — because a switch re-shapes the whole document in one write.

Changing a paper's template from the metadata form is deliberately disabled: a silent re-shape of the document is exactly what the preview exists to prevent.

Directory Structure

paper-doc/
├── backend/                 # FastAPI application
│   ├── app/
│   │   ├── api/routes/      # route handlers (health, papers, templates,
│   │   │                    #   template_field_library)
│   │   ├── core/            # settings and configuration
│   │   ├── crud/            # data-access helpers
│   │   ├── db/              # engine, session, declarative base
│   │   ├── mcp/             # MCP server: tools, specs, transports, self-check
│   │   ├── models/          # SQLAlchemy models
│   │   └── schemas/         # Pydantic request/response models
│   ├── alembic/             # migration environment and revisions
│   ├── scripts/seed.py      # idempotent seed for the field library + templates
│   ├── scripts/smoke_papers.py  # end-to-end check of the writing loop
│   ├── scripts/mcp_server.py    # MCP entry point (stdio / http)
│   ├── scripts/smoke_mcp.py     # the same loop, spoken over MCP by a real client
│   ├── alembic.ini
│   ├── requirements.txt
│   └── .env.example
├── frontend/                # Vue 3 SPA
│   ├── src/
│   │   ├── api/             # axios instance and endpoint modules
│   │   ├── components/      # shell, field, template and paper components
│   │   ├── router/          # vue-router configuration
│   │   ├── stores/          # pinia stores (UI prefs persisted, the paper list not)
│   │   ├── styles/          # global reset and the shell viewport contract
│   │   ├── utils/           # formatting helpers
│   │   └── views/           # route-level components (papers/, templates/)
│   ├── package.json
│   └── vite.config.ts
└── docs/                    # project documentation
    └── OVERVIEW.md

Getting Started

Prerequisites

  • Python 3.10+ with venv
  • Node.js 18+ with pnpm
  • Network access to the TiDB instance (local development uses the NodePort)

Backend

cd backend
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env          # then fill in the database credentials
uvicorn app.main:app --reload --port 8000

API docs are then served at http://127.0.0.1:8000/docs.

Frontend

cd frontend
pnpm install
pnpm dev

The SPA is served at http://127.0.0.1:5173 and proxies /api to the backend on port 8000.

Database

The paper_doc database is created once, out of band, against TiDB. Schema changes are applied through Alembic:

cd backend
alembic upgrade head

Seed data

scripts/seed.py fills the field library with a standard academic outline (20 fields, from 0 Abstract to 7 References) and creates three starter templates. It matches fields and templates by name, so running it twice adds nothing:

cd backend
.venv/bin/python scripts/seed.py            # add anything missing
.venv/bin/python scripts/seed.py --reset    # empty the tables first

Checking the paper feature end to end

scripts/smoke_papers.py walks the whole writing loop against a running API — create a paper on a template, verify the empty structure, fill a paragraph with sentences and citations, switch the template, check that unmatched positions survive under an unset heading, move a paragraph, and delete it all again. It exits non-zero on the first failed expectation and cleans up after itself:

cd backend
.venv/bin/python scripts/smoke_papers.py

Checking the MCP server

scripts/mcp_server.py --check connects to the database once and prints the tool surface, which is the failure this catches: a client that spawned the server successfully and then sees every call fail. scripts/smoke_mcp.py drives the whole writing loop through a real MCP client — the same child process and JSON-RPC over stdin/stdout that Claude Code, Codex and the Harness use — and runs unchanged against --url for the HTTP transport:

cd backend
.venv/bin/python scripts/mcp_server.py --check
.venv/bin/python scripts/smoke_mcp.py