feat(mcp): три read-инструмента для агентского lookup — search (подстрока/path/сниппет/scope), get_tree (одним запросом), get_page_context #443
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?
Цель
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— доработка существующего инструментаПроблема
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*;englishвtsvсам по-своему режетbackup-srv.localи10.0.12.5, так что префиксный матч частей не совпадает с ожиданием «подстрока».traefikв разных ветках.ts_headlineсMinWords=9, MaxWords=10слишком короткий — после поиска всё равно нуженget_page.Контракт инструмента (MCP)
Архитектурные решения
Серверная часть (
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 в один флаг режима — на усмотрение, но веб-клиент без флагов должен получать байт-в-байт текущее поведение).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.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нет. Добавить миграцию: GINgin_trgm_opsпоf_unaccent(lower(title)); поtext_content— на усмотрение после замера (инстансы маленькие, seq scan внутри спейса может быть ок; решение зафиксировать в PR).MCP-часть:
packages/mcp/src/index.ts:703–727— расширить schema инструмента (spaceId,parentPageId,titleOnly,limit1–50, default 10) и описание; обновитьSERVER_INSTRUCTIONS(:52–58) — это enforced тестомtest/unit/server-instructions.test.mjs.packages/mcp/src/client.ts:2195search()— прокинуть новые параметры + флаг нового режима; маппер результата (filterSearchResult) переделать под выходной контракт{pageId, title, path, snippet, score}.whitelist: true— поиск деградирует до текущего поведения. Это приемлемо, задокументировать в описании инструмента не нужно, только комментарием в коде.Приёмка (интеграционные тесты на реальной схеме БД)
srv.localbackup-srv.local10.0.1210.0.12.5WB-MGE-30D86BWB-MGE-30D86B(цифробуквенный токен с дефисами)Теги: DockerТеги: DockerПлюс: каждый хит содержит непустой
path(кроме корневых —[]) иsnippet;parentPageIdотсекает чужие ветки;titleOnlyне матчит по тексту; пустая выдача —[], не ошибка; выдача веб-UI (без новых флагов) не изменилась; запрос%или_не матчит всё подряд (экранирование).2.
get_tree— новый инструментПроблема
Единственный способ получить иерархию —
list_pages tree:true: BFS по sidebar-эндпоинту, десятки–сотни HTTP-вызовов и молчаливая потеря страниц (детали и root cause — #442: на спейсе ~300 страниц теряется ~20).Контракт
Архитектурные решения
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"— ровно как в сайдбаре. Серверных изменений для этого инструмента НЕ требуется./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./pages/treeнет): при 404/405 — курсорный BFS из #442 (B+D). Не реализовывать здесь второй раз — общий код с #442.Приёмка
get_treeвозвращает все неудалённые страницы, каждая ровно один раз; число узлов = числу неудалённых страниц спейса (включая 20 теряемых сейчас — список в #442)./pages/tree(без запросов на каждый узел).get_tree(spaceId, rootPageId=X)эквивалентен поддереву X из полного дерева.maxDepth=1возвращает корни сhasChildrenбез внуков.position).tree.ts), обрезкаmaxDepth+ расстановкаhasChildren, orphan-узлы (родитель отфильтрован пермишенами) поднимаются в корни, а не исчезают.3.
get_page_context— новый инструмент (низкий приоритет)Проблема
Есть
pageId(из поиска, из ссылки) — нужно понять «где я и что вокруг». Сейчас этоget_page+ полное дерево.Контракт
Архитектурные решения
breadcrumbs.POST /pages/breadcrumbs(page.controller.ts:804→getPageBreadCrumbs,page.service.ts:1072) — recursive CTE вверх, возвращает цепочку включая саму страницу, послеreverse()— от корня к странице. В MCP: последний элемент → полеpage, остальное →breadcrumbs(проверить порядок в тесте, не полагаться на память).listSidebarPages(spaceId, pageId)(ПОСЛЕ курсорного фикса B из #442): элементы уже несутhasChildrenи порядок поposition.resolvePageId(client.ts:674), как делают остальные инструменты.get_tree: те же id (UUID), те же названия, тот же порядок детей — обеспечивается общими эндпоинтами, закрепить интеграционным тестом.SERVER_INSTRUCTIONS— как у остальных.Приёмка
breadcrumbs= два предка от корня; для корневой —[].childrenсовпадают по составу/порядку с соответствующим срезомget_tree.Общие требования к реализации
SERVER_INSTRUCTIONSвpackages/mcp/src/index.ts(иначе упадётtest/unit/server-instructions.test.mjs); свериться с паттерномtool-specs.tsдля in-app/transport-расхождений — in-app-поиск (hybrid RRF) не трогаем./search— строго opt-in, поведение веб-клиента без новых флагов неизменно.list_pages tree:true, координация с #442); (3) get_page_context.