fix(mcp): list_pages tree:true молча теряет страницы и жжёт до 50 холостых запросов на родителя — клиент шлёт офсетный page против курсорной пагинации се…
#442
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Симптом
list_pages tree:trueна спейсе Netmap (docs.vvzvlad.xyz) молча теряет 20 страниц из 299 и делает 160 HTTP-запросов вместо необходимых ~62 (а после фикса — 1). На спейсе Obsidian — 2 потерянных из 168, 69 запросов. Потерянные страницы существуют, открываются в UI, но в дерево MCP не попадают — вместе со всеми своими поддеревьями. Ошибок нет, потеря полностью молчаливая.Root cause: рассинхрон протоколов пагинации
Upstream-коммит
78b1c1a4(«feat: switch to cursor pagination (#1884)», 30.01.2026) перевёл серверные эндпоинты на курсорную пагинацию:PaginationOptions(apps/server/src/database/pagination/pagination-options.ts) теперь содержит толькоlimit/cursor/beforeCursor. Поляpageбольше не существует./pages/sidebar-pages→getSidebarPages()(page.service.ts:405) используетexecuteWithCursorPaginationпо(position collate "C", id), вmetaкладётhasNextPageиnextCursor.ValidationPipe({ whitelist: true })безforbidNonWhitelisted(main.ts:183) молча вырезает неизвестное полеpageиз тела запроса.А MCP-клиент так и остался на офсетном протоколе (
packages/mcp/src/client.ts:571-598):Итог для родителя с >20 детьми (клиент не шлёт
limit, серверный дефолт — 20): каждая итерация возвращает одну и ту же первую двадцатку сhasNextPage: true→ цикл выкручивается до потолка 50 одинаковых запросов, дети №21+ не выгружаются никогда, вместе со всеми поддеревьями. Дубликаты гасятсяvisited-сетом вenumerateSpacePages— поэтому снаружи ни ошибок, ни дублей, просто «дырявое» дерево.hasChildrenпри этом честный — «врёт» именно пагинация.Эмпирическое подтверждение (живой инстанс)
Прямой запрос к
/api/pages/sidebar-pages(родитель с 33 детьми, Netmap):{pageId, page: 2}→ байт-в-байт та же первая страница из 20 элементов,hasNextPage: true;{pageId, cursor: <nextCursor>}→ недостающие 13 элементов,hasNextPage: false.Точная симуляция клиентского алгоритма против корректного курсорного обхода:
Затронутые места (все в
packages/mcp/src/client.ts)list_pages tree:true→enumerateSpacePages(:617) →listSidebarPages(:571) — потеря страниц + лавина запросов.check_new_comments(:2777, :2790) — тот жеenumerateSpacePages: комментарии на потерянных страницах не сканируются вообще.get_page→ subpages (:708) — хуже: уlistSidebarPagesнет дедупа, для страницы с >20 детьми subpages = 50×20 = 1000 дублированных записей первой двадцатки, дети №21+ отсутствуют.paginateAll(:458, используетсяgetSpaces/spacesиlist_shares/shares— оба тоже на курсорах после78b1c1a4) — спасается только эвристикой «страница короче limit → стоп», т.е. ломается при >100 элементов: 50 страниц дублей + молчаливое усечение до первой сотни.Дизайн исправления
A.
enumerateSpacePages→ один запросPOST /pages/treeВ форке уже есть эндпоинт
POST /pages/tree(page.controller.ts:582→getSidebarPagesTree, добавлен вb81819ef), который отдаёт весь спейс или поддерево одним запросом без пагинации: плоский список{id, slugId, title, icon, position, parentPageId, spaceId, hasChildren, canEdit}— ровно та форма узла, которую потребляетbuildPageTree(lib/tree.ts). Семантика совпадает с sidebar-pages:deletedAt is nullна обоих уровнях рекурсии (page.repo.ts: getSpaceDescendants / getPageAndDescendants), permission-логика воспроизведена точно (открытый спейс / restricted с per-page грантами,hasChildrenот финального отфильтрованного набора — см.sidebar-pages-tree.util.tsи spec).Переписать внутренности
enumerateSpacePages(spaceId, rootPageId?):Изменения семантики, которые надо разрулить:
getPageAndDescendantsсеется сid = rootPageId), а старый BFS начинался с детей. Это ровно то, что нужноcheck_new_comments— он сейчас докачивает родителя отдельнымgetPageRaw(:2781-2788); этот код упростить и убрать лишний запрос.list_pages tree:trueне передаётrootPageId— не затронут.MAX_NODES = 10000BFS-кап умирает вместе с обходом; флагtruncated = pagesInScope.length >= 10000вcheck_new_comments(:2822) пересмотреть (сервер отдаёт всё; оценка размера ответа: ~150 байт/узел → 10k страниц ≈ 1.5 МБ, приемлемо).Эффект: Netmap — 160 запросов → 1, потерь нет.
B.
listSidebarPages→ курсорная пагинация (нужен дляget_pagesubpages)get_pageхочет только прямых детей — тащить всё поддерево через/pages/treeрасточительно, оставляемsidebar-pages, но чиним протокол:Защита от повторения этой же истории: выход при
nextCursor, равном предыдущему или отсутствующем, — если сервер снова перестанет понимать наш параметр, цикл не будет молча крутить дубли.C.
paginateAll→ та же курсорная миграция/spacesи/shares— тот же паттернcursor/nextCursor+limit: 100, сохранить потолокMAX_PAGESи предупреждение об усечении. Проверить, что других вызововpaginateAllне осталось.D. Fallback для standalone-режима
MCP имеет standalone stdio-вход (
stdio.ts), который могут направить на stock upstream Docmost, где форковогоPOST /pages/treeнет. Решение: при 404/405 от/pages/tree— фолбэк на курсорный BFS (тот жеlistSidebarPagesиз пункта B, старый каркасenumerateSpacePagesсvisited/MAX_NODESсохранить как fallback-ветку). Встроенный HTTP-режим всегда живёт на том же сервере — там эндпоинт гарантирован.E. Тесты
listSidebarPages: мок сервера с 2–3 курсорными страницами → все элементы собраны, число запросов = числу страниц.hasNextPage: true) → цикл обязан завершиться без дублей, а не накрутить MAX_PAGES.enumerateSpacePages: happy-path через/pages/tree(один запрос) + fallback-ветка на 404.check_new_commentssubtree: корень включён в scope без отдельногоgetPageRaw.Критерии приёмки
list_pages tree:trueна Netmap возвращает все 299 страниц (включаяipad 10.20.30.5,wireguard-node 10.20.30.10,gsm-barrier 10.31.41.91и остальные 17 сейчас теряемых) за 1 HTTP-запрос к/pages/tree.check_new_commentsсканирует все страницы спейса/поддерева; subtree-scope включает корень без лишнего запроса.get_pageдля страницы с >20 детьми отдаёт полный список subpages без дублей.getSpaces/list_sharesкорректно выгружают >100 элементов.Приоритет
Класс data-loss: инструмент выдаёт неполные данные без какого-либо признака неполноты — агент, строящий работу поверх
list_pages/check_new_comments, молча не видит части вики. Плюс 50-кратная лавина запросов на каждого «широкого» родителя.