feat(mcp): markdown — формат по умолчанию для блочных операций getNode/patchNode/insertNode (PM JSON — опция для тонких работ) #413

Open
opened 2026-07-07 20:57:11 +03:00 by agent_vscode · 0 comments
Collaborator

Мотивация

Три сходящиеся проблемы:

  1. patchNode/insertNode требуют от агента сочинять ProseMirror JSON руками — главный источник их ошибок (~34 Yjs-encode падений, разбор в #409: вложенная нода без type и т.п.).
  2. editPageText по контракту не умеет менять форматирование — его собственное описание отправляет агента в page-JSON + patchNode (packages/mcp/src/tool-specs.ts:470-491).
  3. Инцидент #410: инкрементальные пути записи не материализуют ^[...] — сноски оседают литералами.

Канонический конвертер (#293/#345/#351: P1–P4, байтовый fixpoint) достаточно зрел, чтобы доверить ему блочный уровень: markdown становится форматом по умолчанию для чтения и записи одного блока, PM JSON остаётся опцией «для тонких работ». Редактировать целую страницу маркдауном неудобно (full-body случай закрывает #411); блочный уровень сохраняет хирургичность: соседние блоки не пересылаются и не трогаются вовсе — их attrs.id целы автоматически (главное ограничение конвертера — ids регенерируются на импорте — на блочном уровне не мешает).

Новых тулов не появляется, поверхность (~42) не растёт.

Дизайн

getNode(pageId, nodeId, format?)

  • format по умолчанию 'markdown': обёртка {type:'doc',content:[node]}convertProseMirrorToMarkdown.
  • Resolved-якоря комментариев НЕ выбрасываются (отличие от getPage): это чтение «под редактирование», потеря <span data-comment-id> при записи назад осиротит тред.
  • format:'json' — текущее поведение (сабтри как есть).
  • Типы, не живущие на верхнем уровне документа (tableRow/tableCell/tableHeader по #<index>-ссылкам) — автоматический фолбэк в JSON с явным полем format:"json" в ответе. Проверку делать через contentMatch схемы (schema.nodes.doc.contentMatch.matchType(...)), не рукописным списком.

patchNode(pageId, nodeId, {markdown | node})

  • Ровно один из markdown|node (оба optional в схеме, XOR на рантайме; описание рекомендует markdown, node — для точной работы с атрибутами/марками).
  • markdown → канонический импортёр → фрагмент из N блоков; 1→N сплайс («переписать секцию» одним вызовом); первый блок наследует attrs.id цели (как сейчас нитуется в client.ts:2101), остальные получают свежие ids (задокументировать).
  • Guard от молчаливой потери: если целевой блок содержит непредставимые в markdown атрибуты (colspan/rowspan/colwidth/backgroundColor ячеек — ACCEPTED-список конвертера), md-патч отклоняется с указанием на table-тулы/node-JSON. Простые таблицы — можно.
  • Семантика #159 (ambiguous id → отказ, без записи) не меняется.

insertNode(..., {markdown | node})

  • То же; markdown может дать несколько блоков — вставляются по порядку относительно якоря. Вставка голых tableRow/tableCell остаётся JSON-only (в markdown их не выразить).

Сноски на markdown-пути

^[...] во фрагменте парсится каноническим токенизатором; фрагмент приходит как «блоки + footnotesList». Определения сливаются в хвостовой список страницы существующей машинерией insertFootnote (footnoteContentKey-дедуп + canonicalizeFootnotes, client.ts:1961) — тот же инвариант и та же задокументированная оговорка (полная канонизация дропает орфанные определения — как у insertFootnote сегодня). Альтернатива «отклонять ^[...] во фрагменте» отвергнута: при markdown-по-умолчанию это регресс UX относительно сегодняшнего литерала.

Сырые JSON-пути (node, updatePageJson, docmostTransform) в части литеральных ^[...] не трогаем — материализация там осознанно вне скоупа (решение владельца); точечный тул — insertFootnote (#410).

Не трогаем

editPageText (plain-text контракт остаётся нишей «дёшево поправить опечатку, не пересылая блок»; опциональный guard на ^[ — в #410), table-тулы, docmostTransform, updatePageJson.

Скоуп правок

  • packages/mcp/src/tool-specs.ts — shapes/descriptions/catalogLine трёх спек.
  • packages/mcp/src/index.ts — SERVER_INSTRUCTIONS (READ/EDIT-роутинг: «блок правится так: getNode(markdown) → правка → patchNode(markdown); node-JSON — для тонкой работы; таблицы со спанами/цветами — table-тулы») + прокидка новых аргументов в execute; test/unit/server-instructions.test.mjs.
  • packages/mcp/src/client.tsgetNode/patchNode/insertNode.
  • packages/mcp/src/lib/node-ops.tsreplaceNodeByIdWithMany/insertNodesRelative (сплайс массива). Координация: #409 (pre-validate там же) и #414 (дедупликация node-ops-форка).
  • apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts — прокидка format/markdown в execute (схемы приедут из SHARED_TOOL_SPECS автоматически).
  • Тесты: packages/mcp/test/*, ai-chat-tools.service.spec.ts, shared-tool-specs.contract.spec.ts.

Тесты (обязательные)

  • Сходимость канона: блок, записанный через patchNode(markdown), канонически равен тому же контенту, прошедшему полный markdown-импорт (docsCanonicallyEqual) — иначе появляется «второй канон».
  • id-нить: при 1→N первый блок наследует id, остальные свежие; соседние блоки byte-identical до/после.
  • XOR-валидация; guard на span/цвет-атрибуты цели; фолбэк не-топ-левел типов в getNode; сохранение comment-якорей (включая resolved) в md-чтении; ^[...] во фрагменте → сноска в хвостовом списке + перенумерация.

Breaking

Смена дефолта getNode — breaking для внешних MCP-клиентов, ожидающих JSON-ответ. Выпускать одним breaking-релизом с #411/#412 (внешние конфиги ломаются один раз), миграционная заметка в CHANGELOG.

План работ

  • PR 1 — чтение: format в getNode (default markdown), фолбэки, описания, SERVER_INSTRUCTIONS.
  • PR 2 — запись: markdown в patchNode/insertNode, сплайс 1→N, guard'ы, слияние сносок, тест сходимости.

Связано

#409 (валидация JSON-пути — комплементарно: md-путь устраняет сам класс ошибок «нода без type»), #410 (инцидент сносок; insertFootnote остаётся точечным тулом), #411 (full-body updatePageMarkdown — парный случай к блочному), #412 (camelCase: имена здесь даны уже в целевом виде), #414 (дедупликация node-ops — те же файлы), #415 (честные описания getPage/exportPageMarkdown), #293/#345/#351 (зрелость конвертера).

## Мотивация Три сходящиеся проблемы: 1. `patchNode`/`insertNode` требуют от агента сочинять ProseMirror JSON руками — главный источник их ошибок (~34 Yjs-encode падений, разбор в #409: вложенная нода без `type` и т.п.). 2. `editPageText` по контракту не умеет менять форматирование — его собственное описание отправляет агента в page-JSON + `patchNode` (`packages/mcp/src/tool-specs.ts:470-491`). 3. Инцидент #410: инкрементальные пути записи не материализуют `^[...]` — сноски оседают литералами. Канонический конвертер (#293/#345/#351: P1–P4, байтовый fixpoint) достаточно зрел, чтобы доверить ему блочный уровень: **markdown становится форматом по умолчанию для чтения и записи одного блока**, PM JSON остаётся опцией «для тонких работ». Редактировать целую страницу маркдауном неудобно (full-body случай закрывает #411); блочный уровень сохраняет хирургичность: соседние блоки не пересылаются и не трогаются вовсе — их `attrs.id` целы автоматически (главное ограничение конвертера — ids регенерируются на импорте — на блочном уровне не мешает). Новых тулов не появляется, поверхность (~42) не растёт. ## Дизайн ### getNode(pageId, nodeId, format?) - `format` по умолчанию `'markdown'`: обёртка `{type:'doc',content:[node]}` → `convertProseMirrorToMarkdown`. - Resolved-якоря комментариев **НЕ** выбрасываются (отличие от `getPage`): это чтение «под редактирование», потеря `<span data-comment-id>` при записи назад осиротит тред. - `format:'json'` — текущее поведение (сабтри как есть). - Типы, не живущие на верхнем уровне документа (tableRow/tableCell/tableHeader по `#<index>`-ссылкам) — автоматический фолбэк в JSON с явным полем `format:"json"` в ответе. Проверку делать через contentMatch схемы (`schema.nodes.doc.contentMatch.matchType(...)`), не рукописным списком. ### patchNode(pageId, nodeId, {markdown | node}) - Ровно один из `markdown`|`node` (оба optional в схеме, XOR на рантайме; описание рекомендует markdown, node — для точной работы с атрибутами/марками). - markdown → канонический импортёр → фрагмент из N блоков; **1→N сплайс** («переписать секцию» одним вызовом); первый блок наследует `attrs.id` цели (как сейчас нитуется в `client.ts:2101`), остальные получают свежие ids (задокументировать). - **Guard от молчаливой потери**: если целевой блок содержит непредставимые в markdown атрибуты (colspan/rowspan/colwidth/backgroundColor ячеек — ACCEPTED-список конвертера), md-патч отклоняется с указанием на table-тулы/`node`-JSON. Простые таблицы — можно. - Семантика #159 (ambiguous id → отказ, без записи) не меняется. ### insertNode(..., {markdown | node}) - То же; markdown может дать несколько блоков — вставляются по порядку относительно якоря. Вставка голых tableRow/tableCell остаётся JSON-only (в markdown их не выразить). ### Сноски на markdown-пути `^[...]` во фрагменте парсится каноническим токенизатором; фрагмент приходит как «блоки + footnotesList». Определения сливаются в хвостовой список страницы существующей машинерией `insertFootnote` (`footnoteContentKey`-дедуп + `canonicalizeFootnotes`, `client.ts:1961`) — тот же инвариант и та же задокументированная оговорка (полная канонизация дропает орфанные определения — как у `insertFootnote` сегодня). Альтернатива «отклонять `^[...]` во фрагменте» отвергнута: при markdown-по-умолчанию это регресс UX относительно сегодняшнего литерала. Сырые JSON-пути (`node`, `updatePageJson`, `docmostTransform`) в части литеральных `^[...]` **не трогаем** — материализация там осознанно вне скоупа (решение владельца); точечный тул — `insertFootnote` (#410). ### Не трогаем `editPageText` (plain-text контракт остаётся нишей «дёшево поправить опечатку, не пересылая блок»; опциональный guard на `^[` — в #410), table-тулы, `docmostTransform`, `updatePageJson`. ## Скоуп правок - `packages/mcp/src/tool-specs.ts` — shapes/descriptions/catalogLine трёх спек. - `packages/mcp/src/index.ts` — SERVER_INSTRUCTIONS (READ/EDIT-роутинг: «блок правится так: getNode(markdown) → правка → patchNode(markdown); node-JSON — для тонкой работы; таблицы со спанами/цветами — table-тулы») + прокидка новых аргументов в execute; `test/unit/server-instructions.test.mjs`. - `packages/mcp/src/client.ts` — `getNode`/`patchNode`/`insertNode`. - `packages/mcp/src/lib/node-ops.ts` — `replaceNodeByIdWithMany`/`insertNodesRelative` (сплайс массива). Координация: #409 (pre-validate там же) и #414 (дедупликация node-ops-форка). - `apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts` — прокидка `format`/`markdown` в execute (схемы приедут из SHARED_TOOL_SPECS автоматически). - Тесты: `packages/mcp/test/*`, `ai-chat-tools.service.spec.ts`, `shared-tool-specs.contract.spec.ts`. ## Тесты (обязательные) - **Сходимость канона**: блок, записанный через `patchNode(markdown)`, канонически равен тому же контенту, прошедшему полный markdown-импорт (`docsCanonicallyEqual`) — иначе появляется «второй канон». - id-нить: при 1→N первый блок наследует id, остальные свежие; **соседние блоки byte-identical** до/после. - XOR-валидация; guard на span/цвет-атрибуты цели; фолбэк не-топ-левел типов в getNode; сохранение comment-якорей (включая resolved) в md-чтении; `^[...]` во фрагменте → сноска в хвостовом списке + перенумерация. ## Breaking Смена дефолта `getNode` — breaking для внешних MCP-клиентов, ожидающих JSON-ответ. Выпускать одним breaking-релизом с #411/#412 (внешние конфиги ломаются один раз), миграционная заметка в CHANGELOG. ## План работ - [ ] **PR 1 — чтение**: `format` в getNode (default markdown), фолбэки, описания, SERVER_INSTRUCTIONS. - [ ] **PR 2 — запись**: `markdown` в patchNode/insertNode, сплайс 1→N, guard'ы, слияние сносок, тест сходимости. ## Связано #409 (валидация JSON-пути — комплементарно: md-путь устраняет сам класс ошибок «нода без type»), #410 (инцидент сносок; insertFootnote остаётся точечным тулом), #411 (full-body `updatePageMarkdown` — парный случай к блочному), #412 (camelCase: имена здесь даны уже в целевом виде), #414 (дедупликация node-ops — те же файлы), #415 (честные описания getPage/exportPageMarkdown), #293/#345/#351 (зрелость конвертера).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#413