Проект 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 | Подписка наблюдателя, пересчёт гейта, дебаунсный гейт, вызов части A | CLI | этот MR |
| C | Реинициализация LSP | Core | TODO (позже) |
| 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
-
Добавить сеттер после инициализации, который обновляет снимок настроек, читаемый согласованием:
/** * Замена во время выполнения (горячей перезагрузки) карты MCP-серверов настроечного слоя. * В отличие от addMcpServers(), обходит защиту `initialized` и является ЗАМЕНОЙ * (не слиянием), поэтому удаления вступают в силу. Наложение времени выполнения * (addRuntimeMcpServer) и вклады расширений не затрагиваются — getMcpServers() * по-прежнему наслаивается поверх него. */ setMcpServers(servers: Record<string, MCPServerConfig> | undefined): void { this.mcpServers = servers; }getMcpServers()(:3128) уже наслаивает расширения +runtimeMcpServersповерхthis.mcpServers, поэтому замена только настроечного слоя безопасна для записей времени выполнения/расширений. -
Списки гейтов соединений: три списка имён, которые определяют, может ли каждый MCP-сервер подключаться —
excluded(заблокирован),allowed(если задан, подключаются только эти),pending(гейтовый источник, требуется одобрение пользователя перед подключением). Они отделены отmcpServers(конфигурация сервера): первые определяют «подключаться ли», вторые — «какие серверы и как». Добавить сеттеры для этих трёх списков, которые консультируютgetMcpServers()/ обнаружение:setExcludedMcpServers()существует (:3167); добавитьsetAllowedMcpServers()(поле сейчасreadonlyи используется как фильтр внутриgetMcpServers()) плюс сеттер для набора ожидающих одобрения. -
Добавить лёгкий оркестровочный метод: сначала обновить конфиг, затем запустить существующее инкрементальное согласование, обёрнутое общей защитой «согласование в процессе», чтобы
/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. Удалённый сервер → освободить + удалить инструменты/приглашения; изменённый отпечаток → освободить + перезахватить; неизменный → оставить. -
Добавить проверку ожидания одобрения к пути общего пула (граница доверия, обязательно в этом 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 через событие, передача решения интерфейсу
-
Добавить событие
AppEvent.McpPendingApprovalChanged(packages/cli/src/utils/events.ts). ПосколькуappEventsнаходится на уровне CLI иhot-reload.tsтоже, слушатель может отправлять напрямую, без изменений в ядре. -
hot-reload.tsотправляет после reconcile (размещено послеawait reinitializeMcpServers, чтобыconfig.getMcpServers()уже отражал новую карту; отправлять независимо от успеха/неудачи reconcile — сервер, оставшийся в состоянии pending, всё равно требует решения пользователя). -
useMcpApprovalизвлекаетcomputePending(): вычислить один раз при монтировании (существующее поведение) плюс пересчитать очередь после подписки наMcpPendingApprovalChanged→ непустая очередь показывает модальное окно.computePendingпересчитывает из авторитетных источников (живая карта серверов + файл сохранённых утверждений), поэтому уже утверждённые / уже отклонённые серверы не вызывают повторного запроса.
Ключевое решение: отправлять на основе “строгого pending”, а не разности множеств имён (issue #6 / решение A1)
Обратите внимание, что два предиката намеренно различны, что является сутью этого раздела:
| Функция | Предикат | Использование |
|---|---|---|
getPendingGatedMcpServers | state !== '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 на будущее (НЕ обработано здесь)
- Несоответствие ключей
getTargetDir()иgetWorkingDir()(риск B): пересчёт gating (recomputeMcpGating→getPendingGatedMcpServers) использует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.ts | MCPServerDisplayInfo добавляет approvalState?: 'pending' | 'rejected' |
packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx | fetchServerData вычисляет approvalState, ключ — getWorkingDir() |
packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx | отображать причину утверждения; исключать пропуски из-за утверждения из подсказки “см. журналы ошибок” |
packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx | отображать причину утверждения (согласованно со списком) |
Верификация (Часть E)
ServerListStep.test.tsx: gatedrejected→ показывает текст подсказки о повторном утверждении;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
(параметр cliAllowedMcpServerNames → getCliAllowedMcpServerNames(); отличается от изменяемого
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_approval | gated-сервер ожидает утверждения (#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.ts | recomputeMcpGating ограничивает allowed границей запуска (K) и сохраняет [] (H); mcpGatingEqual делает отсутствующий allowed ≠ [] (H) |
packages/core/src/core/coreToolScheduler.ts | getMcpToolUnavailableMessage направляет по getMcpServerUnavailableReason (B) |
Верификация (Часть F)
hot-reload.test.ts: K — с флагом запуска и без списка разрешённых в настройках, применяет флаг целиком; список разрешённых из настроек ограничивается флагом (не может расширить) и может сужать внутри него; без флага настройки побеждают без ограничений. H —mcp.allowed: []передаётся как deny-all;mcpGatingEqualсчитает отсутствующийallowedи[]разными (ноexcludedundefined ≡[]).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) и уведомление UIneedsRefresh(#6).
Ключевые файлы
| Файл | Изменение |
|---|---|
packages/core/src/config/config.ts | setMcpServers(), 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)
setMcpServersвыполняет замену (не слияние) и вступает в силу после инициализации (больше не выбрасывает исключение через защитуinitialized).reinitializeMcpServersсначала вызываетsetMcpServers, а затемdiscoverAllMcpToolsIncremental; вызов доinitialize()является безопасной пустой операцией (без исключений, без подключения).- Утвердить, что
removeServer()/removeRuntimeMcpServer()теперь вызываютremovePromptsByServer()(защита от регрессии утечки подсказок). Использовать фикстурыmcp-client-manager.test.ts(которые уже импортируютconnectionIdOf). 3b. Различие отпечатка в рамках одной сессии: создать mock-клиент, у которогоgetStatus()всегда возвращаетCONNECTED, выполнитьdiscoverAllMcpToolsIncrementalтри раза — первый вызов фиксирует отпечаток; повторный вызов с той же конфигурацией не вызывает пересоздания (connectостаётся 1×); изменениеargsна месте (отпечаток меняется) → отключение+подключение (disconnect1×,connect2×). Гарантирует, что путь одной сессии больше не пропускает ситуацию “подключено, но конфиг изменился” как пустую операцию (согласовано с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.serverCommand — true. Предотвращает случайное переключение ключей MCP обратно на “требуется перезапуск” и полное разрушение горячей перезагрузки.
3d. Реальный watcher больше не подавляет (settingsWatcher.test.ts, с реальным SettingsWatcher — имитация файловой системы): изменение только mcpServers / только mcp.excluded каждый вызывает одно событие SettingsChangeEvent (до исправления оно было бы подавлено). Это концевая регрессионная проверка того, что слушатель подзадачи 3 может фактически сработать.
B. Модульные тесты точек ветвления подписчика (cli, hot-reload.test.ts)
Эмулируем SettingsWatcher, покрывая каждую точку ветвления:
- Изменение
mcpServers→ вызовreinitializeMcpServersс собранной картой (включая верхний уровень). - Редактирование только
mcp.excluded(илиmcp.allowed/ pending), без измененияmcpServers→ всё равно запускает согласование, и до согласования уже вызываетsetExcludedMcpServers/setAllowedMcpServers/setPendingMcpServers. Это проверяет веткуmcpGatingEqual— исправленный пробел: сравнение толькоmcpServersпропустило бы это изменение. - Ни
mcpServers, ни списки шлюзов MCP не изменились (например, правка темы / навыков) → не вызываетreinitializeMcpServers(проверяет ранний возврат, когда оба шлюза “не изменились”). - Два изменения, произошедшие во время выполняющегося согласования → объединение и повторная проверка выполняются ещё раз (повторное вхождение).
- Дребезг: несколько последовательных сохранений (< 300 мс) вызывают согласование один раз (согласовано с дребезгом watcher’а в 300 мс).
C. Модульные тесты чистых функций-помощников шлюзов (cli, hot-reload.test.ts)
mcpServersEqual: разный порядок ключей, одинаковые значения →true; изменение вложенных полей конфига (args/env/headers) →false;undefinedvs{}→true; добавление/удаление сервера →false; изменение порядка массиваargs→false(порядок аргументов команды имеет значение).mcpGatingEqual: три списка сравниваются “независимо от порядка” (['a','b']vs['b','a']→true); добавление/удаление элемента из любого списка →false;undefinedvs[]→true.
D. Пограничные случаи доверительных границ (cli + core)
Оба пункта — критические точки доверительных границ. Пункт 11 проверяет границу допуска (пункт K части F — настройки сужаются внутри, но никогда не выходят за пределы флага запуска); пункт 12 соответствует пункту 4 части A (проверка отложенного решения в пуле).
-
Горячая перезагрузка допускает сужение в пределах — но никогда не расширяет за пределы — флага запуска (граница пункта K части F; заменяет более раннюю позицию “настройки могут расширять”). Запуск с флагом
--allowed-mcp-server-names=a,b; затем изменение настроек устанавливаетmcp.allowedв[a, b, c]. Утверждение: после согласованияcпо-прежнему исключён (ограничен границей запуска), в то время какaдопущен; редактирование настроек, сужающее до[a], вступает в силу; при отсутствии флага запуска список разрешённых из настроек действует без ограничений. (См. Часть F → Верификация для полной матрицы.) Защита:recomputeMcpGatingпересекает список разрешённых из настроек сgetCliAllowedMcpServerNames()и никогда не расширяет его за пределы. -
Шлюз отложенного одобрения не обходится в режиме общего пула (высокий риск: подключение сервера под гейтом до одобрения). В режиме демона / общего пула (
runDiscoverAllMcpToolsViaPool) пусть горячая перезагрузка настроек добавляет/редактирует сервер, ожидающий одобрения (.mcp.json/ рабочая область). Утверждение: до одобрения пользователем пул не получает соединение и не запускает процесс; отклонённый сервер под гейтом остаётся отключённым. В отличие от пути одной сессии, который уже пропускает ожидающие серверы, этот тест защищает путь пула. Защита: Пункт 4 части A — проверкаisMcpServerPendingApprovalв пути пула перед построениемdesiredIds/ перед получением.
E. Пограничные случаи согласования (рекомендуемое покрытие, проверка “инкрементально, а не полное стирание”)
- Пусто ↔ не пусто: от 0 серверов до 1 (первый), от 1 до 0 (последний) — оба согласования выполняются корректно, не оставляя остаточных соединений/инструментов/подсказок.
- Изменение отпечатка затрагивает только этот сервер: изменение
command/url/env/headersсервера → только он отключается+подключается, все остальные соединения сохраняются (проверяет отсутствие полного стирания, отсутствие промежутка “0 инструментов”). - Небезопасная директория: когда
isTrustedFolder()возвращаетfalse, горячая перезагрузка не выполняется (соединение не устанавливается). - Переключение
mcp.excluded: добавление работающего сервера в исключённые → он отключается, инструменты/подсказки очищаются; удаление из исключённых → он подключается заново.