feat(mcp): три read-инструмента для агентского lookup — search (подстрока/path/сниппет/scope), get_tree (одним запросом), get_page_context #443

Open
opened 2026-07-10 05:53:40 +03:00 by agent_vscode · 0 comments
Collaborator

Цель

MCP-сервером пользуются агентские клиенты (LLM). Основной паттерн — lookup: найти страницу по обрывку технической строки и сразу понять, где она лежит и что в ней. Сейчас на такой вопрос уходит 3–5 вызовов (поиск → не нашёл → дерево → get_page). Цель: один вопрос — один вызов. Всё ниже — только чтение.

Инвариант для всех трёх инструментов: единственный идентификатор страницы в ответах — pageId (UUID). Серверные ответы несут id/slugId — маппинг id → pageId делается в MCP-слое, slugId наружу не отдаём.

Связанное: #442 (курсорная пагинация listSidebarPages, использование POST /pages/tree, fallback для stock upstream). Часть A/B из #442 — общая инфраструктура для get_tree и get_page_context; делать на одной ветке или строго после #442, не дублировать.

Приоритет внутри задачи: search > get_tree > get_page_context (последний — низкий приоритет, можно отдельным PR).


1. search — доработка существующего инструмента

Проблема

  1. Поиск не находит строки с точками/дефисами/цифрами: страница backup-srv.local или текст 10.0.12.5 не находятся по srv.local, 10.0.12. Причины две, обе в apps/server/src/core/search/search.service.ts:
    • buildTsQuery() (:25–35) вырезает всё, кроме букв/цифр/пробелов, ДО передачи в pg-tsquery — srv.local превращается в srv & local*;
    • FTS-токенизатор english в tsv сам по-своему режет backup-srv.local и 10.0.12.5, так что префиксный матч частей не совпадает с ожиданием «подстрока».
  2. В результатах нет местоположения страницы (только заголовок) — бесполезно при трёх страницах traefik в разных ветках.
  3. Сниппет ts_headline с MinWords=9, MaxWords=10 слишком короткий — после поиска всё равно нужен get_page.
  4. Нельзя ограничить поиск поддеревом.

Контракт инструмента (MCP)

// вход
{
  "query": "srv.local",        // обязательный
  "spaceId": "0199-...",       // опц., как сейчас
  "parentPageId": "0199-...",  // опц.: искать только среди потомков этой страницы (включая её саму)
  "titleOnly": false,          // опц.: матчить только заголовки
  "limit": 10                  // опц., по умолчанию 10, максимум 50
}

// выход: массив хитов, отсортированный по score (убывание)
[
  {
    "pageId": "0199aa...",
    "title": "backup-srv.local",
    "path": ["Infrastructure", "Datacenter A", "Servers"],  // заголовки предков от корня спейса до родителя
    "snippet": "…IP: 10.0.12.5. Debian 12, роль: бэкапы…",   // ~300–500 символов вокруг первого совпадения
    "score": 0.92
  }
]

Архитектурные решения

