fix(mcp): list_pages tree:true молча теряет страницы и жжёт до 50 холостых запросов на родителя — клиент шлёт офсетный page против курсорной пагинации се… #442

Closed
opened 2026-07-10 05:47:16 +03:00 by agent_vscode · 0 comments
Collaborator

Симптом

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-pagesgetSidebarPages() (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):

// listSidebarPages: the client-side loop
while (hasNextPage && page <= MAX_PAGES) {           // MAX_PAGES = 50
  const payload: Record<string, any> = { spaceId, page }; // `page` is silently stripped by the server
  if (pageId) payload.pageId = pageId;
  const response = await this.client.post("/pages/sidebar-pages", payload);
  // ...
  hasNextPage = data?.meta?.hasNextPage || false;    // server keeps saying "there is more"
  page++;                                            // ...but page: 2 changes nothing
}

Итог для родителя с >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.

Точная симуляция клиентского алгоритма против корректного курсорного обхода:

Спейс Реально страниц Видит MCP Потеряно Запросов (MCP-алгоритм) Запросов (курсор)
Netmap 299 279 20 160 62
Obsidian 168 166 2 69 20
General 46 46 0 17 17

Затронутые места (все в packages/mcp/src/client.ts)

  1. list_pages tree:trueenumerateSpacePages (:617) → listSidebarPages (:571) — потеря страниц + лавина запросов.
  2. check_new_comments (:2777, :2790) — тот же enumerateSpacePages: комментарии на потерянных страницах не сканируются вообще.
  3. get_page → subpages (:708) — хуже: у listSidebarPages нет дедупа, для страницы с >20 детьми subpages = 50×20 = 1000 дублированных записей первой двадцатки, дети №21+ отсутствуют.
  4. paginateAll (:458, используется getSpaces /spaces и list_shares /shares — оба тоже на курсорах после 78b1c1a4) — спасается только эвристикой «страница короче limit → стоп», т.е. ломается при >100 элементов: 50 страниц дублей + молчаливое усечение до первой сотни.

Дизайн исправления

A. enumerateSpacePages → один запрос POST /pages/tree

В форке уже есть эндпоинт POST /pages/tree (page.controller.ts:582getSidebarPagesTree, добавлен в 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?):

// Single request replaces the whole BFS: the /pages/tree endpoint returns the
// full permission-filtered flat page set of a space (or a subtree) at once.
const payload = rootPageId ? { pageId: rootPageId } : { spaceId };
const response = await this.client.post("/pages/tree", payload);
return (response.data?.data ?? response.data)?.items ?? [];

Изменения семантики, которые надо разрулить:

  • Subtree-вариант теперь включает сам корень (getPageAndDescendants сеется с id = rootPageId), а старый BFS начинался с детей. Это ровно то, что нужно check_new_comments — он сейчас докачивает родителя отдельным getPageRaw (:2781-2788); этот код упростить и убрать лишний запрос. list_pages tree:true не передаёт rootPageId — не затронут.
  • MAX_NODES = 10000 BFS-кап умирает вместе с обходом; флаг truncated = pagesInScope.length >= 10000 в check_new_comments (:2822) пересмотреть (сервер отдаёт всё; оценка размера ответа: ~150 байт/узел → 10k страниц ≈ 1.5 МБ, приемлемо).

Эффект: Netmap — 160 запросов → 1, потерь нет.

B. listSidebarPages → курсорная пагинация (нужен для get_page subpages)

get_page хочет только прямых детей — тащить всё поддерево через /pages/tree расточительно, оставляем sidebar-pages, но чиним протокол:

const MAX_PAGES = 50;
let cursor: string | undefined;
let allItems: any[] = [];
for (let i = 0; i < MAX_PAGES; i++) {
  // limit: 100 is the server-side Max; cuts request count 5x vs the default 20
  const payload: Record<string, any> = { spaceId, limit: 100 };
  if (pageId) payload.pageId = pageId;
  if (cursor) payload.cursor = cursor;
  const data = (await this.client.post("/pages/sidebar-pages", payload)).data?.data;
  allItems = allItems.concat(data?.items ?? []);
  // Advance strictly via the server-issued cursor; a missing/repeated cursor
  // means the protocol drifted again — stop instead of looping on page one.
  const next = data?.meta?.hasNextPage ? data?.meta?.nextCursor : null;
  if (!next || next === cursor) break;
  cursor = next;
}

Защита от повторения этой же истории: выход при 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. Тесты

  1. Unit на курсорный цикл listSidebarPages: мок сервера с 2–3 курсорными страницами → все элементы собраны, число запросов = числу страниц.
  2. Regression на сам класс бага: мок, игнорирующий параметр пагинации (всегда первая страница, hasNextPage: true) → цикл обязан завершиться без дублей, а не накрутить MAX_PAGES.
  3. enumerateSpacePages: happy-path через /pages/tree (один запрос) + fallback-ветка на 404.
  4. check_new_comments subtree: корень включён в 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 элементов.
  • Ни один цикл пагинации не может молча крутить одну и ту же страницу (guard на неподвижный курсор).
  • Тесты из раздела E зелёные.

