Skip to Content
ДизайнHot ReloadПроект MCP Runtime Hot-Reload: обновление на основе настроек с инкрементальным переподключением (подзадача 3 задачи #3696)

Проект MCP Runtime Hot-Reload: обновление на основе настроек с инкрементальным переподключением (подзадача 3 задачи #3696)

Note: исходная область подзадачи 3 — «MCP/LSP» переподключение во время выполнения; этот MR включает только MCP. Для LSP оставлен только набросок + TODO в части C, отложенный до следующего MR.

Контекст

Задача #3696 — общая задача для системы горячей перезагрузки. Подзадача 1 (SettingsWatcher обнаружение изменений файлов) слита, но пока не имеет подписчикаgemini.tsx:784 запускает наблюдатель, а проект подзадачи 1 явно оставляет подключение слушателей подзадачам 2–6. Сегодня добавление/удаление/редактирование MCP-сервера в settings.json (или установка расширения) требует перезапуска всей сессии, с потерей контекста разговора.

Этот MR сосредоточен на MCP и предоставляет две вещи: (a) точку входа во время выполнения, которая передает обновленные настройки в активный Config; (b) инкрементальное переподключение MCP, управляемое SettingsWatcher. Переподключение LSP во время выполнения относится к этой подзадаче, но не реализовано здесь, оставлен только TODO в части C.

Ключевое наблюдение: инкрементальное согласование «по разнице» уже существует в коде (для одной сессии discoverAllMcpToolsIncremental, для общего пула runDiscoverAllMcpToolsViaPool, затрагивающее только измененные серверы по их отпечатку connectionIdOf). Единственный пробел — Config не может обновить свой снимок настроек после запуска (addMcpServers() выбрасывает исключение, config.ts:3200). Добавление этой точки входа во время выполнения — часть A; вызов её из наблюдателя — часть B — это и есть весь MR. Два чётких компромисса: повторное использование существующего инкрементального согласования вместо полного сброса restartMcpServers() (который вызывает разрыв «0 инструментов»); а для пути общего пула необходимо добавить шлюз одобрения isMcpServerPendingApproval, чтобы соответствовать пути одной сессии (пункт 4 части A). См. «Архитектура» ниже для обзора компонентов и «Проект» для пошагового потока и деталей.


Архитектура

В одной фразе: подключить уже существующее инкрементальное согласование к изменениям файла настроек, и заполнить границу доверия и обратную связь в UI. Изменение разделено по ответственности между пакетами CLI / Core, связанными через методы Config и одно UI-событие:

Пакет CLI Пакет Core ┌──────────────────────────────────────────┐ ┌────────────────────────────────────┐ │ SettingsWatcher (подзадача 1, слит) │ │ Config │ │ └─[Часть B] hot-reload.ts │ вызывает│ └─[Часть A] reinitializeMcpServers │ │ когда запускать · пересчёт гейта · гейт│ ────▶ │ setMcpServers + инкр. согласование│ │ │ │ (McpClientManager пул/одиночн.)│ │ └─[Часть D] useMcpApproval · модал одобрения │ ◀──── │ └─[Часть A④] гейт ожидания для пути пула │ │ во время сессии ожидающий → повторный запрос│ событие │ │ │ └─[Часть E] просмотр /mcp │ └────────────────────────────────────┘ │ показать причину «пропущено из-за одобрения» │ └──────────────────────────────────────────┘
  • Принцип слоёв: ядро не должно знать о settings.json / семантике наблюдателя. «Когда запускать» принадлежит CLI (часть B), «как обновить + согласовать» — Core (часть A), что согласуется с подзадачей 1; часть B — единственный потребитель части A, взаимодействуя только через методы Config.
  • Основной путь: изменение настроек → часть B перестраивает желаемый список + списки гейтов, дебаунсный гейт → вызывает часть A → инкрементальное согласование Core (включая шлюз одобрения для пути пула) → испускает mcp-client-update для обновления индикаторов состояния.
  • Ветвь одобрения: если после согласования сервер остаётся в состоянии pending, часть D запускает модал одобрения через событие McpPendingApprovalChanged; причина пропуска отображается частью E в представлении /mcp.
  • Жёсткое предусловие: три ключа схемы mcpServers / mcp.allowed / mcp.excluded должны быть переведены в режим горячей перезагрузки, иначе шлюз подавления требования перезапуска наблюдателя поглощает изменения только MCP, и вся цепочка становится бездействующей (см. предупреждение ⚠️ в начале «Проекта»).
ЧастьОтветственностьСлойСтатус
AОбновляемая во время выполнения конфигурация MCP в Config + инкрементальное согласование + шлюз одобрения для пути пулаCoreэтот MR
BПодписка наблюдателя, пересчёт гейта, дебаунсный гейт, вызов части ACLIэтот MR
CРеинициализация LSPCoreTODO (позже)
DОжидающие во время сессии запускают модал одобрения (и исправляют пропущенное приглашение #6)CLIв следующих
E/mcp показывает причину «пропущено из-за одобрения»CLIв следующих
FСемантика допуска: белый список CLI — верхняя граница, mcp.allowed: [] = запретить всё, а инструмент не найден объясняет почему сервер недоступенCLI + Coreв следующих

«Проект» ниже даёт пошаговый поток данных от файла на диске до активного соединения, а также детали реализации каждой части.


Проект

Диаграмма ниже представляет полный поток данных одного изменения настроек от «файла на диске» до «соединение вступает в силу» ([CLI] = часть B, [Core] = часть A, [подзадача 1] = слитый наблюдатель):

① Пользователь редактирует .qwen/settings.json (добавляет/удаляет/редактирует mcpServers, или mcp.excluded / mcp.allowed) ② [подзадача 1] SettingsWatcher обнаруживает изменение файла │ · Дебаунс 300 мс: объединение последовательных сохранений │ · Семантическая разница всего файла: уведомлять только если содержимое действительно изменилось (самозапись / чистое форматирование → нет уведомления) ③ [CLI · Часть B] вызывается колбэк, зарегистрированный registerMcpHotReload (до него доходит любое изменение настроек) ├─ a. assembleMcpServers(settings.merged.mcpServers, cwd, topTier) │ → слияние по приоритету в полный список серверов `next` (вкл. .mcp.json / --mcp-config / сессия) ├─ b. пересчёт списков гейтов соединений nextGating = { excluded, allowed, pending } └─ c. гейт: mcpServersEqual(old, next) И mcpGatingEqual(old, nextGating) оба «не изменились» → ранний возврат (игнорировать изменения темы / навыков и другие, не связанные с MCP) │ (продолжить только если изменились mcpServers ИЛИ списки гейтов mcp ↓) ④ [CLI→Core] сначала передать списки гейтов в config (обнаружение читает их во время согласования): config.setExcludedMcpServers / setAllowedMcpServers / setPendingMcpServers ⑤ [Core · Часть A] config.reinitializeMcpServers(next) │ (обёрнуто защитой «согласование в процессе» для избежания гонки с /reload) ├─ a. setMcpServers(next): замена снимка настроечного слоя (слой расширений / времени выполнения не трогается) └─ b. discoverAllMcpToolsIncremental: согласование в стиле инкрементального примирения · Вычислить отпечаток connectionIdOf каждого сервера, сравнить «желаемый» с «активным» · добавлен → подключить; удалён → отключить + удалить инструменты/приглашения; отпечаток изменился → отключить + удалить старые инструменты/приглашения, затем переподключить с новыми настройками; не изменился → оставить · пропустить отключённые / ожидающие / недоверенные каталоги; испустить mcp-client-update ⑥ [CLI · Часть B] завершение UI: mcp-client-update обновляет индикаторы состояния MCP; (опционально) изменились приглашения MCP → reloadCommands(); установить needsRefresh (подзадача 6)

Время срабатывания: registerMcpHotReload выполняется только один раз при запуске (прикрепляет слушатель, возвращает освобождатель); колбэк, который он регистрирует, срабатывает при каждом изменении настроек через наблюдателя (т.е. начиная с шага ③) — именно тогда выполняется согласование.

⚠️ Жёсткое предусловие: три ключа схемы MCP должны быть переведены в режим горячей перезагрузки (скрытый переключатель на шаге ②). У наблюдателя есть «шлюз подавления требования перезапуска»: если все ключи, затронутые изменением, имеют requiresRestart: true, то событие не испускается. Но mcpServers / mcp.allowed / mcp.excluded были все true — поэтому изменение только MCP никогда не вызывает колбэк и часть B бездействует. Этот MR обязательно должен установить эти три листа в false; родительский узел mcp и стартовый только mcp.serverCommand остаются true (сопоставление использует isRestartRequiredKey с совпадением по самому длинному префиксу + flattenSchema, выигрывает лист). Все три имеют showInDialog: false, поэтому изменение не меняет приглашение перезапуска в диалоге настроек; радиус поражения — только путь наблюдателя.

Далее по порядку описаны часть A (возможности Core), часть B (проводка CLI), часть C (LSP, только TODO в этом MR).

Часть A — Core: сделать Config обновляемым во время выполнения для конфигурации MCP и запустить инкрементальное согласование

Файл: packages/core/src/config/config.ts

  1. Добавить сеттер после инициализации, который обновляет снимок настроек, читаемый согласованием:

    /** * Замена во время выполнения (горячей перезагрузки) карты MCP-серверов настроечного слоя. * В отличие от addMcpServers(), обходит защиту `initialized` и является ЗАМЕНОЙ * (не слиянием), поэтому удаления вступают в силу. Наложение времени выполнения * (addRuntimeMcpServer) и вклады расширений не затрагиваются — getMcpServers() * по-прежнему наслаивается поверх него. */ setMcpServers(servers: Record<string, MCPServerConfig> | undefined): void { this.mcpServers = servers; }

    getMcpServers() (:3128) уже наслаивает расширения + runtimeMcpServers поверх this.mcpServers, поэтому замена только настроечного слоя безопасна для записей времени выполнения/расширений.

  2. Списки гейтов соединений: три списка имён, которые определяют, может ли каждый MCP-сервер подключаться — excluded (заблокирован), allowed (если задан, подключаются только эти), pending (гейтовый источник, требуется одобрение пользователя перед подключением). Они отделены от mcpServers (конфигурация сервера): первые определяют «подключаться ли», вторые — «какие серверы и как». Добавить сеттеры для этих трёх списков, которые консультируют getMcpServers() / обнаружение: setExcludedMcpServers() существует (:3167); добавить setAllowedMcpServers() (поле сейчас readonly и используется как фильтр внутри getMcpServers()) плюс сеттер для набора ожидающих одобрения.

  3. Добавить лёгкий оркестровочный метод: сначала обновить конфиг, затем запустить существующее инкрементальное согласование, обёрнутое общей защитой «согласование в процессе», чтобы /reload (подзадача 5) и наблюдатель не гонялись:

    /** * Применить новую карту MCP-серверов настроечного слоя и инкрементально согласовать активные соединения * (подключить добавленные, отключить удалённые, перезапустить изменённые; неизменные оставить нетронутыми). * Вызов до initialize() — безопасная пустая операция. */ async reinitializeMcpServers(servers: Record<string, MCPServerConfig> | undefined): Promise<void> { this.setMcpServers(servers); const registry = this.getToolRegistry(); await registry.getMcpClientManager().discoverAllMcpToolsIncremental(this); }

    discoverAllMcpToolsIncremental уже проверяет isTrustedFolder(), обрабатывает отключённые/SDK серверы и испускает mcp-client-update для обновления индикаторов состояния UI. Удалённый сервер → освободить + удалить инструменты/приглашения; изменённый отпечаток → освободить + перезахватить; неизменный → оставить.

  4. Добавить проверку ожидания одобрения к пути общего пула (граница доверия, обязательно в этом MR): путь одной сессии пропускает серверы, ожидающие одобрения, но когда существует общий пул, discoverAllMcpToolsIncremental делегирует runDiscoverAllMcpToolsViaPool, и путь пула пропускает только отключённые / SDK, а не isMcpServerPendingApproval (около mcp-client-manager.ts:1461). Без этого исправления, в режиме демона / общего пула горячая перезагрузка, которая добавляет/редактирует гейтовый .mcp.json / сервер рабочей области, получит соединение пула и запустит процесс до одобрения пользователем, обходя шлюз одобрения #4615. Исправление: добавить проверку isMcpServerPendingApproval в пути пула до построения desiredIds и до acquire, сделав его семантику допуска соответствующей пути одной сессии.

Часть B — CLI: подписка SettingsWatcher → согласование MCP

Новый файл: packages/cli/src/config/hot-reload.ts, подключён после settingsWatcher.startWatching() (:785) в gemini.tsx.

export function registerMcpHotReload( watcher: SettingsWatcher, settings: LoadedSettings, config: Config, topTierMcpServers: Record<string, MCPServerConfig> | undefined, ): () => void { return watcher.addChangeListener(async (events) => { // Перестроить точно так же, как загрузка Config — включая верхний уровень (CLI/сессия) источники. const next = assembleMcpServers( settings.merged.mcpServers, config.getTargetDir(), topTierMcpServers, ); // Пересчитать списки гейтов (excluded/allowed/pending) — [настройки во время горячей перезагрузки выигрывают], // см. решение «позиция допуска» ниже; pending всегда пересчитывается в соответствии с гейтом #4615. const nextGating = { excluded: recomputeExcluded(settings, next), allowed: recomputeAllowed(settings, next), pending: recomputePending(settings, next), }; // гейт: согласовывать только если изменились mcpServers ИЛИ списки гейтов mcp; // если оба не изменились, ранний возврат (игнорировать изменения темы / навыков и другие, не связанные с MCP). const serversChanged = !mcpServersEqual( config.getSettingsMcpServers(), next, ); const gatingChanged = !mcpGatingEqual(config.getMcpGating(), nextGating); if (!serversChanged && !gatingChanged) return; // Передать списки гейтов в config до согласования (обнаружение внутри reinitializeMcpServers читает их). config.setExcludedMcpServers(nextGating.excluded); config.setAllowedMcpServers(nextGating.allowed); config.setPendingMcpServers(nextGating.pending); await config.reinitializeMcpServers(next); // Уведомить UI: изменились приглашения MCP → reloadCommands(); установить needsRefresh (подзадача 6). }); }

Решение по позиции допуска (намеренно): горячая перезагрузка делает текущие настройки выигрывающими в рамках стартовой границы --allowed-mcp-server-names — изменение во время выполнения mcp.allowed / mcp.excluded в settings.json вступает в силу немедленно, но только сужает допуск, никогда не расширяет его за пределы флага запуска (см. часть F для правила верхней границы и семантики mcp.allowed: []). Если флаг --allowed-mcp-server-names не был передан, настройки полностью управляют допуском. Гейт ожидания одобрения (#4615) никогда не уступает независимо: гейтовый сервер всегда должен быть сначала одобрен (пункт 4 части A).

История: более ранняя ревизия позволяла изменению настроек во время выполнения расширять допуск за пределы стартового флага (рассматривая флаг как простое удобство фильтрации по имени). Экспертный обзор указал, что это молчаливое ослабление границы на момент запуска; часть F (пункт K) отменяет это — флаг теперь является неизменной верхней границей.

Повторно использовать существующие хелперы — не реализовывать логику слияния заново:

  • assembleMcpServers(settings.mcpServers, cwd, topTierMcpServers)packages/cli/src/config/mcpServers.ts:27 (соответствует вызову загрузки Config в packages/cli/src/config/config.ts:1812).
  • SettingsWatcher.addChangeListener возвращает функцию отписки (settingsWatcher.ts:253).
  • config.getSettingsMcpServers() (:3124) как предварительное состояние для разницы mcpServers; config.getMcpGating() как предварительное состояние для разницы списков гейтов (небольшой новый геттер, возвращающий { excluded, allowed, pending }, в паре с сеттерами части A).

Гейт использует две небольшие чистые функции для сужения поверхности срабатывания (избегать запуска согласования при изменениях темы / навыков и других нерелевантных изменениях, что согласуется с собственной семантической разницей наблюдателя), обе использующие fast-deep-equal (пакет cli должен повысить его с транзитивной до прямой зависимости):

  • mcpServersEqual(a, b): порядок ключей объекта не важен (устраняет ложные срабатывания от порядка серверов / полей), порядок массивов важен (args и другой порядок аргументов команды имеет значение); undefined{}.
  • mcpGatingEqual(a, b): excluded / allowed / pending сравниваются как множества (сначала отсортировать копии); undefined[]. Именно это позволяет «редактировать только mcp.excluded / mcp.allowed, не трогая mcpServers» всё равно запускать согласование — закрывает пробел, где сравнение только mcpServers пропустило бы изменения гейтов.

UI-завершение обновляет индикаторы состояния через существующее событие mcp-client-update, устанавливая needsRefresh при необходимости (подзадача 6). Минимум для этой подзадачи: согласование на уровне конфига завершено + существующий испуск обновляет состояние.

Часть C — Реинициализация LSP (не реализована в этом MR, TODO)

Конфигурация LSP поступает из .lsp.json + конфигурации расширения (не settings.json), поэтому она не запускается автоматически SettingsWatcher; её переподключение во время выполнения должно управляться вручную позднее командой /reload (подзадача 5). NativeLspService (защищённый флагом --experimental-lsp) уже имеет методы жизненного цикла discoverAndPrepare / start / stop, достаточные для реализации примитива reinitialize(), доступного /reload через LspClient.reinitialize?() + Config.reinitializeLsp(), без крупных изменений.

TODO (следующий MR): реализовать NativeLspService.reinitialize() и его раскрытие через Config.reinitializeLsp(), с детальным проектом в документации того MR (включая то, что discoverAndPrepare() сначала вызывает clearServerHandles(), предотвращая инкрементальную разницу, поэтому v1 использует остановить все → запустить все, и т.д.). Этот MR не содержит изменений кода LSP.

Часть D — В следующих: горячая перезагрузка запускает модал одобрения для гейтовых серверов (взаимодействует с #4615)

Этот раздел был добавлен после того, как части A/B были приняты, во время отладки «изменил URL гейтового сервера, но он не переподключается». Он исправляет ошибку, когда «горячая перезагрузка помечает гейтовый сервер как ожидающий, но UI не показывает модал одобрения», и попутно исправляет пропущенное приглашение, вызванное логикой принятия решения (проблема #6 ниже).

Предыстория: модал одобрения вычислялся только один раз при запуске

Сервер из гейтового источника (.mcp.json проекта и .qwen/settings.json рабочей области, см. isGatedMcpScope) имеет одобрение пользователя привязанное к хэшу конфигурации (mcpApprovals.ts’s getState: нет записи, или запись, чей хэш отличается от текущей конфигурации → pending). Поэтому, если горячая перезагрузка изменяет конфигурацию гейтового сервера (даже просто httpUrl), изменение хэша аннулирует старое одобрение, и он снова становится pending.

Цепочка частей A/B обрабатывает это корректно: recomputeMcpGating помещает его в pending, setPendingMcpServers передаёт его обнаружению, и согласование пропускает его (нет подключения, состояние disconnected). Но UI не показывает модал одобрения — коренная причина в том, что useMcpApproval (хук, управляющий модалом одобрения) вычисляет свою очередь только при монтировании через useEffect(…, [config]), и ссылка config стабильна в течение сессии → эффект никогда не запускается повторно. То есть:

  • ядро помечает сервер как ожидающий (обнаружение пропускает его) ✓
  • очередь одобрения UI никогда не пересчитывается → нет модала ✗ (пользователь видит только disconnected, без возможности одобрить) Два пути не связаны во время выполнения.

Исправление: соединение ядра → UI через событие, передача решения интерфейсу

  1. Добавить событие AppEvent.McpPendingApprovalChanged (packages/cli/src/utils/events.ts). Поскольку appEvents находится на уровне CLI и hot-reload.ts тоже, слушатель может отправлять напрямую, без изменений в ядре.

  2. hot-reload.ts отправляет после reconcile (размещено после await reinitializeMcpServers, чтобы config.getMcpServers() уже отражал новую карту; отправлять независимо от успеха/неудачи reconcile — сервер, оставшийся в состоянии pending, всё равно требует решения пользователя).

  3. useMcpApproval извлекает computePending(): вычислить один раз при монтировании (существующее поведение) плюс пересчитать очередь после подписки на McpPendingApprovalChanged → непустая очередь показывает модальное окно. computePending пересчитывает из авторитетных источников (живая карта серверов + файл сохранённых утверждений), поэтому уже утверждённые / уже отклонённые серверы не вызывают повторного запроса.

Ключевое решение: отправлять на основе “строгого pending”, а не разности множеств имён (issue #6 / решение A1)

Обратите внимание, что два предиката намеренно различны, что является сутью этого раздела:

ФункцияПредикатИспользование
getPendingGatedMcpServersstate !== 'approved' (включает rejected)питает discovery: rejected должен продолжать пропускаться
getPromptableMcpServers (новая)state === 'pending' (исключает rejected)питает модальное окно: rejected больше не беспокоит

Первоначальное решение об отправке использовало “разность множества имён nextGating.pending по сравнению с предыдущим разом”, чтобы решить, показывать ли модальное окно, что приводило к пропущенному запросу (см. issue #6):

  • отклонённый сервер остаётся в списке pending из-за !== 'approved';
  • затем пользователь снова редактирует конфигурацию того же сервера (хэш меняется → он снова становится pending и должен быть перезапрошен), но его имя уже “было в” списке → разность множеств пуста → нет события → пропущенный запрос.

Исправление A1: использовать getPromptableMcpServers(next, cwd) (строгое === 'pending') для решения об отправке, передавая истинность решения computePending. Результат:

  • после отклонения, редактирование конфигурации того же сервера (хэш меняется) → снова pendingповторный запрос ✓ (исправляет #6)
  • после отклонения, не связанное редактирование (хэш не меняется) → всё ещё rejected → не запрашивается → нет запроса
  • уже approved → нет запроса; новый нерешённый gated-сервер → запрос ✓

Семантика reject (подтверждена после ревью)

handleMcpApprovalSelect(REJECT): сохраняет rejected (привязан к текущему хэшу), не вызывает reconnect, не трогает config.pendingMcpServers → discovery продолжает пропускать → сервер остаётся disconnected. Нет необходимости активно разрывать старое соединение: отправка происходит после await reinitializeMcpServers, поэтому к моменту появления модального окна reconcile уже разорвал его. После перезапуска сессии computePending читает rejected → не ставится в очередь, остаётся отключённым, поведение согласовано.

Дополнение к потоку данных (продолжение после ⑥ на схеме обзора главы)

⑥' [CLI · Часть D] после reconcile, если существует строго ожидающий gated-сервер: hot-reload → appEvents.emit(McpPendingApprovalChanged) → useMcpApproval.computePending() пересчитывает очередь → показывает модальное окно утверждения → пользователь утверждает: approveMcpServerForSession + discoverToolsForServer (подключение с новой конфигурацией) пользователь отклоняет: сохранить rejected, остаться отключённым

Ключевые файлы (Часть D)

ФайлИзменение
packages/cli/src/utils/events.tsдобавить AppEvent.McpPendingApprovalChanged
packages/cli/src/config/mcpApprovals.tsдобавить getPromptableMcpServers() (строгое === 'pending', отличается от включающего rejected getPendingGatedMcpServers)
packages/cli/src/config/hot-reload.tsпосле reconcile, решение через getPromptableMcpServers; если не пусто, appEvents.emit(McpPendingApprovalChanged)
packages/cli/src/ui/hooks/useMcpApproval.tsизвлечь computePending(); вычислить один раз при монтировании + пересчитать по событию

Верификация (Часть D)

  • hot-reload.test.ts: новый gated-сервер в состоянии pending → отправка; изменение, не связанное с gated-серверами → нет отправки; отклонение→редактирование конфигурации → отправка снова (старая разность множеств имён дала бы 0 раз, зафиксировав регрессию #6); отклонение→не связанное редактирование → нет отправки.
  • mcpApprovals.test.ts: набор тестов getPromptableMcpServers — отсутствие решения не запрашивается, rejected не запрашивается (в отличие от getPendingGatedMcpServers, который всё ещё пропускает), повторный запрос после изменения хэша, approved не запрашивается.
  • useMcpApproval.test.ts: событие в середине сессии показывает модальное окно для нового gated-сервера; уже утверждённый сервер не запрашивается повторно.

Известная проблема / TODO на будущее (НЕ обработано здесь)

  1. Несоответствие ключей getTargetDir() и getWorkingDir() (риск B): пересчёт gating (recomputeMcpGatinggetPendingGatedMcpServers) использует config.getTargetDir() как projectRoot, в то время как useMcpApproval читает/записывает утверждение, используя config.getWorkingDir(). Обычно они равны; как только они расходятся (пользовательский cwd или различия в realpath симлинков), утверждение записывается под ключом cwd, а gating запрашивает ключ targetDir → после утверждения gating всё ещё пропускает и никогда не подключается. Существующая проблема, не внесённая Частью D. Рекомендуется унифицировать корень (склоняться к getWorkingDir(), т.е. стороне записи утверждения), или сначала добавить утверждение, что они равны во время выполнения.

Часть E — Доработка: показывать в /mcp, почему gated-сервер пропущен для утверждения

Этот раздел был добавлен после внедрения Части D, при отладке “после отклонения gated-сервера, затем его удаления и повторного добавления с теми же параметрами, /mcp показывает Disconnected без пояснений”. Вывод: это не ошибка жизненного цикла записи; единственный дефект — причина пропуска невидима, поэтому мы только добавляем видимость и не трогаем логику хранения утверждений / reconcile.

Почему “больше не запрашивать” — это ожидаемое поведение

Запись утверждения привязана к (projectRoot, serverName, hash) и не зависит от того, присутствует ли сервер в данный момент в конфигурации — ничто не удаляет запись, когда сервер исчезает из конфигурации. Следовательно:

  • утверждённое уже сохраняется при удалении/повторном добавлении: утвердить (hash H) → удалить → добавить снова идентично (всё ещё hash H) → getState возвращает approved → тихое переподключение. Преднамеренное удобство.
  • отклонённое, соответствующее этому состоянию отклонения при том же “идентичном повторном добавлении”, симметрично и согласовано: подтверждённое отклонение остаётся в силе, пока хэш конфигурации не изменён; единственный способ снова его отобразить — изменить конфигурацию (изменить хэш) (т.е. путь повторного запроса строго pending из getPromptableMcpServers в Части D).

Поэтому мы намеренно не вводим “забыть запись при удалении”: это позволило бы переходам присутствия изменять постоянные решения, нарушая принцип, что решения изменяются только через хэш или явное действие, и создавая асимметрию утверждённых/отклонённых.

Фактический дефект и исправление (только видимость)

/mcp (ServerListStep / ServerDetailStep) отображал просто Disconnected, что делало “я отклонил его / ожидается утверждение” неотличимым от “настоящей ошибки подключения”, поэтому пользователь не знал пути восстановления (изменить конфигурацию для смены хэша → повторный запрос). Исправление: добавить approvalState?: 'pending' | 'rejected' в MCPServerDisplayInfo, вычисляется в MCPManagementDialog.fetchServerData с помощью loadMcpApprovals + isGatedMcpScope, ключ — config.getWorkingDir() (оставлено пустым для не-gated / утверждённых); представление списка / деталей, используя существующий шаблон переопределения needsAuth, показывает причину первой (rejected → "rejected — измените конфигурацию для повторного утверждения", pending → "требуется утверждение", жёлтое предупреждение) и исключает эти пропуски из-за утверждения из подсказки “см. журналы ошибок” в нижнем колонтитуле.

Использование getWorkingDir() на стороне записи здесь — это именно то направление, которое рекомендовано Частью D, “Известная проблема 1 (риск B)” — читать и записывать утверждение с одним и тем же корнем. Существующий запрос gating в hot-reload.ts по-прежнему использует getTargetDir() (сегодня они равны); этот раздел не меняет его поведение. Он не трогает хранилище mcpApprovals.ts, путь удаления/переподключения hot-reload.ts и не добавляет никаких действий с утверждением.

Ключевые файлы (Часть E)

ФайлИзменение
packages/cli/src/ui/components/mcp/types.tsMCPServerDisplayInfo добавляет approvalState?: 'pending' | 'rejected'
packages/cli/src/ui/components/mcp/MCPManagementDialog.tsxfetchServerData вычисляет approvalState, ключ — getWorkingDir()
packages/cli/src/ui/components/mcp/steps/ServerListStep.tsxотображать причину утверждения; исключать пропуски из-за утверждения из подсказки “см. журналы ошибок”
packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsxотображать причину утверждения (согласованно со списком)

Верификация (Часть E)

  • ServerListStep.test.tsx: gated rejected → показывает текст подсказки о повторном утверждении; pending → “требуется утверждение”; пропуск из-за утверждения не показывает подсказку “см. журналы ошибок”, в то время как действительно неудачное подключение всё ещё показывает.
  • Вручную: отклонить сервер рабочего пространства → /mcp показывает причину (не просто Disconnected) → изменить его конфигурацию для смены хэша → модальное окно Части D появляется снова (существующий путь восстановления, не изменён здесь).

Часть F — Доработка: семантика допуска (верхняя граница CLI, deny-all, причины недоступности)

Добавлено после третьего ревью с adversarial-подходом к Частям A/B. Три связанных уточнения допуска, сгруппированы, так как они затрагивают одну поверхность “какие серверы могут подключаться, и как мы объясняем, когда какой-то не может”. Элементы помечены K / H / B в соответствии с их обсуждениями в ревью.

K — флаг запуска --allowed-mcp-server-names является неизменяемой верхней границей

Отменяет предыдущую позицию “настройки всегда побеждают” (см. примечание Части B). При загрузке loadCliConfig даёт флагу приоритет над settings.mcp.allowed; но пересчёт hot-reload читал allowed только из настроек, поэтому любое изменение настроек молча отбрасывало ограничение по имени, установленное при запуске — ослабляя, в рамках сессии, границу, которую оператор установил специально для ограничения, какие локальные MCP команды могут выполняться.

Исправление: захватить только значение флага как неизменяемую границу в Config (параметр cliAllowedMcpServerNamesgetCliAllowedMcpServerNames(); отличается от изменяемого allowedMcpServers, которое перезаписывается hot-reload). Затем recomputeMcpGating ограничивает список разрешённых, полученный из настроек, этой границей:

  • флаг передан + настройки имеют mcp.allowedпересечение (настройки могут сужать в рамках границы);
  • флаг передан + нет настроек mcp.allowedфлаг целиком;
  • нет флага → настройки полностью управляют допуском (без изменений).

Таким образом, изменение во время выполнения может только сузить допуск MCP ниже флага запуска, но никогда не расширить его за пределы. mcp.excluded по-прежнему сужает дополнительно на этапе discovery, согласованно с принципом “только строже, никогда не мягче”.

H — mcp.allowed: [] означает deny-all, согласованно при запуске и hot-reload

Загрузка обрабатывает пустой список разрешённых как deny-all (getMcpServers() фильтрует, когда allowedMcpServers истинно, а [] истинно). Пересчёт hot-reload ранее преобразовывал []undefined (“разрешить всё”) — так что редактирование mcp.allowed до [] в ожидании deny-all оставляло все серверы доступными. Исправление: recomputeMcpGating сохраняет [] (только отсутствующий ключ даёт undefined), а mcpGatingEqual различает отсутствующий (разрешить всё) и [] (deny-all) для allowed — иначе изменение считалось бы равным и никогда не вызывало бы reconcile. excluded / pending сохраняют undefined ≡ [] (оба “нет записей”).

B — tool-not-found объясняет, почему сервер недоступен

getMcpToolUnavailableMessage ранее различал только “удалён в этой сессии” vs “не настроен”. С учётом gating по допуску он теперь классифицирует владеющий сервер через единый API ядра, Config.getMcpServerUnavailableReason(name), охватывающий все шлюзы:

причиназначениедействие по восстановлению, предлагаемое в сообщении
removedудалён из объединённой конфигурации в этой сессииповторно добавьте его в настройки
not_allowedотфильтрован mcp.allowed / границей CLIдобавьте его в mcp.allowed
excludedуказан в mcp.excludedудалите его из mcp.excluded
pending_approvalgated-сервер ожидает утверждения (#4615)утвердите его (выполните /mcp)
(нет)настроен и допущеннастоящий “инструмент не найден” (отключён / переименован)

Два вспомогательных изменения: приватный getMergedMcpServers() (объединение без фильтрации списком разрешённых), чтобы “настроен” можно было отличить от “отфильтрован”; и отслеживание удалений теперь сравнивает эту независимую от gating объединённую карту, что означает, что сервер, отфильтрованный суженным списком разрешённых, больше не сообщается неверно как removed (он not_allowed). Это также позволяет удалить параметр prevEffectiveServerNames, добавленный для более раннего исправления сужения списка разрешённых — сравнение объединённой карты не затрагивается установщиками gating, которые вызывающий применяет непосредственно перед reconcile.

Ключевые файлы (Часть F)

ФайлИзменение
packages/cli/src/config/config.ts (loadCliConfig)передать значение флага --allowed-mcp-server-names отдельно как cliAllowedMcpServerNames
packages/core/src/config/config.tsполе cliAllowedMcpServerNames + getCliAllowedMcpServerNames() (K); getMergedMcpServers() (нефильтрованная) + getMcpServerNames(); McpServerUnavailableReason + getMcpServerUnavailableReason() (B); отслеживание удалений сравнивает объединённую карту и reinitializeMcpServers убирает параметр prevEffectiveServerNames
packages/cli/src/config/hot-reload.tsrecomputeMcpGating ограничивает allowed границей запуска (K) и сохраняет [] (H); mcpGatingEqual делает отсутствующий allowed[] (H)
packages/core/src/core/coreToolScheduler.tsgetMcpToolUnavailableMessage направляет по getMcpServerUnavailableReason (B)

Верификация (Часть F)

  • hot-reload.test.ts: K — с флагом запуска и без списка разрешённых в настройках, применяет флаг целиком; список разрешённых из настроек ограничивается флагом (не может расширить) и может сужать внутри него; без флага настройки побеждают без ограничений. Hmcp.allowed: [] передаётся как deny-all; mcpGatingEqual считает отсутствующий allowed и [] разными (но excluded undefined ≡ []).
  • config.test.ts: getMcpServerUnavailableReason возвращает not_allowed / excluded / pending_approval / removed для каждого шлюза и undefined для настроенного и допущенного или никогда не настроенного сервера.
  • coreToolScheduler.test.ts: сообщение об отсутствующем инструменте называет правильный сервер и действие по восстановлению в зависимости от причины.

Вне рамок (другие подзадачи)

  • Всё переподключение LSP во время выполнения (NativeLspService.reinitialize() + Config.reinitializeLsp() + связывание) — отложено до более позднего MR, см. TODO Части C.
  • Команда /reload (#5) — вызывает config.reinitializeMcpServers(currentSettings) (часть LSP будет подключена, когда её примитив появится в более позднем MR) + перезагрузка навыков/команд.
  • clearAllCaches() (#4) и уведомление UI needsRefresh (#6).

Ключевые файлы

ФайлИзменение
packages/core/src/config/config.tssetMcpServers(), setAllowedMcpServers() + сеттер для pending, getMcpGating() (возвращает { excluded, allowed, pending }), reinitializeMcpServers() (с защитой от повторного входа)
packages/core/src/tools/mcp-client-manager.ts① добавить removePromptsByServer() в removeServer() и removeRuntimeMcpServer(); ② в общем пуле runDiscoverAllMcpToolsViaPool (:1461), добавить проверку isMcpServerPendingApproval перед построением desiredIds / перед acquire (соответствует допуску в отдельных сессиях); ③ добавить сравнение отпечатков в отдельный сессионный путь: новая карта connectionFingerprints; discoverAllMcpToolsIncremental также запускает отключение+переподключение для сервера, который “подключён, но его отпечаток connectionIdOf изменился” (согласованно с путем пула desiredIds), очищая карту при каждом завершении; ④ очищать старые инструменты/подсказки перед переподключением: когда discoverMcpToolsForServerInternal заменяет существующий клиент, выполнить removeMcpToolsByServer + removePromptsByServer перед повторным discovery — потому что disconnect() не трогает реестр, а discover() только добавляет/перезаписывает по имени, иначе инструменты, удалённые/переименованные при изменении конфигурации, оставались бы привязанными к закрытому клиенту (и сохранялись бы при неудачном discovery), соответствуя существующей очистке в removeServer / addRuntimeMcpServer
packages/cli/src/config/settingsSchema.tsпредварительное условие: переключить три ключа mcpServers (:274), mcp.allowed, mcp.excluded с requiresRestart: true на false, чтобы наблюдатель больше не подавлял изменения, относящиеся только к MCP; родительский mcp и mcp.serverCommand остаются true (см. примечание “Жёсткое предварительное условие” выше)
packages/cli/src/config/hot-reload.ts (новый)registerMcpHotReload(): перестроить через assembleMcpServers(..., topTierMcpServers); пересчитать списки gating из текущих настроек (см. “решение о позиции допуска”); применить gating через mcpServersEqual + mcpGatingEqual (построено на fast-deep-equal); дебаунс + объединение и повторная проверка
packages/cli/package.jsonпродвинуть fast-deep-equal из транзитивной в прямую зависимость
packages/cli/src/gemini.tsxвызвать registerMcpHotReload после :785; зарегистрировать освободитель
Тесты (параллельно с переключением схемы)settingsSchema.test.ts фиксирует значения requiresRestart трёх ключей MCP (включая то, что mcp / mcp.serverCommand остаются true); settingsWatcher.test.ts добавляет две положительные регрессии (“редактирование только mcpServers / только mcp.excluded → всё ещё уведомлять”); settingsUtils.test.ts использует свою собственную макетную схему, не связанную с реальным переключением, изменений не требуется

Файлы, связанные с LSP (NativeLspService.ts / NativeLspClient.ts / lsp/types.ts), не изменялись в этом MR, см. TODO части C.

Верификация

A. Модульные тесты основных возможностей (core, config.test.ts / mcp-client-manager.test.ts)

  1. setMcpServers выполняет замену (не слияние) и вступает в силу после инициализации (больше не выбрасывает исключение через защиту initialized).
  2. reinitializeMcpServers сначала вызывает setMcpServers, а затем discoverAllMcpToolsIncremental; вызов до initialize() является безопасной пустой операцией (без исключений, без подключения).
  3. Утвердить, что removeServer() / removeRuntimeMcpServer() теперь вызывают removePromptsByServer() (защита от регрессии утечки подсказок). Использовать фикстуры mcp-client-manager.test.ts (которые уже импортируют connectionIdOf). 3b. Различие отпечатка в рамках одной сессии: создать mock-клиент, у которого getStatus() всегда возвращает CONNECTED, выполнить discoverAllMcpToolsIncremental три раза — первый вызов фиксирует отпечаток; повторный вызов с той же конфигурацией не вызывает пересоздания (connect остаётся 1×); изменение args на месте (отпечаток меняется) → отключение+подключение (disconnect 1×, connect 2×). Гарантирует, что путь одной сессии больше не пропускает ситуацию “подключено, но конфиг изменился” как пустую операцию (согласовано с desiredIds общего пула). Также утвердить, что этот вызов перед повторным обнаруживанием вызывает removeMcpToolsByServer + removePromptsByServer для этого сервера — защита “очистить старые инструменты/подсказки перед переподключением”, предотвращая сохранение инструментов, удалённых или переименованных при изменении конфига.

A’. Интеграционная защита watcher↔схема (cli, settingsSchema.test.ts / settingsWatcher.test.ts)

Эти два пункта — критические нарушения интеграции: изменение только MCP может быть поглощено механизмом подавления watcher’а, требующим перезапуска, из-за чего колбэк части B никогда не сработает. Обязательно должно быть реальное покрытие на уровне watcher’а; прямой вызов колбэка в hot-reload.test.ts не сможет отловить этот сбой.

3c. Фиксация схемы (settingsSchema.test.ts): mcpServers / mcp.allowed / mcp.excluded имеют requiresRestart false; родительский mcp и mcp.serverCommandtrue. Предотвращает случайное переключение ключей MCP обратно на “требуется перезапуск” и полное разрушение горячей перезагрузки. 3d. Реальный watcher больше не подавляет (settingsWatcher.test.ts, с реальным SettingsWatcher — имитация файловой системы): изменение только mcpServers / только mcp.excluded каждый вызывает одно событие SettingsChangeEvent (до исправления оно было бы подавлено). Это концевая регрессионная проверка того, что слушатель подзадачи 3 может фактически сработать.

B. Модульные тесты точек ветвления подписчика (cli, hot-reload.test.ts)

Эмулируем SettingsWatcher, покрывая каждую точку ветвления:

  1. Изменение mcpServers → вызов reinitializeMcpServers с собранной картой (включая верхний уровень).
  2. Редактирование только mcp.excluded (или mcp.allowed / pending), без изменения mcpServersвсё равно запускает согласование, и до согласования уже вызывает setExcludedMcpServers / setAllowedMcpServers / setPendingMcpServers. Это проверяет ветку mcpGatingEqual — исправленный пробел: сравнение только mcpServers пропустило бы это изменение.
  3. Ни mcpServers, ни списки шлюзов MCP не изменились (например, правка темы / навыков) → не вызывает reinitializeMcpServers (проверяет ранний возврат, когда оба шлюза “не изменились”).
  4. Два изменения, произошедшие во время выполняющегося согласования → объединение и повторная проверка выполняются ещё раз (повторное вхождение).
  5. Дребезг: несколько последовательных сохранений (< 300 мс) вызывают согласование один раз (согласовано с дребезгом watcher’а в 300 мс).

C. Модульные тесты чистых функций-помощников шлюзов (cli, hot-reload.test.ts)

  1. mcpServersEqual: разный порядок ключей, одинаковые значения → true; изменение вложенных полей конфига (args / env / headers) → false; undefined vs {}true; добавление/удаление сервера → false; изменение порядка массива argsfalse (порядок аргументов команды имеет значение).
  2. mcpGatingEqual: три списка сравниваются “независимо от порядка” (['a','b'] vs ['b','a']true); добавление/удаление элемента из любого списка → false; undefined vs []true.

D. Пограничные случаи доверительных границ (cli + core)

Оба пункта — критические точки доверительных границ. Пункт 11 проверяет границу допуска (пункт K части F — настройки сужаются внутри, но никогда не выходят за пределы флага запуска); пункт 12 соответствует пункту 4 части A (проверка отложенного решения в пуле).

  1. Горячая перезагрузка допускает сужение в пределах — но никогда не расширяет за пределы — флага запуска (граница пункта K части F; заменяет более раннюю позицию “настройки могут расширять”). Запуск с флагом --allowed-mcp-server-names=a,b; затем изменение настроек устанавливает mcp.allowed в [a, b, c]. Утверждение: после согласования c по-прежнему исключён (ограничен границей запуска), в то время как a допущен; редактирование настроек, сужающее до [a], вступает в силу; при отсутствии флага запуска список разрешённых из настроек действует без ограничений. (См. Часть F → Верификация для полной матрицы.) Защита: recomputeMcpGating пересекает список разрешённых из настроек с getCliAllowedMcpServerNames() и никогда не расширяет его за пределы.

  2. Шлюз отложенного одобрения не обходится в режиме общего пула (высокий риск: подключение сервера под гейтом до одобрения). В режиме демона / общего пула (runDiscoverAllMcpToolsViaPool) пусть горячая перезагрузка настроек добавляет/редактирует сервер, ожидающий одобрения (.mcp.json / рабочая область). Утверждение: до одобрения пользователем пул не получает соединение и не запускает процесс; отклонённый сервер под гейтом остаётся отключённым. В отличие от пути одной сессии, который уже пропускает ожидающие серверы, этот тест защищает путь пула. Защита: Пункт 4 части A — проверка isMcpServerPendingApproval в пути пула перед построением desiredIds / перед получением.

E. Пограничные случаи согласования (рекомендуемое покрытие, проверка “инкрементально, а не полное стирание”)

  1. Пусто ↔ не пусто: от 0 серверов до 1 (первый), от 1 до 0 (последний) — оба согласования выполняются корректно, не оставляя остаточных соединений/инструментов/подсказок.
  2. Изменение отпечатка затрагивает только этот сервер: изменение command / url / env / headers сервера → только он отключается+подключается, все остальные соединения сохраняются (проверяет отсутствие полного стирания, отсутствие промежутка “0 инструментов”).
  3. Небезопасная директория: когда isTrustedFolder() возвращает false, горячая перезагрузка не выполняется (соединение не устанавливается).
  4. Переключение mcp.excluded: добавление работающего сервера в исключённые → он отключается, инструменты/подсказки очищаются; удаление из исключённых → он подключается заново.
Last updated on