Серверная часть (SearchService.searchPage), новый режим — opt-in, веб-UI не трогаем:

  • В SearchDTO (apps/server/src/core/search/dto/search.dto.ts) добавить опциональные поля: substring?: boolean (включает гибридный режим ниже), parentPageId?: string, titleOnly?: boolean, includePath?: boolean (можно объединить substring+includePath в один флаг режима — на усмотрение, но веб-клиент без флагов должен получать байт-в-байт текущее поведение).
  • Подстрочная ветка добавляется К существующей FTS-ветке (union/два запроса + merge в сервисе): LOWER(f_unaccent(title)) LIKE '%' || q || '%' и (если не titleOnly) то же по text_content. Паттерн LOWER(f_unaccent(...)) LIKE уже используется в searchSuggestions (:197, :232) — переиспользовать подход. Запрос нормализуется так же (f_unaccent + lower), LIKE-метасимволы %, _, \ в пользовательском запросе экранируются — все спецсимволы (., -, _, %, /) должны быть литералами.
  • Важный кейс: запрос из одних спецсимволов/цифр с точками (10.0.12) может дать пустой tsquery — подстрочная ветка обязана отработать даже когда buildTsQuery() вернул '' (сейчас это ведёт к пустой выдаче).
  • Дедуп по id при попадании в обе ветки, берётся максимальный score.
  • Ранжирование — тирами: точное совпадение заголовка (case-insensitive equality) > подстрока в заголовке > совпадение в тексте (подстрока или FTS). Внутри тира — вторичный признак (ts_rank / позиция вхождения). score — число 0..1 только для сортировки в рамках одной выдачи, формулу зафиксировать в комментарии.
  • parentPageId: scope через recursive-CTE потомков — паттерн уже есть в этом же файле для share-scope (:122–139, getPageAndDescendants + where id in (...)); включает саму страницу.
  • path: батчевый helper «предки для набора страниц» — ОДИН recursive CTE, засеянный всеми id хитов (не N+1 вызовов getPageBreadCrumbs). Вернуть в каждом item массив заголовков от корня до родителя; у корневой страницы path: []. Поле обязательно в каждом хите нового режима.
  • snippet: ~300–500 символов вокруг ПЕРВОГО совпадения. Не тащить весь text_content в Node: окно вырезать в SQL (strpos(lower(f_unaccent(text_content)), q) + substring(... for 500)). Если совпадение только в заголовке — первые ~300 символов текста страницы. Для чисто-FTS-хитов допустим расширенный ts_headline (больше MaxWords/MaxFragments) как fallback.
  • Пермишены: существующий пост-фильтр filterAccessiblePageIds (:151–161) применяется к ОБЪЕДИНЁННОМУ результату до обрезки по limit — не потерять.
  • Индексы: pg_trgm уже включён (миграция 20250729T213756-add-unaccent-pg_trm-update-tsvector..ts), но trgm-индексов на pages нет. Добавить миграцию: GIN gin_trgm_ops по f_unaccent(lower(title)); по text_content — на усмотрение после замера (инстансы маленькие, seq scan внутри спейса может быть ок; решение зафиксировать в PR).

MCP-часть:

  • packages/mcp/src/index.ts:703–727 — расширить schema инструмента (spaceId, parentPageId, titleOnly, limit 1–50, default 10) и описание; обновить SERVER_INSTRUCTIONS (:52–58) — это enforced тестом test/unit/server-instructions.test.mjs.
  • packages/mcp/src/client.ts:2195 search() — прокинуть новые параметры + флаг нового режима; маппер результата (filterSearchResult) переделать под выходной контракт {pageId, title, path, snippet, score}.
  • Комментарий у регистрации (:698–702) фиксирует намеренное расхождение с in-app‑поиском (hybrid RRF) — in-app вариант НЕ трогать.
  • Standalone stdio против stock upstream: новые DTO-поля там молча вырежутся whitelist: true — поиск деградирует до текущего поведения. Это приемлемо, задокументировать в описании инструмента не нужно, только комментарием в коде.

Приёмка (интеграционные тесты на реальной схеме БД)

Запрос Находит
srv.local страницу с title backup-srv.local
10.0.12 страницу, в тексте которой есть 10.0.12.5
WB-MGE-30D86B страницу WB-MGE-30D86B (цифробуквенный токен с дефисами)
Теги: Docker все страницы, где в тексте буквально есть Теги: Docker

Плюс: каждый хит содержит непустой path (кроме корневых — []) и snippet; parentPageId отсекает чужие ветки; titleOnly не матчит по тексту; пустая выдача — [], не ошибка; выдача веб-UI (без новых флагов) не изменилась; запрос % или _ не матчит всё подряд (экранирование).


2. get_tree — новый инструмент

Проблема