Приоритет

Класс data-loss: инструмент выдаёт неполные данные без какого-либо признака неполноты — агент, строящий работу поверх list_pages/check_new_comments, молча не видит части вики. Плюс 50-кратная лавина запросов на каждого «широкого» родителя.

# Симптом `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`): ```ts // listSidebarPages: the client-side loop while (hasNextPage && page <= MAX_PAGES) { // MAX_PAGES = 50 const payload: Record<string, any> = { spaceId, page }; // `page` is silently stripped by the server if (pageId) payload.pageId = pageId; const response = await this.client.post("/pages/sidebar-pages", payload); // ... hasNextPage = data?.meta?.hasNextPage || false; // server keeps saying "there is more" page++; // ...but page: 2 changes nothing } ``` Итог для родителя с >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`. Точная симуляция клиентского алгоритма против корректного курсорного обхода: | Спейс | Реально страниц | Видит MCP | Потеряно | Запросов (MCP-алгоритм) | Запросов (курсор) | |---|---|---|---|---|---| | Netmap | 299 | 279 | **20** | **160** | 62 | | Obsidian | 168 | 166 | 2 | 69 | 20 | | General | 46 | 46 | 0 | 17 | 17 | # Затронутые места (все в `packages/mcp/src/client.ts`) 1. **`list_pages tree:true`** → `enumerateSpacePages` (:617) → `listSidebarPages` (:571) — потеря страниц + лавина запросов. 2. **`check_new_comments`** (:2777, :2790) — тот же `enumerateSpacePages`: комментарии на потерянных страницах **не сканируются вообще**. 3. **`get_page` → subpages** (:708) — хуже: у `listSidebarPages` нет дедупа, для страницы с >20 детьми subpages = 50×20 = **1000 дублированных записей** первой двадцатки, дети №21+ отсутствуют. 4. **`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?)`: ```ts // Single request replaces the whole BFS: the /pages/tree endpoint returns the // full permission-filtered flat page set of a space (or a subtree) at once. const payload = rootPageId ? { pageId: rootPageId } : { spaceId }; const response = await this.client.post("/pages/tree", payload); return (response.data?.data ?? response.data)?.items ?? []; ``` Изменения семантики, которые надо разрулить: - **Subtree-вариант теперь включает сам корень** (`getPageAndDescendants` сеется с `id = rootPageId`), а старый BFS начинался с детей. Это ровно то, что нужно `check_new_comments` — он сейчас докачивает родителя отдельным `getPageRaw` (:2781-2788); этот код упростить и убрать лишний запрос. `list_pages tree:true` не передаёт `rootPageId` — не затронут. - **`MAX_NODES = 10000` BFS-кап умирает** вместе с обходом; флаг `truncated = pagesInScope.length >= 10000` в `check_new_comments` (:2822) пересмотреть (сервер отдаёт всё; оценка размера ответа: ~150 байт/узел → 10k страниц ≈ 1.5 МБ, приемлемо). Эффект: Netmap — 160 запросов → **1**, потерь нет. ## B. `listSidebarPages` → курсорная пагинация (нужен для `get_page` subpages) `get_page` хочет только прямых детей — тащить всё поддерево через `/pages/tree` расточительно, оставляем `sidebar-pages`, но чиним протокол: ```ts const MAX_PAGES = 50; let cursor: string | undefined; let allItems: any[] = []; for (let i = 0; i < MAX_PAGES; i++) { // limit: 100 is the server-side Max; cuts request count 5x vs the default 20 const payload: Record<string, any> = { spaceId, limit: 100 }; if (pageId) payload.pageId = pageId; if (cursor) payload.cursor = cursor; const data = (await this.client.post("/pages/sidebar-pages", payload)).data?.data; allItems = allItems.concat(data?.items ?? []); // Advance strictly via the server-issued cursor; a missing/repeated cursor // means the protocol drifted again — stop instead of looping on page one. const next = data?.meta?.hasNextPage ? data?.meta?.nextCursor : null; if (!next || next === cursor) break; cursor = next; } ``` Защита от повторения этой же истории: выход при `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. Тесты 1. Unit на курсорный цикл `listSidebarPages`: мок сервера с 2–3 курсорными страницами → все элементы собраны, число запросов = числу страниц. 2. Regression на сам класс бага: мок, **игнорирующий** параметр пагинации (всегда первая страница, `hasNextPage: true`) → цикл обязан завершиться без дублей, а не накрутить MAX_PAGES. 3. `enumerateSpacePages`: happy-path через `/pages/tree` (один запрос) + fallback-ветка на 404. 4. `check_new_comments` subtree: корень включён в 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 элементов. - [ ] Ни один цикл пагинации не может молча крутить одну и ту же страницу (guard на неподвижный курсор). - [ ] Тесты из раздела E зелёные. # Приоритет Класс data-loss: инструмент выдаёт неполные данные без какого-либо признака неполноты — агент, строящий работу поверх `list_pages`/`check_new_comments`, молча не видит части вики. Плюс 50-кратная лавина запросов на каждого «широкого» родителя.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#442