From a1df00c042a814fb023a36da6f14a3ad64906b24 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 18 Sep 2026 22:00:00 +0000 Subject: [PATCH] Connect MCP servers concurrently at startup --- docs/spec/SPEC-011-daemon-architecture.md | 6 ++ .../.system/files/netclaw-operations/SKILL.md | 7 +- .../Mcp/McpClientManagerLifecycleTests.cs | 84 ++++++++++++++++++- src/Netclaw.Daemon/Mcp/McpClientManager.cs | 5 +- 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/docs/spec/SPEC-011-daemon-architecture.md b/docs/spec/SPEC-011-daemon-architecture.md index 2b3874244..92770e1a3 100644 --- a/docs/spec/SPEC-011-daemon-architecture.md +++ b/docs/spec/SPEC-011-daemon-architecture.md @@ -55,6 +55,12 @@ Netclaw.Actors, Netclaw.Configuration, OllamaSharp / OpenAI client Binds: address and port from `DaemonConfig` (`Host`, `Port`); defaults to `http://127.0.0.1:5199` (loopback only). `ExposureMode` declares network reachability and tunnel infrastructure, separately from chat audience/profile selection. +At startup, `McpClientManager` starts each enabled server connection concurrently. +It waits for every initial attempt before the daemon listener starts. Each +server uses its own connection gate. The shared registries publish complete +catalogs before the listener accepts a session. A failed server keeps its +error status while other server catalogs remain available. + ### `Netclaw.Cli` Lightweight CLI and TUI client. No actor system, no persistence, no tool diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index 4799e2e97..da424a7f8 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -3,7 +3,7 @@ name: netclaw-operations description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance." metadata: author: netclaw - version: "2.74.7" + version: "2.74.9" --- # Netclaw Operations @@ -308,6 +308,11 @@ with that provider and set it with `netclaw mcp add --client-id ...`. | `AuthFailed` | The server rejected credentials that were supplied. Reauthorize SDK-managed OAuth, or check the configured `Authorization` header if it owns auth. | | `Unreachable` | A non-auth transport, network, timeout, or initialization failure prevented connection. Check the endpoint and daemon logs. | +At startup, the daemon connects enabled MCP servers concurrently. It waits +for each initial attempt before it reports ready. A failed server has its own +status; other server tools remain available. Use `netclaw mcp list` to inspect +each result. + ### Diagnose failures ```bash diff --git a/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerLifecycleTests.cs b/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerLifecycleTests.cs index 6d172a9af..3f300cabf 100644 --- a/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerLifecycleTests.cs +++ b/src/Netclaw.Daemon.Tests/Mcp/McpClientManagerLifecycleTests.cs @@ -32,6 +32,78 @@ public sealed class McpClientManagerLifecycleTests private static readonly McpServerName ServerName = new("test"); private static readonly DateTimeOffset InitialTime = DateTimeOffset.Parse("2026-07-22T12:00:00Z"); + [Fact] + public async Task StartupConnectsIndependentServersBeforeEitherFinishes() + { + var runtime = new ControlledMcpClientRuntime(); + var firstGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var first = runtime.Enqueue(new ClientPlan("first_tool") + { + Initialize = ct => firstGate.Task.WaitAsync(ct), + }); + var second = runtime.Enqueue(new ClientPlan("second_tool") + { + Initialize = ct => secondGate.Task.WaitAsync(ct), + }); + await using var harness = new ManagerHarness( + runtime, + new FakeTimeProvider(InitialTime), + NullNotificationSink.Instance, + new Dictionary + { + ["first"] = HttpEntry(), + ["second"] = HttpEntry(), + }); + + var start = harness.Manager.StartAsync(TestContext.Current.CancellationToken); + try + { + await first.Created.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + await second.Created.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + Assert.False(start.IsCompleted); + } + finally + { + firstGate.TrySetResult(); + secondGate.TrySetResult(); + await start; + } + + Assert.Single(harness.Registry.GetToolsForServer(new McpServerName("first"), int.MaxValue)); + Assert.Single(harness.Registry.GetToolsForServer(new McpServerName("second"), int.MaxValue)); + Assert.All(harness.Manager.GetServerStatuses().Values, + status => Assert.Equal(McpConnectionState.Connected, status.State)); + } + + [Fact] + public async Task StartupFailureForOneServerKeepsAnotherServerCatalog() + { + var runtime = new ControlledMcpClientRuntime(); + runtime.Enqueue(new ClientPlan("available_tool")); + runtime.Enqueue(new ClientPlan("unavailable_tool") + { + Initialize = _ => Task.FromException(new InvalidOperationException("server unavailable")), + }); + await using var harness = new ManagerHarness( + runtime, + new FakeTimeProvider(InitialTime), + NullNotificationSink.Instance, + new Dictionary + { + ["available"] = HttpEntry(), + ["unavailable"] = HttpEntry(), + }); + + await harness.Manager.StartAsync(TestContext.Current.CancellationToken); + + Assert.Single(harness.Registry.GetToolsForServer(new McpServerName("available"), int.MaxValue)); + Assert.Empty(harness.Registry.GetToolsForServer(new McpServerName("unavailable"), int.MaxValue)); + var statuses = harness.Manager.GetServerStatuses(); + Assert.Equal(McpConnectionState.Connected, statuses[new McpServerName("available")].State); + Assert.Equal(McpConnectionState.Unreachable, statuses[new McpServerName("unavailable")].State); + } + [Fact] public async Task ConcurrentReconnects_CreateOneCandidateAndPublishOneGeneration() { @@ -972,6 +1044,16 @@ public ManagerHarness( FakeTimeProvider timeProvider, IOperationalNotificationSink notificationSink, McpServerEntry entry) + : this(runtime, timeProvider, notificationSink, + new Dictionary { [ServerName.Value] = entry }) + { + } + + public ManagerHarness( + ControlledMcpClientRuntime runtime, + FakeTimeProvider timeProvider, + IOperationalNotificationSink notificationSink, + Dictionary serverEntries) { var paths = new NetclawPaths(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); paths.EnsureDirectoriesExist(); @@ -987,7 +1069,7 @@ public ManagerHarness( SkillIndex = dependencies.SkillIndex; Logger = new RecordingLogger(); Manager = new McpClientManager( - new Dictionary { [ServerName.Value] = entry }, + serverEntries, Registry, dependencies.SkillRegistry, dependencies.SkillIndexPublisher, diff --git a/src/Netclaw.Daemon/Mcp/McpClientManager.cs b/src/Netclaw.Daemon/Mcp/McpClientManager.cs index 4b09c34d2..faf0af119 100644 --- a/src/Netclaw.Daemon/Mcp/McpClientManager.cs +++ b/src/Netclaw.Daemon/Mcp/McpClientManager.cs @@ -130,6 +130,7 @@ internal bool IsStopping public async Task StartAsync(CancellationToken cancellationToken) { + var connections = new List>(_serverEntries.Count); foreach (var (name, entry) in _serverEntries) { var serverName = new McpServerName(name); @@ -142,8 +143,10 @@ public async Task StartAsync(CancellationToken cancellationToken) var observed = lifecycle.Snapshot; if (observed is not null) - await ReconnectAsync(lifecycle, entry, observed, cancellationToken, null); + connections.Add(ReconnectAsync(lifecycle, entry, observed, cancellationToken, null)); } + + await Task.WhenAll(connections); } public Task StopAsync(CancellationToken cancellationToken)