Task 100 — MCP hot-reload (IConfigReload)
Phase: 8
Status: done
Owner: —
Slug: mcp-hot-reload
Studio Stage: 6
Goal
Реализовать McpClientService : IConfigReload, чтобы изменения MCP-серверов через PATCH /api/config применялись без рестарта процесса. POST /api/mcp/servers/reload переустанавливает соединения с обновлённым конфигом.
Acceptance criteria
Sub-tasks
IToolRegistryService+ToolRegistryService— добавитьUnregisterEntry(string name)для снятия MCP-инструментов с диспетчеризации при удалении сервераMcpClientService— заменить snapshotMcpConfig _configнаRuntimeConfigStore _store; реализоватьIConfigReload.Reload(AppConfig)(sync trigger → background async apply)McpClientService.ReloadFromConfigAsync(McpConfig)— diff-логика: новые сервера подключаются, удалённые — дисконнект + unregister tools, изменённые — reconnect с unregister старых toolsMcpClientService.ReloadAsync()(публичный дляPOST /api/mcp/servers/reload) — используетstore.Current.Mcp(живой конфиг, а не snapshot)Program.cs— зарегистрироватьMcpClientServiceкакIConfigReload-потребитель рядом сAgentCore/SkillManager- Unit-тесты
McpClientServiceTests— обновить существующие под новый конструктор (RuntimeConfigStore + temp file); добавить 6 новых: adds / removes / updates / empty / IConfigReload-fire-and-forget / adds-with-registry - Unit-тесты
ToolRegistryServiceTests— 4 новых дляUnregisterEntry(existing / nonexistent / empty / case-insensitive) dotnet build+dotnet testpass; никаких регрессий
Dependencies
- нет (можно делать независимо, зависит только от текущей архитектуры)
Scope / Likely files
src/agent/Mcp/McpClientService.cs, src/agent/Hercules.WebApi/Program.cs, src/agent/Tools/Registry/IToolRegistryService.cs, src/agent/Tools/Registry/ToolRegistryService.cs, tests/Hercules.Agent.Tests/Mcp/McpClientServiceTests.cs, tests/Hercules.Agent.Tests/Tools/Registry/ToolRegistryServiceTests.cs
Implementation notes
Round 1 (this tick) — completed
MCP hot-reload для PATCH /api/config (Phase 8 task_100, Studio Stage 6).
Архитектурное изменение McpClientService:
- Снапшот
McpConfig _configв конструкторе заменён наRuntimeConfigStore _store. Сервис больше не владеет snapshot’ом — читаетstore.Current.Mcpлениво приInitializeAsyncиReloadAsync. Это разрывает связь «сервис живёт дольше, чем его config». McpClientService : IConfigReload— реализуетvoid Reload(AppConfig config). ПосколькуIConfigReload.Reloadсинхронный, а сетевое подключение асинхронное, метод запускает фоновыйTask.Run(() => ReloadFromConfigAsync(config.Mcp, ct))с try/catch + логированием ошибок. Reactor contract: fire-and-forget.ReloadAsync(CancellationToken)(публичный, дляPOST /api/mcp/servers/reload) используетstore.Current.Mcpнапрямую — awaitable, чтобы контроллер мог вернуть подтверждение оператору.
Diff-логика ReloadFromConfigAsync(McpConfig newConfig):
- Строим
Dictionary<string, McpServerConfig>желаемого состояния поName. - Remove: для каждого сервера в
_connections, которого нет вnewConfig—TryRemove+DisposeAsync(best effort) +UnregisterServerTools(name). - Update: для каждого сервера, который есть и в
_connections, и вnewConfig, ноServerConfigChanged(existing.Config, desired)(сравниваемTransport,Command,Endpoint,Enabled,HealthCheckEnabled,TimeoutSeconds,Args) — disconnect + unregister tools + reconnect. - Add: для каждого сервера, который есть в
newConfig, но не в_connections—ConnectServerAsync.- Все операции сериализованы через
SemaphoreSlim _reloadLock— параллельные PATCH-и + manual reload не race-ятся. McpServerConnectionтеперь хранит оригинальныйMcpServerConfig— нужен дляServerConfigChanged.
- Все операции сериализованы через
Tool unregistration (новый API):
IToolRegistryService.UnregisterEntry(string name) : bool— case-insensitiveTryRemove+ лог. ИспользуетсяMcpClientService.UnregisterServerTools(name)для снятия всех entries с префиксомmcp.{serverName}..
DI wiring (Program.cs):
builder.Services.AddSingleton<IConfigReload>(sp => sp.GetRequiredService<Hercules.Mcp.McpClientService>());— рядом сAgentCore/SkillManager. RuntimeConfigReactor автоматически прокинет новыйAppConfigвMcpClientService.ReloadприChangedevent.McpClientServiceзарегистрирован как singleton без factory — DI инжектитRuntimeConfigStore,ILoggerFactory,ILogger<McpClientService>, optionalToolRegistryService.
Тесты — 13 McpClientService + 4 ToolRegistryService = 17 новых/обновлённых:
McpClientServiceTests— все 7 существующих обновлены под новый конструктор (RuntimeConfigStore+ temp file черезNewStore()хелпер). 6 новых:ReloadAsync_AddsNewServer_ConnectsNewAndKeepsExisting— diff: [A] → [A,B] даёт 2 connection.ReloadAsync_RemovesOldServer_DisconnectsAndUnregistersTools— diff: [A,B] → [B] снимает connection A + unregister toolsmcp.a.*;mcp.b.*остаются.ReloadAsync_UpdatesExistingServer_ReconnectsWithNewConfig— изменениеCommandна существующем сервере вызывает reconnect.ReloadAsync_EmptyNewConfig_DisconnectsAll— полный clear.IConfigReload_Reload_AppliesInBackground— fire-and-forget path черезservice.Reload(store.Current)+ poll-ожидание.ReloadAsync_AddsNewServer_RegistersItsTools— single-server add через realToolRegistryService.
ToolRegistryServiceTests— 4 новых дляUnregisterEntry: existing-tool / nonexistent / empty+null / case-insensitive.
Round 1 — validation
dotnet build src/agent/Hercules.csproj→ exit 0 (0 errors, 0 warnings).dotnet build src/agent/Hercules.WebApi/Hercules.WebApi.csproj→ exit 0 (0 errors, 0 warnings).dotnet build tests/Hercules.Agent.Tests/Hercules.Agent.Tests.csproj→ exit 0 (0 errors; pre-existing xUnit1031 warnings не относятся к task_100).dotnet test --filter "FullyQualifiedName~McpClientService"→ 13/13 passed.dotnet test --filter "FullyQualifiedName~ToolRegistryService"→ 23/23 passed (19 существующих + 4 новых).dotnet test --filter "FullyQualifiedName~Mcp|FullyQualifiedName~ToolRegistry|FullyQualifiedName~RuntimeConfig|FullyQualifiedName~IConfigReload|FullyQualifiedName~ConfigReactor"→ 60/60 passed.dotnet test --filter "FullyQualifiedName~Mcp|FullyQualifiedName~ToolRegistry|FullyQualifiedName~RuntimeConfig|FullyQualifiedName~WebApi|FullyQualifiedName~CheckIn|FullyQualifiedName~Restart"→ 150/150 passed.- Полный suite: 2087/2097 passed. 10 pre-existing failures (OtelService, RedisTaskQueue, WasmTool, NumericValidator, InProcessTaskQueue, ResilientLLMClientSampledLog) — все требуют внешних сервисов (OTLP collector, Redis, Wasmtime), задокументированы в task_097, не связаны с task_100 (нет ссылок на
Mcp*/IConfigReload/RuntimeConfig*в их stack-traces).
Notes
McpServerConfigне реализуетIEquatable— вместо этого используется field-by-field comparison вServerConfigChanged. Покрывает все значимые поля (Transport, Command, Endpoint, Args, Enabled, HealthCheckEnabled, TimeoutSeconds). Если в будущем добавится новое поле, влияющее на соединение — нужно дополнитьServerConfigChanged.IConfigReload.Reloadне await’ится — оператор, ждущий подтверждения, используетPOST /api/mcp/servers/reload(awaitable). PATCH-путь сознательно fire-and-forget, потому что PATCH может менять много секций сразу и не должен блокироваться на сетевом reconnect.- Существующий баг (вне scope):
McpClientServiceрегистрирует tools вToolRegistryService, ноToolRegistry(базовый, для исполнения через_tools.Get) их не получает. Это pre-existing limitation — agent выполняет MCP tools черезToolRegistryService(?) или вообще не выполняет. Фиксить task_100 не должен; задокументировано в issue-tracking. - Manual smoke (
dotnet run+ curlPATCH /api/config+GET /api/mcp/servers) не выполнялся в cron-окружении; покрыт 17 unit-тестами + 60 связанных тестов.
Links
- Studio Stage 5: ../EPIC_Hercules_Studio/tasks/stage_05_tools_mcp.md
- Backlog: ../backlog.md