7.8 KiB
Дублирование определений инструментов: in-app агент vs standalone MCP-пакет
Статус: зафиксировано в беклоге, код не менялся. Это forward-looking стоимость поддержки, НЕ баг — код корректен сегодня. Фиксируем, чтобы при росте набора инструментов (см. §16) долг не разъезжался молча.
Суть
Один и тот же набор инструментов поверх одного DocmostClient описан
тремя независимыми рукописными слоями. Каждое добавление инструмента или
правка его model-facing описания требует синхронной правки в 2–3 местах, а
parity-баги (расхождение копий) приходится чинить/переоткрывать дважды.
Где дублируется (три слоя)
- Standalone MCP-сервер —
packages/mcp/src/index.ts(~38registerTool). Для внешних MCP-клиентов (stdio/http). На каждый инструмент: zod-схема + длинное model-facing описание + тонкийexecute, вызывающийDocmostClient. - Встроенный AI-чат —
apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts(~39tool({...})черезai-SDK). Своя zod-схема + своё описание + свойexecuteповерх ТОГО ЖЕ клиента (@docmost/mcpгрузится вtools/docmost-client.loader.ts:188через динамическийimport()). - Ручная копия сигнатур — интерфейс
DocmostClientLikeвapps/server/src/core/ai-chat/tools/docmost-client.loader.ts:9(в комментарии прямо: «Signatures here mirror that file exactly»), скопирован руками изpackages/mcp/src/client.ts.
Что именно продублировано (с подтверждением по коду)
- zod-схема + описание каждого инструмента — в слоях 1 и 2 целиком.
- Квирк «node как объект ИЛИ JSON-строка» реализован дважды (НЕ в общем
клиенте):
- in-app:
ai-chat-tools.service.ts:686(patchNode),:745(insertNode),:800(updatePageJson); - standalone:
index.ts:526(patch_node),:578(insert_node),:350(update_page_json).
- in-app:
- Guardrail/семантика
transformPage(dryRun) описана в обоих:ai-chat-tools.service.ts:~935иindex.ts:~1006.
Почему разделение слоёв 1 и 2 само по себе оправдано
У путей разный транспорт и auth-контекст, и это правильно держать раздельно:
in-app путь чеканит per-user JWT + provenance collab-токен (подписанная
agent-claim, docmost-client.loader.ts:159 — getCollabToken; см. план §6.5),
а standalone обслуживает внешних клиентов по stdio/http. Но это оправдывает
два тонких адаптера (execute + auth-обвязка), а НЕ две рукописные копии
МЕТАДАННЫХ (схема + описание + квирки). Метаданные можно объявить один раз и
переиспользовать обоими транспортами.
Доказательство стоимости (наблюдалось при фиксе edit_page_text)
При исправлении ложного «успеха» edit_page_text (refuse форматных правок +
verify-отчёт):
- Поведение легло в общий
DocmostClient→ автоматически дошло до обоих агентов ОДНОЙ правкой. Это «хороший» случай — логика в едином источнике. - Описание инструмента пришлось править ДВАЖДЫ: в
index.ts(кодером) и отдельно вai-chat-tools.service.ts:617, где описание продолжало рекламировать «Markdown wrappers tolerated via strip-and-retry» — ровно ту формулировку, что ввела исходного агента в заблуждение. Копия молча разъехалась и какое-то время встроенный агент получал устаревшую подсказку. Это и есть материализованный parity-баг.
Варианты фикса (выбрать при реализации)
- A. Единый реестр спеков (полное устранение дублирования). Вынести в
packages/mcpодин источник на инструмент:name+ zod-схема + model-facing описание + общий хелпер нормализации node-строки (для patch/insert/update). Иindex.ts, иai-chat-tools.service.tsимпортируют спеки и добавляют только свойexecute/auth.DocmostClientLike— выводить из типа реального клиента (type-only import / генерация), а не копировать руками.- Ограничение:
@docmost/mcp— ESM-only, сервер грузит его через трюкnew Function('import(specifier)')(docmost-client.loader.ts:174), потому чтоmodule:commonjsдаунлевелитimport()вrequire(). Реестр спеков (данные + zod) должен пересекать ту же ESM/CJS-границу — выполнимо тем же динамическим импортом;ai-SDKtool()и MCPregisterTool()имеют разную форму, поэтому реестр экспортирует транспорт-агностичные{name, schema, description}, а каждая сторона оборачивает их сама.zod— общая зависимость обоих пакетов, типы переносятся.
- Ограничение:
- B. Минимально — общий источник описаний + node-хелпер. Свести в один
модуль только длинные model-facing описания (то, что реально разъезжается и
уже дало баг) и хелпер нормализации node-строки; zod-схемы и
executeоставить раздельными. Меньше риска и проще через ESM-границу (описания — просто строки), закрывает основной симптом (дрейф описаний), но не убирает дубль схем.
Рекомендация: B как дешёвый первый шаг (убирает дрейф описаний — главный наблюдавшийся вред), A — когда набор инструментов начнёт активно расти (§16) и дубль схем/квирков станет ощутимым.
Процесс
- Реализация — режим делегирования (по умолчанию): рефакторинг через два пакета
(packages/mcp + apps/server) → general-purpose кодеру, затем обязательный
прогон
review. Прогнатьpackages/mcpunit-тесты и серверные spec'и (ai-chat-tools.service.spec.ts). - Не коммитить; в конце предложить сообщение коммита.