Files
gitmost/packages/mcp/src/server-instructions.ts
T
agent_coder ccef8d0a7c refactor(tools): updatePageContent → updatePageMarkdown; внешний MCP: +updatePageMarkdown, −import_page_markdown (#411)
Поверхности записи «целым телом» были несимметричны: у in-app агента полная
замена тела markdown называлась updatePageContent (имя не про формат, тогда как
парный updatePageJson — про JSON), а у внешнего MCP голого plain-body-replace
не было вовсе (только import_page_markdown — на деле парсер round-trip к
export_page_markdown, не plain-replace). Пара должна быть updatePageMarkdown /
updatePageJson.

Пост-Фаза-1б архитектура (реестр + циклы по обоим хостам):
- новая shared-спека updatePageMarkdown (mcpName update_page_markdown, inAppKey
  updatePageMarkdown, tier как у updatePageJson) с execute (client, {pageId,
  content, title}) => client.updatePage(...) — тот же путь updatePageContentRealtime
  → markdownToProseMirrorCanonical, ^[...]-сноски парсятся. Реестровый цикл
  регистрирует её на ОБОИХ хостах автоматически. Добавлен 'updatePage' в
  Pick DocmostClientLike.
- import_page_markdown убран с внешнего MCP через inAppOnly:true у спеки
  importPageMarkdown — MCP-цикл и генератор инвентаря её пропускают, in-app
  агент сохраняет importPageMarkdown; спека и client-метод НЕ удалены.
- удалён inline in-app updatePageContent tool (теперь из реестра под inAppKey
  updatePageMarkdown) + его INLINE_TOOL_TIERS-энтри.
- ROUTING_PROSE: bulk-rewrite ссылается на update_page_markdown|update_page_json;
  убрано упоминание import_page_markdown; инвентарь генерируется из catalogLine.
- лейбл-мапы chat-markdown.util (en/ru), человекочитаемые метки не тронуты.
- НЕ тронуты одноимённые внутренности: PageService.updatePageContent,
  updatePageContentRealtime, collaboration.handler — переименовано только имя тула.

Тесты: updatePageMarkdown на обеих поверхностях с идентичной схемой, forward в
client.updatePage; import_page_markdown ОТСУТСТВУЕТ на MCP, присутствует in-app;
^[...]→сноски покрыт через collaboration.test. CHANGELOG BREAKING + миграция;
README/README.ru пакета обновлены. Гейт: mcp node --test 646/646, server jest
259, tsc чисто. Первый линк breaking-окна #416 (#411→#412→#413→#415).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 17:06:10 +03:00

240 lines
12 KiB
TypeScript

// SERVER_INSTRUCTIONS — the editing guide surfaced to MCP clients in the
// initialize result so they can pick the right tool by intent and avoid
// resending whole documents.
//
// This guide is split into TWO parts that are composed at the bottom:
//
// 1. ROUTING_PROSE — the hand-written "when to use what" intent hints (READ /
// EDIT / PAGES / COMMENTS / HISTORY). This is legitimately manual: it
// encodes editorial judgement (which tool for which situation, the cheap-
// first ordering, the guardrail nudges) that cannot be derived from the
// registry. It is NOT the drift-guard for the tool set.
//
// 2. A GENERATED <tool_inventory> — every tool the server registers, listed
// by name + one-line purpose, grouped by family, built from the SAME
// registry the server registers tools from (SHARED_TOOL_SPECS' mcpName +
// catalogLine) PLUS the handful of inline MCP-only tools (their inventory
// lines live in INLINE_MCP_INVENTORY below). Because this list is BUILT
// from the registry, it can never drift out of sync with the registered
// tools — adding/renaming/removing a spec changes it automatically, with no
// prose edit and no scraper test. An unmapped tool still appears (under
// "OTHER"), so a new tool can never silently vanish from the guide.
//
// This replaces the old hand-maintained monolithic guide + its regex scraper
// test (test/unit/server-instructions.test.mjs), which only checked that every
// registered name appeared SOMEWHERE in the prose and drifted whenever a name
// was reworded.
//
// OUT OF SCOPE (issue #448): the README / README.ru tool catalogs are still
// hand-maintained prose and are NOT generated from this registry. Regenerating
// them from SHARED_TOOL_SPECS is tracked separately as an optional docs script
// under issue #412 — until then a tool rename still needs a manual README edit.
import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
/**
* The hand-written routing prose — the intent hints that tell a client which
* tool to reach for in which situation. Kept manual on purpose (it encodes
* editorial judgement, not a mechanical name list). The generated inventory
* below is spliced in after it.
*/
export const ROUTING_PROSE =
"Docmost editing guide — choose the tool by intent. The <tool_inventory> at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" +
"READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#<index>\" for tables, which carry no id). Find every occurrence of a string/regex ON a page (and where each is) -> search_in_page, NOT block-by-block get_node — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> get_page (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" +
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Draw.io diagrams -> drawio_create (create from mxGraph XML and insert), drawio_get (read a diagram as mxGraph XML + a hash), drawio_update (replace a diagram; pass the hash from drawio_get as baseHash for optimistic locking). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace) or update_page_markdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
"PAGES: new -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
"COMMENTS: create_comment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" +
"HISTORY: review what changed -> diff_page_versions (a historyId vs current, or two versions). List saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> export_page_markdown.";
/**
* A single generated inventory line: the tool's registered NAME + a one-line
* purpose. For a registry tool the purpose is its `catalogLine` (falling back
* to the first sentence of its description); for an inline MCP-only tool it is
* the hand-written line in INLINE_MCP_INVENTORY.
*/
export interface ToolInventoryLine {
name: string;
purpose: string;
}
/**
* The families the inventory is grouped under, in display order. A tool is
* placed by looking its mcpName up in TOOL_FAMILY; anything not listed there
* falls into "OTHER" so it is never dropped from the guide.
*/
const FAMILY_ORDER = [
"READ",
"EDIT",
"PAGES",
"COMMENTS",
"HISTORY",
"OTHER",
] as const;
type Family = (typeof FAMILY_ORDER)[number];
/**
* mcpName -> family for the generated inventory grouping. Purely cosmetic (it
* orders the inventory to mirror the routing prose); an unmapped tool still
* appears under OTHER, so forgetting to add an entry here can never drop a tool
* from the guide — it only lands it in the catch-all group.
*/
const TOOL_FAMILY: Record<string, Family> = {
// READ
search: "READ",
list_pages: "READ",
list_spaces: "READ",
get_outline: "READ",
get_node: "READ",
search_in_page: "READ",
get_page: "READ",
get_page_json: "READ",
get_workspace: "READ",
stash_page: "READ",
// EDIT
edit_page_text: "EDIT",
patch_node: "EDIT",
insert_node: "EDIT",
delete_node: "EDIT",
update_page_json: "EDIT",
update_page_markdown: "EDIT",
table_get: "EDIT",
table_update_cell: "EDIT",
table_insert_row: "EDIT",
table_delete_row: "EDIT",
insert_image: "EDIT",
replace_image: "EDIT",
insert_footnote: "EDIT",
drawio_get: "EDIT",
drawio_create: "EDIT",
drawio_update: "EDIT",
docmost_transform: "EDIT",
// PAGES
create_page: "PAGES",
rename_page: "PAGES",
move_page: "PAGES",
delete_page: "PAGES",
copy_page_content: "PAGES",
share_page: "PAGES",
unshare_page: "PAGES",
list_shares: "PAGES",
// COMMENTS
create_comment: "COMMENTS",
list_comments: "COMMENTS",
update_comment: "COMMENTS",
resolve_comment: "COMMENTS",
delete_comment: "COMMENTS",
check_new_comments: "COMMENTS",
// HISTORY
diff_page_versions: "HISTORY",
list_page_history: "HISTORY",
restore_page_version: "HISTORY",
export_page_markdown: "HISTORY",
// import_page_markdown is now inAppOnly (#411) — it is not registered on the
// external MCP host, so it no longer appears in the generated inventory.
};
/**
* Inventory lines for the INLINE MCP-only tools — the ones registered directly
* in index.ts (not via SHARED_TOOL_SPECS) because they diverge per transport or
* exist only on this standalone surface. They carry no `catalogLine`, so their
* one-line purpose is hand-written here. This is the ONLY hand-maintained tool
* list left, and it is tiny; a new inline tool without an entry here is caught
* by the completeness guard in `tool-inventory.test.mjs`.
*/
export const INLINE_MCP_INVENTORY: ToolInventoryLine[] = [
{
name: "table_get",
purpose:
"read a table as a matrix of cell texts + per-cell paragraph ids.",
},
{
name: "search",
purpose:
"full-text search for pages and content across the whole workspace.",
},
{
name: "docmost_transform",
purpose:
"edit a page by running a sandboxed JS `(doc, ctx) => doc` transform, with a dryRun diff preview.",
},
{
name: "update_comment",
purpose: "update an existing comment's content (creator only).",
},
{
name: "delete_comment",
purpose: "delete a comment (creator or space admin only).",
},
];
/**
* Derive the one-line purpose for a registry spec: prefer its hand-written
* `catalogLine` (already a "name — purpose" line — we take the purpose after
* the em dash), else fall back to the first sentence of its description.
*/
function purposeForSpec(spec: SharedToolSpec): string {
const line = spec.catalogLine?.trim();
if (line) {
const dash = line.indexOf(" — ");
if (dash >= 0) return line.slice(dash + 3).trim();
return line;
}
const desc = (spec.description ?? "").replace(/\s+/g, " ").trim();
const firstSentence = desc.split(/(?<=[.!?])\s/)[0];
return firstSentence || desc || "(no description)";
}
/**
* Build the flat list of every registered tool's inventory line: one per shared
* registry spec (skipping `inAppOnly` specs, which are not registered on this
* MCP host) PLUS every inline MCP-only tool. Pure and deterministic — the
* registry drives it, so it can never drift from what index.ts registers.
*/
export function buildToolInventoryLines(
specs: Record<string, SharedToolSpec> = SHARED_TOOL_SPECS,
inline: ToolInventoryLine[] = INLINE_MCP_INVENTORY,
): ToolInventoryLine[] {
const lines: ToolInventoryLine[] = [];
for (const spec of Object.values(specs)) {
if (spec.inAppOnly) continue; // not registered on the MCP host
lines.push({ name: spec.mcpName, purpose: purposeForSpec(spec) });
}
for (const l of inline) lines.push({ ...l });
return lines;
}
/**
* Render the generated `<tool_inventory>` block: every tool name + purpose,
* grouped by family (families in FAMILY_ORDER; tools within a family sorted by
* name for stable output; unmapped tools fall into OTHER). Pure.
*/
export function buildToolInventory(
specs: Record<string, SharedToolSpec> = SHARED_TOOL_SPECS,
inline: ToolInventoryLine[] = INLINE_MCP_INVENTORY,
): string {
const byFamily = new Map<Family, ToolInventoryLine[]>();
for (const family of FAMILY_ORDER) byFamily.set(family, []);
for (const line of buildToolInventoryLines(specs, inline)) {
const family = TOOL_FAMILY[line.name] ?? "OTHER";
byFamily.get(family)!.push(line);
}
const sections: string[] = [];
for (const family of FAMILY_ORDER) {
const items = byFamily.get(family)!;
if (items.length === 0) continue;
items.sort((a, b) => a.name.localeCompare(b.name));
for (const item of items) {
sections.push(` ${family} ${item.name}${item.purpose}`);
}
}
return ["<tool_inventory>", ...sections, "</tool_inventory>"].join("\n");
}
/**
* The composed editing guide: the hand-written routing prose followed by the
* generated, drift-proof tool inventory. Exported (and used by index.ts /
* createDocmostMcpServer) as the MCP server's `instructions`.
*/
export const SERVER_INSTRUCTIONS =
ROUTING_PROSE + "\n" + buildToolInventory();