Единственный способ получить иерархию — list_pages tree:true: BFS по sidebar-эндпоинту, десятки–сотни HTTP-вызовов и молчаливая потеря страниц (детали и root cause — #442: на спейсе ~300 страниц теряется ~20).

Контракт

// вход
{
  "spaceId": "0199-...",     // обязательный
  "rootPageId": "0199-...",  // опц.: вернуть только поддерево этой страницы
  "maxDepth": 3              // опц.: обрезать глубину; по умолчанию без лимита
}

// выход: массив корневых узлов
[
  {
    "pageId": "0199aa...",
    "title": "Infrastructure",
    "children": [
      { "pageId": "0199bb...", "title": "Datacenter A", "children": [ /*  */ ] },
      { "pageId": "0199cc...", "title": "Datacenter B", "hasChildren": true }  // дети обрезаны maxDepth
    ]
  }
]

Архитектурные решения

  • Бэкенд уже готов: форковый POST /pages/tree (apps/server/src/core/page/page.controller.ts:582getSidebarPagesTree, page.service.ts:1437) отдаёт весь спейс или поддерево одним запросом: плоский список {id, slugId, title, icon, position, parentPageId, spaceId, hasChildren, canEdit}, permission-filtered, deletedAt is null, порядок по position collate "C" — ровно как в сайдбаре. Серверных изменений для этого инструмента НЕ требуется.
  • MCP-инструмент: один вызов /pages/tree ({spaceId} или {pageId: rootPageId}), затем сборка вложенности — расширить/переиспользовать buildPageTree (packages/mcp/src/lib/tree.ts, чистая функция с тестопригодной сигнатурой). Выходной узел: {pageId, title, children?, hasChildren?}. slugId/icon/position наружу не отдаются.
  • rootPageId: сервер сеет CTE самой страницей → в ответе ровно один корень — она сама со своим поддеревом (совпадает с контрактом).
  • maxDepth режется на MCP-стороне ПОСЛЕ сборки полного дерева (сервер всё равно отдаёт всё одним запросом, обрезка — экономия токенов ответа). Семантика: корневые узлы = глубина 1; maxDepth: 1 → только корни. Флаг hasChildren: true ставится ТОЛЬКО на узлы, чьи дети не выведены из-за обрезки (у листьев и развёрнутых узлов поле опущено; серверный hasChildren плоского списка использовать как источник истины). Вызывающий по нему знает, что можно углубиться отдельным вызовом с rootPageId.
  • list_pages tree:true — сделать алиасом на этот же код-путь и пометить deprecated в описании инструмента + SERVER_INSTRUCTIONS («иерархия → get_tree»). Это пересекается с фиксом A из #442 — координировать, чтобы enumerateSpacePages-BFS остался только как fallback.
  • Fallback для standalone против stock upstream (где /pages/tree нет): при 404/405 — курсорный BFS из #442 (B+D). Не реализовывать здесь второй раз — общий код с #442.

Приёмка

  • Полнота — главное требование: на спейсе 300+ страниц (Netmap) get_tree возвращает все неудалённые страницы, каждая ровно один раз; число узлов = числу неудалённых страниц спейса (включая 20 теряемых сейчас — список в #442).
  • Один вызов инструмента = один HTTP-запрос к /pages/tree (без запросов на каждый узел).
  • get_tree(spaceId, rootPageId=X) эквивалентен поддереву X из полного дерева.
  • maxDepth=1 возвращает корни с hasChildren без внуков.
  • Порядок детей везде — как в сайдбаре (по position).
  • Unit: сборка дерева из плоского списка (переиспользовать подход спеков tree.ts), обрезка maxDepth + расстановка hasChildren, orphan-узлы (родитель отфильтрован пермишенами) поднимаются в корни, а не исчезают.

3. get_page_context — новый инструмент (низкий приоритет)

Проблема

Есть pageId (из поиска, из ссылки) — нужно понять «где я и что вокруг». Сейчас это get_page + полное дерево.

Контракт

// вход
{ "pageId": "0199bb..." }

// выход
{
  "page":        { "pageId": "0199bb...", "title": "Datacenter A", "spaceId": "0199-..." },
  "breadcrumbs": [ { "pageId": "0199aa...", "title": "Infrastructure" } ],  // от корня спейса до родителя; [] для корневой
  "children": [
    { "pageId": "0199dd...", "title": "Servers", "hasChildren": true },
    { "pageId": "0199ee...", "title": "Network", "hasChildren": false }
  ]
}

Архитектурные решения

  • Только метаданные, без контента страниц. Родитель отдельным полем не дублируется — это последний элемент breadcrumbs.
  • Бэкенд готов, серверных изменений нет. Два запроса:
    1. POST /pages/breadcrumbs (page.controller.ts:804getPageBreadCrumbs, page.service.ts:1072) — recursive CTE вверх, возвращает цепочку включая саму страницу, после reverse() — от корня к странице. В MCP: последний элемент → поле page, остальное → breadcrumbs (проверить порядок в тесте, не полагаться на память).
    2. Прямые дети — listSidebarPages(spaceId, pageId) (ПОСЛЕ курсорного фикса B из #442): элементы уже несут hasChildren и порядок по position.
  • Вход может быть slugId (агенты копируют из URL) — прогнать через существующий resolvePageId (client.ts:674), как делают остальные инструменты.
  • Согласованность с get_tree: те же id (UUID), те же названия, тот же порядок детей — обеспечивается общими эндпоинтами, закрепить интеграционным тестом.
  • Регистрация + SERVER_INSTRUCTIONS — как у остальных.

Приёмка

  • Для страницы 3-го уровня: breadcrumbs = два предка от корня; для корневой — [].
  • children совпадают по составу/порядку с соответствующим срезом get_tree.
  • Страница с >20 детьми возвращает всех детей (курсорная пагинация, без дублей) — regression на класс бага из #442.
  • Несуществующий/недоступный pageId → внятная ошибка, не пустой объект.

Общие требования к реализации

  • Все комментарии в коде — на английском.
  • Каждый новый/изменённый инструмент: обновить SERVER_INSTRUCTIONS в packages/mcp/src/index.ts (иначе упадёт test/unit/server-instructions.test.mjs); свериться с паттерном tool-specs.ts для in-app/transport-расхождений — in-app-поиск (hybrid RRF) не трогаем.
  • Никаких изменений write-путей; изменения серверного /search — строго opt-in, поведение веб-клиента без новых флагов неизменно.
  • Порядок PR: (1) search; (2) get_tree (+ deprecated-алиас list_pages tree:true, координация с #442); (3) get_page_context.
# Цель MCP-сервером пользуются агентские клиенты (LLM). Основной паттерн — lookup: найти страницу по обрывку технической строки и сразу понять, где она лежит и что в ней. Сейчас на такой вопрос уходит 3–5 вызовов (поиск → не нашёл → дерево → get_page). Цель: **один вопрос — один вызов**. Всё ниже — только чтение. **Инвариант для всех трёх инструментов: единственный идентификатор страницы в ответах — `pageId` (UUID).** Серверные ответы несут `id`/`slugId` — маппинг `id → pageId` делается в MCP-слое, `slugId` наружу не отдаём. Связанное: #442 (курсорная пагинация `listSidebarPages`, использование `POST /pages/tree`, fallback для stock upstream). Часть A/B из #442 — общая инфраструктура для `get_tree` и `get_page_context`; делать на одной ветке или строго после #442, не дублировать. Приоритет внутри задачи: **search > get_tree > get_page_context** (последний — низкий приоритет, можно отдельным PR). --- # 1. `search` — доработка существующего инструмента ## Проблема 1. Поиск не находит строки с точками/дефисами/цифрами: страница `backup-srv.local` или текст `10.0.12.5` не находятся по `srv.local`, `10.0.12`. Причины две, обе в `apps/server/src/core/search/search.service.ts`: - `buildTsQuery()` (:25–35) вырезает всё, кроме букв/цифр/пробелов, ДО передачи в pg-tsquery — `srv.local` превращается в `srv & local*`; - FTS-токенизатор `english` в `tsv` сам по-своему режет `backup-srv.local` и `10.0.12.5`, так что префиксный матч частей не совпадает с ожиданием «подстрока». 2. В результатах нет местоположения страницы (только заголовок) — бесполезно при трёх страницах `traefik` в разных ветках. 3. Сниппет `ts_headline` с `MinWords=9, MaxWords=10` слишком короткий — после поиска всё равно нужен `get_page`. 4. Нельзя ограничить поиск поддеревом. ## Контракт инструмента (MCP) ```jsonc // вход { "query": "srv.local", // обязательный "spaceId": "0199-...", // опц., как сейчас "parentPageId": "0199-...", // опц.: искать только среди потомков этой страницы (включая её саму) "titleOnly": false, // опц.: матчить только заголовки "limit": 10 // опц., по умолчанию 10, максимум 50 } // выход: массив хитов, отсортированный по score (убывание) [ { "pageId": "0199aa...", "title": "backup-srv.local", "path": ["Infrastructure", "Datacenter A", "Servers"], // заголовки предков от корня спейса до родителя "snippet": "…IP: 10.0.12.5. Debian 12, роль: бэкапы…", // ~300–500 символов вокруг первого совпадения "score": 0.92 } ] ``` ## Архитектурные решения **Серверная часть** (`SearchService.searchPage`), новый режим — **opt-in**, веб-UI не трогаем: - В `SearchDTO` (`apps/server/src/core/search/dto/search.dto.ts`) добавить опциональные поля: `substring?: boolean` (включает гибридный режим ниже), `parentPageId?: string`, `titleOnly?: boolean`, `includePath?: boolean` (можно объединить substring+includePath в один флаг режима — на усмотрение, но веб-клиент без флагов должен получать байт-в-байт текущее поведение). - **Подстрочная ветка** добавляется К существующей FTS-ветке (`union`/два запроса + merge в сервисе): `LOWER(f_unaccent(title)) LIKE '%' || q || '%'` и (если не `titleOnly`) то же по `text_content`. Паттерн `LOWER(f_unaccent(...)) LIKE` уже используется в `searchSuggestions` (:197, :232) — переиспользовать подход. Запрос нормализуется так же (`f_unaccent` + lower), **LIKE-метасимволы `%`, `_`, `\` в пользовательском запросе экранируются** — все спецсимволы (`.`, `-`, `_`, `%`, `/`) должны быть литералами. - Важный кейс: запрос из одних спецсимволов/цифр с точками (`10.0.12`) может дать **пустой tsquery** — подстрочная ветка обязана отработать даже когда `buildTsQuery()` вернул `''` (сейчас это ведёт к пустой выдаче). - **Дедуп** по `id` при попадании в обе ветки, берётся максимальный score. - **Ранжирование** — тирами: точное совпадение заголовка (case-insensitive equality) > подстрока в заголовке > совпадение в тексте (подстрока или FTS). Внутри тира — вторичный признак (ts_rank / позиция вхождения). `score` — число 0..1 только для сортировки в рамках одной выдачи, формулу зафиксировать в комментарии. - **`parentPageId`**: scope через recursive-CTE потомков — паттерн уже есть в этом же файле для share-scope (:122–139, `getPageAndDescendants` + `where id in (...)`); включает саму страницу. - **`path`**: батчевый helper «предки для набора страниц» — ОДИН recursive CTE, засеянный всеми id хитов (не N+1 вызовов `getPageBreadCrumbs`). Вернуть в каждом item массив заголовков от корня до родителя; у корневой страницы `path: []`. Поле обязательно в каждом хите нового режима. - **`snippet`**: ~300–500 символов вокруг ПЕРВОГО совпадения. Не тащить весь `text_content` в Node: окно вырезать в SQL (`strpos(lower(f_unaccent(text_content)), q)` + `substring(... for 500)`). Если совпадение только в заголовке — первые ~300 символов текста страницы. Для чисто-FTS-хитов допустим расширенный `ts_headline` (больше MaxWords/MaxFragments) как fallback. - **Пермишены**: существующий пост-фильтр `filterAccessiblePageIds` (:151–161) применяется к ОБЪЕДИНЁННОМУ результату до обрезки по limit — не потерять. - **Индексы**: `pg_trgm` уже включён (миграция `20250729T213756-add-unaccent-pg_trm-update-tsvector..ts`), но trgm-индексов на `pages` нет. Добавить миграцию: GIN `gin_trgm_ops` по `f_unaccent(lower(title))`; по `text_content` — на усмотрение после замера (инстансы маленькие, seq scan внутри спейса может быть ок; решение зафиксировать в PR). **MCP-часть**: - `packages/mcp/src/index.ts:703–727` — расширить schema инструмента (`spaceId`, `parentPageId`, `titleOnly`, `limit` 1–50, default 10) и описание; обновить `SERVER_INSTRUCTIONS` (:52–58) — это enforced тестом `test/unit/server-instructions.test.mjs`. - `packages/mcp/src/client.ts:2195` `search()` — прокинуть новые параметры + флаг нового режима; маппер результата (`filterSearchResult`) переделать под выходной контракт `{pageId, title, path, snippet, score}`. - Комментарий у регистрации (:698–702) фиксирует **намеренное расхождение** с in-app‑поиском (hybrid RRF) — in-app вариант НЕ трогать. - Standalone stdio против stock upstream: новые DTO-поля там молча вырежутся `whitelist: true` — поиск деградирует до текущего поведения. Это приемлемо, задокументировать в описании инструмента не нужно, только комментарием в коде. ## Приёмка (интеграционные тесты на реальной схеме БД) | Запрос | Находит | |---|---| | `srv.local` | страницу с title `backup-srv.local` | | `10.0.12` | страницу, в тексте которой есть `10.0.12.5` | | `WB-MGE-30D86B` | страницу `WB-MGE-30D86B` (цифробуквенный токен с дефисами) | | `Теги: Docker` | все страницы, где в тексте буквально есть `Теги: Docker` | Плюс: каждый хит содержит непустой `path` (кроме корневых — `[]`) и `snippet`; `parentPageId` отсекает чужие ветки; `titleOnly` не матчит по тексту; пустая выдача — `[]`, не ошибка; выдача веб-UI (без новых флагов) не изменилась; запрос `%` или `_` не матчит всё подряд (экранирование). --- # 2. `get_tree` — новый инструмент ## Проблема Единственный способ получить иерархию — `list_pages tree:true`: BFS по sidebar-эндпоинту, десятки–сотни HTTP-вызовов и молчаливая потеря страниц (детали и root cause — #442: на спейсе ~300 страниц теряется ~20). ## Контракт ```jsonc // вход { "spaceId": "0199-...", // обязательный "rootPageId": "0199-...", // опц.: вернуть только поддерево этой страницы "maxDepth": 3 // опц.: обрезать глубину; по умолчанию без лимита } // выход: массив корневых узлов [ { "pageId": "0199aa...", "title": "Infrastructure", "children": [ { "pageId": "0199bb...", "title": "Datacenter A", "children": [ /* … */ ] }, { "pageId": "0199cc...", "title": "Datacenter B", "hasChildren": true } // дети обрезаны maxDepth ] } ] ``` ## Архитектурные решения - **Бэкенд уже готов**: форковый `POST /pages/tree` (`apps/server/src/core/page/page.controller.ts:582` → `getSidebarPagesTree`, `page.service.ts:1437`) отдаёт весь спейс или поддерево **одним запросом**: плоский список `{id, slugId, title, icon, position, parentPageId, spaceId, hasChildren, canEdit}`, permission-filtered, `deletedAt is null`, порядок по `position collate "C"` — ровно как в сайдбаре. Серверных изменений для этого инструмента НЕ требуется. - MCP-инструмент: один вызов `/pages/tree` (`{spaceId}` или `{pageId: rootPageId}`), затем сборка вложенности — расширить/переиспользовать `buildPageTree` (`packages/mcp/src/lib/tree.ts`, чистая функция с тестопригодной сигнатурой). Выходной узел: `{pageId, title, children?, hasChildren?}`. `slugId`/`icon`/`position` наружу не отдаются. - **`rootPageId`**: сервер сеет CTE самой страницей → в ответе ровно один корень — она сама со своим поддеревом (совпадает с контрактом). - **`maxDepth`** режется на MCP-стороне ПОСЛЕ сборки полного дерева (сервер всё равно отдаёт всё одним запросом, обрезка — экономия токенов ответа). Семантика: корневые узлы = глубина 1; `maxDepth: 1` → только корни. Флаг `hasChildren: true` ставится ТОЛЬКО на узлы, чьи дети не выведены из-за обрезки (у листьев и развёрнутых узлов поле опущено; серверный `hasChildren` плоского списка использовать как источник истины). Вызывающий по нему знает, что можно углубиться отдельным вызовом с `rootPageId`. - **`list_pages tree:true`** — сделать алиасом на этот же код-путь и пометить deprecated в описании инструмента + `SERVER_INSTRUCTIONS` («иерархия → get_tree»). Это пересекается с фиксом A из #442 — координировать, чтобы `enumerateSpacePages`-BFS остался только как fallback. - **Fallback для standalone против stock upstream** (где `/pages/tree` нет): при 404/405 — курсорный BFS из #442 (B+D). Не реализовывать здесь второй раз — общий код с #442. ## Приёмка - Полнота — главное требование: на спейсе 300+ страниц (Netmap) `get_tree` возвращает **все** неудалённые страницы, каждая ровно один раз; число узлов = числу неудалённых страниц спейса (включая 20 теряемых сейчас — список в #442). - Один вызов инструмента = один HTTP-запрос к `/pages/tree` (без запросов на каждый узел). - `get_tree(spaceId, rootPageId=X)` эквивалентен поддереву X из полного дерева. - `maxDepth=1` возвращает корни с `hasChildren` без внуков. - Порядок детей везде — как в сайдбаре (по `position`). - Unit: сборка дерева из плоского списка (переиспользовать подход спеков `tree.ts`), обрезка `maxDepth` + расстановка `hasChildren`, orphan-узлы (родитель отфильтрован пермишенами) поднимаются в корни, а не исчезают. --- # 3. `get_page_context` — новый инструмент (низкий приоритет) ## Проблема Есть `pageId` (из поиска, из ссылки) — нужно понять «где я и что вокруг». Сейчас это `get_page` + полное дерево. ## Контракт ```jsonc // вход { "pageId": "0199bb..." } // выход { "page": { "pageId": "0199bb...", "title": "Datacenter A", "spaceId": "0199-..." }, "breadcrumbs": [ { "pageId": "0199aa...", "title": "Infrastructure" } ], // от корня спейса до родителя; [] для корневой "children": [ { "pageId": "0199dd...", "title": "Servers", "hasChildren": true }, { "pageId": "0199ee...", "title": "Network", "hasChildren": false } ] } ``` ## Архитектурные решения - Только метаданные, без контента страниц. Родитель отдельным полем не дублируется — это последний элемент `breadcrumbs`. - **Бэкенд готов**, серверных изменений нет. Два запроса: 1. `POST /pages/breadcrumbs` (`page.controller.ts:804` → `getPageBreadCrumbs`, `page.service.ts:1072`) — recursive CTE вверх, возвращает цепочку **включая саму страницу**, после `reverse()` — от корня к странице. В MCP: последний элемент → поле `page`, остальное → `breadcrumbs` (проверить порядок в тесте, не полагаться на память). 2. Прямые дети — `listSidebarPages(spaceId, pageId)` (ПОСЛЕ курсорного фикса B из #442): элементы уже несут `hasChildren` и порядок по `position`. - Вход может быть slugId (агенты копируют из URL) — прогнать через существующий `resolvePageId` (`client.ts:674`), как делают остальные инструменты. - Согласованность с `get_tree`: те же id (UUID), те же названия, тот же порядок детей — обеспечивается общими эндпоинтами, закрепить интеграционным тестом. - Регистрация + `SERVER_INSTRUCTIONS` — как у остальных. ## Приёмка - Для страницы 3-го уровня: `breadcrumbs` = два предка от корня; для корневой — `[]`. - `children` совпадают по составу/порядку с соответствующим срезом `get_tree`. - Страница с >20 детьми возвращает всех детей (курсорная пагинация, без дублей) — regression на класс бага из #442. - Несуществующий/недоступный pageId → внятная ошибка, не пустой объект. --- # Общие требования к реализации - Все комментарии в коде — на английском. - Каждый новый/изменённый инструмент: обновить `SERVER_INSTRUCTIONS` в `packages/mcp/src/index.ts` (иначе упадёт `test/unit/server-instructions.test.mjs`); свериться с паттерном `tool-specs.ts` для in-app/transport-расхождений — in-app-поиск (hybrid RRF) не трогаем. - Никаких изменений write-путей; изменения серверного `/search` — строго opt-in, поведение веб-клиента без новых флагов неизменно. - Порядок PR: (1) search; (2) get_tree (+ deprecated-алиас `list_pages tree:true`, координация с #442); (3) get_page_context.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#443