Compare commits

..

9 Commits

Author SHA1 Message Date
vvzvlad 443cc0e88b Merge pull request 'feat(mcp): getPageContext — «где я / что вокруг» по pageId (#443, часть 3/3, closes #443)' (#470) from feat/443-get-page-context into feat/443-get-tree
Reviewed-on: #470
2026-07-10 23:45:38 +03:00
agent_coder 5dc7a2703f feat(mcp): getPageContext — «где я / что вокруг» по pageId одним вызовом (#443, часть 3/3)
Финальная из трёх частей #443. Есть pageId (из поиска, из ссылки) — нужно понять
местоположение и окружение. Раньше это get_page + полное дерево. Только чтение,
только метаданные (без контента). closes #443.

- client.ts getPageContext(pageId): resolvePageId (slugId→uuid, для uuid без
  round-trip) → POST /pages/breadcrumbs + listSidebarPages(spaceId, uuid). Два
  запроса для uuid-входа (spaceId берётся из ответа breadcrumbs), +1 для slugId.
  Выход {page:{pageId,title,spaceId}, breadcrumbs:[{pageId,title}] (root→parent;
  [] для корня), children:[{pageId,title,hasChildren}]}.
- Порядок breadcrumbs выверен по серверному источнику (getPageBreadCrumbs: CTE
  сидится на childPageId, идёт вверх, .reverse() → root→page; страница —
  ПОСЛЕДНИЙ элемент). page = chain[last], breadcrumbs = chain.slice(0,-1); корень
  → []. Проекция явная — наружу только pageId (UUID), slugId/icon/position не
  утекают. Пустой chain / 404 / 403 → явная ошибка, не пустой объект.
- children — прямые дети через listSidebarPages (cursor-пагинация #442/#451,
  страница с >20 детьми отдаёт всех без дублей, порядок по position, hasChildren).
- Общий реестр: getPageContext на ОБОИХ хостах через цикл спеков (mcpName===
  inAppKey==="getPageContext", camelCase); DocmostClientLike/DocmostClientMethod
  += it; compile-time contract; TOOL_FAMILY READ + routing-проза.

Тесты: get-page-context.test.mjs +6 (3-й уровень split, no-slugId-leak + 2
запроса, корень→[], slugId-resolve, >20 детей без cap/дублей, плохой id→ошибка,
пустой chain→throw), tool-specs +1. mcp node --test 725/725, tsc чисто; server
jest (contract + ai-chat) 265/265.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:19:25 +03:00
agent_coder bfb4c8d8d0 feat(mcp): getTree — иерархия пространства/поддерева одним вызовом (#443, часть 2/3)
Раньше единственный способ получить дерево — listPages tree:true: BFS по
sidebar-эндпоинту, десятки-сотни HTTP-вызовов и молчаливая потеря страниц
(#442). Бэкенд форка уже отдаёт всё одним POST /pages/tree (getSidebarPagesTree,
merged #442/#451), так что это MCP-сторона + общий реестр. Только чтение.

- lib/tree.ts: buildPageTree(nodes) аддитивно расширен до buildPageTree(nodes,
  options?) с {shape?: "lean"|"getTree"; maxDepth?}. Дефолт/{} — байт-идентичный
  lean {id,slugId,title,children?} (существующие вызыватели listPages tree:true
  и subtree-BFS не тронуты, есть регрессионный тест на точный набор полей).
  shape:"getTree" проецирует {pageId, title, children?, hasChildren?} (id→pageId;
  slugId/icon/position/parentPageId не утекают ни на одной глубине).
- client.ts: getTree(spaceId, rootPageId?, maxDepth?) — тот же код-путь, что
  listPages tree:true: один enumerateSpacePages(spaceId, rootPageId) (единичный
  /pages/tree + cursor-BFS фолбэк для stock upstream) → buildPageTree(pages,
  {shape:"getTree", maxDepth}). listPages tree:true помечен DEPRECATED в JSDoc
  (BFS-фолбэк на месте, поведение не тронуто).
- maxDepth/hasChildren: полное дерево строится, потом обрезается. Корни = глубина
  1; maxDepth:1 → только корни; hasChildren:true ТОЛЬКО на срезанном узле с
  серверным hasChildren (source of truth), опущен у листьев и раскрытых узлов.
  Схема тула min(1) отбраковывает 0/отрицательные.
- tool-specs.ts: shared-спек getTree (оба хоста через цикл реестра),
  DocmostClientLike Pick += getTree, listPages desc/catalogLine — про депрекацию.
  server-instructions.ts: getTree:"READ" + routing-проза. Loader
  DocmostClientMethod += getTree, compile-time client-call contract += getTree.

Циклы/self-ref безопасны: project рекурсирует только от корней, циклический
компонент недостижим из корней (нет бесконечной рекурсии). Orphan (родитель
отфильтрован пермишенами) всплывает как корень.

Тесты: tree.test.mjs +9 (nesting+порядок+no-leak, maxDepth:1/2 + hasChildren,
orphan→root, seeded rootPageId, ≤0/нефинитный = без среза, lean-байт-идентичность),
tool-specs.test.mjs (схема getTree + депрекация listPages). mcp node --test
718/718, tsc чисто; server jest (shared-tool-specs.contract + ai-chat) 263/263.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:58:32 +03:00
agent_coder f794ac6d6c fix(search): оживить trgm-индекс — убрать coalesce из LIKE-предикатов (#443, ревью)
Ревьюер поймал EXPLAIN'ом: lookup-query фильтровал по
LOWER(f_unaccent(coalesce(col,''))), а GIN-trgm-индексы — на coalesce-free
LOWER(f_unaccent(col)). Postgres берёт функциональный индекс только при точном
совпадении выражения, так что обёртка coalesce отключала индекс → Seq Scan по
pages на КАЖДЫЙ lookup (MCP-клиент всегда шлёт substring:true). Зелёный
int-гейт это не ловил (проверял корректность на крошечном датасете, не
использование индекса).

- search.service.ts: убрал coalesce из двух index-driving LIKE-предикатов
  (title + text_content). Семантически эквивалентно: NULL LIKE '%q%' = NULL
  (falsy), NULL-страница просто не матчится — как пустая строка не матчит %q%.
  SELECT/ORDER BY оставлены с coalesce (индекс не выбирают, Node гардит NULL).
- Миграция: убран избыточный ре-ассерт idx_pages_title_trgm — его создаёт #348
  на том же coalesce-free выражении, теперь title-предикат его использует.
  idx_pages_text_content_trgm без изменений (уже coalesce-free), поправлены
  вводящие в заблуждение комментарии. down() дропает только text-индекс.
- Новый тест search-lookup-explain.int-spec.ts (3): title→idx_pages_title_trgm,
  text→idx_pages_text_content_trgm (оба ассертят отсутствие Seq Scan on pages),
  + негативный контроль (coalesce-обёртка не может использовать индекс) против
  тихой ре-регрессии. Дискриминатор enable_seqscan=off.
- CHANGELOG: две записи (opt-in substring lookup mode + смена shape MCP search).

EXPLAIN на реальном PG: обе fixed-ветки → Bitmap Index Scan on trgm; buggy
coalesce → Seq Scan (Disabled:true). Гейт: server jest src/core/search 27/27
(16 исходных int без изменений + 3 EXPLAIN + 8 unit), mcp node --test 708/708,
tsc чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:57:16 +03:00
agent_coder e4e788f151 feat(search): агентский lookup-режим — substring по точкам/дефисам/цифрам, path, snippet, scope (#443, часть 1/3)
MCP-сервером пользуются LLM-агенты; основной паттерн — lookup: найти страницу по
обрывку технической строки и сразу понять, где она лежит и что в ней. Раньше на
такой вопрос уходило 3–5 вызовов. Эта часть закрывает `search` (часть 1 из 3;
get_tree и get_page_context — следующими PR стопкой). Только чтение.

Серверная часть — opt-in, веб-UI байт-в-байт неизменен:
- SearchDTO: опциональные substring/parentPageId/titleOnly (default-off; без
  флагов путь FTS не тронут — guard `if (substring) return searchPageLookup`).
- searchPageLookup: один скан pages, WHERE = title LIKE '%q%' OR (если не
  titleOnly) text LIKE '%q%' OR (если tsquery непустой) tsv @@ ... . LIKE-
  метасимволы %/_/\ экранируются (escapeLikePattern, ESCAPE '\') — `%`/`_` не
  матчат всё; substring-ветка работает даже при пустом tsquery (кейс 10.0.12).
- Ранжирование тирами (TITLE_EXACT > TITLE_SUBSTRING > TEXT), вторичный сигнал
  ts_rank / позиция; score∈(0,1] только для сортировки одной выдачи (формула в
  комментарии). 200-cap упорядочен по SQL-прокси тира ДО среза (иначе Postgres
  отдаёт произвольные 200 и сильный хит мог выпасть). Пермишен-фильтр к
  merged-набору ДО limit. path — одна рекурсивная CTE на все хиты (не N+1).
- snippet оконный в SQL (~500 символов вокруг первого совпадения). Позиция и
  срез в ОДНОМ пространстве LOWER(f_unaccent(...)) — f_unaccent не length-
  preserving (ß→ss, лигатуры, …→...), иначе окно смещалось/пустело. titleOnly →
  пустой snippet. Компромисс задокументирован.
- Миграция: GIN gin_trgm_ops по LOWER(f_unaccent(text_content)); title-trgm
  индекс #348 переиспользован (IF NOT EXISTS), down() дропает только новый.

MCP: схема search (spaceId/parentPageId/titleOnly/limit 1–50, default 10),
client.search прокидывает substring:true, filterSearchResult → {pageId, title,
path, snippet, score}. Инвариант: наружу только pageId (UUID), slugId/id
никогда. Комментарии про намеренное расхождение с in-app hybrid-RRF (не тронут)
и про деградацию на stock-upstream/Typesense (substring→plain FTS, без path/
snippet).

Проверка на реальном Postgres: server integration 16/16 (вся acceptance-таблица
#443 + регрессии на смещение snippet и cap-200), server unit 27/27, mcp
node --test 708/708, tsc чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:34:53 +03:00
agent_coder 4be4a75fa3 docs(mcp): точные описания потерь getPage/exportPageMarkdown — конвертер канонический (#415)
Описания двух тулов устарели после #345 (канонический конвертер, #293/#351):
«LOSSY…approximated» у getPage и «lossless» у exportPageMarkdown искажали
роутинг агентов и молчаливо скрывали реальную потерю данных. Четвёртый линк
breaking-окна, стоит на #413.

- tool-specs.ts: getPage — вместо «LOSSY…approximated» точный закрытый список
  потерь (canonical for text; теряет лишь id блоков, resolved-якоря
  комментариев и фиксированный набор ACCEPTED-атрибутов без markdown-
  представления: спаны/colwidth/фон ячеек таблиц, indent, callout.icon,
  orderedList.type, link internal/target/rel/class). exportPageMarkdown —
  убрано «lossless»: round-trip перегенерирует id блоков и молча отбрасывает
  тот же набор (в первую очередь merge-спаны ячеек); держать в page-JSON, если
  нужны. Список выведен из источника истины (ATTR_VALUE_FUZZ_ALLOWLIST +
  MARK_ATTR_ALLOWLIST); opaque carried-verbatim токены (attachmentId, mime,
  slugId и пр.) намеренно НЕ в списке потерь — они round-trip'ятся.
- server-instructions.ts: READ-строка ROUTING_PROSE приведена к тому же
  точному списку, без противоречия markdown-default роутингу #413.
- Исправлена вторая латентная неточность: getPage описывался как
  сохраняющий resolved-якоря — но client.ts передаёт dropResolvedCommentAnchors:
  true, resolved-якоря скрыты (getNode, наоборот, их сохраняет — другой тул).
- README пакета (EN + RU-зеркало) приведены в соответствие passage-for-passage:
  убраны безоговорочные «lossless/lossy» для Markdown round-trip; genuinely-
  lossless ссылки на raw-JSON (getPageJson/getNode) оставлены.

Логику не трогает. server-instructions.test.mjs удалён и заменён
tool-inventory.test.mjs (без пинов на старую формулировку). mcp tsc чисто,
node --test 702/702, tool-inventory 5/5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:55:28 +03:00
agent_coder e6171a1810 fix(mcp): сходимость orphan-сноски + дедуп block-id в markdown-splice (#413, ревью)
Правки по внутреннему ревью #413.

1. Orphan-сноска (нарушение «no second canon»). mergeFootnoteDefinitions
   раньше делал ранний return при пустых definitions, пропуская
   canonicalizeFootnotes. Если markdown-фрагмент БЕЗ сносок заменял/удалял
   блок, бывший последним referrer'ом существующей сноски, её определение
   оставалось orphan в хвостовом списке (полный ре-импорт его бы убрал).
   Теперь fast-path (возврат doc по ссылке без clone) только когда
   defs.length===0 И !hasFootnoteArtifacts(doc); иначе клон → append (no-op
   при пустых) → normalizeAndMergeFootnotes → canonicalizeFootnotes, как при
   полном импорте. Новый предикат hasFootnoteArtifacts обходит дерево на
   любой footnotesList/footnoteReference (существующие walk/isObject).
   Идемпотентно: несвязанный plain-патч на странице со сносками не трогает
   их топологию (canonicalizeFootnotes шаг 6 возвращает как есть).

2. Block-id disjoint от страницы. Новый экспорт reassignCollidingBlockIds
   (liveDoc, blocks, skipIndex?) в prosemirror-markdown/node-ops (переиспользует
   collectIds/makeFreshId) + barrel. Зовётся перед сплайсами в client.ts:
   patch — (liveDoc, threaded, 0) (skip 0 = блок, унаследовавший id цели);
   insert — (liveDoc, blocks) без skip. used-аккумулятор ловит и коллизию со
   страницей, и внутрифрагментную. freshBlockId/freshId без изменений.

Тесты (+6, markdown-patch-insert): orphan-repro (0 defs/0 list/0 refs +
docsCanonicallyEqual полному импорту), fast-path (footnote-free patch не трогает
топологию, соседи byte-identical), insert канонизирует при пустых defs со
страничной сноской, уникальность top-level block-id для patch 1→N и insert N.
mcp node --test 702/702, pmd vitest 736, tsc чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:38:06 +03:00
agent_coder d269bd9efe feat(mcp): markdown — формат по умолчанию для блочных getNode/patchNode/insertNode (#413)
Канонический конвертер (#293/#345/#351) зрел для блочного уровня: markdown
становится дефолтом чтения/записи ОДНОГО блока, PM JSON — опция «для тонких
работ». Новых тулов нет, поверхность не растёт. Имена уже camelCase (стоит на #412).

- getNode(pageId, nodeId, format='markdown'): дефолт markdown — обёртка
  {type:doc,content:[node]} → convertProseMirrorToMarkdown, comment-якоря
  (ВКЛЮЧАЯ resolved) сохранены (это чтение под редактирование, не getPage).
  format:'json' — сырой сабтри. Авто-фолбэк не-топ-левел типов (tableRow/Cell/
  Header по #<index>) в JSON через canBeDocChild = docmostSchema.nodes.doc.
  contentMatch.matchType (не рукописный список); поле format на каждом ответе.
- patchNode/insertNode: XOR-вход {markdown?|node?} (оба optional в схеме, XOR
  на рантайме). markdown → импорт фрагмента → 1→N сплайс: первый блок наследует
  id цели, остальные свежие; dry replaceNodeById для #159-ambiguity ДО сплайса;
  соседние блоки byte-identical. Guard findUnrepresentableTableAttrs: цель со
  span/colwidth/backgroundColor → отказ с указанием на table-тулы/node-JSON.
  insertNode — insertNodesRelative (N блоков по порядку); голый tableRow/Cell
  JSON-only.
- Сноски: ^[...] во фрагменте → канон-импортёр; importMarkdownFragment делит
  блоки от footnotesList, РЕМАПИТ id сносок фрагмента в свежие uuid (fn-1
  фрагмента не коллизит с fn-1 страницы), mergeFootnoteDefinitions добавляет
  через ту же машинерию appendDefinition→normalizeAndMergeFootnotes→
  canonicalizeFootnotes, что insertFootnote. Сырые JSON-пути не тронуты.
- node-ops: replaceNodeByIdWithMany/insertNodesRelative (сплайс массива) в
  prosemirror-markdown/node-ops (канон после #414) + barrel.
- ROUTING_PROSE (READ/EDIT), compile-time client-call contract, CHANGELOG
  (getNode default→markdown, breaking для внешних клиентов, в окне #411/#412).

Тесты: сходимость (patchNode(markdown) блок docsCanonicallyEqual полному
импорту — нет «второго канона»); id-нить 1→N (первый наследует, остальные
свежие, соседи byte-identical); XOR; getNode markdown/json/non-top-level-fallback;
сохранение comment-якорей (active+resolved); ^[...]→хвостовой список+перенумерация;
guard. mcp node --test 697/697; pmd vitest 736; tsc чисто; server jest 273.
Третий линк breaking-окна, стоит на #412 (#411→#412→ЭТОТ→#415).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:10:38 +03:00
agent_coder 7cb3199d09 refactor(mcp)!: BREAKING — все имена MCP-тулов snake_case → camelCase (унификация с in-app) (#412)
Один логический тул жил под двумя именами: внешний MCP snake_case
(edit_page_text), in-app camelCase (editPageText) — дублирование доков, путаница
при переносе промптов/скиллов, помеха шарингу спек (#294). Решение владельца:
единый camelCase везде, включая внешний MCP. После этого mcpName === inAppKey.

- tool-specs.ts: mcpName ВЫВЕДЕН из ключа спеки (mcpName == inAppKey) для всех
  43 shared-спек — раньше divergent snake, теперь равен ключу (проверено: mcpName
  читается только структурно — цикл регистрации, генератор <tool_inventory>,
  TOOL_FAMILY). +5 inline-регистраций (tableGet/updateComment/deleteComment/
  docmostTransform; search без изменений). Рантайм: 47 тулов, все camelCase, ноль
  подчёркиваний.
- Контракт-конвенция ИНВЕРТИРОВАНА: shared-tool-specs.contract.spec
  `mcpName === toSnake(inAppKey)` → `mcpName === inAppKey`; tool-specs.test
  и tool-inventory.test обновлены.
- ROUTING_PROSE/TOOL_FAMILY/INLINE_MCP_INVENTORY (server-instructions.ts) →
  camelCase (105 замен). ai-chat.prompt/guard уже на in-app camelCase-ключах —
  без изменений (guard прошёл). comment-signal EXCLUDED_TOOLS схлопнут с
  дублей snake+camel до camelCase.
- Некоторое неочевидное: assertUnambiguousMatch(op: "patch_node"|"delete_node")
  в prosemirror-markdown/node-ops — op интерполируется в model-facing ошибку;
  литерал-юнион + call-sites → "patchNode"|"deleteNode".
- Все snake-имена в описаниях/error-строках/комментах/тестах/доках → camelCase
  (whole-token, longest-match-first). CHANGELOG: BREAKING-таблица 46 строк +
  миграция (allowlists mcp__gitmost-*__get_node→__getNode, промпты/скиллы,
  .mcp.json, метрики по tool-label); релизится вместе с #411.
  Внутренние имена методов (PageService.updatePageContent и т.п.) НЕ тронуты —
  переименованы только ИМЕНА ТУЛОВ.

Гейт: mcp node --test 677/677; tsc -p apps/server чисто; jest ai-chat-tools.
service + shared-tool-specs.contract + tool-tiers + ai-chat.prompt +
comment-signal-inapp → 323. Второй линк breaking-окна (#411→ЭТОТ→#413→#415).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 19:10:38 +03:00
79 changed files with 5051 additions and 1396 deletions
-6
View File
@@ -157,12 +157,6 @@ jobs:
- name: Build prosemirror-markdown - name: Build prosemirror-markdown
run: pnpm --filter @docmost/prosemirror-markdown build run: pnpm --filter @docmost/prosemirror-markdown build
# docmost-client.loader.ts type-imports from @docmost/mcp (issue #446); its
# build/ is gitignored and `test:e2e` type-checks, so build it here or tsc
# fails with TS2307 (mirrors the e2e-mcp / mcp-server-parity jobs).
- name: Build mcp
run: pnpm --filter @docmost/mcp build
- name: Run migrations - name: Run migrations
run: pnpm --filter ./apps/server migration:latest run: pnpm --filter ./apps/server migration:latest
+111 -7
View File
@@ -12,20 +12,108 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Breaking Changes ### Breaking Changes
- **External MCP tool names are now camelCase (all renamed).** Every tool on the
external `/mcp` surface was renamed from `snake_case` to `camelCase`, so the
external MCP name now matches the in-app tool name exactly (one logical tool,
one name everywhere). For example `get_node``getNode`, `edit_page_text`
`editPageText`, `patch_node``patchNode`. The tools' behaviour, inputs and
outputs are unchanged — only the names change. The single-word `search`
keeps its name.
*Migration (external MCP clients only — the in-app AI agent already used these
names and is unaffected):* update anything that refers to a tool by its
string name — permission allowlists (`mcp__gitmost-*__get_node`
`mcp__gitmost-*__getNode`), saved prompts/skills, `.mcp.json` tool filters,
and metrics dashboards that group by the `tool` label — and roll it out in
lockstep with this deploy, because the old snake_case names stop resolving.
Released together with the `import_page_markdown`/`update_page_markdown`
change below so external configs break exactly once.
Full mapping (old → new):
| Old (snake_case) | New (camelCase) |
| --- | --- |
| `check_new_comments` | `checkNewComments` |
| `copy_page_content` | `copyPageContent` |
| `create_comment` | `createComment` |
| `create_page` | `createPage` |
| `delete_comment` | `deleteComment` |
| `delete_node` | `deleteNode` |
| `delete_page` | `deletePage` |
| `diff_page_versions` | `diffPageVersions` |
| `docmost_transform` | `docmostTransform` |
| `drawio_create` | `drawioCreate` |
| `drawio_get` | `drawioGet` |
| `drawio_guide` | `drawioGuide` |
| `drawio_shapes` | `drawioShapes` |
| `drawio_update` | `drawioUpdate` |
| `edit_page_text` | `editPageText` |
| `export_page_markdown` | `exportPageMarkdown` |
| `get_node` | `getNode` |
| `get_outline` | `getOutline` |
| `get_page` | `getPage` |
| `get_page_json` | `getPageJson` |
| `get_workspace` | `getWorkspace` |
| `insert_footnote` | `insertFootnote` |
| `insert_image` | `insertImage` |
| `insert_node` | `insertNode` |
| `list_comments` | `listComments` |
| `list_page_history` | `listPageHistory` |
| `list_pages` | `listPages` |
| `list_shares` | `listShares` |
| `list_spaces` | `listSpaces` |
| `move_page` | `movePage` |
| `patch_node` | `patchNode` |
| `rename_page` | `renamePage` |
| `replace_image` | `replaceImage` |
| `resolve_comment` | `resolveComment` |
| `restore_page_version` | `restorePageVersion` |
| `search` | `search` (unchanged) |
| `search_in_page` | `searchInPage` |
| `share_page` | `sharePage` |
| `stash_page` | `stashPage` |
| `table_delete_row` | `tableDeleteRow` |
| `table_get` | `tableGet` |
| `table_insert_row` | `tableInsertRow` |
| `table_update_cell` | `tableUpdateCell` |
| `unshare_page` | `unsharePage` |
| `update_comment` | `updateComment` |
| `update_page_json` | `updatePageJson` |
| `update_page_markdown` | `updatePageMarkdown` |
(#412)
- **External MCP: `import_page_markdown` removed, `update_page_markdown` added.** - **External MCP: `import_page_markdown` removed, `update_page_markdown` added.**
The external `/mcp` surface no longer exposes `import_page_markdown` (the The external `/mcp` surface no longer exposes `importPageMarkdown` (the
round-trip parser for a self-contained *exported* Docmost-Markdown file). In round-trip parser for a self-contained *exported* Docmost-Markdown file). In
its place it now exposes **`update_page_markdown`** — a plain-Markdown its place it now exposes **`updatePageMarkdown`** — a plain-Markdown
full-body replace (`{pageId, content, title?}`) that pairs with full-body replace (`{pageId, content, title?}`) that pairs with
`update_page_json`, re-imports the whole body (block ids regenerate) and `updatePageJson`, re-imports the whole body (block ids regenerate) and
parses Docmost-flavoured markdown including `^[...]` inline footnotes. parses Docmost-flavoured markdown including `^[...]` inline footnotes.
*Migration:* MCP clients that called `import_page_markdown` to overwrite a *Migration:* MCP clients that called `importPageMarkdown` to overwrite a
page's body from Markdown should call `update_page_markdown` instead (pass the page's body from Markdown should call `updatePageMarkdown` instead (pass the
markdown as `content`). Round-tripping an exported Docmost-Markdown file with markdown as `content`). Round-tripping an exported Docmost-Markdown file with
comment anchors/diagrams is no longer available on the external MCP surface; comment anchors/diagrams is no longer available on the external MCP surface;
export remains via `export_page_markdown`. The in-app AI agent is unaffected — export remains via `exportPageMarkdown`. The in-app AI agent is unaffected —
it keeps both `importPageMarkdown` and the renamed `updatePageMarkdown` (was it keeps both `importPageMarkdown` and the renamed `updatePageMarkdown` (was
`updatePageContent`). The total MCP tool count is unchanged (−1 / +1). (#411) `updatePageContent`). The total MCP tool count is unchanged (−1 / +1). The
external names shown here are the post-#412 camelCase names. (#411)
- **`getNode` now returns Markdown by default (was ProseMirror JSON).** The
block-level read/write tools default to Markdown so a block round trip is
`getNode` (markdown) → edit → `patchNode` (markdown). `getNode` now returns
`{ …, format: "markdown", markdown }` unless you pass `format: "json"` (which
restores the previous `{ …, node }` ProseMirror subtree); comment anchors —
including resolved ones — are preserved in the markdown so a write-back never
orphans a thread, and a node that cannot be a document top-level block
(`tableRow`/`tableCell`/`tableHeader` addressed via `#<index>`) auto-falls back
to JSON with `format: "json"` in the response. `patchNode`/`insertNode` gain a
`markdown` input alongside `node` (provide exactly one): the markdown fragment
may rewrite/insert several blocks at once and supports `^[...]` footnotes.
*Migration (external MCP clients only):* a client that consumed `getNode`'s
`node` field must now either read `markdown`, or pass `format: "json"` to keep
the old ProseMirror-JSON output. Released together with the `#411`/`#412`
breaking window so external configs break exactly once. (#413)
### Added ### Added
@@ -163,6 +251,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
by physical key position and matched against the commands; genuine Cyrillic by physical key position and matched against the commands; genuine Cyrillic
search terms keep priority over remapped candidates, and short wrong-layout search terms keep priority over remapped candidates, and short wrong-layout
prefixes match by command title. (#283, #285, #287) prefixes match by command title. (#283, #285, #287)
- **Opt-in substring "lookup" search mode for agents.** `/api/search` gains an
additive, opt-in mode (guarded by a new `substring` flag) that matches literal
substrings of page titles and body text — so technical tokens the full-text
tokenizer mangles (`backup-srv.local`, `10.0.12.5`, `WB-MGE-30D86B`) are found
even when the FTS query is empty. It returns a location `path`, a windowed
`snippet` and a per-response relevance `score`, supports `titleOnly` and a
`parentPageId` subtree scope, and applies the page-level permission filter
before the limit. The web UI never sets `substring`, so its full-text search
behaviour is byte-for-byte unchanged. The leading-wildcard `LIKE` predicates
are backed by GIN trigram indexes on `LOWER(f_unaccent(title))` and
`LOWER(f_unaccent(text_content))` so lookups use a bitmap index scan instead of
a sequential scan. (#443)
- **MCP `search` tool returns richer, agent-oriented results.** The external MCP
`search` response shape changes for the agent surface: each hit now carries
`pageId` (renamed from `id`), plus `path`, `snippet` and `score`; the
UI-oriented `spaceId`, `rank` and `highlight` fields are dropped. (#443)
### Changed ### Changed
@@ -25,7 +25,7 @@ const mockLoaded = (DocmostClient: loader.DocmostClientCtor) => ({
DocmostClient, DocmostClient,
sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<string, loader.SharedToolSpec>, sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<string, loader.SharedToolSpec>,
// Pure no-network draw.io helpers (#424). Type-correct stubs: these tests // Pure no-network draw.io helpers (#424). Type-correct stubs: these tests
// never execute the drawio_shapes / drawio_guide tool bodies. // never execute the drawioShapes / drawioGuide tool bodies.
searchShapes: (() => []) as unknown as loader.SearchShapesFn, searchShapes: (() => []) as unknown as loader.SearchShapesFn,
getGuideSection: (() => ({ getGuideSection: (() => ({
section: 'index', section: 'index',
@@ -355,23 +355,32 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
content: [{ type: 'text', text: 'Hello' }], content: [{ type: 'text', text: 'Hello' }],
}; };
it('patchNode parses a JSON-string node and forwards it as an object', async () => { it('patchNode parses a JSON-string node and forwards it as { node } (object)', async () => {
const tools = await buildTools(); const tools = await buildTools();
await tools.patchNode.execute( await tools.patchNode.execute(
{ pageId: 'p1', nodeId: 'n1', node: JSON.stringify(NODE_OBJ) } as never, { pageId: 'p1', nodeId: 'n1', node: JSON.stringify(NODE_OBJ) } as never,
{} as never, {} as never,
); );
expect(patchNodeCalls).toHaveLength(1); expect(patchNodeCalls).toHaveLength(1);
expect(patchNodeCalls[0]).toEqual(['p1', 'n1', NODE_OBJ]); // #413: the 3rd arg is now the XOR input { markdown?, node? }.
expect(patchNodeCalls[0]).toEqual([
'p1',
'n1',
{ markdown: undefined, node: NODE_OBJ },
]);
}); });
it('patchNode passes an object node through unchanged', async () => { it('patchNode passes an object node through unchanged inside { node }', async () => {
const tools = await buildTools(); const tools = await buildTools();
await tools.patchNode.execute( await tools.patchNode.execute(
{ pageId: 'p1', nodeId: 'n1', node: NODE_OBJ } as never, { pageId: 'p1', nodeId: 'n1', node: NODE_OBJ } as never,
{} as never, {} as never,
); );
expect(patchNodeCalls[0]).toEqual(['p1', 'n1', NODE_OBJ]); expect(patchNodeCalls[0]).toEqual([
'p1',
'n1',
{ markdown: undefined, node: NODE_OBJ },
]);
}); });
it('patchNode throws the documented message on invalid JSON string', async () => { it('patchNode throws the documented message on invalid JSON string', async () => {
@@ -385,7 +394,7 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
expect(patchNodeCalls).toHaveLength(0); expect(patchNodeCalls).toHaveLength(0);
}); });
it('insertNode parses a JSON-string node and forwards it as an object', async () => { it('insertNode parses a JSON-string node and forwards it inside { node }', async () => {
const tools = await buildTools(); const tools = await buildTools();
await tools.insertNode.execute( await tools.insertNode.execute(
{ {
@@ -396,9 +405,15 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
{} as never, {} as never,
); );
expect(insertNodeCalls).toHaveLength(1); expect(insertNodeCalls).toHaveLength(1);
const [pageId, node] = insertNodeCalls[0]; // #413: the 2nd arg is the XOR input { markdown?, node? }, the 3rd is opts.
const [pageId, input, opts] = insertNodeCalls[0] as [
string,
{ markdown?: unknown; node?: unknown },
{ position?: string },
];
expect(pageId).toBe('p1'); expect(pageId).toBe('p1');
expect(node).toEqual(NODE_OBJ); expect(input).toEqual({ markdown: undefined, node: NODE_OBJ });
expect(opts.position).toBe('append');
}); });
it('insertNode throws the documented message on invalid JSON string', async () => { it('insertNode throws the documented message on invalid JSON string', async () => {
@@ -912,7 +927,7 @@ describe('AiChatToolsService getCurrentPage selection (#388)', () => {
}); });
/** /**
* #440 review: the in-app drawio_create / drawio_update handlers must forward * #440 review: the in-app drawioCreate / drawioUpdate handlers must forward
* the optional `layout:"elk"` param to the client (5th positional arg), exactly * the optional `layout:"elk"` param to the client (5th positional arg), exactly
* like the MCP host. It was silently dropped, so ELK auto-layout worked only via * like the MCP host. It was silently dropped, so ELK auto-layout worked only via
* the standalone MCP server, not in-app. These tests pin per-host parity. * the standalone MCP server, not in-app. These tests pin per-host parity.
@@ -59,10 +59,12 @@ function __assertClientCallContract(client: DocmostClientLike): void {
void client.getWorkspace(); void client.getWorkspace();
void client.getSpaces(); void client.getSpaces();
void client.listPages(s, n, true); void client.listPages(s, n, true);
void client.getTree(s, s, n);
void client.getPageContext(s);
void client.listSidebarPages(s, s); void client.listSidebarPages(s, s);
void client.getOutline(s); void client.getOutline(s);
void client.getPageJson(s); void client.getPageJson(s);
void client.getNode(s, s); void client.getNode(s, s, 'markdown');
void client.searchInPage(s, s, { void client.searchInPage(s, s, {
regex: true, regex: true,
caseSensitive: true, caseSensitive: true,
@@ -84,12 +86,16 @@ function __assertClientCallContract(client: DocmostClientLike): void {
void client.movePage(s, s, s); void client.movePage(s, s, s);
void client.deletePage(s); void client.deletePage(s);
void client.editPageText(s, edits); void client.editPageText(s, edits);
void client.patchNode(s, s, node); void client.patchNode(s, s, { markdown: s, node });
void client.insertNode(s, node, { void client.insertNode(
position: 'append', s,
anchorNodeId: s, { markdown: s, node },
anchorText: s, {
}); position: 'append',
anchorNodeId: s,
anchorText: s,
},
);
void client.deleteNode(s, s); void client.deleteNode(s, s);
void client.updatePageJson(s, node, s); void client.updatePageJson(s, node, s);
void client.tableInsertRow(s, s, cells, n); void client.tableInsertRow(s, s, cells, n);
@@ -266,7 +272,7 @@ export class AiChatToolsService {
// construction is shared with the page-change detection path (#274) via // construction is shared with the page-change detection path (#274) via
// buildDocmostClient so both go over the exact same authenticated route. // buildDocmostClient so both go over the exact same authenticated route.
// searchShapes / getGuideSection (#424) are the PURE, no-network helpers // searchShapes / getGuideSection (#424) are the PURE, no-network helpers
// backing drawio_shapes / drawio_guide. They are `inlineBothHosts` specs (no // backing drawioShapes / drawioGuide. They are `inlineBothHosts` specs (no
// canonical execute — their catalog loader uses import.meta and can't be // canonical execute — their catalog loader uses import.meta and can't be
// value-imported into the zod-agnostic tool-specs.ts under the server's // value-imported into the zod-agnostic tool-specs.ts under the server's
// commonjs type-check), so the shared registry loop below SKIPS them and this // commonjs type-check), so the shared registry loop below SKIPS them and this
@@ -308,9 +314,10 @@ export class AiChatToolsService {
// The in-app toolset. It starts with the tools kept INLINE here for a // The in-app toolset. It starts with the tools kept INLINE here for a
// documented per-layer reason: an intentional behaviour/schema divergence from // documented per-layer reason: an intentional behaviour/schema divergence from
// the standalone MCP surface (searchPages' hybrid RRF, // the standalone MCP surface (searchPages' hybrid RRF,
// transformPage's guardrailed shorter schema), a // transformPage's guardrailed shorter schema), a name clash the shared
// snake_case/camelCase naming clash the shared registry forbids (getTable vs // registry forbids (in-app `getTable` verb-first vs the MCP noun-first
// the MCP `table_get`), per-request state the registry loop cannot provide // `tableGet` — the registry requires mcpName === inAppKey), per-request
// state the registry loop cannot provide
// (getCurrentPage reads the resolved openedPage; searchPages closes over the // (getCurrentPage reads the resolved openedPage; searchPages closes over the
// per-request user/embedding deps), or a tool with no MCP twin // per-request user/embedding deps), or a tool with no MCP twin
// (listSidebarPages/getComment/getPageHistory). Every SHARED tool is then added // (listSidebarPages/getComment/getPageHistory). Every SHARED tool is then added
@@ -477,9 +484,9 @@ export class AiChatToolsService {
await client.listSidebarPages(spaceId, pageId), await client.listSidebarPages(spaceId, pageId),
}), }),
// NOT shared (kept inline): the MCP tool name `table_get` is noun-first // NOT shared (kept inline): the MCP tool name `tableGet` is noun-first
// while this key is `getTable` (verb-first), breaking the // while this key is `getTable` (verb-first), so it cannot satisfy the
// snake_case(inAppKey) convention the shared registry enforces. Its // shared registry's `mcpName === inAppKey` convention (#412). Its
// reference parameter is still named `table` (was `tableRef`) so it matches // reference parameter is still named `table` (was `tableRef`) so it matches
// the migrated table row/cell tools below. // the migrated table row/cell tools below.
getTable: tool({ getTable: tool({
@@ -522,7 +529,7 @@ export class AiChatToolsService {
// INTENTIONAL per-transport divergence (not shared): deliberately omits the // INTENTIONAL per-transport divergence (not shared): deliberately omits the
// `deleteComments` schema field (comment-deletion guardrail) and carries a // `deleteComments` schema field (comment-deletion guardrail) and carries a
// much shorter description; the standalone MCP `docmost_transform` exposes // much shorter description; the standalone MCP `docmostTransform` exposes
// the full helper catalogue. Different schema, so kept per-layer. // the full helper catalogue. Different schema, so kept per-layer.
transformPage: tool({ transformPage: tool({
description: description:
@@ -553,7 +560,7 @@ export class AiChatToolsService {
// WHICH mapping to run and returns its value directly (no envelope). For each // WHICH mapping to run and returns its value directly (no envelope). For each
// spec: // spec:
// - skip `mcpOnly` specs (they belong to the standalone MCP host only); // - skip `mcpOnly` specs (they belong to the standalone MCP host only);
// - skip `inlineBothHosts` specs (drawio_shapes / drawio_guide): they carry // - skip `inlineBothHosts` specs (drawioShapes / drawioGuide): they carry
// no execute and are wired INLINE just below, calling the pure helpers; // no execute and are wired INLINE just below, calling the pure helpers;
// - use `inAppExecute` when the spec declares a DELIBERATE per-layer // - use `inAppExecute` when the spec declares a DELIBERATE per-layer
// difference (a projected result shape, a different guardrail message); // difference (a projected result shape, a different guardrail message);
@@ -574,7 +581,7 @@ export class AiChatToolsService {
); );
} }
// drawio_shapes / drawio_guide (#424): `inlineBothHosts` registry specs wired // drawioShapes / drawioGuide (#424): `inlineBothHosts` registry specs wired
// here with the SAME schema+description the shared spec pins, but calling the // here with the SAME schema+description the shared spec pins, but calling the
// pure searchShapes / getGuideSection helpers off the loaded @docmost/mcp // pure searchShapes / getGuideSection helpers off the loaded @docmost/mcp
// module — they are not client methods and their catalog loader uses // module — they are not client methods and their catalog loader uses
@@ -23,6 +23,8 @@ type DocmostClientMethod =
| 'getWorkspace' | 'getWorkspace'
| 'getSpaces' | 'getSpaces'
| 'listPages' | 'listPages'
| 'getTree'
| 'getPageContext'
| 'listSidebarPages' | 'listSidebarPages'
| 'getOutline' | 'getOutline'
| 'getPageJson' | 'getPageJson'
@@ -146,7 +148,7 @@ export type CommentSignalTrackerFactory = (options: {
// Pure, no-network draw.io helpers (#424). These are plain functions on the // Pure, no-network draw.io helpers (#424). These are plain functions on the
// module (NOT DocmostClient methods) — the in-app AI-SDK service calls them // module (NOT DocmostClient methods) — the in-app AI-SDK service calls them
// directly to wire drawio_shapes / drawio_guide, mirroring the MCP server. // directly to wire drawioShapes / drawioGuide, mirroring the MCP server.
export type SearchShapesFn = ( export type SearchShapesFn = (
query: string, query: string,
opts?: { category?: string; limit?: number }, opts?: { category?: string; limit?: number },
@@ -169,7 +171,7 @@ interface DocmostMcpModule {
// the mocked loader in unit tests) — the stale-check below is a NO-OP when it // the mocked loader in unit tests) — the stale-check below is a NO-OP when it
// is missing, so an older build never wrongly fails startup. // is missing, so an older build never wrongly fails startup.
REGISTRY_STAMP?: string; REGISTRY_STAMP?: string;
// Pure, no-network draw.io helpers (#424) backing drawio_shapes / drawio_guide. // Pure, no-network draw.io helpers (#424) backing drawioShapes / drawioGuide.
// Those two specs are `inlineBothHosts` (they stay in SHARED_TOOL_SPECS for the // Those two specs are `inlineBothHosts` (they stay in SHARED_TOOL_SPECS for the
// shared contract but carry no execute — their catalog loader uses import.meta // shared contract but carry no execute — their catalog loader uses import.meta
// and can't be value-imported into the zod-agnostic tool-specs.ts), so the // and can't be value-imported into the zod-agnostic tool-specs.ts), so the
@@ -17,8 +17,10 @@ import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs
* This test fails the build if a spec is added to the registry but never wired * This test fails the build if a spec is added to the registry but never wired
* in-app, if an `inAppKey` is renamed without updating the service, if the * in-app, if an `inAppKey` is renamed without updating the service, if the
* description drifts between the registry and the exposed tool, if the * description drifts between the registry and the exposed tool, if the
* snake_case `mcpName` <-> camelCase `inAppKey` convention is broken, or if the * `mcpName === inAppKey` convention is broken (issue #412 unified the external
* exposed tool's input-schema keys diverge from the spec's `buildShape`. * MCP tool name with the in-app key — both are the same camelCase identifier),
* or if the exposed tool's input-schema keys diverge from the spec's
* `buildShape`.
* *
* It does NOT need @docmost/mcp built: the registry is imported from TS source, * It does NOT need @docmost/mcp built: the registry is imported from TS source,
* and the ESM loader is mocked so `forUser()` never dynamically imports the * and the ESM loader is mocked so `forUser()` never dynamically imports the
@@ -74,10 +76,6 @@ describe('SHARED_TOOL_SPECS contract parity', () => {
afterAll(() => jest.restoreAllMocks()); afterAll(() => jest.restoreAllMocks());
// camelCase -> snake_case, matching the registry's mcpName convention.
const toSnake = (s: string) =>
s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
// Type as the (optional-buildShape) SharedToolSpec; the `satisfies` literal // Type as the (optional-buildShape) SharedToolSpec; the `satisfies` literal
// above otherwise narrows to a union where some members lack buildShape. // above otherwise narrows to a union where some members lack buildShape.
const specEntries = Object.entries(SHARED_TOOL_SPECS) as unknown as Array< const specEntries = Object.entries(SHARED_TOOL_SPECS) as unknown as Array<
@@ -96,8 +94,8 @@ describe('SHARED_TOOL_SPECS contract parity', () => {
expect(spec.inAppKey).toBe(registryKey); expect(spec.inAppKey).toBe(registryKey);
}); });
it('mcpName is the snake_case form of inAppKey', () => { it('mcpName equals inAppKey (unified camelCase name, #412)', () => {
expect(spec.mcpName).toBe(toSnake(spec.inAppKey)); expect(spec.mcpName).toBe(spec.inAppKey);
}); });
it('is exposed in-app under its inAppKey', () => { it('is exposed in-app under its inAppKey', () => {
@@ -36,7 +36,7 @@ describe('tool tier metadata (#332)', () => {
}); });
it('#410 image tools are DEFERRED, footnote tool is CORE', () => { it('#410 image tools are DEFERRED, footnote tool is CORE', () => {
// insert_footnote is core (symmetric with editPageText); the image tools stay // insertFootnote is core (symmetric with editPageText); the image tools stay
// deferred (rare, fat — loaded on demand). Assert both the spec tier and the // deferred (rare, fat — loaded on demand). Assert both the spec tier and the
// CORE_TOOL_SET membership so a future tier edit that desyncs them fails here. // CORE_TOOL_SET membership so a future tier edit that desyncs them fails here.
expect(SHARED_TOOL_SPECS.insertFootnote.tier).toBe('core'); expect(SHARED_TOOL_SPECS.insertFootnote.tier).toBe('core');
@@ -60,10 +60,10 @@ export const CORE_TOOL_KEYS = [
'listComments', 'listComments',
'resolveComment', 'resolveComment',
'editPageText', 'editPageText',
// #330 search_in_page — frequent for editorial sweeps; core despite predating // #330 searchInPage — frequent for editorial sweeps; core despite predating
// the issue's tier list. // the issue's tier list.
'searchInPage', 'searchInPage',
// #410 insert_footnote — core so pinpoint citations to already-written text // #410 insertFootnote — core so pinpoint citations to already-written text
// don't degrade into literal `^[...]`; kept symmetric with editPageText. // don't degrade into literal `^[...]`; kept symmetric with editPageText.
'insertFootnote', 'insertFootnote',
] as const; ] as const;
@@ -138,7 +138,7 @@ export const INLINE_TOOL_TIERS: Record<
}, },
// NOTE: tableInsertRow, tableDeleteRow and tableUpdateCell moved to // NOTE: tableInsertRow, tableDeleteRow and tableUpdateCell moved to
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own deferred tier + // @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own deferred tier +
// catalogLine there. getTable stays inline (its MCP name table_get breaks the // catalogLine there. getTable stays inline (its MCP name tableGet breaks the
// snake_case(inAppKey) convention, so it has no shared spec). // snake_case(inAppKey) convention, so it has no shared spec).
// NOTE: checkNewComments moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); // NOTE: checkNewComments moved to @docmost/mcp's SHARED_TOOL_SPECS (#294);
// it carries its own deferred tier + catalogLine there. // it carries its own deferred tier + catalogLine there.
@@ -150,7 +150,7 @@ export const INLINE_TOOL_TIERS: Record<
// NOTE: sharePage moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); it carries // NOTE: sharePage moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); it carries
// its own deferred tier + catalogLine there. transformPage stays inline (its // its own deferred tier + catalogLine there. transformPage stays inline (its
// schema deliberately diverges — it omits the deleteComments field the MCP // schema deliberately diverges — it omits the deleteComments field the MCP
// docmost_transform exposes, a comment-deletion guardrail). // docmostTransform exposes, a comment-deletion guardrail).
transformPage: { transformPage: {
tier: 'deferred', tier: 'deferred',
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.", catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
@@ -12,3 +12,22 @@ export class SearchResponseDto {
updatedAt: Date; updatedAt: Date;
space: Partial<Space>; space: Partial<Space>;
} }
// Response shape for the opt-in agent-lookup mode (#443, `substring: true`).
// Additive to the FTS response: carries the location (`path`), a windowed
// `snippet` around the first match and a per-response sort `score`. The MCP
// layer maps `id → pageId`; `slugId` is never exposed.
export class SearchLookupResponseDto {
id: string;
slugId: string;
title: string;
parentPageId: string | null;
// Ancestor titles from the space root down to the direct parent; [] for a
// root page.
path: string[];
// ~300–500 chars around the first match (or a leading text window / extended
// ts_headline fallback).
snippet: string;
// 0..1 float, meaningful ONLY for sorting within one response.
score: number;
}
@@ -30,6 +30,31 @@ export class SearchDTO {
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
offset?: number; offset?: number;
// --- Opt-in agent-lookup mode (#443). ------------------------------------
// These fields are ADDITIVE and default-off: a web client that sends none of
// them gets byte-identical FTS behaviour and result shape. They are only read
// by the substring/path/snippet code path in SearchService.searchPage.
//
// NOTE (standalone stdio vs stock upstream): stock upstream validates this DTO
// with `whitelist: true`, so an older server silently strips these unknown
// fields and the request degrades gracefully to the plain FTS behaviour.
// Enables the hybrid substring branch (title + text_content LIKE) merged with
// the existing FTS branch, plus tiered ranking, path and windowed snippet.
@IsOptional()
@IsBoolean()
substring?: boolean;
// Restrict the search to a page and all of its descendants (inclusive).
@IsOptional()
@IsString()
parentPageId?: string;
// Match titles only; do not scan text_content.
@IsOptional()
@IsBoolean()
titleOnly?: boolean;
} }
export class SearchShareDTO extends SearchDTO { export class SearchShareDTO extends SearchDTO {
@@ -60,6 +60,12 @@ export class SearchController {
} }
} }
// #443 graceful degradation: on EE/Typesense instances the request routes to
// the Typesense backend, which does NOT implement the opt-in agent-lookup
// mode. The `substring`/`parentPageId`/`titleOnly` fields are silently ignored
// and the response carries no `path`/`snippet`/`score` and no substring/tier
// ranking — it degrades to plain Typesense FTS. The native lookup mode below
// is Postgres-search-driver only.
if (this.environmentService.getSearchDriver() === 'typesense') { if (this.environmentService.getSearchDriver() === 'typesense') {
return this.searchTypesense(searchDto, { return this.searchTypesense(searchDto, {
userId: user.id, userId: user.id,
@@ -0,0 +1,95 @@
import {
computeLookupScore,
escapeLikePattern,
SearchLookupTier,
} from './search.service';
/**
* Pure-function coverage for the #443 agent-lookup helpers:
* - escapeLikePattern: LIKE-metacharacter escaping so `%`/`_`/`\` are literals
* (the acceptance-table requirement that a query of `%` or `_` does NOT match
* everything);
* - computeLookupScore: the tiered 0..1 ranking score, where a stronger tier
* always outranks a weaker one regardless of the in-tier secondary signal.
*
* The DB-touching branch (substring UNION FTS, path CTE, snippet window) is
* covered by the integration spec against the real schema.
*/
describe('escapeLikePattern', () => {
it('escapes the LIKE metacharacters % _ and \\', () => {
expect(escapeLikePattern('%')).toBe('\\%');
expect(escapeLikePattern('_')).toBe('\\_');
expect(escapeLikePattern('\\')).toBe('\\\\');
});
it('escapes the backslash FIRST so it does not double-escape %/_', () => {
// Input `\%` must become `\\` + `\%` = `\\\%`, not `\\%`.
expect(escapeLikePattern('\\%')).toBe('\\\\\\%');
});
it('leaves ordinary technical chars (. - / digits) untouched', () => {
expect(escapeLikePattern('backup-srv.local')).toBe('backup-srv.local');
expect(escapeLikePattern('10.0.12')).toBe('10.0.12');
expect(escapeLikePattern('WB-MGE-30D86B')).toBe('WB-MGE-30D86B');
expect(escapeLikePattern('a/b')).toBe('a/b');
});
it('escapes only the metacharacters in a mixed string', () => {
expect(escapeLikePattern('50%_off.zip')).toBe('50\\%\\_off.zip');
});
it('is null/undefined-safe', () => {
expect(escapeLikePattern(undefined as any)).toBe('');
expect(escapeLikePattern(null as any)).toBe('');
});
});
describe('computeLookupScore', () => {
it('keeps every score within (0, 1]', () => {
for (const tier of [
SearchLookupTier.TITLE_EXACT,
SearchLookupTier.TITLE_SUBSTRING,
SearchLookupTier.TEXT,
]) {
for (const secondary of [0, 0.001, 1, 100, 1e6]) {
const s = computeLookupScore({ tier, secondary });
expect(s).toBeGreaterThan(0);
expect(s).toBeLessThanOrEqual(1);
}
}
});
it('a stronger tier ALWAYS outranks a weaker tier, whatever the secondary', () => {
// Weak tier with a huge secondary must still lose to a strong tier with a
// tiny secondary — tiers dominate.
const strongLowSecondary = computeLookupScore({
tier: SearchLookupTier.TITLE_EXACT,
secondary: 0,
});
const weakHighSecondary = computeLookupScore({
tier: SearchLookupTier.TEXT,
secondary: 1e9,
});
expect(strongLowSecondary).toBeGreaterThan(weakHighSecondary);
});
it('within a tier a larger secondary sorts higher', () => {
const lo = computeLookupScore({
tier: SearchLookupTier.TEXT,
secondary: 0.1,
});
const hi = computeLookupScore({
tier: SearchLookupTier.TEXT,
secondary: 5,
});
expect(hi).toBeGreaterThan(lo);
});
it('treats a negative/absent secondary as 0', () => {
const zero = computeLookupScore({ tier: SearchLookupTier.TEXT, secondary: 0 });
expect(computeLookupScore({ tier: SearchLookupTier.TEXT })).toBe(zero);
expect(
computeLookupScore({ tier: SearchLookupTier.TEXT, secondary: -5 }),
).toBe(zero);
});
});
+401 -2
View File
@@ -1,6 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto'; import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
import { SearchResponseDto } from './dto/search-response.dto'; import {
SearchLookupResponseDto,
SearchResponseDto,
} from './dto/search-response.dto';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import { sql } from 'kysely'; import { sql } from 'kysely';
@@ -34,6 +37,53 @@ export function buildTsQuery(raw: string): string {
return tsquery(cleaned + '*'); return tsquery(cleaned + '*');
} }
// Escape the LIKE metacharacters (`%`, `_`, `\`) in a raw user query so every
// character — including `.`, `-`, `_`, `%`, `/` — is matched LITERALLY by a
// `col LIKE '%' || q || '%'` predicate. Without this, a query of `%` or `_`
// would match every row (see the #443 acceptance table). The backslash is the
// escape char (Postgres LIKE default), so it must be escaped first.
export function escapeLikePattern(raw: string): string {
return (raw ?? '')
.replace(/\\/g, '\\\\')
.replace(/%/g, '\\%')
.replace(/_/g, '\\_');
}
// Ranking tiers for the agent-lookup mode (#443), highest first. A hit's tier
// is the strongest way it matched; ties inside a tier break on a secondary
// signal (FTS rank, or first-match position). The numeric `score` returned to
// the caller is derived from (tier, secondary) and is meaningful ONLY for
// ordering within a single response.
export enum SearchLookupTier {
// Title equals the query, case-insensitively.
TITLE_EXACT = 3,
// Query is a substring of the title.
TITLE_SUBSTRING = 2,
// Query matched in the text (substring or FTS).
TEXT = 1,
}
export interface RankableHit {
tier: SearchLookupTier;
// Secondary in-tier signal, higher = better (e.g. ts_rank, or a
// position-derived closeness score). Defaults to 0.
secondary?: number;
}
// Map (tier, secondary) → a 0..1 float used ONLY to sort one response.
//
// Formula: score = (tier + squash(secondary)) / (maxTier + 1), where
// squash(x) = x / (1 + x) maps any non-negative secondary into [0, 1)
// so a stronger tier ALWAYS outranks a weaker one regardless of the secondary
// value, and within a tier a larger secondary sorts higher. maxTier is the top
// enum value (TITLE_EXACT = 3), so the divisor keeps the result in (0, 1].
export function computeLookupScore(hit: RankableHit): number {
const maxTier = SearchLookupTier.TITLE_EXACT;
const secondary = Math.max(0, hit.secondary ?? 0);
const squashed = secondary / (1 + secondary);
return (hit.tier + squashed) / (maxTier + 1);
}
@Injectable() @Injectable()
export class SearchService { export class SearchService {
constructor( constructor(
@@ -50,12 +100,19 @@ export class SearchService {
userId?: string; userId?: string;
workspaceId: string; workspaceId: string;
}, },
): Promise<{ items: SearchResponseDto[] }> { ): Promise<{ items: SearchResponseDto[] | SearchLookupResponseDto[] }> {
const { query } = searchParams; const { query } = searchParams;
if (query.length < 1) { if (query.length < 1) {
return { items: [] }; return { items: [] };
} }
// Opt-in agent-lookup mode (#443). Guarded by the `substring` flag so the
// web-UI (which never sets it) keeps byte-identical FTS behaviour below.
if (searchParams.substring) {
return this.searchPageLookup(searchParams, opts);
}
const searchQuery = buildTsQuery(query); const searchQuery = buildTsQuery(query);
let queryResults = this.db let queryResults = this.db
@@ -175,6 +232,348 @@ export class SearchService {
return { items: searchResults }; return { items: searchResults };
} }
/**
* Agent-lookup search (#443, opt-in via `SearchDTO.substring`).
*
* ADDITIVE to the FTS path: runs a substring branch (title + optionally
* text_content, LIKE with metacharacters escaped) MERGED with the existing
* FTS branch, so technical tokens that the `english` tokenizer mangles
* (`backup-srv.local`, `10.0.12.5`, `WB-MGE-30D86B`) are still found — even
* when `buildTsQuery()` returns '' for a dotted/numeric query. Results carry a
* location (`path`), a windowed `snippet` and a per-response `score`.
*
* The whole method is only reached when `substring: true`; the web-UI never
* sets it, so its behaviour is unchanged.
*/
private async searchPageLookup(
searchParams: SearchDTO,
opts: { userId?: string; workspaceId: string },
): Promise<{ items: SearchLookupResponseDto[] }> {
const rawQuery = searchParams.query.trim();
if (!rawQuery) {
return { items: [] };
}
const limit = Math.min(Math.max(searchParams.limit || 10, 1), 50);
// Normalize the query the same way as the FTS / suggest path: f_unaccent +
// lower, done in SQL. `q` is the escaped LIKE pattern body (literal chars).
const likeBody = escapeLikePattern(rawQuery);
// Compare against `LOWER(f_unaccent(col))`; unaccent+lower the needle too.
const needle = sql<string>`LOWER(f_unaccent(${rawQuery}))`;
const likePattern = sql<string>`LOWER(f_unaccent(${'%' + likeBody + '%'}))`;
const tsQuery = buildTsQuery(rawQuery);
const hasTsQuery = tsQuery.length > 0;
// --- Resolve the space scope. ---------------------------------------------
// Mirrors searchPage: explicit spaceId, else the authenticated user's member
// spaces. The share path is not exposed to this opt-in mode.
let spaceIds: string[] = [];
if (searchParams.spaceId) {
spaceIds = [searchParams.spaceId];
} else if (opts.userId) {
spaceIds = await this.spaceMemberRepo.getUserSpaceIds(opts.userId);
} else {
return { items: [] };
}
if (spaceIds.length === 0) {
return { items: [] };
}
// --- Optional parentPageId subtree scope (inclusive). ---------------------
// Reuse the same recursive-descendants pattern used for share-scope.
let descendantIds: string[] | null = null;
if (searchParams.parentPageId) {
const descendants = await this.pageRepo.getPageAndDescendants(
searchParams.parentPageId,
{ includeContent: false },
);
descendantIds = descendants.map((p: any) => p.id);
if (descendantIds.length === 0) {
return { items: [] };
}
}
// --- Candidate query: substring (title + text) UNION FTS. -----------------
// We compute everything the ranker needs in SQL and pull only small columns
// (never the whole text_content) into Node:
// - titleExact / titleSub: tier signals
// - textMatchPos: 1-based position of the first text match (0 = none)
// - ftsRank: ts_rank for the FTS secondary signal (0 when no tsquery)
// - snippet: windowed ~500 chars around the first text match, or a leading
// text window (title-only hit), or an extended ts_headline fallback.
const N_BEFORE = 60; // chars of context before the first match
const SNIPPET_LEN = 500;
let candidates = this.db
.selectFrom('pages')
.select([
'pages.id as id',
'pages.slugId as slugId',
'pages.title as title',
'pages.parentPageId as parentPageId',
// Tier signals.
sql<boolean>`LOWER(f_unaccent(coalesce(pages.title, ''))) = ${needle}`.as(
'titleExact',
),
sql<boolean>`LOWER(f_unaccent(coalesce(pages.title, ''))) LIKE ${likePattern} ESCAPE '\\'`.as(
'titleSub',
),
// 1-based position of the first text match (0 = no text match).
sql<number>`strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle})`.as(
'textMatchPos',
),
// FTS secondary signal (0 when the tsquery is empty).
hasTsQuery
? sql<number>`ts_rank(pages.tsv, to_tsquery('english', f_unaccent(${tsQuery})))`.as(
'ftsRank',
)
: sql<number>`0`.as('ftsRank'),
// Windowed snippet, computed entirely in SQL. Priority:
// 1. window around the first text match;
// 2. otherwise (titleOnly: no snippet; else) a leading window of the
// page text (title-only hit);
// 3. otherwise an extended ts_headline for pure-FTS hits.
//
// #443 snippet-position fix: the match position (`strpos`) is computed in
// the LOWER(f_unaccent(...)) space, but f_unaccent is NOT length-
// preserving (ß→ss, æ→ae, …→..., ½→ 1/2, full-width forms), so slicing
// the ORIGINAL text at that position was misaligned — a single expanding
// char before the match shifted the window (or ran it past end → empty).
// We now slice from the SAME LOWER(f_unaccent(...)) string so position
// and slice share one coordinate space. DELIBERATE trade-off: the snippet
// loses original case/diacritics — acceptable for an agent-facing snippet
// (position accuracy over original-glyph fidelity). The ts_headline branch
// matches over the ORIGINAL text itself, so it is unaffected and kept as-is.
searchParams.titleOnly
? sql<string>`''`.as('snippet')
: sql<string>`
coalesce(
case
when strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) > 0
then substring(
LOWER(f_unaccent(coalesce(pages.text_content, '')))
from greatest(1, strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) - ${N_BEFORE})
for ${SNIPPET_LEN}
)
when coalesce(pages.text_content, '') <> ''
then substring(LOWER(f_unaccent(pages.text_content)) from 1 for 300)
${
hasTsQuery
? sql`else ts_headline('english', coalesce(pages.text_content, ''), to_tsquery('english', f_unaccent(${tsQuery})), 'MinWords=25, MaxWords=40, MaxFragments=3')`
: sql``
}
end,
''
)
`.as('snippet'),
])
.where('pages.deletedAt', 'is', null)
.where('pages.spaceId', 'in', spaceIds);
if (descendantIds) {
candidates = candidates.where('pages.id', 'in', descendantIds);
}
// Match predicate: title substring OR (unless titleOnly) text substring OR
// (unless titleOnly) FTS. The substring branch runs even when the tsquery is
// empty — that is the dotted/numeric-token case the FTS path misses.
//
// #443 dead-index fix: these two LIKE predicates MUST match the GIN trgm
// index expressions EXACTLY for Postgres to use them. The indexes are on the
// coalesce-FREE expressions `LOWER(f_unaccent(title))` (#348's
// idx_pages_title_trgm) and `LOWER(f_unaccent(text_content))` (this PR's
// idx_pages_text_content_trgm). A `coalesce(col,'')` wrapper here would make
// the query expression differ from the index expression and force a Seq Scan
// on pages for every lookup. Dropping coalesce is SEMANTICALLY EQUIVALENT:
// `NULL LIKE '%q%'` is NULL (falsy), so a NULL title/text simply doesn't
// match — exactly as an empty string wouldn't match `%q%`.
candidates = candidates.where((eb) => {
const ors = [
eb(
sql`LOWER(f_unaccent(pages.title))`,
'like',
sql`${likePattern} ESCAPE '\\'`,
),
];
if (!searchParams.titleOnly) {
ors.push(
eb(
sql`LOWER(f_unaccent(pages.text_content))`,
'like',
sql`${likePattern} ESCAPE '\\'`,
),
);
if (hasTsQuery) {
ors.push(
sql<boolean>`pages.tsv @@ to_tsquery('english', f_unaccent(${tsQuery}))` as any,
);
}
}
return eb.or(ors);
});
// Pull a generous candidate set (before permission filtering + limit).
// Cap it so a pathological match set cannot blow up memory; 200 >> limit
// (max 50) leaves ample headroom for the post-permission truncation.
//
// #443 cap-ordering fix: the 200-cap MUST be deterministic and relevance-
// biased. Without an ORDER BY, Postgres returns an ARBITRARY 200 rows, so on
// a broad match set (common word / short substring) a strong TITLE_EXACT hit
// could be among the dropped rows while 200 low-tier TEXT hits fill the cap.
// We order by the SAME SQL tier proxies the Node ranker uses — title-exact,
// then title-substring, then fts-rank (nulls last), then earliest text-match
// position — so the cap keeps the strongest candidates. The Node-side final
// tier sort + slice(0, limit) below still runs and stays authoritative; this
// ORDER BY only decides WHICH candidates survive the 200-cap.
// NB: a BARE integer literal in ORDER BY is read by Postgres as an ordinal
// column position (`ORDER BY 0` → "position 0 is not in select list"), so the
// no-tsquery fallback is `0::float`, not `0`.
const ftsRankExpr = hasTsQuery
? sql`ts_rank(pages.tsv, to_tsquery('english', f_unaccent(${tsQuery})))`
: sql`0::float`;
const candidatesCapped = candidates
// Raw-SQL ORDER BY expressions: pass the full `<expr> <dir>` as ONE arg
// (the two-arg form treats a raw-SQL second arg as an ORDER BY position).
.orderBy(
sql`(LOWER(f_unaccent(coalesce(pages.title, ''))) = ${needle}) desc`,
)
.orderBy(
sql`(LOWER(f_unaccent(coalesce(pages.title, ''))) LIKE ${likePattern} ESCAPE '\\') desc`,
)
.orderBy(sql`${ftsRankExpr} desc nulls last`)
// Earlier text match first; strpos returns 0 for "no match", which would
// sort BEFORE a real (>=1) position under plain ASC, so push 0 to the end.
.orderBy(
sql`case when strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) = 0 then 2147483647 else strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) end asc`,
);
let rows: any[] = await candidatesCapped.limit(200).execute();
if (rows.length === 0) {
return { items: [] };
}
// --- Permissions BEFORE limit. --------------------------------------------
// Apply the existing page-level post-filter to the MERGED set, then rank and
// only THEN truncate to `limit` — never lose the permission filter.
if (opts.userId) {
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds: rows.map((r) => r.id),
userId: opts.userId,
spaceId: searchParams.spaceId,
workspaceId: opts.workspaceId,
});
const accessibleSet = new Set(accessibleIds);
rows = rows.filter((r) => accessibleSet.has(r.id));
}
if (rows.length === 0) {
return { items: [] };
}
// --- Tiered ranking + dedup. ----------------------------------------------
// Rows are already unique by id (single pages scan), so no cross-branch
// dedup is needed here; the tier captures the strongest match reason.
const ranked = rows.map((r) => {
let tier: SearchLookupTier;
let secondary: number;
if (r.titleExact) {
tier = SearchLookupTier.TITLE_EXACT;
secondary = Number(r.ftsRank) || 0;
} else if (r.titleSub) {
tier = SearchLookupTier.TITLE_SUBSTRING;
secondary = Number(r.ftsRank) || 0;
} else {
tier = SearchLookupTier.TEXT;
// Prefer earlier text matches; map position → closeness in (0, 1].
const pos = Number(r.textMatchPos) || 0;
secondary =
pos > 0 ? 1 / (1 + (pos - 1) / 100) : Number(r.ftsRank) || 0;
}
return { row: r, tier, score: computeLookupScore({ tier, secondary }) };
});
ranked.sort((a, b) => b.score - a.score);
const top = ranked.slice(0, limit);
// --- Batch ancestor path (ONE recursive CTE, not N+1). --------------------
const pathById = await this.buildAncestorPaths(top.map((t) => t.row.id));
const items: SearchLookupResponseDto[] = top.map((t) => ({
id: t.row.id,
slugId: t.row.slugId,
title: t.row.title,
parentPageId: t.row.parentPageId ?? null,
path: pathById.get(t.row.id) ?? [],
snippet: (t.row.snippet ?? '')
.replace(/\r\n|\r|\n/g, ' ')
.replace(/\s+/g, ' ')
.trim(),
score: t.score,
}));
return { items };
}
/**
* Batch ancestor-titles helper (#443): ONE recursive CTE seeded with ALL hit
* ids, walking UP parentPageId. Returns a map hitId → ancestor titles ordered
* root → direct parent (the hit's own title is excluded). Root pages map to
* an empty array. Avoids the N+1 of a per-page breadcrumb call.
*/
private async buildAncestorPaths(
hitIds: string[],
): Promise<Map<string, string[]>> {
const result = new Map<string, string[]>();
if (hitIds.length === 0) return result;
// ancestry(hit_id, page_id, title, parent_page_id, depth): seed one row per
// hit at depth 0 (the hit itself), then walk to parents (increasing depth).
const rows = await this.db
.withRecursive('ancestry', (db) =>
db
.selectFrom('pages')
.select([
'pages.id as hitId',
'pages.id as pageId',
'pages.title as title',
'pages.parentPageId as parentPageId',
sql<number>`0`.as('depth'),
])
.where('pages.id', 'in', hitIds)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.innerJoin('ancestry as a', 'p.id', 'a.parentPageId')
.select([
'a.hitId as hitId',
'p.id as pageId',
'p.title as title',
'p.parentPageId as parentPageId',
sql<number>`a.depth + 1`.as('depth'),
]),
),
)
.selectFrom('ancestry')
.select(['hitId', 'title', 'depth'])
// depth 0 is the hit itself — excluded from the path.
.where('depth', '>', 0)
.orderBy('hitId')
// Larger depth = closer to the space root. Ordering DESC gives
// root → parent once collected.
.orderBy('depth', 'desc')
.execute();
for (const r of rows as any[]) {
const list = result.get(r.hitId) ?? [];
list.push(r.title);
result.set(r.hitId, list);
}
return result;
}
async searchSuggestions( async searchSuggestions(
suggestion: SearchSuggestionDTO, suggestion: SearchSuggestionDTO,
userId: string, userId: string,
@@ -0,0 +1,55 @@
import { type Kysely, sql } from 'kysely';
/**
* #443 — trigram indexes for the opt-in agent-lookup search mode.
*
* The lookup mode adds a substring branch that runs leading-wildcard
* `LOWER(f_unaccent(col)) LIKE '%q%'` predicates on pages.title and
* pages.text_content. A leading wildcard cannot use a b-tree index, so without a
* GIN trigram index each such predicate is a sequential scan.
*
* - TITLE: the lookup-mode title predicate is `LOWER(f_unaccent(title)) LIKE
* '%q%'` (coalesce-free, so it can use a functional index), which is IDENTICAL
* to the one added for /search/suggest (#348). #348's perf-indexes migration
* already created `idx_pages_title_trgm` on `(LOWER(f_unaccent(title)))
* gin_trgm_ops`, so the title predicate is already covered — we do NOT
* re-create that index here (it would be redundant).
*
* - TEXT_CONTENT: NEW. The substring branch scans text_content when the query
* is not titleOnly. text_content is the large column, so a GIN trigram index
* on it is the meaningful acceleration for the lookup mode. The lookup search
* is ALWAYS space-scoped (spaceId or the user's member spaces), so on small
* instances a per-space sequential scan is tolerable — but the index turns the
* `%q%` text predicate into a Bitmap Index Scan and removes the only
* unbounded-per-space cost of the feature. We add it. The trade-off is disk +
* write amplification on page edits (GIN trigram indexes are larger and slower
* to update than b-trees); on the small instances this fork targets that cost
* is acceptable and the read win on agent lookups is the priority.
*
* DEPLOY-TIME LOCK WARNING: plain (non-CONCURRENT) CREATE INDEX — Kysely runs
* each migration in a transaction, so CONCURRENTLY is impossible. The build takes
* a SHARE lock that BLOCKS writes on `pages` for its duration. The text_content
* GIN build is the slow one and can take minutes on a large tenant. For large
* installations, run this in a maintenance window or build the index out-of-band
* with CREATE INDEX CONCURRENTLY before deploying (then `IF NOT EXISTS` no-ops
* here). Small/typical tenants are unaffected.
*/
export async function up(db: Kysely<any>): Promise<void> {
// The title predicate is served by #348's idx_pages_title_trgm — see header.
// Only the text_content index is introduced here.
// text_content trigram index. Its expression is coalesce-free —
// `LOWER(f_unaccent(text_content))` — to EXACTLY match the coalesce-free
// lookup-mode text substring predicate in search.service.ts, so Postgres can
// use it (a `coalesce(...)` mismatch would silently fall back to a Seq Scan).
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_text_content_trgm
ON pages USING gin ((LOWER(f_unaccent(text_content))) gin_trgm_ops)
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
// Only drop the index this migration introduced. idx_pages_title_trgm is owned
// by the #348 perf-indexes migration, so leave it for that migration's down().
await sql`DROP INDEX IF EXISTS idx_pages_text_content_trgm`.execute(db);
}
@@ -143,7 +143,7 @@ export type DocmostMcpConfig = (
buf: Buffer, buf: Buffer,
mime: string, mime: string,
) => { uri: string; sha256: string; size: number }; ) => { uri: string; sha256: string; size: number };
// Optional live/evict probes the package uses to keep stash_page's mirror // Optional live/evict probes the package uses to keep stashPage's mirror
// counts honest under the store's FIFO eviction (mirror of the package's // counts honest under the store's FIFO eviction (mirror of the package's
// sink type); older bindings omit them. // sink type); older bindings omit them.
has?: (uri: string) => boolean; has?: (uri: string) => boolean;
@@ -332,7 +332,7 @@ export class McpService implements OnModuleDestroy {
// Should never happen: handle() always stashes before delegating. // Should never happen: handle() always stashes before delegating.
throw new UnauthorizedException('MCP authentication missing.'); throw new UnauthorizedException('MCP authentication missing.');
} }
// Inject the blob-sandbox sink after the auth decision so stash_page // Inject the blob-sandbox sink after the auth decision so stashPage
// can store blobs in the shared in-RAM store regardless of which // can store blobs in the shared in-RAM store regardless of which
// credential variant resolved. The sink (put/has/evict + uri↔id // credential variant resolved. The sink (put/has/evict + uri↔id
// mapping) is owned by SandboxStore.asSink(). // mapping) is owned by SandboxStore.asSink().
@@ -182,7 +182,6 @@ describe('AiChatService run-stream attach [integration]', () => {
{ {
isAiChatDeferredToolsEnabled: () => false, isAiChatDeferredToolsEnabled: () => false,
isAiChatResumableStreamEnabled: () => true, isAiChatResumableStreamEnabled: () => true,
isAiChatFinalStepLockdownEnabled: () => false,
} as any, } as any,
registry, registry,
); );
@@ -500,7 +499,6 @@ describe('AiChatService run-stream attach [integration]', () => {
{ {
isAiChatDeferredToolsEnabled: () => false, isAiChatDeferredToolsEnabled: () => false,
isAiChatResumableStreamEnabled: () => true, isAiChatResumableStreamEnabled: () => true,
isAiChatFinalStepLockdownEnabled: () => false,
} as any, } as any,
registry, registry,
); );
@@ -150,7 +150,7 @@ describe('AiChatService.stream [integration]', () => {
{} as any, // pageAccess (idem) {} as any, // pageAccess (idem)
// environment (#332): keep deferred tool loading OFF for this lifecycle // environment (#332): keep deferred tool loading OFF for this lifecycle
// harness so the toolset/behavior is exactly as before. // harness so the toolset/behavior is exactly as before.
{ isAiChatDeferredToolsEnabled: () => false, isAiChatFinalStepLockdownEnabled: () => false } as any, { isAiChatDeferredToolsEnabled: () => false } as any,
); );
} }
@@ -378,7 +378,7 @@ describe('AiChatService.stream [integration]', () => {
{} as any, {} as any,
{} as any, {} as any,
// #332: deferred tool loading ON — the property under test. // #332: deferred tool loading ON — the property under test.
{ isAiChatDeferredToolsEnabled: () => true, isAiChatFinalStepLockdownEnabled: () => false } as any, { isAiChatDeferredToolsEnabled: () => true } as any,
); );
} }
@@ -0,0 +1,123 @@
import { randomUUID } from 'node:crypto';
import { Kysely, sql } from 'kysely';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createSpace,
} from './db';
/**
* #443 dead-index guard EXPLAIN on the REAL DB.
*
* The lookup mode's substring predicates run a leading-wildcard
* `LOWER(f_unaccent(col)) LIKE '%q%'`. Those are only fast when Postgres uses
* the GIN trigram indexes:
* - idx_pages_title_trgm on (LOWER(f_unaccent(title))) [#348]
* - idx_pages_text_content_trgm on (LOWER(f_unaccent(text_content))) [#443]
*
* Postgres uses a functional index ONLY when the query expression matches the
* index expression EXACTLY. The original lookup query wrapped the columns in
* `coalesce(col,'')`, which differs from the coalesce-FREE index expression and
* silently forced a Seq Scan on pages for EVERY lookup (the MCP client always
* sends substring:true). This test locks that in.
*
* Discriminator: `SET enable_seqscan = off` asks the planner "CAN this predicate
* use the index at all?" which is exactly what the coalesce bug breaks. With
* seqscan disabled:
* - the coalesce-FREE (fixed) predicate plans a Bitmap Index Scan on the trgm
* index (no Seq Scan on pages);
* - the coalesce-WRAPPED (buggy) predicate cannot use the index and falls back
* to a Seq Scan on pages even though seqscan is disabled.
* We assert both to prove the fix and to keep the regression from silently
* returning.
*/
describe('SearchService agent-lookup EXPLAIN — trgm index is live [integration]', () => {
let db: Kysely<any>;
let workspaceId: string;
let spaceId: string;
async function insertPage(title: string, textContent: string): Promise<void> {
const id = randomUUID();
await db
.insertInto('pages')
.values({
id,
slugId: `slug-${id.slice(0, 12)}`,
title,
textContent,
spaceId,
workspaceId,
})
.execute();
}
// Run EXPLAIN (no ANALYZE — we only inspect the chosen plan) and return the
// concatenated plan text.
async function explain(query: string): Promise<string> {
const rows = await sql<{ 'QUERY PLAN': string }>`EXPLAIN ${sql.raw(query)}`.execute(
db,
);
return (rows.rows as any[]).map((r) => r['QUERY PLAN']).join('\n');
}
beforeAll(async () => {
db = getTestDb();
workspaceId = (await createWorkspace(db)).id;
spaceId = (await createSpace(db, workspaceId)).id;
// Seed enough rows that a trigram index is a plausible plan. The content is
// varied so the '%needle%' pattern is selective.
for (let i = 0; i < 200; i++) {
await insertPage(
`seed-title-${i}`,
`seed body content number ${i} lorem ipsum dolor sit amet ${i}`,
);
}
await insertPage('backup-srv.local', 'the needle-token-xyz lives here');
// Keep the trgm indexes' stats fresh so the planner costs them correctly.
await sql`ANALYZE pages`.execute(db);
});
afterAll(async () => {
await destroyTestDb();
});
// Force the planner to answer "can the index be used?" rather than "is it
// cheaper than a seq scan on this size?". Restored after each test.
beforeEach(async () => {
await sql`SET enable_seqscan = off`.execute(db);
});
afterEach(async () => {
await sql`RESET enable_seqscan`.execute(db);
});
it('title predicate (coalesce-FREE, as fixed) uses idx_pages_title_trgm, not a Seq Scan', async () => {
const plan = await explain(
`SELECT id FROM pages WHERE LOWER(f_unaccent(title)) LIKE '%srv.local%'`,
);
expect(plan).toContain('idx_pages_title_trgm');
expect(plan).not.toMatch(/Seq Scan on pages/i);
});
it('text_content predicate (coalesce-FREE, as fixed) uses idx_pages_text_content_trgm, not a Seq Scan', async () => {
const plan = await explain(
`SELECT id FROM pages WHERE LOWER(f_unaccent(text_content)) LIKE '%needle-token%'`,
);
expect(plan).toContain('idx_pages_text_content_trgm');
expect(plan).not.toMatch(/Seq Scan on pages/i);
});
// Negative control: the OLD coalesce-wrapped predicate must NOT be able to use
// the index — even with seqscan disabled it can only Seq Scan pages. If this
// ever stops seq-scanning, the coalesce/index expressions have re-aligned and
// the guard above is no longer meaningful.
it('coalesce-WRAPPED text predicate (the bug) cannot use the index — falls to Seq Scan', async () => {
const plan = await explain(
`SELECT id FROM pages WHERE LOWER(f_unaccent(coalesce(text_content,''))) LIKE '%needle-token%'`,
);
expect(plan).not.toContain('idx_pages_text_content_trgm');
expect(plan).toMatch(/Seq Scan on pages/i);
});
});
@@ -0,0 +1,462 @@
import { randomUUID } from 'node:crypto';
import { Kysely } from 'kysely';
import { SearchService } from 'src/core/search/search.service';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createSpace,
} from './db';
/**
* #443 agent-lookup search mode, acceptance on the REAL DB schema.
*
* Exercises SearchService.searchPage(..., { substring: true }) against a
* migrated Postgres: substring matching of technical tokens the FTS tokenizer
* mangles (backup-srv.local, 10.0.12.5, WB-MGE-30D86B, "Теги: Docker"), the
* populated path + snippet, parentPageId subtree scoping, titleOnly, the empty
* result, LIKE-metacharacter escaping (`%`/`_` must NOT match everything), the
* permission post-filter applied BEFORE the limit, and the web-UI path staying
* on the legacy FTS shape when `substring` is absent.
*
* The tsv column is populated by the pages_tsvector_trigger on insert, so the
* FTS branch is exercised too.
*/
describe('SearchService agent-lookup mode [integration]', () => {
let db: Kysely<any>;
let service: SearchService;
let workspaceId: string;
let spaceId: string;
// Direct page insert (the shared createPage seeder omits text_content /
// parent_page_id, both of which this mode depends on). Returns the id.
async function insertPage(args: {
title: string;
textContent?: string;
parentPageId?: string | null;
spaceId?: string;
}): Promise<string> {
const id = randomUUID();
await db
.insertInto('pages')
.values({
id,
slugId: `slug-${id.slice(0, 12)}`,
title: args.title,
textContent: args.textContent ?? null,
parentPageId: args.parentPageId ?? null,
spaceId: args.spaceId ?? spaceId,
workspaceId,
})
.execute();
return id;
}
// Build a SearchService wired to the real DB + a real PageRepo (only its
// recursive-descendants method is used by this mode, and it needs only `db`),
// with lightweight stubs for the space-membership and permission repos so a
// test can drive scope + the permission post-filter explicitly.
function buildService(opts?: {
userSpaceIds?: string[];
// ids to KEEP after the permission post-filter; undefined = keep all.
accessibleIds?: string[];
}): SearchService {
const pageRepo = new PageRepo(db as any, null as any, null as any);
const spaceMemberRepo = {
getUserSpaceIds: async () => opts?.userSpaceIds ?? [spaceId],
};
const pagePermissionRepo = {
filterAccessiblePageIds: async ({ pageIds }: { pageIds: string[] }) =>
opts?.accessibleIds
? pageIds.filter((id) => opts.accessibleIds!.includes(id))
: pageIds,
};
return new SearchService(
db as any,
pageRepo as any,
{} as any, // shareRepo — unused by the lookup path
spaceMemberRepo as any,
pagePermissionRepo as any,
);
}
beforeAll(async () => {
db = getTestDb();
workspaceId = (await createWorkspace(db)).id;
spaceId = (await createSpace(db, workspaceId)).id;
service = buildService();
});
afterAll(async () => {
await destroyTestDb();
});
it('finds `backup-srv.local` by the fragment `srv.local`', async () => {
const pageId = await insertPage({
title: 'backup-srv.local',
textContent: 'A backup server node.',
});
const { items } = (await service.searchPage(
{ query: 'srv.local', spaceId, substring: true } as any,
{ workspaceId },
)) as any;
expect(items.map((i: any) => i.id)).toContain(pageId);
const hit = items.find((i: any) => i.id === pageId);
expect(hit.title).toBe('backup-srv.local');
// slugId must never be part of the server response shape.
expect('slugId' in hit).toBe(true); // server carries it; MCP strips it
});
it('finds a page whose TEXT contains `10.0.12.5` by the fragment `10.0.12` (empty-tsquery case)', async () => {
const pageId = await insertPage({
title: 'Server inventory',
textContent: 'The backup box lives at IP: 10.0.12.5. Debian 12, backups.',
});
const { items } = (await service.searchPage(
{ query: '10.0.12', spaceId, substring: true } as any,
{ workspaceId },
)) as any;
const hit = items.find((i: any) => i.id === pageId);
expect(hit).toBeDefined();
// The windowed snippet must include the matched text.
expect(hit.snippet).toContain('10.0.12.5');
});
it('finds `WB-MGE-30D86B` (alphanumeric token with dashes) by title', async () => {
const pageId = await insertPage({
title: 'WB-MGE-30D86B',
textContent: 'Device page.',
});
const { items } = (await service.searchPage(
{ query: 'WB-MGE-30D86B', spaceId, substring: true } as any,
{ workspaceId },
)) as any;
const hit = items.find((i: any) => i.id === pageId);
expect(hit).toBeDefined();
// Exact title match → top tier (TITLE_EXACT=3) → score in [0.75, 1].
expect(hit.score).toBeGreaterThanOrEqual(0.75);
// And it is the top-ranked hit of its own result set.
expect(items[0].id).toBe(pageId);
});
it('finds every page whose text literally contains `Теги: Docker`', async () => {
const a = await insertPage({
title: 'Container host A',
textContent: 'Some notes.\nТеги: Docker, compose\nmore.',
});
const b = await insertPage({
title: 'Container host B',
textContent: 'Prelude.\nТеги: Docker\nepilogue.',
});
const noise = await insertPage({
title: 'Unrelated',
textContent: 'Теги: Kubernetes',
});
const { items } = (await service.searchPage(
{ query: 'Теги: Docker', spaceId, substring: true, limit: 50 } as any,
{ workspaceId },
)) as any;
const ids = items.map((i: any) => i.id);
expect(ids).toContain(a);
expect(ids).toContain(b);
expect(ids).not.toContain(noise);
});
it('populates a non-empty `path` for a nested hit and `[]` for a root hit', async () => {
const root = await insertPage({ title: 'Infrastructure' });
const mid = await insertPage({ title: 'Datacenter A', parentPageId: root });
const leaf = await insertPage({
title: 'unique-nested-host',
parentPageId: mid,
});
const { items } = (await service.searchPage(
{ query: 'unique-nested-host', spaceId, substring: true } as any,
{ workspaceId },
)) as any;
const hit = items.find((i: any) => i.id === leaf);
expect(hit.path).toEqual(['Infrastructure', 'Datacenter A']);
const rootHits = (await service.searchPage(
{ query: 'Infrastructure', spaceId, substring: true } as any,
{ workspaceId },
)) as any;
const rootHit = rootHits.items.find((i: any) => i.id === root);
expect(rootHit.path).toEqual([]);
});
it('scopes to a subtree with parentPageId (cutting off sibling branches)', async () => {
const branchA = await insertPage({ title: 'BranchA-root' });
const inA = await insertPage({
title: 'scoped-target-xyz',
parentPageId: branchA,
});
const branchB = await insertPage({ title: 'BranchB-root' });
const inB = await insertPage({
title: 'scoped-target-xyz',
parentPageId: branchB,
});
const { items } = (await service.searchPage(
{
query: 'scoped-target-xyz',
spaceId,
substring: true,
parentPageId: branchA,
} as any,
{ workspaceId },
)) as any;
const ids = items.map((i: any) => i.id);
expect(ids).toContain(inA);
expect(ids).not.toContain(inB);
});
it('includes the parent page itself in the parentPageId subtree', async () => {
const parent = await insertPage({ title: 'self-included-parent' });
await insertPage({ title: 'child-of-self', parentPageId: parent });
const { items } = (await service.searchPage(
{
query: 'self-included-parent',
spaceId,
substring: true,
parentPageId: parent,
} as any,
{ workspaceId },
)) as any;
expect(items.map((i: any) => i.id)).toContain(parent);
});
it('titleOnly does NOT match on text_content', async () => {
const pageId = await insertPage({
title: 'Plain title',
textContent: 'body mentions the-secret-token here',
});
const withText = (await service.searchPage(
{ query: 'the-secret-token', spaceId, substring: true } as any,
{ workspaceId },
)) as any;
expect(withText.items.map((i: any) => i.id)).toContain(pageId);
const titleOnly = (await service.searchPage(
{
query: 'the-secret-token',
spaceId,
substring: true,
titleOnly: true,
} as any,
{ workspaceId },
)) as any;
expect(titleOnly.items.map((i: any) => i.id)).not.toContain(pageId);
});
// #443 Fix #1 regression: f_unaccent is NOT length-preserving, so an
// expanding char (ß→ss, …→...) BEFORE the match shifted the strpos position
// relative to the ORIGINAL text and the snippet slice ran past end → empty.
// The position and the slice now share the LOWER(f_unaccent(...)) space, so
// the window is aligned and always contains the matched (unaccented) token.
it('returns a populated snippet when an unaccent-EXPANDING char precedes the match', async () => {
// 300 × `ß` (each f_unaccent-expands to `ss`) before the needle. Under the
// old code strpos returned a position ~593 in the expanded space but the
// slice ran over the ORIGINAL (~360 char) text → empty snippet, match lost.
const prefix = 'ß'.repeat(300);
const pageId = await insertPage({
title: 'Expanding-unaccent page',
textContent: `${prefix} needle-token-xyz trailing.`,
});
const { items } = (await service.searchPage(
{ query: 'needle-token-xyz', spaceId, substring: true } as any,
{ workspaceId },
)) as any;
const hit = items.find((i: any) => i.id === pageId);
expect(hit).toBeDefined();
// Snippet must be non-empty AND contain the matched token (unaccented form).
expect(hit.snippet.length).toBeGreaterThan(0);
expect(hit.snippet).toContain('needle-token-xyz');
});
// #443 Fix #2 regression: >200 matching pages for a broad substring, with
// exactly ONE exact-title hit. Without an ORDER BY on the 200-cap the exact
// hit could be among the arbitrarily-dropped rows; the ORDER BY keeps the
// strongest candidates so it must survive the cap and rank at the top.
it('keeps an exact-title hit through the 200-cap on a >200-row match set', async () => {
const isoSpace = (await createSpace(db, workspaceId)).id;
const svc = buildService({ userSpaceIds: [isoSpace] });
// 250 low-tier TEXT hits: the shared substring `capword` appears only in the
// body, never the title, so each is a TEXT-tier match (weakest tier).
for (let i = 0; i < 250; i++) {
await insertPage({
title: `filler-page-${i}`,
textContent: `body contains capword here #${i}`,
spaceId: isoSpace,
});
}
// Exactly one EXACT-title hit for the same query token.
const exact = await insertPage({
title: 'capword',
textContent: 'unrelated body text',
spaceId: isoSpace,
});
const { items } = (await svc.searchPage(
{ query: 'capword', spaceId: isoSpace, substring: true, limit: 10 } as any,
{ workspaceId },
)) as any;
const ids = items.map((i: any) => i.id);
// The exact-title hit must survive the 200-cap and appear in the top `limit`.
expect(ids).toContain(exact);
// And, being TITLE_EXACT, it must be the single strongest hit.
expect(items[0].id).toBe(exact);
});
// #443 Fix #3: titleOnly matches only the title, so it must not leak the page
// body as the snippet (the old "first 300 chars of text_content" fallback).
it('titleOnly does NOT return a text-body snippet', async () => {
const pageId = await insertPage({
title: 'titleonly-snippet-page',
textContent: 'SECRET-BODY-CONTENT-NOT-IN-TITLE that must not leak.',
});
const { items } = (await service.searchPage(
{
query: 'titleonly-snippet-page',
spaceId,
substring: true,
titleOnly: true,
} as any,
{ workspaceId },
)) as any;
const hit = items.find((i: any) => i.id === pageId);
expect(hit).toBeDefined();
// The body text must not appear in the snippet; titleOnly → empty snippet.
expect(hit.snippet).not.toContain('SECRET-BODY-CONTENT-NOT-IN-TITLE');
expect(hit.snippet).toBe('');
});
it('returns [] (not an error) for a query that matches nothing', async () => {
const { items } = (await service.searchPage(
{
query: 'zzz-no-such-string-anywhere-42',
spaceId,
substring: true,
} as any,
{ workspaceId },
)) as any;
expect(items).toEqual([]);
});
it('a `%` query does NOT match everything (LIKE metacharacter escaped)', async () => {
// Fresh space so we can assert on total counts without cross-test noise.
const isoSpace = (await createSpace(db, workspaceId)).id;
const svc = buildService({ userSpaceIds: [isoSpace] });
await insertPage({ title: 'alpha', spaceId: isoSpace });
await insertPage({ title: 'beta', spaceId: isoSpace });
const literal = await insertPage({
title: '100%-coverage',
spaceId: isoSpace,
});
const { items } = (await svc.searchPage(
{ query: '%', spaceId: isoSpace, substring: true, limit: 50 } as any,
{ workspaceId },
)) as any;
const ids = items.map((i: any) => i.id);
// `%` is a literal → matches only the page that actually contains '%'.
expect(ids).toContain(literal);
expect(ids).not.toContain(
items.find((i: any) => i.title === 'alpha')?.id,
);
expect(items.length).toBe(1);
});
it('an `_` query does NOT match everything (LIKE metacharacter escaped)', async () => {
const isoSpace = (await createSpace(db, workspaceId)).id;
const svc = buildService({ userSpaceIds: [isoSpace] });
await insertPage({ title: 'gamma', spaceId: isoSpace });
const literal = await insertPage({
title: 'snake_case_name',
spaceId: isoSpace,
});
const { items } = (await svc.searchPage(
{ query: '_', spaceId: isoSpace, substring: true, limit: 50 } as any,
{ workspaceId },
)) as any;
const ids = items.map((i: any) => i.id);
expect(ids).toContain(literal);
expect(items.length).toBe(1);
});
it('applies the permission post-filter to the MERGED set BEFORE the limit', async () => {
const isoSpace = (await createSpace(db, workspaceId)).id;
const keep = await insertPage({
title: 'perm-visible-target',
spaceId: isoSpace,
});
const hidden = await insertPage({
title: 'perm-hidden-target',
spaceId: isoSpace,
});
// Authenticated (userId set) so the permission filter runs; only `keep` is
// accessible. limit 1 must NOT be able to select `hidden`.
const svc = buildService({
userSpaceIds: [isoSpace],
accessibleIds: [keep],
});
const { items } = (await svc.searchPage(
{
query: 'perm-',
spaceId: isoSpace,
substring: true,
limit: 1,
} as any,
{ userId: 'user-1', workspaceId },
)) as any;
const ids = items.map((i: any) => i.id);
expect(ids).toContain(keep);
expect(ids).not.toContain(hidden);
});
it('web-UI path (no `substring` flag) keeps the legacy FTS response shape', async () => {
await insertPage({
title: 'legacy shape page',
textContent: 'searchable legacyword content',
});
const { items } = (await service.searchPage(
{ query: 'legacyword', spaceId } as any,
{ userId: 'user-1', workspaceId },
)) as any;
// Legacy hits carry rank + highlight + space, and NO path/snippet/score.
const hit = items[0];
expect(hit).toBeDefined();
expect('rank' in hit).toBe(true);
expect('highlight' in hit).toBe(true);
expect('path' in hit).toBe(false);
expect('snippet' in hit).toBe(false);
expect('score' in hit).toBe(false);
});
});
+93 -86
View File
@@ -12,7 +12,7 @@ license.
> better at *writing a small function that fixes the text* than at re-reading and > better at *writing a small function that fixes the text* than at re-reading and
> re-emitting a whole document. So this server is built around the way a model actually > re-emitting a whole document. So this server is built around the way a model actually
> wants to edit: address a block by id, run a find/replace, or hand it a > wants to edit: address a block by id, run a find/replace, or hand it a
> `(doc, ctx) => doc` transform and let it *program* the change. `docmost_transform` is > `(doc, ctx) => doc` transform and let it *program* the change. `docmostTransform` is
> that interface. Other Docmost MCPs are human-shaped — they expose "open the page" and > that interface. Other Docmost MCPs are human-shaped — they expose "open the page" and
> "replace the page"; this one exposes the editing primitives a model is good at. > "replace the page"; this one exposes the editing primitives a model is good at.
@@ -40,7 +40,7 @@ There are several Docmost MCPs. Here is a capability-by-capability comparison.
| **Enterprise license required** | **No** | **Yes** | No | No | No | | **Enterprise license required** | **No** | **Yes** | No | No | No |
| Authentication | email + password, **auto re-auth** | API key | email + password | cookie `authToken` (copy from DevTools) | Docmost API / **direct PostgreSQL** | | Authentication | email + password, **auto re-auth** | API key | email + password | cookie `authToken` (copy from DevTools) | Docmost API / **direct PostgreSQL** |
| Read page as Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (read-only) | | Read page as Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (read-only) |
| **Lossless Markdown round-trip** (export / import, keeps comment anchors) | ✅ | — | — | — | — | | **Markdown round-trip** (export / import, keeps comment anchors) | ✅ | — | — | — | — |
| Read **lossless ProseMirror JSON** (with block ids) | ✅ | — | — | — | — | | Read **lossless ProseMirror JSON** (with block ids) | ✅ | — | — | — | — |
| **Compact page outline** (cheap block-id lookup) | ✅ | — | — | — | — | | **Compact page outline** (cheap block-id lookup) | ✅ | — | — | — | — |
| **Fetch a single block** (by id or index) | ✅ | — | — | — | — | | **Fetch a single block** (by id or index) | ✅ | — | — | — | — |
@@ -69,9 +69,9 @@ There are several Docmost MCPs. Here is a capability-by-capability comparison.
- **Token-efficient editing.** Most Docmost MCPs (and the official one) only offer - **Token-efficient editing.** Most Docmost MCPs (and the official one) only offer
"replace the whole page" writes — the agent must download the entire document, mutate "replace the whole page" writes — the agent must download the entire document, mutate
it, and upload it back, paying for the full document **twice** on every tiny fix. it, and upload it back, paying for the full document **twice** on every tiny fix.
This server lets the agent change exactly one block (`patch_node` / `insert_node` / This server lets the agent change exactly one block (`patchNode` / `insertNode` /
`delete_node`), do a structure-preserving find/replace (`edit_page_text`), or copy a `deleteNode`), do a structure-preserving find/replace (`editPageText`), or copy a
whole page server-side (`copy_page_content`) — **without the document ever passing whole page server-side (`copyPageContent`) — **without the document ever passing
through the model**. through the model**.
- **Writes that don't fight the editor.** Naive REST writes race with whatever a human - **Writes that don't fight the editor.** Naive REST writes race with whatever a human
@@ -85,12 +85,12 @@ There are several Docmost MCPs. Here is a capability-by-capability comparison.
- **Agent-native editing model.** Human-facing servers expose "open the page" and "replace - **Agent-native editing model.** Human-facing servers expose "open the page" and "replace
the page", because that mirrors how a person works. A model edits better by *programming* the page", because that mirrors how a person works. A model edits better by *programming*
the change — addressing blocks by id, running a find/replace, or supplying a the change — addressing blocks by id, running a find/replace, or supplying a
`(doc, ctx) => doc` transform (`docmost_transform`, with a dry-run diff before it `(doc, ctx) => doc` transform (`docmostTransform`, with a dry-run diff before it
commits). This server is shaped around that, which is why it has editing primitives the commits). This server is shaped around that, which is why it has editing primitives the
others simply don't. others simply don't.
- **An editing safety net the others lack.** `list_page_history``diff_page_versions` - **An editing safety net the others lack.** `listPageHistory``diffPageVersions`
`restore_page_version` give an agent (and you) a full view-and-undo loop. The diff `restorePageVersion` give an agent (and you) a full view-and-undo loop. The diff
uses the *same* `recreateTransform → ChangeSet → simplifyChanges` pipeline Docmost's uses the *same* `recreateTransform → ChangeSet → simplifyChanges` pipeline Docmost's
own history viewer uses, so what you see matches the product. own history viewer uses, so what you see matches the product.
@@ -110,56 +110,58 @@ All 41 tools, grouped by what you'd reach for them.
### Exploration & retrieval ### Exploration & retrieval
- **`get_workspace`** — Information about the current Docmost workspace. - **`getWorkspace`** — Information about the current Docmost workspace.
- **`list_spaces`** — All spaces in the workspace. - **`listSpaces`** — All spaces in the workspace.
- **`list_pages`** — Recent pages in a space, ordered by `updatedAt` desc (default 50, - **`listPages`** — Recent pages in a space, ordered by `updatedAt` desc (default 50,
max 100). Use `search` for lookups in large spaces. max 100). Use `search` for lookups in large spaces.
- **`search`** — Full-text search across pages and content (bounded by `limit`, max 100). - **`search`** — Full-text search across pages and content (bounded by `limit`, max 100).
- **`get_page`** — A page's content as clean **Markdown** (convenient, but a *lossy* - **`getPage`** — A page's content as clean **Markdown** (canonical for text; drops only
view — block ids and exact table/callout structure are approximated). block ids, resolved-comment anchors, and a fixed no-Markdown-representation attr set —
- **`get_page_json`** — A page's **lossless ProseMirror/TipTap JSON**, including every table spans/colwidth/background, indent, `callout.icon`, `orderedList.type`, and link
`internal`/`target`/`rel`/`class`; use `getPageJson` when you need those).
- **`getPageJson`** — A page's **lossless ProseMirror/TipTap JSON**, including every
block's `attrs.id` and the `slugId` used in URLs. This is what the per-block editing block's `attrs.id` and the `slugId` used in URLs. This is what the per-block editing
tools consume. tools consume.
- **`get_outline`** — A compact outline of a page's top-level blocks (`{index, type, id, - **`getOutline`** — A compact outline of a page's top-level blocks (`{index, type, id,
level, firstText}`; tables add row/column counts and their header-cell texts, lists add level, firstText}`; tables add row/column counts and their header-cell texts, lists add
item counts) **without** the document body. The cheap way to locate a section or table item counts) **without** the document body. The cheap way to locate a section or table
and grab its block id before and grab its block id before
`get_node` / `patch_node` / `insert_node`. `getNode` / `patchNode` / `insertNode`.
- **`get_node`** — Fetch a single block's full ProseMirror subtree (lossless) without - **`getNode`** — Fetch a single block's full ProseMirror subtree (lossless) without
pulling the whole page. Address it by a block id (from `get_outline` / `get_page_json`), pulling the whole page. Address it by a block id (from `getOutline` / `getPageJson`),
or by `#<index>` for a top-level block — use the `#<index>` form for tables/rows/cells, or by `#<index>` for a top-level block — use the `#<index>` form for tables/rows/cells,
which carry no id. which carry no id.
### Page lifecycle ### Page lifecycle
- **`create_page`** — Create a page from Markdown and place it in the hierarchy (optional - **`createPage`** — Create a page from Markdown and place it in the hierarchy (optional
`parentPageId`) in one call. Uses Docmost's import API for clean Markdown→ProseMirror. `parentPageId`) in one call. Uses Docmost's import API for clean Markdown→ProseMirror.
- **`rename_page`** — Change a page's title only, without touching or resending content. - **`renamePage`** — Change a page's title only, without touching or resending content.
- **`move_page`** — Re-parent a page (nest it, or move to root); supports fractional-index - **`movePage`** — Re-parent a page (nest it, or move to root); supports fractional-index
positioning. Returns only on a *positively confirmed* success. positioning. Returns only on a *positively confirmed* success.
- **`delete_page`** — Delete a single page. - **`deletePage`** — Delete a single page.
- **`copy_page_content`** — Replace one page's body with a copy of another's, **entirely - **`copyPageContent`** — Replace one page's body with a copy of another's, **entirely
server-side** — the document never passes through the model. The target keeps its own server-side** — the document never passes through the model. The target keeps its own
title and slug (so its URL is preserved). title and slug (so its URL is preserved).
### Editing ### Editing
- **`edit_page_text`** — Surgical find/replace inside a page's text. Preserves **all** - **`editPageText`** — Surgical find/replace inside a page's text. Preserves **all**
structure: block ids, marks, links, callouts, tables. The preferred tool for fixing structure: block ids, marks, links, callouts, tables. The preferred tool for fixing
wording, typos, numbers and names. wording, typos, numbers and names.
- **`patch_node`** — Replace a single block addressed by its `attrs.id` (from - **`patchNode`** — Replace a single block addressed by its `attrs.id` (from
`get_page_json`), without resending the document. `getPageJson`), without resending the document.
- **`insert_node`** — Insert a block before/after another (by `attrs.id` or anchor text), - **`insertNode`** — Insert a block before/after another (by `attrs.id` or anchor text),
or append at the end. or append at the end.
- **`delete_node`** — Remove a single block by its `attrs.id`. - **`deleteNode`** — Remove a single block by its `attrs.id`.
- **`update_page_json`** — Replace a page's entire content with a ProseMirror document - **`updatePageJson`** — Replace a page's entire content with a ProseMirror document
(bulk rewrites, or when nodes lack ids). `content` is optional — omit it to update only (bulk rewrites, or when nodes lack ids). `content` is optional — omit it to update only
the title. Keeps the block ids you pass in, so heading anchors and history stay stable. the title. Keeps the block ids you pass in, so heading anchors and history stay stable.
- **`update_page_markdown`** — Replace a page's body (and optionally its title) with new - **`updatePageMarkdown`** — Replace a page's body (and optionally its title) with new
**plain Markdown**. The whole body is re-imported (block ids regenerate — for surgical or **plain Markdown**. The whole body is re-imported (block ids regenerate — for surgical or
id-preserving edits prefer `edit_page_text` / `patch_node` / `update_page_json`). id-preserving edits prefer `editPageText` / `patchNode` / `updatePageJson`).
Docmost-flavoured markdown is parsed, including `^[...]` inline footnotes. Docmost-flavoured markdown is parsed, including `^[...]` inline footnotes.
- **`docmost_transform`** — The agent-native editing interface: instead of retyping a - **`docmostTransform`** — The agent-native editing interface: instead of retyping a
document, the agent **writes a function that fixes it**. Edit a page by running an document, the agent **writes a function that fixes it**. Edit a page by running an
arbitrary **`(doc, ctx) => doc` JavaScript transform** against its *live* ProseMirror arbitrary **`(doc, ctx) => doc` JavaScript transform** against its *live* ProseMirror
document. Runs **sandboxed** document. Runs **sandboxed**
@@ -172,42 +174,46 @@ All 41 tools, grouped by what you'd reach for them.
### Tables ### Tables
- **`table_get`** — Read a table as a matrix: `{rows, cols, cells (text[][]), cellIds}` - **`tableGet`** — Read a table as a matrix: `{rows, cols, cells (text[][]), cellIds}`
(a paragraph id per cell, or `null`). Address the table by `#<index>` (from (a paragraph id per cell, or `null`). Address the table by `#<index>` (from
`get_outline`) or any block id inside it. Use `cellIds` with `patch_node` for `getOutline`) or any block id inside it. Use `cellIds` with `patchNode` for
rich-formatted cell edits. rich-formatted cell edits.
- **`table_insert_row`** — Insert a row of plain-text cells, padded to the table's column - **`tableInsertRow`** — Insert a row of plain-text cells, padded to the table's column
count (passing more cells than columns is an error). `index` is the 0-based insert count (passing more cells than columns is an error). `index` is the 0-based insert
position (0 inserts before the header); omit it to append at the end. position (0 inserts before the header); omit it to append at the end.
- **`table_delete_row`** — Delete the row at a 0-based `index`. Refuses to delete a table's - **`tableDeleteRow`** — Delete the row at a 0-based `index`. Refuses to delete a table's
only row; deleting row 0 promotes the next row to header. only row; deleting row 0 promotes the next row to header.
- **`table_update_cell`** — Set the plain-text content of cell `[row, col]` (0-based). For - **`tableUpdateCell`** — Set the plain-text content of cell `[row, col]` (0-based). For
rich formatting, `patch_node` the cell's paragraph id from `table_get`. rich formatting, `patchNode` the cell's paragraph id from `tableGet`.
### Markdown round-trip ### Markdown round-trip
- **`export_page_markdown`** — Export a page to a single self-contained, **lossless - **`exportPageMarkdown`** — Export a page to a single self-contained
Docmost-flavoured Markdown** file: a meta header, the body with inline comment anchors **Docmost-flavoured Markdown** file: a meta header, the body with inline comment anchors
and diagrams, and a trailing comments-thread block. To replace a page's body from plain and diagrams, and a trailing comments-thread block. The download → edit → import
authoring Markdown, use `update_page_markdown`. round-trip regenerates block ids and **silently drops** the no-Markdown-representation
attr set (table merge spans/colwidth/background, indent, `callout.icon`,
`orderedList.type`, link `internal`/`target`/`rel`/`class`); keep those in ProseMirror
JSON if they must survive. To replace a page's body from plain authoring Markdown, use
`updatePageMarkdown`.
> **Removed in this release:** `import_page_markdown` (the round-trip parser for an > **Removed in this release:** `importPageMarkdown` (the round-trip parser for an
> exported Docmost-Markdown file) is **no longer exposed on the external MCP surface**. > exported Docmost-Markdown file) is **no longer exposed on the external MCP surface**.
> To replace a page's body from Markdown, use **`update_page_markdown`** (plain Markdown > To replace a page's body from Markdown, use **`updatePageMarkdown`** (plain Markdown
> body replace). See the CHANGELOG for the migration note. > body replace). See the CHANGELOG for the migration note.
### Images ### Images
- **`insert_image`** — Download an image from a web (http/https) URL and insert it in one - **`insertImage`** — Download an image from a web (http/https) URL and insert it in one
step: append it, drop it in place of a text placeholder (`replaceText`), or put it after step: append it, drop it in place of a text placeholder (`replaceText`), or put it after
a given block (`afterText`). Preserves all other block ids. a given block (`afterText`). Preserves all other block ids.
- **`replace_image`** — Swap an existing image for one fetched from a web (http/https) URL. - **`replaceImage`** — Swap an existing image for one fetched from a web (http/https) URL.
Uploads the new file as a **fresh Uploads the new file as a **fresh
attachment** (clean URL that renders and busts browser caches), then re-points every attachment** (clean URL that renders and busts browser caches), then re-points every
node referencing the old attachment (recursively, including callouts/tables) via the node referencing the old attachment (recursively, including callouts/tables) via the
live document, preserving comments, alignment and alt text. (In-place overwrite is live document, preserving comments, alignment and alt text. (In-place overwrite is
deliberately avoided — some Docmost versions corrupt the attachment on overwrite.) deliberately avoided — some Docmost versions corrupt the attachment on overwrite.)
- **`stash_page`** — Serialize a whole page (its full ProseMirror JSON) into an ephemeral - **`stashPage`** — Serialize a whole page (its full ProseMirror JSON) into an ephemeral
in-RAM blob and return ONLY a short anonymous URL — the body never enters the model in-RAM blob and return ONLY a short anonymous URL — the body never enters the model
context, so it is the way to hand a large page (and its images) to an external consumer context, so it is the way to hand a large page (and its images) to an external consumer
without truncation. Every internal file/image attachment is mirrored into the same without truncation. Every internal file/image attachment is mirrored into the same
@@ -218,35 +224,35 @@ All 41 tools, grouped by what you'd reach for them.
### Comments ### Comments
- **`create_comment`** — Add a page comment, optionally **anchored inline** to an exact - **`createComment`** — Add a page comment, optionally **anchored inline** to an exact
span of text (the first occurrence is wrapped in a comment mark). span of text (the first occurrence is wrapped in a comment mark).
- **`list_comments`** — List a page's comments (content returned as Markdown). - **`listComments`** — List a page's comments (content returned as Markdown).
- **`update_comment`** — Edit an existing comment. - **`updateComment`** — Edit an existing comment.
- **`delete_comment`** — Delete a comment. - **`deleteComment`** — Delete a comment.
- **`resolve_comment`** — Resolve (close) or reopen a comment thread (reversible). Only top-level - **`resolveComment`** — Resolve (close) or reopen a comment thread (reversible). Only top-level
comments can be resolved; the thread and its replies are kept, unlike `delete_comment`. comments can be resolved; the thread and its replies are kept, unlike `deleteComment`.
- **`check_new_comments`** — Find comments created after a given ISO-8601 timestamp across - **`checkNewComments`** — Find comments created after a given ISO-8601 timestamp across
a space, optionally scoped to a page subtree — ideal for an agent that watches a doc for a space, optionally scoped to a page subtree — ideal for an agent that watches a doc for
feedback. feedback.
### Versioning & history ### Versioning & history
- **`list_page_history`** — A page's saved versions (Docmost auto-snapshots on save), - **`listPageHistory`** — A page's saved versions (Docmost auto-snapshots on save),
newest first, cursor-paginated. Each item's id is the `historyId`. newest first, cursor-paginated. Each item's id is the `historyId`.
- **`diff_page_versions`** — Diff two versions (or a version against the live page). - **`diffPageVersions`** — Diff two versions (or a version against the live page).
Returns inserted/deleted text, integrity counts (images, links, tables, callouts, Returns inserted/deleted text, integrity counts (images, links, tables, callouts,
footnote markers), and a human-readable Markdown summary — computed with the same footnote markers), and a human-readable Markdown summary — computed with the same
pipeline Docmost's own history viewer uses. pipeline Docmost's own history viewer uses.
- **`restore_page_version`** — Write a saved version back as the current content. Docmost - **`restorePageVersion`** — Write a saved version back as the current content. Docmost
has no restore endpoint, so this creates a **new** snapshot — the restore is itself has no restore endpoint, so this creates a **new** snapshot — the restore is itself
revertible. revertible.
### Sharing ### Sharing
- **`share_page`** — Make a page publicly accessible (idempotent) and return its public - **`sharePage`** — Make a page publicly accessible (idempotent) and return its public
URL (`<app>/share/<key>/p/<slugId>`); optional search-engine indexing. URL (`<app>/share/<key>/p/<slugId>`); optional search-engine indexing.
- **`unshare_page`** — Revoke a page's public share. - **`unsharePage`** — Revoke a page's public share.
- **`list_shares`** — All public shares in the workspace, with titles and public URLs. - **`listShares`** — All public shares in the workspace, with titles and public URLs.
--- ---
@@ -255,27 +261,27 @@ All 41 tools, grouped by what you'd reach for them.
This same guidance is also delivered at runtime via the MCP server `instructions` field, This same guidance is also delivered at runtime via the MCP server `instructions` field,
so capable clients steer the model automatically. so capable clients steer the model automatically.
- **Text fixes** (wording, typos, numbers): `edit_page_text`. - **Text fixes** (wording, typos, numbers): `editPageText`.
- **One block** (paragraph/heading/callout/table cell): `patch_node` / `insert_node` / - **One block** (paragraph/heading/callout/table cell): `patchNode` / `insertNode` /
`delete_node`, addressing the node by its `attrs.id` from `get_page_json`. `deleteNode`, addressing the node by its `attrs.id` from `getPageJson`.
- **Images**: `insert_image` / `replace_image`. - **Images**: `insertImage` / `replaceImage`.
- **A new page**: `create_page`. - **A new page**: `createPage`.
- **Bulk rewrite, or nodes without ids**: `update_page_json` (ProseMirror) or - **Bulk rewrite, or nodes without ids**: `updatePageJson` (ProseMirror) or
`update_page_markdown` (plain Markdown body replace). `updatePageMarkdown` (plain Markdown body replace).
- **Multi-step / scripted rewrite** (renumbering, footnotes, coordinated edits): - **Multi-step / scripted rewrite** (renumbering, footnotes, coordinated edits):
`docmost_transform` — preview with `dryRun`, then apply. `docmostTransform` — preview with `dryRun`, then apply.
- **Copy a whole page's content from another page** (server-side): `copy_page_content`. - **Copy a whole page's content from another page** (server-side): `copyPageContent`.
- **Rename a page** (title only): `rename_page`. - **Rename a page** (title only): `renamePage`.
- **Reads**: `get_page` (Markdown) / `get_page_json` (lossless ProseMirror with ids). - **Reads**: `getPage` (Markdown) / `getPageJson` (lossless ProseMirror with ids).
- **Review changes**: `list_page_history``diff_page_versions``restore_page_version`. - **Review changes**: `listPageHistory``diffPageVersions``restorePageVersion`.
- **Comments**: `create_comment` (with optional inline anchoring) / `list_comments` / - **Comments**: `createComment` (with optional inline anchoring) / `listComments` /
`update_comment` / `resolve_comment` / `delete_comment` / `check_new_comments`. `updateComment` / `resolveComment` / `deleteComment` / `checkNewComments`.
- **Navigate a page cheaply** (find a section/table, grab a block id): `get_outline` - **Navigate a page cheaply** (find a section/table, grab a block id): `getOutline`
`get_node`. `getNode`.
- **Tables** (add/remove a row, set a cell): `table_get` / `table_insert_row` / - **Tables** (add/remove a row, set a cell): `tableGet` / `tableInsertRow` /
`table_delete_row` / `table_update_cell`. `tableDeleteRow` / `tableUpdateCell`.
- **Export a page as self-contained Markdown** (with comment anchors): `export_page_markdown`. - **Export a page as self-contained Markdown** (with comment anchors): `exportPageMarkdown`.
- **Replace a page's body from Markdown**: `update_page_markdown`. - **Replace a page's body from Markdown**: `updatePageMarkdown`.
--- ---
@@ -293,19 +299,20 @@ so capable clients steer the model automatically.
refreshed automatically on the first 401/403 (covering JSON, multipart upload, and the refreshed automatically on the first 401/403 (covering JSON, multipart upload, and the
collaboration-token path), with in-flight login de-duplication so a burst of calls collaboration-token path), with in-flight login de-duplication so a burst of calls
triggers a single re-login. triggers a single re-login.
- **Lossless and lossy reads.** `get_page_json` returns the exact ProseMirror tree with - **Precise reads.** `getPageJson` returns the exact ProseMirror tree with block ids;
block ids; `get_page` returns clean Markdown for convenience. `getPage` returns canonical Markdown that drops only a fixed, documented attr set.
- **Full Docmost schema.** Markdown↔ProseMirror conversion supports callouts (including - **Full Docmost schema.** Markdown↔ProseMirror conversion supports callouts (including
nested), task lists (bullet *and* numbered checklists), tables, math blocks, embeds, nested), task lists (bullet *and* numbered checklists), tables, math blocks, embeds,
highlights, sub/superscript and more, with defensive caps against pathological input. highlights, sub/superscript and more, with defensive caps against pathological input.
- **Structured tables & lossless Markdown round-trip.** Tables can be edited as a matrix - **Structured tables & Markdown round-trip.** Tables can be edited as a matrix
(read, insert/delete rows, set cells by `[row,col]`) without resending the document, and (read, insert/delete rows, set cells by `[row,col]`) without resending the document, and
a page can be exported to and re-imported from a self-contained Docmost-flavoured a page can be exported to and re-imported from a self-contained Docmost-flavoured
Markdown file that preserves inline comment anchors and diagrams. Markdown file that preserves inline comment anchors and diagrams (block ids regenerate
and a fixed no-Markdown-representation attr set is dropped — see `exportPageMarkdown`).
- **Token-optimized responses.** API responses are filtered down to the fields agents - **Token-optimized responses.** API responses are filtered down to the fields agents
actually need, and large collections (spaces, pages, comments, history) are paginated. actually need, and large collections (spaces, pages, comments, history) are paginated.
- **Hardened runtime.** Global handlers keep a stray socket error from tearing down the - **Hardened runtime.** Global handlers keep a stray socket error from tearing down the
stdio server; `move_page` requires a positively confirmed success; the diff engine stdio server; `movePage` requires a positively confirmed success; the diff engine
falls back to a coarse block diff rather than hard-failing on a pathological document. falls back to a coarse block diff rather than hard-failing on a pathological document.
--- ---
@@ -363,7 +370,7 @@ npm run test:e2e
This project began as a fork of [MrMartiniMo/docmost-mcp](https://github.com/MrMartiniMo/docmost-mcp) This project began as a fork of [MrMartiniMo/docmost-mcp](https://github.com/MrMartiniMo/docmost-mcp)
(by Moritz Krause) and extends it substantially — adding per-block node editing, (by Moritz Krause) and extends it substantially — adding per-block node editing,
surgical text edits, the sandboxed `docmost_transform`, version history / diff / restore, surgical text edits, the sandboxed `docmostTransform`, version history / diff / restore,
comments, image insert/replace, public sharing, server-side page copy, dual comments, image insert/replace, public sharing, server-side page copy, dual
JSON/Markdown reads, transparent re-authentication and significant hardening. The comment JSON/Markdown reads, transparent re-authentication and significant hardening. The comment
tools were ported from upstream PR #3 by Max Nikitin. Thanks to both. tools were ported from upstream PR #3 by Max Nikitin. Thanks to both.
+97 -87
View File
@@ -12,7 +12,7 @@
> небольшую функцию, которая чинит текст*, чем перечитывать и заново выдавать весь > небольшую функцию, которая чинит текст*, чем перечитывать и заново выдавать весь
> документ. Поэтому сервер построен вокруг того, как модели на самом деле удобно > документ. Поэтому сервер построен вокруг того, как модели на самом деле удобно
> редактировать: адресовать блок по id, сделать find/replace или передать трансформ > редактировать: адресовать блок по id, сделать find/replace или передать трансформ
> `(doc, ctx) => doc` и позволить модели *запрограммировать* правку. `docmost_transform` > `(doc, ctx) => doc` и позволить модели *запрограммировать* правку. `docmostTransform`
> это и есть такой интерфейс. Другие Docmost-MCP «заточены под человека» — они дают > это и есть такой интерфейс. Другие Docmost-MCP «заточены под человека» — они дают
> «открыть страницу» и «заменить страницу»; этот даёт примитивы редактирования, в которых > «открыть страницу» и «заменить страницу»; этот даёт примитивы редактирования, в которых
> модель сильна. > модель сильна.
@@ -43,7 +43,7 @@ Docmost-MCP не сочетают:
| **Нужна enterprise-лицензия** | **Нет** | **Да** | Нет | Нет | Нет | | **Нужна enterprise-лицензия** | **Нет** | **Да** | Нет | Нет | Нет |
| Аутентификация | email + пароль, **авто-переавторизация** | API-ключ | email + пароль | cookie `authToken` (копировать из DevTools) | API Docmost / **напрямую PostgreSQL** | | Аутентификация | email + пароль, **авто-переавторизация** | API-ключ | email + пароль | cookie `authToken` (копировать из DevTools) | API Docmost / **напрямую PostgreSQL** |
| Чтение страницы как Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (только чтение) | | Чтение страницы как Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (только чтение) |
| **Lossless Markdown round-trip** (экспорт/импорт, сохраняет якоря комментариев) | ✅ | — | — | — | — | | **Markdown round-trip** (экспорт/импорт, сохраняет якоря комментариев) | ✅ | — | — | — | — |
| Чтение **lossless ProseMirror JSON** (с id блоков) | ✅ | — | — | — | — | | Чтение **lossless ProseMirror JSON** (с id блоков) | ✅ | — | — | — | — |
| **Компактная структура страницы** (дешёвый поиск id блока) | ✅ | — | — | — | — | | **Компактная структура страницы** (дешёвый поиск id блока) | ✅ | — | — | — | — |
| **Получение одного блока** (по id или индексу) | ✅ | — | — | — | — | | **Получение одного блока** (по id или индексу) | ✅ | — | — | — | — |
@@ -71,9 +71,9 @@ Docmost-MCP не сочетают:
- **Экономия токенов при редактировании.** Большинство Docmost-MCP (и официальный) - **Экономия токенов при редактировании.** Большинство Docmost-MCP (и официальный)
предлагают только запись «заменить всю страницу» — агент вынужден скачать весь документ, предлагают только запись «заменить всю страницу» — агент вынужден скачать весь документ,
изменить и загрузить обратно, оплачивая весь документ **дважды** на каждой мелкой изменить и загрузить обратно, оплачивая весь документ **дважды** на каждой мелкой
правке. Этот сервер позволяет агенту изменить ровно один блок (`patch_node` / правке. Этот сервер позволяет агенту изменить ровно один блок (`patchNode` /
`insert_node` / `delete_node`), сделать find/replace с сохранением структуры `insertNode` / `deleteNode`), сделать find/replace с сохранением структуры
(`edit_page_text`) или скопировать страницу на стороне сервера (`copy_page_content`) — (`editPageText`) или скопировать страницу на стороне сервера (`copyPageContent`) —
**причём документ ни разу не проходит через модель**. **причём документ ни разу не проходит через модель**.
- **Записи, которые не воюют с редактором.** Наивная запись через REST конфликтует с тем, - **Записи, которые не воюют с редактором.** Наивная запись через REST конфликтует с тем,
@@ -87,12 +87,12 @@ Docmost-MCP не сочетают:
- **Агентоориентированная модель редактирования.** Серверы «под человека» дают «открыть - **Агентоориентированная модель редактирования.** Серверы «под человека» дают «открыть
страницу» и «заменить страницу», потому что это отражает то, как работает человек. Модель страницу» и «заменить страницу», потому что это отражает то, как работает человек. Модель
редактирует лучше, *программируя* правку — адресуя блоки по id, делая find/replace или редактирует лучше, *программируя* правку — адресуя блоки по id, делая find/replace или
передавая трансформ `(doc, ctx) => doc` (`docmost_transform`, с dry-run диффом перед передавая трансформ `(doc, ctx) => doc` (`docmostTransform`, с dry-run диффом перед
коммитом). Этот сервер построен вокруг этого — поэтому у него есть примитивы коммитом). Этот сервер построен вокруг этого — поэтому у него есть примитивы
редактирования, которых у остальных просто нет. редактирования, которых у остальных просто нет.
- **Страховка при редактировании, которой нет у других.** `list_page_history` - **Страховка при редактировании, которой нет у других.** `listPageHistory`
`diff_page_versions``restore_page_version` дают агенту (и вам) полный цикл «посмотреть `diffPageVersions``restorePageVersion` дают агенту (и вам) полный цикл «посмотреть
и откатить». Дифф использует *тот же* конвейер `recreateTransform → ChangeSet → и откатить». Дифф использует *тот же* конвейер `recreateTransform → ChangeSet →
simplifyChanges`, что и встроенный просмотр истории Docmost, так что результат совпадает simplifyChanges`, что и встроенный просмотр истории Docmost, так что результат совпадает
с продуктом. с продуктом.
@@ -113,59 +113,62 @@ Docmost-MCP не сочетают:
### Чтение и поиск ### Чтение и поиск
- **`get_workspace`** — Информация о текущем воркспейсе Docmost. - **`getWorkspace`** — Информация о текущем воркспейсе Docmost.
- **`list_spaces`** — Все пространства воркспейса. - **`listSpaces`** — Все пространства воркспейса.
- **`list_pages`** — Недавние страницы пространства, по убыванию `updatedAt` (по умолчанию - **`listPages`** — Недавние страницы пространства, по убыванию `updatedAt` (по умолчанию
50, максимум 100). Для поиска в больших пространствах используйте `search`. 50, максимум 100). Для поиска в больших пространствах используйте `search`.
- **`search`** — Полнотекстовый поиск по страницам и контенту (ограничен `limit`, максимум - **`search`** — Полнотекстовый поиск по страницам и контенту (ограничен `limit`, максимум
100). 100).
- **`get_page`** — Контент страницы как чистый **Markdown** (удобно, но это - **`getPage`** — Контент страницы как чистый **Markdown** (канонично для текста; теряет
*lossy*-представление — id блоков и точная структура таблиц/коллаутов аппроксимируются). лишь id блоков, якоря разрешённых комментариев и фиксированный набор атрибутов без
- **`get_page_json`** — **Lossless ProseMirror/TipTap JSON** страницы, включая `attrs.id` markdown-представления — спаны/colwidth/фон ячеек таблиц, отступы (indent),
`callout.icon`, `orderedList.type` и `internal`/`target`/`rel`/`class` у ссылок;
используйте `getPageJson`, когда они нужны).
- **`getPageJson`** — **Lossless ProseMirror/TipTap JSON** страницы, включая `attrs.id`
каждого блока и `slugId`, используемый в URL. Именно его потребляют инструменты каждого блока и `slugId`, используемый в URL. Именно его потребляют инструменты
поблочного редактирования. поблочного редактирования.
- **`get_outline`** — Компактная структура страницы из блоков верхнего уровня (`{index, - **`getOutline`** — Компактная структура страницы из блоков верхнего уровня (`{index,
type, id, level, firstText}`; для таблиц добавляются число строк/столбцов и тексты ячеек type, id, level, firstText}`; для таблиц добавляются число строк/столбцов и тексты ячеек
заголовка, для списков — число пунктов) **без** тела документа. Дешёвый способ найти раздел или таблицу и получить заголовка, для списков — число пунктов) **без** тела документа. Дешёвый способ найти раздел или таблицу и получить
id блока перед `get_node` / `patch_node` / `insert_node`. id блока перед `getNode` / `patchNode` / `insertNode`.
- **`get_node`** — Получить полное ProseMirror-поддерево одного блока (lossless), не - **`getNode`** — Получить полное ProseMirror-поддерево одного блока (lossless), не
вытягивая всю страницу. Адресуйте его по id блока (из `get_outline` / `get_page_json`) вытягивая всю страницу. Адресуйте его по id блока (из `getOutline` / `getPageJson`)
или формой `#<index>` для блока верхнего уровня — используйте `#<index>` для или формой `#<index>` для блока верхнего уровня — используйте `#<index>` для
таблиц/строк/ячеек, у которых нет id. таблиц/строк/ячеек, у которых нет id.
### Жизненный цикл страниц ### Жизненный цикл страниц
- **`create_page`** — Создать страницу из Markdown и поместить в иерархию (опционально - **`createPage`** — Создать страницу из Markdown и поместить в иерархию (опционально
`parentPageId`) одним вызовом. Использует import API Docmost для чистой конвертации `parentPageId`) одним вызовом. Использует import API Docmost для чистой конвертации
Markdown→ProseMirror. Markdown→ProseMirror.
- **`rename_page`** — Изменить только заголовок страницы, не трогая и не пересылая контент. - **`renamePage`** — Изменить только заголовок страницы, не трогая и не пересылая контент.
- **`move_page`** — Сменить родителя страницы (вложить или вынести в корень); поддерживает - **`movePage`** — Сменить родителя страницы (вложить или вынести в корень); поддерживает
позиционирование по fractional-index. Возвращает успех только при *положительно позиционирование по fractional-index. Возвращает успех только при *положительно
подтверждённом* результате. подтверждённом* результате.
- **`delete_page`** — Удалить одну страницу. - **`deletePage`** — Удалить одну страницу.
- **`copy_page_content`** — Заменить тело одной страницы копией тела другой, **полностью на - **`copyPageContent`** — Заменить тело одной страницы копией тела другой, **полностью на
стороне сервера** — документ не проходит через модель. У целевой страницы сохраняются стороне сервера** — документ не проходит через модель. У целевой страницы сохраняются
собственные заголовок и slug (URL не меняется). собственные заголовок и slug (URL не меняется).
### Редактирование ### Редактирование
- **`edit_page_text`** — Хирургический find/replace внутри текста страницы. Сохраняет - **`editPageText`** — Хирургический find/replace внутри текста страницы. Сохраняет
**всю** структуру: id блоков, marks, ссылки, коллауты, таблицы. Предпочтительный **всю** структуру: id блоков, marks, ссылки, коллауты, таблицы. Предпочтительный
инструмент для правки формулировок, опечаток, чисел и имён. инструмент для правки формулировок, опечаток, чисел и имён.
- **`patch_node`** — Заменить один блок, адресованный по `attrs.id` (из `get_page_json`), - **`patchNode`** — Заменить один блок, адресованный по `attrs.id` (из `getPageJson`),
без пересылки документа. без пересылки документа.
- **`insert_node`** — Вставить блок до/после другого (по `attrs.id` или по якорному тексту) - **`insertNode`** — Вставить блок до/после другого (по `attrs.id` или по якорному тексту)
либо добавить в конец. либо добавить в конец.
- **`delete_node`** — Удалить один блок по его `attrs.id`. - **`deleteNode`** — Удалить один блок по его `attrs.id`.
- **`update_page_json`** — Заменить весь контент страницы документом ProseMirror (массовые - **`updatePageJson`** — Заменить весь контент страницы документом ProseMirror (массовые
перезаписи или когда у узлов нет id). `content` опционален — опустите его, чтобы изменить перезаписи или когда у узлов нет id). `content` опционален — опустите его, чтобы изменить
только заголовок. Сохраняет переданные id блоков, поэтому якоря заголовков и история только заголовок. Сохраняет переданные id блоков, поэтому якоря заголовков и история
остаются стабильными. остаются стабильными.
- **`update_page_markdown`** — Заменить тело страницы (и опционально заголовок) новым - **`updatePageMarkdown`** — Заменить тело страницы (и опционально заголовок) новым
**обычным Markdown**. Всё тело переимпортируется (id блоков перегенерируются — для **обычным Markdown**. Всё тело переимпортируется (id блоков перегенерируются — для
хирургических правок или сохранения id используйте `edit_page_text` / `patch_node` / хирургических правок или сохранения id используйте `editPageText` / `patchNode` /
`update_page_json`). Markdown в диалекте Docmost разбирается, включая inline-сноски `^[...]`. `updatePageJson`). Markdown в диалекте Docmost разбирается, включая inline-сноски `^[...]`.
- **`docmost_transform`** — Агентоориентированный интерфейс редактирования: вместо - **`docmostTransform`** — Агентоориентированный интерфейс редактирования: вместо
перепечатывания документа агент **пишет функцию, которая его чинит**. Редактирует перепечатывания документа агент **пишет функцию, которая его чинит**. Редактирует
страницу, запуская произвольный **JS-трансформ `(doc, ctx) => doc`** на её *живом* страницу, запуская произвольный **JS-трансформ `(doc, ctx) => doc`** на её *живом*
документе ProseMirror. Работает в **песочнице** (без `require`/`process`/`fs`/сети, документе ProseMirror. Работает в **песочнице** (без `require`/`process`/`fs`/сети,
@@ -177,42 +180,46 @@ Docmost-MCP не сочетают:
### Таблицы ### Таблицы
- **`table_get`** — Прочитать таблицу как матрицу: `{rows, cols, cells (text[][]), - **`tableGet`** — Прочитать таблицу как матрицу: `{rows, cols, cells (text[][]),
cellIds}` (id абзаца на ячейку или `null`). Адресуйте таблицу через `#<index>` (из cellIds}` (id абзаца на ячейку или `null`). Адресуйте таблицу через `#<index>` (из
`get_outline`) или любой id блока внутри неё. Используйте `cellIds` вместе с `patch_node` `getOutline`) или любой id блока внутри неё. Используйте `cellIds` вместе с `patchNode`
для правок ячеек с форматированием. для правок ячеек с форматированием.
- **`table_insert_row`** — Вставить строку из текстовых ячеек, дополненную до числа - **`tableInsertRow`** — Вставить строку из текстовых ячеек, дополненную до числа
столбцов таблицы (передать ячеек больше числа столбцов — ошибка). `index` — 0-based столбцов таблицы (передать ячеек больше числа столбцов — ошибка). `index` — 0-based
позиция вставки (0 вставляет перед заголовком); опустите, чтобы добавить в конец. позиция вставки (0 вставляет перед заголовком); опустите, чтобы добавить в конец.
- **`table_delete_row`** — Удалить строку по 0-based `index`. Отказывается удалять - **`tableDeleteRow`** — Удалить строку по 0-based `index`. Отказывается удалять
единственную строку таблицы; удаление строки 0 делает заголовком следующую строку. единственную строку таблицы; удаление строки 0 делает заголовком следующую строку.
- **`table_update_cell`** — Задать текстовое содержимое ячейки `[row, col]` (0-based). Для - **`tableUpdateCell`** — Задать текстовое содержимое ячейки `[row, col]` (0-based). Для
форматирования используйте `patch_node` по id абзаца ячейки из `table_get`. форматирования используйте `patchNode` по id абзаца ячейки из `tableGet`.
### Markdown: экспорт и импорт ### Markdown: экспорт и импорт
- **`export_page_markdown`** — Экспортировать страницу в один самодостаточный, **lossless - **`exportPageMarkdown`** — Экспортировать страницу в один самодостаточный
Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и **Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и
диаграммами и завершающий блок тредов комментариев. Чтобы заменить тело страницы из диаграммами и завершающий блок тредов комментариев. Round-trip скачать → отредактировать →
обычного авторского Markdown, используйте `update_page_markdown`. импортировать перегенерирует id блоков и **молча отбрасывает** набор атрибутов без
markdown-представления (спаны/colwidth/фон ячеек таблиц, отступы (indent), `callout.icon`,
`orderedList.type`, `internal`/`target`/`rel`/`class` у ссылок); держите их в ProseMirror
JSON, если они должны выжить. Чтобы заменить тело страницы из обычного авторского Markdown,
используйте `updatePageMarkdown`.
> **Удалено в этом релизе:** `import_page_markdown` (парсер round-trip для > **Удалено в этом релизе:** `importPageMarkdown` (парсер round-trip для
> экспортированного Docmost-Markdown-файла) **больше не отдаётся на внешней MCP-поверхности**. > экспортированного Docmost-Markdown-файла) **больше не отдаётся на внешней MCP-поверхности**.
> Чтобы заменить тело страницы из Markdown, используйте **`update_page_markdown`** (замена > Чтобы заменить тело страницы из Markdown, используйте **`updatePageMarkdown`** (замена
> тела обычным Markdown). См. заметку о миграции в CHANGELOG. > тела обычным Markdown). См. заметку о миграции в CHANGELOG.
### Изображения ### Изображения
- **`insert_image`** — Загрузить локальное изображение и вставить за один шаг: добавить в - **`insertImage`** — Загрузить локальное изображение и вставить за один шаг: добавить в
конец, поставить вместо текстового плейсхолдера (`replaceText`) или после заданного блока конец, поставить вместо текстового плейсхолдера (`replaceText`) или после заданного блока
(`afterText`). Сохраняет id всех остальных блоков. (`afterText`). Сохраняет id всех остальных блоков.
- **`replace_image`** — Заменить существующее изображение. Загружает новый файл как **новое - **`replaceImage`** — Заменить существующее изображение. Загружает новый файл как **новое
вложение** (чистый URL, который рендерится и сбрасывает кэш браузера), затем вложение** (чистый URL, который рендерится и сбрасывает кэш браузера), затем
перенаправляет все узлы, ссылавшиеся на старое вложение (рекурсивно, включая перенаправляет все узлы, ссылавшиеся на старое вложение (рекурсивно, включая
коллауты/таблицы), через живой документ, сохраняя комментарии, выравнивание и alt-текст. коллауты/таблицы), через живой документ, сохраняя комментарии, выравнивание и alt-текст.
(Перезапись «по месту» намеренно не используется — некоторые версии Docmost портят (Перезапись «по месту» намеренно не используется — некоторые версии Docmost портят
вложение при перезаписи.) вложение при перезаписи.)
- **`stash_page`** — Сериализовать страницу целиком (её полный ProseMirror JSON) в - **`stashPage`** — Сериализовать страницу целиком (её полный ProseMirror JSON) в
эфемерный blob в оперативной памяти и вернуть ТОЛЬКО короткий анонимный URL — тело эфемерный blob в оперативной памяти и вернуть ТОЛЬКО короткий анонимный URL — тело
никогда не попадает в контекст модели, поэтому это способ передать большую страницу никогда не попадает в контекст модели, поэтому это способ передать большую страницу
(вместе с её изображениями) внешнему потребителю без усечения. Каждое внутреннее (вместе с её изображениями) внешнему потребителю без усечения. Каждое внутреннее
@@ -224,35 +231,35 @@ Docmost-MCP не сочетают:
### Комментарии ### Комментарии
- **`create_comment`** — Добавить комментарий к странице, опционально **привязав inline** к - **`createComment`** — Добавить комментарий к странице, опционально **привязав inline** к
точному фрагменту текста (первое вхождение оборачивается comment-маркой). точному фрагменту текста (первое вхождение оборачивается comment-маркой).
- **`list_comments`** — Список комментариев страницы (контент возвращается как Markdown). - **`listComments`** — Список комментариев страницы (контент возвращается как Markdown).
- **`update_comment`** — Изменить существующий комментарий. - **`updateComment`** — Изменить существующий комментарий.
- **`delete_comment`** — Удалить комментарий. - **`deleteComment`** — Удалить комментарий.
- **`resolve_comment`** — Закрыть (resolve) или переоткрыть тред комментария (обратимо). Resolve - **`resolveComment`** — Закрыть (resolve) или переоткрыть тред комментария (обратимо). Resolve
доступен только для корневых комментариев; тред и ответы сохраняются, в отличие от `delete_comment`. доступен только для корневых комментариев; тред и ответы сохраняются, в отличие от `deleteComment`.
- **`check_new_comments`** — Найти комментарии, созданные после заданной метки времени - **`checkNewComments`** — Найти комментарии, созданные после заданной метки времени
ISO-8601, по пространству, опционально в рамках поддерева страниц — идеально для агента, ISO-8601, по пространству, опционально в рамках поддерева страниц — идеально для агента,
который следит за обратной связью в документе. который следит за обратной связью в документе.
### Версии и история ### Версии и история
- **`list_page_history`** — Сохранённые версии страницы (Docmost авто-снапшотит при каждом - **`listPageHistory`** — Сохранённые версии страницы (Docmost авто-снапшотит при каждом
сохранении), новые сверху, курсорная пагинация. id каждого элемента — это `historyId`. сохранении), новые сверху, курсорная пагинация. id каждого элемента — это `historyId`.
- **`diff_page_versions`** — Дифф двух версий (или версии против живой страницы). - **`diffPageVersions`** — Дифф двух версий (или версии против живой страницы).
Возвращает вставленный/удалённый текст, счётчики целостности (изображения, ссылки, Возвращает вставленный/удалённый текст, счётчики целостности (изображения, ссылки,
таблицы, коллауты, маркеры сносок) и человекочитаемую Markdown-сводку — посчитано тем же таблицы, коллауты, маркеры сносок) и человекочитаемую Markdown-сводку — посчитано тем же
конвейером, что использует встроенный просмотр истории Docmost. конвейером, что использует встроенный просмотр истории Docmost.
- **`restore_page_version`** — Записать сохранённую версию обратно как текущий контент. У - **`restorePageVersion`** — Записать сохранённую версию обратно как текущий контент. У
Docmost нет эндпоинта восстановления, поэтому создаётся **новый** снапшот — само Docmost нет эндпоинта восстановления, поэтому создаётся **новый** снапшот — само
восстановление тоже обратимо. восстановление тоже обратимо.
### Публикация ### Публикация
- **`share_page`** — Сделать страницу публично доступной (идемпотентно) и вернуть её - **`sharePage`** — Сделать страницу публично доступной (идемпотентно) и вернуть её
публичный URL (`<app>/share/<key>/p/<slugId>`); опционально индексирование поисковиками. публичный URL (`<app>/share/<key>/p/<slugId>`); опционально индексирование поисковиками.
- **`unshare_page`** — Отозвать публичный доступ к странице. - **`unsharePage`** — Отозвать публичный доступ к странице.
- **`list_shares`** — Все публичные ссылки воркспейса с заголовками и публичными URL. - **`listShares`** — Все публичные ссылки воркспейса с заголовками и публичными URL.
--- ---
@@ -261,29 +268,29 @@ Docmost-MCP не сочетают:
Та же подсказка отдаётся в рантайме через поле `instructions` MCP-сервера, так что Та же подсказка отдаётся в рантайме через поле `instructions` MCP-сервера, так что
подходящие клиенты направляют модель автоматически. подходящие клиенты направляют модель автоматически.
- **Правки текста** (формулировки, опечатки, числа): `edit_page_text`. - **Правки текста** (формулировки, опечатки, числа): `editPageText`.
- **Один блок** (абзац/заголовок/коллаут/ячейка таблицы): `patch_node` / `insert_node` / - **Один блок** (абзац/заголовок/коллаут/ячейка таблицы): `patchNode` / `insertNode` /
`delete_node`, адресуя узел по его `attrs.id` из `get_page_json`. `deleteNode`, адресуя узел по его `attrs.id` из `getPageJson`.
- **Изображения**: `insert_image` / `replace_image`. - **Изображения**: `insertImage` / `replaceImage`.
- **Новая страница**: `create_page`. - **Новая страница**: `createPage`.
- **Массовая перезапись или узлы без id**: `update_page_json` (ProseMirror) или - **Массовая перезапись или узлы без id**: `updatePageJson` (ProseMirror) или
`update_page_markdown` (замена тела обычным Markdown). `updatePageMarkdown` (замена тела обычным Markdown).
- **Многошаговая / скриптовая перезапись** (перенумерация, сноски, согласованные правки): - **Многошаговая / скриптовая перезапись** (перенумерация, сноски, согласованные правки):
`docmost_transform` — предпросмотр через `dryRun`, затем применение. `docmostTransform` — предпросмотр через `dryRun`, затем применение.
- **Скопировать контент целой страницы из другой** (на стороне сервера): - **Скопировать контент целой страницы из другой** (на стороне сервера):
`copy_page_content`. `copyPageContent`.
- **Переименовать страницу** (только заголовок): `rename_page`. - **Переименовать страницу** (только заголовок): `renamePage`.
- **Чтение**: `get_page` (Markdown) / `get_page_json` (lossless ProseMirror с id). - **Чтение**: `getPage` (Markdown) / `getPageJson` (lossless ProseMirror с id).
- **Просмотр изменений**: `list_page_history``diff_page_versions` - **Просмотр изменений**: `listPageHistory``diffPageVersions`
`restore_page_version`. `restorePageVersion`.
- **Комментарии**: `create_comment` (с опциональной inline-привязкой) / `list_comments` / - **Комментарии**: `createComment` (с опциональной inline-привязкой) / `listComments` /
`update_comment` / `resolve_comment` / `delete_comment` / `check_new_comments`. `updateComment` / `resolveComment` / `deleteComment` / `checkNewComments`.
- **Дешёвая навигация по странице** (найти раздел/таблицу, получить id блока): `get_outline` - **Дешёвая навигация по странице** (найти раздел/таблицу, получить id блока): `getOutline`
`get_node`. `getNode`.
- **Таблицы** (добавить/удалить строку, задать ячейку): `table_get` / `table_insert_row` / - **Таблицы** (добавить/удалить строку, задать ячейку): `tableGet` / `tableInsertRow` /
`table_delete_row` / `table_update_cell`. `tableDeleteRow` / `tableUpdateCell`.
- **Экспорт страницы в самодостаточный Markdown** (с якорями комментариев): `export_page_markdown`. - **Экспорт страницы в самодостаточный Markdown** (с якорями комментариев): `exportPageMarkdown`.
- **Заменить тело страницы из Markdown**: `update_page_markdown`. - **Заменить тело страницы из Markdown**: `updatePageMarkdown`.
--- ---
@@ -302,21 +309,24 @@ Docmost-MCP не сочетают:
автоматически на первом 401/403 (покрывая JSON, multipart-загрузку и путь токена автоматически на первом 401/403 (покрывая JSON, multipart-загрузку и путь токена
коллаборации), с дедупликацией параллельных логинов, так что пачка вызовов вызывает один коллаборации), с дедупликацией параллельных логинов, так что пачка вызовов вызывает один
повторный логин. повторный логин.
- **Lossless- и lossy-чтение.** `get_page_json` возвращает точное дерево ProseMirror с id - **Точные чтения.** `getPageJson` возвращает точное дерево ProseMirror с id блоков;
блоков; `get_page` возвращает чистый Markdown для удобства. `getPage` возвращает канонический Markdown, теряющий лишь фиксированный, документированный
набор атрибутов.
- **Полная схема Docmost.** Конвертация Markdown↔ProseMirror поддерживает коллауты - **Полная схема Docmost.** Конвертация Markdown↔ProseMirror поддерживает коллауты
(включая вложенные), списки задач (маркированные *и* нумерованные чек-листы), таблицы, (включая вложенные), списки задач (маркированные *и* нумерованные чек-листы), таблицы,
блоки формул, эмбеды, выделение, под/надстрочный текст и прочее, с защитными лимитами блоки формул, эмбеды, выделение, под/надстрочный текст и прочее, с защитными лимитами
против патологического ввода. против патологического ввода.
- **Структурные таблицы и lossless Markdown round-trip.** Таблицы можно редактировать как - **Структурные таблицы и Markdown round-trip.** Таблицы можно редактировать как
матрицу (чтение, вставка/удаление строк, задание ячеек по `[row, col]`) без пересылки матрицу (чтение, вставка/удаление строк, задание ячеек по `[row, col]`) без пересылки
документа, а страницу — экспортировать и заново импортировать как самодостаточный документа, а страницу — экспортировать и заново импортировать как самодостаточный
Markdown-файл в диалекте Docmost, сохраняющий inline-якоря комментариев и диаграммы. Markdown-файл в диалекте Docmost, сохраняющий inline-якоря комментариев и диаграммы
(id блоков перегенерируются, а фиксированный набор атрибутов без markdown-представления
отбрасывается — см. `exportPageMarkdown`).
- **Ответы, оптимизированные по токенам.** Ответы API урезаются до полей, действительно - **Ответы, оптимизированные по токенам.** Ответы API урезаются до полей, действительно
нужных агентам, а большие коллекции (пространства, страницы, комментарии, история) нужных агентам, а большие коллекции (пространства, страницы, комментарии, история)
пагинируются. пагинируются.
- **Закалённый рантайм.** Глобальные обработчики не дают случайной ошибке сокета уронить - **Закалённый рантайм.** Глобальные обработчики не дают случайной ошибке сокета уронить
stdio-сервер; `move_page` требует положительно подтверждённого успеха; движок диффа stdio-сервер; `movePage` требует положительно подтверждённого успеха; движок диффа
откатывается к грубому поблочному диффу, а не падает на патологическом документе. откатывается к грубому поблочному диффу, а не падает на патологическом документе.
--- ---
@@ -376,7 +386,7 @@ npm run test:e2e
Проект начинался как форк Проект начинался как форк
[MrMartiniMo/docmost-mcp](https://github.com/MrMartiniMo/docmost-mcp) (автор Moritz Krause) [MrMartiniMo/docmost-mcp](https://github.com/MrMartiniMo/docmost-mcp) (автор Moritz Krause)
и существенно его расширяет — добавлены поблочное редактирование узлов, хирургические и существенно его расширяет — добавлены поблочное редактирование узлов, хирургические
правки текста, песочница `docmost_transform`, история версий / дифф / восстановление, правки текста, песочница `docmostTransform`, история версий / дифф / восстановление,
комментарии, вставка/замена изображений, публичные ссылки, серверное копирование страниц, комментарии, вставка/замена изображений, публичные ссылки, серверное копирование страниц,
двойное чтение JSON/Markdown, прозрачная переавторизация и значительное упрочнение. двойное чтение JSON/Markdown, прозрачная переавторизация и значительное упрочнение.
Инструменты комментариев портированы из upstream PR #3 от Max Nikitin. Спасибо обоим. Инструменты комментариев портированы из upstream PR #3 от Max Nikitin. Спасибо обоим.
+10 -10
View File
@@ -22,14 +22,14 @@ are debounced server-side, so the script waits ~16 s before reading back via RES
| # | Tool / path | What is checked | Expected | | # | Tool / path | What is checked | Expected |
|---|-------------|-----------------|----------| |---|-------------|-----------------|----------|
| 1 | `create_page` | title with spaces, slugId returned | page created, title intact | | 1 | `createPage` | title with spaces, slugId returned | page created, title intact |
| 2 | `update_page` (markdown) | headings, **bold**/*italic*/~~strike~~/`code`/link, nested bullet + ordered lists, blockquote, code block, `:::callout:::`, table | all structures survive re-import | | 2 | `update_page` (markdown) | headings, **bold**/*italic*/~~strike~~/`code`/link, nested bullet + ordered lists, blockquote, code block, `:::callout:::`, table | all structures survive re-import |
| 3 | `get_page_json` | lossless ProseMirror, block ids, callout/table nodes | present (note: reads the **debounced** REST snapshot — recent collab writes may lag a few seconds) | | 3 | `getPageJson` | lossless ProseMirror, block ids, callout/table nodes | present (note: reads the **debounced** REST snapshot — recent collab writes may lag a few seconds) |
| 4 | `edit_page_text` | surgical replace; block ids + marks preserved; ambiguous match rejected; missing match reported | edits applied, ids stable, errors correct | | 4 | `editPageText` | surgical replace; block ids + marks preserved; ambiguous match rejected; missing match reported | edits applied, ids stable, errors correct |
| 5 | `update_page_json` | full lossless write; custom block ids preserved; existing content (text edits, images, callout, table) not lost | round-trips intact | | 5 | `updatePageJson` | full lossless write; custom block ids preserved; existing content (text edits, images, callout, table) not lost | round-trips intact |
| 6 | `upload_image` | uploads attachment, returns node | src is a **clean** `/api/files/<id>/<file>` URL, served `200 image/*` | | 6 | `upload_image` | uploads attachment, returns node | src is a **clean** `/api/files/<id>/<file>` URL, served `200 image/*` |
| 7 | `insert_image` (append / `replaceText` / `afterText`) | three placements | image lands in the right place, all other block ids preserved | | 7 | `insertImage` (append / `replaceText` / `afterText`) | three placements | image lands in the right place, all other block ids preserved |
| 8 | **`replace_image`** | swap an existing figure for new bytes; comments/align/alt preserved; **the new URL must actually serve the image** | new image renders (`200`), old node repointed | | 8 | **`replaceImage`** | swap an existing figure for new bytes; comments/align/alt preserved; **the new URL must actually serve the image** | new image renders (`200`), old node repointed |
## Image-specific assertions (the recurring bug area) ## Image-specific assertions (the recurring bug area)
@@ -39,7 +39,7 @@ For every uploaded/inserted/replaced image, assert at the HTTP level that the
* `GET <src>``200`, `Content-Type: image/*`, body starts with the image magic * `GET <src>``200`, `Content-Type: image/*`, body starts with the image magic
(`89 50 4E 47` for PNG, etc.). (`89 50 4E 47` for PNG, etc.).
* `src` does **not** contain a `?v=` query (see "Known pitfalls"). * `src` does **not** contain a `?v=` query (see "Known pitfalls").
* After `replace_image`: the returned `newAttachmentId` **differs** from the old * After `replaceImage`: the returned `newAttachmentId` **differs** from the old
one (replacement uses a fresh attachment → fresh URL), and `GET <new src>``200`. one (replacement uses a fresh attachment → fresh URL), and `GET <new src>``200`.
* The old image node on the page is repointed to the new attachmentId. * The old image node on the page is repointed to the new attachmentId.
@@ -64,7 +64,7 @@ broken/empty figure.
Uploading with an existing `attachmentId` (`POST /files/upload` + `attachmentId`) Uploading with an existing `attachmentId` (`POST /files/upload` + `attachmentId`)
overwrites the bytes in place. On this Docmost the attachment then returns overwrites the bytes in place. On this Docmost the attachment then returns
**500 for every URL** (clean, `?v=`, any filename) → broken image. Therefore **500 for every URL** (clean, `?v=`, any filename) → broken image. Therefore
`replace_image` must upload a **new** attachment and repoint the nodes; the new `replaceImage` must upload a **new** attachment and repoint the nodes; the new
id yields a new URL that both renders and busts the browser cache. The old id yields a new URL that both renders and busts the browser cache. The old
attachment is left as an unreferenced orphan: Docmost exposes **no HTTP API to attachment is left as an unreferenced orphan: Docmost exposes **no HTTP API to
delete a single content attachment** (verified against the attachment delete a single content attachment** (verified against the attachment
@@ -80,9 +80,9 @@ broken/empty figure.
from `?v=`. Image `src` is kept clean (`/api/files/<id>/<file>`); cache-busting from `?v=`. Image `src` is kept clean (`/api/files/<id>/<file>`); cache-busting
on replace is achieved by the new attachment id. on replace is achieved by the new attachment id.
3. **REST snapshot lag.** `get_page_json` reads the debounced DB snapshot, so a 3. **REST snapshot lag.** `getPageJson` reads the debounced DB snapshot, so a
write made moments earlier may not be visible yet. Wait (~16 s) before reading write made moments earlier may not be visible yet. Wait (~16 s) before reading
back, and never feed a possibly-stale snapshot straight into `update_page_json`. back, and never feed a possibly-stale snapshot straight into `updatePageJson`.
4. **Callout type narrowing (minor, open).** A `:::warning` callout is imported as 4. **Callout type narrowing (minor, open).** A `:::warning` callout is imported as
`type: "info"` — the markdown→callout conversion does not carry non-`info` `type: "info"` — the markdown→callout conversion does not carry non-`info`
+494 -122
View File
File diff suppressed because it is too large Load Diff
+4 -7
View File
@@ -57,17 +57,14 @@ export const DEFAULT_COMMENT_SIGNAL_DEBOUNCE_MS = 20_000;
/** /**
* Tools whose OWN result must NOT carry the signal it would be tautological * Tools whose OWN result must NOT carry the signal it would be tautological
* (the agent is already looking at comments) and noisy. Listed in BOTH the * (the agent is already looking at comments) and noisy. Since issue #412 both
* standalone MCP snake_case names AND the in-app camelCase keys so a single set * the standalone MCP surface and the in-app agent use the same camelCase tool
* covers both surfaces (the signal text itself uses the camelCase `listComments` * names, so a single set of camelCase names covers both surfaces. `getComment`
* per roadmap #412). `getComment` (single fetch) is intentionally NOT excluded. * (single fetch) is intentionally NOT excluded.
*/ */
export const COMMENT_SIGNAL_EXCLUDED_TOOLS: ReadonlySet<string> = new Set([ export const COMMENT_SIGNAL_EXCLUDED_TOOLS: ReadonlySet<string> = new Set([
"list_comments",
"listComments", "listComments",
"check_new_comments",
"checkNewComments", "checkNewComments",
"create_comment",
"createComment", "createComment",
]); ]);
+63 -30
View File
@@ -58,7 +58,7 @@ export type {
CommentSignalTrackerOptions, CommentSignalTrackerOptions,
} from "./comment-signal.js"; } from "./comment-signal.js";
// Re-export the pure, no-network draw.io helpers (#424) so the in-app AI-SDK // Re-export the pure, no-network draw.io helpers (#424) so the in-app AI-SDK
// service can wire drawio_shapes / drawio_guide off the loaded module. These are // service can wire drawioShapes / drawioGuide off the loaded module. These are
// NOT client methods (no page/backend hit) — the in-app handler calls them // NOT client methods (no page/backend hit) — the in-app handler calls them
// directly, mirroring how the standalone MCP server wires them here. // directly, mirroring how the standalone MCP server wires them here.
export { searchShapes } from "./lib/drawio-shapes.js"; export { searchShapes } from "./lib/drawio-shapes.js";
@@ -89,7 +89,7 @@ const VERSION = packageJson.version;
// (SHARED_TOOL_SPECS + INLINE_MCP_INVENTORY), so it can no longer drift out of // (SHARED_TOOL_SPECS + INLINE_MCP_INVENTORY), so it can no longer drift out of
// sync with the registered tools. Re-exported here (its old home) so existing // sync with the registered tools. Re-exported here (its old home) so existing
// importers are unaffected; the composition lives in server-instructions.ts. // importers are unaffected; the composition lives in server-instructions.ts.
// The drawio_shapes / drawio_guide tools (#424) stay in SHARED_TOOL_SPECS (so the // The drawioShapes / drawioGuide tools (#424) stay in SHARED_TOOL_SPECS (so the
// generated <tool_inventory> picks them up from their catalogLine automatically) // generated <tool_inventory> picks them up from their catalogLine automatically)
// but are flagged `inlineBothHosts` and registered inline below (their pure // but are flagged `inlineBothHosts` and registered inline below (their pure
// helpers can't cross into tool-specs.ts); only the hand-written routing prose in // helpers can't cross into tool-specs.ts); only the hand-written routing prose in
@@ -286,7 +286,7 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
// the wrapping is typed loosely and cast — runtime behaviour is unchanged. // the wrapping is typed loosely and cast — runtime behaviour is unchanged.
const registerSharedFromSpec = (spec: SharedToolSpec) => { const registerSharedFromSpec = (spec: SharedToolSpec) => {
if (spec.inAppOnly) return; if (spec.inAppOnly) return;
// `inlineBothHosts` specs (drawio_shapes / drawio_guide) carry no execute — // `inlineBothHosts` specs (drawioShapes / drawioGuide) carry no execute —
// their pure helper cannot cross into the zod-agnostic tool-specs.ts, so they // their pure helper cannot cross into the zod-agnostic tool-specs.ts, so they
// are registered INLINE below (searchShapes / getGuideSection). Skip them here // are registered INLINE below (searchShapes / getGuideSection). Skip them here
// so the loop never dereferences a missing `execute`. // so the loop never dereferences a missing `execute`.
@@ -316,7 +316,7 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
} }
// --- INLINE drawio helper tools (IN the shared registry, but inlineBothHosts) --- // --- INLINE drawio helper tools (IN the shared registry, but inlineBothHosts) ---
// drawio_shapes / drawio_guide (#424) live in SHARED_TOOL_SPECS (so the shared // drawioShapes / drawioGuide (#424) live in SHARED_TOOL_SPECS (so the shared
// contract pins their name/description/schema across both hosts) but carry the // contract pins their name/description/schema across both hosts) but carry the
// `inlineBothHosts` flag and NO execute: their pure backing helpers // `inlineBothHosts` flag and NO execute: their pure backing helpers
// (searchShapes / getGuideSection) cannot be value-imported into the // (searchShapes / getGuideSection) cannot be value-imported into the
@@ -356,25 +356,25 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
// --- INLINE tools kept per-transport (NOT in the shared registry) --- // --- INLINE tools kept per-transport (NOT in the shared registry) ---
// Each stays inline for a documented reason: a snake_case/camelCase naming // Each stays inline for a documented reason: a snake_case/camelCase naming
// clash the registry convention forbids (table_get), an intentional // clash the registry convention forbids (tableGet), an intentional
// per-transport behaviour/schema divergence (search, docmost_transform), or a // per-transport behaviour/schema divergence (search, docmostTransform), or a
// tool that exists ONLY on this standalone MCP surface (update_comment, // tool that exists ONLY on this standalone MCP surface (updateComment,
// delete_comment — the in-app agent deliberately exposes no hard comment // deleteComment — the in-app agent deliberately exposes no hard comment
// edit/delete tool). // edit/delete tool).
// Tool: table_get // Tool: tableGet
// NOT in the shared registry: the MCP tool name `table_get` is noun-first while // NOT in the shared registry: the MCP tool name `tableGet` is noun-first while
// the in-app key is `getTable` (verb-first), breaking the snake_case(inAppKey) // the in-app key is `getTable` (verb-first), breaking the snake_case(inAppKey)
// convention the shared registry enforces (shared-tool-specs.contract.spec.ts). // convention the shared registry enforces (shared-tool-specs.contract.spec.ts).
// Renaming the public MCP tool would break external clients, so it stays inline. // Renaming the public MCP tool would break external clients, so it stays inline.
server.registerTool( server.registerTool(
"table_get", "tableGet",
{ {
description: description:
"Read a table as a matrix. Returns {rows, cols, cells (text[][]), " + "Read a table as a matrix. Returns {rows, cols, cells (text[][]), " +
"cellIds (paragraph id per cell, or null)}. `table` = `#<index>` from " + "cellIds (paragraph id per cell, or null)}. `table` = `#<index>` from " +
"get_outline, or any block id inside the table. Use cellIds with " + "getOutline, or any block id inside the table. Use cellIds with " +
"patch_node for rich-formatted cell edits. `cols` is the FIRST row's " + "patchNode for rich-formatted cell edits. `cols` is the FIRST row's " +
"width; ragged tables may vary per row, so use the per-row length of " + "width; ragged tables may vary per row, so use the per-row length of " +
"`cells` for each row.", "`cells` for each row.",
inputSchema: { inputSchema: {
@@ -388,9 +388,9 @@ server.registerTool(
}, },
); );
// Tool: update_comment // Tool: updateComment
server.registerTool( server.registerTool(
"update_comment", "updateComment",
{ {
description: description:
"Update an existing comment's content. Only the comment creator can " + "Update an existing comment's content. Only the comment creator can " +
@@ -409,9 +409,9 @@ server.registerTool(
}, },
); );
// Tool: delete_comment // Tool: deleteComment
server.registerTool( server.registerTool(
"delete_comment", "deleteComment",
{ {
description: description:
"Delete a comment. Only the comment creator or space admin can delete it.", "Delete a comment. Only the comment creator or space admin can delete it.",
@@ -435,41 +435,74 @@ server.registerTool(
// Tool: search // Tool: search
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages` // INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
// runs a semantic + keyword hybrid (RRF) with in-process access control and a // runs a semantic + keyword hybrid (RRF) with in-process access control and a
// different schema (limit 1-20); this transport is a plain REST full-text search // different schema; this transport is the #443 agent-lookup search — a hybrid
// (limit up to 100). Different behaviour AND schema, so kept per-layer. // substring + full-text search that also returns each hit's location (`path`)
// and a windowed `snippet`, so one call answers "where is it and what's in it".
// The in-app hybrid-RRF search is deliberately NOT touched. Different behaviour
// AND schema, so kept per-layer.
//
// STANDALONE-vs-STOCK-UPSTREAM: the client sends the opt-in `substring`/
// `parentPageId`/`titleOnly` DTO fields. A stock upstream server validates the
// DTO with `whitelist: true` and silently strips these unknown fields, so the
// request degrades gracefully to plain FTS (no path/snippet, current shape).
//
// EE/TYPESENSE DEGRADATION (#443): on an instance whose SEARCH_DRIVER is
// `typesense`, the server routes this request to the Typesense backend, which
// does NOT implement agent-lookup — the substring/path/snippet/tiering is
// ignored and the response degrades to plain Typesense FTS. The rich lookup
// shape is only produced by the native Postgres search driver.
server.registerTool( server.registerTool(
"search", "search",
{ {
description: description:
"Full-text search for pages and content across the whole workspace. " + "Find pages by a fragment of a technical string (hostnames, IPs, IDs " +
"Results are bounded by `limit` (1-100; when omitted the server applies " + "like `srv.local`, `10.0.12`, `WB-MGE-30D86B`) — one call returns each " +
"its own default).", "hit's location (`path`: ancestor titles root→parent) and a `snippet` " +
"around the first match, so you rarely need a follow-up get_page. " +
"Matches substrings literally (dots/dashes/digits are not tokenized) as " +
"well as full-text. Returns `{ pageId, title, path, snippet, score }` " +
"sorted by `score` (a per-response relevance float).",
inputSchema: { inputSchema: {
query: z.string().min(1).describe("Search query"), query: z.string().min(1).describe("Search query"),
spaceId: z
.string()
.optional()
.describe("Restrict the search to a single space"),
parentPageId: z
.string()
.optional()
.describe(
"Restrict to a page and all its descendants (the page itself included)",
),
titleOnly: z
.boolean()
.optional()
.describe("Match page titles only; skip page text"),
limit: z limit: z
.number() .number()
.int() .int()
.min(1) .min(1)
.max(100) .max(50)
.optional() .optional()
.describe("Max results to return (max 100)"), .describe("Max results to return (1-50, default 10)"),
}, },
}, },
async ({ query, limit }) => { async ({ query, spaceId, parentPageId, titleOnly, limit }) => {
// The tool exposes no spaceId filter, so pass undefined for the client's const result = await docmostClient.search(query, spaceId, limit, {
// optional spaceId parameter and forward limit into its correct slot. parentPageId,
const result = await docmostClient.search(query, undefined, limit); titleOnly,
});
return jsonContent(result); return jsonContent(result);
}, },
); );
// Tool: docmost_transform // Tool: docmostTransform
// INTENTIONAL per-transport divergence (not shared): the in-app `transformPage` // INTENTIONAL per-transport divergence (not shared): the in-app `transformPage`
// deliberately omits the `deleteComments` schema field (comment-deletion // deliberately omits the `deleteComments` schema field (comment-deletion
// guardrail) and carries a much shorter description; this transport exposes the // guardrail) and carries a much shorter description; this transport exposes the
// full helper catalogue. Different schema, so kept per-layer. // full helper catalogue. Different schema, so kept per-layer.
server.registerTool( server.registerTool(
"docmost_transform", "docmostTransform",
{ {
description: description:
"Edit a page by running an arbitrary JS transform `(doc, ctx) => doc` " + "Edit a page by running an arbitrary JS transform `(doc, ctx) => doc` " +
+3 -3
View File
@@ -87,8 +87,8 @@ global.WebSocket = WebSocket;
* bodies merged. So the import output is ALREADY in canonical footnote * bodies merged. So the import output is ALREADY in canonical footnote
* topology. * topology.
* - `canonicalizeFootnotes` runs AFTER as the mcp write-path invariant shared * - `canonicalizeFootnotes` runs AFTER as the mcp write-path invariant shared
* with every other full-document persist path (`update_page_json`, * with every other full-document persist path (`updatePageJson`,
* `docmost_transform`, `insert_footnote`, ). Because the package output is * `docmostTransform`, `insertFootnote`, ). Because the package output is
* already canonical, this layer is a no-op here (idempotent) it exists so * already canonical, this layer is a no-op here (idempotent) it exists so
* the page-write contract is enforced uniformly regardless of how the PM doc * the page-write contract is enforced uniformly regardless of how the PM doc
* was produced, not because the import needs fixing. * was produced, not because the import needs fixing.
@@ -282,7 +282,7 @@ export async function mutatePageContent(
* it was produced from markdown (ids regenerate) or edited in place * it was produced from markdown (ids regenerate) or edited in place
* (existing block ids preserved). * (existing block ids preserved).
* *
* This is an intentional full replace (used by update_page / update_page_json), * This is an intentional full replace (used by update_page / updatePageJson),
* but now runs under the per-page lock and waits for server persistence via * but now runs under the per-page lock and waits for server persistence via
* mutatePageContent. * mutatePageContent.
*/ */
+1 -1
View File
@@ -20,7 +20,7 @@
* *
* MARKDOWN-STRIP FALLBACK: when the agent copies a selection that still carries * MARKDOWN-STRIP FALLBACK: when the agent copies a selection that still carries
* inline markdown (`**bold**`, `` `code` ``, `[t](u)`), the raw locator will not * inline markdown (`**bold**`, `` `code` ``, `[t](u)`), the raw locator will not
* match the document's plain text. Exactly like edit_page_text's json-edit * match the document's plain text. Exactly like editPageText's json-edit
* fallback, we first try the verbatim selection and, ONLY if it anchors nowhere * fallback, we first try the verbatim selection and, ONLY if it anchors nowhere
* in the whole document, retry with `stripInlineMarkdown` applied. `canAnchorInDoc`, * in the whole document, retry with `stripInlineMarkdown` applied. `canAnchorInDoc`,
* `getAnchoredText` and `applyAnchorInDoc` share this decision via * `getAnchoredText` and `applyAnchorInDoc` share this decision via
+53 -163
View File
@@ -14,19 +14,7 @@
* signature. * signature.
* *
* If recreateTransform / the changeset throws on a pathological document pair, * If recreateTransform / the changeset throws on a pathological document pair,
* OR the pair is too large to diff cheaply (see the size guard below), we fall * we fall back to a coarse block-level text diff so the tool never hard-fails.
* back to a coarse block-level text diff so the tool never hard-fails and never
* pins the event loop.
*
* SIZE GUARD (issue #464 prod CPU-DoS). recreateTransform computes its diff via
* rfc6902.createPatch, whose array diff is O(n·m) Levenshtein per array pair and
* whose per-run word diff is O(w²); on a large/heavily-changed doc this runs for
* seconds-to-hours and starves the whole process (BullMQ, Redis lock renewals,
* embeddings). It never THROWS it just never finishes so the try/catch below
* cannot save us. Because diffDocs runs on EVERY in-app/MCP content edit's verify
* report, we PRE-FLIGHT the doc size and route anything above a cheap cap straight
* to the coarse fallback (the same shape the catch produces). Same cap+fallback
* pattern as the ELK-layout DoS fix (#440 / c917dcc3).
*/ */
import { Node } from "@tiptap/pm/model"; import { Node } from "@tiptap/pm/model";
@@ -84,56 +72,6 @@ function countNodes(doc: any, pred: (node: any) => boolean): number {
return n; return n;
} }
// --- Issue #464: pre-flight size guard for the precise diff ------------------
// Defaults are BENCHMARK-derived on the recreateTransform(complexSteps:false,
// wordDiffs:true, simplifyDiff:true) pipeline, chosen so the WORST case (a fully
// re-written doc — the adversarial shape that drove the incident) keeps the
// synchronous block under ~200ms REGARDLESS of input:
// - 150 total nodes: worst-case pair ~176ms; the O(node²) array diff crosses
// 200ms at ~170 nodes and then explodes super-linearly (400 nodes ~1.3s,
// 800 ~5.5s), so cap just below the crossover.
// - 12 KiB serialized JSON: an independent axis, because the per-run word diff
// is O(words²) — a FEW nodes with very long text runs is dangerous even at a
// low node count (17 nodes / ~11 KiB ~176ms, / ~14 KiB ~290ms). A node-light
// but byte-heavy doc is still refused.
// Either metric over its cap routes to the coarse fallback. Both are env-tunable
// for operators who accept more CPU in exchange for exact diffs on larger docs.
const DEFAULT_MAX_NODES = 150;
const DEFAULT_MAX_BYTES = 12 * 1024;
/**
* Read a positive-integer env override, falling back to `dflt`. Garbage / unset /
* non-finite / non-positive all fall back (so the guard can never be accidentally
* disabled by a malformed value). Read fresh on every call so a test / operator
* can flip the knob without a restart.
*/
function readPositiveIntEnv(name: string, dflt: number): number {
const raw = parseInt(process.env[name] ?? "", 10);
return Number.isFinite(raw) && raw > 0 ? raw : dflt;
}
/**
* True when the pair is too large for the precise (recreateTransform) diff and
* must degrade to the coarse fallback. Takes the MAX of the two docs on each
* metric so an ASYMMETRIC pair (a small new doc vs a huge old doc, or vice
* versa) which still explodes rfc6902 is caught. Cheap: one node walk +
* one JSON.stringify per doc, both O(size).
*/
function exceedsDiffSizeGuard(oldDoc: any, newDoc: any): boolean {
const maxNodes = readPositiveIntEnv("MCP_DIFF_MAX_NODES", DEFAULT_MAX_NODES);
const maxBytes = readPositiveIntEnv("MCP_DIFF_MAX_BYTES", DEFAULT_MAX_BYTES);
const nodes = Math.max(
countNodes(oldDoc, () => true),
countNodes(newDoc, () => true),
);
if (nodes > maxNodes) return true;
const bytes = Math.max(
JSON.stringify(oldDoc)?.length ?? 0,
JSON.stringify(newDoc)?.length ?? 0,
);
return bytes > maxBytes;
}
/** /**
* Count UNIQUE links in a JSON doc by their `href`. A single link can be split * Count UNIQUE links in a JSON doc by their `href`. A single link can be split
* across several adjacent text runs (e.g. a "link+bold" run followed by a "link" * across several adjacent text runs (e.g. a "link+bold" run followed by a "link"
@@ -288,81 +226,6 @@ function coarseDiff(oldDoc: any, newDoc: any): DiffChange[] {
return changes; return changes;
} }
/** Accumulated textual changes plus their derived char/block tallies. */
interface DiffTally {
changes: DiffChange[];
inserted: number;
deleted: number;
changedBlocks: Set<string>;
}
/**
* Produce the coarse-fallback tally for a pair. This is the SINGLE source of the
* `fellBack:true` result shape, shared by BOTH degrade paths in diffDocs (the
* pre-flight size guard and the recreateTransform catch) so they behave and
* report identically.
*/
function coarseDiffTally(oldDoc: any, newDoc: any): DiffTally {
const changes = coarseDiff(oldDoc, newDoc);
let inserted = 0;
let deleted = 0;
const changedBlocks = new Set<string>();
for (const c of changes) {
if (c.op === "insert") inserted += c.text.length;
else deleted += c.text.length;
if (c.block) changedBlocks.add(c.op[0] + ":" + c.block);
}
return { changes, inserted, deleted, changedBlocks };
}
/**
* Compute the PRECISE tally via the recreateTransform pipeline. Callers MUST
* gate this behind the size guard (it can block the event loop for a large pair)
* and wrap it in try/catch (a pathological pair can throw); on either the guard
* or a throw, use `coarseDiffTally` instead. Kept as a sibling of
* `coarseDiffTally` so both produce the same `DiffTally` shape.
*/
function preciseDiffTally(oldDocJson: any, newDocJson: any): DiffTally {
const oldNode = Node.fromJSON(docmostSchema, oldDocJson);
const newNode = Node.fromJSON(docmostSchema, newDocJson);
const tr = recreateTransform(oldNode, newNode, {
complexSteps: false,
wordDiffs: true,
simplifyDiff: true,
});
const changeSet = ChangeSet.create(oldNode).addSteps(tr.doc, tr.mapping.maps, []);
const simplified = simplifyChanges(changeSet.changes, newNode);
const changes: DiffChange[] = [];
let inserted = 0;
let deleted = 0;
const changedBlocks = new Set<string>();
for (const change of simplified) {
// Deleted text lives in the OLD doc coordinate range [fromA, toA).
if (change.toA > change.fromA) {
const text = oldNode.textBetween(change.fromA, change.toA, "\n", " ");
if (text.length > 0) {
deleted += text.length;
const block = blockContextAt(oldNode, change.fromA);
changes.push({ op: "delete", block, text });
if (block) changedBlocks.add("d:" + block);
}
}
// Inserted text lives in the NEW doc coordinate range [fromB, toB).
if (change.toB > change.fromB) {
const text = newNode.textBetween(change.fromB, change.toB, "\n", " ");
if (text.length > 0) {
inserted += text.length;
const block = blockContextAt(newNode, change.fromB);
changes.push({ op: "insert", block, text });
if (block) changedBlocks.add("i:" + block);
}
}
}
return { changes, inserted, deleted, changedBlocks };
}
/** Build the human-readable unified-ish markdown summary. */ /** Build the human-readable unified-ish markdown summary. */
function renderMarkdown( function renderMarkdown(
result: Omit<DiffResult, "markdown">, result: Omit<DiffResult, "markdown">,
@@ -413,39 +276,66 @@ export function diffDocs(
newDocJson: any, newDocJson: any,
notesHeading: string = "Примечания переводчика", notesHeading: string = "Примечания переводчика",
): DiffResult { ): DiffResult {
// computeIntegrity is cheap (linear node walks) and its counts are needed in
// BOTH the precise and coarse paths, so it always runs first.
const integrity = computeIntegrity(oldDocJson, newDocJson, notesHeading); const integrity = computeIntegrity(oldDocJson, newDocJson, notesHeading);
let changes: DiffChange[] = [];
let inserted = 0;
let deleted = 0;
let fellBack = false; let fellBack = false;
let tally: DiffTally; const changedBlocks = new Set<string>();
// Pre-flight size guard (#464): a too-large pair would make recreateTransform try {
// block the event loop for seconds-to-hours WITHOUT throwing, so route it to const oldNode = Node.fromJSON(docmostSchema, oldDocJson);
// the coarse fallback BEFORE calling recreateTransform at all. Both this path const newNode = Node.fromJSON(docmostSchema, newDocJson);
// and the catch below go through coarseDiffTally for an identical `fellBack` const tr = recreateTransform(oldNode, newNode, {
// result shape. complexSteps: false,
if (exceedsDiffSizeGuard(oldDocJson, newDocJson)) { wordDiffs: true,
simplifyDiff: true,
});
const changeSet = ChangeSet.create(oldNode).addSteps(
tr.doc,
tr.mapping.maps,
[],
);
const simplified = simplifyChanges(changeSet.changes, newNode);
for (const change of simplified) {
// Deleted text lives in the OLD doc coordinate range [fromA, toA).
if (change.toA > change.fromA) {
const text = oldNode.textBetween(change.fromA, change.toA, "\n", " ");
if (text.length > 0) {
deleted += text.length;
const block = blockContextAt(oldNode, change.fromA);
changes.push({ op: "delete", block, text });
if (block) changedBlocks.add("d:" + block);
}
}
// Inserted text lives in the NEW doc coordinate range [fromB, toB).
if (change.toB > change.fromB) {
const text = newNode.textBetween(change.fromB, change.toB, "\n", " ");
if (text.length > 0) {
inserted += text.length;
const block = blockContextAt(newNode, change.fromB);
changes.push({ op: "insert", block, text });
if (block) changedBlocks.add("i:" + block);
}
}
}
} catch {
// Pathological pair: degrade to a coarse block-level diff so we never throw.
fellBack = true; fellBack = true;
tally = coarseDiffTally(oldDocJson, newDocJson); changes = coarseDiff(oldDocJson, newDocJson);
} else { for (const c of changes) {
try { if (c.op === "insert") inserted += c.text.length;
tally = preciseDiffTally(oldDocJson, newDocJson); else deleted += c.text.length;
} catch { if (c.block) changedBlocks.add(c.op[0] + ":" + c.block);
// Pathological pair: degrade to a coarse block-level diff so we never throw.
fellBack = true;
tally = coarseDiffTally(oldDocJson, newDocJson);
} }
} }
const partial: Omit<DiffResult, "markdown"> = { const partial: Omit<DiffResult, "markdown"> = {
summary: { summary: { inserted, deleted, blocksChanged: changedBlocks.size },
inserted: tally.inserted,
deleted: tally.deleted,
blocksChanged: tally.changedBlocks.size,
},
integrity, integrity,
changes: tally.changes, changes,
}; };
return { ...partial, markdown: renderMarkdown(partial, fellBack) }; return { ...partial, markdown: renderMarkdown(partial, fellBack) };
} }
@@ -490,7 +380,7 @@ export interface VerifyReport {
/** /**
* ONLY structural integrity types whose count changed, as [before, after] * ONLY structural integrity types whose count changed, as [before, after]
* (images/links/tables/callouts). Surfaces structural mutations that touch * (images/links/tables/callouts). Surfaces structural mutations that touch
* neither text nor marks (e.g. insert_image, deleting a table) which diffDocs * neither text nor marks (e.g. insertImage, deleting a table) which diffDocs
* being TEXT-only would otherwise report as "no content change". * being TEXT-only would otherwise report as "no content change".
*/ */
structure?: Record<string, [number, number]>; structure?: Record<string, [number, number]>;
@@ -510,7 +400,7 @@ export interface VerifyReport {
* *
* The structural integrity delta (from diffDocs's `integrity` tuples) is what * The structural integrity delta (from diffDocs's `integrity` tuples) is what
* makes `changed` true for an image/table/callout/link count change that diffs * makes `changed` true for an image/table/callout/link count change that diffs
* to zero text closing a verify blind spot for insert_image, delete_node on a * to zero text closing a verify blind spot for insertImage, deleteNode on a
* table, etc. * table, etc.
*/ */
export function summarizeChange(before: any, after: any): VerifyReport { export function summarizeChange(before: any, after: any): VerifyReport {
+14 -14
View File
@@ -1,4 +1,4 @@
// Progressive-disclosure authoring reference for the `drawio_guide` tool // Progressive-disclosure authoring reference for the `drawioGuide` tool
// (issue #424, stage 2). The FULL draw.io authoring guide would bloat every // (issue #424, stage 2). The FULL draw.io authoring guide would bloat every
// context window, so it is split into small sections the model reads on demand: // context window, so it is split into small sections the model reads on demand:
// skeleton | layout | containers | icons-aws | icons-azure // skeleton | layout | containers | icons-aws | icons-azure
@@ -22,7 +22,7 @@ export const GUIDE_SECTIONS: GuideSection[] = [
"icons-azure", "icons-azure",
]; ];
const SKELETON = `# drawio_guide: skeleton const SKELETON = `# drawioGuide: skeleton
Canonical mxGraph skeleton. id="0" and id="1" are MANDATORY sentinels; every Canonical mxGraph skeleton. id="0" and id="1" are MANDATORY sentinels; every
real cell has parent="1" (or a container id). Set adaptiveColors="auto" on the real cell has parent="1" (or a container id). Set adaptiveColors="auto" on the
@@ -50,7 +50,7 @@ model so Docmost's dark theme adapts strokeColor/fillColor/fontColor="default".
</mxGraphModel> </mxGraphModel>
\`\`\` \`\`\`
Three accepted inputs to drawio_create/drawio_update: a bare <mxGraphModel>, a Three accepted inputs to drawioCreate/drawioUpdate: a bare <mxGraphModel>, a
full <mxfile> (decoded to its first page), or a raw list of <mxCell> (the server full <mxfile> (decoded to its first page), or a raw list of <mxCell> (the server
wraps it and adds the id=0/id=1 sentinels). wraps it and adds the id=0/id=1 sentinels).
@@ -58,12 +58,12 @@ Hard rules: a cell is vertex="1" XOR edge="1" (a container/group is neither);
every edge has a child <mxGeometry relative="1" as="geometry"/>; ids are unique; every edge has a child <mxGeometry relative="1" as="geometry"/>; ids are unique;
no XML comments; put html=1 in styles and XML-escape value (& -> &amp;, no XML comments; put html=1 in styles and XML-escape value (& -> &amp;,
< -> &lt;); a newline in a label is &#xa;, never a literal \\n. Don't guess < -> &lt;); a newline in a label is &#xa;, never a literal \\n. Don't guess
shape=mxgraph.* names call drawio_shapes first (a wrong name renders empty).`; shape=mxgraph.* names call drawioShapes first (a wrong name renders empty).`;
const LAYOUT = `# drawio_guide: layout const LAYOUT = `# drawioGuide: layout
Turn "make it look good" into checkable numbers. Or pass layout:"elk" to Turn "make it look good" into checkable numbers. Or pass layout:"elk" to
drawio_create/drawio_update and the server computes coordinates for you (ELK drawioCreate/drawioUpdate and the server computes coordinates for you (ELK
layered layout, honouring nested containers) you declare structure, it places layered layout, honouring nested containers) you declare structure, it places
pixels. pixels.
@@ -96,7 +96,7 @@ The linter returns quality WARNINGS (bbox overlap, edge through a shape,
edge-on-edge, gap <150px, label wider than its shape, negative/off-page coords). edge-on-edge, gap <150px, label wider than its shape, negative/off-page coords).
They do not block the write fix them and retry, max 2 iterations.`; They do not block the write fix them and retry, max 2 iterations.`;
const CONTAINERS = `# drawio_guide: containers const CONTAINERS = `# drawioGuide: containers
Groups/zones are TRANSPARENT containers. A coloured group fill is an instant Groups/zones are TRANSPARENT containers. A coloured group fill is an instant
"AI-generated" tell never fill a group. "AI-generated" tell never fill a group.
@@ -133,10 +133,10 @@ Example (transparent zone with two children and an internal edge):
</mxCell> </mxCell>
\`\`\``; \`\`\``;
const ICONS_AWS = `# drawio_guide: icons-aws const ICONS_AWS = `# drawioGuide: icons-aws
Two mutually-exclusive AWS icon patterns mixing them is the #1 cause of empty Two mutually-exclusive AWS icon patterns mixing them is the #1 cause of empty
boxes. Always call drawio_shapes for the exact resIcon name; do not guess. boxes. Always call drawioShapes for the exact resIcon name; do not guess.
| Level | style | strokeColor | | Level | style | strokeColor |
|---|---|---| |---|---|---|
@@ -168,7 +168,7 @@ Group stencils (transparent containers): AWS Cloud group_aws_cloud_alt, VPC
group_vpc2, Subnet group_security_group, Account group_account; subnets use group_vpc2, Subnet group_security_group, Account group_account; subnets use
shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_public_subnet;.`; shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_public_subnet;.`;
const ICONS_AZURE = `# drawio_guide: icons-azure const ICONS_AZURE = `# drawioGuide: icons-azure
shape=mxgraph.azure2.* does NOT render in every host. Use the portable shape=mxgraph.azure2.* does NOT render in every host. Use the portable
image-style instead: image-style instead:
@@ -190,7 +190,7 @@ an absolute URL fallback for the image:
https://raw.githubusercontent.com/jgraph/drawio/dev/src/main/webapp/img/lib/azure2/<category>/<Icon>.svg https://raw.githubusercontent.com/jgraph/drawio/dev/src/main/webapp/img/lib/azure2/<category>/<Icon>.svg
\`\`\` \`\`\`
Call drawio_shapes with the service name (e.g. "cosmos", "api management", Call drawioShapes with the service name (e.g. "cosmos", "api management",
"front door") to get the exact image-style string and default 68x68 size.`; "front door") to get the exact image-style string and default 68x68 size.`;
const CONTENT: Record<GuideSection, string> = { const CONTENT: Record<GuideSection, string> = {
@@ -216,13 +216,13 @@ export function getGuideSection(section?: string): {
return { section: key, content: CONTENT[key], sections: GUIDE_SECTIONS }; return { section: key, content: CONTENT[key], sections: GUIDE_SECTIONS };
} }
const index = const index =
"# drawio_guide\n\nProgressive-disclosure draw.io authoring reference. " + "# drawioGuide\n\nProgressive-disclosure draw.io authoring reference. " +
"Call drawio_guide(section) with one of:\n" + "Call drawioGuide(section) with one of:\n" +
"- skeleton — canonical mxGraph XML, sentinels, the three accepted inputs, hard rules\n" + "- skeleton — canonical mxGraph XML, sentinels, the three accepted inputs, hard rules\n" +
"- layout — spacing heuristics, edge routing, the layout:\"elk\" option, quality warnings\n" + "- layout — spacing heuristics, edge routing, the layout:\"elk\" option, quality warnings\n" +
"- containers — transparent groups, relative child coords, cross-container edges, swimlanes\n" + "- containers — transparent groups, relative child coords, cross-container edges, swimlanes\n" +
"- icons-aws — the service/resource icon patterns, category colors, rebrandings, blocklist\n" + "- icons-aws — the service/resource icon patterns, category colors, rebrandings, blocklist\n" +
"- icons-azure — the portable image-style paths\n\n" + "- icons-azure — the portable image-style paths\n\n" +
"Also call drawio_shapes(query) for verified stencil style-strings."; "Also call drawioShapes(query) for verified stencil style-strings.";
return { section: "index", content: index, sections: GUIDE_SECTIONS }; return { section: "index", content: index, sections: GUIDE_SECTIONS };
} }
+2 -2
View File
@@ -19,7 +19,7 @@ const DEFAULT_W = 140;
const DEFAULT_H = 60; const DEFAULT_H = 60;
// DoS bounds for the in-process ELK layout. The mxGraph XML is LLM-supplied // DoS bounds for the in-process ELK layout. The mxGraph XML is LLM-supplied
// (layout:"elk" in drawio_create/drawio_update) and elkjs runs synchronously on // (layout:"elk" in drawioCreate/drawioUpdate) and elkjs runs synchronously on
// the MCP server's event loop, so an unbounded graph would block it for // the MCP server's event loop, so an unbounded graph would block it for
// seconds-to-minutes. A ~1MB XML (well under the stage-1 16MB cap) can carry // seconds-to-minutes. A ~1MB XML (well under the stage-1 16MB cap) can carry
// thousands of nodes. We cap the graph size and race the layout against a // thousands of nodes. We cap the graph size and race the layout against a
@@ -81,7 +81,7 @@ interface ElkGraph extends ElkNode {
/** /**
* Apply an ELK layered layout to a drawio input and return a full mxGraphModel * Apply an ELK layered layout to a drawio input and return a full mxGraphModel
* string with rewritten geometry. Accepts the same three input forms as * string with rewritten geometry. Accepts the same three input forms as
* drawio_create (a bare model, an <mxfile>, or a <mxCell> list). Async because * drawioCreate (a bare model, an <mxfile>, or a <mxCell> list). Async because
* elkjs' layout() is promise-based. On any layout failure the ORIGINAL * elkjs' layout() is promise-based. On any layout failure the ORIGINAL
* (normalized) model is returned unchanged layout is best-effort polish, never * (normalized) model is returned unchanged layout is best-effort polish, never
* a reason to fail the write. * a reason to fail the write.
+1 -1
View File
@@ -1,4 +1,4 @@
// Verified draw.io shape catalog for the `drawio_shapes` tool (issue #424, // Verified draw.io shape catalog for the `drawioShapes` tool (issue #424,
// stage 2). This is the fix for AI-generated diagrams' #1 defect: guessed // stage 2). This is the fix for AI-generated diagrams' #1 defect: guessed
// `shape=mxgraph.*` names that render as EMPTY BOXES because the stencil does // `shape=mxgraph.*` names that render as EMPTY BOXES because the stencil does
// not exist. Instead of guessing, the model queries this catalog and gets back // not exist. Instead of guessing, the model queries this catalog and gets back
+1 -1
View File
@@ -1070,7 +1070,7 @@ export function prepareModel(inputXml: string): PreparedModel {
}; };
} }
/** Cell count of a decoded model (user cells only) — used by drawio_get meta. */ /** Cell count of a decoded model (user cells only) — used by drawioGet meta. */
export function countUserCells(modelXml: string): number { export function countUserCells(modelXml: string): number {
return parseCells(modelXml).filter((c) => c.id !== "0" && c.id !== "1").length; return parseCells(modelXml).filter((c) => c.id !== "0" && c.id !== "1").length;
} }
+24 -8
View File
@@ -83,16 +83,32 @@ export function filterComment(comment: any, markdownContent?: string) {
}; };
} }
// Map one server search hit to the MCP output contract (#443):
// { pageId, title, path, snippet, score }
//
// INVARIANT: the only page identifier exposed is `pageId` (the server `id`
// UUID). The server also carries `slugId` — it is NEVER surfaced.
//
// GRACEFUL DEGRADATION: against a stock upstream server the opt-in lookup DTO
// fields are stripped, so the response is the legacy FTS shape (no path/snippet/
// score, a `highlight` + `rank` instead). We synthesize the contract from
// whatever is present: `snippet` falls back to the FTS `highlight`, `score` to
// the FTS `rank`, and `path` to [] (upstream has no path). This keeps the tool
// usable even when the server has not been upgraded.
export function filterSearchResult(result: any) { export function filterSearchResult(result: any) {
return { return {
id: result.id, pageId: result.id,
title: result.title, title: result.title,
parentPageId: result.parentPageId, path: Array.isArray(result.path) ? result.path : [],
createdAt: result.createdAt, snippet:
updatedAt: result.updatedAt, typeof result.snippet === "string"
rank: result.rank, ? result.snippet
highlight: result.highlight, : (result.highlight ?? ""),
spaceId: result.space?.id, score:
spaceName: result.space?.name, typeof result.score === "number"
? result.score
: typeof result.rank === "number"
? result.rank
: 0,
}; };
} }
@@ -16,8 +16,8 @@
* `insertInlineFootnote` live in `@docmost/prosemirror-markdown` (next to the * `insertInlineFootnote` live in `@docmost/prosemirror-markdown` (next to the
* importer's `assembleFootnotes`, #414), so this file stays a pure mirror. * importer's `assembleFootnotes`, #414), so this file stays a pure mirror.
* *
* Why it exists: every NON-editor write path (markdown import, update_page_json, * Why it exists: every NON-editor write path (markdown import, updatePageJson,
* docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the * docmostTransform, insertFootnote) builds ProseMirror JSON directly, so the
* editor's footnote plugins never run and the canonical topology (sequential * editor's footnote plugins never run and the canonical topology (sequential
* numbering by first reference, one trailing list, no orphans, no raw `[^id]`) * numbering by first reference, one trailing list, no orphans, no raw `[^id]`)
* was never enforced. Running this at the end of every write path closes that * was never enforced. Running this at the end of every write path closes that
@@ -28,8 +28,8 @@
* `canonicalizeFootnotes(doc)` before writing the current callers are * `canonicalizeFootnotes(doc)` before writing the current callers are
* `markdownToProseMirrorCanonical` (page markdown import/update; the plain * `markdownToProseMirrorCanonical` (page markdown import/update; the plain
* `markdownToProseMirror` used for COMMENT bodies must NOT, or it would drop a * `markdownToProseMirror` used for COMMENT bodies must NOT, or it would drop a
* reference-less definition), `update_page_json`, `docmost_transform`, * reference-less definition), `updatePageJson`, `docmostTransform`,
* `insert_footnote`, and `copy_page_content`. Append/prepend FRAGMENT writes MUST * `insertFootnote`, and `copyPageContent`. Append/prepend FRAGMENT writes MUST
* NOT canonicalize. This is deliberately per-call-site (the replace-vs-fragment * NOT canonicalize. This is deliberately per-call-site (the replace-vs-fragment
* and comment-vs-page nuances make a single naive wrapper unsafe). * and comment-vs-page nuances make a single naive wrapper unsafe).
*/ */
+8 -8
View File
@@ -283,7 +283,7 @@ export function applyTextEdits(
for (const edit of edits) { for (const edit of edits) {
if (!edit.find) throw new Error("edit.find must be a non-empty string"); if (!edit.find) throw new Error("edit.find must be a non-empty string");
// HARD-REFUSE formatting changes. edit_page_text edits PLAIN TEXT only and // HARD-REFUSE formatting changes. editPageText edits PLAIN TEXT only and
// writes the replacement verbatim, so it cannot add/remove marks. We refuse // writes the replacement verbatim, so it cannot add/remove marks. We refuse
// only a pure formatting TOGGLE: find and replace differ ONLY by balanced // only a pure formatting TOGGLE: find and replace differ ONLY by balanced
// markdown markers (e.g. find:"~~$69~~" / replace:"$69", or find:"M5Stack" / // markdown markers (e.g. find:"~~$69~~" / replace:"$69", or find:"M5Stack" /
@@ -304,22 +304,22 @@ export function applyTextEdits(
failed.push({ failed.push({
find: edit.find, find: edit.find,
reason: reason:
"edit_page_text edits plain text only and cannot add or remove formatting marks (bold/italic/strike/code/link); it writes the replacement as LITERAL text. This edit looks like a formatting change (markdown markers in find/replace). To change marks, read the block with get_page_json and use patch_node (or update_page_json) to set the node's marks array.", "editPageText edits plain text only and cannot add or remove formatting marks (bold/italic/strike/code/link); it writes the replacement as LITERAL text. This edit looks like a formatting change (markdown markers in find/replace). To change marks, read the block with getPageJson and use patchNode (or updatePageJson) to set the node's marks array.",
}); });
continue; continue;
} }
// HARD-REFUSE inline footnote tokens (#410). `^[...]` in a `replace` is // HARD-REFUSE inline footnote tokens (#410). `^[...]` in a `replace` is
// markdown that only becomes a real footnote when a whole markdown body is // markdown that only becomes a real footnote when a whole markdown body is
// written (create_page / update_page_content / import_page_markdown). Written // written (createPage / update_page_content / importPageMarkdown). Written
// through edit_page_text it stays a LITERAL string in the text — the exact // through editPageText it stays a LITERAL string in the text — the exact
// failure mode #410 fixes — so refuse it here (defense-in-depth) and point the // failure mode #410 fixes — so refuse it here (defense-in-depth) and point the
// caller at insert_footnote, mirroring the formatting-marker refusal above. // caller at insertFootnote, mirroring the formatting-marker refusal above.
if (/\^\[[\s\S]*?\]/.test(edit.replace)) { if (/\^\[[\s\S]*?\]/.test(edit.replace)) {
failed.push({ failed.push({
find: edit.find, find: edit.find,
reason: reason:
"edit_page_text writes the replacement as LITERAL text, so a `^[...]` footnote token does not parse into a real footnote (it would appear verbatim in the page). To add a footnote to existing text, use insert_footnote (anchorText = where, text = the note).", "editPageText writes the replacement as LITERAL text, so a `^[...]` footnote token does not parse into a real footnote (it would appear verbatim in the page). To add a footnote to existing text, use insertFootnote (anchorText = where, text = the note).",
}); });
continue; continue;
} }
@@ -381,12 +381,12 @@ export function applyTextEdits(
let reason: string; let reason: string;
if (existsAcrossAtom) { if (existsAcrossAtom) {
reason = reason =
"match crosses a non-text inline node (image/break/mention); use update_page_json for structural changes."; "match crosses a non-text inline node (image/break/mention); use updatePageJson for structural changes.";
} else { } else {
// Append a bounded "closest text" hint: find the FIRST block that // Append a bounded "closest text" hint: find the FIRST block that
// contains the longest whitespace-delimited token (>= 3 chars) of the // contains the longest whitespace-delimited token (>= 3 chars) of the
// (stripped, then raw) locator, and quote that block's plain text. Shared // (stripped, then raw) locator, and quote that block's plain text. Shared
// with create_comment via closestBlockHint so both give the same hint. // with createComment via closestBlockHint so both give the same hint.
reason = "text not found in the document." + closestBlockHint(blockPlain, edit.find); reason = "text not found in the document." + closestBlockHint(blockPlain, edit.find);
} }
failed.push({ find: edit.find, reason }); failed.push({ find: edit.find, reason });
+243
View File
@@ -0,0 +1,243 @@
/**
* Single-BLOCK markdown fragment support for `patch_node` / `insert_node`
* (#413). These tools accept EITHER a raw ProseMirror `node` (fine attr/mark
* work) OR a `markdown` string (the recommended default): a small markdown
* fragment is run through the canonical importer, yielding the SAME topology a
* full-page markdown import would so a block written via markdown is
* canonically identical to the same content imported whole (no "second canon").
*
* The importer produces a full `{type:"doc", content:[...blocks..., footnotesList?]}`.
* A fragment write needs the BLOCKS separately from the footnote DEFINITIONS so
* the caller can splice the blocks into the live document and merge the
* definitions into the page's TAIL footnote list via the existing footnote
* machinery (`insertInlineFootnote`'s `appendDefinition` + `canonicalizeFootnotes`).
*
* Footnote id-collision safety: the importer assigns sequential ids (`fn-1`,
* `fn-2`, ) starting from 1 for EVERY fragment, so a fragment's `fn-1` would
* collide with an existing page footnote also numbered `fn-1` and
* `canonicalizeFootnotes` matches references to definitions BY id, so the
* fragment's reference would silently re-hang onto the page's unrelated
* definition. To make the merge safe regardless of the page's current numbering,
* every fragment footnote id is REMAPPED to a fresh uuid (via the importer's own
* `generateFootnoteId`) across BOTH the references (inside the blocks) and the
* definitions before either is handed back. Content-identical notes still merge
* downstream via `normalizeAndMergeFootnotes` (content-key), and the whole doc is
* renumbered by `canonicalizeFootnotes`, so the caller-visible numbering stays
* canonical.
*/
import { markdownToProseMirror } from "./collaboration.js";
import { generateFootnoteId } from "@docmost/prosemirror-markdown";
import { docmostSchema } from "./docmost-schema.js";
/** True if `value` is a non-null, non-array object. */
function isObject(value: any): value is Record<string, any> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
/**
* Deep-walk `node` collecting every footnote id it uses (on `footnoteReference`
* and `footnoteDefinition` nodes) and build a stable OLD->NEW remap, minting a
* fresh uuid per distinct old id. The map is shared across a fragment's blocks
* and definitions so a reference and its definition receive the SAME new id.
*/
function buildFootnoteIdRemap(nodes: any[]): Map<string, string> {
const remap = new Map<string, string>();
const visit = (node: any): void => {
if (!isObject(node)) return;
if (
(node.type === "footnoteReference" ||
node.type === "footnoteDefinition") &&
isObject(node.attrs) &&
typeof node.attrs.id === "string" &&
node.attrs.id !== ""
) {
if (!remap.has(node.attrs.id)) {
remap.set(node.attrs.id, generateFootnoteId());
}
}
if (Array.isArray(node.content)) {
for (const child of node.content) visit(child);
}
};
for (const n of nodes) visit(n);
return remap;
}
/** Rewrite every footnote id in `node` IN PLACE using `remap` (deep). */
function applyFootnoteIdRemap(node: any, remap: Map<string, string>): void {
if (!isObject(node)) return;
if (
(node.type === "footnoteReference" || node.type === "footnoteDefinition") &&
isObject(node.attrs) &&
typeof node.attrs.id === "string"
) {
const next = remap.get(node.attrs.id);
if (next) node.attrs.id = next;
}
if (Array.isArray(node.content)) {
for (const child of node.content) applyFootnoteIdRemap(child, remap);
}
}
/**
* Generate a short random block id for an imported block that arrives without one
* (the markdown importer emits `attrs.id: null`). Mirrors the mcp `freshId`
* convention (base36 random, unique within one document). The patch path then
* OVERWRITES the first block's id with the target id; every other block keeps the
* fresh id minted here so a 1 -> N section rewrite yields addressable,
* comment-anchorable blocks rather than a run of null-id paragraphs.
*/
function freshBlockId(): string {
return (
Math.random().toString(36).slice(2, 12) +
Math.random().toString(36).slice(2, 6)
);
}
/**
* Assign a fresh id to every top-level block whose `attrs.id` is null/missing,
* IN PLACE. Only the block's own id is touched (not descendants those keep the
* importer's structure). Ensures each imported block is independently addressable.
*/
function assignFreshBlockIds(blocks: any[]): void {
for (const b of blocks) {
if (!isObject(b)) continue;
if (!isObject(b.attrs)) b.attrs = {};
if (b.attrs.id == null || b.attrs.id === "") {
b.attrs.id = freshBlockId();
}
}
}
/** The parsed shape of a markdown fragment: its blocks + footnote definitions. */
export interface MarkdownFragment {
/** Top-level blocks, in order, with the trailing `footnotesList` removed. */
blocks: any[];
/**
* The `footnoteDefinition` nodes lifted from the imported `footnotesList`, with
* ids already remapped to match the references left inside `blocks`. Empty when
* the fragment used no footnotes.
*/
definitions: any[];
}
/**
* Import a markdown fragment and return its blocks separately from its footnote
* definitions, with all footnote ids remapped to fresh uuids (see the file
* header). The importer's `^[body]` inline-footnote handling is used verbatim
* `^[...]` in the fragment is a first-class footnote, NOT rejected so the
* markdown path matches the full-page import exactly.
*
* Throws when the fragment imports to zero blocks (an empty / whitespace-only
* markdown string is not a valid block write).
*/
export async function importMarkdownFragment(
markdown: string,
): Promise<MarkdownFragment> {
const doc = await markdownToProseMirror(markdown);
const content: any[] = Array.isArray(doc?.content) ? doc.content : [];
const blocks: any[] = [];
const definitions: any[] = [];
for (const node of content) {
if (isObject(node) && node.type === "footnotesList") {
// Lift the definitions out of the list; the list wrapper itself is
// reconstructed on the page by the canonicalizer after the merge.
if (Array.isArray(node.content)) {
for (const def of node.content) {
if (isObject(def) && def.type === "footnoteDefinition") {
definitions.push(def);
}
}
}
continue;
}
blocks.push(node);
}
if (blocks.length === 0) {
throw new Error(
"markdown fragment produced no blocks — provide non-empty markdown, or use `node` for a raw ProseMirror node",
);
}
// Remap footnote ids across BOTH blocks and definitions so a fragment `fn-1`
// cannot collide with a page footnote of the same number.
const remap = buildFootnoteIdRemap([...blocks, ...definitions]);
if (remap.size > 0) {
for (const b of blocks) applyFootnoteIdRemap(b, remap);
for (const d of definitions) applyFootnoteIdRemap(d, remap);
}
// Every top-level block needs a stable id (the importer leaves them null). The
// patch path OVERWRITES the first block's id with the target id afterwards.
assignFreshBlockIds(blocks);
return { blocks, definitions };
}
/**
* True when `type` is a valid TOP-LEVEL child of the document node per the
* canonical schema's content model i.e. `get_node` can serialize it to
* markdown by wrapping it in `{type:"doc",content:[node]}`. Derived from the
* schema's `doc` contentMatch (NOT a hand-written type list) so it tracks the
* schema automatically: `tableRow`/`tableCell`/`tableHeader` (addressed only via
* `#<index>`) are NOT doc children and yield false, so `get_node` auto-falls back
* to JSON for them.
*/
export function canBeDocChild(type: string | undefined): boolean {
if (typeof type !== "string") return false;
const nodeType = docmostSchema.nodes[type];
if (!nodeType) return false;
return docmostSchema.nodes.doc.contentMatch.matchType(nodeType) != null;
}
/**
* Table-cell attributes that CANNOT survive a markdown round-trip: the converter
* emits colspan/rowspan (and align) as HTML `<table>` cell attrs, but silently
* drops `colwidth`, `backgroundColor`, and `backgroundColorName`. A markdown
* `patch_node` on a block that carries any of these (a merged / colored /
* fixed-width cell) would therefore lose them so it is REJECTED, pointing the
* caller at the table tools or the raw-`node` JSON path. `align` is intentionally
* absent: it round-trips as GFM alignment.
*/
function cellCarriesUnrepresentableAttrs(node: any): boolean {
if (!isObject(node)) return false;
if (node.type !== "tableCell" && node.type !== "tableHeader") return false;
const a = isObject(node.attrs) ? node.attrs : {};
if ((a.colspan ?? 1) > 1) return true;
if ((a.rowspan ?? 1) > 1) return true;
if (a.colwidth != null) return true;
if (a.backgroundColor != null) return true;
if (a.backgroundColorName != null) return true;
return false;
}
/**
* Scan a target block (the node being replaced) for any table cell carrying an
* attribute markdown cannot represent (colspan/rowspan/colwidth/background). When
* one is found, return a human-readable list of the offending attr NAMES so the
* caller can build an actionable rejection message; return null when the block is
* safe to rewrite from markdown. Deep a colored cell nested inside a table
* inside a callout is still caught.
*/
export function findUnrepresentableTableAttrs(node: any): string | null {
const found = new Set<string>();
const visit = (n: any): void => {
if (!isObject(n)) return;
if (cellCarriesUnrepresentableAttrs(n)) {
const a = isObject(n.attrs) ? n.attrs : {};
if ((a.colspan ?? 1) > 1) found.add("colspan");
if ((a.rowspan ?? 1) > 1) found.add("rowspan");
if (a.colwidth != null) found.add("colwidth");
if (a.backgroundColor != null) found.add("backgroundColor");
if (a.backgroundColorName != null) found.add("backgroundColorName");
}
if (Array.isArray(n.content)) {
for (const child of n.content) visit(child);
}
};
visit(node);
return found.size > 0 ? Array.from(found).sort().join(", ") : null;
}
+12 -12
View File
@@ -3,7 +3,7 @@
* *
* `searchInDoc(doc, query, opts)` finds every occurrence of a literal substring * `searchInDoc(doc, query, opts)` finds every occurrence of a literal substring
* (default) or a regular expression across the page's TEXT CONTAINERS and * (default) or a regular expression across the page's TEXT CONTAINERS and
* reports WHERE each match is the container's ref (for get_node/patch_node; * reports WHERE each match is the container's ref (for getNode/patchNode;
* see the SearchMatch.nodeId note for the `#<index>` caveat), the top-level * see the SearchMatch.nodeId note for the `#<index>` caveat), the top-level
* block index, and a short context window around the hit. It never touches the * block index, and a short context window around the hit. It never touches the
* network, the DB, or the schema mirror; like `comment-anchor.ts` it is * network, the DB, or the schema mirror; like `comment-anchor.ts` it is
@@ -69,24 +69,24 @@ export interface SearchOptions {
/** One located occurrence. */ /** One located occurrence. */
export interface SearchMatch { export interface SearchMatch {
/** /**
* The container's ref, for addressing the block with get_node/patch_node: its * The container's ref, for addressing the block with getNode/patchNode: its
* `attrs.id` when it has one, otherwise `#<topLevelIndex>` of the nearest * `attrs.id` when it has one, otherwise `#<topLevelIndex>` of the nearest
* top-level block. Table-cell/list-item paragraphs that carry no id fall back * top-level block. Table-cell/list-item paragraphs that carry no id fall back
* to the `#<index>` form. * to the `#<index>` form.
* *
* CAVEAT: the `#<index>` form is accepted by get_node (getNodeByRef resolves * CAVEAT: the `#<index>` form is accepted by getNode (getNodeByRef resolves
* it by top-level index) but NOT by patch_node (replaceNodeById resolves only * it by top-level index) but NOT by patchNode (replaceNodeById resolves only
* by `attrs.id`), so id-less table/cell content can be READ by this ref but * by `attrs.id`), so id-less table/cell content can be READ by this ref but
* not PATCHED by it. * not PATCHED by it.
* *
* To anchor a comment, do NOT pass this ref to create_comment it has no * To anchor a comment, do NOT pass this ref to createComment it has no
* nodeId parameter. A top-level comment needs an exact-text `selection` that * nodeId parameter. A top-level comment needs an exact-text `selection` that
* occurs once on the page (it fails if the text isn't found), so build a * occurs once on the page (it fails if the text isn't found), so build a
* UNIQUE `selection` from before+match+after and pass THAT as create_comment's * UNIQUE `selection` from before+match+after and pass THAT as createComment's
* `selection`. * `selection`.
*/ */
nodeId: string; nodeId: string;
/** The top-level block index (as in get_outline). */ /** The top-level block index (as in getOutline). */
blockIndex: number; blockIndex: number;
/** The container node's type (paragraph/heading/...). */ /** The container node's type (paragraph/heading/...). */
type: string | undefined; type: string | undefined;
@@ -188,12 +188,12 @@ export function searchInDoc(
// --- edge-case guards (fail loudly so the agent can correct the call) --- // --- edge-case guards (fail loudly so the agent can correct the call) ---
if (typeof query !== "string" || query.trim().length === 0) { if (typeof query !== "string" || query.trim().length === 0) {
throw new Error( throw new Error(
"search_in_page: query is empty — pass the text (or regex) to look for.", "searchInPage: query is empty — pass the text (or regex) to look for.",
); );
} }
if (query.length > MAX_PATTERN_LENGTH) { if (query.length > MAX_PATTERN_LENGTH) {
throw new Error( throw new Error(
`search_in_page: query is too long (${query.length} chars; max ${MAX_PATTERN_LENGTH}). Shorten the search text/pattern.`, `searchInPage: query is too long (${query.length} chars; max ${MAX_PATTERN_LENGTH}). Shorten the search text/pattern.`,
); );
} }
@@ -212,7 +212,7 @@ export function searchInDoc(
re = new RE2(query, caseSensitive ? "g" : "gi"); re = new RE2(query, caseSensitive ? "g" : "gi");
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`search_in_page: invalid or unsupported regular expression: ${ `searchInPage: invalid or unsupported regular expression: ${
e instanceof Error ? e.message : String(e) e instanceof Error ? e.message : String(e)
} RE2 does not support lookaround ((?=)/(?<=)) or backreferences (\\1); rewrite the pattern without them.`, } RE2 does not support lookaround ((?=)/(?<=)) or backreferences (\\1); rewrite the pattern without them.`,
); );
@@ -237,9 +237,9 @@ export function searchInDoc(
// in a very long container. // in a very long container.
const text = blockPlainText(node); const text = blockPlainText(node);
// The container's own id addresses it verbatim in get_node/patch_node; a // The container's own id addresses it verbatim in getNode/patchNode; a
// container with no id (e.g. a table-cell paragraph) falls back to the // container with no id (e.g. a table-cell paragraph) falls back to the
// top-level block's #<index> (readable via get_node, but not patchable — // top-level block's #<index> (readable via getNode, but not patchable —
// see the SearchMatch.nodeId note). // see the SearchMatch.nodeId note).
const id = const id =
isObject(node.attrs) && typeof node.attrs.id === "string" && node.attrs.id.length > 0 isObject(node.attrs) && typeof node.attrs.id === "string" && node.attrs.id.length > 0
+1 -1
View File
@@ -117,7 +117,7 @@ export function stripInlineMarkdown(s: string): string {
/** /**
* Build a bounded "closest text" hint for an anchor/find MISS, shared by * Build a bounded "closest text" hint for an anchor/find MISS, shared by
* edit_page_text (json-edit) and create_comment (client) so both surface the * editPageText (json-edit) and createComment (client) so both surface the
* same self-correction affordance. * same self-correction affordance.
* *
* Take the longest whitespace-delimited token (>= 3 chars) of the locator * Take the longest whitespace-delimited token (>= 3 chars) of the locator
+56 -1
View File
@@ -740,7 +740,7 @@ export function insertInlineFootnote(
// subtree, so a reference is never glued inside an existing definition (which // subtree, so a reference is never glued inside an existing definition (which
// the canonicalizer would then drop as an orphan, losing that definition's // the canonicalizer would then drop as an orphan, losing that definition's
// prose); and forbidBlockTypes refuses codeBlocks (an inline atom there is a // prose); and forbidBlockTypes refuses codeBlocks (an inline atom there is a
// schema-invalid doc; insert_footnote skips validateDocStructure). // schema-invalid doc; insertFootnote skips validateDocStructure).
// When the only anchor match is in such a place, the insert is refused and the // When the only anchor match is in such a place, the insert is refused and the
// write aborts cleanly (inserted:false) instead of destroying content. // write aborts cleanly (inserted:false) instead of destroying content.
const boundaryIdx = Array.isArray(doc?.content) const boundaryIdx = Array.isArray(doc?.content)
@@ -774,6 +774,61 @@ export function insertInlineFootnote(
return { doc: working, inserted: true, footnoteId, reused }; return { doc: working, inserted: true, footnoteId, reused };
} }
/**
* Merge an ARRAY of footnote definitions (e.g. the definitions lifted from an
* imported markdown FRAGMENT) into `doc`\'s footnote list, then re-derive the
* canonical footnote topology the SAME two-step machinery `insertInlineFootnote`
* uses (`appendDefinition` -> `normalizeAndMergeFootnotes` -> `canonicalizeFootnotes`).
*
* The fragment\'s `footnoteReference` nodes are assumed to ALREADY be spliced into
* `doc` (inside the just-inserted blocks) with ids matching these definitions, so
* after appending the definitions the canonicalizer orders/numbers everything by
* first-reference order, merges content-identical notes, and drops any orphan.
* Same documented caveat as every other write path: full canonicalization drops a
* definition no reference points at.
*
* NOT merely a no-op when `definitions` is empty: it still canonicalizes when
* the (post-splice) `doc` carries footnote artifacts (a `footnotesList` or any
* `footnoteReference`), so a splice that removed the LAST referrer of a page
* footnote drops the now-orphaned definition matching a full page re-import
* (which always canonicalizes) and preserving the "canonically identical to the
* same content imported whole" invariant. A truly footnote-free doc (no artifacts
* and no definitions) is returned untouched the fast path, no clone. When the
* work runs it goes through the pure passes (which clone), so the caller\'s `doc`
* is not mutated.
*/
export function mergeFootnoteDefinitions(doc: any, definitions: any[]): any {
const defs = Array.isArray(definitions) ? definitions : [];
// True fast path ONLY when there is nothing to merge AND nothing to canonicalize
// away; otherwise fall through so an orphan left by a splice is still dropped.
if (defs.length === 0 && !hasFootnoteArtifacts(doc)) return doc;
// Clone before appending: `appendDefinition` mutates in place, and the caller
// must not see a half-merged doc if a later pass throws.
let working = clone(doc);
for (const def of defs) {
appendDefinition(working, def);
}
// #419: normalize + merge glyph-forked definitions before canonicalizing.
working = normalizeAndMergeFootnotes(working);
working = canonicalizeFootnotes(working);
return working;
}
/**
* True if `doc`'s tree contains any `footnotesList` node OR any
* `footnoteReference` node. Used to decide whether an empty-`definitions` merge
* must still canonicalize (to drop an orphan a splice left behind).
*/
function hasFootnoteArtifacts(doc: any): boolean {
let found = false;
walk(doc, (n) => {
if (isObject(n) && (n.type === "footnotesList" || n.type === "footnoteReference")) {
found = true;
}
});
return found;
}
/** /**
* Append a definition node so the canonicalizer can order/place it: into the * Append a definition node so the canonicalizer can order/place it: into the
* first existing footnotesList, or a new trailing list when none exists. * first existing footnotesList, or a new trailing list when none exists.
+85 -8
View File
@@ -1,11 +1,30 @@
/**
* Options for `buildPageTree`. Fully OPTIONAL so the existing call form
* `buildPageTree(nodes)` keeps its historic behaviour (lean `{id, slugId,
* title, children?}` output, no depth cut) unchanged.
*
* - `shape: "getTree"` emit the #443 `getTree` output node shape
* `{pageId, title, children?, hasChildren?}` instead of the lean
* `{id, slugId, title, children?}` shape. `slugId`/`icon`/`position` are
* never exposed (INVARIANT: only the UUID `pageId` leaves the MCP layer).
* - `maxDepth` trim the built tree to this many levels (root nodes are
* depth 1). Only meaningful together with `shape: "getTree"` (the lean shape
* has no `hasChildren` to signal a cut). See the depth logic below.
*/
export interface BuildPageTreeOptions {
shape?: "lean" | "getTree";
maxDepth?: number;
}
/** /**
* Pure tree-builder: turn a flat array of sidebar-style page nodes (as produced * Pure tree-builder: turn a flat array of sidebar-style page nodes (as produced
* by `enumerateSpacePages`) into a nested tree. * by `enumerateSpacePages`) into a nested tree.
* *
* Input: a flat array of nodes. Each node is expected to carry at least * Input: a flat array of nodes. Each node is expected to carry at least
* { id, slugId, title, position, parentPageId } (extra fields are ignored). * { id, slugId, title, position, parentPageId } (extra fields are ignored),
* plus a server `hasChildren` boolean used by the `getTree` shape below.
* *
* Output: an array of ROOT nodes, each shaped as * Output (default / `shape: "lean"`): an array of ROOT nodes, each shaped as
* { id, slugId, title, children? } * { id, slugId, title, children? }
* where `children` is the array of child nodes (same shape, recursively). The * where `children` is the array of child nodes (same shape, recursively). The
* `children` key is OMITTED entirely when a node has no children consistent * `children` key is OMITTED entirely when a node has no children consistent
@@ -13,6 +32,14 @@
* lean (nesting alone conveys the structure; parentPageId/position/hasChildren * lean (nesting alone conveys the structure; parentPageId/position/hasChildren
* are intentionally dropped from the output). * are intentionally dropped from the output).
* *
* Output (`shape: "getTree"`, the #443 tool shape): each node is
* { pageId, title, children?, hasChildren? }
* the server `id` is exposed as `pageId` (never `slugId`/`icon`/`position`).
* `children` is omitted for leaves and for nodes trimmed by `maxDepth`.
* `hasChildren: true` is set ONLY on a node whose children exist on the server
* (per the flat item's `hasChildren`) but were CUT by `maxDepth`; on leaves and
* on fully-expanded interior nodes the field is omitted (see `maxDepth` below).
*
* Linking rule: a node is attached as a child of `parentPageId` only when that * Linking rule: a node is attached as a child of `parentPageId` only when that
* parent id is actually present in the input. Otherwise including a null / * parent id is actually present in the input. Otherwise including a null /
* undefined `parentPageId`, or a parent that was capped out of the bounded walk * undefined `parentPageId`, or a parent that was capped out of the bounded walk
@@ -26,18 +53,42 @@
* fractional-index ASCII keys (e.g. "a0", "a1"). Nodes with a missing/undefined * fractional-index ASCII keys (e.g. "a0", "a1"). Nodes with a missing/undefined
* `position` sort last. * `position` sort last.
* *
* maxDepth (getTree shape only): the tree is built in FULL first, then trimmed
* on the way out. Root nodes are depth 1. `maxDepth: N` keeps nodes at depth
* <= N and drops the `children` of any node AT depth N. A node whose children
* were dropped this way gets `hasChildren: true` when it actually had children
* in the flat input (source of truth = the server `hasChildren` flag), so the
* caller knows it can descend further with a follow-up `rootPageId` call. An
* absent/undefined `maxDepth` means no cut (whole tree). `maxDepth <= 0` is
* treated as "no cut" (defensive; the tool schema clamps to >= 1).
*
* Pure: no I/O, no network, deterministic. * Pure: no I/O, no network, deterministic.
*/ */
export function buildPageTree(nodes: any[]): any[] { export function buildPageTree(
type OutputNode = { nodes: any[],
options: BuildPageTreeOptions = {},
): any[] {
const getTreeShape = options.shape === "getTree";
// A finite, positive cut only; anything else means "no cut".
const maxDepth =
typeof options.maxDepth === "number" &&
Number.isFinite(options.maxDepth) &&
options.maxDepth > 0
? Math.floor(options.maxDepth)
: undefined;
type InternalNode = {
id: string; id: string;
// Retained internally for shaping; never all emitted at once.
slugId: any; slugId: any;
title: any; title: any;
children?: OutputNode[]; hasServerChildren: boolean;
children?: InternalNode[];
}; };
// Map id -> output node. Build the lean output shape up front. // Map id -> internal node. Build up front; the output shape is projected at
const byId = new Map<string, OutputNode>(); // the very end so the maxDepth cut can consult `hasServerChildren`.
const byId = new Map<string, InternalNode>();
// Preserve the original position string for sorting (kept off the output). // Preserve the original position string for sorting (kept off the output).
const positionById = new Map<string, string | undefined>(); const positionById = new Map<string, string | undefined>();
@@ -49,6 +100,7 @@ export function buildPageTree(nodes: any[]): any[] {
id: node.id, id: node.id,
slugId: node.slugId, slugId: node.slugId,
title: node.title, title: node.title,
hasServerChildren: node.hasChildren === true,
}); });
positionById.set(node.id, node.position); positionById.set(node.id, node.position);
} }
@@ -90,5 +142,30 @@ export function buildPageTree(nodes: any[]): any[] {
} }
roots.sort(byPosition); roots.sort(byPosition);
return roots.map((id) => byId.get(id)!); const rootNodes = roots.map((id) => byId.get(id)!);
// Project the internal nodes into the requested OUTPUT shape, applying the
// maxDepth cut for the getTree shape. `depth` is 1-based (roots = depth 1).
const project = (node: InternalNode, depth: number): any => {
if (getTreeShape) {
const out: any = { pageId: node.id, title: node.title };
const atCut = maxDepth !== undefined && depth >= maxDepth;
if (!atCut && node.children && node.children.length > 0) {
out.children = node.children.map((c) => project(c, depth + 1));
} else if (atCut && node.hasServerChildren) {
// Children exist on the server but were trimmed by maxDepth: signal it
// so the caller can descend with a follow-up rootPageId call.
out.hasChildren = true;
}
return out;
}
// Lean (historic) shape: cycle-safe, no depth cut, no hasChildren.
const out: any = { id: node.id, slugId: node.slugId, title: node.title };
if (node.children && node.children.length > 0) {
out.children = node.children.map((c) => project(c, depth + 1));
}
return out;
};
return rootNodes.map((n) => project(n, 1));
} }
+59 -57
View File
@@ -40,11 +40,11 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
*/ */
export const ROUTING_PROSE = 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" + "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" + "READ: find a page by a fragment of a technical string (hostname/IP/ID like srv.local, 10.0.12, WB-MGE-30D86B) -> search — hybrid substring + full-text, returns each hit's location (path: root->parent titles) and a snippet around the match, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only. A space's page HIERARCHY (or one subtree) -> getTree (one request, complete, `{pageId,title,children?}`; rootPageId for a subtree, maxDepth to trim depth — a trimmed node gets hasChildren:true); prefer it over listPages tree:true (deprecated). Have a pageId, need WHERE-AM-I / what's around it (its breadcrumbs + direct children, metadata only) -> getPageContext (one call; parent = last breadcrumb, [] for a root page). list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#<index>\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline <span data-comment-id> tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (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); before authoring a diagram, drawio_shapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawio_guide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawio_create/drawio_update to auto-place nodes. 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" + "EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> drawioCreate (create from mxGraph XML and insert), drawioGet (read a diagram as mxGraph XML + a hash), drawioUpdate (replace a diagram; pass the hash from drawioGet as baseHash for optimistic locking); before authoring a diagram, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (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) -> docmostTransform: 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" + "PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (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) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage 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" + "COMMENTS: createComment 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 -> createComment 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 -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\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."; "HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown.";
/** /**
* A single generated inventory line: the tool's registered NAME + a one-line * A single generated inventory line: the tool's registered NAME + a one-line
@@ -81,57 +81,59 @@ type Family = (typeof FAMILY_ORDER)[number];
const TOOL_FAMILY: Record<string, Family> = { const TOOL_FAMILY: Record<string, Family> = {
// READ // READ
search: "READ", search: "READ",
list_pages: "READ", listPages: "READ",
list_spaces: "READ", getTree: "READ",
get_outline: "READ", getPageContext: "READ",
get_node: "READ", listSpaces: "READ",
search_in_page: "READ", getOutline: "READ",
get_page: "READ", getNode: "READ",
get_page_json: "READ", searchInPage: "READ",
get_workspace: "READ", getPage: "READ",
stash_page: "READ", getPageJson: "READ",
getWorkspace: "READ",
stashPage: "READ",
// EDIT // EDIT
edit_page_text: "EDIT", editPageText: "EDIT",
patch_node: "EDIT", patchNode: "EDIT",
insert_node: "EDIT", insertNode: "EDIT",
delete_node: "EDIT", deleteNode: "EDIT",
update_page_json: "EDIT", updatePageJson: "EDIT",
update_page_markdown: "EDIT", updatePageMarkdown: "EDIT",
table_get: "EDIT", tableGet: "EDIT",
table_update_cell: "EDIT", tableUpdateCell: "EDIT",
table_insert_row: "EDIT", tableInsertRow: "EDIT",
table_delete_row: "EDIT", tableDeleteRow: "EDIT",
insert_image: "EDIT", insertImage: "EDIT",
replace_image: "EDIT", replaceImage: "EDIT",
insert_footnote: "EDIT", insertFootnote: "EDIT",
drawio_get: "EDIT", drawioGet: "EDIT",
drawio_create: "EDIT", drawioCreate: "EDIT",
drawio_update: "EDIT", drawioUpdate: "EDIT",
drawio_shapes: "EDIT", drawioShapes: "EDIT",
drawio_guide: "EDIT", drawioGuide: "EDIT",
docmost_transform: "EDIT", docmostTransform: "EDIT",
// PAGES // PAGES
create_page: "PAGES", createPage: "PAGES",
rename_page: "PAGES", renamePage: "PAGES",
move_page: "PAGES", movePage: "PAGES",
delete_page: "PAGES", deletePage: "PAGES",
copy_page_content: "PAGES", copyPageContent: "PAGES",
share_page: "PAGES", sharePage: "PAGES",
unshare_page: "PAGES", unsharePage: "PAGES",
list_shares: "PAGES", listShares: "PAGES",
// COMMENTS // COMMENTS
create_comment: "COMMENTS", createComment: "COMMENTS",
list_comments: "COMMENTS", listComments: "COMMENTS",
update_comment: "COMMENTS", updateComment: "COMMENTS",
resolve_comment: "COMMENTS", resolveComment: "COMMENTS",
delete_comment: "COMMENTS", deleteComment: "COMMENTS",
check_new_comments: "COMMENTS", checkNewComments: "COMMENTS",
// HISTORY // HISTORY
diff_page_versions: "HISTORY", diffPageVersions: "HISTORY",
list_page_history: "HISTORY", listPageHistory: "HISTORY",
restore_page_version: "HISTORY", restorePageVersion: "HISTORY",
export_page_markdown: "HISTORY", exportPageMarkdown: "HISTORY",
// import_page_markdown is now inAppOnly (#411) — it is not registered on the // importPageMarkdown is now inAppOnly (#411) — it is not registered on the
// external MCP host, so it no longer appears in the generated inventory. // external MCP host, so it no longer appears in the generated inventory.
}; };
@@ -145,26 +147,26 @@ const TOOL_FAMILY: Record<string, Family> = {
*/ */
export const INLINE_MCP_INVENTORY: ToolInventoryLine[] = [ export const INLINE_MCP_INVENTORY: ToolInventoryLine[] = [
{ {
name: "table_get", name: "tableGet",
purpose: purpose:
"read a table as a matrix of cell texts + per-cell paragraph ids.", "read a table as a matrix of cell texts + per-cell paragraph ids.",
}, },
{ {
name: "search", name: "search",
purpose: purpose:
"full-text search for pages and content across the whole workspace.", "find pages by a fragment of a technical string (hybrid substring + full-text); returns each hit's path and a snippet.",
}, },
{ {
name: "docmost_transform", name: "docmostTransform",
purpose: purpose:
"edit a page by running a sandboxed JS `(doc, ctx) => doc` transform, with a dryRun diff preview.", "edit a page by running a sandboxed JS `(doc, ctx) => doc` transform, with a dryRun diff preview.",
}, },
{ {
name: "update_comment", name: "updateComment",
purpose: "update an existing comment's content (creator only).", purpose: "update an existing comment's content (creator only).",
}, },
{ {
name: "delete_comment", name: "deleteComment",
purpose: "delete a comment (creator or space admin only).", purpose: "delete a comment (creator or space admin only).",
}, },
]; ];
+310 -159
View File
@@ -8,7 +8,7 @@
// z.array() and z.object() — API identical across v3 and v4 — so a single // z.array() and z.object() — API identical across v3 and v4 — so a single
// builder works with either namespace. // builder works with either namespace.
// //
// Only tools whose snake_case/camelCase name, input schema AND model-facing // Only tools whose camelCase name, input schema AND model-facing
// description are genuinely identical across both layers live here. Tools that // description are genuinely identical across both layers live here. Tools that
// diverge on purpose (security guardrails, tuned UX, "Reversible" framing on // diverge on purpose (security guardrails, tuned UX, "Reversible" framing on
// some write tools, different limits, hybrid-RRF search, etc.) stay defined // some write tools, different limits, hybrid-RRF search, etc.) stay defined
@@ -29,8 +29,8 @@
// one of them. Each builder uses only the common, stable subset of the API. // one of them. Each builder uses only the common, stable subset of the API.
type ZodLike = any; type ZodLike = any;
// The `node` normalizer shared by BOTH hosts (patch_node / insert_node / // The `node` normalizer shared by BOTH hosts (patchNode / insertNode /
// update_page_json): the model sometimes serializes a ProseMirror node arg as a // updatePageJson): the model sometimes serializes a ProseMirror node arg as a
// JSON string, so we parse a string to an object (throwing a documented message // JSON string, so we parse a string to an object (throwing a documented message
// on invalid JSON) and pass an object through. It lives in the converter package // on invalid JSON) and pass an object through. It lives in the converter package
// (#414) so it is the ONE copy both the MCP server and the in-app server import; // (#414) so it is the ONE copy both the MCP server and the in-app server import;
@@ -63,6 +63,8 @@ export type DocmostClientLike = Pick<
| 'getSpaces' | 'getSpaces'
| 'listShares' | 'listShares'
| 'listPages' | 'listPages'
| 'getTree'
| 'getPageContext'
| 'getPage' | 'getPage'
| 'getPageJson' | 'getPageJson'
| 'getOutline' | 'getOutline'
@@ -117,7 +119,8 @@ export type SharedToolExecute = (
) => Promise<unknown>; ) => Promise<unknown>;
export interface SharedToolSpec { export interface SharedToolSpec {
/** snake_case tool name passed to McpServer.registerTool. */ /** camelCase tool name passed to McpServer.registerTool. Since issue #412 the
* external MCP name equals the in-app key (mcpName === inAppKey). */
mcpName: string; mcpName: string;
/** camelCase key in the ai-SDK tools object (the in-app layer). */ /** camelCase key in the ai-SDK tools object (the in-app layer). */
inAppKey: string; inAppKey: string;
@@ -178,7 +181,7 @@ export interface SharedToolSpec {
* description / schema across both hosts) but carries NO `execute`/override and * description / schema across both hosts) but carries NO `execute`/override and
* is registered INLINE by BOTH hosts instead of through the registry loop. Used * is registered INLINE by BOTH hosts instead of through the registry loop. Used
* for tools whose implementation cannot cross into this zod-agnostic file the * for tools whose implementation cannot cross into this zod-agnostic file the
* drawio_shapes / drawio_guide pure helpers, whose backing module resolves a * drawioShapes / drawioGuide pure helpers, whose backing module resolves a
* bundled data file via `import.meta` and so cannot be value-imported here * bundled data file via `import.meta` and so cannot be value-imported here
* without breaking the in-app server's commonjs type-check of this source. Both * without breaking the in-app server's commonjs type-check of this source. Both
* registry loops SKIP a spec with this flag; the per-host inline registrations * registry loops SKIP a spec with this flag; the per-host inline registrations
@@ -206,10 +209,10 @@ const mcpJson = (data: unknown) => ({
}); });
/** /**
* Compact HARD-RULES block injected into the drawio_create / drawio_update * Compact HARD-RULES block injected into the drawioCreate / drawioUpdate
* descriptions (issue #424 the jgraph/drawio-mcp pattern of putting the * descriptions (issue #424 the jgraph/drawio-mcp pattern of putting the
* must-follow rules right where the model reads them at call time). Deliberately * must-follow rules right where the model reads them at call time). Deliberately
* terse; the long-form authoring guidance lives in drawio_guide. * terse; the long-form authoring guidance lives in drawioGuide.
*/ */
export const DRAWIO_HARD_RULES = export const DRAWIO_HARD_RULES =
' RULES: id="0" and id="1"(parent="0") sentinels are MANDATORY; each cell is ' + ' RULES: id="0" and id="1"(parent="0") sentinels are MANDATORY; each cell is ' +
@@ -221,8 +224,8 @@ export const DRAWIO_HARD_RULES =
'and RELATIVE coords, and an edge between different containers is parent="1"; set ' + 'and RELATIVE coords, and an edge between different containers is parent="1"; set ' +
'adaptiveColors="auto" on <mxGraphModel> (free dark-theme adaptation for ' + 'adaptiveColors="auto" on <mxGraphModel> (free dark-theme adaptation for ' +
'strokeColor/fillColor/fontColor="default"); do NOT guess shape=mxgraph.* names ' + 'strokeColor/fillColor/fontColor="default"); do NOT guess shape=mxgraph.* names ' +
"(a wrong name renders as an empty box) — call drawio_shapes first; call " + "(a wrong name renders as an empty box) — call drawioShapes first; call " +
"drawio_guide(section) for authoring help. Pass layout:\"elk\" to let the server " + "drawioGuide(section) for authoring help. Pass layout:\"elk\" to let the server " +
"compute coordinates from your rough placement. The result carries geometry " + "compute coordinates from your rough placement. The result carries geometry " +
"WARNINGS (overlaps, an edge through a shape, edge-on-edge, gaps <150px, a label " + "WARNINGS (overlaps, an edge through a shape, edge-on-edge, gaps <150px, a label " +
"wider than its shape, negative coords) — they do NOT block the write; fix them " + "wider than its shape, negative coords) — they do NOT block the write; fix them " +
@@ -232,7 +235,7 @@ export const SHARED_TOOL_SPECS = {
// --- no-argument read tools --- // --- no-argument read tools ---
getWorkspace: { getWorkspace: {
mcpName: 'get_workspace', mcpName: 'getWorkspace',
inAppKey: 'getWorkspace', inAppKey: 'getWorkspace',
description: 'Fetch metadata about the current workspace (name, settings).', description: 'Fetch metadata about the current workspace (name, settings).',
tier: 'core', tier: 'core',
@@ -241,7 +244,7 @@ export const SHARED_TOOL_SPECS = {
}, },
listSpaces: { listSpaces: {
mcpName: 'list_spaces', mcpName: 'listSpaces',
inAppKey: 'listSpaces', inAppKey: 'listSpaces',
description: description:
'List the spaces the current user can access. Returns the array of ' + 'List the spaces the current user can access. Returns the array of ' +
@@ -252,7 +255,7 @@ export const SHARED_TOOL_SPECS = {
}, },
listShares: { listShares: {
mcpName: 'list_shares', mcpName: 'listShares',
inAppKey: 'listShares', inAppKey: 'listShares',
description: description:
'List all public shares in the workspace with page titles and public URLs.', 'List all public shares in the workspace with page titles and public URLs.',
@@ -264,7 +267,7 @@ export const SHARED_TOOL_SPECS = {
// --- single-pageId read tools --- // --- single-pageId read tools ---
getPageJson: { getPageJson: {
mcpName: 'get_page_json', mcpName: 'getPageJson',
inAppKey: 'getPageJson', inAppKey: 'getPageJson',
description: description:
'Get page details with the raw ProseMirror JSON content (lossless: ' + 'Get page details with the raw ProseMirror JSON content (lossless: ' +
@@ -281,7 +284,7 @@ export const SHARED_TOOL_SPECS = {
}, },
getOutline: { getOutline: {
mcpName: 'get_outline', mcpName: 'getOutline',
inAppKey: 'getOutline', inAppKey: 'getOutline',
description: description:
"Return a COMPACT outline of a page's top-level blocks ({index, type, " + "Return a COMPACT outline of a page's top-level blocks ({index, type, " +
@@ -301,43 +304,62 @@ export const SHARED_TOOL_SPECS = {
// --- two-id read tool --- // --- two-id read tool ---
getNode: { getNode: {
mcpName: 'get_node', mcpName: 'getNode',
inAppKey: 'getNode', inAppKey: 'getNode',
description: description:
"Fetch a single node's full ProseMirror subtree (lossless) without " + "Fetch a single block for editing. `nodeId` is a block id from the page " +
'pulling the whole document. `nodeId` is a block id from the page ' +
'outline or page-JSON view (works for headings/paragraphs/callouts/images), OR ' + 'outline or page-JSON view (works for headings/paragraphs/callouts/images), OR ' +
'`#<index>` to fetch a top-level block by its outline index — use the ' + '`#<index>` to fetch a top-level block by its outline index — use the ' +
'`#<index>` form for tables/rows/cells, which carry no id.', '`#<index>` form for tables/rows/cells, which carry no id. ' +
"`format` defaults to \"markdown\": the block is returned as a canonical " +
'markdown fragment (comment anchors are KEPT so a patchNode write-back does ' +
'not orphan a thread) — edit it and write it back with patchNode({markdown}). ' +
'Pass format:"json" for the raw lossless ProseMirror subtree (for precise ' +
'attr/mark work). A node that cannot be a document top-level block ' +
'(tableRow/tableCell/tableHeader via "#<index>") auto-falls back to JSON with ' +
'format:"json" in the response.',
tier: 'core', tier: 'core',
catalogLine: catalogLine:
"getNode — fetch one block's ProseMirror subtree by block id or #index.", "getNode — fetch one block (markdown by default; json for the raw subtree).",
buildShape: (z) => ({ buildShape: (z) => ({
pageId: z.string().min(1), pageId: z.string().min(1),
nodeId: z.string().min(1), nodeId: z.string().min(1),
format: z
.enum(['markdown', 'json'])
.optional()
.describe(
'Output format: "markdown" (default, for editing → patchNode) or ' +
'"json" (raw ProseMirror subtree). A non-top-level type auto-falls ' +
'back to json.',
),
}), }),
execute: (client, { pageId, nodeId }) => execute: (client, { pageId, nodeId, format }) =>
client.getNode(pageId as string, nodeId as string), client.getNode(
pageId as string,
nodeId as string,
format as 'markdown' | 'json' | undefined,
),
}, },
// --- in-page occurrence search (client-side, over ProseMirror plain text) --- // --- in-page occurrence search (client-side, over ProseMirror plain text) ---
searchInPage: { searchInPage: {
mcpName: 'search_in_page', mcpName: 'searchInPage',
inAppKey: 'searchInPage', inAppKey: 'searchInPage',
description: description:
'Find every occurrence of a string (or regex) INSIDE one page and get ' + 'Find every occurrence of a string (or regex) INSIDE one page and get ' +
'WHERE each is — instead of pulling blocks one-by-one with get_node. ' + 'WHERE each is — instead of pulling blocks one-by-one with getNode. ' +
'Searches the plain text of each text block/cell (marks glued, so a match ' + 'Searches the plain text of each text block/cell (marks glued, so a match ' +
'survives bold/italic/link splits; comment anchors do not interfere). ' + 'survives bold/italic/link splits; comment anchors do not interfere). ' +
'Returns { total, truncated, matches:[{ nodeId, blockIndex, type, before, ' + 'Returns { total, truncated, matches:[{ nodeId, blockIndex, type, before, ' +
'match, after }] }: `nodeId` is the block id (or "#<index>" for ' + 'match, after }] }: `nodeId` is the block id (or "#<index>" for ' +
'table/cell content) — pass it to get_node/patch_node (the "#<index>" ' + 'table/cell content) — pass it to getNode/patchNode (the "#<index>" ' +
'form resolves with get_node but NOT patch_node, which only accepts a real ' + 'form resolves with getNode but NOT patchNode, which only accepts a real ' +
'block id). To anchor a comment, do NOT pass nodeId to create_comment (it ' + 'block id). To anchor a comment, do NOT pass nodeId to createComment (it ' +
'has no nodeId param); build a UNIQUE text selection from before+match+' + 'has no nodeId param); build a UNIQUE text selection from before+match+' +
'after and pass it as create_comment\'s `selection`. `blockIndex` is the ' + 'after and pass it as createComment\'s `selection`. `blockIndex` is the ' +
'get_outline index; `before`/`after` give ~40 chars of context to build ' + 'getOutline index; `before`/`after` give ~40 chars of context to build ' +
'that unique selection. `total` counts all ' + 'that unique selection. `total` counts all ' +
'hits and `truncated` is true when more than `limit` were found (nothing ' + 'hits and `truncated` is true when more than `limit` were found (nothing ' +
'is silently dropped). Default is a literal, case-INSENSITIVE substring; ' + 'is silently dropped). Default is a literal, case-INSENSITIVE substring; ' +
@@ -386,7 +408,7 @@ export const SHARED_TOOL_SPECS = {
// --- node delete --- // --- node delete ---
deleteNode: { deleteNode: {
mcpName: 'delete_node', mcpName: 'deleteNode',
inAppKey: 'deleteNode', inAppKey: 'deleteNode',
description: description:
'Remove a single block by its attrs.id (from the page outline or ' + 'Remove a single block by its attrs.id (from the page outline or ' +
@@ -408,30 +430,36 @@ export const SHARED_TOOL_SPECS = {
// AND the in-app copy's "keeps the same node id" + "Reversible via page // AND the in-app copy's "keeps the same node id" + "Reversible via page
// history" framing — nothing either side conveyed is dropped. Sibling tools are // history" framing — nothing either side conveyed is dropped. Sibling tools are
// named in transport-neutral prose ("the page-JSON view", "a full-document // named in transport-neutral prose ("the page-JSON view", "a full-document
// replace") to match the rest of the registry, since the two layers expose // replace") to match the rest of the registry, so a description reads the same
// those siblings under different (snake_case vs camelCase) identifiers. // on both layers.
patchNode: { patchNode: {
mcpName: 'patch_node', mcpName: 'patchNode',
inAppKey: 'patchNode', inAppKey: 'patchNode',
description: description:
'Replace a single content block identified by its attrs.id with a new ' + 'Replace a single content block identified by its attrs.id, WITHOUT ' +
'ProseMirror node, WITHOUT resending the whole document; the replacement ' + 'resending the whole document; the replacement keeps the same block id. ' +
'keeps the same node id. Get the block id from the page outline (cheap) ' + 'Get the block id from the page outline (cheap) or the page-JSON view. ' +
'or the page-JSON view, then ' + 'Provide EXACTLY ONE of `markdown` or `node`. ' +
'pass a ProseMirror node to put in its place. Example node: a paragraph ' + '`markdown` (RECOMMENDED for prose): a canonical markdown fragment — the ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' + 'usual round trip is getNode (markdown) → edit the markdown → patchNode ' +
'heading {"type":"heading","attrs":{"level":2},"content":' + '(markdown). The fragment may be SEVERAL blocks (a "1 → N" splice: rewrite a ' +
'whole section in one call) — the first block inherits this block id, the ' +
'rest get fresh ids. `^[...]` footnotes are supported (their definitions ' +
"merge into the page's footnote list). REJECTED when the target is a table " +
'cell with attributes markdown cannot represent (merged/colored/fixed-width) ' +
'— use the table tools or `node`. ' +
'`node` (for precise attr/mark work): a raw ProseMirror node, e.g. a ' +
'paragraph {"type":"paragraph","content":[{"type":"text","text":"Hello"}]} ' +
'or a heading {"type":"heading","attrs":{"level":2},"content":' +
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' + '[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' + '{"type":"text","text":"x","marks":[{"type":"bold"}]}. EVERY node, including ' +
'JSON object or a JSON string (both accepted). EVERY node, including ' + 'nested children, must carry a string `type` from the Docmost schema; text ' +
'nested children, must carry a string `type` from the Docmost schema; ' + 'leaves are {"type":"text","text":"..."} (a bare {"text":"..."} is rejected). ' +
'text leaves are {"type":"text","text":"..."} (a bare {"text":"..."} is ' + 'The node may be a JSON object or a JSON string (both accepted). Reversible: ' +
'rejected up front). Cheaper and safer than ' +
'replacing the whole document for one-block structural edits. Reversible: ' +
'the previous version is kept in page history.', 'the previous version is kept in page history.',
tier: 'deferred', tier: 'deferred',
catalogLine: catalogLine:
'patchNode — replace one block with a new ProseMirror node, keeping its id.', 'patchNode — rewrite one block from markdown (or a raw node), keeping its id.',
buildShape: (z) => ({ buildShape: (z) => ({
pageId: z.string().min(1).describe('ID of the page containing the block'), pageId: z.string().min(1).describe('ID of the page containing the block'),
nodeId: z nodeId: z
@@ -441,35 +469,53 @@ export const SHARED_TOOL_SPECS = {
'attrs.id of the block to replace (from the page outline or ' + 'attrs.id of the block to replace (from the page outline or ' +
'page-JSON view)', 'page-JSON view)',
), ),
markdown: z
.string()
.optional()
.describe(
'RECOMMENDED. Canonical markdown to replace the block with; may be ' +
'several blocks (the first inherits the id, the rest get fresh ids). ' +
'Exactly one of markdown / node.',
),
node: z node: z
.any() .any()
.optional()
.describe( .describe(
'ProseMirror node to put in place of the node with this id, e.g. ' + 'For precise attr/mark work: a ProseMirror node to put in place of ' +
'the block, e.g. ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' + '{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
'JSON object or JSON string both accepted.', 'JSON object or JSON string both accepted. Exactly one of markdown / node.',
), ),
}), }),
// parseNodeArg normalizes a JSON-string node into an object (the model // parseNodeArg normalizes a JSON-string node into an object (the model
// sometimes serializes it as a string) before the client's typeof-object // sometimes serializes it as a string) before the client's typeof-object
// guard rejects it — identical on both hosts. // guard rejects it — identical on both hosts. The XOR (markdown vs node) is
execute: (client, { pageId, nodeId, node }) => // enforced at runtime in the client (both schema-optional).
client.patchNode(pageId as string, nodeId as string, parseNodeArg(node)), execute: (client, { pageId, nodeId, markdown, node }) =>
client.patchNode(pageId as string, nodeId as string, {
markdown: markdown as string | undefined,
node: node == null ? undefined : parseNodeArg(node),
}),
}, },
insertNode: { insertNode: {
mcpName: 'insert_node', mcpName: 'insertNode',
inAppKey: 'insertNode', inAppKey: 'insertNode',
description: description:
'Insert a block before/after another block (by attrs.id or anchor text) ' + 'Insert content before/after another block (by attrs.id or anchor text) ' +
'or append it at the end (top level). For before/after you MUST provide ' + 'or append it at the end (top level). For before/after you MUST provide ' +
'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' + 'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' +
'page outline or the page-JSON view. Avoids resending the whole document. ' + 'page outline or the page-JSON view. Avoids resending the whole document. ' +
'Can also insert ' + 'Provide EXACTLY ONE of `markdown` or `node`. ' +
'table structure: to add a tableRow, pass a tableRow node with position ' + '`markdown` (RECOMMENDED): a canonical markdown fragment — may be SEVERAL ' +
'before/after and anchor INSIDE the target table — anchorNodeId of any ' + 'blocks, inserted in order at the anchor; `^[...]` footnotes supported. ' +
'block/cell in it, or anchorText matching the table; to add a ' + '`node` (for precise attr/mark work OR table structure): a raw ProseMirror ' +
'tableCell/tableHeader, use anchorNodeId of a block inside the target row ' + 'node. Table structure is JSON-only (not expressible in markdown): to add a ' +
'(anchorText only resolves top-level blocks, so it cannot target a row). ' + 'tableRow, pass a tableRow node with position before/after and anchor INSIDE ' +
'the target table — anchorNodeId of any block/cell in it, or anchorText ' +
'matching the table; to add a tableCell/tableHeader, use anchorNodeId of a ' +
'block inside the target row (anchorText only resolves top-level blocks). ' +
"`anchorText` is matched against the block's literal rendered plain text " + "`anchorText` is matched against the block's literal rendered plain text " +
'(no markdown); markdown/emoji are tolerated as a fallback; prefer plain ' + '(no markdown); markdown/emoji are tolerated as a fallback; prefer plain ' +
'text or anchorNodeId. Note: append is top-level only and rejects ' + 'text or anchorNodeId. Note: append is top-level only and rejects ' +
@@ -484,15 +530,23 @@ export const SHARED_TOOL_SPECS = {
'JSON object or a JSON string (both accepted). Reversible via page history.', 'JSON object or a JSON string (both accepted). Reversible via page history.',
tier: 'deferred', tier: 'deferred',
catalogLine: catalogLine:
'insertNode — insert a block before/after an anchor, or append at the end.', 'insertNode — insert markdown (or a raw node) before/after an anchor, or append.',
buildShape: (z) => ({ buildShape: (z) => ({
pageId: z.string().min(1), pageId: z.string().min(1),
markdown: z
.string()
.optional()
.describe(
'RECOMMENDED. Canonical markdown to insert; may be several blocks ' +
'(inserted in order). Exactly one of markdown / node.',
),
node: z node: z
.any() .any()
.optional()
.describe( .describe(
'ProseMirror node to insert, e.g. ' + 'For precise attr/mark work or table structure: a ProseMirror node, ' +
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' + 'e.g. {"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
'JSON object or JSON string both accepted.', 'JSON object or JSON string both accepted. Exactly one of markdown / node.',
), ),
position: z position: z
.enum(['before', 'after', 'append']) .enum(['before', 'after', 'append'])
@@ -510,14 +564,27 @@ export const SHARED_TOOL_SPECS = {
'are tolerated as a fallback; prefer plain text or anchorNodeId.', 'are tolerated as a fallback; prefer plain text or anchorNodeId.',
), ),
}), }),
execute: (client, { pageId, node, position, anchorNodeId, anchorText }) => // The XOR (markdown vs node) is enforced at runtime in the client (both
client.insertNode(pageId as string, parseNodeArg(node), { // schema-optional). parseNodeArg only runs on the node path.
position: position as 'before' | 'after' | 'append', execute: (
anchorNodeId: anchorNodeId as string | undefined, client,
anchorText: anchorText as string | undefined, { pageId, markdown, node, position, anchorNodeId, anchorText },
}), ) =>
client.insertNode(
pageId as string,
{
markdown: markdown as string | undefined,
node: node == null ? undefined : parseNodeArg(node),
},
{
position: position as 'before' | 'after' | 'append',
anchorNodeId: anchorNodeId as string | undefined,
anchorText: anchorText as string | undefined,
},
),
}, },
// --- share management --- // --- share management ---
// Unified from the per-layer inline definitions (#294). Both layers already // Unified from the per-layer inline definitions (#294). Both layers already
@@ -525,7 +592,7 @@ export const SHARED_TOOL_SPECS = {
// "per-transport divergence" note on the old inline copies was stale), so // "per-transport divergence" note on the old inline copies was stale), so
// there was no real behavioral divergence to preserve — only wording drift. // there was no real behavioral divergence to preserve — only wording drift.
sharePage: { sharePage: {
mcpName: 'share_page', mcpName: 'sharePage',
inAppKey: 'sharePage', inAppKey: 'sharePage',
// CANONICAL: merges the MCP copy's URL-format + idempotency detail with the // CANONICAL: merges the MCP copy's URL-format + idempotency detail with the
// in-app copy's reversibility note; keeps the security framing both had. // in-app copy's reversibility note; keeps the security framing both had.
@@ -554,7 +621,7 @@ export const SHARED_TOOL_SPECS = {
}, },
unsharePage: { unsharePage: {
mcpName: 'unshare_page', mcpName: 'unsharePage',
inAppKey: 'unsharePage', inAppKey: 'unsharePage',
description: 'Remove the public share of a page (revokes the public URL).', description: 'Remove the public share of a page (revokes the public URL).',
tier: 'deferred', tier: 'deferred',
@@ -568,7 +635,7 @@ export const SHARED_TOOL_SPECS = {
// --- version history --- // --- version history ---
diffPageVersions: { diffPageVersions: {
mcpName: 'diff_page_versions', mcpName: 'diffPageVersions',
inAppKey: 'diffPageVersions', inAppKey: 'diffPageVersions',
description: description:
'Diff two versions of a page and return a Docmost-equivalent change set ' + 'Diff two versions of a page and return a Docmost-equivalent change set ' +
@@ -600,7 +667,7 @@ export const SHARED_TOOL_SPECS = {
}, },
listPageHistory: { listPageHistory: {
mcpName: 'list_page_history', mcpName: 'listPageHistory',
inAppKey: 'listPageHistory', inAppKey: 'listPageHistory',
description: description:
"List a page's saved versions (Docmost auto-snapshots on every save), " + "List a page's saved versions (Docmost auto-snapshots on every save), " +
@@ -621,7 +688,7 @@ export const SHARED_TOOL_SPECS = {
}, },
restorePageVersion: { restorePageVersion: {
mcpName: 'restore_page_version', mcpName: 'restorePageVersion',
inAppKey: 'restorePageVersion', inAppKey: 'restorePageVersion',
description: description:
'Restore a page to a saved version: writes that version\'s content back ' + 'Restore a page to a saved version: writes that version\'s content back ' +
@@ -641,13 +708,13 @@ export const SHARED_TOOL_SPECS = {
// --- markdown round-trip --- // --- markdown round-trip ---
importPageMarkdown: { importPageMarkdown: {
mcpName: 'import_page_markdown', mcpName: 'importPageMarkdown',
inAppKey: 'importPageMarkdown', inAppKey: 'importPageMarkdown',
// IN-APP ONLY (issue #411): the external /mcp surface no longer exposes // IN-APP ONLY (issue #411): the external /mcp surface no longer exposes
// import_page_markdown — the registry loop in index.ts skips inAppOnly specs, // importPageMarkdown — the registry loop in index.ts skips inAppOnly specs,
// so this stays available to the in-app agent (round-tripping an EXPORTED // so this stays available to the in-app agent (round-tripping an EXPORTED
// Docmost-Markdown file) but is removed from the public MCP tool set. Plain // Docmost-Markdown file) but is removed from the public MCP tool set. Plain
// authoring-markdown body replace on the MCP surface is update_page_markdown. // authoring-markdown body replace on the MCP surface is updatePageMarkdown.
inAppOnly: true, inAppOnly: true,
description: description:
"Replace a page's content from a self-contained Docmost-flavoured " + "Replace a page's content from a self-contained Docmost-flavoured " +
@@ -670,7 +737,7 @@ export const SHARED_TOOL_SPECS = {
// --- server-side content copy --- // --- server-side content copy ---
copyPageContent: { copyPageContent: {
mcpName: 'copy_page_content', mcpName: 'copyPageContent',
inAppKey: 'copyPageContent', inAppKey: 'copyPageContent',
description: description:
"Replace targetPageId's content with a copy of sourcePageId's content, " + "Replace targetPageId's content with a copy of sourcePageId's content, " +
@@ -698,7 +765,7 @@ export const SHARED_TOOL_SPECS = {
// stale MCP claim that "Markdown wrappers are tolerated via a strip-and-retry // stale MCP claim that "Markdown wrappers are tolerated via a strip-and-retry
// fallback" is intentionally absent here. // fallback" is intentionally absent here.
editPageText: { editPageText: {
mcpName: 'edit_page_text', mcpName: 'editPageText',
inAppKey: 'editPageText', inAppKey: 'editPageText',
description: description:
"Surgical find/replace inside a page's text, preserving all block " + "Surgical find/replace inside a page's text, preserving all block " +
@@ -747,10 +814,10 @@ export const SHARED_TOOL_SPECS = {
// --- hand a large page to an external consumer without bloating context --- // --- hand a large page to an external consumer without bloating context ---
stashPage: { stashPage: {
mcpName: 'stash_page', mcpName: 'stashPage',
inAppKey: 'stashPage', inAppKey: 'stashPage',
description: description:
'Serialize a whole page (the full ProseMirror JSON, as get_page_json ' + 'Serialize a whole page (the full ProseMirror JSON, as getPageJson ' +
'returns) into an ephemeral in-memory blob and return ONLY a short ' + 'returns) into an ephemeral in-memory blob and return ONLY a short ' +
'anonymous URL to it — the body NEVER enters the model context, so this ' + 'anonymous URL to it — the body NEVER enters the model context, so this ' +
'is the way to hand a large page (or its images) to an external consumer ' + 'is the way to hand a large page (or its images) to an external consumer ' +
@@ -808,15 +875,21 @@ export const SHARED_TOOL_SPECS = {
// in-app layer deliberately allowed a looser value (documented per field). // in-app layer deliberately allowed a looser value (documented per field).
getPage: { getPage: {
mcpName: 'get_page', mcpName: 'getPage',
inAppKey: 'getPage', inAppKey: 'getPage',
description: description:
'Fetch a single page as Markdown by its id. Returns the page title and ' + 'Fetch a single page as Markdown by its id. Returns the page title and ' +
'its Markdown content. The Markdown conversion is LOSSY (block ids, exact ' + 'its Markdown content. The converter is canonical (round-trips text and ' +
'table/callout structure are approximated); for a lossless representation ' + 'block structure), so this is sufficient for text edits; use the ' +
'use the lossless page-JSON read tool. Inline <span data-comment-id> tags in the markdown ' + 'page-JSON read tool only when you need what Markdown cannot carry. The ' +
'are comment highlight anchors (also present for RESOLVED threads) — ' + 'Markdown drops exactly: (1) block ids (not visible in Markdown); ' +
'treat them as markup, not page text.', '(2) resolved-comment anchors (hidden here; only active <span ' +
'data-comment-id> anchors remain); (3) a fixed set of attributes with no ' +
'Markdown representation — table-cell colspan/rowspan/colwidth/' +
'backgroundColor/backgroundColorName, heading/paragraph indent, ' +
'callout.icon, orderedList.type, and link internal/target/rel/class. ' +
'Inline <span data-comment-id> tags in the markdown are comment highlight ' +
'anchors — treat them as markup, not page text.',
tier: 'core', tier: 'core',
catalogLine: 'getPage — fetch a page as Markdown by its id.', catalogLine: 'getPage — fetch a page as Markdown by its id.',
// Reconciled: MCP's stricter .min(1) kept; in-app's more-informative // Reconciled: MCP's stricter .min(1) kept; in-app's more-informative
@@ -841,16 +914,18 @@ export const SHARED_TOOL_SPECS = {
}, },
listPages: { listPages: {
mcpName: 'list_pages', mcpName: 'listPages',
inAppKey: 'listPages', inAppKey: 'listPages',
description: description:
'List the most recent pages (ordered by updatedAt, descending), ' + 'List the most recent pages (ordered by updatedAt, descending), ' +
'optionally scoped to a single space. Returns a bounded list (default ' + 'optionally scoped to a single space. Returns a bounded list (default ' +
'50, max 100) — use search for lookups in large spaces. Pass tree:true ' + '50, max 100) — use search for lookups in large spaces. tree:true (with ' +
"(with spaceId) to instead get the space's full page hierarchy as a " + "spaceId) returns the space's full page hierarchy as a nested tree, but " +
'nested tree.', 'is DEPRECATED — use getTree instead (leaner nodes, plus rootPageId / ' +
'maxDepth).',
tier: 'core', tier: 'core',
catalogLine: "listPages — list recent pages, or a space's full page tree.", catalogLine:
"listPages — list recent pages (tree:true is deprecated; use getTree for the hierarchy).",
buildShape: (z) => ({ buildShape: (z) => ({
spaceId: z spaceId: z
.string() .string()
@@ -883,8 +958,81 @@ export const SHARED_TOOL_SPECS = {
), ),
}, },
getTree: {
mcpName: 'getTree',
inAppKey: 'getTree',
description:
"Get a space's page hierarchy (or one subtree) as a nested tree in a " +
'SINGLE request — completely and without loss. Each node is ' +
'`{ pageId, title, children? }`; children are ordered as in the sidebar. ' +
'Pass rootPageId to return only that page and its descendants (exactly ' +
'one root). Pass maxDepth to trim depth and save tokens (root nodes are ' +
'depth 1, so maxDepth:1 returns only the roots); a node whose children ' +
'were trimmed carries `hasChildren:true` so you can descend later with ' +
'getTree(rootPageId=that page). Prefer this over listPages tree:true.',
tier: 'core',
catalogLine:
"getTree — a space's page hierarchy (or a subtree) as a nested tree in one request.",
buildShape: (z) => ({
spaceId: z
.string()
.min(1)
.describe('The id of the space whose page tree to return.'),
rootPageId: z
.string()
.optional()
.describe(
'Optional page id: return only this page and its descendants (one root).',
),
maxDepth: z
.number()
.int()
.min(1)
.optional()
.describe(
'Optional depth cap (roots are depth 1). maxDepth:1 returns only the ' +
'roots; trimmed nodes carry hasChildren:true.',
),
}),
execute: (client, { spaceId, rootPageId, maxDepth }) =>
client.getTree(
spaceId as string,
rootPageId as string | undefined,
maxDepth as number | undefined,
),
},
getPageContext: {
mcpName: 'getPageContext',
inAppKey: 'getPageContext',
description:
'Given a pageId, get its LOCATION and immediate surroundings (metadata ' +
'only, no page content) in one call — answers "where am I / what is ' +
"around this page\". Returns `{ page: { pageId, title, spaceId }, " +
'breadcrumbs: [{ pageId, title }], children: [{ pageId, title, ' +
'hasChildren }] }`. `breadcrumbs` is the ancestor chain from the space ' +
'root down to the PARENT (the parent is its last element; a root page ' +
'has `breadcrumbs: []`). `children` are the direct children in sidebar ' +
'order, each flagged `hasChildren` so you know which can be expanded ' +
'(descend with getTree(rootPageId=that child) or another getPageContext). ' +
'Ids, titles and child order are consistent with getTree.',
tier: 'core',
catalogLine:
'getPageContext — a page’s breadcrumbs + direct children (where-am-I) in one call.',
buildShape: (z) => ({
pageId: z
.string()
.min(1)
.describe(
'The id of the page to locate (a pageId/UUID, or a slugId from a URL).',
),
}),
execute: (client, { pageId }) =>
client.getPageContext(pageId as string),
},
createPage: { createPage: {
mcpName: 'create_page', mcpName: 'createPage',
inAppKey: 'createPage', inAppKey: 'createPage',
description: description:
'Create a new page with a Markdown body in a space, optionally under a ' + 'Create a new page with a Markdown body in a space, optionally under a ' +
@@ -896,7 +1044,7 @@ export const SHARED_TOOL_SPECS = {
// Reconciled schema DRIFT: the MCP copy pinned `content` to .min(1) while // Reconciled schema DRIFT: the MCP copy pinned `content` to .min(1) while
// the in-app copy left it unbounded and DOCUMENTS an empty body as valid // the in-app copy left it unbounded and DOCUMENTS an empty body as valid
// ("may be empty") — creating an empty page to fill in later is a real use // ("may be empty") — creating an empty page to fill in later is a real use
// case. The looser (no-min) form is kept, so create_page now also accepts an // case. The looser (no-min) form is kept, so createPage now also accepts an
// empty body (harmless — it creates an empty page) and no previously-valid // empty body (harmless — it creates an empty page) and no previously-valid
// in-app input is ever rejected. `title`/`spaceId` keep the MCP .min(1) // in-app input is ever rejected. `title`/`spaceId` keep the MCP .min(1)
// (an empty title or space is never valid). // (an empty title or space is never valid).
@@ -934,7 +1082,7 @@ export const SHARED_TOOL_SPECS = {
}, },
movePage: { movePage: {
mcpName: 'move_page', mcpName: 'movePage',
inAppKey: 'movePage', inAppKey: 'movePage',
description: description:
'Move a page under a new parent page, or to the space root when no ' + 'Move a page under a new parent page, or to the space root when no ' +
@@ -1027,7 +1175,7 @@ export const SHARED_TOOL_SPECS = {
}, },
renamePage: { renamePage: {
mcpName: 'rename_page', mcpName: 'renamePage',
inAppKey: 'renamePage', inAppKey: 'renamePage',
description: description:
'Rename a page (change its title only; the body is untouched, never ' + 'Rename a page (change its title only; the body is untouched, never ' +
@@ -1048,7 +1196,7 @@ export const SHARED_TOOL_SPECS = {
}, },
deletePage: { deletePage: {
mcpName: 'delete_page', mcpName: 'deletePage',
inAppKey: 'deletePage', inAppKey: 'deletePage',
description: description:
'Move a page to the trash — SOFT delete only: the page can be restored ' + 'Move a page to the trash — SOFT delete only: the page can be restored ' +
@@ -1081,7 +1229,7 @@ export const SHARED_TOOL_SPECS = {
}, },
updatePageJson: { updatePageJson: {
mcpName: 'update_page_json', mcpName: 'updatePageJson',
inAppKey: 'updatePageJson', inAppKey: 'updatePageJson',
description: description:
"Replace a page's content with a raw ProseMirror JSON document (lossless " + "Replace a page's content with a raw ProseMirror JSON document (lossless " +
@@ -1137,8 +1285,7 @@ export const SHARED_TOOL_SPECS = {
// registry loop registers it on BOTH hosts (external MCP + in-app agent) — // registry loop registers it on BOTH hosts (external MCP + in-app agent) —
// #411 replaced the old inline in-app `updatePageContent` tool with this. // #411 replaced the old inline in-app `updatePageContent` tool with this.
updatePageMarkdown: { updatePageMarkdown: {
// snake_case for now; camelCase public MCP naming is the next issue (#412). mcpName: 'updatePageMarkdown',
mcpName: 'update_page_markdown',
inAppKey: 'updatePageMarkdown', inAppKey: 'updatePageMarkdown',
description: description:
"Replace a page's body with new Markdown content (and optionally its " + "Replace a page's body with new Markdown content (and optionally its " +
@@ -1172,17 +1319,21 @@ export const SHARED_TOOL_SPECS = {
}, },
exportPageMarkdown: { exportPageMarkdown: {
mcpName: 'export_page_markdown', mcpName: 'exportPageMarkdown',
inAppKey: 'exportPageMarkdown', inAppKey: 'exportPageMarkdown',
// CANONICAL: the MCP copy (a strict superset of the terse in-app wording). // CANONICAL: the MCP copy (a strict superset of the terse in-app wording).
description: description:
'Export a page to a single self-contained, lossless Docmost-flavoured ' + 'Export a page to a single self-contained Docmost-flavoured Markdown ' +
'Markdown file (custom extensions): YAML-free meta header, body with ' + 'file (custom extensions): YAML-free meta header, body with inline ' +
'inline comment anchors and diagrams, and a trailing comments-thread ' + 'comment anchors (resolved ones kept) and diagrams, and a trailing ' +
'block. Designed for a download -> edit body -> page-Markdown import ' + 'comments-thread block. Designed for a download -> edit body -> ' +
'round-trip that preserves everything, including comment highlights. ' + 'page-Markdown import round-trip; block ids regenerate and comment ' +
'Comment THREADS are preserved in the file but are not re-pushed to the ' + 'THREADS, though kept in the file, are not re-pushed to the server on ' +
'server on import.', 'import. The round-trip SILENTLY DROPS a fixed set of attributes with no ' +
'Markdown representation — table-cell merge spans (colspan/rowspan), ' +
'colwidth, backgroundColor/backgroundColorName, heading/paragraph indent, ' +
'callout.icon, orderedList.type, and link internal/target/rel/class. Use ' +
'the page-JSON tools if those must survive.',
tier: 'deferred', tier: 'deferred',
catalogLine: catalogLine:
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).', 'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
@@ -1204,19 +1355,19 @@ export const SHARED_TOOL_SPECS = {
// --- comment tools (unified from the per-layer inline definitions, #294) --- // --- comment tools (unified from the per-layer inline definitions, #294) ---
// //
// create_comment and resolve_comment previously carried a "per-transport // createComment and resolveComment previously carried a "per-transport
// divergence" note in BOTH layers; #294 unifies their schema + description // divergence" note in BOTH layers; #294 unifies their schema + description
// here. Only the four tools that genuinely exist in BOTH layers live in the // here. Only the four tools that genuinely exist in BOTH layers live in the
// registry: create/list/resolve comment and check_new_comments. // registry: create/list/resolve comment and checkNewComments.
// //
// update_comment and delete_comment are intentionally NOT here: they exist // updateComment and deleteComment are intentionally NOT here: they exist
// ONLY on the standalone MCP server. The in-app agent deliberately exposes no // ONLY on the standalone MCP server. The in-app agent deliberately exposes no
// hard comment edit/delete tool (comment edits are irreversible / not // hard comment edit/delete tool (comment edits are irreversible / not
// version-tracked; see the guardrail tests in ai-chat-tools.service.spec.ts), // version-tracked; see the guardrail tests in ai-chat-tools.service.spec.ts),
// so there is nothing to unify — they stay inline in index.ts. // so there is nothing to unify — they stay inline in index.ts.
createComment: { createComment: {
mcpName: 'create_comment', mcpName: 'createComment',
inAppKey: 'createComment', inAppKey: 'createComment',
// CANONICAL: the in-app copy (the more-maintained one). It keeps the same // CANONICAL: the in-app copy (the more-maintained one). It keeps the same
// rules as the MCP copy — inline-only, top-level requires a `selection`, no // rules as the MCP copy — inline-only, top-level requires a `selection`, no
@@ -1231,7 +1382,7 @@ export const SHARED_TOOL_SPECS = {
'(which gets highlighted); page-level comments are NOT supported. A ' + '(which gets highlighted); page-level comments are NOT supported. A ' +
'new top-level comment REQUIRES a `selection`. Replies inherit the ' + 'new top-level comment REQUIRES a `selection`. Replies inherit the ' +
"parent's anchor and take no selection. Always COPY the `selection` " + "parent's anchor and take no selection. Always COPY the `selection` " +
'VERBATIM from get_page / search_in_page output — do NOT quote it from ' + 'VERBATIM from getPage / searchInPage output — do NOT quote it from ' +
'memory (stale-memory quoting is the top cause of anchor misses). If the ' + 'memory (stale-memory quoting is the top cause of anchor misses). If the ' +
'call fails with a "selection not found" error, the error quotes the ' + 'call fails with a "selection not found" error, the error quotes the ' +
"closest block text (or says the selection spans multiple blocks); retry " + "closest block text (or says the selection spans multiple blocks); retry " +
@@ -1284,25 +1435,25 @@ export const SHARED_TOOL_SPECS = {
}), }),
// Both hosts enforce the SAME guardrails (a top-level comment requires a // Both hosts enforce the SAME guardrails (a top-level comment requires a
// selection; suggestedText is forbidden on a reply / without a selection) but // selection; suggestedText is forbidden on a reply / without a selection) but
// with per-layer error wording (snake_case 'create_comment:' on the MCP // with per-layer error wording (both now prefixed 'createComment', the MCP
// surface, camelCase 'createComment' in-app) and different result shapes (MCP // and in-app messages differing only in their trailing guidance) and different
// jsonContent, in-app projects `{ commentId, pageId }`). Preserved byte-for- // result shapes (MCP jsonContent, in-app projects `{ commentId, pageId }`).
// byte via the two overrides. // Preserved byte-for-byte via the two overrides.
mcpExecute: async (client, { pageId, content, selection, parentCommentId, suggestedText }) => { mcpExecute: async (client, { pageId, content, selection, parentCommentId, suggestedText }) => {
if (!parentCommentId && (!selection || !(selection as string).trim())) { if (!parentCommentId && (!selection || !(selection as string).trim())) {
throw new Error( throw new Error(
"create_comment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.", "createComment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.",
); );
} }
if (suggestedText !== undefined) { if (suggestedText !== undefined) {
if (parentCommentId) { if (parentCommentId) {
throw new Error( throw new Error(
"create_comment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.", "createComment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.",
); );
} }
if (!selection || !(selection as string).trim()) { if (!selection || !(selection as string).trim()) {
throw new Error( throw new Error(
"create_comment: 'suggestedText' requires a 'selection' to anchor and rewrite.", "createComment: 'suggestedText' requires a 'selection' to anchor and rewrite.",
); );
} }
} }
@@ -1348,7 +1499,7 @@ export const SHARED_TOOL_SPECS = {
}, },
listComments: { listComments: {
mcpName: 'list_comments', mcpName: 'listComments',
inAppKey: 'listComments', inAppKey: 'listComments',
// CANONICAL: the two copies are near-identical; the MCP copy is the // CANONICAL: the two copies are near-identical; the MCP copy is the
// superset (it keeps the "(pagination is handled internally)" note the // superset (it keeps the "(pagination is handled internally)" note the
@@ -1375,10 +1526,10 @@ export const SHARED_TOOL_SPECS = {
}, },
resolveComment: { resolveComment: {
mcpName: 'resolve_comment', mcpName: 'resolveComment',
inAppKey: 'resolveComment', inAppKey: 'resolveComment',
// CANONICAL: the MCP copy's richer wording, minus its snake_case reference // CANONICAL: the MCP copy's richer wording, minus its reference
// to `delete_comment` (a sibling tool that does NOT exist in the in-app // to `deleteComment` (a sibling tool that does NOT exist in the in-app
// layer) — rephrased transport-neutrally per the registry convention. // layer) — rephrased transport-neutrally per the registry convention.
description: description:
'Resolve (close) or reopen a top-level comment thread (reversible — ' + 'Resolve (close) or reopen a top-level comment thread (reversible — ' +
@@ -1415,7 +1566,7 @@ export const SHARED_TOOL_SPECS = {
}, },
checkNewComments: { checkNewComments: {
mcpName: 'check_new_comments', mcpName: 'checkNewComments',
inAppKey: 'checkNewComments', inAppKey: 'checkNewComments',
// CANONICAL: the MCP copy (the more detailed of the two). The MCP layer's // CANONICAL: the MCP copy (the more detailed of the two). The MCP layer's
// execute-side guard that rejects an unparseable `since` timestamp stays in // execute-side guard that rejects an unparseable `since` timestamp stays in
@@ -1486,15 +1637,15 @@ export const SHARED_TOOL_SPECS = {
// behavior) plus the in-app copy's "Reversible via page history" note; sibling // behavior) plus the in-app copy's "Reversible via page history" note; sibling
// tool references are phrased transport-neutrally. // tool references are phrased transport-neutrally.
// //
// NOT here (kept inline in index.ts): table_get / getTable. Its MCP tool name // NOT here (kept inline in index.ts): tableGet / getTable. Its MCP tool name
// is noun-first (`table_get`) while the in-app key is verb-first (`getTable`), // is noun-first (`tableGet`) while the in-app key is verb-first (`getTable`),
// so it breaks the snake_case(inAppKey) naming convention the registry enforces // so it breaks the mcpName === inAppKey naming convention the registry enforces
// (shared-tool-specs.contract.spec.ts). Renaming the public MCP tool would // (shared-tool-specs.contract.spec.ts). Renaming either public name would break
// break external clients, so it stays per-transport (its in-app param was still // external clients or the in-app tool key, so it stays per-transport (its in-app
// aligned to `table` for consistency with the migrated trio below). // param was still aligned to `table` for consistency with the migrated trio below).
tableInsertRow: { tableInsertRow: {
mcpName: 'table_insert_row', mcpName: 'tableInsertRow',
inAppKey: 'tableInsertRow', inAppKey: 'tableInsertRow',
description: description:
'Insert a row of plain-text cells into a table. `table` is `#<index>` ' + 'Insert a row of plain-text cells into a table. `table` is `#<index>` ' +
@@ -1527,7 +1678,7 @@ export const SHARED_TOOL_SPECS = {
}, },
tableDeleteRow: { tableDeleteRow: {
mcpName: 'table_delete_row', mcpName: 'tableDeleteRow',
inAppKey: 'tableDeleteRow', inAppKey: 'tableDeleteRow',
description: description:
'Delete the row at 0-based `index` from a table (`table` is `#<index>` ' + 'Delete the row at 0-based `index` from a table (`table` is `#<index>` ' +
@@ -1550,7 +1701,7 @@ export const SHARED_TOOL_SPECS = {
}, },
tableUpdateCell: { tableUpdateCell: {
mcpName: 'table_update_cell', mcpName: 'tableUpdateCell',
inAppKey: 'tableUpdateCell', inAppKey: 'tableUpdateCell',
description: description:
'Set the plain-text content of cell [row, col] (0-based) in a table ' + 'Set the plain-text content of cell [row, col] (0-based) in a table ' +
@@ -1590,7 +1741,7 @@ export const SHARED_TOOL_SPECS = {
// external MCP clients see identical tool names, fields and text. // external MCP clients see identical tool names, fields and text.
insertFootnote: { insertFootnote: {
mcpName: 'insert_footnote', mcpName: 'insertFootnote',
inAppKey: 'insertFootnote', inAppKey: 'insertFootnote',
description: description:
'Insert an AUTHOR-INLINE footnote: you specify only WHERE (anchorText) ' + 'Insert an AUTHOR-INLINE footnote: you specify only WHERE (anchorText) ' +
@@ -1627,7 +1778,7 @@ export const SHARED_TOOL_SPECS = {
}, },
insertImage: { insertImage: {
mcpName: 'insert_image', mcpName: 'insertImage',
inAppKey: 'insertImage', inAppKey: 'insertImage',
description: description:
'Download an image from a web (http/https) URL and insert it into ' + 'Download an image from a web (http/https) URL and insert it into ' +
@@ -1671,7 +1822,7 @@ export const SHARED_TOOL_SPECS = {
}, },
replaceImage: { replaceImage: {
mcpName: 'replace_image', mcpName: 'replaceImage',
inAppKey: 'replaceImage', inAppKey: 'replaceImage',
description: description:
'Replace an existing image on a page with a new image fetched from a web ' + 'Replace an existing image on a page with a new image fetched from a web ' +
@@ -1709,15 +1860,15 @@ export const SHARED_TOOL_SPECS = {
// --- draw.io diagrams (issue #423 stage 1, #424 stage 2) --- // --- draw.io diagrams (issue #423 stage 1, #424 stage 2) ---
drawioGet: { drawioGet: {
mcpName: 'drawio_get', mcpName: 'drawioGet',
inAppKey: 'drawioGet', inAppKey: 'drawioGet',
description: description:
'Read a draw.io diagram on a page as mxGraph XML (default) or as its raw ' + 'Read a draw.io diagram on a page as mxGraph XML (default) or as its raw ' +
'`.drawio.svg`. `node` is the drawio node\'s attrs.id (from get_outline / ' + '`.drawio.svg`. `node` is the drawio node\'s attrs.id (from getOutline / ' +
'get_page_json) or "#<index>" for a top-level block. Returns the decoded ' + 'getPageJson) or "#<index>" for a top-level block. Returns the decoded ' +
'mxGraphModel XML plus meta { attachmentId, title, width, height, ' + 'mxGraphModel XML plus meta { attachmentId, title, width, height, ' +
'cellCount, hash }. `hash` is the optimistic-lock key you MUST pass back ' + 'cellCount, hash }. `hash` is the optimistic-lock key you MUST pass back ' +
'as baseHash to drawio_update. Diagrams a human saved from the editor ' + 'as baseHash to drawioUpdate. Diagrams a human saved from the editor ' +
'(including draw.io\'s compressed format) decode losslessly.', '(including draw.io\'s compressed format) decode losslessly.',
tier: 'deferred', tier: 'deferred',
catalogLine: catalogLine:
@@ -1742,7 +1893,7 @@ export const SHARED_TOOL_SPECS = {
}, },
drawioCreate: { drawioCreate: {
mcpName: 'drawio_create', mcpName: 'drawioCreate',
inAppKey: 'drawioCreate', inAppKey: 'drawioCreate',
description: description:
'Create a draw.io diagram from mxGraph XML and insert it as a diagram ' + 'Create a draw.io diagram from mxGraph XML and insert it as a diagram ' +
@@ -1753,14 +1904,14 @@ export const SHARED_TOOL_SPECS = {
'source/target and every parent resolve, style parses, no XML comments, ' + 'source/target and every parent resolve, style parses, no XML comments, ' +
'value escaping) — a violation returns a structured error naming the rule ' + 'value escaping) — a violation returns a structured error naming the rule ' +
'and cellId so you can fix and retry. `where` positions the block like ' + 'and cellId so you can fix and retry. `where` positions the block like ' +
'insert_node: position before/after (with exactly one of anchorNodeId or ' + 'insertNode: position before/after (with exactly one of anchorNodeId or ' +
'anchorText) or append. Returns { nodeId, attachmentId, warnings }. The ' + 'anchorText) or append. Returns { nodeId, attachmentId, warnings }. The ' +
'returned `nodeId` is an index-based "#<index>" handle (drawio nodes carry ' + 'returned `nodeId` is an index-based "#<index>" handle (drawio nodes carry ' +
'no attrs.id): it addresses the new top-level block and can be fed straight ' + 'no attrs.id): it addresses the new top-level block and can be fed straight ' +
'back into drawio_get / drawio_update for THIS document. It is positional, ' + 'back into drawioGet / drawioUpdate for THIS document. It is positional, ' +
'so if you add or remove blocks before it, re-resolve via get_outline. The ' + 'so if you add or remove blocks before it, re-resolve via getOutline. The ' +
'diagram is editable in the draw.io editor and can be re-read with ' + 'diagram is editable in the draw.io editor and can be re-read with ' +
'drawio_get.' + 'drawioGet.' +
DRAWIO_HARD_RULES, DRAWIO_HARD_RULES,
tier: 'deferred', tier: 'deferred',
catalogLine: catalogLine:
@@ -1812,14 +1963,14 @@ export const SHARED_TOOL_SPECS = {
}, },
drawioUpdate: { drawioUpdate: {
mcpName: 'drawio_update', mcpName: 'drawioUpdate',
inAppKey: 'drawioUpdate', inAppKey: 'drawioUpdate',
description: description:
'Replace a draw.io diagram\'s content with new mxGraph XML (same lint ' + 'Replace a draw.io diagram\'s content with new mxGraph XML (same lint ' +
'pipeline as drawio_create). `baseHash` is MANDATORY: pass the hash from ' + 'pipeline as drawioCreate). `baseHash` is MANDATORY: pass the hash from ' +
'the drawio_get you based the edit on. If the diagram changed since ' + 'the drawioGet you based the edit on. If the diagram changed since ' +
'(a human or another agent edited it) the hash mismatches and the update ' + '(a human or another agent edited it) the hash mismatches and the update ' +
'is refused with a conflict error — re-read with drawio_get and retry. On ' + 'is refused with a conflict error — re-read with drawioGet and retry. On ' +
'success it overwrites the diagram attachment and updates the node ' + 'success it overwrites the diagram attachment and updates the node ' +
'width/height. `node` is the drawio node attrs.id or "#<index>".' + 'width/height. `node` is the drawio node attrs.id or "#<index>".' +
DRAWIO_HARD_RULES, DRAWIO_HARD_RULES,
@@ -1841,7 +1992,7 @@ export const SHARED_TOOL_SPECS = {
baseHash: z baseHash: z
.string() .string()
.min(1) .min(1)
.describe('The meta.hash from the drawio_get this edit is based on.'), .describe('The meta.hash from the drawioGet this edit is based on.'),
layout: z layout: z
.enum(['elk']) .enum(['elk'])
.optional() .optional()
@@ -1863,7 +2014,7 @@ export const SHARED_TOOL_SPECS = {
}, },
drawioShapes: { drawioShapes: {
mcpName: 'drawio_shapes', mcpName: 'drawioShapes',
inAppKey: 'drawioShapes', inAppKey: 'drawioShapes',
description: description:
'Look up VERIFIED draw.io stencil style-strings so you never guess a ' + 'Look up VERIFIED draw.io stencil style-strings so you never guess a ' +
@@ -1876,7 +2027,7 @@ export const SHARED_TOOL_SPECS = {
'stencils to working replacements (e.g. dynamodb_table -> dynamodb) with a ' + 'stencils to working replacements (e.g. dynamodb_table -> dynamodb) with a ' +
'note. Each hit is { style, w, h, title, type, category?, note? } — copy ' + 'note. Each hit is { style, w, h, title, type, category?, note? } — copy ' +
'`style` verbatim onto the cell and use w/h as the default size. Call this ' + '`style` verbatim onto the cell and use w/h as the default size. Call this ' +
'BEFORE drawio_create/drawio_update whenever you need a specific icon ' + 'BEFORE drawioCreate/drawioUpdate whenever you need a specific icon ' +
'(AWS/Azure/GCP/network/UML/flowchart).', '(AWS/Azure/GCP/network/UML/flowchart).',
tier: 'deferred', tier: 'deferred',
catalogLine: catalogLine:
@@ -1895,7 +2046,7 @@ export const SHARED_TOOL_SPECS = {
.optional() .optional()
.describe('Max results (default 12, capped at 50).'), .describe('Max results (default 12, capped at 50).'),
}), }),
// INLINE on both hosts (no `execute`): drawio_shapes calls the PURE helper // INLINE on both hosts (no `execute`): drawioShapes calls the PURE helper
// searchShapes, which is NOT a client method — it reads the bundled shape // searchShapes, which is NOT a client method — it reads the bundled shape
// catalog via `import.meta.url` (drawio-shapes.ts). tool-specs.ts is // catalog via `import.meta.url` (drawio-shapes.ts). tool-specs.ts is
// type-checked FROM SOURCE by the in-app server under module:commonjs, where a // type-checked FROM SOURCE by the in-app server under module:commonjs, where a
@@ -1909,7 +2060,7 @@ export const SHARED_TOOL_SPECS = {
}, },
drawioGuide: { drawioGuide: {
mcpName: 'drawio_guide', mcpName: 'drawioGuide',
inAppKey: 'drawioGuide', inAppKey: 'drawioGuide',
description: description:
'Progressive-disclosure draw.io authoring reference. Call with a `section` ' + 'Progressive-disclosure draw.io authoring reference. Call with a `section` ' +
@@ -1920,7 +2071,7 @@ export const SHARED_TOOL_SPECS = {
'child coords, cross-container edges, swimlanes), "icons-aws" (the ' + 'child coords, cross-container edges, swimlanes), "icons-aws" (the ' +
'service/resource icon patterns, category colors, rebrandings, blocklist), ' + 'service/resource icon patterns, category colors, rebrandings, blocklist), ' +
'"icons-azure" (portable image-style paths). Omit `section` to get the ' + '"icons-azure" (portable image-style paths). Omit `section` to get the ' +
'index of sections. Pair with drawio_shapes for exact stencil styles.', 'index of sections. Pair with drawioShapes for exact stencil styles.',
tier: 'deferred', tier: 'deferred',
catalogLine: catalogLine:
'drawioGuide — on-demand draw.io authoring reference (skeleton/layout/containers/icons).', 'drawioGuide — on-demand draw.io authoring reference (skeleton/layout/containers/icons).',
@@ -1930,10 +2081,10 @@ export const SHARED_TOOL_SPECS = {
.optional() .optional()
.describe('Which section to read; omit for the section index.'), .describe('Which section to read; omit for the section index.'),
}), }),
// INLINE on both hosts (no `execute`) — same reason as drawio_shapes above: // INLINE on both hosts (no `execute`) — same reason as drawioShapes above:
// drawio_guide calls the PURE helper getGuideSection (drawio-guide.ts, no // drawioGuide calls the PURE helper getGuideSection (drawio-guide.ts, no
// client, no network). getGuideSection itself has no `import.meta`, but it is // client, no network). getGuideSection itself has no `import.meta`, but it is
// kept inline for SYMMETRY with drawio_shapes (both drawio helper tools wired // kept inline for SYMMETRY with drawioShapes (both drawio helper tools wired
// the same way in one place) and to avoid pulling any drawio lib source into // the same way in one place) and to avoid pulling any drawio lib source into
// the in-app server's commonjs type-check. `inlineBothHosts` makes both loops // the in-app server's commonjs type-check. `inlineBothHosts` makes both loops
// skip it; index.ts and ai-chat-tools.service.ts register it directly. // skip it; index.ts and ai-chat-tools.service.ts register it directly.
+76 -76
View File
@@ -84,20 +84,20 @@ async function main() {
let pageId = null; let pageId = null;
try { try {
// 1. create_page: title with spaces must survive (was: underscores bug) // 1. createPage: title with spaces must survive (was: underscores bug)
const created = await client.createPage("Тест апгрейда MCP сервера", MD, spaceId); const created = await client.createPage("Тест апгрейда MCP сервера", MD, spaceId);
pageId = created.data.id; pageId = created.data.id;
check("create_page: title keeps spaces", created.data.title === "Тест апгрейда MCP сервера", created.data.title); check("createPage: title keeps spaces", created.data.title === "Тест апгрейда MCP сервера", created.data.title);
check("create_page: slugId exposed", typeof created.data.slugId === "string" && created.data.slugId.length > 0, created.data.slugId); check("createPage: slugId exposed", typeof created.data.slugId === "string" && created.data.slugId.length > 0, created.data.slugId);
// 2. get_page_json: raw ProseMirror with callout + table // 2. getPageJson: raw ProseMirror with callout + table
const pj = await client.getPageJson(pageId); const pj = await client.getPageJson(pageId);
const types = pj.content.content.map((n) => n.type); const types = pj.content.content.map((n) => n.type);
check("get_page_json: callout node present", types.includes("callout"), types.join(",")); check("getPageJson: callout node present", types.includes("callout"), types.join(","));
check("get_page_json: table node present", types.includes("table")); check("getPageJson: table node present", types.includes("table"));
check("get_page_json: slugId present", !!pj.slugId); check("getPageJson: slugId present", !!pj.slugId);
// 3. edit_page_text: surgical replace, ids preserved // 3. editPageText: surgical replace, ids preserved
const idsBefore = JSON.stringify( const idsBefore = JSON.stringify(
pj.content.content.filter((n) => n.attrs?.id).map((n) => n.attrs.id), pj.content.content.filter((n) => n.attrs?.id).map((n) => n.attrs.id),
); );
@@ -105,26 +105,26 @@ async function main() {
{ find: "БУКВОЕД", replace: "КНИГОЛЮБ" }, { find: "БУКВОЕД", replace: "КНИГОЛЮБ" },
{ find: "[1]", replace: "[42]" }, { find: "[1]", replace: "[42]" },
]); ]);
check("edit_page_text: both edits applied", editRes.applied.every((e) => e.replacements === 1)); check("editPageText: both edits applied", editRes.applied.every((e) => e.replacements === 1));
await new Promise((r) => setTimeout(r, 16000)); // wait for server persistence await new Promise((r) => setTimeout(r, 16000)); // wait for server persistence
const pj2 = await client.getPageJson(pageId); const pj2 = await client.getPageJson(pageId);
const text2 = JSON.stringify(pj2.content); const text2 = JSON.stringify(pj2.content);
check("edit_page_text: replacement visible", text2.includes("КНИГОЛЮБ") && text2.includes("[42]")); check("editPageText: replacement visible", text2.includes("КНИГОЛЮБ") && text2.includes("[42]"));
check("edit_page_text: old text gone", !text2.includes("БУКВОЕД")); check("editPageText: old text gone", !text2.includes("БУКВОЕД"));
const idsAfter = JSON.stringify( const idsAfter = JSON.stringify(
pj2.content.content.filter((n) => n.attrs?.id).map((n) => n.attrs.id), pj2.content.content.filter((n) => n.attrs?.id).map((n) => n.attrs.id),
); );
check("edit_page_text: block ids preserved", idsBefore === idsAfter); check("editPageText: block ids preserved", idsBefore === idsAfter);
check("edit_page_text: callout survived", JSON.stringify(pj2.content).includes('"callout"')); check("editPageText: callout survived", JSON.stringify(pj2.content).includes('"callout"'));
check("edit_page_text: table survived", pj2.content.content.some((n) => n.type === "table")); check("editPageText: table survived", pj2.content.content.some((n) => n.type === "table"));
// 4. error reporting: ambiguous and missing finds // 4. error reporting: ambiguous and missing finds
let err1 = ""; let err1 = "";
try { await client.editPageText(pageId, [{ find: "Колонка", replace: "X" }]); } catch (e) { err1 = e.message; } try { await client.editPageText(pageId, [{ find: "Колонка", replace: "X" }]); } catch (e) { err1 = e.message; }
check("edit_page_text: ambiguous match rejected", err1.includes("matches"), err1); check("editPageText: ambiguous match rejected", err1.includes("matches"), err1);
let err2 = ""; let err2 = "";
try { await client.editPageText(pageId, [{ find: "НЕСУЩЕСТВУЮЩЕЕ", replace: "X" }]); } catch (e) { err2 = e.message; } try { await client.editPageText(pageId, [{ find: "НЕСУЩЕСТВУЮЩЕЕ", replace: "X" }]); } catch (e) { err2 = e.message; }
check("edit_page_text: missing text reported", err2.includes("not found"), err2); check("editPageText: missing text reported", err2.includes("not found"), err2);
// 5. update_page (markdown): table + callout must survive the re-import // 5. update_page (markdown): table + callout must survive the re-import
await client.updatePage(pageId, MD + "\nДобавленный абзац.\n"); await client.updatePage(pageId, MD + "\nДобавленный абзац.\n");
@@ -137,21 +137,21 @@ async function main() {
const cellText = JSON.stringify(tableNode); const cellText = JSON.stringify(tableNode);
check("update_page md: table cells intact", cellText.includes("четыре") && cellText.includes("Колонка А")); check("update_page md: table cells intact", cellText.includes("четыре") && cellText.includes("Колонка А"));
// 6. update_page_json: lossless write round-trip // 6. updatePageJson: lossless write round-trip
pj3.content.content.push({ pj3.content.content.push({
type: "paragraph", type: "paragraph",
attrs: { id: "testidjsonpush", indent: 0, textAlign: null }, attrs: { id: "testidjsonpush", indent: 0, textAlign: null },
content: [{ type: "text", text: "Абзац, добавленный через update_page_json." }], content: [{ type: "text", text: "Абзац, добавленный через updatePageJson." }],
}); });
await client.updatePageJson(pageId, pj3.content); await client.updatePageJson(pageId, pj3.content);
await new Promise((r) => setTimeout(r, 16000)); await new Promise((r) => setTimeout(r, 16000));
const pj4 = await client.getPageJson(pageId); const pj4 = await client.getPageJson(pageId);
const lastNode = pj4.content.content[pj4.content.content.length - 1]; const lastNode = pj4.content.content[pj4.content.content.length - 1];
check("update_page_json: paragraph appended", JSON.stringify(pj4.content).includes("добавленный через update_page_json")); check("updatePageJson: paragraph appended", JSON.stringify(pj4.content).includes("добавленный через updatePageJson"));
check("update_page_json: custom node id preserved", lastNode.attrs?.id === "testidjsonpush", lastNode.attrs?.id); check("updatePageJson: custom node id preserved", lastNode.attrs?.id === "testidjsonpush", lastNode.attrs?.id);
// 6b. images: upload / insert / replace (clean src, fresh attachment on replace). // 6b. images: upload / insert / replace (clean src, fresh attachment on replace).
// insert_image / replace_image take an http(s) URL that the SERVER fetches; // insertImage / replaceImage take an http(s) URL that the SERVER fetches;
// local file paths are intentionally unsupported. The Docmost server runs on // local file paths are intentionally unsupported. The Docmost server runs on
// the same host as this test, so serve the PNG bytes over a throwaway // the same host as this test, so serve the PNG bytes over a throwaway
// localhost HTTP server it can reach. // localhost HTTP server it can reach.
@@ -186,13 +186,13 @@ async function main() {
validateStatus: () => true, validateStatus: () => true,
}); });
// insert_image: append the first PNG, src must be clean (no ?v=) and fetchable. // insertImage: append the first PNG, src must be clean (no ?v=) and fetchable.
const ins = await client.insertImage(pageId, urlA); const ins = await client.insertImage(pageId, urlA);
check("insert_image: src has no ?v= cache-buster", !ins.src.includes("?v="), ins.src); check("insertImage: src has no ?v= cache-buster", !ins.src.includes("?v="), ins.src);
const fileA = await fetchFile(ins.src); const fileA = await fetchFile(ins.src);
check("insert_image: file fetch returns 200", fileA.status === 200, `status=${fileA.status}`); check("insertImage: file fetch returns 200", fileA.status === 200, `status=${fileA.status}`);
check( check(
"insert_image: content-type is image/*", "insertImage: content-type is image/*",
String(fileA.headers["content-type"] || "").startsWith("image/"), String(fileA.headers["content-type"] || "").startsWith("image/"),
String(fileA.headers["content-type"]), String(fileA.headers["content-type"]),
); );
@@ -209,25 +209,25 @@ async function main() {
}; };
const imgNode = findImage(pjImg.content.content); const imgNode = findImage(pjImg.content.content);
const oldAttachmentId = imgNode?.attrs?.attachmentId; const oldAttachmentId = imgNode?.attrs?.attachmentId;
check("insert_image: image node present after persist", !!oldAttachmentId, oldAttachmentId); check("insertImage: image node present after persist", !!oldAttachmentId, oldAttachmentId);
// replace_image: must create a NEW attachment with a clean, fetchable URL. // replaceImage: must create a NEW attachment with a clean, fetchable URL.
// The 200 fetch is the assertion that catches the in-place-overwrite HTTP 500 regression. // The 200 fetch is the assertion that catches the in-place-overwrite HTTP 500 regression.
const rep = await client.replaceImage(pageId, oldAttachmentId, urlB); const rep = await client.replaceImage(pageId, oldAttachmentId, urlB);
check("replace_image: new attachment id differs from old", rep.newAttachmentId !== oldAttachmentId, `${oldAttachmentId} -> ${rep.newAttachmentId}`); check("replaceImage: new attachment id differs from old", rep.newAttachmentId !== oldAttachmentId, `${oldAttachmentId} -> ${rep.newAttachmentId}`);
check("replace_image: src has no ?v= cache-buster", !rep.src.includes("?v="), rep.src); check("replaceImage: src has no ?v= cache-buster", !rep.src.includes("?v="), rep.src);
const fileB = await fetchFile(rep.src); const fileB = await fetchFile(rep.src);
check("replace_image: new file fetch returns 200", fileB.status === 200, `status=${fileB.status}`); check("replaceImage: new file fetch returns 200", fileB.status === 200, `status=${fileB.status}`);
check( check(
"replace_image: new content-type is image/*", "replaceImage: new content-type is image/*",
String(fileB.headers["content-type"] || "").startsWith("image/"), String(fileB.headers["content-type"] || "").startsWith("image/"),
String(fileB.headers["content-type"]), String(fileB.headers["content-type"]),
); );
await new Promise((r) => setTimeout(r, 16000)); await new Promise((r) => setTimeout(r, 16000));
const pjImg2 = await client.getPageJson(pageId); const pjImg2 = await client.getPageJson(pageId);
check("replace_image: page has new attachment id", !!findImage(pjImg2.content.content, rep.newAttachmentId), rep.newAttachmentId); check("replaceImage: page has new attachment id", !!findImage(pjImg2.content.content, rep.newAttachmentId), rep.newAttachmentId);
check("replace_image: old attachment id repointed away", !findImage(pjImg2.content.content, oldAttachmentId), oldAttachmentId); check("replaceImage: old attachment id repointed away", !findImage(pjImg2.content.content, oldAttachmentId), oldAttachmentId);
} finally { } finally {
imgServer.close(); imgServer.close();
} }
@@ -275,10 +275,10 @@ async function main() {
await client.editPageText(fid, [{ find: "PRICEMARK", replace: "$& costs $100" }]); await client.editPageText(fid, [{ find: "PRICEMARK", replace: "$& costs $100" }]);
await new Promise((r) => setTimeout(r, 16000)); await new Promise((r) => setTimeout(r, 16000));
const ftext = JSON.stringify((await client.getPageJson(fid)).content); const ftext = JSON.stringify((await client.getPageJson(fid)).content);
check("feature: edit_page_text inserts $-pattern literally (no $& expansion)", ftext.includes("$& costs $100") && !ftext.includes("PRICEMARK costs")); check("feature: editPageText inserts $-pattern literally (no $& expansion)", ftext.includes("$& costs $100") && !ftext.includes("PRICEMARK costs"));
let badThrew = false; let badThrew = false;
try { await client.replaceImage(fid, "00000000-0000-0000-0000-000000000000", featPng); } catch (e) { badThrew = /no image with attachmentId/.test(e.message); } try { await client.replaceImage(fid, "00000000-0000-0000-0000-000000000000", featPng); } catch (e) { badThrew = /no image with attachmentId/.test(e.message); }
check("feature: replace_image with unknown id throws (no orphan upload)", badThrew); check("feature: replaceImage with unknown id throws (no orphan upload)", badThrew);
} finally { } finally {
try { await client.deletePage(fid); } catch {} try { await client.deletePage(fid); } catch {}
try { unlinkSync(featPng); } catch {} try { unlinkSync(featPng); } catch {}
@@ -286,7 +286,7 @@ async function main() {
} }
// 6d. node ops: patch / insert / delete a block by id on a throwaway page. // 6d. node ops: patch / insert / delete a block by id on a throwaway page.
// Three paragraphs are written with KNOWN ids via update_page_json so the // Three paragraphs are written with KNOWN ids via updatePageJson so the
// ids can be targeted directly; each op is verified via getPageJson after // ids can be targeted directly; each op is verified via getPageJson after
// the standard 16s persistence wait. // the standard 16s persistence wait.
{ {
@@ -348,7 +348,7 @@ async function main() {
} }
} }
// 6e. rename_page: title-only update must leave the content untouched. // 6e. renamePage: title-only update must leave the content untouched.
{ {
const rp = await client.createPage("E2E rename before " + Date.now(), "Rename body marker RENAMEBODY.", spaceId); const rp = await client.createPage("E2E rename before " + Date.now(), "Rename body marker RENAMEBODY.", spaceId);
const rid = rp.data.id; const rid = rp.data.id;
@@ -357,19 +357,19 @@ async function main() {
const beforeContent = JSON.stringify(beforeJson); const beforeContent = JSON.stringify(beforeJson);
const newTitle = "E2E rename AFTER " + Date.now(); const newTitle = "E2E rename AFTER " + Date.now();
const rr = await client.renamePage(rid, newTitle); const rr = await client.renamePage(rid, newTitle);
check("rename_page: returns success+title", rr.success === true && rr.title === newTitle, JSON.stringify(rr)); check("renamePage: returns success+title", rr.success === true && rr.title === newTitle, JSON.stringify(rr));
await new Promise((r) => setTimeout(r, 16000)); await new Promise((r) => setTimeout(r, 16000));
const afterJson = await client.getPageJson(rid); const afterJson = await client.getPageJson(rid);
check("rename_page: title changed", afterJson.title === newTitle, afterJson.title); check("renamePage: title changed", afterJson.title === newTitle, afterJson.title);
check("rename_page: content unchanged", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("RENAMEBODY")); check("renamePage: content unchanged", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("RENAMEBODY"));
const afterMd = (await client.getPage(rid)).data; const afterMd = (await client.getPage(rid)).data;
check("rename_page: get_page reflects new title", afterMd.title === newTitle, afterMd.title); check("renamePage: getPage reflects new title", afterMd.title === newTitle, afterMd.title);
} finally { } finally {
try { await client.deletePage(rid); } catch {} try { await client.deletePage(rid); } catch {}
} }
} }
// 6f. update_page_json title-only: omitting content updates the title and // 6f. updatePageJson title-only: omitting content updates the title and
// leaves the body intact; supplying neither content nor title throws. // leaves the body intact; supplying neither content nor title throws.
{ {
const up = await client.createPage("E2E upj-title before " + Date.now(), "Title-only body marker UPJTITLEBODY.", spaceId); const up = await client.createPage("E2E upj-title before " + Date.now(), "Title-only body marker UPJTITLEBODY.", spaceId);
@@ -378,20 +378,20 @@ async function main() {
const beforeContent = JSON.stringify((await client.getPageJson(uid)).content); const beforeContent = JSON.stringify((await client.getPageJson(uid)).content);
const newTitle = "E2E upj-title AFTER " + Date.now(); const newTitle = "E2E upj-title AFTER " + Date.now();
const ur = await client.updatePageJson(uid, undefined, newTitle); const ur = await client.updatePageJson(uid, undefined, newTitle);
check("update_page_json title-only: succeeds", ur.success === true, JSON.stringify(ur)); check("updatePageJson title-only: succeeds", ur.success === true, JSON.stringify(ur));
await new Promise((r) => setTimeout(r, 16000)); await new Promise((r) => setTimeout(r, 16000));
const afterJson = await client.getPageJson(uid); const afterJson = await client.getPageJson(uid);
check("update_page_json title-only: title updated", afterJson.title === newTitle, afterJson.title); check("updatePageJson title-only: title updated", afterJson.title === newTitle, afterJson.title);
check("update_page_json title-only: content intact", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("UPJTITLEBODY")); check("updatePageJson title-only: content intact", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("UPJTITLEBODY"));
let upjErr = ""; let upjErr = "";
try { await client.updatePageJson(uid); } catch (e) { upjErr = e.message; } try { await client.updatePageJson(uid); } catch (e) { upjErr = e.message; }
check("update_page_json: neither content nor title throws", upjErr.includes("nothing to update"), upjErr); check("updatePageJson: neither content nor title throws", upjErr.includes("nothing to update"), upjErr);
} finally { } finally {
try { await client.deletePage(uid); } catch {} try { await client.deletePage(uid); } catch {}
} }
} }
// 6g. copy_page_content: B's body becomes a copy of A's body, server-side, // 6g. copyPageContent: B's body becomes a copy of A's body, server-side,
// while B's title/slugId stay put. Both pages are throwaways. // while B's title/slugId stay put. Both pages are throwaways.
{ {
let aid = null; let aid = null;
@@ -409,24 +409,24 @@ async function main() {
const aNodeCount = aJson.content.content.length; const aNodeCount = aJson.content.content.length;
const cr = await client.copyPageContent(aid, bid); const cr = await client.copyPageContent(aid, bid);
check("copy_page_content: returns success + node count", cr.success === true && cr.copiedNodes === aNodeCount, JSON.stringify(cr)); check("copyPageContent: returns success + node count", cr.success === true && cr.copiedNodes === aNodeCount, JSON.stringify(cr));
await new Promise((r) => setTimeout(r, 16000)); await new Promise((r) => setTimeout(r, 16000));
const bAfter = await client.getPageJson(bid); const bAfter = await client.getPageJson(bid);
const bText = JSON.stringify(bAfter.content); const bText = JSON.stringify(bAfter.content);
check("copy_page_content: B now has A's marker", bText.includes("COPYSOURCE")); check("copyPageContent: B now has A's marker", bText.includes("COPYSOURCE"));
check("copy_page_content: B's old marker gone", !bText.includes("COPYTARGET")); check("copyPageContent: B's old marker gone", !bText.includes("COPYTARGET"));
check("copy_page_content: B node count equals A's", bAfter.content.content.length === aNodeCount, `${bAfter.content.content.length} vs ${aNodeCount}`); check("copyPageContent: B node count equals A's", bAfter.content.content.length === aNodeCount, `${bAfter.content.content.length} vs ${aNodeCount}`);
check("copy_page_content: B title unchanged", bAfter.title === bTitleBefore, bAfter.title); check("copyPageContent: B title unchanged", bAfter.title === bTitleBefore, bAfter.title);
check("copy_page_content: B slugId unchanged", bAfter.slugId === bSlugBefore, bAfter.slugId); check("copyPageContent: B slugId unchanged", bAfter.slugId === bSlugBefore, bAfter.slugId);
// Source must be left untouched by the copy. // Source must be left untouched by the copy.
const aAfter = JSON.stringify((await client.getPageJson(aid)).content); const aAfter = JSON.stringify((await client.getPageJson(aid)).content);
check("copy_page_content: source page unchanged", aAfter === JSON.stringify(aJson.content) && aAfter.includes("COPYSOURCE")); check("copyPageContent: source page unchanged", aAfter === JSON.stringify(aJson.content) && aAfter.includes("COPYSOURCE"));
let copyErr = ""; let copyErr = "";
try { await client.copyPageContent(aid, aid); } catch (e) { copyErr = e.message; } try { await client.copyPageContent(aid, aid); } catch (e) { copyErr = e.message; }
check("copy_page_content: self-copy rejected", copyErr.includes("same page"), copyErr); check("copyPageContent: self-copy rejected", copyErr.includes("same page"), copyErr);
} finally { } finally {
try { if (bid) await client.deletePage(bid); } catch {} try { if (bid) await client.deletePage(bid); } catch {}
try { if (aid) await client.deletePage(aid); } catch {} try { if (aid) await client.deletePage(aid); } catch {}
@@ -435,22 +435,22 @@ async function main() {
// 7. shares: create (idempotent), public access, list, unshare // 7. shares: create (idempotent), public access, list, unshare
const share = await client.sharePage(pageId); const share = await client.sharePage(pageId);
check("share_page: returns public URL", share.publicUrl?.startsWith(`${APP}/share/`), share.publicUrl); check("sharePage: returns public URL", share.publicUrl?.startsWith(`${APP}/share/`), share.publicUrl);
const share2 = await client.sharePage(pageId); const share2 = await client.sharePage(pageId);
check("share_page: idempotent", share2.key === share.key); check("sharePage: idempotent", share2.key === share.key);
const anon = await axios.post(`${API}/shares/page-info`, { pageId: pj4.slugId, shareId: share.key }, { validateStatus: () => true }); const anon = await axios.post(`${API}/shares/page-info`, { pageId: pj4.slugId, shareId: share.key }, { validateStatus: () => true });
check("share_page: anonymous access works", anon.status === 200); check("sharePage: anonymous access works", anon.status === 200);
const shares = await client.listShares(); const shares = await client.listShares();
check("list_shares: contains our page", shares.some((s) => s.pageId === pageId && s.publicUrl === share.publicUrl)); check("listShares: contains our page", shares.some((s) => s.pageId === pageId && s.publicUrl === share.publicUrl));
const un = await client.unsharePage(pageId); const un = await client.unsharePage(pageId);
check("unshare_page: success", un.success === true); check("unsharePage: success", un.success === true);
const anon2 = await axios.post(`${API}/shares/page-info`, { pageId: pj4.slugId, shareId: share.key }, { validateStatus: () => true }); const anon2 = await axios.post(`${API}/shares/page-info`, { pageId: pj4.slugId, shareId: share.key }, { validateStatus: () => true });
check("unshare_page: public access revoked", anon2.status !== 200, `status=${anon2.status}`); check("unsharePage: public access revoked", anon2.status !== 200, `status=${anon2.status}`);
// 8. get_page markdown round-trip sanity (table separator present) // 8. getPage markdown round-trip sanity (table separator present)
const md = await client.getPage(pageId); const md = await client.getPage(pageId);
check("get_page md: table separator emitted", md.data.content.includes("| --- |"), ""); check("getPage md: table separator emitted", md.data.content.includes("| --- |"), "");
check("get_page md: callout exported as Obsidian '> [!info]'", md.data.content.includes("> [!info]")); check("getPage md: callout exported as Obsidian '> [!info]'", md.data.content.includes("> [!info]"));
// 9. comments: create / list / reply / update / check_new / delete // 9. comments: create / list / reply / update / check_new / delete
const beforeComments = new Date(Date.now() - 1000).toISOString(); const beforeComments = new Date(Date.now() - 1000).toISOString();
@@ -458,34 +458,34 @@ async function main() {
// that exists in the persisted page to anchor on. "Добавленный абзац." is a // that exists in the persisted page to anchor on. "Добавленный абзац." is a
// plain paragraph re-imported in section 5 and still present here. // plain paragraph re-imported in section 5 and still present here.
const c1 = await client.createComment(pageId, "Первый **комментарий** с [ссылкой](https://example.com).", "inline", "Добавленный абзац."); const c1 = await client.createComment(pageId, "Первый **комментарий** с [ссылкой](https://example.com).", "inline", "Добавленный абзац.");
check("create_comment: created", !!c1.data.id, c1.data.id); check("createComment: created", !!c1.data.id, c1.data.id);
check("create_comment: markdown round-trip", c1.data.content.includes("**комментарий**"), c1.data.content); check("createComment: markdown round-trip", c1.data.content.includes("**комментарий**"), c1.data.content);
const reply = await client.createComment(pageId, "Ответ на комментарий.", "page", undefined, c1.data.id); const reply = await client.createComment(pageId, "Ответ на комментарий.", "page", undefined, c1.data.id);
check("create_comment: reply has parent", reply.data.parentCommentId === c1.data.id); check("createComment: reply has parent", reply.data.parentCommentId === c1.data.id);
const list = (await client.listComments(pageId)).items; const list = (await client.listComments(pageId)).items;
check("list_comments: both visible", list.length === 2, `count=${list.length}`); check("listComments: both visible", list.length === 2, `count=${list.length}`);
await client.updateComment(c1.data.id, "Обновлённый текст комментария."); await client.updateComment(c1.data.id, "Обновлённый текст комментария.");
const got = await client.getComment(c1.data.id); const got = await client.getComment(c1.data.id);
check("update_comment + get_comment: content updated", got.data.content.includes("Обновлённый"), got.data.content); check("updateComment + get_comment: content updated", got.data.content.includes("Обновлённый"), got.data.content);
const news = await client.checkNewComments(spaceId, beforeComments, pageId); const news = await client.checkNewComments(spaceId, beforeComments, pageId);
check("check_new_comments: finds new comments in subtree", news.totalNewComments >= 2, `total=${news.totalNewComments}`); check("checkNewComments: finds new comments in subtree", news.totalNewComments >= 2, `total=${news.totalNewComments}`);
// resolve_comment: close the top-level thread, verify resolvedAt surfaces, then reopen // resolveComment: close the top-level thread, verify resolvedAt surfaces, then reopen
const resolvedRes = await client.resolveComment(c1.data.id, true); const resolvedRes = await client.resolveComment(c1.data.id, true);
check("resolve_comment: marks resolved", resolvedRes.success === true && resolvedRes.resolved === true); check("resolveComment: marks resolved", resolvedRes.success === true && resolvedRes.resolved === true);
// c1 is now resolved; the default feed hides resolved threads, so pass // c1 is now resolved; the default feed hides resolved threads, so pass
// includeResolved:true to still see it and assert its resolvedAt (#328). // includeResolved:true to still see it and assert its resolvedAt (#328).
const listResolved = (await client.listComments(pageId, true)).items; const listResolved = (await client.listComments(pageId, true)).items;
const c1Resolved = listResolved.find((c) => c.id === c1.data.id); const c1Resolved = listResolved.find((c) => c.id === c1.data.id);
check("resolve_comment: resolvedAt set in list", !!c1Resolved?.resolvedAt, `resolvedAt=${c1Resolved?.resolvedAt}`); check("resolveComment: resolvedAt set in list", !!c1Resolved?.resolvedAt, `resolvedAt=${c1Resolved?.resolvedAt}`);
const reopenedRes = await client.resolveComment(c1.data.id, false); const reopenedRes = await client.resolveComment(c1.data.id, false);
check("resolve_comment: reopen succeeds", reopenedRes.resolved === false); check("resolveComment: reopen succeeds", reopenedRes.resolved === false);
const listReopened = (await client.listComments(pageId)).items; const listReopened = (await client.listComments(pageId)).items;
const c1Reopened = listReopened.find((c) => c.id === c1.data.id); const c1Reopened = listReopened.find((c) => c.id === c1.data.id);
check("resolve_comment: resolvedAt cleared on reopen", !c1Reopened?.resolvedAt, `resolvedAt=${c1Reopened?.resolvedAt}`); check("resolveComment: resolvedAt cleared on reopen", !c1Reopened?.resolvedAt, `resolvedAt=${c1Reopened?.resolvedAt}`);
await client.deleteComment(reply.data.id); await client.deleteComment(reply.data.id);
await client.deleteComment(c1.data.id); await client.deleteComment(c1.data.id);
const listAfter = (await client.listComments(pageId)).items; const listAfter = (await client.listComments(pageId)).items;
check("delete_comment: comments removed", listAfter.length === 0, `count=${listAfter.length}`); check("deleteComment: comments removed", listAfter.length === 0, `count=${listAfter.length}`);
} finally { } finally {
if (pageId) { if (pageId) {
await client.deletePage(pageId); await client.deletePage(pageId);
@@ -1,4 +1,4 @@
// Mock collab regression for the AMBIGUOUS-id refusal in patch_node / delete_node // Mock collab regression for the AMBIGUOUS-id refusal in patchNode / deleteNode
// (#159, PR #185 review pt 1). When a page has TWO blocks sharing one attrs.id // (#159, PR #185 review pt 1). When a page has TWO blocks sharing one attrs.id
// (Docmost duplicates block ids on copy/paste), the transform's // (Docmost duplicates block ids on copy/paste), the transform's
// `if (replaced !== 1) return null` / `if (deleted !== 1) return null` guard must // `if (replaced !== 1) return null` / `if (deleted !== 1) return null` guard must
@@ -126,18 +126,20 @@ after(async () => {
); );
}); });
test("patch_node REFUSES an ambiguous (duplicate) id without writing to collab", async () => { test("patchNode REFUSES an ambiguous (duplicate) id without writing to collab", async () => {
const { state, baseURL } = await spawnCollabStack(); const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw"); const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects( await assert.rejects(
() => () =>
client.patchNode("11111111-1111-4111-8111-111111111111", DUP_ID, { client.patchNode("11111111-1111-4111-8111-111111111111", DUP_ID, {
type: "paragraph", node: {
content: [{ type: "text", text: "replacement" }], type: "paragraph",
content: [{ type: "text", text: "replacement" }],
},
}), }),
/ambiguous/i, /ambiguous/i,
"patch_node must reject a duplicate-id target with an 'ambiguous' error", "patchNode must reject a duplicate-id target with an 'ambiguous' error",
); );
assert.equal( assert.equal(
@@ -147,14 +149,14 @@ test("patch_node REFUSES an ambiguous (duplicate) id without writing to collab",
); );
}); });
test("delete_node REFUSES an ambiguous (duplicate) id without writing to collab", async () => { test("deleteNode REFUSES an ambiguous (duplicate) id without writing to collab", async () => {
const { state, baseURL } = await spawnCollabStack(); const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw"); const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects( await assert.rejects(
() => client.deleteNode("22222222-2222-4222-8222-222222222222", DUP_ID), () => client.deleteNode("22222222-2222-4222-8222-222222222222", DUP_ID),
/ambiguous/i, /ambiguous/i,
"delete_node must reject a duplicate-id target with an 'ambiguous' error", "deleteNode must reject a duplicate-id target with an 'ambiguous' error",
); );
assert.equal( assert.equal(
+21 -21
View File
@@ -1,4 +1,4 @@
// Contract tests for the drawio_get / drawio_create / drawio_update client // Contract tests for the drawioGet / drawioCreate / drawioUpdate client
// methods (issue #423). Follows the repo's seam-override pattern (see // methods (issue #423). Follows the repo's seam-override pattern (see
// full-doc-write-canonicalize.test.mjs): a DocmostClient subclass stubs the I/O // full-doc-write-canonicalize.test.mjs): a DocmostClient subclass stubs the I/O
// seams (auth, collab token, page read, attachment upload/fetch, the mutatePage // seams (auth, collab token, page read, attachment upload/fetch, the mutatePage
@@ -114,9 +114,9 @@ function findDrawio(node, acc = []) {
return acc; return acc;
} }
// --- drawio_create --------------------------------------------------------- // --- drawioCreate ---------------------------------------------------------
test("drawio_create: lints, builds the .drawio.svg, uploads and inserts a node", async () => { test("drawioCreate: lints, builds the .drawio.svg, uploads and inserts a node", async () => {
const pageDoc = { const pageDoc = {
type: "doc", type: "doc",
content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }], content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }],
@@ -148,7 +148,7 @@ test("drawio_create: lints, builds the .drawio.svg, uploads and inserts a node",
assert.equal(n.attrs.title, "My diagram"); assert.equal(n.attrs.title, "My diagram");
}); });
test("drawio_create: a lint violation throws before any upload", async () => { test("drawioCreate: a lint violation throws before any upload", async () => {
const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } }); const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } });
// Edge with no child geometry -> edge-geometry rule. // Edge with no child geometry -> edge-geometry rule.
const bad = const bad =
@@ -162,7 +162,7 @@ test("drawio_create: a lint violation throws before any upload", async () => {
assert.equal(calls.uploads.length, 0, "no attachment uploaded on lint failure"); assert.equal(calls.uploads.length, 0, "no attachment uploaded on lint failure");
}); });
test("drawio_create: before/after requires exactly one anchor", async () => { test("drawioCreate: before/after requires exactly one anchor", async () => {
const { client } = makeClient({ pageDoc: { type: "doc", content: [] } }); const { client } = makeClient({ pageDoc: { type: "doc", content: [] } });
await assert.rejects( await assert.rejects(
() => client.drawioCreate("page1", { position: "before" }, MODEL), () => client.drawioCreate("page1", { position: "before" }, MODEL),
@@ -170,9 +170,9 @@ test("drawio_create: before/after requires exactly one anchor", async () => {
); );
}); });
// --- drawio_get ------------------------------------------------------------ // --- drawioGet ------------------------------------------------------------
test("drawio_get: decodes the model and returns meta with a hash", async () => { test("drawioGet: decodes the model and returns meta with a hash", async () => {
const pageDoc = { const pageDoc = {
type: "doc", type: "doc",
content: [ content: [
@@ -198,7 +198,7 @@ test("drawio_get: decodes the model and returns meta with a hash", async () => {
assert.equal(res.meta.hash, mxHash(normalizeXml(MODEL))); assert.equal(res.meta.hash, mxHash(normalizeXml(MODEL)));
}); });
test("drawio_get: format=svg returns the raw .drawio.svg", async () => { test("drawioGet: format=svg returns the raw .drawio.svg", async () => {
const svg = svgFor(MODEL); const svg = svgFor(MODEL);
const pageDoc = { const pageDoc = {
type: "doc", type: "doc",
@@ -211,7 +211,7 @@ test("drawio_get: format=svg returns the raw .drawio.svg", async () => {
assert.equal(res.content, svg); assert.equal(res.content, svg);
}); });
test("drawio_get: reads a HUMAN-saved compressed diagram losslessly (pako)", async () => { test("drawioGet: reads a HUMAN-saved compressed diagram losslessly (pako)", async () => {
const pageDoc = { const pageDoc = {
type: "doc", type: "doc",
content: [ content: [
@@ -223,7 +223,7 @@ test("drawio_get: reads a HUMAN-saved compressed diagram losslessly (pako)", asy
assert.equal(res.content, normalizeXml(MODEL)); assert.equal(res.content, normalizeXml(MODEL));
}); });
// --- drawio_update --------------------------------------------------------- // --- drawioUpdate ---------------------------------------------------------
const UPDATED_MODEL = const UPDATED_MODEL =
'<mxGraphModel><root>' + '<mxGraphModel><root>' +
@@ -250,7 +250,7 @@ function updatePageDoc() {
}; };
} }
test("drawio_update: stale baseHash -> conflict, no upload", async () => { test("drawioUpdate: stale baseHash -> conflict, no upload", async () => {
const { client, calls } = makeClient({ const { client, calls } = makeClient({
pageDoc: updatePageDoc(), pageDoc: updatePageDoc(),
attachmentSvg: svgFor(MODEL), attachmentSvg: svgFor(MODEL),
@@ -262,7 +262,7 @@ test("drawio_update: stale baseHash -> conflict, no upload", async () => {
assert.equal(calls.uploads.length, 0, "no upload on conflict"); assert.equal(calls.uploads.length, 0, "no upload on conflict");
}); });
test("drawio_update: current baseHash -> uploads new attachment and repoints node dims", async () => { test("drawioUpdate: current baseHash -> uploads new attachment and repoints node dims", async () => {
const currentHash = mxHash(normalizeXml(MODEL)); const currentHash = mxHash(normalizeXml(MODEL));
const { client, calls } = makeClient({ const { client, calls } = makeClient({
pageDoc: updatePageDoc(), pageDoc: updatePageDoc(),
@@ -285,7 +285,7 @@ test("drawio_update: current baseHash -> uploads new attachment and repoints nod
assert.equal(n.attrs.id, undefined); assert.equal(n.attrs.id, undefined);
}); });
test("drawio_update: baseHash is mandatory", async () => { test("drawioUpdate: baseHash is mandatory", async () => {
const { client } = makeClient({ pageDoc: updatePageDoc(), attachmentSvg: svgFor(MODEL) }); const { client } = makeClient({ pageDoc: updatePageDoc(), attachmentSvg: svgFor(MODEL) });
await assert.rejects( await assert.rejects(
() => client.drawioUpdate("page1", "d1", UPDATED_MODEL, ""), () => client.drawioUpdate("page1", "d1", UPDATED_MODEL, ""),
@@ -295,7 +295,7 @@ test("drawio_update: baseHash is mandatory", async () => {
// --- Fix 1: the create handle must resolve on the SAVED doc (no id) --------- // --- Fix 1: the create handle must resolve on the SAVED doc (no id) ---------
test("drawio_create -> get/update: returned #<index> handle resolves on the saved doc (id dropped)", async () => { test("drawioCreate -> get/update: returned #<index> handle resolves on the saved doc (id dropped)", async () => {
// Create appends a drawio node after the existing paragraph. // Create appends a drawio node after the existing paragraph.
const createDoc = { const createDoc = {
type: "doc", type: "doc",
@@ -316,13 +316,13 @@ test("drawio_create -> get/update: returned #<index> handle resolves on the save
const savedDoc = create.calls.mutations[0].doc; const savedDoc = create.calls.mutations[0].doc;
assert.equal(findDrawio(savedDoc)[0].attrs.id, undefined); assert.equal(findDrawio(savedDoc)[0].attrs.id, undefined);
// drawio_get with the returned handle resolves the just-created node. // drawioGet with the returned handle resolves the just-created node.
const getClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) }); const getClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) });
const got = await getClient.client.drawioGet("page1", res.nodeId, "xml"); const got = await getClient.client.drawioGet("page1", res.nodeId, "xml");
assert.equal(got.nodeId, res.nodeId); assert.equal(got.nodeId, res.nodeId);
assert.equal(got.content, normalizeXml(MODEL)); assert.equal(got.content, normalizeXml(MODEL));
// drawio_update with the same handle + the hash from get repoints that node. // drawioUpdate with the same handle + the hash from get repoints that node.
const upClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) }); const upClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) });
const upd = await upClient.client.drawioUpdate( const upd = await upClient.client.drawioUpdate(
"page1", "page1",
@@ -342,7 +342,7 @@ test("drawio_create -> get/update: returned #<index> handle resolves on the save
// --- error paths: the LLM must get a clean error, not a crash -------------- // --- error paths: the LLM must get a clean error, not a crash --------------
test("drawio_get: a bad node ref -> clean 'no node found' error", async () => { test("drawioGet: a bad node ref -> clean 'no node found' error", async () => {
// Page has one paragraph; the requested ref resolves to nothing. // Page has one paragraph; the requested ref resolves to nothing.
const pageDoc = { const pageDoc = {
type: "doc", type: "doc",
@@ -355,7 +355,7 @@ test("drawio_get: a bad node ref -> clean 'no node found' error", async () => {
); );
}); });
test("drawio_get: a drawio node with no src -> clean 'has no src to read' error", async () => { test("drawioGet: a drawio node with no src -> clean 'has no src to read' error", async () => {
const pageDoc = { const pageDoc = {
type: "doc", type: "doc",
content: [ content: [
@@ -370,7 +370,7 @@ test("drawio_get: a drawio node with no src -> clean 'has no src to read' error"
); );
}); });
test("drawio_update: the resolved node is NOT a drawio node -> clean error, no upload", async () => { test("drawioUpdate: the resolved node is NOT a drawio node -> clean error, no upload", async () => {
// "#0" resolves to a paragraph. The update must refuse cleanly rather than // "#0" resolves to a paragraph. The update must refuse cleanly rather than
// crash or repoint the wrong node. // crash or repoint the wrong node.
const pageDoc = { const pageDoc = {
@@ -386,7 +386,7 @@ test("drawio_update: the resolved node is NOT a drawio node -> clean error, no u
assert.equal(calls.mutations.length, 0, "no write when the node is not a diagram"); assert.equal(calls.mutations.length, 0, "no write when the node is not a diagram");
}); });
test("drawio_create: anchor not found -> clean error that reports the orphan attachment", async () => { test("drawioCreate: anchor not found -> clean error that reports the orphan attachment", async () => {
// The upload happens before the mutate transform; when the anchor cannot be // The upload happens before the mutate transform; when the anchor cannot be
// found the write is skipped and the (now unreferenced) attachment is named // found the write is skipped and the (now unreferenced) attachment is named
// in the error, exactly as the code documents. // in the error, exactly as the code documents.
@@ -416,7 +416,7 @@ test("drawio_create: anchor not found -> clean error that reports the orphan att
// --- Fix 2: update targets ONLY the resolved node -------------------------- // --- Fix 2: update targets ONLY the resolved node --------------------------
test("drawio_update: repoints ONLY the addressed node, not siblings sharing an attachmentId", async () => { test("drawioUpdate: repoints ONLY the addressed node, not siblings sharing an attachmentId", async () => {
// A copied diagram: two drawio nodes share one attachmentId. Updating via the // A copied diagram: two drawio nodes share one attachmentId. Updating via the
// "#0" handle must touch node #0 only, never the sibling copy. // "#0" handle must touch node #0 only, never the sibling copy.
const shared = { const shared = {
@@ -2,7 +2,7 @@
// (issue #228): // (issue #228):
// - insertFootnote (#11): the required-argument guards reject BEFORE any write, // - insertFootnote (#11): the required-argument guards reject BEFORE any write,
// and never touch the collab/mutate path. // and never touch the collab/mutate path.
// - transformPage / docmost_transform (#13): the auto-canonicalize step // - transformPage / docmostTransform (#13): the auto-canonicalize step
// (`result = canonicalizeFootnotes(raw)`) runs after every transform, so a // (`result = canonicalizeFootnotes(raw)`) runs after every transform, so a
// transform that introduces an orphan footnote definition is silently tidied // transform that introduces an orphan footnote definition is silently tidied
// away — observable as an EMPTY diff in a dryRun preview. // away — observable as an EMPTY diff in a dryRun preview.
@@ -10,7 +10,7 @@
// These stand a local http.createServer in for Docmost and only exercise plain // These stand a local http.createServer in for Docmost and only exercise plain
// HTTP routes (login / comments / pages.info), deliberately avoiding the live // HTTP routes (login / comments / pages.info), deliberately avoiding the live
// Hocuspocus collab WebSocket: the insertFootnote guards short-circuit before it, // Hocuspocus collab WebSocket: the insertFootnote guards short-circuit before it,
// and docmost_transform's dryRun preview never opens it. The collab mutate path // and docmostTransform's dryRun preview never opens it. The collab mutate path
// itself — abort-via-throw on a missing anchor with NO persisted write, and the // itself — abort-via-throw on a missing anchor with NO persisted write, and the
// reused-vs-new response shaping — is covered in // reused-vs-new response shaping — is covered in
// test/mock/insert-footnote-wrapper.test.mjs (which overrides the mutatePage // test/mock/insert-footnote-wrapper.test.mjs (which overrides the mutatePage
@@ -101,7 +101,7 @@ test("insertFootnote rejects an empty text before any write", async () => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// #13 docmost_transform auto-canonicalization: a transform that adds an orphan // #13 docmostTransform auto-canonicalization: a transform that adds an orphan
// footnote definition produces NO net change (the canonicalizer drops it), so a // footnote definition produces NO net change (the canonicalizer drops it), so a
// dryRun preview reports an empty diff. Without the auto-canonicalize step the // dryRun preview reports an empty diff. Without the auto-canonicalize step the
// orphan would survive and the diff would be non-empty. // orphan would survive and the diff would be non-empty.
@@ -1,5 +1,5 @@
// Footnote-canonicalization binding tests for the MCP FULL-document write tools // Footnote-canonicalization binding tests for the MCP FULL-document write tools
// (issue #228, review #4): update_page_json and copy_page_content must persist a // (issue #228, review #4): updatePageJson and copyPageContent must persist a
// footnote-canonical doc. These override the `replacePage` seam (symmetric to the // footnote-canonical doc. These override the `replacePage` seam (symmetric to the
// `mutatePage` seam used by the insert-footnote-wrapper test) to capture the // `mutatePage` seam used by the insert-footnote-wrapper test) to capture the
// persisted doc WITHOUT a live Hocuspocus collab socket. Symmetric to the // persisted doc WITHOUT a live Hocuspocus collab socket. Symmetric to the
@@ -44,7 +44,7 @@ function makeClient(sourceDoc) {
return { client, calls }; return { client, calls };
} }
test("update_page_json canonicalizes the persisted full doc (out-of-order -> reference order)", async () => { test("updatePageJson canonicalizes the persisted full doc (out-of-order -> reference order)", async () => {
const { client, calls } = makeClient(); const { client, calls } = makeClient();
const outOfOrder = { const outOfOrder = {
type: "doc", type: "doc",
@@ -60,7 +60,7 @@ test("update_page_json canonicalizes the persisted full doc (out-of-order -> ref
assert.equal(findAll(calls.replaced[0].doc, "footnotesList").length, 1); assert.equal(findAll(calls.replaced[0].doc, "footnotesList").length, 1);
}); });
test("copy_page_content canonicalizes the persisted copy (orphan definition dropped)", async () => { test("copyPageContent canonicalizes the persisted copy (orphan definition dropped)", async () => {
const sourceDoc = { const sourceDoc = {
type: "doc", type: "doc",
content: [ content: [
@@ -0,0 +1,157 @@
// #413: getNode's markdown-default format, its JSON opt-in, the non-top-level
// AUTO fallback to JSON, and comment-anchor preservation (incl. resolved) on the
// markdown read. getNode only reads (getPageRaw), so a lightweight subclass that
// stubs auth + the page fetch is enough — no collab socket needed.
import { test } from "node:test";
import assert from "node:assert/strict";
import { DocmostClient } from "../../build/client.js";
function makeClient(doc) {
class TestClient extends DocmostClient {
async ensureAuthenticated() {}
async getPageRaw(pageId) {
return { id: pageId, slugId: "s", title: "P", spaceId: "sp", content: doc };
}
}
return new TestClient("http://127.0.0.1:1/api", "e@x.com", "pw");
}
const P = "p1";
test("getNode defaults to markdown for a paragraph", async () => {
const doc = {
type: "doc",
content: [
{
type: "paragraph",
attrs: { id: "b1" },
content: [{ type: "text", text: "hello world" }],
},
],
};
const res = await makeClient(doc).getNode(P, "b1");
assert.equal(res.format, "markdown");
assert.equal(typeof res.markdown, "string");
assert.match(res.markdown, /hello world/);
assert.equal(res.node, undefined, "markdown result carries no raw node");
});
test("getNode format:'json' returns the raw subtree verbatim", async () => {
const target = {
type: "paragraph",
attrs: { id: "b1" },
content: [{ type: "text", text: "hello" }],
};
const doc = { type: "doc", content: [target] };
const res = await makeClient(doc).getNode(P, "b1", "json");
assert.equal(res.format, "json");
assert.deepEqual(res.node, target);
assert.equal(res.markdown, undefined);
});
test("getNode AUTO-falls back to JSON for a non-top-level type (tableRow via #index)", async () => {
const doc = {
type: "doc",
content: [
{
type: "table",
content: [
{
type: "tableRow",
content: [
{
type: "tableCell",
attrs: { colspan: 1, rowspan: 1 },
content: [
{
type: "paragraph",
attrs: { id: "cp" },
content: [{ type: "text", text: "x" }],
},
],
},
],
},
],
},
],
};
// "#0.0"-style refs are not supported; the whole table is "#0", a row is only
// reachable by drilling — but a tableRow IS a non-doc-child type. Address the
// table itself as "#0": a table CAN be a doc child, so markdown is fine there.
// To hit the fallback, address the row by walking: getNode resolves "#0" to the
// table (doc child -> markdown). Instead we verify the schema gate directly by
// asking for the table (markdown) and a row is exercised via the unit test on
// canBeDocChild; here confirm a table renders as markdown.
const tableRes = await makeClient(doc).getNode(P, "#0");
assert.equal(tableRes.format, "markdown", "a table is a doc child -> markdown");
// Now build a doc whose top-level block IS a tableRow (schematically invalid but
// exercises the getNode fallback branch): getNode("#0") resolves it and, because
// tableRow cannot be a doc child, must fall back to JSON.
const rowDoc = {
type: "doc",
content: [
{
type: "tableRow",
content: [
{
type: "tableCell",
attrs: { colspan: 1, rowspan: 1 },
content: [{ type: "paragraph", content: [{ type: "text", text: "y" }] }],
},
],
},
],
};
const rowRes = await makeClient(rowDoc).getNode(P, "#0");
assert.equal(rowRes.format, "json", "a tableRow cannot be a doc child -> JSON fallback");
assert.equal(rowRes.type, "tableRow");
assert.ok(rowRes.node, "the JSON fallback returns the raw subtree");
});
test("getNode(markdown) PRESERVES comment anchors — active and resolved", async () => {
// A paragraph with two comment marks: one active, one resolved. get_page strips
// resolved anchors; getNode must NOT (a read for editing/write-back).
const doc = {
type: "doc",
content: [
{
type: "paragraph",
attrs: { id: "b1" },
content: [
{ type: "text", text: "start " },
{
type: "text",
text: "active",
marks: [{ type: "comment", attrs: { commentId: "cid-active" } }],
},
{ type: "text", text: " mid " },
{
type: "text",
text: "resolved",
marks: [
{
type: "comment",
attrs: { commentId: "cid-resolved", resolved: true },
},
],
},
{ type: "text", text: " end" },
],
},
],
};
const res = await makeClient(doc).getNode(P, "b1");
assert.equal(res.format, "markdown");
assert.match(
res.markdown,
/data-comment-id="cid-active"/,
"the active comment anchor is preserved",
);
assert.match(
res.markdown,
/data-comment-id="cid-resolved"/,
"the RESOLVED comment anchor is ALSO preserved (unlike get_page)",
);
});
@@ -0,0 +1,375 @@
// Mock-HTTP tests for DocmostClient.getPageContext — the #443 "where am I /
// what's around" read tool. A local http.createServer stands in for Docmost
// (same harness style as pagination-cursor.test.mjs) so everything is
// deterministic and offline.
//
// Contract pinned here:
// - Two requests: POST /pages/breadcrumbs (ancestor chain root->page, page
// INCLUDED as the LAST element) + listSidebarPages (direct children).
// - Split: last chain element -> `page`; the rest (root->parent) ->
// `breadcrumbs`. A ROOT page (chain length 1) -> breadcrumbs: [].
// - children: {pageId, title, hasChildren} in sidebar order.
// - INVARIANT: only the UUID `pageId` is exposed, never `slugId`.
// - A slugId input is resolved via /pages/info first (adds one request); a
// UUID input short-circuits (stays at two requests).
// - A bad/inaccessible pageId throws a CLEAR error, not an empty object.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { DocmostClient } from "../../build/client.js";
function readBody(req) {
return new Promise((resolve) => {
let raw = "";
req.on("data", (chunk) => {
raw += chunk;
});
req.on("end", () => resolve(raw));
});
}
function startServer(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve({ server, baseURL: `http://127.0.0.1:${port}/api` });
});
});
}
function closeServer(server) {
return new Promise((resolve) => server.close(resolve));
}
function sendJson(res, status, obj, extraHeaders = {}) {
res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders });
res.end(JSON.stringify(obj));
}
const openServers = [];
async function spawn(handler) {
const { server, baseURL } = await startServer(handler);
openServers.push(server);
return { server, baseURL };
}
after(async () => {
await Promise.all(openServers.map((s) => closeServer(s)));
});
function handleLogin(req, res) {
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return true;
}
return false;
}
// Two real UUIDs so resolvePageId short-circuits (no /pages/info round-trip).
const ROOT_UUID = "00000000-0000-4000-8000-000000000001";
const MID_UUID = "00000000-0000-4000-8000-000000000002";
const PAGE_UUID = "00000000-0000-4000-8000-000000000003";
const CHILD_A = "00000000-0000-4000-8000-00000000000a";
const CHILD_B = "00000000-0000-4000-8000-00000000000b";
// Build a breadcrumbs response as the server sends it: root->page order, page
// LAST, wrapped in the {data,success} envelope. slugId/icon/position are present
// on the wire (they must NOT leak into the tool output).
function breadcrumbsEnvelope(chain) {
return { success: true, data: chain };
}
// -----------------------------------------------------------------------------
// 1) 3rd-level page: page = last chain element; breadcrumbs = the two ancestors
// root->parent; children mapped {pageId,title,hasChildren} in order; no leak;
// exactly two requests for a UUID input.
// -----------------------------------------------------------------------------
test("getPageContext: 3rd-level page splits chain, maps children, no slugId leak, 2 requests", async () => {
let breadcrumbReqs = 0;
let sidebarReqs = 0;
let infoReqs = 0;
let breadcrumbBody = null;
const { baseURL } = await spawn(async (req, res) => {
const raw = await readBody(req);
if (handleLogin(req, res)) return;
if (req.url === "/api/pages/info") {
infoReqs++;
sendJson(res, 404, {});
return;
}
if (req.url === "/api/pages/breadcrumbs") {
breadcrumbReqs++;
breadcrumbBody = JSON.parse(raw || "{}");
// root -> parent -> page (page LAST). slugId/icon/position on the wire.
sendJson(
res,
200,
breadcrumbsEnvelope([
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", position: "a", icon: null, parentPageId: null, hasChildren: true },
{ id: MID_UUID, slugId: "midSlug", title: "Datacenter A", spaceId: "sp1", position: "a", icon: null, parentPageId: ROOT_UUID, hasChildren: true },
{ id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1", position: "b", icon: null, parentPageId: MID_UUID, hasChildren: true },
]),
);
return;
}
if (req.url === "/api/pages/sidebar-pages") {
sidebarReqs++;
const body = JSON.parse(raw || "{}");
assert.equal(body.pageId, PAGE_UUID, "children scoped to the page UUID");
assert.equal(body.spaceId, "sp1", "children scoped to the page's space");
sendJson(res, 200, {
success: true,
data: {
items: [
{ id: CHILD_A, slugId: "aSlug", title: "Servers", parentPageId: PAGE_UUID, hasChildren: true, position: "a" },
{ id: CHILD_B, slugId: "bSlug", title: "Network", parentPageId: PAGE_UUID, hasChildren: false, position: "b" },
],
meta: { hasNextPage: false, nextCursor: null },
},
});
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.getPageContext(PAGE_UUID);
assert.equal(infoReqs, 0, "UUID input short-circuits resolvePageId (no /pages/info)");
assert.equal(breadcrumbReqs, 1, "exactly one breadcrumbs request");
assert.equal(sidebarReqs, 1, "exactly one sidebar request");
assert.deepEqual(breadcrumbBody, { pageId: PAGE_UUID }, "breadcrumbs posts the UUID");
// page = the LAST chain element.
assert.deepEqual(result.page, {
pageId: PAGE_UUID,
title: "Rack 12",
spaceId: "sp1",
});
// breadcrumbs = root->parent (the chain minus the page itself).
assert.deepEqual(result.breadcrumbs, [
{ pageId: ROOT_UUID, title: "Infrastructure" },
{ pageId: MID_UUID, title: "Datacenter A" },
]);
// children mapped in order, hasChildren coerced to boolean.
assert.deepEqual(result.children, [
{ pageId: CHILD_A, title: "Servers", hasChildren: true },
{ pageId: CHILD_B, title: "Network", hasChildren: false },
]);
// No slugId anywhere in the output.
const dump = JSON.stringify(result);
assert.ok(!dump.includes("Slug"), "no slugId leaks into the output");
assert.ok(!/\bslugId\b/.test(dump), "no slugId key in the output");
});
// -----------------------------------------------------------------------------
// 2) ROOT page: chain has ONE element (the page itself) -> breadcrumbs: [].
// -----------------------------------------------------------------------------
test("getPageContext: a root page has breadcrumbs: []", async () => {
const { baseURL } = await spawn(async (req, res) => {
const raw = await readBody(req);
if (handleLogin(req, res)) return;
if (req.url === "/api/pages/breadcrumbs") {
// A root page: the CTE returns only the page itself.
sendJson(
res,
200,
breadcrumbsEnvelope([
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", parentPageId: null, hasChildren: false },
]),
);
return;
}
if (req.url === "/api/pages/sidebar-pages") {
sendJson(res, 200, {
success: true,
data: { items: [], meta: { hasNextPage: false, nextCursor: null } },
});
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.getPageContext(ROOT_UUID);
assert.deepEqual(result.page, {
pageId: ROOT_UUID,
title: "Infrastructure",
spaceId: "sp1",
});
assert.deepEqual(result.breadcrumbs, [], "root page: no ancestors");
assert.deepEqual(result.children, [], "no children");
});
// -----------------------------------------------------------------------------
// 3) A slugId input is resolved via /pages/info first (one extra request), then
// breadcrumbs/sidebar use the resolved UUID.
// -----------------------------------------------------------------------------
test("getPageContext: a slugId input is resolved via /pages/info", async () => {
let infoReqs = 0;
let infoBody = null;
let breadcrumbBody = null;
const { baseURL } = await spawn(async (req, res) => {
const raw = await readBody(req);
if (handleLogin(req, res)) return;
if (req.url === "/api/pages/info") {
infoReqs++;
infoBody = JSON.parse(raw || "{}");
// getPageRaw: slugId -> canonical UUID.
sendJson(res, 200, {
success: true,
data: { id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1" },
});
return;
}
if (req.url === "/api/pages/breadcrumbs") {
breadcrumbBody = JSON.parse(raw || "{}");
sendJson(
res,
200,
breadcrumbsEnvelope([
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", parentPageId: null },
{ id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1", parentPageId: ROOT_UUID },
]),
);
return;
}
if (req.url === "/api/pages/sidebar-pages") {
sendJson(res, 200, {
success: true,
data: { items: [], meta: { hasNextPage: false, nextCursor: null } },
});
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.getPageContext("pageSlug");
assert.equal(infoReqs, 1, "slugId resolved via one /pages/info");
assert.deepEqual(infoBody, { pageId: "pageSlug" }, "resolve posts the raw slugId");
assert.deepEqual(
breadcrumbBody,
{ pageId: PAGE_UUID },
"breadcrumbs posts the RESOLVED uuid, not the slugId",
);
assert.equal(result.page.pageId, PAGE_UUID, "page.pageId is the UUID");
assert.deepEqual(result.breadcrumbs, [
{ pageId: ROOT_UUID, title: "Infrastructure" },
]);
});
// -----------------------------------------------------------------------------
// 4) >20 children: cursor pagination returns ALL of them, no dupes (regression
// on the #442 bug class — getPageContext must not re-introduce a cap).
// -----------------------------------------------------------------------------
test("getPageContext: a page with >20 children returns ALL of them (no cap, no dupes)", async () => {
// 45 children spread over three cursor pages.
const all = Array.from({ length: 45 }, (_, i) => ({
id: `child-${i}`,
slugId: `slug-${i}`,
title: `Child ${i}`,
parentPageId: PAGE_UUID,
hasChildren: i % 2 === 0,
}));
const PAGES = {
"": { items: all.slice(0, 20), nextCursor: "c1" },
c1: { items: all.slice(20, 40), nextCursor: "c2" },
c2: { items: all.slice(40), nextCursor: null },
};
const { baseURL } = await spawn(async (req, res) => {
const raw = await readBody(req);
if (handleLogin(req, res)) return;
if (req.url === "/api/pages/breadcrumbs") {
sendJson(
res,
200,
breadcrumbsEnvelope([
{ id: PAGE_UUID, slugId: "pageSlug", title: "Big Parent", spaceId: "sp1", parentPageId: null },
]),
);
return;
}
if (req.url === "/api/pages/sidebar-pages") {
const body = JSON.parse(raw || "{}");
const page = PAGES[body.cursor ?? ""] ?? { items: [], nextCursor: null };
sendJson(res, 200, {
success: true,
data: {
items: page.items,
meta: { hasNextPage: page.nextCursor != null, nextCursor: page.nextCursor },
},
});
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.getPageContext(PAGE_UUID);
assert.equal(result.children.length, 45, "all 45 children returned");
const ids = result.children.map((c) => c.pageId);
assert.equal(new Set(ids).size, 45, "no duplicate children");
assert.deepEqual(ids, all.map((c) => c.id), "children in server order across cursor pages");
assert.equal(result.children[0].hasChildren, true, "hasChildren preserved (child 0)");
assert.equal(result.children[1].hasChildren, false, "hasChildren preserved (child 1)");
});
// -----------------------------------------------------------------------------
// 5) A nonexistent / inaccessible pageId -> a CLEAR error, NOT an empty object.
// -----------------------------------------------------------------------------
test("getPageContext: a bad/inaccessible pageId throws a clear error (not {})", async () => {
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (handleLogin(req, res)) return;
if (req.url === "/api/pages/breadcrumbs") {
// Server rejects an unknown/forbidden page.
sendJson(res, 404, { message: "Page not found" });
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects(
() => client.getPageContext(PAGE_UUID),
(err) => {
assert.ok(err instanceof Error, "throws an Error");
return true;
},
"a 404 from breadcrumbs propagates as a thrown error, not a hollow {}",
);
});
// -----------------------------------------------------------------------------
// 6) An empty breadcrumbs chain (should never happen — the endpoint always
// includes the page itself) is treated as not-found, not a hollow {page:...}.
// -----------------------------------------------------------------------------
test("getPageContext: an empty breadcrumbs chain throws (defensive)", async () => {
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (handleLogin(req, res)) return;
if (req.url === "/api/pages/breadcrumbs") {
sendJson(res, 200, breadcrumbsEnvelope([]));
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects(
() => client.getPageContext(PAGE_UUID),
/not found or inaccessible/,
"an empty chain is a clear error, not {}",
);
});
@@ -1,6 +1,6 @@
// Mock regression for the FAIL-FAST invalid-node validation (#409). // Mock regression for the FAIL-FAST invalid-node validation (#409).
// //
// A structural editor (patch_node / insert_node / update_page_json) given a doc // A structural editor (patchNode / insertNode / updatePageJson) given a doc
// whose NESTED child has an absent/unknown `type` (the exact shape the Yjs // whose NESTED child has an absent/unknown `type` (the exact shape the Yjs
// encoder rejects with `Unknown node type: undefined`) must throw a RICH, // encoder rejects with `Unknown node type: undefined`) must throw a RICH,
// path-anchored error BEFORE it ever opens a collab session or takes a page // path-anchored error BEFORE it ever opens a collab session or takes a page
@@ -23,7 +23,7 @@ import { Hocuspocus } from "@hocuspocus/server";
import { DocmostClient } from "../../build/client.js"; import { DocmostClient } from "../../build/client.js";
import { buildYDoc } from "../../build/lib/collaboration.js"; import { buildYDoc } from "../../build/lib/collaboration.js";
// A minimal valid seed doc with a real block id, so the happy-path patch_node // A minimal valid seed doc with a real block id, so the happy-path patchNode
// finds its target. // finds its target.
const SEED_ID = "seed-para-id"; const SEED_ID = "seed-para-id";
function seedDoc() { function seedDoc() {
@@ -130,14 +130,14 @@ const nestedUnknownTypeNode = () => ({
content: [{ type: "paragraf", content: [{ type: "text", text: "x" }] }], content: [{ type: "paragraf", content: [{ type: "text", text: "x" }] }],
}); });
test("patch_node fails fast on a nested typeless node — no collab connection", async () => { test("patchNode fails fast on a nested typeless node — no collab connection", async () => {
const { state, baseURL } = await spawnCollabStack(); const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw"); const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects( await assert.rejects(
() => client.patchNode(PAGE, SEED_ID, nestedTypelessNode()), () => client.patchNode(PAGE, SEED_ID, { node: nestedTypelessNode() }),
(err) => { (err) => {
assert.match(err.message, /patch_node: invalid node/); assert.match(err.message, /patchNode: invalid node/);
assert.match(err.message, /missing "type"/); assert.match(err.message, /missing "type"/);
assert.match(err.message, /content\[0\]/); // path-anchored assert.match(err.message, /content\[0\]/); // path-anchored
return true; return true;
@@ -152,17 +152,21 @@ test("patch_node fails fast on a nested typeless node — no collab connection",
assert.equal(state.changed, false, "the collab doc must never be written"); assert.equal(state.changed, false, "the collab doc must never be written");
}); });
test("insert_node fails fast on a nested UNKNOWN type — no collab connection", async () => { test("insertNode fails fast on a nested UNKNOWN type — no collab connection", async () => {
const { state, baseURL } = await spawnCollabStack(); const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw"); const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects( await assert.rejects(
() => () =>
client.insertNode(PAGE, nestedUnknownTypeNode(), { client.insertNode(
position: "append", PAGE,
}), { node: nestedUnknownTypeNode() },
{
position: "append",
},
),
(err) => { (err) => {
assert.match(err.message, /insert_node: invalid node/); assert.match(err.message, /insertNode: invalid node/);
assert.match(err.message, /unknown node type "paragraf"/); assert.match(err.message, /unknown node type "paragraf"/);
return true; return true;
}, },
@@ -172,7 +176,7 @@ test("insert_node fails fast on a nested UNKNOWN type — no collab connection",
assert.equal(state.changed, false); assert.equal(state.changed, false);
}); });
test("update_page_json fails fast on a nested typeless node — no collab connection", async () => { test("updatePageJson fails fast on a nested typeless node — no collab connection", async () => {
const { state, baseURL } = await spawnCollabStack(); const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw"); const client = new DocmostClient(baseURL, "user@example.com", "pw");
@@ -184,7 +188,7 @@ test("update_page_json fails fast on a nested typeless node — no collab connec
await assert.rejects( await assert.rejects(
() => client.updatePageJson(PAGE, badDoc), () => client.updatePageJson(PAGE, badDoc),
(err) => { (err) => {
// update_page_json runs validateDocStructure first (string-type check), // updatePageJson runs validateDocStructure first (string-type check),
// which already rejects a typeless node — so the message may come from // which already rejects a typeless node — so the message may come from
// either guard, but the write must not happen. // either guard, but the write must not happen.
assert.match(err.message, /type/i); assert.match(err.message, /type/i);
@@ -196,7 +200,7 @@ test("update_page_json fails fast on a nested typeless node — no collab connec
assert.equal(state.changed, false); assert.equal(state.changed, false);
}); });
test("update_page_json fails fast on a nested UNKNOWN type name — rich #409 message", async () => { test("updatePageJson fails fast on a nested UNKNOWN type name — rich #409 message", async () => {
const { state, baseURL } = await spawnCollabStack(); const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw"); const client = new DocmostClient(baseURL, "user@example.com", "pw");
@@ -210,7 +214,7 @@ test("update_page_json fails fast on a nested UNKNOWN type name — rich #409 me
await assert.rejects( await assert.rejects(
() => client.updatePageJson(PAGE, badDoc), () => client.updatePageJson(PAGE, badDoc),
(err) => { (err) => {
assert.match(err.message, /update_page_json: invalid node/); assert.match(err.message, /updatePageJson: invalid node/);
assert.match(err.message, /unknown node type "paragraf"/); assert.match(err.message, /unknown node type "paragraf"/);
return true; return true;
}, },
@@ -220,13 +224,15 @@ test("update_page_json fails fast on a nested UNKNOWN type name — rich #409 me
assert.equal(state.changed, false); assert.equal(state.changed, false);
}); });
test("patch_node with a well-formed node proceeds to the collab write", async () => { test("patchNode with a well-formed node proceeds to the collab write", async () => {
const { state, baseURL } = await spawnCollabStack(); const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw"); const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.patchNode(PAGE, SEED_ID, { const result = await client.patchNode(PAGE, SEED_ID, {
type: "paragraph", node: {
content: [{ type: "text", text: "replacement" }], type: "paragraph",
content: [{ type: "text", text: "replacement" }],
},
}); });
assert.equal(result.success, true); assert.equal(result.success, true);
@@ -0,0 +1,538 @@
// Mock collab tests for the #413 MARKDOWN path of patchNode / insertNode and the
// markdown-default getNode. These stand up a real Hocuspocus collab server seeded
// with a chosen document (mirroring ambiguous-node-id.test.mjs), let the client
// run its real transform against a live Y.Doc, and read the persisted result back
// to assert on the written document.
//
// Coverage (issue #413):
// - CANON CONVERGENCE: a block written via patchNode(markdown) is canonically
// equal to the SAME content run through a full markdown import (no "second
// canon" appears on the block-level path).
// - id-THREAD on a 1->N splice: the first block inherits the target id, the rest
// get fresh ids, and every NEIGHBOUR block is byte-identical before/after.
// - XOR validation (both / neither markdown+node -> error).
// - span/color-attr GUARD on the target block (a merged/colored cell refuses a
// markdown patch, nothing written).
// - `^[...]` footnote in the fragment -> a definition in the tail list + renumber.
// - insertNode(markdown) inserts N blocks in order at the anchor.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { WebSocketServer } from "ws";
import { Hocuspocus } from "@hocuspocus/server";
import { DocmostClient } from "../../build/client.js";
import { buildYDoc } from "../../build/lib/collaboration.js";
import {
docsCanonicallyEqual,
markdownToProseMirror,
} from "@docmost/prosemirror-markdown";
const PAGE = "11111111-1111-4111-8111-111111111111";
// Deep JSON clone for byte-identity assertions.
const jclone = (v) => JSON.parse(JSON.stringify(v));
function findAll(node, type, acc = []) {
if (!node || typeof node !== "object") return acc;
if (node.type === type) acc.push(node);
if (Array.isArray(node.content))
for (const c of node.content) findAll(c, type, acc);
return acc;
}
// Stand up an HTTP+Hocuspocus stack seeded with `seedDoc`. `state.lastDoc` holds
// the most recently persisted document JSON (decoded from the live Y.Doc on every
// change) so a test can inspect exactly what was written.
async function spawnCollabStack(seedDoc) {
const state = { changed: false, lastDoc: null };
const hocuspocus = new Hocuspocus({
quiet: true,
async onLoadDocument() {
return buildYDoc(seedDoc);
},
async onChange(data) {
state.changed = true;
try {
const frag = data.document.getXmlFragment("default");
// Decode the live fragment back to JSON via the same helper the client
// reads with — but simpler: use the yjs->json path exposed by the doc.
state.lastDoc = fragmentToJson(frag);
} catch {
/* ignore decode errors in teardown races */
}
},
});
const wss = new WebSocketServer({ noServer: true });
const server = http.createServer((req, res) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => {
if (req.url === "/api/auth/login") {
res.writeHead(200, {
"Content-Type": "application/json",
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
res.end(JSON.stringify({ success: true }));
return;
}
if (req.url === "/api/auth/collab-token") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ data: { token: "collab-jwt" } }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ message: "not found" }));
});
});
server.on("upgrade", (request, socket, head) => {
if (!request.url || !request.url.startsWith("/collab")) {
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
hocuspocus.handleConnection(ws, request);
});
});
const baseURL = await new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve(`http://127.0.0.1:${port}/api`);
});
});
openStacks.push({ server, hocuspocus });
return { state, baseURL };
}
// Minimal XmlFragment -> ProseMirror JSON decode, mirroring the shape Docmost
// stores. Reads element name as node type, attributes as attrs, and recurses into
// children; text nodes carry their string.
function fragmentToJson(frag) {
const decodeNode = (el) => {
if (el.constructor.name === "YXmlText") {
// A yjs text node: collect the string with its formatting deltas.
const delta = el.toDelta();
return delta.map((d) => {
const node = { type: "text", text: d.insert };
if (d.attributes && Object.keys(d.attributes).length) {
node.marks = Object.entries(d.attributes).map(([type, attrs]) =>
attrs && typeof attrs === "object" && Object.keys(attrs).length
? { type, attrs }
: { type },
);
}
return node;
});
}
const node = { type: el.nodeName };
const attrs = el.getAttributes();
if (attrs && Object.keys(attrs).length) node.attrs = attrs;
const children = [];
for (const child of el.toArray()) {
const decoded = decodeNode(child);
if (Array.isArray(decoded)) children.push(...decoded);
else children.push(decoded);
}
if (children.length) node.content = children;
return node;
};
const content = [];
for (const child of frag.toArray()) content.push(decodeNode(child));
return { type: "doc", content };
}
const openStacks = [];
after(async () => {
await Promise.all(
openStacks.map(
({ server, hocuspocus }) =>
new Promise((resolve) => {
server.close(() => {
Promise.resolve(hocuspocus.destroy?.()).finally(resolve);
});
}),
),
);
});
// A seed doc with two neighbour paragraphs around a target paragraph.
function seed3() {
return {
type: "doc",
content: [
{
type: "paragraph",
attrs: { id: "before-id" },
content: [{ type: "text", text: "before" }],
},
{
type: "paragraph",
attrs: { id: "target-id" },
content: [{ type: "text", text: "old target" }],
},
{
type: "paragraph",
attrs: { id: "after-id" },
content: [{ type: "text", text: "after" }],
},
],
};
}
test("patchNode(markdown): XOR — both markdown and node is rejected, nothing written", async () => {
const { state, baseURL } = await spawnCollabStack(seed3());
const client = new DocmostClient(baseURL, "e@x.com", "pw");
await assert.rejects(
() =>
client.patchNode(PAGE, "target-id", {
markdown: "hello",
node: { type: "paragraph" },
}),
/exactly one of/i,
);
assert.equal(state.changed, false, "no write on an XOR violation");
});
test("patchNode(markdown): XOR — neither markdown nor node is rejected", async () => {
const { baseURL } = await spawnCollabStack(seed3());
const client = new DocmostClient(baseURL, "e@x.com", "pw");
await assert.rejects(
() => client.patchNode(PAGE, "target-id", {}),
/exactly one of/i,
);
});
test("patchNode(markdown): single block keeps the id; neighbours byte-identical", async () => {
const before = seed3();
const { state, baseURL } = await spawnCollabStack(before);
const client = new DocmostClient(baseURL, "e@x.com", "pw");
const res = await client.patchNode(PAGE, "target-id", {
markdown: "the **new** target",
});
assert.equal(res.success, true);
assert.equal(res.replaced, 1);
assert.equal(res.blocks, 1);
const doc = state.lastDoc;
const paras = doc.content;
// The rewritten block still carries the target id.
const target = paras.find((p) => p.attrs?.id === "target-id");
assert.ok(target, "rewritten block inherits target-id");
assert.equal(target.content.some((n) => n.text === "new"), true);
// Neighbours are byte-identical to the seed.
const beforeNode = paras.find((p) => p.attrs?.id === "before-id");
const afterNode = paras.find((p) => p.attrs?.id === "after-id");
assert.deepEqual(beforeNode, before.content[0]);
assert.deepEqual(afterNode, before.content[2]);
});
test("patchNode(markdown): 1->N splice threads the id onto the first block; neighbours byte-identical", async () => {
const before = seed3();
const { state, baseURL } = await spawnCollabStack(before);
const client = new DocmostClient(baseURL, "e@x.com", "pw");
// Two paragraphs of markdown -> a 2-block fragment replacing one block.
const res = await client.patchNode(PAGE, "target-id", {
markdown: "first para\n\nsecond para",
});
assert.equal(res.blocks, 2);
const doc = state.lastDoc;
const idx = doc.content.findIndex((p) => p.attrs?.id === "target-id");
assert.ok(idx >= 0, "first spliced block inherits target-id");
const first = doc.content[idx];
const second = doc.content[idx + 1];
assert.equal(first.content.some((n) => n.text === "first para"), true);
assert.equal(second.content.some((n) => n.text === "second para"), true);
// The second block has a DIFFERENT (fresh) id.
assert.notEqual(second.attrs?.id, "target-id");
assert.ok(second.attrs?.id, "the extra block gets a fresh id");
// Neighbours untouched, byte-identical.
assert.deepEqual(
doc.content.find((p) => p.attrs?.id === "before-id"),
before.content[0],
);
assert.deepEqual(
doc.content.find((p) => p.attrs?.id === "after-id"),
before.content[2],
);
});
test("patchNode(markdown): CANON CONVERGENCE — block equals the same content full-imported", async () => {
const { state, baseURL } = await spawnCollabStack(seed3());
const client = new DocmostClient(baseURL, "e@x.com", "pw");
const md = "a paragraph with **bold**, _italic_ and `code`";
await client.patchNode(PAGE, "target-id", { markdown: md });
// The block as persisted.
const target = state.lastDoc.content.find((p) => p.attrs?.id === "target-id");
// The same markdown run through the full-page importer.
const full = await markdownToProseMirror(md);
const fullBlock = full.content[0];
assert.ok(
docsCanonicallyEqual(
{ type: "doc", content: [target] },
{ type: "doc", content: [fullBlock] },
),
"a patchNode(markdown) block must be canonically equal to a full import — no second canon",
);
});
test("patchNode(markdown): a paragraph inside a merged (colspan) cell rewrites fine — the cell's span is preserved", async () => {
// A cell paragraph carries an id and IS id-targetable; rewriting ITS content
// from markdown replaces only the paragraph, so the cell's colspan is NOT lost
// (the span lives on the cell, which patchNode leaves in place). This is the
// correct behavior: no false guard, no loss. The guard's REJECTION logic (when
// the replaced block itself carries/contains an unrepresentable span) is proven
// by the findUnrepresentableTableAttrs unit test — that case is not reachable
// through the id-targeting API because tables/cells carry no addressable id.
const doc = {
type: "doc",
content: [
{
type: "table",
content: [
{
type: "tableRow",
content: [
{
type: "tableCell",
attrs: { colspan: 2, rowspan: 1 },
content: [
{
type: "paragraph",
attrs: { id: "cell-para" },
content: [{ type: "text", text: "merged" }],
},
],
},
],
},
],
},
],
};
const { state, baseURL } = await spawnCollabStack(doc);
const client = new DocmostClient(baseURL, "e@x.com", "pw");
const res = await client.patchNode(PAGE, "cell-para", { markdown: "rewritten" });
assert.equal(res.success, true);
// The cell's colspan survives (the span is on the cell, not the paragraph).
const cell = findAll(state.lastDoc, "tableCell")[0];
assert.equal(cell.attrs.colspan, 2, "the cell's colspan is preserved");
const para = findAll(cell, "paragraph")[0];
assert.equal(
(para.content || []).some((n) => n.text === "rewritten"),
true,
);
});
test("patchNode(markdown): a `^[...]` footnote in the fragment lands in the tail list", async () => {
const { state, baseURL } = await spawnCollabStack(seed3());
const client = new DocmostClient(baseURL, "e@x.com", "pw");
await client.patchNode(PAGE, "target-id", {
markdown: "a claim^[the supporting note]",
});
const doc = state.lastDoc;
const lists = findAll(doc, "footnotesList");
assert.equal(lists.length, 1, "exactly one tail footnotesList");
const defs = findAll(doc, "footnoteDefinition");
assert.equal(defs.length, 1, "one definition for the fragment footnote");
const refs = findAll(doc, "footnoteReference");
assert.equal(refs.length, 1, "one reference in the body");
// Reference and definition share an id (renumbered canonically).
assert.equal(refs[0].attrs.id, defs[0].attrs.id);
});
test("insertNode(markdown): inserts N blocks in order after the anchor", async () => {
const before = seed3();
const { state, baseURL } = await spawnCollabStack(before);
const client = new DocmostClient(baseURL, "e@x.com", "pw");
const res = await client.insertNode(
PAGE,
{ markdown: "new one\n\nnew two" },
{ position: "after", anchorNodeId: "before-id" },
);
assert.equal(res.success, true);
assert.equal(res.blocks, 2);
const texts = state.lastDoc.content.map((p) => (p.content || []).map((n) => n.text).join(""));
// Order: before, new one, new two, target, after.
assert.deepEqual(texts, ["before", "new one", "new two", "old target", "after"]);
});
test("insertNode(markdown): XOR — both markdown and node is rejected", async () => {
const { baseURL } = await spawnCollabStack(seed3());
const client = new DocmostClient(baseURL, "e@x.com", "pw");
await assert.rejects(
() =>
client.insertNode(
PAGE,
{ markdown: "x", node: { type: "paragraph" } },
{ position: "append" },
),
/exactly one of/i,
);
});
// A seed page whose ONLY footnote reference lives in the target paragraph p1,
// with a matching definition in a trailing footnotesList. Rewriting p1 with a
// footnote-free fragment removes the last referrer -> the definition is orphaned.
function seedOrphanFootnote() {
return {
type: "doc",
content: [
{
type: "paragraph",
attrs: { id: "p1" },
content: [
{ type: "text", text: "a claim" },
{ type: "footnoteReference", attrs: { id: "fn-1", referenceNumber: 1 } },
],
},
{
type: "footnotesList",
content: [
{
type: "footnoteDefinition",
attrs: { id: "fn-1" },
content: [
{
type: "paragraph",
attrs: { id: "def-para" },
content: [{ type: "text", text: "the supporting note" }],
},
],
},
],
},
],
};
}
test("patchNode(markdown): removing the LAST footnote referrer drops the now-orphan definition (canonical convergence)", async () => {
const { state, baseURL } = await spawnCollabStack(seedOrphanFootnote());
const client = new DocmostClient(baseURL, "e@x.com", "pw");
// The fragment has NO footnotes -> definitions=[]; the splice removes the only
// footnoteReference, leaving the tail definition orphaned. The canonicalization
// pass (which mergeFootnoteDefinitions must still run) has to drop it.
await client.patchNode(PAGE, "p1", { markdown: "just text" });
const doc = state.lastDoc;
assert.equal(
findAll(doc, "footnoteDefinition").length,
0,
"the orphaned definition is dropped",
);
assert.equal(
findAll(doc, "footnotesList").length,
0,
"the emptied footnotesList is removed",
);
assert.equal(findAll(doc, "footnoteReference").length, 0, "no references remain");
// Convergence: the persisted result equals the SAME content imported whole.
const full = await markdownToProseMirror("just text");
const target = doc.content.find((p) => p.attrs?.id === "p1");
assert.ok(
docsCanonicallyEqual(
{ type: "doc", content: [target] },
{ type: "doc", content: [full.content[0]] },
),
"the post-splice doc is canonically identical to a full re-import",
);
});
test("patchNode(markdown): a pure-text patch on a footnote-FREE page leaves footnote topology untouched (fast path)", async () => {
const before = seed3();
const { state, baseURL } = await spawnCollabStack(before);
const client = new DocmostClient(baseURL, "e@x.com", "pw");
await client.patchNode(PAGE, "target-id", { markdown: "plain replacement" });
const doc = state.lastDoc;
assert.equal(findAll(doc, "footnotesList").length, 0, "no footnotesList appears");
assert.equal(findAll(doc, "footnoteDefinition").length, 0, "no definition appears");
assert.equal(findAll(doc, "footnoteReference").length, 0, "no reference appears");
// Neighbours byte-identical (the fast path does not clone/reshape the tree).
assert.deepEqual(
doc.content.find((p) => p.attrs?.id === "before-id"),
before.content[0],
);
assert.deepEqual(
doc.content.find((p) => p.attrs?.id === "after-id"),
before.content[2],
);
});
test("insertNode(markdown): a footnote-free insert on a page carrying a footnote still canonicalizes (definitions empty)", async () => {
// The page has an existing footnote (ref + tail def). Inserting a footnote-free
// fragment keeps the reference alive, so the definition stays — but the write
// path must still run canonicalization (definitions=[]), producing exactly one
// tail list with the reference/definition ids in sync.
const { state, baseURL } = await spawnCollabStack(seedOrphanFootnote());
const client = new DocmostClient(baseURL, "e@x.com", "pw");
const res = await client.insertNode(
PAGE,
{ markdown: "unrelated one\n\nunrelated two" },
{ position: "after", anchorNodeId: "p1" },
);
assert.equal(res.success, true);
const doc = state.lastDoc;
assert.equal(findAll(doc, "footnoteReference").length, 1, "the existing reference survives");
assert.equal(findAll(doc, "footnotesList").length, 1, "exactly one tail list");
const defs = findAll(doc, "footnoteDefinition");
assert.equal(defs.length, 1, "the definition is kept (still referenced)");
assert.equal(findAll(doc, "footnoteReference")[0].attrs.id, defs[0].attrs.id);
});
// Collect every TOP-LEVEL block id in a doc (the invariant the splice dedup
// guarantees is page-wide top-level uniqueness).
function topLevelIds(doc) {
return doc.content
.map((b) => b?.attrs?.id)
.filter((id) => id != null);
}
test("patchNode(markdown): a 1->N splice yields page-wide UNIQUE top-level block ids", async () => {
const before = seed3();
const { state, baseURL } = await spawnCollabStack(before);
const client = new DocmostClient(baseURL, "e@x.com", "pw");
await client.patchNode(PAGE, "target-id", {
markdown: "one\n\ntwo\n\nthree",
});
const ids = topLevelIds(state.lastDoc);
assert.equal(new Set(ids).size, ids.length, "all top-level block ids are unique");
// The target id is still present (threaded onto the first block).
assert.ok(ids.includes("target-id"), "the first block still inherits target-id");
});
test("insertNode(markdown): inserting multiple blocks yields page-wide UNIQUE top-level block ids", async () => {
const before = seed3();
const { state, baseURL } = await spawnCollabStack(before);
const client = new DocmostClient(baseURL, "e@x.com", "pw");
await client.insertNode(
PAGE,
{ markdown: "alpha\n\nbeta\n\ngamma" },
{ position: "after", anchorNodeId: "before-id" },
);
const ids = topLevelIds(state.lastDoc);
assert.equal(new Set(ids).size, ids.length, "all top-level block ids are unique");
});
@@ -155,7 +155,7 @@ test("listSidebarPages terminates (no dups) when the server ignores the cursor",
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// 3a) enumerateSpacePages happy path: a SINGLE /pages/tree request. // 3a) enumerateSpacePages happy path: a SINGLE /pages/tree request.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
test("enumerateSpacePages (via list_pages tree) uses one /pages/tree request", async () => { test("enumerateSpacePages (via listPages tree) uses one /pages/tree request", async () => {
let treeRequests = 0; let treeRequests = 0;
let sidebarRequests = 0; let sidebarRequests = 0;
let treeBody = null; let treeBody = null;
@@ -184,7 +184,7 @@ test("enumerateSpacePages (via list_pages tree) uses one /pages/tree request", a
}); });
const client = new DocmostClient(baseURL, "user@example.com", "pw"); const client = new DocmostClient(baseURL, "user@example.com", "pw");
// list_pages tree:true -> enumerateSpacePages(spaceId) -> buildPageTree. // listPages tree:true -> enumerateSpacePages(spaceId) -> buildPageTree.
const tree = await client.listPages("space-1", 50, true); const tree = await client.listPages("space-1", 50, true);
assert.equal(treeRequests, 1, "exactly one /pages/tree request for the space"); assert.equal(treeRequests, 1, "exactly one /pages/tree request for the space");
@@ -375,7 +375,7 @@ test("listComments terminates (no dups) when the server ignores the cursor", asy
}); });
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// 4) check_new_comments subtree: the root is included in scope WITHOUT a // 4) checkNewComments subtree: the root is included in scope WITHOUT a
// separate getPageRaw (/pages/info) request for the parent. // separate getPageRaw (/pages/info) request for the parent.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
test("checkNewComments subtree includes the root without a separate getPageRaw", async () => { test("checkNewComments subtree includes the root without a separate getPageRaw", async () => {
@@ -244,7 +244,7 @@ test("tableInsertRow with a slugId opens the collab doc by the resolved UUID (#2
); );
}); });
test("the generic mutate (insert_footnote) with a slugId opens by the resolved UUID (#260)", async () => { test("the generic mutate (insertFootnote) with a slugId opens by the resolved UUID (#260)", async () => {
const { state, baseURL } = await spawnCollabStack(); const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw"); const client = new DocmostClient(baseURL, "user@example.com", "pw");
@@ -254,7 +254,7 @@ test("the generic mutate (insert_footnote) with a slugId opens by the resolved U
assert.deepEqual( assert.deepEqual(
state.docNames, state.docNames,
[`page.${UUID}`], [`page.${UUID}`],
"insert_footnote (via the mutatePage seam) must open the collab doc by UUID", "insertFootnote (via the mutatePage seam) must open the collab doc by UUID",
); );
}); });
@@ -1,4 +1,4 @@
// Server round-trip test for the stash_page MCP tool result shape. The in-app // Server round-trip test for the stashPage MCP tool result shape. The in-app
// path returns the full documented `{ uri, size, sha256, images }` object, but // path returns the full documented `{ uri, size, sha256, images }` object, but
// the MCP transport must deliver the SAME shape: a resource_link (primary // the MCP transport must deliver the SAME shape: a resource_link (primary
// payload) PLUS a `structuredContent` mirror carrying sha256 + image counts. // payload) PLUS a `structuredContent` mirror carrying sha256 + image counts.
@@ -107,7 +107,7 @@ async function buildBaseURL() {
}); });
} }
test("stash_page MCP tool returns a resource_link AND a structuredContent mirror", async () => { test("stashPage MCP tool returns a resource_link AND a structuredContent mirror", async () => {
const baseURL = await buildBaseURL(); const baseURL = await buildBaseURL();
const sandbox = makeSandbox(); const sandbox = makeSandbox();
const server = createDocmostMcpServer({ const server = createDocmostMcpServer({
@@ -124,7 +124,7 @@ test("stash_page MCP tool returns a resource_link AND a structuredContent mirror
try { try {
const res = await client.callTool({ const res = await client.callTool({
name: "stash_page", name: "stashPage",
arguments: { pageId: "page-1" }, arguments: { pageId: "page-1" },
}); });
@@ -20,11 +20,11 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createDocmostMcpServer } from "../../build/index.js"; import { createDocmostMcpServer } from "../../build/index.js";
// The tool we drive. get_workspace has NO input schema, so protocol-level input // The tool we drive. getWorkspace has NO input schema, so protocol-level input
// validation cannot short-circuit before the handler runs — the wrapped handler // validation cannot short-circuit before the handler runs — the wrapped handler
// is guaranteed to execute (and then fail on the unreachable backend, which is // is guaranteed to execute (and then fail on the unreachable backend, which is
// exactly what we want: the wrapper times in a finally on throw too). // exactly what we want: the wrapper times in a finally on throw too).
const TOOL_NAME = "get_workspace"; const TOOL_NAME = "getWorkspace";
test("the factory's registerTool monkeypatch times a live tool call and labels it with the registration name", async () => { test("the factory's registerTool monkeypatch times a live tool call and labels it with the registration name", async () => {
const calls = []; const calls = [];
@@ -152,7 +152,7 @@ test("tautological comment tools are excluded and never probe", async () => {
const { comments, probeCalls, tracker } = makeWorld(); const { comments, probeCalls, tracker } = makeWorld();
tracker.noteWorkingPage("p1"); tracker.noteWorkingPage("p1");
comments.push({ createdAt: 9_999_999 }); comments.push({ createdAt: 9_999_999 });
for (const name of ["listComments", "list_comments", "checkNewComments", "createComment"]) { for (const name of ["listComments", "listComments", "checkNewComments", "createComment"]) {
assert.equal(await tracker.maybeSignal(name), null); assert.equal(await tracker.maybeSignal(name), null);
} }
assert.equal(probeCalls.length, 0); assert.equal(probeCalls.length, 0);
@@ -188,7 +188,7 @@ function fakeTracker({ line }) {
noteWorkingPage: (p) => events.push(["note", p]), noteWorkingPage: (p) => events.push(["note", p]),
advanceWatermark: () => events.push(["advance"]), advanceWatermark: () => events.push(["advance"]),
isExcludedTool: (n) => isExcludedTool: (n) =>
new Set(["listComments", "list_comments"]).has(n), new Set(["listComments", "listComments"]).has(n),
maybeSignal: async () => line, maybeSignal: async () => line,
}; };
} }
@@ -221,7 +221,7 @@ test("withCommentSignal: appends ONE extra text element when signalled", async (
test("withCommentSignal: excluded tool advances the watermark and does not append", async () => { test("withCommentSignal: excluded tool advances the watermark and does not append", async () => {
const tracker = fakeTracker({ line: "SHOULD-NOT-APPEAR" }); const tracker = fakeTracker({ line: "SHOULD-NOT-APPEAR" });
const original = { content: [{ type: "text", text: "comments" }] }; const original = { content: [{ type: "text", text: "comments" }] };
const wrapped = withCommentSignal("list_comments", async () => original, tracker); const wrapped = withCommentSignal("listComments", async () => original, tracker);
const result = await wrapped({ pageId: "p1" }); const result = await wrapped({ pageId: "p1" });
assert.equal(result, original); // unchanged assert.equal(result, original); // unchanged
assert.ok(tracker.events.some((e) => e[0] === "advance")); assert.ok(tracker.events.some((e) => e[0] === "advance"));
@@ -1,86 +0,0 @@
// Issue #464 — prove the size guard SKIPS the recreateTransform pipeline over
// the cap, not merely that it returns "coarse". node:test's mock.module needs an
// experimental flag the suite does not pass, so instead of a module spy we use a
// deterministic BEHAVIORAL proxy that isolates the one variable — the guard:
//
// Same over-cap pair, run twice:
// (a) default caps -> guard trips -> recreateTransform skipped,
// (b) caps raised above the doc -> guard OFF -> recreateTransform DOES run.
//
// The only code path that differs between (a) and (b) is whether
// recreateTransform executes. recreateTransform on this pair is O(n²) and takes
// SECONDS; the guarded path is a linear coarse diff taking milliseconds. So a
// large (a)≪(b) time ratio can ONLY be explained by (a) skipping the transform.
// This asserts the skip without depending on mock.module.
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffDocs } from "../../build/lib/diff.js";
const t = (text) => ({ type: "text", text });
const para = (text) => ({ type: "paragraph", content: text ? [t(text)] : [] });
const doc = (children) => ({ type: "doc", content: children });
function buildDoc(n, seed) {
return doc(
Array.from({ length: n }, (_, i) =>
para(Array.from({ length: 8 }, (_, w) => `${seed}${i}_${w}`).join(" ")),
),
);
}
function clearEnv() {
delete process.env.MCP_DIFF_MAX_NODES;
delete process.env.MCP_DIFF_MAX_BYTES;
}
function timed(fn) {
const s = performance.now();
const out = fn();
return { out, ms: performance.now() - s };
}
// A 300-para (~600-node) pair: comfortably over the 150-node default, yet small
// enough that the un-guarded recreateTransform still FINISHES (~1-3s) so the
// test can time the contrast without hanging.
const OLD = buildDoc(300, "a");
const NEW = buildDoc(300, "b");
test("guard skips recreateTransform over-cap (guarded run is far faster than un-guarded)", () => {
// (a) Guarded: default caps -> should short-circuit to coarse, near-instant.
clearEnv();
const guarded = timed(() => diffDocs(OLD, NEW));
assert.match(
guarded.out.markdown,
/coarse block-level diff/,
"guarded run must be coarse (guard tripped)",
);
// (b) Un-guarded: raise both caps above the doc so the precise path runs.
process.env.MCP_DIFF_MAX_NODES = "1000000";
process.env.MCP_DIFF_MAX_BYTES = "100000000";
let unguarded;
try {
unguarded = timed(() => diffDocs(OLD, NEW));
} finally {
clearEnv();
}
assert.doesNotMatch(
unguarded.out.markdown,
/coarse block-level diff/,
"with caps raised, the precise recreateTransform path runs",
);
// The precise run executed recreateTransform (O(n²)); the guarded run did not.
// Require a large speedup so the ONLY explanation is the skipped transform.
assert.ok(
guarded.ms * 5 < unguarded.ms,
`guarded (${guarded.ms.toFixed(1)}ms) must be >=5x faster than un-guarded ` +
`(${unguarded.ms.toFixed(1)}ms); a small gap would mean the transform still ran`,
);
});
test("guarded over-cap call stays within the ~200ms event-loop budget", () => {
clearEnv();
// Best-of-3 to shed GC/JIT noise; the guarded coarse path is a linear walk.
let best = Infinity;
for (let i = 0; i < 3; i++) best = Math.min(best, timed(() => diffDocs(OLD, NEW)).ms);
assert.ok(best < 200, `guarded over-cap diff must be <200ms, was ${best.toFixed(1)}ms`);
});
@@ -1,229 +0,0 @@
// Issue #464 — prod CPU-DoS pre-flight size guard for diffDocs.
//
// diffDocs synchronously calls recreateTransform (rfc6902) which is O(n·m) in
// node count and O(w²) in per-run word count; on a large/heavily-changed doc it
// pins the event loop for seconds-to-hours WITHOUT throwing. A pre-flight size
// guard routes any doc over MCP_DIFF_MAX_NODES / MCP_DIFF_MAX_BYTES straight to
// the coarse fallback (`fellBack:true`), so the sync block stays ~<200ms.
//
// These tests assert the BEHAVIOR of the guard (fast + coarse-mode + asymmetry +
// env knobs). A sibling test (diff-guard-skips-recreate.test.mjs) proves
// recreateTransform is skipped over the cap via a behavioral proxy (guarded run
// is orders of magnitude faster than the same pair with the caps raised).
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffDocs } from "../../build/lib/diff.js";
// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------
const t = (text) => ({ type: "text", text });
const para = (text) => ({ type: "paragraph", content: text ? [t(text)] : [] });
const doc = (children) => ({ type: "doc", content: children });
/** A doc of `n` paragraphs whose words are seeded from `seed` (fully changeable). */
function buildDoc(n, wordsPerPara, seed) {
const blocks = [];
for (let i = 0; i < n; i++) {
const words = [];
for (let w = 0; w < wordsPerPara; w++) words.push(`${seed}${i}_${w}`);
blocks.push(para(words.join(" ")));
}
return doc(blocks);
}
/** Reset the env knobs to their unset default between tests. */
function clearEnv() {
delete process.env.MCP_DIFF_MAX_NODES;
delete process.env.MCP_DIFF_MAX_BYTES;
}
// ---------------------------------------------------------------------------
// Over-threshold (by node count) -> FAST + coarse mode.
// A fully re-written 600-para doc is the worst case that drove the incident;
// with the guard it must return in well under the ~200ms budget and in coarse
// mode. Without the guard this single call takes multiple SECONDS.
// ---------------------------------------------------------------------------
test("over-threshold doc falls back to coarse mode and returns fast", () => {
clearEnv();
// 600 paragraphs -> ~1200 nodes, far over the 150-node default.
const oldDoc = buildDoc(600, 8, "a");
const newDoc = buildDoc(600, 8, "b");
const start = performance.now();
const r = diffDocs(oldDoc, newDoc);
const elapsed = performance.now() - start;
// Coarse mode is signalled in the markdown note (fellBack path).
assert.match(
r.markdown,
/coarse block-level diff/,
"over-threshold pair must use the coarse fallback",
);
// Budget: the guard makes this near-instant. Generous 1s ceiling to avoid CI
// flake while still being ~10x under the multi-second un-guarded cost.
assert.ok(
elapsed < 1000,
`expected fast coarse fallback, took ${elapsed.toFixed(0)}ms`,
);
// Coarse diff still detects the wholesale change.
assert.ok(r.summary.inserted > 0 || r.summary.deleted > 0, "reports changes");
});
// ---------------------------------------------------------------------------
// Under-threshold (small) doc -> precise diff, NOT coarse mode. No regression.
// ---------------------------------------------------------------------------
test("under-threshold doc uses the precise diff (no fallback note)", () => {
clearEnv();
const oldDoc = doc([para("Hello world")]);
const newDoc = doc([para("Hello brave world")]);
const r = diffDocs(oldDoc, newDoc);
assert.doesNotMatch(
r.markdown,
/coarse block-level diff/,
"a small doc must take the precise path",
);
// Precise word diff finds exactly the inserted word.
const ins = r.changes.find((c) => c.op === "insert");
assert.ok(ins && /brave/.test(ins.text), "precise diff isolates the inserted word");
});
// ---------------------------------------------------------------------------
// Asymmetry: a small NEW doc vs a huge OLD doc (and vice versa) still explodes
// rfc6902, so max(old,new) must trip the guard in BOTH directions.
// ---------------------------------------------------------------------------
test("asymmetric pair (huge old, tiny new) falls back to coarse", () => {
clearEnv();
const hugeOld = buildDoc(600, 8, "a");
const tinyNew = doc([para("just one line")]);
const r = diffDocs(hugeOld, tinyNew);
assert.match(r.markdown, /coarse block-level diff/, "huge-old side must trip the guard");
});
test("asymmetric pair (tiny old, huge new) falls back to coarse", () => {
clearEnv();
const tinyOld = doc([para("just one line")]);
const hugeNew = buildDoc(600, 8, "b");
const r = diffDocs(tinyOld, hugeNew);
assert.match(r.markdown, /coarse block-level diff/, "huge-new side must trip the guard");
});
// ---------------------------------------------------------------------------
// Byte axis: a FEW nodes but a very large serialized size (long text runs) is
// dangerous too (per-run word diff is O(words²)), so the byte cap must trip
// independently of the node count.
// ---------------------------------------------------------------------------
test("node-light but byte-heavy doc falls back on the byte cap", () => {
clearEnv();
// 5 paragraphs (~11 nodes, well under the node cap) but each a very long run,
// pushing the serialized size far over the 12 KiB byte default.
const bigRun = (seed) =>
doc(
Array.from({ length: 5 }, (_, i) =>
para(Array.from({ length: 800 }, (_, w) => `${seed}${i}_${w}`).join(" ")),
),
);
const oldDoc = bigRun("a");
const newDoc = bigRun("b");
// Sanity: node count is under the default node cap, so ONLY the byte cap can
// be what trips the guard here.
const nodeCount = (d) => {
let n = 0;
const v = (x) => {
if (!x || typeof x !== "object") return;
n++;
if (Array.isArray(x.content)) for (const c of x.content) v(c);
};
v(d);
return n;
};
assert.ok(nodeCount(oldDoc) < 150, "node count is under the node cap");
assert.ok(JSON.stringify(oldDoc).length > 12 * 1024, "serialized size is over the byte cap");
const r = diffDocs(oldDoc, newDoc);
assert.match(r.markdown, /coarse block-level diff/, "byte cap must trip independently");
});
// ---------------------------------------------------------------------------
// Env override: a very low MCP_DIFF_MAX_NODES forces fallback on a tiny doc,
// proving the knob is read fresh and actually gates the diff.
// ---------------------------------------------------------------------------
test("MCP_DIFF_MAX_NODES override forces fallback on a small doc", () => {
clearEnv();
const oldDoc = doc([para("Hello world")]);
const newDoc = doc([para("Hello brave world")]);
// Baseline: default caps -> precise diff.
assert.doesNotMatch(diffDocs(oldDoc, newDoc).markdown, /coarse block-level diff/);
// Knob set absurdly low -> even this 4-node doc trips the guard.
process.env.MCP_DIFF_MAX_NODES = "1";
try {
const r = diffDocs(oldDoc, newDoc);
assert.match(r.markdown, /coarse block-level diff/, "low node cap forces fallback");
} finally {
clearEnv();
}
});
test("MCP_DIFF_MAX_BYTES override forces fallback on a small doc", () => {
clearEnv();
const oldDoc = doc([para("Hello world")]);
const newDoc = doc([para("Hello brave world")]);
process.env.MCP_DIFF_MAX_BYTES = "1";
try {
const r = diffDocs(oldDoc, newDoc);
assert.match(r.markdown, /coarse block-level diff/, "low byte cap forces fallback");
} finally {
clearEnv();
}
});
// ---------------------------------------------------------------------------
// Garbage / unset env values fall back to the DEFAULT (the guard can never be
// accidentally disabled by a malformed knob). A small doc must still diff
// precisely under a garbage cap.
// ---------------------------------------------------------------------------
test("garbage env values fall back to the default cap (guard not disabled)", () => {
clearEnv();
const oldDoc = doc([para("Hello world")]);
const newDoc = doc([para("Hello brave world")]);
for (const bad of ["not-a-number", "0", "-5", "", "NaN", "1e999"]) {
process.env.MCP_DIFF_MAX_NODES = bad;
process.env.MCP_DIFF_MAX_BYTES = bad;
// Under the DEFAULT caps this small doc is precise (garbage did not raise
// OR disable the cap). "1e999" -> parseInt yields 1 (finite) which is a
// valid low cap and would fall back; exclude that from the precise check.
const r = diffDocs(oldDoc, newDoc);
if (bad === "1e999") {
// parseInt("1e999",10) === 1 -> a legit low cap -> fallback. Guard active.
assert.match(r.markdown, /coarse block-level diff/);
} else {
assert.doesNotMatch(
r.markdown,
/coarse block-level diff/,
`garbage value ${JSON.stringify(bad)} must fall back to the default cap`,
);
}
}
clearEnv();
});
// ---------------------------------------------------------------------------
// A large doc that trips the guard must still return the correct INTEGRITY
// counts (computeIntegrity runs before the diff and is unaffected by fallback).
// ---------------------------------------------------------------------------
test("integrity counts are still correct on a guard-tripped (coarse) doc", () => {
clearEnv();
const image = { type: "image", attrs: { src: "/api/files/a.png" } };
const oldDoc = doc([image, ...buildDoc(600, 8, "a").content]);
const newDoc = doc([...buildDoc(600, 8, "b").content]); // image removed
const r = diffDocs(oldDoc, newDoc);
assert.match(r.markdown, /coarse block-level diff/, "large pair fell back");
assert.deepEqual(r.integrity.images, [1, 0], "integrity is computed regardless of fallback");
});
+1 -1
View File
@@ -114,7 +114,7 @@ test("summarizeChange treats a key-order-only difference as no change", () => {
// (v) CRITICAL: a structural change that touches no text/marks — adding an // (v) CRITICAL: a structural change that touches no text/marks — adding an
// image node (images 0 -> 1) — must report changed:true and surface the // image node (images 0 -> 1) — must report changed:true and surface the
// integrity delta in structure + summary, closing the verify blind spot for // integrity delta in structure + summary, closing the verify blind spot for
// insert_image / delete_node on structural nodes. // insertImage / deleteNode on structural nodes.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
test("summarizeChange surfaces an image-count change (0->1)", () => { test("summarizeChange surfaces an image-count change (0->1)", () => {
const before = doc(para(t("caption"))); const before = doc(para(t("caption")));
+1 -1
View File
@@ -1,4 +1,4 @@
// Unit tests for the drawio_guide progressive-disclosure reference (issue #424). // Unit tests for the drawioGuide progressive-disclosure reference (issue #424).
// Acceptance #2: every section is returned and each is <= ~4KB so pulling one // Acceptance #2: every section is returned and each is <= ~4KB so pulling one
// does not bloat the model's context. // does not bloat the model's context.
import { test } from "node:test"; import { test } from "node:test";
@@ -1,4 +1,4 @@
// Unit tests for the drawio_shapes verified-stencil catalog (issue #424). // Unit tests for the drawioShapes verified-stencil catalog (issue #424).
// Covers acceptance #1: a "lambda" query returns a valid mxgraph.aws4 icon with // Covers acceptance #1: a "lambda" query returns a valid mxgraph.aws4 icon with
// the right service/resource pattern + sizes; a blocklisted stencil query // the right service/resource pattern + sizes; a blocklisted stencil query
// returns its working replacement. // returns its working replacement.
@@ -22,7 +22,7 @@ test("the bundled index loads and is the real ~10k-shape catalog", () => {
} }
}); });
test('drawio_shapes("lambda") returns a valid mxgraph.aws4 service icon', () => { test('drawioShapes("lambda") returns a valid mxgraph.aws4 service icon', () => {
const results = searchShapes("lambda", { limit: 5 }); const results = searchShapes("lambda", { limit: 5 });
assert.ok(results.length > 0); assert.ok(results.length > 0);
// Acceptance #1: a valid aws4 service-level icon (resourceIcon + resIcon) // Acceptance #1: a valid aws4 service-level icon (resourceIcon + resIcon)
@@ -7,16 +7,16 @@ import assert from "node:assert/strict";
import { SERVER_INSTRUCTIONS } from "../../build/index.js"; import { SERVER_INSTRUCTIONS } from "../../build/index.js";
import { SHARED_TOOL_SPECS } from "../../build/tool-specs.js"; import { SHARED_TOOL_SPECS } from "../../build/tool-specs.js";
test("drawio_shapes and drawio_guide are in the shared registry", () => { test("drawioShapes and drawioGuide are in the shared registry", () => {
assert.equal(SHARED_TOOL_SPECS.drawioShapes.mcpName, "drawio_shapes"); assert.equal(SHARED_TOOL_SPECS.drawioShapes.mcpName, "drawioShapes");
assert.equal(SHARED_TOOL_SPECS.drawioGuide.mcpName, "drawio_guide"); assert.equal(SHARED_TOOL_SPECS.drawioGuide.mcpName, "drawioGuide");
// Deferred tier, matching the stage-1 drawio tools. // Deferred tier, matching the stage-1 drawio tools.
assert.equal(SHARED_TOOL_SPECS.drawioShapes.tier, "deferred"); assert.equal(SHARED_TOOL_SPECS.drawioShapes.tier, "deferred");
assert.equal(SHARED_TOOL_SPECS.drawioGuide.tier, "deferred"); assert.equal(SHARED_TOOL_SPECS.drawioGuide.tier, "deferred");
}); });
test("the new tools are routed in SERVER_INSTRUCTIONS", () => { test("the new tools are routed in SERVER_INSTRUCTIONS", () => {
for (const name of ["drawio_shapes", "drawio_guide"]) { for (const name of ["drawioShapes", "drawioGuide"]) {
assert.match(SERVER_INSTRUCTIONS, new RegExp(`\\b${name}\\b`), `${name} missing from guide`); assert.match(SERVER_INSTRUCTIONS, new RegExp(`\\b${name}\\b`), `${name} missing from guide`);
} }
}); });
@@ -26,7 +26,7 @@ test("the hard-rules block is injected into create/update descriptions", () => {
const d = SHARED_TOOL_SPECS[key].description; const d = SHARED_TOOL_SPECS[key].description;
assert.match(d, /sentinels are MANDATORY/); assert.match(d, /sentinels are MANDATORY/);
assert.match(d, /vertex="1" XOR edge="1"/); assert.match(d, /vertex="1" XOR edge="1"/);
assert.match(d, /call drawio_shapes first/); assert.match(d, /call drawioShapes first/);
assert.match(d, /adaptiveColors="auto"/); assert.match(d, /adaptiveColors="auto"/);
assert.match(d, /&#xa;/); assert.match(d, /&#xa;/);
} }
@@ -258,7 +258,7 @@ test("a non-axios error is passed through untouched", () => {
const GOOD_UUID = "019f499a-9f8c-7d68-b7be-ce100d7c6c56"; const GOOD_UUID = "019f499a-9f8c-7d68-b7be-ce100d7c6c56";
test("assertFullUuid accepts a full canonical UUID (any version nibble)", () => { test("assertFullUuid accepts a full canonical UUID (any version nibble)", () => {
assert.doesNotThrow(() => assertFullUuid("resolve_comment", "commentId", GOOD_UUID)); assert.doesNotThrow(() => assertFullUuid("resolveComment", "commentId", GOOD_UUID));
// A v4 id also passes (version/variant-agnostic). // A v4 id also passes (version/variant-agnostic).
assert.doesNotThrow(() => assert.doesNotThrow(() =>
assertFullUuid("get_comment", "commentId", "3d5b7c1e-2f4a-4b6c-8d9e-0f1a2b3c4d5e"), assertFullUuid("get_comment", "commentId", "3d5b7c1e-2f4a-4b6c-8d9e-0f1a2b3c4d5e"),
@@ -267,10 +267,10 @@ test("assertFullUuid accepts a full canonical UUID (any version nibble)", () =>
test("assertFullUuid rejects a truncated prefix", () => { test("assertFullUuid rejects a truncated prefix", () => {
assert.throws( assert.throws(
() => assertFullUuid("resolve_comment", "commentId", "019f499a"), () => assertFullUuid("resolveComment", "commentId", "019f499a"),
(e) => (e) =>
e.message.startsWith( e.message.startsWith(
"resolve_comment: 'commentId' must be the FULL comment UUID", "resolveComment: 'commentId' must be the FULL comment UUID",
) && ) &&
e.message.includes("got '019f499a'") && e.message.includes("got '019f499a'") &&
e.message.includes("Copy the id verbatim"), e.message.includes("Copy the id verbatim"),
@@ -279,11 +279,11 @@ test("assertFullUuid rejects a truncated prefix", () => {
test("assertFullUuid rejects garbage and empty string", () => { test("assertFullUuid rejects garbage and empty string", () => {
assert.throws( assert.throws(
() => assertFullUuid("delete_comment", "commentId", "not-a-uuid"), () => assertFullUuid("deleteComment", "commentId", "not-a-uuid"),
/must be the FULL comment UUID.*got 'not-a-uuid'/s, /must be the FULL comment UUID.*got 'not-a-uuid'/s,
); );
assert.throws( assert.throws(
() => assertFullUuid("update_comment", "commentId", ""), () => assertFullUuid("updateComment", "commentId", ""),
/must be the FULL comment UUID.*got ''/s, /must be the FULL comment UUID.*got ''/s,
); );
}); });
@@ -436,14 +436,14 @@ test("all 5 comment-id call sites reject a bad id with ZERO network traffic", as
const client = new DocmostClient(baseURL, "u@example.com", "pw"); const client = new DocmostClient(baseURL, "u@example.com", "pw");
const bad = "019f499a"; // truncated const bad = "019f499a"; // truncated
await assert.rejects(() => client.resolveComment(bad, true), /resolve_comment: 'commentId'/); await assert.rejects(() => client.resolveComment(bad, true), /resolveComment: 'commentId'/);
await assert.rejects(() => client.updateComment(bad, "hi"), /update_comment: 'commentId'/); await assert.rejects(() => client.updateComment(bad, "hi"), /updateComment: 'commentId'/);
await assert.rejects(() => client.deleteComment(bad), /delete_comment: 'commentId'/); await assert.rejects(() => client.deleteComment(bad), /deleteComment: 'commentId'/);
await assert.rejects(() => client.getComment(bad), /get_comment: 'commentId'/); await assert.rejects(() => client.getComment(bad), /get_comment: 'commentId'/);
// createComment validates parentCommentId only when provided. // createComment validates parentCommentId only when provided.
await assert.rejects( await assert.rejects(
() => client.createComment("page-1", "body", "inline", "sel", bad), () => client.createComment("page-1", "body", "inline", "sel", bad),
/create_comment: 'parentCommentId'/, /createComment: 'parentCommentId'/,
); );
assert.equal(requests, 0, "no request (not even /auth/login) may be issued for a bad id"); assert.equal(requests, 0, "no request (not even /auth/login) may be issued for a bad id");
+93 -1
View File
@@ -1,7 +1,11 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { filterComment, filterPage } from "../../build/lib/filters.js"; import {
filterComment,
filterPage,
filterSearchResult,
} from "../../build/lib/filters.js";
test("filterComment includes resolvedAt/resolvedById as null when absent", () => { test("filterComment includes resolvedAt/resolvedById as null when absent", () => {
const result = filterComment({ const result = filterComment({
@@ -171,3 +175,91 @@ test("filterPage includes both content and subpages together", () => {
assert.equal(result.content, "body"); assert.equal(result.content, "body");
assert.deepEqual(result.subpages, [{ id: "s1", title: "Sub" }]); assert.deepEqual(result.subpages, [{ id: "s1", title: "Sub" }]);
}); });
// --- filterSearchResult (#443 agent-lookup contract) -------------------------
test("filterSearchResult maps the lookup shape to {pageId,title,path,snippet,score}", () => {
const result = filterSearchResult({
id: "0199aa-uuid",
slugId: "slug-secret",
title: "backup-srv.local",
parentPageId: "0199pp",
path: ["Infrastructure", "Datacenter A", "Servers"],
snippet: "…IP: 10.0.12.5. Debian 12…",
score: 0.92,
});
assert.deepEqual(result, {
pageId: "0199aa-uuid",
title: "backup-srv.local",
path: ["Infrastructure", "Datacenter A", "Servers"],
snippet: "…IP: 10.0.12.5. Debian 12…",
score: 0.92,
});
});
test("filterSearchResult NEVER exposes slugId (pageId is the only identifier)", () => {
const result = filterSearchResult({
id: "uuid-1",
slugId: "slug-1",
title: "t",
path: [],
snippet: "s",
score: 0.1,
});
assert.equal("slugId" in result, false);
assert.equal("id" in result, false);
assert.equal(result.pageId, "uuid-1");
});
test("filterSearchResult root page yields path: []", () => {
const result = filterSearchResult({
id: "uuid-root",
title: "Root",
path: [],
snippet: "s",
score: 0.5,
});
assert.deepEqual(result.path, []);
});
test("filterSearchResult degrades a legacy FTS hit (no lookup fields)", () => {
// Stock upstream stripped the opt-in DTO fields → legacy shape with
// highlight + rank and no path/snippet/score.
const result = filterSearchResult({
id: "uuid-legacy",
slugId: "slug-legacy",
title: "Legacy",
parentPageId: null,
rank: 0.37,
highlight: "…matched <b>text</b>…",
space: { id: "sp1", name: "Space" },
});
assert.equal(result.pageId, "uuid-legacy");
assert.equal(result.title, "Legacy");
// snippet falls back to highlight, score to rank, path to [].
assert.equal(result.snippet, "…matched <b>text</b>…");
assert.equal(result.score, 0.37);
assert.deepEqual(result.path, []);
assert.equal("slugId" in result, false);
});
test("filterSearchResult is null-safe on missing snippet/score/path", () => {
const result = filterSearchResult({ id: "u", title: "t" });
assert.equal(result.pageId, "u");
assert.equal(result.snippet, "");
assert.equal(result.score, 0);
assert.deepEqual(result.path, []);
});
test("filterSearchResult ignores a non-array path", () => {
const result = filterSearchResult({
id: "u",
title: "t",
path: "not-an-array",
snippet: "s",
score: 1,
});
assert.deepEqual(result.path, []);
});
@@ -9,7 +9,7 @@ import {
// Pins the footnoteWarnings PLUMBING contract (#169 review; reduced in #414): the // Pins the footnoteWarnings PLUMBING contract (#169 review; reduced in #414): the
// field is present only when legacy reference-style `[^id]:` syntax is used and // field is present only when legacy reference-style `[^id]:` syntax is used and
// omitted otherwise, AND `import_page_markdown` analyzes the BODY (after the // omitted otherwise, AND `importPageMarkdown` analyzes the BODY (after the
// docmost:meta / docmost:comments blocks) — so a footnote-like token inside those // docmost:meta / docmost:comments blocks) — so a footnote-like token inside those
// JSON blocks never warns, while a real definition in the body does. // JSON blocks never warns, while a real definition in the body does.
// importPageMarkdown does exactly `footnoteWarningsField(parseDocmostMarkdown(full).body)` // importPageMarkdown does exactly `footnoteWarningsField(parseDocmostMarkdown(full).body)`
@@ -25,7 +25,7 @@ test("formatting-only edit (strip-toggle) is refused, not applied", () => {
assert.equal(failed.length, 1, "one refused edit"); assert.equal(failed.length, 1, "one refused edit");
assert.equal(failed[0].find, "~~x~~"); assert.equal(failed[0].find, "~~x~~");
assert.match(failed[0].reason, /cannot add or remove formatting marks/); assert.match(failed[0].reason, /cannot add or remove formatting marks/);
assert.match(failed[0].reason, /patch_node/); assert.match(failed[0].reason, /patchNode/);
// The document is untouched (the strike mark is preserved). // The document is untouched (the strike mark is preserved).
assert.deepEqual(out, snapshot); assert.deepEqual(out, snapshot);
}); });
@@ -143,7 +143,7 @@ test("typo fix wrapped in markdown still applies (not refused)", () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// (iv) #410 footnote token: a `replace` containing `^[...]` is refused into // (iv) #410 footnote token: a `replace` containing `^[...]` is refused into
// failed[] (it would be written as a LITERAL string, never a real footnote). // failed[] (it would be written as a LITERAL string, never a real footnote).
// Nothing is applied; the reason points at insert_footnote. // Nothing is applied; the reason points at insertFootnote.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
test("replace containing a `^[...]` footnote token is refused, not applied", () => { test("replace containing a `^[...]` footnote token is refused, not applied", () => {
const input = doc(paragraph(textNode("The claim stands."))); const input = doc(paragraph(textNode("The claim stands.")));
@@ -156,7 +156,7 @@ test("replace containing a `^[...]` footnote token is refused, not applied", ()
assert.equal(results.length, 0, "nothing applied"); assert.equal(results.length, 0, "nothing applied");
assert.equal(failed.length, 1, "one refused edit"); assert.equal(failed.length, 1, "one refused edit");
assert.equal(failed[0].find, "The claim stands."); assert.equal(failed[0].find, "The claim stands.");
assert.match(failed[0].reason, /insert_footnote/); assert.match(failed[0].reason, /insertFootnote/);
// The document is byte-for-byte untouched — no literal `^[` was written. // The document is byte-for-byte untouched — no literal `^[` was written.
assert.deepEqual(out, snapshot); assert.deepEqual(out, snapshot);
}); });
@@ -0,0 +1,121 @@
// #413: unit tests for the markdown-fragment helpers used by patchNode/insertNode.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
importMarkdownFragment,
canBeDocChild,
findUnrepresentableTableAttrs,
} from "../../build/lib/markdown-fragment.js";
function findAll(node, type, acc = []) {
if (!node || typeof node !== "object") return acc;
if (node.type === type) acc.push(node);
if (Array.isArray(node.content))
for (const c of node.content) findAll(c, type, acc);
return acc;
}
test("importMarkdownFragment: plain markdown -> blocks, no definitions", async () => {
const { blocks, definitions } = await importMarkdownFragment(
"first\n\nsecond",
);
assert.equal(blocks.length, 2);
assert.equal(definitions.length, 0);
assert.equal(blocks[0].type, "paragraph");
});
test("importMarkdownFragment: `^[...]` footnote -> a definition + a remapped ref", async () => {
const { blocks, definitions } = await importMarkdownFragment(
"a claim^[the note]",
);
assert.equal(definitions.length, 1);
const refs = findAll({ type: "doc", content: blocks }, "footnoteReference");
assert.equal(refs.length, 1);
// The reference id must match the (remapped) definition id.
assert.equal(refs[0].attrs.id, definitions[0].attrs.id);
// The id is NOT the importer's sequential "fn-1" — it was remapped to a fresh
// uuid so it cannot collide with a page footnote of the same number.
assert.notEqual(refs[0].attrs.id, "fn-1");
});
test("importMarkdownFragment: whitespace markdown imports to a single empty paragraph", async () => {
// The importer yields one empty paragraph for whitespace-only input (not zero
// blocks), so the fragment path returns that block. The client's XOR guard
// (markdown.trim() !== "") is what rejects an empty-string patch up front, so
// importMarkdownFragment never sees a truly empty string via patch/insert.
const { blocks, definitions } = await importMarkdownFragment(" \n ");
assert.equal(blocks.length, 1);
assert.equal(blocks[0].type, "paragraph");
assert.equal(definitions.length, 0);
});
test("canBeDocChild: paragraph/heading/table are doc children; tableRow/cell are not", () => {
assert.equal(canBeDocChild("paragraph"), true);
assert.equal(canBeDocChild("heading"), true);
assert.equal(canBeDocChild("table"), true);
assert.equal(canBeDocChild("tableRow"), false);
assert.equal(canBeDocChild("tableCell"), false);
assert.equal(canBeDocChild("tableHeader"), false);
assert.equal(canBeDocChild("text"), false);
assert.equal(canBeDocChild(undefined), false);
assert.equal(canBeDocChild("notARealType"), false);
});
const cell = (attrs, text) => ({
type: "tableCell",
attrs,
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
});
test("findUnrepresentableTableAttrs: null for a plain paragraph and a simple table", () => {
assert.equal(
findUnrepresentableTableAttrs({
type: "paragraph",
content: [{ type: "text", text: "x" }],
}),
null,
);
const simpleTable = {
type: "table",
content: [
{
type: "tableRow",
content: [cell({ colspan: 1, rowspan: 1 }, "a")],
},
],
};
assert.equal(findUnrepresentableTableAttrs(simpleTable), null);
});
test("findUnrepresentableTableAttrs: flags colspan/rowspan/colwidth/backgroundColor", () => {
const mk = (attrs) => ({
type: "table",
content: [{ type: "tableRow", content: [cell(attrs, "a")] }],
});
assert.match(findUnrepresentableTableAttrs(mk({ colspan: 2 })), /colspan/);
assert.match(findUnrepresentableTableAttrs(mk({ rowspan: 2 })), /rowspan/);
assert.match(
findUnrepresentableTableAttrs(mk({ colwidth: [120] })),
/colwidth/,
);
assert.match(
findUnrepresentableTableAttrs(mk({ backgroundColor: "#eee" })),
/backgroundColor/,
);
});
test("findUnrepresentableTableAttrs: finds a span nested deep (table inside a callout)", () => {
const doc = {
type: "callout",
content: [
{
type: "table",
content: [
{ type: "tableRow", content: [cell({ colspan: 3 }, "wide")] },
],
},
],
};
assert.match(findUnrepresentableTableAttrs(doc), /colspan/);
});
+7 -7
View File
@@ -486,34 +486,34 @@ test("insertNodeRelative truly-missing anchor still returns inserted:false", ()
assert.equal(inserted, false); assert.equal(inserted, false);
}); });
// assertUnambiguousMatch (#159, #185 review pt 2): the patch_node/delete_node // assertUnambiguousMatch (#159, #185 review pt 2): the patchNode/deleteNode
// guard. Docmost duplicates block ids on copy/paste, so a write by id that // guard. Docmost duplicates block ids on copy/paste, so a write by id that
// matches >1 node must be REFUSED (the caller already skipped the write for any // matches >1 node must be REFUSED (the caller already skipped the write for any
// count !== 1; this reports the error). The duplicate COUNT itself is covered by // count !== 1; this reports the error). The duplicate COUNT itself is covered by
// the replaceNodeById/deleteNodeById tests above (count===2 for a 2-dup doc). // the replaceNodeById/deleteNodeById tests above (count===2 for a 2-dup doc).
test("assertUnambiguousMatch: count 0 throws 'no node found'", () => { test("assertUnambiguousMatch: count 0 throws 'no node found'", () => {
assert.throws( assert.throws(
() => assertUnambiguousMatch("patch_node", "replace", 0, "n1", "p1"), () => assertUnambiguousMatch("patchNode", "replace", 0, "n1", "p1"),
/patch_node: no node with id "n1" found on page p1/, /patchNode: no node with id "n1" found on page p1/,
); );
}); });
test("assertUnambiguousMatch: count > 1 refuses with an 'ambiguous' error", () => { test("assertUnambiguousMatch: count > 1 refuses with an 'ambiguous' error", () => {
assert.throws( assert.throws(
() => assertUnambiguousMatch("patch_node", "replace", 2, "dup", "p1"), () => assertUnambiguousMatch("patchNode", "replace", 2, "dup", "p1"),
/ambiguous.*Refusing to replace all of them; nothing was changed/, /ambiguous.*Refusing to replace all of them; nothing was changed/,
); );
assert.throws( assert.throws(
() => assertUnambiguousMatch("delete_node", "delete", 3, "dup", "p1"), () => assertUnambiguousMatch("deleteNode", "delete", 3, "dup", "p1"),
/ambiguous.*Refusing to delete all of them; nothing was changed/, /ambiguous.*Refusing to delete all of them; nothing was changed/,
); );
}); });
test("assertUnambiguousMatch: exactly one match does NOT throw", () => { test("assertUnambiguousMatch: exactly one match does NOT throw", () => {
assert.doesNotThrow(() => assert.doesNotThrow(() =>
assertUnambiguousMatch("patch_node", "replace", 1, "n1", "p1"), assertUnambiguousMatch("patchNode", "replace", 1, "n1", "p1"),
); );
assert.doesNotThrow(() => assert.doesNotThrow(() =>
assertUnambiguousMatch("delete_node", "delete", 1, "n1", "p1"), assertUnambiguousMatch("deleteNode", "delete", 1, "n1", "p1"),
); );
}); });
+5 -5
View File
@@ -155,7 +155,7 @@ test("insertTableRow at index 0 inserts before the header and pads to 3 cells",
test("insertTableRow throws when given more cells than columns", () => { test("insertTableRow throws when given more cells than columns", () => {
assert.throws( assert.throws(
() => insertTableRow(makeDoc(), "#1", ["a", "b", "c", "d"]), () => insertTableRow(makeDoc(), "#1", ["a", "b", "c", "d"]),
/table_insert_row: got 4 cell\(s\) but the table has 3 column\(s\)/, /tableInsertRow: got 4 cell\(s\) but the table has 3 column\(s\)/,
); );
}); });
@@ -232,7 +232,7 @@ test("insertTableRow uses the max column count across all rows (ragged table)",
// ...but 4 cells exceed the widest row and throw. // ...but 4 cells exceed the widest row and throw.
assert.throws( assert.throws(
() => insertTableRow(makeRaggedDoc(), "#0", ["a", "b", "c", "d"]), () => insertTableRow(makeRaggedDoc(), "#0", ["a", "b", "c", "d"]),
/table_insert_row: got 4 cell\(s\) but the table has 3 column\(s\)/, /tableInsertRow: got 4 cell\(s\) but the table has 3 column\(s\)/,
); );
}); });
@@ -286,7 +286,7 @@ test("deleteTableRow removes the 3rd row -> rows:2", () => {
test("deleteTableRow out-of-range index throws", () => { test("deleteTableRow out-of-range index throws", () => {
assert.throws( assert.throws(
() => deleteTableRow(makeDoc(), "#1", 9), () => deleteTableRow(makeDoc(), "#1", 9),
/table_delete_row: row index 9 out of range \(table has 3 row\(s\)\)/, /tableDeleteRow: row index 9 out of range \(table has 3 row\(s\)\)/,
); );
}); });
@@ -329,10 +329,10 @@ test("updateTableCell sets cell [1,1] to 'Z' and preserves the paragraph id", ()
test("updateTableCell out-of-range row/col throws", () => { test("updateTableCell out-of-range row/col throws", () => {
assert.throws( assert.throws(
() => updateTableCell(makeDoc(), "#1", 9, 0, "x"), () => updateTableCell(makeDoc(), "#1", 9, 0, "x"),
/table_update_cell: cell \[9,0\] out of range/, /tableUpdateCell: cell \[9,0\] out of range/,
); );
assert.throws( assert.throws(
() => updateTableCell(makeDoc(), "#1", 0, 9, "x"), () => updateTableCell(makeDoc(), "#1", 0, 9, "x"),
/table_update_cell: cell \[0,9\] out of range/, /tableUpdateCell: cell \[0,9\] out of range/,
); );
}); });
+15 -14
View File
@@ -35,13 +35,13 @@ function registeredToolNames() {
const indexSrc = readFileSync(join(SRC, "index.ts"), "utf8"); const indexSrc = readFileSync(join(SRC, "index.ts"), "utf8");
const specsSrc = readFileSync(join(SRC, "tool-specs.ts"), "utf8"); const specsSrc = readFileSync(join(SRC, "tool-specs.ts"), "utf8");
const names = new Set(); const names = new Set();
for (const m of indexSrc.matchAll(/registerTool\(\s*"([a-z0-9_]+)"/g)) { for (const m of indexSrc.matchAll(/registerTool\(\s*"([a-zA-Z0-9_]+)"/g)) {
names.add(m[1]); names.add(m[1]);
} }
// Each spec is one `{ ... }` block; scrape its mcpName but skip a block that // Each spec is one `{ ... }` block; scrape its mcpName but skip a block that
// carries `inAppOnly: true` (not registered on the external MCP host). // carries `inAppOnly: true` (not registered on the external MCP host).
for (const block of specsSrc.split(/\n\s{2}\w+:\s*\{/)) { for (const block of specsSrc.split(/\n\s{2}\w+:\s*\{/)) {
const nameMatch = block.match(/mcpName:\s*['"]([a-z0-9_]+)['"]/); const nameMatch = block.match(/mcpName:\s*['"]([a-zA-Z0-9_]+)['"]/);
if (!nameMatch) continue; if (!nameMatch) continue;
if (/inAppOnly:\s*true/.test(block)) continue; if (/inAppOnly:\s*true/.test(block)) continue;
names.add(nameMatch[1]); names.add(nameMatch[1]);
@@ -82,27 +82,28 @@ test("the inventory has no phantom tool (every line is a real registered tool)",
); );
}); });
// #411: the external MCP surface gains update_page_markdown and LOSES // #411: the external MCP surface gains updatePageMarkdown and LOSES
// import_page_markdown (now inAppOnly). The in-app agent still keeps // importPageMarkdown (now inAppOnly). The in-app agent still keeps
// importPageMarkdown — asserted in the server-side contract spec. // importPageMarkdown — asserted in the server-side contract spec. (#412 renamed
test("update_page_markdown is on the MCP surface; import_page_markdown is NOT", () => { // both public MCP tool names to camelCase.)
test("updatePageMarkdown is on the MCP surface; importPageMarkdown is NOT", () => {
const inventory = new Set(buildToolInventoryLines().map((l) => l.name)); const inventory = new Set(buildToolInventoryLines().map((l) => l.name));
assert.ok( assert.ok(
inventory.has("update_page_markdown"), inventory.has("updatePageMarkdown"),
"update_page_markdown should be registered on the external MCP surface", "updatePageMarkdown should be registered on the external MCP surface",
); );
assert.ok( assert.ok(
!inventory.has("import_page_markdown"), !inventory.has("importPageMarkdown"),
"import_page_markdown must be dropped from the external MCP surface (#411)", "importPageMarkdown must be dropped from the external MCP surface (#411)",
); );
// And the routing prose no longer points MCP clients at it. // And the routing prose no longer points MCP clients at it.
assert.ok( assert.ok(
!ROUTING_PROSE.includes("import_page_markdown"), !ROUTING_PROSE.includes("importPageMarkdown"),
"ROUTING_PROSE still mentions the removed import_page_markdown", "ROUTING_PROSE still mentions the removed importPageMarkdown",
); );
assert.ok( assert.ok(
ROUTING_PROSE.includes("update_page_markdown"), ROUTING_PROSE.includes("updatePageMarkdown"),
"ROUTING_PROSE should mention update_page_markdown", "ROUTING_PROSE should mention updatePageMarkdown",
); );
}); });
+120 -31
View File
@@ -22,10 +22,13 @@ test("every spec exposes mcpName + inAppKey, and the key matches inAppKey", () =
} }
}); });
test("mcpName uses snake_case and inAppKey uses camelCase", () => { // Since issue #412 the external MCP name equals the in-app key: both are the
// same camelCase identifier (mcpName === inAppKey).
test("mcpName and inAppKey are the same camelCase identifier", () => {
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) { for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
assert.match(spec.mcpName, /^[a-z0-9]+(_[a-z0-9]+)*$/, `${key}: mcpName not snake_case`); assert.match(spec.mcpName, /^[a-z][a-zA-Z0-9]*$/, `${key}: mcpName not camelCase`);
assert.match(spec.inAppKey, /^[a-z][a-zA-Z0-9]*$/, `${key}: inAppKey not camelCase`); assert.match(spec.inAppKey, /^[a-z][a-zA-Z0-9]*$/, `${key}: inAppKey not camelCase`);
assert.equal(spec.mcpName, spec.inAppKey, `${key}: mcpName must equal inAppKey`);
} }
}); });
@@ -59,7 +62,7 @@ test("buildShape (when present) returns a usable ZodRawShape with a real zod", (
test("editPageText builder produces { pageId, edits } and drops the stale strip-and-retry claim", () => { test("editPageText builder produces { pageId, edits } and drops the stale strip-and-retry claim", () => {
const spec = SHARED_TOOL_SPECS.editPageText; const spec = SHARED_TOOL_SPECS.editPageText;
assert.equal(spec.mcpName, "edit_page_text"); assert.equal(spec.mcpName, "editPageText");
const shape = spec.buildShape(z); const shape = spec.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["edits", "pageId"]); assert.deepEqual(Object.keys(shape).sort(), ["edits", "pageId"]);
// A valid edits batch parses. // A valid edits batch parses.
@@ -78,49 +81,68 @@ test("editPageText builder produces { pageId, edits } and drops the stale strip-
assert.match(spec.description, /REFUSED into\s+failed\[\]/); assert.match(spec.description, /REFUSED into\s+failed\[\]/);
}); });
test("getNode builder produces exactly { pageId, nodeId }", () => { // #413: getNode gained an optional `format` (markdown default / json opt-in).
const shape = SHARED_TOOL_SPECS.getNode.buildShape(z); test("getNode builder produces { pageId, nodeId, format? } with format optional", () => {
assert.deepEqual(Object.keys(shape).sort(), ["nodeId", "pageId"]); const spec = SHARED_TOOL_SPECS.getNode;
const shape = spec.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["format", "nodeId", "pageId"]);
const schema = z.object(shape);
// format is optional (markdown default lives in the client).
assert.doesNotThrow(() => schema.parse({ pageId: "p1", nodeId: "n1" }));
assert.doesNotThrow(() =>
schema.parse({ pageId: "p1", nodeId: "n1", format: "json" }),
);
assert.throws(() =>
schema.parse({ pageId: "p1", nodeId: "n1", format: "yaml" }),
);
// The description advertises the markdown default and the json opt-in.
assert.match(spec.description, /markdown/i);
assert.match(spec.description, /json/i);
}); });
test("patchNode spec exists, merges BOTH descriptions, builds { pageId, nodeId, node }", () => { // #413: patchNode takes XOR { markdown | node } (both schema-optional).
test("patchNode spec exists, describes markdown+node XOR, builds { pageId, nodeId, markdown?, node? }", () => {
const spec = SHARED_TOOL_SPECS.patchNode; const spec = SHARED_TOOL_SPECS.patchNode;
assert.ok(spec, "patchNode spec missing"); assert.ok(spec, "patchNode spec missing");
assert.equal(spec.mcpName, "patch_node"); assert.equal(spec.mcpName, "patchNode");
assert.equal(spec.inAppKey, "patchNode"); assert.equal(spec.inAppKey, "patchNode");
// The canonical description must carry the key guidance from BOTH originals: // The canonical description must carry the #413 guidance.
// - MCP-only: "WITHOUT resending the whole document" + the cheaper/safer note. assert.match(spec.description, /WITHOUT/i);
// - in-app-only: "keeps the same node id" + the "Reversible ... page history" assert.match(spec.description, /EXACTLY ONE of `markdown` or `node`/);
// framing the MCP copy lacked. assert.match(spec.description, /RECOMMENDED/);
assert.match(spec.description, /WITHOUT resending the whole document/); assert.match(spec.description, /keeps the same block id/i);
assert.match(spec.description, /Cheaper and safer/);
assert.match(spec.description, /keeps the same node id/i);
assert.match(spec.description, /Reversible/i); assert.match(spec.description, /Reversible/i);
assert.match(spec.description, /page history/i); assert.match(spec.description, /page history/i);
const shape = spec.buildShape(z); const shape = spec.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["node", "nodeId", "pageId"]); assert.deepEqual(
// A minimal valid input parses (node accepts an arbitrary object via z.any()). Object.keys(shape).sort(),
const parsed = z.object(shape).parse({ ["markdown", "node", "nodeId", "pageId"],
);
// markdown and node are BOTH optional in the schema (XOR enforced at runtime).
const schema = z.object(shape);
const parsedMd = schema.parse({ pageId: "p1", nodeId: "n1", markdown: "hi" });
assert.equal(parsedMd.markdown, "hi");
const parsedNode = schema.parse({
pageId: "p1", pageId: "p1",
nodeId: "n1", nodeId: "n1",
node: { type: "paragraph" }, node: { type: "paragraph" },
}); });
assert.equal(parsed.pageId, "p1"); assert.equal(parsedNode.pageId, "p1");
assert.equal(parsed.nodeId, "n1"); // Neither given parses at the schema level (the client throws the XOR error).
assert.doesNotThrow(() => schema.parse({ pageId: "p1", nodeId: "n1" }));
}); });
test("insertNode spec exists, merges BOTH descriptions, builds the full anchor shape", () => { // #413: insertNode also takes XOR { markdown | node } plus the anchor shape.
test("insertNode spec exists, describes markdown+node XOR, builds the full anchor+content shape", () => {
const spec = SHARED_TOOL_SPECS.insertNode; const spec = SHARED_TOOL_SPECS.insertNode;
assert.ok(spec, "insertNode spec missing"); assert.ok(spec, "insertNode spec missing");
assert.equal(spec.mcpName, "insert_node"); assert.equal(spec.mcpName, "insertNode");
assert.equal(spec.inAppKey, "insertNode"); assert.equal(spec.inAppKey, "insertNode");
// Canonical description must keep BOTH sides' nuance:
// - in-app-only: "EXACTLY ONE of anchorNodeId or anchorText" + "Reversible".
// - MCP-only: the table-structure (tableRow/tableCell) insertion guidance.
assert.match(spec.description, /EXACTLY ONE of anchorNodeId or anchorText/); assert.match(spec.description, /EXACTLY ONE of anchorNodeId or anchorText/);
assert.match(spec.description, /EXACTLY ONE of `markdown` or `node`/);
assert.match(spec.description, /tableRow/); assert.match(spec.description, /tableRow/);
assert.match(spec.description, /append is top-level only/); assert.match(spec.description, /append is top-level only/);
assert.match(spec.description, /Reversible via page history/); assert.match(spec.description, /Reversible via page history/);
@@ -128,18 +150,85 @@ test("insertNode spec exists, merges BOTH descriptions, builds the full anchor s
const shape = spec.buildShape(z); const shape = spec.buildShape(z);
assert.deepEqual( assert.deepEqual(
Object.keys(shape).sort(), Object.keys(shape).sort(),
["anchorNodeId", "anchorText", "node", "pageId", "position"], ["anchorNodeId", "anchorText", "markdown", "node", "pageId", "position"],
); );
// before/after/append are the only accepted positions; anchors are optional. // before/after/append are the only accepted positions; markdown/node/anchors optional.
const schema = z.object(shape); const schema = z.object(shape);
assert.doesNotThrow(() =>
schema.parse({ pageId: "p1", markdown: "hi", position: "append" }),
);
assert.doesNotThrow(() => assert.doesNotThrow(() =>
schema.parse({ pageId: "p1", node: { type: "paragraph" }, position: "append" }), schema.parse({ pageId: "p1", node: { type: "paragraph" }, position: "append" }),
); );
assert.throws(() => assert.throws(() =>
schema.parse({ pageId: "p1", node: {}, position: "sideways" }), schema.parse({ pageId: "p1", markdown: "x", position: "sideways" }),
); );
}); });
// #443: getTree — a space's page hierarchy (or a subtree) in one request.
test("getTree spec exists on both hosts, builds { spaceId, rootPageId?, maxDepth? }", () => {
const spec = SHARED_TOOL_SPECS.getTree;
assert.ok(spec, "getTree spec missing");
assert.equal(spec.mcpName, "getTree");
assert.equal(spec.inAppKey, "getTree");
// Shared spec: registered on BOTH hosts.
assert.notEqual(spec.inAppOnly, true);
assert.notEqual(spec.mcpOnly, true);
const shape = spec.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["maxDepth", "rootPageId", "spaceId"]);
const schema = z.object(shape);
// spaceId required; rootPageId + maxDepth optional.
assert.doesNotThrow(() => schema.parse({ spaceId: "sp1" }));
assert.throws(() => schema.parse({}));
assert.doesNotThrow(() =>
schema.parse({ spaceId: "sp1", rootPageId: "p1", maxDepth: 2 }),
);
// maxDepth is an integer >= 1.
assert.throws(() => schema.parse({ spaceId: "sp1", maxDepth: 0 }));
assert.throws(() => schema.parse({ spaceId: "sp1", maxDepth: 1.5 }));
// The description advertises the output node shape, rootPageId, maxDepth, and
// steers away from the deprecated listPages tree:true.
assert.match(spec.description, /pageId/);
assert.match(spec.description, /rootPageId/);
assert.match(spec.description, /maxDepth/);
assert.match(spec.description, /hasChildren/);
assert.match(spec.description, /listPages tree:true/);
});
// #443: getPageContext — a page's breadcrumbs + direct children in one call.
test("getPageContext spec exists on both hosts, builds { pageId }", () => {
const spec = SHARED_TOOL_SPECS.getPageContext;
assert.ok(spec, "getPageContext spec missing");
assert.equal(spec.mcpName, "getPageContext");
assert.equal(spec.inAppKey, "getPageContext");
// Shared spec: registered on BOTH hosts.
assert.notEqual(spec.inAppOnly, true);
assert.notEqual(spec.mcpOnly, true);
const shape = spec.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["pageId"]);
const schema = z.object(shape);
// pageId required.
assert.doesNotThrow(() => schema.parse({ pageId: "p1" }));
assert.throws(() => schema.parse({}));
// The description advertises the output shape (page/breadcrumbs/children) and
// the root-page empty-breadcrumbs contract.
assert.match(spec.description, /breadcrumbs/);
assert.match(spec.description, /children/);
assert.match(spec.description, /hasChildren/);
assert.match(spec.description, /getTree/);
});
// #443: listPages tree:true is deprecated in favour of getTree.
test("listPages description deprecates tree:true and points at getTree", () => {
const spec = SHARED_TOOL_SPECS.listPages;
assert.match(spec.description, /DEPRECATED/i);
assert.match(spec.description, /getTree/);
});
test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () => { test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () => {
for (const key of ["getWorkspace", "listSpaces", "listShares"]) { for (const key of ["getWorkspace", "listSpaces", "listShares"]) {
assert.equal(SHARED_TOOL_SPECS[key].buildShape, undefined, `${key} should be no-arg`); assert.equal(SHARED_TOOL_SPECS[key].buildShape, undefined, `${key} should be no-arg`);
@@ -150,7 +239,7 @@ test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () =>
test("updatePageMarkdown spec exists, pairs with updatePageJson, builds { pageId, content, title }", () => { test("updatePageMarkdown spec exists, pairs with updatePageJson, builds { pageId, content, title }", () => {
const spec = SHARED_TOOL_SPECS.updatePageMarkdown; const spec = SHARED_TOOL_SPECS.updatePageMarkdown;
assert.ok(spec, "updatePageMarkdown spec missing"); assert.ok(spec, "updatePageMarkdown spec missing");
assert.equal(spec.mcpName, "update_page_markdown"); assert.equal(spec.mcpName, "updatePageMarkdown");
assert.equal(spec.inAppKey, "updatePageMarkdown"); assert.equal(spec.inAppKey, "updatePageMarkdown");
// Registered on BOTH hosts (a shared spec, no inAppOnly/mcpOnly flag). // Registered on BOTH hosts (a shared spec, no inAppOnly/mcpOnly flag).
assert.notEqual(spec.inAppOnly, true); assert.notEqual(spec.inAppOnly, true);
@@ -168,13 +257,13 @@ test("updatePageMarkdown spec exists, pairs with updatePageJson, builds { pageId
assert.match(spec.description, /\^\[/); assert.match(spec.description, /\^\[/);
}); });
// #411: import_page_markdown is dropped from the EXTERNAL MCP surface but stays // #411: importPageMarkdown is dropped from the EXTERNAL MCP surface but stays
// available to the in-app agent — encoded as inAppOnly on the shared spec. // available to the in-app agent — encoded as inAppOnly on the shared spec.
test("importPageMarkdown spec is inAppOnly (removed from the external MCP surface, kept in-app)", () => { test("importPageMarkdown spec is inAppOnly (removed from the external MCP surface, kept in-app)", () => {
const spec = SHARED_TOOL_SPECS.importPageMarkdown; const spec = SHARED_TOOL_SPECS.importPageMarkdown;
assert.ok(spec, "importPageMarkdown spec missing"); assert.ok(spec, "importPageMarkdown spec missing");
assert.equal(spec.inAppOnly, true); assert.equal(spec.inAppOnly, true);
// The spec + its client method are NOT deleted — only hidden from the MCP host. // The spec + its client method are NOT deleted — only hidden from the MCP host.
assert.equal(spec.mcpName, "import_page_markdown"); assert.equal(spec.mcpName, "importPageMarkdown");
assert.equal(spec.inAppKey, "importPageMarkdown"); assert.equal(spec.inAppKey, "importPageMarkdown");
}); });
+2 -2
View File
@@ -13,7 +13,7 @@ test("times a tool and preserves the handler's return value", async () => {
const onMetric = (name, value, labels) => calls.push({ name, value, labels }); const onMetric = (name, value, labels) => calls.push({ name, value, labels });
const handler = async (arg) => ({ ok: true, echo: arg }); const handler = async (arg) => ({ ok: true, echo: arg });
const wrapped = timeToolHandler("get_page", handler, onMetric); const wrapped = timeToolHandler("getPage", handler, onMetric);
const result = await wrapped("hello"); const result = await wrapped("hello");
// Return value passes through untouched. // Return value passes through untouched.
@@ -22,7 +22,7 @@ test("times a tool and preserves the handler's return value", async () => {
// Exactly one sample, correct name/labels, numeric non-negative duration. // Exactly one sample, correct name/labels, numeric non-negative duration.
assert.equal(calls.length, 1); assert.equal(calls.length, 1);
assert.equal(calls[0].name, "mcp_tool_duration_seconds"); assert.equal(calls[0].name, "mcp_tool_duration_seconds");
assert.deepEqual(calls[0].labels, { tool: "get_page" }); assert.deepEqual(calls[0].labels, { tool: "getPage" });
assert.equal(typeof calls[0].value, "number"); assert.equal(typeof calls[0].value, "number");
assert.ok(calls[0].value >= 0, "duration must be non-negative seconds"); assert.ok(calls[0].value >= 0, "duration must be non-negative seconds");
}); });
+161
View File
@@ -137,3 +137,164 @@ test("buildPageTree output shape is lean (drops position/parentPageId/hasChildre
assert.equal("hasChildren" in node, false); assert.equal("hasChildren" in node, false);
assert.equal("spaceId" in node, false); assert.equal("spaceId" in node, false);
}); });
// ---------------------------------------------------------------------------
// #443 getTree output shape: { pageId, title, children?, hasChildren? }
// ---------------------------------------------------------------------------
// A small representative space used across the getTree tests:
// r1 (Infrastructure)
// c1 (Datacenter A)
// g1 (Servers) [leaf]
// c2 (Datacenter B) [leaf]
// r2 (Notes) [leaf]
const SAMPLE = [
{ id: "r2", slugId: "s-r2", title: "Notes", position: "a1", icon: "📝", hasChildren: false },
{ id: "r1", slugId: "s-r1", title: "Infrastructure", position: "a0", icon: "🏢", hasChildren: true },
{ id: "c2", slugId: "s-c2", title: "Datacenter B", position: "b1", parentPageId: "r1", icon: "🅱️", hasChildren: false },
{ id: "c1", slugId: "s-c1", title: "Datacenter A", position: "b0", parentPageId: "r1", icon: "🅰️", hasChildren: true },
{ id: "g1", slugId: "s-g1", title: "Servers", position: "c0", parentPageId: "c1", icon: "🖥️", hasChildren: false },
];
test("getTree shape: correct nesting + order-by-position, only {pageId,title,children?}, no leak", () => {
const tree = buildPageTree(SAMPLE, { shape: "getTree" });
// Roots sorted by position: r1 (a0) before r2 (a1).
assert.deepEqual(
tree.map((n) => n.pageId),
["r1", "r2"],
);
// r1's children sorted by position: c1 (b0) before c2 (b1).
assert.deepEqual(
tree[0].children.map((n) => n.pageId),
["c1", "c2"],
);
// Deep nesting: g1 under c1.
assert.deepEqual(
tree[0].children[0].children.map((n) => n.pageId),
["g1"],
);
// No slugId/icon/position/parentPageId/hasChildren leak on any node.
const walk = (nodes) => {
for (const n of nodes) {
assert.deepEqual(
Object.keys(n).sort(),
n.children ? ["children", "pageId", "title"] : ["pageId", "title"],
`unexpected keys on ${n.pageId}: ${Object.keys(n)}`,
);
assert.equal("slugId" in n, false);
assert.equal("icon" in n, false);
assert.equal("position" in n, false);
assert.equal("parentPageId" in n, false);
// Fully-expanded tree (no maxDepth): hasChildren never set.
assert.equal("hasChildren" in n, false);
if (n.children) walk(n.children);
}
};
walk(tree);
});
test("getTree maxDepth:1 returns roots only, each with hasChildren from the flat item", () => {
const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: 1 });
assert.deepEqual(
tree.map((n) => n.pageId),
["r1", "r2"],
);
// No children arrays at depth 1 when maxDepth:1.
for (const n of tree) assert.equal("children" in n, false);
// r1 has children on the server -> hasChildren:true; r2 is a leaf -> omitted.
assert.equal(tree[0].hasChildren, true);
assert.equal("hasChildren" in tree[1], false);
});
test("getTree maxDepth:2 cuts grandchildren; hasChildren only on the cut interior node", () => {
const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: 2 });
const r1 = tree[0];
// Depth-1 node r1 was EXPANDED (its children are present) -> no hasChildren.
assert.equal("hasChildren" in r1, false);
assert.equal(r1.children.length, 2);
const [c1, c2] = r1.children;
// c1 is at depth 2 (the cut) and has children on the server -> hasChildren:true,
// and its grandchild g1 is NOT present.
assert.equal(c1.pageId, "c1");
assert.equal("children" in c1, false);
assert.equal(c1.hasChildren, true);
// c2 is at depth 2 but is a leaf on the server -> hasChildren omitted.
assert.equal(c2.pageId, "c2");
assert.equal("children" in c2, false);
assert.equal("hasChildren" in c2, false);
// r2 is a depth-1 leaf -> no hasChildren, no children.
assert.equal("hasChildren" in tree[1], false);
});
test("getTree hasChildren is set ONLY on depth-cut nodes (not leaves, not expanded interior nodes)", () => {
// Full tree (no cut): NO node anywhere carries hasChildren.
const full = buildPageTree(SAMPLE, { shape: "getTree" });
const anyHasChildren = (nodes) =>
nodes.some((n) => "hasChildren" in n || (n.children && anyHasChildren(n.children)));
assert.equal(anyHasChildren(full), false);
});
test("getTree orphan (parent filtered out) surfaces as a root, not dropped", () => {
const tree = buildPageTree(
[
{ id: "root", slugId: "s-root", title: "Root", position: "a0", hasChildren: true },
// parentPageId points at an id NOT in the flat list (parent filtered by perms).
{ id: "orphan", slugId: "s-orphan", title: "Orphan", position: "a1", parentPageId: "gone", hasChildren: false },
],
{ shape: "getTree" },
);
assert.deepEqual(
tree.map((n) => n.pageId).sort(),
["orphan", "root"],
);
const orphan = tree.find((n) => n.pageId === "orphan");
assert.equal("children" in orphan, false);
assert.equal("hasChildren" in orphan, false);
});
test("getTree rootPageId path: a seeded single-root subtree keeps the getTree shape", () => {
// Simulate the server seeding the CTE with the subtree root c1: the flat list
// it returns contains c1 (now a root, parent absent) + its descendant g1.
const subtree = [
{ id: "c1", slugId: "s-c1", title: "Datacenter A", position: "b0", hasChildren: true },
{ id: "g1", slugId: "s-g1", title: "Servers", position: "c0", parentPageId: "c1", hasChildren: false },
];
const tree = buildPageTree(subtree, { shape: "getTree" });
assert.equal(tree.length, 1);
assert.equal(tree[0].pageId, "c1");
assert.deepEqual(
tree[0].children.map((n) => n.pageId),
["g1"],
);
assert.equal("slugId" in tree[0], false);
});
test("getTree maxDepth<=0 / non-finite is treated as no cut (whole tree)", () => {
for (const bad of [0, -3, NaN, Infinity, undefined]) {
const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: bad });
// Grandchild g1 present -> no cut applied.
assert.deepEqual(
tree[0].children[0].children.map((n) => n.pageId),
["g1"],
`maxDepth=${bad} should not cut`,
);
}
});
test("buildPageTree() with no options is byte-identical to the historic lean call", () => {
// Guard the existing callers: buildPageTree(pages) must be unchanged by the
// additive options param.
const withoutOpts = buildPageTree(SAMPLE);
const withEmptyOpts = buildPageTree(SAMPLE, {});
assert.deepEqual(withoutOpts, withEmptyOpts);
// And it is the lean {id,slugId,title,children?} shape, not the getTree shape.
assert.deepEqual(Object.keys(withoutOpts[0]).sort(), ["children", "id", "slugId", "title"]);
});
@@ -334,7 +334,7 @@ const DocmostAttributes = Extension.create({
* Docmost inline comment mark. Anchors a comment thread to a text range via * Docmost inline comment mark. Anchors a comment thread to a text range via
* `commentId`. Without it, any document containing comment highlights fails to * `commentId`. Without it, any document containing comment highlights fails to
* round-trip through the schema ("There is no mark type comment in this schema"), * round-trip through the schema ("There is no mark type comment in this schema"),
* which breaks update_page_json and edit_page_text on every commented page. * which breaks updatePageJson and editPageText on every commented page.
* Mirrors Docmost's @docmost/editor-ext comment mark (commentId / resolved). * Mirrors Docmost's @docmost/editor-ext comment mark (commentId / resolved).
*/ */
const Comment = Mark.create({ const Comment = Mark.create({
@@ -53,11 +53,14 @@ export {
buildOutline, buildOutline,
getNodeByRef, getNodeByRef,
replaceNodeById, replaceNodeById,
replaceNodeByIdWithMany,
reassignCollidingBlockIds,
deleteNodeById, deleteNodeById,
sanitizeForYjs, sanitizeForYjs,
findUnstorableAttr, findUnstorableAttr,
findInvalidNode, findInvalidNode,
insertNodeRelative, insertNodeRelative,
insertNodesRelative,
readTable, readTable,
insertTableRow, insertTableRow,
deleteTableRow, deleteTableRow,
@@ -217,6 +217,54 @@ export function replaceNodeById(
return { doc: out, replaced }; return { doc: out, replaced };
} }
/**
* Splice a SINGLE node whose `attrs.id === nodeId` with an ORDERED ARRAY of new
* nodes (a "1 -> N" replacement), anywhere in the tree. Used by the markdown
* patch path, where importing a markdown fragment can yield several blocks that
* must replace one existing block in place ("rewrite a section" in one call).
*
* Unlike `replaceNodeById` (which substitutes EVERY match), this walks to the
* FIRST match only and splices `newNodes` in its position, so ordering and the
* neighbouring blocks are preserved byte-for-byte. It deliberately does NOT
* touch further duplicates: the caller (#159 semantics) must have already
* verified the id is unambiguous via a `replaceNodeById` dry pass, so a single
* splice here is safe and every other block is untouched.
*
* Each entry of `newNodes` is deep-cloned so they never share references with
* each other or with the caller\'s array. Operates on a clone of `doc`; returns
* `{ doc, replaced }` where `replaced` is 1 when a match was spliced, else 0.
*/
export function replaceNodeByIdWithMany(
doc: any,
nodeId: string,
newNodes: any[],
): { doc: any; replaced: number } {
const out = clone(doc);
const fresh = Array.isArray(newNodes) ? newNodes.map((n) => clone(n)) : [];
let replaced = 0;
// Walk to the FIRST match and splice the array in its place; stop afterwards.
const walkContent = (content: any[]): boolean => {
for (let i = 0; i < content.length; i++) {
const child = content[i];
if (matchesId(child, nodeId)) {
content.splice(i, 1, ...fresh);
replaced = 1;
return true;
}
if (isObject(child) && Array.isArray(child.content)) {
if (walkContent(child.content)) return true;
}
}
return false;
};
if (isObject(out) && Array.isArray(out.content)) {
walkContent(out.content);
}
return { doc: out, replaced };
}
/** /**
* Remove EVERY node whose `attrs.id === nodeId` from its parent `content` * Remove EVERY node whose `attrs.id === nodeId` from its parent `content`
* array, anywhere in the tree (recursive, including callouts and tables). * array, anywhere in the tree (recursive, including callouts and tables).
@@ -263,7 +311,7 @@ export function deleteNodeById(
* changed. No-op for the unambiguous single-match case. * changed. No-op for the unambiguous single-match case.
*/ */
export function assertUnambiguousMatch( export function assertUnambiguousMatch(
op: "patch_node" | "delete_node", op: "patchNode" | "deleteNode",
verb: "replace" | "delete", verb: "replace" | "delete",
count: number, count: number,
nodeId: string, nodeId: string,
@@ -631,7 +679,7 @@ export function insertNodeRelative(
// top level — appending one would produce invalid nesting. // top level — appending one would produce invalid nesting.
if (isStructural) { if (isStructural) {
throw new Error( throw new Error(
`insert_node: cannot append a ${node.type} at the top level; use ` + `insertNode: cannot append a ${node.type} at the top level; use ` +
`position before/after with an anchor inside the target table`, `position before/after with an anchor inside the target table`,
); );
} }
@@ -666,7 +714,7 @@ export function insertNodeRelative(
if (containerIdx === -1) { if (containerIdx === -1) {
throw new Error( throw new Error(
`insert_node: cannot insert a ${node.type} here — the anchor is not ` + `insertNode: cannot insert a ${node.type} here — the anchor is not ` +
`inside a ${containerType}. Anchor on a cell's text or a block id ` + `inside a ${containerType}. Anchor on a cell's text or a block id ` +
`that lives inside the target table.`, `that lives inside the target table.`,
); );
@@ -725,6 +773,88 @@ export function insertNodeRelative(
return { doc: out, inserted: false }; return { doc: out, inserted: false };
} }
/**
* Insert an ORDERED ARRAY of nodes relative to an anchor, preserving their
* order. This is the multi-node twin of `insertNodeRelative`, used by the
* markdown insert path where importing a markdown fragment can yield several
* blocks that must land, in order, at one anchor.
*
* Semantics mirror `insertNodeRelative` exactly:
* - position "append": push every node onto the top-level `doc.content`.
* - position "before"/"after": splice every node into the anchor\'s parent
* `content` array immediately before / after it, keeping array order.
*
* The structural-table branch of `insertNodeRelative` is intentionally NOT
* duplicated here: a markdown fragment can never produce a bare tableRow/
* tableCell/tableHeader (those are not expressible in markdown), so the markdown
* insert path only ever hands whole top-level blocks. Structural inserts stay on
* the single-node JSON path. An empty `nodes` array is a no-op that still
* reports `inserted:false` (nothing to place).
*
* Operates on a clone of `doc`; returns `{ doc, inserted }`. `inserted` is false
* when the anchor could not be resolved (doc returned unchanged apart from the
* clone) or when `nodes` is empty.
*/
export function insertNodesRelative(
doc: any,
nodes: any[],
opts: InsertOptions,
): { doc: any; inserted: boolean } {
const out = clone(doc);
const fresh = Array.isArray(nodes) ? nodes.map((n) => clone(n)) : [];
if (!isObject(opts) || fresh.length === 0) {
return { doc: out, inserted: false };
}
// "append": push every node at the top level, in order.
if (opts.position === "append") {
if (isObject(out)) {
if (!Array.isArray(out.content)) out.content = [];
out.content.push(...fresh);
return { doc: out, inserted: true };
}
return { doc: out, inserted: false };
}
const offset = opts.position === "after" ? 1 : 0;
// Resolve by id anywhere in the tree: splice the whole array into the parent.
if (opts.anchorNodeId != null) {
let inserted = false;
const walkContent = (content: any[]): void => {
for (let i = 0; i < content.length; i++) {
const child = content[i];
if (matchesId(child, opts.anchorNodeId as string)) {
content.splice(i + offset, 0, ...fresh);
inserted = true;
return;
}
if (isObject(child) && Array.isArray(child.content)) {
walkContent(child.content);
if (inserted) return;
}
}
};
if (isObject(out) && Array.isArray(out.content)) {
walkContent(out.content);
}
return { doc: out, inserted };
}
// Resolve by text: only top-level doc.content blocks are scanned. Exact match
// wins; a markdown-stripped fallback is tried only on a miss.
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
const i = findAnchorTextIndex(out.content, opts.anchorText);
if (i !== -1) {
out.content.splice(i + offset, 0, ...fresh);
return { doc: out, inserted: true };
}
}
return { doc: out, inserted: false };
}
// =========================================================================== // ===========================================================================
// Table editing helpers // Table editing helpers
// //
@@ -773,6 +903,27 @@ function makeFreshId(used: Set<string>): string {
return id; return id;
} }
/**
* Re-mint any top-level block id in `blocks` that already exists in `liveDoc`,
* so a 1 -> N splice cannot introduce a duplicate id. `skipIndex` (optional) is a
* block whose id is intentionally set (the patch path's first block inherits the
* target node's id) and must not be re-minted. Mutates `blocks` in place.
*/
export function reassignCollidingBlockIds(
liveDoc: any,
blocks: any[],
skipIndex?: number,
): void {
const used = new Set<string>();
collectIds(liveDoc, used);
blocks.forEach((b, i) => {
if (i === skipIndex || !isObject(b)) return;
if (!isObject(b.attrs)) b.attrs = {};
if (b.attrs.id != null && used.has(b.attrs.id)) b.attrs.id = makeFreshId(used);
if (b.attrs.id != null) used.add(b.attrs.id);
});
}
/** /**
* Resolve a table reference against an ALREADY-CLONED doc and return the LIVE * Resolve a table reference against an ALREADY-CLONED doc and return the LIVE
* table node (a reference inside `rootClone`, so the caller may mutate it) plus * table node (a reference inside `rootClone`, so the caller may mutate it) plus
@@ -854,7 +1005,7 @@ function makeCellParagraph(id: string, text: string): any {
* width. * width.
* - `cells`: `string[][]` of each cell's `blockPlainText`. * - `cells`: `string[][]` of each cell's `blockPlainText`.
* - `cellIds`: `(string|null)[][]` of each cell's FIRST paragraph id (or null), * - `cellIds`: `(string|null)[][]` of each cell's FIRST paragraph id (or null),
* so callers can `patch_node` a cell for rich-formatted edits. * so callers can `patchNode` a cell for rich-formatted edits.
* - `path`: index path of the table within the doc. * - `path`: index path of the table within the doc.
*/ */
export function readTable( export function readTable(
@@ -884,7 +1035,7 @@ export function readTable(
const rowIds: (string | null)[] = []; const rowIds: (string | null)[] = [];
for (const cellNode of cellNodes) { for (const cellNode of cellNodes) {
rowText.push(blockPlainText(cellNode)); rowText.push(blockPlainText(cellNode));
// The cell's first paragraph carries the id used for patch_node. // The cell's first paragraph carries the id used for patchNode.
const firstPara = Array.isArray(cellNode?.content) const firstPara = Array.isArray(cellNode?.content)
? cellNode.content[0] ? cellNode.content[0]
: undefined; : undefined;
@@ -940,7 +1091,7 @@ export function insertTableRow(
if (Array.isArray(cells) && cells.length > colCount) { if (Array.isArray(cells) && cells.length > colCount) {
throw new Error( throw new Error(
`table_insert_row: got ${cells.length} cell(s) but the table has ${colCount} column(s)`, `tableInsertRow: got ${cells.length} cell(s) but the table has ${colCount} column(s)`,
); );
} }
@@ -1006,12 +1157,12 @@ export function deleteTableRow(
if (!Number.isInteger(index) || index < 0 || index >= rows) { if (!Number.isInteger(index) || index < 0 || index >= rows) {
throw new Error( throw new Error(
`table_delete_row: row index ${index} out of range (table has ${rows} row(s))`, `tableDeleteRow: row index ${index} out of range (table has ${rows} row(s))`,
); );
} }
if (rows <= 1) { if (rows <= 1) {
throw new Error( throw new Error(
"table_delete_row: refusing to delete the only row of the table", "tableDeleteRow: refusing to delete the only row of the table",
); );
} }
@@ -1055,7 +1206,7 @@ export function updateTableCell(
col < 0 || col < 0 ||
col >= cols col >= cols
) { ) {
throw new Error(`table_update_cell: cell [${row},${col}] out of range`); throw new Error(`tableUpdateCell: cell [${row},${col}] out of range`);
} }
const cellNode = rowNode.content[col]; const cellNode = rowNode.content[col];
@@ -1,7 +1,7 @@
// The model sometimes serializes a ProseMirror node arg as a JSON string // The model sometimes serializes a ProseMirror node arg as a JSON string
// instead of an object. Normalize: parse a string to an object (throwing on // instead of an object. Normalize: parse a string to an object (throwing on
// invalid JSON), pass an object through unchanged. Shared by patch_node / // invalid JSON), pass an object through unchanged. Shared by patchNode /
// insert_node (and the analogous update_page_json content parsing). // insertNode (and the analogous updatePageJson content parsing).
// //
// This lives in the converter package (#414) so BOTH consumers import the ONE // This lives in the converter package (#414) so BOTH consumers import the ONE
// copy: `@docmost/mcp` (ESM) and the CommonJS server app. The server cannot // copy: `@docmost/mcp` (ESM) and the CommonJS server app. The server cannot
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
replaceNodeByIdWithMany,
insertNodesRelative,
} from "../src/lib/node-ops.js";
// #413: the array-splice helpers used by the markdown patch/insert paths.
const p = (id: string, t: string): any => ({
type: "paragraph",
attrs: { id },
content: [{ type: "text", text: t }],
});
describe("replaceNodeByIdWithMany", () => {
it("splices N nodes in place of the first match, keeping neighbours byte-identical", () => {
const before = { type: "doc", content: [p("a", "A"), p("b", "B"), p("c", "C")] };
const snap = JSON.parse(JSON.stringify(before));
const { doc, replaced } = replaceNodeByIdWithMany(before, "b", [
p("b1", "B1"),
p("b2", "B2"),
]);
expect(replaced).toBe(1);
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "b1", "b2", "c"]);
// Input never mutated.
expect(before).toEqual(snap);
// Neighbours byte-identical.
expect(doc.content[0]).toEqual(snap.content[0]);
expect(doc.content[3]).toEqual(snap.content[2]);
});
it("reaches a nested match (inside a callout) and splices there", () => {
const before = {
type: "doc",
content: [
{ type: "callout", attrs: { id: "co" }, content: [p("x", "X")] },
],
};
const { doc, replaced } = replaceNodeByIdWithMany(before, "x", [
p("x1", "X1"),
p("x2", "X2"),
]);
expect(replaced).toBe(1);
expect(doc.content[0].content.map((n: any) => n.attrs.id)).toEqual(["x1", "x2"]);
});
it("only touches the FIRST duplicate (caller guards ambiguity)", () => {
const before = { type: "doc", content: [p("d", "1"), p("d", "2")] };
const { doc, replaced } = replaceNodeByIdWithMany(before, "d", [p("n", "N")]);
expect(replaced).toBe(1);
// First replaced; the second duplicate survives untouched.
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["n", "d"]);
});
it("reports replaced:0 for no match, doc unchanged", () => {
const before = { type: "doc", content: [p("a", "A")] };
const { doc, replaced } = replaceNodeByIdWithMany(before, "zzz", [p("n", "N")]);
expect(replaced).toBe(0);
expect(doc).toEqual(before);
});
});
describe("insertNodesRelative", () => {
it("inserts an ordered array after an id anchor", () => {
const before = { type: "doc", content: [p("a", "A"), p("b", "B")] };
const { doc, inserted } = insertNodesRelative(
before,
[p("n1", "N1"), p("n2", "N2")],
{ position: "after", anchorNodeId: "a" },
);
expect(inserted).toBe(true);
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n1", "n2", "b"]);
});
it("inserts before an id anchor", () => {
const before = { type: "doc", content: [p("a", "A"), p("b", "B")] };
const { doc } = insertNodesRelative(before, [p("n", "N")], {
position: "before",
anchorNodeId: "b",
});
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n", "b"]);
});
it("appends an ordered array at the top level", () => {
const before = { type: "doc", content: [p("a", "A")] };
const { doc } = insertNodesRelative(before, [p("n1", "N1"), p("n2", "N2")], {
position: "append",
});
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n1", "n2"]);
});
it("resolves an anchor by top-level text", () => {
const before = { type: "doc", content: [p("a", "hello there"), p("b", "B")] };
const { doc, inserted } = insertNodesRelative(before, [p("n", "N")], {
position: "after",
anchorText: "hello",
});
expect(inserted).toBe(true);
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n", "b"]);
});
it("reports inserted:false when the anchor is missing", () => {
const before = { type: "doc", content: [p("a", "A")] };
const { doc, inserted } = insertNodesRelative(before, [p("n", "N")], {
position: "after",
anchorNodeId: "missing",
});
expect(inserted).toBe(false);
expect(doc).toEqual(before);
});
it("is a no-op for an empty node array", () => {
const before = { type: "doc", content: [p("a", "A")] };
const { doc, inserted } = insertNodesRelative(before, [], {
position: "append",
});
expect(inserted).toBe(false);
expect(doc).toEqual(before);
});
});