fix(mcp): невалидный/недоступный spaceId — внятная ошибка вместо «Space permissions not found» #534

Open
opened 2026-07-12 02:02:16 +03:00 by agent_vscode · 0 comments
Collaborator

Проблема / Цель

MCP-инструменты, принимающие spaceId (getTree, listPages, checkNewComments, search), при передаче валидного по форме, но несуществующего или недоступного spaceId отдают агенту:
POST /pages/sidebar-pages failed (404 Not Found): Space permissions not found.
Формулировка вводит в заблуждение: в типичном случае причина — опечатка/устаревший id (UUID правильной формы, но такого спейса у пользователя нет), а текст говорит «нет прав», будто спейс существует и доступ отобрали. Агент по такому сообщению не может себя починить — не понимает, что надо взять правильный id из listSpaces.

Репортед-кейс: getTree с id …59e81b3b8863 (одна hex-цифра мимо реального …59e81b3c8863) → это самое 404-сообщение.

«Готово» = на битый/недоступный spaceId инструмент возвращает сообщение вида «такого spaceId среди доступных тебе нет; вот валидные id; возьми из listSpaces».

Контекст и исследование

Развилка «в каком слое чинить» решена человеком → вариант A: только обёртка packages/mcp (бэкенд space-ability.factory не трогаем; форк Docmost не дивергируем).

  • Источник текста (бэкенд, НЕ меняем): apps/server/src/core/casl/abilities/space-ability.factory.ts:20-37getUserSpaceRoles(user, spaceId); если ролей нет → default: throw new NotFoundException('Space permissions not found'). Для несуществующего spaceId ролей тоже нет → тот же путь. Бэкенд физически не различает «спейса нет» и «есть, но не участник».
  • Проброс в обёртке: единый диагностический интерцептор packages/mcp/src/client/context.ts:312-318client/errors.ts formatDocmostAxiosError (#437/#450) дословно проксирует backend-текст как <METHOD> <path> failed (<status> <statusText>): <serverMessage>. Интерцептор синхронный и без this — обогатить из него нельзя, поэтому обогащаем на уровне методов клиента.
  • Прецедент fail-fast с actionable-текстом: assertFullUuid (client/errors.ts:35, #436) — но он проверяет форму UUID; наш …b8863 формально валиден, проверки формы недостаточно.
  • Оракул доступности: список /spaces (getSpacespaginateAll('/spaces'), client/read.ts:96). Проверено по бэкенду: /spaces (getUserSpaceIdsQuery, space-member.repo.ts) и space-ability.factory (getUserSpaceRoles) оба берут объединение прямого + группового членства → «spaceId отсутствует в /spaces» ⇔ «space-scoped вызов 404-ит по членству». Оракул согласован.
  • Инвариант single-key-404 (проверено, load-bearing): среди обёрнутых тулов 404 приходит ТОЛЬКО из проверки членства по spaceId. Их pageId/rootPageId/parentPageId-ветки возвращают 403 (page.controller.ts:566 sidebar, :608 tree) или 200 — не 404. Поэтому на каждом enrich-eligible 404 виноват именно spaceId.
    • getTree/checkNewComments идут через enumerateSpacePages (read.ts:327): primary /pages/tree 404 проглатывается как «эндпоинт отсутствует» → BFS-fallback → seeding listSidebarPages (read.ts:377, не обёрнут в try/catch) снова бьёт createForUser(user, spaceId) (page.controller.ts:572) → 404 пробрасывается. Ровно это в транскрипте.
  • createPage ИСКЛЮЧЁН: его сервер 404-ит по parent-mismatch (page.controller.ts:220, 'Parent page not found') до проверки прав (:227) — 404 мульти-причинный, обогащение соврало бы. (Клиент pages.ts:78 ещё и пред-валидирует parentPageId через getPage и бросает обычный Error — тоже не наш axios-404.)
  • formatDocmostAxiosError мутирует только .message, сохраняя axios.isAxiosError и error.response.status (коммент errors.ts:110-119) → 404 после форматирования детектируется по статусу, без привязки к строке «Space permissions not found» (R3: не создаём хрупкое зеркало backend-текста).
  • Per-instance кэши уже есть (pageIdCache, collabTokenCache в context.ts), с инвалидацией в login() — тем же паттерном кэшируем список спейсов.

Дизайн

Механизм — обогащение ошибки на 404 (enrich-on-404) на уровне методов клиента. Happy-path не трогается: при валидном spaceId дополнительных запросов нет.

1. Индекс доступных спейсов (на базе DocmostClientContext):
getAccessibleSpaceIndex(): Promise<{ ids: Set<string>; spaces: {id:string; name:string}[]; complete: boolean }>

  • Внутри — новый paginateAllWithMeta('/spaces'), возвращающий {items, truncated} (сейчас paginateAll глотает truncated, только warn'ит — нужен вариант/overload, отдающий флаг наружу; getSpaces() НЕ меняем, чтобы не ломать выхлоп тула listSpaces). spaces = items.map(filterSpace)filterSpace есть id и name, filters.ts:19). complete = !truncated.
  • Кэш (per-instance) с коротким TTL: поле на базе; TTL по умолчанию 60 000 мс, читается из process.env.MCP_SPACES_CACHE_TTL_MS (паттерн readCollabTokenTtlMs, context.ts:114); 0 = кэш выключен. Инвалидация в login() и при сбросе this.token (как collabTokenCache, context.ts:281,414). Кэшируется только результат с complete:true.
  • Single-flight (как loginPromise, context.ts:394): ДВА разных поля — in-flight Promise и persistent cachedIndex. Инвариант (критично): in-flight зануляется в .finally в т.ч. при reject (никогда не мемоизировать отклонённый/settled промис — иначе сетевой блип /spaces отравит фичу до конца сессии); cachedIndex пишется ТОЛЬКО из резолвнутого fetch.

2. Обёртка-диагностика (на базе):
withSpaceAccessDiagnostics<T>(spaceId: string, mcpName: string, fn: () => Promise<T>): Promise<T>

try { return await fn(); }
catch (e) {
  // abort/cap имеет приоритет над 404: детект по ФЛАГУ сигнала, не по типу reason
  if (this.toolAbortSignal?.aborted) throw e;              // Stop / wall-clock cap → пробросить как есть
  if (!(axios.isAxiosError(e) && e.response?.status === 404)) throw e;
  let idx;
  try { idx = await this.getAccessibleSpaceIndex(); }
  catch (fetchErr) {
    if (this.toolAbortSignal?.aborted) throw fetchErr;     // прервано во время fetch
    if (process.env.DEBUG) console.error("space-diag: /spaces fetch failed:", fetchErr);
    throw e;                                               // fail-open: исходная серверная ошибка
  }
  if (!idx.complete) throw e;                              // список неполон → не утверждаем «нет»
  if (idx.ids.has(spaceId)) throw e;                       // spaceId доступен → 404 не про него
  throw new Error(formatSpaceNotAccessible(mcpName, spaceId, idx.spaces));
}

Обернуть тела клиентских методов: getTree, listPages (и recent-with-spaceId, и tree-mode), checkNewComments, search (когда spaceId задан). НЕ оборачивать: createPage (мульти-причина), getPageContext (принимает pageId).

Abort-детект (важно, hole B): прерывание определяем по this.toolAbortSignal?.aborted === true, НЕ по e.name === 'AbortError' — cap может быть AbortSignal.timeout() (reason = TimeoutError) или кастомный reason, имя не гарантировано (context.ts:184-190,650).

Текст (FACT про spaceId, не причина сбоя):
<mcpName>: spaceId "<id>" не найден среди доступных тебе спейсов. Доступные: <id1> (<name1>), <id2> (<name2>), … — скопируй нужный id дословно из listSpaces.

  • Перечислять ≤ 10 спейсов (policy), дальше (+N ещё, см. listSpaces).
  • 0 доступных → <mcpName>: spaceId "<id>" недоступен, и доступных тебе спейсов нет — проверь доступ токена / вызови listSpaces.
  • Формулировка «не найден среди доступных» истинна на каждом достижении ветки (spaceId свежо подтверждён отсутствующим), поэтому заменяем серверный текст, а не дополняем. Load-bearing инвариант single-key-404 запинить комментом в коде у allowlist (если кто-то добавит NotFoundException в /pages/tree|recent|sidebar-pages или /search — вернётся misattribution).

Где живёт канон (R3): единственный источник доступности — /spaces. Backend-строку не парсим/не дублируем; детект по HTTP-404.

Семантика отказов (R4): диагностика — ХИНТ, не контроль доступа; авторитет — бэкенд. Обёртка fail-open на любой неопределённости (fetch упал / !complete / spaceId в списке / abort) → отдаёт исходную серверную ошибку без изменений. Реальные ошибки никогда не подавляются.

Policy-константы (R7): (a) лимит перечисления = 10 (компактность; полный список — listSpaces); (b) TTL кэша спейсов = 60 000 мс, env MCP_SPACES_CACHE_TTL_MS, 0=выкл (<< длины сессии; ограничивает и устаревание хинта, и нагрузку на /spaces; это лишь текст ошибки, не безопасность — сервер остаётся авторитетным).

Границы scope

  • НЕ трогаем бэкенд (space-ability.factory.ts как есть) — вариант A.
  • НЕ различаем «спейса нет» vs «есть, но нет прав» (обёртка видит только доступные — схлопываем). Различать → отдельная backend-ишья (вариант B), вне scope.
  • НЕ оборачиваем createPage (мульти-причинный 404) и getPageContext (pageId). Внятная ошибка на битый spaceId в createPage — возможная отдельная ишья.
  • НЕ валидируем parentPageId/pageId/rootPageId — только spaceId.
  • /search, вероятно, на битый spaceId отдаёт 200+пусто, а не 404 → там обёртка чаще инертна; это ок (fail-open).
  • Никаких изменений выхлопа getSpaces()/listSpaces.

Критерии приёмки

Проверяемо юнит-тестом со стаб-клиентом (как test/unit/error-diagnostics.test.mjs) и/или на стенде:

  1. getTree с валидным по форме, но несуществующим spaceId (реальный id с заменой одной hex-цифры) → ошибка тула, в тексте: переданный spaceId, ≥1 валидный id (name), слово listSpaces; и нет подстроки Space permissions not found.
  2. getTree с валидным доступным spaceId → дерево как раньше (регрессии нет); на happy-path не выполняется запрос /spaces (спай на client.post).
  3. Второй подряд getTree с тем же битым spaceId в пределах TTL → повторного /spaces fetch нет (кэш); по истечении TTL — один рефетч.
  4. listPages/search без spaceId → поведение не изменилось (обёртка не срабатывает).
  5. !complete (обрезанный /spaces, застабать truncated=true) на 404 → исходная серверная ошибка (fail-open), без ложного «не найден».
  6. getAccessibleSpaceIndex бросает (сеть /spaces) во время обогащения → исходная серверная ошибка; при DEBUG — строка в stderr. In-flight промис после reject занулён (следующий вызов делает свежий fetch, не переиспользует отклонённый).
  7. 0 доступных спейсов (/spaces[]) + 404 → спец-сообщение «доступных тебе спейсов нет».
  8. toolAbortSignal.aborted===true во время обогащения → пробрасывается abort/cap-reason, а не переписанный/исходный 404 (reason сигнала — TimeoutError или кастомный, не AbortError).
  9. createPage с недоступным spaceId → серверная ошибка как есть (не обёрнут).

Отказы и наблюдаемость

  • Провал обогащения (fetch /spaces упал / !complete) → агент/оператор видит РОВНО исходную серверную ошибку (текущее поведение); под DEBUG — stderr-строка (как errors.ts:138). Ничего не маскируется.
  • Необратимых действий нет; audit trail не затрагивается.
  • Rollback: чистое дополнение на пути ошибки; TTL можно выставить 0 (env), полный откат = revert коммита.

Совместимость и миграции

  • Изменение поведения: ТОЛЬКО текст ошибки на битый/недоступный spaceId (был …404: Space permissions not found, стал внятный). Успешные вызовы и любые другие ошибки — без изменений. Дефолты поведения не меняются.
  • Риск: код, программно матчащий строку Space permissions not found от MCP, увидит новый текст. Оценка: низкий (диагностическое сообщение для агента, не контракт; голден-тесты этой строки не матчат). Отметить в PR.
  • Новый env-флаг MCP_SPACES_CACHE_TTL_MS (необязательный, дефолт 60 000). Миграций нет.

Решённые развилки

  • В каком слое чинить → A: обёртка packages/mcp — решил человек.
  • Различать «нет спейса» vs «нет прав» → нет, схлопываем — следствие A.
  • fail-fast-до-вызова vs enrich-on-404 → enrich-on-404 (нулевая цена happy-path).
  • Детект 404 по статусу vs по строке backend → по статусу (не зеркалим backend-текст, R3).
  • Устаревание кэша: forced-refetch vs TTL → короткий TTL (60s, env) — forced-refetch усиливает нагрузку на /spaces на горячем error-пути (циклящий агент × N); ограниченное окно устаревания хинта приемлемо (подтвердили критики).
  • Replace vs append серверного текста → replace + FACT-формулировка + пин-инвариант комментом — прямо убирает вводящий в заблуждение текст; FACT-текст истинен даже при дрейфе инварианта.
  • createPageисключён (мульти-причинный 404).

Связи

  • Наследует диагностический слой #437/#450 и паттерн fail-fast #436 (те же client/errors.ts, client/context.ts).
  • Возможные будущие ишьи (вне scope, не блокируют): backend вариант B (честный 404 vs 403 в space-ability.factory); внятная ошибка на битый spaceId для createPage.

Дизайн прошёл adversarial self-attack (3 критика: hostile-scenarios / implementer-dry-run / failure-ops) + раунд переатаки. Закрыто: мнимая «dead code getTree», thundering-herd на /spaces, stale-кэш → ложное «не найден», мульти-причинный 404 createPage, blocking-пробел с truncated, abort-детект по имени reason.

## Проблема / Цель MCP-инструменты, принимающие `spaceId` (`getTree`, `listPages`, `checkNewComments`, `search`), при передаче **валидного по форме, но несуществующего или недоступного** `spaceId` отдают агенту: `POST /pages/sidebar-pages failed (404 Not Found): Space permissions not found`. Формулировка вводит в заблуждение: в типичном случае причина — опечатка/устаревший id (UUID правильной формы, но такого спейса у пользователя нет), а текст говорит «нет прав», будто спейс существует и доступ отобрали. Агент по такому сообщению не может себя починить — не понимает, что надо взять правильный id из `listSpaces`. Репортед-кейс: `getTree` с id `…59e81b3b8863` (одна hex-цифра мимо реального `…59e81b3c8863`) → это самое 404-сообщение. «Готово» = на битый/недоступный `spaceId` инструмент возвращает сообщение вида «такого spaceId среди доступных тебе нет; вот валидные id; возьми из listSpaces». ## Контекст и исследование Развилка «в каком слое чинить» решена человеком → **вариант A: только обёртка `packages/mcp`** (бэкенд `space-ability.factory` не трогаем; форк Docmost не дивергируем). - **Источник текста (бэкенд, НЕ меняем):** `apps/server/src/core/casl/abilities/space-ability.factory.ts:20-37` — `getUserSpaceRoles(user, spaceId)`; если ролей нет → `default: throw new NotFoundException('Space permissions not found')`. Для несуществующего spaceId ролей тоже нет → тот же путь. Бэкенд физически не различает «спейса нет» и «есть, но не участник». - **Проброс в обёртке:** единый диагностический интерцептор `packages/mcp/src/client/context.ts:312-318` → `client/errors.ts` `formatDocmostAxiosError` (#437/#450) дословно проксирует backend-текст как `<METHOD> <path> failed (<status> <statusText>): <serverMessage>`. Интерцептор **синхронный и без `this`** — обогатить из него нельзя, поэтому обогащаем на уровне методов клиента. - **Прецедент fail-fast с actionable-текстом:** `assertFullUuid` (`client/errors.ts:35`, #436) — но он проверяет *форму* UUID; наш `…b8863` формально валиден, проверки формы недостаточно. - **Оракул доступности:** список `/spaces` (`getSpaces` → `paginateAll('/spaces')`, `client/read.ts:96`). Проверено по бэкенду: `/spaces` (`getUserSpaceIdsQuery`, `space-member.repo.ts`) и `space-ability.factory` (`getUserSpaceRoles`) оба берут объединение прямого + группового членства → **«spaceId отсутствует в /spaces» ⇔ «space-scoped вызов 404-ит по членству»**. Оракул согласован. - **Инвариант single-key-404 (проверено, load-bearing):** среди обёрнутых тулов 404 приходит ТОЛЬКО из проверки членства по `spaceId`. Их `pageId`/`rootPageId`/`parentPageId`-ветки возвращают **403** (`page.controller.ts:566` sidebar, `:608` tree) или 200 — не 404. Поэтому на каждом enrich-eligible 404 виноват именно `spaceId`. - `getTree`/`checkNewComments` идут через `enumerateSpacePages` (`read.ts:327`): primary `/pages/tree` 404 проглатывается как «эндпоинт отсутствует» → BFS-fallback → seeding `listSidebarPages` (`read.ts:377`, **не** обёрнут в try/catch) снова бьёт `createForUser(user, spaceId)` (`page.controller.ts:572`) → 404 **пробрасывается**. Ровно это в транскрипте. - **`createPage` ИСКЛЮЧЁН:** его сервер 404-ит по parent-mismatch (`page.controller.ts:220`, `'Parent page not found'`) **до** проверки прав (`:227`) — 404 мульти-причинный, обогащение соврало бы. (Клиент `pages.ts:78` ещё и пред-валидирует parentPageId через getPage и бросает обычный Error — тоже не наш axios-404.) - **`formatDocmostAxiosError` мутирует только `.message`**, сохраняя `axios.isAxiosError` и `error.response.status` (коммент `errors.ts:110-119`) → 404 после форматирования детектируется по статусу, **без** привязки к строке «Space permissions not found» (R3: не создаём хрупкое зеркало backend-текста). - Per-instance кэши уже есть (`pageIdCache`, `collabTokenCache` в `context.ts`), с инвалидацией в `login()` — тем же паттерном кэшируем список спейсов. ## Дизайн Механизм — **обогащение ошибки на 404** (enrich-on-404) на уровне методов клиента. Happy-path не трогается: при валидном spaceId дополнительных запросов нет. **1. Индекс доступных спейсов (на базе `DocmostClientContext`):** `getAccessibleSpaceIndex(): Promise<{ ids: Set<string>; spaces: {id:string; name:string}[]; complete: boolean }>` - Внутри — новый `paginateAllWithMeta('/spaces')`, возвращающий `{items, truncated}` (сейчас `paginateAll` глотает `truncated`, только warn'ит — нужен вариант/overload, отдающий флаг наружу; `getSpaces()` НЕ меняем, чтобы не ломать выхлоп тула `listSpaces`). `spaces` = `items.map(filterSpace)` (у `filterSpace` есть `id` и `name`, `filters.ts:19`). `complete = !truncated`. - **Кэш (per-instance) с коротким TTL:** поле на базе; TTL по умолчанию **60 000 мс**, читается из `process.env.MCP_SPACES_CACHE_TTL_MS` (паттерн `readCollabTokenTtlMs`, `context.ts:114`); `0` = кэш выключен. Инвалидация в `login()` и при сбросе `this.token` (как `collabTokenCache`, `context.ts:281,414`). Кэшируется только результат с `complete:true`. - **Single-flight** (как `loginPromise`, `context.ts:394`): ДВА разных поля — in-flight `Promise` и persistent `cachedIndex`. **Инвариант (критично):** in-flight зануляется в `.finally` **в т.ч. при reject** (никогда не мемоизировать отклонённый/settled промис — иначе сетевой блип `/spaces` отравит фичу до конца сессии); `cachedIndex` пишется ТОЛЬКО из резолвнутого fetch. **2. Обёртка-диагностика (на базе):** `withSpaceAccessDiagnostics<T>(spaceId: string, mcpName: string, fn: () => Promise<T>): Promise<T>` ``` try { return await fn(); } catch (e) { // abort/cap имеет приоритет над 404: детект по ФЛАГУ сигнала, не по типу reason if (this.toolAbortSignal?.aborted) throw e; // Stop / wall-clock cap → пробросить как есть if (!(axios.isAxiosError(e) && e.response?.status === 404)) throw e; let idx; try { idx = await this.getAccessibleSpaceIndex(); } catch (fetchErr) { if (this.toolAbortSignal?.aborted) throw fetchErr; // прервано во время fetch if (process.env.DEBUG) console.error("space-diag: /spaces fetch failed:", fetchErr); throw e; // fail-open: исходная серверная ошибка } if (!idx.complete) throw e; // список неполон → не утверждаем «нет» if (idx.ids.has(spaceId)) throw e; // spaceId доступен → 404 не про него throw new Error(formatSpaceNotAccessible(mcpName, spaceId, idx.spaces)); } ``` Обернуть тела клиентских методов: `getTree`, `listPages` (и recent-with-spaceId, и tree-mode), `checkNewComments`, `search` (когда `spaceId` задан). **НЕ** оборачивать: `createPage` (мульти-причина), `getPageContext` (принимает pageId). **Abort-детект (важно, hole B):** прерывание определяем по `this.toolAbortSignal?.aborted === true`, НЕ по `e.name === 'AbortError'` — cap может быть `AbortSignal.timeout()` (reason = `TimeoutError`) или кастомный reason, имя не гарантировано (`context.ts:184-190,650`). **Текст (FACT про spaceId, не причина сбоя):** `<mcpName>: spaceId "<id>" не найден среди доступных тебе спейсов. Доступные: <id1> (<name1>), <id2> (<name2>), … — скопируй нужный id дословно из listSpaces.` - Перечислять ≤ **10** спейсов (policy), дальше `(+N ещё, см. listSpaces)`. - 0 доступных → `<mcpName>: spaceId "<id>" недоступен, и доступных тебе спейсов нет — проверь доступ токена / вызови listSpaces.` - Формулировка «не найден среди доступных» истинна на каждом достижении ветки (spaceId свежо подтверждён отсутствующим), поэтому **заменяем** серверный текст, а не дополняем. Load-bearing инвариант single-key-404 запинить **комментом в коде** у allowlist (если кто-то добавит `NotFoundException` в `/pages/tree|recent|sidebar-pages` или `/search` — вернётся misattribution). **Где живёт канон (R3):** единственный источник доступности — `/spaces`. Backend-строку не парсим/не дублируем; детект по HTTP-404. **Семантика отказов (R4):** диагностика — ХИНТ, не контроль доступа; авторитет — бэкенд. Обёртка fail-open на любой неопределённости (fetch упал / `!complete` / spaceId в списке / abort) → отдаёт исходную серверную ошибку без изменений. Реальные ошибки никогда не подавляются. **Policy-константы (R7):** (a) лимит перечисления = 10 (компактность; полный список — listSpaces); (b) TTL кэша спейсов = 60 000 мс, env `MCP_SPACES_CACHE_TTL_MS`, `0`=выкл (<< длины сессии; ограничивает и устаревание хинта, и нагрузку на `/spaces`; это лишь текст ошибки, не безопасность — сервер остаётся авторитетным). ## Границы scope - НЕ трогаем бэкенд (`space-ability.factory.ts` как есть) — вариант A. - НЕ различаем «спейса нет» vs «есть, но нет прав» (обёртка видит только доступные — схлопываем). Различать → отдельная backend-ишья (вариант B), вне scope. - НЕ оборачиваем `createPage` (мульти-причинный 404) и `getPageContext` (pageId). Внятная ошибка на битый spaceId в `createPage` — возможная отдельная ишья. - НЕ валидируем `parentPageId`/`pageId`/`rootPageId` — только `spaceId`. - `/search`, вероятно, на битый spaceId отдаёт 200+пусто, а не 404 → там обёртка чаще инертна; это ок (fail-open). - Никаких изменений выхлопа `getSpaces()`/`listSpaces`. ## Критерии приёмки Проверяемо юнит-тестом со стаб-клиентом (как `test/unit/error-diagnostics.test.mjs`) и/или на стенде: 1. `getTree` с валидным по форме, но несуществующим `spaceId` (реальный id с заменой одной hex-цифры) → ошибка тула, в тексте: переданный spaceId, ≥1 валидный `id (name)`, слово `listSpaces`; и **нет** подстроки `Space permissions not found`. 2. `getTree` с валидным доступным `spaceId` → дерево как раньше (регрессии нет); на happy-path **не** выполняется запрос `/spaces` (спай на `client.post`). 3. Второй подряд `getTree` с тем же битым `spaceId` в пределах TTL → повторного `/spaces` fetch **нет** (кэш); по истечении TTL — один рефетч. 4. `listPages`/`search` **без** `spaceId` → поведение не изменилось (обёртка не срабатывает). 5. `!complete` (обрезанный `/spaces`, застабать `truncated=true`) на 404 → исходная серверная ошибка (fail-open), без ложного «не найден». 6. `getAccessibleSpaceIndex` бросает (сеть `/spaces`) во время обогащения → исходная серверная ошибка; при `DEBUG` — строка в stderr. In-flight промис после reject занулён (следующий вызов делает свежий fetch, не переиспользует отклонённый). 7. 0 доступных спейсов (`/spaces` → `[]`) + 404 → спец-сообщение «доступных тебе спейсов нет». 8. `toolAbortSignal.aborted===true` во время обогащения → пробрасывается abort/cap-reason, а не переписанный/исходный 404 (reason сигнала — `TimeoutError` или кастомный, не `AbortError`). 9. `createPage` с недоступным `spaceId` → серверная ошибка как есть (не обёрнут). ## Отказы и наблюдаемость - Провал обогащения (fetch `/spaces` упал / `!complete`) → агент/оператор видит РОВНО исходную серверную ошибку (текущее поведение); под `DEBUG` — stderr-строка (как `errors.ts:138`). Ничего не маскируется. - Необратимых действий нет; audit trail не затрагивается. - Rollback: чистое дополнение на пути ошибки; TTL можно выставить `0` (env), полный откат = revert коммита. ## Совместимость и миграции - Изменение поведения: ТОЛЬКО текст ошибки на битый/недоступный spaceId (был `…404: Space permissions not found`, стал внятный). Успешные вызовы и любые другие ошибки — без изменений. Дефолты поведения не меняются. - Риск: код, программно матчащий строку `Space permissions not found` от MCP, увидит новый текст. Оценка: низкий (диагностическое сообщение для агента, не контракт; голден-тесты этой строки не матчат). Отметить в PR. - Новый env-флаг `MCP_SPACES_CACHE_TTL_MS` (необязательный, дефолт 60 000). Миграций нет. ## Решённые развилки - В каком слое чинить → **A: обёртка `packages/mcp`** — решил человек. - Различать «нет спейса» vs «нет прав» → **нет, схлопываем** — следствие A. - fail-fast-до-вызова vs enrich-on-404 → **enrich-on-404** (нулевая цена happy-path). - Детект 404 по статусу vs по строке backend → **по статусу** (не зеркалим backend-текст, R3). - Устаревание кэша: forced-refetch vs TTL → **короткий TTL (60s, env)** — forced-refetch усиливает нагрузку на `/spaces` на горячем error-пути (циклящий агент × N); ограниченное окно устаревания хинта приемлемо (подтвердили критики). - Replace vs append серверного текста → **replace + FACT-формулировка + пин-инвариант комментом** — прямо убирает вводящий в заблуждение текст; FACT-текст истинен даже при дрейфе инварианта. - `createPage` → **исключён** (мульти-причинный 404). ## Связи - Наследует диагностический слой #437/#450 и паттерн fail-fast #436 (те же `client/errors.ts`, `client/context.ts`). - Возможные будущие ишьи (вне scope, не блокируют): backend вариант B (честный 404 vs 403 в `space-ability.factory`); внятная ошибка на битый spaceId для `createPage`. <sub>Дизайн прошёл adversarial self-attack (3 критика: hostile-scenarios / implementer-dry-run / failure-ops) + раунд переатаки. Закрыто: мнимая «dead code getTree», thundering-herd на `/spaces`, stale-кэш → ложное «не найден», мульти-причинный 404 createPage, blocking-пробел с `truncated`, abort-детект по имени reason.</sub>
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#534