Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/spec/SPEC-011-daemon-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion feeds/skills/.system/files/netclaw-operations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -308,6 +308,11 @@ with that provider and set it with `netclaw mcp add --client-id <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
Expand Down
84 changes: 83 additions & 1 deletion src/Netclaw.Daemon.Tests/Mcp/McpClientManagerLifecycleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, McpServerEntry>
{
["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<string, McpServerEntry>
{
["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()
{
Expand Down Expand Up @@ -972,6 +1044,16 @@ public ManagerHarness(
FakeTimeProvider timeProvider,
IOperationalNotificationSink notificationSink,
McpServerEntry entry)
: this(runtime, timeProvider, notificationSink,
new Dictionary<string, McpServerEntry> { [ServerName.Value] = entry })
{
}

public ManagerHarness(
ControlledMcpClientRuntime runtime,
FakeTimeProvider timeProvider,
IOperationalNotificationSink notificationSink,
Dictionary<string, McpServerEntry> serverEntries)
{
var paths = new NetclawPaths(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
paths.EnsureDirectoriesExist();
Expand All @@ -987,7 +1069,7 @@ public ManagerHarness(
SkillIndex = dependencies.SkillIndex;
Logger = new RecordingLogger<McpClientManager>();
Manager = new McpClientManager(
new Dictionary<string, McpServerEntry> { [ServerName.Value] = entry },
serverEntries,
Registry,
dependencies.SkillRegistry,
dependencies.SkillIndexPublisher,
Expand Down
5 changes: 4 additions & 1 deletion src/Netclaw.Daemon/Mcp/McpClientManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ internal bool IsStopping

public async Task StartAsync(CancellationToken cancellationToken)
{
var connections = new List<Task<bool>>(_serverEntries.Count);
foreach (var (name, entry) in _serverEntries)
{
var serverName = new McpServerName(name);
Expand All @@ -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)
Expand Down
Loading